Files
PacketStation/frontend/src/screens/admin/AdminPage.tsx
T

708 lines
23 KiB
TypeScript
Raw Normal View History

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";
import { FeedTab, ResourcesTab } from "./FeedTabs";
type Tab = "users" | "lookup" | "devices" | "groups" | "attempts" | "feed" | "resources";
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" },
{ id: "feed", label: "Feed" },
{ id: "resources", label: "VISUAL resources" },
];
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}
{tab === "feed" ? <FeedTab /> : null}
{tab === "resources" ? <ResourcesTab /> : null}
</div>
</>
);
}