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() {
|
||||
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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
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 { 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<{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue