feat(account): show the account its own last sign-in
Settings → Account names the latest sign-in on any device (only the admin's user list did, though the 0.88 notes said otherwise), and the role in the reader's language instead of the raw `user`.
This commit is contained in:
@@ -23,6 +23,7 @@ from app import logs
|
||||
from app import sysconfig
|
||||
from app.db import get_db
|
||||
from app.models import Invite, User
|
||||
from app.utils import iso
|
||||
|
||||
router = APIRouter(prefix="/api/me", tags=["me"])
|
||||
|
||||
@@ -138,6 +139,9 @@ def get_me(
|
||||
user.google_email if user.google_email and user.google_email != user.email else None
|
||||
),
|
||||
"has_password": user.password_hash is not None,
|
||||
# The account's latest sign-in on any device — shown in Settings → Account, so a sign-in the
|
||||
# user does not recognise is something they can see (null: none since 0.88, or the demo).
|
||||
"last_login_at": iso(user.last_login_at),
|
||||
# Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides
|
||||
# YouTube-access affordances and the onboarding nudge when it isn't configured).
|
||||
"google_enabled": google_enabled(db),
|
||||
|
||||
@@ -95,3 +95,13 @@ def test_the_admin_user_list_carries_last_login(as_user, db, make_user):
|
||||
|
||||
assert rows["boss@example.com"]["last_login_at"].startswith("2026-09-16T08:30")
|
||||
assert rows[never.email]["last_login_at"] is None
|
||||
|
||||
|
||||
def test_the_account_sees_its_own_last_sign_in(client, db, make_user):
|
||||
# Settings → Account shows it (prod UAT 2026-09-19: only the admin's user list did).
|
||||
make_user(email="me@example.com", password=_PW)
|
||||
client.post("/auth/password-login", json={"email": "me@example.com", "password": _PW})
|
||||
|
||||
stamp = client.get("/api/me").json()["last_login_at"]
|
||||
|
||||
assert _recent(datetime.fromisoformat(stamp))
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Settings → Account names the account's latest sign-in (prod UAT 2026-09-19: only the admin's user
|
||||
// list showed it), and its role in the reader's language — the raw `user` read in English under HU.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
|
||||
import type { Me } from "../lib/api";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars?.time != null ? `${key}:${String(vars.time)}` : key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("../lib/format", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/format")>();
|
||||
return { ...actual, formatDateTime: (iso: string) => `at ${iso}` };
|
||||
});
|
||||
|
||||
import { AccountHeader } from "./SettingsPanel";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const me = (extra: Partial<Me>): Me =>
|
||||
({
|
||||
email: "someone@example.com",
|
||||
display_name: "Someone",
|
||||
avatar_url: null,
|
||||
role: "user",
|
||||
is_demo: false,
|
||||
last_login_at: null,
|
||||
...extra,
|
||||
}) as Me;
|
||||
|
||||
describe("AccountHeader", () => {
|
||||
it("names the latest sign-in and the role in the reader's language", () => {
|
||||
render(<AccountHeader me={me({ last_login_at: "2026-09-19T02:00:00Z" })} />);
|
||||
expect(screen.getByTestId("account-last-login").textContent).toBe(
|
||||
"settings.account.lastLogin:at 2026-09-19T02:00:00Z",
|
||||
);
|
||||
expect(screen.getByText("nav.role.user")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says when none is recorded, and says nothing about it for the demo", () => {
|
||||
render(<AccountHeader me={me({})} />);
|
||||
expect(screen.getByTestId("account-last-login").textContent).toBe(
|
||||
"settings.account.lastLoginNone",
|
||||
);
|
||||
cleanup();
|
||||
render(<AccountHeader me={me({ is_demo: true })} />);
|
||||
expect(screen.queryByTestId("account-last-login")).toBeNull();
|
||||
expect(screen.getByText("nav.role.demo")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { withActiveAccount } from "../lib/activeAccount";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { formatDateTime } from "../lib/format";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Activity, Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
|
||||
@@ -778,6 +779,38 @@ export function DownloadAuth() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Who is signed in: name, address, role — and the latest sign-in on any device, so one the user does
|
||||
* not recognise is something they can see. */
|
||||
export function AccountHeader({ me }: { me: Me }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Section title={t("settings.account.title")}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Avatar
|
||||
src={me.avatar_url}
|
||||
fallback={me.display_name ?? me.email}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold truncate">{me.display_name ?? me.email.split("@")[0]}</div>
|
||||
<div className="text-xs text-muted truncate">{me.email}</div>
|
||||
{/* Translated, like the sidebar's tooltip: the raw `user` read in English under HU. */}
|
||||
<div className="text-xs text-muted">
|
||||
{t(`nav.role.${me.is_demo ? "demo" : me.role === "admin" ? "admin" : "user"}`)}
|
||||
</div>
|
||||
{!me.is_demo && (
|
||||
<div className="text-xs text-muted" data-testid="account-last-login">
|
||||
{me.last_login_at
|
||||
? t("settings.account.lastLogin", { time: formatDateTime(me.last_login_at) })
|
||||
: t("settings.account.lastLoginNone")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
@@ -801,22 +834,7 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Section title={t("settings.account.title")}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Avatar
|
||||
src={me.avatar_url}
|
||||
fallback={me.display_name ?? me.email}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold truncate">
|
||||
{me.display_name ?? me.email.split("@")[0]}
|
||||
</div>
|
||||
<div className="text-xs text-muted truncate">{me.email}</div>
|
||||
<div className="text-xs text-muted capitalize">{me.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
<AccountHeader me={me} />
|
||||
|
||||
{!me.is_demo && <SignInMethods me={me} />}
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
},
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"lastLogin": "Last sign-in: {{time}}",
|
||||
"lastLoginNone": "No sign-in recorded yet",
|
||||
"youtubeAccess": "YouTube access",
|
||||
"youtubeAccessIntro": "Sign-in only shares your name and email. YouTube access is granted separately, below — each is optional and revocable anytime in your Google Account.",
|
||||
"readTitle": "Read subscriptions (your Feed)",
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
},
|
||||
"account": {
|
||||
"title": "Fiók",
|
||||
"lastLogin": "Utolsó belépés: {{time}}",
|
||||
"lastLoginNone": "Még nincs rögzített belépés",
|
||||
"youtubeAccess": "YouTube-hozzáférés",
|
||||
"youtubeAccessIntro": "A bejelentkezés csak a nevedet és e-mail-címedet osztja meg. A YouTube-hozzáférést külön kell megadni, lent — mindegyik opcionális és bármikor visszavonható a Google-fiókodban.",
|
||||
"readTitle": "Feliratkozások olvasása (a Feeded)",
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface Me {
|
||||
avatar_url: string | null;
|
||||
role: string;
|
||||
is_demo: boolean;
|
||||
/** The latest sign-in on any device (ISO), or null when none is recorded. */
|
||||
last_login_at: string | null;
|
||||
has_google: boolean;
|
||||
/** The address Google answers with, ONLY when it differs from `email` — the backend does that
|
||||
* comparison, so a surface can render this whenever it is present. Someone who registered as
|
||||
|
||||
Reference in New Issue
Block a user