From 82d8e0b60dcf0dd3880ce048d11ef72f74abeb9c Mon Sep 17 00:00:00 2001
From: shibinsp
Date: Tue, 23 Jun 2026 22:54:47 +0530
Subject: [PATCH 1/2] fix: route standard auth through backend API
Make email/password login, signup, and token refresh independent of the frontend Supabase URL so production auth keeps working when Vercel ships a bad browser Supabase config. Keep social sign-in behind a reachable Supabase client and surface a clear warning when that path is unavailable.
Fixes #21
Co-Authored-By: Beeax
---
Frontend/src/lib/api-client.ts | 48 +++++---
Frontend/src/lib/supabase.ts | 134 ++++++++++++++++++++--
Frontend/src/pages/LoginPage.tsx | 49 +++++++-
Frontend/src/pages/SignupPage.tsx | 47 +++++++-
Frontend/src/services/auth.service.ts | 24 ++++
Frontend/src/store/authStore.ts | 158 +++++++++++++++++---------
6 files changed, 376 insertions(+), 84 deletions(-)
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 */}
-
+ {authConfigError && (
+
+
+ Google sign-in is unavailable
+
+ {authConfigError}
+
+
+ )}
+
{/* Progress */}
{[1, 2].map((s) => (
@@ -130,6 +166,7 @@ export default function SignupPage() {
{
try {
await oauthLogin();
@@ -146,7 +183,11 @@ export default function SignupPage() {
Google
-
+
GitHub
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();
};
},
}),
From 028e8dcc82099a6a65c761ca98c9e90adedd867e Mon Sep 17 00:00:00 2001
From: shibinsp
Date: Fri, 26 Jun 2026 14:29:47 +0530
Subject: [PATCH 2/2] fix: provision demo logins via Supabase Auth and correct
README credentials
- seed_data.py: create Supabase Auth users and set supabase_auth_id so the
Supabase-based auth flow resolves seeded accounts; add idempotent
--users-only mode that reuses the app engine (Supabase pooler SSL)
- README: replace non-existent @taskpulse.demo accounts with the real
@acme.com / demo123 seed accounts and document the seed options
Refs #21
Co-Authored-By: Beeax
---
README.md | 25 ++++--
backend/scripts/seed_data.py | 144 ++++++++++++++++++++++++++++++++++-
2 files changed, 160 insertions(+), 9 deletions(-)
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())