diff --git a/apps/rebreak-native/app/(app)/mail.tsx b/apps/rebreak-native/app/(app)/mail.tsx index 8f933ea..1fb96f8 100644 --- a/apps/rebreak-native/app/(app)/mail.tsx +++ b/apps/rebreak-native/app/(app)/mail.tsx @@ -275,20 +275,24 @@ export default function MailScreen() { ) : ( - {accounts.map((account, idx) => ( - - toggleAccount(account.id)} - onDisconnect={handleDisconnect} - onIntervalChanged={refresh} - onEditSuccess={handleConnectSuccess} - disconnecting={disconnectingId === account.id && disconnecting} - /> - - ))} + {accounts.map((account, idx) => { + const connStat = blockedByConnection.find((c) => c.connectionId === account.id); + return ( + + toggleAccount(account.id)} + onDisconnect={handleDisconnect} + onIntervalChanged={refresh} + onEditSuccess={handleConnectSuccess} + disconnecting={disconnectingId === account.id && disconnecting} + blockedLast30d={connStat?.count} + /> + + ); + })} )} diff --git a/apps/rebreak-native/components/mail/ConnectMailSheet.tsx b/apps/rebreak-native/components/mail/ConnectMailSheet.tsx index 98729ae..53ea8ba 100644 --- a/apps/rebreak-native/components/mail/ConnectMailSheet.tsx +++ b/apps/rebreak-native/components/mail/ConnectMailSheet.tsx @@ -190,6 +190,7 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) { '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'); @@ -197,9 +198,23 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) { return; } - const url = new URL(result.url); + 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')); @@ -218,7 +233,12 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) { handleClose(); onSuccess(); } catch (e: any) { - setOauthError(t('mail.oauth.error_callback_failed')); + // 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); } diff --git a/apps/rebreak-native/components/mail/MailAccountCard.tsx b/apps/rebreak-native/components/mail/MailAccountCard.tsx index af9b11d..0360dc1 100644 --- a/apps/rebreak-native/components/mail/MailAccountCard.tsx +++ b/apps/rebreak-native/components/mail/MailAccountCard.tsx @@ -1,6 +1,5 @@ import { useState } from 'react'; import { - ActivityIndicator, LayoutAnimation, Linking, Modal, @@ -15,8 +14,10 @@ import { useTranslation } from 'react-i18next'; import { ConfirmAlert } from '../ConfirmAlert'; import { EditMailAccountSheet } from './EditMailAccountSheet'; import { EditMailTitleSheet } from './EditMailTitleSheet'; -import { useMailInterval } from '../../hooks/useMailInterval'; +import { MailAccountSettingsSheet } from './MailAccountSettingsSheet'; +import { MailBlockedByDayChart } from './MailBlockedByDayChart'; import type { MailAccount } from '../../hooks/useMailStatus'; +import type { BlockedByDayEntry } from '../../hooks/useMailStats'; if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { UIManager.setLayoutAnimationEnabledExperimental(true); @@ -31,300 +32,10 @@ type Props = { onIntervalChanged: () => void; onEditSuccess: () => void; disconnecting?: boolean; + blockedLast30d?: number; + connectionBlockedByDay?: BlockedByDayEntry[]; }; -function PausedBadge({ t }: { t: (k: string) => string }) { - return ( - - - {t('plan_limit.mail_account_paused')} - - - ); -} - -function resolveProviderIcon(provider: string): { - icon: React.ComponentProps['name']; - color: string; -} { - const p = provider.toLowerCase(); - if (p.includes('gmail') || p.includes('google')) return { icon: 'mail', color: '#EA4335' }; - if (p.includes('icloud') || p.includes('apple')) return { icon: 'cloud', color: '#007AFF' }; - if (p.includes('outlook') || p.includes('hotmail') || p.includes('microsoft')) - return { icon: 'mail-open', color: '#0078D4' }; - if (p.includes('yahoo')) return { icon: 'at', color: '#7C3AED' }; - if (p.includes('gmx') || p.includes('web.de')) - return { icon: 'mail-unread', color: '#E87A22' }; - return { icon: 'server', color: '#737373' }; -} - -function isOAuthProvider(provider: string): boolean { - return provider === 'outlook_oauth'; -} - -const STALE_THRESHOLD_MS = 5 * 60 * 1_000; -const IDLE_HEARTBEAT_STALE_MS = STALE_THRESHOLD_MS; -const NO_NEW_MAIL_THRESHOLD_MS = 60 * 60_000; - -function formatRelativeAbsolute(ts: Date): string { - const min = Math.floor((Date.now() - ts.getTime()) / 60_000); - const todayStr = new Date().toDateString(); - const yesterdayStr = new Date(Date.now() - 86_400_000).toDateString(); - - const hh = ts.getHours().toString().padStart(2, '0'); - const mm = ts.getMinutes().toString().padStart(2, '0'); - - let dayLabel: string; - if (ts.toDateString() === todayStr) dayLabel = 'heute'; - else if (ts.toDateString() === yesterdayStr) dayLabel = 'gestern'; - else dayLabel = ts.toLocaleDateString('de', { day: '2-digit', month: '2-digit' }); - - let rel: string; - if (min < 1) rel = 'gerade eben'; - else if (min < 60) rel = `vor ${min} min`; - else if (min < 1440) rel = `vor ${Math.floor(min / 60)}h`; - else rel = `vor ${Math.floor(min / 1440)}d`; - - return `${rel} (${dayLabel} ${hh}:${mm})`; -} - -function idleHeartbeatAlive(lastIdleHeartbeatAt: string | null | undefined): boolean { - if (!lastIdleHeartbeatAt) return false; - return Date.now() - new Date(lastIdleHeartbeatAt).getTime() < IDLE_HEARTBEAT_STALE_MS; -} - -function StatusBadgeRow({ - account, - isLegend, - t, -}: { - account: MailAccount; - isLegend: boolean; - t: (k: string, opts?: Record) => string; -}) { - if (account.lastConnectError) { - const isAuthError = - account.lastConnectError.toLowerCase().includes('invalid credentials') || - account.lastConnectError.toLowerCase().includes('authentication failed'); - const errorLabel = isAuthError ? t('mail.status_auth_error') : t('mail.status_connect_error'); - const since = account.lastConnectErrorAt - ? formatRelativeAbsolute(new Date(account.lastConnectErrorAt)) - : null; - return ( - - - - - {errorLabel} - - - · {t('mail.status_error_tap_hint')} - - - {since ? ( - - {since} - - ) : null} - - ); - } - - if (!account.lastScannedAt) { - return ( - - - - {t('mail.status_waiting_first_connect')} - - - ); - } - - const heartbeatAlive = idleHeartbeatAlive(account.lastIdleHeartbeatAt); - const lastScannedTs = new Date(account.lastScannedAt); - const scannedAgo = Date.now() - lastScannedTs.getTime(); - const scannedRelAbs = formatRelativeAbsolute(lastScannedTs); - - if (!heartbeatAlive && scannedAgo > STALE_THRESHOLD_MS) { - return ( - - - - - {t('mail.status_stale')} - - - - {t('mail.status_stale_last_scan', { rel: scannedRelAbs })} - - - ); - } - - if (heartbeatAlive) { - const heartbeatTs = new Date(account.lastIdleHeartbeatAt!); - const heartbeatMin = Math.floor((Date.now() - heartbeatTs.getTime()) / 60_000); - const idleSince = heartbeatMin < 1 ? 'gerade eben' : `${heartbeatMin} min`; - - if (scannedAgo > NO_NEW_MAIL_THRESHOLD_MS) { - return ( - - - - - {isLegend ? t('mail.live') : t('mail.account_active')} - - - - {t('mail.status_live_no_new_mail', { rel: scannedRelAbs })} - - - ); - } - - return ( - - - - - {isLegend ? t('mail.live') : t('mail.account_active')} - - - - {t('mail.status_live_idle', { rel: idleSince })} - - - ); - } - - return ( - - - - - {isLegend ? t('mail.live') : t('mail.account_active')} - - - - {formatRelativeAbsolute(new Date(account.lastScannedAt!))} - - - ); -} - -const INTERVAL_OPTIONS_BY_PLAN: Record<'free' | 'pro' | 'legend', number[]> = { - free: [4], - pro: [1, 4, 8], - legend: [1, 4, 8], -}; - -function maskEmail(email: string): string { - const [local, domain] = email.split('@'); - if (!local || !domain) return email; - if (local.length <= 3) return `${local[0]}***@${domain}`; - return `${local.slice(0, 3)}***@${domain}`; -} - -function domainFromEmail(email: string): string { - return email.split('@')[1] ?? email; -} - -function SettingsRow({ - icon, - label, - value, - onPress, - destructive, -}: { - icon: React.ComponentProps['name']; - label: string; - value?: string; - onPress?: () => void; - destructive?: boolean; -}) { - const color = destructive ? '#dc2626' : '#0a0a0a'; - const Wrapper = onPress ? TouchableOpacity : View; - const wrapperProps = onPress - ? { activeOpacity: 0.7, onPress } - : {}; - - return ( - - - - {label} - - {value !== undefined && ( - - {value} - - )} - {onPress && !destructive && ( - - )} - - ); -} - function OAuthDisconnectHintModal({ visible, onClose, @@ -440,6 +151,97 @@ function OAuthDisconnectHintModal({ ); } +function resolveProviderIcon(provider: string): { + icon: React.ComponentProps['name']; + color: string; +} { + const p = provider.toLowerCase(); + if (p.includes('gmail') || p.includes('google')) return { icon: 'mail', color: '#EA4335' }; + if (p.includes('icloud') || p.includes('apple')) return { icon: 'cloud', color: '#007AFF' }; + if (p.includes('outlook') || p.includes('hotmail') || p.includes('microsoft')) + return { icon: 'mail-open', color: '#0078D4' }; + if (p.includes('yahoo')) return { icon: 'at', color: '#7C3AED' }; + if (p.includes('gmx') || p.includes('web.de')) + return { icon: 'mail-unread', color: '#E87A22' }; + return { icon: 'server', color: '#737373' }; +} + +function isOAuthProvider(provider: string): boolean { + return provider === 'outlook_oauth'; +} + +const STALE_THRESHOLD_MS = 5 * 60 * 1_000; +const IDLE_HEARTBEAT_STALE_MS = STALE_THRESHOLD_MS; + +function idleHeartbeatAlive(lastIdleHeartbeatAt: string | null | undefined): boolean { + if (!lastIdleHeartbeatAt) return false; + return Date.now() - new Date(lastIdleHeartbeatAt).getTime() < IDLE_HEARTBEAT_STALE_MS; +} + +function domainFromEmail(email: string): string { + return email.split('@')[1] ?? email; +} + +type StatusDot = 'live' | 'stale' | 'error' | 'waiting'; + +function resolveStatusDot(account: MailAccount): StatusDot { + if (account.lastConnectError) return 'error'; + if (!account.lastScannedAt) return 'waiting'; + const heartbeatAlive = idleHeartbeatAlive(account.lastIdleHeartbeatAt); + const scannedAgo = Date.now() - new Date(account.lastScannedAt).getTime(); + if (!heartbeatAlive && scannedAgo > STALE_THRESHOLD_MS) return 'stale'; + return 'live'; +} + +function StatusDotRow({ + account, + isLegend, + blockedLast30d, + t, +}: { + account: MailAccount; + isLegend: boolean; + blockedLast30d: number | undefined; + t: (k: string) => string; +}) { + const dot = resolveStatusDot(account); + + const dotColor = + dot === 'live' ? '#16a34a' : + dot === 'stale' ? '#d97706' : + dot === 'error' ? '#dc2626' : + '#a3a3a3'; + + const label = + dot === 'live' ? (isLegend ? t('mail.live') : t('mail.account_active')) : + dot === 'stale' ? t('mail.status_stale') : + dot === 'error' ? t('mail.status_auth_error') : + t('mail.status_waiting_first_connect'); + + const blockedLabel = + blockedLast30d !== undefined + ? `${blockedLast30d}` + : account.totalBlocked > 0 + ? `${account.totalBlocked}` + : '0'; + + return ( + + + + {label} + + + {blockedLabel} + + + + ); +} + export function MailAccountCard({ account, plan, @@ -449,26 +251,27 @@ export function MailAccountCard({ onIntervalChanged, onEditSuccess, disconnecting, + blockedLast30d, + connectionBlockedByDay, }: Props) { const { t } = useTranslation(); - const [confirmVisible, setConfirmVisible] = useState(false); + const [settingsVisible, setSettingsVisible] = useState(false); const [editPasswordVisible, setEditPasswordVisible] = useState(false); const [editTitleVisible, setEditTitleVisible] = useState(false); + const [confirmVisible, setConfirmVisible] = useState(false); const [oauthDisconnectHintVisible, setOauthDisconnectHintVisible] = useState(false); const [localTitle, setLocalTitle] = useState(account.title ?? null); - const { setInterval, updating } = useMailInterval(); const { icon, color } = resolveProviderIcon(account.provider); const isOAuth = isOAuthProvider(account.provider); const isLegend = plan === 'legend'; const isPaused = account.paused === true; - const intervalOptions = INTERVAL_OPTIONS_BY_PLAN[plan]; + const hasError = !!account.lastConnectError; const displayTitle = localTitle ?? domainFromEmail(account.email); - const subEmail = maskEmail(account.email); function handleToggle() { - if (account.lastConnectError) { + if (hasError) { setEditPasswordVisible(true); return; } @@ -476,11 +279,6 @@ export function MailAccountCard({ onToggle(); } - async function handleSetInterval(value: number) { - const res = await setInterval(account.id, value); - if (res.ok) onIntervalChanged(); - } - function handleTitleSaved(newTitle: string | null) { setLocalTitle(newTitle); onEditSuccess(); @@ -493,25 +291,25 @@ export function MailAccountCard({ backgroundColor: isPaused ? '#fafafa' : '#fff', borderRadius: 16, borderWidth: 1, - borderColor: account.lastConnectError ? '#fecaca' : isPaused ? '#d4d4d4' : '#e5e5e5', + borderColor: hasError ? '#fecaca' : isPaused ? '#d4d4d4' : '#e5e5e5', overflow: 'hidden', opacity: isPaused ? 0.75 : 1, }} > - {/* Header — always visible, tap to expand settings */} + {/* Header */} - + - - {/* Title — prominent */} + {displayTitle} - {/* Email — small sub-label */} - - {subEmail} - - {isPaused - ? - : - } + {isPaused ? ( + + + {t('plan_limit.mail_account_paused')} + + + ) : ( + + )} - {/* Collapsible: Settings section */} + {/* Expanded body */} {expanded && ( - {/* Stats banner */} - - - - - {t('mail.account_stat_blocked')} - - + {connectionBlockedByDay && connectionBlockedByDay.length > 0 ? ( + + ) : ( + - {account.totalBlocked.toLocaleString()} - - - - {t('mail.account_of_scanned', { - scanned: account.totalScanned.toLocaleString(), - })} - - - - {/* Scan interval (non-legend) */} - {isLegend ? ( - - - - {t('mail.realtime_desc')} - - - ) : ( - - - {t('mail.scan_interval_label')} - - - {intervalOptions.map((opt, idx) => { - const active = account.scanInterval === opt; - const disabled = plan === 'free' || updating === account.id; - return ( - handleSetInterval(opt)} - style={{ - flex: 1, - paddingVertical: 9, - borderRadius: 10, - alignItems: 'center', - backgroundColor: active ? '#007AFF' : '#f5f5f5', - marginLeft: idx === 0 ? 0 : 6, - opacity: disabled && !active ? 0.5 : 1, - }} - > - - {opt}h - - - ); - })} - - {plan === 'free' && ( - - {t('mail.free_scan_interval_hint')} + + {t('mail.account_chart_unavailable')} - )} - - )} - - {/* Settings separator label */} - - - {t('mail.settings_section_label')} - + + )} - {/* Settings rows */} - setEditTitleVisible(true)} - /> - - - - {!isOAuth && ( - setEditPasswordVisible(true)} - /> - )} - + {/* Einstellungen tap-row */} setConfirmVisible(true)} - disabled={disconnecting} + onPress={() => setSettingsVisible(true)} style={{ flexDirection: 'row', alignItems: 'center', - paddingVertical: 12, paddingHorizontal: 14, + paddingVertical: 13, borderTopWidth: 1, borderTopColor: '#f5f5f5', - opacity: disconnecting ? 0.5 : 1, + marginTop: 8, }} > - {disconnecting ? ( - - ) : ( - - )} - {t('mail.row_disconnect')} + {t('mail.settings_section_label')} + )} + {/* Settings sub-sheet */} + setSettingsVisible(false)} + onEditTitle={() => { setSettingsVisible(false); setEditTitleVisible(true); }} + onEditPassword={() => { setSettingsVisible(false); setEditPasswordVisible(true); }} + onDisconnectRequest={() => { setSettingsVisible(false); setConfirmVisible(true); }} + onIntervalChanged={onIntervalChanged} + /> + void; + onEditTitle: () => void; + onEditPassword: () => void; + onDisconnectRequest: () => void; + onIntervalChanged: () => void; +}; + +const INTERVAL_OPTIONS_BY_PLAN: Record<'free' | 'pro' | 'legend', number[]> = { + free: [4], + pro: [1, 4, 8], + legend: [1, 4, 8], +}; + +function domainFromEmail(email: string): string { + return email.split('@')[1] ?? email; +} + +function SettingsRow({ + icon, + label, + value, + onPress, + destructive, +}: { + icon: React.ComponentProps['name']; + label: string; + value?: string; + onPress?: () => void; + destructive?: boolean; +}) { + const labelColor = destructive ? '#dc2626' : '#0a0a0a'; + const Wrapper = onPress ? TouchableOpacity : View; + const wrapperProps = onPress ? { activeOpacity: 0.7 as const, onPress } : {}; + + return ( + + + + {label} + + {value !== undefined && ( + + {value} + + )} + {onPress && !destructive && ( + + )} + + ); +} + +export function MailAccountSettingsSheet({ + visible, + account, + localTitle, + isOAuth, + plan, + disconnecting, + onClose, + onEditTitle, + onEditPassword, + onDisconnectRequest, + onIntervalChanged, +}: Props) { + const { t } = useTranslation(); + const { setInterval, updating } = useMailInterval(); + const isLegend = plan === 'legend'; + const intervalOptions = INTERVAL_OPTIONS_BY_PLAN[plan]; + + const displayTitle = localTitle ?? domainFromEmail(account.email); + + async function handleSetInterval(value: number) { + const res = await setInterval(account.id, value); + if (res.ok) onIntervalChanged(); + } + + return ( + + + {/* Bezeichnung */} + + + {/* E-Mail (read-only) */} + + + {/* Passwort — nur für IMAP-Accounts */} + {!isOAuth && ( + + )} + + {/* Scan-Intervall */} + {!isLegend ? ( + + + {t('mail.scan_interval_label')} + + + {intervalOptions.map((opt) => { + const active = account.scanInterval === opt; + const disabled = plan === 'free' || updating === account.id; + return ( + handleSetInterval(opt)} + style={{ + flex: 1, + paddingVertical: 9, + borderRadius: 10, + alignItems: 'center', + backgroundColor: active ? '#007AFF' : '#f5f5f5', + opacity: disabled && !active ? 0.5 : 1, + }} + > + + {opt}h + + + ); + })} + + {plan === 'free' && ( + + {t('mail.free_scan_interval_hint')} + + )} + + ) : ( + + + + {t('mail.realtime_desc')} + + + )} + + {/* Separator */} + + + {/* Verbindung trennen */} + + + + {t('mail.row_disconnect')} + + + + + ); +} diff --git a/apps/rebreak-native/components/mail/MailDistributionChart.tsx b/apps/rebreak-native/components/mail/MailDistributionChart.tsx index 0de308e..ddfc476 100644 --- a/apps/rebreak-native/components/mail/MailDistributionChart.tsx +++ b/apps/rebreak-native/components/mail/MailDistributionChart.tsx @@ -14,10 +14,16 @@ const OTHER_COLOR = '#a3a3a3'; const MAX_SLICES = 5; const R_OUTER = 54; -const R_INNER = 32; +const R_INNER = 34; const CX = 64; const CY = 64; +// Half-donut renders the UPPER semicircle (flat edge at bottom). +// CY=64 places the center at the bottom of the 68px-tall viewBox. +// angleDeg=0 → top (12 o'clock), angleDeg=-90 → left, angleDeg=90 → right. +// Slices sweep from -90° (left) to +90° (right) = 180° total. +const HALF_DONUT_START_DEG = -90; + function domainFromEmail(email: string): string { return email.split('@')[1] ?? email; } @@ -42,12 +48,11 @@ function arcPath( startDeg: number, endDeg: number, ): string { - const clampedEnd = Math.min(endDeg, startDeg + 179.99); const outerStart = polarToXY(cx, cy, rOuter, startDeg); - const outerEnd = polarToXY(cx, cy, rOuter, clampedEnd); - const innerEnd = polarToXY(cx, cy, rInner, clampedEnd); + const outerEnd = polarToXY(cx, cy, rOuter, endDeg); + const innerEnd = polarToXY(cx, cy, rInner, endDeg); const innerStart = polarToXY(cx, cy, rInner, startDeg); - const large = clampedEnd - startDeg > 90 ? 1 : 0; + const large = endDeg - startDeg > 180 ? 1 : 0; return [ `M ${outerStart.x} ${outerStart.y}`, @@ -90,7 +95,7 @@ export function MailDistributionChart({ data }: Props) { if (data.length <= 1 || total === 0) return null; - let cursor = 0; + let cursor = HALF_DONUT_START_DEG; return ( - {/* Half-donut — upper half of a donut ring, 180° arc from left to right */} + {/* Half-donut — upper semicircle, center pinned at bottom of viewBox */} {slices.map((slice) => { const sweep = (slice.count / total) * 180; @@ -132,7 +137,7 @@ export function MailDistributionChart({ data }: Props) { /> ); })} - {/* Center circle to keep donut look consistent */} + {/* Inner fill to enforce donut shape */} diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 455722d..e0e877d 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -449,6 +449,7 @@ "row_email": "E-Mail", "row_password": "Passwort", "row_disconnect": "Verbindung trennen", + "account_chart_unavailable": "Tages-Verlauf wird geladen …", "disconnect_confirm_title": "Verbindung trennen?", "disconnect_confirm_body": "%{email} wird getrennt und alle Scan-Daten gelöscht.", "stats": { diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 28ba39a..0503904 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -449,6 +449,7 @@ "row_email": "Email", "row_password": "Password", "row_disconnect": "Disconnect", + "account_chart_unavailable": "Daily chart loading …", "disconnect_confirm_title": "Disconnect mailbox?", "disconnect_confirm_body": "%{email} will be disconnected and all scan data deleted.", "stats": { diff --git a/backend/server/utils/ms-oauth.ts b/backend/server/utils/ms-oauth.ts index 054e833..f0840c0 100644 --- a/backend/server/utils/ms-oauth.ts +++ b/backend/server/utils/ms-oauth.ts @@ -19,14 +19,20 @@ export const MS_AUTH_BASE = `https://login.microsoftonline.com/${MS_TENANT}/oaut /** * OAuth scopes requested. * Matches the DSGVO-Memo Section 4.3 (Datenminimierung). - * User.Read is included per User decision (email extraction from ID-token). - * Hans-Müller-Memo will document this in the next revision. + * + * Single-resource constraint: Microsoft V2.0 erlaubt im /token-Exchange nur + * Scopes EINES Resource-Servers. IMAP.AccessAsUser.All zielt auf + * outlook.office.com, User.Read auf graph.microsoft.com — die Kombination + * wirft `AADSTS70011: scopes are not compatible with each other`. + * + * Daher: KEIN User.Read. Email-Adresse kommt aus dem id_token-Claim + * `preferred_username` (openid-Scope reicht). Falls künftig der Display-Name + * gebraucht wird → separater Microsoft-Graph-Token-Exchange (OBO-Pattern). */ export const MS_OAUTH_SCOPES = [ "https://outlook.office.com/IMAP.AccessAsUser.All", "offline_access", "openid", - "User.Read", ].join(" "); /**