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,