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>
507 lines
15 KiB
TypeScript
507 lines
15 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
Linking,
|
|
Pressable,
|
|
Share,
|
|
Text,
|
|
TouchableOpacity,
|
|
View,
|
|
} from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import * as Clipboard from 'expo-clipboard';
|
|
import { useTranslation } from 'react-i18next';
|
|
import type { ColorScheme } from '../../lib/theme';
|
|
import { apiFetch } from '../../lib/api';
|
|
import { useUserPlan } from '../../hooks/useUserPlan';
|
|
import { FormSheet } from '../FormSheet';
|
|
|
|
type PairResponse = {
|
|
code: string;
|
|
expiresAt: string;
|
|
expiresInSeconds: number;
|
|
};
|
|
|
|
type MagicDevice = {
|
|
deviceId: string;
|
|
hostname: string;
|
|
model: string | null;
|
|
osVersion: string | null;
|
|
magicEnrolledAt: string;
|
|
/** "magic" = Desktop-Binding (Mac/Win), "locked" = Mobile, "protected" = Legacy-DNS */
|
|
source?: 'magic' | 'locked' | 'protected';
|
|
};
|
|
|
|
type MagicInfo = {
|
|
latestVersion: string;
|
|
downloadUrl: string;
|
|
dmgUrl: string;
|
|
minMacosVersion: string;
|
|
windowsInstallerUrl?: string;
|
|
minWindowsVersion?: string;
|
|
};
|
|
|
|
type DesktopPlatform = 'mac' | 'windows';
|
|
|
|
/**
|
|
* MagicSheet — Rebreak-Magic-Pairing-Flow als geteiltes FormSheet
|
|
* (Bottom-Sheet mit Titel-Header). Wird aus settings.tsx getriggert.
|
|
*/
|
|
export function MagicSheet({
|
|
visible,
|
|
onClose,
|
|
colors,
|
|
initialPlatform = 'mac',
|
|
}: {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
colors: ColorScheme;
|
|
/** Vorauswahl wenn aus dem "Gerät hinzufügen"-Menü (Mac/Windows) geöffnet */
|
|
initialPlatform?: DesktopPlatform;
|
|
}) {
|
|
const [info, setInfo] = useState<MagicInfo | null>(null);
|
|
const [platform, setPlatform] = useState<DesktopPlatform>(initialPlatform);
|
|
|
|
// Bei jedem Öffnen auf die gewünschte Plattform zurücksetzen
|
|
useEffect(() => {
|
|
if (visible) setPlatform(initialPlatform);
|
|
}, [visible, initialPlatform]);
|
|
const [pair, setPair] = useState<PairResponse | null>(null);
|
|
const [pairLoading, setPairLoading] = useState(false);
|
|
const [pairError, setPairError] = useState<string | null>(null);
|
|
const [now, setNow] = useState(Date.now());
|
|
const [devices, setDevices] = useState<MagicDevice[] | null>(null);
|
|
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const { t } = useTranslation();
|
|
const { plan } = useUserPlan();
|
|
// Desktop-Slots (Mac/Windows) — Mirror von backend plan-features.maxProtectedDevices
|
|
const desktopLimit = plan === 'legend' ? 2 : 1;
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
const i = await apiFetch<MagicInfo>('/api/magic/info');
|
|
setInfo(i);
|
|
} catch {
|
|
setInfo({
|
|
latestVersion: '0.1.0',
|
|
downloadUrl: 'https://staging.rebreak.org/download/rebreakmagic',
|
|
dmgUrl: 'https://staging.rebreak.org/downloads/RebreakMagic-latest.dmg',
|
|
minMacosVersion: '13.0',
|
|
windowsInstallerUrl: 'https://staging.rebreak.org/downloads/RebreakMagic-Setup.exe',
|
|
minWindowsVersion: '10 (21H2)',
|
|
});
|
|
}
|
|
loadDevices();
|
|
})();
|
|
return () => {
|
|
if (tickRef.current) clearInterval(tickRef.current);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (pair) {
|
|
if (tickRef.current) clearInterval(tickRef.current);
|
|
tickRef.current = setInterval(() => setNow(Date.now()), 1000);
|
|
return () => {
|
|
if (tickRef.current) clearInterval(tickRef.current);
|
|
};
|
|
}
|
|
}, [pair]);
|
|
|
|
async function loadDevices() {
|
|
try {
|
|
const d = await apiFetch<MagicDevice[]>('/api/magic/devices');
|
|
setDevices(d);
|
|
} catch {
|
|
setDevices([]);
|
|
}
|
|
}
|
|
|
|
async function handleGenerateCode() {
|
|
setPairLoading(true);
|
|
setPairError(null);
|
|
try {
|
|
const res = await apiFetch<PairResponse>('/api/magic/pair/create', {
|
|
method: 'POST',
|
|
body: {},
|
|
});
|
|
setPair(res);
|
|
setNow(Date.now());
|
|
} catch (e: any) {
|
|
setPairError(e?.message ?? t('magic.generate_error'));
|
|
} finally {
|
|
setPairLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleCopyCode() {
|
|
if (!pair) return;
|
|
await Clipboard.setStringAsync(pair.code);
|
|
}
|
|
|
|
const remaining = useMemo(() => {
|
|
if (!pair) return 0;
|
|
const exp = new Date(pair.expiresAt).getTime();
|
|
return Math.max(0, Math.floor((exp - now) / 1000));
|
|
}, [pair, now]);
|
|
|
|
const codeExpired = pair !== null && remaining <= 0;
|
|
|
|
// Desktop-Geräte (Mac/Windows) — "locked" sind Mobile-Devices aus dem
|
|
// merged /api/magic/devices-Endpoint und zählen NICHT auf Desktop-Slots.
|
|
const desktopDevices = useMemo(
|
|
() => (devices ?? []).filter((d) => d.source !== 'locked'),
|
|
[devices],
|
|
);
|
|
const limitReached = desktopDevices.length >= desktopLimit;
|
|
|
|
const isMac = platform === 'mac';
|
|
const appName = t(isMac ? 'magic.app_mac' : 'magic.app_windows');
|
|
const downloadHref = isMac
|
|
? info?.downloadUrl
|
|
: (info?.windowsInstallerUrl ?? info?.downloadUrl);
|
|
|
|
return (
|
|
<FormSheet
|
|
visible={visible}
|
|
onClose={onClose}
|
|
title="Rebreak Magic"
|
|
growWithKeyboard={false}
|
|
>
|
|
<View style={{ paddingHorizontal: 20, paddingTop: 4, paddingBottom: 24 }}>
|
|
{/* Sub-Header (Tagline) */}
|
|
<Text style={{ fontSize: 13, color: colors.textMuted, marginBottom: 16, marginLeft: 4 }}>
|
|
{t(isMac ? 'magic.tagline_mac' : 'magic.tagline_windows')}
|
|
</Text>
|
|
|
|
{/* Plattform-Wahl */}
|
|
<SectionTitle text={t('magic.platform_question')} colors={colors} />
|
|
<View style={{ flexDirection: 'row', gap: 10, marginBottom: 16 }}>
|
|
<PlatformOption
|
|
icon="laptop-outline"
|
|
label="Mac"
|
|
selected={isMac}
|
|
onPress={() => setPlatform('mac')}
|
|
colors={colors}
|
|
/>
|
|
<PlatformOption
|
|
icon="desktop-outline"
|
|
label="Windows"
|
|
selected={!isMac}
|
|
onPress={() => setPlatform('windows')}
|
|
colors={colors}
|
|
/>
|
|
</View>
|
|
|
|
{/* Step 1 — Download */}
|
|
<SectionTitle text={t('magic.step1_title', { app: appName })} colors={colors} />
|
|
<View style={cardStyle(colors)}>
|
|
<Text style={{ fontSize: 14, color: colors.text, marginBottom: 12 }}>
|
|
{isMac
|
|
? t('magic.step1_body_mac', { version: info?.minMacosVersion ?? '13.0' })
|
|
: t('magic.step1_body_windows', { version: info?.minWindowsVersion ?? '10 (21H2)' })}
|
|
</Text>
|
|
<PrimaryButton
|
|
icon="cloud-download-outline"
|
|
label={t('magic.open_download')}
|
|
onPress={() => downloadHref && Linking.openURL(downloadHref)}
|
|
/>
|
|
<TouchableOpacity
|
|
onPress={() => downloadHref && Share.share({ message: downloadHref })}
|
|
style={{ marginTop: 10, alignSelf: 'flex-start' }}
|
|
>
|
|
<Text style={{ fontSize: 13, color: '#007AFF' }}>
|
|
{t(isMac ? 'magic.send_link_mac' : 'magic.send_link_windows')}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Step 2 — Pairing-Code */}
|
|
<SectionTitle text={t('magic.step2_title')} colors={colors} />
|
|
<View style={cardStyle(colors)}>
|
|
{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 }}>
|
|
{t('magic.code_explainer', { app: appName })}
|
|
</Text>
|
|
<PrimaryButton
|
|
icon="key-outline"
|
|
label={
|
|
pairLoading
|
|
? t('magic.generating')
|
|
: codeExpired
|
|
? t('magic.generate_new')
|
|
: t('magic.generate')
|
|
}
|
|
onPress={handleGenerateCode}
|
|
loading={pairLoading}
|
|
/>
|
|
{pairError && (
|
|
<Text style={{ marginTop: 10, color: colors.error, fontSize: 13 }}>{pairError}</Text>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Text
|
|
style={{
|
|
fontSize: 13,
|
|
color: colors.textMuted,
|
|
textAlign: 'center',
|
|
marginBottom: 12,
|
|
}}
|
|
>
|
|
{t('magic.enter_in_app', { app: appName })}
|
|
</Text>
|
|
<Pressable
|
|
onPress={handleCopyCode}
|
|
style={{
|
|
flexDirection: 'row',
|
|
justifyContent: 'center',
|
|
gap: 8,
|
|
paddingVertical: 18,
|
|
borderRadius: 14,
|
|
backgroundColor: colors.groupedBg,
|
|
marginBottom: 12,
|
|
}}
|
|
>
|
|
{pair.code.split('').map((d, i) => (
|
|
<View
|
|
key={i}
|
|
style={{
|
|
width: 38,
|
|
height: 52,
|
|
borderRadius: 8,
|
|
backgroundColor: colors.card,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
<Text style={{ fontSize: 28, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
|
{d}
|
|
</Text>
|
|
</View>
|
|
))}
|
|
</Pressable>
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
}}
|
|
>
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
|
<Ionicons name="time-outline" size={14} color={colors.textMuted} />
|
|
<Text style={{ fontSize: 13, color: colors.textMuted }}>
|
|
{t('magic.expires_in', { time: formatRemaining(remaining) })}
|
|
</Text>
|
|
</View>
|
|
<TouchableOpacity onPress={handleCopyCode}>
|
|
<Text style={{ fontSize: 13, color: '#007AFF', fontWeight: '600' }}>
|
|
{t('magic.copy')}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
<TouchableOpacity
|
|
onPress={() => {
|
|
setPair(null);
|
|
setPairError(null);
|
|
}}
|
|
style={{ marginTop: 14, alignSelf: 'center' }}
|
|
>
|
|
<Text style={{ fontSize: 13, color: colors.textMuted }}>
|
|
{t('magic.discard_code')}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</>
|
|
)}
|
|
</View>
|
|
|
|
{/* Verbundene Computer + Slot-Anzeige */}
|
|
<SectionTitle
|
|
text={`${t('magic.connected_title')} \u00b7 ${desktopDevices.length}/${desktopLimit}`}
|
|
colors={colors}
|
|
/>
|
|
<View style={cardStyle(colors)}>
|
|
{devices === null ? (
|
|
<ActivityIndicator />
|
|
) : desktopDevices.length === 0 ? (
|
|
<Text style={{ fontSize: 14, color: colors.textMuted }}>
|
|
{t('magic.connected_empty')}
|
|
</Text>
|
|
) : (
|
|
desktopDevices.map((d, i) => (
|
|
<View
|
|
key={d.deviceId}
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingVertical: 12,
|
|
borderTopWidth: i === 0 ? 0 : 1,
|
|
borderTopColor: colors.border,
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 9,
|
|
backgroundColor: colors.groupedBg,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginRight: 12,
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name={
|
|
d.model?.toLowerCase().includes('windows')
|
|
? 'desktop-outline'
|
|
: 'laptop-outline'
|
|
}
|
|
size={20}
|
|
color={colors.text}
|
|
/>
|
|
</View>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={{ fontSize: 15, color: colors.text, fontWeight: '600' }}>
|
|
{d.hostname}
|
|
</Text>
|
|
{d.model && (
|
|
<Text style={{ fontSize: 12, color: colors.textMuted, marginTop: 1 }}>
|
|
{d.model}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
</View>
|
|
))
|
|
)}
|
|
</View>
|
|
</View>
|
|
</FormSheet>
|
|
);
|
|
}
|
|
|
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
function cardStyle(colors: ColorScheme) {
|
|
return {
|
|
backgroundColor: colors.card,
|
|
borderRadius: 14,
|
|
padding: 16,
|
|
marginBottom: 16,
|
|
} as const;
|
|
}
|
|
|
|
function SectionTitle({ text, colors }: { text: string; colors: ColorScheme }) {
|
|
return (
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 0.4,
|
|
color: colors.textMuted,
|
|
marginBottom: 8,
|
|
marginLeft: 4,
|
|
fontFamily: 'Nunito_700Bold',
|
|
}}
|
|
>
|
|
{text}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
function PrimaryButton({
|
|
icon,
|
|
label,
|
|
onPress,
|
|
loading,
|
|
}: {
|
|
icon: React.ComponentProps<typeof Ionicons>['name'];
|
|
label: string;
|
|
onPress: () => void;
|
|
loading?: boolean;
|
|
}) {
|
|
return (
|
|
<TouchableOpacity
|
|
onPress={onPress}
|
|
disabled={loading}
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 8,
|
|
backgroundColor: '#007AFF',
|
|
paddingVertical: 12,
|
|
paddingHorizontal: 16,
|
|
borderRadius: 12,
|
|
opacity: loading ? 0.6 : 1,
|
|
}}
|
|
>
|
|
{loading ? (
|
|
<ActivityIndicator color="#fff" />
|
|
) : (
|
|
<Ionicons name={icon} size={18} color="#fff" />
|
|
)}
|
|
<Text style={{ color: '#fff', fontSize: 15, fontFamily: 'Nunito_700Bold' }}>{label}</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
}
|
|
|
|
function PlatformOption({
|
|
icon,
|
|
label,
|
|
selected,
|
|
onPress,
|
|
colors,
|
|
}: {
|
|
icon: React.ComponentProps<typeof Ionicons>['name'];
|
|
label: string;
|
|
selected: boolean;
|
|
onPress: () => void;
|
|
colors: ColorScheme;
|
|
}) {
|
|
return (
|
|
<TouchableOpacity
|
|
onPress={onPress}
|
|
activeOpacity={0.7}
|
|
style={{
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 8,
|
|
paddingVertical: 14,
|
|
borderRadius: 12,
|
|
backgroundColor: colors.card,
|
|
borderWidth: 2,
|
|
borderColor: selected ? '#007AFF' : colors.border,
|
|
}}
|
|
>
|
|
<Ionicons name={icon} size={18} color={selected ? '#007AFF' : colors.textMuted} />
|
|
<Text
|
|
style={{
|
|
fontSize: 15,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: selected ? '#007AFF' : colors.text,
|
|
}}
|
|
>
|
|
{label}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
}
|
|
|
|
function formatRemaining(seconds: number): string {
|
|
const m = Math.floor(seconds / 60);
|
|
const s = seconds % 60;
|
|
return `${m}:${String(s).padStart(2, '0')}`;
|
|
}
|