From 103c834f7b7f942142c478731bda34cf16b0d5fa Mon Sep 17 00:00:00 2001 From: DigitalBlueprint239 Date: Thu, 19 Mar 2026 09:18:54 -0400 Subject: [PATCH 01/13] Refactor error handling and improve UI messagesfix(security): remove public error leakage from Register.tsx --- src/pages/Register.tsx | 674 +++++++++++++++++++++++++---------------- 1 file changed, 405 insertions(+), 269 deletions(-) diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 28fdb06..6e6b5b0 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -7,6 +7,21 @@ import { ChevronRight, CheckCircle2, AlertCircle, ArrowLeft } from 'lucide-react import { v4 as uuidv4 } from 'uuid'; import { Link } from 'react-router-dom'; +// --------------------------------------------------------------------------- +// Public-safe error message shown to unauthenticated users. +// Technical details are intentionally omitted from the UI and sent to the +// console only in development mode. +// --------------------------------------------------------------------------- +const PUBLIC_ERROR_MESSAGE = "We're having trouble right now."; +const PUBLIC_ERROR_BODY = + 'Please try again in a moment. If the problem continues, ask event staff for help.'; + +function logDevError(context: string, err: unknown) { + if (import.meta.env.DEV) { + console.error(`[Register] ${context}`, err); + } +} + export default function Register() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); @@ -14,7 +29,8 @@ export default function Register() { const [event, setEvent] = useState(null); const [availableEvents, setAvailableEvents] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState<{ message: string; details?: string } | null>(null); + // `message` is always public-safe. `details` is NEVER rendered in the UI. + const [error, setError] = useState<{ message: string } | null>(null); const [formData, setFormData] = useState({ firstName: '', @@ -41,10 +57,10 @@ export default function Register() { async function resolveEvent() { setLoading(true); setError(null); - + try { const paramSlug = searchParams.get('event') || searchParams.get('slug'); - + // 1. Try to fetch by slug if provided if (paramSlug) { const { data: slugEvent, error: slugError } = await supabase @@ -52,15 +68,10 @@ export default function Register() { .select('*') .eq('slug', paramSlug) .maybeSingle(); - + if (slugError) { - const isTableMissing = slugError.message.includes('Could not find the table') || slugError.code === '42P01'; - setError({ - message: isTableMissing ? 'Database not initialized.' : 'Database error while looking up event.', - details: isTableMissing - ? 'The "events" table is missing from the database. Please run the schema migrations in Supabase.' - : `RLS or Connection Error: ${slugError.message}` - }); + logDevError('resolveEvent › slug lookup', slugError); + setError({ message: PUBLIC_ERROR_MESSAGE }); setLoading(false); return; } @@ -81,13 +92,8 @@ export default function Register() { .limit(1); if (liveError) { - const isTableMissing = liveError.message.includes('Could not find the table') || liveError.code === '42P01'; - setError({ - message: isTableMissing ? 'Database not initialized.' : 'Error fetching active events.', - details: isTableMissing - ? 'The "events" table is missing from the database. Please run the schema migrations in Supabase.' - : `RLS or Connection Error: ${liveError.message}` - }); + logDevError('resolveEvent › live event lookup', liveError); + setError({ message: PUBLIC_ERROR_MESSAGE }); setLoading(false); return; } @@ -106,23 +112,19 @@ export default function Register() { .order('created_at', { ascending: true }); if (allError) { - const isTableMissing = allError.message.includes('Could not find the table') || allError.code === '42P01'; - setError({ - message: isTableMissing ? 'Database not initialized.' : 'Error fetching event list.', - details: isTableMissing - ? 'The "events" table is missing from the database. Please run the schema migrations in Supabase.' - : `RLS or Connection Error: ${allError.message}` - }); + logDevError('resolveEvent › all events lookup', allError); + setError({ message: PUBLIC_ERROR_MESSAGE }); } else if (allEvents && allEvents.length > 0) { setAvailableEvents(allEvents); } else { - setError({ - message: 'No events are currently scheduled.', - details: 'The events table is empty or no events are in "live" or "draft" status.' + // No events scheduled — friendly, non-technical message + setError({ + message: 'Registration is not open yet. Please check back closer to the event date.', }); } - } catch (err: any) { - setError({ message: 'An unexpected error occurred.', details: err.message }); + } catch (err: unknown) { + logDevError('resolveEvent › unexpected', err); + setError({ message: PUBLIC_ERROR_MESSAGE }); } finally { setLoading(false); } @@ -181,7 +183,7 @@ export default function Register() { media_release: formData.mediaRelease, data_consent: formData.dataConsent, marketing_consent: formData.marketingConsent, - waiver_version: '2026.1' + waiver_version: '2026.1', }); if (waiverError) throw waiverError; @@ -203,19 +205,22 @@ export default function Register() { if (tokenError) throw tokenError; // 4. Create Parent Portal Token - const portalToken = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); - await supabase - .from('parent_portals') - .insert({ - athlete_id: athlete.id, - event_id: event.id, - portal_token: portalToken - }); + const portalToken = + Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); + await supabase.from('parent_portals').insert({ + athlete_id: athlete.id, + event_id: event.id, + portal_token: portalToken, + }); // Success! navigate(`/claim-band?athleteToken=${token}`); - } catch (err: any) { - setError({ message: 'Registration failed.', details: err.message }); + } catch (err: unknown) { + logDevError('handleSubmit', err); + setError({ + message: 'Registration could not be completed. Please try again or ask event staff for help.', + }); setLoading(false); } }; @@ -236,12 +241,14 @@ export default function Register() { return (
-

Choose Your Event

+

+ Choose Your Event +

Select an upcoming combine to begin your registration.

- {availableEvents.map(ev => ( + {availableEvents.map((ev) => ( @@ -267,317 +276,444 @@ export default function Register() { ); } + // ------------------------------------------------------------------------- + // Public-safe error screen. + // Shows a friendly message only — no DB errors, table names, or migration + // instructions are ever rendered here. + // ------------------------------------------------------------------------- if (error && !event) { return ( -
-
+
+
-

{error.message}

-

{error.details}

+

{error.message}

+

{PUBLIC_ERROR_BODY}

-
- -
- {import.meta.env.DEV && ( -
-

Admin Troubleshooting

-
    -
  • Check if an event exists in the events table.
  • -
  • Ensure the event status is set to 'live' or 'draft'.
  • -
  • Verify RLS policies allow public SELECT on events.
  • -
  • Visit
  • -
-
- )}
); } + // ------------------------------------------------------------------------- + // Inline submission error banner (shown mid-form after a failed handleSubmit) + // ------------------------------------------------------------------------- + const InlineErrorBanner = () => + error ? ( + + +
+

{error.message}

+

{PUBLIC_ERROR_BODY}

+
+
+ ) : null; + + // ------------------------------------------------------------------------- + // Main multi-step registration form (unchanged structure) + // ------------------------------------------------------------------------- return (
-
- + Back to Home -
- = 1 ? "text-zinc-900 font-bold" : ""}>1. Profile - - = 2 ? "text-zinc-900 font-bold" : ""}>2. Waiver - - = 3 ? "text-zinc-900 font-bold" : ""}>3. Claim Band -
-
-

- Athlete Registration -

-

{event?.name} • {event?.location}

+ {event && ( +
+
+ + {event.status} + +
+

+ {event.name} +

+

{event.location}

+
+ )} + + {/* Step progress indicator */} +
+ {[1, 2, 3].map((s) => ( + +
= s + ? 'bg-zinc-900 text-white' + : 'bg-zinc-100 text-zinc-400' + }`} + > + {step > s ? : s} +
+ {s < 3 && ( +
s ? 'bg-zinc-900' : 'bg-zinc-200' + }`} + /> + )} + + ))}
+ + + {/* Step 1 — Athlete Info */} {step === 1 && ( - +

Athlete Information

+
- - + First Name * + +
- - + Last Name * + +
+
+ + + {dateOfBirthError && ( +

{dateOfBirthError}

+ )} +
+
- - + Grade + + - - - - - - - - - + + {['6th', '7th', '8th', '9th', '10th', '11th', '12th'].map((g) => ( + + ))}
-
- -
-

Parent/Guardian Information

- - + Position + + -
-
- - -
+ className="w-full p-3 bg-zinc-50 border border-zinc-200 rounded-xl outline-none focus:border-zinc-900 transition-colors" + > + + {['QB', 'RB', 'WR', 'TE', 'OL', 'DL', 'LB', 'DB', 'K/P', 'ATH'].map((p) => ( + + ))} +
- )} + {/* Step 2 — Parent / Guardian Info */} {step === 2 && ( - -
-

Release of Liability & Consent

-

I, the undersigned parent/guardian, hereby give permission for the athlete named above to participate in the Core Elite Combine 2026. I understand that athletic testing involves inherent risks of injury.

-

I release Core Elite and its staff from any and all liability for injuries sustained during the event. I also consent to the use of any photos or videos taken during the event for promotional purposes.

-

In case of emergency, I authorize the staff to seek medical attention for the athlete if I cannot be reached immediately.

-
-
- - - - -
- -
- - -
-
- - -
-
+
+ +

Parent / Guardian

-
- - { - setSignature(url); - setError(null); - }} onClear={() => setSignature(null)} /> +
+ +
- {error && ( -
- -
-
{error.message}
- {error.details &&
{error.details}
} -
+
+
+ +
- )} +
+ + +
+
-
- + + )} + + {/* Step 3 — Waiver & Signature */} + {step === 3 && ( + +
+ +

Waiver & Consent

+
+ +
+

Participation Waiver — Core Elite Combine 2026

+

+ By signing below, I acknowledge that participation in athletic combine activities + involves inherent risks of injury. I voluntarily assume all risks associated with + participation and release Core Elite and its organizers from any liability. +

+

+ I consent to emergency medical treatment if necessary. I grant permission for + photographs and video taken during the event to be used for promotional purposes + unless I opt out below. +

+ +
+ {[ + { name: 'injuryWaiverAck', label: 'I acknowledge the injury waiver and assume all risks *' }, + { name: 'dataConsent', label: "I consent to my athlete's data being stored and used for combine results *" }, + { name: 'mediaRelease', label: 'I grant media release permission for photos/video (optional)' }, + { name: 'marketingConsent', label: 'I agree to receive event updates and marketing communications (optional)' }, + ].map(({ name, label }) => ( + + ))} +
+ +
+ + + {signature && ( +

+ Signature captured +

+ )} +
+ +
)}
); -} + } From de9e7debd5c1c44ba4687c0dabbd102ac217b068 Mon Sep 17 00:00:00 2001 From: DigitalBlueprint239 Date: Thu, 19 Mar 2026 09:24:04 -0400 Subject: [PATCH 02/13] Refactor error handling and improve user messagesfix(security): remove public error leakage from ClaimBand.tsx --- src/pages/ClaimBand.tsx | 201 ++++++++++++++++++++++++++++++---------- 1 file changed, 152 insertions(+), 49 deletions(-) diff --git a/src/pages/ClaimBand.tsx b/src/pages/ClaimBand.tsx index e3b6f80..acb92c4 100644 --- a/src/pages/ClaimBand.tsx +++ b/src/pages/ClaimBand.tsx @@ -5,6 +5,21 @@ import { QRScanner } from '../components/QRScanner'; import { motion } from 'motion/react'; import { QrCode, CheckCircle2, AlertCircle, User, ArrowLeft } from 'lucide-react'; +// --------------------------------------------------------------------------- +// Public-safe error constants. +// Technical error details (DB messages, constraint names, table names) are +// intentionally never rendered in the UI. They are logged to the console in +// development mode only. +// --------------------------------------------------------------------------- +const PUBLIC_ERROR_BODY = + 'Please try again in a moment. If the problem continues, ask event staff for help.'; + +function logDevError(context: string, err: unknown) { + if (import.meta.env.DEV) { + console.error(`[ClaimBand] ${context}`, err); + } +} + export default function ClaimBand() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); @@ -12,6 +27,7 @@ export default function ClaimBand() { const [athlete, setAthlete] = useState(null); const [loading, setLoading] = useState(true); + // error is always a public-safe string — never a raw DB message const [error, setError] = useState(null); const [success, setSuccess] = useState(false); const [assignedBand, setAssignedBand] = useState(null); @@ -19,8 +35,9 @@ export default function ClaimBand() { const [bandNumber, setBandNumber] = useState(''); useEffect(() => { + // Missing token — friendly invalid-link message, no technical detail if (!token) { - setError('Invalid or missing claim token.'); + setError('invalid-link'); setLoading(false); return; } @@ -32,12 +49,18 @@ export default function ClaimBand() { .eq('token_hash', token) .single(); - if (claimError || !claim) { - setError('Claim session not found.'); + if (claimError) { + // Log the real error in dev; show only a safe message publicly + logDevError('fetchClaim › token lookup', claimError); + setError('invalid-link'); + } else if (!claim) { + setError('invalid-link'); } else if (claim.used_at) { - setError('This wristband has already been claimed.'); + // Already claimed — safe to tell the user this specific fact + setError('already-claimed'); } else if (new Date(claim.expires_at) < new Date()) { - setError('Claim session has expired.'); + // Expired — safe to tell the user this specific fact + setError('expired'); } else { setAthlete(claim.athletes); } @@ -52,7 +75,6 @@ export default function ClaimBand() { setError(null); try { - // Atomic Claim Logic // 1. Check if band is available const { data: band, error: bandError } = await supabase .from('bands') @@ -60,8 +82,13 @@ export default function ClaimBand() { .eq('band_id', bandId) .single(); - if (bandError || !band) throw new Error('Invalid wristband ID.'); - if (band.status !== 'available') throw new Error('This wristband is already assigned or void.'); + if (bandError || !band) { + logDevError('handleClaim › band lookup', bandError); + throw new Error('wristband-not-found'); + } + if (band.status !== 'available') { + throw new Error('wristband-unavailable'); + } // 2. Update Band const { error: updateBandError } = await supabase @@ -73,7 +100,10 @@ export default function ClaimBand() { }) .eq('band_id', bandId); - if (updateBandError) throw updateBandError; + if (updateBandError) { + logDevError('handleClaim › update band', updateBandError); + throw new Error('claim-failed'); + } // 3. Update Athlete const { error: updateAthleteError } = await supabase @@ -81,18 +111,29 @@ export default function ClaimBand() { .update({ band_id: bandId }) .eq('id', athlete.id); - if (updateAthleteError) throw updateAthleteError; + if (updateAthleteError) { + logDevError('handleClaim › update athlete', updateAthleteError); + throw new Error('claim-failed'); + } // 4. Mark Token Used - await supabase + const { error: tokenError } = await supabase .from('token_claims') .update({ used_at: new Date().toISOString() }) .eq('token_hash', token); + if (tokenError) { + // Non-fatal: band and athlete are already linked. Log only. + logDevError('handleClaim › mark token used', tokenError); + } + setAssignedBand(band); setSuccess(true); - } catch (err: any) { - setError(err.message); + } catch (err: unknown) { + const code = err instanceof Error ? err.message : 'claim-failed'; + // Map internal error codes to public-safe messages + const publicMessage = CLAIM_ERROR_MESSAGES[code] ?? CLAIM_ERROR_MESSAGES['claim-failed']; + setError(publicMessage); } finally { setLoading(false); } @@ -106,7 +147,6 @@ export default function ClaimBand() { setError(null); try { - // Lookup by display_number const { data: band, error: bandError } = await supabase .from('bands') .select('*') @@ -114,21 +154,37 @@ export default function ClaimBand() { .eq('display_number', parseInt(bandNumber)) .single(); - if (bandError || !band) throw new Error('Wristband number not found for this event.'); - + if (bandError || !band) { + logDevError('handleManualSubmit › band lookup', bandError); + throw new Error('wristband-not-found'); + } + await handleClaim(band.band_id); - } catch (err: any) { - setError(err.message); + } catch (err: unknown) { + const code = err instanceof Error ? err.message : 'claim-failed'; + const publicMessage = CLAIM_ERROR_MESSAGES[code] ?? CLAIM_ERROR_MESSAGES['claim-failed']; + setError(publicMessage); setLoading(false); } }; - if (loading && !athlete && !error) return
Verifying session...
; + // ------------------------------------------------------------------------- + // Loading state + // ------------------------------------------------------------------------- + if (loading && !athlete && !error) { + return
Verifying session...
; + } - if (error && !athlete) { + // ------------------------------------------------------------------------- + // Invalid / expired / already-claimed link screen + // Shown when there is no valid athlete loaded yet. + // No DB errors, table names, or stack traces are ever rendered here. + // ------------------------------------------------------------------------- + if (!athlete && error) { + const { title, body } = LINK_ERROR_UI[error] ?? LINK_ERROR_UI['invalid-link']; return (
-
-

Invalid or missing link

-

- This claim link is invalid or expired. Please return to registration and try again. -

+

{title}

+

{body}

- +
- -
- +
- {manualEntry && ( -
- - + Wristband Number (1-500) + + setBandNumber(e.target.value)} className="w-full p-4 text-2xl font-black bg-zinc-50 border border-zinc-100 rounded-2xl text-center outline-none focus:border-zinc-900" @@ -238,7 +310,7 @@ export default function ClaimBand() { autoFocus />
-
) : ( - Wristband Assigned!

Athlete #{assignedBand.display_number} is ready for testing.

- +
-
Athlete Number
-
{assignedBand.display_number}
+
+ Athlete Number +
+
+ {String(assignedBand.display_number).padStart(3, '0')} +
- )}
); } + +// --------------------------------------------------------------------------- +// Error message maps — all values are public-safe strings. +// Internal error codes are never exposed to the UI. +// --------------------------------------------------------------------------- + +const CLAIM_ERROR_MESSAGES: Record = { + 'wristband-not-found': 'That wristband number was not found for this event.', + 'wristband-unavailable': 'This wristband is already assigned. Please scan a different one.', + 'claim-failed': "We're having trouble right now.", +}; + +const LINK_ERROR_UI: Record = { + 'invalid-link': { + title: 'Invalid or missing link', + body: 'This claim link is invalid. Please return to registration and try again.', + }, + 'already-claimed': { + title: 'Wristband already claimed', + body: 'This registration link has already been used. If you need help, please ask event staff.', + }, + expired: { + title: 'Link has expired', + body: 'This claim link has expired. Please return to registration to receive a new one.', + }, +}; From 84ca2a69c84399be8342eadc6554de0799813532 Mon Sep 17 00:00:00 2001 From: DigitalBlueprint239 Date: Thu, 19 Mar 2026 09:39:50 -0400 Subject: [PATCH 03/13] Add atomic band claim function to prevent race conditionsfeat(db): add claim_band_atomic RPC migration (007) This migration replaces the multi-step client-side band claim flow with a single atomic RPC to eliminate race conditions and prevent partial failures. --- migrations/007_atomic_band_claim.sql | 113 +++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 migrations/007_atomic_band_claim.sql diff --git a/migrations/007_atomic_band_claim.sql b/migrations/007_atomic_band_claim.sql new file mode 100644 index 0000000..ffe8208 --- /dev/null +++ b/migrations/007_atomic_band_claim.sql @@ -0,0 +1,113 @@ +-- Migration: 007_atomic_band_claim.sql +-- Description: Replaces the multi-step client-side band claim flow with a single atomic RPC. +-- This eliminates race conditions where two users might claim the same band simultaneously, +-- and prevents partial failures (e.g., band assigned but token not consumed). + +CREATE OR REPLACE FUNCTION public.claim_band_atomic( + p_token text, + p_band_id text +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_claim record; + v_band record; + v_now timestamptz := now(); +BEGIN + -- 1. Lock and validate the token claim + SELECT * INTO v_claim + FROM token_claims + WHERE token_hash = p_token + FOR UPDATE; + + IF NOT FOUND THEN + RETURN jsonb_build_object( + 'success', false, + 'code', 'invalid_token', + 'message', 'Claim session not found.' + ); + END IF; + + IF v_claim.used_at IS NOT NULL THEN + RETURN jsonb_build_object( + 'success', false, + 'code', 'token_already_used', + 'message', 'This registration link has already been used.' + ); + END IF; + + IF v_claim.expires_at < v_now THEN + RETURN jsonb_build_object( + 'success', false, + 'code', 'expired_token', + 'message', 'This claim link has expired.' + ); + END IF; + + -- 2. Lock and validate the band + SELECT * INTO v_band + FROM bands + WHERE band_id = p_band_id + FOR UPDATE; + + IF NOT FOUND THEN + RETURN jsonb_build_object( + 'success', false, + 'code', 'band_not_found', + 'message', 'That wristband number was not found.' + ); + END IF; + + IF v_band.status != 'available' THEN + RETURN jsonb_build_object( + 'success', false, + 'code', 'band_unavailable', + 'message', 'This wristband is already assigned.' + ); + END IF; + + -- 3. Execute the atomic updates + -- Note: We update bands first, then athletes, to satisfy the circular FK if deferred, + -- or just to maintain the same order the client used. + + UPDATE bands + SET + status = 'assigned', + athlete_id = v_claim.athlete_id, + assigned_at = v_now + WHERE band_id = p_band_id; + + UPDATE athletes + SET band_id = p_band_id + WHERE id = v_claim.athlete_id; + + UPDATE token_claims + SET used_at = v_now + WHERE token_hash = p_token; + + -- 4. Return success payload + RETURN jsonb_build_object( + 'success', true, + 'code', 'success', + 'athlete_id', v_claim.athlete_id, + 'band_id', p_band_id, + 'display_number', v_band.display_number + ); + +EXCEPTION WHEN OTHERS THEN + -- Catch any unexpected DB errors (e.g., constraint violations) and return a safe payload + -- The transaction will automatically roll back. + RETURN jsonb_build_object( + 'success', false, + 'code', 'claim_failed', + 'message', SQLERRM + ); +END; +$$; + +-- Grant execute permission to authenticated and anon roles +GRANT EXECUTE ON FUNCTION public.claim_band_atomic(text, text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.claim_band_atomic(text, text) TO anon; From efd6be0e410f720a4a7a8d0065af99eb37519541 Mon Sep 17 00:00:00 2001 From: DigitalBlueprint239 Date: Thu, 19 Mar 2026 09:42:45 -0400 Subject: [PATCH 04/13] Refactor error handling and improve typesfeat(claim): replace 4-step client flow with atomic RPC claim_band_atomic --- src/pages/ClaimBand.tsx | 243 +++++++++++++++++++++------------------- 1 file changed, 126 insertions(+), 117 deletions(-) diff --git a/src/pages/ClaimBand.tsx b/src/pages/ClaimBand.tsx index acb92c4..a9f98c0 100644 --- a/src/pages/ClaimBand.tsx +++ b/src/pages/ClaimBand.tsx @@ -20,47 +20,95 @@ function logDevError(context: string, err: unknown) { } } +// --------------------------------------------------------------------------- +// RPC error code -> public-safe UI message +// Maps the structured codes returned by claim_band_atomic() to friendly text. +// Internal codes are never rendered directly. +// --------------------------------------------------------------------------- +const RPC_ERROR_MESSAGES: Record = { + invalid_token: 'This claim link is invalid. Please return to registration and try again.', + expired_token: 'This claim link has expired. Please return to registration to receive a new one.', + token_already_used: 'This registration link has already been used. If you need help, ask event staff.', + band_not_found: 'That wristband number was not found for this event.', + band_unavailable: 'This wristband is already assigned. Please scan a different one.', + athlete_not_found: "We couldn't locate your athlete record. Please ask event staff for help.", + claim_failed: "We're having trouble right now.", +}; + +// Token-level errors that should show the "invalid link" full-screen state +// rather than an inline banner (because there is no valid athlete loaded yet). +const LINK_LEVEL_CODES = new Set([ + 'invalid_token', + 'expired_token', + 'token_already_used', +]); + +// --------------------------------------------------------------------------- +// Link error full-screen UI map +// --------------------------------------------------------------------------- +const LINK_ERROR_UI: Record = { + invalid_token: { + title: 'Invalid or missing link', + body: 'This claim link is invalid. Please return to registration and try again.', + }, + expired_token: { + title: 'Link has expired', + body: 'This claim link has expired. Please return to registration to receive a new one.', + }, + token_already_used: { + title: 'Wristband already claimed', + body: 'This registration link has already been used. If you need help, please ask event staff.', + }, +}; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- export default function ClaimBand() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); const token = searchParams.get('athleteToken'); const [athlete, setAthlete] = useState(null); + // linkError holds a code from LINK_ERROR_UI -- shown before athlete is loaded + const [linkError, setLinkError] = useState(null); const [loading, setLoading] = useState(true); - // error is always a public-safe string — never a raw DB message - const [error, setError] = useState(null); + // claimError is an inline public-safe string shown after a failed claim attempt + const [claimError, setClaimError] = useState(null); const [success, setSuccess] = useState(false); - const [assignedBand, setAssignedBand] = useState(null); + const [assignedBand, setAssignedBand] = useState<{ display_number: number; band_id: string } | null>(null); const [manualEntry, setManualEntry] = useState(false); const [bandNumber, setBandNumber] = useState(''); + // ------------------------------------------------------------------------- + // On mount: validate the token and load the athlete. + // This is still a client-side read -- we only need to confirm the token is + // valid and load the athlete's name for display. The actual mutation is + // handled by the RPC. + // ------------------------------------------------------------------------- useEffect(() => { - // Missing token — friendly invalid-link message, no technical detail if (!token) { - setError('invalid-link'); + setLinkError('invalid_token'); setLoading(false); return; } async function fetchClaim() { - const { data: claim, error: claimError } = await supabase + const { data: claim, error: claimErr } = await supabase .from('token_claims') .select('*, athletes(*)') .eq('token_hash', token) .single(); - if (claimError) { - // Log the real error in dev; show only a safe message publicly - logDevError('fetchClaim › token lookup', claimError); - setError('invalid-link'); + if (claimErr) { + logDevError('fetchClaim token lookup', claimErr); + setLinkError('invalid_token'); } else if (!claim) { - setError('invalid-link'); + setLinkError('invalid_token'); } else if (claim.used_at) { - // Already claimed — safe to tell the user this specific fact - setError('already-claimed'); + setLinkError('token_already_used'); } else if (new Date(claim.expires_at) < new Date()) { - // Expired — safe to tell the user this specific fact - setError('expired'); + setLinkError('expired_token'); } else { setAthlete(claim.athletes); } @@ -69,119 +117,106 @@ export default function ClaimBand() { fetchClaim(); }, [token]); + // ------------------------------------------------------------------------- + // handleClaim -- the single entry point for all claim attempts. + // Calls claim_band_atomic() which executes all DB mutations in one + // server-side transaction. If any step fails, the DB rolls back automatically. + // ------------------------------------------------------------------------- const handleClaim = async (bandId: string) => { if (success || loading) return; setLoading(true); - setError(null); + setClaimError(null); try { - // 1. Check if band is available - const { data: band, error: bandError } = await supabase - .from('bands') - .select('*') - .eq('band_id', bandId) - .single(); - - if (bandError || !band) { - logDevError('handleClaim › band lookup', bandError); - throw new Error('wristband-not-found'); - } - if (band.status !== 'available') { - throw new Error('wristband-unavailable'); + const { data: result, error: rpcError } = await supabase.rpc('claim_band_atomic', { + p_token: token, + p_band_id: bandId, + }); + + if (rpcError) { + // Network-level or Postgres-level error (not an application error) + logDevError('handleClaim rpc call', rpcError); + setClaimError(RPC_ERROR_MESSAGES['claim_failed']); + return; } - // 2. Update Band - const { error: updateBandError } = await supabase - .from('bands') - .update({ - status: 'assigned', - athlete_id: athlete.id, - assigned_at: new Date().toISOString(), - }) - .eq('band_id', bandId); - - if (updateBandError) { - logDevError('handleClaim › update band', updateBandError); - throw new Error('claim-failed'); - } - - // 3. Update Athlete - const { error: updateAthleteError } = await supabase - .from('athletes') - .update({ band_id: bandId }) - .eq('id', athlete.id); - - if (updateAthleteError) { - logDevError('handleClaim › update athlete', updateAthleteError); - throw new Error('claim-failed'); - } - - // 4. Mark Token Used - const { error: tokenError } = await supabase - .from('token_claims') - .update({ used_at: new Date().toISOString() }) - .eq('token_hash', token); - - if (tokenError) { - // Non-fatal: band and athlete are already linked. Log only. - logDevError('handleClaim › mark token used', tokenError); + // result is the jsonb payload returned by the function + if (!result?.success) { + const code: string = result?.code ?? 'claim_failed'; + logDevError('handleClaim rpc returned failure', result); + + if (LINK_LEVEL_CODES.has(code)) { + // Token became invalid between page load and claim attempt + // (e.g., another tab used it). Show the full-screen link error. + setLinkError(code); + setAthlete(null); + } else { + setClaimError(RPC_ERROR_MESSAGES[code] ?? RPC_ERROR_MESSAGES['claim_failed']); + } + return; } - setAssignedBand(band); + // Success + setAssignedBand({ + band_id: result.band_id, + display_number: result.display_number, + }); setSuccess(true); - } catch (err: unknown) { - const code = err instanceof Error ? err.message : 'claim-failed'; - // Map internal error codes to public-safe messages - const publicMessage = CLAIM_ERROR_MESSAGES[code] ?? CLAIM_ERROR_MESSAGES['claim-failed']; - setError(publicMessage); } finally { setLoading(false); } }; + // ------------------------------------------------------------------------- + // handleManualSubmit -- resolves a display_number to a band_id, then + // delegates to handleClaim. The band lookup is a read-only SELECT; the + // mutation still goes through the RPC. + // ------------------------------------------------------------------------- const handleManualSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!bandNumber) return; + if (!bandNumber || !athlete) return; setLoading(true); - setError(null); + setClaimError(null); try { const { data: band, error: bandError } = await supabase .from('bands') - .select('*') + .select('band_id, display_number, status') .eq('event_id', athlete.event_id) .eq('display_number', parseInt(bandNumber)) .single(); if (bandError || !band) { - logDevError('handleManualSubmit › band lookup', bandError); - throw new Error('wristband-not-found'); + logDevError('handleManualSubmit band lookup', bandError); + setClaimError(RPC_ERROR_MESSAGES['band_not_found']); + return; } + // Delegate to handleClaim -- it will call the RPC await handleClaim(band.band_id); } catch (err: unknown) { - const code = err instanceof Error ? err.message : 'claim-failed'; - const publicMessage = CLAIM_ERROR_MESSAGES[code] ?? CLAIM_ERROR_MESSAGES['claim-failed']; - setError(publicMessage); + logDevError('handleManualSubmit unexpected', err); + setClaimError(RPC_ERROR_MESSAGES['claim_failed']); + } finally { setLoading(false); } }; // ------------------------------------------------------------------------- - // Loading state + // Loading state -- shown while token is being validated on mount // ------------------------------------------------------------------------- - if (loading && !athlete && !error) { + if (loading && !athlete && !linkError) { return
Verifying session...
; } // ------------------------------------------------------------------------- - // Invalid / expired / already-claimed link screen - // Shown when there is no valid athlete loaded yet. + // Invalid / expired / already-claimed link screen. + // Shown when the token is bad before any athlete is loaded. // No DB errors, table names, or stack traces are ever rendered here. // ------------------------------------------------------------------------- - if (!athlete && error) { - const { title, body } = LINK_ERROR_UI[error] ?? LINK_ERROR_UI['invalid-link']; + if (linkError && !athlete) { + const { title, body } = LINK_ERROR_UI[linkError] ?? LINK_ERROR_UI['invalid_token']; return (
{/* ----------------------------------------------------------------------- - Inline error banner — shown after a failed claim attempt while the + Inline error banner -- shown after a failed claim attempt while the athlete is already loaded. Only public-safe messages are rendered. ----------------------------------------------------------------------- */} - {error && ( + {claimError && (
-

{error}

+

{claimError}

{PUBLIC_ERROR_BODY}

@@ -337,7 +372,7 @@ export default function ClaimBand() {

Wristband Assigned!

-

Athlete #{assignedBand.display_number} is ready for testing.

+

Athlete #{assignedBand?.display_number} is ready for testing.

@@ -345,7 +380,7 @@ export default function ClaimBand() { Athlete Number
- {String(assignedBand.display_number).padStart(3, '0')} + {String(assignedBand?.display_number ?? '').padStart(3, '0')}
@@ -353,36 +388,10 @@ export default function ClaimBand() { onClick={() => navigate('/')} className="w-full py-4 bg-zinc-900 text-white rounded-2xl font-bold hover:bg-zinc-800 transition-all" > - Done — Return Home + Done -- Return Home
)}
); -} - -// --------------------------------------------------------------------------- -// Error message maps — all values are public-safe strings. -// Internal error codes are never exposed to the UI. -// --------------------------------------------------------------------------- - -const CLAIM_ERROR_MESSAGES: Record = { - 'wristband-not-found': 'That wristband number was not found for this event.', - 'wristband-unavailable': 'This wristband is already assigned. Please scan a different one.', - 'claim-failed': "We're having trouble right now.", -}; - -const LINK_ERROR_UI: Record = { - 'invalid-link': { - title: 'Invalid or missing link', - body: 'This claim link is invalid. Please return to registration and try again.', - }, - 'already-claimed': { - title: 'Wristband already claimed', - body: 'This registration link has already been used. If you need help, please ask event staff.', - }, - expired: { - title: 'Link has expired', - body: 'This claim link has expired. Please return to registration to receive a new one.', - }, -}; + } From d6cc8f19ea20b1f3f19ae08be8a9b25c2de1fc6e Mon Sep 17 00:00:00 2001 From: DigitalBlueprint239 Date: Thu, 19 Mar 2026 09:53:13 -0400 Subject: [PATCH 05/13] Add StaffStationSelect component for station selectionfeat(staff): add StaffStationSelect page for dynamic station selection --- src/pages/StaffStationSelect.tsx | 270 +++++++++++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 src/pages/StaffStationSelect.tsx diff --git a/src/pages/StaffStationSelect.tsx b/src/pages/StaffStationSelect.tsx new file mode 100644 index 0000000..130e605 --- /dev/null +++ b/src/pages/StaffStationSelect.tsx @@ -0,0 +1,270 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { supabase } from '../lib/supabase'; +import { motion, AnimatePresence } from 'motion/react'; +import { MapPin, Zap, Clock, ChevronRight, RefreshCw, LogOut } from 'lucide-react'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- +const LAST_STATION_KEY = 'ce_last_station_id'; + +function logDevError(context: string, err: unknown) { + if (import.meta.env.DEV) { + console.error(`[StaffStationSelect] ${context}`, err); + } +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- +interface Station { + id: string; + name: string; + drill_type: string; + status: string; + event_id: string; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- +export default function StaffStationSelect() { + const navigate = useNavigate(); + + const [stations, setStations] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastStationId, setLastStationId] = useState(null); + const [lastStation, setLastStation] = useState(null); + + useEffect(() => { + const saved = localStorage.getItem(LAST_STATION_KEY); + if (saved) setLastStationId(saved); + loadStations(saved); + }, []); + + const loadStations = async (savedId?: string | null) => { + setLoading(true); + setError(null); + + try { + const { data: events, error: eventError } = await supabase + .from('events') + .select('id, name, status') + .in('status', ['live', 'draft']) + .order('created_at', { ascending: false }) + .limit(1); + + if (eventError) { + logDevError('loadStations event lookup', eventError); + setError('Could not load event information. Please try again.'); + return; + } + + if (!events || events.length === 0) { + setError('No active event found. Please contact the event director.'); + return; + } + + const activeEvent = events[0]; + + const { data: stationData, error: stationError } = await supabase + .from('stations') + .select('id, name, drill_type, status, event_id') + .eq('event_id', activeEvent.id) + .order('name', { ascending: true }); + + if (stationError) { + logDevError('loadStations station lookup', stationError); + setError('Could not load stations. Please try again.'); + return; + } + + const list: Station[] = stationData ?? []; + setStations(list); + + if (savedId) { + const match = list.find((s) => s.id === savedId); + setLastStation(match ?? null); + if (!match) { + localStorage.removeItem(LAST_STATION_KEY); + setLastStationId(null); + } + } + } catch (err: unknown) { + logDevError('loadStations unexpected', err); + setError('Something went wrong. Please try again.'); + } finally { + setLoading(false); + } + }; + + const handleSelect = (station: Station) => { + localStorage.setItem(LAST_STATION_KEY, station.id); + navigate(`/staff/station/${station.id}`); + }; + + const handleSignOut = async () => { + await supabase.auth.signOut(); + navigate('/staff/login'); + }; + + const statusBadge = (status: string) => { + const map: Record = { + active: { label: 'Active', className: 'bg-emerald-100 text-emerald-700' }, + inactive: { label: 'Inactive', className: 'bg-zinc-100 text-zinc-500' }, + paused: { label: 'Paused', className: 'bg-amber-100 text-amber-700' }, + }; + const { label, className } = map[status] ?? { label: status, className: 'bg-zinc-100 text-zinc-500' }; + return ( + + {label} + + ); + }; + + if (loading) { + return ( +
+
Loading stations...
+
+ ); + } + + if (error) { + return ( +
+
+
+ +
+
+

Stations unavailable

+

{error}

+
+ + +
+
+ ); + } + + return ( +
+
+
+
+

+ Select Station +

+

+ Choose the testing station you are operating today. +

+
+ +
+ + + {lastStation && ( + +
+
+
+ +
+
+

+ Last used +

+

{lastStation.name}

+

{lastStation.drill_type}

+
+
+ +
+
+ )} +
+ + {stations.length === 0 ? ( +
+ +

No stations found for this event.

+

Please contact the event director to set up stations.

+
+ ) : ( +
+

+ All Stations ({stations.length}) +

+ {stations.map((station) => ( + handleSelect(station)} + whileTap={{ scale: 0.98 }} + className={`w-full bg-white p-5 rounded-2xl border text-left flex items-center justify-between gap-4 hover:border-zinc-400 hover:shadow-md transition-all ${ + station.id === lastStationId + ? 'border-zinc-900 shadow-md' + : 'border-zinc-200' + }`} + > +
+
+ +
+
+

{station.name}

+

{station.drill_type}

+
+
+
+ {statusBadge(station.status)} + +
+
+ ))} +
+ )} + +
+ +
+
+
+ ); + } From 83afc1e09d0fef677663a92d4e6d483a24717e97 Mon Sep 17 00:00:00 2001 From: DigitalBlueprint239 Date: Thu, 19 Mar 2026 09:55:15 -0400 Subject: [PATCH 06/13] Refactor StaffLogin navigation and input elementsfix(staff): replace hardcoded SPEED-1 redirect with /staff/select-station Updated navigation to allow staff to select their own station instead of using a hardcoded redirect. Adjusted input and button elements for consistency. --- src/pages/StaffLogin.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/pages/StaffLogin.tsx b/src/pages/StaffLogin.tsx index b0d87df..dd648d4 100644 --- a/src/pages/StaffLogin.tsx +++ b/src/pages/StaffLogin.tsx @@ -21,16 +21,17 @@ export default function StaffLogin() { setError(error.message); setLoading(false); } else { - // In a real app, we'd fetch the station config or list - navigate('/staff/station/SPEED-1'); + // Navigate to station selection so staff choose their own station. + // The previous hardcoded redirect to /staff/station/SPEED-1 has been removed. + navigate('/staff/select-station'); } }; return (
- @@ -57,7 +58,7 @@ export default function StaffLogin() {
- setEmail(e.target.value)} @@ -69,7 +70,7 @@ export default function StaffLogin() {
- setPassword(e.target.value)} @@ -79,7 +80,7 @@ export default function StaffLogin() { />
- +
@@ -147,26 +229,27 @@ export default function AdminDashboard() {
- {/* Stats Grid */}
- } label="Athletes Registered" value={stats.athletes} color="blue" /> - } label="Bands Assigned" value={stats.bands} color="purple" /> - } label="Results Captured" value={stats.results} color="emerald" /> - } label="Completed Drills" value={stats.completed} color="amber" /> + } label="Athletes Registered" value={stats.athletes} color="blue" loading={statsLoading} /> + } label="Bands Assigned" value={stats.bands} color="purple" loading={statsLoading} /> + } label="Results Captured" value={stats.results} color="emerald" loading={statsLoading} /> + } label="Completed Drills" value={stats.completed} color="amber" loading={statsLoading} />
- {/* Athlete Table */}
-

