chahinebrini 01d515d137 feat(rebreak-native): persistent FaceID-sign-in + iOS-grouped UI + Outlook guard + sparkline cooldowns
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>
2026-05-13 16:15:54 +02:00

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