Revert "feat(clock): add named kiosk and supervisor sessions"

This reverts commit b163ce9f94.
This commit is contained in:
DEls 2026-09-25 03:22:43 +02:00
parent b163ce9f94
commit 29edf2e5aa
6 changed files with 2 additions and 340 deletions

View file

@ -1,49 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const login = vi.fn();
const refresh = vi.fn();
vi.mock("pocketbase", () => ({
default: class {
authStore = { save: vi.fn(), get token() { return "user-token"; } };
collection() { return { authWithPassword: login, authRefresh: refresh }; }
},
}));
import { DELETE, GET, POST } from "./route";
function post(body: unknown, origin = "https://mega.example") {
return new Request("https://mega.example/api/clock/session", {
method: "POST", headers: { origin, "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => { login.mockReset(); refresh.mockReset(); });
describe("clock session route", () => {
it("rejects missing credentials, wrong role, and cross-origin login", async () => {
expect((await POST(post({ purpose: "kiosk" }))).status).toBe(400);
expect((await POST(post({ email: "a", password: "b", purpose: "kiosk" }, "https://bad.example"))).status).toBe(403);
login.mockResolvedValue({ record: { id: "u1", role: "supervisor", allowed_sites: ["site1"] } });
expect((await POST(post({ email: "a", password: "b", purpose: "kiosk" }))).status).toBe(403);
});
it("sets a bounded HttpOnly purpose cookie and returns no token", async () => {
login.mockResolvedValue({ record: { id: "u1", role: "kiosk", allowed_sites: ["site1"] } });
const response = await POST(post({ email: "a", password: "b", purpose: "kiosk" }));
expect(response.status).toBe(200);
expect(response.headers.get("set-cookie")).toContain("HttpOnly");
expect(response.headers.get("set-cookie")).toContain("SameSite=strict");
expect(response.headers.get("set-cookie")).toContain("Secure");
expect(JSON.stringify(await response.json())).not.toContain("user-token");
});
it("reports unsigned sessions and logs out", async () => {
const unsigned = new Request("https://mega.example/api/clock/session?purpose=kiosk");
expect((await (await GET(unsigned)).json()).authenticated).toBe(false);
const logout = new Request("https://mega.example/api/clock/session?purpose=kiosk", {
method: "DELETE", headers: { origin: "https://mega.example" },
});
expect((await DELETE(logout)).status).toBe(200);
});
});

View file

@ -1,80 +0,0 @@
import { NextResponse } from "next/server";
import {
ClockAuthError,
clockCookieName,
loginClockActor,
requireClockActor,
requireSameOrigin,
type ClockRole,
} from "@/lib/clock-auth";
function purposeFrom(request: Request): ClockRole | null {
const purpose = new URL(request.url).searchParams.get("purpose");
return purpose === "kiosk" || purpose === "supervisor" ? purpose : null;
}
function errorResponse(error: unknown) {
if (error instanceof ClockAuthError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
return NextResponse.json({ error: "Clock session failed" }, { status: 500 });
}
export async function POST(request: Request) {
try {
requireSameOrigin(request);
const body = await request.json() as Record<string, unknown>;
const { email, password, purpose } = body;
if (typeof email !== "string" || !email.trim() ||
typeof password !== "string" || !password ||
(purpose !== "kiosk" && purpose !== "supervisor")) {
return NextResponse.json({ error: "Email, password and purpose are required" }, { status: 400 });
}
const { actor, token } = await loginClockActor(email.trim(), password, purpose);
const response = NextResponse.json({ authenticated: true, role: actor.role, allowedSites: actor.allowedSites });
response.cookies.set(clockCookieName(purpose), token, {
httpOnly: true,
sameSite: "strict",
secure: new URL(request.url).protocol === "https:",
path: "/",
maxAge: purpose === "supervisor" ? 4 * 3600 : 12 * 3600,
});
return response;
} catch (error) {
if (error instanceof SyntaxError) {
return NextResponse.json({ error: "Body must be JSON" }, { status: 400 });
}
return errorResponse(error);
}
}
export async function GET(request: Request) {
const purpose = purposeFrom(request);
if (!purpose) return NextResponse.json({ error: "Purpose is required" }, { status: 400 });
try {
const actor = await requireClockActor(request, purpose);
return NextResponse.json({ authenticated: true, role: actor.role, allowedSites: actor.allowedSites });
} catch (error) {
if (error instanceof ClockAuthError) {
return NextResponse.json({ authenticated: false });
}
return errorResponse(error);
}
}
export async function DELETE(request: Request) {
try {
requireSameOrigin(request);
const purpose = purposeFrom(request);
if (!purpose) return NextResponse.json({ error: "Purpose is required" }, { status: 400 });
const response = NextResponse.json({ authenticated: false });
response.cookies.set(clockCookieName(purpose), "", {
httpOnly: true, sameSite: "strict",
secure: new URL(request.url).protocol === "https:",
path: "/", maxAge: 0,
});
return response;
} catch (error) {
return errorResponse(error);
}
}

View file

@ -5,7 +5,6 @@ import { FileText, Truck, User } from "lucide-react";
import { cn } from "@/lib/utils";
import type { SitePoint } from "@/lib/nearest-site";
import { PersonScanner } from "./person-scanner";
import { KioskSession } from "./kiosk-session";
import { FleetScanner } from "./fleet-scanner";
import { DocumentScanner } from "./document-scanner";
@ -15,18 +14,6 @@ export function ClockView({ sites }: { sites: SitePoint[] }) {
const [tab, setTab] = React.useState<Tab>("person");
const [now, setNow] = React.useState(() => new Date());
const [scanCount, setScanCount] = React.useState(0);
const [kioskReady, setKioskReady] = React.useState<boolean | null>(null);
React.useEffect(() => {
let active = true;
fetch("/api/clock/session?purpose=kiosk", { credentials: "same-origin" })
.then((response) => response.json())
.then((body: { authenticated?: boolean }) => {
if (active) setKioskReady(body.authenticated === true);
})
.catch(() => { if (active) setKioskReady(false); });
return () => { active = false; };
}, []);
React.useEffect(() => {
const id = setInterval(() => setNow(new Date()), 30_000);
@ -51,11 +38,7 @@ export function ClockView({ sites }: { sites: SitePoint[] }) {
</header>
<main className="flex flex-1 flex-col overflow-hidden">
{tab === "person" && kioskReady === null && (
<div className="flex flex-1 items-center justify-center text-muted-foreground">Checking clock sign-in…</div>
)}
{tab === "person" && kioskReady === false && <KioskSession onSignedIn={() => setKioskReady(true)} />}
{tab === "person" && kioskReady === true && <PersonScanner onScan={() => setScanCount((c) => c + 1)} />}
{tab === "person" && <PersonScanner onScan={() => setScanCount((c) => c + 1)} />}
{tab === "fleet" && <FleetScanner onScan={() => setScanCount((c) => c + 1)} />}
{/* Remounted per visit rather than hidden: the camera stream and
the screen wake lock must not stay live behind the badge
@ -113,4 +96,4 @@ function TabButton({
{label}
</button>
);
}
}

View file

@ -1,62 +0,0 @@
"use client";
import * as React from "react";
export function KioskSession({ onSignedIn }: { onSignedIn: () => void }) {
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState("");
async function signIn(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setBusy(true);
setError("");
try {
const response = await fetch("/api/clock/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ email, password, purpose: "kiosk" }),
});
if (!response.ok) {
const body = await response.json() as { error?: string };
throw new Error(body.error ?? "Sign-in failed");
}
setPassword("");
onSignedIn();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Sign-in failed");
} finally {
setBusy(false);
}
}
return (
<div className="flex flex-1 items-center justify-center p-5">
<form onSubmit={signIn} className="w-full max-w-sm space-y-4 rounded-xl border border-border bg-card p-6 shadow-sm">
<div>
<h2 className="text-xl font-semibold">Sign in this clock phone</h2>
<p className="mt-1 text-sm text-muted-foreground">A kiosk account signs in once for the shift. Workers still use their badges.</p>
</div>
<label className="block text-sm font-medium">
Email
<input type="email" autoComplete="username" required value={email}
onChange={(event) => setEmail(event.target.value)}
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2" />
</label>
<label className="block text-sm font-medium">
Password
<input type="password" autoComplete="current-password" required value={password}
onChange={(event) => setPassword(event.target.value)}
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2" />
</label>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<button type="submit" disabled={busy}
className="w-full rounded-md bg-primary px-4 py-2 font-semibold text-primary-foreground disabled:opacity-50">
{busy ? "Signing in…" : "Sign in"}
</button>
</form>
</div>
);
}

View file

@ -1,40 +0,0 @@
import { describe, expect, it, vi } from "vitest";
const refresh = vi.fn();
const save = vi.fn();
vi.mock("pocketbase", () => ({
default: class {
authStore = { save, get token() { return "refreshed-token"; } };
collection() { return { authRefresh: refresh }; }
},
}));
import { requireClockActor, requireSameOrigin } from "./clock-auth";
function request(cookie = "clock_supervisor=session-token", origin?: string) {
return new Request("https://mega.example/api/clock/shifts/s1/correct", {
headers: { cookie, ...(origin ? { origin } : {}) },
});
}
describe("clock session", () => {
it("rejects missing and expired supervisor sessions", async () => {
await expect(requireClockActor(request(""), "supervisor")).rejects.toMatchObject({ status: 401 });
refresh.mockRejectedValueOnce(new Error("expired"));
await expect(requireClockActor(request(), "supervisor")).rejects.toMatchObject({ status: 401 });
});
it("checks refreshed role and site, not the cookie's claimed purpose", async () => {
refresh.mockResolvedValue({ record: { id: "u1", role: "supervisor", allowed_sites: ["site1"] } });
await expect(requireClockActor(request(), "supervisor", "site2")).rejects.toMatchObject({ status: 403 });
expect((await requireClockActor(request(), "supervisor", "site1")).id).toBe("u1");
refresh.mockResolvedValueOnce({ record: { id: "u1", role: "kiosk", allowed_sites: ["site1"] } });
await expect(requireClockActor(request(), "supervisor", "site1")).rejects.toMatchObject({ status: 403 });
expect(save).toHaveBeenCalledWith("session-token", null);
});
it("rejects cross-origin writes", () => {
expect(() => requireSameOrigin(request("", "https://attacker.example"))).toThrow();
expect(() => requireSameOrigin(request("", "https://mega.example"))).not.toThrow();
});
});

View file

@ -1,90 +0,0 @@
import PocketBase from "pocketbase";
export type ClockRole = "kiosk" | "supervisor";
export interface ClockActor {
id: string;
role: ClockRole;
allowedSites: string[];
}
export class ClockAuthError extends Error {
constructor(message: string, public readonly status: 401 | 403) {
super(message);
}
}
export function clockClient(): PocketBase {
return new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL ?? "http://127.0.0.1:8090");
}
export function clockCookieName(role: ClockRole): string {
return `clock_${role}`;
}
function readCookie(request: Request, name: string): string | undefined {
const pair = request.headers.get("cookie")?.split(";").map((part) => part.trim())
.find((part) => part.startsWith(`${name}=`));
return pair ? decodeURIComponent(pair.slice(name.length + 1)) : undefined;
}
export function requireSameOrigin(request: Request): void {
const origin = request.headers.get("origin");
if (!origin || origin !== new URL(request.url).origin) {
throw new ClockAuthError("Origin is not allowed", 403);
}
}
function actorFromRecord(record: Record<string, unknown>, role: ClockRole): ClockActor {
if (record.role !== role) {
throw new ClockAuthError(`Account does not have the ${role} role`, 403);
}
if (typeof record.id !== "string" || !Array.isArray(record.allowed_sites)) {
throw new ClockAuthError("Clock account has no site assignments", 403);
}
return {
id: record.id,
role,
allowedSites: record.allowed_sites.filter((site): site is string => typeof site === "string"),
};
}
export async function loginClockActor(
email: string,
password: string,
role: ClockRole,
): Promise<{ actor: ClockActor; token: string }> {
const client = clockClient();
let record: Record<string, unknown>;
try {
const auth = await client.collection("users").authWithPassword(email, password);
record = auth.record as Record<string, unknown>;
} catch {
throw new ClockAuthError("Invalid account credentials", 401);
}
const actor = actorFromRecord(record, role);
return { actor, token: client.authStore.token };
}
export async function requireClockActor(
request: Request,
role: ClockRole,
siteId?: string,
): Promise<ClockActor> {
const token = readCookie(request, clockCookieName(role));
if (!token) throw new ClockAuthError("Clock sign-in required", 401);
const client = clockClient();
client.authStore.save(token, null);
let record: Record<string, unknown>;
try {
const refreshed = await client.collection("users").authRefresh();
record = refreshed.record as Record<string, unknown>;
} catch {
throw new ClockAuthError("Clock session expired", 401);
}
const actor = actorFromRecord(record, role);
if (siteId && !actor.allowedSites.includes(siteId)) {
throw new ClockAuthError("Account is not assigned to this site", 403);
}
return actor;
}