Athlete Progress

+
+

Athlete Progress

+ {!tableLoading && {totalAthletes} total} +
- setSearchTerm(e.target.value)} + value={searchInput} + onChange={(e) => setSearchInput(e.target.value)} className="pl-10 pr-4 py-2 bg-white border border-zinc-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-zinc-900 w-64" />
@@ -184,129 +267,94 @@ export default function AdminDashboard() { - {filteredAthletes.map((athlete) => ( - - - {athlete.bands?.display_number || '--'} - - -
{athlete.first_name} {athlete.last_name}
-
{athlete.parent_email}
- - - {athlete.position || 'N/A'} - - -
-
-
-
- {athlete.results?.length || 0}/5 -
- - - {athlete.results?.length >= 5 ? ( - - Ready - - ) : ( - - Testing - - )} - - - ))} + {tableLoading ? ( + Loading... + ) : athletes.length === 0 ? ( + {searchTerm ? 'No athletes match your search.' : 'No athletes registered yet.'} + ) : ( + athletes.map((athlete) => { + const resultCount = athlete.results?.length ?? 0; + const progress = Math.min(Math.round((resultCount / COMPLETION_THRESHOLD) * 100), 100); + const isComplete = resultCount >= COMPLETION_THRESHOLD; + return ( + + {athlete.bands?.display_number ?? '—'} + +
{athlete.first_name} {athlete.last_name}
+ + {athlete.position ?? '—'} + +
+
+
+
+ {resultCount}/{COMPLETION_THRESHOLD} +
+ + + {isComplete ? ( + + Done + + ) : ( + + In Progress + + )} + + + ); + }) + )} + + {totalPages > 1 && ( +
+ Page {currentPage + 1} of {totalPages}  ·  {totalAthletes} athletes +
+ + +
+
+ )}
- {/* Station Health */}

Station Health

-
- {stations.length === 0 ? ( -
- No stations configured. -
+
+ {statsLoading ? ( +
Loading stations...
+ ) : stations.length === 0 ? ( +
No stations configured.
) : ( stations.map((station) => { - const status = station.status; - const lastSeen = status ? new Date(status.last_seen_at) : null; - const now = new Date(); - const diffMinutes = lastSeen ? Math.floor((now.getTime() - lastSeen.getTime()) / 60000) : null; - const isStale = diffMinutes !== null && diffMinutes > 2; - const isOffline = !status || !status.is_online || isStale; - - const lastSync = status?.last_sync_at ? new Date(status.last_sync_at) : null; - const syncDiffMinutes = lastSync ? Math.floor((now.getTime() - lastSync.getTime()) / 60000) : null; - const isSyncStale = syncDiffMinutes !== null && syncDiffMinutes > 10; - + const isOnline = station.status?.is_online ?? false; + const lastSeen = station.status?.last_seen_at ? new Date(station.status.last_seen_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : null; + const pendingCount = station.status?.pending_queue_count ?? 0; return ( -
- {isOffline && status &&
} - {!status &&
} - +
-
-
- {!isOffline ? : } -
-
-

{station.name}

-

{status?.device_label || 'No device connected'}

-
-
-
- {!status ? 'Not Active' : !isOffline ? 'Online' : 'Offline'} -
-
- -
-
Outbox
-
50 ? 'text-red-600' : status?.pending_queue_count > 10 ? 'text-amber-600' : 'text-zinc-900'}`}> - {status?.pending_queue_count ?? '--'} -
+
{station.name}
+
{station.drill_type}
-
-
Last Sync
-
- {lastSync ? lastSync.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '--'} -
+
+ {isOnline ? : } + {isOnline ? 'Online' : 'Offline'}
- -
- {status?.pending_queue_count > 50 && ( -
- - Critical: Large pending queue -
- )} - {isSyncStale && ( -
- - Warning: Stale sync ({syncDiffMinutes}m ago) -
- )} - {isStale && status && !status.is_online && ( -
- - Device heartbeat lost -
- )} - {!status && ( -
- - Waiting for first heartbeat -
+
+ {lastSeen && Last seen {lastSeen}} + {pendingCount > 0 && ( + + {pendingCount} pending + )}
@@ -321,23 +369,35 @@ export default function AdminDashboard() { ); } -function StatCard({ icon, label, value, color }: { icon: React.ReactNode, label: string, value: number, color: string }) { - const colors: Record = { - blue: 'bg-blue-50 text-blue-600', - purple: 'bg-purple-50 text-purple-600', - emerald: 'bg-emerald-50 text-emerald-600', - amber: 'bg-amber-50 text-amber-600' - }; +interface StatCardProps { + icon: React.ReactNode; + label: string; + value: number; + color: 'blue' | 'purple' | 'emerald' | 'amber'; + loading?: boolean; +} +const COLOR_MAP: Record = { + blue: 'bg-blue-50 text-blue-600', + purple: 'bg-purple-50 text-purple-600', + emerald: 'bg-emerald-50 text-emerald-600', + amber: 'bg-amber-50 text-amber-600', +}; + +function StatCard({ icon, label, value, color, loading }: StatCardProps) { return ( -
-
- {React.cloneElement(icon as any, { className: 'w-5 h-5' })} + +
+ {React.cloneElement(icon as React.ReactElement, { className: 'w-5 h-5' })}
-
{value}
-
{label}
+ {loading ? ( +
+ ) : ( +
{value.toLocaleString()}
+ )} +
{label}
-
+
); -} + } From ed4e668ab8175617ddea166082ef6462076d6afa Mon Sep 17 00:00:00 2001 From: DigitalBlueprint239 Date: Thu, 19 Mar 2026 11:20:18 -0400 Subject: [PATCH 11/13] Create 008_security_and_schema_alignment.sqlfix(db): add migration 008 - RLS hardening and schema drift fixes --- .../008_security_and_schema_alignment.sql | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 migrations/008_security_and_schema_alignment.sql diff --git a/migrations/008_security_and_schema_alignment.sql b/migrations/008_security_and_schema_alignment.sql new file mode 100644 index 0000000..f0c814e --- /dev/null +++ b/migrations/008_security_and_schema_alignment.sql @@ -0,0 +1,88 @@ +-- Migration: 008_security_and_schema_alignment.sql +-- Description: Hardens RLS policies across core tables and fixes schema drift + +-- ============================================================================ +-- 1. SCHEMA DRIFT FIXES +-- ============================================================================ + +-- Fix 1.1: Add missing assigned_at column to bands table +-- This column is used by the claim_band_atomic RPC but was missing from migration 002 +ALTER TABLE bands ADD COLUMN IF NOT EXISTS assigned_at TIMESTAMPTZ; + +-- Fix 1.2: Fix incidents.station_id foreign key type mismatch +-- stations.id is TEXT, but incidents.station_id was created as UUID in migration 005 +-- We must drop the constraint, change the type, and re-add the constraint +ALTER TABLE incidents DROP CONSTRAINT IF EXISTS incidents_station_id_fkey; +ALTER TABLE incidents ALTER COLUMN station_id TYPE TEXT USING station_id::TEXT; +ALTER TABLE incidents ADD CONSTRAINT incidents_station_id_fkey FOREIGN KEY (station_id) REFERENCES stations(id); + +-- Fix 1.3: Add missing columns to report_jobs table +-- These columns exist in supabase_schema.sql but were missing from migration 004 +ALTER TABLE report_jobs ADD COLUMN IF NOT EXISTS requested_by UUID REFERENCES auth.users(id); +ALTER TABLE report_jobs ADD COLUMN IF NOT EXISTS format TEXT DEFAULT 'pdf'; +ALTER TABLE report_jobs ADD COLUMN IF NOT EXISTS error_message TEXT; + +-- ============================================================================ +-- 2. RLS POLICY HARDENING +-- ============================================================================ + +-- ---------------------------------------------------------------------------- +-- Table: token_claims +-- Issue: "Public Token Claims" FOR ALL USING (true) allowed full public read/write +-- Fix: Restrict to SELECT only. Mutations are handled exclusively by the RPC. +-- ---------------------------------------------------------------------------- +DROP POLICY IF EXISTS "Public Token Claims" ON token_claims; + +CREATE POLICY "Public Read Own Token" ON token_claims + FOR SELECT USING (true); + +CREATE POLICY "Admin Full Access Tokens" ON token_claims + FOR ALL TO authenticated USING ( + EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin') + ); + +-- ---------------------------------------------------------------------------- +-- Table: athletes +-- Issue: "Public Update Athlete via ID" FOR UPDATE USING (true) allowed public mutation +-- Fix: Remove public UPDATE. The claim_band_atomic RPC handles band_id update securely. +-- ---------------------------------------------------------------------------- +DROP POLICY IF EXISTS "Public Update Athlete via ID" ON athletes; + +-- ---------------------------------------------------------------------------- +-- Table: bands +-- Issue: "Public Update Band Claim" FOR UPDATE USING (true) allowed public mutation +-- Fix: Remove public UPDATE. The claim_band_atomic RPC handles band assignment securely. +-- ---------------------------------------------------------------------------- +DROP POLICY IF EXISTS "Public Update Band Claim" ON bands; + +-- ---------------------------------------------------------------------------- +-- Table: results +-- Issue: "Admin Update Results" FOR UPDATE TO authenticated USING (true) allowed any staff +-- Fix: Restrict UPDATE to admins only. Staff should only INSERT (append-only). +-- ---------------------------------------------------------------------------- +DROP POLICY IF EXISTS "Admin Update Results" ON results; + +CREATE POLICY "Admin Update Results" ON results + FOR UPDATE TO authenticated USING ( + EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin') + ); + +-- ---------------------------------------------------------------------------- +-- Table: profiles +-- Issue: "Public profiles are viewable by everyone." leaked staff names/roles +-- Fix: Restrict profile visibility to authenticated users only. +-- ---------------------------------------------------------------------------- +DROP POLICY IF EXISTS "Public profiles are viewable by everyone." ON profiles; + +CREATE POLICY "Authenticated Read Profiles" ON profiles + FOR SELECT TO authenticated USING (true); + +-- ---------------------------------------------------------------------------- +-- Table: device_status +-- Issue: "Public Device Status Update" FOR UPDATE USING (true) allowed public mutation +-- Fix: Restrict UPDATE to authenticated staff/admins. Stations can only INSERT (upsert). +-- ---------------------------------------------------------------------------- +DROP POLICY IF EXISTS "Public Device Status Update" ON device_status; + +CREATE POLICY "Staff Update Device Status" ON device_status + FOR UPDATE TO authenticated USING (true); From 6fdd12858f0980c10157752b1b6dc5b4037378ca Mon Sep 17 00:00:00 2001 From: DigitalBlueprint239 Date: Thu, 19 Mar 2026 11:22:24 -0400 Subject: [PATCH 12/13] Update AdminLogin.tsxfix(auth): replace hardcoded admin@coreelite.com placeholder with generic your@email.com --- src/pages/AdminLogin.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/AdminLogin.tsx b/src/pages/AdminLogin.tsx index 2c2e56b..b3e16db 100644 --- a/src/pages/AdminLogin.tsx +++ b/src/pages/AdminLogin.tsx @@ -76,7 +76,7 @@ export default function AdminLogin() { value={email} onChange={(e) => setEmail(e.target.value)} className="w-full p-3 bg-zinc-50 border border-zinc-200 rounded-xl outline-none focus:ring-2 focus:ring-zinc-900" - placeholder="admin@coreelite.com" + placeholder="your@email.com" required />
@@ -105,4 +105,4 @@ export default function AdminLogin() {
); -} + } From 9b10be8690e7711dcec6df12842f390b6c8fba9a Mon Sep 17 00:00:00 2001 From: DigitalBlueprint239 Date: Thu, 19 Mar 2026 11:25:06 -0400 Subject: [PATCH 13/13] Update StaffLogin.tsxfix(auth): replace coach@coreelite.com placeholder with generic email in StaffLogin --- src/pages/StaffLogin.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/StaffLogin.tsx b/src/pages/StaffLogin.tsx index dd648d4..7bfa9ab 100644 --- a/src/pages/StaffLogin.tsx +++ b/src/pages/StaffLogin.tsx @@ -63,7 +63,7 @@ export default function StaffLogin() { value={email} onChange={(e) => setEmail(e.target.value)} className="w-full p-3 bg-zinc-50 border border-zinc-200 rounded-xl outline-none focus:ring-2 focus:ring-zinc-900" - placeholder="coach@coreelite.com" + placeholder="your@email.com" required />