Auth / FaceID — eingeloggt bleiben funktioniert jetzt: - AppLock-Init idempotent: late re-init durch router.replace-Re-Mount behält locked-State (fixt Endlosschleife: unlock → re-mount → init reset → lock) - LockScreen-Auto-Prompt nur wenn AppState=active (verhindert silent FaceID- Fail wenn LockScreen während background-Event mountet — User sah dann nur Fallback-Button) - index.tsx: wenn Session schon in AsyncStorage liegt → router.replace zu /(app), Landing wird übersprungen; early-return nach allen Hooks (Rules of Hooks) - WebBrowser.dismissAuthSession vor openAuthSessionAsync (verhindert "Another web browser is already open" nach abgebrochenen OAuth-Flows) UI — iOS-Grouped-Look auf Settings + Profile: - Neue Theme-Tokens groupedBg (#F2F2F7 / #000) + card (#fff / #1c1c1e), identisch zu Apples systemGroupedBackground / secondarySystemGroupedBackground - settings.tsx + profile/index.tsx + profile/[userId].tsx: Page-BG → groupedBg - StreakSection / UrgeStatsCard / DemographicsAccordion / StatsBar / ApprovedDomainsList: Card-BG colors.surface → colors.card Mail-Connect — Outlook-Tile entschärft: - Microsoft hat App-Passwords für consumer-Outlook (.com/hotmail/live/msn) im September 2024 abgeschaltet, der bisherige Guide-Flow ist seit ~8 Monaten wirkungslos → AUTHENTICATIONFAILED - Tile bleibt sichtbar mit opacity 0.45, "Kommt bald"-Sub-Label, disabled=true - Provider-Typ um disabled? + disabledLabelKey? erweitert (wiederverwendbar) - Backend-OAuth-Plan unter backend/docs/mail-outlook-oauth-plan.md (mo) → Generisches AuthMethod-Framework (app_password | oauth) geplant Profile — Cooldown-Verlauf als Sparkline statt Endlos-Liste: - 8 Wochen-Buckets, Bar-Höhe nach Frequenz (cap 5/Woche), leere Wochen als 2px-Flatlines - Sub-Label: "{n} Cooldowns in 8 Wochen · Ø 1 pro {avg} Wochen · zuletzt {date}" - Neutral formuliert (Sucht-/Stigma-Sensibilität: Cooldown = Schutz-Pause, kein Rückfall) - useProfileData.ts liefert rawStartedAt (ISO) zusätzlich zum formatierten Wert - i18n-Keys unter profile.cooldown.* in DE + EN Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
168 lines
5.4 KiB
TypeScript
168 lines
5.4 KiB
TypeScript
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';
|
||
|
||
WebBrowser.maybeCompleteAuthSession();
|
||
|
||
type AuthState = {
|
||
user: User | null;
|
||
session: Session | null;
|
||
loading: boolean;
|
||
|
||
init: () => Promise<void>;
|
||
signInWithPassword: (email: string, password: string) => Promise<{ error?: string }>;
|
||
signUp: (
|
||
email: string,
|
||
password: string,
|
||
metadata: { username: string; firstName?: string; lastName?: 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) => Promise<{ error?: string }>;
|
||
resendConfirmation: (email: 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,
|
||
});
|
||
|
||
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 });
|
||
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();
|
||
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,
|
||
},
|
||
});
|
||
|
||
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 {};
|
||
},
|
||
}));
|