diff --git a/Frontend/src/lib/api-client.ts b/Frontend/src/lib/api-client.ts index 81f5846..477c6a4 100644 --- a/Frontend/src/lib/api-client.ts +++ b/Frontend/src/lib/api-client.ts @@ -15,7 +15,6 @@ */ import axios from 'axios'; -import { supabase } from '@/lib/supabase'; const AUTH_STORAGE_KEY = 'taskpulse-auth'; @@ -61,6 +60,27 @@ function clearAuth() { } } +async function refreshAccessToken(refreshToken: string) { + const { data } = await axios.post<{ + message: string; + tokens: { + access_token: string; + refresh_token: string; + token_type: string; + expires_in: number; + }; + }>( + '/api/v1/auth/refresh', + { refresh_token: refreshToken }, + { + headers: { 'Content-Type': 'application/json' }, + withCredentials: true, + } + ); + + return data.tokens; +} + // SEC-006: Read the csrf_token cookie set by the backend function getCsrfToken(): string | null { const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]*)/); @@ -78,18 +98,8 @@ const apiClient = axios.create({ // ─── Request interceptor: inject token + CSRF ──────────────────────── apiClient.interceptors.request.use(async (config) => { - // Attach JWT access token (prefer zustand store, fallback to Supabase session) - let { accessToken } = getTokens(); - if (!accessToken) { - try { - const { data: { session } } = await supabase.auth.getSession(); - if (session?.access_token) { - accessToken = session.access_token; - } - } catch { - // Supabase client may not be initialized yet - } - } + // Attach JWT access token from persisted auth state + const { accessToken } = getTokens(); if (accessToken) { config.headers.Authorization = `Bearer ${accessToken}`; } @@ -157,12 +167,14 @@ apiClient.interceptors.response.use( isRefreshing = true; try { - const { data: refreshData, error: refreshErr } = await supabase.auth.refreshSession(); - if (refreshErr || !refreshData.session) { - throw refreshErr || new Error('No session after refresh'); + const { refreshToken } = getTokens(); + if (!refreshToken) { + throw new Error('No refresh token available'); } - const newAccessToken = refreshData.session.access_token; - const newRefreshToken = refreshData.session.refresh_token; + + const refreshData = await refreshAccessToken(refreshToken); + const newAccessToken = refreshData.access_token; + const newRefreshToken = refreshData.refresh_token; setTokens(newAccessToken, newRefreshToken); processQueue(null, newAccessToken); originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; diff --git a/Frontend/src/lib/supabase.ts b/Frontend/src/lib/supabase.ts index 9459512..e69e769 100644 --- a/Frontend/src/lib/supabase.ts +++ b/Frontend/src/lib/supabase.ts @@ -1,12 +1,130 @@ -import { createClient } from '@supabase/supabase-js'; +import { createClient, type SupabaseClient } from '@supabase/supabase-js'; -const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; -const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; +type BrowserSupabaseClient = SupabaseClient; -if (!supabaseUrl || !supabaseAnonKey) { - throw new Error( - 'Missing Supabase environment variables. Set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY in your .env file.' - ); +const rawSupabaseUrl = import.meta.env.VITE_SUPABASE_URL?.trim(); +const rawSupabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY?.trim(); + +export class SupabaseConfigurationError extends Error { + constructor(message: string) { + super(message); + this.name = 'SupabaseConfigurationError'; + } +} + +function getValidatedSupabaseConfig() { + if (!rawSupabaseUrl || !rawSupabaseAnonKey) { + throw new SupabaseConfigurationError( + 'Missing Supabase environment variables. Set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY for this deployment.' + ); + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(rawSupabaseUrl); + } catch { + throw new SupabaseConfigurationError( + `VITE_SUPABASE_URL is not a valid URL: ${rawSupabaseUrl}` + ); + } + + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + throw new SupabaseConfigurationError( + `VITE_SUPABASE_URL must use http or https: ${rawSupabaseUrl}` + ); + } + + if (rawSupabaseUrl.includes('your-project.supabase.co')) { + throw new SupabaseConfigurationError( + 'VITE_SUPABASE_URL is still set to the example placeholder value.' + ); + } + + return { + anonKey: rawSupabaseAnonKey, + url: parsedUrl.toString().replace(/\/$/, ''), + }; +} + +let supabaseConfigError: SupabaseConfigurationError | null = null; +let supabaseConfig: ReturnType | null = null; + +try { + supabaseConfig = getValidatedSupabaseConfig(); +} catch (error) { + supabaseConfigError = error instanceof SupabaseConfigurationError + ? error + : new SupabaseConfigurationError('Supabase configuration is invalid for this deployment.'); } -export const supabase = createClient(supabaseUrl, supabaseAnonKey); +let supabaseClient: BrowserSupabaseClient | null = null; +let supabaseClientPromise: Promise | null = null; +let reachabilityProbe: Promise | null = null; + +async function ensureSupabaseReachable() { + if (supabaseConfigError) { + throw supabaseConfigError; + } + + if (!supabaseConfig) { + throw new SupabaseConfigurationError('Supabase configuration is missing for this deployment.'); + } + + if (!reachabilityProbe) { + const probeUrl = `${supabaseConfig.url}/auth/v1/settings`; + reachabilityProbe = fetch(probeUrl, { + headers: { + apikey: supabaseConfig.anonKey, + }, + }) + .then((response) => { + if (!response.ok) { + throw new SupabaseConfigurationError( + `Supabase auth endpoint responded with ${response.status} at ${probeUrl}` + ); + } + }) + .catch((error: unknown) => { + reachabilityProbe = null; + if (error instanceof SupabaseConfigurationError) { + throw error; + } + throw new SupabaseConfigurationError( + `Supabase auth endpoint is unreachable at ${probeUrl}. Check VITE_SUPABASE_URL for this deployment.` + ); + }); + } + + return reachabilityProbe; +} + +export function getSupabaseConfigError() { + return supabaseConfigError; +} + +export async function getSupabase() { + if (supabaseClient) { + return supabaseClient; + } + + if (supabaseConfigError) { + throw supabaseConfigError; + } + + if (!supabaseConfig) { + throw new SupabaseConfigurationError('Supabase configuration is missing for this deployment.'); + } + + if (!supabaseClientPromise) { + supabaseClientPromise = (async () => { + await ensureSupabaseReachable(); + supabaseClient = createClient(supabaseConfig!.url, supabaseConfig!.anonKey); + return supabaseClient; + })().catch((error) => { + supabaseClientPromise = null; + throw error; + }); + } + + return supabaseClientPromise; +} diff --git a/Frontend/src/pages/LoginPage.tsx b/Frontend/src/pages/LoginPage.tsx index 14637ae..5101eac 100644 --- a/Frontend/src/pages/LoginPage.tsx +++ b/Frontend/src/pages/LoginPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { motion } from 'framer-motion'; import { Link, useNavigate } from 'react-router-dom'; import { useMutation } from '@tanstack/react-query'; @@ -9,8 +9,10 @@ import { EyeOff, ArrowRight, Github, - Loader2 + Loader2, + AlertTriangle, } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -25,8 +27,9 @@ import { DialogTrigger, } from '@/components/ui/dialog'; import { useAuthStore } from '@/store/authStore'; -import { authService } from '@/services/auth.service'; import { getApiErrorMessage } from '@/lib/api-client'; +import { getSupabase, getSupabaseConfigError } from '@/lib/supabase'; +import { authService } from '@/services/auth.service'; import { toast } from 'sonner'; function ForgotPasswordDialog() { @@ -94,12 +97,35 @@ export default function LoginPage() { const navigate = useNavigate(); const { login, oauthLogin, isLoading } = useAuthStore(); const [showPassword, setShowPassword] = useState(false); + const [authConfigError, setAuthConfigError] = useState( + getSupabaseConfigError()?.message ?? null + ); const [formData, setFormData] = useState({ email: '', password: '', rememberMe: false, }); + useEffect(() => { + let active = true; + + void getSupabase() + .then(() => { + if (active) { + setAuthConfigError(null); + } + }) + .catch((error) => { + if (active) { + setAuthConfigError(getApiErrorMessage(error)); + } + }); + + return () => { + active = false; + }; + }, []); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -141,11 +167,22 @@ export default function LoginPage() {

