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:
2026-09-19 04:51:43 +02:00
parent 582c033e1c
commit 373415a2eb
7 changed files with 114 additions and 16 deletions
+4
View File
@@ -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),
+10
View File
@@ -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();
});
});
+34 -16
View File
@@ -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)",
+2
View File
@@ -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