import type { SupportedLocale } from '../i18n/types.js'; import type { AppSupabaseClient } from '../supabase/client.js'; export interface SignupParams { email: string; // Login handle. Stored with original casing but uniqueness is case-insensitive // (citext). Must match ^[A-Za-z0-9_]{3,32}$. username: string; // Optional human-readable name. Defaults to username server-side if omitted. displayName?: string; // Invite code is mandatory (enforced by the handle_new_user trigger). inviteCode: string; // Deep link the magic-link email should bounce back to. // e.g. `chatapp://auth/callback` on mobile, `http://localhost:1420/auth/callback` on desktop dev. redirectTo: string; // Optional initial locale for the profile row. Defaults to 'en' server-side. locale?: SupportedLocale; } // Trigger signup. User receives a magic link email. Completion happens in // `completeSessionFromUrl` below once the callback fires. export async function signUpWithMagicLink( client: AppSupabaseClient, params: SignupParams, ): Promise { const { error } = await client.auth.signInWithOtp({ email: params.email, options: { emailRedirectTo: params.redirectTo, shouldCreateUser: true, data: { invite_code: params.inviteCode, // Preserve case. `profiles.username` is citext so uniqueness + lookups // stay case-insensitive regardless of stored casing. username: params.username.trim(), display_name: params.displayName ?? params.username, ...(params.locale ? { locale: params.locale } : {}), }, }, }); if (error) throw error; } // Trigger magic-link login for an existing user. No invite code needed. export async function loginWithMagicLink( client: AppSupabaseClient, email: string, redirectTo: string, ): Promise { const { error } = await client.auth.signInWithOtp({ email, options: { emailRedirectTo: redirectTo, shouldCreateUser: false, }, }); if (error) throw error; } // Parse a callback URL produced by the magic-link email and establish a session. // Works for both PKCE (`?code=…`) and legacy token-hash (`#access_token=…`). export async function completeSessionFromUrl( client: AppSupabaseClient, url: string, ): Promise { const u = new URL(url); // PKCE flow: exchange the code. const code = u.searchParams.get('code'); if (code) { const { error } = await client.auth.exchangeCodeForSession(code); if (error) throw error; return; } // Hash fragment flow. if (u.hash && u.hash.length > 1) { const hash = new URLSearchParams(u.hash.slice(1)); const access_token = hash.get('access_token'); const refresh_token = hash.get('refresh_token'); if (access_token && refresh_token) { const { error } = await client.auth.setSession({ access_token, refresh_token }); if (error) throw error; return; } } throw new Error('callback URL contained no auth params'); } export async function signOut(client: AppSupabaseClient): Promise { // `scope: 'local'` only ends the session in THIS client. Without it Supabase // defaults to 'global', which invalidates the user's refresh tokens // everywhere — meaning a logout in the browser would also kick the desktop // app (and vice versa) the next time it tries to refresh its token. const { error } = await client.auth.signOut({ scope: 'local' }); if (error) throw error; } // Verify a 6-digit OTP that arrived via magic-link email. Establishes a // session in the CURRENT webview — no browser redirect involved. Used by the // desktop/mobile apps where cross-origin redirects don't carry the session // back to the native webview. export async function verifyMagicLinkOtp( client: AppSupabaseClient, email: string, token: string, ): Promise { const { error } = await client.auth.verifyOtp({ email, token: token.trim(), type: 'email', }); if (error) throw error; }