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
|
# Next Release
|
||||||
|
|
||||||
## Fixes
|
## New
|
||||||
- **Calls: fixed phantom/zombie incoming calls (iOS).** After an incoming call ended without the in-app call screen ever mounting (iOS shows the native CallKit banner, not our `/call` screen), the call store stayed stuck in the `ended` state forever. The `ended → idle` reset only lived in the `/call` screen, which never mounts for banner-only incoming calls. A stuck `ended` state then silently blocked every subsequent incoming call (RING + VoIP push were received but ignored by the `status !== 'idle'` guard), so accepting from the banner produced a phantom CallKit call that ticked as "active" with no real connection, and the caller saw a missed call.
|
- Device detail view: tap any device on the Devices page to open a sheet with its connection status, when it was connected, and a protected/unprotected coverage donut (same visual as your profile)
|
||||||
- Store now self-heals back to `idle` after a call ends, decoupled from the call screen (fallback timer in `teardown`).
|
- "New device connected" push notification — when a Mac or Windows computer is bound via Rebreak Magic, your phone(s) get notified
|
||||||
- `receiveIncoming` and the realtime ring handler now treat a stale `ended` state as acceptable (clear + proceed) instead of dropping the new call.
|
- Devices page now updates in real time the moment a new computer is paired — no manual refresh needed
|
||||||
- `onAnswer` now ends the native CallKit call when the store has no `incoming` call, preventing a phantom "active" call.
|
- Onboarding now sets up the full protection using the exact same guided, gated step flow as the protection screen (single source of truth) — Android: VPN → Device Administrator → Accessibility (strict order: the tamper lock has to come last, otherwise it would block the device-admin screen); iOS: App Lock → Screen Time passcode → content filter. Previously the device-admin / screen-time hardening steps only existed in the protection screen after onboarding.
|
||||||
- `RNCallKeep.endAllCalls()` on app launch clears leftover CallKit zombies from a previous session.
|
|
||||||
- **DM header: online dot now matches the online text.** The green online dot on the partner avatar used the follow-gated presence (`isOnline` = online AND you follow them), while the "online" text next to it used raw presence. In a DM the dot now uses raw presence too, so it shows whenever the partner is online — consistent with the text, regardless of follow relationship. (Looked like an Android-only bug but was the follow gate + asymmetric follow between the test accounts.)
|
## Changed
|
||||||
|
- Stationary protection (Mac/Windows) now runs exclusively via Rebreak Magic — the manual offline profile download has been removed. The offline profile would have shipped the removal password in plain text inside the file (bypass risk); with Magic the lock password stays server-side and is never shown to the user.
|
||||||
|
- Mac DNS profile hardened with `ProhibitDisablement` — the filter can no longer be toggled off in System Settings.
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
- Android onboarding: if the VPN permission dialog failed to open (e.g. another always-on VPN active, work profile, or certain OEM quirks), the protection step would silently get stuck with no dialog and no error message — especially on Play Store builds, where the underlying error was swallowed. The step now surfaces the real error and offers a retry instead of dead-ending.
|
||||||
|
|||||||
@ -100,7 +100,7 @@ function RootLayoutInner() {
|
|||||||
if (!response) return;
|
if (!response) return;
|
||||||
const data = response.notification.request.content.data as
|
const data = response.notification.request.content.data as
|
||||||
| {
|
| {
|
||||||
type?: 'dm' | 'room' | 'call';
|
type?: 'dm' | 'room' | 'call' | 'device_added';
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
callId?: string;
|
callId?: string;
|
||||||
from?: { id: string; nickname: string; avatar: string | null };
|
from?: { id: string; nickname: string; avatar: string | null };
|
||||||
@ -111,6 +111,8 @@ function RootLayoutInner() {
|
|||||||
router.push({ pathname: '/dm', params: { userId: data.targetId } });
|
router.push({ pathname: '/dm', params: { userId: data.targetId } });
|
||||||
} else if (data.type === 'room' && data.targetId) {
|
} else if (data.type === 'room' && data.targetId) {
|
||||||
router.push({ pathname: '/room', params: { roomId: data.targetId } });
|
router.push({ pathname: '/room', params: { roomId: data.targetId } });
|
||||||
|
} else if (data.type === 'device_added') {
|
||||||
|
router.push('/devices');
|
||||||
} else if (data.type === 'call' && data.callId && data.from) {
|
} else if (data.type === 'call' && data.callId && data.from) {
|
||||||
// Eingehender Anruf via regulärem Push (Android-Pfad / iOS-Fallback wenn
|
// Eingehender Anruf via regulärem Push (Android-Pfad / iOS-Fallback wenn
|
||||||
// kein voipToken). Auf iOS gilt: VoIP-PushKit ist der einzige Pfad
|
// kein voipToken). Auf iOS gilt: VoIP-PushKit ist der einzige Pfad
|
||||||
|
|||||||
@ -27,11 +27,12 @@ function formatCountdown(isoTarget: string): string {
|
|||||||
}
|
}
|
||||||
import { useProtectedDevicesStore, type ProtectedDevice } from '../stores/protectedDevices';
|
import { useProtectedDevicesStore, type ProtectedDevice } from '../stores/protectedDevices';
|
||||||
import { useProtectedDevicesRealtime } from '../hooks/useProtectedDevicesRealtime';
|
import { useProtectedDevicesRealtime } from '../hooks/useProtectedDevicesRealtime';
|
||||||
|
import { useUserDevicesRealtime } from '../hooks/useUserDevicesRealtime';
|
||||||
import { useUserPlan } from '../hooks/useUserPlan';
|
import { useUserPlan } from '../hooks/useUserPlan';
|
||||||
import { AppHeader } from '../components/AppHeader';
|
import { AppHeader } from '../components/AppHeader';
|
||||||
import { AddMacSheet } from '../components/devices/AddMacSheet';
|
import { MagicSheet } from '../components/devices/MagicSheet';
|
||||||
import { AddWindowsSheet } from '../components/devices/AddWindowsSheet';
|
|
||||||
import { DeviceProgressBar } from '../components/devices/DeviceProgressBar';
|
import { DeviceProgressBar } from '../components/devices/DeviceProgressBar';
|
||||||
|
import { DeviceDetailSheet, type DeviceDetail } from '../components/devices/DeviceDetailSheet';
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -124,11 +125,13 @@ function MobileDeviceRow({
|
|||||||
onRemove,
|
onRemove,
|
||||||
onRequestRelease,
|
onRequestRelease,
|
||||||
onCancelRelease,
|
onCancelRelease,
|
||||||
|
onOpenDetail,
|
||||||
}: {
|
}: {
|
||||||
device: UserDevice;
|
device: UserDevice;
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
onRequestRelease: (id: string) => void;
|
onRequestRelease: (id: string) => void;
|
||||||
onCancelRelease: (id: string) => void;
|
onCancelRelease: (id: string) => void;
|
||||||
|
onOpenDetail: (d: DeviceDetail) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
@ -187,6 +190,20 @@ function MobileDeviceRow({
|
|||||||
const deviceName = device.model ?? device.name ?? device.platform;
|
const deviceName = device.model ?? device.name ?? device.platform;
|
||||||
const footerText = `${formatLastSeen(device.lastSeenAt, t)} · ${t('settings.devices_since')} ${formatSince(device.createdAt)}`;
|
const footerText = `${formatLastSeen(device.lastSeenAt, t)} · ${t('settings.devices_since')} ${formatSince(device.createdAt)}`;
|
||||||
|
|
||||||
|
function openDetail() {
|
||||||
|
onOpenDetail({
|
||||||
|
name: deviceName,
|
||||||
|
icon: mobileIcon(device.platform),
|
||||||
|
platform: device.platform,
|
||||||
|
createdAt: device.createdAt,
|
||||||
|
lastSeenAt: device.lastSeenAt,
|
||||||
|
statusLabel: device.isCurrent
|
||||||
|
? t('settings.devices_this_device')
|
||||||
|
: t('devices.status_active'),
|
||||||
|
statusColor: colors.success,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@ -196,6 +213,11 @@ function MobileDeviceRow({
|
|||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={openDetail}
|
||||||
|
activeOpacity={0.6}
|
||||||
|
style={{ flex: 1, flexDirection: 'row', alignItems: 'center', gap: 12, minWidth: 0 }}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@ -283,6 +305,7 @@ function MobileDeviceRow({
|
|||||||
: footerText}
|
: footerText}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
{device.isCurrent ? null : releaseActive ? (
|
{device.isCurrent ? null : releaseActive ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@ -318,13 +341,38 @@ function MobileDeviceRow({
|
|||||||
function ProtectedDeviceRow({
|
function ProtectedDeviceRow({
|
||||||
device,
|
device,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onOpenDetail,
|
||||||
}: {
|
}: {
|
||||||
device: ProtectedDevice;
|
device: ProtectedDevice;
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
|
onOpenDetail: (d: DeviceDetail) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
|
|
||||||
|
const statusMeta: Record<string, { label: string; color: string }> = {
|
||||||
|
pending: { label: t('devices.status_pending'), color: '#f59e0b' },
|
||||||
|
active: { label: t('devices.status_active'), color: colors.success },
|
||||||
|
revoked: { label: t('devices.status_revoked'), color: colors.textMuted },
|
||||||
|
degraded: { label: t('plan_limit.device_degraded_title'), color: colors.error },
|
||||||
|
};
|
||||||
|
const meta = statusMeta[device.status] ?? {
|
||||||
|
label: device.status,
|
||||||
|
color: colors.textMuted,
|
||||||
|
};
|
||||||
|
|
||||||
|
function openDetail() {
|
||||||
|
onOpenDetail({
|
||||||
|
name: device.label,
|
||||||
|
icon: protectedDeviceIcon(device.platform),
|
||||||
|
platform: device.platform,
|
||||||
|
createdAt: device.createdAt,
|
||||||
|
lastSeenAt: null,
|
||||||
|
statusLabel: meta.label,
|
||||||
|
statusColor: meta.color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const menuActions = device.status === 'pending'
|
const menuActions = device.status === 'pending'
|
||||||
? [
|
? [
|
||||||
{ id: 'remove', title: t('settings.devices_remove_confirm'), attributes: { destructive: true } },
|
{ id: 'remove', title: t('settings.devices_remove_confirm'), attributes: { destructive: true } },
|
||||||
@ -359,6 +407,11 @@ function ProtectedDeviceRow({
|
|||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={openDetail}
|
||||||
|
activeOpacity={0.6}
|
||||||
|
style={{ flex: 1, flexDirection: 'row', alignItems: 'center', gap: 12, minWidth: 0 }}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@ -403,6 +456,7 @@ function ProtectedDeviceRow({
|
|||||||
: `${t('settings.devices_since')} ${formatSince(device.createdAt)}`}
|
: `${t('settings.devices_since')} ${formatSince(device.createdAt)}`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
<MenuView
|
<MenuView
|
||||||
title={device.label}
|
title={device.label}
|
||||||
@ -487,8 +541,14 @@ export default function DevicesScreen() {
|
|||||||
remove: removeProtected,
|
remove: removeProtected,
|
||||||
} = useProtectedDevicesStore();
|
} = useProtectedDevicesStore();
|
||||||
|
|
||||||
const [addMacVisible, setAddMacVisible] = useState(false);
|
const [magicVisible, setMagicVisible] = useState(false);
|
||||||
const [addWindowsVisible, setAddWindowsVisible] = useState(false);
|
const [magicPlatform, setMagicPlatform] = useState<'mac' | 'windows'>('mac');
|
||||||
|
const [detailDevice, setDetailDevice] = useState<DeviceDetail | null>(null);
|
||||||
|
|
||||||
|
function openMagic(platform: 'mac' | 'windows') {
|
||||||
|
setMagicPlatform(platform);
|
||||||
|
setMagicVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDevices();
|
loadDevices();
|
||||||
@ -496,9 +556,12 @@ export default function DevicesScreen() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useProtectedDevicesRealtime();
|
useProtectedDevicesRealtime();
|
||||||
|
useUserDevicesRealtime();
|
||||||
|
|
||||||
const TOTAL_DEVICE_SLOTS = 3;
|
// Geräte-Matrix (Mirror von backend plan-features):
|
||||||
const activeProtectedCount = protectedDevices.filter((d) => d.status !== 'revoked').length;
|
// Pro = 1 mobil + 1 stationär, Legend = 3 mobil + 2 stationär ("lückenlos auf 5")
|
||||||
|
const mobileLimit = isLegend ? 3 : 1;
|
||||||
|
const desktopLimit = isLegend ? 2 : 1;
|
||||||
|
|
||||||
// Dedupe: wenn ein UserDevice (mobile/desktop) auf der gleichen Plattform
|
// Dedupe: wenn ein UserDevice (mobile/desktop) auf der gleichen Plattform
|
||||||
// (mac/ios/android/win) bereits existiert, blende die entsprechende
|
// (mac/ios/android/win) bereits existiert, blende die entsprechende
|
||||||
@ -519,8 +582,17 @@ export default function DevicesScreen() {
|
|||||||
(d) => !mobilePlatformKeys.has(normalizePlatform(d.platform)),
|
(d) => !mobilePlatformKeys.has(normalizePlatform(d.platform)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalRegistered = mobileDevices.length + dedupedProtected.filter((d) => d.status !== 'revoked').length;
|
// Mobile vs Desktop getrennt zählen: UserDevices mit mac/win-Plattform sind
|
||||||
const atDeviceLimit = isLegend && totalRegistered >= TOTAL_DEVICE_SLOTS;
|
// Magic-Desktops, der Rest (ios/android) zählt auf die Mobile-Slots.
|
||||||
|
const isDesktopPlatform = (d: { platform?: string | null; model?: string | null }) => {
|
||||||
|
const key = normalizePlatform(d.platform || d.model || '');
|
||||||
|
return key === 'mac' || key === 'win';
|
||||||
|
};
|
||||||
|
const mobileCount = mobileDevices.filter((d) => !isDesktopPlatform(d)).length;
|
||||||
|
const desktopCount =
|
||||||
|
mobileDevices.filter(isDesktopPlatform).length +
|
||||||
|
dedupedProtected.filter((d) => d.status !== 'revoked').length;
|
||||||
|
const atDesktopLimit = desktopCount >= desktopLimit;
|
||||||
|
|
||||||
// Mobile zuerst (current oben), danach Desktop/Protected.
|
// Mobile zuerst (current oben), danach Desktop/Protected.
|
||||||
const sortedMobile = [...mobileDevices].sort((a, b) => {
|
const sortedMobile = [...mobileDevices].sort((a, b) => {
|
||||||
@ -530,7 +602,7 @@ export default function DevicesScreen() {
|
|||||||
});
|
});
|
||||||
const isLoading = mobileLoading || protectedLoading;
|
const isLoading = mobileLoading || protectedLoading;
|
||||||
const isEmpty = !isLoading && sortedMobile.length === 0 && dedupedProtected.length === 0;
|
const isEmpty = !isLoading && sortedMobile.length === 0 && dedupedProtected.length === 0;
|
||||||
const subtitle = isLegend ? t('devices.subtitle_legend') : t('devices.subtitle_free');
|
const subtitle = isLegend ? t('devices.subtitle_legend') : t('devices.subtitle_pro');
|
||||||
|
|
||||||
async function handleRemoveProtected(id: string) {
|
async function handleRemoveProtected(id: string) {
|
||||||
try {
|
try {
|
||||||
@ -569,13 +641,18 @@ export default function DevicesScreen() {
|
|||||||
>
|
>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</Text>
|
</Text>
|
||||||
{isLegend ? (
|
|
||||||
<DeviceProgressBar
|
<DeviceProgressBar
|
||||||
count={totalRegistered}
|
count={mobileCount}
|
||||||
max={TOTAL_DEVICE_SLOTS}
|
max={mobileLimit}
|
||||||
atLimit={atDeviceLimit}
|
atLimit={mobileCount >= mobileLimit}
|
||||||
|
label={t('devices.progress_mobile')}
|
||||||
|
/>
|
||||||
|
<DeviceProgressBar
|
||||||
|
count={desktopCount}
|
||||||
|
max={desktopLimit}
|
||||||
|
atLimit={atDesktopLimit}
|
||||||
|
label={t('devices.progress_desktop')}
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Unified devices section: Mobile zuerst, dann Desktop */}
|
{/* Unified devices section: Mobile zuerst, dann Desktop */}
|
||||||
@ -616,6 +693,7 @@ export default function DevicesScreen() {
|
|||||||
onRemove={removeMobileDevice}
|
onRemove={removeMobileDevice}
|
||||||
onRequestRelease={requestRelease}
|
onRequestRelease={requestRelease}
|
||||||
onCancelRelease={cancelRelease}
|
onCancelRelease={cancelRelease}
|
||||||
|
onOpenDetail={setDetailDevice}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@ -628,7 +706,11 @@ export default function DevicesScreen() {
|
|||||||
borderBottomColor: colors.border,
|
borderBottomColor: colors.border,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ProtectedDeviceRow device={device} onRemove={handleRemoveProtected} />
|
<ProtectedDeviceRow
|
||||||
|
device={device}
|
||||||
|
onRemove={handleRemoveProtected}
|
||||||
|
onOpenDetail={setDetailDevice}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@ -636,34 +718,15 @@ export default function DevicesScreen() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* CTA or Upgrade */}
|
{/* CTA — Desktop-Gerät hinzufügen (Pro: 1 Slot, Legend: 2 Slots) */}
|
||||||
{isLegend ? (
|
{atDesktopLimit ? (
|
||||||
atDeviceLimit ? (
|
isLegend ? (
|
||||||
<Button
|
<Button
|
||||||
title={t('devices.add_device')}
|
title={t('devices.add_device')}
|
||||||
icon="add-circle-outline"
|
icon="add-circle-outline"
|
||||||
disabled
|
disabled
|
||||||
style={{ backgroundColor: colors.surfaceElevated }}
|
style={{ backgroundColor: colors.surfaceElevated }}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<MenuView
|
|
||||||
title={t('devices.add_device')}
|
|
||||||
actions={[
|
|
||||||
{ id: 'mac', title: 'Mac' },
|
|
||||||
{ id: 'windows', title: 'Windows-PC' },
|
|
||||||
]}
|
|
||||||
onPressAction={({ nativeEvent: { event } }) => {
|
|
||||||
if (event === 'mac') setAddMacVisible(true);
|
|
||||||
else if (event === 'windows') setAddWindowsVisible(true);
|
|
||||||
}}
|
|
||||||
shouldOpenOnLongPress={false}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
title={t('devices.add_device')}
|
|
||||||
icon="add-circle-outline"
|
|
||||||
/>
|
|
||||||
</MenuView>
|
|
||||||
)
|
|
||||||
) : (
|
) : (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@ -685,6 +748,15 @@ export default function DevicesScreen() {
|
|||||||
</View>
|
</View>
|
||||||
<Button title={t('devices.upgrade_cta')} />
|
<Button title={t('devices.upgrade_cta')} />
|
||||||
</View>
|
</View>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
// Kein Mac/Windows-Menü mehr — das MagicSheet hat selbst den
|
||||||
|
// Plattform-Umschalter (Mac/Windows). Direkt öffnen.
|
||||||
|
<Button
|
||||||
|
title={t('devices.add_device')}
|
||||||
|
icon="add-circle-outline"
|
||||||
|
onPress={() => openMagic('mac')}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
@ -700,20 +772,21 @@ export default function DevicesScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<AddMacSheet
|
<MagicSheet
|
||||||
visible={addMacVisible}
|
visible={magicVisible}
|
||||||
|
initialPlatform={magicPlatform}
|
||||||
|
colors={colors}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setAddMacVisible(false);
|
setMagicVisible(false);
|
||||||
|
loadDevices();
|
||||||
loadProtected();
|
loadProtected();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddWindowsSheet
|
<DeviceDetailSheet
|
||||||
visible={addWindowsVisible}
|
visible={!!detailDevice}
|
||||||
onClose={() => {
|
device={detailDevice}
|
||||||
setAddWindowsVisible(false);
|
onClose={() => setDetailDevice(null)}
|
||||||
loadProtected();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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';
|
} from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import * as Clipboard from 'expo-clipboard';
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { ColorScheme } from '../../lib/theme';
|
import type { ColorScheme } from '../../lib/theme';
|
||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
|
import { useUserPlan } from '../../hooks/useUserPlan';
|
||||||
import { FormSheet } from '../FormSheet';
|
import { FormSheet } from '../FormSheet';
|
||||||
|
|
||||||
type PairResponse = {
|
type PairResponse = {
|
||||||
@ -26,6 +28,8 @@ type MagicDevice = {
|
|||||||
model: string | null;
|
model: string | null;
|
||||||
osVersion: string | null;
|
osVersion: string | null;
|
||||||
magicEnrolledAt: string;
|
magicEnrolledAt: string;
|
||||||
|
/** "magic" = Desktop-Binding (Mac/Win), "locked" = Mobile, "protected" = Legacy-DNS */
|
||||||
|
source?: 'magic' | 'locked' | 'protected';
|
||||||
};
|
};
|
||||||
|
|
||||||
type MagicInfo = {
|
type MagicInfo = {
|
||||||
@ -33,8 +37,12 @@ type MagicInfo = {
|
|||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
dmgUrl: string;
|
dmgUrl: string;
|
||||||
minMacosVersion: string;
|
minMacosVersion: string;
|
||||||
|
windowsInstallerUrl?: string;
|
||||||
|
minWindowsVersion?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DesktopPlatform = 'mac' | 'windows';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MagicSheet — Rebreak-Magic-Pairing-Flow als geteiltes FormSheet
|
* MagicSheet — Rebreak-Magic-Pairing-Flow als geteiltes FormSheet
|
||||||
* (Bottom-Sheet mit Titel-Header). Wird aus settings.tsx getriggert.
|
* (Bottom-Sheet mit Titel-Header). Wird aus settings.tsx getriggert.
|
||||||
@ -43,18 +51,31 @@ export function MagicSheet({
|
|||||||
visible,
|
visible,
|
||||||
onClose,
|
onClose,
|
||||||
colors,
|
colors,
|
||||||
|
initialPlatform = 'mac',
|
||||||
}: {
|
}: {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
colors: ColorScheme;
|
colors: ColorScheme;
|
||||||
|
/** Vorauswahl wenn aus dem "Gerät hinzufügen"-Menü (Mac/Windows) geöffnet */
|
||||||
|
initialPlatform?: DesktopPlatform;
|
||||||
}) {
|
}) {
|
||||||
const [info, setInfo] = useState<MagicInfo | null>(null);
|
const [info, setInfo] = useState<MagicInfo | null>(null);
|
||||||
|
const [platform, setPlatform] = useState<DesktopPlatform>(initialPlatform);
|
||||||
|
|
||||||
|
// Bei jedem Öffnen auf die gewünschte Plattform zurücksetzen
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) setPlatform(initialPlatform);
|
||||||
|
}, [visible, initialPlatform]);
|
||||||
const [pair, setPair] = useState<PairResponse | null>(null);
|
const [pair, setPair] = useState<PairResponse | null>(null);
|
||||||
const [pairLoading, setPairLoading] = useState(false);
|
const [pairLoading, setPairLoading] = useState(false);
|
||||||
const [pairError, setPairError] = useState<string | null>(null);
|
const [pairError, setPairError] = useState<string | null>(null);
|
||||||
const [now, setNow] = useState(Date.now());
|
const [now, setNow] = useState(Date.now());
|
||||||
const [devices, setDevices] = useState<MagicDevice[] | null>(null);
|
const [devices, setDevices] = useState<MagicDevice[] | null>(null);
|
||||||
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { plan } = useUserPlan();
|
||||||
|
// Desktop-Slots (Mac/Windows) — Mirror von backend plan-features.maxProtectedDevices
|
||||||
|
const desktopLimit = plan === 'legend' ? 2 : 1;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@ -67,6 +88,8 @@ export function MagicSheet({
|
|||||||
downloadUrl: 'https://staging.rebreak.org/download/rebreakmagic',
|
downloadUrl: 'https://staging.rebreak.org/download/rebreakmagic',
|
||||||
dmgUrl: 'https://staging.rebreak.org/downloads/RebreakMagic-latest.dmg',
|
dmgUrl: 'https://staging.rebreak.org/downloads/RebreakMagic-latest.dmg',
|
||||||
minMacosVersion: '13.0',
|
minMacosVersion: '13.0',
|
||||||
|
windowsInstallerUrl: 'https://staging.rebreak.org/downloads/RebreakMagic-Setup.exe',
|
||||||
|
minWindowsVersion: '10 (21H2)',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
loadDevices();
|
loadDevices();
|
||||||
@ -106,7 +129,7 @@ export function MagicSheet({
|
|||||||
setPair(res);
|
setPair(res);
|
||||||
setNow(Date.now());
|
setNow(Date.now());
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setPairError(e?.message ?? 'Fehler beim Generieren');
|
setPairError(e?.message ?? t('magic.generate_error'));
|
||||||
} finally {
|
} finally {
|
||||||
setPairLoading(false);
|
setPairLoading(false);
|
||||||
}
|
}
|
||||||
@ -125,6 +148,20 @@ export function MagicSheet({
|
|||||||
|
|
||||||
const codeExpired = pair !== null && remaining <= 0;
|
const codeExpired = pair !== null && remaining <= 0;
|
||||||
|
|
||||||
|
// Desktop-Geräte (Mac/Windows) — "locked" sind Mobile-Devices aus dem
|
||||||
|
// merged /api/magic/devices-Endpoint und zählen NICHT auf Desktop-Slots.
|
||||||
|
const desktopDevices = useMemo(
|
||||||
|
() => (devices ?? []).filter((d) => d.source !== 'locked'),
|
||||||
|
[devices],
|
||||||
|
);
|
||||||
|
const limitReached = desktopDevices.length >= desktopLimit;
|
||||||
|
|
||||||
|
const isMac = platform === 'mac';
|
||||||
|
const appName = t(isMac ? 'magic.app_mac' : 'magic.app_windows');
|
||||||
|
const downloadHref = isMac
|
||||||
|
? info?.downloadUrl
|
||||||
|
: (info?.windowsInstallerUrl ?? info?.downloadUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormSheet
|
<FormSheet
|
||||||
visible={visible}
|
visible={visible}
|
||||||
@ -135,45 +172,75 @@ export function MagicSheet({
|
|||||||
<View style={{ paddingHorizontal: 20, paddingTop: 4, paddingBottom: 24 }}>
|
<View style={{ paddingHorizontal: 20, paddingTop: 4, paddingBottom: 24 }}>
|
||||||
{/* Sub-Header (Tagline) */}
|
{/* Sub-Header (Tagline) */}
|
||||||
<Text style={{ fontSize: 13, color: colors.textMuted, marginBottom: 16, marginLeft: 4 }}>
|
<Text style={{ fontSize: 13, color: colors.textMuted, marginBottom: 16, marginLeft: 4 }}>
|
||||||
iPhone in 30 Sek. binden — ohne Werks-Reset.
|
{t(isMac ? 'magic.tagline_mac' : 'magic.tagline_windows')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{/* Plattform-Wahl */}
|
||||||
|
<SectionTitle text={t('magic.platform_question')} colors={colors} />
|
||||||
|
<View style={{ flexDirection: 'row', gap: 10, marginBottom: 16 }}>
|
||||||
|
<PlatformOption
|
||||||
|
icon="laptop-outline"
|
||||||
|
label="Mac"
|
||||||
|
selected={isMac}
|
||||||
|
onPress={() => setPlatform('mac')}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
<PlatformOption
|
||||||
|
icon="desktop-outline"
|
||||||
|
label="Windows"
|
||||||
|
selected={!isMac}
|
||||||
|
onPress={() => setPlatform('windows')}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Step 1 — Download */}
|
{/* Step 1 — Download */}
|
||||||
<SectionTitle text="1. Mac-App herunterladen" colors={colors} />
|
<SectionTitle text={t('magic.step1_title', { app: appName })} colors={colors} />
|
||||||
<View style={cardStyle(colors)}>
|
<View style={cardStyle(colors)}>
|
||||||
<Text style={{ fontSize: 14, color: colors.text, marginBottom: 12 }}>
|
<Text style={{ fontSize: 14, color: colors.text, marginBottom: 12 }}>
|
||||||
Auf deinem Mac öffnen (min. macOS {info?.minMacosVersion ?? '13.0'}).
|
{isMac
|
||||||
|
? t('magic.step1_body_mac', { version: info?.minMacosVersion ?? '13.0' })
|
||||||
|
: t('magic.step1_body_windows', { version: info?.minWindowsVersion ?? '10 (21H2)' })}
|
||||||
</Text>
|
</Text>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
icon="cloud-download-outline"
|
icon="cloud-download-outline"
|
||||||
label="Download öffnen"
|
label={t('magic.open_download')}
|
||||||
onPress={() => info && Linking.openURL(info.downloadUrl)}
|
onPress={() => downloadHref && Linking.openURL(downloadHref)}
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => info && Share.share({ message: info.downloadUrl })}
|
onPress={() => downloadHref && Share.share({ message: downloadHref })}
|
||||||
style={{ marginTop: 10, alignSelf: 'flex-start' }}
|
style={{ marginTop: 10, alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: 13, color: '#007AFF' }}>Link an meinen Mac senden</Text>
|
<Text style={{ fontSize: 13, color: '#007AFF' }}>
|
||||||
|
{t(isMac ? 'magic.send_link_mac' : 'magic.send_link_windows')}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Step 2 — Pairing-Code */}
|
{/* Step 2 — Pairing-Code */}
|
||||||
<SectionTitle text="2. Pairing-Code generieren" colors={colors} />
|
<SectionTitle text={t('magic.step2_title')} colors={colors} />
|
||||||
<View style={cardStyle(colors)}>
|
<View style={cardStyle(colors)}>
|
||||||
{!pair || codeExpired ? (
|
{limitReached && (!pair || codeExpired) ? (
|
||||||
|
<Text style={{ fontSize: 14, color: colors.textMuted }}>
|
||||||
|
{t('magic.limit_reached', {
|
||||||
|
count: desktopDevices.length,
|
||||||
|
max: desktopLimit,
|
||||||
|
})}{' '}
|
||||||
|
{t(plan === 'legend' ? 'magic.limit_hint_legend' : 'magic.limit_hint_pro')}
|
||||||
|
</Text>
|
||||||
|
) : !pair || codeExpired ? (
|
||||||
<>
|
<>
|
||||||
<Text style={{ fontSize: 14, color: colors.text, marginBottom: 14 }}>
|
<Text style={{ fontSize: 14, color: colors.text, marginBottom: 14 }}>
|
||||||
Erzeuge einen 6-stelligen Code und gib ihn in der Mac-App ein. Gültig 10
|
{t('magic.code_explainer', { app: appName })}
|
||||||
Minuten, nur einmal verwendbar.
|
|
||||||
</Text>
|
</Text>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
icon="key-outline"
|
icon="key-outline"
|
||||||
label={
|
label={
|
||||||
pairLoading
|
pairLoading
|
||||||
? 'Generiere…'
|
? t('magic.generating')
|
||||||
: codeExpired
|
: codeExpired
|
||||||
? 'Neuen Code erzeugen'
|
? t('magic.generate_new')
|
||||||
: 'Code erzeugen'
|
: t('magic.generate')
|
||||||
}
|
}
|
||||||
onPress={handleGenerateCode}
|
onPress={handleGenerateCode}
|
||||||
loading={pairLoading}
|
loading={pairLoading}
|
||||||
@ -192,7 +259,7 @@ export function MagicSheet({
|
|||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
In Mac-App eingeben:
|
{t('magic.enter_in_app', { app: appName })}
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleCopyCode}
|
onPress={handleCopyCode}
|
||||||
@ -234,11 +301,13 @@ export function MagicSheet({
|
|||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
<Ionicons name="time-outline" size={14} color={colors.textMuted} />
|
<Ionicons name="time-outline" size={14} color={colors.textMuted} />
|
||||||
<Text style={{ fontSize: 13, color: colors.textMuted }}>
|
<Text style={{ fontSize: 13, color: colors.textMuted }}>
|
||||||
Läuft ab in {formatRemaining(remaining)}
|
{t('magic.expires_in', { time: formatRemaining(remaining) })}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity onPress={handleCopyCode}>
|
<TouchableOpacity onPress={handleCopyCode}>
|
||||||
<Text style={{ fontSize: 13, color: '#007AFF', fontWeight: '600' }}>Kopieren</Text>
|
<Text style={{ fontSize: 13, color: '#007AFF', fontWeight: '600' }}>
|
||||||
|
{t('magic.copy')}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@ -248,24 +317,28 @@ export function MagicSheet({
|
|||||||
}}
|
}}
|
||||||
style={{ marginTop: 14, alignSelf: 'center' }}
|
style={{ marginTop: 14, alignSelf: 'center' }}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: 13, color: colors.textMuted }}>Code verwerfen</Text>
|
<Text style={{ fontSize: 13, color: colors.textMuted }}>
|
||||||
|
{t('magic.discard_code')}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Verbundene Ger\u00e4te */}
|
{/* Verbundene Computer + Slot-Anzeige */}
|
||||||
<SectionTitle text="Verbundene Ger\u00e4te" colors={colors} />
|
<SectionTitle
|
||||||
|
text={`${t('magic.connected_title')} \u00b7 ${desktopDevices.length}/${desktopLimit}`}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
<View style={cardStyle(colors)}>
|
<View style={cardStyle(colors)}>
|
||||||
{devices === null ? (
|
{devices === null ? (
|
||||||
<ActivityIndicator />
|
<ActivityIndicator />
|
||||||
) : devices.length === 0 ? (
|
) : desktopDevices.length === 0 ? (
|
||||||
<Text style={{ fontSize: 14, color: colors.textMuted }}>
|
<Text style={{ fontSize: 14, color: colors.textMuted }}>
|
||||||
Noch keine Macs verbunden. Sobald du einen Pairing-Code einlöst und ein iPhone
|
{t('magic.connected_empty')}
|
||||||
bindest, erscheint es hier.
|
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
devices.map((d, i) => (
|
desktopDevices.map((d, i) => (
|
||||||
<View
|
<View
|
||||||
key={d.deviceId}
|
key={d.deviceId}
|
||||||
style={{
|
style={{
|
||||||
@ -287,7 +360,15 @@ export function MagicSheet({
|
|||||||
marginRight: 12,
|
marginRight: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="laptop-outline" size={20} color={colors.text} />
|
<Ionicons
|
||||||
|
name={
|
||||||
|
d.model?.toLowerCase().includes('windows')
|
||||||
|
? 'desktop-outline'
|
||||||
|
: 'laptop-outline'
|
||||||
|
}
|
||||||
|
size={20}
|
||||||
|
color={colors.text}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={{ fontSize: 15, color: colors.text, fontWeight: '600' }}>
|
<Text style={{ fontSize: 15, color: colors.text, fontWeight: '600' }}>
|
||||||
@ -374,6 +455,50 @@ function PrimaryButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlatformOption({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
selected,
|
||||||
|
onPress,
|
||||||
|
colors,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||||
|
label: string;
|
||||||
|
selected: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
colors: ColorScheme;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: selected ? '#007AFF' : colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={18} color={selected ? '#007AFF' : colors.textMuted} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
color: selected ? '#007AFF' : colors.text,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatRemaining(seconds: number): string {
|
function formatRemaining(seconds: number): string {
|
||||||
const m = Math.floor(seconds / 60);
|
const m = Math.floor(seconds / 60);
|
||||||
const s = seconds % 60;
|
const s = seconds % 60;
|
||||||
|
|||||||
@ -110,6 +110,16 @@ export async function registerPushTokenWithBackend(): Promise<string | null> {
|
|||||||
sound: 'default',
|
sound: 'default',
|
||||||
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC,
|
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC,
|
||||||
});
|
});
|
||||||
|
// Account-Security: „Neues Gerät verbunden" — eigener Channel, damit der
|
||||||
|
// User Geräte-Alerts nicht zusammen mit Chat stummschaltet.
|
||||||
|
await Notifications.setNotificationChannelAsync('devices', {
|
||||||
|
name: 'Geräte & Sicherheit',
|
||||||
|
importance: Notifications.AndroidImportance.HIGH,
|
||||||
|
vibrationPattern: [0, 250, 250, 250],
|
||||||
|
lightColor: '#007AFF',
|
||||||
|
sound: 'default',
|
||||||
|
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Token holen
|
// 3) Token holen
|
||||||
|
|||||||
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": "Geräte",
|
||||||
"devices_desc": "Registrierte Geräte verwalten",
|
"devices_desc": "Registrierte Geräte verwalten",
|
||||||
"rebreak_magic": "Rebreak Magic",
|
"rebreak_magic": "Rebreak Magic",
|
||||||
"rebreak_magic_desc": "iPhone in 30 Sek. binden (Mac-App)",
|
"rebreak_magic_desc": "Mac & Windows-PC schützen, iPhone binden",
|
||||||
"subscription": "Abonnement",
|
"subscription": "Abonnement",
|
||||||
"subscription_desc": "Plan & Upgrade-Pfad",
|
"subscription_desc": "Plan & Upgrade-Pfad",
|
||||||
"subscription_plan_free": "Free",
|
"subscription_plan_free": "Free",
|
||||||
@ -1323,7 +1323,7 @@
|
|||||||
"devices": {
|
"devices": {
|
||||||
"section_title_this": "Dieses Gerät",
|
"section_title_this": "Dieses Gerät",
|
||||||
"section_title_others": "Weitere geschützte Geräte",
|
"section_title_others": "Weitere geschützte Geräte",
|
||||||
"subtitle_legend": "Schutz auf bis zu 3 Geräten — egal welches du benutzt.",
|
"subtitle_legend": "Lückenloser Schutz auf bis zu 5 Geräten — 3 mobil, 2 stationär.",
|
||||||
"subtitle_free": "Aktuelles Gerät geschützt.",
|
"subtitle_free": "Aktuelles Gerät geschützt.",
|
||||||
"add_mac": "Mac hinzufügen",
|
"add_mac": "Mac hinzufügen",
|
||||||
"add_windows": "Windows hinzufügen (bald)",
|
"add_windows": "Windows hinzufügen (bald)",
|
||||||
@ -1384,7 +1384,16 @@
|
|||||||
"release_cancel": "Freigabe abbrechen",
|
"release_cancel": "Freigabe abbrechen",
|
||||||
"release_cancel_confirm": "Freigabe wirklich abbrechen?",
|
"release_cancel_confirm": "Freigabe wirklich abbrechen?",
|
||||||
"release_cancel_body": "Das Gerät bleibt weiterhin an deinen Account gebunden.",
|
"release_cancel_body": "Das Gerät bleibt weiterhin an deinen Account gebunden.",
|
||||||
"release_cancel_cta": "Ja, abbrechen"
|
"release_cancel_cta": "Ja, abbrechen",
|
||||||
|
"subtitle_pro": "Schutz für dein Handy und deinen Computer — dort, wo du wirklich gefährdet bist.",
|
||||||
|
"progress_mobile": "Mobil (iOS / Android)",
|
||||||
|
"progress_desktop": "Computer (Mac / Windows)",
|
||||||
|
"detail_connection": "Verbindung",
|
||||||
|
"detail_added": "Verbunden seit",
|
||||||
|
"detail_last_seen": "Zuletzt aktiv",
|
||||||
|
"detail_coverage_label": "Schutz-Verlauf",
|
||||||
|
"detail_this_device_protected": "Tage geschützt",
|
||||||
|
"detail_before_binding": "vor der Bindung"
|
||||||
},
|
},
|
||||||
"plan": {
|
"plan": {
|
||||||
"change": {
|
"change": {
|
||||||
@ -1571,5 +1580,34 @@
|
|||||||
"body": "Das ist außergewöhnlich — und du hilfst uns, ReBreak als offizielle DiGA (Digitale Gesundheitsanwendung) zuzulassen. Dafür brauchen wir anonyme demografische Daten. Freiwillig, 2 Minuten.",
|
"body": "Das ist außergewöhnlich — und du hilfst uns, ReBreak als offizielle DiGA (Digitale Gesundheitsanwendung) zuzulassen. Dafür brauchen wir anonyme demografische Daten. Freiwillig, 2 Minuten.",
|
||||||
"cta": "Daten ausfüllen",
|
"cta": "Daten ausfüllen",
|
||||||
"later": "Vielleicht später"
|
"later": "Vielleicht später"
|
||||||
|
},
|
||||||
|
"magic": {
|
||||||
|
"tagline_mac": "iPhone binden & Mac schützen — in 30 Sekunden.",
|
||||||
|
"tagline_windows": "Glücksspiel-Schutz für deinen Windows-PC — in 2 Minuten.",
|
||||||
|
"platform_question": "Welchen Computer möchtest du schützen?",
|
||||||
|
"step1_title": "1. %{app} herunterladen",
|
||||||
|
"step1_body_mac": "Auf deinem Mac öffnen (min. macOS %{version}).",
|
||||||
|
"step1_body_windows": "Auf deinem Windows-PC öffnen (min. Windows %{version}).",
|
||||||
|
"open_download": "Download öffnen",
|
||||||
|
"send_link_mac": "Link an meinen Mac senden",
|
||||||
|
"send_link_windows": "Link an meinen PC senden",
|
||||||
|
"step2_title": "2. Pairing-Code generieren",
|
||||||
|
"limit_reached": "Desktop-Limit erreicht (%{count}/%{max}).",
|
||||||
|
"limit_hint_legend": "Entferne zuerst einen verbundenen Computer.",
|
||||||
|
"limit_hint_pro": "Entferne zuerst einen Computer — oder upgrade auf Legend für 2 Desktop-Geräte.",
|
||||||
|
"code_explainer": "Erzeuge einen 6-stelligen Code und gib ihn in der %{app} ein. Gültig 10 Minuten, nur einmal verwendbar.",
|
||||||
|
"generating": "Generiere…",
|
||||||
|
"generate_new": "Neuen Code erzeugen",
|
||||||
|
"generate": "Code erzeugen",
|
||||||
|
"enter_in_app": "In der %{app} eingeben:",
|
||||||
|
"expires_in": "Läuft ab in %{time}",
|
||||||
|
"copy": "Kopieren",
|
||||||
|
"discard_code": "Code verwerfen",
|
||||||
|
"connected_title": "Verbundene Computer",
|
||||||
|
"connected_empty": "Noch kein Computer verbunden. Sobald du einen Pairing-Code in der Mac- oder Windows-App einlöst, erscheint er hier.",
|
||||||
|
"generate_error": "Fehler beim Generieren",
|
||||||
|
"app_mac": "Mac-App",
|
||||||
|
"app_windows": "Windows-App",
|
||||||
|
"manual_fallback": "Ohne App? DNS-Profil manuell installieren"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -510,7 +510,9 @@
|
|||||||
"diga_choice": {
|
"diga_choice": {
|
||||||
"body": "Do you have a prescription code from your health insurance? Then everything's unlocked for you."
|
"body": "Do you have a prescription code from your health insurance? Then everything's unlocked for you."
|
||||||
},
|
},
|
||||||
"diga_code": { "body": "Type your code — I'll check it for you." },
|
"diga_code": {
|
||||||
|
"body": "Type your code — I'll check it for you."
|
||||||
|
},
|
||||||
"plan": {
|
"plan": {
|
||||||
"body": "To keep the protection running on your device, we need a plan — first 14 days are free. What feels right for you?"
|
"body": "To keep the protection running on your device, we need a plan — first 14 days are free. What feels right for you?"
|
||||||
},
|
},
|
||||||
@ -867,7 +869,7 @@
|
|||||||
"devices": "Devices",
|
"devices": "Devices",
|
||||||
"devices_desc": "Manage registered devices",
|
"devices_desc": "Manage registered devices",
|
||||||
"rebreak_magic": "Rebreak Magic",
|
"rebreak_magic": "Rebreak Magic",
|
||||||
"rebreak_magic_desc": "Bind iPhone in 30s (Mac app)",
|
"rebreak_magic_desc": "Protect Mac & Windows PC, bind iPhone",
|
||||||
"subscription": "Subscription",
|
"subscription": "Subscription",
|
||||||
"subscription_desc": "Plan & upgrade path",
|
"subscription_desc": "Plan & upgrade path",
|
||||||
"subscription_plan_free": "Free",
|
"subscription_plan_free": "Free",
|
||||||
@ -1321,7 +1323,7 @@
|
|||||||
"devices": {
|
"devices": {
|
||||||
"section_title_this": "This device",
|
"section_title_this": "This device",
|
||||||
"section_title_others": "Other protected devices",
|
"section_title_others": "Other protected devices",
|
||||||
"subtitle_legend": "Protection across up to 3 devices — whichever one you use.",
|
"subtitle_legend": "Seamless protection on up to 5 devices — 3 mobile, 2 desktop.",
|
||||||
"subtitle_free": "Current device protected.",
|
"subtitle_free": "Current device protected.",
|
||||||
"add_mac": "Add Mac",
|
"add_mac": "Add Mac",
|
||||||
"add_windows": "Add Windows (coming soon)",
|
"add_windows": "Add Windows (coming soon)",
|
||||||
@ -1382,7 +1384,16 @@
|
|||||||
"release_cancel": "Cancel release",
|
"release_cancel": "Cancel release",
|
||||||
"release_cancel_confirm": "Really cancel the release?",
|
"release_cancel_confirm": "Really cancel the release?",
|
||||||
"release_cancel_body": "The device will remain bound to your account.",
|
"release_cancel_body": "The device will remain bound to your account.",
|
||||||
"release_cancel_cta": "Yes, cancel"
|
"release_cancel_cta": "Yes, cancel",
|
||||||
|
"subtitle_pro": "Protection for your phone and your computer — where it really matters.",
|
||||||
|
"progress_mobile": "Mobile (iOS / Android)",
|
||||||
|
"progress_desktop": "Computer (Mac / Windows)",
|
||||||
|
"detail_connection": "Connection",
|
||||||
|
"detail_added": "Connected since",
|
||||||
|
"detail_last_seen": "Last active",
|
||||||
|
"detail_coverage_label": "Protection history",
|
||||||
|
"detail_this_device_protected": "days protected",
|
||||||
|
"detail_before_binding": "before binding"
|
||||||
},
|
},
|
||||||
"plan": {
|
"plan": {
|
||||||
"change": {
|
"change": {
|
||||||
@ -1552,5 +1563,34 @@
|
|||||||
"body": "That's extraordinary — and you help us get ReBreak officially certified as a DiGA (Digital Health Application). We need anonymous demographic data for that. Voluntary, 2 minutes.",
|
"body": "That's extraordinary — and you help us get ReBreak officially certified as a DiGA (Digital Health Application). We need anonymous demographic data for that. Voluntary, 2 minutes.",
|
||||||
"cta": "Fill in data",
|
"cta": "Fill in data",
|
||||||
"later": "Maybe later"
|
"later": "Maybe later"
|
||||||
|
},
|
||||||
|
"magic": {
|
||||||
|
"tagline_mac": "Bind your iPhone & protect your Mac — in 30 seconds.",
|
||||||
|
"tagline_windows": "Gambling protection for your Windows PC — set up in 2 minutes.",
|
||||||
|
"platform_question": "Which computer do you want to protect?",
|
||||||
|
"step1_title": "1. Download the %{app}",
|
||||||
|
"step1_body_mac": "Open on your Mac (min. macOS %{version}).",
|
||||||
|
"step1_body_windows": "Open on your Windows PC (min. Windows %{version}).",
|
||||||
|
"open_download": "Open download",
|
||||||
|
"send_link_mac": "Send link to my Mac",
|
||||||
|
"send_link_windows": "Send link to my PC",
|
||||||
|
"step2_title": "2. Generate pairing code",
|
||||||
|
"limit_reached": "Desktop limit reached (%{count}/%{max}).",
|
||||||
|
"limit_hint_legend": "Remove a connected computer first.",
|
||||||
|
"limit_hint_pro": "Remove a computer first — or upgrade to Legend for 2 desktop devices.",
|
||||||
|
"code_explainer": "Generate a 6-digit code and enter it in the %{app}. Valid for 10 minutes, single use.",
|
||||||
|
"generating": "Generating…",
|
||||||
|
"generate_new": "Generate new code",
|
||||||
|
"generate": "Generate code",
|
||||||
|
"enter_in_app": "Enter in the %{app}:",
|
||||||
|
"expires_in": "Expires in %{time}",
|
||||||
|
"copy": "Copy",
|
||||||
|
"discard_code": "Discard code",
|
||||||
|
"connected_title": "Connected computers",
|
||||||
|
"connected_empty": "No computer connected yet. As soon as you redeem a pairing code in the Mac or Windows app, it will appear here.",
|
||||||
|
"generate_error": "Failed to generate code",
|
||||||
|
"app_mac": "Mac app",
|
||||||
|
"app_windows": "Windows app",
|
||||||
|
"manual_fallback": "No app? Install the DNS profile manually"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
magicRevokedAt DateTime? @map("magic_revoked_at")
|
||||||
/// Mac-Hostname für UI (z.B. "Chahines MacBook Pro"). Nur bei Magic-Devices.
|
/// Mac-Hostname für UI (z.B. "Chahines MacBook Pro"). Nur bei Magic-Devices.
|
||||||
magicHostname String? @map("magic_hostname")
|
magicHostname String? @map("magic_hostname")
|
||||||
|
/// Server-gehaltenes Removal-Passwort für das Mac/Win-Config-Profil.
|
||||||
|
/// Wird in com.apple.profileRemovalPassword injiziert — User sieht es NIE,
|
||||||
|
/// nur nach Cooldown-Release (Offboarding). NULL → noch keins generiert.
|
||||||
|
magicRemovalPassword String? @map("magic_removal_password")
|
||||||
|
/// Wann der User die Entfernung des Magic-Profils beantragt hat.
|
||||||
|
/// Removal-Passwort wird erst nach +MAGIC_RELEASE_COOLDOWN_H sichtbar.
|
||||||
|
magicReleaseRequestedAt DateTime? @map("magic_release_requested_at")
|
||||||
|
|
||||||
@@unique([userId, deviceId])
|
@@unique([userId, deviceId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
|||||||
@ -1,89 +1,19 @@
|
|||||||
import { randomBytes } from "crypto";
|
|
||||||
import { getProfile } from "../../db/profile";
|
|
||||||
import { getPlanLimits } from "../../utils/plan-features";
|
|
||||||
import {
|
|
||||||
countActiveProtectedDevices,
|
|
||||||
createProtectedDevice,
|
|
||||||
} from "../../db/protectedDevices";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/devices/enroll
|
* POST /api/devices/enroll — DEAKTIVIERT (410 Gone)
|
||||||
*
|
*
|
||||||
* Legend-only. User klickt "Mac hinzufügen" in der App.
|
* Der manuelle Offline-Profil-Download ist abgeschaltet: Das Profil hätte das
|
||||||
* Legt ein ProtectedDevice (status=pending) an und gibt die Download-URL
|
* RemovalPassword im Klartext im heruntergeladenen .mobileconfig → Bypass-Risiko.
|
||||||
* für das mobileconfig-Profil zurück.
|
* Stationärer Schutz (Mac/Windows) läuft jetzt ausschließlich über Rebreak Magic
|
||||||
|
* (Server hält das Lock-Passwort, der User sieht es nie).
|
||||||
*
|
*
|
||||||
* Body: { platform: "mac" | "windows" | "ios" | "android", label: string }
|
* Bestehende ProtectedDevices bleiben sichtbar/entfernbar (GET + DELETE), nur
|
||||||
* Response: { deviceId, dnsToken, downloadUrl }
|
* NEUE Enrollments über diesen Pfad sind gesperrt.
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(() => {
|
||||||
const user = await requireUser(event);
|
|
||||||
|
|
||||||
const profile = await getProfile(user.id);
|
|
||||||
const limits = getPlanLimits(profile?.plan ?? "free");
|
|
||||||
|
|
||||||
// maxProtectedDevices=0 → Feature nicht verfügbar (free/pro)
|
|
||||||
if (limits.maxProtectedDevices === 0) {
|
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 410,
|
||||||
data: { error: "LEGEND_REQUIRED" },
|
message:
|
||||||
|
"Der manuelle Profil-Download ist deaktiviert. Stationäre Geräte werden über Rebreak Magic verbunden.",
|
||||||
|
data: { error: "OFFLINE_ENROLL_DISABLED" },
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const body = await readBody(event);
|
|
||||||
const platform = body?.platform as string | undefined;
|
|
||||||
const label = body?.label as string | undefined;
|
|
||||||
|
|
||||||
const VALID_PLATFORMS = ["mac", "windows", "ios", "android"];
|
|
||||||
if (!platform || !VALID_PLATFORMS.includes(platform)) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
data: { error: "INVALID_PLATFORM", validValues: VALID_PLATFORMS },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!label || typeof label !== "string" || label.trim().length === 0) {
|
|
||||||
throw createError({ statusCode: 400, data: { error: "LABEL_REQUIRED" } });
|
|
||||||
}
|
|
||||||
const trimmedLabel = label.trim().slice(0, 100);
|
|
||||||
|
|
||||||
// Limit: max. maxProtectedDevices active+pending Devices
|
|
||||||
const activeCount = await countActiveProtectedDevices(user.id);
|
|
||||||
if (activeCount >= limits.maxProtectedDevices) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 409,
|
|
||||||
data: {
|
|
||||||
error: "plan_limit",
|
|
||||||
resource: "protected_devices",
|
|
||||||
current: activeCount,
|
|
||||||
limit: limits.maxProtectedDevices,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 32-char hex token — kryptografisch sicher
|
|
||||||
const dnsToken = randomBytes(16).toString("hex");
|
|
||||||
|
|
||||||
const device = await createProtectedDevice({
|
|
||||||
userId: user.id,
|
|
||||||
dnsToken,
|
|
||||||
platform,
|
|
||||||
label: trimmedLabel,
|
|
||||||
});
|
|
||||||
|
|
||||||
const config = useRuntimeConfig(event);
|
|
||||||
const apiBase =
|
|
||||||
(config.public as any)?.apiBase ?? "https://api.rebreak.org";
|
|
||||||
|
|
||||||
// Platform-aware download URL: Windows gets .reg, everything else .mobileconfig
|
|
||||||
const profileExt = platform === "windows" ? "reg" : "mobileconfig";
|
|
||||||
const downloadUrl = `${apiBase}/api/devices/${device.id}/profile.${profileExt}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
deviceId: device.id,
|
|
||||||
dnsToken: device.dnsToken,
|
|
||||||
downloadUrl,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -76,6 +76,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
<string>com.apple.dnsSettings.managed</string>
|
<string>com.apple.dnsSettings.managed</string>
|
||||||
<key>PayloadVersion</key>
|
<key>PayloadVersion</key>
|
||||||
<integer>1</integer>
|
<integer>1</integer>
|
||||||
|
<key>ProhibitDisablement</key>
|
||||||
|
<true/>
|
||||||
<key>DNSSettings</key>
|
<key>DNSSettings</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>DNSProtocol</key>
|
<key>DNSProtocol</key>
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
* GET /api/magic/info
|
* GET /api/magic/info
|
||||||
*
|
*
|
||||||
* Public — keine Auth. Liefert Metadaten für die Native-App-Settings-Seite:
|
* Public — keine Auth. Liefert Metadaten für die Native-App-Settings-Seite:
|
||||||
* Download-URL der aktuellen DMG + Latest-Version.
|
* Download-URLs (Mac DMG + Windows Installer) + Latest-Versions.
|
||||||
*
|
*
|
||||||
* Auto-Updates passieren in der Mac-App selbst — hier nur Erstinstallation.
|
* Auto-Updates passieren in den Desktop-Apps selbst — hier nur Erstinstallation.
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(() => {
|
export default defineEventHandler(() => {
|
||||||
return {
|
return {
|
||||||
@ -14,6 +14,9 @@ export default defineEventHandler(() => {
|
|||||||
downloadUrl: "https://rebreak.org/download/rebreakmagic",
|
downloadUrl: "https://rebreak.org/download/rebreakmagic",
|
||||||
dmgUrl: "https://rebreak.org/downloads/RebreakMagic-latest.dmg",
|
dmgUrl: "https://rebreak.org/downloads/RebreakMagic-latest.dmg",
|
||||||
minMacosVersion: "13.0",
|
minMacosVersion: "13.0",
|
||||||
|
// Windows (apps/rebreak-magic-win, Tauri NSIS-Installer)
|
||||||
|
windowsInstallerUrl: "https://rebreak.org/downloads/RebreakMagic-Setup.exe",
|
||||||
|
minWindowsVersion: "10 (21H2)",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,21 +1,29 @@
|
|||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { findMagicDeviceByToken } from "../../db/devices";
|
import {
|
||||||
|
findMagicDeviceByToken,
|
||||||
|
ensureMagicRemovalPassword,
|
||||||
|
} from "../../db/devices";
|
||||||
import { MAGIC_PROFILE_TEMPLATE } from "../../utils/magic-profile-template";
|
import { MAGIC_PROFILE_TEMPLATE } from "../../utils/magic-profile-template";
|
||||||
|
import {
|
||||||
|
buildRemovalPasswordPayload,
|
||||||
|
generateRemovalPassword,
|
||||||
|
signProfileIfConfigured,
|
||||||
|
} from "../../utils/magic-lock";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/magic/profile.mobileconfig?token=<dnsToken>
|
* GET /api/magic/profile.mobileconfig?token=<dnsToken>
|
||||||
*
|
*
|
||||||
* Generiert personalisiertes DNS-Configuration-Profile für macOS.
|
* Generiert das personalisierte, GESPERRTE DNS-Config-Profil für macOS/Windows.
|
||||||
* Template: ops/mdm/rebreak-mac-dns-filter.mobileconfig (inlined als TS
|
* Template: ops/mdm/rebreak-mac-dns-filter.mobileconfig (inlined als TS const).
|
||||||
* constant via backend/server/utils/magic-profile-template.ts — überlebt
|
|
||||||
* jeden Build/Deploy ohne FS- oder serverAssets-Magic).
|
|
||||||
*
|
*
|
||||||
* Ersetzt:
|
* Hard-Lock-Payloads:
|
||||||
* - ServerURL: /dns-query → /dns-query/{token}
|
* - DNS-Filter (com.apple.dnsSettings.managed) mit ProhibitDisablement
|
||||||
* - PayloadUUID: 2× neu generieren (DNSSettings + Profile root)
|
* - PayloadRemovalDisallowed + PayloadScope=System (im Template)
|
||||||
* - PayloadIdentifier: unique pro Device
|
* - com.apple.profileRemovalPassword mit server-gehaltenem Passwort (hier
|
||||||
|
* injiziert) — der User sieht es NIE, nur nach Cooldown-Release (Offboarding).
|
||||||
*
|
*
|
||||||
* TODO: Profile-Signierung via Apple Developer Certificate (Phase 2)
|
* Signing: wenn Cert via runtimeConfig konfiguriert → CMS-signiert (grünes
|
||||||
|
* „Verifiziert"), sonst unsigniert (Lock greift trotzdem). Siehe magic-lock.ts.
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const query = getQuery(event);
|
const query = getQuery(event);
|
||||||
@ -28,7 +36,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token in DB suchen (nur aktive, nicht revoked)
|
|
||||||
const device = await findMagicDeviceByToken(token);
|
const device = await findMagicDeviceByToken(token);
|
||||||
if (!device) {
|
if (!device) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@ -37,39 +44,51 @@ export default defineEventHandler(async (event) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Template via Nitro serverAssets lesen (build-time eingebundelt → cwd-unabhängig).
|
// Removal-Passwort: aus DB oder Lazy-Backfill (Devices vor dem Hard-Lock).
|
||||||
const template = MAGIC_PROFILE_TEMPLATE;
|
let removalPassword = device.magicRemovalPassword;
|
||||||
|
if (!removalPassword) {
|
||||||
|
removalPassword = generateRemovalPassword();
|
||||||
|
await ensureMagicRemovalPassword(device.id, removalPassword);
|
||||||
|
}
|
||||||
|
|
||||||
// ServerURL ersetzen: /dns-query → /dns-query/{token}
|
const deviceSlice = device.deviceId.slice(0, 8);
|
||||||
const personalizedProfile = template
|
|
||||||
.replace(
|
// Personalisierung: ServerURL → token-spezifisch, UUIDs/Identifier unique.
|
||||||
|
let personalizedProfile = MAGIC_PROFILE_TEMPLATE.replace(
|
||||||
"https://dns.rebreak.org/dns-query",
|
"https://dns.rebreak.org/dns-query",
|
||||||
`https://dns.rebreak.org/dns-query/${token}`,
|
`https://dns.rebreak.org/dns-query/${token}`,
|
||||||
)
|
)
|
||||||
// PayloadUUID neu generieren (2 Stellen im Template)
|
|
||||||
.replace("7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0", randomUUID().toUpperCase())
|
.replace("7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0", randomUUID().toUpperCase())
|
||||||
.replace("8C3F9A2B-D4E5-4F87-9A12-B5C6D7E8F901", randomUUID().toUpperCase())
|
.replace("8C3F9A2B-D4E5-4F87-9A12-B5C6D7E8F901", randomUUID().toUpperCase())
|
||||||
// PayloadIdentifier unique machen (optional, verhindert Konflikt bei Multi-Device)
|
|
||||||
.replace(
|
.replace(
|
||||||
"org.rebreak.protection.dns.filter",
|
"org.rebreak.protection.dns.filter",
|
||||||
`org.rebreak.protection.dns.filter.${device.deviceId.slice(0, 8)}`,
|
`org.rebreak.protection.dns.filter.${deviceSlice}`,
|
||||||
)
|
)
|
||||||
.replace(
|
.replace(
|
||||||
"org.rebreak.protection.profile",
|
"org.rebreak.protection.profile",
|
||||||
`org.rebreak.protection.profile.${device.deviceId.slice(0, 8)}`,
|
`org.rebreak.protection.profile.${deviceSlice}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Removal-Passwort-Payload in die PayloadContent-Array injizieren.
|
||||||
|
const removalPayload = buildRemovalPasswordPayload(removalPassword, deviceSlice);
|
||||||
|
personalizedProfile = personalizedProfile.replace(
|
||||||
|
" </array>",
|
||||||
|
`${removalPayload}\n </array>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Optional signieren (config-gated; inaktiv ohne Cert).
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
const signed = signProfileIfConfigured(
|
||||||
|
personalizedProfile,
|
||||||
|
(config as any).magicSigning,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Response-Headers
|
|
||||||
setHeader(event, "Content-Type", "application/x-apple-aspen-config");
|
setHeader(event, "Content-Type", "application/x-apple-aspen-config");
|
||||||
setHeader(
|
setHeader(
|
||||||
event,
|
event,
|
||||||
"Content-Disposition",
|
"Content-Disposition",
|
||||||
`attachment; filename="RebreakMagic-${device.deviceId.slice(0, 8)}.mobileconfig"`,
|
`attachment; filename="RebreakMagic-${deviceSlice}.mobileconfig"`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: Profile-Signierung via /usr/bin/security cms -S
|
return signed;
|
||||||
// Requires: Apple Developer Certificate + Private Key in Keychain
|
|
||||||
// Siehe: https://developer.apple.com/documentation/devicemanagement/profile-specific_payload_keys
|
|
||||||
|
|
||||||
return personalizedProfile;
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
import { countActiveMagicBindings, listMagicDevices, MAGIC_DEVICE_LIMIT } from "../../db/devices";
|
import { countActiveMagicBindings, listMagicDevices } from "../../db/devices";
|
||||||
|
import { countActiveProtectedDevices } from "../../db/protectedDevices";
|
||||||
|
import { getProfile } from "../../db/profile";
|
||||||
|
import { getPlanLimits } from "../../utils/plan-features";
|
||||||
import { requireUser } from "../../utils/auth";
|
import { requireUser } from "../../utils/auth";
|
||||||
import { createAdGuardClient } from "../../utils/adguard";
|
import { createAdGuardClient } from "../../utils/adguard";
|
||||||
|
import { sendDeviceAddedPush } from "../../services/push";
|
||||||
|
import { generateRemovalPassword } from "../../utils/magic-lock";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/magic/register
|
* POST /api/magic/register
|
||||||
@ -17,11 +22,12 @@ import { createAdGuardClient } from "../../utils/adguard";
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const user = await requireUser(event);
|
const user = await requireUser(event);
|
||||||
const body = await readBody(event);
|
const body = await readBody(event);
|
||||||
const { deviceId, hostname, model, osVersion } = body as {
|
const { deviceId, hostname, model, osVersion, platform } = body as {
|
||||||
deviceId?: string;
|
deviceId?: string;
|
||||||
hostname?: string;
|
hostname?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
osVersion?: string;
|
osVersion?: string;
|
||||||
|
platform?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!deviceId || !hostname) {
|
if (!deviceId || !hostname) {
|
||||||
@ -31,6 +37,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plattform: Mac-App sendet nichts (legacy default), Windows-App sendet "windows"
|
||||||
|
const devicePlatform =
|
||||||
|
platform === "windows" ? "windows" : "macos";
|
||||||
|
|
||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
|
|
||||||
// 1. Prüfe ob Device bereits als Magic-Client gebunden ist (idempotent)
|
// 1. Prüfe ob Device bereits als Magic-Client gebunden ist (idempotent)
|
||||||
@ -42,6 +52,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
magicDnsToken: true,
|
magicDnsToken: true,
|
||||||
magicEnrolledAt: true,
|
magicEnrolledAt: true,
|
||||||
magicRevokedAt: true,
|
magicRevokedAt: true,
|
||||||
|
magicRemovalPassword: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -62,16 +73,28 @@ export default defineEventHandler(async (event) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Limit-Check (nur wenn kein vorheriges Binding existiert)
|
// 2. Plan-gated Desktop-Slot-Check (nur wenn kein vorheriges Binding existiert).
|
||||||
|
// Pro: 1 stationäres Gerät (Mac ODER Windows), Legend: 2 (§ Geräte-Matrix).
|
||||||
|
// Grandfathering-Pattern wie bei Custom-Domains: bestehende Bindings bleiben
|
||||||
|
// nach Downgrade aktiv, nur NEUE Registrierungen werden hier geblockt.
|
||||||
if (!existing || !existing.magicEnrolledAt) {
|
if (!existing || !existing.magicEnrolledAt) {
|
||||||
const activeCount = await countActiveMagicBindings(user.id);
|
const profile = await getProfile(user.id);
|
||||||
if (activeCount >= MAGIC_DEVICE_LIMIT) {
|
const desktopLimit = getPlanLimits(profile?.plan ?? "pro").maxProtectedDevices;
|
||||||
|
// Cross-Counting: Magic-Bindings + legacy ProtectedDevices (manueller
|
||||||
|
// Profil-Download) teilen sich denselben Desktop-Slot-Pool.
|
||||||
|
const [magicCount, protectedCount] = await Promise.all([
|
||||||
|
countActiveMagicBindings(user.id),
|
||||||
|
countActiveProtectedDevices(user.id),
|
||||||
|
]);
|
||||||
|
const activeCount = magicCount + protectedCount;
|
||||||
|
if (activeCount >= desktopLimit) {
|
||||||
const activeBindings = await listMagicDevices(user.id);
|
const activeBindings = await listMagicDevices(user.id);
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
message: `Magic-Device-Limit erreicht (max ${MAGIC_DEVICE_LIMIT})`,
|
message: `Geräte-Limit erreicht (max. ${desktopLimit} Computer in deinem Plan).`,
|
||||||
data: {
|
data: {
|
||||||
code: "limit_reached",
|
code: "limit_reached",
|
||||||
|
limit: desktopLimit,
|
||||||
activeBindings,
|
activeBindings,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -83,6 +106,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
// verbietet `_` (das base64url als Ersatz für `/` generiert) → 400 "bad hostname label rune".
|
// verbietet `_` (das base64url als Ersatz für `/` generiert) → 400 "bad hostname label rune".
|
||||||
const dnsToken = randomBytes(32).toString("hex");
|
const dnsToken = randomBytes(32).toString("hex");
|
||||||
|
|
||||||
|
// Hard-Lock: server-gehaltenes Removal-Passwort. Stabil über Re-Registrierungen
|
||||||
|
// (sonst würde ein laufender Offboarding-Cooldown sein PW wechseln).
|
||||||
|
const removalPassword =
|
||||||
|
existing?.magicRemovalPassword ?? generateRemovalPassword();
|
||||||
|
|
||||||
// 4. Provisioniere AdGuard Client
|
// 4. Provisioniere AdGuard Client
|
||||||
const adguardClientName = `magic_${deviceId}`;
|
const adguardClientName = `magic_${deviceId}`;
|
||||||
try {
|
try {
|
||||||
@ -101,19 +129,20 @@ export default defineEventHandler(async (event) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Upsert UserDevice (platform="macos")
|
// 5. Upsert UserDevice (platform="macos" | "windows")
|
||||||
const device = await db.userDevice.upsert({
|
const device = await db.userDevice.upsert({
|
||||||
where: { userId_deviceId: { userId: user.id, deviceId } },
|
where: { userId_deviceId: { userId: user.id, deviceId } },
|
||||||
create: {
|
create: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
deviceId,
|
deviceId,
|
||||||
platform: "macos",
|
platform: devicePlatform,
|
||||||
model: model ?? null,
|
model: model ?? null,
|
||||||
name: hostname,
|
name: hostname,
|
||||||
osVersion: osVersion ?? null,
|
osVersion: osVersion ?? null,
|
||||||
magicDnsToken: dnsToken,
|
magicDnsToken: dnsToken,
|
||||||
magicEnrolledAt: new Date(),
|
magicEnrolledAt: new Date(),
|
||||||
magicHostname: hostname,
|
magicHostname: hostname,
|
||||||
|
magicRemovalPassword: removalPassword,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
magicDnsToken: dnsToken,
|
magicDnsToken: dnsToken,
|
||||||
@ -123,6 +152,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
model: model ?? undefined,
|
model: model ?? undefined,
|
||||||
osVersion: osVersion ?? undefined,
|
osVersion: osVersion ?? undefined,
|
||||||
lastSeenAt: new Date(),
|
lastSeenAt: new Date(),
|
||||||
|
magicRemovalPassword: removalPassword,
|
||||||
|
magicReleaseRequestedAt: null, // Re-Bind bricht laufenden Release ab
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
deviceId: true,
|
deviceId: true,
|
||||||
@ -130,6 +161,15 @@ export default defineEventHandler(async (event) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Account-Security-Push „Neues Gerät verbunden" — nur bei NEUER Bindung
|
||||||
|
// (idempotente Re-Registrierungen oben returnen vorher mit existing:true).
|
||||||
|
// Fire-and-forget: blockt die Response nicht.
|
||||||
|
void sendDeviceAddedPush({
|
||||||
|
userId: user.id,
|
||||||
|
deviceLabel: hostname,
|
||||||
|
platform: devicePlatform,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
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 { usePrisma } from "../../utils/prisma";
|
||||||
import { runDowngradeReconciliation } from "../../utils/downgrade-reconciliation";
|
import { runDowngradeReconciliation } from "../../utils/downgrade-reconciliation";
|
||||||
import type { Plan } from "../../utils/plan-features";
|
import type { Plan } from "../../utils/plan-features";
|
||||||
|
import { listMagicRemovalCredentials } from "../../db/devices";
|
||||||
|
import { sendMagicRemovalEmail } from "../../utils/magic-removal-email";
|
||||||
|
import { serverSupabaseServiceRole } from "../../utils/useSupabase";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/stripe/webhook
|
* POST /api/stripe/webhook
|
||||||
@ -107,7 +110,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const profile = await db.profile.findFirst({
|
const profile = await db.profile.findFirst({
|
||||||
where: { stripeCustomerId: customerId },
|
where: { stripeCustomerId: customerId },
|
||||||
select: { id: true, plan: true },
|
select: { id: true, plan: true, nickname: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (profile) {
|
if (profile) {
|
||||||
@ -120,6 +123,30 @@ export default defineEventHandler(async (event) => {
|
|||||||
await runDowngradeReconciliation(profile.id, fromPlan, "free").catch(
|
await runDowngradeReconciliation(profile.id, fromPlan, "free").catch(
|
||||||
(err) => console.error("[stripe-webhook] reconciliation error:", err),
|
(err) => console.error("[stripe-webhook] reconciliation error:", err),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Magic Hard-Lock Reveal bei Kündigung: Removal-Passwörter per Mail,
|
||||||
|
// damit der User die gesperrten Mac/Windows-Profile entfernen kann.
|
||||||
|
try {
|
||||||
|
const creds = await listMagicRemovalCredentials(profile.id);
|
||||||
|
if (creds.length > 0) {
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
const resendApiKey = (config as any).resendApiKey as string | undefined;
|
||||||
|
const supabase = serverSupabaseServiceRole(event);
|
||||||
|
const { data } = await supabase.auth.admin.getUserById(profile.id);
|
||||||
|
const email = data?.user?.email;
|
||||||
|
if (email && resendApiKey) {
|
||||||
|
await sendMagicRemovalEmail({
|
||||||
|
recipientEmail: email,
|
||||||
|
recipientNickname: profile.nickname,
|
||||||
|
credentials: creds,
|
||||||
|
reason: "cancellation",
|
||||||
|
resendApiKey,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[stripe-webhook] magic removal reveal failed:", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,8 @@ import {
|
|||||||
} from "../../db/mail";
|
} from "../../db/mail";
|
||||||
import { writeConsentRevoke } from "../../db/consent";
|
import { writeConsentRevoke } from "../../db/consent";
|
||||||
import { usePrisma } from "../../utils/prisma";
|
import { usePrisma } from "../../utils/prisma";
|
||||||
|
import { listMagicRemovalCredentials } from "../../db/devices";
|
||||||
|
import { sendMagicRemovalEmail } from "../../utils/magic-removal-email";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const user = await requireUser(event);
|
const user = await requireUser(event);
|
||||||
@ -46,6 +48,37 @@ export default defineEventHandler(async (event) => {
|
|||||||
// Tracking: consent-gap-plan.md TODO #2
|
// Tracking: consent-gap-plan.md TODO #2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Magic Hard-Lock Reveal: BEVOR die Geräte gelöscht werden, dem User die
|
||||||
|
// Removal-Passwörter geben (Mail + Response), damit er die gesperrten
|
||||||
|
// Mac/Windows-Profile entfernen kann. Im Normalbetrieb bleibt das PW geheim.
|
||||||
|
let magicRemovalCredentials: Awaited<
|
||||||
|
ReturnType<typeof listMagicRemovalCredentials>
|
||||||
|
> = [];
|
||||||
|
try {
|
||||||
|
magicRemovalCredentials = await listMagicRemovalCredentials(userId);
|
||||||
|
if (magicRemovalCredentials.length > 0) {
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
const resendApiKey = (config as any).resendApiKey as string | undefined;
|
||||||
|
const { data: authData } = await supabase.auth.admin.getUserById(userId);
|
||||||
|
const email = authData?.user?.email;
|
||||||
|
if (email && resendApiKey) {
|
||||||
|
const p = await db.profile.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { nickname: true },
|
||||||
|
});
|
||||||
|
await sendMagicRemovalEmail({
|
||||||
|
recipientEmail: email,
|
||||||
|
recipientNickname: p?.nickname ?? null,
|
||||||
|
credentials: magicRemovalCredentials,
|
||||||
|
reason: "account_deletion",
|
||||||
|
resendApiKey,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[user-delete] magic removal reveal failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
// Delete all user data (DSGVO Art. 17)
|
// Delete all user data (DSGVO Art. 17)
|
||||||
// Reihenfolge: Samples VOR Connections löschen (oder parallel — FK-Reihenfolge
|
// Reihenfolge: Samples VOR Connections löschen (oder parallel — FK-Reihenfolge
|
||||||
// egal weil wir nach userId filtern). Samples haben keine userId-FK-Cascade
|
// egal weil wir nach userId filtern). Samples haben keine userId-FK-Cascade
|
||||||
@ -68,5 +101,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
// Auth-User löschen (bleibt Supabase)
|
// Auth-User löschen (bleibt Supabase)
|
||||||
await supabase.auth.admin.deleteUser(userId);
|
await supabase.auth.admin.deleteUser(userId);
|
||||||
|
|
||||||
return { success: true };
|
// Removal-Passwörter im Response mitgeben (In-App-Reveal vor Logout).
|
||||||
|
return { success: true, magicRemovalCredentials };
|
||||||
});
|
});
|
||||||
|
|||||||
@ -413,8 +413,8 @@ export async function deleteUserDevice(
|
|||||||
// RebreakMagic DNS-Device-Binding
|
// RebreakMagic DNS-Device-Binding
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Hard-Limit für Magic-Bindings pro User (5 für Legend-Plan / Staging-Testing). */
|
// Magic-Binding-Limit ist plan-gated (plan-features.maxProtectedDevices:
|
||||||
export const MAGIC_DEVICE_LIMIT = 5;
|
// Pro 1 / Legend 2) — geprüft in api/magic/register.post.ts.
|
||||||
|
|
||||||
export interface MagicDeviceRecord {
|
export interface MagicDeviceRecord {
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
@ -481,7 +481,13 @@ export async function countActiveMagicBindings(
|
|||||||
*/
|
*/
|
||||||
export async function findMagicDeviceByToken(
|
export async function findMagicDeviceByToken(
|
||||||
token: string,
|
token: string,
|
||||||
): Promise<(DeviceRecord & { magicDnsToken: string }) | null> {
|
): Promise<
|
||||||
|
| (DeviceRecord & {
|
||||||
|
magicDnsToken: string;
|
||||||
|
magicRemovalPassword: string | null;
|
||||||
|
})
|
||||||
|
| null
|
||||||
|
> {
|
||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
const device = await db.userDevice.findUnique({
|
const device = await db.userDevice.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@ -493,6 +499,7 @@ export async function findMagicDeviceByToken(
|
|||||||
magicEnrolledAt: true,
|
magicEnrolledAt: true,
|
||||||
magicRevokedAt: true,
|
magicRevokedAt: true,
|
||||||
magicHostname: true,
|
magicHostname: true,
|
||||||
|
magicRemovalPassword: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -504,3 +511,53 @@ export async function findMagicDeviceByToken(
|
|||||||
magicDnsToken: device.magicDnsToken!,
|
magicDnsToken: device.magicDnsToken!,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt ein Removal-Passwort nachträglich (Lazy-Backfill für Magic-Devices,
|
||||||
|
* die vor dem Hard-Lock gebunden wurden). Idempotent über das WHERE-Guard.
|
||||||
|
*/
|
||||||
|
export async function ensureMagicRemovalPassword(
|
||||||
|
rowId: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const db = usePrisma();
|
||||||
|
await db.userDevice.updateMany({
|
||||||
|
where: { id: rowId, magicRemovalPassword: null },
|
||||||
|
data: { magicRemovalPassword: password },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MagicRemovalCredential {
|
||||||
|
hostname: string | null;
|
||||||
|
model: string | null;
|
||||||
|
removalPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removal-Credentials aller aktiven Magic-Devices eines Users — NUR für den
|
||||||
|
* Reveal bei Kündigung/Account-Löschung (Offboarding). Liefert das server-
|
||||||
|
* gehaltene Passwort, damit der User die gesperrten Profile entfernen kann.
|
||||||
|
*/
|
||||||
|
export async function listMagicRemovalCredentials(
|
||||||
|
userId: string,
|
||||||
|
): Promise<MagicRemovalCredential[]> {
|
||||||
|
const db = usePrisma();
|
||||||
|
const devices = await db.userDevice.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
magicEnrolledAt: { not: null },
|
||||||
|
magicRevokedAt: null,
|
||||||
|
magicRemovalPassword: { not: null },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
magicHostname: true,
|
||||||
|
model: true,
|
||||||
|
magicRemovalPassword: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return devices.map((d) => ({
|
||||||
|
hostname: d.magicHostname,
|
||||||
|
model: d.model,
|
||||||
|
removalPassword: d.magicRemovalPassword!,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@ -128,6 +128,98 @@ export function truncatePreview(text: string, max = 100): string {
|
|||||||
return text.slice(0, max - 1) + "…";
|
return text.slice(0, max - 1) + "…";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeviceAddedPushPayload {
|
||||||
|
/** Account-Owner — bekommt den Push auf seine mobilen Geräte */
|
||||||
|
userId: string;
|
||||||
|
/** Anzeigename des neu verbundenen Geräts (Hostname/Label) */
|
||||||
|
deviceLabel: string;
|
||||||
|
/** "macos" | "windows" | … — für den Plattform-Hinweis */
|
||||||
|
platform: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function devicePlatformLabel(platform: string): string {
|
||||||
|
const p = platform.toLowerCase();
|
||||||
|
if (p.startsWith("mac")) return "Mac";
|
||||||
|
if (p.startsWith("win")) return "Windows";
|
||||||
|
if (p.startsWith("ios")) return "iPhone";
|
||||||
|
if (p.startsWith("android")) return "Android";
|
||||||
|
return "Gerät";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push bei neuer Geräte-Bindung („Neues Gerät verbunden").
|
||||||
|
*
|
||||||
|
* Account-Security-Signal: geht an ALLE aktiven Push-Tokens des Users (nur
|
||||||
|
* mobile Geräte haben welche — Mac/Windows-Magic registriert keine Tokens).
|
||||||
|
* Bewusst NICHT durch `chatPushEnabled` gegated — das ist eine sicherheits-
|
||||||
|
* relevante Account-Benachrichtigung, kein Social-Push.
|
||||||
|
*
|
||||||
|
* Fire-and-forget wie die übrigen Push-Pfade.
|
||||||
|
*/
|
||||||
|
export async function sendDeviceAddedPush(
|
||||||
|
payload: DeviceAddedPushPayload,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = usePrisma();
|
||||||
|
|
||||||
|
const profile = await db.profile.findUnique({
|
||||||
|
where: { id: payload.userId },
|
||||||
|
select: { deletedAt: true },
|
||||||
|
});
|
||||||
|
if (!profile || profile.deletedAt) return;
|
||||||
|
|
||||||
|
const tokens = await db.pushToken.findMany({
|
||||||
|
where: { userId: payload.userId, enabled: true },
|
||||||
|
select: { id: true, token: true },
|
||||||
|
});
|
||||||
|
if (tokens.length === 0) return;
|
||||||
|
|
||||||
|
const platformLabel = devicePlatformLabel(payload.platform);
|
||||||
|
const messages: ExpoPushMessage[] = [];
|
||||||
|
const validTokenIds: string[] = [];
|
||||||
|
|
||||||
|
for (const t of tokens) {
|
||||||
|
if (!Expo.isExpoPushToken(t.token)) {
|
||||||
|
await db.pushToken
|
||||||
|
.update({ where: { id: t.id }, data: { enabled: false } })
|
||||||
|
.catch(() => {});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
messages.push({
|
||||||
|
to: t.token,
|
||||||
|
sound: "default",
|
||||||
|
title: "Neues Gerät verbunden",
|
||||||
|
body: `${payload.deviceLabel} (${platformLabel}) ist jetzt geschützt.`,
|
||||||
|
data: { type: "device_added", platform: payload.platform },
|
||||||
|
// Dedizierter Channel (HIGH) — siehe usePushTokenRegistration.ts.
|
||||||
|
// Fällt vor App-Re-Registrierung graceful auf Default-Channel zurück.
|
||||||
|
channelId: "devices",
|
||||||
|
});
|
||||||
|
validTokenIds.push(t.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
|
||||||
|
const chunks = expo.chunkPushNotifications(messages);
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
try {
|
||||||
|
await expo.sendPushNotificationsAsync(chunk);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[push] device-added chunk send failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.pushToken
|
||||||
|
.updateMany({
|
||||||
|
where: { id: { in: validTokenIds } },
|
||||||
|
data: { lastUsedAt: new Date() },
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[push] sendDeviceAddedPush failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface CallRingPushPayload {
|
export interface CallRingPushPayload {
|
||||||
/** Empfänger (Callee) — bekommt den Push */
|
/** Empfänger (Callee) — bekommt den Push */
|
||||||
receiverId: string;
|
receiverId: string;
|
||||||
|
|||||||
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>
|
<string>7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0</string>
|
||||||
<key>PayloadVersion</key>
|
<key>PayloadVersion</key>
|
||||||
<integer>1</integer>
|
<integer>1</integer>
|
||||||
|
<key>ProhibitDisablement</key>
|
||||||
|
<true/>
|
||||||
<key>DNSSettings</key>
|
<key>DNSSettings</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>DNSProtocol</key>
|
<key>DNSProtocol</key>
|
||||||
|
|||||||
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;
|
maxAppDevices: number;
|
||||||
/**
|
/**
|
||||||
* Max. zusätzliche Geräte (Mac/Windows) die per DNS-Profil geschützt werden.
|
* Max. stationäre Geräte (Mac/Windows) die per DNS geschützt werden.
|
||||||
* Bezieht sich auf ProtectedDevice (Legend-only Feature).
|
* Gilt für Magic-Bindings (UserDevice.magicDnsToken — Magic-Mac/Win-App)
|
||||||
* 0 = Feature nicht verfügbar.
|
* sowie legacy ProtectedDevice. Pro: 1 (Hauptrisiko-Gerät am Desktop),
|
||||||
|
* Legend: 2 ("lückenlos auf 5 Geräten" — 3 mobil + 2 stationär).
|
||||||
*/
|
*/
|
||||||
maxProtectedDevices: number;
|
maxProtectedDevices: number;
|
||||||
|
|
||||||
@ -89,7 +90,7 @@ export const PLAN_LIMITS: Record<Exclude<Plan, "free">, PlanLimits> = {
|
|||||||
canCreateGroup: false,
|
canCreateGroup: false,
|
||||||
canAddToBlocklist: false,
|
canAddToBlocklist: false,
|
||||||
maxAppDevices: 1,
|
maxAppDevices: 1,
|
||||||
maxProtectedDevices: 0,
|
maxProtectedDevices: 1, // 1 Desktop (Mac ODER Windows) — Hauptrisiko-Gerät schützen
|
||||||
aiModel: "llama-3.3-70b-versatile",
|
aiModel: "llama-3.3-70b-versatile",
|
||||||
aiModelFallbacks: [
|
aiModelFallbacks: [
|
||||||
{ provider: "groq", model: "llama-3.1-8b-instant" },
|
{ provider: "groq", model: "llama-3.1-8b-instant" },
|
||||||
|
|||||||
@ -17,6 +17,8 @@
|
|||||||
<string>7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0</string>
|
<string>7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0</string>
|
||||||
<key>PayloadVersion</key>
|
<key>PayloadVersion</key>
|
||||||
<integer>1</integer>
|
<integer>1</integer>
|
||||||
|
<key>ProhibitDisablement</key>
|
||||||
|
<true/>
|
||||||
<key>DNSSettings</key>
|
<key>DNSSettings</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>DNSProtocol</key>
|
<key>DNSProtocol</key>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user