From e299c6612194f7c67196227b9c95a4abe4f413c3 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 16 Sep 2026 00:45:03 +0200 Subject: [PATCH] feat: show each account's last sign-in in Users A right-aligned column at the end of every Users & roles row says how long ago the account signed in, with the exact moment as the hint and as a line in the account panel; a dash where nothing is recorded, and the demo account says it is not recorded. formatDateTime formats that moment in the UI language rather than the browser's. --- frontend/src/components/AccountPanel.tsx | 17 +++++++++++++- .../src/components/AdminUsers.dom.test.tsx | 22 +++++++++++++++++++ frontend/src/components/AdminUsers.tsx | 18 +++++++++++++++ frontend/src/i18n/locales/en/users.json | 6 +++++ frontend/src/i18n/locales/hu/users.json | 6 +++++ frontend/src/lib/format.ts | 6 +++++ 6 files changed, 74 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/AccountPanel.tsx b/frontend/src/components/AccountPanel.tsx index 897099cc..b93377e4 100644 --- a/frontend/src/components/AccountPanel.tsx +++ b/frontend/src/components/AccountPanel.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, Clock, Copy, Globe, KeyRound, Mail, Send, ShieldAlert } from "lucide-react"; +import { Check, Clock, Copy, Globe, KeyRound, LogIn, Mail, Send, ShieldAlert } from "lucide-react"; import Modal from "./ui/Modal"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { api } from "../lib/api"; @@ -9,6 +9,7 @@ import { qk } from "../lib/queryKeys"; import { notify } from "../lib/notifications"; import { notifyActionError } from "../lib/actionErrors"; import { copyText } from "../lib/clipboard"; +import { formatDateTime } from "../lib/format"; import { invalidateUserAccess } from "../lib/adminUsers"; import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin"; @@ -168,6 +169,20 @@ export default function AccountPanel({ } tone={a.has_google ? "ok" : "muted"} /> + {/* The exact moment; the list row beside it only says how long ago. Null has two different + meanings, so it says which: nothing recorded yet, or the shared demo account, which is + deliberately never stamped. */} + {/* The gate that explains a Google sign-in silently becoming a fresh access request. */} {!a.is_allowed && ( ({ reportError: vi.fn() })); import AdminUsers from "./AdminUsers"; import { api, type AdminUserRow } from "../lib/api"; +import { relativeTime } from "../lib/format"; import i18n from "../i18n"; const row = { @@ -76,6 +77,27 @@ function mount() { return users; } +describe("the last sign-in column (E22 S1)", () => { + it("says how long ago each account signed in, and a dash where nothing is recorded", async () => { + const at = new Date(Date.now() - 3 * 86_400_000).toISOString(); + vi.spyOn(api, "adminUsers").mockResolvedValue([ + { ...row, id: 3, email: "seen@x", last_login_at: at }, + { ...row, id: 4, email: "never@x", last_login_at: null }, + { ...row, id: 5, email: "demo@x", is_demo: true, last_login_at: null }, + ] as never); + render( + + + , + ); + + expect(await screen.findByText(relativeTime(at))).toBeTruthy(); + expect(screen.getAllByText("—")).toHaveLength(2); + }); +}); + describe("a user action that fails", () => { it("says a user who is gone, and refetches the list", async () => { const users = mount(); diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 916d5508..833b8052 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -24,6 +24,7 @@ import { type PendingAccessRow, } from "../lib/api"; import { notify } from "../lib/notifications"; +import { formatDateTime, relativeTime } from "../lib/format"; import { invalidateUserAccess } from "../lib/adminUsers"; import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab"; import { Button } from "./ui/Button"; @@ -310,6 +311,23 @@ function UsersRoles({ me }: { me: Me }) { icon={} /> + {/* How long ago this account last signed in. LAST in the row on purpose: the badges and + the role button before it vary in width row to row, so only the row's own right + edge lines the values up into one column. The exact moment is the hint here and a + line in the account panel; below `sm` the row has no room, and the panel still says it. */} + + + {u.last_login_at ? relativeTime(u.last_login_at) : "—"} + + ); })} diff --git a/frontend/src/i18n/locales/en/users.json b/frontend/src/i18n/locales/en/users.json index 8d2d2a34..da41c7fb 100644 --- a/frontend/src/i18n/locales/en/users.json +++ b/frontend/src/i18n/locales/en/users.json @@ -30,6 +30,9 @@ "suspended": "Suspended", "pending": "Pending approval", "unverified": "Unverified email", + "lastLogin": "Last sign-in: {{time}}", + "lastLoginNone": "No sign-in recorded yet", + "lastLoginDemo": "Not recorded for the demo account", "demoLocked": "The demo account's role can't be changed.", "selfLocked": "You can't change your own role.", "makeAdmin": "Make admin", @@ -81,6 +84,9 @@ "googleConnected": "connected", "googleNone": "not connected", "invite": "Invite", + "lastLogin": "Last sign-in", + "lastLoginNone": "none recorded yet", + "lastLoginDemo": "not recorded for the demo account", "inviteMissing": "this address is not on the allow-list", "linkNote": "Valid 24 hours, works once. Opening it lets this person set a password and signs out anyone already using the account. It does not confirm their address.", "mailOff": "no mail configured", diff --git a/frontend/src/i18n/locales/hu/users.json b/frontend/src/i18n/locales/hu/users.json index 48d6e7ef..3e41e878 100644 --- a/frontend/src/i18n/locales/hu/users.json +++ b/frontend/src/i18n/locales/hu/users.json @@ -30,6 +30,9 @@ "suspended": "Felfüggesztve", "pending": "Jóváhagyásra vár", "unverified": "Nem igazolt e-mail", + "lastLogin": "Utolsó belépés: {{time}}", + "lastLoginNone": "Nincs még rögzített belépés", + "lastLoginDemo": "A demo fióknál nem rögzítjük", "demoLocked": "A demo fiók szerepe nem módosítható.", "selfLocked": "A saját szerepedet nem módosíthatod.", "makeAdmin": "Adminná tesz", @@ -81,6 +84,9 @@ "googleConnected": "összekötve", "googleNone": "nincs összekötve", "invite": "Meghívó", + "lastLogin": "Utolsó belépés", + "lastLoginNone": "még nincs rögzítve", + "lastLoginDemo": "a demo fióknál nem rögzítjük", "inviteMissing": "ez a cím nincs az engedélyezési listán", "linkNote": "24 óráig érvényes, egyszer használható. Aki megnyitja, beállíthat egy jelszót, és ezzel kilép mindenki, aki eddig használta a fiókot. A címet nem erősíti meg.", "mailOff": "nincs beállítva levelezés", diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 2e2a0958..16e7bf9d 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -148,6 +148,12 @@ export function formatCountOrDash(n: number | null | undefined): string { return n != null ? formatViews(n) : "—"; } +// Absolute date AND time in the UI language (e.g. "16 Sept 2026, 08:30:14"). A bare `toLocaleString()` +// formats in the browser's locale instead, so an English UI showed a Hungarian-shaped date. Empty for null. +export function formatDateTime(iso: string | null | undefined): string { + return iso ? new Date(iso).toLocaleString(i18n.language) : ""; +} + // Absolute day-precision date (e.g. "12 Jan 2017"), shown alongside a relative time. Empty for null. export function formatDate(iso: string | null | undefined, lang: string): string { return iso