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