chahinebrini 50425a62ee fix(devices): Magic-Hub zeigt jetzt alle Native-Geraete, Native dedupliziert Mac
Magic-Mac-Hub (/api/magic/devices):
- Filter boundToPlan war zu eng \u2014 iPhone/iPad ohne aktiven Plan-Lock
  fielen raus. Jetzt: alle UserDevice-Rows des Users ausser den
  magic-enrolled, plus ProtectedDevice mit Dedupe.

Native /devices Page:
- MacBook erschien doppelt: einmal als UserDevice (registriert via
  Magic-Mac, model=Mac14,9) und einmal als ProtectedDevice (alter
  DNS-Flow). Dedupe per platform-key (mac/ios/android/win):
  wenn UserDevice mit gleicher Plattform existiert, blende
  ProtectedDevice aus.
- Slot-Counter zaehlt jetzt nach dedupe (totalRegistered).
2026-06-03 19:43:33 +02:00

382 lines
11 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 type { ColorScheme } from '../../lib/theme';
import { apiFetch } from '../../lib/api';
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;
};
type MagicInfo = {
latestVersion: string;
downloadUrl: string;
dmgUrl: string;
minMacosVersion: string;
};
/**
* MagicSheet — Rebreak-Magic-Pairing-Flow als geteiltes FormSheet
* (Bottom-Sheet mit Titel-Header). Wird aus settings.tsx getriggert.
*/
export function MagicSheet({
visible,
onClose,
colors,
}: {
visible: boolean;
onClose: () => void;
colors: ColorScheme;
}) {
const [info, setInfo] = useState<MagicInfo | null>(null);
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);
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',
});
}
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 ?? 'Fehler beim Generieren');
} 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;
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 }}>
iPhone in 30 Sek. binden ohne Werks-Reset.
</Text>
{/* Step 1 — Download */}
<SectionTitle text="1. Mac-App herunterladen" colors={colors} />
<View style={cardStyle(colors)}>
<Text style={{ fontSize: 14, color: colors.text, marginBottom: 12 }}>
Auf deinem Mac öffnen (min. macOS {info?.minMacosVersion ?? '13.0'}).
</Text>
<PrimaryButton
icon="cloud-download-outline"
label="Download öffnen"
onPress={() => info && Linking.openURL(info.downloadUrl)}
/>
<TouchableOpacity
onPress={() => info && Share.share({ message: info.downloadUrl })}
style={{ marginTop: 10, alignSelf: 'flex-start' }}
>
<Text style={{ fontSize: 13, color: '#007AFF' }}>Link an meinen Mac senden</Text>
</TouchableOpacity>
</View>
{/* Step 2 — Pairing-Code */}
<SectionTitle text="2. Pairing-Code generieren" colors={colors} />
<View style={cardStyle(colors)}>
{!pair || codeExpired ? (
<>
<Text style={{ fontSize: 14, color: colors.text, marginBottom: 14 }}>
Erzeuge einen 6-stelligen Code und gib ihn in der Mac-App ein. Gültig 10
Minuten, nur einmal verwendbar.
</Text>
<PrimaryButton
icon="key-outline"
label={
pairLoading
? 'Generiere…'
: codeExpired
? 'Neuen Code erzeugen'
: 'Code erzeugen'
}
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,
}}
>
In Mac-App eingeben:
</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 }}>
Läuft ab in {formatRemaining(remaining)}
</Text>
</View>
<TouchableOpacity onPress={handleCopyCode}>
<Text style={{ fontSize: 13, color: '#007AFF', fontWeight: '600' }}>Kopieren</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
onPress={() => {
setPair(null);
setPairError(null);
}}
style={{ marginTop: 14, alignSelf: 'center' }}
>
<Text style={{ fontSize: 13, color: colors.textMuted }}>Code verwerfen</Text>
</TouchableOpacity>
</>
)}
</View>
{/* Verbundene Macs */}
<SectionTitle text="Verbundene Macs" colors={colors} />
<View style={cardStyle(colors)}>
{devices === null ? (
<ActivityIndicator />
) : devices.length === 0 ? (
<Text style={{ fontSize: 14, color: colors.textMuted }}>
Noch keine Macs verbunden. Sobald du einen Pairing-Code einlöst und ein iPhone
bindest, erscheint es hier.
</Text>
) : (
devices.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="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 formatRemaining(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}