From 5434254f743c48e554c1694f2b495f914b8fe52d Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Tue, 19 May 2026 10:49:23 +0200 Subject: [PATCH] feat(auth,mail): pw-reset OTP-flow + custom mail templates + account-switch cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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() --- .../app/(auth)/forgot-password.tsx | 72 +++---- .../app/(auth)/new-password.tsx | 133 ++++++++++++ apps/rebreak-native/app/(auth)/reset-otp.tsx | 194 ++++++++++++++++++ apps/rebreak-native/locales/de.json | 16 +- apps/rebreak-native/locales/en.json | 16 +- apps/rebreak-native/stores/auth.ts | 54 ++++- apps/rebreak-native/stores/community.ts | 3 + apps/rebreak-native/stores/deviceLimit.ts | 3 + apps/rebreak-native/stores/devices.ts | 3 + apps/rebreak-native/stores/lyraVoice.ts | 8 + apps/rebreak-native/stores/mailConsent.ts | 2 + .../stores/notificationPrefs.ts | 8 + .../rebreak-native/stores/protectedDevices.ts | 3 + backend/public/templates/confirmation.html | 55 +++++ backend/public/templates/email_change.html | 55 +++++ backend/public/templates/invite.html | 55 +++++ backend/public/templates/magic_link.html | 55 +++++ backend/public/templates/recovery.html | 55 +++++ 18 files changed, 737 insertions(+), 53 deletions(-) create mode 100644 apps/rebreak-native/app/(auth)/new-password.tsx create mode 100644 apps/rebreak-native/app/(auth)/reset-otp.tsx create mode 100644 backend/public/templates/confirmation.html create mode 100644 backend/public/templates/email_change.html create mode 100644 backend/public/templates/invite.html create mode 100644 backend/public/templates/magic_link.html create mode 100644 backend/public/templates/recovery.html diff --git a/apps/rebreak-native/app/(auth)/forgot-password.tsx b/apps/rebreak-native/app/(auth)/forgot-password.tsx index 6055c43..d90d2fb 100644 --- a/apps/rebreak-native/app/(auth)/forgot-password.tsx +++ b/apps/rebreak-native/app/(auth)/forgot-password.tsx @@ -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(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')} - {!sent ? ( - <> - + - {error && ( - - {error} - - )} - - - {loading ? ( - - ) : ( - {t('auth.resetPasswordSend')} - )} - - - ) : ( - - {t('auth.resetPasswordSent')} - - {t('auth.resetPasswordSentDescPrefix')}{email}{t('auth.resetPasswordSentDescSuffix')} - + {error && ( + + {error} )} + + {loading ? ( + + ) : ( + {t('auth.resetPasswordSend')} + )} + + router.back()} activeOpacity={0.7} diff --git a/apps/rebreak-native/app/(auth)/new-password.tsx b/apps/rebreak-native/app/(auth)/new-password.tsx new file mode 100644 index 0000000..4cbc27d --- /dev/null +++ b/apps/rebreak-native/app/(auth)/new-password.tsx @@ -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(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 ( + + + + {t('auth.newPasswordTitle')} + + + {t('auth.newPasswordSubtitle')} + + + + + + + {error && ( + + {error} + + )} + + {saved && ( + + {t('auth.newPasswordSaved')} + + )} + + + {loading ? ( + + ) : ( + {t('auth.newPasswordSave')} + )} + + + + {t('auth.newPasswordCancelLink')} + + + + ); +} diff --git a/apps/rebreak-native/app/(auth)/reset-otp.tsx b/apps/rebreak-native/app/(auth)/reset-otp.tsx new file mode 100644 index 0000000..19394d1 --- /dev/null +++ b/apps/rebreak-native/app/(auth)/reset-otp.tsx @@ -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(Array(OTP_LENGTH).fill('')); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [resendCooldown, setResendCooldown] = useState(0); + + const inputs = useRef>([]); + + 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 ( + + + + + + + + {t('auth.resetOtpTitle')} + + + {t('auth.resetOtpLine1')}{'\n'} + {email} + {'\n'}{t('auth.resetOtpLine2')} + + + + + {digits.map((digit, index) => ( + { 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 + /> + ))} + + + {error && ( + + {error} + + )} + + + {loading ? ( + + ) : ( + {t('auth.resetOtpConfirmBtn')} + )} + + + 0 || loading} + activeOpacity={0.7} + className="py-3 items-center" + > + + {t('auth.noCode')}{' '} + 0 ? 'text-neutral-400' : 'text-rebreak-500'} style={{ fontFamily: 'Nunito_600SemiBold' }}> + {resendCooldown > 0 ? t('auth.resendCooldown', { seconds: resendCooldown }) : t('auth.resetOtpResend')} + + + + + router.push('/(auth)/forgot-password')} + activeOpacity={0.7} + className="py-3 items-center mt-2" + > + {t('auth.resetOtpBackToForgot')} + + + + ); +} diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index a209a86..c1dc82d 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -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", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index aa278cc..941b5eb 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -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", diff --git a/apps/rebreak-native/stores/auth.ts b/apps/rebreak-native/stores/auth.ts index 22b7de0..3d51b8b 100644 --- a/apps/rebreak-native/stores/auth.ts +++ b/apps/rebreak-native/stores/auth.ts @@ -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; 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((set) => ({ @@ -107,6 +128,7 @@ export const useAuthStore = create((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((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((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((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 {}; + }, })); diff --git a/apps/rebreak-native/stores/community.ts b/apps/rebreak-native/stores/community.ts index d7bdca4..d7d6ec9 100644 --- a/apps/rebreak-native/stores/community.ts +++ b/apps/rebreak-native/stores/community.ts @@ -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((set, get) => ({ @@ -103,4 +104,6 @@ export const useCommunityStore = create((set, get) => ({ return { optimisticLikes: rest }; }); }, + + reset: () => set({ activeCategory: 'all', optimisticLikes: {} }), })); diff --git a/apps/rebreak-native/stores/deviceLimit.ts b/apps/rebreak-native/stores/deviceLimit.ts index b466933..df18cc7 100644 --- a/apps/rebreak-native/stores/deviceLimit.ts +++ b/apps/rebreak-native/stores/deviceLimit.ts @@ -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((set) => ({ @@ -32,4 +33,6 @@ export const useDeviceLimitStore = create((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' }), })); diff --git a/apps/rebreak-native/stores/devices.ts b/apps/rebreak-native/stores/devices.ts index 58425cf..d1b036d 100644 --- a/apps/rebreak-native/stores/devices.ts +++ b/apps/rebreak-native/stores/devices.ts @@ -26,6 +26,7 @@ type DevicesState = { removeDevice: (id: string) => Promise; requestRelease: (id: string) => Promise; cancelRelease: (id: string) => Promise; + reset: () => void; }; export const useDevicesStore = create((set) => ({ @@ -90,4 +91,6 @@ export const useDevicesStore = create((set) => ({ ), })); }, + + reset: () => set({ devices: [], maxDevices: 1, plan: 'free', loading: false }), })); diff --git a/apps/rebreak-native/stores/lyraVoice.ts b/apps/rebreak-native/stores/lyraVoice.ts index af92bd9..4c503e4 100644 --- a/apps/rebreak-native/stores/lyraVoice.ts +++ b/apps/rebreak-native/stores/lyraVoice.ts @@ -20,6 +20,7 @@ type LyraVoiceState = { init: () => Promise; setEnabled: (enabled: boolean) => Promise; toggle: () => Promise; + reset: () => Promise; }; export const useLyraVoiceStore = create((set, get) => ({ @@ -48,4 +49,11 @@ export const useLyraVoiceStore = create((set, get) => ({ const next = !get().enabled; await get().setEnabled(next); }, + + reset: async () => { + set({ enabled: false }); + try { + await AsyncStorage.removeItem(STORAGE_KEY); + } catch {} + }, })); diff --git a/apps/rebreak-native/stores/mailConsent.ts b/apps/rebreak-native/stores/mailConsent.ts index 59e6f11..f867141 100644 --- a/apps/rebreak-native/stores/mailConsent.ts +++ b/apps/rebreak-native/stores/mailConsent.ts @@ -11,6 +11,7 @@ type MailConsentState = { show: (connections: PendingConsentConnection[]) => void; hide: () => void; markConsented: () => void; + reset: () => void; }; export const useMailConsentStore = create((set) => ({ @@ -20,4 +21,5 @@ export const useMailConsentStore = create((set) => ({ show: (connections) => set({ visible: true, connections }), hide: () => set({ visible: false }), markConsented: () => set({ visible: false, connections: [] }), + reset: () => set({ visible: false, connections: [] }), })); diff --git a/apps/rebreak-native/stores/notificationPrefs.ts b/apps/rebreak-native/stores/notificationPrefs.ts index 5c678aa..ba59aef 100644 --- a/apps/rebreak-native/stores/notificationPrefs.ts +++ b/apps/rebreak-native/stores/notificationPrefs.ts @@ -13,6 +13,7 @@ type NotificationPrefsState = { setPushEnabled: (value: boolean) => Promise; setStreakReminderEnabled: (value: boolean) => Promise; setStreakReminderTime: (hour: number, minute: number) => Promise; + reset: () => Promise; }; async function persist(patch: Partial>) { @@ -60,4 +61,11 @@ export const useNotificationPrefsStore = create((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 {} + }, })); diff --git a/apps/rebreak-native/stores/protectedDevices.ts b/apps/rebreak-native/stores/protectedDevices.ts index 7b07754..19774b8 100644 --- a/apps/rebreak-native/stores/protectedDevices.ts +++ b/apps/rebreak-native/stores/protectedDevices.ts @@ -26,6 +26,7 @@ type ProtectedDevicesState = { enroll: (label: string, platform: 'mac' | 'windows') => Promise; confirmInstalled: (id: string) => Promise; remove: (id: string) => Promise<{ manualRemovalRequired: boolean }>; + reset: () => void; }; export const useProtectedDevicesStore = create((set, get) => ({ @@ -75,4 +76,6 @@ export const useProtectedDevicesStore = create((set, get) set((s) => ({ devices: s.devices.filter((d) => d.id !== id) })); return res; }, + + reset: () => set({ devices: [], loading: false, enrolling: false }), })); diff --git a/backend/public/templates/confirmation.html b/backend/public/templates/confirmation.html new file mode 100644 index 0000000..f555a49 --- /dev/null +++ b/backend/public/templates/confirmation.html @@ -0,0 +1,55 @@ + + + + + +ReBreak + + + + +
+ + + + + + + + +
+
ReBreak
+
+ {{ if eq .Data.locale "de" }} +

Willkommen bei ReBreak

+

Gib diesen Code in der App ein, um deine E-Mail-Adresse zu bestätigen:

+ {{ else if eq .Data.locale "fr" }} +

Bienvenue sur ReBreak

+

Saisis ce code dans l'application pour confirmer ton adresse e-mail :

+ {{ else if eq .Data.locale "ar" }} +

مرحبًا بك في ReBreak

+

أدخل هذا الرمز في التطبيق لتأكيد بريدك الإلكتروني:

+ {{ else }} +

Welcome to ReBreak

+

Enter this code in the app to confirm your email address:

+ {{ end }} + +
+
{{ .Token }}
+
+ + {{ if eq .Data.locale "de" }} +

Falls du dich nicht bei ReBreak registriert hast, kannst du diese E-Mail ignorieren.

+ {{ else if eq .Data.locale "fr" }} +

Si tu n'as pas créé de compte ReBreak, tu peux ignorer ce message.

+ {{ else if eq .Data.locale "ar" }} +

إذا لم تقم بإنشاء حساب على ReBreak، يمكنك تجاهل هذه الرسالة.

+ {{ else }} +

If you didn't sign up for ReBreak, you can safely ignore this email.

+ {{ end }} +
+

© ReBreak

+
+
+ + diff --git a/backend/public/templates/email_change.html b/backend/public/templates/email_change.html new file mode 100644 index 0000000..2d015cc --- /dev/null +++ b/backend/public/templates/email_change.html @@ -0,0 +1,55 @@ + + + + + +ReBreak + + + + +
+ + + + + + + + +
+
ReBreak
+
+ {{ if eq .Data.locale "de" }} +

E-Mail-Adresse ändern

+

Gib diesen Code in der App ein, um die Änderung deiner E-Mail-Adresse zu bestätigen:

+ {{ else if eq .Data.locale "fr" }} +

Modifier ton adresse e-mail

+

Saisis ce code dans l'application pour confirmer la modification de ton adresse e-mail :

+ {{ else if eq .Data.locale "ar" }} +

تغيير عنوان البريد الإلكتروني

+

أدخل هذا الرمز في التطبيق لتأكيد تغيير عنوان بريدك الإلكتروني:

+ {{ else }} +

Change your email address

+

Enter this code in the app to confirm your new email address:

+ {{ end }} + +
+
{{ .Token }}
+
+ + {{ if eq .Data.locale "de" }} +

Falls du diese Änderung nicht angefordert hast, kannst du diese E-Mail ignorieren — deine alte E-Mail-Adresse bleibt erhalten.

+ {{ else if eq .Data.locale "fr" }} +

Si tu n'as pas demandé ce changement, ignore ce message — ton adresse actuelle reste inchangée.

+ {{ else if eq .Data.locale "ar" }} +

إذا لم تطلب هذا التغيير، يمكنك تجاهل هذه الرسالة — سيبقى بريدك الحالي دون تغيير.

+ {{ else }} +

If you didn't request this change, you can ignore this email — your current address stays the same.

+ {{ end }} +
+

© ReBreak

+
+
+ + diff --git a/backend/public/templates/invite.html b/backend/public/templates/invite.html new file mode 100644 index 0000000..cb55128 --- /dev/null +++ b/backend/public/templates/invite.html @@ -0,0 +1,55 @@ + + + + + +ReBreak + + + + +
+ + + + + + + + +
+
ReBreak
+
+ {{ if eq .Data.locale "de" }} +

Du wurdest zu ReBreak eingeladen

+

Verwende diesen Code in der App, um deinen Account zu aktivieren:

+ {{ else if eq .Data.locale "fr" }} +

Tu as été invité·e sur ReBreak

+

Utilise ce code dans l'application pour activer ton compte :

+ {{ else if eq .Data.locale "ar" }} +

تمت دعوتك إلى ReBreak

+

استخدم هذا الرمز في التطبيق لتفعيل حسابك:

+ {{ else }} +

You've been invited to ReBreak

+

Use this code in the app to activate your account:

+ {{ end }} + +
+
{{ .Token }}
+
+ + {{ if eq .Data.locale "de" }} +

Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren.

+ {{ else if eq .Data.locale "fr" }} +

Si tu n'attendais pas cette invitation, tu peux ignorer ce message.

+ {{ else if eq .Data.locale "ar" }} +

إذا لم تكن تتوقع هذه الدعوة، يمكنك تجاهل هذه الرسالة.

+ {{ else }} +

If you weren't expecting this invite, you can ignore this email.

+ {{ end }} +
+

© ReBreak

+
+
+ + diff --git a/backend/public/templates/magic_link.html b/backend/public/templates/magic_link.html new file mode 100644 index 0000000..cc1f54f --- /dev/null +++ b/backend/public/templates/magic_link.html @@ -0,0 +1,55 @@ + + + + + +ReBreak + + + + +
+ + + + + + + + +
+
ReBreak
+
+ {{ if eq .Data.locale "de" }} +

Dein Login-Code

+

Gib diesen Code in der App ein, um dich anzumelden:

+ {{ else if eq .Data.locale "fr" }} +

Ton code de connexion

+

Saisis ce code dans l'application pour te connecter :

+ {{ else if eq .Data.locale "ar" }} +

رمز تسجيل الدخول

+

أدخل هذا الرمز في التطبيق لتسجيل الدخول:

+ {{ else }} +

Your sign-in code

+

Enter this code in the app to sign in:

+ {{ end }} + +
+
{{ .Token }}
+
+ + {{ if eq .Data.locale "de" }} +

Falls du keinen Login angefordert hast, kannst du diese E-Mail ignorieren.

+ {{ else if eq .Data.locale "fr" }} +

Si tu n'as pas demandé à te connecter, ignore ce message.

+ {{ else if eq .Data.locale "ar" }} +

إذا لم تطلب تسجيل الدخول، يمكنك تجاهل هذه الرسالة.

+ {{ else }} +

If you didn't request a sign-in, you can ignore this email.

+ {{ end }} +
+

© ReBreak

+
+
+ + diff --git a/backend/public/templates/recovery.html b/backend/public/templates/recovery.html new file mode 100644 index 0000000..fddc0d0 --- /dev/null +++ b/backend/public/templates/recovery.html @@ -0,0 +1,55 @@ + + + + + +ReBreak + + + + +
+ + + + + + + + +
+
ReBreak
+
+ {{ if eq .Data.locale "de" }} +

Passwort zurücksetzen

+

Gib diesen Code in der App ein, um ein neues Passwort zu setzen:

+ {{ else if eq .Data.locale "fr" }} +

Réinitialise ton mot de passe

+

Saisis ce code dans l'application pour définir un nouveau mot de passe :

+ {{ else if eq .Data.locale "ar" }} +

إعادة تعيين كلمة المرور

+

أدخل هذا الرمز في التطبيق لتعيين كلمة مرور جديدة:

+ {{ else }} +

Reset your password

+

Enter this code in the app to set a new password:

+ {{ end }} + +
+
{{ .Token }}
+
+ + {{ if eq .Data.locale "de" }} +

Falls du das nicht angefordert hast, kannst du diese E-Mail ignorieren — dein Passwort bleibt unverändert.

+ {{ else if eq .Data.locale "fr" }} +

Si tu n'as pas demandé cette réinitialisation, ignore ce message — ton mot de passe reste inchangé.

+ {{ else if eq .Data.locale "ar" }} +

إذا لم تطلب إعادة التعيين، تجاهل هذه الرسالة — لن تتغير كلمة المرور.

+ {{ else }} +

If you didn't request this, you can ignore this email — your password stays the same.

+ {{ end }} +
+

© ReBreak

+
+
+ +