import { create } from 'zustand'; import { Platform } from 'react-native'; import type { Session, User } from '@supabase/supabase-js'; import * as WebBrowser from 'expo-web-browser'; import * as Linking from 'expo-linking'; import * as AppleAuthentication from 'expo-apple-authentication'; import { supabase } from '../lib/supabase'; import { apiFetch } from '../lib/api'; import { getDeviceId } from '../lib/deviceId'; import Constants from 'expo-constants'; import i18n from '../lib/i18n'; import RebreakProtection from '../modules/rebreak-protection'; import { syncLanguageFromUserMetadata } from './language'; const SUPPORTED_LOCALES = ['de', 'en', 'fr', 'ar'] as const; type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; function currentLocale(): SupportedLocale { const raw = (i18n.language ?? 'en').split('-')[0].toLowerCase(); return (SUPPORTED_LOCALES as readonly string[]).includes(raw) ? (raw as SupportedLocale) : 'en'; } const API_BASE = (Constants.expoConfig?.extra?.apiUrl as string) ?? 'https://staging.rebreak.org'; async function syncExtensionCredentials(session: Session | null) { try { if (!session?.access_token) { await RebreakProtection.setExtensionCredentials('', '', API_BASE); return; } const deviceId = await getDeviceId(); await RebreakProtection.setExtensionCredentials(session.access_token, deviceId, API_BASE); } catch { // Best-effort; Extension kann bei fehlenden Credentials nicht melden. } } import { invalidateMe } from '../hooks/useMe'; import { useDevicesStore } from './devices'; import { useDeviceLimitStore } from './deviceLimit'; import { useProtectedDevicesStore } from './protectedDevices'; import { useMailConsentStore } from './mailConsent'; import { useCommunityStore } from './community'; import { useCoachStore } from './coach'; import { useNotificationStore } from './notifications'; import { useMailConnectDraft } from './mailConnectDraft'; import { useLyraVoiceStore } from './lyraVoice'; import { useNotificationPrefsStore } from './notificationPrefs'; 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; 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, type?: 'signup' | 'recovery') => Promise<{ error?: string }>; resendConfirmation: (email: string) => Promise<{ error?: string }>; updatePassword: (newPassword: 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, }); if (data.session?.user) { void syncLanguageFromUserMetadata(data.session.user); } void syncExtensionCredentials(data.session); supabase.auth.onAuthStateChange((_event, session) => { set({ session, user: session?.user ?? null }); if (session?.user) { void syncLanguageFromUserMetadata(session.user); } void syncExtensionCredentials(session); }); }, 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, avatar_id: metadata.avatarId, avatar_url: metadata.avatarUrl, locale: currentLocale(), }, // 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(); try { await WebBrowser.coolDownAsync(); } catch {} useDevicesStore.getState().reset(); useDeviceLimitStore.getState().reset(); useProtectedDevicesStore.getState().reset(); useMailConsentStore.getState().reset(); useCommunityStore.getState().reset(); useCoachStore.getState().reset(); useNotificationStore.getState().reset(); useMailConnectDraft.getState().reset(); useLyraVoiceStore.getState().reset().catch(() => {}); useNotificationPrefsStore.getState().reset().catch(() => {}); invalidateMe(); set({ session: null, user: null }); }, signInWithOAuth: async (provider) => { // Apple Sign-In native flow (iOS only) — KEIN Supabase-Apple-OAuth-Provider- // Config nötig (Bundle-ID = Apple-Client-ID). expo-apple-authentication // liefert identityToken → supabase.auth.signInWithIdToken verifiziert direkt // gegen Apple's public keys. App-Store-Submit-Pflicht weil Google-Sign-In // auch da ist (Apple Guideline 4.8). if (provider === 'apple') { if (Platform.OS !== 'ios') { return { error: 'Apple Sign-In ist nur auf iOS verfügbar.' }; } const available = await AppleAuthentication.isAvailableAsync(); if (!available) { return { error: 'Apple Sign-In auf diesem Gerät nicht verfügbar.' }; } try { const credential = await AppleAuthentication.signInAsync({ requestedScopes: [ AppleAuthentication.AppleAuthenticationScope.FULL_NAME, AppleAuthentication.AppleAuthenticationScope.EMAIL, ], }); if (!credential.identityToken) { return { error: 'Apple identityToken fehlt.' }; } const { data, error } = await supabase.auth.signInWithIdToken({ provider: 'apple', token: credential.identityToken, }); if (error) return { error: error.message }; set({ session: data.session, user: data.user ?? null }); return {}; } catch (e: any) { // User-Cancel ist kein Error — leere Antwort. if (e?.code === 'ERR_REQUEST_CANCELED') return {}; return { error: e?.message ?? 'Apple Sign-In fehlgeschlagen.' }; } } const redirectUri = Linking.createURL('auth/callback'); 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, type = 'signup') => { const { data, error } = await supabase.auth.verifyOtp({ email, token, type, }); 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 {}; }, updatePassword: async (newPassword) => { const { error } = await supabase.auth.updateUser({ password: newPassword }); if (error) return { error: error.message }; return {}; }, }));