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 import sysconfig
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Invite, User
|
from app.models import Invite, User
|
||||||
|
from app.utils import iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/me", tags=["me"])
|
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
|
user.google_email if user.google_email and user.google_email != user.email else None
|
||||||
),
|
),
|
||||||
"has_password": user.password_hash is not 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
|
# 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).
|
# YouTube-access affordances and the onboarding nudge when it isn't configured).
|
||||||
"google_enabled": google_enabled(db),
|
"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["boss@example.com"]["last_login_at"].startswith("2026-09-16T08:30")
|
||||||
assert rows[never.email]["last_login_at"] is None
|
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 { useState } from "react";
|
||||||
import { withActiveAccount } from "../lib/activeAccount";
|
import { withActiveAccount } from "../lib/activeAccount";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { formatDateTime } from "../lib/format";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Activity, Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
|
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 }) {
|
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
@@ -801,22 +834,7 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section title={t("settings.account.title")}>
|
<AccountHeader me={me} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{!me.is_demo && <SignInMethods me={me} />}
|
{!me.is_demo && <SignInMethods me={me} />}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,8 @@
|
|||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"title": "Account",
|
"title": "Account",
|
||||||
|
"lastLogin": "Last sign-in: {{time}}",
|
||||||
|
"lastLoginNone": "No sign-in recorded yet",
|
||||||
"youtubeAccess": "YouTube access",
|
"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.",
|
"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)",
|
"readTitle": "Read subscriptions (your Feed)",
|
||||||
|
|||||||
@@ -53,6 +53,8 @@
|
|||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"title": "Fiók",
|
"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",
|
"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.",
|
"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)",
|
"readTitle": "Feliratkozások olvasása (a Feeded)",
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface Me {
|
|||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
is_demo: boolean;
|
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;
|
has_google: boolean;
|
||||||
/** The address Google answers with, ONLY when it differs from `email` — the backend does that
|
/** 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
|
* comparison, so a surface can render this whenever it is present. Someone who registered as
|
||||||
|
|||||||
Reference in New Issue
Block a user