feat(rebreak-native): persistent FaceID-sign-in + iOS-grouped UI + Outlook guard + sparkline cooldowns

Auth / FaceID — eingeloggt bleiben funktioniert jetzt:
- AppLock-Init idempotent: late re-init durch router.replace-Re-Mount behält
  locked-State (fixt Endlosschleife: unlock → re-mount → init reset → lock)
- LockScreen-Auto-Prompt nur wenn AppState=active (verhindert silent FaceID-
  Fail wenn LockScreen während background-Event mountet — User sah dann nur
  Fallback-Button)
- index.tsx: wenn Session schon in AsyncStorage liegt → router.replace zu /(app),
  Landing wird übersprungen; early-return nach allen Hooks (Rules of Hooks)
- WebBrowser.dismissAuthSession vor openAuthSessionAsync (verhindert
  "Another web browser is already open" nach abgebrochenen OAuth-Flows)

UI — iOS-Grouped-Look auf Settings + Profile:
- Neue Theme-Tokens groupedBg (#F2F2F7 / #000) + card (#fff / #1c1c1e),
  identisch zu Apples systemGroupedBackground / secondarySystemGroupedBackground
- settings.tsx + profile/index.tsx + profile/[userId].tsx: Page-BG → groupedBg
- StreakSection / UrgeStatsCard / DemographicsAccordion / StatsBar /
  ApprovedDomainsList: Card-BG colors.surface → colors.card

Mail-Connect — Outlook-Tile entschärft:
- Microsoft hat App-Passwords für consumer-Outlook (.com/hotmail/live/msn) im
  September 2024 abgeschaltet, der bisherige Guide-Flow ist seit ~8 Monaten
  wirkungslos → AUTHENTICATIONFAILED
- Tile bleibt sichtbar mit opacity 0.45, "Kommt bald"-Sub-Label, disabled=true
- Provider-Typ um disabled? + disabledLabelKey? erweitert (wiederverwendbar)
- Backend-OAuth-Plan unter backend/docs/mail-outlook-oauth-plan.md (mo)
  → Generisches AuthMethod-Framework (app_password | oauth) geplant

Profile — Cooldown-Verlauf als Sparkline statt Endlos-Liste:
- 8 Wochen-Buckets, Bar-Höhe nach Frequenz (cap 5/Woche), leere Wochen als
  2px-Flatlines
- Sub-Label: "{n} Cooldowns in 8 Wochen · Ø 1 pro {avg} Wochen · zuletzt {date}"
- Neutral formuliert (Sucht-/Stigma-Sensibilität: Cooldown = Schutz-Pause,
  kein Rückfall)
- useProfileData.ts liefert rawStartedAt (ISO) zusätzlich zum formatierten Wert
- i18n-Keys unter profile.cooldown.* in DE + EN

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-13 16:15:54 +02:00
parent a3f892ddac
commit 01d515d137
18 changed files with 785 additions and 143 deletions

View File

@ -4,6 +4,7 @@ import Svg, { Defs, RadialGradient, Rect, Stop } from 'react-native-svg';
import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../stores/auth';
const { width: SW, height: SH } = Dimensions.get('window');
@ -12,6 +13,19 @@ export default function LandingScreen() {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
// Reaktiver Routing-Fix für „eingeloggt bleiben": wenn beim Cold-Start (oder
// nach einem `router.replace('/')` aus dem LockScreen-Sign-Out) bereits eine
// gültige Session in AsyncStorage liegt, überspringen wir das Landing und
// schicken den User direkt in `(app)`.
const session = useAuthStore((s) => s.session);
const loading = useAuthStore((s) => s.loading);
useEffect(() => {
if (!loading && session) {
router.replace('/(app)');
}
}, [loading, session, router]);
const glowTopOpacity = useRef(new Animated.Value(0.5)).current;
const glowCenterOpacity = useRef(new Animated.Value(0)).current;
const glowCenterScale = useRef(new Animated.Value(0.6)).current;
@ -91,6 +105,11 @@ export default function LandingScreen() {
logoPulse, taglineOpacity, taglineTranslateY, ctaOpacity, ctaTranslateY, footerOpacity,
]);
// Early-return MUSS nach allen Hooks stehen (Rules of Hooks) — sonst wirft
// React "Rendered fewer hooks than expected" wenn sich loading/session zwischen
// Renders ändert.
if (loading || session) return null;
return (
<View style={{ flex: 1, backgroundColor: '#0f172a', overflow: 'hidden' }}>
{/* Top breathing glow */}

View File

@ -57,7 +57,7 @@ function ForeignStat({ value, label }: StatProps) {
<View
style={{
flex: 1,
backgroundColor: colors.bg,
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
@ -101,11 +101,11 @@ export default function ForeignProfileScreen() {
const planStyle = planColors[profile.plan];
return (
<View style={{ flex: 1, backgroundColor: colors.bg }}>
<View style={{ flex: 1, backgroundColor: colors.groupedBg }}>
<View
style={{
paddingTop: insets.top,
backgroundColor: colors.bg,
backgroundColor: colors.groupedBg,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}}
@ -243,7 +243,7 @@ export default function ForeignProfileScreen() {
<View style={{
paddingVertical: 11,
borderRadius: 12,
backgroundColor: colors.bg,
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
alignItems: 'center',
@ -291,7 +291,7 @@ export default function ForeignProfileScreen() {
</Text>
<View
style={{
backgroundColor: colors.bg,
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,

View File

@ -152,7 +152,7 @@ export default function ProfileScreen() {
}
return (
<View style={{ flex: 1, backgroundColor: colors.bg }}>
<View style={{ flex: 1, backgroundColor: colors.groupedBg }}>
<AppHeader showBack title="Profil" />
<ScrollView
ref={scrollViewRef}

View File

@ -422,7 +422,7 @@ export default function SettingsScreen() {
}
return (
<View style={{ flex: 1, backgroundColor: colors.bg }}>
<View style={{ flex: 1, backgroundColor: colors.groupedBg }}>
<AppHeader showBack title={t('settings.title')} />
<ScrollView
@ -451,7 +451,7 @@ export default function SettingsScreen() {
</Text>
<View
style={{
backgroundColor: colors.surface,
backgroundColor: colors.card,
borderRadius: 14,
overflow: 'hidden',
shadowColor: '#000',

View File

@ -51,9 +51,17 @@ export function LockScreen() {
}
}, [authenticate, t]);
// Auto-Prompt beim ersten Erscheinen
// Auto-Prompt beim ersten Erscheinen — aber NUR wenn die App schon im
// Foreground ist. Wird LockScreen während eines `background`/`inactive`-State
// gemountet (typisch wenn der Lock durch das background-Event selbst getriggert
// wurde), zeigt FaceID keinen sichtbaren Prompt und failed silent — der User
// sieht dann nur den Fallback-Button.
// Wenn beim Mount nicht active, fängt der background→active-Listener unten
// den Foreground-Wechsel und prompted dann.
useEffect(() => {
if (AppState.currentState === 'active') {
tryUnlock();
}
}, [tryUnlock]);
// Rückkehr aus dem Hintergrund zur noch gesperrten App → erneut prompten

View File

@ -28,6 +28,8 @@ type ProviderConfig = {
color: string;
guideKey: string;
guideUrl: string;
disabled?: boolean;
disabledLabelKey?: string;
};
const PROVIDERS: ProviderConfig[] = [
@ -54,6 +56,8 @@ const PROVIDERS: ProviderConfig[] = [
color: '#0078D4',
guideKey: 'mail.app_password_guide_outlook',
guideUrl: 'https://account.microsoft.com/security',
disabled: true,
disabledLabelKey: 'mail.provider_outlook_disabled_badge',
},
{
id: 'yahoo',
@ -356,9 +360,10 @@ function ProviderGrid({
{providers.map((p) => (
<TouchableOpacity
key={p.id}
onPress={() => onSelect(p)}
activeOpacity={0.7}
style={{ width: '47%' }}
onPress={p.disabled ? undefined : () => onSelect(p)}
activeOpacity={p.disabled ? 1 : 0.7}
disabled={p.disabled}
style={{ width: '47%', opacity: p.disabled ? 0.45 : 1 }}
>
<View
style={{
@ -391,8 +396,23 @@ function ProviderGrid({
>
{t(p.labelKey)}
</Text>
{p.disabled && p.disabledLabelKey && (
<Text
style={{
fontSize: 10,
fontFamily: 'Nunito_600SemiBold',
color: '#737373',
marginTop: 2,
}}
numberOfLines={1}
>
{t(p.disabledLabelKey)}
</Text>
)}
</View>
{!p.disabled && (
<Ionicons name="chevron-forward" size={14} color={colors.border} />
)}
</View>
</TouchableOpacity>
))}

View File

@ -37,7 +37,7 @@ export function ApprovedDomainsList({ domains, loading }: Props) {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: colors.surface,
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
@ -64,7 +64,7 @@ export function ApprovedDomainsList({ domains, loading }: Props) {
<View
style={{
marginTop: 6,
backgroundColor: colors.surface,
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,

View File

@ -221,7 +221,7 @@ export function DemographicsAccordion({
>
<View
style={{
backgroundColor: colors.surface,
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
@ -317,7 +317,7 @@ export function DemographicsAccordion({
<View
style={{
marginTop: 8,
backgroundColor: colors.surface,
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,

View File

@ -25,7 +25,7 @@ function StatPill({ value, label, onPress }: CardProps) {
>
<View
style={{
backgroundColor: colors.surface,
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 999,

View File

@ -1,10 +1,12 @@
import { View, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
export type CooldownEntry = {
id: string;
startedAt: string;
rawStartedAt: string;
durationLabel: string;
status: 'active' | 'resolved' | 'cancelled';
reason: string | null;
@ -17,20 +19,88 @@ type Props = {
cooldowns: CooldownEntry[];
};
const statusLabel: Record<CooldownEntry['status'], string> = {
active: 'aktiv',
resolved: 'beendet',
cancelled: 'abgebrochen',
};
const WEEKS = 8;
const MAX_BAR_HEIGHT = 28;
const MIN_BAR_HEIGHT = 2;
const statusColor: Record<CooldownEntry['status'], { bg: string; text: string }> = {
active: { bg: '#fff7ed', text: '#c2410c' },
resolved: { bg: '#f0fdf4', text: '#15803d' },
cancelled: { bg: '#f5f5f5', text: '#737373' },
};
function getMondayOfWeek(date: Date): Date {
const d = new Date(date);
const day = d.getDay();
const diff = (day === 0 ? -6 : 1 - day);
d.setDate(d.getDate() + diff);
d.setHours(0, 0, 0, 0);
return d;
}
function buildWeekBuckets(cooldowns: CooldownEntry[]): number[] {
const now = new Date();
const currentWeekMonday = getMondayOfWeek(now);
const buckets: number[] = Array(WEEKS).fill(0);
for (const c of cooldowns) {
if (!c.rawStartedAt) continue;
const started = new Date(c.rawStartedAt);
const weekMonday = getMondayOfWeek(started);
const diffMs = currentWeekMonday.getTime() - weekMonday.getTime();
const diffWeeks = Math.round(diffMs / (7 * 24 * 60 * 60 * 1000));
if (diffWeeks >= 0 && diffWeeks < WEEKS) {
const bucketIndex = WEEKS - 1 - diffWeeks;
buckets[bucketIndex]++;
}
}
return buckets;
}
function formatLastDate(cooldowns: CooldownEntry[], language: string): string {
if (cooldowns.length === 0) return '';
const sorted = [...cooldowns].sort(
(a, b) => new Date(b.rawStartedAt).getTime() - new Date(a.rawStartedAt).getTime(),
);
const latest = new Date(sorted[0].rawStartedAt);
if (language === 'de') {
const day = String(latest.getDate()).padStart(2, '0');
const month = String(latest.getMonth() + 1).padStart(2, '0');
return `${day}.${month}.`;
}
return latest.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function formatAvg(totalCount: number, language: string): string {
if (totalCount === 0) return '0';
const avg = WEEKS / totalCount;
if (language === 'de') {
return avg.toFixed(1).replace('.', ',');
}
return avg.toFixed(1);
}
export function StreakSection({ currentDays, longestDays, startDate, cooldowns }: Props) {
const colors = useColors();
const { t, i18n } = useTranslation();
const lang = i18n.language ?? 'de';
const buckets = buildWeekBuckets(cooldowns);
const maxCount = Math.max(...buckets, 1);
const totalInWindow = buckets.reduce((s, v) => s + v, 0);
const cooldownsInWindow = totalInWindow;
const lastDate = cooldowns.length > 0 ? formatLastDate(cooldowns, lang) : null;
const avgStr = formatAvg(cooldownsInWindow, lang);
const countLabel =
cooldownsInWindow === 0
? t('profile.cooldown.none')
: cooldownsInWindow === 1
? t('profile.cooldown.count_one', { weeks: WEEKS })
: t('profile.cooldown.count_other', { n: cooldownsInWindow, weeks: WEEKS });
const avgLabel =
cooldownsInWindow > 0 && lastDate
? t('profile.cooldown.avg_last', { avg: avgStr, date: lastDate })
: null;
return (
<View style={{ marginHorizontal: 16, marginTop: 24 }}>
<View
@ -50,13 +120,13 @@ export function StreakSection({ currentDays, longestDays, startDate, cooldowns }
letterSpacing: 0.8,
}}
>
STREAK
{t('profile.streak_section_label')}
</Text>
</View>
<View
style={{
backgroundColor: colors.surface,
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
@ -80,7 +150,7 @@ export function StreakSection({ currentDays, longestDays, startDate, cooldowns }
fontFamily: 'Nunito_600SemiBold',
}}
>
Tage geschützt
{t('profile.streak_days_protected')}
</Text>
</View>
<Text
@ -91,7 +161,7 @@ export function StreakSection({ currentDays, longestDays, startDate, cooldowns }
fontFamily: 'Nunito_400Regular',
}}
>
seit {startDate}
{t('profile.streak_since', { date: startDate })}
</Text>
<Text
style={{
@ -101,133 +171,128 @@ export function StreakSection({ currentDays, longestDays, startDate, cooldowns }
fontFamily: 'Nunito_400Regular',
}}
>
Längste Streak: {longestDays} Tage
{t('profile.streak_longest', { days: longestDays })}
</Text>
</View>
{cooldowns.length > 0 ? (
<View style={{ marginTop: 16 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
paddingHorizontal: 2,
}}
>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.8,
marginBottom: 8,
paddingHorizontal: 2,
}}
>
COOLDOWN-VERLAUF
{t('profile.cooldown.heading')}
</Text>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{t('profile.cooldown.window_label', { weeks: WEEKS })}
</Text>
</View>
<View
style={{
backgroundColor: colors.surface,
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
padding: 14,
padding: 16,
}}
>
{cooldowns.map((c, idx) => {
const isLast = idx === cooldowns.length - 1;
const colorPair = statusColor[c.status];
return (
<View key={c.id} style={{ flexDirection: 'row' }}>
<View style={{ width: 16, alignItems: 'center' }}>
<View
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor:
c.status === 'active'
? colors.brandOrange
: c.status === 'resolved'
? '#15803d'
: '#a3a3a3',
marginTop: 4,
}}
/>
{!isLast ? (
<View
style={{
width: 1,
flex: 1,
backgroundColor: colors.border,
marginTop: 2,
}}
/>
) : null}
</View>
<View style={{ flex: 1, paddingLeft: 12, paddingBottom: isLast ? 0 : 14 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
alignItems: 'flex-end',
justifyContent: 'space-between',
height: MAX_BAR_HEIGHT + 20,
marginBottom: 6,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Text
style={{
fontSize: 13,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
{c.startedAt}
</Text>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{c.durationLabel}
</Text>
</View>
{buckets.map((count, i) => {
const isEmpty = count === 0;
const barHeight = isEmpty
? MIN_BAR_HEIGHT
: Math.max(
MIN_BAR_HEIGHT,
Math.min(count, 5) / Math.min(maxCount, 5) * MAX_BAR_HEIGHT,
);
return (
<View key={i} style={{ alignItems: 'center', flex: 1 }}>
<View
style={{
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
backgroundColor: colorPair.bg,
width: 10,
height: barHeight,
borderRadius: 3,
backgroundColor: isEmpty ? colors.border : colors.brandOrange,
}}
>
<Text
style={{
fontSize: 10,
color: colorPair.text,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.4,
}}
>
{statusLabel[c.status].toUpperCase()}
</Text>
</View>
</View>
{c.reason ? (
<Text
style={{
marginTop: 2,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{c.reason}
</Text>
) : null}
</View>
/>
</View>
);
})}
</View>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 12,
}}
>
{buckets.map((_, i) => (
<View key={i} style={{ flex: 1, alignItems: 'center' }}>
<Text
style={{
fontSize: 9,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{t('profile.cooldown.week_label', { n: i + 1 })}
</Text>
</View>
))}
</View>
<Text
style={{
fontSize: 12,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
{countLabel}
</Text>
{avgLabel ? (
<Text
style={{
marginTop: 2,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{avgLabel}
</Text>
) : null}
</View>
</View>
</View>
);
}

View File

@ -45,7 +45,7 @@ export function UrgeStatsCard({ sessions, overcome, helpedBy, topEmotion }: Prop
<View
style={{
backgroundColor: colors.surface,
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,

View File

@ -52,6 +52,7 @@ function mapCooldownEntry(raw: BackendCooldownEntry): CooldownEntry {
return {
id: raw.id,
startedAt: formatStartedAt(raw.startedAt),
rawStartedAt: raw.startedAt,
durationLabel: formatDuration(raw.durationMinutes),
status: raw.status,
reason: raw.reason,

View File

@ -16,6 +16,10 @@ export type ColorScheme = {
bg: string;
surface: string;
surfaceElevated: string;
/** iOS systemGroupedBackground — page background for list/settings screens */
groupedBg: string;
/** iOS secondarySystemGroupedBackground — card/row surface within grouped pages */
card: string;
border: string;
text: string;
textMuted: string;
@ -30,6 +34,8 @@ const light: ColorScheme = {
bg: '#ffffff',
surface: '#fafafa',
surfaceElevated: '#f5f5f5',
groupedBg: '#F2F2F7',
card: '#ffffff',
border: '#e5e5e5',
text: '#0a0a0a',
textMuted: '#737373',
@ -44,6 +50,8 @@ const dark: ColorScheme = {
bg: '#000000',
surface: '#1c1c1e',
surfaceElevated: '#2c2c2e',
groupedBg: '#000000',
card: '#1c1c1e',
border: '#38383a',
text: '#ffffff',
textMuted: '#8e8e93',

View File

@ -334,6 +334,8 @@
"provider_gmail": "Gmail",
"provider_icloud": "iCloud Mail",
"provider_outlook": "Outlook",
"provider_outlook_disabled_badge": "Kommt bald",
"provider_outlook_disabled_hint": "Microsoft-Konten brauchen eine neue Anmeldemethode, wir arbeiten daran.",
"provider_yahoo": "Yahoo Mail",
"provider_gmx": "GMX / Web.de",
"app_password_required_title": "App-Passwort erforderlich",
@ -706,7 +708,20 @@
"crop_title": "Ausschnitt wählen",
"crop_confirm": "Übernehmen",
"crop_hint": "Bewege und zoome das Bild um den gewünschten Ausschnitt zu wählen.",
"crop_reset": "Zurücksetzen"
"crop_reset": "Zurücksetzen",
"streak_section_label": "STREAK",
"streak_days_protected": "Tage geschützt",
"streak_since": "seit {{date}}",
"streak_longest": "Längste Streak: {{days}} Tage",
"cooldown": {
"heading": "COOLDOWN-VERLAUF",
"window_label": "letzte {{weeks}}W",
"week_label": "W{{n}}",
"none": "Keine Cooldowns in den letzten 8 Wochen",
"count_one": "1 Cooldown in {{weeks}} Wochen",
"count_other": "{{n}} Cooldowns in {{weeks}} Wochen",
"avg_last": "Ø 1 pro {{avg}} Wochen · zuletzt {{date}}"
}
},
"demographics": {
"employment_status_employed": "angestellt",

View File

@ -334,6 +334,8 @@
"provider_gmail": "Gmail",
"provider_icloud": "iCloud Mail",
"provider_outlook": "Outlook",
"provider_outlook_disabled_badge": "Coming soon",
"provider_outlook_disabled_hint": "Microsoft accounts need a new sign-in method, we're working on it.",
"provider_yahoo": "Yahoo Mail",
"provider_gmx": "GMX / Web.de",
"app_password_required_title": "App password required",
@ -706,7 +708,20 @@
"crop_title": "Choose crop",
"crop_confirm": "Apply",
"crop_hint": "Move and zoom the image to select the desired crop area.",
"crop_reset": "Reset"
"crop_reset": "Reset",
"streak_section_label": "STREAK",
"streak_days_protected": "days protected",
"streak_since": "since {{date}}",
"streak_longest": "Longest streak: {{days}} days",
"cooldown": {
"heading": "COOLDOWN HISTORY",
"window_label": "last {{weeks}}W",
"week_label": "W{{n}}",
"none": "No cooldowns in the last 8 weeks",
"count_one": "1 cooldown over {{weeks}} weeks",
"count_other": "{{n}} cooldowns over {{weeks}} weeks",
"avg_last": "Ø 1 every {{avg}} weeks · last {{date}}"
}
},
"demographics": {
"employment_status_employed": "employed",

View File

@ -62,6 +62,11 @@ export const useAppLockStore = create<AppLockState>((set, get) => ({
ready: false,
init: async () => {
// Idempotenz: nur beim allerersten init() den locked-Default setzen. Spätere
// init()-Calls (z.B. wenn RootLayoutInner durch router.replace re-mountet)
// dürfen den aktuellen locked-Zustand NICHT zurücksetzen — sonst entsteht
// eine Endlosschleife: unlock → re-mount → init() → locked=true wieder.
const alreadyReady = get().ready;
if (!LocalAuthentication) {
// Native-Modul fehlt (alter Dev-Client) → Sperre nicht verfügbar, App läuft weiter.
set({ enabled: false, available: false, locked: false, ready: true });
@ -79,9 +84,9 @@ export const useAppLockStore = create<AppLockState>((set, get) => ({
set({
enabled,
available,
// Cold-Start: wenn aktiviert → sofort gesperrt starten (kein Flash von App-Inhalt,
// der AppLockGate rendert dann den LockScreen bevor irgendwas sichtbar wird).
locked: enabled,
// Cold-Start: locked=enabled (kein Flash von App-Inhalt vor LockScreen).
// Re-Init: aktuellen locked-Stand erhalten — sonst Loop.
locked: alreadyReady ? get().locked : enabled,
ready: true,
});
},

View File

@ -97,6 +97,16 @@ export const useAuthStore = create<AuthState>((set) => ({
if (error) return { error: error.message };
if (!data.url) return { error: 'Kein OAuth-URL erhalten' };
// Cleanup eines evtl. noch offenen WebBrowser-Sessions aus einem vorherigen,
// abgebrochenen OAuth-Versuch — sonst wirft openAuthSessionAsync mit
// „Another web browser is already open". Idempotent, safe auch wenn nichts
// offen ist.
try {
await WebBrowser.dismissAuthSession();
} catch {
// ignore
}
const result = await WebBrowser.openAuthSessionAsync(data.url, redirectUri);
if (result.type !== 'success') {

View File

@ -0,0 +1,476 @@
# Outlook OAuth2 — Implementierungsplan
Stand: 2026-05-13
Autor: Mo (Mail-Stack-Owner)
Status: Plan, kein Code
---
## 1. Status-Recherche: Microsoft Basic-Auth-Deprecation
### Was ist passiert
Microsoft hat Basic-Auth (username + password) für consumer-Outlook-Mailboxen
(outlook.com, hotmail.com, hotmail.de, live.com, live.de, msn.com) schrittweise
abgeschaltet:
- **September 2024**: Vollständige Abschaltung für neue IMAP/POP/SMTP-Verbindungen
mit Basic-Auth auf consumer-Tenants. Bestehende Verbindungen hatten eine
Übergangsfrist.
- **Stand Mai 2026**: Basic-Auth ist für alle consumer-Outlook-Postfächer tot.
IMAP-Login mit Passwort schlägt mit `[AUTHENTICATIONFAILED]` fehl — egal ob
App-Passwort oder normales Passwort.
### Edge-Cases
| Szenario | Basic-Auth möglich? |
|---|---|
| outlook.com / hotmail / live / msn — consumer | Nein, komplett tot |
| Microsoft 365 Business (firmeneigene Domain, Azure-AD-Tenant) | Nein, Admins können es nicht reaktivieren |
| Outlook.com custom domain (eigene Domain via Outlook-Webmail) | Nein, gleiche Infrastruktur |
| On-Premise Exchange (eigener Firmen-Server) | Hypothetisch ja, aber nicht unser Use-Case |
**Fazit**: Es gibt keinen Edge-Case der uns rettet. Der App-Passwort-Guide im
ConnectMailSheet ist für Outlook-User seit September 2024 nutzlos. Jeder
Outlook-User der jetzt "Verbinden" drückt bekommt vom Backend
`AUTHENTICATIONFAILED` zurück.
### Benoetigte OAuth-Scopes
Fuer IMAP read + delete via XOAUTH2 gegen Microsoft Identity Platform:
```
https://outlook.office.com/IMAP.AccessAsUser.All
offline_access
openid
```
- `IMAP.AccessAsUser.All` — erlaubt IMAP-Zugriff im Namen des Users (lesen,
loeschen, verschieben). Kein weiterer Mail-Scope noetig.
- `offline_access` — liefert einen refresh_token (ohne ihn gibt es keinen
refresh_token, nur kurze access_tokens). Pflicht fuer langlebige IDLE-Sessions.
- `openid` — liefert sub/email im ID-Token fuer Account-Identifikation.
Explizit NICHT anfordern: `Mail.Read`, `Mail.ReadWrite`, `Contacts.*`,
`Calendars.*`, `User.Read` (ausser sub/email). Minimale Scope-Anforderung.
### Consumer Identity Platform vs Azure-AD
Microsoft hat zwei Systeme:
- **Microsoft Identity Platform v2 (consumers)** — fuer outlook.com/hotmail-Privat-
konten. Endpoint: `https://login.microsoftonline.com/consumers/oauth2/v2.0/...`
oder tenant-agnostisch `common`. Azure-App-Registrierung mit "Supported account
types: Personal Microsoft accounts only" oder "Any Microsoft account (multi-tenant
+ personal)".
- **Azure-AD / Entra ID (work/school)** — fuer M365-Business. Nicht unser
primaerer Use-Case.
Fuer Rebreak: App-Registrierung mit `consumers`-Endpoint — deckt alle genannten
Domains ab (outlook.com, hotmail, live, msn). Wer ein M365-Business-Konto hat,
faellt spaeter unter den gleichen Flow wenn wir auf `common` wechseln.
---
## 2. Architektur-Plan
### 2.1 Azure-App-Registrierung
Einmaliges Setup im Azure-Portal (portal.azure.com):
| Feld | Wert |
|---|---|
| Name | Rebreak Mail Access |
| Supported account types | Personal Microsoft accounts only |
| Redirect URI (Mobile) | `msauth.org.rebreak.app://auth` (MSAL-Schema) |
| Redirect URI (Web/BFF) | `https://api.rebreak.org/api/mail/oauth/microsoft/callback` |
| API Permissions | `IMAP.AccessAsUser.All` (delegated), `offline_access`, `openid` |
| Client secret | Ja (fuer BFF-Token-Exchange) |
| Public client flows | Ja aktivieren (fuer PKCE) |
Scopes muessen im Portal unter "API Permissions" explizit hinzugefuegt und fuer
`consumers`-Tenant fuer alle User freigegeben werden. Kein "Grant admin consent"
noetig fuer delegated permissions auf consumer-Tenant.
**Multi-Tenant-Approval erforderlich?** Nein. Bei "Personal Microsoft accounts
only" gibt es keinen App-Review-Prozess bei Microsoft — jeder MS-User kann der
App konsentieren. App-Reviews sind nur noetig wenn man `All organizations`-Tenant
anfordert und enterprise-Features braucht.
### 2.2 OAuth-Flow: BFF-Pattern (Backend-mediated)
Empfehlung: **BFF-Pattern**, nicht PKCE direkt im Mobile-Client.
Begruendung:
- Client-secret darf nicht im App-Bundle liegen (App-Store-Guidelines, Reverse-
Engineering). PKCE ohne client_secret ist moeglich aber dann kein refresh_token
via MSAL fuer native — Microsoft erlaubt es fuer public clients, aber Token-
Rotation ist dann Clients-Sache.
- Wir haben bereits den BFF-Ansatz beim Auth-Login. Konsistenz.
- Token-Storage (encrypted, server-side) ist ohnehin Backend-Aufgabe.
**Flow-Sequenz:**
```
Native App Backend Microsoft
| | |
| GET /api/mail/oauth/ | |
| microsoft/authorize | |
| (mit state+code_challenge) | |
|---------------------------->| |
| | build auth URL |
| 302 redirect URL | (PKCE, state, scopes) |
|<----------------------------| |
| | |
| WebBrowser.openAuthSession | |
| oeffnet MS-Login | |
|-------------------------------------------->| |
| | | User loggt |
| | | ein, |
| | | konsentiert |
|<--------------------------------------------| |
| redirect: .../callback?code=XXX&state=YYY | |
| | |
| POST /api/mail/oauth/ | |
| microsoft/exchange | |
| body: { code, state } | |
|---------------------------->| |
| | POST token endpoint |
| | (code + code_verifier) |
| |---------------------------->|
| |<----------------------------|
| | { access_token, |
| | refresh_token, |
| | expires_in } |
| | |
| | decrypt+store tokens |
| | upsert MailConnection |
| | |
| { connected: true } | |
|<----------------------------| |
```
Zwei neue Backend-Endpoints:
- `GET /api/mail/oauth/microsoft/authorize` — generiert state + PKCE-Verifier,
speichert state temporaer in DB/Session, gibt redirect URL zurueck
- `POST /api/mail/oauth/microsoft/exchange` — empfaengt code + state, tauscht
gegen tokens, speichert in MailConnection
### 2.3 Token-Storage: Schema-Aenderung (Eskalation an rebreak-backend)
**ESKALATION AN rebreak-backend erforderlich.**
Das aktuelle `MailConnection`-Schema hat `passwordEncrypted: String`. Fuer OAuth
brauchen wir:
```prisma
// Neue Felder in MailConnection:
authMethod String @default("password") @map("auth_method")
// "password" | "oauth2_microsoft" | "oauth2_google" (future)
oauthAccessToken String? @map("oauth_access_token") // AES-256-GCM encrypted
oauthRefreshToken String? @map("oauth_refresh_token") // AES-256-GCM encrypted
oauthTokenExpiry DateTime? @map("oauth_token_expiry") // UTC, naechste Ablaufzeit
oauthScope String? @map("oauth_scope") // gespeicherter Scope-String
```
`passwordEncrypted` bleibt fuer bestehende password-basierte Connections.
**Fuer OAuth-Connections**: `passwordEncrypted` = leer string oder `"oauth"` als
Marker, damit bestehender Code nicht bricht. Besser: `authMethod`-Flag pruefe
zuerst.
Schema-Migration: `ALTER TABLE rebreak.mail_connections ADD COLUMN ...` (4 neue
Spalten). Kein Breaking Change fuer bestehende Rows.
### 2.4 IMAP-Connect-Logik: XOAUTH2 in ImapFlow
**Gute Nachricht**: `imapflow` (aktuell `^1.2.18`) unterstuetzt XOAUTH2 nativ.
Aktueller Auth-Block in `connect.post.ts` und `imap-idle/index.mjs`:
```js
auth: { user: email, pass: password }
```
Fuer OAuth: ImapFlow akzeptiert stattdessen:
```js
auth: {
user: email,
accessToken: decryptedAccessToken
}
```
ImapFlow baut daraus automatisch den XOAUTH2-SASL-String. Kein manueller
Base64-Encoding noetig, keine Library-Aenderung erforderlich.
`imap-providers.ts` braucht ein neues Interface:
```ts
export interface ImapAuth {
type: 'password' | 'oauth2';
value: string; // password (plaintext, decrypted) ODER access_token
}
```
Die Resolve-Logik in `connect.post.ts` muss `authMethod` aus MailConnection
lesen und die richtige `ImapAuth` zusammenbauen.
### 2.5 Token-Refresh-Flow
**Das haerteste Problem.** Access-tokens laufen bei Microsoft nach 1 Stunde ab.
Betroffen sind zwei Stellen:
**A. IMAP-Idle-Daemon** (langlebige Verbindung, laeuft tage-/wochenlang):
Der Daemon muss vor jedem connect (und nach AUTHENTICATIONFAILED-Fehlern) pruefen
ob der access_token noch gueltig ist. Refresh-Logik:
```
1. oauthTokenExpiry aus DB lesen
2. Wenn expiry < now + 5min:
a. POST https://login.microsoftonline.com/consumers/oauth2/v2.0/token
mit: grant_type=refresh_token, refresh_token=<decrypted>, client_id, client_secret
b. Neues access_token + refresh_token in DB speichern (encrypted)
c. oauthTokenExpiry updaten
3. ImapFlow mit frischem access_token verbinden
```
Refresh im Daemon direkt (kein HTTP-Roundtrip zum Backend noetig — Daemon hat
direkten DB-Zugriff). Der Daemon erhaelt client_id + client_secret als Env-Vars.
**Token-Rotation**: Microsoft kann bei refresh auch ein neues refresh_token liefern
("refresh token rotation"). Daemon muss das neue refresh_token persistieren,
sonst ist nach einem Refresh der naechste fehlgeschlagen.
**B. scan.post.ts / connect.post.ts** (kurze Connections):
Beim On-Demand-Scan: pruefe `oauthTokenExpiry` und refresh wenn noetig, bevor
IMAP-Connection aufgebaut wird. Da dieser Code im Nitro-Kontext laeuft, kann
er direkt Prisma nutzen.
**Refresh-Token-Revocation bei User-Logout / Account-Loeschung**: Backend muss
`POST https://login.microsoftonline.com/consumers/oauth2/v2.0/logout` aufrufen
wenn User die Verbindung trennt oder Account loescht. Sonst bleibt unsere App-
Autorisierung bei Microsoft aktiv.
---
## 3. ConnectMailSheet UX-Plan (fuer rebreak-native-ui-Agent)
### Geaenderter Flow fuer Outlook
Aktuell: Outlook-Provider-Tile -> Formular mit Email + App-Passwort-Hinweis + Link.
Neu: Outlook-Provider-Tile -> Anderer View (kein Passwort-Formular):
```
[Tile: Outlook / Hotmail / Live]
|
v
View: "outlook-oauth"
+---------------------------------+
| [Outlook-Icon] |
| Mit Microsoft anmelden |
| |
| Rebreak benoetigt Zugriff auf |
| dein Postfach um Gluecksspiel- |
| Mails automatisch zu loeschen. |
| |
| [Schild-Icon] Datenschutz: |
| Wir lesen keine Inhalte. Nur |
| Absender + Betreff zum Matching|
| |
| [Button] Mit Microsoft anmelden|
| |
| [Spinner waehrend OAuth laeuft]|
+---------------------------------+
```
States:
- **idle**: Button aktiv, Datenschutz-Hinweis sichtbar
- **loading**: Button disabled, ActivityIndicator, Text "Verbindung wird hergestellt..."
- **error**: Roter Error-Text unter Button (z.B. "Zugriff verweigert" wenn User
Consent ablehnt, oder "Verbindung fehlgeschlagen" bei Network-Error)
- **success**: Sheet schliesst sich, onSuccess() wird aufgerufen
Technisch im Client:
```
1. Button-Tap → GET /api/mail/oauth/microsoft/authorize
2. Backend gibt { authUrl: "https://login.microsoftonline.com/..." } zurueck
3. expo-web-browser: WebBrowser.openAuthSessionAsync(authUrl, redirectUri)
4. Deep-Link-Handler empfaengt Callback-URL mit code + state
5. POST /api/mail/oauth/microsoft/exchange mit { code, state }
6. On success: handleClose() + onSuccess()
```
Redirect-URI in der App: `msauth.org.rebreak.app://auth` — muss in
`app.json`-Scheme registriert und in Azure-App-Registrierung eingetragen sein.
**Bestehende Provider unveraendert**: Gmail, iCloud, Yahoo, GMX, Other behalten
den Passwort-Formular-Flow. Nur Outlook-Tile bekommt anderen View.
---
## 4. DSGVO-/Compliance-Aspekte (fuer Hans-Mueller-DSB-Review)
**ESKALATION AN hans-mueller** fuer formelles Review.
### 4.1 Microsoft als Sub-Auftragsverarbeiter
Microsoft wird durch den OAuth-Flow zusaetzlicher Sub-AV (Art. 28 DSGVO).
Microsoft hat ein Standard-DPA das automatisch gilt wenn man Azure-Services nutzt
(Microsoft Products and Services Data Protection Addendum — DPA). Zu pruefen:
- Gilt das DPA auch fuer consumer Microsoft Identity Platform?
- Muss in unserem AV-Vertraege-Verzeichnis (VVT) erwaehnt werden?
- Microsoft hat EU-Datenzentren — Transfer-Grundlage sollte Standard-Vertragsklauseln
oder Adequacy-Decision sein.
### 4.2 Token-Speicherung = sensibler als Passwort
Ein refresh_token gibt persistenten Zugriff auf das Postfach bis zur Revocation —
laenger als ein App-Passwort (das der User jederzeit in Sekunden zurueckziehen
kann). Konsequenzen:
- Verschluesselung at-rest: gleicher AES-256-GCM wie bei `passwordEncrypted`.
Gleicher ENCRYPTION_KEY. Kein anderer Speicherweg.
- Zugriff auf refresh_token = Zugriff auf gesamtes Postfach. Breach-Impact hoeher
als bei App-Passwort.
- Im Datenschutzhinweis in der App und in der Datenschutzerklaerung explizit
erwaehnen: "Wir speichern einen Zugriffstoken der im Namen des Users auf das
Postfach zugreift".
### 4.3 Datenminimierung
Scopes beschraenken auf:
- `IMAP.AccessAsUser.All` — Minimum fuer IMAP
- `offline_access` — Minimum fuer Token-Refresh
- `openid` — fuer Email-Identifikation (kein `profile`-Scope)
Kein `User.Read.All`, kein `Contacts.*`, kein `Calendars.*`.
### 4.4 Loeschpflicht / Widerrufs-Pflicht
Bei User-Disconnect oder Account-Loeschung:
1. refresh_token + access_token aus DB loeschen
2. Token bei Microsoft revoken via:
`POST https://login.microsoftonline.com/consumers/oauth2/v2.0/token/revoke`
(mit refresh_token als Parameter)
Ohne Revocation bleibt Rebreaks App-Autorisierung bei Microsoft aktiv — auch wenn
wir die DB-Eintraege loeschen.
### 4.5 Speicherort
Token in `MailConnection`-Tabelle, gleicher Postgres-Host wie alle anderen User-
daten. Kein separater Secret-Store noetig wenn AES-256-GCM konsistent angewandt
wird.
---
## 5. Aufwands-Schaetzung
### MVP-Scope
MVP = OAuth-Login funktioniert, User kann Outlook verbinden, IMAP-IDLE loescht
Gambling-Mails, Token-Refresh laeuft automatisch.
| Komponente | Aufwand |
|---|---|
| Azure-App-Registrierung (einmaliges Setup) | 0.5 Tage |
| Schema-Migration (4 neue Spalten, rebreak-backend) | 0.5 Tage |
| Backend: 2 neue Endpoints (authorize + exchange) | 1.5 Tage |
| connect.post.ts: authMethod-Logik + XOAUTH2-Support | 0.5 Tage |
| imap-idle: Token-Refresh-Logik + XOAUTH2-Auth | 1.5 Tage |
| scan.post.ts: Token-Refresh vor on-demand-scan | 0.5 Tage |
| disconnect.delete.ts: Token-Revocation bei MS | 0.5 Tage |
| ConnectMailSheet: Outlook-OAuth-View (native-ui-agent) | 1.0 Tag |
| Deep-Link-Handling in App + expo-web-browser Setup | 0.5 Tage |
| Testen end-to-end (inkl. Token-Refresh-Simulation) | 1.0 Tag |
| **Gesamt** | **~8 Personentage** |
### Risiken
**1. Microsoft Rate Limits auf Free-Tier Azure-App**
Azure-Apps haben per default Rate-Limits auf den Token-Endpoint. Bei vielen
Usern gleichzeitig (Token-Refresh alle ~55min pro User) koennte das ein Problem
werden. Grenzwert: 30 Requests/Sekunde per App fuer `/token`-Endpoint.
Bei 1000 aktiven Outlook-Usern: ~18 Refreshes/Minute → kein Problem. Bei 10.000
Users: Grenzwert naeherungsweise erreicht. Fruehzeitig Azure-Subscription-Limit
pruefen.
**2. Token-Rotation race condition im IDLE-Daemon**
Wenn mehrere IDLE-Sessions parallel starten (z.B. nach Daemon-Restart) und alle
gleichzeitig ein abgelaufenes Token refreshen wollen, koennen race conditions
entstehen: doppelter Refresh → alter refresh_token ungueltig → zweite Session
failt. Loesung: DB-Lock oder last-writer-wins mit Timestamp-Check.
**3. Consumer-Tenant Consent-Screen**
Beim ersten OAuth-Login sieht der User den Microsoft-Consent-Screen mit der
Formulierung "Rebreak moechte auf dein Postfach zugreifen". Fuer manche User
(besonders aengstliche) koennte das abschreckend wirken. Das ist kein technisches
Risiko aber ein UX-Risiko — der Datenschutz-Hinweis im Sheet muss das vorab
erklaeren.
**4. App-Registrierung: Publisher-Verification**
Microsoft kann nicht-verifizierte Publisher-Apps auf dem Consent-Screen als
"unverified" markieren. Fuer Produktivbetrieb sollte Publisher-Verification in
Azure abgeschlossen werden (Domain-Verifikation von rebreak.org). Aufwand: ~1 Tag
einmalig. Ohne Verifikation funktioniert der Flow trotzdem, aber der Consent-
Screen zeigt "unverified publisher" — schlechtes Vertrauen.
**5. Apple App-Store-Review: OAuth-Flows**
OAuth-Flows in iOS-Apps koennen zu App-Store-Review-Verzoegerungen fuehren wenn
der Reviewer nicht einen echten Microsoft-Account zum Testen hat. Testaccount
fuer Review bereitstellen (outlook.com-Testaccount mit Gambling-Mails).
**6. Kein App-Review bei Microsoft selbst erforderlich**
"Personal Microsoft accounts only"-Apps brauchen keine Microsoft-seitige
Freigabe. Kein Warten auf MS-Approval.
---
## 6. Abhaengigkeiten und naechste Schritte
### Sofortige Eskalationen
1. **rebreak-backend**: Schema-Migration fuer 4 neue Felder in `MailConnection`.
Neue Felder: `auth_method`, `oauth_access_token`, `oauth_refresh_token`,
`oauth_token_expiry`. Migration kann non-destructive (additive) sein.
2. **hans-mueller**: DSGVO-Review der Token-Speicherung (Abschnitt 4). Insbesondere:
Microsoft als Sub-AV ins VVT aufnehmen, Datenschutzerklaerung anpassen
(refresh_token = persistenter Zugriff), Revocations-Pflicht bei Loeschung.
### Entscheidung vor Implementierungsstart
- Azure-Account + App-Registrierung: wer legt an? (ops-Aufgabe)
- client_id + client_secret: werden via Infisical verwaltet (klar), aber
Infisical-Secret-Naming vorab festlegen.
- Redirect-URI-Schema (`msauth.org.rebreak.app`): muss in `app.json` registriert
sein bevor iOS-Build fuer Tests.
### Kein Handlungsbedarf bis Schema-Migration done
Die Backend-Endpoints koennen erst nach dem Schema-Change implementiert werden.
Warten auf rebreak-backend, dann direkt loslegen.
---
## 7. Was wir heute sofort tun koennen (ohne Schema-Change)
Unabhaengig vom OAuth-Implementierungs-Timeline:
1. **ConnectMailSheet**: Outlook-Tile sofort deaktivieren oder Hinweis einblenden
"Outlook wird bald unterstuetzt". Besser als den User einen Fehler erleben
lassen ("AUTHENTICATIONFAILED" nach Eingabe eines App-Passworts das sowieso
nicht funktioniert). Das ist eine UI-Aenderung fuer native-ui-agent.
2. **imap-providers.ts**: `isOAuthRequired`-Flag fuer Outlook-Domains vorbereiten,
damit connect.post.ts frueizeitig auf "oauth not yet implemented" antworten
kann statt mit generischem Auth-Fehler zu failen.
Diese zwei Punkte koennen vor der Schema-Migration deployed werden.