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:
parent
869d8afd30
commit
a95e66560d
@ -1,9 +1,14 @@
|
||||
# Next Release
|
||||
|
||||
## Fixes
|
||||
- **Calls: fixed phantom/zombie incoming calls (iOS).** After an incoming call ended without the in-app call screen ever mounting (iOS shows the native CallKit banner, not our `/call` screen), the call store stayed stuck in the `ended` state forever. The `ended → idle` reset only lived in the `/call` screen, which never mounts for banner-only incoming calls. A stuck `ended` state then silently blocked every subsequent incoming call (RING + VoIP push were received but ignored by the `status !== 'idle'` guard), so accepting from the banner produced a phantom CallKit call that ticked as "active" with no real connection, and the caller saw a missed call.
|
||||
- Store now self-heals back to `idle` after a call ends, decoupled from the call screen (fallback timer in `teardown`).
|
||||
- `receiveIncoming` and the realtime ring handler now treat a stale `ended` state as acceptable (clear + proceed) instead of dropping the new call.
|
||||
- `onAnswer` now ends the native CallKit call when the store has no `incoming` call, preventing a phantom "active" call.
|
||||
- `RNCallKeep.endAllCalls()` on app launch clears leftover CallKit zombies from a previous session.
|
||||
- **DM header: online dot now matches the online text.** The green online dot on the partner avatar used the follow-gated presence (`isOnline` = online AND you follow them), while the "online" text next to it used raw presence. In a DM the dot now uses raw presence too, so it shows whenever the partner is online — consistent with the text, regardless of follow relationship. (Looked like an Android-only bug but was the follow gate + asymmetric follow between the test accounts.)
|
||||
## New
|
||||
- Device detail view: tap any device on the Devices page to open a sheet with its connection status, when it was connected, and a protected/unprotected coverage donut (same visual as your profile)
|
||||
- "New device connected" push notification — when a Mac or Windows computer is bound via Rebreak Magic, your phone(s) get notified
|
||||
- Devices page now updates in real time the moment a new computer is paired — no manual refresh needed
|
||||
- Onboarding now sets up the full protection using the exact same guided, gated step flow as the protection screen (single source of truth) — Android: VPN → Device Administrator → Accessibility (strict order: the tamper lock has to come last, otherwise it would block the device-admin screen); iOS: App Lock → Screen Time passcode → content filter. Previously the device-admin / screen-time hardening steps only existed in the protection screen after onboarding.
|
||||
|
||||
## Changed
|
||||
- Stationary protection (Mac/Windows) now runs exclusively via Rebreak Magic — the manual offline profile download has been removed. The offline profile would have shipped the removal password in plain text inside the file (bypass risk); with Magic the lock password stays server-side and is never shown to the user.
|
||||
- Mac DNS profile hardened with `ProhibitDisablement` — the filter can no longer be toggled off in System Settings.
|
||||
|
||||
## Fixed
|
||||
- Android onboarding: if the VPN permission dialog failed to open (e.g. another always-on VPN active, work profile, or certain OEM quirks), the protection step would silently get stuck with no dialog and no error message — especially on Play Store builds, where the underlying error was swallowed. The step now surfaces the real error and offers a retry instead of dead-ending.
|
||||
|
||||
@ -100,7 +100,7 @@ function RootLayoutInner() {
|
||||
if (!response) return;
|
||||
const data = response.notification.request.content.data as
|
||||
| {
|
||||
type?: 'dm' | 'room' | 'call';
|
||||
type?: 'dm' | 'room' | 'call' | 'device_added';
|
||||
targetId?: string;
|
||||
callId?: string;
|
||||
from?: { id: string; nickname: string; avatar: string | null };
|
||||
@ -111,6 +111,8 @@ function RootLayoutInner() {
|
||||
router.push({ pathname: '/dm', params: { userId: data.targetId } });
|
||||
} else if (data.type === 'room' && data.targetId) {
|
||||
router.push({ pathname: '/room', params: { roomId: data.targetId } });
|
||||
} else if (data.type === 'device_added') {
|
||||
router.push('/devices');
|
||||
} else if (data.type === 'call' && data.callId && data.from) {
|
||||
// Eingehender Anruf via regulärem Push (Android-Pfad / iOS-Fallback wenn
|
||||
// kein voipToken). Auf iOS gilt: VoIP-PushKit ist der einzige Pfad
|
||||
|
||||
@ -27,11 +27,12 @@ function formatCountdown(isoTarget: string): string {
|
||||
}
|
||||
import { useProtectedDevicesStore, type ProtectedDevice } from '../stores/protectedDevices';
|
||||
import { useProtectedDevicesRealtime } from '../hooks/useProtectedDevicesRealtime';
|
||||
import { useUserDevicesRealtime } from '../hooks/useUserDevicesRealtime';
|
||||
import { useUserPlan } from '../hooks/useUserPlan';
|
||||
import { AppHeader } from '../components/AppHeader';
|
||||
import { AddMacSheet } from '../components/devices/AddMacSheet';
|
||||
import { AddWindowsSheet } from '../components/devices/AddWindowsSheet';
|
||||
import { MagicSheet } from '../components/devices/MagicSheet';
|
||||
import { DeviceProgressBar } from '../components/devices/DeviceProgressBar';
|
||||
import { DeviceDetailSheet, type DeviceDetail } from '../components/devices/DeviceDetailSheet';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -124,11 +125,13 @@ function MobileDeviceRow({
|
||||
onRemove,
|
||||
onRequestRelease,
|
||||
onCancelRelease,
|
||||
onOpenDetail,
|
||||
}: {
|
||||
device: UserDevice;
|
||||
onRemove: (id: string) => void;
|
||||
onRequestRelease: (id: string) => void;
|
||||
onCancelRelease: (id: string) => void;
|
||||
onOpenDetail: (d: DeviceDetail) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
@ -187,6 +190,20 @@ function MobileDeviceRow({
|
||||
const deviceName = device.model ?? device.name ?? device.platform;
|
||||
const footerText = `${formatLastSeen(device.lastSeenAt, t)} · ${t('settings.devices_since')} ${formatSince(device.createdAt)}`;
|
||||
|
||||
function openDetail() {
|
||||
onOpenDetail({
|
||||
name: deviceName,
|
||||
icon: mobileIcon(device.platform),
|
||||
platform: device.platform,
|
||||
createdAt: device.createdAt,
|
||||
lastSeenAt: device.lastSeenAt,
|
||||
statusLabel: device.isCurrent
|
||||
? t('settings.devices_this_device')
|
||||
: t('devices.status_active'),
|
||||
statusColor: colors.success,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@ -197,6 +214,11 @@ function MobileDeviceRow({
|
||||
paddingVertical: 14,
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={openDetail}
|
||||
activeOpacity={0.6}
|
||||
style={{ flex: 1, flexDirection: 'row', alignItems: 'center', gap: 12, minWidth: 0 }}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 40,
|
||||
@ -283,6 +305,7 @@ function MobileDeviceRow({
|
||||
: footerText}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{device.isCurrent ? null : releaseActive ? (
|
||||
<TouchableOpacity
|
||||
@ -318,13 +341,38 @@ function MobileDeviceRow({
|
||||
function ProtectedDeviceRow({
|
||||
device,
|
||||
onRemove,
|
||||
onOpenDetail,
|
||||
}: {
|
||||
device: ProtectedDevice;
|
||||
onRemove: (id: string) => void;
|
||||
onOpenDetail: (d: DeviceDetail) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
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'
|
||||
? [
|
||||
{ id: 'remove', title: t('settings.devices_remove_confirm'), attributes: { destructive: true } },
|
||||
@ -360,6 +408,11 @@ function ProtectedDeviceRow({
|
||||
paddingVertical: 14,
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={openDetail}
|
||||
activeOpacity={0.6}
|
||||
style={{ flex: 1, flexDirection: 'row', alignItems: 'center', gap: 12, minWidth: 0 }}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 40,
|
||||
@ -403,6 +456,7 @@ function ProtectedDeviceRow({
|
||||
: `${t('settings.devices_since')} ${formatSince(device.createdAt)}`}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<MenuView
|
||||
title={device.label}
|
||||
@ -487,8 +541,14 @@ export default function DevicesScreen() {
|
||||
remove: removeProtected,
|
||||
} = useProtectedDevicesStore();
|
||||
|
||||
const [addMacVisible, setAddMacVisible] = useState(false);
|
||||
const [addWindowsVisible, setAddWindowsVisible] = useState(false);
|
||||
const [magicVisible, setMagicVisible] = 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(() => {
|
||||
loadDevices();
|
||||
@ -496,9 +556,12 @@ export default function DevicesScreen() {
|
||||
}, []);
|
||||
|
||||
useProtectedDevicesRealtime();
|
||||
useUserDevicesRealtime();
|
||||
|
||||
const TOTAL_DEVICE_SLOTS = 3;
|
||||
const activeProtectedCount = protectedDevices.filter((d) => d.status !== 'revoked').length;
|
||||
// Geräte-Matrix (Mirror von backend plan-features):
|
||||
// Pro = 1 mobil + 1 stationär, Legend = 3 mobil + 2 stationär ("lückenlos auf 5")
|
||||
const mobileLimit = isLegend ? 3 : 1;
|
||||
const desktopLimit = isLegend ? 2 : 1;
|
||||
|
||||
// Dedupe: wenn ein UserDevice (mobile/desktop) auf der gleichen Plattform
|
||||
// (mac/ios/android/win) bereits existiert, blende die entsprechende
|
||||
@ -519,8 +582,17 @@ export default function DevicesScreen() {
|
||||
(d) => !mobilePlatformKeys.has(normalizePlatform(d.platform)),
|
||||
);
|
||||
|
||||
const totalRegistered = mobileDevices.length + dedupedProtected.filter((d) => d.status !== 'revoked').length;
|
||||
const atDeviceLimit = isLegend && totalRegistered >= TOTAL_DEVICE_SLOTS;
|
||||
// Mobile vs Desktop getrennt zählen: UserDevices mit mac/win-Plattform sind
|
||||
// Magic-Desktops, der Rest (ios/android) zählt auf die Mobile-Slots.
|
||||
const isDesktopPlatform = (d: { platform?: string | null; model?: string | null }) => {
|
||||
const key = normalizePlatform(d.platform || d.model || '');
|
||||
return key === 'mac' || key === 'win';
|
||||
};
|
||||
const mobileCount = mobileDevices.filter((d) => !isDesktopPlatform(d)).length;
|
||||
const desktopCount =
|
||||
mobileDevices.filter(isDesktopPlatform).length +
|
||||
dedupedProtected.filter((d) => d.status !== 'revoked').length;
|
||||
const atDesktopLimit = desktopCount >= desktopLimit;
|
||||
|
||||
// Mobile zuerst (current oben), danach Desktop/Protected.
|
||||
const sortedMobile = [...mobileDevices].sort((a, b) => {
|
||||
@ -530,7 +602,7 @@ export default function DevicesScreen() {
|
||||
});
|
||||
const isLoading = mobileLoading || protectedLoading;
|
||||
const isEmpty = !isLoading && sortedMobile.length === 0 && dedupedProtected.length === 0;
|
||||
const subtitle = isLegend ? t('devices.subtitle_legend') : t('devices.subtitle_free');
|
||||
const subtitle = isLegend ? t('devices.subtitle_legend') : t('devices.subtitle_pro');
|
||||
|
||||
async function handleRemoveProtected(id: string) {
|
||||
try {
|
||||
@ -569,13 +641,18 @@ export default function DevicesScreen() {
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
{isLegend ? (
|
||||
<DeviceProgressBar
|
||||
count={totalRegistered}
|
||||
max={TOTAL_DEVICE_SLOTS}
|
||||
atLimit={atDeviceLimit}
|
||||
/>
|
||||
) : null}
|
||||
<DeviceProgressBar
|
||||
count={mobileCount}
|
||||
max={mobileLimit}
|
||||
atLimit={mobileCount >= mobileLimit}
|
||||
label={t('devices.progress_mobile')}
|
||||
/>
|
||||
<DeviceProgressBar
|
||||
count={desktopCount}
|
||||
max={desktopLimit}
|
||||
atLimit={atDesktopLimit}
|
||||
label={t('devices.progress_desktop')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Unified devices section: Mobile zuerst, dann Desktop */}
|
||||
@ -616,6 +693,7 @@ export default function DevicesScreen() {
|
||||
onRemove={removeMobileDevice}
|
||||
onRequestRelease={requestRelease}
|
||||
onCancelRelease={cancelRelease}
|
||||
onOpenDetail={setDetailDevice}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@ -628,7 +706,11 @@ export default function DevicesScreen() {
|
||||
borderBottomColor: colors.border,
|
||||
}}
|
||||
>
|
||||
<ProtectedDeviceRow device={device} onRemove={handleRemoveProtected} />
|
||||
<ProtectedDeviceRow
|
||||
device={device}
|
||||
onRemove={handleRemoveProtected}
|
||||
onOpenDetail={setDetailDevice}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
@ -636,9 +718,9 @@ export default function DevicesScreen() {
|
||||
</SectionCard>
|
||||
</View>
|
||||
|
||||
{/* CTA or Upgrade */}
|
||||
{isLegend ? (
|
||||
atDeviceLimit ? (
|
||||
{/* CTA — Desktop-Gerät hinzufügen (Pro: 1 Slot, Legend: 2 Slots) */}
|
||||
{atDesktopLimit ? (
|
||||
isLegend ? (
|
||||
<Button
|
||||
title={t('devices.add_device')}
|
||||
icon="add-circle-outline"
|
||||
@ -646,45 +728,35 @@ export default function DevicesScreen() {
|
||||
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
|
||||
style={{
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||
<Ionicons name="shield-checkmark-outline" size={22} color={colors.brandOrange} />
|
||||
<Text
|
||||
style={{ fontSize: 15, color: colors.text, fontFamily: 'Nunito_700Bold', flex: 1 }}
|
||||
>
|
||||
{t('devices.subtitle_legend')}
|
||||
</Text>
|
||||
</View>
|
||||
<Button title={t('devices.upgrade_cta')} />
|
||||
</View>
|
||||
)
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||
<Ionicons name="shield-checkmark-outline" size={22} color={colors.brandOrange} />
|
||||
<Text
|
||||
style={{ fontSize: 15, color: colors.text, fontFamily: 'Nunito_700Bold', flex: 1 }}
|
||||
>
|
||||
{t('devices.subtitle_legend')}
|
||||
</Text>
|
||||
</View>
|
||||
<Button title={t('devices.upgrade_cta')} />
|
||||
</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
|
||||
@ -700,20 +772,21 @@ export default function DevicesScreen() {
|
||||
</Text>
|
||||
</ScrollView>
|
||||
|
||||
<AddMacSheet
|
||||
visible={addMacVisible}
|
||||
<MagicSheet
|
||||
visible={magicVisible}
|
||||
initialPlatform={magicPlatform}
|
||||
colors={colors}
|
||||
onClose={() => {
|
||||
setAddMacVisible(false);
|
||||
setMagicVisible(false);
|
||||
loadDevices();
|
||||
loadProtected();
|
||||
}}
|
||||
/>
|
||||
|
||||
<AddWindowsSheet
|
||||
visible={addWindowsVisible}
|
||||
onClose={() => {
|
||||
setAddWindowsVisible(false);
|
||||
loadProtected();
|
||||
}}
|
||||
<DeviceDetailSheet
|
||||
visible={!!detailDevice}
|
||||
device={detailDevice}
|
||||
onClose={() => setDetailDevice(null)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
263
apps/rebreak-native/components/devices/DeviceDetailSheet.tsx
Normal file
263
apps/rebreak-native/components/devices/DeviceDetailSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -10,8 +10,10 @@ import {
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ColorScheme } from '../../lib/theme';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
import { useUserPlan } from '../../hooks/useUserPlan';
|
||||
import { FormSheet } from '../FormSheet';
|
||||
|
||||
type PairResponse = {
|
||||
@ -26,6 +28,8 @@ type MagicDevice = {
|
||||
model: string | null;
|
||||
osVersion: string | null;
|
||||
magicEnrolledAt: string;
|
||||
/** "magic" = Desktop-Binding (Mac/Win), "locked" = Mobile, "protected" = Legacy-DNS */
|
||||
source?: 'magic' | 'locked' | 'protected';
|
||||
};
|
||||
|
||||
type MagicInfo = {
|
||||
@ -33,8 +37,12 @@ type MagicInfo = {
|
||||
downloadUrl: string;
|
||||
dmgUrl: string;
|
||||
minMacosVersion: string;
|
||||
windowsInstallerUrl?: string;
|
||||
minWindowsVersion?: string;
|
||||
};
|
||||
|
||||
type DesktopPlatform = 'mac' | 'windows';
|
||||
|
||||
/**
|
||||
* MagicSheet — Rebreak-Magic-Pairing-Flow als geteiltes FormSheet
|
||||
* (Bottom-Sheet mit Titel-Header). Wird aus settings.tsx getriggert.
|
||||
@ -43,18 +51,31 @@ export function MagicSheet({
|
||||
visible,
|
||||
onClose,
|
||||
colors,
|
||||
initialPlatform = 'mac',
|
||||
}: {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
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 [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 [pairLoading, setPairLoading] = useState(false);
|
||||
const [pairError, setPairError] = useState<string | null>(null);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const [devices, setDevices] = useState<MagicDevice[] | 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(() => {
|
||||
(async () => {
|
||||
@ -67,6 +88,8 @@ export function MagicSheet({
|
||||
downloadUrl: 'https://staging.rebreak.org/download/rebreakmagic',
|
||||
dmgUrl: 'https://staging.rebreak.org/downloads/RebreakMagic-latest.dmg',
|
||||
minMacosVersion: '13.0',
|
||||
windowsInstallerUrl: 'https://staging.rebreak.org/downloads/RebreakMagic-Setup.exe',
|
||||
minWindowsVersion: '10 (21H2)',
|
||||
});
|
||||
}
|
||||
loadDevices();
|
||||
@ -106,7 +129,7 @@ export function MagicSheet({
|
||||
setPair(res);
|
||||
setNow(Date.now());
|
||||
} catch (e: any) {
|
||||
setPairError(e?.message ?? 'Fehler beim Generieren');
|
||||
setPairError(e?.message ?? t('magic.generate_error'));
|
||||
} finally {
|
||||
setPairLoading(false);
|
||||
}
|
||||
@ -125,6 +148,20 @@ export function MagicSheet({
|
||||
|
||||
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 (
|
||||
<FormSheet
|
||||
visible={visible}
|
||||
@ -135,45 +172,75 @@ export function MagicSheet({
|
||||
<View style={{ paddingHorizontal: 20, paddingTop: 4, paddingBottom: 24 }}>
|
||||
{/* Sub-Header (Tagline) */}
|
||||
<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>
|
||||
|
||||
{/* 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 */}
|
||||
<SectionTitle text="1. Mac-App herunterladen" colors={colors} />
|
||||
<SectionTitle text={t('magic.step1_title', { app: appName })} colors={colors} />
|
||||
<View style={cardStyle(colors)}>
|
||||
<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>
|
||||
<PrimaryButton
|
||||
icon="cloud-download-outline"
|
||||
label="Download öffnen"
|
||||
onPress={() => info && Linking.openURL(info.downloadUrl)}
|
||||
label={t('magic.open_download')}
|
||||
onPress={() => downloadHref && Linking.openURL(downloadHref)}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => info && Share.share({ message: info.downloadUrl })}
|
||||
onPress={() => downloadHref && Share.share({ message: downloadHref })}
|
||||
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>
|
||||
</View>
|
||||
|
||||
{/* Step 2 — Pairing-Code */}
|
||||
<SectionTitle text="2. Pairing-Code generieren" colors={colors} />
|
||||
<SectionTitle text={t('magic.step2_title')} colors={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 }}>
|
||||
Erzeuge einen 6-stelligen Code und gib ihn in der Mac-App ein. Gültig 10
|
||||
Minuten, nur einmal verwendbar.
|
||||
{t('magic.code_explainer', { app: appName })}
|
||||
</Text>
|
||||
<PrimaryButton
|
||||
icon="key-outline"
|
||||
label={
|
||||
pairLoading
|
||||
? 'Generiere…'
|
||||
? t('magic.generating')
|
||||
: codeExpired
|
||||
? 'Neuen Code erzeugen'
|
||||
: 'Code erzeugen'
|
||||
? t('magic.generate_new')
|
||||
: t('magic.generate')
|
||||
}
|
||||
onPress={handleGenerateCode}
|
||||
loading={pairLoading}
|
||||
@ -192,7 +259,7 @@ export function MagicSheet({
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
In Mac-App eingeben:
|
||||
{t('magic.enter_in_app', { app: appName })}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={handleCopyCode}
|
||||
@ -234,11 +301,13 @@ export function MagicSheet({
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||
<Ionicons name="time-outline" size={14} color={colors.textMuted} />
|
||||
<Text style={{ fontSize: 13, color: colors.textMuted }}>
|
||||
Läuft ab in {formatRemaining(remaining)}
|
||||
{t('magic.expires_in', { time: formatRemaining(remaining) })}
|
||||
</Text>
|
||||
</View>
|
||||
<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>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
@ -248,24 +317,28 @@ export function MagicSheet({
|
||||
}}
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Verbundene Ger\u00e4te */}
|
||||
<SectionTitle text="Verbundene Ger\u00e4te" colors={colors} />
|
||||
{/* Verbundene Computer + Slot-Anzeige */}
|
||||
<SectionTitle
|
||||
text={`${t('magic.connected_title')} \u00b7 ${desktopDevices.length}/${desktopLimit}`}
|
||||
colors={colors}
|
||||
/>
|
||||
<View style={cardStyle(colors)}>
|
||||
{devices === null ? (
|
||||
<ActivityIndicator />
|
||||
) : devices.length === 0 ? (
|
||||
) : desktopDevices.length === 0 ? (
|
||||
<Text style={{ fontSize: 14, color: colors.textMuted }}>
|
||||
Noch keine Macs verbunden. Sobald du einen Pairing-Code einlöst und ein iPhone
|
||||
bindest, erscheint es hier.
|
||||
{t('magic.connected_empty')}
|
||||
</Text>
|
||||
) : (
|
||||
devices.map((d, i) => (
|
||||
desktopDevices.map((d, i) => (
|
||||
<View
|
||||
key={d.deviceId}
|
||||
style={{
|
||||
@ -287,7 +360,15 @@ export function MagicSheet({
|
||||
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 style={{ flex: 1 }}>
|
||||
<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 {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
|
||||
@ -110,6 +110,16 @@ export async function registerPushTokenWithBackend(): Promise<string | null> {
|
||||
sound: 'default',
|
||||
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
|
||||
|
||||
71
apps/rebreak-native/hooks/useUserDevicesRealtime.ts
Normal file
71
apps/rebreak-native/hooks/useUserDevicesRealtime.ts
Normal 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]);
|
||||
}
|
||||
@ -869,7 +869,7 @@
|
||||
"devices": "Geräte",
|
||||
"devices_desc": "Registrierte Geräte verwalten",
|
||||
"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_desc": "Plan & Upgrade-Pfad",
|
||||
"subscription_plan_free": "Free",
|
||||
@ -1323,7 +1323,7 @@
|
||||
"devices": {
|
||||
"section_title_this": "Dieses Gerät",
|
||||
"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.",
|
||||
"add_mac": "Mac hinzufügen",
|
||||
"add_windows": "Windows hinzufügen (bald)",
|
||||
@ -1384,7 +1384,16 @@
|
||||
"release_cancel": "Freigabe abbrechen",
|
||||
"release_cancel_confirm": "Freigabe wirklich abbrechen?",
|
||||
"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": {
|
||||
"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.",
|
||||
"cta": "Daten ausfüllen",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -510,7 +510,9 @@
|
||||
"diga_choice": {
|
||||
"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": {
|
||||
"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_desc": "Manage registered devices",
|
||||
"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_desc": "Plan & upgrade path",
|
||||
"subscription_plan_free": "Free",
|
||||
@ -1321,7 +1323,7 @@
|
||||
"devices": {
|
||||
"section_title_this": "This device",
|
||||
"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.",
|
||||
"add_mac": "Add Mac",
|
||||
"add_windows": "Add Windows (coming soon)",
|
||||
@ -1382,7 +1384,16 @@
|
||||
"release_cancel": "Cancel release",
|
||||
"release_cancel_confirm": "Really cancel the release?",
|
||||
"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": {
|
||||
"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.",
|
||||
"cta": "Fill in data",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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);
|
||||
@ -1098,6 +1098,13 @@ model UserDevice {
|
||||
magicRevokedAt DateTime? @map("magic_revoked_at")
|
||||
/// Mac-Hostname für UI (z.B. "Chahines MacBook Pro"). Nur bei Magic-Devices.
|
||||
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])
|
||||
@@index([userId])
|
||||
|
||||
@ -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.
|
||||
* Legt ein ProtectedDevice (status=pending) an und gibt die Download-URL
|
||||
* für das mobileconfig-Profil zurück.
|
||||
* Der manuelle Offline-Profil-Download ist abgeschaltet: Das Profil hätte das
|
||||
* RemovalPassword im Klartext im heruntergeladenen .mobileconfig → Bypass-Risiko.
|
||||
* 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 }
|
||||
* Response: { deviceId, dnsToken, downloadUrl }
|
||||
* Bestehende ProtectedDevices bleiben sichtbar/entfernbar (GET + DELETE), nur
|
||||
* NEUE Enrollments über diesen Pfad sind gesperrt.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
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({
|
||||
statusCode: 403,
|
||||
data: { error: "LEGEND_REQUIRED" },
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
export default defineEventHandler(() => {
|
||||
throw createError({
|
||||
statusCode: 410,
|
||||
message:
|
||||
"Der manuelle Profil-Download ist deaktiviert. Stationäre Geräte werden über Rebreak Magic verbunden.",
|
||||
data: { error: "OFFLINE_ENROLL_DISABLED" },
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@ -76,6 +76,8 @@ export default defineEventHandler(async (event) => {
|
||||
<string>com.apple.dnsSettings.managed</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>ProhibitDisablement</key>
|
||||
<true/>
|
||||
<key>DNSSettings</key>
|
||||
<dict>
|
||||
<key>DNSProtocol</key>
|
||||
|
||||
@ -2,9 +2,9 @@
|
||||
* GET /api/magic/info
|
||||
*
|
||||
* 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(() => {
|
||||
return {
|
||||
@ -14,6 +14,9 @@ export default defineEventHandler(() => {
|
||||
downloadUrl: "https://rebreak.org/download/rebreakmagic",
|
||||
dmgUrl: "https://rebreak.org/downloads/RebreakMagic-latest.dmg",
|
||||
minMacosVersion: "13.0",
|
||||
// Windows (apps/rebreak-magic-win, Tauri NSIS-Installer)
|
||||
windowsInstallerUrl: "https://rebreak.org/downloads/RebreakMagic-Setup.exe",
|
||||
minWindowsVersion: "10 (21H2)",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@ -1,21 +1,29 @@
|
||||
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 {
|
||||
buildRemovalPasswordPayload,
|
||||
generateRemovalPassword,
|
||||
signProfileIfConfigured,
|
||||
} from "../../utils/magic-lock";
|
||||
|
||||
/**
|
||||
* GET /api/magic/profile.mobileconfig?token=<dnsToken>
|
||||
*
|
||||
* Generiert personalisiertes DNS-Configuration-Profile für macOS.
|
||||
* Template: ops/mdm/rebreak-mac-dns-filter.mobileconfig (inlined als TS
|
||||
* constant via backend/server/utils/magic-profile-template.ts — überlebt
|
||||
* jeden Build/Deploy ohne FS- oder serverAssets-Magic).
|
||||
* Generiert das personalisierte, GESPERRTE DNS-Config-Profil für macOS/Windows.
|
||||
* Template: ops/mdm/rebreak-mac-dns-filter.mobileconfig (inlined als TS const).
|
||||
*
|
||||
* Ersetzt:
|
||||
* - ServerURL: /dns-query → /dns-query/{token}
|
||||
* - PayloadUUID: 2× neu generieren (DNSSettings + Profile root)
|
||||
* - PayloadIdentifier: unique pro Device
|
||||
* Hard-Lock-Payloads:
|
||||
* - DNS-Filter (com.apple.dnsSettings.managed) mit ProhibitDisablement
|
||||
* - PayloadRemovalDisallowed + PayloadScope=System (im Template)
|
||||
* - 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) => {
|
||||
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);
|
||||
if (!device) {
|
||||
throw createError({
|
||||
@ -37,39 +44,51 @@ export default defineEventHandler(async (event) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Template via Nitro serverAssets lesen (build-time eingebundelt → cwd-unabhängig).
|
||||
const template = MAGIC_PROFILE_TEMPLATE;
|
||||
// Removal-Passwort: aus DB oder Lazy-Backfill (Devices vor dem Hard-Lock).
|
||||
let removalPassword = device.magicRemovalPassword;
|
||||
if (!removalPassword) {
|
||||
removalPassword = generateRemovalPassword();
|
||||
await ensureMagicRemovalPassword(device.id, removalPassword);
|
||||
}
|
||||
|
||||
// ServerURL ersetzen: /dns-query → /dns-query/{token}
|
||||
const personalizedProfile = template
|
||||
.replace(
|
||||
"https://dns.rebreak.org/dns-query",
|
||||
`https://dns.rebreak.org/dns-query/${token}`,
|
||||
)
|
||||
// PayloadUUID neu generieren (2 Stellen im Template)
|
||||
const deviceSlice = device.deviceId.slice(0, 8);
|
||||
|
||||
// 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/${token}`,
|
||||
)
|
||||
.replace("7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0", randomUUID().toUpperCase())
|
||||
.replace("8C3F9A2B-D4E5-4F87-9A12-B5C6D7E8F901", randomUUID().toUpperCase())
|
||||
// PayloadIdentifier unique machen (optional, verhindert Konflikt bei Multi-Device)
|
||||
.replace(
|
||||
"org.rebreak.protection.dns.filter",
|
||||
`org.rebreak.protection.dns.filter.${device.deviceId.slice(0, 8)}`,
|
||||
`org.rebreak.protection.dns.filter.${deviceSlice}`,
|
||||
)
|
||||
.replace(
|
||||
"org.rebreak.protection.profile",
|
||||
`org.rebreak.protection.profile.${device.deviceId.slice(0, 8)}`,
|
||||
`org.rebreak.protection.profile.${deviceSlice}`,
|
||||
);
|
||||
|
||||
// Response-Headers
|
||||
// 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,
|
||||
);
|
||||
|
||||
setHeader(event, "Content-Type", "application/x-apple-aspen-config");
|
||||
setHeader(
|
||||
event,
|
||||
"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
|
||||
// Requires: Apple Developer Certificate + Private Key in Keychain
|
||||
// Siehe: https://developer.apple.com/documentation/devicemanagement/profile-specific_payload_keys
|
||||
|
||||
return personalizedProfile;
|
||||
return signed;
|
||||
});
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
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 { createAdGuardClient } from "../../utils/adguard";
|
||||
import { sendDeviceAddedPush } from "../../services/push";
|
||||
import { generateRemovalPassword } from "../../utils/magic-lock";
|
||||
|
||||
/**
|
||||
* POST /api/magic/register
|
||||
@ -17,11 +22,12 @@ import { createAdGuardClient } from "../../utils/adguard";
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event);
|
||||
const body = await readBody(event);
|
||||
const { deviceId, hostname, model, osVersion } = body as {
|
||||
const { deviceId, hostname, model, osVersion, platform } = body as {
|
||||
deviceId?: string;
|
||||
hostname?: string;
|
||||
model?: string;
|
||||
osVersion?: string;
|
||||
platform?: string;
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
// 1. Prüfe ob Device bereits als Magic-Client gebunden ist (idempotent)
|
||||
@ -42,6 +52,7 @@ export default defineEventHandler(async (event) => {
|
||||
magicDnsToken: true,
|
||||
magicEnrolledAt: 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) {
|
||||
const activeCount = await countActiveMagicBindings(user.id);
|
||||
if (activeCount >= MAGIC_DEVICE_LIMIT) {
|
||||
const profile = await getProfile(user.id);
|
||||
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);
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: `Magic-Device-Limit erreicht (max ${MAGIC_DEVICE_LIMIT})`,
|
||||
message: `Geräte-Limit erreicht (max. ${desktopLimit} Computer in deinem Plan).`,
|
||||
data: {
|
||||
code: "limit_reached",
|
||||
limit: desktopLimit,
|
||||
activeBindings,
|
||||
},
|
||||
});
|
||||
@ -83,6 +106,11 @@ export default defineEventHandler(async (event) => {
|
||||
// verbietet `_` (das base64url als Ersatz für `/` generiert) → 400 "bad hostname label rune".
|
||||
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
|
||||
const adguardClientName = `magic_${deviceId}`;
|
||||
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({
|
||||
where: { userId_deviceId: { userId: user.id, deviceId } },
|
||||
create: {
|
||||
userId: user.id,
|
||||
deviceId,
|
||||
platform: "macos",
|
||||
platform: devicePlatform,
|
||||
model: model ?? null,
|
||||
name: hostname,
|
||||
osVersion: osVersion ?? null,
|
||||
magicDnsToken: dnsToken,
|
||||
magicEnrolledAt: new Date(),
|
||||
magicHostname: hostname,
|
||||
magicRemovalPassword: removalPassword,
|
||||
},
|
||||
update: {
|
||||
magicDnsToken: dnsToken,
|
||||
@ -123,6 +152,8 @@ export default defineEventHandler(async (event) => {
|
||||
model: model ?? undefined,
|
||||
osVersion: osVersion ?? undefined,
|
||||
lastSeenAt: new Date(),
|
||||
magicRemovalPassword: removalPassword,
|
||||
magicReleaseRequestedAt: null, // Re-Bind bricht laufenden Release ab
|
||||
},
|
||||
select: {
|
||||
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 {
|
||||
success: true,
|
||||
data: {
|
||||
|
||||
34
backend/server/api/magic/status.get.ts
Normal file
34
backend/server/api/magic/status.get.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
@ -2,6 +2,9 @@ import Stripe from "stripe";
|
||||
import { usePrisma } from "../../utils/prisma";
|
||||
import { runDowngradeReconciliation } from "../../utils/downgrade-reconciliation";
|
||||
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
|
||||
@ -107,7 +110,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const profile = await db.profile.findFirst({
|
||||
where: { stripeCustomerId: customerId },
|
||||
select: { id: true, plan: true },
|
||||
select: { id: true, plan: true, nickname: true },
|
||||
});
|
||||
|
||||
if (profile) {
|
||||
@ -120,6 +123,30 @@ export default defineEventHandler(async (event) => {
|
||||
await runDowngradeReconciliation(profile.id, fromPlan, "free").catch(
|
||||
(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;
|
||||
}
|
||||
|
||||
@ -15,6 +15,8 @@ import {
|
||||
} from "../../db/mail";
|
||||
import { writeConsentRevoke } from "../../db/consent";
|
||||
import { usePrisma } from "../../utils/prisma";
|
||||
import { listMagicRemovalCredentials } from "../../db/devices";
|
||||
import { sendMagicRemovalEmail } from "../../utils/magic-removal-email";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event);
|
||||
@ -46,6 +48,37 @@ export default defineEventHandler(async (event) => {
|
||||
// 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)
|
||||
// Reihenfolge: Samples VOR Connections löschen (oder parallel — FK-Reihenfolge
|
||||
// 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)
|
||||
await supabase.auth.admin.deleteUser(userId);
|
||||
|
||||
return { success: true };
|
||||
// Removal-Passwörter im Response mitgeben (In-App-Reveal vor Logout).
|
||||
return { success: true, magicRemovalCredentials };
|
||||
});
|
||||
|
||||
@ -413,8 +413,8 @@ export async function deleteUserDevice(
|
||||
// RebreakMagic DNS-Device-Binding
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Hard-Limit für Magic-Bindings pro User (5 für Legend-Plan / Staging-Testing). */
|
||||
export const MAGIC_DEVICE_LIMIT = 5;
|
||||
// Magic-Binding-Limit ist plan-gated (plan-features.maxProtectedDevices:
|
||||
// Pro 1 / Legend 2) — geprüft in api/magic/register.post.ts.
|
||||
|
||||
export interface MagicDeviceRecord {
|
||||
deviceId: string;
|
||||
@ -481,7 +481,13 @@ export async function countActiveMagicBindings(
|
||||
*/
|
||||
export async function findMagicDeviceByToken(
|
||||
token: string,
|
||||
): Promise<(DeviceRecord & { magicDnsToken: string }) | null> {
|
||||
): Promise<
|
||||
| (DeviceRecord & {
|
||||
magicDnsToken: string;
|
||||
magicRemovalPassword: string | null;
|
||||
})
|
||||
| null
|
||||
> {
|
||||
const db = usePrisma();
|
||||
const device = await db.userDevice.findUnique({
|
||||
where: {
|
||||
@ -493,6 +499,7 @@ export async function findMagicDeviceByToken(
|
||||
magicEnrolledAt: true,
|
||||
magicRevokedAt: true,
|
||||
magicHostname: true,
|
||||
magicRemovalPassword: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -504,3 +511,53 @@ export async function findMagicDeviceByToken(
|
||||
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!,
|
||||
}));
|
||||
}
|
||||
|
||||
@ -128,6 +128,98 @@ export function truncatePreview(text: string, max = 100): string {
|
||||
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 {
|
||||
/** Empfänger (Callee) — bekommt den Push */
|
||||
receiverId: string;
|
||||
|
||||
100
backend/server/utils/magic-lock.ts
Normal file
100
backend/server/utils/magic-lock.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -26,6 +26,8 @@ export const MAGIC_PROFILE_TEMPLATE = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<string>7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>ProhibitDisablement</key>
|
||||
<true/>
|
||||
<key>DNSSettings</key>
|
||||
<dict>
|
||||
<key>DNSProtocol</key>
|
||||
|
||||
98
backend/server/utils/magic-removal-email.ts
Normal file
98
backend/server/utils/magic-removal-email.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -51,9 +51,10 @@ export interface PlanLimits {
|
||||
*/
|
||||
maxAppDevices: number;
|
||||
/**
|
||||
* Max. zusätzliche Geräte (Mac/Windows) die per DNS-Profil geschützt werden.
|
||||
* Bezieht sich auf ProtectedDevice (Legend-only Feature).
|
||||
* 0 = Feature nicht verfügbar.
|
||||
* Max. stationäre Geräte (Mac/Windows) die per DNS geschützt werden.
|
||||
* Gilt für Magic-Bindings (UserDevice.magicDnsToken — Magic-Mac/Win-App)
|
||||
* 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;
|
||||
|
||||
@ -89,7 +90,7 @@ export const PLAN_LIMITS: Record<Exclude<Plan, "free">, PlanLimits> = {
|
||||
canCreateGroup: false,
|
||||
canAddToBlocklist: false,
|
||||
maxAppDevices: 1,
|
||||
maxProtectedDevices: 0,
|
||||
maxProtectedDevices: 1, // 1 Desktop (Mac ODER Windows) — Hauptrisiko-Gerät schützen
|
||||
aiModel: "llama-3.3-70b-versatile",
|
||||
aiModelFallbacks: [
|
||||
{ provider: "groq", model: "llama-3.1-8b-instant" },
|
||||
|
||||
@ -17,6 +17,8 @@
|
||||
<string>7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>ProhibitDisablement</key>
|
||||
<true/>
|
||||
<key>DNSSettings</key>
|
||||
<dict>
|
||||
<key>DNSProtocol</key>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user