Single shared affordance for adding either a website-domain or a mail-
sender-domain. The per-section add buttons (one inside "Eigene Domains"
and one inside "Eigene Mails") are gone — replaced by a CustomFilter-
Overview card above both sections with:
- title "Eigene Filter" and a "X von 20" counter (free/pro: 10, legend:
20 — sum of the two per-type buckets)
- a 2-colour progress pill: brandOrange for the web slice, success-green
for the mail slice on top of the surface-elevated rest
- a 48×48 rounded-full TouchableOpacity on the right (brandOrange,
ionicons add 24px, white) that opens the AddDomainSheet directly
AddDomainSheet was rewritten one more time: the Seite / E-Mail type
picker is gone. The user types one thing — domain or full address —
and a live preview shows which one we detected (Domain-Filter for a
bare host, Mail-Filter for input that contains "@", stripping to the
domain after the last @). The shape is also what we send: the body is
{ pattern } with no kind field. The backend (commit a2680f6) does the
authoritative auto-detect and sends back the resolved type with the
created row, so the frontend never has to guess in two places.
useCustomDomains.addDomain now treats kind as optional. When omitted,
the request body just carries pattern — when present it's still sent
through verbatim so any caller that wants to force a category still can.
DomainSection no longer renders a per-section add button when its onAdd
prop is undefined — domains and mails sections in blocker.tsx both
omit onAdd now. The mails section stays default-collapsed.
i18n: new keys custom_filter_overview_title / count + preview_web /
preview_mail / preview_invalid; tabs_web / tabs_mail removed since the
TypePicker is gone. type_web / type_mail kept in the locales as
inactive entries in case the type-picker comes back in a future
direct-add flow.
User found that adding bet365.com (which is in the 208k global filter)
silently took a custom-domain slot — they paid a slot for something
the global blocklist already covered. Two pieces:
1. backend/custom-domains/index.post.ts: before any slot-limit check or
DB insert, look the domain up in blocklist_domain (active rows). If
present, return 200 { alreadyGlobal: true, domain }. No row gets
written, no slot consumed. The existing frontend hook + AddSheet
already handle the alreadyGlobal flag — they surface the
"bereits global blockiert" alert and don't refresh as if the entry
landed in the user's list.
2. blocker.tsx default mailOpen state flipped from true to false so the
Eigene Mails section starts collapsed on page load. Domains stays
the primary affordance; mail-patterns are an opt-in expansion.
DomainSection bekommt collapsible-Prop (default false).
Domains-Section: kein Chevron, kein useState, Content immer sichtbar.
Mails-Section: collapsible={true} + open/onToggle wie bisher.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Seiten/Mails top-tabs added in 5c6fa3d are gone. Per the user's
revised vision, web-domains and mail-patterns live side by side as two
collapsible <DomainSection>s with their own header, slot pill, progress
bar, and add-button — closer to the original Eigene-Domains affordance
plus a sibling Eigene-Mails section. Both default open; chevron-up/down
per the existing icon convention.
AddDomainSheet was rewritten from scratch to fix the layout-bug
visible in the screenshot — SheetFieldStack's two-ScrollView intro/
fields split was wrong for a single-input use case and was rendering
the chip at the bottom of the scroll area with a huge gap under the
TypePicker. The new sheet is a plain ScrollView with TypePicker, label,
TextInput, help-card, preview-card, warning-card, confirm-row, and the
Cancel + Hinzufügen buttons stacked top-to-bottom with `gap: 12`. No
Pressable anywhere — TouchableOpacity only, per the hard rule.
DomainGrid is now a pure tile renderer: the header / slot pill / add
affordance live on the section component above it. Its `kind` prop
(renamed from `activeTab`) drives the type filter — for v1.0, mail
means strictly `mail_domain` (display-name is gone).
i18n: new keys section_domains / section_mails / add_sheet_cta. mail-
related copy (label, placeholder, help, empty) had every "Display-Name"
mention stripped so the user can't read about an option that doesn't
ship.
Progressbar inline in DomainSection with the same Animated.timing
pattern DeviceProgressBar uses, with a 3-step color threshold
(green / brandOrange / error) keyed on the bucket fill ratio.
Top-tabs above the custom-domains grid: Seiten (web) and Mails (mail_*).
2px underline highlight in colors.brandOrange for the active tab, the
muted label otherwise — matches the community/feed tab style we already
use. Pill segmented control would have needed extra inset math for two
tabs without adding clarity.
- DomainGrid filters items by the active tab. Tab-specific empty-state
copy and icon (mail-outline for the Mails tab) so the empty Mails tab
doesn't read like a broken Web view.
- mail_display_name tiles hide the submit-to-global button entirely —
matches the v1.0 backend lock; the user can't accidentally tap into a
400 from the API.
- useCustomDomains exposes countsByType + limits. Provisional client-
side estimation until the new API response shape (extended in the
parallel backend commit f2b81ee) is wired through — same TS shape,
so dropping the estimation is a one-line swap when ready.
- AddDomainSheet picks up initialType so tapping "+" while the Mails tab
is active opens the sheet pre-selected to E-Mail. Plan-limit error
handling maps WEB_LIMIT_REACHED / MAIL_LIMIT_REACHED to the right
per-bucket message.
i18n: tabs_web / tabs_mail / count_label / error_web_limit_reached /
error_mail_limit_reached / empty_web / empty_mail across DE/EN/FR with
%{var} placeholders.
AddDomainSheet now opens with a Seite / E-Mail segmented control.
Web keeps the existing flow (label, placeholder, favicon preview,
domain normalization). Mail switches to a free-form pattern input
(address / domain / display-name — user types what they see in
their inbox) with a mail-icon preview after the field is filled.
addDomain(pattern, kind) now sends { pattern, kind: 'web' | 'mail' }
and the server decides the concrete type. Type field flows through
the CustomDomain type so DomainGrid tiles render the mail-outline
icon for mail entries instead of the favicon fallback.
i18n: blocker.type_web / type_mail / add_web_* / add_mail_* across
de/en/fr with %{var} placeholders per repo convention.
Adds a tabBarBadge on the bottom Chat tab driven by the same
dm-conversations query the chat screen already uses — React Query
dedupes the call. Badge shows the unread total (capped to "99+")
and disappears when 0. Query is gated on session so unauthenticated
launches don't fire it.
- DmItem now goes through resolveAvatar(partnerAvatar, partnerName) so
the Dicebear-initials fallback kicks in for null avatars, hero ids
resolve to their image url, and direct URLs pass through. Adds the
PostCard-style avatarLoadFailed state for graceful broken-image
fallback.
- Search row pill-shaped (borderRadius 999) with 16px horizontal padding
and the outline search icon for better visual rhythm.
Consistent with chat.tsx refactor — ActivityIndicator, joinBtn, and
avatarEdit badge all now use the theme token.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes 2-tab Groups/DMs layout; Chat screen is now DM-only for v1.0.
Groups tab state, rooms query, RoomCard/CreateRoomSheet imports removed.
Replaces static title+create-button header with sticky search field
(client-side filter on partnerName + lastMessage). No create-DM button
added — /dm-new route does not exist yet (follow-up task).
All #007AFF in chat.tsx replaced with colors.brandOrange.
Adds chat.search_placeholder to de/en/fr locales.
Tab-bar styles kept in makeStyles (dead code, v1.1 Groups comeback path).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MobileDeviceRow now handles three binding states driven by
boundToPlan / releaseRequestedAt from the UserDevice type:
- Bound, no release pending: blue "Gebunden" badge next to device name;
trash icon replaced by lock-open icon → Alert → requestRelease()
- Release active (countdown running): footer shows "Freigabe in Xh Ymin"
in amber; close-circle icon → Alert → cancelRelease()
- Current device (isCurrent): existing behaviour unchanged, no action
button regardless of binding state
releaseAt is computed client-side as releaseRequestedAt + 24h — avoids
a backend round-trip for the countdown display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After Supabase auth succeeds the store calls POST /api/devices/check-lock
(x-device-id auto-attached via apiFetch). A 409 DEVICE_LOCKED response
triggers a Supabase sign-out and returns { deviceLocked } instead of
proceeding. The signin screen swaps to DeviceLockedPanel which shows:
- lock icon + headline + explanatory body
- amber countdown badge if a release is already in progress
- grey hint pointing to the email notification
- primary CTA to go back and sign in with the original account
Backend TODO: POST /api/devices/check-lock endpoint — same device-lock
query as login.post.ts but callable with a valid Supabase session token
(for email-login flow that bypasses /api/auth/login).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ImagePicker.launchImageLibraryAsync now opens with `allowsEditing: true`
and `aspect: [1, 1]`, which triggers Apple's built-in square crop UI
(pan + zoom on the user's selection). The output URI is the actually
cropped image — fixing the long-standing bug where AvatarCropSheet
displayed a visual transform but `manipulateAsync` only resized the
original, so any pan/zoom the user did was discarded on confirm.
Removes the entire AvatarCropSheet component (~285 lines) and its sole
consumer wiring in profile/edit.tsx. The avatar continues to render as
a circle everywhere via borderRadius — the underlying square output is
just storage-agnostic.
Native-look-first per memory rule, zero new dependencies, no new
native module to link.
Replaces ad-hoc TouchableOpacity+styled-Text pairs with a single
`<Button>` covering the four variants we actually use (primary,
secondary, ghost, destructive), with size (sm/md/lg), loading,
disabled, icon, iconPosition, and a style escape hatch.
Migrated files: AddMacSheet, AddWindowsSheet, PlanChangeSheet,
devices.tsx CTA, settings SubscriptionSheet CTA.
Skipped (kept as-is to avoid hostile overrides): auth flow buttons
(Google/Apple OAuth with custom SVGs), list-row Touchables, blocker
& mail components (separate sweep when those screens come up).
paddingVertical default 12 (md) — matches the slimmer-buttons direction
we landed on in the devices-page redesign.
- MobileDeviceRow: collapse to 2 lines (name+badge / lastSeen · seit date)
- ProtectedDeviceRow: collapse to 2 lines (name+badge / seit date or degraded hint)
- Both rows now use alignItems:center for visual parity
- Replace dual Mac/Windows buttons with single UIMenu "+ neues Gerät hinzufügen"
- MenuView disabled (no-op TouchableOpacity) when at device limit
- Dynamic counter below subtitle: "X von 3 Geräten · noch Y frei" / "Maximum erreicht"
- paddingVertical 16→12 on all primary CTAs in devices.tsx, AddMacSheet, AddWindowsSheet
- New i18n keys: devices.add_device, devices.counter_some, devices.counter_limit (DE/EN/FR)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the manual "I've installed it" button in AddMacSheet with an
auto-advancing waiting-pill. As soon as the backend flips status from
pending → active (triggered by the DoH handshake from the AdGuard
watcher), the sheet jumps to the success step automatically.
- useProtectedDevicesRealtime hook subscribes to rebreak.protected_devices
UPDATE events for the current user, with auto-reconnect on CHANNEL_ERROR
- AddMacSheet listens only while in step 2 (download/install)
- devices.tsx keeps a list-level subscription so the table refreshes even
if the user dismissed the sheet before activation
- i18n: waiting_install / waiting_hint / activated_toast (DE + EN)
Bug (diagnosed by backyard, see project_session_2026-05-15_push.md):
- Manual `supabase.realtime.setAuth()` calls in subscribe-hooks set
`_manuallySetToken=true` internally, blocking the automatic token-refresh
on heartbeat. After ~1h the cached access_token expires → Postgres-Changes
silently stop arriving (channel still shows "joined" but no events).
- Plus: no AppState handler → no Foreground-Reconnect trigger after
Background-kill of WebSocket.
Fix A — lib/supabase.ts: createClient now passes a `realtime.accessToken`
async callback that returns the current session token. Heartbeat picks
fresh tokens automatically, no manual setAuth needed.
Fix A — all 5 manual `supabase.realtime.setAuth()` calls removed from
useChatRealtime, useCommunityRealtime, useDomainSubmissionRealtime,
stores/notifications. Token is handled by the callback now.
Fix B — _layout.tsx: AppState listener calls
supabase.auth.startAutoRefresh()/stopAutoRefresh() — official Supabase RN
pattern. On Foreground-Return, onAuthStateChange fires TOKEN_REFRESHED →
realtime.setAuth gets called internally.
Required for upcoming Auto-Detect protected-device handshake (Realtime
channel listens on protected_devices status transitions pending→active).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- mail/MailAccountSettingsSheet: handleSaveTitle + handleSavePassword now
dismiss sheet FIRST, then trigger parent SuccessAlert via setTimeout(350ms).
Fixes iOS "already presenting" crash + page-freeze when editing mailbox name.
Also fixes double-click-needed UX bug.
- stores/auth: signOut adds WebBrowser.coolDownAsync() to clear OAuth cookies.
signInWithOAuth for Google adds prompt=select_account — forces account-picker
on every sign-in attempt instead of auto-reusing previous account.
- app/(app)/index: feed page uses colors.groupedBg instead of colors.bg —
matches iOS Mail/Messages list-style, post-cards stand out clearer.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- stores/realtimeDebug.ts: neuer DEV-only Zustand-Store mit connection-state,
reconnect-counter, token-expiry-countdown, channel-liste, rolling log-buffer
(last 100 events). Hookt Phoenix-Socket open/close/reconnect + Channel-subscribe.
- _layout.tsx: initRealtimeDebug() im __DEV__-Block beim App-Start.
- debug.tsx: zwei neue Cards (RealtimeStatusCard + RealtimeLogCard) mit
1s-Tick-Refresh, Copy + Clear Buttons. Settings-Entry 'Realtime connection (DEV)'.
- protectedDevices.ts: Array.isArray-Guard für apiFetch-Response — verhindert
TypeError 'devices.filter is not a function' wenn Backend Non-Array zurückgibt.
Diagnostik-Tool für Realtime-Disconnect-Bug bei lange eingeloggten Usern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two small fixes blocking real "feierabend":
1. Stats-Counter veraltet nach Scan/Connect/Disconnect:
- mail.tsx hatte zwei separate Data-Sources: useMailStatus (accounts +
errors + heartbeat) und useMailStats (blockedByDay + blockedByConnection)
- onScanSuccess + onIntervalChanged + OAuth-onSuccess + disconnect-handler
refreshten nur useMailStatus → der Account-Collapsible-Counter (kommt
aus useMailStats.blockedByConnection) blieb veraltet
- Beobachtet: GMX-Scan-Button meldet "90 blockiert" als Feedback, aber
Card-Header zeigt weiter 60
- Fix: refreshAll() = refresh() + refreshStats() parallel. Alle reactive
callsites (4 Stellen) auf refreshAll umgestellt
- useMailStats hatte refresh schon exportiert (Z. 153), nur nicht
verdrahtet
2. Donut + Legend horizontal zentriert:
- vorher: alignItems center (vertikal), Legend flex:1 → linksbündig mit
Legend bis Card-Rand gestreckt
- jetzt: justifyContent center + Legend ohne flex:1 → Block in der Mitte
mit Whitespace links/rechts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
USP-Confirmed: Outlook-OAuth Casino-Bonus-Mail wurde end-to-end gefiltert
(User-verifiziert). Mit dieser Welle ist der Daemon plus alle Scan-Pfade
OAuth-aware.
Backend — Mail-Stack (mo):
- backend/server/utils/mail-auth.ts NEU: zentraler resolveImapAuth-Helper
kapselt OAuth-vs-AppPassword-Entscheidung. 5-min-Token-Expiry-Puffer,
race-condition-sicheres Refresh via refreshAndSaveTokens.
- scan.post.ts + scan-internal.post.ts nutzen jetzt resolveImapAuth statt
decrypt(passwordEncrypted). Vorher: Outlook-Connections wurden still
übersprungen weil passwordEncrypted='' → decrypt failed. Cron + manueller
Scan-Button funktionieren jetzt für OAuth-Connections.
- imap-idle: Initial-Sweep via triggerScan(conn) direkt nach Connect-Success.
Neue Outlook-Connections kriegen sofort einen Full-Folder-Scan statt bis
zu 30 Min Cron-Lag zu warten. scan-internal scannt ohnehin schon alle
Folders via imap.list() (Junk, Spam, Archive, Custom) — Multi-Folder-
Anforderung ist damit erfüllt.
Frontend — Mail-Page Polish v4 (rebreak-native-ui):
- MailDistributionChart: Donut zurück auf 200px (240 wuchs auch in der
Breite und quetschte die Legend), "Live"-Pill-Header komplett raus
(paddingTop von 16 auf 13 reduziert für tighteres Layout)
- mail.tsx Page-Hierarchie: "Mehr Infos"-Collapsible wandert von unter
der Postfach-Liste direkt unter den Hero-Donut. Sub-Beschreibung
"Blockiert — letzte 30 Tage" entfernt — Title reicht.
- Account-Card Expanded: adaptive Bar-Chart über Connection-Age
(too-new <24h zeigt Empty-State, 1-14d Day-Buckets via Backend
?connectionId=, 15-90d client-Week-Aggregation, >90d Month)
- Account-Card Expanded: Scan-Button "Jetzt scannen" mit Refresh-Icon
(Memory: kein Pen-Icon, refresh ok). Spinner während Scan, Feedback
mit Blocked-Count nach Success.
Eskalations-Hinweis (nicht in dieser Welle):
- POST /api/mail/scan akzeptiert noch keinen connectionId-Filter →
Scan-Button-Tap scannt aktuell alle Connections statt nur die
angeklickte. Kleiner Folge-Patch, nicht blocking.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-Feedback nach Live-Test:
Frontend (mail page):
- HalfDonut als shared component in components/common/HalfDonut.tsx
extrahiert (vorher local in ProtectionDetailsSheet). Mail-Page nutzt
jetzt dieselbe SVG-Math, Animation und Stroke-Style wie der
Blocker-Schutz-Details-Sheet — visuelle Konsistenz auf einen Blick.
Mail-Donut: width=168 (kompakter als die 220 in Blocker, weil Legend
rechts daneben sitzt).
- Donut zeigt Total in der Mitte mit kompaktem Format:
< 1000 → "999", >=1000 → "1.2k+" / "12k+" / "27k+"
Headline-Zahl oben links entfällt — Total ist im Donut-Center.
- "Mehr Infos" + "Kürzlich blockiert" zu EINER Top-Level-Collapsible
zusammengefasst. Beim Aufklappen: Bar-Chart direkt sichtbar, nested
Collapsible "Kürzlich blockiert" darunter (default zu).
- Account-Card Expanded: per-Connection-Bar-Chart mit adaptive
Granularität nach Connection-Age:
· <24h → Empty-State "Daten werden gesammelt, Auswertung nach 24h"
· 1-14d → Day-Buckets (echte Daten via /api/mail/stats/blocked-by-day
?connectionId=)
· 15-90d → Week-Buckets (client-aggregiert)
· >90d → Month-Buckets (client-aggregiert)
- Settings-Sheet komplett refactored: State-Machine `mode: 'list' |
'edit-title' | 'edit-email' | 'edit-password'` mit Back-Pfeil. Inline-
Edit im selben Sheet statt Sub-Sheet öffnen (FormSheet-Pattern).
Email-Edit-Row vorbereitet (Backend-PATCH-Endpoint kommt separat).
- Pen-Icons app-weit entfernt: SheetFieldStack-Row, alle Settings-Rows
auf chevron-forward (Memory-Konvention).
Frontend (MailAccountCard status fix):
- resolveStatusDot nutzt jetzt heartbeat-as-fallback. Vorher: "waiting"
wenn lastScannedAt=null, egal ob Daemon längst connected war. Jetzt:
"waiting" nur wenn weder lebendiger Heartbeat noch vergangener Scan
existiert → frisch verbundene Connections (z.B. OAuth-Outlook 5s nach
Connect) zeigen direkt "live".
- Behebt User-Beobachtung: "wartet auf erste verbindung" bei Outlook
obwohl Daemon-Log "connected, auth=xoauth2" zeigt.
Backend (imap-idle daemon):
- getMailboxLock("INBOX") jetzt mit 30s Promise.race-Timeout gewrappt.
- Outlook/XOAUTH2 hat den Edge-Case, dass der Mailbox-Lock lautlos
hängt nach erfolgreichem connect — die Session bleibt offen ohne
Fortschritt bis der Renew-Timer (10min) ein imap.close() schickt.
Mit Timeout wird das Failure-Mode explizit → Auth-Retry-Loop greift
sauber + last_connect_error mit klarem Text (statt stiller Hänger).
- Root-Cause "warum hängt es" noch nicht behoben — Diagnose nach
Deploy in Logs (mo).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-Feedback nach Live-Test:
Frontend:
- FAB raus, Plus-Button zurück in den Account-Liste-Section-Header
(`add-circle-outline` in brandOrange + Label "Postfach hinzufügen").
FAB stört am unteren Rand, oben passt zum iOS-NavBar-Pattern.
- Half-Donut Legend strikt max Top-3 + "Sonstige" — Threshold von ≤4
auf ≤3 gesenkt. Auch bei 4 Connections wird jetzt schon komprimiert.
- Hero-Donut-Subtitle "über N Postfächer" entfernt — Title-Block ist
jetzt eine Zeile: "XX blockiert · ● Live"
- Activity-Log default-collapsed war schon richtig (kein Change)
- Activity-Item-Redesign: x-Icon-Pille raus, Zeit + Provider als
Sub-Zeile unter dem Subject ("vor 2h · GMX"), kein Zeit-Label rechts mehr
Bug-Fix — NaNd in Activity-Row:
- Root-Cause: snake_case/camelCase-Mismatch. Backend liefert
`receivedAt`, `senderEmail`, `senderName`, `connectionId` (camelCase),
Frontend-Type hatte snake_case → undefined-Werte → `new Date(undefined)`
→ NaN → "NaNd"-Render
- MailBlockedItem-Type auf camelCase umgestellt + nested `connection`-Objekt
(passt jetzt zum Backend-Response)
- formatDate mit Number.isFinite-Guard — gibt null zurück bei ungültigem
Datum statt NaN-String zu rendern
Backend (imap-idle daemon):
- Daemon schreibt jetzt unmittelbar nach `client.connect()` einen Heartbeat
(last_idle_heartbeat_at = NOW()) + clear last_connect_error parallel
- Vorher: User sah 2-9min lang "wartet auf erste verbindung" obwohl
Connection längst aktiv war (Heartbeat kam erst beim ersten NOOP-Cycle)
- Re-Connect-Pfad nach AUTHENTICATIONFAILED ist automatisch mit
abgedeckt (geht durch denselben connect-Block)
- ESM-Daemon, kein Build-Step — Pipeline scp + pm2-restart reicht
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UX-Welle nach User-Feedback aus dem ersten Live-Test der Mail-Page:
Page-Hierarchie neu (top → bottom):
1. HALF-DONUT als HERO-Karte — bisherige "BLOCKIERT XX über N Postfächer Live"-
Banner-Card weg, Inhalt ist jetzt Title-Zeile innerhalb der Donut-Karte
(rendert nur ab ≥2 Connections; Fallback-Stats-Row für 0-1 Connections)
2. Postfach-Liste (Account-Cards aus letztem Refactor — schlanker Header)
3. NEU: "Mehr Infos"-Collapsible — Bar-Chart "Blockiert letzte 30 Tage"
liegt jetzt versteckt drin (default collapsed)
4. Activity-Log "Kürzlich blockiert" (unverändert)
5. NEU: FAB unten rechts — 56pt brandOrange Kreis mit "+"-Icon,
öffnet ConnectMailSheet. Section-Header-Plus-Button entfällt.
Half-Donut Legend-Truncation:
- ≤3 Connections → alle anzeigen
- =4 Connections → alle anzeigen
- ≥5 Connections → Top-3 by blocked-count + "Sonstige"-Bucket
· Donut: 4 Segmente (Top-3 + OTHER_COLOR grau)
· Legend: 4 Zeilen (Top-3 fett, "weitere"-Zeile in regular grau)
Backend: GET /api/mail/stats/blocked-by-day?connectionId=<uuid> als
optionaler Filter (für per-Connection-Bar-Chart in expanded Account-Card,
in dieser Welle noch nicht im UI verdrahtet — Erweiterung kommt wenn
gewünscht).
FAB-Details (iOS-diskreter Shadow statt Material-Glow):
- position absolute, right 24, bottom = tabBarHeight + insets.bottom + 16
- 56pt, borderRadius 28, brandOrange BG, weißes Plus-Icon
- ScrollView paddingBottom angehoben damit kein Content unter dem FAB clipped
Edge-Cases:
- 0 Accounts → FAB sichtbar, Donut/Stats/Charts/Log versteckt + EmptyState
- 1 Account → Donut hidden (nur mit ≥2 Connections sinnvoll), Fallback-Stats-Row
- limitReached + FAB-Tap → bestehender Plan-Alert (FAB ist visuell nicht disabled)
Memory: Pull-to-refresh + bestehendes 30s-Status-Polling reichen für "wartet
auf erste verbindung"→"aktiv"-Übergang nach OAuth-Connect (Daemon-Heartbeat
braucht initial 2-9min, mo-Befund). UX-Polish-Option für später: in der
Initial-Phase einen freundlicheren "Verbinde gerade…"-Status anzeigen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Microsoft V2.0 OAuth-Spezifikation: ein einzelner /token-Exchange darf nur
Scopes EINES Resource-Servers enthalten. Unsere bisherige Scope-Liste
mischte:
https://outlook.office.com/IMAP.AccessAsUser.All (outlook.office.com)
User.Read (graph.microsoft.com)
Im /authorize akzeptiert MS das (Multi-Consent-Screen), aber beim Token-
Exchange wirft MS AADSTS70011:
"The provided value for the input parameter 'scope' is not valid.
One or more scopes [...] are not compatible with each other."
Fix: User.Read raus. Display-Name in der App entfällt vorerst — Email
kommt sauber aus id_token.preferred_username (bei Consumer-MS-Accounts
typisch die Login-Email). Falls Display-Name künftig gebraucht wird →
separater Graph-Token-Exchange via On-Behalf-Of-Pattern.
Plus: ConnectMailSheet zeigt jetzt im roten Error-Banner den echten
Backend-Error (API-Status + Body) statt nur generischen Text — sonst
würden wir solche MS-Spezifika nie auf dem Device sehen.
Hans-Müller-Memo Section 3.1 (Datenkategorien) + Section 4.1
(Datenschutzerklärung) müssen entsprechend zurückgerollt werden — siehe
separater DSB-Update-Stream.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Microsoft hat App-Passwords für consumer-Outlook im September 2024 abgeschaltet.
Diese Welle bringt OAuth2/XOAUTH2-Support als zweiten AuthMethod-Pfad — Gmail/
iCloud/GMX/Yahoo bleiben unangetastet auf App-Password.
Backend (rebreak-backend):
- POST /api/mail/oauth/microsoft/init: PKCE-Flow-Start, generiert
code_verifier + Authorization-URL, persistiert pending state mit TTL
- POST /api/mail/oauth/microsoft/callback: Token-Exchange (PKCE, kein
client_secret weil Public Client), id_token-Decode für Email, MailConnection
upsert mit auth_method='oauth2_microsoft' + encrypted Tokens
- Token-Refresh-Util backend/server/utils/ms-oauth.ts + DB-Function
refreshAndSaveTokens(connectionId, clientId) mit optimistic-concurrency-
Race-Condition-Schutz (UPDATE WHERE oauth_token_expiry = <gelesener-wert>,
bei affected_rows=0 → frischen Wert lesen statt nochmal refreshen sonst
invalid_grant via Token-Rotation)
- Neue Tabelle oauth_pending_states (TTL via createdAt + Cleanup-Job-TODO)
- [id].delete.ts: echter OAuth-Disconnect — DB-Token-Löschung + Audit-Log
(MS hat keinen Drittanbieter-Revoke-Endpoint, daher User-Information-Pflicht
per Frontend-Modal, siehe DSB-Memo Section 5.1)
- Consent-Gate auch in scan.post.ts + scan-internal.post.ts (Cron-Trigger
war ohne Consent-Check = DSGVO-Lücke, jetzt geschlossen mit
skippedNoConsent-Field in Response)
IDLE-Daemon (backend/imap-idle/index.mjs, mo):
- XOAUTH2-Auth-Branch via getCredentialsForConnection() — wenn
auth_method='oauth2_microsoft', Token-Expiry-Check (<5min remaining →
proaktiver Refresh), sonst decrypted accessToken zu ImapFlow
- AUTHENTICATIONFAILED-Recovery: bis 3× reaktiv refresh + reconnect, danach
last_connect_error='auth_revoked' (kein Endlos-Loop)
- IDLE_RENEW_INTERVAL_MS = 10min — passt für MS 29min-Timeout (gleich wie
Gmail/iCloud)
- Consent-Pause: Connections mit consent_at=null laufen IDLE weiter (für
exists-Event-Wiederaufnahme), aber triggerScan() ist deaktiviert bis
consent erteilt
- start-idle-staging.sh: MS_OAUTH_CLIENT_ID explizit weiterleiten in den
inneren bash -c-Block (war Infisical-Var, ging aber durch strict-mode
verloren)
Frontend (rebreak-native-ui):
- Outlook-Tile re-aktiviert (war disabled mit "Kommt bald" seit Sept-2024-
Awareness), authMethod-Discriminator löst statt Email+Pw-Form den
OAuth-Flow aus
- ConnectMailSheet: neuer view-State 'oauth_warning' (Outing-Effekt-Hinweis
per Hans-Müller-Memo Section 6.1) + 'oauth_pending' (Browser-Step-Spinner)
- Deep-Link-Handler app/auth/mail-oauth-callback.tsx — auto-registriert
durch expo-router-File-Routing, kein Native-Rebuild (scheme 'rebreak'
schon im app.config.ts)
- mailConnectDraft-Store: pendingOAuthConnectionId für Title-Edit-Sheet
direkt nach Connect
- MailAccountCard: Password-Row hidden für OAuth-Connections, Post-Disconnect-
Modal mit MS-Account-Anleitung (DSB-konform — kompensiert fehlenden
Drittanbieter-Revoke-Endpoint mit User-Information)
Hans-Müller-DSB-Memo (mail-outlook-oauth-dsgvo-review.md):
- Section 4.1 Datenschutzerklärung-Textbaustein: "Wir widerrufen den Token
aktiv bei Microsoft"-Satz raus (war faktisch falsch — MS hat keinen
Drittanbieter-Revoke). Neuer Wortlaut: DB-Löschung + User-Anleitung
account.microsoft.com → Sicherheit → App-Berechtigungen
- Section 4.1: User.Read-Scope offen dokumentiert mit Datenminimierungs-
Klausel (Scope breiter, wir nutzen NUR Display-Name + Email-Claim)
- Section 5.1: ehrliche Doku dass MS keinen RFC-7009-Revoke hat
- Section 9 Anwalts-Themen: neue Frage 5 zur Art. 17-Erfüllung trotz
fehlendem MS-Revoke
Architektur-Eigenschaften:
- Generisches AuthMethod-Framework — Gmail/iCloud/Yahoo können später als
reine Config-Erweiterung OAuth bekommen, kein Refactor nötig
- Token-Encryption via bestehendes crypto.ts (AES-256-GCM, Key aus
Infisical)
- Consent-Gate konsistent: ConnectMailSheet-Consent-Step VOR Provider-
Auswahl (Frontend), backend-Endpoint 412 wenn consent fehlt, Daemon +
Scan-Endpoints pausieren bei consent_at=null
Open follow-ups:
- oauth_pending_states-Cleanup-Cron für abgelaufene Entries (TODO im
Backend-Code dokumentiert)
- Anwalts-Klärung Hans-Müller Section 9 (DPA-Anspruch ohne MS-Lizenz +
Art. 17 mit User-Information statt Revoke-Endpoint)
- TIA (Transfer Impact Assessment) für MS-Sub-AV — Hans-Müller-Draft-Aufgabe
- Outlook-Tile-Wieder-Aktivierung ist live, aber Phase-1-Production-Test
steht aus (User Test auf iPhone nach Pipeline-Deploy)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mail-Page-Refactor — Privacy-friendly + DiGA-tauglich:
- Custom title pro mail-connection (z.B. "Privat-Gmail" statt voller E-Mail).
Memory-Pattern: Anonymität via Nickname jetzt auch für Mail-Adressen
sichtbar, Datenminimierung. Title nullable, Fallback auf Email-Domain.
- Schema-Migration mail_connection_title (additiv, NULL default für Bestand)
- Endpoint PATCH /api/mail-connections/:id mit title-Validation (max 60,
trim, leerer String → NULL)
- "Passwort ändern"-Collapsible → vollwertige "Einstellungen"-Sektion:
Title editieren · Email read-only · Passwort neu setzen · Verbindung
trennen (mit Confirm-Dialog)
- EditMailTitleSheet als FormSheet-Pattern für Title-Edit
- mailConnectDraft-Store kriegt Title-Feld für Pre-Fill bei Re-Open
Zwei neue Stats-Charts auf der Mail-Page:
- MailBlockedByDayChart — 30-Tage-Bar-Chart, Plain-View-Bars (Pattern wie
Sparkline-Profile), Empty-State bei 0 Cooldowns
· Backend: GET /api/mail/stats/blocked-by-day?days=30
- MailDistributionChart — Half-Donut via react-native-svg, Top-5 Connections
+ "Sonstige", rendert nicht bei ≤1 Connection
· Backend: GET /api/mail/stats/blocked-by-connection
Activity-Log mit Provider-Filter:
- Filter-Chips Mo Gmail/Outlook/iCloud/etc. über bestehendem Activity-Log
- GET /api/mail/results?provider=X (war vorher hardcoded all)
- Endpoint-Naming-Fix in useMailResults (war /api/mail/blocked, jetzt
korrekt /api/mail/results — UI-Agent hatte falschen Path geraten)
Backend-Side-Effects:
- imap-providers util resolveProviderMeta(host) — gibt {provider, label,
isCustomDomain} zurück, von 3 Endpoints konsumiert
- /api/mail/status erweitert: title, provider, providerLabel,
isCustomDomain im Account-Shape
- /api/mail/results erweitert: connection-Sub-Objekt pro Entry +
provider-Filter-Query
Open follow-ups (TODOs):
- deleteOldMailBlocked-Cron löscht <24h → Bar-Chart-Daten weg. Retention
auf 90 Tage hochsetzen oder Cron stoppen.
- POST /api/mail/connect könnte die neue connection.id im Response
mitliefern → Title-PATCH direkt ohne Extra-GET (UI-Agent-Empfehlung).
- /api/mail/status zeigt nur active Connections — paused mit Title wären
unsichtbar. Entscheiden.
18 neue i18n-Keys (mail.title_*, mail.settings_*, mail.row_*,
mail.disconnect_confirm_*, mail.stats.*, mail.filter.all) in DE + EN.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DSGVO Art. 9 — Compliance-Gap im Mail-Connect-Flow geschlossen (Hans-Müller-DSB
hat den Gap für Gmail/iCloud/GMX identifiziert, schon vor Outlook-OAuth-Pflicht):
- Schema: mail_connections.consent_at + consent_version + consent_ip_address;
neue consent_logs-Tabelle für Audit (grant + revoke append-only)
- Endpoints:
- POST /api/mail-connections/consent (Bulk-Array für Re-Consent, partial-fail
wirft sofort = DSGVO-sicher gegen silent-skip fremder IDs)
- POST /api/mail-connections/:id mit consent-gate (412 wenn consentVersion fehlt)
- DELETE /api/mail-connections/:id mit Widerruf-Log (OAuth-Token-Revoke als
TODO für mo Phase 2)
- GET /api/mail-connections/pending-consent — listet Bestands-Connections
mit consent_at=NULL für Re-Consent-Modal
- Account-Lösch-Bug fix: deleteAllMailConnections() war in user/delete nicht
eingebunden — Verbindungen blieben als Waisen
- Frontend:
- ConnectMailSheet: neuer Consent-Step VOR Provider-Grid (view-Machine
consent → grid → form), exakter Hans-Müller-Wortlaut für Art. 9 Abs. 2
lit. a Einwilligung
- MailConsentReminderSheet: Re-Consent-Modal beim App-Open für Bestands-User
- Stores mailConsent + mailConnectDraft (letzterer fixt Bug: Email/Provider
ging verloren wenn User Browser für App-Pw-Generierung öffnete)
- 12 neue i18n-Keys mail.consent.* in DE + EN
- Versionierter Consent-Text: art9-mail-v1-2026-05-13 (Bump bei Text-Änderung
triggert Re-Consent für alle)
Outlook-OAuth Schema (Phase 0 — additiv, Endpoints kommen später):
- mail_connections: auth_method (default 'app_password' → keine Bestands-
Connection bricht), oauth_access_token, oauth_refresh_token,
oauth_token_expiry, oauth_scope
- Encryption via bestehendes server/utils/crypto.ts (AES-256-GCM, Key aus
Infisical)
- Plan-Doc backend/docs/mail-outlook-oauth-plan.md (mo)
- DSB-Review backend/docs/mail-outlook-oauth-dsgvo-review.md (Hans-Müller):
MS als Sub-AV via DPA Sep 2025, EU Data Boundary seit Feb 2025; 5 Pflicht-
Aufgaben + Anwalts-Klärung zu DPA-Anspruch ohne MS-Lizenz
Profile — Cooldown-Pattern-Analysis als Collapsible:
- CooldownPatternAnalysis: 24h-Uhrzeit-Heatmap, Mo–So-Wochentag-Histogramm,
Top-5-Reason-Wortcloud mit Stop-Words-Filter, Cancel-Rate-Anzeige
- DiGA-relevant: NLP läuft client-side, reason-Texte verlassen das Device
nicht (gut für DSB-Akte)
- useProfileData: useCooldownHistoryFull (limit=100) für Pattern-Analyse
- Neutral formuliert, kein Stigma, alle Headings als Frage
Plan-Docs (kein Code):
- backend/docs/mail-custom-keywords-plan.md — Pro/Legend Custom-Keyword-Filter
(3.25 PT MVP, user-scoped, Body-Match in Phase 2)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- mail.tsx: hide section-header "+" button when accounts.length === 0 — MailEmptyState's CTA is the sole add trigger; also replaces Pressable with TouchableOpacity
- MailEmptyState: Pressable → TouchableOpacity (no-Pressable rule)
- SheetFieldStack: add optional `intro?: ReactNode` prop rendered in a flexShrink:1 ScrollView above chips/active-input so it compresses gracefully when the keyboard is up
- ConnectMailSheet: move app-password guide + green AES block into `intro` prop so they're visible from the start, before the user types anything
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New component/KeyboardAwareScreen.tsx encapsulates the standard
KeyboardAvoidingView pattern for full-screen forms:
- iOS behavior="padding", Android no-op (adjustResize covers it)
- scrollable prop: ScrollView with keyboardShouldPersistTaps="handled"
- non-scrollable: TouchableWithoutFeedback+View for tap-to-dismiss
- headerOffset prop for screens owning their own header padding
Migrated to KeyboardAwareScreen: signin, signup, forgot-password,
confirm-otp (SafeAreaView-wrapped, no headerOffset needed) and
profile/edit (KAV wrapper only, explicit ScrollView retained).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
expo-local-authentication already works on Android (fingerprint / face unlock /
device PIN — the module auto-adds the biometric permission, no app.config change
needed). Only the settings description was iOS-flavoured ("Face ID, Touch ID");
add an Android variant and pick by Platform.OS. The lock screen + biometric
prompt strings were already generic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Privacy/stigma layer on top of the authenticated Supabase session — re-auth on
open so nobody but the user can open Rebreak. Not a login replacement.
- expo-local-authentication; NSFaceIDUsageDescription in app.config
- stores/appLock.ts: persisted `enabled` pref, in-memory `locked`, device-
capability check (`available`), device-passcode fallback on biometric failure
- AppLockGate wraps the root layout: locks immediately on `background` (not
`inactive` → app-switcher peek doesn't lock), renders LockScreen while
`enabled && locked && session`
- LockScreen: dark brand screen, auto-prompts on mount + on return from
background, "Abmelden" escape hatch (clears session → fresh login next launch)
- Settings: new "Sicherheit" section, native UISwitch; enabling requires a
successful biometric prompt first; row disabled + explained when device has no
biometrics/passcode
- de/en strings
Per product call: the lock gates the whole app incl. SOS (SOS already requires
an authenticated user, so there's no unauthenticated path to carve out).
Cold-start: appLock init blocks the splash → `locked` is set before first paint,
no flash of unlocked content. ios/ is gitignored so EAS prebuilds the new module.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- app/index.tsx: replaced the placeholder landing with the BrandSplash look
(#0f172a bg, SVG radial glows, breathing animation, staggered fade/bounce-ins
for app name / logo / tagline / CTAs, "Made in Germany" footer). Dropped the
"v0.1.0 RN Migration Phase 1 Skeleton" line; landing.version removed from locales.
- AddDomainSheet: onBlur runs normalizeDomain() (strips scheme/www./path/query and
email local-part) so the user sees the cleaned registrable domain before adding;
also swapped the two leftover Pressables → TouchableOpacity (no-Pressable rule).
- KeyboardAwareSheet: clamp the sheet height to (screenHeight - insets.top - 20)
while the keyboard is up, so tall sheets (e.g. AddDomainSheet's 600px) don't grow
off-screen and clip the inputs at the top.
- ConnectMailSheet: automaticallyAdjustKeyboardInsets on iOS so focused inputs scroll
into view. Covered sheets: AddDomainSheet, ConnectMailSheet, EditMailAccountSheet,
AddMacSheet, AddWindowsSheet.
JS-only (hot-reloadable).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After an APK reinstall (or an OS low-memory kill that START_STICKY didn't recover
promptly), the VpnService dies but `filter_enabled` stays true. isVpnEffectivelyOn
then reports vpn:true (from the flag) → tamperLock:true → lockedIn:true → the green
"protection active" card with no toggles, while in reality nothing is filtering.
New native reconcileVpn(): if `filter_enabled` && !RebreakVpnService.isRunning &&
VpnService.prepare()==null → startVpnService(). Wired into _layout.tsx enforceProtection()
(runs on launch / foreground / 15s poll), called before reading combined state. No-op
on iOS/web. If the VPN consent was revoked, isVpnEffectivelyOn already clears the flag,
so that case self-resolves too.
Net behavior: while `filter_enabled` is true (user hasn't exited via the cooldown),
the app keeps the VPN alive. Exiting still goes through the cooldown → forceDisable →
filter_enabled=false → reconcile leaves it off. DiGA-compliant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The a11y (App-Lock) permission flow now runs only the first time the user turns
protection on. Reactivating after a cooldown / external disable just re-starts the
VPN/DNS filter — no a11y system prompt, no modal loop ("a11y can't be activated…").
- blocker.tsx handleActivateFamilyControls: no error modal when error === 'accessibility_pending'
(we just opened the a11y settings — that's the feedback; tapping again re-opens, no loop).
- lib/protection.ts getCombinedState: "active" = urlFilter on (App-Lock is optional hardening,
not a precondition); "recoveringFromBypass" now means urlFilter is OFF while the backend
says it should be on (a real bypass), instead of "lock is off".
- blocker.tsx recoveringFromBypass alert: offers "turn back on" → activateUrlFilter (VPN),
not activateFamilyControls.
- _layout.tsx bypass re-arm (enforceProtection fallback + onBypassNotificationTap):
protection.activate() instead of activateFamilyControls().
- new i18n keys: blocker.protection_off_title / protection_off_message / reactivate_btn.
JS-only (hot-reloadable).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- protection.ts: setCooldownTestMode/getCooldownTestMode (AsyncStorage 'dev:cooldown-testmode');
requestDeactivation sends testMode:true when on (__DEV__ only)
- debug.tsx: CooldownTestModeToggle (Switch) — '40s instead of 24h, staging only'
- useProtectionState.ts: wire applyCooldownDisableIfElapsed() — fires on cooldown
active→false transition (guarded so no extra fetch per poll) + on AppState 'active';
protection actually turns off when the (test-)cooldown elapses (the 'Step 5b' auto-disable)
- DeactivationExplainerSheet.tsx: useSafeAreaInsets — header paddingTop insets.top+14,
ScrollView paddingBottom max(insets.bottom,12)+24; back btn Pressable→TouchableOpacity
- ProtectionDetailsSheet.tsx: ScrollView paddingBottom max(insets.bottom,16)+24 (was 40);
backdrop + 'Fertig' Pressable→TouchableOpacity
tsc clean. (Note: 'sheet doesn't scroll' — the bottom content was being clipped under the
home indicator; the paddingBottom fix should resolve it. Broader UI polish deferred to a
separate session — Task #10.)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- components/plan/PlanChangeSheet.tsx — upgrade/downgrade briefing per pricing-tiers.md §4
(fetches GET /api/plan/change-preview; gains/keeps/changes; recovery-safety line;
billing hint w/o purchase button; CTA row, no 'are you sure?' interstitial)
- debug.tsx: PlanOverrideToggle routes every flip through PlanChangeSheet first
- devices.tsx + protectedDevices.ts: 'degraded' status (red, inline 'protection expired —
remove the profile yourself' hint, no green checkmark); maxProtectedDevices limit hint
- mail.tsx + MailAccountCard.tsx + useMailStatus.ts: over-limit banner + paused-account
greyed-out + PausedBadge (all defensive — only shows if backend sends the field)
- blocker.tsx: free-tier transparency hint ('Grundschutz aktiv — voller Schutz: Pro/Legend')
+ custom-domain over-limit banner
- locales: plan.change.* + plan_limit.* (de + en)
tsc clean. Backend side (GET /api/plan/change-preview, paused/degraded fields) in progress
in parallel — UI built defensively to work before it lands.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
debug.tsx: removed admin-403 special-case, calls /api/dev/set-plan directly.
settings.tsx: new PlanPickerSheetContent (TrueSheet, DEV-only) in debug section
with three plan options; uses same endpoint + invalidateMe().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Picker now uses allowsEditing:false + quality:1; picked URI routes through
AvatarCropSheet (Pinch+Pan via RNGH+Reanimated, square crop frame with
corner markers). manipulateAsync crop left as TODO — expo-image-manipulator
not yet installed; sheet passes URI through unchanged until then.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Alle <Pressable style={({pressed}) => ({...})}> ersetzt — style-Funktion
droppt auf Android (New Arch) intermittierend width/height, führt zu 0×0
unsichtbaren Elementen. TouchableOpacity mit activeOpacity ist stabil.
Außerdem übrige Pressables (plain style) aus components/ und app/
migriert sowie zwei überschüssige </View>-Tags in chat.tsx + RoomCard.tsx
entfernt die TS-Fehler verursacht haben.
64 Dateien, typecheck sauber.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- settings.tsx: real "Abo" row showing current plan (Free/Pro/Legend badge),
taps open a sheet explaining subscriptions are managed on rebreak.org
(Linking.openURL → /account; TODO: gate for iOS App-Store submission per
Apple 3.1.1 — no in-app purchase flow)
- debug.tsx: __DEV__-only plan-override toggle (free/pro/legend) via
PATCH /api/admin/users/:id + invalidateMe(); shows admin-only hint on 403
- locales: settings.subscription_* keys (de + en)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>