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:
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
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 { 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 Modal from "./ui/Modal";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
@@ -9,6 +9,7 @@ import { qk } from "../lib/queryKeys";
|
|||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { copyText } from "../lib/clipboard";
|
import { copyText } from "../lib/clipboard";
|
||||||
|
import { formatDateTime } from "../lib/format";
|
||||||
import { invalidateUserAccess } from "../lib/adminUsers";
|
import { invalidateUserAccess } from "../lib/adminUsers";
|
||||||
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
||||||
|
|
||||||
@@ -168,6 +169,20 @@ export default function AccountPanel({
|
|||||||
}
|
}
|
||||||
tone={a.has_google ? "ok" : "muted"}
|
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. */}
|
{/* The gate that explains a Google sign-in silently becoming a fresh access request. */}
|
||||||
{!a.is_allowed && (
|
{!a.is_allowed && (
|
||||||
<AccessRow
|
<AccessRow
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ vi.mock("../lib/errorDialog", () => ({ reportError: vi.fn() }));
|
|||||||
|
|
||||||
import AdminUsers from "./AdminUsers";
|
import AdminUsers from "./AdminUsers";
|
||||||
import { api, type AdminUserRow } from "../lib/api";
|
import { api, type AdminUserRow } from "../lib/api";
|
||||||
|
import { relativeTime } from "../lib/format";
|
||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
|
|
||||||
const row = {
|
const row = {
|
||||||
@@ -76,6 +77,27 @@ function mount() {
|
|||||||
return users;
|
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", () => {
|
describe("a user action that fails", () => {
|
||||||
it("says a user who is gone, and refetches the list", async () => {
|
it("says a user who is gone, and refetches the list", async () => {
|
||||||
const users = mount();
|
const users = mount();
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
type PendingAccessRow,
|
type PendingAccessRow,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
|
import { formatDateTime, relativeTime } from "../lib/format";
|
||||||
import { invalidateUserAccess } from "../lib/adminUsers";
|
import { invalidateUserAccess } from "../lib/adminUsers";
|
||||||
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
@@ -310,6 +311,23 @@ function UsersRoles({ me }: { me: Me }) {
|
|||||||
icon={<Trash2 className="w-4 h-4" />}
|
icon={<Trash2 className="w-4 h-4" />}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -30,6 +30,9 @@
|
|||||||
"suspended": "Suspended",
|
"suspended": "Suspended",
|
||||||
"pending": "Pending approval",
|
"pending": "Pending approval",
|
||||||
"unverified": "Unverified email",
|
"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.",
|
"demoLocked": "The demo account's role can't be changed.",
|
||||||
"selfLocked": "You can't change your own role.",
|
"selfLocked": "You can't change your own role.",
|
||||||
"makeAdmin": "Make admin",
|
"makeAdmin": "Make admin",
|
||||||
@@ -81,6 +84,9 @@
|
|||||||
"googleConnected": "connected",
|
"googleConnected": "connected",
|
||||||
"googleNone": "not connected",
|
"googleNone": "not connected",
|
||||||
"invite": "Invite",
|
"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",
|
"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.",
|
"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",
|
"mailOff": "no mail configured",
|
||||||
|
|||||||
@@ -30,6 +30,9 @@
|
|||||||
"suspended": "Felfüggesztve",
|
"suspended": "Felfüggesztve",
|
||||||
"pending": "Jóváhagyásra vár",
|
"pending": "Jóváhagyásra vár",
|
||||||
"unverified": "Nem igazolt e-mail",
|
"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ó.",
|
"demoLocked": "A demo fiók szerepe nem módosítható.",
|
||||||
"selfLocked": "A saját szerepedet nem módosíthatod.",
|
"selfLocked": "A saját szerepedet nem módosíthatod.",
|
||||||
"makeAdmin": "Adminná tesz",
|
"makeAdmin": "Adminná tesz",
|
||||||
@@ -81,6 +84,9 @@
|
|||||||
"googleConnected": "összekötve",
|
"googleConnected": "összekötve",
|
||||||
"googleNone": "nincs összekötve",
|
"googleNone": "nincs összekötve",
|
||||||
"invite": "Meghívó",
|
"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",
|
"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.",
|
"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",
|
"mailOff": "nincs beállítva levelezés",
|
||||||
|
|||||||
@@ -148,6 +148,12 @@ export function formatCountOrDash(n: number | null | undefined): string {
|
|||||||
return n != null ? formatViews(n) : "—";
|
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.
|
// 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 {
|
export function formatDate(iso: string | null | undefined, lang: string): string {
|
||||||
return iso
|
return iso
|
||||||
|
|||||||
Reference in New Issue
Block a user