From a95e66560d94576db84f5e43e7ab834e734755c9 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sun, 7 Jun 2026 22:26:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(magic):=20Hard-Lock=20+=20Ger=C3=A4te-UX?= =?UTF-8?q?=20(Push,=20Realtime,=20Detail-Sheet,=20Offline-Removal)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devices/Magic: - Offline-Profil-Enroll deaktiviert (410) — Lock-PW würde im Klartext im Download landen; stationärer Schutz läuft jetzt nur über Rebreak Magic - Mac-DNS-Template: ProhibitDisablement (Filter nicht abschaltbar) - Push "Neues Gerät verbunden" an mobile Geräte bei neuer Bindung - Realtime auf user_devices → Settings aktualisiert Magic-Bindings live - Geräte-Detail-Sheet (Tap auf Gerät): Status, verbunden-seit, Schutz-Donut Hard-Lock (server-gehaltenes Removal-PW, User sieht es nie): - magic_removal_password generiert/gespeichert + in Profil injiziert (Lazy-Backfill) - Reveal NUR bei Account-Löschung (user/delete) + Kündigung (stripe webhook), per Resend-Mail + in-Response - Signing config-gated (inaktiv ohne Cert; Lock greift auch unsigniert) Migrations: user_devices-Realtime-Publication + magic_removal_password-Spalten Co-Authored-By: Claude Opus 4.8 --- apps/rebreak-native/NEXT_RELEASE.md | 19 +- apps/rebreak-native/app/_layout.tsx | 4 +- apps/rebreak-native/app/devices.tsx | 205 ++++++--- .../components/devices/AddMacSheet.tsx | 402 ------------------ .../components/devices/DeviceDetailSheet.tsx | 263 ++++++++++++ .../components/devices/MagicSheet.tsx | 177 ++++++-- .../hooks/usePushTokenRegistration.ts | 10 + .../hooks/useUserDevicesRealtime.ts | 71 ++++ apps/rebreak-native/locales/de.json | 44 +- apps/rebreak-native/locales/en.json | 48 ++- .../migration.sql | 12 + .../migration.sql | 13 + backend/prisma/schema.prisma | 7 + backend/server/api/devices/enroll.post.ts | 96 +---- backend/server/api/dns/profile.get.ts | 2 + backend/server/api/magic/info.get.ts | 7 +- .../api/magic/profile.mobileconfig.get.ts | 79 ++-- backend/server/api/magic/register.post.ts | 56 ++- backend/server/api/magic/status.get.ts | 34 ++ backend/server/api/stripe/webhook.post.ts | 29 +- backend/server/api/user/delete.delete.ts | 36 +- backend/server/db/devices.ts | 63 ++- backend/server/services/push.ts | 92 ++++ backend/server/utils/magic-lock.ts | 100 +++++ .../server/utils/magic-profile-template.ts | 2 + backend/server/utils/magic-removal-email.ts | 98 +++++ backend/server/utils/plan-features.ts | 9 +- ops/mdm/rebreak-mac-dns-filter.mobileconfig | 2 + 28 files changed, 1339 insertions(+), 641 deletions(-) delete mode 100644 apps/rebreak-native/components/devices/AddMacSheet.tsx create mode 100644 apps/rebreak-native/components/devices/DeviceDetailSheet.tsx create mode 100644 apps/rebreak-native/hooks/useUserDevicesRealtime.ts create mode 100644 backend/prisma/migrations/20260607_enable_user_devices_realtime/migration.sql create mode 100644 backend/prisma/migrations/20260607_magic_removal_password/migration.sql create mode 100644 backend/server/api/magic/status.get.ts create mode 100644 backend/server/utils/magic-lock.ts create mode 100644 backend/server/utils/magic-removal-email.ts diff --git a/apps/rebreak-native/NEXT_RELEASE.md b/apps/rebreak-native/NEXT_RELEASE.md index 85ca870..858f041 100644 --- a/apps/rebreak-native/NEXT_RELEASE.md +++ b/apps/rebreak-native/NEXT_RELEASE.md @@ -1,9 +1,14 @@ # Next Release -## Fixes -- **Calls: fixed phantom/zombie incoming calls (iOS).** After an incoming call ended without the in-app call screen ever mounting (iOS shows the native CallKit banner, not our `/call` screen), the call store stayed stuck in the `ended` state forever. The `ended → idle` reset only lived in the `/call` screen, which never mounts for banner-only incoming calls. A stuck `ended` state then silently blocked every subsequent incoming call (RING + VoIP push were received but ignored by the `status !== 'idle'` guard), so accepting from the banner produced a phantom CallKit call that ticked as "active" with no real connection, and the caller saw a missed call. - - Store now self-heals back to `idle` after a call ends, decoupled from the call screen (fallback timer in `teardown`). - - `receiveIncoming` and the realtime ring handler now treat a stale `ended` state as acceptable (clear + proceed) instead of dropping the new call. - - `onAnswer` now ends the native CallKit call when the store has no `incoming` call, preventing a phantom "active" call. - - `RNCallKeep.endAllCalls()` on app launch clears leftover CallKit zombies from a previous session. -- **DM header: online dot now matches the online text.** The green online dot on the partner avatar used the follow-gated presence (`isOnline` = online AND you follow them), while the "online" text next to it used raw presence. In a DM the dot now uses raw presence too, so it shows whenever the partner is online — consistent with the text, regardless of follow relationship. (Looked like an Android-only bug but was the follow gate + asymmetric follow between the test accounts.) +## New +- Device detail view: tap any device on the Devices page to open a sheet with its connection status, when it was connected, and a protected/unprotected coverage donut (same visual as your profile) +- "New device connected" push notification — when a Mac or Windows computer is bound via Rebreak Magic, your phone(s) get notified +- Devices page now updates in real time the moment a new computer is paired — no manual refresh needed +- Onboarding now sets up the full protection using the exact same guided, gated step flow as the protection screen (single source of truth) — Android: VPN → Device Administrator → Accessibility (strict order: the tamper lock has to come last, otherwise it would block the device-admin screen); iOS: App Lock → Screen Time passcode → content filter. Previously the device-admin / screen-time hardening steps only existed in the protection screen after onboarding. + +## Changed +- Stationary protection (Mac/Windows) now runs exclusively via Rebreak Magic — the manual offline profile download has been removed. The offline profile would have shipped the removal password in plain text inside the file (bypass risk); with Magic the lock password stays server-side and is never shown to the user. +- Mac DNS profile hardened with `ProhibitDisablement` — the filter can no longer be toggled off in System Settings. + +## Fixed +- Android onboarding: if the VPN permission dialog failed to open (e.g. another always-on VPN active, work profile, or certain OEM quirks), the protection step would silently get stuck with no dialog and no error message — especially on Play Store builds, where the underlying error was swallowed. The step now surfaces the real error and offers a retry instead of dead-ending. diff --git a/apps/rebreak-native/app/_layout.tsx b/apps/rebreak-native/app/_layout.tsx index 0f3de1f..8b71b75 100644 --- a/apps/rebreak-native/app/_layout.tsx +++ b/apps/rebreak-native/app/_layout.tsx @@ -100,7 +100,7 @@ function RootLayoutInner() { if (!response) return; const data = response.notification.request.content.data as | { - type?: 'dm' | 'room' | 'call'; + type?: 'dm' | 'room' | 'call' | 'device_added'; targetId?: string; callId?: string; from?: { id: string; nickname: string; avatar: string | null }; @@ -111,6 +111,8 @@ function RootLayoutInner() { router.push({ pathname: '/dm', params: { userId: data.targetId } }); } else if (data.type === 'room' && data.targetId) { router.push({ pathname: '/room', params: { roomId: data.targetId } }); + } else if (data.type === 'device_added') { + router.push('/devices'); } else if (data.type === 'call' && data.callId && data.from) { // Eingehender Anruf via regulärem Push (Android-Pfad / iOS-Fallback wenn // kein voipToken). Auf iOS gilt: VoIP-PushKit ist der einzige Pfad diff --git a/apps/rebreak-native/app/devices.tsx b/apps/rebreak-native/app/devices.tsx index 47ddaf8..cf10e9d 100644 --- a/apps/rebreak-native/app/devices.tsx +++ b/apps/rebreak-native/app/devices.tsx @@ -27,11 +27,12 @@ function formatCountdown(isoTarget: string): string { } import { useProtectedDevicesStore, type ProtectedDevice } from '../stores/protectedDevices'; import { useProtectedDevicesRealtime } from '../hooks/useProtectedDevicesRealtime'; +import { useUserDevicesRealtime } from '../hooks/useUserDevicesRealtime'; import { useUserPlan } from '../hooks/useUserPlan'; import { AppHeader } from '../components/AppHeader'; -import { AddMacSheet } from '../components/devices/AddMacSheet'; -import { AddWindowsSheet } from '../components/devices/AddWindowsSheet'; +import { MagicSheet } from '../components/devices/MagicSheet'; import { DeviceProgressBar } from '../components/devices/DeviceProgressBar'; +import { DeviceDetailSheet, type DeviceDetail } from '../components/devices/DeviceDetailSheet'; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -124,11 +125,13 @@ function MobileDeviceRow({ onRemove, onRequestRelease, onCancelRelease, + onOpenDetail, }: { device: UserDevice; onRemove: (id: string) => void; onRequestRelease: (id: string) => void; onCancelRelease: (id: string) => void; + onOpenDetail: (d: DeviceDetail) => void; }) { const { t } = useTranslation(); const colors = useColors(); @@ -187,6 +190,20 @@ function MobileDeviceRow({ const deviceName = device.model ?? device.name ?? device.platform; const footerText = `${formatLastSeen(device.lastSeenAt, t)} · ${t('settings.devices_since')} ${formatSince(device.createdAt)}`; + function openDetail() { + onOpenDetail({ + name: deviceName, + icon: mobileIcon(device.platform), + platform: device.platform, + createdAt: device.createdAt, + lastSeenAt: device.lastSeenAt, + statusLabel: device.isCurrent + ? t('settings.devices_this_device') + : t('devices.status_active'), + statusColor: colors.success, + }); + } + return ( + + {device.isCurrent ? null : releaseActive ? ( void; + onOpenDetail: (d: DeviceDetail) => void; }) { const { t } = useTranslation(); const colors = useColors(); + const statusMeta: Record = { + pending: { label: t('devices.status_pending'), color: '#f59e0b' }, + active: { label: t('devices.status_active'), color: colors.success }, + revoked: { label: t('devices.status_revoked'), color: colors.textMuted }, + degraded: { label: t('plan_limit.device_degraded_title'), color: colors.error }, + }; + const meta = statusMeta[device.status] ?? { + label: device.status, + color: colors.textMuted, + }; + + function openDetail() { + onOpenDetail({ + name: device.label, + icon: protectedDeviceIcon(device.platform), + platform: device.platform, + createdAt: device.createdAt, + lastSeenAt: null, + statusLabel: meta.label, + statusColor: meta.color, + }); + } + const menuActions = device.status === 'pending' ? [ { id: 'remove', title: t('settings.devices_remove_confirm'), attributes: { destructive: true } }, @@ -360,6 +408,11 @@ function ProtectedDeviceRow({ paddingVertical: 14, }} > + + ('mac'); + const [detailDevice, setDetailDevice] = useState(null); + + function openMagic(platform: 'mac' | 'windows') { + setMagicPlatform(platform); + setMagicVisible(true); + } useEffect(() => { loadDevices(); @@ -496,9 +556,12 @@ export default function DevicesScreen() { }, []); useProtectedDevicesRealtime(); + useUserDevicesRealtime(); - const TOTAL_DEVICE_SLOTS = 3; - const activeProtectedCount = protectedDevices.filter((d) => d.status !== 'revoked').length; + // Geräte-Matrix (Mirror von backend plan-features): + // Pro = 1 mobil + 1 stationär, Legend = 3 mobil + 2 stationär ("lückenlos auf 5") + const mobileLimit = isLegend ? 3 : 1; + const desktopLimit = isLegend ? 2 : 1; // Dedupe: wenn ein UserDevice (mobile/desktop) auf der gleichen Plattform // (mac/ios/android/win) bereits existiert, blende die entsprechende @@ -519,8 +582,17 @@ export default function DevicesScreen() { (d) => !mobilePlatformKeys.has(normalizePlatform(d.platform)), ); - const totalRegistered = mobileDevices.length + dedupedProtected.filter((d) => d.status !== 'revoked').length; - const atDeviceLimit = isLegend && totalRegistered >= TOTAL_DEVICE_SLOTS; + // Mobile vs Desktop getrennt zählen: UserDevices mit mac/win-Plattform sind + // Magic-Desktops, der Rest (ios/android) zählt auf die Mobile-Slots. + const isDesktopPlatform = (d: { platform?: string | null; model?: string | null }) => { + const key = normalizePlatform(d.platform || d.model || ''); + return key === 'mac' || key === 'win'; + }; + const mobileCount = mobileDevices.filter((d) => !isDesktopPlatform(d)).length; + const desktopCount = + mobileDevices.filter(isDesktopPlatform).length + + dedupedProtected.filter((d) => d.status !== 'revoked').length; + const atDesktopLimit = desktopCount >= desktopLimit; // Mobile zuerst (current oben), danach Desktop/Protected. const sortedMobile = [...mobileDevices].sort((a, b) => { @@ -530,7 +602,7 @@ export default function DevicesScreen() { }); const isLoading = mobileLoading || protectedLoading; const isEmpty = !isLoading && sortedMobile.length === 0 && dedupedProtected.length === 0; - const subtitle = isLegend ? t('devices.subtitle_legend') : t('devices.subtitle_free'); + const subtitle = isLegend ? t('devices.subtitle_legend') : t('devices.subtitle_pro'); async function handleRemoveProtected(id: string) { try { @@ -569,13 +641,18 @@ export default function DevicesScreen() { > {subtitle} - {isLegend ? ( - - ) : null} + = mobileLimit} + label={t('devices.progress_mobile')} + /> + {/* Unified devices section: Mobile zuerst, dann Desktop */} @@ -616,6 +693,7 @@ export default function DevicesScreen() { onRemove={removeMobileDevice} onRequestRelease={requestRelease} onCancelRelease={cancelRelease} + onOpenDetail={setDetailDevice} /> ); @@ -628,7 +706,11 @@ export default function DevicesScreen() { borderBottomColor: colors.border, }} > - + ))} @@ -636,9 +718,9 @@ export default function DevicesScreen() { - {/* CTA or Upgrade */} - {isLegend ? ( - atDeviceLimit ? ( + {/* CTA — Desktop-Gerät hinzufügen (Pro: 1 Slot, Legend: 2 Slots) */} + {atDesktopLimit ? ( + isLegend ? (