feat(magic): Hard-Lock + Geräte-UX (Push, Realtime, Detail-Sheet, Offline-Removal)

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 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-07 22:26:25 +02:00
parent 869d8afd30
commit a95e66560d
28 changed files with 1339 additions and 641 deletions

View File

@ -1,9 +1,14 @@
# Next Release # Next Release
## Fixes ## New
- **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. - 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)
- Store now self-heals back to `idle` after a call ends, decoupled from the call screen (fallback timer in `teardown`). - "New device connected" push notification — when a Mac or Windows computer is bound via Rebreak Magic, your phone(s) get notified
- `receiveIncoming` and the realtime ring handler now treat a stale `ended` state as acceptable (clear + proceed) instead of dropping the new call. - Devices page now updates in real time the moment a new computer is paired — no manual refresh needed
- `onAnswer` now ends the native CallKit call when the store has no `incoming` call, preventing a phantom "active" call. - 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.
- `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.) ## 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.

View File

@ -100,7 +100,7 @@ function RootLayoutInner() {
if (!response) return; if (!response) return;
const data = response.notification.request.content.data as const data = response.notification.request.content.data as
| { | {
type?: 'dm' | 'room' | 'call'; type?: 'dm' | 'room' | 'call' | 'device_added';
targetId?: string; targetId?: string;
callId?: string; callId?: string;
from?: { id: string; nickname: string; avatar: string | null }; from?: { id: string; nickname: string; avatar: string | null };
@ -111,6 +111,8 @@ function RootLayoutInner() {
router.push({ pathname: '/dm', params: { userId: data.targetId } }); router.push({ pathname: '/dm', params: { userId: data.targetId } });
} else if (data.type === 'room' && data.targetId) { } else if (data.type === 'room' && data.targetId) {
router.push({ pathname: '/room', params: { roomId: 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) { } else if (data.type === 'call' && data.callId && data.from) {
// Eingehender Anruf via regulärem Push (Android-Pfad / iOS-Fallback wenn // Eingehender Anruf via regulärem Push (Android-Pfad / iOS-Fallback wenn
// kein voipToken). Auf iOS gilt: VoIP-PushKit ist der einzige Pfad // kein voipToken). Auf iOS gilt: VoIP-PushKit ist der einzige Pfad

View File

@ -27,11 +27,12 @@ function formatCountdown(isoTarget: string): string {
} }
import { useProtectedDevicesStore, type ProtectedDevice } from '../stores/protectedDevices'; import { useProtectedDevicesStore, type ProtectedDevice } from '../stores/protectedDevices';
import { useProtectedDevicesRealtime } from '../hooks/useProtectedDevicesRealtime'; import { useProtectedDevicesRealtime } from '../hooks/useProtectedDevicesRealtime';
import { useUserDevicesRealtime } from '../hooks/useUserDevicesRealtime';
import { useUserPlan } from '../hooks/useUserPlan'; import { useUserPlan } from '../hooks/useUserPlan';
import { AppHeader } from '../components/AppHeader'; import { AppHeader } from '../components/AppHeader';
import { AddMacSheet } from '../components/devices/AddMacSheet'; import { MagicSheet } from '../components/devices/MagicSheet';
import { AddWindowsSheet } from '../components/devices/AddWindowsSheet';
import { DeviceProgressBar } from '../components/devices/DeviceProgressBar'; import { DeviceProgressBar } from '../components/devices/DeviceProgressBar';
import { DeviceDetailSheet, type DeviceDetail } from '../components/devices/DeviceDetailSheet';
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
@ -124,11 +125,13 @@ function MobileDeviceRow({
onRemove, onRemove,
onRequestRelease, onRequestRelease,
onCancelRelease, onCancelRelease,
onOpenDetail,
}: { }: {
device: UserDevice; device: UserDevice;
onRemove: (id: string) => void; onRemove: (id: string) => void;
onRequestRelease: (id: string) => void; onRequestRelease: (id: string) => void;
onCancelRelease: (id: string) => void; onCancelRelease: (id: string) => void;
onOpenDetail: (d: DeviceDetail) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const colors = useColors(); const colors = useColors();
@ -187,6 +190,20 @@ function MobileDeviceRow({
const deviceName = device.model ?? device.name ?? device.platform; const deviceName = device.model ?? device.name ?? device.platform;
const footerText = `${formatLastSeen(device.lastSeenAt, t)} · ${t('settings.devices_since')} ${formatSince(device.createdAt)}`; 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 ( return (
<View <View
style={{ style={{
@ -196,6 +213,11 @@ function MobileDeviceRow({
paddingHorizontal: 14, paddingHorizontal: 14,
paddingVertical: 14, paddingVertical: 14,
}} }}
>
<TouchableOpacity
onPress={openDetail}
activeOpacity={0.6}
style={{ flex: 1, flexDirection: 'row', alignItems: 'center', gap: 12, minWidth: 0 }}
> >
<View <View
style={{ style={{
@ -283,6 +305,7 @@ function MobileDeviceRow({
: footerText} : footerText}
</Text> </Text>
</View> </View>
</TouchableOpacity>
{device.isCurrent ? null : releaseActive ? ( {device.isCurrent ? null : releaseActive ? (
<TouchableOpacity <TouchableOpacity
@ -318,13 +341,38 @@ function MobileDeviceRow({
function ProtectedDeviceRow({ function ProtectedDeviceRow({
device, device,
onRemove, onRemove,
onOpenDetail,
}: { }: {
device: ProtectedDevice; device: ProtectedDevice;
onRemove: (id: string) => void; onRemove: (id: string) => void;
onOpenDetail: (d: DeviceDetail) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const colors = useColors(); const colors = useColors();
const statusMeta: Record<string, { label: string; color: string }> = {
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' const menuActions = device.status === 'pending'
? [ ? [
{ id: 'remove', title: t('settings.devices_remove_confirm'), attributes: { destructive: true } }, { id: 'remove', title: t('settings.devices_remove_confirm'), attributes: { destructive: true } },
@ -359,6 +407,11 @@ function ProtectedDeviceRow({
paddingHorizontal: 14, paddingHorizontal: 14,
paddingVertical: 14, paddingVertical: 14,
}} }}
>
<TouchableOpacity
onPress={openDetail}
activeOpacity={0.6}
style={{ flex: 1, flexDirection: 'row', alignItems: 'center', gap: 12, minWidth: 0 }}
> >
<View <View
style={{ style={{
@ -403,6 +456,7 @@ function ProtectedDeviceRow({
: `${t('settings.devices_since')} ${formatSince(device.createdAt)}`} : `${t('settings.devices_since')} ${formatSince(device.createdAt)}`}
</Text> </Text>
</View> </View>
</TouchableOpacity>
<MenuView <MenuView
title={device.label} title={device.label}
@ -487,8 +541,14 @@ export default function DevicesScreen() {
remove: removeProtected, remove: removeProtected,
} = useProtectedDevicesStore(); } = useProtectedDevicesStore();
const [addMacVisible, setAddMacVisible] = useState(false); const [magicVisible, setMagicVisible] = useState(false);
const [addWindowsVisible, setAddWindowsVisible] = useState(false); const [magicPlatform, setMagicPlatform] = useState<'mac' | 'windows'>('mac');
const [detailDevice, setDetailDevice] = useState<DeviceDetail | null>(null);
function openMagic(platform: 'mac' | 'windows') {
setMagicPlatform(platform);
setMagicVisible(true);
}
useEffect(() => { useEffect(() => {
loadDevices(); loadDevices();
@ -496,9 +556,12 @@ export default function DevicesScreen() {
}, []); }, []);
useProtectedDevicesRealtime(); useProtectedDevicesRealtime();
useUserDevicesRealtime();
const TOTAL_DEVICE_SLOTS = 3; // Geräte-Matrix (Mirror von backend plan-features):
const activeProtectedCount = protectedDevices.filter((d) => d.status !== 'revoked').length; // 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 // Dedupe: wenn ein UserDevice (mobile/desktop) auf der gleichen Plattform
// (mac/ios/android/win) bereits existiert, blende die entsprechende // (mac/ios/android/win) bereits existiert, blende die entsprechende
@ -519,8 +582,17 @@ export default function DevicesScreen() {
(d) => !mobilePlatformKeys.has(normalizePlatform(d.platform)), (d) => !mobilePlatformKeys.has(normalizePlatform(d.platform)),
); );
const totalRegistered = mobileDevices.length + dedupedProtected.filter((d) => d.status !== 'revoked').length; // Mobile vs Desktop getrennt zählen: UserDevices mit mac/win-Plattform sind
const atDeviceLimit = isLegend && totalRegistered >= TOTAL_DEVICE_SLOTS; // 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. // Mobile zuerst (current oben), danach Desktop/Protected.
const sortedMobile = [...mobileDevices].sort((a, b) => { const sortedMobile = [...mobileDevices].sort((a, b) => {
@ -530,7 +602,7 @@ export default function DevicesScreen() {
}); });
const isLoading = mobileLoading || protectedLoading; const isLoading = mobileLoading || protectedLoading;
const isEmpty = !isLoading && sortedMobile.length === 0 && dedupedProtected.length === 0; 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) { async function handleRemoveProtected(id: string) {
try { try {
@ -569,13 +641,18 @@ export default function DevicesScreen() {
> >
{subtitle} {subtitle}
</Text> </Text>
{isLegend ? (
<DeviceProgressBar <DeviceProgressBar
count={totalRegistered} count={mobileCount}
max={TOTAL_DEVICE_SLOTS} max={mobileLimit}
atLimit={atDeviceLimit} atLimit={mobileCount >= mobileLimit}
label={t('devices.progress_mobile')}
/>
<DeviceProgressBar
count={desktopCount}
max={desktopLimit}
atLimit={atDesktopLimit}
label={t('devices.progress_desktop')}
/> />
) : null}
</View> </View>
{/* Unified devices section: Mobile zuerst, dann Desktop */} {/* Unified devices section: Mobile zuerst, dann Desktop */}
@ -616,6 +693,7 @@ export default function DevicesScreen() {
onRemove={removeMobileDevice} onRemove={removeMobileDevice}
onRequestRelease={requestRelease} onRequestRelease={requestRelease}
onCancelRelease={cancelRelease} onCancelRelease={cancelRelease}
onOpenDetail={setDetailDevice}
/> />
</View> </View>
); );
@ -628,7 +706,11 @@ export default function DevicesScreen() {
borderBottomColor: colors.border, borderBottomColor: colors.border,
}} }}
> >
<ProtectedDeviceRow device={device} onRemove={handleRemoveProtected} /> <ProtectedDeviceRow
device={device}
onRemove={handleRemoveProtected}
onOpenDetail={setDetailDevice}
/>
</View> </View>
))} ))}
</> </>
@ -636,34 +718,15 @@ export default function DevicesScreen() {
</SectionCard> </SectionCard>
</View> </View>
{/* CTA or Upgrade */} {/* CTA — Desktop-Gerät hinzufügen (Pro: 1 Slot, Legend: 2 Slots) */}
{isLegend ? ( {atDesktopLimit ? (
atDeviceLimit ? ( isLegend ? (
<Button <Button
title={t('devices.add_device')} title={t('devices.add_device')}
icon="add-circle-outline" icon="add-circle-outline"
disabled disabled
style={{ backgroundColor: colors.surfaceElevated }} style={{ backgroundColor: colors.surfaceElevated }}
/> />
) : (
<MenuView
title={t('devices.add_device')}
actions={[
{ id: 'mac', title: 'Mac' },
{ id: 'windows', title: 'Windows-PC' },
]}
onPressAction={({ nativeEvent: { event } }) => {
if (event === 'mac') setAddMacVisible(true);
else if (event === 'windows') setAddWindowsVisible(true);
}}
shouldOpenOnLongPress={false}
>
<Button
title={t('devices.add_device')}
icon="add-circle-outline"
/>
</MenuView>
)
) : ( ) : (
<View <View
style={{ style={{
@ -685,6 +748,15 @@ export default function DevicesScreen() {
</View> </View>
<Button title={t('devices.upgrade_cta')} /> <Button title={t('devices.upgrade_cta')} />
</View> </View>
)
) : (
// Kein Mac/Windows-Menü mehr — das MagicSheet hat selbst den
// Plattform-Umschalter (Mac/Windows). Direkt öffnen.
<Button
title={t('devices.add_device')}
icon="add-circle-outline"
onPress={() => openMagic('mac')}
/>
)} )}
<Text <Text
@ -700,20 +772,21 @@ export default function DevicesScreen() {
</Text> </Text>
</ScrollView> </ScrollView>
<AddMacSheet <MagicSheet
visible={addMacVisible} visible={magicVisible}
initialPlatform={magicPlatform}
colors={colors}
onClose={() => { onClose={() => {
setAddMacVisible(false); setMagicVisible(false);
loadDevices();
loadProtected(); loadProtected();
}} }}
/> />
<AddWindowsSheet <DeviceDetailSheet
visible={addWindowsVisible} visible={!!detailDevice}
onClose={() => { device={detailDevice}
setAddWindowsVisible(false); onClose={() => setDetailDevice(null)}
loadProtected();
}}
/> />
</View> </View>
); );

View File

@ -1,402 +0,0 @@
import {
ActivityIndicator,
Alert,
Linking,
ScrollView,
Text,
TextInput,
View,
} from 'react-native';
import { useCallback, useState } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics';
import { useColors } from '../../lib/theme';
import { FormSheet } from '../FormSheet';
import { RiveAvatar } from '../RiveAvatar';
import { Button } from '../Button';
import { useProtectedDevicesStore } from '../../stores/protectedDevices';
import { useProtectedDevicesRealtime } from '../../hooks/useProtectedDevicesRealtime';
import { useRouter } from 'expo-router';
// TODO lyra-persona: review lyra_intro + step_* body strings for coach tone
type Step = 1 | 2 | 3;
interface StepItem {
titleKey: string;
bodyKey: string;
icon: React.ComponentProps<typeof Ionicons>['name'];
}
const STEPS: StepItem[] = [
{ titleKey: 'devices.step_1_title', bodyKey: 'devices.step_1_body', icon: 'download-outline' },
{ titleKey: 'devices.step_2_title', bodyKey: 'devices.step_2_body', icon: 'settings-outline' },
{ titleKey: 'devices.step_3_title', bodyKey: 'devices.step_3_body', icon: 'person-outline' },
{ titleKey: 'devices.step_4_title', bodyKey: 'devices.step_4_body', icon: 'checkmark-circle-outline' },
];
export function AddMacSheet({
visible,
onClose,
}: {
visible: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const colors = useColors();
const router = useRouter();
const { enroll, enrolling } = useProtectedDevicesStore();
const [step, setStep] = useState<Step>(1);
const [label, setLabel] = useState('MacBook Pro');
const [labelError, setLabelError] = useState('');
const [enrollResult, setEnrollResult] = useState<{ deviceId: string; downloadUrl: string } | null>(null);
const handleActivated = useCallback(() => {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
setStep(3);
}, []);
useProtectedDevicesRealtime(
step === 2 ? handleActivated : undefined,
step === 2,
);
function reset() {
setStep(1);
setLabel('MacBook Pro');
setLabelError('');
setEnrollResult(null);
}
function handleClose() {
reset();
onClose();
}
async function handlePrepare() {
const trimmed = label.trim();
if (!trimmed) {
setLabelError(t('devices.label_question'));
return;
}
if (trimmed.length > 32) {
setLabelError(t('devices.label_question'));
return;
}
setLabelError('');
try {
const result = await enroll(trimmed, 'mac');
setEnrollResult(result);
setStep(2);
} catch {
Alert.alert(t('common.error'), t('common.unknown_error'));
}
}
function handleDownload() {
if (!enrollResult?.downloadUrl) return;
Linking.openURL(enrollResult.downloadUrl).catch(() => {});
}
function handleNeedHelp() {
handleClose();
router.push('/coach');
}
const sheetTitle =
step === 1
? t('devices.label_question')
: step === 2
? t('devices.download_button')
: t('devices.success_title');
const initialHeightPct = step === 1 ? 0.42 : step === 2 ? 0.74 : 0.52;
return (
<FormSheet
visible={visible}
onClose={handleClose}
title={sheetTitle}
initialHeightPct={initialHeightPct}
growWithKeyboard={step === 1}
>
{step === 1 && (
<Step1LabelContent
label={label}
setLabel={setLabel}
labelError={labelError}
onPrepare={handlePrepare}
enrolling={enrolling}
onOpenMagic={() => {
handleClose();
router.push('/magic');
}}
colors={colors}
t={t}
/>
)}
{step === 2 && (
<Step2OnboardingContent
onDownload={handleDownload}
onNeedHelp={handleNeedHelp}
colors={colors}
t={t}
/>
)}
{step === 3 && (
<Step3SuccessContent
onClose={handleClose}
colors={colors}
t={t}
/>
)}
</FormSheet>
);
}
function Step1LabelContent({
label,
setLabel,
labelError,
onPrepare,
enrolling,
colors,
t,
}: {
label: string;
setLabel: (v: string) => void;
labelError: string;
onPrepare: () => void;
enrolling: boolean;
colors: ReturnType<typeof useColors>;
t: (k: string) => string;
}) {
return (
<View style={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 16, gap: 16 }}>
<TextInput
value={label}
onChangeText={setLabel}
placeholder={t('devices.label_placeholder')}
placeholderTextColor={colors.textMuted}
maxLength={32}
returnKeyType="done"
onSubmitEditing={onPrepare}
style={{
fontSize: 16,
color: colors.text,
fontFamily: 'Nunito_400Regular',
borderWidth: 1,
borderColor: labelError ? colors.error : colors.border,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 14,
backgroundColor: colors.surface,
}}
/>
{labelError ? (
<Text style={{ fontSize: 12, color: colors.error, fontFamily: 'Nunito_400Regular' }}>
{labelError}
</Text>
) : null}
<Button
title={t('devices.prepare_profile')}
onPress={onPrepare}
loading={enrolling}
disabled={enrolling}
/>
</View>
);
}
function Step2OnboardingContent({
onDownload,
onNeedHelp,
colors,
t,
}: {
onDownload: () => void;
onNeedHelp: () => void;
colors: ReturnType<typeof useColors>;
t: (k: string) => string;
}) {
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 4, paddingBottom: 16, gap: 16 }}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* Lyra intro card */}
<View
style={{
flexDirection: 'row',
alignItems: 'flex-start',
gap: 12,
backgroundColor: colors.surfaceElevated,
borderRadius: 14,
padding: 14,
}}
>
<RiveAvatar emotion="empathy" size="sm" />
<Text
style={{
flex: 1,
fontSize: 13,
color: colors.text,
fontFamily: 'Nunito_400Regular',
lineHeight: 19,
}}
>
{t('devices.lyra_intro')}
</Text>
</View>
{/* 4-step list */}
<View style={{ gap: 12 }}>
{STEPS.map((item, idx) => (
<View
key={idx}
style={{
flexDirection: 'row',
alignItems: 'flex-start',
gap: 10,
}}
>
<View
style={{
width: 32,
height: 32,
borderRadius: 10,
backgroundColor: 'rgba(0,122,255,0.1)',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Ionicons name={item.icon} size={16} color={colors.brandOrange} />
</View>
<View style={{ flex: 1, gap: 2 }}>
<Text style={{ fontSize: 13, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
{t(item.titleKey)}
</Text>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
lineHeight: 17,
}}
>
{t(item.bodyKey)}
</Text>
</View>
</View>
))}
</View>
{/* Download button */}
<Button
title={t('devices.download_button')}
onPress={onDownload}
icon="download-outline"
/>
{/* Pending auto-detect pill */}
<View
style={{
borderRadius: 14,
paddingVertical: 14,
paddingHorizontal: 16,
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
gap: 6,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<ActivityIndicator size="small" color={colors.brandOrange} />
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_600SemiBold', flex: 1 }}>
{t('devices.waiting_install')}
</Text>
</View>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
lineHeight: 17,
marginLeft: 30,
}}
>
{t('devices.waiting_hint')}
</Text>
</View>
{/* Need help */}
<Button
title={t('devices.need_help')}
onPress={onNeedHelp}
variant="ghost"
size="sm"
style={{ alignSelf: 'center' }}
/>
</ScrollView>
);
}
function Step3SuccessContent({
onClose,
colors,
t,
}: {
onClose: () => void;
colors: ReturnType<typeof useColors>;
t: (k: string) => string;
}) {
return (
<View
style={{
paddingHorizontal: 20,
paddingTop: 16,
paddingBottom: 16,
alignItems: 'center',
gap: 16,
}}
>
<RiveAvatar emotion="happy" size="md" />
<View style={{ alignItems: 'center', gap: 6 }}>
<Text
style={{
fontSize: 22,
color: colors.text,
fontFamily: 'Nunito_700Bold',
textAlign: 'center',
}}
>
{t('devices.success_title')}
</Text>
<Text
style={{
fontSize: 14,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
textAlign: 'center',
lineHeight: 20,
}}
>
{t('devices.success_body')}
</Text>
</View>
<Button
title={t('common.ok')}
onPress={onClose}
style={{ alignSelf: 'stretch' }}
/>
</View>
);
}

View File

@ -0,0 +1,263 @@
import { useMemo } from 'react';
import { Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
import { FormSheet } from '../FormSheet';
import { HalfDonut } from '../common/HalfDonut';
import { useProtectionCoverage } from '../../hooks/useProfileData';
const PROTECTED_COLOR = '#22c55e';
const UNPROTECTED_COLOR = '#e5e5e5';
const DONUT_WIDTH = 200;
const DAY_MS = 86_400_000;
export type DeviceDetail = {
name: string;
icon: React.ComponentProps<typeof Ionicons>['name'];
platform: string;
/** ISO — Bindungs-/Verbindungsdatum */
createdAt: string;
lastSeenAt?: string | null;
statusLabel: string;
statusColor: string;
};
function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
function fmtLastSeen(iso: string, t: (k: string, o?: any) => string): string {
const min = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
if (min < 1) return t('settings.devices_just_now');
if (min < 60) return t('settings.devices_mins_ago', { count: min });
const hr = Math.floor(min / 60);
if (hr < 24) return t('settings.devices_hours_ago', { count: hr });
const day = Math.floor(hr / 24);
if (day < 30) return t('settings.devices_days_ago', { count: day });
return fmtDate(iso);
}
/**
* DeviceDetailSheet Detail-Ansicht beim Tap auf eine Geräte-Row.
*
* Zeigt Verbindungsstatus, Bindungs-Datum und den Schutz-Verlauf als
* HalfDonut (geschützt vs. ungeschützt) gleiche Visualisierung wie auf der
* Profile-Page. Per-Gerät-Split wird client-side berechnet:
* geschützt = volle Tage seit Bindung dieses Geräts
* ungeschützt = Account-Schutzalter, das VOR der Bindung lag
* echte gerätespezifische Verteilung + Progress, ohne neuen Backend-Endpoint.
*/
export function DeviceDetailSheet({
visible,
device,
onClose,
}: {
visible: boolean;
device: DeviceDetail | null;
onClose: () => void;
}) {
const { t } = useTranslation();
const colors = useColors();
const { coverage } = useProtectionCoverage();
const { protectedDays, unprotectedDays } = useMemo(() => {
if (!device) return { protectedDays: 0, unprotectedDays: 0 };
const boundAt = new Date(device.createdAt).getTime();
const devProtected = Math.max(0, Math.floor((Date.now() - boundAt) / DAY_MS));
const accountSpan = coverage
? coverage.protectedDays + coverage.unprotectedDays
: devProtected;
const before = Math.max(0, accountSpan - devProtected);
return { protectedDays: devProtected, unprotectedDays: before };
}, [device, coverage]);
const segments = useMemo(
() => [
{ value: Math.max(protectedDays, 1), color: PROTECTED_COLOR },
{ value: Math.max(unprotectedDays, 0), color: UNPROTECTED_COLOR },
],
[protectedDays, unprotectedDays],
);
return (
<FormSheet
visible={visible}
onClose={onClose}
title={device?.name ?? ''}
initialHeightPct={0.6}
growWithKeyboard={false}
>
{device && (
<View style={{ paddingHorizontal: 20, paddingTop: 4, paddingBottom: 24, gap: 20 }}>
{/* Identität */}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
<View
style={{
width: 48,
height: 48,
borderRadius: 14,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={device.icon} size={24} color={colors.text} />
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text
numberOfLines={1}
style={{ fontSize: 17, color: colors.text, fontFamily: 'Nunito_700Bold' }}
>
{device.name}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 3 }}>
<View
style={{ width: 7, height: 7, borderRadius: 4, backgroundColor: device.statusColor }}
/>
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
{device.statusLabel}
</Text>
</View>
</View>
</View>
{/* Schutz-Verlauf (HalfDonut, wie Profile) */}
<View
style={{
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
padding: 16,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<Ionicons name="shield-checkmark-outline" size={14} color={colors.textMuted} />
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.8,
textTransform: 'uppercase',
}}
>
{t('devices.detail_coverage_label')}
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'flex-end', gap: 16 }}>
<HalfDonut
segments={segments}
centerValue={protectedDays}
centerLabel={t('profile.coverage_center_label')}
width={DONUT_WIDTH}
/>
<View style={{ flex: 1, gap: 8, paddingBottom: 8 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: PROTECTED_COLOR }} />
<Text style={{ fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: colors.text }}>
{protectedDays} {t('devices.detail_this_device_protected')}
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<View
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: UNPROTECTED_COLOR,
borderWidth: 1,
borderColor: colors.border,
}}
/>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{unprotectedDays} {t('devices.detail_before_binding')}
</Text>
</View>
</View>
</View>
</View>
{/* Meta-Infos */}
<View
style={{
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
overflow: 'hidden',
}}
>
<InfoRow
label={t('devices.detail_connection')}
value={device.statusLabel}
valueColor={device.statusColor}
colors={colors}
/>
<InfoRow
label={t('devices.detail_added')}
value={fmtDate(device.createdAt)}
colors={colors}
divider
/>
{device.lastSeenAt ? (
<InfoRow
label={t('devices.detail_last_seen')}
value={fmtLastSeen(device.lastSeenAt, t)}
colors={colors}
divider
/>
) : null}
</View>
</View>
)}
</FormSheet>
);
}
function InfoRow({
label,
value,
valueColor,
colors,
divider,
}: {
label: string;
value: string;
valueColor?: string;
colors: ReturnType<typeof useColors>;
divider?: boolean;
}) {
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 14,
paddingVertical: 13,
borderTopWidth: divider ? 1 : 0,
borderTopColor: colors.border,
}}
>
<Text style={{ fontSize: 14, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
{label}
</Text>
<Text
style={{
fontSize: 14,
color: valueColor ?? colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
{value}
</Text>
</View>
);
}

View File

@ -10,8 +10,10 @@ import {
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import * as Clipboard from 'expo-clipboard'; import * as Clipboard from 'expo-clipboard';
import { useTranslation } from 'react-i18next';
import type { ColorScheme } from '../../lib/theme'; import type { ColorScheme } from '../../lib/theme';
import { apiFetch } from '../../lib/api'; import { apiFetch } from '../../lib/api';
import { useUserPlan } from '../../hooks/useUserPlan';
import { FormSheet } from '../FormSheet'; import { FormSheet } from '../FormSheet';
type PairResponse = { type PairResponse = {
@ -26,6 +28,8 @@ type MagicDevice = {
model: string | null; model: string | null;
osVersion: string | null; osVersion: string | null;
magicEnrolledAt: string; magicEnrolledAt: string;
/** "magic" = Desktop-Binding (Mac/Win), "locked" = Mobile, "protected" = Legacy-DNS */
source?: 'magic' | 'locked' | 'protected';
}; };
type MagicInfo = { type MagicInfo = {
@ -33,8 +37,12 @@ type MagicInfo = {
downloadUrl: string; downloadUrl: string;
dmgUrl: string; dmgUrl: string;
minMacosVersion: string; minMacosVersion: string;
windowsInstallerUrl?: string;
minWindowsVersion?: string;
}; };
type DesktopPlatform = 'mac' | 'windows';
/** /**
* MagicSheet Rebreak-Magic-Pairing-Flow als geteiltes FormSheet * MagicSheet Rebreak-Magic-Pairing-Flow als geteiltes FormSheet
* (Bottom-Sheet mit Titel-Header). Wird aus settings.tsx getriggert. * (Bottom-Sheet mit Titel-Header). Wird aus settings.tsx getriggert.
@ -43,18 +51,31 @@ export function MagicSheet({
visible, visible,
onClose, onClose,
colors, colors,
initialPlatform = 'mac',
}: { }: {
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
colors: ColorScheme; colors: ColorScheme;
/** Vorauswahl wenn aus dem "Gerät hinzufügen"-Menü (Mac/Windows) geöffnet */
initialPlatform?: DesktopPlatform;
}) { }) {
const [info, setInfo] = useState<MagicInfo | null>(null); const [info, setInfo] = useState<MagicInfo | null>(null);
const [platform, setPlatform] = useState<DesktopPlatform>(initialPlatform);
// Bei jedem Öffnen auf die gewünschte Plattform zurücksetzen
useEffect(() => {
if (visible) setPlatform(initialPlatform);
}, [visible, initialPlatform]);
const [pair, setPair] = useState<PairResponse | null>(null); const [pair, setPair] = useState<PairResponse | null>(null);
const [pairLoading, setPairLoading] = useState(false); const [pairLoading, setPairLoading] = useState(false);
const [pairError, setPairError] = useState<string | null>(null); const [pairError, setPairError] = useState<string | null>(null);
const [now, setNow] = useState(Date.now()); const [now, setNow] = useState(Date.now());
const [devices, setDevices] = useState<MagicDevice[] | null>(null); const [devices, setDevices] = useState<MagicDevice[] | null>(null);
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null); const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
const { t } = useTranslation();
const { plan } = useUserPlan();
// Desktop-Slots (Mac/Windows) — Mirror von backend plan-features.maxProtectedDevices
const desktopLimit = plan === 'legend' ? 2 : 1;
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@ -67,6 +88,8 @@ export function MagicSheet({
downloadUrl: 'https://staging.rebreak.org/download/rebreakmagic', downloadUrl: 'https://staging.rebreak.org/download/rebreakmagic',
dmgUrl: 'https://staging.rebreak.org/downloads/RebreakMagic-latest.dmg', dmgUrl: 'https://staging.rebreak.org/downloads/RebreakMagic-latest.dmg',
minMacosVersion: '13.0', minMacosVersion: '13.0',
windowsInstallerUrl: 'https://staging.rebreak.org/downloads/RebreakMagic-Setup.exe',
minWindowsVersion: '10 (21H2)',
}); });
} }
loadDevices(); loadDevices();
@ -106,7 +129,7 @@ export function MagicSheet({
setPair(res); setPair(res);
setNow(Date.now()); setNow(Date.now());
} catch (e: any) { } catch (e: any) {
setPairError(e?.message ?? 'Fehler beim Generieren'); setPairError(e?.message ?? t('magic.generate_error'));
} finally { } finally {
setPairLoading(false); setPairLoading(false);
} }
@ -125,6 +148,20 @@ export function MagicSheet({
const codeExpired = pair !== null && remaining <= 0; const codeExpired = pair !== null && remaining <= 0;
// Desktop-Geräte (Mac/Windows) — "locked" sind Mobile-Devices aus dem
// merged /api/magic/devices-Endpoint und zählen NICHT auf Desktop-Slots.
const desktopDevices = useMemo(
() => (devices ?? []).filter((d) => d.source !== 'locked'),
[devices],
);
const limitReached = desktopDevices.length >= desktopLimit;
const isMac = platform === 'mac';
const appName = t(isMac ? 'magic.app_mac' : 'magic.app_windows');
const downloadHref = isMac
? info?.downloadUrl
: (info?.windowsInstallerUrl ?? info?.downloadUrl);
return ( return (
<FormSheet <FormSheet
visible={visible} visible={visible}
@ -135,45 +172,75 @@ export function MagicSheet({
<View style={{ paddingHorizontal: 20, paddingTop: 4, paddingBottom: 24 }}> <View style={{ paddingHorizontal: 20, paddingTop: 4, paddingBottom: 24 }}>
{/* Sub-Header (Tagline) */} {/* Sub-Header (Tagline) */}
<Text style={{ fontSize: 13, color: colors.textMuted, marginBottom: 16, marginLeft: 4 }}> <Text style={{ fontSize: 13, color: colors.textMuted, marginBottom: 16, marginLeft: 4 }}>
iPhone in 30 Sek. binden ohne Werks-Reset. {t(isMac ? 'magic.tagline_mac' : 'magic.tagline_windows')}
</Text> </Text>
{/* Plattform-Wahl */}
<SectionTitle text={t('magic.platform_question')} colors={colors} />
<View style={{ flexDirection: 'row', gap: 10, marginBottom: 16 }}>
<PlatformOption
icon="laptop-outline"
label="Mac"
selected={isMac}
onPress={() => setPlatform('mac')}
colors={colors}
/>
<PlatformOption
icon="desktop-outline"
label="Windows"
selected={!isMac}
onPress={() => setPlatform('windows')}
colors={colors}
/>
</View>
{/* Step 1 — Download */} {/* Step 1 — Download */}
<SectionTitle text="1. Mac-App herunterladen" colors={colors} /> <SectionTitle text={t('magic.step1_title', { app: appName })} colors={colors} />
<View style={cardStyle(colors)}> <View style={cardStyle(colors)}>
<Text style={{ fontSize: 14, color: colors.text, marginBottom: 12 }}> <Text style={{ fontSize: 14, color: colors.text, marginBottom: 12 }}>
Auf deinem Mac öffnen (min. macOS {info?.minMacosVersion ?? '13.0'}). {isMac
? t('magic.step1_body_mac', { version: info?.minMacosVersion ?? '13.0' })
: t('magic.step1_body_windows', { version: info?.minWindowsVersion ?? '10 (21H2)' })}
</Text> </Text>
<PrimaryButton <PrimaryButton
icon="cloud-download-outline" icon="cloud-download-outline"
label="Download öffnen" label={t('magic.open_download')}
onPress={() => info && Linking.openURL(info.downloadUrl)} onPress={() => downloadHref && Linking.openURL(downloadHref)}
/> />
<TouchableOpacity <TouchableOpacity
onPress={() => info && Share.share({ message: info.downloadUrl })} onPress={() => downloadHref && Share.share({ message: downloadHref })}
style={{ marginTop: 10, alignSelf: 'flex-start' }} style={{ marginTop: 10, alignSelf: 'flex-start' }}
> >
<Text style={{ fontSize: 13, color: '#007AFF' }}>Link an meinen Mac senden</Text> <Text style={{ fontSize: 13, color: '#007AFF' }}>
{t(isMac ? 'magic.send_link_mac' : 'magic.send_link_windows')}
</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Step 2 — Pairing-Code */} {/* Step 2 — Pairing-Code */}
<SectionTitle text="2. Pairing-Code generieren" colors={colors} /> <SectionTitle text={t('magic.step2_title')} colors={colors} />
<View style={cardStyle(colors)}> <View style={cardStyle(colors)}>
{!pair || codeExpired ? ( {limitReached && (!pair || codeExpired) ? (
<Text style={{ fontSize: 14, color: colors.textMuted }}>
{t('magic.limit_reached', {
count: desktopDevices.length,
max: desktopLimit,
})}{' '}
{t(plan === 'legend' ? 'magic.limit_hint_legend' : 'magic.limit_hint_pro')}
</Text>
) : !pair || codeExpired ? (
<> <>
<Text style={{ fontSize: 14, color: colors.text, marginBottom: 14 }}> <Text style={{ fontSize: 14, color: colors.text, marginBottom: 14 }}>
Erzeuge einen 6-stelligen Code und gib ihn in der Mac-App ein. Gültig 10 {t('magic.code_explainer', { app: appName })}
Minuten, nur einmal verwendbar.
</Text> </Text>
<PrimaryButton <PrimaryButton
icon="key-outline" icon="key-outline"
label={ label={
pairLoading pairLoading
? 'Generiere…' ? t('magic.generating')
: codeExpired : codeExpired
? 'Neuen Code erzeugen' ? t('magic.generate_new')
: 'Code erzeugen' : t('magic.generate')
} }
onPress={handleGenerateCode} onPress={handleGenerateCode}
loading={pairLoading} loading={pairLoading}
@ -192,7 +259,7 @@ export function MagicSheet({
marginBottom: 12, marginBottom: 12,
}} }}
> >
In Mac-App eingeben: {t('magic.enter_in_app', { app: appName })}
</Text> </Text>
<Pressable <Pressable
onPress={handleCopyCode} onPress={handleCopyCode}
@ -234,11 +301,13 @@ export function MagicSheet({
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Ionicons name="time-outline" size={14} color={colors.textMuted} /> <Ionicons name="time-outline" size={14} color={colors.textMuted} />
<Text style={{ fontSize: 13, color: colors.textMuted }}> <Text style={{ fontSize: 13, color: colors.textMuted }}>
Läuft ab in {formatRemaining(remaining)} {t('magic.expires_in', { time: formatRemaining(remaining) })}
</Text> </Text>
</View> </View>
<TouchableOpacity onPress={handleCopyCode}> <TouchableOpacity onPress={handleCopyCode}>
<Text style={{ fontSize: 13, color: '#007AFF', fontWeight: '600' }}>Kopieren</Text> <Text style={{ fontSize: 13, color: '#007AFF', fontWeight: '600' }}>
{t('magic.copy')}
</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<TouchableOpacity <TouchableOpacity
@ -248,24 +317,28 @@ export function MagicSheet({
}} }}
style={{ marginTop: 14, alignSelf: 'center' }} style={{ marginTop: 14, alignSelf: 'center' }}
> >
<Text style={{ fontSize: 13, color: colors.textMuted }}>Code verwerfen</Text> <Text style={{ fontSize: 13, color: colors.textMuted }}>
{t('magic.discard_code')}
</Text>
</TouchableOpacity> </TouchableOpacity>
</> </>
)} )}
</View> </View>
{/* Verbundene Ger\u00e4te */} {/* Verbundene Computer + Slot-Anzeige */}
<SectionTitle text="Verbundene Ger\u00e4te" colors={colors} /> <SectionTitle
text={`${t('magic.connected_title')} \u00b7 ${desktopDevices.length}/${desktopLimit}`}
colors={colors}
/>
<View style={cardStyle(colors)}> <View style={cardStyle(colors)}>
{devices === null ? ( {devices === null ? (
<ActivityIndicator /> <ActivityIndicator />
) : devices.length === 0 ? ( ) : desktopDevices.length === 0 ? (
<Text style={{ fontSize: 14, color: colors.textMuted }}> <Text style={{ fontSize: 14, color: colors.textMuted }}>
Noch keine Macs verbunden. Sobald du einen Pairing-Code einlöst und ein iPhone {t('magic.connected_empty')}
bindest, erscheint es hier.
</Text> </Text>
) : ( ) : (
devices.map((d, i) => ( desktopDevices.map((d, i) => (
<View <View
key={d.deviceId} key={d.deviceId}
style={{ style={{
@ -287,7 +360,15 @@ export function MagicSheet({
marginRight: 12, marginRight: 12,
}} }}
> >
<Ionicons name="laptop-outline" size={20} color={colors.text} /> <Ionicons
name={
d.model?.toLowerCase().includes('windows')
? 'desktop-outline'
: 'laptop-outline'
}
size={20}
color={colors.text}
/>
</View> </View>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text style={{ fontSize: 15, color: colors.text, fontWeight: '600' }}> <Text style={{ fontSize: 15, color: colors.text, fontWeight: '600' }}>
@ -374,6 +455,50 @@ function PrimaryButton({
); );
} }
function PlatformOption({
icon,
label,
selected,
onPress,
colors,
}: {
icon: React.ComponentProps<typeof Ionicons>['name'];
label: string;
selected: boolean;
onPress: () => void;
colors: ColorScheme;
}) {
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.7}
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
paddingVertical: 14,
borderRadius: 12,
backgroundColor: colors.card,
borderWidth: 2,
borderColor: selected ? '#007AFF' : colors.border,
}}
>
<Ionicons name={icon} size={18} color={selected ? '#007AFF' : colors.textMuted} />
<Text
style={{
fontSize: 15,
fontFamily: 'Nunito_700Bold',
color: selected ? '#007AFF' : colors.text,
}}
>
{label}
</Text>
</TouchableOpacity>
);
}
function formatRemaining(seconds: number): string { function formatRemaining(seconds: number): string {
const m = Math.floor(seconds / 60); const m = Math.floor(seconds / 60);
const s = seconds % 60; const s = seconds % 60;

View File

@ -110,6 +110,16 @@ export async function registerPushTokenWithBackend(): Promise<string | null> {
sound: 'default', sound: 'default',
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC, lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC,
}); });
// Account-Security: „Neues Gerät verbunden" — eigener Channel, damit der
// User Geräte-Alerts nicht zusammen mit Chat stummschaltet.
await Notifications.setNotificationChannelAsync('devices', {
name: 'Geräte & Sicherheit',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#007AFF',
sound: 'default',
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC,
});
} }
// 3) Token holen // 3) Token holen

View File

@ -0,0 +1,71 @@
import { useEffect } from "react";
import { supabase } from "../lib/supabase";
import type { RealtimeChannel } from "@supabase/supabase-js";
import { useDevicesStore } from "../stores/devices";
/**
* Realtime-Subscription auf `rebreak.user_devices` für den eingeloggten User.
*
* Trigger-Fall: Die Magic-Desktop-App (Mac/Windows) ruft serverseitig
* /api/magic/register neues UserDevice-INSERT. Diese Subscription lässt die
* Geräte-Liste in den Settings sofort nachladen, ohne dass der User pullt.
*
* Spiegelt [[useProtectedDevicesRealtime]]: publication-only (keine RLS), wir
* reagieren auf jedes Event und reloaden den Store keine Abhängigkeit von
* payload.old (REPLICA IDENTITY irrelevant).
*/
export function useUserDevicesRealtime(enabled: boolean = true) {
useEffect(() => {
if (!enabled) return;
let channel: RealtimeChannel | null = null;
let cancelled = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
async function subscribe() {
const { data } = await supabase.auth.getSession();
const session = data.session;
if (!session?.access_token || cancelled) return;
const userId = session.user.id;
channel = supabase
.channel(`user-devices:${userId}:${Date.now()}`)
.on(
"postgres_changes",
{
event: "*",
schema: "rebreak",
table: "user_devices",
filter: `user_id=eq.${userId}`,
},
() => {
useDevicesStore.getState().loadDevices();
},
)
.subscribe((status: string) => {
if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
cleanup();
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
if (!cancelled) subscribe();
}, 3000);
}
});
}
function cleanup() {
if (channel) {
supabase.removeChannel(channel);
channel = null;
}
}
subscribe();
return () => {
cancelled = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
cleanup();
};
}, [enabled]);
}

View File

@ -869,7 +869,7 @@
"devices": "Geräte", "devices": "Geräte",
"devices_desc": "Registrierte Geräte verwalten", "devices_desc": "Registrierte Geräte verwalten",
"rebreak_magic": "Rebreak Magic", "rebreak_magic": "Rebreak Magic",
"rebreak_magic_desc": "iPhone in 30 Sek. binden (Mac-App)", "rebreak_magic_desc": "Mac & Windows-PC schützen, iPhone binden",
"subscription": "Abonnement", "subscription": "Abonnement",
"subscription_desc": "Plan & Upgrade-Pfad", "subscription_desc": "Plan & Upgrade-Pfad",
"subscription_plan_free": "Free", "subscription_plan_free": "Free",
@ -1323,7 +1323,7 @@
"devices": { "devices": {
"section_title_this": "Dieses Gerät", "section_title_this": "Dieses Gerät",
"section_title_others": "Weitere geschützte Geräte", "section_title_others": "Weitere geschützte Geräte",
"subtitle_legend": "Schutz auf bis zu 3 Geräten — egal welches du benutzt.", "subtitle_legend": "Lückenloser Schutz auf bis zu 5 Geräten — 3 mobil, 2 stationär.",
"subtitle_free": "Aktuelles Gerät geschützt.", "subtitle_free": "Aktuelles Gerät geschützt.",
"add_mac": "Mac hinzufügen", "add_mac": "Mac hinzufügen",
"add_windows": "Windows hinzufügen (bald)", "add_windows": "Windows hinzufügen (bald)",
@ -1384,7 +1384,16 @@
"release_cancel": "Freigabe abbrechen", "release_cancel": "Freigabe abbrechen",
"release_cancel_confirm": "Freigabe wirklich abbrechen?", "release_cancel_confirm": "Freigabe wirklich abbrechen?",
"release_cancel_body": "Das Gerät bleibt weiterhin an deinen Account gebunden.", "release_cancel_body": "Das Gerät bleibt weiterhin an deinen Account gebunden.",
"release_cancel_cta": "Ja, abbrechen" "release_cancel_cta": "Ja, abbrechen",
"subtitle_pro": "Schutz für dein Handy und deinen Computer — dort, wo du wirklich gefährdet bist.",
"progress_mobile": "Mobil (iOS / Android)",
"progress_desktop": "Computer (Mac / Windows)",
"detail_connection": "Verbindung",
"detail_added": "Verbunden seit",
"detail_last_seen": "Zuletzt aktiv",
"detail_coverage_label": "Schutz-Verlauf",
"detail_this_device_protected": "Tage geschützt",
"detail_before_binding": "vor der Bindung"
}, },
"plan": { "plan": {
"change": { "change": {
@ -1571,5 +1580,34 @@
"body": "Das ist außergewöhnlich — und du hilfst uns, ReBreak als offizielle DiGA (Digitale Gesundheitsanwendung) zuzulassen. Dafür brauchen wir anonyme demografische Daten. Freiwillig, 2 Minuten.", "body": "Das ist außergewöhnlich — und du hilfst uns, ReBreak als offizielle DiGA (Digitale Gesundheitsanwendung) zuzulassen. Dafür brauchen wir anonyme demografische Daten. Freiwillig, 2 Minuten.",
"cta": "Daten ausfüllen", "cta": "Daten ausfüllen",
"later": "Vielleicht später" "later": "Vielleicht später"
},
"magic": {
"tagline_mac": "iPhone binden & Mac schützen — in 30 Sekunden.",
"tagline_windows": "Glücksspiel-Schutz für deinen Windows-PC — in 2 Minuten.",
"platform_question": "Welchen Computer möchtest du schützen?",
"step1_title": "1. %{app} herunterladen",
"step1_body_mac": "Auf deinem Mac öffnen (min. macOS %{version}).",
"step1_body_windows": "Auf deinem Windows-PC öffnen (min. Windows %{version}).",
"open_download": "Download öffnen",
"send_link_mac": "Link an meinen Mac senden",
"send_link_windows": "Link an meinen PC senden",
"step2_title": "2. Pairing-Code generieren",
"limit_reached": "Desktop-Limit erreicht (%{count}/%{max}).",
"limit_hint_legend": "Entferne zuerst einen verbundenen Computer.",
"limit_hint_pro": "Entferne zuerst einen Computer — oder upgrade auf Legend für 2 Desktop-Geräte.",
"code_explainer": "Erzeuge einen 6-stelligen Code und gib ihn in der %{app} ein. Gültig 10 Minuten, nur einmal verwendbar.",
"generating": "Generiere…",
"generate_new": "Neuen Code erzeugen",
"generate": "Code erzeugen",
"enter_in_app": "In der %{app} eingeben:",
"expires_in": "Läuft ab in %{time}",
"copy": "Kopieren",
"discard_code": "Code verwerfen",
"connected_title": "Verbundene Computer",
"connected_empty": "Noch kein Computer verbunden. Sobald du einen Pairing-Code in der Mac- oder Windows-App einlöst, erscheint er hier.",
"generate_error": "Fehler beim Generieren",
"app_mac": "Mac-App",
"app_windows": "Windows-App",
"manual_fallback": "Ohne App? DNS-Profil manuell installieren"
} }
} }

View File

@ -510,7 +510,9 @@
"diga_choice": { "diga_choice": {
"body": "Do you have a prescription code from your health insurance? Then everything's unlocked for you." "body": "Do you have a prescription code from your health insurance? Then everything's unlocked for you."
}, },
"diga_code": { "body": "Type your code — I'll check it for you." }, "diga_code": {
"body": "Type your code — I'll check it for you."
},
"plan": { "plan": {
"body": "To keep the protection running on your device, we need a plan — first 14 days are free. What feels right for you?" "body": "To keep the protection running on your device, we need a plan — first 14 days are free. What feels right for you?"
}, },
@ -867,7 +869,7 @@
"devices": "Devices", "devices": "Devices",
"devices_desc": "Manage registered devices", "devices_desc": "Manage registered devices",
"rebreak_magic": "Rebreak Magic", "rebreak_magic": "Rebreak Magic",
"rebreak_magic_desc": "Bind iPhone in 30s (Mac app)", "rebreak_magic_desc": "Protect Mac & Windows PC, bind iPhone",
"subscription": "Subscription", "subscription": "Subscription",
"subscription_desc": "Plan & upgrade path", "subscription_desc": "Plan & upgrade path",
"subscription_plan_free": "Free", "subscription_plan_free": "Free",
@ -1321,7 +1323,7 @@
"devices": { "devices": {
"section_title_this": "This device", "section_title_this": "This device",
"section_title_others": "Other protected devices", "section_title_others": "Other protected devices",
"subtitle_legend": "Protection across up to 3 devices — whichever one you use.", "subtitle_legend": "Seamless protection on up to 5 devices — 3 mobile, 2 desktop.",
"subtitle_free": "Current device protected.", "subtitle_free": "Current device protected.",
"add_mac": "Add Mac", "add_mac": "Add Mac",
"add_windows": "Add Windows (coming soon)", "add_windows": "Add Windows (coming soon)",
@ -1382,7 +1384,16 @@
"release_cancel": "Cancel release", "release_cancel": "Cancel release",
"release_cancel_confirm": "Really cancel the release?", "release_cancel_confirm": "Really cancel the release?",
"release_cancel_body": "The device will remain bound to your account.", "release_cancel_body": "The device will remain bound to your account.",
"release_cancel_cta": "Yes, cancel" "release_cancel_cta": "Yes, cancel",
"subtitle_pro": "Protection for your phone and your computer — where it really matters.",
"progress_mobile": "Mobile (iOS / Android)",
"progress_desktop": "Computer (Mac / Windows)",
"detail_connection": "Connection",
"detail_added": "Connected since",
"detail_last_seen": "Last active",
"detail_coverage_label": "Protection history",
"detail_this_device_protected": "days protected",
"detail_before_binding": "before binding"
}, },
"plan": { "plan": {
"change": { "change": {
@ -1552,5 +1563,34 @@
"body": "That's extraordinary — and you help us get ReBreak officially certified as a DiGA (Digital Health Application). We need anonymous demographic data for that. Voluntary, 2 minutes.", "body": "That's extraordinary — and you help us get ReBreak officially certified as a DiGA (Digital Health Application). We need anonymous demographic data for that. Voluntary, 2 minutes.",
"cta": "Fill in data", "cta": "Fill in data",
"later": "Maybe later" "later": "Maybe later"
},
"magic": {
"tagline_mac": "Bind your iPhone & protect your Mac — in 30 seconds.",
"tagline_windows": "Gambling protection for your Windows PC — set up in 2 minutes.",
"platform_question": "Which computer do you want to protect?",
"step1_title": "1. Download the %{app}",
"step1_body_mac": "Open on your Mac (min. macOS %{version}).",
"step1_body_windows": "Open on your Windows PC (min. Windows %{version}).",
"open_download": "Open download",
"send_link_mac": "Send link to my Mac",
"send_link_windows": "Send link to my PC",
"step2_title": "2. Generate pairing code",
"limit_reached": "Desktop limit reached (%{count}/%{max}).",
"limit_hint_legend": "Remove a connected computer first.",
"limit_hint_pro": "Remove a computer first — or upgrade to Legend for 2 desktop devices.",
"code_explainer": "Generate a 6-digit code and enter it in the %{app}. Valid for 10 minutes, single use.",
"generating": "Generating…",
"generate_new": "Generate new code",
"generate": "Generate code",
"enter_in_app": "Enter in the %{app}:",
"expires_in": "Expires in %{time}",
"copy": "Copy",
"discard_code": "Discard code",
"connected_title": "Connected computers",
"connected_empty": "No computer connected yet. As soon as you redeem a pairing code in the Mac or Windows app, it will appear here.",
"generate_error": "Failed to generate code",
"app_mac": "Mac app",
"app_windows": "Windows app",
"manual_fallback": "No app? Install the DNS profile manually"
} }
} }

View File

@ -0,0 +1,12 @@
-- Enable Supabase Realtime for user_devices table.
-- Lets the Settings/Devices screen reflect a new Magic binding (Mac/Windows)
-- the instant the desktop app calls /api/magic/register — no manual refresh.
--
-- Mirror of 20260515_enable_protected_devices_realtime: publication-add only,
-- no RLS (the rebreak schema relies on the client-side user_id filter, same as
-- protected_devices). INSERT/UPDATE/DELETE are published by default.
--
-- STOP: run this only after User-GO via:
-- pnpm prisma migrate deploy (on Hetzner, via GitHub Actions deploy)
ALTER PUBLICATION supabase_realtime ADD TABLE rebreak.user_devices;

View File

@ -0,0 +1,13 @@
-- Hard-Lock für RebreakMagic Mac/Windows-Profile.
--
-- magic_removal_password: server-gehaltenes Removal-Passwort, das in das
-- com.apple.profileRemovalPassword-Payload injiziert wird. Der User sieht es
-- NIE direkt — nur nach Cooldown-Release (Offboarding). Damit kann das Profil
-- nicht trivial entfernt werden (Schutz gegen impulsives Abschalten).
--
-- magic_release_requested_at: Zeitpunkt des Entfern-Antrags. Das Removal-
-- Passwort wird erst nach Ablauf des Cooldowns sichtbar gemacht.
ALTER TABLE "rebreak"."user_devices"
ADD COLUMN "magic_removal_password" TEXT,
ADD COLUMN "magic_release_requested_at" TIMESTAMP(3);

View File

@ -1098,6 +1098,13 @@ model UserDevice {
magicRevokedAt DateTime? @map("magic_revoked_at") magicRevokedAt DateTime? @map("magic_revoked_at")
/// Mac-Hostname für UI (z.B. "Chahines MacBook Pro"). Nur bei Magic-Devices. /// Mac-Hostname für UI (z.B. "Chahines MacBook Pro"). Nur bei Magic-Devices.
magicHostname String? @map("magic_hostname") magicHostname String? @map("magic_hostname")
/// Server-gehaltenes Removal-Passwort für das Mac/Win-Config-Profil.
/// Wird in com.apple.profileRemovalPassword injiziert — User sieht es NIE,
/// nur nach Cooldown-Release (Offboarding). NULL → noch keins generiert.
magicRemovalPassword String? @map("magic_removal_password")
/// Wann der User die Entfernung des Magic-Profils beantragt hat.
/// Removal-Passwort wird erst nach +MAGIC_RELEASE_COOLDOWN_H sichtbar.
magicReleaseRequestedAt DateTime? @map("magic_release_requested_at")
@@unique([userId, deviceId]) @@unique([userId, deviceId])
@@index([userId]) @@index([userId])

View File

@ -1,89 +1,19 @@
import { randomBytes } from "crypto";
import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features";
import {
countActiveProtectedDevices,
createProtectedDevice,
} from "../../db/protectedDevices";
/** /**
* POST /api/devices/enroll * POST /api/devices/enroll DEAKTIVIERT (410 Gone)
* *
* Legend-only. User klickt "Mac hinzufügen" in der App. * Der manuelle Offline-Profil-Download ist abgeschaltet: Das Profil hätte das
* Legt ein ProtectedDevice (status=pending) an und gibt die Download-URL * RemovalPassword im Klartext im heruntergeladenen .mobileconfig Bypass-Risiko.
* für das mobileconfig-Profil zurück. * Stationärer Schutz (Mac/Windows) läuft jetzt ausschließlich über Rebreak Magic
* (Server hält das Lock-Passwort, der User sieht es nie).
* *
* Body: { platform: "mac" | "windows" | "ios" | "android", label: string } * Bestehende ProtectedDevices bleiben sichtbar/entfernbar (GET + DELETE), nur
* Response: { deviceId, dnsToken, downloadUrl } * NEUE Enrollments über diesen Pfad sind gesperrt.
*/ */
export default defineEventHandler(async (event) => { export default defineEventHandler(() => {
const user = await requireUser(event);
const profile = await getProfile(user.id);
const limits = getPlanLimits(profile?.plan ?? "free");
// maxProtectedDevices=0 → Feature nicht verfügbar (free/pro)
if (limits.maxProtectedDevices === 0) {
throw createError({ throw createError({
statusCode: 403, statusCode: 410,
data: { error: "LEGEND_REQUIRED" }, message:
"Der manuelle Profil-Download ist deaktiviert. Stationäre Geräte werden über Rebreak Magic verbunden.",
data: { error: "OFFLINE_ENROLL_DISABLED" },
}); });
}
const body = await readBody(event);
const platform = body?.platform as string | undefined;
const label = body?.label as string | undefined;
const VALID_PLATFORMS = ["mac", "windows", "ios", "android"];
if (!platform || !VALID_PLATFORMS.includes(platform)) {
throw createError({
statusCode: 400,
data: { error: "INVALID_PLATFORM", validValues: VALID_PLATFORMS },
});
}
if (!label || typeof label !== "string" || label.trim().length === 0) {
throw createError({ statusCode: 400, data: { error: "LABEL_REQUIRED" } });
}
const trimmedLabel = label.trim().slice(0, 100);
// Limit: max. maxProtectedDevices active+pending Devices
const activeCount = await countActiveProtectedDevices(user.id);
if (activeCount >= limits.maxProtectedDevices) {
throw createError({
statusCode: 409,
data: {
error: "plan_limit",
resource: "protected_devices",
current: activeCount,
limit: limits.maxProtectedDevices,
},
});
}
// 32-char hex token — kryptografisch sicher
const dnsToken = randomBytes(16).toString("hex");
const device = await createProtectedDevice({
userId: user.id,
dnsToken,
platform,
label: trimmedLabel,
});
const config = useRuntimeConfig(event);
const apiBase =
(config.public as any)?.apiBase ?? "https://api.rebreak.org";
// Platform-aware download URL: Windows gets .reg, everything else .mobileconfig
const profileExt = platform === "windows" ? "reg" : "mobileconfig";
const downloadUrl = `${apiBase}/api/devices/${device.id}/profile.${profileExt}`;
return {
success: true,
data: {
deviceId: device.id,
dnsToken: device.dnsToken,
downloadUrl,
},
};
}); });

View File

@ -76,6 +76,8 @@ export default defineEventHandler(async (event) => {
<string>com.apple.dnsSettings.managed</string> <string>com.apple.dnsSettings.managed</string>
<key>PayloadVersion</key> <key>PayloadVersion</key>
<integer>1</integer> <integer>1</integer>
<key>ProhibitDisablement</key>
<true/>
<key>DNSSettings</key> <key>DNSSettings</key>
<dict> <dict>
<key>DNSProtocol</key> <key>DNSProtocol</key>

View File

@ -2,9 +2,9 @@
* GET /api/magic/info * GET /api/magic/info
* *
* Public keine Auth. Liefert Metadaten für die Native-App-Settings-Seite: * Public keine Auth. Liefert Metadaten für die Native-App-Settings-Seite:
* Download-URL der aktuellen DMG + Latest-Version. * Download-URLs (Mac DMG + Windows Installer) + Latest-Versions.
* *
* Auto-Updates passieren in der Mac-App selbst hier nur Erstinstallation. * Auto-Updates passieren in den Desktop-Apps selbst hier nur Erstinstallation.
*/ */
export default defineEventHandler(() => { export default defineEventHandler(() => {
return { return {
@ -14,6 +14,9 @@ export default defineEventHandler(() => {
downloadUrl: "https://rebreak.org/download/rebreakmagic", downloadUrl: "https://rebreak.org/download/rebreakmagic",
dmgUrl: "https://rebreak.org/downloads/RebreakMagic-latest.dmg", dmgUrl: "https://rebreak.org/downloads/RebreakMagic-latest.dmg",
minMacosVersion: "13.0", minMacosVersion: "13.0",
// Windows (apps/rebreak-magic-win, Tauri NSIS-Installer)
windowsInstallerUrl: "https://rebreak.org/downloads/RebreakMagic-Setup.exe",
minWindowsVersion: "10 (21H2)",
}, },
}; };
}); });

View File

@ -1,21 +1,29 @@
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { findMagicDeviceByToken } from "../../db/devices"; import {
findMagicDeviceByToken,
ensureMagicRemovalPassword,
} from "../../db/devices";
import { MAGIC_PROFILE_TEMPLATE } from "../../utils/magic-profile-template"; import { MAGIC_PROFILE_TEMPLATE } from "../../utils/magic-profile-template";
import {
buildRemovalPasswordPayload,
generateRemovalPassword,
signProfileIfConfigured,
} from "../../utils/magic-lock";
/** /**
* GET /api/magic/profile.mobileconfig?token=<dnsToken> * GET /api/magic/profile.mobileconfig?token=<dnsToken>
* *
* Generiert personalisiertes DNS-Configuration-Profile für macOS. * Generiert das personalisierte, GESPERRTE DNS-Config-Profil für macOS/Windows.
* Template: ops/mdm/rebreak-mac-dns-filter.mobileconfig (inlined als TS * Template: ops/mdm/rebreak-mac-dns-filter.mobileconfig (inlined als TS const).
* constant via backend/server/utils/magic-profile-template.ts überlebt
* jeden Build/Deploy ohne FS- oder serverAssets-Magic).
* *
* Ersetzt: * Hard-Lock-Payloads:
* - ServerURL: /dns-query /dns-query/{token} * - DNS-Filter (com.apple.dnsSettings.managed) mit ProhibitDisablement
* - PayloadUUID: 2× neu generieren (DNSSettings + Profile root) * - PayloadRemovalDisallowed + PayloadScope=System (im Template)
* - PayloadIdentifier: unique pro Device * - com.apple.profileRemovalPassword mit server-gehaltenem Passwort (hier
* injiziert) der User sieht es NIE, nur nach Cooldown-Release (Offboarding).
* *
* TODO: Profile-Signierung via Apple Developer Certificate (Phase 2) * Signing: wenn Cert via runtimeConfig konfiguriert CMS-signiert (grünes
* Verifiziert"), sonst unsigniert (Lock greift trotzdem). Siehe magic-lock.ts.
*/ */
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const query = getQuery(event); const query = getQuery(event);
@ -28,7 +36,6 @@ export default defineEventHandler(async (event) => {
}); });
} }
// Token in DB suchen (nur aktive, nicht revoked)
const device = await findMagicDeviceByToken(token); const device = await findMagicDeviceByToken(token);
if (!device) { if (!device) {
throw createError({ throw createError({
@ -37,39 +44,51 @@ export default defineEventHandler(async (event) => {
}); });
} }
// Template via Nitro serverAssets lesen (build-time eingebundelt → cwd-unabhängig). // Removal-Passwort: aus DB oder Lazy-Backfill (Devices vor dem Hard-Lock).
const template = MAGIC_PROFILE_TEMPLATE; let removalPassword = device.magicRemovalPassword;
if (!removalPassword) {
removalPassword = generateRemovalPassword();
await ensureMagicRemovalPassword(device.id, removalPassword);
}
// ServerURL ersetzen: /dns-query → /dns-query/{token} const deviceSlice = device.deviceId.slice(0, 8);
const personalizedProfile = template
.replace( // Personalisierung: ServerURL → token-spezifisch, UUIDs/Identifier unique.
let personalizedProfile = MAGIC_PROFILE_TEMPLATE.replace(
"https://dns.rebreak.org/dns-query", "https://dns.rebreak.org/dns-query",
`https://dns.rebreak.org/dns-query/${token}`, `https://dns.rebreak.org/dns-query/${token}`,
) )
// PayloadUUID neu generieren (2 Stellen im Template)
.replace("7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0", randomUUID().toUpperCase()) .replace("7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0", randomUUID().toUpperCase())
.replace("8C3F9A2B-D4E5-4F87-9A12-B5C6D7E8F901", randomUUID().toUpperCase()) .replace("8C3F9A2B-D4E5-4F87-9A12-B5C6D7E8F901", randomUUID().toUpperCase())
// PayloadIdentifier unique machen (optional, verhindert Konflikt bei Multi-Device)
.replace( .replace(
"org.rebreak.protection.dns.filter", "org.rebreak.protection.dns.filter",
`org.rebreak.protection.dns.filter.${device.deviceId.slice(0, 8)}`, `org.rebreak.protection.dns.filter.${deviceSlice}`,
) )
.replace( .replace(
"org.rebreak.protection.profile", "org.rebreak.protection.profile",
`org.rebreak.protection.profile.${device.deviceId.slice(0, 8)}`, `org.rebreak.protection.profile.${deviceSlice}`,
);
// Removal-Passwort-Payload in die PayloadContent-Array injizieren.
const removalPayload = buildRemovalPasswordPayload(removalPassword, deviceSlice);
personalizedProfile = personalizedProfile.replace(
" </array>",
`${removalPayload}\n </array>`,
);
// Optional signieren (config-gated; inaktiv ohne Cert).
const config = useRuntimeConfig(event);
const signed = signProfileIfConfigured(
personalizedProfile,
(config as any).magicSigning,
); );
// Response-Headers
setHeader(event, "Content-Type", "application/x-apple-aspen-config"); setHeader(event, "Content-Type", "application/x-apple-aspen-config");
setHeader( setHeader(
event, event,
"Content-Disposition", "Content-Disposition",
`attachment; filename="RebreakMagic-${device.deviceId.slice(0, 8)}.mobileconfig"`, `attachment; filename="RebreakMagic-${deviceSlice}.mobileconfig"`,
); );
// TODO: Profile-Signierung via /usr/bin/security cms -S return signed;
// Requires: Apple Developer Certificate + Private Key in Keychain
// Siehe: https://developer.apple.com/documentation/devicemanagement/profile-specific_payload_keys
return personalizedProfile;
}); });

