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 */}
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(" ");
/**