+ {authConfigError && ( + + + Google sign-in is unavailable + + {authConfigError} + + + )} + {/* Social Login */}
- diff --git a/Frontend/src/pages/SignupPage.tsx b/Frontend/src/pages/SignupPage.tsx index 61bc4c1..3b3c19c 100644 --- a/Frontend/src/pages/SignupPage.tsx +++ b/Frontend/src/pages/SignupPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { motion } from 'framer-motion'; import { Link, useNavigate } from 'react-router-dom'; import { @@ -11,9 +11,11 @@ import { Loader2, User, Building2, - Shield + Shield, + AlertTriangle, } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -28,6 +30,7 @@ import { } from '@/components/ui/select'; import { useAuthStore } from '@/store/authStore'; import { getApiErrorMessage } from '@/lib/api-client'; +import { getSupabase, getSupabaseConfigError } from '@/lib/supabase'; import { toast } from 'sonner'; const SIGNUP_ROLES = [ @@ -42,6 +45,9 @@ export default function SignupPage() { const { signup, oauthLogin, isLoading } = useAuthStore(); const [showPassword, setShowPassword] = useState(false); const [step, setStep] = useState(1); + const [authConfigError, setAuthConfigError] = useState( + getSupabaseConfigError()?.message ?? null + ); const [formData, setFormData] = useState({ name: '', email: '', @@ -51,6 +57,26 @@ export default function SignupPage() { agreeToTerms: false, }); + useEffect(() => { + let active = true; + + void getSupabase() + .then(() => { + if (active) { + setAuthConfigError(null); + } + }) + .catch((error) => { + if (active) { + setAuthConfigError(getApiErrorMessage(error)); + } + }); + + return () => { + active = false; + }; + }, []); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -111,6 +137,16 @@ export default function SignupPage() {

+ {authConfigError && ( + + + Google sign-in is unavailable + + {authConfigError} + + + )} + {/* Progress */}
{[1, 2].map((s) => ( @@ -130,6 +166,7 @@ export default function SignupPage() { - diff --git a/Frontend/src/services/auth.service.ts b/Frontend/src/services/auth.service.ts index 63f0a5e..9e13bf6 100644 --- a/Frontend/src/services/auth.service.ts +++ b/Frontend/src/services/auth.service.ts @@ -1,5 +1,7 @@ import apiClient from '@/lib/api-client'; import type { + ApiLoginResponse, + ApiTokenResponse, ApiRegisterResponse, ApiCurrentUser, ApiPasswordChange, @@ -9,6 +11,17 @@ import type { } from '@/types/api'; export const authService = { + async login( + email: string, + password: string, + ): Promise { + const { data } = await apiClient.post('/auth/login', { + email, + password, + }); + return data; + }, + async register( email: string, password: string, @@ -28,6 +41,13 @@ export const authService = { return data; }, + async refreshSession(refreshToken: string): Promise { + const { data } = await apiClient.post<{ message: string; tokens: ApiTokenResponse }>('/auth/refresh', { + refresh_token: refreshToken, + }); + return data.tokens; + }, + async getMe(): Promise { const { data } = await apiClient.get('/auth/me'); return data; @@ -46,6 +66,10 @@ export const authService = { return data; }, + async logout(): Promise { + await apiClient.post('/auth/logout'); + }, + async getConsent(): Promise { const { data } = await apiClient.get('/auth/consent'); return data; diff --git a/Frontend/src/store/authStore.ts b/Frontend/src/store/authStore.ts index 4118cd5..c3fca96 100644 --- a/Frontend/src/store/authStore.ts +++ b/Frontend/src/store/authStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { supabase } from '@/lib/supabase'; +import { getSupabase } from '@/lib/supabase'; import { authService } from '@/services/auth.service'; import { mapCurrentUserToFrontend, splitFullName } from '@/types/mappers'; import { queryClient } from '@/hooks/useApi'; @@ -30,7 +30,7 @@ interface AuthState { export const useAuthStore = create()( persist( - (set) => ({ + (set, get) => ({ user: null, accessToken: null, refreshToken: null, @@ -40,12 +40,11 @@ export const useAuthStore = create()( login: async (email: string, password: string) => { set({ isLoading: true }); try { - const { data, error } = await supabase.auth.signInWithPassword({ email, password }); - if (error) throw error; + const loginResponse = await authService.login(email, password); // Store tokens first so the API client interceptor can attach them to getMe() - const accessToken = data.session?.access_token ?? null; - const refreshToken = data.session?.refresh_token ?? null; + const accessToken = loginResponse.tokens.access_token ?? null; + const refreshToken = loginResponse.tokens.refresh_token ?? null; set({ accessToken, refreshToken }); // Fetch full user profile with RBAC data from backend @@ -66,6 +65,7 @@ export const useAuthStore = create()( oauthLogin: async () => { set({ isLoading: true }); try { + const supabase = await getSupabase(); const { error } = await supabase.auth.signInWithOAuth({ provider: 'google', options: { @@ -85,23 +85,7 @@ export const useAuthStore = create()( try { const { firstName, lastName } = splitFullName(name); - // Sign up with Supabase Auth - const { data, error } = await supabase.auth.signUp({ - email, - password, - options: { - data: { first_name: firstName, last_name: lastName }, - }, - }); - if (error) throw error; - - // Store tokens first so the API client interceptor can attach them - const accessToken = data.session?.access_token ?? null; - const refreshToken = data.session?.refresh_token ?? null; - set({ accessToken, refreshToken }); - - // Create the local user record in backend (org, role, etc.) - const registerResponse = await authService.register( + await authService.register( email, password, firstName, @@ -109,7 +93,14 @@ export const useAuthStore = create()( company || undefined, role || undefined, ); - const frontendUser = mapCurrentUserToFrontend(registerResponse.user); + + const loginResponse = await authService.login(email, password); + const accessToken = loginResponse.tokens.access_token ?? null; + const refreshToken = loginResponse.tokens.refresh_token ?? null; + set({ accessToken, refreshToken }); + + const meResponse = await authService.getMe(); + const frontendUser = mapCurrentUserToFrontend(meResponse); set({ user: frontendUser, @@ -123,7 +114,8 @@ export const useAuthStore = create()( }, logout: () => { - supabase.auth.signOut().catch(() => {}); + void authService.logout().catch(() => {}); + void getSupabase().then((supabase) => supabase.auth.signOut()).catch(() => {}); queryClient.clear(); set({ user: null, @@ -140,42 +132,106 @@ export const useAuthStore = create()( }, initAuth: () => { - const { data: { subscription } } = supabase.auth.onAuthStateChange( - async (event, session) => { - if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') { - if (session) { - set({ - accessToken: session.access_token, - refreshToken: session.refresh_token, - }); - try { - const meResponse = await authService.getMe(); - const frontendUser = mapCurrentUserToFrontend(meResponse); - set({ - user: frontendUser, - isAuthenticated: true, - isLoading: false, - }); - } catch { - // Backend may not be reachable yet; tokens are stored, - // user profile will be fetched on next navigation - } - } - } else if (event === 'SIGNED_OUT') { + let unsubscribe = () => {}; + let cancelled = false; + + const bootstrapStoredSession = async () => { + const { accessToken, refreshToken, isAuthenticated, user } = get(); + + if (!accessToken && !refreshToken) { + if (isAuthenticated || user) { queryClient.clear(); set({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false, + isLoading: false, }); } - }, - ); + return; + } + + set({ isLoading: true }); + + try { + const meResponse = await authService.getMe(); + if (cancelled) { + return; + } + + const frontendUser = mapCurrentUserToFrontend(meResponse); + set({ + user: frontendUser, + isAuthenticated: true, + isLoading: false, + }); + } catch { + if (cancelled) { + return; + } + + queryClient.clear(); + set({ + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + isLoading: false, + }); + } + }; + + void bootstrapStoredSession(); + + void getSupabase() + .then((supabase) => { + if (cancelled) { + return; + } + + const { data: { subscription } } = supabase.auth.onAuthStateChange( + async (event, session) => { + if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') { + if (session) { + set({ + accessToken: session.access_token, + refreshToken: session.refresh_token, + }); + try { + const meResponse = await authService.getMe(); + const frontendUser = mapCurrentUserToFrontend(meResponse); + set({ + user: frontendUser, + isAuthenticated: true, + isLoading: false, + }); + } catch { + // Backend may not be reachable yet; tokens are stored, + // user profile will be fetched on next navigation + } + } + } else if (event === 'SIGNED_OUT') { + queryClient.clear(); + set({ + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + }); + } + }, + ); + + unsubscribe = () => { + subscription.unsubscribe(); + }; + }) + .catch(() => {}); - // Return unsubscribe function for cleanup return () => { - subscription.unsubscribe(); + cancelled = true; + unsubscribe(); }; }, }), diff --git a/README.md b/README.md index 811bcaa..a7667b9 100644 --- a/README.md +++ b/README.md @@ -43,14 +43,17 @@ **Production**: [https://relaxed-gates.vercel.app](https://relaxed-gates.vercel.app) +Demo accounts (created by the seed script — all share the password `demo123`): + | Email | Password | Role | |-------|----------|------| -| `admin@taskpulse.demo` | `TaskPulse2024` | Super Admin | -| `orgadmin@taskpulse.demo` | `TaskPulse2024` | Org Admin | -| `manager@taskpulse.demo` | `TaskPulse2024` | Manager | -| `lead@taskpulse.demo` | `TaskPulse2024` | Team Lead | -| `dev@taskpulse.demo` | `TaskPulse2024` | Employee | -| `viewer@taskpulse.demo` | `TaskPulse2024` | Viewer | +| `admin@acme.com` | `demo123` | Org Admin | +| `manager@acme.com` | `demo123` | Manager | +| `lead@acme.com` | `demo123` | Team Lead | +| `dev1@acme.com` | `demo123` | Employee | +| `viewer@acme.com` | `demo123` | Viewer | + +> Additional seeded employees (`dev2`–`dev5@acme.com`) and a second team lead (`lead2@acme.com`) are also available, all with `demo123`. --- @@ -194,9 +197,19 @@ npm run dev ```bash cd backend + +# Full demo dataset (org, users, tasks, skills, automation, etc.) python scripts/seed_data.py + +# Login accounts only (org + demo users + their Supabase Auth records). +# Idempotent and safe to re-run — use this to (re)provision logins. +python scripts/seed_data.py --users-only ``` +> The seed provisions each demo account in **Supabase Auth** and links it via +> `supabase_auth_id`, since authentication is delegated to Supabase. A valid +> `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, and `DATABASE_URL` must be set. + ### 5. Access | Service | URL | diff --git a/backend/scripts/seed_data.py b/backend/scripts/seed_data.py index 5114752..2e48b66 100644 --- a/backend/scripts/seed_data.py +++ b/backend/scripts/seed_data.py @@ -35,11 +35,15 @@ from app.models.audit import AuditLog, ActorType, AuditAction from app.models.agent import Agent, AgentExecution, AgentType, AgentStatusDB, ExecutionStatus from app.core.security import hash_password +from app.supabase_client import get_supabase_client from app.utils.helpers import generate_uuid NOW = datetime.now(timezone.utc) +# Shared demo password for all seeded accounts. +DEMO_PASSWORD = "demo123" + def days_ago(d: int, h: int = 0) -> datetime: return NOW - timedelta(days=d, hours=h) @@ -49,6 +53,45 @@ def days_from_now(d: int) -> datetime: return NOW + timedelta(days=d) +def ensure_supabase_auth_user( + supabase, email: str, password: str, first_name: str, last_name: str +) -> str: + """Idempotently ensure a Supabase Auth user exists for ``email``. + + Authentication is delegated entirely to Supabase Auth (see auth_service), + and authenticated requests resolve the local user by ``supabase_auth_id``, + so every demo account must have a matching Supabase Auth record. Returns + the Supabase Auth user id. Safe to re-run: if the user already exists, its + existing id is returned instead of raising. + """ + try: + resp = supabase.auth.admin.create_user( + { + "email": email, + "password": password, + "email_confirm": True, + "user_metadata": {"first_name": first_name, "last_name": last_name}, + } + ) + return str(resp.user.id) + except Exception as create_err: + # Likely "email already registered" — look the user up to stay idempotent. + try: + existing = supabase.auth.admin.list_users() + users = existing if isinstance(existing, list) else getattr(existing, "users", []) + for u in users: + if (getattr(u, "email", "") or "").lower() == email.lower(): + return str(u.id) + except Exception as list_err: + raise RuntimeError( + f"Could not create or find Supabase Auth user {email}: " + f"create error={create_err}; list error={list_err}" + ) + raise RuntimeError( + f"Supabase Auth user {email} could not be created and was not found: {create_err}" + ) + + # ─── Data definitions ──────────────────────────────────────────────── DEMO_USERS = [ @@ -369,15 +412,20 @@ async def seed_database(): await session.flush() # ─── 2. Users ─────────────────────────────────────────────── - print("2/27 Creating 10 users...") - pw_hash = hash_password("demo123") + print("2/27 Creating 10 users (provisioning Supabase Auth)...") + supabase = get_supabase_client() + pw_hash = hash_password(DEMO_PASSWORD) users = [] for ud in DEMO_USERS: + auth_id = ensure_supabase_auth_user( + supabase, ud["email"], DEMO_PASSWORD, ud["first_name"], ud["last_name"] + ) user = User( id=generate_uuid(), org_id=org.id, email=ud["email"], password_hash=pw_hash, + supabase_auth_id=auth_id, first_name=ud["first_name"], last_name=ud["last_name"], role=ud["role"], @@ -1185,6 +1233,93 @@ async def seed_database(): await engine.dispose() +async def seed_users_only(): + """Idempotently provision ONLY the demo login accounts (org + users). + + Unlike ``seed_database()``, this creates no tasks/skills/automation/etc., so + it is safe to run against production: it touches only the demo organization + and the demo users, provisions their Supabase Auth records, and re-running + updates existing rows instead of duplicating them. + """ + from sqlalchemy import select + + # Reuse the app's engine/session so the Supabase pooler SSL context and + # statement_cache_size=0 settings are applied (a raw engine would fail to + # connect to the production pooler). + from app.database import AsyncSessionLocal, engine + + print("=" * 60) + print("Provisioning demo login accounts (users-only mode)") + print("=" * 60) + + supabase = get_supabase_client() + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with AsyncSessionLocal() as session: + # Organization — idempotent by slug. + org = ( + await session.execute( + select(Organization).where(Organization.slug == "acme-corp") + ) + ).scalar_one_or_none() + if org is None: + org = Organization( + id=generate_uuid(), + name="Acme Corporation", + slug="acme-corp", + description="Demo organization for TaskPulse AI.", + plan="professional", + settings_json=json.dumps({ + "timezone": "America/New_York", + "checkin_interval_hours": 3, + "notifications_enabled": True, + }), + is_active=True, + ) + session.add(org) + await session.flush() + print(f" + Created organization '{org.name}'") + else: + print(f" = Organization '{org.name}' already exists") + + pw_hash = hash_password(DEMO_PASSWORD) + for ud in DEMO_USERS: + auth_id = ensure_supabase_auth_user( + supabase, ud["email"], DEMO_PASSWORD, ud["first_name"], ud["last_name"] + ) + existing = ( + await session.execute(select(User).where(User.email == ud["email"])) + ).scalar_one_or_none() + if existing is None: + session.add(User( + id=generate_uuid(), + org_id=org.id, + email=ud["email"], + password_hash=pw_hash, + supabase_auth_id=auth_id, + first_name=ud["first_name"], + last_name=ud["last_name"], + role=ud["role"], + skill_level=ud["skill_level"], + is_active=True, + is_email_verified=True, + team_id=ud["team"], + timezone="America/New_York", + )) + print(f" + Created user {ud['email']} (auth_id={auth_id})") + else: + existing.supabase_auth_id = auth_id + existing.is_active = True + existing.is_email_verified = True + print(f" = Updated user {ud['email']} (auth_id={auth_id})") + await session.commit() + + await engine.dispose() + print(f"\nDone. Log in with any demo email and password: {DEMO_PASSWORD}") + print("=" * 60) + + async def clear_database(): """Clear all data from the database.""" print("Clearing database...") @@ -1198,7 +1333,10 @@ async def clear_database(): if __name__ == "__main__": import sys - if len(sys.argv) > 1 and sys.argv[1] == "--clear": + arg = sys.argv[1] if len(sys.argv) > 1 else "" + if arg == "--clear": asyncio.run(clear_database()) + elif arg == "--users-only": + asyncio.run(seed_users_only()) else: asyncio.run(seed_database())