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:
parent
b9b397b346
commit
5434254f74
@ -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,8 +51,6 @@ export default function ForgotPasswordScreen() {
|
||||
{t('auth.resetPasswordSubtitle')}
|
||||
</Text>
|
||||
|
||||
{!sent ? (
|
||||
<>
|
||||
<TextInput
|
||||
className="bg-neutral-100 rounded-xl mb-3"
|
||||
style={INPUT_STYLE}
|
||||
@ -86,15 +83,6 @@ export default function ForgotPasswordScreen() {
|
||||
<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>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
|
||||
133
apps/rebreak-native/app/(auth)/new-password.tsx
Normal file
133
apps/rebreak-native/app/(auth)/new-password.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
apps/rebreak-native/app/(auth)/reset-otp.tsx
Normal file
194
apps/rebreak-native/app/(auth)/reset-otp.tsx
Normal 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' }}>✉</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>
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 {};
|
||||
},
|
||||
}));
|
||||
|
||||
@ -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: {} }),
|
||||
}));
|
||||
|
||||
@ -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' }),
|
||||
}));
|
||||
|
||||
@ -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 }),
|
||||
}));
|
||||
|
||||
@ -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 {}
|
||||
},
|
||||
}));
|
||||
|
||||
@ -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: [] }),
|
||||
}));
|
||||
|
||||
@ -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 {}
|
||||
},
|
||||
}));
|
||||
|
||||
@ -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 }),
|
||||
}));
|
||||
|
||||
55
backend/public/templates/confirmation.html
Normal file
55
backend/public/templates/confirmation.html
Normal 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>
|
||||
55
backend/public/templates/email_change.html
Normal file
55
backend/public/templates/email_change.html
Normal 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>
|
||||
55
backend/public/templates/invite.html
Normal file
55
backend/public/templates/invite.html
Normal 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>
|
||||
55
backend/public/templates/magic_link.html
Normal file
55
backend/public/templates/magic_link.html
Normal 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>
|
||||
55
backend/public/templates/recovery.html
Normal file
55
backend/public/templates/recovery.html
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user