diff --git a/src/app/api/clock/session/route.test.ts b/src/app/api/clock/session/route.test.ts deleted file mode 100644 index 3551227..0000000 --- a/src/app/api/clock/session/route.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/app/api/clock/session/route.ts b/src/app/api/clock/session/route.ts deleted file mode 100644 index 222382d..0000000 --- a/src/app/api/clock/session/route.ts +++ /dev/null @@ -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; - 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); - } -} diff --git a/src/app/clock/clock-view.tsx b/src/app/clock/clock-view.tsx index 533886b..9851965 100644 --- a/src/app/clock/clock-view.tsx +++ b/src/app/clock/clock-view.tsx @@ -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("person"); const [now, setNow] = React.useState(() => new Date()); const [scanCount, setScanCount] = React.useState(0); - const [kioskReady, setKioskReady] = React.useState(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[] }) {
- {tab === "person" && kioskReady === null && ( -
Checking clock sign-in…
- )} - {tab === "person" && kioskReady === false && setKioskReady(true)} />} - {tab === "person" && kioskReady === true && setScanCount((c) => c + 1)} />} + {tab === "person" && setScanCount((c) => c + 1)} />} {tab === "fleet" && 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} ); -} +} \ No newline at end of file diff --git a/src/app/clock/kiosk-session.tsx b/src/app/clock/kiosk-session.tsx deleted file mode 100644 index d6cfb2f..0000000 --- a/src/app/clock/kiosk-session.tsx +++ /dev/null @@ -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) { - 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 ( -
-
-
-

Sign in this clock phone

-

A kiosk account signs in once for the shift. Workers still use their badges.

-
- - - {error &&

{error}

} - -
-
- ); -} diff --git a/src/lib/clock-auth.test.ts b/src/lib/clock-auth.test.ts deleted file mode 100644 index 2c724f3..0000000 --- a/src/lib/clock-auth.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/src/lib/clock-auth.ts b/src/lib/clock-auth.ts deleted file mode 100644 index 24392f6..0000000 --- a/src/lib/clock-auth.ts +++ /dev/null @@ -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, 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; - try { - const auth = await client.collection("users").authWithPassword(email, password); - record = auth.record as Record; - } 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 { - 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; - try { - const refreshed = await client.collection("users").authRefresh(); - record = refreshed.record as Record; - } 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; -}