View File

@ -1,7 +1,12 @@
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import { countActiveMagicBindings, listMagicDevices, MAGIC_DEVICE_LIMIT } from "../../db/devices"; import { countActiveMagicBindings, listMagicDevices } from "../../db/devices";
import { countActiveProtectedDevices } from "../../db/protectedDevices";
import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features";
import { requireUser } from "../../utils/auth"; import { requireUser } from "../../utils/auth";
import { createAdGuardClient } from "../../utils/adguard"; import { createAdGuardClient } from "../../utils/adguard";
import { sendDeviceAddedPush } from "../../services/push";
import { generateRemovalPassword } from "../../utils/magic-lock";
/** /**
* POST /api/magic/register * POST /api/magic/register
@ -17,11 +22,12 @@ import { createAdGuardClient } from "../../utils/adguard";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = await requireUser(event); const user = await requireUser(event);
const body = await readBody(event); const body = await readBody(event);
const { deviceId, hostname, model, osVersion } = body as { const { deviceId, hostname, model, osVersion, platform } = body as {
deviceId?: string; deviceId?: string;
hostname?: string; hostname?: string;
model?: string; model?: string;
osVersion?: string; osVersion?: string;
platform?: string;
}; };
if (!deviceId || !hostname) { if (!deviceId || !hostname) {
@ -31,6 +37,10 @@ export default defineEventHandler(async (event) => {
}); });
} }
// Plattform: Mac-App sendet nichts (legacy default), Windows-App sendet "windows"
const devicePlatform =
platform === "windows" ? "windows" : "macos";
const db = usePrisma(); const db = usePrisma();
// 1. Prüfe ob Device bereits als Magic-Client gebunden ist (idempotent) // 1. Prüfe ob Device bereits als Magic-Client gebunden ist (idempotent)
@ -42,6 +52,7 @@ export default defineEventHandler(async (event) => {
magicDnsToken: true, magicDnsToken: true,
magicEnrolledAt: true, magicEnrolledAt: true,
magicRevokedAt: true, magicRevokedAt: true,
magicRemovalPassword: true,
}, },
}); });
@ -62,16 +73,28 @@ export default defineEventHandler(async (event) => {
}; };
} }
// 2. Limit-Check (nur wenn kein vorheriges Binding existiert) // 2. Plan-gated Desktop-Slot-Check (nur wenn kein vorheriges Binding existiert).
// Pro: 1 stationäres Gerät (Mac ODER Windows), Legend: 2 (§ Geräte-Matrix).
// Grandfathering-Pattern wie bei Custom-Domains: bestehende Bindings bleiben
// nach Downgrade aktiv, nur NEUE Registrierungen werden hier geblockt.
if (!existing || !existing.magicEnrolledAt) { if (!existing || !existing.magicEnrolledAt) {
const activeCount = await countActiveMagicBindings(user.id); const profile = await getProfile(user.id);
if (activeCount >= MAGIC_DEVICE_LIMIT) { const desktopLimit = getPlanLimits(profile?.plan ?? "pro").maxProtectedDevices;
// Cross-Counting: Magic-Bindings + legacy ProtectedDevices (manueller
// Profil-Download) teilen sich denselben Desktop-Slot-Pool.
const [magicCount, protectedCount] = await Promise.all([
countActiveMagicBindings(user.id),
countActiveProtectedDevices(user.id),
]);
const activeCount = magicCount + protectedCount;
if (activeCount >= desktopLimit) {
const activeBindings = await listMagicDevices(user.id); const activeBindings = await listMagicDevices(user.id);
throw createError({ throw createError({
statusCode: 409, statusCode: 409,
message: `Magic-Device-Limit erreicht (max ${MAGIC_DEVICE_LIMIT})`, message: `Geräte-Limit erreicht (max. ${desktopLimit} Computer in deinem Plan).`,
data: { data: {
code: "limit_reached", code: "limit_reached",
limit: desktopLimit,
activeBindings, activeBindings,
}, },
}); });
@ -83,6 +106,11 @@ export default defineEventHandler(async (event) => {
// verbietet `_` (das base64url als Ersatz für `/` generiert) → 400 "bad hostname label rune". // verbietet `_` (das base64url als Ersatz für `/` generiert) → 400 "bad hostname label rune".
const dnsToken = randomBytes(32).toString("hex"); const dnsToken = randomBytes(32).toString("hex");
// Hard-Lock: server-gehaltenes Removal-Passwort. Stabil über Re-Registrierungen
// (sonst würde ein laufender Offboarding-Cooldown sein PW wechseln).
const removalPassword =
existing?.magicRemovalPassword ?? generateRemovalPassword();
// 4. Provisioniere AdGuard Client // 4. Provisioniere AdGuard Client
const adguardClientName = `magic_${deviceId}`; const adguardClientName = `magic_${deviceId}`;
try { try {
@ -101,19 +129,20 @@ export default defineEventHandler(async (event) => {
}); });
} }
// 5. Upsert UserDevice (platform="macos") // 5. Upsert UserDevice (platform="macos" | "windows")
const device = await db.userDevice.upsert({ const device = await db.userDevice.upsert({
where: { userId_deviceId: { userId: user.id, deviceId } }, where: { userId_deviceId: { userId: user.id, deviceId } },
create: { create: {
userId: user.id, userId: user.id,
deviceId, deviceId,
platform: "macos", platform: devicePlatform,
model: model ?? null, model: model ?? null,
name: hostname, name: hostname,
osVersion: osVersion ?? null, osVersion: osVersion ?? null,
magicDnsToken: dnsToken, magicDnsToken: dnsToken,
magicEnrolledAt: new Date(), magicEnrolledAt: new Date(),
magicHostname: hostname, magicHostname: hostname,
magicRemovalPassword: removalPassword,
}, },
update: { update: {
magicDnsToken: dnsToken, magicDnsToken: dnsToken,
@ -123,6 +152,8 @@ export default defineEventHandler(async (event) => {
model: model ?? undefined, model: model ?? undefined,
osVersion: osVersion ?? undefined, osVersion: osVersion ?? undefined,
lastSeenAt: new Date(), lastSeenAt: new Date(),
magicRemovalPassword: removalPassword,
magicReleaseRequestedAt: null, // Re-Bind bricht laufenden Release ab
}, },
select: { select: {
deviceId: true, deviceId: true,
@ -130,6 +161,15 @@ export default defineEventHandler(async (event) => {
}, },
}); });
// Account-Security-Push „Neues Gerät verbunden" — nur bei NEUER Bindung
// (idempotente Re-Registrierungen oben returnen vorher mit existing:true).
// Fire-and-forget: blockt die Response nicht.
void sendDeviceAddedPush({
userId: user.id,
deviceLabel: hostname,
platform: devicePlatform,
});
return { return {
success: true, success: true,
data: { data: {

View File

@ -0,0 +1,34 @@
import { findMagicDeviceByToken } from "../../db/devices";
/**
* GET /api/magic/status?token=<dnsToken>
*
* KEIN auth required Token selbst ist das Secret (64-char hex).
*
* Polling-Endpoint für den Windows-Tamper-Service (rebreak-magic-win):
* Service prüft alle 5 Minuten ob das Binding noch aktiv ist.
* - active=true DoH-Schutz muss aktiv sein (bei Manipulation re-applien)
* - active=false Release-Cooldown abgelaufen, Token revoked Teardown erlaubt
*
* Offline-Verhalten ist Client-Sache: kein Response fail-closed (Schutz bleibt).
*/
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const token = query.token as string | undefined;
if (!token || !/^[0-9a-f]{64}$/.test(token)) {
throw createError({
statusCode: 400,
message: "token query parameter required",
});
}
const device = await findMagicDeviceByToken(token);
return {
success: true,
data: {
active: device !== null,
},
};
});

