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";
|
2026-10-21 18:27:09 -06:00
|
|
|
import { admin9Api, type ReconcileAnswer, type RefreshRequested } from "../../api/admin";
|
|
|
|
|
import type { AdLookup, AdminUser, GroupRow, PinState } from "../../auth/types";
|
2026-10-16 07:40:29 -06:00
|
|
|
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-21 18:27:09 -06:00
|
|
|
import { AccessLogTab } from "./AccessLogTab";
|
|
|
|
|
import { ChecklistsTab } from "./ChecklistsTab";
|
|
|
|
|
import { DepartmentsTab } from "./DepartmentsTab";
|
|
|
|
|
import { NotificationsTab } from "./NotificationsTab";
|
|
|
|
|
import { PresenceTab } from "./PresenceTab";
|
|
|
|
|
import { ProfilesTab } from "./ProfilesTab";
|
|
|
|
|
import { SecurityTab } from "./SecurityTab";
|
|
|
|
|
import { StampVerifyTab } from "./StampVerifyTab";
|
2026-10-16 17:13:31 -06:00
|
|
|
import { StorageTab } from "./StorageTab";
|
2026-10-16 07:40:29 -06:00
|
|
|
|
2026-10-21 18:27:09 -06:00
|
|
|
type Tab =
|
|
|
|
|
| "users"
|
|
|
|
|
| "lookup"
|
|
|
|
|
| "devices"
|
|
|
|
|
| "groups"
|
|
|
|
|
| "attempts"
|
|
|
|
|
| "feed"
|
|
|
|
|
| "resources"
|
|
|
|
|
| "storage"
|
|
|
|
|
| "departments"
|
|
|
|
|
| "profiles"
|
|
|
|
|
| "checklists"
|
|
|
|
|
| "security"
|
|
|
|
|
| "accesslog"
|
|
|
|
|
| "presence"
|
|
|
|
|
| "stampverify"
|
|
|
|
|
| "notifications";
|
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 17:13:31 -06:00
|
|
|
{ id: "storage", label: "Storage" },
|
2026-10-21 18:27:09 -06:00
|
|
|
{ id: "departments", label: "Departments" },
|
|
|
|
|
{ id: "profiles", label: "Profiles" },
|
|
|
|
|
{ id: "checklists", label: "Checklists" },
|
|
|
|
|
{ id: "security", label: "Security" },
|
|
|
|
|
{ id: "accesslog", label: "Access log" },
|
|
|
|
|
{ id: "presence", label: "Who is in" },
|
|
|
|
|
{ id: "stampverify", label: "Stamp check" },
|
|
|
|
|
{ id: "notifications", label: "Notifications" },
|
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>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-21 18:27:09 -06:00
|
|
|
type ReportValue = string[] | number | undefined;
|
|
|
|
|
|
|
|
|
|
const count = (v: ReportValue) => (Array.isArray(v) ? v.length : (v ?? 0));
|
|
|
|
|
const names = (v: ReportValue) => (Array.isArray(v) ? v : []);
|
|
|
|
|
|
|
|
|
|
function ReconcileReportView({ load, onReload }: { load: Load<ReconcileAnswer>; onReload: () => void }) {
|
|
|
|
|
if (load.state !== "ok") return <Problem load={load} />;
|
|
|
|
|
const { run, report } = load.data;
|
|
|
|
|
const r = report ?? {};
|
2026-10-16 07:40:29 -06:00
|
|
|
const tiles: Array<[number, string, boolean]> = [
|
2026-10-21 18:27:09 -06:00
|
|
|
[count(r.groups_missing), "Groups missing", count(r.groups_missing) > 0],
|
|
|
|
|
[count(r.groups_extra), "Extra groups", false],
|
|
|
|
|
[r.users_seen ?? 0, "Users seen", false],
|
|
|
|
|
[r.created ?? 0, "Created", false],
|
|
|
|
|
[r.updated ?? 0, "Updated", false],
|
|
|
|
|
[r.deactivated ?? 0, "Deactivated", (r.deactivated ?? 0) > 0],
|
|
|
|
|
[r.sessions_ended ?? 0, "Sessions ended", (r.sessions_ended ?? 0) > 0],
|
|
|
|
|
[count(r.emp_missing), "No employee ID", count(r.emp_missing) > 0],
|
|
|
|
|
[count(r.emp_duplicate), "Duplicate employee IDs", count(r.emp_duplicate) > 0],
|
2026-10-16 07:40:29 -06:00
|
|
|
];
|
|
|
|
|
return (
|
2026-10-21 18:27:09 -06:00
|
|
|
<section aria-labelledby="recon-h" className="stack">
|
|
|
|
|
<div className="sec-h flush">
|
|
|
|
|
<h3 id="recon-h">Latest reconcile</h3>
|
|
|
|
|
<span className="ct">
|
|
|
|
|
{run ? `${run.status}, finished ${fmtTime(run.finished_at)}` : "Not run yet. It runs nightly at 01:30."}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="grow" />
|
|
|
|
|
<button className="btn sm ghost" type="button" onClick={onReload}>
|
|
|
|
|
Show the latest report
|
|
|
|
|
</button>
|
2026-10-16 07:40:29 -06:00
|
|
|
</div>
|
2026-10-21 18:27:09 -06:00
|
|
|
{run ? (
|
|
|
|
|
<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>
|
|
|
|
|
))}
|
2026-10-16 07:40:29 -06:00
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-10-21 18:27:09 -06:00
|
|
|
{names(r.groups_missing).length ? (
|
|
|
|
|
<div className="callout warn flush">
|
|
|
|
|
Missing groups: <span className="mono">{names(r.groups_missing).join(", ")}</span>
|
2026-10-16 07:40:29 -06:00
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-10-21 18:27:09 -06:00
|
|
|
{names(r.emp_missing).length ? (
|
|
|
|
|
<div className="callout warn flush">
|
|
|
|
|
No employee ID in AD: <span className="mono">{names(r.emp_missing).join(", ")}</span>
|
2026-10-16 07:40:29 -06:00
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-10-21 18:27:09 -06:00
|
|
|
{names(r.emp_duplicate).length ? (
|
|
|
|
|
<div className="callout warn flush">
|
|
|
|
|
Employee IDs used by more than one account: <span className="mono">{names(r.emp_duplicate).join(", ")}</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</section>
|
2026-10-16 07:40:29 -06:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function GroupsTab() {
|
|
|
|
|
const { roleLabel } = useAuth();
|
|
|
|
|
const [load, reload] = useLoad((s) => adminApi.groups(s));
|
2026-10-21 18:27:09 -06:00
|
|
|
const [report, reloadReport] = useLoad((s) => admin9Api.reconcile(s));
|
2026-10-16 07:40:29 -06:00
|
|
|
const [busy, setBusy] = useState(false);
|
2026-10-21 18:27:09 -06:00
|
|
|
const [requested, setRequested] = useState<RefreshRequested | null>(null);
|
2026-10-16 07:40:29 -06:00
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const refresh = async () => {
|
|
|
|
|
setBusy(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
try {
|
2026-10-21 18:27:09 -06:00
|
|
|
setRequested(await admin9Api.refreshAd());
|
2026-10-16 07:40:29 -06:00
|
|
|
reload();
|
2026-10-21 18:27:09 -06:00
|
|
|
reloadReport();
|
2026-10-16 07:40:29 -06:00
|
|
|
} 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()}>
|
2026-10-21 18:27:09 -06:00
|
|
|
{busy ? "Asking the worker" : "Refresh from AD now"}
|
2026-10-16 07:40:29 -06:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
{error ? (
|
|
|
|
|
<div className="callout warn" role="alert">
|
|
|
|
|
{error}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-10-21 18:27:09 -06:00
|
|
|
{requested ? (
|
|
|
|
|
<div className="callout flush" role="status">
|
|
|
|
|
Refresh requested. The worker runs the AD reconcile within a minute
|
|
|
|
|
{requested.pending > 1 ? `, after ${requested.pending - 1} other ${requested.pending === 2 ? "request" : "requests"}` : ""}. Then show the latest report.
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
<ReconcileReportView load={report} onReload={reloadReport} />
|
2026-10-16 07:40:29 -06:00
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-21 18:27:09 -06:00
|
|
|
/** /admin: users, AD, devices, groups, sign-ins, the feed, storage and the phase 9 tabs. Needs a password session. */
|
2026-10-16 07:40:29 -06:00
|
|
|
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}
|
2026-10-21 18:27:09 -06:00
|
|
|
{/* Sixteen tabs: they wrap into rows rather than scroll sideways on a narrow screen. */}
|
|
|
|
|
<div className="tabs" role="tablist" aria-label="Admin" style={{ flexWrap: "wrap" }}>
|
2026-10-16 07:40:29 -06:00
|
|
|
{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 17:13:31 -06:00
|
|
|
{tab === "storage" ? <StorageTab /> : null}
|
2026-10-21 18:27:09 -06:00
|
|
|
{tab === "departments" ? <DepartmentsTab /> : null}
|
|
|
|
|
{tab === "profiles" ? <ProfilesTab /> : null}
|
|
|
|
|
{tab === "checklists" ? <ChecklistsTab /> : null}
|
|
|
|
|
{tab === "security" ? <SecurityTab /> : null}
|
|
|
|
|
{tab === "accesslog" ? <AccessLogTab /> : null}
|
|
|
|
|
{tab === "presence" ? <PresenceTab /> : null}
|
|
|
|
|
{tab === "stampverify" ? <StampVerifyTab /> : null}
|
|
|
|
|
{tab === "notifications" ? <NotificationsTab /> : null}
|
2026-10-16 07:40:29 -06:00
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|