4-digit auto-submit PIN + operator name in audit logs

Login: fixed-length 4-digit PIN that auto-submits on the 4th digit (no submit
button to scroll to on small iPhone screens) and clears on a wrong PIN.
Compact, vertically-centered keypad so it fits without scrolling.

Operator tracking: after PIN auth, staff enter their name (new /operator
screen, persisted per device). The name is sent as X-Operator on every authed
request and recorded on each check-in/undo/ice audit entry (new Operator
column), so logs show who did what. Shown in the scanner header and the admin
audit view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-08 16:45:23 +00:00
parent 336d2a5c83
commit 0e8fe3bb9a
11 changed files with 311 additions and 102 deletions

View file

@ -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 (

View file

@ -33,6 +33,7 @@ function AuditList({ entries }: { entries: AuditEntry[] }) {
</Text>
<Text style={styles.auditMeta}>
{fmtTime(e.at)} · {e.action} · {e.remainingAfter} left
{e.operator ? ` · ${e.operator}` : ""}
</Text>
</View>
</View>

View file

@ -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<Mode>("tickets");
const [phase, setPhase] = useState<Phase>("scanning");
const [ticket, setTicket] = useState<TicketView | null>(null);
@ -155,7 +155,10 @@ export default function ScannerScreen() {
return (
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
<View style={styles.topbar}>
<Text style={styles.brand}>🐻 Camp Scan</Text>
<View>
<Text style={styles.brand}>🐻 Camp Scan</Text>
{!!operator && <Text style={styles.operator}>{operator}</Text>}
</View>
<View style={styles.topActions}>
<Pressable onPress={() => router.push("/admin")} hitSlop={10}>
<Text style={styles.link}>Admin</Text>
@ -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 },

View file

@ -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 (
<SafeAreaView style={styles.root}>
<View style={styles.header}>
<Text style={styles.logo}>🐻</Text>
<Text style={styles.title}>Camp Scan</Text>
<Text style={styles.subtitle}>Enter the gate PIN</Text>
<View style={styles.inner}>
<View style={styles.header}>
<Text style={styles.logo}>🐻</Text>
<Text style={styles.title}>Camp Scan</Text>
<Text style={styles.subtitle}>{busy ? "Checking…" : "Enter the gate PIN"}</Text>
</View>
<View style={styles.dots}>
{Array.from({ length: PIN_LENGTH }).map((_, i) => (
<View key={i} style={[styles.dot, i < pin.length && styles.dotFilled]} />
))}
</View>
<Text style={[styles.error, !error && styles.errorHidden]}>{error || " "}</Text>
<View style={styles.pad}>
{KEYS.map((k, i) => (
<Pressable
key={i}
style={[styles.key, (k === "" || k === "back") && styles.keyAlt]}
onPress={() => press(k)}
disabled={k === "" || busy}
>
<Text style={styles.keyText}>{k === "back" ? "⌫" : k}</Text>
</Pressable>
))}
</View>
</View>
<View style={styles.dots}>
{Array.from({ length: Math.max(4, pin.length) }).map((_, i) => (
<View key={i} style={[styles.dot, i < pin.length && styles.dotFilled]} />
))}
</View>
{!!error && <Text style={styles.error}>{error}</Text>}
<View style={styles.pad}>
{KEYS.map((k) => (
<Pressable
key={k}
style={[styles.key, (k === "clear" || k === "back") && styles.keyAlt]}
onPress={() => press(k)}
>
<Text style={styles.keyText}>{k === "back" ? "⌫" : k === "clear" ? "C" : k}</Text>
</Pressable>
))}
</View>
<Pressable
style={[styles.submit, (busy || !pin) && styles.submitDisabled]}
onPress={submit}
disabled={busy || !pin}
>
<Text style={styles.submitText}>{busy ? "Signing in…" : "Sign in"}</Text>
</Pressable>
</SafeAreaView>
);
}
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" },
});

96
app/app/operator.tsx Normal file
View file

@ -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 (
<SafeAreaView style={styles.root}>
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<View style={styles.inner}>
<Text style={styles.logo}>🐻</Text>
<Text style={styles.title}>Who's scanning?</Text>
<Text style={styles.subtitle}>Your name is recorded with every check-in.</Text>
<TextInput
style={styles.input}
placeholder="Your name"
placeholderTextColor={theme.textDim}
value={name}
onChangeText={setName}
autoCapitalize="words"
autoCorrect={false}
autoFocus
returnKeyType="go"
onSubmitEditing={submit}
maxLength={60}
/>
<Pressable
style={[styles.btn, (busy || !name.trim()) && styles.btnDisabled]}
onPress={submit}
disabled={busy || !name.trim()}
>
<Text style={styles.btnText}>{busy ? "Starting…" : "Start scanning"}</Text>
</Pressable>
<Pressable style={styles.back} onPress={() => signOut()} hitSlop={10}>
<Text style={styles.backText}>Sign out</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
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 },
});

View file

@ -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<string | null> {
if (cachedToken) return cachedToken;
@ -46,6 +47,17 @@ export async function getToken(): Promise<string | null> {
return cachedToken;
}
export async function getOperator(): Promise<string | null> {
if (cachedOperator !== null) return cachedOperator;
cachedOperator = await loadOperator();
return cachedOperator;
}
export async function setOperator(name: string): Promise<void> {
cachedOperator = name;
await saveOperator(name);
}
export async function login(pin: string): Promise<void> {
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<void> {
cachedToken = null;
cachedOperator = null;
await clearToken();
await clearOperator();
onCleared?.();
}
async function authed<T>(path: string, init: RequestInit = {}): Promise<T> {
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<{

View file

@ -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<void>;
setOperator: (name: string) => Promise<void>;
signOut: () => Promise<void>;
}
@ -13,14 +22,19 @@ const Ctx = createContext<AuthState | null>(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 <Ctx.Provider value={{ ready, signedIn, signIn, signOut }}>{children}</Ctx.Provider>;
return (
<Ctx.Provider value={{ ready, signedIn, operator, signIn, setOperator, signOut }}>{children}</Ctx.Provider>
);
}
export function useAuth(): AuthState {

View file

@ -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<void> {
if (Platform.OS === "web") {
@ -40,3 +41,44 @@ export async function clearToken(): Promise<void> {
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<void> {
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<string | null> {
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<void> {
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);
}

View file

@ -87,7 +87,8 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
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);
},
);

View file

@ -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<void> {
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",
}));
}
}

View file

@ -60,6 +60,7 @@ export async function redeem(
code: string,
count: number,
resource: Resource = "tickets",
operator = "",
): Promise<RedeemResult> {
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,