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:
parent
336d2a5c83
commit
0e8fe3bb9a
11 changed files with 311 additions and 102 deletions
|
|
@ -18,16 +18,26 @@ export default function RootLayout() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function AuthGate() {
|
function AuthGate() {
|
||||||
const { ready, signedIn } = useAuth();
|
const { ready, signedIn, operator } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ready) return;
|
if (!ready) return;
|
||||||
const onLogin = segments[0] === "login";
|
const route = segments[0];
|
||||||
if (!signedIn && !onLogin) router.replace("/login");
|
const onLogin = route === "login";
|
||||||
if (signedIn && onLogin) router.replace("/");
|
const onOperator = route === "operator";
|
||||||
}, [ready, signedIn, segments, router]);
|
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) {
|
if (!ready) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ function AuditList({ entries }: { entries: AuditEntry[] }) {
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.auditMeta}>
|
<Text style={styles.auditMeta}>
|
||||||
{fmtTime(e.at)} · {e.action} · {e.remainingAfter} left
|
{fmtTime(e.at)} · {e.action} · {e.remainingAfter} left
|
||||||
|
{e.operator ? ` · ${e.operator}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const MODES: { key: Mode; label: string; icon: string }[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ScannerScreen() {
|
export default function ScannerScreen() {
|
||||||
const { signOut } = useAuth();
|
const { signOut, operator } = useAuth();
|
||||||
const [mode, setMode] = useState<Mode>("tickets");
|
const [mode, setMode] = useState<Mode>("tickets");
|
||||||
const [phase, setPhase] = useState<Phase>("scanning");
|
const [phase, setPhase] = useState<Phase>("scanning");
|
||||||
const [ticket, setTicket] = useState<TicketView | null>(null);
|
const [ticket, setTicket] = useState<TicketView | null>(null);
|
||||||
|
|
@ -155,7 +155,10 @@ export default function ScannerScreen() {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
|
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
|
||||||
<View style={styles.topbar}>
|
<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}>
|
<View style={styles.topActions}>
|
||||||
<Pressable onPress={() => router.push("/admin")} hitSlop={10}>
|
<Pressable onPress={() => router.push("/admin")} hitSlop={10}>
|
||||||
<Text style={styles.link}>Admin</Text>
|
<Text style={styles.link}>Admin</Text>
|
||||||
|
|
@ -428,7 +431,8 @@ const styles = StyleSheet.create({
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
},
|
},
|
||||||
brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
|
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" },
|
link: { color: theme.textDim, fontSize: 15, fontWeight: "600" },
|
||||||
|
|
||||||
modeBar: { flexDirection: "row", gap: 8, paddingHorizontal: 12, paddingBottom: 8 },
|
modeBar: { flexDirection: "row", gap: 8, paddingHorizontal: 12, paddingBottom: 8 },
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { StyleSheet, View, Text, Pressable } from "react-native";
|
import { StyleSheet, View, Text, Pressable } from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { AuthError } from "../lib/api";
|
import { AuthError } from "../lib/api";
|
||||||
|
|
@ -6,115 +6,125 @@ import { useAuth } from "../lib/auth";
|
||||||
import { primeFeedback } from "../lib/feedback";
|
import { primeFeedback } from "../lib/feedback";
|
||||||
import { theme } from "../lib/theme";
|
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() {
|
export default function LoginScreen() {
|
||||||
const { signIn } = useAuth();
|
const { signIn } = useAuth();
|
||||||
const [pin, setPin] = useState("");
|
const [pin, setPin] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const pinRef = useRef("");
|
||||||
|
const busyRef = useRef(false);
|
||||||
|
|
||||||
async function press(k: string) {
|
const submit = useCallback(
|
||||||
// First tap unlocks web audio playback.
|
async (value: string) => {
|
||||||
primeFeedback();
|
busyRef.current = true;
|
||||||
setError("");
|
setBusy(true);
|
||||||
if (k === "clear") return setPin("");
|
setError("");
|
||||||
if (k === "back") return setPin((p) => p.slice(0, -1));
|
try {
|
||||||
const next = (pin + k).slice(0, 12);
|
await signIn(value);
|
||||||
setPin(next);
|
// 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() {
|
const press = useCallback(
|
||||||
if (!pin) return;
|
(k: string) => {
|
||||||
setBusy(true);
|
if (busyRef.current || k === "") return;
|
||||||
setError("");
|
// First tap unlocks web audio playback.
|
||||||
try {
|
primeFeedback();
|
||||||
await signIn(pin);
|
setError("");
|
||||||
// The auth gate in _layout navigates once signedIn flips to true.
|
if (k === "back") {
|
||||||
} catch (e: any) {
|
pinRef.current = pinRef.current.slice(0, -1);
|
||||||
setError(e instanceof AuthError ? "Incorrect PIN" : (e?.message ?? "Login failed"));
|
setPin(pinRef.current);
|
||||||
setPin("");
|
return;
|
||||||
} finally {
|
}
|
||||||
setBusy(false);
|
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 (
|
return (
|
||||||
<SafeAreaView style={styles.root}>
|
<SafeAreaView style={styles.root}>
|
||||||
<View style={styles.header}>
|
<View style={styles.inner}>
|
||||||
<Text style={styles.logo}>🐻</Text>
|
<View style={styles.header}>
|
||||||
<Text style={styles.title}>Camp Scan</Text>
|
<Text style={styles.logo}>🐻</Text>
|
||||||
<Text style={styles.subtitle}>Enter the gate PIN</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>
|
||||||
|
|
||||||
<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>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const KEY = 72;
|
||||||
|
const GAP = 16;
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
root: { flex: 1, backgroundColor: theme.bg, alignItems: "center", paddingHorizontal: 24 },
|
root: { flex: 1, backgroundColor: theme.bg },
|
||||||
header: { alignItems: "center", marginTop: 48 },
|
inner: { flex: 1, alignItems: "center", justifyContent: "center", paddingHorizontal: 24, paddingVertical: 12 },
|
||||||
logo: { fontSize: 56 },
|
header: { alignItems: "center" },
|
||||||
title: { color: theme.text, fontSize: 30, fontWeight: "800", marginTop: 8 },
|
logo: { fontSize: 44 },
|
||||||
subtitle: { color: theme.textDim, fontSize: 16, marginTop: 6 },
|
title: { color: theme.text, fontSize: 26, fontWeight: "800", marginTop: 4 },
|
||||||
dots: { flexDirection: "row", gap: 14, marginTop: 32, minHeight: 18 },
|
subtitle: { color: theme.textDim, fontSize: 15, marginTop: 4 },
|
||||||
dot: { width: 14, height: 14, borderRadius: 7, backgroundColor: theme.cardBorder },
|
dots: { flexDirection: "row", gap: 16, marginTop: 20 },
|
||||||
|
dot: { width: 15, height: 15, borderRadius: 8, backgroundColor: theme.cardBorder },
|
||||||
dotFilled: { backgroundColor: theme.successBright },
|
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: {
|
pad: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
gap: 16,
|
gap: GAP,
|
||||||
marginTop: 28,
|
marginTop: 8,
|
||||||
maxWidth: 300,
|
width: KEY * 3 + GAP * 2,
|
||||||
},
|
},
|
||||||
key: {
|
key: {
|
||||||
width: 84,
|
width: KEY,
|
||||||
height: 84,
|
height: KEY,
|
||||||
borderRadius: 42,
|
borderRadius: KEY / 2,
|
||||||
backgroundColor: theme.card,
|
backgroundColor: theme.card,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.cardBorder,
|
borderColor: theme.cardBorder,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
keyAlt: { backgroundColor: "transparent" },
|
keyAlt: { backgroundColor: "transparent", borderColor: "transparent" },
|
||||||
keyText: { color: theme.text, fontSize: 30, fontWeight: "600" },
|
keyText: { color: theme.text, fontSize: 28, 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" },
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
96
app/app/operator.tsx
Normal file
96
app/app/operator.tsx
Normal 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 },
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Platform } from "react-native";
|
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
|
* 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 {}
|
export class ApiError extends Error {}
|
||||||
|
|
||||||
let cachedToken: string | null = null;
|
let cachedToken: string | null = null;
|
||||||
|
let cachedOperator: string | null = null;
|
||||||
|
|
||||||
export async function getToken(): Promise<string | null> {
|
export async function getToken(): Promise<string | null> {
|
||||||
if (cachedToken) return cachedToken;
|
if (cachedToken) return cachedToken;
|
||||||
|
|
@ -46,6 +47,17 @@ export async function getToken(): Promise<string | null> {
|
||||||
return cachedToken;
|
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> {
|
export async function login(pin: string): Promise<void> {
|
||||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -68,18 +80,22 @@ export function onAuthCleared(cb: (() => void) | null): void {
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
cachedToken = null;
|
cachedToken = null;
|
||||||
|
cachedOperator = null;
|
||||||
await clearToken();
|
await clearToken();
|
||||||
|
await clearOperator();
|
||||||
onCleared?.();
|
onCleared?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function authed<T>(path: string, init: RequestInit = {}): Promise<T> {
|
async function authed<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
if (!token) throw new AuthError("Not logged in");
|
if (!token) throw new AuthError("Not logged in");
|
||||||
|
const operator = await getOperator();
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
|
...(operator ? { "X-Operator": operator } : {}),
|
||||||
...(init.headers || {}),
|
...(init.headers || {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -167,9 +183,10 @@ export interface AuditEntry {
|
||||||
code: string;
|
code: string;
|
||||||
people: number;
|
people: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
operator: string;
|
||||||
remainingAfter: number;
|
remainingAfter: number;
|
||||||
at: string;
|
at: string;
|
||||||
action: "check-in" | "undo";
|
action: "check-in" | "undo" | "ice" | "ice-undo";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAudit(opts: { code?: string; limit?: number } = {}): Promise<{
|
export function getAudit(opts: { code?: string; limit?: number } = {}): Promise<{
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,19 @@
|
||||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
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 {
|
interface AuthState {
|
||||||
ready: boolean; // finished the initial token load
|
ready: boolean; // finished the initial load
|
||||||
signedIn: boolean;
|
signedIn: boolean; // has a valid PIN token
|
||||||
|
operator: string; // gate staff name (empty until set)
|
||||||
signIn: (pin: string) => Promise<void>;
|
signIn: (pin: string) => Promise<void>;
|
||||||
|
setOperator: (name: string) => Promise<void>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -13,14 +22,19 @@ const Ctx = createContext<AuthState | null>(null);
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [signedIn, setSignedIn] = useState(false);
|
const [signedIn, setSignedIn] = useState(false);
|
||||||
|
const [operator, setOperatorState] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getToken().then((t) => {
|
Promise.all([getToken(), getOperator()]).then(([t, op]) => {
|
||||||
setSignedIn(!!t);
|
setSignedIn(!!t);
|
||||||
|
setOperatorState(op ?? "");
|
||||||
setReady(true);
|
setReady(true);
|
||||||
});
|
});
|
||||||
// Keep state in sync when the token is cleared elsewhere (401 handling).
|
// Keep state in sync when the token is cleared elsewhere (401 handling).
|
||||||
onAuthCleared(() => setSignedIn(false));
|
onAuthCleared(() => {
|
||||||
|
setSignedIn(false);
|
||||||
|
setOperatorState("");
|
||||||
|
});
|
||||||
return () => onAuthCleared(null);
|
return () => onAuthCleared(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -28,12 +42,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
await apiLogin(pin);
|
await apiLogin(pin);
|
||||||
setSignedIn(true);
|
setSignedIn(true);
|
||||||
};
|
};
|
||||||
|
const setOperator = async (name: string) => {
|
||||||
|
await apiSetOperator(name);
|
||||||
|
setOperatorState(name);
|
||||||
|
};
|
||||||
const signOut = async () => {
|
const signOut = async () => {
|
||||||
await apiLogout();
|
await apiLogout();
|
||||||
setSignedIn(false);
|
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 {
|
export function useAuth(): AuthState {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Platform } from "react-native";
|
||||||
|
|
||||||
// Token persistence: localStorage on web, SecureStore on native.
|
// Token persistence: localStorage on web, SecureStore on native.
|
||||||
const KEY = "campscan.token";
|
const KEY = "campscan.token";
|
||||||
|
const OPERATOR_KEY = "campscan.operator";
|
||||||
|
|
||||||
export async function saveToken(token: string): Promise<void> {
|
export async function saveToken(token: string): Promise<void> {
|
||||||
if (Platform.OS === "web") {
|
if (Platform.OS === "web") {
|
||||||
|
|
@ -40,3 +41,44 @@ export async function clearToken(): Promise<void> {
|
||||||
const SecureStore = await import("expo-secure-store");
|
const SecureStore = await import("expo-secure-store");
|
||||||
await SecureStore.deleteItemAsync(KEY);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,8 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
|
||||||
count?: number;
|
count?: number;
|
||||||
resource?: "tickets" | "ice";
|
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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ export const AUDIT_COL = {
|
||||||
code: "Ticket Code",
|
code: "Ticket Code",
|
||||||
people: "People",
|
people: "People",
|
||||||
action: "Action",
|
action: "Action",
|
||||||
name: "Name",
|
name: "Name", // the ticket holder's name
|
||||||
|
operator: "Operator", // the gate staff member who performed the action
|
||||||
remainingAfter: "Remaining After",
|
remainingAfter: "Remaining After",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
@ -15,6 +16,7 @@ export interface AuditEntry {
|
||||||
code: string;
|
code: string;
|
||||||
people: number; // positive = checked in, negative = undo
|
people: number; // positive = checked in, negative = undo
|
||||||
name: string;
|
name: string;
|
||||||
|
operator: string;
|
||||||
remainingAfter: number;
|
remainingAfter: number;
|
||||||
at: string; // ISO
|
at: string; // ISO
|
||||||
action: "check-in" | "undo" | "ice" | "ice-undo";
|
action: "check-in" | "undo" | "ice" | "ice-undo";
|
||||||
|
|
@ -51,7 +53,8 @@ export class AuditLogger {
|
||||||
async log(entry: AuditEntry): Promise<void> {
|
async log(entry: AuditEntry): Promise<void> {
|
||||||
if (!this.tableId) return;
|
if (!this.tableId) return;
|
||||||
const sign = entry.people >= 0 ? "+" : "";
|
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 {
|
try {
|
||||||
const res = await fetch(this.url, {
|
const res = await fetch(this.url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -63,6 +66,7 @@ export class AuditLogger {
|
||||||
[AUDIT_COL.people]: entry.people,
|
[AUDIT_COL.people]: entry.people,
|
||||||
[AUDIT_COL.action]: entry.action,
|
[AUDIT_COL.action]: entry.action,
|
||||||
[AUDIT_COL.name]: entry.name,
|
[AUDIT_COL.name]: entry.name,
|
||||||
|
[AUDIT_COL.operator]: entry.operator,
|
||||||
[AUDIT_COL.remainingAfter]: entry.remainingAfter,
|
[AUDIT_COL.remainingAfter]: entry.remainingAfter,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
@ -94,9 +98,10 @@ export class AuditLogger {
|
||||||
code: r[AUDIT_COL.code] ?? "",
|
code: r[AUDIT_COL.code] ?? "",
|
||||||
people: Number(r[AUDIT_COL.people]) || 0,
|
people: Number(r[AUDIT_COL.people]) || 0,
|
||||||
name: r[AUDIT_COL.name] ?? "",
|
name: r[AUDIT_COL.name] ?? "",
|
||||||
|
operator: r[AUDIT_COL.operator] ?? "",
|
||||||
remainingAfter: Number(r[AUDIT_COL.remainingAfter]) || 0,
|
remainingAfter: Number(r[AUDIT_COL.remainingAfter]) || 0,
|
||||||
at: r[AUDIT_COL.at] ?? r.CreatedAt ?? "",
|
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",
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ export async function redeem(
|
||||||
code: string,
|
code: string,
|
||||||
count: number,
|
count: number,
|
||||||
resource: Resource = "tickets",
|
resource: Resource = "tickets",
|
||||||
|
operator = "",
|
||||||
): Promise<RedeemResult> {
|
): Promise<RedeemResult> {
|
||||||
const n = Math.trunc(count);
|
const n = Math.trunc(count);
|
||||||
if (!Number.isFinite(n) || n === 0) {
|
if (!Number.isFinite(n) || n === 0) {
|
||||||
|
|
@ -106,6 +107,7 @@ export async function redeem(
|
||||||
code: view.code,
|
code: view.code,
|
||||||
people: delta,
|
people: delta,
|
||||||
name: view.name,
|
name: view.name,
|
||||||
|
operator,
|
||||||
remainingAfter,
|
remainingAfter,
|
||||||
at: new Date().toISOString(),
|
at: new Date().toISOString(),
|
||||||
action: delta >= 0 ? cfg.auditIn : cfg.auditUndo,
|
action: delta >= 0 ? cfg.auditIn : cfg.auditUndo,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue