feat: implement full authentication system with middleware protection

- Install @supabase/ssr package for server-side auth
- Create middleware.ts for route protection (redirects to login if not authenticated)
- Fix login page admin check to verify specific user ID
- Add AUTH_SETUP.md with complete setup instructions
- Add setup-admin.sql for database configuration
- Logout button already exists in sidebar
This commit is contained in:
Millian Lamiaux
2026-03-17 10:59:52 +01:00
parent 4c5bcc41c5
commit e0057e18e0
6 changed files with 368 additions and 3 deletions

101
admin-web/AUTH_SETUP.md Normal file
View File

@@ -0,0 +1,101 @@
# Authentication Setup Guide
## Overview
This guide sets up full server-side authentication for the TabataFit Admin panel.
## Prerequisites
- Supabase project running (local or hosted)
- Admin-web Next.js app running
- Database schema already applied
## Step 1: Configure Supabase Dashboard
1. Go to your Supabase Dashboard → Authentication → Providers
2. **Enable Email Provider**
3. **Disable "Confirm email"** (for easier testing, re-enable for production)
4. Go to Authentication → URL Configuration
5. Set **Site URL**: `http://localhost:3000` (or your production URL)
6. Add **Redirect URLs**: `http://localhost:3000/**`
## Step 2: Create Admin User
### Option A: Via Supabase Dashboard (Easiest)
1. Go to Supabase Dashboard → Authentication → Users
2. Click "Add user" or "Invite user"
3. Enter email: `admin@tabatafit.com`
4. Set password: Your chosen password
5. Click "Create user"
6. Copy the user's UUID (click on the user to see details)
### Option B: Via Login Page
1. Go to `http://localhost:3000/login`
2. Click "Sign up" (if available) or use dashboard method above
## Step 3: Add User to Admin Table
**Important**: After creating the user, you MUST add them to the admin_users table.
Run this SQL in Supabase SQL Editor:
```sql
-- Replace with your actual email or UUID
INSERT INTO public.admin_users (id, email, role)
SELECT id, email, 'admin'
FROM auth.users
WHERE email = 'admin@tabatafit.com'
ON CONFLICT (id) DO NOTHING;
```
Or if you have the UUID:
```sql
INSERT INTO public.admin_users (id, email, role)
VALUES ('paste-uuid-here', 'admin@tabatafit.com', 'admin');
```
## Step 4: Verify Setup
Run this to confirm:
```sql
SELECT au.id, au.email, au.role, u.email as auth_email
FROM public.admin_users au
JOIN auth.users u ON au.id = u.id;
```
You should see your admin user listed.
## Step 5: Login
1. Go to `http://localhost:3000/login`
2. Enter email: `admin@tabatafit.com`
3. Enter password: (the one you set)
4. You should be redirected to the dashboard
## Troubleshooting
### "Not authorized as admin" error
- User exists in auth.users but not in admin_users table
- Run Step 3 SQL to add them
### "Failed to fetch" errors
- Check that EXPO_PUBLIC_SUPABASE_URL is set in .env.local
- For admin-web, also add NEXT_PUBLIC_SUPABASE_URL with same value
### Still can't create workouts
- Verify you're logged in (check browser cookies)
- Check that admin_users table has your user
- Check RLS policies are applied correctly
## Default Credentials (Example)
**Email**: `admin@tabatafit.com`
**Password**: (You choose during setup)
**Note**: Change this to a secure password in production!
## Security Notes
1. **Production**: Enable email confirmations
2. **Production**: Use strong passwords
3. **Production**: Enable 2FA if available
4. **Production**: Restrict CORS origins in Supabase
5. **Production**: Use environment-specific admin credentials

View File

