diff --git a/admin-web/AUTH_SETUP.md b/admin-web/AUTH_SETUP.md new file mode 100644 index 0000000..1e763f9 --- /dev/null +++ b/admin-web/AUTH_SETUP.md @@ -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 \ No newline at end of file diff --git a/admin-web/app/login/page.tsx b/admin-web/app/login/page.tsx index 0f84307..d38bacc 100644 --- a/admin-web/app/login/page.tsx +++ b/admin-web/app/login/page.tsx @@ -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) { diff --git a/admin-web/middleware.ts b/admin-web/middleware.ts new file mode 100644 index 0000000..f42a481 --- /dev/null +++ b/admin-web/middleware.ts @@ -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)$).*)', + ], +} \ No newline at end of file diff --git a/admin-web/package-lock.json b/admin-web/package-lock.json index f759d8a..3cd33ec 100644 --- a/admin-web/package-lock.json +++ b/admin-web/package-lock.json @@ -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", diff --git a/admin-web/package.json b/admin-web/package.json index f70d2a0..a48ecc6 100644 --- a/admin-web/package.json +++ b/admin-web/package.json @@ -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", diff --git a/supabase/setup-admin.sql b/supabase/setup-admin.sql new file mode 100644 index 0000000..3d0211c --- /dev/null +++ b/supabase/setup-admin.sql @@ -0,0 +1,44 @@ +-- ============================================================ +-- Setup: Initial Admin User and Authentication +-- ============================================================ +-- Run this SQL after creating your first user in Supabase Auth + +-- Step 1: Create helper function to check admin status +CREATE OR REPLACE FUNCTION is_admin(user_id UUID) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 FROM public.admin_users + WHERE id = user_id + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Step 2: Add initial admin user +-- Method A: If user already exists in auth.users, run this: +/* +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; +*/ + +-- Method B: Insert directly with known UUID +-- INSERT INTO public.admin_users (id, email, role) +-- VALUES ('paste-uuid-here', 'admin@tabatafit.com', 'admin'); + +-- Step 3: Verify admin setup +SELECT + au.id, + au.email, + au.role, + au.created_at, + u.email as auth_email +FROM public.admin_users au +JOIN auth.users u ON au.id = u.id; + +-- Step 4: List all users in auth (to find your UUID) +SELECT id, email, created_at, last_sign_in_at +FROM auth.users +ORDER BY created_at DESC; \ No newline at end of file