feat(auth,mail): pw-reset OTP-flow + custom mail templates + account-switch cleanup

- Phase 3 PW-Reset: 3 screens (forgot-password → reset-otp → new-password),
  verifyOtp({type:'recovery'}), new updatePassword() action
- Custom Brevo-Mail templates (backend/public/templates/) — 5 HTMLs with
  go-template i18n (de/en/fr/ar incl. RTL for AR), OTP-only (no link),
  ReBreak branding
- signUp metadata.data.locale aus i18n.language → templates resolven Sprache
- Account-Switch-Bug fix: signOut() resettet alle 10 user-spezifischen stores
  + invalidateMe()
This commit is contained in:
chahinebrini 2026-05-19 10:49:23 +02:00
parent b9b397b346
commit 5434254f74
18 changed files with 737 additions and 53 deletions

View File

@ -28,7 +28,6 @@ export default function ForgotPasswordScreen() {
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
const onSubmit = async () => {
@ -41,7 +40,7 @@ export default function ForgotPasswordScreen() {
setError(res.error);
return;
}
setSent(true);
router.push(`/(auth)/reset-otp?email=${encodeURIComponent(email.trim())}`);
};
return (
@ -52,50 +51,39 @@ export default function ForgotPasswordScreen() {
{t('auth.resetPasswordSubtitle')}
</Text>
{!sent ? (
<>
<TextInput
className="bg-neutral-100 rounded-xl mb-3"
style={INPUT_STYLE}
placeholder={t('auth.emailPlaceholder')}
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
autoFocus
/>
<TextInput
className="bg-neutral-100 rounded-xl mb-3"
style={INPUT_STYLE}
placeholder={t('auth.emailPlaceholder')}
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
autoFocus
/>
{error && (
<View className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 mb-3">
<Text className="text-red-600 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>{error}</Text>
</View>
)}
<TouchableOpacity
onPress={onSubmit}
disabled={loading || !email.trim()}
activeOpacity={0.8}
className="bg-rebreak-500 rounded-xl items-center mt-1 disabled:opacity-40"
style={{ paddingVertical: 16 }}
>
{loading ? (
<ActivityIndicator color="white" size="small" />
) : (
<Text className="text-white text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.resetPasswordSend')}</Text>
)}
</TouchableOpacity>
</>
) : (
<View className="bg-green-50 border border-green-200 rounded-xl px-5 py-6 mb-4">
<Text className="text-green-700 text-base mb-2" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.resetPasswordSent')}</Text>
<Text className="text-green-600 text-sm leading-5" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.resetPasswordSentDescPrefix')}<Text style={{ fontFamily: 'Nunito_600SemiBold' }}>{email}</Text>{t('auth.resetPasswordSentDescSuffix')}
</Text>
{error && (
<View className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 mb-3">
<Text className="text-red-600 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>{error}</Text>
</View>
)}
<TouchableOpacity
onPress={onSubmit}
disabled={loading || !email.trim()}
activeOpacity={0.8}
className="bg-rebreak-500 rounded-xl items-center mt-1 disabled:opacity-40"
style={{ paddingVertical: 16 }}
>
{loading ? (
<ActivityIndicator color="white" size="small" />
) : (
<Text className="text-white text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.resetPasswordSend')}</Text>
)}
</TouchableOpacity>
<TouchableOpacity
onPress={() => router.back()}
activeOpacity={0.7}

View File

@ -0,0 +1,133 @@
import { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
ActivityIndicator,
} from 'react-native';
import { useRouter } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../stores/auth';
import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen';
const INPUT_STYLE = {
fontSize: 16,
lineHeight: 22,
paddingVertical: 14,
paddingHorizontal: 16,
color: '#0a0a0a',
fontFamily: 'Nunito_400Regular',
} as const;
export default function NewPasswordScreen() {
const router = useRouter();
const { t } = useTranslation();
const { updatePassword, signOut } = useAuthStore();
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const onSubmit = async () => {
if (!password || !confirm) return;
if (password.length < 8) {
setError(t('auth.passwordMin8'));
return;
}
if (password !== confirm) {
setError(t('auth.newPasswordMismatch'));
return;
}
setError(null);
setLoading(true);
const res = await updatePassword(password);
setLoading(false);
if (res.error) {
setError(res.error);
return;
}
setSaved(true);
router.replace('/(app)');
};
const onCancel = async () => {
await signOut();
router.replace('/(auth)/signin');
};
return (
<SafeAreaView className="flex-1 bg-white">
<KeyboardAwareScreen contentContainerStyle={{ paddingHorizontal: 24, justifyContent: 'center' }}>
<Text className="text-3xl text-neutral-900 mb-2" style={{ fontFamily: 'Nunito_700Bold' }}>
{t('auth.newPasswordTitle')}
</Text>
<Text className="text-base text-neutral-500 mb-8 leading-6" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.newPasswordSubtitle')}
</Text>
<TextInput
className="bg-neutral-100 rounded-xl mb-3"
style={INPUT_STYLE}
placeholder={t('auth.newPasswordPlaceholder')}
placeholderTextColor="#a3a3a3"
secureTextEntry
autoComplete="new-password"
autoCapitalize="none"
value={password}
onChangeText={setPassword}
autoFocus
/>
<TextInput
className="bg-neutral-100 rounded-xl mb-3"
style={INPUT_STYLE}
placeholder={t('auth.newPasswordConfirmPlaceholder')}
placeholderTextColor="#a3a3a3"
secureTextEntry
autoComplete="new-password"
autoCapitalize="none"
value={confirm}
onChangeText={setConfirm}
/>
{error && (
<View className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 mb-3">
<Text className="text-red-600 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>{error}</Text>
</View>
)}
{saved && (
<View className="bg-green-50 border border-green-200 rounded-xl px-4 py-3 mb-3">
<Text className="text-green-700 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>{t('auth.newPasswordSaved')}</Text>
</View>
)}
<TouchableOpacity
onPress={onSubmit}
disabled={loading || !password || !confirm}
activeOpacity={0.8}
className="bg-rebreak-500 rounded-xl items-center mt-1 disabled:opacity-40"
style={{ paddingVertical: 16 }}
>
{loading ? (
<ActivityIndicator color="white" size="small" />
) : (
<Text className="text-white text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.newPasswordSave')}</Text>
)}
</TouchableOpacity>
<TouchableOpacity
onPress={onCancel}
activeOpacity={0.7}
className="py-4 items-center mt-2"
>
<Text className="text-neutral-500 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>{t('auth.newPasswordCancelLink')}</Text>
</TouchableOpacity>
</KeyboardAwareScreen>
</SafeAreaView>
);
}

View File

@ -0,0 +1,194 @@
import { useState, useEffect, useRef } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
ActivityIndicator,
} from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../stores/auth';
import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen';
const OTP_LENGTH = 6;
const OTP_INPUT_STYLE = {
fontSize: 20,
fontFamily: 'Nunito_700Bold',
color: '#0a0a0a',
textAlign: 'center' as const,
width: 48,
height: 56,
borderRadius: 12,
};
export default function ResetOtpScreen() {
const router = useRouter();
const { t } = useTranslation();
const params = useLocalSearchParams<{ email: string }>();
const email = decodeURIComponent(params.email ?? '');
const { verifyOtp, resetPasswordForEmail } = useAuthStore();
const [digits, setDigits] = useState<string[]>(Array(OTP_LENGTH).fill(''));
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [resendCooldown, setResendCooldown] = useState(0);
const inputs = useRef<Array<TextInput | null>>([]);
useEffect(() => {
if (!email) {
router.replace('/(auth)/forgot-password');
}
}, [email]);
useEffect(() => {
if (resendCooldown <= 0) return;
const timer = setInterval(() => {
setResendCooldown((c) => {
if (c <= 1) { clearInterval(timer); return 0; }
return c - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [resendCooldown]);
const otp = digits.join('');
const handleDigit = (value: string, index: number) => {
if (value.length === OTP_LENGTH && /^\d+$/.test(value)) {
const next = value.split('');
setDigits(next);
inputs.current[OTP_LENGTH - 1]?.focus();
return;
}
const digit = value.replace(/\D/g, '').slice(-1);
const next = [...digits];
next[index] = digit;
setDigits(next);
if (digit && index < OTP_LENGTH - 1) {
inputs.current[index + 1]?.focus();
}
};
const handleKeyPress = (key: string, index: number) => {
if (key === 'Backspace' && !digits[index] && index > 0) {
inputs.current[index - 1]?.focus();
}
};
const verify = async () => {
if (otp.length < OTP_LENGTH || loading) return;
setError(null);
setLoading(true);
const res = await verifyOtp(email, otp, 'recovery');
setLoading(false);
if (res.error) {
setError(res.error);
setDigits(Array(OTP_LENGTH).fill(''));
inputs.current[0]?.focus();
return;
}
router.replace(`/(auth)/new-password?email=${encodeURIComponent(email)}`);
};
const resend = async () => {
if (resendCooldown > 0) return;
setError(null);
const res = await resetPasswordForEmail(email);
if (res.error) {
setError(res.error);
return;
}
setResendCooldown(60);
};
return (
<SafeAreaView className="flex-1 bg-white">
<KeyboardAwareScreen contentContainerStyle={{ paddingHorizontal: 24, justifyContent: 'center' }}>
<View className="items-center mb-8">
<View className="w-16 h-16 bg-rebreak-500/10 rounded-full items-center justify-center mb-4">
<Text className="text-3xl" style={{ fontFamily: 'Nunito_400Regular' }}>&#x2709;</Text>
</View>
<Text className="text-2xl text-neutral-900 text-center mb-2" style={{ fontFamily: 'Nunito_700Bold' }}>
{t('auth.resetOtpTitle')}
</Text>
<Text className="text-sm text-neutral-500 text-center leading-5" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.resetOtpLine1')}{'\n'}
<Text className="text-neutral-900" style={{ fontFamily: 'Nunito_600SemiBold' }}>{email}</Text>
{'\n'}{t('auth.resetOtpLine2')}
</Text>
</View>
<View className="flex-row justify-center gap-3 mb-6">
{digits.map((digit, index) => (
<TextInput
key={index}
ref={(ref) => { inputs.current[index] = ref; }}
style={[
OTP_INPUT_STYLE,
{
backgroundColor: '#f5f5f5',
borderWidth: 2,
borderColor: digit ? '#f59e0b' : '#e5e5e5',
},
]}
value={digit}
onChangeText={(val) => handleDigit(val, index)}
onKeyPress={({ nativeEvent }) => handleKeyPress(nativeEvent.key, index)}
keyboardType="number-pad"
maxLength={OTP_LENGTH}
editable={!loading}
selectTextOnFocus
/>
))}
</View>
{error && (
<View className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 mb-4">
<Text className="text-red-600 text-sm text-center" style={{ fontFamily: 'Nunito_400Regular' }}>{error}</Text>
</View>
)}
<TouchableOpacity
onPress={verify}
disabled={otp.length < OTP_LENGTH || loading}
activeOpacity={0.8}
className="bg-rebreak-500 rounded-xl items-center mb-4 disabled:opacity-40"
style={{ paddingVertical: 16 }}
>
{loading ? (
<ActivityIndicator color="white" size="small" />
) : (
<Text className="text-white text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.resetOtpConfirmBtn')}</Text>
)}
</TouchableOpacity>
<TouchableOpacity
onPress={resend}
disabled={resendCooldown > 0 || loading}
activeOpacity={0.7}
className="py-3 items-center"
>
<Text className="text-neutral-500 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.noCode')}{' '}
<Text className={resendCooldown > 0 ? 'text-neutral-400' : 'text-rebreak-500'} style={{ fontFamily: 'Nunito_600SemiBold' }}>
{resendCooldown > 0 ? t('auth.resendCooldown', { seconds: resendCooldown }) : t('auth.resetOtpResend')}
</Text>
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => router.push('/(auth)/forgot-password')}
activeOpacity={0.7}
className="py-3 items-center mt-2"
>
<Text className="text-neutral-400 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>{t('auth.resetOtpBackToForgot')}</Text>
</TouchableOpacity>
</KeyboardAwareScreen>
</SafeAreaView>
);
}

View File

@ -82,7 +82,21 @@
"device_locked_countdown": "Freigabe läuft — noch %{remaining}",
"device_locked_email_hint": "Wenn du dein Original-Konto nicht mehr kennst: Schau in deinem E-Mail-Postfach nach 'Rebreak Gerät gesperrt' — wir haben dir eine Mail geschickt.",
"device_locked_use_original": "Mit Original-Account anmelden",
"device_locked_back": "Zurück zur Anmeldung"
"device_locked_back": "Zurück zur Anmeldung",
"resetOtpTitle": "Code eingeben",
"resetOtpLine1": "Wir haben einen 6-stelligen Code gesendet an",
"resetOtpLine2": "Bitte gib ihn hier ein.",
"resetOtpConfirmBtn": "Weiter",
"resetOtpResend": "Code erneut senden",
"resetOtpBackToForgot": "Zurück",
"newPasswordTitle": "Neues Passwort",
"newPasswordSubtitle": "Wähle ein Passwort mit mindestens 8 Zeichen.",
"newPasswordPlaceholder": "Neues Passwort",
"newPasswordConfirmPlaceholder": "Passwort bestätigen",
"newPasswordSave": "Passwort speichern",
"newPasswordSaved": "Passwort wurde geändert.",
"newPasswordMismatch": "Passwörter stimmen nicht überein.",
"newPasswordCancelLink": "Abbrechen und neu anmelden"
},
"landing": {
"appName": "Rebreak",

View File

@ -82,7 +82,21 @@
"device_locked_countdown": "Release in progress — %{remaining} left",
"device_locked_email_hint": "If you no longer know your original account: check your email inbox for 'Rebreak device locked' — we sent you a message.",
"device_locked_use_original": "Sign in with original account",
"device_locked_back": "Back to sign in"
"device_locked_back": "Back to sign in",
"resetOtpTitle": "Enter code",
"resetOtpLine1": "We sent a 6-digit code to",
"resetOtpLine2": "Please enter it below.",
"resetOtpConfirmBtn": "Continue",
"resetOtpResend": "Resend code",
"resetOtpBackToForgot": "Back",
"newPasswordTitle": "New password",
"newPasswordSubtitle": "Choose a password with at least 8 characters.",
"newPasswordPlaceholder": "New password",
"newPasswordConfirmPlaceholder": "Confirm password",
"newPasswordSave": "Save password",
"newPasswordSaved": "Password updated.",
"newPasswordMismatch": "Passwords don't match.",
"newPasswordCancelLink": "Cancel and sign in again"
},
"landing": {
"appName": "Rebreak",

View File

@ -6,6 +6,26 @@ 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';
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();
@ -32,8 +52,9 @@ type AuthState = {
signOut: () => Promise<void>;
signInWithOAuth: (provider: 'google' | 'apple') => Promise<{ error?: string }>;
resetPasswordForEmail: (email: string) => Promise<{ error?: string }>;
verifyOtp: (email: string, token: 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) => ({
@ -107,6 +128,7 @@ export const useAuthStore = create<AuthState>((set) => ({
last_name: metadata.lastName ?? '',
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',
@ -119,14 +141,22 @@ export const useAuthStore = create<AuthState>((set) => ({
signOut: async () => {
await supabase.auth.signOut();
// Google-OAuth-Cookie in SafariViewController/Chrome Custom Tabs leeren,
// sonst springt der nächste Sign-in stillschweigend auf den vorigen Account
// statt den Account-Picker zu zeigen.
try {
await WebBrowser.coolDownAsync();
} catch {
// ignore
}
} 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 });
},
@ -233,11 +263,11 @@ export const useAuthStore = create<AuthState>((set) => ({
return {};
},
verifyOtp: async (email, token) => {
verifyOtp: async (email, token, type = 'signup') => {
const { data, error } = await supabase.auth.verifyOtp({
email,
token,
type: 'signup',
type,
});
if (error) return { error: error.message };
if (!data.session) return { error: 'Bestätigung fehlgeschlagen bitte erneut versuchen.' };
@ -250,4 +280,10 @@ export const useAuthStore = create<AuthState>((set) => ({
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 {};
},
}));

View File

@ -69,6 +69,7 @@ type CommunityState = {
applyOptimisticLike: (postId: string, currentLike: 'like' | null, currentCount: number) => { newLike: 'like' | null; newCount: number };
revertOptimisticLike: (postId: string) => void;
clearOptimisticLike: (postId: string) => void;
reset: () => void;
};
export const useCommunityStore = create<CommunityState>((set, get) => ({
@ -103,4 +104,6 @@ export const useCommunityStore = create<CommunityState>((set, get) => ({
return { optimisticLikes: rest };
});
},
reset: () => set({ activeCategory: 'all', optimisticLikes: {} }),
}));

View File

@ -20,6 +20,7 @@ type DeviceLimitState = {
show: (devices: DeviceLimitDevice[], max: number, plan: string) => void;
hide: () => void;
removeDevice: (id: string) => void;
reset: () => void;
};
export const useDeviceLimitStore = create<DeviceLimitState>((set) => ({
@ -32,4 +33,6 @@ export const useDeviceLimitStore = create<DeviceLimitState>((set) => ({
hide: () => set({ visible: false }),
removeDevice: (id) =>
set((s) => ({ devices: s.devices.filter((d) => d.id !== id) })),
reset: () => set({ visible: false, devices: [], max: 0, plan: 'free' }),
}));

View File

@ -26,6 +26,7 @@ type DevicesState = {
removeDevice: (id: string) => Promise<void>;
requestRelease: (id: string) => Promise<void>;
cancelRelease: (id: string) => Promise<void>;
reset: () => void;
};
export const useDevicesStore = create<DevicesState>((set) => ({
@ -90,4 +91,6 @@ export const useDevicesStore = create<DevicesState>((set) => ({
),
}));
},
reset: () => set({ devices: [], maxDevices: 1, plan: 'free', loading: false }),
}));

View File

@ -20,6 +20,7 @@ type LyraVoiceState = {
init: () => Promise<void>;
setEnabled: (enabled: boolean) => Promise<void>;
toggle: () => Promise<void>;
reset: () => Promise<void>;
};
export const useLyraVoiceStore = create<LyraVoiceState>((set, get) => ({
@ -48,4 +49,11 @@ export const useLyraVoiceStore = create<LyraVoiceState>((set, get) => ({
const next = !get().enabled;
await get().setEnabled(next);
},
reset: async () => {
set({ enabled: false });
try {
await AsyncStorage.removeItem(STORAGE_KEY);
} catch {}
},
}));

View File

@ -11,6 +11,7 @@ type MailConsentState = {
show: (connections: PendingConsentConnection[]) => void;
hide: () => void;
markConsented: () => void;
reset: () => void;
};
export const useMailConsentStore = create<MailConsentState>((set) => ({
@ -20,4 +21,5 @@ export const useMailConsentStore = create<MailConsentState>((set) => ({
show: (connections) => set({ visible: true, connections }),
hide: () => set({ visible: false }),
markConsented: () => set({ visible: false, connections: [] }),
reset: () => set({ visible: false, connections: [] }),
}));

View File

@ -13,6 +13,7 @@ type NotificationPrefsState = {
setPushEnabled: (value: boolean) => Promise<void>;
setStreakReminderEnabled: (value: boolean) => Promise<void>;
setStreakReminderTime: (hour: number, minute: number) => Promise<void>;
reset: () => Promise<void>;
};
async function persist(patch: Partial<Pick<NotificationPrefsState, 'pushEnabled' | 'streakReminderEnabled' | 'streakReminderTime'>>) {
@ -60,4 +61,11 @@ export const useNotificationPrefsStore = create<NotificationPrefsState>((set, ge
set({ streakReminderTime });
await persist({ streakReminderTime });
},
reset: async () => {
set({ pushEnabled: false, streakReminderEnabled: false, streakReminderTime: { hour: 9, minute: 0 } });
try {
await AsyncStorage.removeItem(STORAGE_KEY);
} catch {}
},
}));

View File

@ -26,6 +26,7 @@ type ProtectedDevicesState = {
enroll: (label: string, platform: 'mac' | 'windows') => Promise<EnrollResult>;
confirmInstalled: (id: string) => Promise<void>;
remove: (id: string) => Promise<{ manualRemovalRequired: boolean }>;
reset: () => void;
};
export const useProtectedDevicesStore = create<ProtectedDevicesState>((set, get) => ({
@ -75,4 +76,6 @@ export const useProtectedDevicesStore = create<ProtectedDevicesState>((set, get)
set((s) => ({ devices: s.devices.filter((d) => d.id !== id) }));
return res;
},
reset: () => set({ devices: [], loading: false, enrolling: false }),
}));

View File

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="{{ if .Data.locale }}{{ .Data.locale }}{{ else }}en{{ end }}" dir="{{ if eq .Data.locale "ar" }}rtl{{ else }}ltr{{ end }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ReBreak</title>
</head>
<body style="margin:0;padding:0;background:#f5f5f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#0a0a0a;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td align="center" style="padding:32px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:480px;background:#ffffff;border-radius:16px;overflow:hidden;">
<tr><td align="center" style="padding:32px 24px 24px;background:#0a0a0a;">
<div style="color:#ffffff;font-size:26px;font-weight:700;letter-spacing:-0.5px;line-height:1;">Re<span style="color:#f59e0b;">B</span>reak</div>
</td></tr>
<tr><td style="padding:32px 24px;">
{{ if eq .Data.locale "de" }}
<h1 style="font-size:22px;font-weight:700;color:#0a0a0a;margin:0 0 12px;">Willkommen bei ReBreak</h1>
<p style="font-size:16px;line-height:24px;color:#525252;margin:0 0 24px;">Gib diesen Code in der App ein, um deine E-Mail-Adresse zu bestätigen:</p>
{{ else if eq .Data.locale "fr" }}
<h1 style="font-size:22px;font-weight:700;color:#0a0a0a;margin:0 0 12px;">Bienvenue sur ReBreak</h1>
<p style="font-size:16px;line-height:24px;color:#525252;margin:0 0 24px;">Saisis ce code dans l'application pour confirmer ton adresse e-mail :</p>
{{ else if eq .Data.locale "ar" }}
<h1 style="font-size:22px;font-weight:700;color:#0a0a0a;margin:0 0 12px;text-align:right;">مرحبًا بك في ReBreak</h1>
<p style="font-size:16px;line-height:24px;color:#525252;margin:0 0 24px;text-align:right;">أدخل هذا الرمز في التطبيق لتأكيد بريدك الإلكتروني:</p>
{{ else }}
<h1 style="font-size:22px;font-weight:700;color:#0a0a0a;margin:0 0 12px;">Welcome to ReBreak</h1>
<p style="font-size:16px;line-height:24px;color:#525252;margin:0 0 24px;">Enter this code in the app to confirm your email address:</p>
{{ end }}
<div style="background:#fafafa;border:1px solid #e5e5e5;border-radius:12px;padding:24px 16px;text-align:center;margin:0 0 24px;">
<div style="font-size:36px;font-weight:700;letter-spacing:10px;color:#f59e0b;font-family:'SF Mono','Menlo','Courier New',monospace;line-height:1;">{{ .Token }}</div>
</div>
{{ if eq .Data.locale "de" }}
<p style="font-size:13px;line-height:20px;color:#a3a3a3;margin:0;">Falls du dich nicht bei ReBreak registriert hast, kannst du diese E-Mail ignorieren.</p>
{{ else if eq .Data.locale "fr" }}
<p style="font-size:13px;line-height:20px;color:#a3a3a3;margin:0;">Si tu n'as pas créé de compte ReBreak, tu peux ignorer ce message.</p>
{{ else if eq .Data.locale "ar" }}
<p style="font-size:13px;line-height:20px;color:#a3a3a3;margin:0;text-align:right;">إذا لم تقم بإنشاء حساب على ReBreak، يمكنك تجاهل هذه الرسالة.</p>
{{ else }}
<p style="font-size:13px;line-height:20px;color:#a3a3a3;margin:0;">If you didn't sign up for ReBreak, you can safely ignore this email.</p>
{{ end }}
</td></tr>
<tr><td align="center" style="padding:16px 24px 24px;background:#fafafa;border-top:1px solid #f0f0f0;">
<p style="font-size:11px;color:#a3a3a3;margin:0;">© ReBreak</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>

View File

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="{{ if .Data.locale }}{{ .Data.locale }}{{ else }}en{{ end }}" dir="{{ if eq .Data.locale "ar" }}rtl{{ else }}ltr{{ end }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ReBreak</title>
</head>
<body style="margin:0;padding:0;background:#f5f5f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#0a0a0a;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td align="center" style="padding:32px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:480px;background:#ffffff;border-radius:16px;overflow:hidden;">
<tr><td align="center" style="padding:32px 24px 24px;background:#0a0a0a;">
<div style="color:#ffffff;font-size:26px;font-weight:700;letter-spacing:-0.5px;line-height:1;">Re<span style="color:#f59e0b;">B</span>reak</div>
</td></tr>
<tr><td style="padding:32px 24px;">
{{ if eq .Data.locale "de" }}
<h1 style="font-size:22px;font-weight:700;color:#0a0a0a;margin:0 0 12px;">E-Mail-Adresse ändern</h1>
<p style="font-size:16px;line-height:24px;color:#525252;margin:0 0 24px;">Gib diesen Code in der App ein, um die Änderung deiner E-Mail-Adresse zu bestätigen:</p>
{{ else if eq .Data.locale "fr" }}
<h1 style="font-size:22px;font-weight:700;color:#0a0a0a;margin:0 0 12px;">Modifier ton adresse e-mail</h1>
<p style="font-size:16px;line-height:24px;color:#525252;margin:0 0 24px;">Saisis ce code dans l'application pour confirmer la modification de ton adresse e-mail :</p>
{{ else if eq .Data.locale "ar" }}
<h1 style="font-size:22px;font-weight:700;color:#0a0a0a;margin:0 0 12px;text-align:right;">تغيير عنوان البريد الإلكتروني</h1>
<p style="font-size:16px;line-height:24px;color:#525252;margin:0 0 24px;text-align:right;">أدخل هذا الرمز في التطبيق لتأكيد تغيير عنوان بريدك الإلكتروني:</p>
{{ else }}
<h1 style="font-size:22px;font-weight:700;color:#0a0a0a;margin:0 0 12px;">Change your email address</h1>
<p style="font-size:16px;line-height:24px;color:#525252;margin:0 0 24px;">Enter this code in the app to confirm your new email address:</p>
{{ end }}
<div style="background:#fafafa;border:1px solid #e5e5e5;border-radius:12px;padding:24px 16px;text-align:center;margin:0 0 24px;">
<div style="font-size:36px;font-weight:700;letter-spacing:10px;color:#f59e0b;font-family:'SF Mono','Menlo','Courier New',monospace;line-height:1;">{{ .Token }}</div>
</div>
{{ if eq .Data.locale "de" }}
<p style="font-size:13px;line-height:20px;color:#a3a3a3;margin:0;">Falls du diese Änderung nicht angefordert hast, kannst du diese E-Mail ignorieren — deine alte E-Mail-Adresse bleibt erhalten.</p>
{{ else if eq .Data.locale "fr" }}
<p style="font-size:13px;line-height:20px;color:#a3a3a3;margin:0;">Si tu n'as pas demandé ce changement, ignore ce message — ton adresse actuelle reste inchangée.</p>
{{ else if eq .Data.locale "ar" }}
<p style="font-size:13px;line-height:20px;color:#a3a3a3;margin:0;text-align:right;">إذا لم تطلب هذا التغيير، يمكنك تجاهل هذه الرسالة — سيبقى بريدك الحالي دون تغيير.</p>
{{ else }}
<p style="font-size:13px;line-height:20px;color:#a3a3a3;margin:0;">If you didn't request this change, you can ignore this email — your current address stays the same.</p>
{{ end }}
</td></tr>
<tr><td align="center" style="padding:16px 24px 24px;background:#fafafa;border-top:1px solid #f0f0f0;">
<p style="font-size:11px;color:#a3a3a3;margin:0;">© ReBreak</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>

View File

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="{{ if .Data.locale }}{{ .Data.locale }}{{ else }}en{{ end }}" dir="{{ if eq .Data.locale "ar" }}rtl{{ else }}ltr{{ end }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ReBreak</title>
</head>
<body style="margin:0;padding:0;background:#f5f5f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#0a0a0a;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td align="center" style="padding:32px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:480px;background:#ffffff;border-radius:16px;overflow:hidden;">
<tr><td align="center" style="padding:32px 24px 24px;background:#0a0a0a;">
<div style="color:#ffffff;font-size:26px;font-weight:700;letter-spacing:-0.5px;line-height:1;">Re<span style="color:#f59e0b;">B</span>reak</div>
</td></tr>
<tr><td style="padding:32px 24px;">
{{ if eq .Data.locale "de" }}
<h1 style="font-size:22px;font-weight:700;color:#0a0a0a;margin:0 0 12px;">Du wurdest zu ReBreak eingeladen</h1>
<p style="font-size:16px;line-height:24px;color:#525252;margin:0 0 24px;">Verwende diesen Code in der App, um deinen Account zu aktivieren:</p>
{{ else if eq .Data.locale "fr" }}
<h1 style="font-size:22px;font-weight:700;color:#0a0a0a;margin:0 0 12px;">Tu as été invité·e sur ReBreak</h1>
<p style="font-size:16px;line-height:24px;color:#525252;margin:0 0 24px;">Utilise ce code dans l'application pour activer ton compte :</p>
{{ else if eq .Data.locale "ar" }}
<h1 style="font-size:22px;font-weight:700;color:#0a0a0a;margin:0 0 12px;text-align:right;">تمت دعوتك إلى ReBreak</h1>
<p style="font-size:16px;line-height:24px;color:#525252;margin:0 0 24px;text-align:right;">استخدم هذا الرمز في التطبيق لتفعيل حسابك:</p>
{{ else }}
<h1 style="font-size:22px;font-weight:700;color:#0a0a0a;margin:0 0 12px;">You've been invited to ReBreak</h1>
<p style="font-size:16px;line-height:24px;color:#525252;margin:0 0 24px;">Use this code in the app to activate your account:</p>
{{ end }}
<div style="background:#fafafa;border:1px solid #e5e5e5;border-radius:12px;padding:24px 16px;text-align:center;margin:0 0 24px;">
<div style="font-size:36px;font-weight:700;letter-spacing:10px;color:#f59e0b;font-family:'SF Mono','Menlo','Courier New',monospace;line-height:1;">{{ .Token }}</div>
</div>
{{ if eq .Data.locale "de" }}
<p style="font-size:13px;line-height:20px;color:#a3a3a3;margin:0;">Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren.</p>
{{ else if eq .Data.locale "fr" }}
<p style="font-size:13px;line-height:20px;color:#a3a3a3;margin:0;">Si tu n'attendais pas cette invitation, tu peux ignorer ce message.</p>
{{ else if eq .Data.locale "ar" }}
<p style="font-size:13px;line-height:20px;color:#a3a3a3;margin:0;text-align:right;">إذا لم تكن تتوقع هذه الدعوة، يمكنك تجاهل هذه الرسالة.</p>
{{ else }}
<p style="font-size:13px;line-height:20px;color:#a3a3a3;margin:0;">If you weren't expecting this invite, you can ignore this email.</p>
{{ end }}
</td></tr>
<tr><td align="center" style="padding:16px 24px 24px;background:#fafafa;border-top:1px solid #f0f0f0;">
<p style="font-size:11px;color:#a3a3a3;margin:0;">© ReBreak</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>

View File

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="{{ if .Data.locale }}{{ .Data.locale }}{{ else }}en{{ end }}" dir="{{ if eq .Data.locale "ar" }}rtl{{ else }}ltr{{ end }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ReBreak</title>
</head>
<body style="margin:0;padding:0;background:#f5f5f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#0a0a0a;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td align="center" style="padding:32px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:480px;background:#ffffff;border-radius:16px;overflow:hidden;">
<tr><td align="center" style="padding:32px 24px 24px;background:#0a0a0a;">
<div style="color:#ffffff;font-size:26px;font-weight:700;letter-spacing:-0.5px;line-height:1;">Re<span style="color:#f59e0b;">B</span>reak</div>
</td></tr>
<tr><td style="padding:32px 24px;">
{{ if eq .Data.locale "de" }}
<h1 style="font-size:22px;font-weight:700;color:#0a0a0a;margin:0 0 12px;">Dein Login-Code</h1>
<p style="font-size:16px;line-height:24px;color:#525252;margin:0 0 24px;">Gib diesen Code in der App ein, um dich anzumelden:</p>
{{ else if eq .Data.locale "fr" }}
<h1 style="font-size:22px;font-weight:700;color:#0a0a0a;margin:0 0 12px;">Ton code de connexion</h1>
<p style="font-size:16px;line-height:24px;color:#525252;margin:0 0 24px;">Saisis ce code dans l'application pour te connecter :</p>
{{ else if eq .Data.locale "ar" }}
<h1 style="font-size:22px;font-weight:700;color:#0a0a0a;margin:0 0 12px;text-align:right;">رمز تسجيل الدخول</h1>
<p style="font-size:16px;line-height:24px;color:#525252;margin:0 0 24px;text-align:right;">أدخل هذا الرمز في التطبيق لتسجيل الدخول:</p>
{{ else }}
<h1 style="font-size:22px;font-weight:700;color:#0a0a0a;margin:0 0 12px;">Your sign-in code</h1>
<p style="font-size:16px;line-height:24px;color:#525252;margin:0 0 24px;">Enter this code in the app to sign in:</p>
{{ end }}
<div style="background:#fafafa;border:1px solid #e5e5e5;border-radius:12px;padding:24px 16px;text-align:center;margin:0 0 24px;">
<div style="font-size:36px;font-weight:700;letter-spacing:10px;color:#f59e0b;font-family:'SF Mono','Menlo','Courier New',monospace;line-height:1;">{{ .Token }}</div>
</div>
{{ if eq .Data.locale "de" }}
<p style="font-size:13px;line-height:20px;color:#a3a3a3;margin:0;">Falls du keinen Login angefordert hast, kannst du diese E-Mail ignorieren.</p>
{{ else if eq .Data.locale "fr" }}
<p style="font-size:13px;line-height:20px;color:#a3a3a3;margin:0;">Si tu n'as pas demandé à te connecter, ignore ce message.</p>
{{ else if eq .Data.locale "ar" }}
<p style="font-size:13px;line-height:20px;color:#a3a3a3;margin:0;text-align:right;">إذا لم تطلب تسجيل الدخول، يمكنك تجاهل هذه الرسالة.</p>
{{ else }}
<p style="font-size:13px;line-height:20px;color:#a3a3a3;margin:0;">If you didn't request a sign-in, you can ignore this email.</p>
{{ end }}
</td></tr>
<tr><td align="center" style="padding:16px 24px 24px;background:#fafafa;border-top:1px solid #f0f0f0;">
<p style="font-size:11px;color:#a3a3a3;margin:0;">© ReBreak</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>

View File

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="{{ if .Data.locale }}{{ .Data.locale }}{{ else }}en{{ end }}" dir="{{ if eq .Data.locale "ar" }}rtl{{ else }}ltr{{ end }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ReBreak</title>
</head>
<body style="margin:0;padding:0;background:#f5f5f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#0a0a0a;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td align="center" style="padding:32px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:480px;background:#ffffff;border-radius:16px;overflow:hidden;">
<tr><td align="center" style="padding:32px 24px 24px;background:#0a0a0a;">
<div style="color:#ffffff;font-size:26px;font-weight:700;letter-spacing:-0.5px;line-height:1;">Re<span style="color:#f59e0b;">B</span>reak</div>
</td></tr>
<tr><td style="padding:32px 24px;">
{{ if eq .Data.locale "de" }}
<h1 style="font-size:22px;font-weight:700;color:#0a0a0a;margin:0 0 12px;">Passwort zurücksetzen</h1>
<p style="font-size:16px;line-height:24px;color:#525252;margin:0 0 24px;">Gib diesen Code in der App ein, um ein neues Passwort zu setzen:</p>
{{ else if eq .Data.locale "fr" }}
<h1 style="font-size:22px;font-weight:700;color:#0a0a0a;margin:0 0 12px;">Réinitialise ton mot de passe</h1>
<p style="font-size:16px;line-height:24px;color:#525252;margin:0 0 24px;">Saisis ce code dans l'application pour définir un nouveau mot de passe :</p>
{{ else if eq .Data.locale "ar" }}
<h1 style="font-size:22px;font-weight:700;color:#0a0a0a;margin:0 0 12px;text-align:right;">إعادة تعيين كلمة المرور</h1>
<p style="font-size:16px;line-height:24px;color:#525252;margin:0 0 24px;text-align:right;">أدخل هذا الرمز في التطبيق لتعيين كلمة مرور جديدة:</p>
{{ else }}
<h1 style="font-size:22px;font-weight:700;color:#0a0a0a;margin:0 0 12px;">Reset your password</h1>
<p style="font-size:16px;line-height:24px;color:#525252;margin:0 0 24px;">Enter this code in the app to set a new password:</p>
{{ end }}
<div style="background:#fafafa;border:1px solid #e5e5e5;border-radius:12px;padding:24px 16px;text-align:center;margin:0 0 24px;">
<div style="font-size:36px;font-weight:700;letter-spacing:10px;color:#f59e0b;font-family:'SF Mono','Menlo','Courier New',monospace;line-height:1;">{{ .Token }}</div>
</div>
{{ if eq .Data.locale "de" }}
<p style="font-size:13px;line-height:20px;color:#a3a3a3;margin:0;">Falls du das nicht angefordert hast, kannst du diese E-Mail ignorieren — dein Passwort bleibt unverändert.</p>
{{ else if eq .Data.locale "fr" }}
<p style="font-size:13px;line-height:20px;color:#a3a3a3;margin:0;">Si tu n'as pas demandé cette réinitialisation, ignore ce message — ton mot de passe reste inchangé.</p>
{{ else if eq .Data.locale "ar" }}
<p style="font-size:13px;line-height:20px;color:#a3a3a3;margin:0;text-align:right;">إذا لم تطلب إعادة التعيين، تجاهل هذه الرسالة — لن تتغير كلمة المرور.</p>
{{ else }}
<p style="font-size:13px;line-height:20px;color:#a3a3a3;margin:0;">If you didn't request this, you can ignore this email — your password stays the same.</p>
{{ end }}
</td></tr>
<tr><td align="center" style="padding:16px 24px 24px;background:#fafafa;border-top:1px solid #f0f0f0;">
<p style="font-size:11px;color:#a3a3a3;margin:0;">© ReBreak</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>