Phase 1: identity, sessions, audit, frontend shell

AD sign-in over LDAPS (search with the service account, bind as the user,
nested groups through LDAP_MATCHING_RULE_IN_CHAIN, disabled accounts refused),
roles as the union of PS-* groups mapped by a table derived from the seeded
departments. Server-side sessions with idle, lock and absolute timeouts, Origin
and X-Requested-With check, step-up unlock. Optional Argon2id PIN set with the
domain password; PIN sign-in by employee ID on registered devices only; five
wrong attempts disable the PIN, three wrong at unlock or signature end the
session. Rate limits per account and per address before any bind. Devices,
signatures table and sign helper, audit rows for every identity event, SSE over
LISTEN/NOTIFY with presence. Account settings and Admin APIs and screens
(AD view, devices, PIN clearing, group map with missing-group flags, refresh
from AD now). CLI ad-groups --list and ad-refresh. Migration 0002.

Frontend: real sign-in (password tab, PIN keypad on registered devices), lock
screen, role switcher, Account settings, Admin shell, screen placeholders.

CI: Samba AD DC container (PSTEST.LOCAL) with a generated CA, 49 of the 50
groups plus one extra, 17 test users; wired into the CI compose overlay and
the Gitea workflow; Playwright sign-in scenario.

Verified on docker-1: 171 server tests green inside the CI stack against
PostgreSQL and the Samba DC, 48 frontend tests, 8 Playwright scenarios against
the live stack (password and PIN sign-in, lock and unlock, Admin group map).
Findings recorded in the docs: the DC needs CAP_SYS_ADMIN, a hidden tab locks
only after a minute as the old build did,
and the app uses the alias ps-app for Playwright because "app" is on
Chromium's HSTS preload list.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 07:40:29 -06:00
co-authored by Claude Fable 5.1
parent 0b663bb2b2
commit 97b0973130
95 changed files with 9609 additions and 183 deletions
@@ -0,0 +1,180 @@
import { fireEvent, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
const ADMIN = makeMe({
user: { display_name: "Ann Admin", sam_account: "admin.ann" },
roles: ["admin"],
screens: ["clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin"],
});
const USERS = [
{
id: "u-1",
sam_account: "dup.dave",
display_name: "Dave Dup",
emp_no: "9001",
email: null,
active: true,
roles: ["shop:03:worker"],
pin: { set: true, disabled: false },
flags: { emp_missing: false, emp_duplicate: true, disabled: false },
last_signin_at: null,
ad_synced_at: null,
},
{
id: "u-2",
sam_account: "noemp.nora",
display_name: "Nora NoEmp",
emp_no: null,
email: null,
active: true,
roles: ["warehouse"],
pin: { set: false, disabled: false },
flags: { emp_missing: true, emp_duplicate: false, disabled: false },
last_signin_at: null,
ad_synced_at: null,
},
];
const GROUPS = {
checked_at: "2026-10-16T08:00:00Z",
ad_reachable: true,
groups: [
{ name: "PS-Admins", role: "admin", dept_code: null, in_ad: true },
{ name: "PS-03-WeldParts-Supervisor", role: "shop_supervisor", dept_code: "03", in_ad: true },
{ name: "PS-14-Electrical-Lead", role: "shop_lead", dept_code: "14", in_ad: false },
],
extra_in_ou: ["PS-Extra-Legacy"],
};
function base(me = ADMIN) {
return {
"/api/auth/me": { body: me },
"/api/departments": { body: DEPARTMENTS },
"/api/admin/users": { body: USERS },
"/api/admin/groups": { body: GROUPS },
};
}
describe("AdminPage", () => {
it("lists users with employee ID flags and opens the detail", async () => {
mockFetch({
...base(),
"/api/admin/users/u-1": {
body: { ...USERS[0], groups: ["PS-03-WeldParts-Worker"], sessions: [], note: "Roles come from AD groups. Ask IT to change them." },
},
});
renderApp("/admin");
const row = (await screen.findByText("Dave Dup")).closest("tr") as HTMLElement;
expect(within(row).getByText("Duplicate")).toHaveClass("pill", "st-bad");
expect(await within(row).findByText("03 Weld Parts · Worker")).toHaveClass("rchip");
const nora = screen.getByText("Nora NoEmp").closest("tr") as HTMLElement;
expect(within(nora).getByText("Missing")).toBeInTheDocument();
fireEvent.click(row);
expect(await screen.findByText("Roles come from AD groups. Ask IT to change them.")).toBeInTheDocument();
expect(screen.getByText("PS-03-WeldParts-Worker")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Clear PIN" })).toBeInTheDocument();
});
it("clears a PIN with a reason", async () => {
const fetch = mockFetch({
...base(),
"/api/admin/users/u-1": { body: { ...USERS[0], groups: [], sessions: [], note: "" } },
"DELETE /api/admin/users/u-1/pin": { status: 204 },
});
renderApp("/admin");
fireEvent.click(await screen.findByText("Dave Dup"));
const clear = await screen.findByRole("button", { name: "Clear PIN" });
fireEvent.click(clear);
expect(await screen.findByRole("alert")).toHaveTextContent("at least 8 characters");
fireEvent.change(screen.getByLabelText("Reason for clearing the PIN"), { target: { value: "Forgot the PIN, asked by phone" } });
fireEvent.click(clear);
expect(await screen.findByText(/PIN cleared/)).toBeInTheDocument();
expect(callsTo(fetch, "DELETE", "/api/admin/users/u-1/pin")).toEqual([{ reason: "Forgot the PIN, asked by phone" }]);
});
it("flags a group missing in AD, lists extras, and refreshes", async () => {
const fetch = mockFetch({
...base(),
"POST /api/admin/ad/refresh": {
body: {
groups_checked: 50,
groups_missing: ["PS-14-Electrical-Lead"],
users_seen: 16,
created: 2,
updated: 14,
deactivated: 1,
emp_missing: ["noemp.nora"],
emp_duplicate: ["9001"],
},
},
});
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Group map" }));
expect(screen.getByRole("tab", { name: "Group map" })).toHaveAttribute("aria-selected", "true");
const row = (await screen.findByText("PS-14-Electrical-Lead")).closest("tr") as HTMLElement;
expect(within(row).getByText("Missing in AD")).toHaveClass("pill", "st-bad");
expect(within(row).getByText("14 Electrical · Lead")).toBeInTheDocument();
const ok = screen.getByText("PS-Admins").closest("tr") as HTMLElement;
expect(within(ok).getByText("In AD")).toHaveClass("st-ok");
expect(screen.getByText("PS-Extra-Legacy")).toBeInTheDocument();
expect(screen.getByText(/1 group is missing in AD/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Refresh from AD now" }));
expect(await screen.findByText("Users seen")).toBeInTheDocument();
expect(screen.getByText("16")).toBeInTheDocument();
expect(screen.getByText(/noemp\.nora/)).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/admin/ad/refresh")).toHaveLength(1);
});
it("marks groups as unreachable when AD is down", async () => {
mockFetch({
...base(),
"/api/admin/groups": {
body: { ...GROUPS, ad_reachable: false, groups: GROUPS.groups.map((g) => ({ ...g, in_ad: null })), extra_in_ou: [] },
},
});
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Group map" }));
expect(await screen.findByText(/AD is unreachable/)).toBeInTheDocument();
expect(screen.getAllByText("AD unreachable")).toHaveLength(3);
});
it("a PIN session sees only the notice", async () => {
const fetch = mockFetch(base(makeMe({ ...ADMIN, session: { kind: "pin" } })));
renderApp("/admin");
expect(await screen.findByText(/Admin needs a password sign-in/)).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "Users" })).not.toBeInTheDocument();
expect(callsTo(fetch, "GET", "/api/admin/users")).toHaveLength(0);
});
it("revokes a device after a confirm", async () => {
const fetch = mockFetch({
...base(),
"/api/admin/devices": {
body: [
{
id: "dev-1",
name: "Weld Parts Stall 3",
registered_by: { id: "u-9", display_name: "Wes Super" },
registered_at: null,
last_seen_at: null,
revoked_at: null,
active_sessions: 2,
},
],
},
"DELETE /api/admin/devices/dev-1": { status: 204 },
});
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Devices" }));
fireEvent.click(await screen.findByRole("button", { name: "Revoke" }));
fireEvent.click(screen.getByRole("button", { name: "Confirm revoke" }));
await screen.findByRole("button", { name: "Revoke" });
expect(callsTo(fetch, "DELETE", "/api/admin/devices/dev-1")).toHaveLength(1);
});
});
+702
View File
@@ -0,0 +1,702 @@
import { useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
import { adminApi } from "../../auth/api";
import { roleChipClass, roleFromKind, sortRoles } from "../../auth/roles";
import type { AdLookup, AdRefreshResult, AdminUser, GroupRow, PinState } from "../../auth/types";
import { useAuth } from "../../auth/useAuth";
import { errorText, fmtTime } from "../../lib/format";
import { useLoad, type Load } from "../../lib/useLoad";
type Tab = "users" | "lookup" | "devices" | "groups" | "attempts";
const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "users", label: "Users" },
{ id: "lookup", label: "Look up AD account" },
{ id: "devices", label: "Devices" },
{ id: "groups", label: "Group map" },
{ id: "attempts", label: "Sign-in attempts" },
];
const ROLES_NOTE = "Roles come from AD groups. Ask IT to change them.";
function Problem({ load }: { load: Load<unknown> }) {
if (load.state === "loading") return <div className="empty slim">Loading</div>;
if (load.state === "error") return <div className="callout warn">{load.message}</div>;
return null;
}
function RoleChips({ roles, label }: { roles: readonly string[]; label: string }) {
const { roleLabel } = useAuth();
if (roles.length === 0) return <span className="muted small">No roles</span>;
return (
<ul className="chips plain tight" aria-label={label}>
{sortRoles(roles).map((r) => (
<li key={r} className={roleChipClass(r)}>
{roleLabel(r)}
</li>
))}
</ul>
);
}
function GroupChips({ groups }: { groups: readonly string[] }) {
if (groups.length === 0) return <span className="muted small">No Packet Station groups</span>;
return (
<ul className="chips plain tight" aria-label="Groups">
{groups.map((g) => (
<li key={g} className="chip static mono">
{g}
</li>
))}
</ul>
);
}
function PinPill({ pin }: { pin: PinState }) {
if (!pin.set) return <span className="muted small">None</span>;
if (pin.disabled) return <span className="pill st-bad">Disabled</span>;
return <span className="pill st-ok">Set</span>;
}
/* ---------- Users ---------- */
function UserDetail({ id, onChanged, onClose }: { id: string; onChanged: () => void; onClose: () => void }) {
const [load, reload] = useLoad((s) => adminApi.user(id, s), id);
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const clearPin = async (e: FormEvent) => {
e.preventDefault();
if (reason.trim().length < 8) {
setMsg({ ok: false, text: "Give a reason of at least 8 characters." });
return;
}
setBusy(true);
try {
await adminApi.clearPin(id, reason.trim());
setReason("");
setMsg({ ok: true, text: "PIN cleared. The user sets a new one from Account." });
reload();
onChanged();
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
if (load.state !== "ok") {
return (
<div className="panel">
<Problem load={load} />
</div>
);
}
const u = load.data;
return (
<section className="panel stack" aria-labelledby="user-detail-h">
<div className="sec-h">
<h3 id="user-detail-h">{u.display_name}</h3>
<span className="mono small muted">{u.sam_account}</span>
<span className="grow" />
<button className="btn sm ghost" type="button" onClick={onClose}>
Close
</button>
</div>
<dl className="kv">
<div>
<dt>Employee ID</dt>
<dd className="mono">{u.emp_no ?? <span className="muted">Missing</span>}</dd>
</div>
<div>
<dt>Email</dt>
<dd>{u.email ?? "-"}</dd>
</div>
<div>
<dt>Last sign-in</dt>
<dd className="mono small">{fmtTime(u.last_signin_at)}</dd>
</div>
<div>
<dt>Read from AD</dt>
<dd className="mono small">{fmtTime(u.ad_synced_at)}</dd>
</div>
<div>
<dt>Groups{u.groups_source === "cache" ? " (cached, AD unreachable)" : " (live)"}</dt>
<dd>
<GroupChips groups={u.groups} />
</dd>
</div>
<div>
<dt>Roles</dt>
<dd>
<RoleChips roles={u.roles} label="Roles" />
</dd>
</div>
</dl>
<div className="callout flush">{u.note || ROLES_NOTE}</div>
<h3>Sessions</h3>
<div className="tw">
<table>
<thead>
<tr>
<th>Started</th>
<th>Last seen</th>
<th>Sign-in</th>
<th>Device</th>
<th>Address</th>
</tr>
</thead>
<tbody>
{u.sessions.map((s) => (
<tr key={s.id}>
<td className="small mono">{fmtTime(s.created_at)}</td>
<td className="small mono">{fmtTime(s.last_seen_at)}</td>
<td>{s.kind === "pin" ? "PIN" : "Password"}</td>
<td>{s.device?.name ?? "-"}</td>
<td className="mono small">{s.ip ?? "-"}</td>
</tr>
))}
{u.sessions.length === 0 ? (
<tr>
<td colSpan={5} className="muted">
Not signed in.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<h3>PIN</h3>
<div>
<PinPill pin={u.pin} />
</div>
{u.pin.set ? (
<form className="frow" onSubmit={clearPin} noValidate>
<label className="f">
Reason for clearing the PIN
<input className="in" value={reason} maxLength={300} onChange={(e) => setReason(e.target.value)} />
</label>
<div className="end">
<button className="btn rejb" type="submit" disabled={busy}>
Clear PIN
</button>
</div>
</form>
) : null}
{msg ? (
<div className={msg.ok ? "callout flush" : "callout warn flush"} role={msg.ok ? "status" : "alert"}>
{msg.text}
</div>
) : null}
</section>
);
}
function UsersTab() {
const [q, setQ] = useState("");
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<string | null>(null);
const [load, reload] = useLoad((s) => adminApi.users(query, s), query);
// Search as the admin types, a moment after the last key.
useEffect(() => {
const t = window.setTimeout(() => setQuery(q.trim()), 250);
return () => window.clearTimeout(t);
}, [q]);
const rows: AdminUser[] = load.state === "ok" ? load.data : [];
const open = (id: string) => setSelected((cur) => (cur === id ? null : id));
const onRowKey = (e: KeyboardEvent, id: string) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
open(id);
}
};
return (
<div className="stack">
<form className="frow" role="search" onSubmit={(e) => e.preventDefault()}>
<label className="f">
Search users
<input className="in" type="search" placeholder="Name, account or employee ID" value={q} onChange={(e) => setQ(e.target.value)} />
</label>
</form>
<Problem load={load} />
{load.state === "ok" ? (
<div className="tw">
<table>
<thead>
<tr>
<th>Name</th>
<th>Account</th>
<th>Employee ID</th>
<th>Roles</th>
<th>PIN</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{rows.map((u) => (
<tr
key={u.id}
className={selected === u.id ? "rowlink on" : "rowlink"}
tabIndex={0}
aria-selected={selected === u.id}
onClick={() => open(u.id)}
onKeyDown={(e) => onRowKey(e, u.id)}
>
<td>{u.display_name}</td>
<td className="mono small">{u.sam_account}</td>
<td className="mono">
{u.emp_no ?? ""} {u.flags.emp_missing ? <span className="pill st-bad">Missing</span> : null}
{u.flags.emp_duplicate ? <span className="pill st-bad">Duplicate</span> : null}
</td>
<td>
<RoleChips roles={u.roles} label={`Roles of ${u.display_name}`} />
</td>
<td>
<PinPill pin={u.pin} />
</td>
<td>
{u.flags.disabled ? (
<span className="pill st-bad">Disabled in AD</span>
) : u.active ? (
<span className="pill st-ok">Active</span>
) : (
<span className="pill st-filed">Inactive</span>
)}
</td>
</tr>
))}
{rows.length === 0 ? (
<tr>
<td colSpan={6} className="muted">
No users match.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : null}
{selected ? <UserDetail key={selected} id={selected} onChanged={reload} onClose={() => setSelected(null)} /> : null}
</div>
);
}
/* ---------- Look up AD account ---------- */
function LookupTab() {
const [login, setLogin] = useState("");
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<AdLookup | null>(null);
const [error, setError] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!login.trim()) return;
setBusy(true);
setError(null);
try {
setResult(await adminApi.lookup(login.trim()));
} catch (err) {
setResult(null);
setError(errorText(err));
} finally {
setBusy(false);
}
};
const u = result?.user ?? null;
return (
<div className="stack">
<div className="small muted">Reads one account live from AD. Nothing is saved.</div>
<form className="frow" onSubmit={submit} noValidate>
<label className="f">
Account name
<input className="in" autoCapitalize="none" spellCheck={false} value={login} onChange={(e) => setLogin(e.target.value)} />
</label>
<div className="end">
<button className="btn pri" type="submit" disabled={busy}>
Look up
</button>
</div>
</form>
{error ? (
<div className="callout warn" role="alert">
{error}
</div>
) : null}
{result && !result.found ? <div className="callout">No account by that name in AD.</div> : null}
{result && result.found ? (
<div className="panel stack">
<div className="sec-h">
<h3>{u?.display_name ?? login}</h3>
<span className="mono small muted">{u?.sam_account ?? ""}</span>
{result.disabled ? <span className="pill st-bad">Disabled in AD</span> : <span className="pill st-ok">Enabled</span>}
</div>
<dl className="kv">
<div>
<dt>UPN</dt>
<dd>{u?.upn ?? "-"}</dd>
</div>
<div>
<dt>Email</dt>
<dd>{u?.email ?? "-"}</dd>
</div>
<div>
<dt>Employee ID</dt>
<dd className="mono">{u?.emp_no ?? <span className="muted">Missing</span>}</dd>
</div>
<div>
<dt>Groups</dt>
<dd>
<GroupChips groups={result.groups ?? []} />
</dd>
</div>
<div>
<dt>Roles</dt>
<dd>
<RoleChips roles={result.roles ?? []} label="Roles" />
</dd>
</div>
</dl>
</div>
) : null}
</div>
);
}
/* ---------- Devices ---------- */
function DevicesTab() {
const [load, reload] = useLoad((s) => adminApi.devices(s));
const [confirm, setConfirm] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const revoke = async (id: string) => {
setError(null);
try {
await adminApi.revokeDevice(id);
setConfirm(null);
reload();
} catch (err) {
setError(errorText(err));
}
};
const rows = load.state === "ok" ? load.data : [];
return (
<div className="stack">
<div className="small muted">Revoking a device ends every PIN session on it.</div>
{error ? (
<div className="callout warn" role="alert">
{error}
</div>
) : null}
<Problem load={load} />
{load.state === "ok" ? (
<div className="tw">
<table>
<thead>
<tr>
<th>Name</th>
<th>Registered by</th>
<th>Registered</th>
<th>Last seen</th>
<th className="num">Sessions</th>
<th>Status</th>
<th />
</tr>
</thead>
<tbody>
{rows.map((d) => (
<tr key={d.id}>
<td>{d.name}</td>
<td>{d.registered_by?.display_name ?? "-"}</td>
<td className="small mono">{fmtTime(d.registered_at)}</td>
<td className="small mono">{fmtTime(d.last_seen_at)}</td>
<td className="num">{d.active_sessions}</td>
<td>{d.revoked_at ? <span className="pill st-filed">Revoked</span> : <span className="pill st-ok">Active</span>}</td>
<td className="nowrap">
{d.revoked_at ? null : confirm === d.id ? (
<>
<button className="btn sm rejb" type="button" onClick={() => void revoke(d.id)}>
Confirm revoke
</button>{" "}
<button className="btn sm ghost" type="button" onClick={() => setConfirm(null)}>
Cancel
</button>
</>
) : (
<button className="btn sm ghost" type="button" onClick={() => setConfirm(d.id)}>
Revoke
</button>
)}
</td>
</tr>
))}
{rows.length === 0 ? (
<tr>
<td colSpan={7} className="muted">
No devices registered.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : null}
</div>
);
}
/* ---------- Group map ---------- */
function InAdPill({ inAd }: { inAd: boolean | null }) {
if (inAd === true) return <span className="pill st-ok">In AD</span>;
if (inAd === false) return <span className="pill st-bad">Missing in AD</span>;
return <span className="pill st-wait">AD unreachable</span>;
}
function RefreshResult({ r }: { r: AdRefreshResult }) {
const tiles: Array<[number, string, boolean]> = [
[r.groups_checked, "Groups checked", false],
[r.groups_missing.length, "Groups missing", r.groups_missing.length > 0],
[r.users_seen, "Users seen", false],
[r.created, "Created", false],
[r.updated, "Updated", false],
[r.deactivated, "Deactivated", r.deactivated > 0],
];
return (
<div role="status">
<div className="kpis">
{tiles.map(([n, l, hot]) => (
<div key={l} className={hot ? "kpi hot" : n === 0 ? "kpi zero" : "kpi"}>
<div className="n">{n}</div>
<div className="l">{l}</div>
</div>
))}
</div>
{r.groups_missing.length ? (
<div className="callout warn">
Missing groups: <span className="mono">{r.groups_missing.join(", ")}</span>
</div>
) : null}
{r.emp_missing.length ? (
<div className="callout warn">
No employee ID in AD: <span className="mono">{r.emp_missing.join(", ")}</span>
</div>
) : null}
{r.emp_duplicate.length ? (
<div className="callout warn">
Employee IDs used by more than one account: <span className="mono">{r.emp_duplicate.join(", ")}</span>
</div>
) : null}
</div>
);
}
function GroupsTab() {
const { roleLabel } = useAuth();
const [load, reload] = useLoad((s) => adminApi.groups(s));
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<AdRefreshResult | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = async () => {
setBusy(true);
setError(null);
try {
setResult(await adminApi.refresh());
reload();
} catch (err) {
setError(errorText(err));
} finally {
setBusy(false);
}
};
const map = load.state === "ok" ? load.data : null;
const missing = map ? map.groups.filter((g) => g.in_ad === false).length : 0;
const label = (g: GroupRow) => roleLabel(roleFromKind(g.role, g.dept_code));
return (
<div className="stack">
<div className="sec-h">
<span className="small muted">
The groups Packet Station reads, from the departments table.
{map?.checked_at ? ` Checked ${fmtTime(map.checked_at)}.` : ""}
</span>
<span className="grow" />
<button className="btn sm pri" type="button" disabled={busy} onClick={() => void refresh()}>
{busy ? "Reading AD" : "Refresh from AD now"}
</button>
</div>
{error ? (
<div className="callout warn" role="alert">
{error}
</div>
) : null}
{result ? <RefreshResult r={result} /> : null}
<Problem load={load} />
{map && !map.ad_reachable ? <div className="callout warn">AD is unreachable. Showing the expected groups only.</div> : null}
{map && missing > 0 ? (
<div className="callout warn">
{missing === 1 ? "1 group is" : `${missing} groups are`} missing in AD. Ask IT to create {missing === 1 ? "it" : "them"}.
</div>
) : null}
{map ? (
<div className="tw">
<table>
<thead>
<tr>
<th>Group</th>
<th>Role</th>
<th>In AD</th>
</tr>
</thead>
<tbody>
{map.groups.map((g) => (
<tr key={g.name}>
<td className="mono small">{g.name}</td>
<td>
<span className={roleChipClass(roleFromKind(g.role, g.dept_code))}>{label(g)}</span>
</td>
<td>
<InAdPill inAd={g.in_ad} />
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{map ? (
<section aria-labelledby="extra-h">
<h3 id="extra-h">Other groups in the OU</h3>
{map.extra_in_ou.length ? (
<>
<div className="small muted">Packet Station does not use these.</div>
<ul className="chips plain">
{map.extra_in_ou.map((g) => (
<li key={g} className="chip static mono">
{g}
</li>
))}
</ul>
</>
) : (
<div className="small muted">None.</div>
)}
</section>
) : null}
</div>
);
}
/* ---------- Sign-in attempts ---------- */
function AttemptsTab() {
const [load, reload] = useLoad((s) => adminApi.signinAttempts(100, s));
const rows = load.state === "ok" ? load.data : [];
return (
<div className="stack">
<div className="sec-h">
<span className="small muted">Recent failed attempts. Newest first.</span>
<span className="grow" />
<button className="btn sm ghost" type="button" onClick={reload}>
Refresh
</button>
</div>
<Problem load={load} />
{load.state === "ok" ? (
<div className="tw">
<table>
<thead>
<tr>
<th>When</th>
<th>Account</th>
<th>Kind</th>
<th>Reason</th>
<th>Address</th>
</tr>
</thead>
<tbody>
{rows.map((a, i) => (
<tr key={a.id ?? `${a.at}-${i}`}>
<td className="small mono">{fmtTime(a.at)}</td>
<td className="mono small">{a.account}</td>
<td>{a.kind}</td>
<td>{a.reason ? a.reason.replace(/_/g, " ") : "-"}</td>
<td className="mono small">{a.ip ?? "-"}</td>
</tr>
))}
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="muted">
No failed attempts.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : null}
</div>
);
}
/** /admin: users, AD lookup, devices, the group map and failed sign-ins. Needs a password session. */
export function AdminPage() {
const { me } = useAuth();
const [tab, setTab] = useState<Tab>("users");
const head = (
<div className="phead">
<div>
<div className="eyebrow">Admin</div>
<h1>Users and access</h1>
</div>
</div>
);
if (me?.session.kind !== "password") {
return (
<>
{head}
<div className="callout warn">Admin needs a password sign-in. Sign out and sign in with your domain password.</div>
</>
);
}
return (
<>
{head}
<div className="tabs" role="tablist" aria-label="Admin">
{TABS.map((t) => (
<button
key={t.id}
type="button"
role="tab"
id={`admtab-${t.id}`}
aria-selected={tab === t.id}
aria-controls={`admpanel-${t.id}`}
onClick={() => setTab(t.id)}
>
{t.label}
</button>
))}
</div>
<div role="tabpanel" id={`admpanel-${tab}`} aria-labelledby={`admtab-${tab}`}>
{tab === "users" ? <UsersTab /> : null}
{tab === "lookup" ? <LookupTab /> : null}
{tab === "devices" ? <DevicesTab /> : null}
{tab === "groups" ? <GroupsTab /> : null}
{tab === "attempts" ? <AttemptsTab /> : null}
</div>
</>
);
}