import { create } from 'zustand'; import type { Session, User } from '@supabase/supabase-js'; import * as WebBrowser from 'expo-web-browser'; import * as Linking from 'expo-linking'; import { supabase } from '../lib/supabase'; import { apiFetch } from '../lib/api'; WebBrowser.maybeCompleteAuthSession(); export type DeviceLockedError = { type: 'DEVICE_LOCKED'; lockedUntil: string | null; releaseRequestable: boolean; }; type SignInResult = { error?: string; deviceLocked?: DeviceLockedError }; type AuthState = { user: User | null; session: Session | null; loading: boolean; init: () => Promise; signInWithPassword: (email: string, password: string) => Promise; signUp: ( email: string, password: string, metadata: { username: string; firstName?: string; lastName?: string; avatarId: string; avatarUrl: string } ) => Promise<{ error?: string }>; signOut: () => Promise; signInWithOAuth: (provider: 'google' | 'apple') => Promise<{ error?: string }>; resetPasswordForEmail: (email: string) => Promise<{ error?: string }>; verifyOtp: (email: string, token: string) => Promise<{ error?: string }>; resendConfirmation: (email: string) => Promise<{ error?: string }>; }; export const useAuthStore = create((set) => ({ user: null, session: null, loading: true, init: async () => { const { data } = await supabase.auth.getSession(); set({ session: data.session, user: data.session?.user ?? null, loading: false, }); supabase.auth.onAuthStateChange((_event, session) => { set({ session, user: session?.user ?? null }); }); }, signInWithPassword: async (email, password) => { const { data, error } = await supabase.auth.signInWithPassword({ email, password }); if (error) return { error: error.message }; set({ session: data.session, user: data.user }); // After Supabase auth succeeds, check device binding via the register endpoint. // If the device is already bound to a different account the backend returns 409 // DEVICE_LOCKED. We sign back out of Supabase in that case — the user cannot // proceed on this device until the lock is released. // TODO(backend): move this check into POST /api/devices/register so it fires // automatically with x-device-id. Currently requires explicit call here. try { await apiFetch('/api/devices/check-lock', { method: 'POST' }); } catch (e: any) { const msg: string = e?.message ?? ''; const status = parseInt(msg.match(/^API (\d+)/)?.[1] ?? '0', 10); if (status === 409) { try { const raw = msg.replace(/^API \d+: /, ''); const parsed = JSON.parse(raw); const lockData = parsed?.data ?? parsed; if (lockData?.error === 'DEVICE_LOCKED') { await supabase.auth.signOut(); set({ session: null, user: null }); return { deviceLocked: { type: 'DEVICE_LOCKED', lockedUntil: lockData.lockedUntil ?? null, releaseRequestable: lockData.releaseRequestable ?? true, }, }; } } catch { // JSON parse failed — not a DEVICE_LOCKED response, ignore } } // Non-409 errors from the check are non-fatal (device check is best-effort) } return {}; }, signUp: async (email, password, metadata) => { const { data, error } = await supabase.auth.signUp({ email, password, options: { data: { username: metadata.username, first_name: metadata.firstName ?? '', last_name: metadata.lastName ?? '', avatar_id: metadata.avatarId, avatar_url: metadata.avatarUrl, }, // Deep-link redirect for email confirmation — scheme registered in app.config.ts emailRedirectTo: 'rebreak://auth/confirm', }, }); if (error) return { error: error.message }; set({ session: data.session, user: data.user ?? null }); return {}; }, signOut: async () => { await supabase.auth.signOut(); // Google-OAuth-Cookie in SafariViewController/Chrome Custom Tabs leeren, // sonst springt der nächste Sign-in stillschweigend auf den vorigen Account // statt den Account-Picker zu zeigen. try { await WebBrowser.coolDownAsync(); } catch { // ignore } set({ session: null, user: null }); }, signInWithOAuth: async (provider) => { const redirectUri = Linking.createURL('auth/callback'); if (provider === 'apple') { // TODO: configure Apple Sign-In // Requires expo-apple-authentication to be installed + Apple Developer entitlement. // Apple Client ID = Bundle ID (org.rebreak.app) for native flow. // For now we fall through to the Supabase OAuth web flow as a temporary path. } const { data, error } = await supabase.auth.signInWithOAuth({ provider, options: { redirectTo: redirectUri, skipBrowserRedirect: true, // Google: prompt=select_account erzwingt den Account-Picker, // auch wenn der Browser noch eine aktive Google-Session hat. ...(provider === 'google' ? { queryParams: { prompt: 'select_account' } } : {}), }, }); if (error) return { error: error.message }; if (!data.url) return { error: 'Kein OAuth-URL erhalten' }; // Cleanup eines evtl. noch offenen WebBrowser-Sessions aus einem vorherigen, // abgebrochenen OAuth-Versuch — sonst wirft openAuthSessionAsync mit // „Another web browser is already open". Idempotent, safe auch wenn nichts // offen ist. try { await WebBrowser.dismissAuthSession(); } catch { // ignore } const result = await WebBrowser.openAuthSessionAsync(data.url, redirectUri); if (result.type !== 'success') { return result.type === 'cancel' ? {} : { error: 'OAuth fehlgeschlagen' }; } // Extract tokens from the deep-link URL fragment const url = result.url; const params = new URLSearchParams(url.split('#')[1] ?? url.split('?')[1] ?? ''); const accessToken = params.get('access_token'); const refreshToken = params.get('refresh_token'); if (!accessToken || !refreshToken) { // Session may already be set via onAuthStateChange — check const { data: sessionData } = await supabase.auth.getSession(); if (sessionData.session) { set({ session: sessionData.session, user: sessionData.session.user }); return {}; } return { error: 'Session konnte nicht gelesen werden' }; } const { data: sessionData, error: sessionError } = await supabase.auth.setSession({ access_token: accessToken, refresh_token: refreshToken, }); if (sessionError) return { error: sessionError.message }; set({ session: sessionData.session, user: sessionData.session?.user ?? null }); return {}; }, resetPasswordForEmail: async (email) => { const { error } = await supabase.auth.resetPasswordForEmail(email, { redirectTo: 'rebreak://auth/reset-password', }); if (error) return { error: error.message }; return {}; }, verifyOtp: async (email, token) => { const { data, error } = await supabase.auth.verifyOtp({ email, token, type: 'signup', }); if (error) return { error: error.message }; if (!data.session) return { error: 'Bestätigung fehlgeschlagen – bitte erneut versuchen.' }; set({ session: data.session, user: data.user ?? null }); return {}; }, resendConfirmation: async (email) => { const { error } = await supabase.auth.resend({ type: 'signup', email }); if (error) return { error: error.message }; return {}; }, }));