Context: You already have 4 tables (
incident_reports,volunteer_signups,newsletter_subscribers,contact_messages) with anonymous INSERT policies. This guide adds admin authentication so you can VIEW submitted data.
- Go to https://supabase.com/dashboard → Select your project
- Navigate to Authentication → Providers (left sidebar)
- Under Email, make sure it's Enabled
- Recommended settings:
- ✅ Enable email confirmations (toggle ON)
- ✅ Enable email change confirmations
- Set Minimum password length to 12 (for admin accounts)
- ❌ Disable "Allow new user signups" — you'll create the admin user manually (prevents random people from signing up)
- Click Save
IMPORTANT: Disabling signups means only you (via the Supabase Dashboard) can create new users. This is the correct security posture for a single-admin setup.
- Go to Authentication → Users (left sidebar)
- Click "Add user" → "Create new user"
- Enter:
- Email: your admin email (e.g.,
admin@yourdomain.com) - Password: a strong password (12+ characters, mix of upper/lower/numbers/symbols)
- ✅ Check "Auto Confirm User" (skips email verification for the admin)
- Email: your admin email (e.g.,
- Click Create user
- Copy the user's UUID — you'll need it for Step 3
-- Only if Option A doesn't work for some reason
-- Go to SQL Editor in Supabase Dashboard and run:
SELECT id, email FROM auth.users;
-- This shows existing users. If you created one via Dashboard,
-- copy the UUID from here.This table maps Supabase Auth users to admin roles. Go to SQL Editor in the Supabase Dashboard and run:
-- =============================================
-- STEP 3A: Create admin_users table
-- =============================================
CREATE TABLE IF NOT EXISTS public.admin_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'admin' CHECK (role IN ('admin', 'superadmin')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(user_id)
);
-- Add a comment for documentation
COMMENT ON TABLE public.admin_users IS 'Maps Supabase Auth users to admin roles. Only users in this table can access the admin dashboard.';
-- =============================================
-- STEP 3B: Insert yourself as admin
-- Replace 'YOUR-USER-UUID-HERE' with the UUID from Step 2
-- =============================================
INSERT INTO public.admin_users (user_id, role)
VALUES ('YOUR-USER-UUID-HERE', 'superadmin');
-- =============================================
-- STEP 3C: Enable RLS on admin_users
-- =============================================
ALTER TABLE public.admin_users ENABLE ROW LEVEL SECURITY;
-- Only authenticated users can read admin_users (to check their own role)
CREATE POLICY "Authenticated users can read own admin record"
ON public.admin_users
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
-- No INSERT/UPDATE/DELETE via API — admin records managed via Dashboard/SQL only
-- This prevents privilege escalationCurrently your 4 tables only allow anonymous INSERT. Run this SQL to add read access for authenticated admins:
-- =============================================
-- Allow admins to READ all 4 submission tables
-- These ADD to existing policies (don't remove the INSERT policies)
-- =============================================
-- incident_reports: Admin can read all
CREATE POLICY "Admin can read incident reports"
ON public.incident_reports
FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.admin_users
WHERE admin_users.user_id = auth.uid()
)
);
-- volunteer_signups: Admin can read all
CREATE POLICY "Admin can read volunteer signups"
ON public.volunteer_signups
FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.admin_users
WHERE admin_users.user_id = auth.uid()
)
);
-- newsletter_subscribers: Admin can read all
CREATE POLICY "Admin can read newsletter subscribers"
ON public.newsletter_subscribers
FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.admin_users
WHERE admin_users.user_id = auth.uid()
)
);
-- contact_messages: Admin can read all
CREATE POLICY "Admin can read contact messages"
ON public.contact_messages
FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.admin_users
WHERE admin_users.user_id = auth.uid()
)
);
-- =============================================
-- OPTIONAL: Allow admin to UPDATE status fields
-- =============================================
CREATE POLICY "Admin can update incident report status"
ON public.incident_reports
FOR UPDATE
TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.admin_users
WHERE admin_users.user_id = auth.uid()
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.admin_users
WHERE admin_users.user_id = auth.uid()
)
);
CREATE POLICY "Admin can update volunteer signup status"
ON public.volunteer_signups
FOR UPDATE
TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.admin_users
WHERE admin_users.user_id = auth.uid()
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.admin_users
WHERE admin_users.user_id = auth.uid()
)
);Run these in the SQL Editor to confirm everything works:
-- Check your admin user exists
SELECT au.*, u.email
FROM public.admin_users au
JOIN auth.users u ON au.user_id = u.id;
-- Check RLS policies on incident_reports
SELECT policyname, cmd, qual
FROM pg_policies
WHERE tablename = 'incident_reports';
-- Should show both the INSERT (anonymous) and SELECT (authenticated) policiesThe following frontend files have been created in this session:
| File | Purpose |
|---|---|
src/services/authService.js |
Auth functions: login, logout, getSession, isAdmin check |
src/contexts/AuthContext.jsx |
React Context for auth state, wraps the app |
src/components/ProtectedRoute.jsx |
Route guard — redirects non-admins to login |
src/pages/AdminLogin.jsx |
Login page with email/password form |
src/pages/AdminDashboard.jsx |
Dashboard to view submitted form data |
src/App.jsx |
Updated with /admin and /admin/login routes |
Make sure your .env (or Cloudflare Pages environment) has:
VITE_SUPABASE_URL=https://YOUR_PROJECT_ID.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-public-key-here
⚠️ CRITICAL: Use the key labeled "anon / public" from Supabase Dashboard → Settings → API. Do NOT use the "service_role / secret" key — it bypasses all Row Level Security. If you use the wrong key, you'll see: "Forbidden use of secret API key in browser"
- Build the app:
npm run build - Navigate to:
https://yoursite.com/admin/login - Enter your admin email and password from Step 2
- You should see the Admin Dashboard with tabs for:
- Incident Reports
- Volunteer Signups
- Newsletter Subscribers
- Contact Messages
- Test unauthorized access: Open an incognito window, go to
/admin— should redirect to/admin/login - Test logout: Click the logout button — should return to login page
- Email auth enabled, new signups DISABLED
- Single admin user created manually
-
admin_userstable with RLS (users can only read their own record) - 4 data tables: admin-only SELECT policies (via admin_users join)
- No admin management via API — only via Dashboard/SQL
- Client-side auth state with Supabase session tokens
- Protected routes redirect unauthenticated users
- PII encryption still in place for stored data
- Anon key is safe to expose (RLS enforces access control)
This is the most common setup mistake. You put the service_role (secret) key into VITE_SUPABASE_ANON_KEY instead of the anon (public) key.
How to fix:
- Go to Supabase Dashboard → Settings → API
- You'll see two keys under "Project API keys":
anon/public— ✅ Use THIS one forVITE_SUPABASE_ANON_KEYservice_role/secret— ❌ NEVER put this in client code
- Copy the anon key and update your environment variable
- Redeploy your site
Why it matters: The service_role key bypasses all Row Level Security — anyone who inspects your frontend JavaScript could extract it and read/write ALL your data. The anon key is safe to expose because RLS policies control what it can access.
How to tell them apart: Both are JWTs (long base64 strings). The anon key's payload contains "role": "anon", while the service_role key contains "role": "service_role". The app now detects this automatically and blocks the service_role key from being used.
- Check that the user exists in Authentication → Users
- Check that Auto Confirm was enabled (or confirm the email)
- Verify the email/password are correct
- Run the Step 4 SQL again — make sure all 4 SELECT policies exist
- Check that the
admin_userstable has your user_id inserted - Verify with:
SELECT * FROM public.admin_users;
- Check that
VITE_SUPABASE_URLandVITE_SUPABASE_ANON_KEYare set - In Cloudflare Pages: Settings → Environment Variables → Add both
- Redeploy after adding env vars
- The encrypted fields (name, email, etc.) will appear as encrypted strings
- This is expected — decryption key is needed to read PII
- Non-PII fields (status, dates, types) are readable as-is