Bug: User mit iOS-Sprache=Arabisch sah App auf Englisch wenn Localization.getLocales() auf seinem Setup nicht zuverlässig 'ar' zurückgab (iOS-Region≠Sprache, App-Override etc). Fix: bei sign-in (init() initial-getSession + onAuthStateChange für SIGNED_IN events) wird session.user.user_metadata.locale gelesen. Wenn AsyncStorage @rebreak/language NOCH NICHT gesetzt ist (User hat keine explicit Choice gemacht) → silent apply der server-locale (inkl. RTL-flip, KEIN Restart-Alert). Respektiert User-Choice: wenn AsyncStorage gefüllt ist (z.B. User hat manuell in Settings gewechselt), bleibt das gewinnen.
295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
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 i18n from '../lib/i18n';
|
||
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';
|
||
}
|
||
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<void>;
|
||
signInWithPassword: (email: string, password: string) => Promise<SignInResult>;
|
||
signUp: (
|
||
email: string,
|
||
password: string,
|
||
metadata: { username: string; avatarId: string; avatarUrl: string }
|
||
) => Promise<{ error?: string }>;
|
||
signOut: () => Promise<void>;
|
||
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<AuthState>((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);
|
||
}
|
||
|
||
supabase.auth.onAuthStateChange((_event, session) => {
|
||
set({ session, user: session?.user ?? null });
|
||
if (session?.user) {
|
||
void syncLanguageFromUserMetadata(session.user);
|
||
}
|
||
});
|
||
},
|
||
|
||
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 {};
|
||
},
|
||
}));
|