chahinebrini c477b300ad
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
Deploy Staging / Build backend (Nitro) (push) Has been cancelled
Deploy Staging / Deploy zu Hetzner (push) Has been cancelled
feat(ios): Extensions melden Protection-State ans Backend
- RebreakProtectionModule.setExtensionCredentials() speichert Token,
  deviceId + baseURL in App-Group Shared UserDefaults.
- Auth-Store ruft setExtensionCredentials bei Session-Änderungen auf.
- ContentFilter-Extension (FilterDataProvider) sendet bei stopFilter()
  /api/protection/event active=false mit x-extension-secret.
- PacketTunnel-Extension (PacketTunnelProvider) sendet bei stopTunnel()
  /api/protection/event active=false mit x-extension-secret.
2026-06-18 09:42:18 +02:00

315 lines
11 KiB
TypeScript
Raw Permalink 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 { 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<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);
}
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 {};
},
}));