diff --git a/app/app/_layout.tsx b/app/app/_layout.tsx index c7c7dca..358a7a4 100644 --- a/app/app/_layout.tsx +++ b/app/app/_layout.tsx @@ -18,16 +18,26 @@ export default function RootLayout() { } function AuthGate() { - const { ready, signedIn } = useAuth(); + const { ready, signedIn, operator } = useAuth(); const router = useRouter(); const segments = useSegments(); useEffect(() => { if (!ready) return; - const onLogin = segments[0] === "login"; - if (!signedIn && !onLogin) router.replace("/login"); - if (signedIn && onLogin) router.replace("/"); - }, [ready, signedIn, segments, router]); + const route = segments[0]; + const onLogin = route === "login"; + const onOperator = route === "operator"; + if (!signedIn) { + if (!onLogin) router.replace("/login"); + return; + } + // Signed in via PIN — require an operator name before using the app. + if (!operator) { + if (!onOperator) router.replace("/operator"); + return; + } + if (onLogin || onOperator) router.replace("/"); + }, [ready, signedIn, operator, segments, router]); if (!ready) { return ( diff --git a/app/app/admin.tsx b/app/app/admin.tsx index 8b5633e..fd9c121 100644 --- a/app/app/admin.tsx +++ b/app/app/admin.tsx @@ -33,6 +33,7 @@ function AuditList({ entries }: { entries: AuditEntry[] }) { {fmtTime(e.at)} · {e.action} · {e.remainingAfter} left + {e.operator ? ` · ${e.operator}` : ""} diff --git a/app/app/index.tsx b/app/app/index.tsx index cc043f6..f845805 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -19,7 +19,7 @@ const MODES: { key: Mode; label: string; icon: string }[] = [ ]; export default function ScannerScreen() { - const { signOut } = useAuth(); + const { signOut, operator } = useAuth(); const [mode, setMode] = useState("tickets"); const [phase, setPhase] = useState("scanning"); const [ticket, setTicket] = useState(null); @@ -155,7 +155,10 @@ export default function ScannerScreen() { return ( - 🐻 Camp Scan + + 🐻 Camp Scan + {!!operator && {operator}} + router.push("/admin")} hitSlop={10}> Admin @@ -428,7 +431,8 @@ const styles = StyleSheet.create({ paddingVertical: 10, }, brand: { color: theme.text, fontSize: 18, fontWeight: "700" }, - topActions: { flexDirection: "row", gap: 18 }, + operator: { color: theme.textDim, fontSize: 13, marginTop: 1 }, + topActions: { flexDirection: "row", gap: 18, alignItems: "center" }, link: { color: theme.textDim, fontSize: 15, fontWeight: "600" }, modeBar: { flexDirection: "row", gap: 8, paddingHorizontal: 12, paddingBottom: 8 }, diff --git a/app/app/login.tsx b/app/app/login.tsx index b03a8d6..624e648 100644 --- a/app/app/login.tsx +++ b/app/app/login.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { StyleSheet, View, Text, Pressable } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { AuthError } from "../lib/api"; @@ -6,115 +6,125 @@ import { useAuth } from "../lib/auth"; import { primeFeedback } from "../lib/feedback"; import { theme } from "../lib/theme"; -const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"]; +const PIN_LENGTH = 4; +// Bottom row: blank / 0 / backspace. +const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "", "0", "back"]; export default function LoginScreen() { const { signIn } = useAuth(); const [pin, setPin] = useState(""); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); + const pinRef = useRef(""); + const busyRef = useRef(false); - async function press(k: string) { - // First tap unlocks web audio playback. - primeFeedback(); - setError(""); - if (k === "clear") return setPin(""); - if (k === "back") return setPin((p) => p.slice(0, -1)); - const next = (pin + k).slice(0, 12); - setPin(next); - } + const submit = useCallback( + async (value: string) => { + busyRef.current = true; + setBusy(true); + setError(""); + try { + await signIn(value); + // The auth gate in _layout navigates once signedIn flips to true. + } catch (e: any) { + setError(e instanceof AuthError ? "Incorrect PIN — try again" : (e?.message ?? "Login failed")); + pinRef.current = ""; // wrong PIN: clear and start over + setPin(""); + } finally { + busyRef.current = false; + setBusy(false); + } + }, + [signIn], + ); - async function submit() { - if (!pin) return; - setBusy(true); - setError(""); - try { - await signIn(pin); - // The auth gate in _layout navigates once signedIn flips to true. - } catch (e: any) { - setError(e instanceof AuthError ? "Incorrect PIN" : (e?.message ?? "Login failed")); - setPin(""); - } finally { - setBusy(false); - } - } + const press = useCallback( + (k: string) => { + if (busyRef.current || k === "") return; + // First tap unlocks web audio playback. + primeFeedback(); + setError(""); + if (k === "back") { + pinRef.current = pinRef.current.slice(0, -1); + setPin(pinRef.current); + return; + } + if (pinRef.current.length >= PIN_LENGTH) return; + pinRef.current += k; + setPin(pinRef.current); + // Auto-submit as soon as the PIN is complete — no button to reach. + if (pinRef.current.length === PIN_LENGTH) submit(pinRef.current); + }, + [submit], + ); return ( - - 🐻 - Camp Scan - Enter the gate PIN + + + 🐻 + Camp Scan + {busy ? "Checking…" : "Enter the gate PIN"} + + + + {Array.from({ length: PIN_LENGTH }).map((_, i) => ( + + ))} + + + {error || " "} + + + {KEYS.map((k, i) => ( + press(k)} + disabled={k === "" || busy} + > + {k === "back" ? "⌫" : k} + + ))} + - - - {Array.from({ length: Math.max(4, pin.length) }).map((_, i) => ( - - ))} - - - {!!error && {error}} - - - {KEYS.map((k) => ( - press(k)} - > - {k === "back" ? "⌫" : k === "clear" ? "C" : k} - - ))} - - - - {busy ? "Signing in…" : "Sign in"} - ); } +const KEY = 72; +const GAP = 16; + const styles = StyleSheet.create({ - root: { flex: 1, backgroundColor: theme.bg, alignItems: "center", paddingHorizontal: 24 }, - header: { alignItems: "center", marginTop: 48 }, - logo: { fontSize: 56 }, - title: { color: theme.text, fontSize: 30, fontWeight: "800", marginTop: 8 }, - subtitle: { color: theme.textDim, fontSize: 16, marginTop: 6 }, - dots: { flexDirection: "row", gap: 14, marginTop: 32, minHeight: 18 }, - dot: { width: 14, height: 14, borderRadius: 7, backgroundColor: theme.cardBorder }, + root: { flex: 1, backgroundColor: theme.bg }, + inner: { flex: 1, alignItems: "center", justifyContent: "center", paddingHorizontal: 24, paddingVertical: 12 }, + header: { alignItems: "center" }, + logo: { fontSize: 44 }, + title: { color: theme.text, fontSize: 26, fontWeight: "800", marginTop: 4 }, + subtitle: { color: theme.textDim, fontSize: 15, marginTop: 4 }, + dots: { flexDirection: "row", gap: 16, marginTop: 20 }, + dot: { width: 15, height: 15, borderRadius: 8, backgroundColor: theme.cardBorder }, dotFilled: { backgroundColor: theme.successBright }, - error: { color: theme.dangerBright, marginTop: 16, fontSize: 15, fontWeight: "600" }, + error: { color: theme.dangerBright, marginTop: 10, marginBottom: 2, fontSize: 15, fontWeight: "600", height: 20 }, + errorHidden: { opacity: 0 }, pad: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", - gap: 16, - marginTop: 28, - maxWidth: 300, + gap: GAP, + marginTop: 8, + width: KEY * 3 + GAP * 2, }, key: { - width: 84, - height: 84, - borderRadius: 42, + width: KEY, + height: KEY, + borderRadius: KEY / 2, backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, alignItems: "center", justifyContent: "center", }, - keyAlt: { backgroundColor: "transparent" }, - keyText: { color: theme.text, fontSize: 30, fontWeight: "600" }, - submit: { - marginTop: 28, - backgroundColor: theme.successBright, - paddingHorizontal: 60, - paddingVertical: 16, - borderRadius: 14, - }, - submitDisabled: { opacity: 0.4 }, - submitText: { color: "#06210f", fontSize: 20, fontWeight: "800" }, + keyAlt: { backgroundColor: "transparent", borderColor: "transparent" }, + keyText: { color: theme.text, fontSize: 28, fontWeight: "600" }, }); diff --git a/app/app/operator.tsx b/app/app/operator.tsx new file mode 100644 index 0000000..41f12c4 --- /dev/null +++ b/app/app/operator.tsx @@ -0,0 +1,96 @@ +import { useState } from "react"; +import { StyleSheet, View, Text, TextInput, Pressable, KeyboardAvoidingView, Platform } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useAuth } from "../lib/auth"; +import { theme } from "../lib/theme"; + +export default function OperatorScreen() { + const { setOperator, signOut } = useAuth(); + const [name, setName] = useState(""); + const [busy, setBusy] = useState(false); + + const submit = async () => { + const trimmed = name.trim(); + if (!trimmed || busy) return; + setBusy(true); + await setOperator(trimmed); + // The auth gate navigates to the scanner once operator is set. + }; + + return ( + + + + 🐻 + Who's scanning? + Your name is recorded with every check-in. + + + + + {busy ? "Starting…" : "Start scanning"} + + + signOut()} hitSlop={10}> + Sign out + + + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: theme.bg }, + flex: { flex: 1 }, + inner: { flex: 1, alignItems: "center", justifyContent: "center", paddingHorizontal: 28 }, + logo: { fontSize: 44 }, + title: { color: theme.text, fontSize: 26, fontWeight: "800", marginTop: 6 }, + subtitle: { color: theme.textDim, fontSize: 15, marginTop: 6, textAlign: "center" }, + input: { + width: "100%", + maxWidth: 340, + backgroundColor: theme.card, + borderWidth: 1, + borderColor: theme.cardBorder, + borderRadius: 14, + paddingHorizontal: 16, + paddingVertical: 16, + color: theme.text, + fontSize: 20, + marginTop: 28, + textAlign: "center", + }, + btn: { + width: "100%", + maxWidth: 340, + backgroundColor: theme.successBright, + paddingVertical: 16, + borderRadius: 14, + marginTop: 18, + alignItems: "center", + }, + btnDisabled: { opacity: 0.4 }, + btnText: { color: "#06210f", fontSize: 20, fontWeight: "800" }, + back: { marginTop: 20, padding: 8 }, + backText: { color: theme.textDim, fontSize: 15 }, +}); diff --git a/app/lib/api.ts b/app/lib/api.ts index e6c7adb..17f2bea 100644 --- a/app/lib/api.ts +++ b/app/lib/api.ts @@ -1,5 +1,5 @@ import { Platform } from "react-native"; -import { loadToken, saveToken, clearToken } from "./storage"; +import { loadToken, saveToken, clearToken, loadOperator, saveOperator, clearOperator } from "./storage"; /** * API base URL. On web the app is served from the same origin as the API, so we @@ -39,6 +39,7 @@ export class AuthError extends Error {} export class ApiError extends Error {} let cachedToken: string | null = null; +let cachedOperator: string | null = null; export async function getToken(): Promise { if (cachedToken) return cachedToken; @@ -46,6 +47,17 @@ export async function getToken(): Promise { return cachedToken; } +export async function getOperator(): Promise { + if (cachedOperator !== null) return cachedOperator; + cachedOperator = await loadOperator(); + return cachedOperator; +} + +export async function setOperator(name: string): Promise { + cachedOperator = name; + await saveOperator(name); +} + export async function login(pin: string): Promise { const res = await fetch(`${API_BASE}/api/auth/login`, { method: "POST", @@ -68,18 +80,22 @@ export function onAuthCleared(cb: (() => void) | null): void { export async function logout(): Promise { cachedToken = null; + cachedOperator = null; await clearToken(); + await clearOperator(); onCleared?.(); } async function authed(path: string, init: RequestInit = {}): Promise { const token = await getToken(); if (!token) throw new AuthError("Not logged in"); + const operator = await getOperator(); const res = await fetch(`${API_BASE}${path}`, { ...init, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, + ...(operator ? { "X-Operator": operator } : {}), ...(init.headers || {}), }, }); @@ -167,9 +183,10 @@ export interface AuditEntry { code: string; people: number; name: string; + operator: string; remainingAfter: number; at: string; - action: "check-in" | "undo"; + action: "check-in" | "undo" | "ice" | "ice-undo"; } export function getAudit(opts: { code?: string; limit?: number } = {}): Promise<{ diff --git a/app/lib/auth.tsx b/app/lib/auth.tsx index 4d903d5..9adc874 100644 --- a/app/lib/auth.tsx +++ b/app/lib/auth.tsx @@ -1,10 +1,19 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; -import { login as apiLogin, logout as apiLogout, getToken, onAuthCleared } from "./api"; +import { + login as apiLogin, + logout as apiLogout, + getToken, + getOperator, + setOperator as apiSetOperator, + onAuthCleared, +} from "./api"; interface AuthState { - ready: boolean; // finished the initial token load - signedIn: boolean; + ready: boolean; // finished the initial load + signedIn: boolean; // has a valid PIN token + operator: string; // gate staff name (empty until set) signIn: (pin: string) => Promise; + setOperator: (name: string) => Promise; signOut: () => Promise; } @@ -13,14 +22,19 @@ const Ctx = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { const [ready, setReady] = useState(false); const [signedIn, setSignedIn] = useState(false); + const [operator, setOperatorState] = useState(""); useEffect(() => { - getToken().then((t) => { + Promise.all([getToken(), getOperator()]).then(([t, op]) => { setSignedIn(!!t); + setOperatorState(op ?? ""); setReady(true); }); // Keep state in sync when the token is cleared elsewhere (401 handling). - onAuthCleared(() => setSignedIn(false)); + onAuthCleared(() => { + setSignedIn(false); + setOperatorState(""); + }); return () => onAuthCleared(null); }, []); @@ -28,12 +42,19 @@ export function AuthProvider({ children }: { children: ReactNode }) { await apiLogin(pin); setSignedIn(true); }; + const setOperator = async (name: string) => { + await apiSetOperator(name); + setOperatorState(name); + }; const signOut = async () => { await apiLogout(); setSignedIn(false); + setOperatorState(""); }; - return {children}; + return ( + {children} + ); } export function useAuth(): AuthState { diff --git a/app/lib/storage.ts b/app/lib/storage.ts index f881d86..fb8dd5e 100644 --- a/app/lib/storage.ts +++ b/app/lib/storage.ts @@ -2,6 +2,7 @@ import { Platform } from "react-native"; // Token persistence: localStorage on web, SecureStore on native. const KEY = "campscan.token"; +const OPERATOR_KEY = "campscan.operator"; export async function saveToken(token: string): Promise { if (Platform.OS === "web") { @@ -40,3 +41,44 @@ export async function clearToken(): Promise { const SecureStore = await import("expo-secure-store"); await SecureStore.deleteItemAsync(KEY); } + +// Operator (gate staff) name — plain persistence, not sensitive. Kept in +// localStorage on both platforms for simplicity (SecureStore is overkill here; +// on native we still use localStorage-less AsyncStorage-free approach below). +export async function saveOperator(name: string): Promise { + if (Platform.OS === "web") { + try { + window.localStorage.setItem(OPERATOR_KEY, name); + } catch { + /* ignore */ + } + return; + } + const SecureStore = await import("expo-secure-store"); + await SecureStore.setItemAsync(OPERATOR_KEY, name); +} + +export async function loadOperator(): Promise { + if (Platform.OS === "web") { + try { + return window.localStorage.getItem(OPERATOR_KEY); + } catch { + return null; + } + } + const SecureStore = await import("expo-secure-store"); + return SecureStore.getItemAsync(OPERATOR_KEY); +} + +export async function clearOperator(): Promise { + if (Platform.OS === "web") { + try { + window.localStorage.removeItem(OPERATOR_KEY); + } catch { + /* ignore */ + } + return; + } + const SecureStore = await import("expo-secure-store"); + await SecureStore.deleteItemAsync(OPERATOR_KEY); +} diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 8411718..c99c2b8 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -87,7 +87,8 @@ export async function ticketRoutes(app: FastifyInstance): Promise { count?: number; resource?: "tickets" | "ice"; }; - return redeem(app.ctx, normalizeCode(code), count ?? 1, resource ?? "tickets"); + const operator = String(req.headers["x-operator"] ?? "").slice(0, 80); + return redeem(app.ctx, normalizeCode(code), count ?? 1, resource ?? "tickets", operator); }, ); diff --git a/backend/src/services/audit.ts b/backend/src/services/audit.ts index 58b80f8..05e3b50 100644 --- a/backend/src/services/audit.ts +++ b/backend/src/services/audit.ts @@ -7,7 +7,8 @@ export const AUDIT_COL = { code: "Ticket Code", people: "People", action: "Action", - name: "Name", + name: "Name", // the ticket holder's name + operator: "Operator", // the gate staff member who performed the action remainingAfter: "Remaining After", } as const; @@ -15,6 +16,7 @@ export interface AuditEntry { code: string; people: number; // positive = checked in, negative = undo name: string; + operator: string; remainingAfter: number; at: string; // ISO action: "check-in" | "undo" | "ice" | "ice-undo"; @@ -51,7 +53,8 @@ export class AuditLogger { async log(entry: AuditEntry): Promise { if (!this.tableId) return; const sign = entry.people >= 0 ? "+" : ""; - const summary = `${entry.code} ${sign}${entry.people} (${entry.action})`; + const who = entry.operator ? ` by ${entry.operator}` : ""; + const summary = `${entry.code} ${sign}${entry.people} (${entry.action})${who}`; try { const res = await fetch(this.url, { method: "POST", @@ -63,6 +66,7 @@ export class AuditLogger { [AUDIT_COL.people]: entry.people, [AUDIT_COL.action]: entry.action, [AUDIT_COL.name]: entry.name, + [AUDIT_COL.operator]: entry.operator, [AUDIT_COL.remainingAfter]: entry.remainingAfter, }), }); @@ -94,9 +98,10 @@ export class AuditLogger { code: r[AUDIT_COL.code] ?? "", people: Number(r[AUDIT_COL.people]) || 0, name: r[AUDIT_COL.name] ?? "", + operator: r[AUDIT_COL.operator] ?? "", remainingAfter: Number(r[AUDIT_COL.remainingAfter]) || 0, at: r[AUDIT_COL.at] ?? r.CreatedAt ?? "", - action: (r[AUDIT_COL.action] ?? "check-in") as "check-in" | "undo", + action: (r[AUDIT_COL.action] ?? "check-in") as "check-in" | "undo" | "ice" | "ice-undo", })); } } diff --git a/backend/src/ticketService.ts b/backend/src/ticketService.ts index 60d27e5..76fa4dc 100644 --- a/backend/src/ticketService.ts +++ b/backend/src/ticketService.ts @@ -60,6 +60,7 @@ export async function redeem( code: string, count: number, resource: Resource = "tickets", + operator = "", ): Promise { const n = Math.trunc(count); if (!Number.isFinite(n) || n === 0) { @@ -106,6 +107,7 @@ export async function redeem( code: view.code, people: delta, name: view.name, + operator, remainingAfter, at: new Date().toISOString(), action: delta >= 0 ? cfg.auditIn : cfg.auditUndo,