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:
+295
-24
@@ -1,35 +1,306 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mockFetch } from "./test/fetchMock";
|
||||
import { AppRoutes } from "./App";
|
||||
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DEPARTMENTS, NO_SESSION, makeMe, renderApp } from "./test/auth";
|
||||
import { callsTo, mockFetch } from "./test/fetchMock";
|
||||
|
||||
function renderAt(path: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
const NO_DEVICE = { body: { device: null, stack: "test", insecure_ldap: false, pin_digits: { min: 6, max: 12 } } };
|
||||
const WITH_DEVICE = {
|
||||
body: { device: { id: "dev-1", name: "Weld Parts Stall 3" }, stack: "test", insecure_ldap: false, pin_digits: { min: 6, max: 12 } },
|
||||
};
|
||||
|
||||
describe("routes", () => {
|
||||
it("home shows the top bar and the version", async () => {
|
||||
mockFetch({ "/api/version": { body: { version: "0.0.1" } }, "/healthz": { body: "ok" } });
|
||||
renderAt("/");
|
||||
it("home sends a signed-out visitor to sign-in", async () => {
|
||||
mockFetch({ "/api/auth/me": NO_SESSION, "/api/auth/context": NO_DEVICE });
|
||||
renderApp("/");
|
||||
expect(await screen.findByRole("heading", { name: "Sign in" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("img", { name: "PESCO" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("about shows the version", async () => {
|
||||
mockFetch({ "/api/auth/me": NO_SESSION, "/api/version": { body: { version: "0.0.1" } }, "/healthz": { body: "ok" } });
|
||||
renderApp("/about");
|
||||
expect(await screen.findByText("0.0.1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sign-in shows both tabs with disabled inputs", () => {
|
||||
renderAt("/signin");
|
||||
expect(screen.getByRole("tab", { name: "Domain password" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tab", { name: "PIN" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Password")).toBeDisabled();
|
||||
expect(screen.getByText(/phase 1/)).toBeInTheDocument();
|
||||
it("unknown deep paths show the 404 page", async () => {
|
||||
mockFetch({ "/api/auth/me": NO_SESSION });
|
||||
renderApp("/no/such/page");
|
||||
expect(await screen.findByRole("heading", { name: "Page not found" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("unknown paths show the 404 page", () => {
|
||||
renderAt("/no/such/page");
|
||||
expect(screen.getByRole("heading", { name: "Page not found" })).toBeInTheDocument();
|
||||
it("a screen route without a session goes to sign-in", async () => {
|
||||
mockFetch({ "/api/auth/me": NO_SESSION, "/api/auth/context": NO_DEVICE });
|
||||
renderApp("/clerical");
|
||||
expect(await screen.findByRole("heading", { name: "Sign in" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a disallowed screen goes to the user's first screen", async () => {
|
||||
mockFetch({ "/api/auth/me": { body: makeMe() } });
|
||||
renderApp("/admin");
|
||||
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
|
||||
expect(screen.getByText("This screen arrives in phase 2.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sign-in", () => {
|
||||
it("posts the password and lands on the user's first screen", async () => {
|
||||
const me = makeMe({ roles: ["shop:03:supervisor"], screens: ["supervisor", "stall"] });
|
||||
const fetch = mockFetch({
|
||||
"/api/auth/me": NO_SESSION,
|
||||
"/api/auth/context": NO_DEVICE,
|
||||
"POST /api/auth/signin/password": { body: me },
|
||||
"/api/departments": { body: DEPARTMENTS },
|
||||
});
|
||||
renderApp("/signin");
|
||||
fireEvent.change(await screen.findByLabelText("User name"), { target: { value: " sup.wes " } });
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret-pass" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Supervisor" })).toBeInTheDocument();
|
||||
expect(callsTo(fetch, "POST", "/api/auth/signin/password")).toEqual([{ login: "sup.wes", password: "secret-pass" }]);
|
||||
expect(screen.getByText("Clara Clerk")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Account" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the server message on a failed sign-in", async () => {
|
||||
mockFetch({
|
||||
"/api/auth/me": NO_SESSION,
|
||||
"/api/auth/context": NO_DEVICE,
|
||||
"POST /api/auth/signin/password": {
|
||||
status: 403,
|
||||
body: { detail: "No role in Packet Station. Ask IT to add you to a group.", code: "no_role" },
|
||||
},
|
||||
});
|
||||
renderApp("/signin");
|
||||
fireEvent.change(await screen.findByLabelText("User name"), { target: { value: "norole.nick" } });
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "x" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("No role in Packet Station. Ask IT to add you to a group.");
|
||||
});
|
||||
|
||||
it("shows the retry time on 429", async () => {
|
||||
mockFetch({
|
||||
"/api/auth/me": NO_SESSION,
|
||||
"/api/auth/context": NO_DEVICE,
|
||||
"POST /api/auth/signin/password": { status: 429, body: { detail: "Too many attempts", code: "rate_limited", retry_after: 150 } },
|
||||
});
|
||||
renderApp("/signin");
|
||||
fireEvent.change(await screen.findByLabelText("User name"), { target: { value: "a" } });
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "b" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Try again in 3 minutes.");
|
||||
});
|
||||
|
||||
it("hides the PIN tab without a registered device", async () => {
|
||||
const fetch = mockFetch({ "/api/auth/me": NO_SESSION, "/api/auth/context": NO_DEVICE });
|
||||
renderApp("/signin");
|
||||
expect(await screen.findByRole("tab", { name: "Domain password" })).toHaveAttribute("aria-selected", "true");
|
||||
await waitFor(() => expect(callsTo(fetch, "GET", "/api/auth/context")).toHaveLength(1));
|
||||
expect(screen.queryByRole("tab", { name: "PIN" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the PIN tab on a registered device and types with the keypad", async () => {
|
||||
const me = makeMe({ session: { kind: "pin" } });
|
||||
const fetch = mockFetch({
|
||||
"/api/auth/me": NO_SESSION,
|
||||
"/api/auth/context": WITH_DEVICE,
|
||||
"POST /api/auth/signin/pin": { body: me },
|
||||
});
|
||||
renderApp("/signin");
|
||||
const pinTab = await screen.findByRole("tab", { name: "PIN" });
|
||||
expect(pinTab).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByText(/Weld Parts Stall 3/)).toBeInTheDocument();
|
||||
|
||||
// The keypad types into the employee ID first.
|
||||
for (const d of ["0", "4", "0", "0", "1"]) fireEvent.click(screen.getByRole("button", { name: `Digit ${d}` }));
|
||||
expect(screen.getByLabelText("Employee ID")).toHaveValue("04001");
|
||||
// Then into the PIN once it has the focus.
|
||||
fireEvent.focus(screen.getByLabelText("PIN", { selector: "input" }));
|
||||
for (const d of ["1", "2", "3", "4", "5", "6", "7"]) fireEvent.click(screen.getByRole("button", { name: `Digit ${d}` }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||
expect(screen.getByLabelText("PIN", { selector: "input" })).toHaveValue("123456");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
|
||||
expect(callsTo(fetch, "POST", "/api/auth/signin/pin")).toEqual([{ emp_no: "04001", pin: "123456" }]);
|
||||
});
|
||||
|
||||
it("redirects away from sign-in when already signed in", async () => {
|
||||
mockFetch({ "/api/auth/me": { body: makeMe() } });
|
||||
renderApp("/signin");
|
||||
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("lock screen", () => {
|
||||
it("appears on 423 from /api/auth/me and a right password hides it", async () => {
|
||||
const fetch = mockFetch({
|
||||
"/api/auth/me": { status: 423, body: { detail: "Locked", code: "locked" } },
|
||||
"POST /api/auth/unlock": { body: makeMe() },
|
||||
});
|
||||
renderApp("/clerical");
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveAttribute("aria-modal", "true");
|
||||
expect(within(dialog).getByText("Locked")).toBeInTheDocument();
|
||||
fireEvent.change(within(dialog).getByLabelText("Password"), { target: { value: "pw" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Unlock" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expect(callsTo(fetch, "POST", "/api/auth/unlock")).toEqual([{ factor: "password", secret: "pw" }]);
|
||||
expect(screen.getByRole("heading", { name: "Clerical" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the PIN keypad, warns after a miss, and ends on session_ended", async () => {
|
||||
let tries = 0;
|
||||
mockFetch({
|
||||
"/api/auth/me": { body: makeMe({ pin: { set: true, disabled: false }, session: { locked: true } }) },
|
||||
"/api/auth/context": NO_DEVICE,
|
||||
"POST /api/auth/unlock": () =>
|
||||
++tries < 2
|
||||
? { status: 401, body: { detail: "Wrong PIN.", code: "bad_secret" } }
|
||||
: { status: 401, body: { detail: "Signed out after three wrong attempts.", code: "session_ended" } },
|
||||
});
|
||||
renderApp("/clerical");
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(within(dialog).getByRole("heading", { name: "Clara Clerk" })).toBeInTheDocument();
|
||||
for (const d of ["9", "9", "9", "9", "9", "9"]) fireEvent.click(within(dialog).getByRole("button", { name: `Digit ${d}` }));
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Unlock" }));
|
||||
expect(await within(dialog).findByText("Wrong PIN.")).toBeInTheDocument();
|
||||
expect(within(dialog).getByText("Three wrong attempts end the session.")).toBeInTheDocument();
|
||||
|
||||
for (const d of ["1", "1", "1", "1", "1", "1"]) fireEvent.click(within(dialog).getByRole("button", { name: `Digit ${d}` }));
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Unlock" }));
|
||||
expect(await screen.findByRole("heading", { name: "Sign in" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Signed out after three wrong attempts.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("locks after the idle time and tells the server", async () => {
|
||||
const fetch = mockFetch({ "/api/auth/me": { body: makeMe() }, "POST /api/auth/lock": { status: 204 } });
|
||||
renderApp("/clerical", { idleLockMs: 30, idleCheckMs: 10 });
|
||||
expect(await screen.findByRole("dialog")).toBeInTheDocument();
|
||||
expect(callsTo(fetch, "POST", "/api/auth/lock")).toHaveLength(1);
|
||||
});
|
||||
|
||||
function setHidden(hidden: boolean) {
|
||||
Object.defineProperty(document, "hidden", { configurable: true, get: () => hidden });
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
}
|
||||
|
||||
it("a short hidden spell does not lock, and both reports are posted", async () => {
|
||||
const fetch = mockFetch({ "/api/auth/me": { body: makeMe() }, "POST /api/auth/visibility": { status: 204 } });
|
||||
renderApp("/clerical");
|
||||
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
|
||||
setHidden(true);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
setHidden(false);
|
||||
await waitFor(() => expect(callsTo(fetch, "GET", "/api/auth/me")).toHaveLength(2));
|
||||
expect(callsTo(fetch, "POST", "/api/auth/visibility")).toEqual([{ hidden: true }, { hidden: false }]);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
Reflect.deleteProperty(document, "hidden");
|
||||
});
|
||||
|
||||
it("a tab hidden over 60 s comes back locked", async () => {
|
||||
let now = 1_000_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
let returned = false;
|
||||
const fetch = mockFetch({
|
||||
"/api/auth/me": () => ({ body: makeMe({ session: { locked: returned } }) }),
|
||||
"POST /api/auth/visibility": ({ body }) => {
|
||||
if ((body as { hidden: boolean }).hidden === false) returned = true;
|
||||
return { status: 204 };
|
||||
},
|
||||
});
|
||||
renderApp("/clerical");
|
||||
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
|
||||
setHidden(true);
|
||||
now += 61_000;
|
||||
setHidden(false);
|
||||
// Locked at once, before the server answers, and still locked once Me says so.
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
await waitFor(() => expect(callsTo(fetch, "GET", "/api/auth/me")).toHaveLength(2));
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(callsTo(fetch, "POST", "/api/auth/visibility")).toEqual([{ hidden: true }, { hidden: false }]);
|
||||
Reflect.deleteProperty(document, "hidden");
|
||||
});
|
||||
|
||||
it("locks on 423 from any other call", async () => {
|
||||
mockFetch({
|
||||
"/api/auth/me": { body: makeMe() },
|
||||
"/api/account": { status: 423, body: { detail: "Locked", code: "locked" } },
|
||||
});
|
||||
renderApp("/account");
|
||||
expect(await screen.findByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("role switcher", () => {
|
||||
it("shows only the user's screens and switches between them", async () => {
|
||||
mockFetch({
|
||||
"/api/auth/me": { body: makeMe({ roles: ["clerical", "shop:06:supervisor"], screens: ["clerical", "supervisor", "stall"] }) },
|
||||
});
|
||||
renderApp("/clerical");
|
||||
const nav = await screen.findByRole("navigation", { name: "Screens" });
|
||||
const buttons = within(nav).getAllByRole("button");
|
||||
expect(buttons.map((b) => b.textContent)).toEqual(["Clerical", "Supervisor", "Weld Stall"]);
|
||||
expect(within(nav).getByRole("button", { name: "Clerical" })).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
fireEvent.click(within(nav).getByRole("button", { name: "Weld Stall" }));
|
||||
expect(await screen.findByRole("heading", { name: "Weld Stall" })).toBeInTheDocument();
|
||||
expect(within(nav).getByRole("button", { name: "Weld Stall" })).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("is hidden for a user with one screen", async () => {
|
||||
mockFetch({ "/api/auth/me": { body: makeMe() } });
|
||||
renderApp("/clerical");
|
||||
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("navigation", { name: "Screens" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("signs out from the top bar", async () => {
|
||||
const fetch = mockFetch({
|
||||
"/api/auth/me": { body: makeMe() },
|
||||
"POST /api/auth/signout": { status: 204 },
|
||||
"/api/auth/context": NO_DEVICE,
|
||||
});
|
||||
renderApp("/clerical");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Sign out" }));
|
||||
expect(await screen.findByRole("heading", { name: "Sign in" })).toBeInTheDocument();
|
||||
expect(callsTo(fetch, "POST", "/api/auth/signout")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("events", () => {
|
||||
it("a session revoked event for this session signs out", async () => {
|
||||
const sources: FakeEventSource[] = [];
|
||||
class FakeEventSource {
|
||||
url: string;
|
||||
listeners = new Map<string, (ev: MessageEvent) => void>();
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
sources.push(this);
|
||||
}
|
||||
addEventListener(type: string, fn: (ev: MessageEvent) => void) {
|
||||
this.listeners.set(type, fn);
|
||||
}
|
||||
close() {}
|
||||
emit(type: string, data: unknown) {
|
||||
this.listeners.get(type)?.(new MessageEvent(type, { data: JSON.stringify(data) }));
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("EventSource", FakeEventSource);
|
||||
mockFetch({ "/api/auth/me": { body: makeMe() }, "/api/auth/context": NO_DEVICE });
|
||||
renderApp("/clerical");
|
||||
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
|
||||
const es = sources.at(-1);
|
||||
expect(es?.url).toBe("/api/events");
|
||||
|
||||
act(() => es?.emit("session", { session_id: "other", action: "revoked" }));
|
||||
expect(screen.getByRole("heading", { name: "Clerical" })).toBeInTheDocument();
|
||||
|
||||
act(() => es?.emit("session", { session_id: "sess-1", action: "revoked" }));
|
||||
expect(await screen.findByRole("heading", { name: "Sign in" })).toBeInTheDocument();
|
||||
expect(screen.getByText("This session was signed out from another place.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+13
-1
@@ -1,8 +1,13 @@
|
||||
import { BrowserRouter, Route, Routes } from "react-router";
|
||||
import { AuthProvider } from "./auth/AuthProvider";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { RequireAuth } from "./components/RequireAuth";
|
||||
import { AboutPage } from "./pages/AboutPage";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
import { SignInPage } from "./pages/SignInPage";
|
||||
import { AccountPage } from "./screens/account/AccountPage";
|
||||
import { ScreenRoute } from "./screens/ScreenRoute";
|
||||
|
||||
/** Route table. Exported apart from the router so tests can mount it in a MemoryRouter. */
|
||||
export function AppRoutes() {
|
||||
@@ -11,6 +16,11 @@ export function AppRoutes() {
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="signin" element={<SignInPage />} />
|
||||
<Route path="about" element={<AboutPage />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="account" element={<AccountPage />} />
|
||||
<Route path=":screen" element={<ScreenRoute />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
@@ -20,7 +30,9 @@ export function AppRoutes() {
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,37 @@ async function readBody(res: Response): Promise<unknown> {
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Called for every non-2xx answer. The auth layer uses it to react to 401 and 423 from any call. */
|
||||
export type ErrorListener = (err: ApiError, path: string, method: Method) => void;
|
||||
|
||||
const errorListeners = new Set<ErrorListener>();
|
||||
|
||||
/** Adds a listener for failed responses. Returns the function that removes it. */
|
||||
export function onApiError(fn: ErrorListener): () => void {
|
||||
errorListeners.add(fn);
|
||||
return () => {
|
||||
errorListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
/** The snake_case code the server puts on every error ({"detail", "code"}), if any. */
|
||||
export function errorCode(err: unknown): string | undefined {
|
||||
if (err instanceof ApiError && err.body && typeof err.body === "object" && "code" in err.body) {
|
||||
const code = (err.body as { code: unknown }).code;
|
||||
if (typeof code === "string") return code;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Seconds from a 429 body ({"retry_after": N}), if any. */
|
||||
export function retryAfter(err: unknown): number | undefined {
|
||||
if (err instanceof ApiError && err.body && typeof err.body === "object" && "retry_after" in err.body) {
|
||||
const n = Number((err.body as { retry_after: unknown }).retry_after);
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
assertSameOrigin(path);
|
||||
const method: Method = opts.method ?? "GET";
|
||||
@@ -88,7 +119,11 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
|
||||
}
|
||||
|
||||
const parsed = await readBody(res);
|
||||
if (!res.ok) throw new ApiError(res.status, parsed);
|
||||
if (!res.ok) {
|
||||
const err = new ApiError(res.status, parsed);
|
||||
for (const fn of errorListeners) fn(err, path, method);
|
||||
throw err;
|
||||
}
|
||||
return parsed as T;
|
||||
}
|
||||
|
||||
@@ -99,4 +134,5 @@ export const api = {
|
||||
put: <T>(path: string, body?: unknown) => request<T>(path, { method: "PUT", body }),
|
||||
patch: <T>(path: string, body?: unknown) => request<T>(path, { method: "PATCH", body }),
|
||||
del: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
delWithBody: <T>(path: string, body: unknown) => request<T>(path, { method: "DELETE", body }),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { ApiError, errorCode, onApiError } from "../api/client";
|
||||
import { authApi, departmentsApi } from "./api";
|
||||
import { AuthCtx, type AuthState, type AuthStatus } from "./context";
|
||||
import { roleLabel as labelFor } from "./roles";
|
||||
import type { Department, Factor, Me } from "./types";
|
||||
import { useEvents } from "./useEvents";
|
||||
|
||||
/** The old build's screen lock. The server locks at SESSION_LOCK_MINUTES too; it is the authority. */
|
||||
export const IDLE_LOCK_MS = 3 * 60_000;
|
||||
/** A tab hidden for longer than this comes back locked (the old build's rule). */
|
||||
export const HIDDEN_LOCK_MS = 60_000;
|
||||
const IDLE_CHECK_MS = 15_000;
|
||||
const ACTIVITY_EVENTS = ["pointerdown", "keydown", "wheel", "touchstart"] as const;
|
||||
|
||||
/** 401 codes that mean this browser's session is gone. Other 401s (a wrong unlock PIN) are not. */
|
||||
const SESSION_GONE = new Set(["no_session", "session_expired", "session_ended"]);
|
||||
|
||||
function goneNotice(err: ApiError): string {
|
||||
const code = errorCode(err);
|
||||
if (code === "no_session") return "Your session ended. Sign in again.";
|
||||
return err.message || "Your session ended. Sign in again.";
|
||||
}
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
/** Idle time before the local lock. Tests pass a small value. */
|
||||
idleLockMs?: number;
|
||||
/** How often the idle timer checks. */
|
||||
idleCheckMs?: number;
|
||||
/** Hidden time after which the tab comes back locked. Tests pass a small value. */
|
||||
hiddenLockMs?: number;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children, idleLockMs = IDLE_LOCK_MS, idleCheckMs = IDLE_CHECK_MS, hiddenLockMs = HIDDEN_LOCK_MS }: AuthProviderProps) {
|
||||
const [status, setStatusState] = useState<AuthStatus>("loading");
|
||||
const [me, setMeState] = useState<Me | null>(null);
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const statusRef = useRef<AuthStatus>("loading");
|
||||
const lastActivity = useRef(Date.now());
|
||||
const deptsFor = useRef<string | null>(null);
|
||||
|
||||
const setStatus = useCallback((s: AuthStatus) => {
|
||||
statusRef.current = s;
|
||||
setStatusState(s);
|
||||
}, []);
|
||||
|
||||
const accept = useCallback(
|
||||
(m: Me) => {
|
||||
setMeState(m);
|
||||
setNotice(null);
|
||||
lastActivity.current = Date.now();
|
||||
setStatus(m.session.locked ? "locked" : "ready");
|
||||
},
|
||||
[setStatus],
|
||||
);
|
||||
|
||||
const endSession = useCallback(
|
||||
(msg: string | null) => {
|
||||
setMeState(null);
|
||||
setDepartments([]);
|
||||
deptsFor.current = null;
|
||||
setNotice(msg);
|
||||
setStatus("anonymous");
|
||||
},
|
||||
[setStatus],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
accept(await authApi.me());
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 423) setStatus("locked");
|
||||
else if (err instanceof ApiError && err.status === 401) endSession(null);
|
||||
else endSession(err instanceof Error ? err.message : "Could not reach the server.");
|
||||
}
|
||||
}, [accept, endSession, setStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// 401 and 423 from any call, not only the auth calls.
|
||||
useEffect(
|
||||
() =>
|
||||
onApiError((err, path) => {
|
||||
if (path.startsWith("/api/auth/signin")) return;
|
||||
const st = statusRef.current;
|
||||
if (st === "loading" || st === "anonymous") return;
|
||||
if (err.status === 423) {
|
||||
setStatus("locked");
|
||||
} else if (err.status === 401 && SESSION_GONE.has(errorCode(err) ?? "")) {
|
||||
endSession(goneNotice(err));
|
||||
}
|
||||
}),
|
||||
[endSession, setStatus],
|
||||
);
|
||||
|
||||
// Department names for the role labels, once per signed-in user.
|
||||
const userId = me?.user.id ?? null;
|
||||
useEffect(() => {
|
||||
if (status !== "ready" || !userId || deptsFor.current === userId) return;
|
||||
deptsFor.current = userId;
|
||||
departmentsApi
|
||||
.list()
|
||||
.then((d) => setDepartments(Array.isArray(d) ? d : []))
|
||||
.catch(() => {
|
||||
deptsFor.current = null;
|
||||
});
|
||||
}, [status, userId]);
|
||||
|
||||
const signInPassword = useCallback(
|
||||
async (login: string, password: string) => {
|
||||
const m = await authApi.signInPassword(login, password);
|
||||
accept(m);
|
||||
return m;
|
||||
},
|
||||
[accept],
|
||||
);
|
||||
|
||||
const signInPin = useCallback(
|
||||
async (empNo: string, pin: string) => {
|
||||
const m = await authApi.signInPin(empNo, pin);
|
||||
accept(m);
|
||||
return m;
|
||||
},
|
||||
[accept],
|
||||
);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
try {
|
||||
await authApi.signOut();
|
||||
} catch {
|
||||
// The cookie may already be gone. Signing out locally is what matters here.
|
||||
}
|
||||
endSession(null);
|
||||
}, [endSession]);
|
||||
|
||||
const unlock = useCallback(
|
||||
async (factor: Factor, secret: string) => {
|
||||
const m = await authApi.unlock(factor, secret);
|
||||
accept({ ...m, session: { ...m.session, locked: false } });
|
||||
return m;
|
||||
},
|
||||
[accept],
|
||||
);
|
||||
|
||||
const lock = useCallback(async () => {
|
||||
if (statusRef.current !== "ready") return;
|
||||
setStatus("locked");
|
||||
try {
|
||||
await authApi.lock();
|
||||
} catch {
|
||||
// A 401 here signs out through the listener. Otherwise the local lock stands.
|
||||
}
|
||||
}, [setStatus]);
|
||||
|
||||
// Hidden is reported, never locked at once: a reload or navigation fires hidden on the old page.
|
||||
// Coming back is reported, then Me decides. Hidden longer than hiddenLockMs locks locally right away.
|
||||
const hiddenAt = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const onVisibility = () => {
|
||||
const st = statusRef.current;
|
||||
if (st !== "ready" && st !== "locked") return;
|
||||
if (document.hidden) {
|
||||
hiddenAt.current = Date.now();
|
||||
authApi.visibility(true).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
const since = hiddenAt.current;
|
||||
hiddenAt.current = null;
|
||||
if (since !== null && Date.now() - since > hiddenLockMs && st === "ready") setStatus("locked");
|
||||
authApi
|
||||
.visibility(false)
|
||||
.catch(() => undefined)
|
||||
.then(() => refresh());
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => document.removeEventListener("visibilitychange", onVisibility);
|
||||
}, [setStatus, refresh, hiddenLockMs]);
|
||||
|
||||
// Local idle lock.
|
||||
useEffect(() => {
|
||||
const bump = () => {
|
||||
lastActivity.current = Date.now();
|
||||
};
|
||||
for (const ev of ACTIVITY_EVENTS) document.addEventListener(ev, bump, { capture: true, passive: true });
|
||||
return () => {
|
||||
for (const ev of ACTIVITY_EVENTS) document.removeEventListener(ev, bump, { capture: true });
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "ready") return;
|
||||
lastActivity.current = Date.now();
|
||||
const id = window.setInterval(() => {
|
||||
if (Date.now() - lastActivity.current >= idleLockMs) void lock();
|
||||
}, idleCheckMs);
|
||||
return () => window.clearInterval(id);
|
||||
}, [status, idleLockMs, idleCheckMs, lock]);
|
||||
|
||||
const sessionId = status === "ready" || status === "locked" ? (me?.session.id ?? null) : null;
|
||||
const presence = useEvents(sessionId, {
|
||||
onRevoked: () => endSession("This session was signed out from another place."),
|
||||
});
|
||||
|
||||
const roleLabel = useCallback((role: string) => labelFor(role, departments), [departments]);
|
||||
|
||||
const value = useMemo<AuthState>(
|
||||
() => ({
|
||||
status,
|
||||
me,
|
||||
departments,
|
||||
notice,
|
||||
presence,
|
||||
refresh,
|
||||
signInPassword,
|
||||
signInPin,
|
||||
signOut,
|
||||
endSession,
|
||||
unlock,
|
||||
lock,
|
||||
setMe: setMeState,
|
||||
roleLabel,
|
||||
}),
|
||||
[status, me, departments, notice, presence, refresh, signInPassword, signInPin, signOut, endSession, unlock, lock, roleLabel],
|
||||
);
|
||||
|
||||
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Typed calls for the phase 1 endpoints. Paths and shapes follow the design contract.
|
||||
import { api } from "../api/client";
|
||||
import type {
|
||||
AccessLogRow,
|
||||
AccountInfo,
|
||||
AdLookup,
|
||||
AdRefreshResult,
|
||||
AdminDevice,
|
||||
AdminUser,
|
||||
AdminUserDetail,
|
||||
AuthContextInfo,
|
||||
Department,
|
||||
DeviceRef,
|
||||
Factor,
|
||||
GroupMap,
|
||||
Me,
|
||||
PinState,
|
||||
SessionRow,
|
||||
SigninAttempt,
|
||||
} from "./types";
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
|
||||
export const authApi = {
|
||||
context: (signal?: AbortSignal) => api.get<AuthContextInfo>("/api/auth/context", signal),
|
||||
me: (signal?: AbortSignal) => api.get<Me>("/api/auth/me", signal),
|
||||
signInPassword: (login: string, password: string) =>
|
||||
api.post<Me>("/api/auth/signin/password", { login, password }),
|
||||
signInPin: (emp_no: string, pin: string) => api.post<Me>("/api/auth/signin/pin", { emp_no, pin }),
|
||||
signOut: () => api.post<null>("/api/auth/signout"),
|
||||
lock: () => api.post<null>("/api/auth/lock"),
|
||||
visibility: (hidden: boolean) => api.post<null>("/api/auth/visibility", { hidden }),
|
||||
unlock: (factor: Factor, secret: string) => api.post<Me>("/api/auth/unlock", { factor, secret }),
|
||||
};
|
||||
|
||||
export const accountApi = {
|
||||
get: (signal?: AbortSignal) => api.get<AccountInfo>("/api/account", signal),
|
||||
setPin: (password: string, pin: string) =>
|
||||
api.post<{ pin: PinState }>("/api/account/pin", { password, pin }),
|
||||
sessions: (signal?: AbortSignal) => api.get<SessionRow[]>("/api/account/sessions", signal),
|
||||
signOutOthers: () => api.post<{ revoked: number }>("/api/account/sessions/signout-others"),
|
||||
accessLog: (limit = 50, signal?: AbortSignal) =>
|
||||
api.get<AccessLogRow[]>(`/api/account/access-log?limit=${limit}`, signal),
|
||||
registerDevice: (name: string) => api.post<{ device: DeviceRef }>("/api/account/device", { name }),
|
||||
};
|
||||
|
||||
export const adminApi = {
|
||||
users: (q: string, signal?: AbortSignal) =>
|
||||
api.get<AdminUser[]>(q ? `/api/admin/users?q=${enc(q)}` : "/api/admin/users", signal),
|
||||
user: (id: string, signal?: AbortSignal) => api.get<AdminUserDetail>(`/api/admin/users/${enc(id)}`, signal),
|
||||
lookup: (login: string, signal?: AbortSignal) =>
|
||||
api.get<AdLookup>(`/api/admin/ad/lookup?login=${enc(login)}`, signal),
|
||||
clearPin: (id: string, reason: string) =>
|
||||
api.delWithBody<null>(`/api/admin/users/${enc(id)}/pin`, { reason }),
|
||||
devices: (signal?: AbortSignal) => api.get<AdminDevice[]>("/api/admin/devices", signal),
|
||||
revokeDevice: (id: string) => api.del<null>(`/api/admin/devices/${enc(id)}`),
|
||||
groups: (signal?: AbortSignal) => api.get<GroupMap>("/api/admin/groups", signal),
|
||||
refresh: () => api.post<AdRefreshResult>("/api/admin/ad/refresh"),
|
||||
signinAttempts: (limit = 100, signal?: AbortSignal) =>
|
||||
api.get<SigninAttempt[]>(`/api/admin/signin-attempts?limit=${limit}`, signal),
|
||||
};
|
||||
|
||||
export const departmentsApi = {
|
||||
list: (signal?: AbortSignal) => api.get<Department[]>("/api/departments", signal),
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createContext } from "react";
|
||||
import type { Department, Factor, Me, PresenceUser } from "./types";
|
||||
|
||||
export type AuthStatus = "loading" | "anonymous" | "ready" | "locked";
|
||||
|
||||
export interface AuthState {
|
||||
status: AuthStatus;
|
||||
/** Null while loading, when signed out, and when a 423 arrived before /me answered. */
|
||||
me: Me | null;
|
||||
departments: Department[];
|
||||
/** Shown on the sign-in page once, for example after a session ended. */
|
||||
notice: string | null;
|
||||
presence: PresenceUser[];
|
||||
refresh: () => Promise<void>;
|
||||
signInPassword: (login: string, password: string) => Promise<Me>;
|
||||
signInPin: (empNo: string, pin: string) => Promise<Me>;
|
||||
signOut: () => Promise<void>;
|
||||
/** Drops the session in the browser without calling the server, with a notice for the sign-in page. */
|
||||
endSession: (notice: string | null) => void;
|
||||
unlock: (factor: Factor, secret: string) => Promise<Me>;
|
||||
lock: () => Promise<void>;
|
||||
/** Replaces the cached Me, for example after a PIN change. */
|
||||
setMe: (me: Me) => void;
|
||||
roleLabel: (role: string) => string;
|
||||
}
|
||||
|
||||
export const AuthCtx = createContext<AuthState | null>(null);
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEPARTMENTS } from "../test/auth";
|
||||
import type { Department } from "./types";
|
||||
import { firstScreen, homePath, roleChipClass, roleFromKind, roleLabel, screensFor } from "./roles";
|
||||
|
||||
const depts = DEPARTMENTS as Department[];
|
||||
|
||||
describe("roles", () => {
|
||||
it("labels shop and office roles", () => {
|
||||
expect(roleLabel("shop:03:supervisor", depts)).toBe("03 Weld Parts · Supervisor");
|
||||
expect(roleLabel("shop:09:lead")).toBe("09 · Lead");
|
||||
expect(roleLabel("clerical_supervisor")).toBe("Clerical supervisor");
|
||||
expect(roleLabel("qc")).toBe("Q.C.");
|
||||
expect(roleLabel("pm")).toBe("Project Management");
|
||||
expect(roleLabel("admin")).toBe("Admin");
|
||||
});
|
||||
|
||||
it("maps roles to screens", () => {
|
||||
expect(screensFor(["clerical", "shop:06:supervisor"])).toEqual(["clerical", "supervisor", "stall"]);
|
||||
expect(screensFor(["shop:03:worker"])).toEqual(["stall"]);
|
||||
expect(screensFor(["warehouse_supervisor", "qc"])).toEqual(["warehouse", "qc"]);
|
||||
expect(screensFor(["admin"])).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("lands on the first non-admin screen", () => {
|
||||
expect(firstScreen(["admin", "clerical"])).toBe("clerical");
|
||||
expect(firstScreen(["admin"])).toBe("admin");
|
||||
expect(homePath(["stall", "supervisor"])).toBe("/supervisor");
|
||||
expect(homePath([])).toBe("/account");
|
||||
});
|
||||
|
||||
it("builds role strings from kinds and picks chip colours", () => {
|
||||
expect(roleFromKind("shop_lead", "03")).toBe("shop:03:lead");
|
||||
expect(roleFromKind("qc_supervisor", "QC")).toBe("qc_supervisor");
|
||||
expect(roleChipClass("shop:03:supervisor")).toBe("rchip supervisor");
|
||||
expect(roleChipClass("admin")).toBe("rchip admin");
|
||||
expect(roleChipClass("clerical")).toBe("rchip");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
// Role strings, the static role-to-screen map and the labels.
|
||||
// The server decides access on every request. This file only shapes the screens.
|
||||
import type { Department } from "./types";
|
||||
|
||||
/** Screens in the order the role switcher shows them (the old build's order). */
|
||||
export const SCREENS = ["clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin"] as const;
|
||||
export type Screen = (typeof SCREENS)[number];
|
||||
|
||||
export const SCREEN_LABELS: Record<Screen, string> = {
|
||||
clerical: "Clerical",
|
||||
warehouse: "Warehouse",
|
||||
supervisor: "Supervisor",
|
||||
stall: "Weld Stall",
|
||||
qc: "Q.C.",
|
||||
pm: "Project Mgmt",
|
||||
admin: "Admin",
|
||||
};
|
||||
|
||||
/** The phase in which each placeholder screen gets its real content. */
|
||||
export const SCREEN_PHASE: Record<Exclude<Screen, "admin">, number> = {
|
||||
clerical: 2,
|
||||
warehouse: 4,
|
||||
supervisor: 5,
|
||||
stall: 5,
|
||||
qc: 6,
|
||||
pm: 8,
|
||||
};
|
||||
|
||||
export function isScreen(s: string | undefined): s is Screen {
|
||||
return s !== undefined && (SCREENS as readonly string[]).includes(s);
|
||||
}
|
||||
|
||||
export type ShopLevel = "worker" | "lead" | "supervisor";
|
||||
|
||||
export type ParsedRole =
|
||||
| { shop: false; kind: string }
|
||||
| { shop: true; dept: string; level: ShopLevel };
|
||||
|
||||
const SHOP_LEVELS: readonly ShopLevel[] = ["worker", "lead", "supervisor"];
|
||||
|
||||
/** "shop:03:supervisor" -> shop role; anything else is an office role kind. */
|
||||
export function parseRole(role: string): ParsedRole {
|
||||
const m = /^shop:([^:]+):(worker|lead|supervisor)$/.exec(role);
|
||||
if (m && m[1] !== undefined && m[2] !== undefined) {
|
||||
return { shop: true, dept: m[1], level: m[2] as ShopLevel };
|
||||
}
|
||||
return { shop: false, kind: role };
|
||||
}
|
||||
|
||||
/** Turns a role kind and department ("shop_lead", "03") into the role string ("shop:03:lead"). */
|
||||
export function roleFromKind(kind: string, dept: string | null): string {
|
||||
if (kind.includes(":")) return kind;
|
||||
const m = /^shop_(worker|lead|supervisor)$/.exec(kind);
|
||||
if (m && m[1] && dept) return `shop:${dept}:${m[1]}`;
|
||||
return kind;
|
||||
}
|
||||
|
||||
/** The static map. Same rule as the server's screens_for(roles). */
|
||||
export function screensFor(roles: readonly string[]): Screen[] {
|
||||
const out = new Set<Screen>();
|
||||
for (const r of roles) {
|
||||
const p = parseRole(r);
|
||||
if (p.shop) {
|
||||
out.add("stall");
|
||||
if (p.level !== "worker") out.add("supervisor");
|
||||
continue;
|
||||
}
|
||||
if (p.kind === "clerical" || p.kind === "clerical_supervisor") out.add("clerical");
|
||||
else if (p.kind.startsWith("warehouse")) out.add("warehouse");
|
||||
else if (p.kind.startsWith("qc")) out.add("qc");
|
||||
else if (p.kind === "pm") out.add("pm");
|
||||
else if (p.kind === "admin") SCREENS.forEach((s) => out.add(s));
|
||||
}
|
||||
return SCREENS.filter((s) => out.has(s));
|
||||
}
|
||||
|
||||
/** Known screens from a server list, in switcher order. */
|
||||
export function orderScreens(list: readonly string[]): Screen[] {
|
||||
return SCREENS.filter((s) => list.includes(s));
|
||||
}
|
||||
|
||||
/** The screen a user lands on: the first non-admin screen, else admin, else null. */
|
||||
export function firstScreen(list: readonly string[]): Screen | null {
|
||||
const ordered = orderScreens(list);
|
||||
return ordered.find((s) => s !== "admin") ?? ordered[0] ?? null;
|
||||
}
|
||||
|
||||
export function homePath(list: readonly string[]): string {
|
||||
const s = firstScreen(list);
|
||||
return s ? `/${s}` : "/account";
|
||||
}
|
||||
|
||||
const OFFICE_LABELS: Record<string, string> = {
|
||||
admin: "Admin",
|
||||
clerical: "Clerical",
|
||||
clerical_supervisor: "Clerical supervisor",
|
||||
warehouse: "Warehouse",
|
||||
warehouse_supervisor: "Warehouse supervisor",
|
||||
qc: "Q.C.",
|
||||
qc_supervisor: "Q.C. supervisor",
|
||||
pm: "Project Management",
|
||||
};
|
||||
|
||||
const LEVEL_LABELS: Record<ShopLevel, string> = { worker: "Worker", lead: "Lead", supervisor: "Supervisor" };
|
||||
|
||||
/** "shop:03:supervisor" -> "03 Weld Parts · Supervisor". Office roles get their plain name. */
|
||||
export function roleLabel(role: string, departments: readonly Department[] = []): string {
|
||||
const p = parseRole(role);
|
||||
if (!p.shop) return OFFICE_LABELS[p.kind] ?? p.kind;
|
||||
const dept = departments.find((d) => d.code === p.dept);
|
||||
const where = dept ? `${p.dept} ${dept.name}` : p.dept;
|
||||
return `${where} · ${LEVEL_LABELS[p.level]}`;
|
||||
}
|
||||
|
||||
/** The .rchip variant for a role: supervisors pink, leads green, admin amber. */
|
||||
export function roleChipClass(role: string): string {
|
||||
const p = parseRole(role);
|
||||
if (p.shop) return p.level === "worker" ? "rchip" : `rchip ${p.level}`;
|
||||
if (p.kind === "admin") return "rchip admin";
|
||||
if (p.kind.endsWith("_supervisor")) return "rchip supervisor";
|
||||
return "rchip";
|
||||
}
|
||||
|
||||
/** Office roles first in a fixed order, then shop roles by department and level. */
|
||||
export function sortRoles(roles: readonly string[]): string[] {
|
||||
const office = Object.keys(OFFICE_LABELS);
|
||||
const rank = (r: string): [number, string, number] => {
|
||||
const p = parseRole(r);
|
||||
if (!p.shop) return [0, "", office.includes(p.kind) ? office.indexOf(p.kind) : office.length];
|
||||
return [1, p.dept, SHOP_LEVELS.indexOf(p.level)];
|
||||
};
|
||||
return [...roles].sort((a, b) => {
|
||||
const [a0, a1, a2] = rank(a);
|
||||
const [b0, b1, b2] = rank(b);
|
||||
return a0 - b0 || a1.localeCompare(b1) || a2 - b2;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// API shapes from the phase 1 design contract. IDs stay text. Timestamps are ISO strings.
|
||||
|
||||
export type SessionKind = "password" | "pin";
|
||||
export type Factor = "password" | "pin";
|
||||
|
||||
export interface DeviceRef {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
sam_account: string;
|
||||
upn: string | null;
|
||||
display_name: string;
|
||||
emp_no: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export interface PinState {
|
||||
set: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string;
|
||||
kind: SessionKind;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
locked: boolean;
|
||||
locked_since: string | null;
|
||||
device: DeviceRef | null;
|
||||
}
|
||||
|
||||
/** GET /api/auth/me and every sign-in or unlock answer. */
|
||||
export interface Me {
|
||||
user: UserInfo;
|
||||
roles: string[];
|
||||
screens: string[];
|
||||
groups: string[];
|
||||
session: SessionInfo;
|
||||
pin: PinState;
|
||||
can_register_device: boolean;
|
||||
}
|
||||
|
||||
/** GET /api/auth/context. No session needed. */
|
||||
export interface AuthContextInfo {
|
||||
device: DeviceRef | null;
|
||||
stack: string;
|
||||
insecure_ldap: boolean;
|
||||
pin_digits: { min: number; max: number };
|
||||
}
|
||||
|
||||
/** GET /api/account. */
|
||||
export interface AccountInfo {
|
||||
user: UserInfo;
|
||||
groups: string[];
|
||||
roles: string[];
|
||||
pin: PinState;
|
||||
ad_synced_at: string | null;
|
||||
}
|
||||
|
||||
/** One row of GET /api/account/sessions. Admin user detail uses the same shape. */
|
||||
export interface SessionRow {
|
||||
id: string;
|
||||
kind: SessionKind;
|
||||
created_at: string;
|
||||
last_seen_at: string;
|
||||
ip: string | null;
|
||||
user_agent: string | null;
|
||||
device: DeviceRef | null;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
/** One row of GET /api/account/access-log. */
|
||||
export interface AccessLogRow {
|
||||
at: string;
|
||||
action: string;
|
||||
detail: unknown;
|
||||
ip: string | null;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
export interface UserFlags {
|
||||
emp_missing: boolean;
|
||||
emp_duplicate: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/** One row of GET /api/admin/users. */
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
sam_account: string;
|
||||
display_name: string;
|
||||
emp_no: string | null;
|
||||
email: string | null;
|
||||
active: boolean;
|
||||
roles: string[];
|
||||
pin: PinState;
|
||||
flags: UserFlags;
|
||||
last_signin_at: string | null;
|
||||
ad_synced_at: string | null;
|
||||
}
|
||||
|
||||
/** GET /api/admin/users/{id}. */
|
||||
export interface AdminUserDetail extends AdminUser {
|
||||
groups: string[];
|
||||
/** "cache" when AD was unreachable. Absent or "live" otherwise. */
|
||||
groups_source?: string;
|
||||
sessions: SessionRow[];
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** GET /api/admin/ad/lookup. */
|
||||
export interface AdLookup {
|
||||
found: boolean;
|
||||
user?: Partial<UserInfo> | null;
|
||||
groups?: string[];
|
||||
roles?: string[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** One row of GET /api/admin/devices. */
|
||||
export interface AdminDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
registered_by: { id: string; display_name: string } | null;
|
||||
registered_at: string | null;
|
||||
last_seen_at: string | null;
|
||||
revoked_at: string | null;
|
||||
active_sessions: number;
|
||||
}
|
||||
|
||||
export interface GroupRow {
|
||||
name: string;
|
||||
/** A role kind ("shop_supervisor") or a role string ("shop:03:supervisor"). Both are handled. */
|
||||
role: string;
|
||||
dept_code: string | null;
|
||||
in_ad: boolean | null;
|
||||
}
|
||||
|
||||
/** GET /api/admin/groups. */
|
||||
export interface GroupMap {
|
||||
checked_at: string | null;
|
||||
ad_reachable: boolean;
|
||||
groups: GroupRow[];
|
||||
extra_in_ou: string[];
|
||||
}
|
||||
|
||||
/** POST /api/admin/ad/refresh. */
|
||||
export interface AdRefreshResult {
|
||||
groups_checked: number;
|
||||
groups_missing: string[];
|
||||
users_seen: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
deactivated: number;
|
||||
emp_missing: string[];
|
||||
emp_duplicate: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of GET /api/admin/signin-attempts. The contract does not list the fields.
|
||||
* These are the signin_attempts columns.
|
||||
*/
|
||||
export interface SigninAttempt {
|
||||
id?: number | string;
|
||||
at: string;
|
||||
account: string;
|
||||
ip: string | null;
|
||||
kind: string;
|
||||
ok: boolean;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
/** Stations may come as names or as rows. */
|
||||
export type StationEntry = string | { name: string; active?: boolean };
|
||||
|
||||
/** One row of GET /api/departments. */
|
||||
export interface Department {
|
||||
code: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
kind: "support" | "shop";
|
||||
stations: StationEntry[];
|
||||
}
|
||||
|
||||
/** Payload of an SSE `presence` event. */
|
||||
export interface PresenceUser {
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
screen: string | null;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useContext } from "react";
|
||||
import { AuthCtx, type AuthState } from "./context";
|
||||
|
||||
/** The auth state. Must be used under AuthProvider. */
|
||||
export function useAuth(): AuthState {
|
||||
const ctx = useContext(AuthCtx);
|
||||
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { PresenceUser } from "./types";
|
||||
|
||||
interface EventHandlers {
|
||||
/** Called when the server revokes this browser's own session. */
|
||||
onRevoked: () => void;
|
||||
/** Called for every `changed` event. Later phases refresh their data from it. */
|
||||
onChanged?: (payload: unknown) => void;
|
||||
}
|
||||
|
||||
function parse(data: unknown): unknown {
|
||||
if (typeof data !== "string") return null;
|
||||
try {
|
||||
return JSON.parse(data) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens /api/events with EventSource while `sessionId` is set.
|
||||
* A `session` event with this session id and action "revoked" calls onRevoked.
|
||||
* `presence` is kept in state for later phases.
|
||||
*/
|
||||
export function useEvents(sessionId: string | null, handlers: EventHandlers): PresenceUser[] {
|
||||
const [presence, setPresence] = useState<PresenceUser[]>([]);
|
||||
const ref = useRef(handlers);
|
||||
useEffect(() => {
|
||||
ref.current = handlers;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || typeof EventSource === "undefined") return;
|
||||
const es = new EventSource("/api/events", { withCredentials: true });
|
||||
|
||||
es.addEventListener("session", (ev) => {
|
||||
const p = parse((ev as MessageEvent).data);
|
||||
if (p && typeof p === "object") {
|
||||
const o = p as { session_id?: unknown; action?: unknown };
|
||||
if (o.session_id === sessionId && o.action === "revoked") ref.current.onRevoked();
|
||||
}
|
||||
});
|
||||
es.addEventListener("presence", (ev) => {
|
||||
const p = parse((ev as MessageEvent).data);
|
||||
if (p && typeof p === "object" && Array.isArray((p as { users?: unknown }).users)) {
|
||||
setPresence((p as { users: PresenceUser[] }).users);
|
||||
}
|
||||
});
|
||||
es.addEventListener("changed", (ev) => {
|
||||
ref.current.onChanged?.(parse((ev as MessageEvent).data));
|
||||
});
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
setPresence([]);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
return presence;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { KeypadKey } from "../lib/keypad";
|
||||
|
||||
const KEYS: ReadonlyArray<{ key: KeypadKey; text: string; label: string }> = [
|
||||
...(["1", "2", "3", "4", "5", "6", "7", "8", "9"] as const).map((d) => ({ key: d, text: d, label: `Digit ${d}` })),
|
||||
{ key: "clear", text: "Clear", label: "Clear" },
|
||||
{ key: "0", text: "0", label: "Digit 0" },
|
||||
{ key: "back", text: "Back", label: "Back" },
|
||||
];
|
||||
|
||||
interface KeypadProps {
|
||||
onKey: (key: KeypadKey) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** On-screen keypad. Pressing a key keeps the focus in the field that had it. */
|
||||
export function Keypad({ onKey, disabled = false, label = "Keypad" }: KeypadProps) {
|
||||
return (
|
||||
<div className="keypad" role="group" aria-label={label}>
|
||||
{KEYS.map((k) => (
|
||||
<button
|
||||
key={k.key}
|
||||
type="button"
|
||||
aria-label={k.label}
|
||||
disabled={disabled}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onKey(k.key)}
|
||||
>
|
||||
{k.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,59 @@
|
||||
import { Outlet } from "react-router";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Link, Outlet, useLocation } from "react-router";
|
||||
import { SCREEN_LABELS, firstScreen, isScreen, orderScreens, type Screen } from "../auth/roles";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
import { LockScreen } from "./LockScreen";
|
||||
import { RoleSwitcher } from "./RoleSwitcher";
|
||||
import { TopBar } from "./TopBar";
|
||||
|
||||
/** Page frame: the top bar, then the routed page inside main.wrap. */
|
||||
function chipClass(s: Screen): string {
|
||||
if (s === "admin") return "rchip admin";
|
||||
if (s === "supervisor") return "rchip supervisor";
|
||||
return "rchip";
|
||||
}
|
||||
|
||||
/** Page frame: the top bar, then the routed page inside main.wrap, and the lock screen over both. */
|
||||
export function Layout() {
|
||||
const auth = useAuth();
|
||||
const { pathname } = useLocation();
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const locked = auth.status === "locked";
|
||||
const me = auth.status === "ready" || locked ? auth.me : null;
|
||||
|
||||
const screens = me ? orderScreens(me.screens) : [];
|
||||
const seg = pathname.split("/")[1];
|
||||
const active = isScreen(seg) && screens.includes(seg) ? seg : null;
|
||||
const shown = active ?? firstScreen(screens);
|
||||
|
||||
// Keep the page mounted behind the lock, but out of reach of the keyboard and screen readers.
|
||||
useEffect(() => {
|
||||
mainRef.current?.toggleAttribute("inert", locked);
|
||||
}, [locked]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar>
|
||||
<span className="proto">Rebuild preview</span>
|
||||
<TopBar nav={me && !locked ? <RoleSwitcher screens={screens} active={active} /> : null}>
|
||||
{me ? (
|
||||
<>
|
||||
<b className="uname">{me.user.display_name}</b>
|
||||
{shown ? <span className={chipClass(shown)}>{SCREEN_LABELS[shown]}</span> : null}
|
||||
<Link className="btn sm ghost" to="/account">
|
||||
Account
|
||||
</Link>
|
||||
<button className="btn sm ghost" type="button" onClick={() => void auth.signOut()}>
|
||||
Sign out
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="proto">Rebuild preview</span>
|
||||
)}
|
||||
</TopBar>
|
||||
<main className="wrap" id="main">
|
||||
<Outlet />
|
||||
</main>
|
||||
<div className={locked ? "blur-hidden" : undefined}>
|
||||
<main className="wrap" id="main" ref={mainRef} aria-hidden={locked ? true : undefined}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
{locked ? <LockScreen key={auth.me?.user.id ?? "unknown"} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { errorCode } from "../api/client";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
import type { Factor } from "../auth/types";
|
||||
import { errorText } from "../lib/format";
|
||||
import { applyKey, type KeypadKey } from "../lib/keypad";
|
||||
import { Keypad } from "./Keypad";
|
||||
|
||||
const PIN_MAX = 12;
|
||||
|
||||
/** Full-screen lock. The page stays mounted behind it, blurred and inert. */
|
||||
export function LockScreen() {
|
||||
const auth = useAuth();
|
||||
const me = auth.me;
|
||||
// Without Me (a 423 before /me answered) the PIN state is unknown, so start on the password.
|
||||
const hasPin = me ? me.pin.set && !me.pin.disabled : false;
|
||||
const [factor, setFactor] = useState<Factor>(hasPin ? "pin" : "password");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [missed, setMissed] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, [factor]);
|
||||
|
||||
const switchTo = (f: Factor) => {
|
||||
setFactor(f);
|
||||
setSecret("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!secret) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await auth.unlock(factor, secret);
|
||||
} catch (err) {
|
||||
if (errorCode(err) === "session_ended") {
|
||||
// The provider has signed out through the 401 listener. Make sure, with the server's words.
|
||||
auth.endSession(errorText(err));
|
||||
return;
|
||||
}
|
||||
setError(errorText(err));
|
||||
setMissed(true);
|
||||
setSecret("");
|
||||
setBusy(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const onKey = (k: KeypadKey) => setSecret((v) => applyKey(v, k, PIN_MAX));
|
||||
|
||||
return (
|
||||
<div className="scrim lock" role="dialog" aria-modal="true" aria-labelledby="lock-title">
|
||||
<div className="auth-card">
|
||||
<div>
|
||||
<div className="eyebrow">Locked</div>
|
||||
<h1 id="lock-title">{me ? me.user.display_name : "Screen locked"}</h1>
|
||||
</div>
|
||||
<div className="small muted">
|
||||
{factor === "pin" ? "Enter your PIN to continue, or sign out." : "Enter your password to continue, or sign out."}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="callout warn flush" role="alert">
|
||||
{error}
|
||||
{missed ? <div className="small">Three wrong attempts end the session.</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="auth-form" onSubmit={submit} noValidate autoComplete="off">
|
||||
{factor === "pin" ? (
|
||||
<>
|
||||
<label className="f">
|
||||
PIN
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="in big"
|
||||
name="unlock-pin"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
maxLength={PIN_MAX}
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value.replace(/\D/g, "").slice(0, PIN_MAX))}
|
||||
/>
|
||||
</label>
|
||||
<Keypad onKey={onKey} disabled={busy} label="Keypad, PIN" />
|
||||
</>
|
||||
) : (
|
||||
<label className="f">
|
||||
Password
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="in"
|
||||
name="unlock-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<button className="btn pri wide" type="submit" disabled={busy || !secret}>
|
||||
{busy ? "Checking" : "Unlock"}
|
||||
</button>
|
||||
{factor === "pin" ? (
|
||||
<button className="lnk small center" type="button" onClick={() => switchTo("password")}>
|
||||
Use your password instead
|
||||
</button>
|
||||
) : hasPin || !me ? (
|
||||
<button className="lnk small center" type="button" onClick={() => switchTo("pin")}>
|
||||
Use your PIN instead
|
||||
</button>
|
||||
) : null}
|
||||
<button className="btn ghost" type="button" onClick={() => void auth.signOut()}>
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Navigate, Outlet } from "react-router";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
|
||||
/** Routes below need a session. Signed out goes to /signin. A locked session stays, under the lock screen. */
|
||||
export function RequireAuth() {
|
||||
const { status } = useAuth();
|
||||
if (status === "loading") return <div className="empty">Loading</div>;
|
||||
if (status === "anonymous") return <Navigate to="/signin" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useNavigate } from "react-router";
|
||||
import { SCREEN_LABELS, type Screen } from "../auth/roles";
|
||||
|
||||
interface RoleSwitcherProps {
|
||||
screens: readonly Screen[];
|
||||
active: Screen | null;
|
||||
}
|
||||
|
||||
/** One button per screen the user has. Drawn only when there is more than one. */
|
||||
export function RoleSwitcher({ screens, active }: RoleSwitcherProps) {
|
||||
const navigate = useNavigate();
|
||||
if (screens.length < 2) return null;
|
||||
return (
|
||||
<nav className="roles" aria-label="Screens">
|
||||
{screens.map((s) => (
|
||||
<button key={s} type="button" aria-pressed={active === s} onClick={() => navigate(`/${s}`)}>
|
||||
{SCREEN_LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,14 @@ import { Link } from "react-router";
|
||||
import logo from "../assets/logo.png";
|
||||
|
||||
interface TopBarProps {
|
||||
/** Right-hand side: the signed-in user, role switcher and so on. */
|
||||
/** The role switcher, drawn between the brand and the spacer. */
|
||||
nav?: ReactNode;
|
||||
/** Right-hand side: the signed-in user, the role chip, Account and Sign out. */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/** Sticky shop header. Same markup classes as the old page's header.top. */
|
||||
export function TopBar({ children }: TopBarProps) {
|
||||
export function TopBar({ nav, children }: TopBarProps) {
|
||||
return (
|
||||
<header className="top">
|
||||
<div className="top-in">
|
||||
@@ -18,6 +20,7 @@ export function TopBar({ children }: TopBarProps) {
|
||||
Packet <span>Station</span>
|
||||
</div>
|
||||
</Link>
|
||||
{nav}
|
||||
<div className="sp" />
|
||||
{children ? <div className="who">{children}</div> : null}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ApiError, retryAfter } from "../api/client";
|
||||
|
||||
/** A short local date and time, or a dash. */
|
||||
export function fmtTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "-";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/** An audit detail dict as "key: value, key: value". Never shows nested objects in full. */
|
||||
export function fmtDetail(detail: unknown): string {
|
||||
if (detail === null || detail === undefined) return "";
|
||||
if (typeof detail !== "object") return String(detail);
|
||||
return Object.entries(detail as Record<string, unknown>)
|
||||
.filter(([, v]) => v !== null && v !== undefined && v !== "")
|
||||
.map(([k, v]) => `${k.replace(/_/g, " ")}: ${typeof v === "object" ? JSON.stringify(v) : String(v)}`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/** "Try again in N minutes." from a 429, rounded up, at least one. */
|
||||
export function retryText(err: unknown): string {
|
||||
const secs = retryAfter(err) ?? 60;
|
||||
const mins = Math.max(1, Math.ceil(secs / 60));
|
||||
return `Try again in ${mins} ${mins === 1 ? "minute" : "minutes"}.`;
|
||||
}
|
||||
|
||||
/** The message to show for a failed call. 429 adds the retry time. */
|
||||
export function errorText(err: unknown): string {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 429) return `Too many failed attempts. ${retryText(err)}`;
|
||||
return err.message;
|
||||
}
|
||||
if (err instanceof Error) return err.message;
|
||||
return "Something went wrong.";
|
||||
}
|
||||
|
||||
export const DIGITS = /^\d+$/;
|
||||
@@ -0,0 +1,9 @@
|
||||
// Keypad keys and how one press changes a field value.
|
||||
export type KeypadKey = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "clear" | "back";
|
||||
|
||||
/** Applies one key press to a field value. Digits stop at `max` characters. */
|
||||
export function applyKey(value: string, key: KeypadKey, max = 64): string {
|
||||
if (key === "clear") return "";
|
||||
if (key === "back") return value.slice(0, -1);
|
||||
return value.length >= max ? value : value + key;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { DIGITS } from "./format";
|
||||
|
||||
export const PIN_MIN = 6;
|
||||
export const PIN_MAX = 12;
|
||||
|
||||
/** Checks a new PIN the way the server does. Returns the message to show, or null. */
|
||||
export function pinProblem(pin: string, confirm: string, min = PIN_MIN, max = PIN_MAX): string | null {
|
||||
if (!pin) return "Enter a new PIN.";
|
||||
if (!DIGITS.test(pin)) return "The PIN must be digits only.";
|
||||
if (pin.length < min || pin.length > max) return `The PIN must be ${min} to ${max} digits.`;
|
||||
if (pin !== confirm) return "The two PINs do not match.";
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { errorText } from "./format";
|
||||
|
||||
export type Load<T> =
|
||||
| { state: "loading" }
|
||||
| { state: "ok"; data: T }
|
||||
| { state: "error"; message: string };
|
||||
|
||||
/**
|
||||
* Runs `fetcher` on mount and whenever `key` changes. `reload()` runs it again.
|
||||
* Answers from an older run are dropped.
|
||||
*/
|
||||
export function useLoad<T>(fetcher: (signal: AbortSignal) => Promise<T>, key: string = ""): [Load<T>, () => void] {
|
||||
const [load, setLoad] = useState<Load<T>>({ state: "loading" });
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
// The fetcher is read fresh on each run; callers pass inline arrows, and `key` says when to rerun.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const run = useCallback(fetcher, [key, tick]);
|
||||
|
||||
useEffect(() => {
|
||||
const ctl = new AbortController();
|
||||
run(ctl.signal)
|
||||
.then((data) => {
|
||||
if (!ctl.signal.aborted) setLoad({ state: "ok", data });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctl.signal.aborted) setLoad({ state: "error", message: errorText(err) });
|
||||
});
|
||||
return () => ctl.abort();
|
||||
}, [run]);
|
||||
|
||||
const reload = useCallback(() => setTick((t) => t + 1), []);
|
||||
return [load, reload];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { VersionStatus } from "../components/VersionStatus";
|
||||
|
||||
/** Build and API health. Open to anyone, so a broken sign-in can still be diagnosed. */
|
||||
export function AboutPage() {
|
||||
return (
|
||||
<>
|
||||
<div className="phead">
|
||||
<div>
|
||||
<div className="eyebrow">About</div>
|
||||
<h1>Packet Station</h1>
|
||||
</div>
|
||||
</div>
|
||||
<VersionStatus />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,12 @@
|
||||
import { Link } from "react-router";
|
||||
import { VersionStatus } from "../components/VersionStatus";
|
||||
import { Navigate } from "react-router";
|
||||
import { homePath } from "../auth/roles";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
|
||||
/** Placeholder home. Shows the build and the API health until the real home lands in phase 2. */
|
||||
/** "/" sends a signed-in user to their first screen, and everyone else to sign-in. */
|
||||
export function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<div className="phead">
|
||||
<div>
|
||||
<div className="eyebrow">Phase 0 shell</div>
|
||||
<h1>Packet Station</h1>
|
||||
</div>
|
||||
<div className="pacts">
|
||||
<Link className="btn pri" to="/signin">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<VersionStatus />
|
||||
<div className="callout">
|
||||
<b>Skeleton build.</b> Boards, packets and sign-in arrive in the next phases.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
const { status, me } = useAuth();
|
||||
if (status === "loading") return <div className="empty">Loading</div>;
|
||||
if (status === "anonymous") return <Navigate to="/signin" replace />;
|
||||
if (!me) return null;
|
||||
return <Navigate to={homePath(me.screens)} replace />;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,104 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { Link, Navigate, useNavigate } from "react-router";
|
||||
import { authApi } from "../auth/api";
|
||||
import { homePath } from "../auth/roles";
|
||||
import type { AuthContextInfo } from "../auth/types";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
import { Keypad } from "../components/Keypad";
|
||||
import { errorText } from "../lib/format";
|
||||
import { applyKey, type KeypadKey } from "../lib/keypad";
|
||||
|
||||
type Mode = "password" | "pin";
|
||||
type PinField = "emp" | "pin";
|
||||
|
||||
const TABS: ReadonlyArray<{ id: Mode; label: string }> = [
|
||||
{ id: "password", label: "Domain password" },
|
||||
{ id: "pin", label: "PIN" },
|
||||
];
|
||||
const PIN_MAX = 12;
|
||||
|
||||
const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "Clear", "0", "Back"] as const;
|
||||
|
||||
/** Placeholder sign-in. Both tabs are drawn, every input is disabled until phase 1 wires the server. */
|
||||
/** Sign-in. Domain password everywhere. Employee ID and PIN only on a registered device. */
|
||||
export function SignInPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [context, setContext] = useState<AuthContextInfo | null>(null);
|
||||
const [mode, setMode] = useState<Mode>("password");
|
||||
const [login, setLogin] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [emp, setEmp] = useState("");
|
||||
const [pin, setPin] = useState("");
|
||||
const [field, setField] = useState<PinField>("emp");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const empRef = useRef<HTMLInputElement>(null);
|
||||
const pinRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const ctl = new AbortController();
|
||||
authApi
|
||||
.context(ctl.signal)
|
||||
.then((c) => {
|
||||
if (ctl.signal.aborted) return;
|
||||
setContext(c);
|
||||
// A registered shop device starts on the PIN tab.
|
||||
if (c.device) setMode("pin");
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => ctl.abort();
|
||||
}, []);
|
||||
|
||||
if (auth.status === "loading") return <div className="empty">Loading</div>;
|
||||
if (auth.status === "ready" || auth.status === "locked") {
|
||||
return <Navigate to={homePath(auth.me?.screens ?? [])} replace />;
|
||||
}
|
||||
|
||||
const device = context?.device ?? null;
|
||||
const pinMax = context?.pin_digits.max ?? PIN_MAX;
|
||||
const tabs: ReadonlyArray<{ id: Mode; label: string }> = device
|
||||
? [
|
||||
{ id: "password", label: "Domain password" },
|
||||
{ id: "pin", label: "PIN" },
|
||||
]
|
||||
: [{ id: "password", label: "Domain password" }];
|
||||
const active: Mode = device ? mode : "password";
|
||||
|
||||
const run = async (call: () => Promise<{ screens: string[] }>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const me = await call();
|
||||
navigate(homePath(me.screens), { replace: true });
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitPassword = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!login.trim() || !password) {
|
||||
setError("Enter your user name and password.");
|
||||
return;
|
||||
}
|
||||
void run(() => auth.signInPassword(login.trim(), password));
|
||||
};
|
||||
|
||||
const submitPin = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!emp.trim()) {
|
||||
setError("Enter your employee ID.");
|
||||
setField("emp");
|
||||
empRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (!pin) {
|
||||
setField("pin");
|
||||
pinRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
void run(() => auth.signInPin(emp.trim(), pin));
|
||||
};
|
||||
|
||||
const onKey = (k: KeypadKey) => {
|
||||
if (field === "emp") setEmp((v) => applyKey(v, k, 32));
|
||||
else setPin((v) => applyKey(v, k, pinMax));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth">
|
||||
@@ -21,62 +108,104 @@ export function SignInPage() {
|
||||
<h1>Sign in</h1>
|
||||
</div>
|
||||
|
||||
{auth.notice ? <div className="callout warn flush">{auth.notice}</div> : null}
|
||||
|
||||
<div className="tabs static" role="tablist" aria-label="Sign-in method">
|
||||
{TABS.map((t) => (
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`tab-${t.id}`}
|
||||
aria-selected={mode === t.id}
|
||||
aria-selected={active === t.id}
|
||||
aria-controls={`panel-${t.id}`}
|
||||
onClick={() => setMode(t.id)}
|
||||
onClick={() => {
|
||||
setMode(t.id);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="callout">
|
||||
Sign-in arrives in <b>phase 1</b>. The fields below are disabled.
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="callout warn flush" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{mode === "password" ? (
|
||||
<form className="auth-form" id="panel-password" role="tabpanel" aria-labelledby="tab-password" onSubmit={(e) => e.preventDefault()}>
|
||||
{active === "password" ? (
|
||||
<form className="auth-form" id="panel-password" role="tabpanel" aria-labelledby="tab-password" onSubmit={submitPassword} noValidate>
|
||||
<label className="f">
|
||||
User name
|
||||
<input className="in" name="username" autoComplete="username" disabled />
|
||||
<input
|
||||
className="in"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
value={login}
|
||||
onChange={(e) => setLogin(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="f">
|
||||
Password
|
||||
<input className="in" name="password" type="password" autoComplete="current-password" disabled />
|
||||
<input
|
||||
className="in"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button className="btn pri" type="submit" disabled>
|
||||
Sign in
|
||||
<button className="btn pri wide" type="submit" disabled={busy}>
|
||||
{busy ? "Checking" : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form className="auth-form" id="panel-pin" role="tabpanel" aria-labelledby="tab-pin" onSubmit={(e) => e.preventDefault()}>
|
||||
<form className="auth-form" id="panel-pin" role="tabpanel" aria-labelledby="tab-pin" onSubmit={submitPin} noValidate autoComplete="off">
|
||||
{device ? <div className="small muted">This device: {device.name}</div> : null}
|
||||
<label className="f">
|
||||
Employee ID
|
||||
<input className="in" name="employee" inputMode="numeric" disabled />
|
||||
<input
|
||||
ref={empRef}
|
||||
className="in big"
|
||||
name="employee"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={emp}
|
||||
onFocus={() => setField("emp")}
|
||||
onChange={(e) => setEmp(e.target.value.trim())}
|
||||
/>
|
||||
</label>
|
||||
<label className="f">
|
||||
PIN
|
||||
<input className="in big" name="pin" type="password" inputMode="numeric" disabled />
|
||||
<input
|
||||
ref={pinRef}
|
||||
className="in big"
|
||||
name="pin"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
maxLength={pinMax}
|
||||
value={pin}
|
||||
onFocus={() => setField("pin")}
|
||||
onChange={(e) => setPin(e.target.value.replace(/\D/g, "").slice(0, pinMax))}
|
||||
/>
|
||||
</label>
|
||||
<div className="keypad" aria-label="PIN keypad">
|
||||
{KEYS.map((k) => (
|
||||
<button key={k} type="button" disabled>
|
||||
{k}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn pri" type="submit" disabled>
|
||||
Sign in
|
||||
<Keypad onKey={onKey} disabled={busy} label={field === "emp" ? "Keypad, employee ID" : "Keypad, PIN"} />
|
||||
<button className="btn pri wide" type="submit" disabled={busy}>
|
||||
{busy ? "Checking" : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<Link className="small muted center" to="/about">
|
||||
About this build
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { SCREEN_LABELS, SCREEN_PHASE, type Screen } from "../auth/roles";
|
||||
|
||||
/** Stand-in for a screen that a later phase builds. */
|
||||
export function PlaceholderScreen({ screen }: { screen: Exclude<Screen, "admin"> }) {
|
||||
return (
|
||||
<>
|
||||
<div className="phead">
|
||||
<div>
|
||||
<div className="eyebrow">Screen</div>
|
||||
<h1>{SCREEN_LABELS[screen]}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="callout">This screen arrives in phase {SCREEN_PHASE[screen]}.</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Navigate, useParams } from "react-router";
|
||||
import { homePath, isScreen, orderScreens } from "../auth/roles";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
import { AdminPage } from "./admin/AdminPage";
|
||||
import { PlaceholderScreen } from "./PlaceholderScreen";
|
||||
|
||||
/** /:screen. An unknown screen, or one the user does not have, goes to the user's first screen. */
|
||||
export function ScreenRoute() {
|
||||
const { screen } = useParams();
|
||||
const { me } = useAuth();
|
||||
if (!me) return null;
|
||||
const allowed = orderScreens(me.screens);
|
||||
if (!isScreen(screen) || !allowed.includes(screen)) return <Navigate to={homePath(me.screens)} replace />;
|
||||
if (screen === "admin") return <AdminPage />;
|
||||
return <PlaceholderScreen key={screen} screen={screen} />;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pinProblem } from "../../lib/pin";
|
||||
import { makeMe, renderApp } from "../../test/auth";
|
||||
import { callsTo, mockFetch } from "../../test/fetchMock";
|
||||
|
||||
const ACCOUNT = {
|
||||
user: makeMe().user,
|
||||
groups: ["PS-Clerical"],
|
||||
roles: ["clerical"],
|
||||
pin: { set: false, disabled: false },
|
||||
ad_synced_at: "2026-10-16T08:00:00Z",
|
||||
};
|
||||
|
||||
function routes(me = makeMe()) {
|
||||
return {
|
||||
"/api/auth/me": { body: me },
|
||||
"/api/account": { body: ACCOUNT },
|
||||
"/api/account/sessions": {
|
||||
body: [
|
||||
{
|
||||
id: "sess-1",
|
||||
kind: "password",
|
||||
created_at: "2026-10-16T08:00:00Z",
|
||||
last_seen_at: "2026-10-16T08:05:00Z",
|
||||
ip: "10.0.0.5",
|
||||
user_agent: "Firefox",
|
||||
device: null,
|
||||
current: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/account/access-log": {
|
||||
body: [{ at: "2026-10-16T08:00:00Z", action: "auth.signin", detail: { kind: "password" }, ip: "10.0.0.5", ok: true }],
|
||||
},
|
||||
"/api/auth/context": { body: { device: null, stack: "test", insecure_ldap: false, pin_digits: { min: 6, max: 12 } } },
|
||||
};
|
||||
}
|
||||
|
||||
describe("pinProblem", () => {
|
||||
it("checks digits, length and the confirmation", () => {
|
||||
expect(pinProblem("12ab56", "12ab56")).toBe("The PIN must be digits only.");
|
||||
expect(pinProblem("12345", "12345")).toBe("The PIN must be 6 to 12 digits.");
|
||||
expect(pinProblem("1234567890123", "1234567890123")).toBe("The PIN must be 6 to 12 digits.");
|
||||
expect(pinProblem("123456", "123465")).toBe("The two PINs do not match.");
|
||||
expect(pinProblem("123456", "123456")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountPage", () => {
|
||||
it("shows the AD details, sessions and the access log", async () => {
|
||||
mockFetch(routes());
|
||||
renderApp("/account");
|
||||
expect(await screen.findByRole("heading", { name: "Clara Clerk" })).toBeInTheDocument();
|
||||
expect(await screen.findByText("PS-Clerical")).toBeInTheDocument();
|
||||
expect(screen.getByText("04001")).toBeInTheDocument();
|
||||
expect(await screen.findByText("This session")).toBeInTheDocument();
|
||||
expect(await screen.findByText("auth.signin")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: "Register this device" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("validates the PIN before it posts, then saves it", async () => {
|
||||
const fetch = mockFetch({ ...routes(), "POST /api/account/pin": { body: { pin: { set: true, disabled: false } } } });
|
||||
renderApp("/account");
|
||||
const submit = await screen.findByRole("button", { name: "Create PIN" });
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "pw" } });
|
||||
|
||||
fireEvent.change(screen.getByLabelText("New PIN"), { target: { value: "12ab56" } });
|
||||
fireEvent.change(screen.getByLabelText("Confirm PIN"), { target: { value: "12ab56" } });
|
||||
fireEvent.click(submit);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("The PIN must be digits only.");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("New PIN"), { target: { value: "123" } });
|
||||
fireEvent.change(screen.getByLabelText("Confirm PIN"), { target: { value: "123" } });
|
||||
fireEvent.click(submit);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("The PIN must be 6 to 12 digits.");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("New PIN"), { target: { value: "135790" } });
|
||||
fireEvent.change(screen.getByLabelText("Confirm PIN"), { target: { value: "135791" } });
|
||||
fireEvent.click(submit);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("The two PINs do not match.");
|
||||
expect(callsTo(fetch, "POST", "/api/account/pin")).toHaveLength(0);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Confirm PIN"), { target: { value: "135790" } });
|
||||
fireEvent.click(submit);
|
||||
expect(await screen.findByText("PIN saved.")).toBeInTheDocument();
|
||||
expect(callsTo(fetch, "POST", "/api/account/pin")).toEqual([{ password: "pw", pin: "135790" }]);
|
||||
expect(screen.getByRole("button", { name: "Change PIN" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a PIN session cannot change the PIN and sees why", async () => {
|
||||
mockFetch(routes(makeMe({ session: { kind: "pin" }, pin: { set: true, disabled: false } })));
|
||||
renderApp("/account");
|
||||
expect(await screen.findByText("Sign in with your password to change account settings.")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("New PIN")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the disabled notice and the device form for a registrar", async () => {
|
||||
const fetch = mockFetch({
|
||||
...routes(makeMe({ pin: { set: true, disabled: true }, can_register_device: true })),
|
||||
"POST /api/account/device": { body: { device: { id: "dev-9", name: "Weld Parts Stall 3" } } },
|
||||
});
|
||||
renderApp("/account");
|
||||
expect(await screen.findByText(/Your PIN is disabled/)).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Device name"), { target: { value: "Weld Parts Stall 3" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Register this device" }));
|
||||
expect(await screen.findByText("Weld Parts Stall 3")).toBeInTheDocument();
|
||||
expect(callsTo(fetch, "POST", "/api/account/device")).toEqual([{ name: "Weld Parts Stall 3" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,396 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { accountApi, authApi } from "../../auth/api";
|
||||
import { roleChipClass, sortRoles } from "../../auth/roles";
|
||||
import type { AccountInfo, DeviceRef, Me } from "../../auth/types";
|
||||
import { useAuth } from "../../auth/useAuth";
|
||||
import { errorText, fmtDetail, fmtTime } from "../../lib/format";
|
||||
import { PIN_MAX, pinProblem } from "../../lib/pin";
|
||||
import { useLoad, type Load } from "../../lib/useLoad";
|
||||
|
||||
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 Details({ me, account }: { me: Me; account: AccountInfo | null }) {
|
||||
const { roleLabel } = useAuth();
|
||||
const user = account?.user ?? me.user;
|
||||
const groups = account?.groups ?? me.groups;
|
||||
const roles = sortRoles(account?.roles ?? me.roles);
|
||||
const rows: Array<[string, string | null]> = [
|
||||
["Name", user.display_name],
|
||||
["Account", user.sam_account],
|
||||
["UPN", user.upn],
|
||||
["Email", user.email],
|
||||
["Employee ID", user.emp_no],
|
||||
];
|
||||
return (
|
||||
<section className="sec" aria-labelledby="acct-ad">
|
||||
<div className="sec-h">
|
||||
<h2 id="acct-ad">Directory details</h2>
|
||||
<span className="ct">From Active Directory. Read only.</span>
|
||||
</div>
|
||||
<div className="panel">
|
||||
<dl className="kv">
|
||||
{rows.map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<dt>{k}</dt>
|
||||
<dd className={k === "Employee ID" || k === "Account" ? "mono" : undefined}>{v || <span className="muted">Not set</span>}</dd>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<dt>Groups</dt>
|
||||
<dd>
|
||||
<ul className="chips plain" aria-label="Groups">
|
||||
{groups.map((g) => (
|
||||
<li key={g} className="chip static mono">
|
||||
{g}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Roles</dt>
|
||||
<dd>
|
||||
<ul className="chips plain" aria-label="Roles">
|
||||
{roles.map((r) => (
|
||||
<li key={r} className={roleChipClass(r)}>
|
||||
{roleLabel(r)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{account?.ad_synced_at ? <div className="small muted">Read from AD {fmtTime(account.ad_synced_at)}.</div> : null}
|
||||
<div className="small muted">Roles come from AD groups. Ask IT to change them.</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PinSection({ me, onSaved }: { me: Me; onSaved: () => void }) {
|
||||
const auth = useAuth();
|
||||
const [password, setPassword] = useState("");
|
||||
const [pin, setPin] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
const isPinSession = me.session.kind === "pin";
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setDone(false);
|
||||
if (!password) {
|
||||
setError("Enter your password.");
|
||||
return;
|
||||
}
|
||||
const problem = pinProblem(pin, confirm);
|
||||
if (problem) {
|
||||
setError(problem);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await accountApi.setPin(password, pin);
|
||||
auth.setMe({ ...me, pin: res.pin });
|
||||
setPassword("");
|
||||
setPin("");
|
||||
setConfirm("");
|
||||
setDone(true);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="sec" aria-labelledby="acct-pin">
|
||||
<div className="sec-h">
|
||||
<h2 id="acct-pin">PIN</h2>
|
||||
<span className="ct">{me.pin.set ? (me.pin.disabled ? "Disabled" : "Set") : "Not set"}</span>
|
||||
</div>
|
||||
<div className="panel stack">
|
||||
<div className="small">The PIN signs you in on a registered shop device and unlocks the screen. Use 6 to 12 digits.</div>
|
||||
{me.pin.disabled ? (
|
||||
<div className="callout warn flush">Your PIN is disabled after too many wrong attempts. Set a new one to use it again.</div>
|
||||
) : null}
|
||||
{isPinSession ? (
|
||||
<div className="callout flush">Sign in with your password to change account settings.</div>
|
||||
) : (
|
||||
<form className="stack" onSubmit={submit} noValidate autoComplete="off">
|
||||
<div className="frow">
|
||||
<label className="f">
|
||||
Password
|
||||
<input className="in" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</label>
|
||||
<label className="f">
|
||||
New PIN
|
||||
<input
|
||||
className="in mono"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
autoComplete="new-password"
|
||||
maxLength={PIN_MAX}
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="f">
|
||||
Confirm PIN
|
||||
<input
|
||||
className="in mono"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
autoComplete="new-password"
|
||||
maxLength={PIN_MAX}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="callout warn flush" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{done ? (
|
||||
<div className="callout flush" role="status">
|
||||
PIN saved.
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<button className="btn pri" type="submit" disabled={busy}>
|
||||
{me.pin.set ? "Change PIN" : "Create PIN"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceSection({ device, onRegistered }: { device: DeviceRef | null; onRegistered: (d: DeviceRef) => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
setError("Give the device a name, for example Weld Parts Stall 3.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await accountApi.registerDevice(name.trim());
|
||||
onRegistered(res.device);
|
||||
setName("");
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="sec" aria-labelledby="acct-device">
|
||||
<div className="sec-h">
|
||||
<h2 id="acct-device">Register this device</h2>
|
||||
</div>
|
||||
<div className="panel stack">
|
||||
<div className="small">A registered device shows the PIN sign-in. Register shared shop screens only.</div>
|
||||
{device ? (
|
||||
<div className="callout flush" role="status">
|
||||
This browser is registered as <b>{device.name}</b>.
|
||||
</div>
|
||||
) : null}
|
||||
<form className="frow" onSubmit={submit} noValidate>
|
||||
<label className="f">
|
||||
Device name
|
||||
<input className="in" value={name} maxLength={80} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<div className="end">
|
||||
<button className="btn pri" type="submit" disabled={busy}>
|
||||
Register this device
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{error ? (
|
||||
<div className="callout warn flush" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsSection({ me }: { me: Me }) {
|
||||
const [load, reload] = useLoad((s) => accountApi.sessions(s));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const isPinSession = me.session.kind === "pin";
|
||||
|
||||
const signOutOthers = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await accountApi.signOutOthers();
|
||||
setMsg(res.revoked === 1 ? "Signed out 1 other session." : `Signed out ${res.revoked} other sessions.`);
|
||||
reload();
|
||||
} catch (err) {
|
||||
setMsg(errorText(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rows = load.state === "ok" ? load.data : [];
|
||||
return (
|
||||
<section className="sec" aria-labelledby="acct-sessions">
|
||||
<div className="sec-h">
|
||||
<h2 id="acct-sessions">Sessions</h2>
|
||||
<span className="ct">Signed in now</span>
|
||||
<span className="grow" />
|
||||
<button className="btn sm" type="button" disabled={busy || isPinSession} onClick={() => void signOutOthers()}>
|
||||
Sign out other sessions
|
||||
</button>
|
||||
</div>
|
||||
{msg ? (
|
||||
<div className="callout" role="status">
|
||||
{msg}
|
||||
</div>
|
||||
) : null}
|
||||
<Problem load={load} />
|
||||
{load.state === "ok" ? (
|
||||
<div className="tw">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Started</th>
|
||||
<th>Last seen</th>
|
||||
<th>Sign-in</th>
|
||||
<th>Device</th>
|
||||
<th>Address</th>
|
||||
<th>Browser</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td className="small mono">
|
||||
{fmtTime(s.created_at)} {s.current ? <span className="pill st-ok">This session</span> : null}
|
||||
</td>
|
||||
<td className="small mono">{fmtTime(s.last_seen_at)}</td>
|
||||
<td>{s.kind === "pin" ? "PIN" : "Password"}</td>
|
||||
<td>{s.device?.name ?? <span className="muted">-</span>}</td>
|
||||
<td className="mono small">{s.ip ?? "-"}</td>
|
||||
<td className="small clip">{s.user_agent ?? "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="muted">
|
||||
No active sessions.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AccessLogSection() {
|
||||
const [load] = useLoad((s) => accountApi.accessLog(50, s));
|
||||
const rows = load.state === "ok" ? load.data : [];
|
||||
return (
|
||||
<section className="sec" aria-labelledby="acct-log">
|
||||
<div className="sec-h">
|
||||
<h2 id="acct-log">Access log</h2>
|
||||
<span className="ct">Your sign-ins, failures and PIN changes. Newest first.</span>
|
||||
</div>
|
||||
<Problem load={load} />
|
||||
{load.state === "ok" ? (
|
||||
<div className="tw">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>What</th>
|
||||
<th>Detail</th>
|
||||
<th>Address</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={`${r.at}-${i}`}>
|
||||
<td className="small mono">{fmtTime(r.at)}</td>
|
||||
<td className="mono small">{r.action}</td>
|
||||
<td className="small">{fmtDetail(r.detail)}</td>
|
||||
<td className="mono small">{r.ip ?? "-"}</td>
|
||||
<td>{r.ok ? <span className="pill st-ok">OK</span> : <span className="pill st-bad">Failed</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="muted">
|
||||
Nothing yet.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** /account: the user's own AD details, PIN, device registration, sessions and access log. */
|
||||
export function AccountPage() {
|
||||
const { me } = useAuth();
|
||||
const [account, reloadAccount] = useLoad((s) => accountApi.get(s));
|
||||
const [context] = useLoad((s) => authApi.context(s));
|
||||
const [registered, setRegistered] = useState<DeviceRef | null>(null);
|
||||
if (!me) return null;
|
||||
|
||||
const device = registered ?? (context.state === "ok" ? context.data.device : null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="phead">
|
||||
<div>
|
||||
<div className="eyebrow">Account</div>
|
||||
<h1>{me.user.display_name}</h1>
|
||||
</div>
|
||||
</div>
|
||||
{me.session.kind === "pin" ? (
|
||||
<div className="callout">
|
||||
You signed in with a PIN{me.session.device ? ` on ${me.session.device.name}` : ""}. Some settings need a password sign-in.
|
||||
</div>
|
||||
) : null}
|
||||
{account.state === "error" ? <div className="callout warn">{account.message}</div> : null}
|
||||
<Details me={me} account={account.state === "ok" ? account.data : null} />
|
||||
<PinSection me={me} onSaved={reloadAccount} />
|
||||
{me.can_register_device && me.session.kind === "password" ? <DeviceSection device={device} onRegistered={setRegistered} /> : null}
|
||||
<SessionsSection me={me} />
|
||||
<AccessLogSection />
|
||||
<div className="small muted">
|
||||
<Link to="/about">About this build</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { AppRoutes } from "../App";
|
||||
import { AuthProvider } from "../auth/AuthProvider";
|
||||
import type { Me } from "../auth/types";
|
||||
|
||||
export const DEPARTMENTS = [
|
||||
{ code: "CL", slug: "Clerical", name: "Clerical", kind: "support", stations: [] },
|
||||
{ code: "03", slug: "WeldParts", name: "Weld Parts", kind: "shop", stations: ["Stall 1", "Stall 2"] },
|
||||
{ code: "14", slug: "Electrical", name: "Electrical", kind: "shop", stations: [] },
|
||||
];
|
||||
|
||||
/** A signed-in Me. Override any part. */
|
||||
export function makeMe(over: Partial<Omit<Me, "session" | "user">> & { session?: Partial<Me["session"]>; user?: Partial<Me["user"]> } = {}): Me {
|
||||
const { session, user, ...rest } = over;
|
||||
return {
|
||||
user: {
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
sam_account: "clerk.clara",
|
||||
upn: "[email protected]",
|
||||
display_name: "Clara Clerk",
|
||||
emp_no: "04001",
|
||||
email: "[email protected]",
|
||||
...user,
|
||||
},
|
||||
roles: ["clerical"],
|
||||
screens: ["clerical"],
|
||||
groups: ["PS-Clerical"],
|
||||
pin: { set: false, disabled: false },
|
||||
can_register_device: false,
|
||||
...rest,
|
||||
session: {
|
||||
id: "sess-1",
|
||||
kind: "password",
|
||||
created_at: "2026-10-16T08:00:00Z",
|
||||
expires_at: "2026-10-16T20:00:00Z",
|
||||
locked: false,
|
||||
locked_since: null,
|
||||
device: null,
|
||||
...session,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const NO_SESSION = { status: 401, body: { detail: "Not signed in", code: "no_session" } };
|
||||
|
||||
/** Mounts the whole app at `path` under the auth provider. */
|
||||
export function renderApp(path: string, props: { idleLockMs?: number; idleCheckMs?: number; hiddenLockMs?: number } = {}) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<AuthProvider {...props}>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,58 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export type Route = { status?: number; body?: unknown; contentType?: string } | Error;
|
||||
export type Reply = { status?: number; body?: unknown; contentType?: string } | Error;
|
||||
|
||||
/** Stubs global fetch. Routes are keyed by path. Unknown paths answer 404. */
|
||||
/** What a route function sees about the request. */
|
||||
export interface MockRequest {
|
||||
method: string;
|
||||
/** Path with the query string, as the app passed it. */
|
||||
url: string;
|
||||
path: string;
|
||||
/** Parsed JSON body, or undefined. */
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
export type Route = Reply | ((req: MockRequest) => Reply);
|
||||
|
||||
/**
|
||||
* Stubs global fetch. Routes are keyed by path, or by "METHOD path".
|
||||
* Lookup order: "METHOD url", "METHOD path", "url", "path". A url key includes the query string.
|
||||
* A route may be a function of the request, for answers that change between calls.
|
||||
* Unknown paths answer 404.
|
||||
*/
|
||||
export function mockFetch(routes: Record<string, Route>) {
|
||||
const fn = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
|
||||
const path = typeof input === "string" ? input : input instanceof URL ? input.pathname : input.url;
|
||||
const r = routes[path];
|
||||
const fn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.pathname + input.search : input.url;
|
||||
const path = url.split("?")[0] ?? url;
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
let body: unknown;
|
||||
if (typeof init?.body === "string") {
|
||||
try {
|
||||
body = JSON.parse(init.body) as unknown;
|
||||
} catch {
|
||||
body = init.body;
|
||||
}
|
||||
}
|
||||
const route = routes[`${method} ${url}`] ?? routes[`${method} ${path}`] ?? routes[url] ?? routes[path];
|
||||
const r = typeof route === "function" ? route({ method, url, path, body }) : route;
|
||||
if (r instanceof Error) throw r;
|
||||
const status = r?.status ?? (r ? 200 : 404);
|
||||
const body = r?.body;
|
||||
const isText = typeof body === "string";
|
||||
const replyBody = r?.body;
|
||||
const isText = typeof replyBody === "string";
|
||||
const contentType = r?.contentType ?? (isText ? "text/plain" : "application/json");
|
||||
const text = body === undefined ? "" : isText ? body : JSON.stringify(body);
|
||||
const text = replyBody === undefined ? "" : isText ? replyBody : JSON.stringify(replyBody);
|
||||
return new Response(status === 204 ? null : text, { status, headers: { "content-type": contentType } });
|
||||
});
|
||||
vi.stubGlobal("fetch", fn);
|
||||
return fn;
|
||||
}
|
||||
|
||||
/** The calls made to one method and path, with parsed JSON bodies. */
|
||||
export function callsTo(fn: ReturnType<typeof mockFetch>, method: string, path: string): unknown[] {
|
||||
return fn.mock.calls
|
||||
.filter(([input, init]) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.pathname : input.url;
|
||||
return (init?.method ?? "GET").toUpperCase() === method && url.split("?")[0] === path;
|
||||
})
|
||||
.map(([, init]) => (typeof init?.body === "string" ? (JSON.parse(init.body) as unknown) : undefined));
|
||||
}
|
||||
|
||||
@@ -239,3 +239,27 @@ td .sub { color: var(--muted); font-size: 12.5px; }
|
||||
.brand .nm { font-size: 18px; }
|
||||
.phead h1 { font-size: 26px; }
|
||||
}
|
||||
|
||||
/* ===== New in the rebuild (phase 1) ===== */
|
||||
/* Sign-in, lock screen, Account and Admin. Built only from the tokens above. */
|
||||
.callout.flush { margin: 0; }
|
||||
.btn.wide { width: 100%; min-height: 56px; font-size: 17px; }
|
||||
.center { align-self: center; text-align: center; }
|
||||
.grow { flex: 1; }
|
||||
.end { display: flex; align-items: flex-end; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.stack { display: flex; flex-direction: column; gap: 12px; }
|
||||
.uname { color: var(--near); font-weight: 600; font-size: 14px; }
|
||||
.chips.plain { list-style: none; margin: 0; padding: 0; }
|
||||
.chips.tight { gap: 4px; }
|
||||
.chip.static { cursor: default; min-height: 0; padding: 4px 10px; font-size: 12.5px; }
|
||||
.kv { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px 20px; margin: 0 0 10px; }
|
||||
.kv dt { font-size: 11.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
|
||||
.kv dd { margin: 2px 0 0; color: var(--near); overflow-wrap: anywhere; }
|
||||
td.clip { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
tr.rowlink { cursor: pointer; }
|
||||
tr.rowlink:hover td { background: var(--panel); }
|
||||
tr.rowlink.on td { background: var(--sel); }
|
||||
tr.rowlink:focus-visible { outline: 2px solid var(--cyan); outline-offset: -2px; }
|
||||
td .pill + .pill { margin-left: 4px; }
|
||||
.lock .auth-card { max-height: calc(100dvh - 32px); overflow: auto; }
|
||||
|
||||
Reference in New Issue
Block a user