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.
This commit is contained in:
2026-09-16 00:45:03 +02:00
parent 11aef4ab7a
commit e299c66121
6 changed files with 74 additions and 1 deletions
+16 -1
View File
@@ -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. */}
<AccessRow
icon={LogIn}
label={t("users.account.lastLogin")}
value={
a.last_login_at
? formatDateTime(a.last_login_at)
: a.is_demo
? t("users.account.lastLoginDemo")
: t("users.account.lastLoginNone")
}
/>
{/* The gate that explains a Google sign-in silently becoming a fresh access request. */}
{!a.is_allowed && (
<AccessRow
@@ -36,6 +36,7 @@ vi.mock("../lib/errorDialog", () => ({ 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(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<AdminUsers />
</QueryClientProvider>,
);
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();
+18
View File
@@ -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={<Trash2 className="w-4 h-4" />}
/>
</Tooltip>
{/* 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. */}
<Tooltip
hint={
u.last_login_at
? t("users.roles.lastLogin", { time: formatDateTime(u.last_login_at) })
: u.is_demo
? t("users.roles.lastLoginDemo")
: t("users.roles.lastLoginNone")
}
>
<span className="hidden sm:inline-block w-20 shrink-0 text-right text-2xs text-muted tabular-nums">
{u.last_login_at ? relativeTime(u.last_login_at) : "—"}
</span>
</Tooltip>
</div>
);
})}
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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