From 373415a2ebac4f4c46994ae07ed0d532660ee0a8 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 19 Sep 2026 04:51:43 +0200 Subject: [PATCH] feat(account): show the account its own last sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- backend/app/routes/me.py | 4 ++ backend/tests/test_last_login.py | 10 ++++ .../SettingsPanel.account.dom.test.tsx | 60 +++++++++++++++++++ frontend/src/components/SettingsPanel.tsx | 50 +++++++++++----- frontend/src/i18n/locales/en/settings.json | 2 + frontend/src/i18n/locales/hu/settings.json | 2 + frontend/src/lib/api/account.ts | 2 + 7 files changed, 114 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/SettingsPanel.account.dom.test.tsx diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index eb586a22..74b15360 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -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), diff --git a/backend/tests/test_last_login.py b/backend/tests/test_last_login.py index b2a48d6d..8b6b2973 100644 --- a/backend/tests/test_last_login.py +++ b/backend/tests/test_last_login.py @@ -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)) diff --git a/frontend/src/components/SettingsPanel.account.dom.test.tsx b/frontend/src/components/SettingsPanel.account.dom.test.tsx new file mode 100644 index 00000000..82598b1c --- /dev/null +++ b/frontend/src/components/SettingsPanel.account.dom.test.tsx @@ -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(); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, vars?: Record) => + vars?.time != null ? `${key}:${String(vars.time)}` : key, + i18n: { language: "en" }, + }), + }; +}); +vi.mock("../lib/format", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, formatDateTime: (iso: string) => `at ${iso}` }; +}); + +import { AccountHeader } from "./SettingsPanel"; + +afterEach(cleanup); + +const me = (extra: Partial): 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(); + 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(); + expect(screen.getByTestId("account-last-login").textContent).toBe( + "settings.account.lastLoginNone", + ); + cleanup(); + render(); + expect(screen.queryByTestId("account-last-login")).toBeNull(); + expect(screen.getByText("nav.role.demo")).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 7c7d8dc7..d70a9a3f 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -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 ( +
+
+ +
+
{me.display_name ?? me.email.split("@")[0]}
+
{me.email}
+ {/* Translated, like the sidebar's tooltip: the raw `user` read in English under HU. */} +
+ {t(`nav.role.${me.is_demo ? "demo" : me.role === "admin" ? "admin" : "user"}`)} +
+ {!me.is_demo && ( +
+ {me.last_login_at + ? t("settings.account.lastLogin", { time: formatDateTime(me.last_login_at) }) + : t("settings.account.lastLoginNone")} +
+ )} +
+
+
+ ); +} + 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 ( <> -
-
- -
-
- {me.display_name ?? me.email.split("@")[0]} -
-
{me.email}
-
{me.role}
-
-
-
+ {!me.is_demo && } diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index 54c2ae95..6e0ed193 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -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)", diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index 6a2d9a55..853a762f 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -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)", diff --git a/frontend/src/lib/api/account.ts b/frontend/src/lib/api/account.ts index 4783dc0d..df21f523 100644 --- a/frontend/src/lib/api/account.ts +++ b/frontend/src/lib/api/account.ts @@ -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