import { useState } from 'react'; import { ActivityIndicator, Linking, ScrollView, Switch, Text, TouchableOpacity, View, } from 'react-native'; import * as WebBrowser from 'expo-web-browser'; import { Ionicons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; import { useMailConnect, detectProvider, type MailProvider } from '../../hooks/useMailConnect'; import { humanizeMailError } from '../../lib/mailErrors'; import { apiFetch } from '../../lib/api'; import { useColors } from '../../lib/theme'; import { FormSheet } from '../FormSheet'; import { SheetFieldStack } from '../SheetFieldStack'; import { useMailConnectDraft } from '../../stores/mailConnectDraft'; const CONSENT_VERSION = 'art9-mail-v1-2026-05-13'; const PRIVACY_URL = 'https://rebreak.org/datenschutz'; type Props = { visible: boolean; onClose: () => void; onSuccess: () => void; }; type ProviderConfig = { id: MailProvider; labelKey: string; icon: React.ComponentProps['name']; color: string; guideKey: string; guideUrl: string; disabled?: boolean; disabledLabelKey?: string; authMethod?: 'imap' | 'oauth_microsoft'; }; const PROVIDERS: ProviderConfig[] = [ { id: 'gmail', labelKey: 'mail.provider_gmail', icon: 'mail', color: '#EA4335', guideKey: 'mail.app_password_guide_gmail', guideUrl: 'https://myaccount.google.com/apppasswords', }, { id: 'icloud', labelKey: 'mail.provider_icloud', icon: 'cloud', color: '#007AFF', guideKey: 'mail.app_password_guide_icloud', guideUrl: 'https://appleid.apple.com/account/manage', }, { id: 'outlook', labelKey: 'mail.provider_outlook', icon: 'mail-open', color: '#0078D4', guideKey: 'mail.app_password_guide_outlook', guideUrl: 'https://account.microsoft.com/security', authMethod: 'oauth_microsoft', }, { id: 'yahoo', labelKey: 'mail.provider_yahoo', icon: 'at', color: '#7C3AED', guideKey: 'mail.app_password_guide_yahoo', guideUrl: 'https://login.yahoo.com/account/security', }, { id: 'gmx', labelKey: 'mail.provider_gmx', icon: 'mail-unread', color: '#E87A22', guideKey: 'mail.app_password_guide_gmx', guideUrl: 'https://www.gmx.net/mail/security', }, { id: 'other', labelKey: 'mail.provider_other', icon: 'server', color: '#737373', guideKey: 'mail.app_password_guide_other', guideUrl: '', }, ]; /** * Bottom-Sheet zum Verbinden eines Postfachs. * * Drei Ansichten im selben Sheet (kein Navigations-Header): * 1. Consent-Screen (Art. 9 DSGVO) — MUSS zuerst bestätigt werden * 2. Provider-Grid (6 Tiles) — nach Consent-Bestätigung freigeschaltet * 3. Formular: Email + App-Passwort als SheetFieldStack */ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) { const { t } = useTranslation(); const colors = useColors(); const { connect, connecting, error: connectError } = useMailConnect(); const { view, consentGiven, selectedProvider, email, title, pendingOAuthConnectionId, setView, setConsentGiven, setSelectedProvider, setEmail, setTitle, setPendingOAuthConnectionId, reset: resetDraft, } = useMailConnectDraft(); const [password, setPassword] = useState(''); const [passwordVisible, setPasswordVisible] = useState(false); const [formError, setFormError] = useState(null); const [fieldsComplete, setFieldsComplete] = useState(false); const [oauthRunning, setOauthRunning] = useState(false); const [oauthError, setOauthError] = useState(null); function handleClose() { resetDraft(); setPassword(''); setPasswordVisible(false); setFormError(null); setFieldsComplete(false); setOauthRunning(false); setOauthError(null); onClose(); } function defaultTitleForProvider(provider: ProviderConfig | null): string { if (!provider) return ''; const labelMap: Record = { gmail: 'Mein Gmail', icloud: 'Mein iCloud', outlook: 'Mein Outlook', yahoo: 'Mein Yahoo', gmx: 'Mein GMX', other: '', }; return labelMap[provider.id] ?? ''; } function handleConsentNext() { setView('grid'); } function handleProviderSelect(provider: ProviderConfig) { setSelectedProvider(provider); setEmail(''); setPassword(''); setTitle(defaultTitleForProvider(provider)); setFormError(null); setFieldsComplete(false); setOauthError(null); if (provider.authMethod === 'oauth_microsoft') { setView('oauth_warning'); } else { setView('form'); } } async function handleOAuthStart() { setOauthRunning(true); setOauthError(null); setView('oauth_pending'); try { const { authorizationUrl } = await apiFetch<{ authorizationUrl: string }>( '/api/mail/oauth/microsoft/init', { method: 'POST', body: email.trim() ? { email: email.trim() } : {} } ); try { await WebBrowser.dismissAuthSession(); } catch {} const result = await WebBrowser.openAuthSessionAsync( authorizationUrl, 'rebreak://auth/mail-oauth-callback' ); console.log('[oauth] WebBrowser result.type=', result.type); if (result.type !== 'success') { setOauthError(t('mail.oauth.error_aborted')); setView('oauth_warning'); setOauthRunning(false); return; } console.log('[oauth] result.url=', (result as any).url); const url = new URL((result as any).url); const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); const msError = url.searchParams.get('error'); const msErrorDescription = url.searchParams.get('error_description'); console.log('[oauth] code?=', !!code, 'state?=', !!state, 'msError=', msError, 'desc=', msErrorDescription); if (msError) { // Microsoft hat einen expliziten Error im Redirect zurückgegeben (z.B. // access_denied wenn User Consent abbricht, invalid_redirect_uri wenn // Azure-App-Config nicht stimmt). Zeig dem User den echten Grund. setOauthError(`Microsoft: ${msError}${msErrorDescription ? ` — ${msErrorDescription}` : ''}`); setView('oauth_warning'); setOauthRunning(false); return; } if (!code || !state) { setOauthError(t('mail.oauth.error_no_code')); setView('oauth_warning'); setOauthRunning(false); return; } const conn = await apiFetch<{ connectionId: string; email: string; provider: string; title: null }>( '/api/mail/oauth/microsoft/callback', { method: 'POST', body: { code, state } } ); setPendingOAuthConnectionId(conn.connectionId); setOauthRunning(false); handleClose(); onSuccess(); } catch (e: any) { // Den echten Backend-Fehler sichtbar machen statt nur generischen Text // (apiFetch wirft `Error("API : ")` — Status + Body landen // dann sowohl in Metro-Logs als auch im UI-Banner). const detail = (e?.message ?? String(e)) || 'unknown'; console.log('[oauth] callback API call failed — error=', detail); setOauthError(`${t('mail.oauth.error_callback_failed')}\n${detail}`); setView('oauth_warning'); setOauthRunning(false); } } async function handleConnect() { setFormError(null); try { await apiFetch('/api/mail-connections/consent', { method: 'POST', body: { consentVersion: CONSENT_VERSION }, }); } catch { // Backend macht Consent atomar beim Connect-Endpoint — Fehler hier ignorieren. } const result = await connect({ email: email.trim(), password }); if (result.ok) { if (title.trim()) { try { const connections = await apiFetch<{ id: string; email: string }[]>('/api/mail-connections'); const match = connections.find((c) => c.email === email.trim()); if (match) { await apiFetch(`/api/mail-connections/${match.id}`, { method: 'PATCH', body: { title: title.trim() }, }); } } catch { // Title-PATCH ist best-effort — Connection selbst ist OK } } handleClose(); onSuccess(); } else if (result.error?.includes('412') || result.error?.includes('consent_required')) { setView('consent'); setConsentGiven(false); } else { setFormError(t(humanizeMailError(result.error))); } } const sheetTitle = view === 'form' && selectedProvider ? t(selectedProvider.labelKey) : view === 'oauth_warning' || view === 'oauth_pending' ? t('mail.provider_outlook') : t('mail.connect_sheet_title'); return ( {view === 'consent' ? ( ) : view === 'grid' ? ( ) : view === 'oauth_warning' ? ( setView('grid')} t={t} colors={colors} /> ) : view === 'oauth_pending' ? ( ) : ( { setEmail(v); setFormError(null); }, keyboardType: 'email-address', autoCapitalize: 'none', autoCorrect: false, validate: (v) => v.trim().length === 0 ? t('mail.form_fields_required') : undefined, }, { key: 'title', label: t('mail.title_label'), placeholder: t('mail.title_placeholder'), value: title, onChangeText: setTitle, autoCapitalize: 'sentences', autoCorrect: false, }, { key: 'password', label: t('mail.form_password_label'), placeholder: t('mail.form_password_placeholder'), value: password, onChangeText: (v) => { setPassword(v); setFormError(null); }, secureTextEntry: !passwordVisible, autoCapitalize: 'none', autoCorrect: false, validate: (v) => v.trim().length === 0 ? t('mail.form_fields_required') : undefined, suffix: ( setPasswordVisible((p) => !p)} hitSlop={8} > ), }, ]} intro={ {/* App-Password-Guide — provider-spezifisch, nicht für 'other' */} {selectedProvider && selectedProvider.id !== 'other' && ( {t('mail.app_password_required_title')} {t(selectedProvider.guideKey)} {selectedProvider.guideUrl.length > 0 && ( Linking.openURL(selectedProvider.guideUrl)} > {t('mail.app_password_open_link')} → )} )} {/* Datenschutz-Zusicherung — immer sichtbar */} {t('mail.form_privacy_note')} } onComplete={() => setFieldsComplete(true)} > {/* Fehler */} {(formError ?? (connectError ? t(humanizeMailError(connectError)) : null)) && ( {formError ?? t(humanizeMailError(connectError))} )} {/* Connect-Button */} {connecting ? ( ) : ( {t('mail.form_connect_btn')} )} )} ); } // --------------------------------------------------------------------------- // Sub-View: Consent (Art. 9 DSGVO) — muss als erster Schritt bestätigt werden // --------------------------------------------------------------------------- function ConsentStep({ consentGiven, onToggleConsent, onNext, t, colors, }: { consentGiven: boolean; onToggleConsent: (v: boolean) => void; onNext: () => void; t: (key: string) => string; colors: ReturnType; }) { return ( {t('mail.consent.intro')} {t('mail.consent.legal_text')} onToggleConsent(!consentGiven)} style={{ flex: 1 }} > {t('mail.consent.checkbox_label')} Linking.openURL(PRIVACY_URL)} > {t('mail.consent.more_link')} → {t('mail.consent.cta_next')} ); } // --------------------------------------------------------------------------- // Sub-View: OAuth Warning (DSB Memo Section 6.1 — Outing-Effekt-Hinweis) // --------------------------------------------------------------------------- function OAuthWarningStep({ error, onContinue, onCancel, t, colors, }: { error: string | null; onContinue: () => void; onCancel: () => void; t: (key: string) => string; colors: ReturnType; }) { return ( {t('mail.oauth.warning_title')} {t('mail.oauth.warning_body')} {error ? ( {error} ) : null} {t('mail.oauth.warning_continue')} {t('mail.oauth.warning_cancel')} ); } // --------------------------------------------------------------------------- // Sub-View: OAuth Pending (Browser läuft gerade) // --------------------------------------------------------------------------- function OAuthPendingStep({ t, colors, }: { t: (key: string) => string; colors: ReturnType; }) { return ( {t('mail.oauth.pending_label')} {t('mail.oauth.pending_hint')} ); } // --------------------------------------------------------------------------- // Sub-View: Provider-Grid // --------------------------------------------------------------------------- function ProviderGrid({ providers, onSelect, t, }: { providers: ProviderConfig[]; onSelect: (p: ProviderConfig) => void; t: (key: string) => string; }) { const colors = useColors(); return ( {t('mail.connect_sheet_subtitle')} {providers.map((p) => ( onSelect(p)} activeOpacity={p.disabled ? 1 : 0.7} disabled={p.disabled} style={{ width: '47%', opacity: p.disabled ? 0.45 : 1 }} > {t(p.labelKey)} {p.disabled && p.disabledLabelKey && ( {t(p.disabledLabelKey)} )} {!p.disabled && ( )} ))} ); }