2026-10-16 07:40:29 -06:00
|
|
|
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";
|
2026-10-16 14:45:38 -06:00
|
|
|
import { FeedTab, ResourcesTab } from "./FeedTabs";
|
2026-10-16 07:40:29 -06:00
|
|
|
|
2026-10-16 14:45:38 -06:00
|
|
|
type Tab = "users" | "lookup" | "devices" | "groups" | "attempts" | "feed" | "resources";
|
2026-10-16 07:40:29 -06:00
|
|
|
|
|
|
|
|
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" },
|
2026-10-16 14:45:38 -06:00
|
|
|
{ id: "feed", label: "Feed" },
|
|
|
|
|
{ id: "resources", label: "VISUAL resources" },
|
2026-10-16 07:40:29 -06:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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}
|
2026-10-16 14:45:38 -06:00
|
|
|
{tab === "feed" ? <FeedTab /> : null}
|
|
|
|
|
{tab === "resources" ? <ResourcesTab /> : null}
|
2026-10-16 07:40:29 -06:00
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|