@@ -23,16 +23,19 @@ export default function LoginPage() {
setError("");
try {
const { error: authError } = await supabase.auth.signInWithPassword({
const { data: authData, error: authError } = await supabase.auth.signInWithPassword({
email,
password,
});
if (authError) throw authError;
if (!authData.user) throw new Error("Login failed");
// Check if the logged-in user is in admin_users table
const { data: adminUser } = await supabase
.from("admin_users")
.select("*")
.eq("id", authData.user.id)
.single();
if (!adminUser) {

57
admin-web/middleware.ts Normal file
View File

@@ -0,0 +1,57 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let response = NextResponse.next({
request: {
headers: request.headers,
},
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return request.cookies.get(name)?.value
},
set(name: string, value: string, options: any) {
request.cookies.set({ name, value, ...options })
response = NextResponse.next({
request: { headers: request.headers },
})
response.cookies.set({ name, value, ...options })
},
remove(name: string, options: any) {
request.cookies.set({ name, value: '', ...options })
response = NextResponse.next({
request: { headers: request.headers },
})
response.cookies.set({ name, value: '', ...options })
},
},
}
)
// Check if user is authenticated
const { data: { user } } = await supabase.auth.getUser()
// Protect all routes except /login
if (!user && request.nextUrl.pathname !== '/login') {
return NextResponse.redirect(new URL('/login', request.url))
}
// If user is authenticated and tries to access login, redirect to home
if (user && request.nextUrl.pathname === '/login') {
return NextResponse.redirect(new URL('/', request.url))
}
return response
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}

View File

@@ -8,6 +8,7 @@
"name": "my-app",
"version": "0.1.0",
"dependencies": {
"@supabase/ssr": "^0.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.576.0",
@@ -4060,6 +4061,117 @@
"dev": true,
"license": "MIT"
},
"node_modules/@supabase/auth-js": {
"version": "2.99.2",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.99.2.tgz",
"integrity": "sha512-uRGNXMKEw4VhwouNW7N0XDAGqJP9redHNDmWi17dTrcO1lvFfyRiXsqqfgnVC8aqtRn8kLkLPEzHjiRWsni+oQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.99.2",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.99.2.tgz",
"integrity": "sha512-xuXQARvjdfB1UPK1yUceZ5EGjOLkVz4rBAaloS9foXiAuseWEdgWBCxkIAFRxGBLGX8Uzo8kseq90jhPb+07Vg==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "2.99.2",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.99.2.tgz",
"integrity": "sha512-ueiOVkbkTQ7RskwVmjR8zxWYw3VKOMxo1+qep+Dx/SgApqyhWBGd92waQb45tbLc7ydB5x8El8utXOLQTuTojQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.99.2",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.99.2.tgz",
"integrity": "sha512-J6Jm9601dkpZf3+EJ48ki2pM4sFtCNm/BI0l8iEnrczgg+JSEQkMoOW5VSpM54t0pNs69bsz5PTmYJahDZKiIQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"tslib": "2.8.1",
"ws": "^8.18.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/ssr": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.9.0.tgz",
"integrity": "sha512-UFY6otYV3yqCgV+AyHj80vNkTvbf1Gas2LW4dpbQ4ap6p6v3eB2oaDfcI99jsuJzwVBCFU4BJI+oDYyhNk1z0Q==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.2"
},
"peerDependencies": {
"@supabase/supabase-js": "^2.97.0"
}
},
"node_modules/@supabase/ssr/node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.99.2",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.99.2.tgz",
"integrity": "sha512-V/FF8kX8JGSefsVCG1spCLSrHdNR/JFeUMn1jS9KG/Eizjx+evtdKQKLJXFgIylY/bKTXKhc2SYDPIGrIhzsug==",
"license": "MIT",
"peer": true,
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.99.2",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.99.2.tgz",
"integrity": "sha512-179rn5wq0wBAqqGwAwR7TUGg2NOaP+fkd5FCVbYJXby85fsRNPFoNJN8YRBepqX2tN7JJcnTjqaAMXuNjiyisA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@supabase/auth-js": "2.99.2",
"@supabase/functions-js": "2.99.2",
"@supabase/postgrest-js": "2.99.2",
"@supabase/realtime-js": "2.99.2",
"@supabase/storage-js": "2.99.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -4584,12 +4696,18 @@
"version": "20.19.35",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz",
"integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/phoenix": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
"license": "MIT",
"peer": true
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -4624,6 +4742,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
@@ -8382,6 +8510,16 @@
"node": ">=18.18.0"
}
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -12743,7 +12881,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unicorn-magic": {
@@ -13661,6 +13798,28 @@
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/wsl-utils": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz",

View File

@@ -12,6 +12,7 @@
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@supabase/ssr": "^0.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.576.0",