chahinebrini 73f70b5e28 fix(language): auto-sync from user_metadata.locale at sign-in
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.
2026-05-19 22:02:34 +02:00

295 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 {};
},
}));