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:
parent
a3f892ddac
commit
01d515d137
@ -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 */}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@ -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') {
|
||||
|
||||
476
backend/docs/mail-outlook-oauth-plan.md
Normal file
476
backend/docs/mail-outlook-oauth-plan.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user