View File

@ -2,6 +2,9 @@ import Stripe from "stripe";
import { usePrisma } from "../../utils/prisma"; import { usePrisma } from "../../utils/prisma";
import { runDowngradeReconciliation } from "../../utils/downgrade-reconciliation"; import { runDowngradeReconciliation } from "../../utils/downgrade-reconciliation";
import type { Plan } from "../../utils/plan-features"; import type { Plan } from "../../utils/plan-features";
import { listMagicRemovalCredentials } from "../../db/devices";
import { sendMagicRemovalEmail } from "../../utils/magic-removal-email";
import { serverSupabaseServiceRole } from "../../utils/useSupabase";
/** /**
* POST /api/stripe/webhook * POST /api/stripe/webhook
@ -107,7 +110,7 @@ export default defineEventHandler(async (event) => {
const profile = await db.profile.findFirst({ const profile = await db.profile.findFirst({
where: { stripeCustomerId: customerId }, where: { stripeCustomerId: customerId },
select: { id: true, plan: true }, select: { id: true, plan: true, nickname: true },
}); });
if (profile) { if (profile) {
@ -120,6 +123,30 @@ export default defineEventHandler(async (event) => {
await runDowngradeReconciliation(profile.id, fromPlan, "free").catch( await runDowngradeReconciliation(profile.id, fromPlan, "free").catch(
(err) => console.error("[stripe-webhook] reconciliation error:", err), (err) => console.error("[stripe-webhook] reconciliation error:", err),
); );
// Magic Hard-Lock Reveal bei Kündigung: Removal-Passwörter per Mail,
// damit der User die gesperrten Mac/Windows-Profile entfernen kann.
try {
const creds = await listMagicRemovalCredentials(profile.id);
if (creds.length > 0) {
const config = useRuntimeConfig(event);
const resendApiKey = (config as any).resendApiKey as string | undefined;
const supabase = serverSupabaseServiceRole(event);
const { data } = await supabase.auth.admin.getUserById(profile.id);
const email = data?.user?.email;
if (email && resendApiKey) {
await sendMagicRemovalEmail({
recipientEmail: email,
recipientNickname: profile.nickname,
credentials: creds,
reason: "cancellation",
resendApiKey,
}).catch(() => {});
}
}
} catch (err) {
console.error("[stripe-webhook] magic removal reveal failed:", err);
}
} }
break; break;
} }

View File

@ -15,6 +15,8 @@ import {
} from "../../db/mail"; } from "../../db/mail";
import { writeConsentRevoke } from "../../db/consent"; import { writeConsentRevoke } from "../../db/consent";
import { usePrisma } from "../../utils/prisma"; import { usePrisma } from "../../utils/prisma";
import { listMagicRemovalCredentials } from "../../db/devices";
import { sendMagicRemovalEmail } from "../../utils/magic-removal-email";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = await requireUser(event); const user = await requireUser(event);
@ -46,6 +48,37 @@ export default defineEventHandler(async (event) => {
// Tracking: consent-gap-plan.md TODO #2 // Tracking: consent-gap-plan.md TODO #2
} }
// Magic Hard-Lock Reveal: BEVOR die Geräte gelöscht werden, dem User die
// Removal-Passwörter geben (Mail + Response), damit er die gesperrten
// Mac/Windows-Profile entfernen kann. Im Normalbetrieb bleibt das PW geheim.
let magicRemovalCredentials: Awaited<
ReturnType<typeof listMagicRemovalCredentials>
> = [];
try {
magicRemovalCredentials = await listMagicRemovalCredentials(userId);
if (magicRemovalCredentials.length > 0) {
const config = useRuntimeConfig(event);
const resendApiKey = (config as any).resendApiKey as string | undefined;
const { data: authData } = await supabase.auth.admin.getUserById(userId);
const email = authData?.user?.email;
if (email && resendApiKey) {
const p = await db.profile.findUnique({
where: { id: userId },
select: { nickname: true },
});
await sendMagicRemovalEmail({
recipientEmail: email,
recipientNickname: p?.nickname ?? null,
credentials: magicRemovalCredentials,
reason: "account_deletion",
resendApiKey,
}).catch(() => {});
}
}
} catch (err) {
console.error("[user-delete] magic removal reveal failed:", err);
}
// Delete all user data (DSGVO Art. 17) // Delete all user data (DSGVO Art. 17)
// Reihenfolge: Samples VOR Connections löschen (oder parallel — FK-Reihenfolge // Reihenfolge: Samples VOR Connections löschen (oder parallel — FK-Reihenfolge
// egal weil wir nach userId filtern). Samples haben keine userId-FK-Cascade // egal weil wir nach userId filtern). Samples haben keine userId-FK-Cascade
@ -68,5 +101,6 @@ export default defineEventHandler(async (event) => {
// Auth-User löschen (bleibt Supabase) // Auth-User löschen (bleibt Supabase)
await supabase.auth.admin.deleteUser(userId); await supabase.auth.admin.deleteUser(userId);
return { success: true }; // Removal-Passwörter im Response mitgeben (In-App-Reveal vor Logout).
return { success: true, magicRemovalCredentials };
}); });

View File

@ -413,8 +413,8 @@ export async function deleteUserDevice(
// RebreakMagic DNS-Device-Binding // RebreakMagic DNS-Device-Binding
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/** Hard-Limit für Magic-Bindings pro User (5 für Legend-Plan / Staging-Testing). */ // Magic-Binding-Limit ist plan-gated (plan-features.maxProtectedDevices:
export const MAGIC_DEVICE_LIMIT = 5; // Pro 1 / Legend 2) — geprüft in api/magic/register.post.ts.
export interface MagicDeviceRecord { export interface MagicDeviceRecord {
deviceId: string; deviceId: string;
@ -481,7 +481,13 @@ export async function countActiveMagicBindings(
*/ */
export async function findMagicDeviceByToken( export async function findMagicDeviceByToken(
token: string, token: string,
): Promise<(DeviceRecord & { magicDnsToken: string }) | null> { ): Promise<
| (DeviceRecord & {
magicDnsToken: string;
magicRemovalPassword: string | null;
})
| null
> {
const db = usePrisma(); const db = usePrisma();
const device = await db.userDevice.findUnique({ const device = await db.userDevice.findUnique({
where: { where: {
@ -493,6 +499,7 @@ export async function findMagicDeviceByToken(
magicEnrolledAt: true, magicEnrolledAt: true,
magicRevokedAt: true, magicRevokedAt: true,
magicHostname: true, magicHostname: true,
magicRemovalPassword: true,
}, },
}); });
@ -504,3 +511,53 @@ export async function findMagicDeviceByToken(
magicDnsToken: device.magicDnsToken!, magicDnsToken: device.magicDnsToken!,
}; };
} }
/**
* Setzt ein Removal-Passwort nachträglich (Lazy-Backfill für Magic-Devices,
* die vor dem Hard-Lock gebunden wurden). Idempotent über das WHERE-Guard.
*/
export async function ensureMagicRemovalPassword(
rowId: string,
password: string,
): Promise<void> {
const db = usePrisma();
await db.userDevice.updateMany({
where: { id: rowId, magicRemovalPassword: null },
data: { magicRemovalPassword: password },
});
}
export interface MagicRemovalCredential {
hostname: string | null;
model: string | null;
removalPassword: string;
}
/**
* Removal-Credentials aller aktiven Magic-Devices eines Users NUR für den
* Reveal bei Kündigung/Account-Löschung (Offboarding). Liefert das server-
* gehaltene Passwort, damit der User die gesperrten Profile entfernen kann.
*/
export async function listMagicRemovalCredentials(
userId: string,
): Promise<MagicRemovalCredential[]> {
const db = usePrisma();
const devices = await db.userDevice.findMany({
where: {
userId,
magicEnrolledAt: { not: null },
magicRevokedAt: null,
magicRemovalPassword: { not: null },
},
select: {
magicHostname: true,
model: true,
magicRemovalPassword: true,
},
});
return devices.map((d) => ({
hostname: d.magicHostname,
model: d.model,
removalPassword: d.magicRemovalPassword!,
}));
}

View File

@ -128,6 +128,98 @@ export function truncatePreview(text: string, max = 100): string {
return text.slice(0, max - 1) + "…"; return text.slice(0, max - 1) + "…";
} }
export interface DeviceAddedPushPayload {
/** Account-Owner — bekommt den Push auf seine mobilen Geräte */
userId: string;
/** Anzeigename des neu verbundenen Geräts (Hostname/Label) */
deviceLabel: string;
/** "macos" | "windows" | … — für den Plattform-Hinweis */
platform: string;
}
function devicePlatformLabel(platform: string): string {
const p = platform.toLowerCase();
if (p.startsWith("mac")) return "Mac";
if (p.startsWith("win")) return "Windows";
if (p.startsWith("ios")) return "iPhone";
if (p.startsWith("android")) return "Android";
return "Gerät";
}
/**
* Push bei neuer Geräte-Bindung (Neues Gerät verbunden").
*
* Account-Security-Signal: geht an ALLE aktiven Push-Tokens des Users (nur
* mobile Geräte haben welche Mac/Windows-Magic registriert keine Tokens).
* Bewusst NICHT durch `chatPushEnabled` gegated das ist eine sicherheits-
* relevante Account-Benachrichtigung, kein Social-Push.
*
* Fire-and-forget wie die übrigen Push-Pfade.
*/
export async function sendDeviceAddedPush(
payload: DeviceAddedPushPayload,
): Promise<void> {
try {
const db = usePrisma();
const profile = await db.profile.findUnique({
where: { id: payload.userId },
select: { deletedAt: true },
});
if (!profile || profile.deletedAt) return;
const tokens = await db.pushToken.findMany({
where: { userId: payload.userId, enabled: true },
select: { id: true, token: true },
});
if (tokens.length === 0) return;
const platformLabel = devicePlatformLabel(payload.platform);
const messages: ExpoPushMessage[] = [];
const validTokenIds: string[] = [];
for (const t of tokens) {
if (!Expo.isExpoPushToken(t.token)) {
await db.pushToken
.update({ where: { id: t.id }, data: { enabled: false } })
.catch(() => {});
continue;
}
messages.push({
to: t.token,
sound: "default",
title: "Neues Gerät verbunden",
body: `${payload.deviceLabel} (${platformLabel}) ist jetzt geschützt.`,
data: { type: "device_added", platform: payload.platform },
// Dedizierter Channel (HIGH) — siehe usePushTokenRegistration.ts.
// Fällt vor App-Re-Registrierung graceful auf Default-Channel zurück.
channelId: "devices",
});
validTokenIds.push(t.id);
}
if (messages.length === 0) return;
const chunks = expo.chunkPushNotifications(messages);
for (const chunk of chunks) {
try {
await expo.sendPushNotificationsAsync(chunk);
} catch (err) {
console.error("[push] device-added chunk send failed:", err);
}
}
await db.pushToken
.updateMany({
where: { id: { in: validTokenIds } },
data: { lastUsedAt: new Date() },
})
.catch(() => {});
} catch (err) {
console.error("[push] sendDeviceAddedPush failed:", err);
}
}
export interface CallRingPushPayload { export interface CallRingPushPayload {
/** Empfänger (Callee) — bekommt den Push */ /** Empfänger (Callee) — bekommt den Push */
receiverId: string; receiverId: string;

View File

@ -0,0 +1,100 @@
import { randomBytes, randomUUID } from "crypto";
import { execFileSync } from "child_process";
import { writeFileSync, mkdtempSync, readFileSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
/**
* Cooldown (Stunden) zwischen Entfern-Antrag und Sichtbarwerden des
* Removal-Passworts. Schützt gegen impulsives Abschalten im Drang-Fenster.
* Bewusst gleich dem Mobile-Device-Lock (24h) gehalten.
*/
export const MAGIC_RELEASE_COOLDOWN_H = 24;
const PW_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // ohne 0/O/1/I/L
const PW_LEN = 10;
/**
* Generiert ein menschenlesbares Removal-Passwort (Offboarding tippt es ab).
* 10 Zeichen, keine mehrdeutigen Glyphen. ~50 Bit Entropie.
*/
export function generateRemovalPassword(): string {
const bytes = randomBytes(PW_LEN);
let out = "";
for (let i = 0; i < PW_LEN; i++) {
out += PW_ALPHABET[bytes[i] % PW_ALPHABET.length];
}
return out;
}
/**
* Baut das com.apple.profileRemovalPassword-Payload-Dict (als XML-String),
* das in die PayloadContent-Array des Mac/Win-Profils injiziert wird.
*/
export function buildRemovalPasswordPayload(
password: string,
deviceIdSlice: string,
): string {
return ` <dict>
<key>PayloadDisplayName</key>
<string>Entfern-Passwort</string>
<key>PayloadIdentifier</key>
<string>org.rebreak.protection.removalpw.${deviceIdSlice}</string>
<key>PayloadType</key>
<string>com.apple.profileRemovalPassword</string>
<key>PayloadUUID</key>
<string>${randomUUID().toUpperCase()}</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>RemovalPassword</key>
<string>${password}</string>
</dict>`;
}
/**
* Signiert ein mobileconfig-Profil per CMS (openssl smime), WENN Signing-Cert
* + Key via runtimeConfig konfiguriert sind. Sonst wird das Profil UNSIGNIERT
* zurückgegeben der Lock (RemovalPassword + PayloadRemovalDisallowed +
* ProhibitDisablement) greift auch unsigniert; macOS zeigt nur Nicht
* verifiziert" statt grünem Häkchen.
*
* Inaktiv bis Cert auf der API-Box provisioniert ist (Pfade via Infisical).
* Verifiziertes Signing-Setup existiert bereits auf der mdm-Box (LE-Cert für
* dns.rebreak.org) die hier erwarteten Pfade müssen dort/da verfügbar sein.
*/
export function signProfileIfConfigured(
profileXml: string,
signing: { certPath?: string; keyPath?: string; chainPath?: string } | undefined,
): Buffer | string {
if (!signing?.certPath || !signing.keyPath) {
return profileXml; // unsigniert — Lock greift trotzdem
}
const dir = mkdtempSync(join(tmpdir(), "rbk-sign-"));
const inPath = join(dir, "in.mobileconfig");
const outPath = join(dir, "out.mobileconfig");
try {
writeFileSync(inPath, profileXml, "utf8");
const args = [
"smime",
"-sign",
"-signer",
signing.certPath,
"-inkey",
signing.keyPath,
"-nodetach",
"-outform",
"der",
"-in",
inPath,
"-out",
outPath,
];
if (signing.chainPath) {
args.push("-certfile", signing.chainPath);
}
execFileSync("openssl", args, { stdio: "pipe" });
return readFileSync(outPath);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}

View File

@ -26,6 +26,8 @@ export const MAGIC_PROFILE_TEMPLATE = `<?xml version="1.0" encoding="UTF-8"?>
<string>7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0</string> <string>7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0</string>
<key>PayloadVersion</key> <key>PayloadVersion</key>
<integer>1</integer> <integer>1</integer>
<key>ProhibitDisablement</key>
<true/>
<key>DNSSettings</key> <key>DNSSettings</key>
<dict> <dict>
<key>DNSProtocol</key> <key>DNSProtocol</key>

View File

@ -0,0 +1,98 @@
/**
* Magic Removal-Password Reveal-Email.
*
* Wird versendet wenn der User KÜNDIGT oder seinen ACCOUNT LÖSCHT dann (und
* nur dann) bekommt er die server-gehaltenen Removal-Passwörter, um die
* gesperrten DNS-Profile von seinen Mac/Windows-Geräten zu entfernen.
*
* Im Normalbetrieb bleibt das Passwort serverseitig der User sieht es nie
* (Hard-Lock, gegen impulsives Abschalten). Siehe memory/mac-magic-profile-lock.
*
* Fire-and-forget: Fehler werden geloggt, nie geworfen.
*/
import { Resend } from "resend";
import type { MagicRemovalCredential } from "../db/devices";
export interface MagicRemovalEmailOpts {
recipientEmail: string;
/** Nickname für die Anrede (NIE firstName/email im Body außer Empfänger). */
recipientNickname?: string | null;
credentials: MagicRemovalCredential[];
reason: "cancellation" | "account_deletion";
resendApiKey: string;
}
export async function sendMagicRemovalEmail(
opts: MagicRemovalEmailOpts,
): Promise<void> {
if (!opts.resendApiKey) {
console.warn("[magic-removal-email] resendApiKey not provided — skipping mail");
return;
}
if (opts.credentials.length === 0) return;
const resend = new Resend(opts.resendApiKey);
const greeting = opts.recipientNickname ? `Hallo ${opts.recipientNickname},` : "Hallo,";
const reasonLine =
opts.reason === "account_deletion"
? "Du hast deinen ReBreak-Account gelöscht."
: "Dein ReBreak-Abo wurde beendet.";
const rows = opts.credentials
.map((c) => {
const label = c.hostname || c.model || "Computer";
return `
<tr>
<td style="padding:10px 14px;border-bottom:1px solid #f0f0f0;font-size:14px;color:#3a3a3a;">${label}</td>
<td style="padding:10px 14px;border-bottom:1px solid #f0f0f0;font-size:16px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:1px;color:#1a1a1a;"><strong>${c.removalPassword}</strong></td>
</tr>`;
})
.join("");
const subject = "Dein Entfern-Passwort für den ReBreak-Schutz";
const html = `
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>${subject}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #1a1a1a; background: #f5f5f7; margin: 0; padding: 0; }
.container { max-width: 560px; margin: 32px auto; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.header { background: #1a1a1a; padding: 24px 32px; }
.header h1 { color: #fff; font-size: 18px; font-weight: 600; margin: 0; letter-spacing: -0.3px; }
.body { padding: 28px 32px; }
.body p { font-size: 15px; line-height: 1.6; color: #3a3a3a; margin: 0 0 16px; }
table { width: 100%; border-collapse: collapse; margin: 8px 0 20px; }
.steps { background: #f5f5f7; border-radius: 8px; padding: 14px 18px; font-size: 14px; color: #555; line-height: 1.6; }
.footer { padding: 16px 32px; font-size: 12px; color: #888; border-top: 1px solid #f0f0f0; }
</style></head>
<body>
<div class="container">
<div class="header"><h1>ReBreak Schutz entfernen</h1></div>
<div class="body">
<p>${greeting}</p>
<p>${reasonLine} Damit du den ReBreak-Schutz von deinen Geräten entfernen kannst, hier deine Entfern-Passwörter:</p>
<table>${rows}</table>
<div class="steps">
<strong>So entfernst du den Schutz (Mac):</strong><br>
Systemeinstellungen Allgemein Geräteverwaltung ReBreak Schutz" Entfernen Passwort eingeben.
</div>
<p style="margin-top:20px;font-size:13px;color:#888;">Bewahre diese Mail auf, bis du den Schutz entfernt hast. Das Passwort wird aus Sicherheitsgründen nur hier herausgegeben.</p>
</div>
<div class="footer">Fragen? support@rebreak.org</div>
</div>
</body>
</html>`.trim();
try {
await resend.emails.send({
from: "ReBreak <noreply@rebreak.org>",
to: opts.recipientEmail,
subject,
html,
});
} catch (err: any) {
console.error("[magic-removal-email] Failed to send:", err?.message ?? err);
}
}

View File

@ -51,9 +51,10 @@ export interface PlanLimits {
*/ */
maxAppDevices: number; maxAppDevices: number;
/** /**
* Max. zusätzliche Geräte (Mac/Windows) die per DNS-Profil geschützt werden. * Max. stationäre Geräte (Mac/Windows) die per DNS geschützt werden.
* Bezieht sich auf ProtectedDevice (Legend-only Feature). * Gilt für Magic-Bindings (UserDevice.magicDnsToken Magic-Mac/Win-App)
* 0 = Feature nicht verfügbar. * sowie legacy ProtectedDevice. Pro: 1 (Hauptrisiko-Gerät am Desktop),
* Legend: 2 ("lückenlos auf 5 Geräten" 3 mobil + 2 stationär).
*/ */
maxProtectedDevices: number; maxProtectedDevices: number;
@ -89,7 +90,7 @@ export const PLAN_LIMITS: Record<Exclude<Plan, "free">, PlanLimits> = {
canCreateGroup: false, canCreateGroup: false,
canAddToBlocklist: false, canAddToBlocklist: false,
maxAppDevices: 1, maxAppDevices: 1,
maxProtectedDevices: 0, maxProtectedDevices: 1, // 1 Desktop (Mac ODER Windows) — Hauptrisiko-Gerät schützen
aiModel: "llama-3.3-70b-versatile", aiModel: "llama-3.3-70b-versatile",
aiModelFallbacks: [ aiModelFallbacks: [
{ provider: "groq", model: "llama-3.1-8b-instant" }, { provider: "groq", model: "llama-3.1-8b-instant" },

View File

@ -17,6 +17,8 @@
<string>7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0</string> <string>7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0</string>
<key>PayloadVersion</key> <key>PayloadVersion</key>
<integer>1</integer> <integer>1</integer>
<key>ProhibitDisablement</key>
<true/>
<key>DNSSettings</key> <key>DNSSettings</key>
<dict> <dict>
<key>DNSProtocol</key> <key>DNSProtocol</key>