iCloud-Sign-In Pattern: wenn ein neues Gerät versucht sich anzumelden und das Plan-Limit erreicht ist, kann der User auf einem bereits angemeldeten Gerät bestätigen — Code wird auf BEIDEN Geräten gezeigt für visuellen Vergleich (verhindert Code-Forwarding-Attacken). Backend: - New table device_approval_requests + supabase_realtime + RLS - POST /api/devices/approvals — create (new device) - GET /api/devices/approvals — list pending (existing devices) - GET /api/devices/approvals/:id — status poll (new device) - POST /api/devices/approvals/:id/approve — approve + atomic evict - POST /api/devices/approvals/:id/reject — reject - POST /api/devices/approvals/:id/email — trigger email fallback - POST /api/devices/approvals/email/:token — magic-link approve (no auth) - Email-Template via Resend (lyra-neutral, security-formal) - 10min TTL, 6-digit numeric codes (crypto-random) Frontend (rebreak-native): - DeviceApprovalIncomingSheet — existing devices: code + device-picker + Allow/Reject - DeviceApprovalPendingSheet — new device: code + spinner + 'Send via email' - useDeviceApprovalRealtime — postgres_changes subscription - DeviceLimitReachedSheet — neues CTA 'Auf anderem Gerät bestätigen' - i18n DE/EN/FR/AR Migration läuft automatisch via prisma migrate deploy bei push.
385 lines
11 KiB
TypeScript
385 lines
11 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { ActivityIndicator, Text, TouchableOpacity, View } from "react-native";
|
|
import { TrueSheet, type SheetDetent } from "@lodev09/react-native-true-sheet";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useColors } from "../lib/theme";
|
|
import { apiFetch } from "../lib/api";
|
|
import {
|
|
useDeviceApprovalStore,
|
|
type DeviceApprovalRecord,
|
|
} from "../stores/deviceApproval";
|
|
|
|
type UserDevice = {
|
|
id: string;
|
|
deviceId: string;
|
|
platform: string;
|
|
model: string | null;
|
|
name: string | null;
|
|
osVersion: string | null;
|
|
lastSeenAt: string;
|
|
boundToPlan: string | null;
|
|
isCurrent?: boolean;
|
|
};
|
|
|
|
function platformIcon(p: string): React.ComponentProps<typeof Ionicons>["name"] {
|
|
if (p === "ios") return "logo-apple";
|
|
if (p === "android") return "logo-android";
|
|
return "phone-portrait-outline";
|
|
}
|
|
|
|
/**
|
|
* Sheet das auf einem EXISTIERENDEN Gerät erscheint, wenn ein NEUES Gerät
|
|
* Approval anfordert (Apple-Style iCloud-Sign-In Prompt).
|
|
*
|
|
* Zeigt:
|
|
* - Info über das neue Gerät
|
|
* - 6-stelligen Code (zum visuellen Vergleich)
|
|
* - [Erlauben] / [Ablehnen]
|
|
*
|
|
* Wenn User am Device-Limit ist → zeigt zusätzlich Device-Picker:
|
|
* "Welches Gerät möchtest du ersetzen?"
|
|
*/
|
|
export function DeviceApprovalIncomingSheet() {
|
|
const { t } = useTranslation();
|
|
const colors = useColors();
|
|
const sheetRef = useRef<TrueSheet>(null);
|
|
const incoming = useDeviceApprovalStore((s) => s.incoming);
|
|
const approve = useDeviceApprovalStore((s) => s.approveIncoming);
|
|
const reject = useDeviceApprovalStore((s) => s.rejectIncoming);
|
|
const setIncoming = useDeviceApprovalStore((s) => s.setIncoming);
|
|
|
|
const [devices, setDevices] = useState<UserDevice[]>([]);
|
|
const [max, setMax] = useState<number>(0);
|
|
const [evictId, setEvictId] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState<"approve" | "reject" | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (incoming) {
|
|
sheetRef.current?.present();
|
|
setError(null);
|
|
setBusy(null);
|
|
setEvictId(null);
|
|
// Hole aktuelle Devices um zu prüfen ob Limit erreicht ist
|
|
apiFetch<{ devices: UserDevice[]; max: number }>("/api/devices")
|
|
.then((r) => {
|
|
setDevices(r.devices ?? []);
|
|
setMax(r.max ?? 0);
|
|
})
|
|
.catch(() => {});
|
|
} else {
|
|
sheetRef.current?.dismiss();
|
|
}
|
|
}, [incoming]);
|
|
|
|
if (!incoming) return null;
|
|
|
|
const atLimit = devices.length >= max && max > 0;
|
|
const evictableDevices = devices.filter((d) => !d.boundToPlan);
|
|
const needsEviction = atLimit;
|
|
|
|
async function handleApprove(rec: DeviceApprovalRecord) {
|
|
setError(null);
|
|
if (needsEviction && !evictId) {
|
|
setError(t("device_approval.pick_to_replace"));
|
|
return;
|
|
}
|
|
setBusy("approve");
|
|
try {
|
|
await approve(needsEviction ? evictId : null);
|
|
sheetRef.current?.dismiss();
|
|
} catch (e: any) {
|
|
setError(e?.message ?? "Fehler");
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
}
|
|
|
|
async function handleReject() {
|
|
setBusy("reject");
|
|
try {
|
|
await reject();
|
|
sheetRef.current?.dismiss();
|
|
} catch (e: any) {
|
|
setError(e?.message ?? "Fehler");
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
}
|
|
|
|
const newDeviceLabel =
|
|
incoming.newName ?? incoming.newModel ?? incoming.newPlatform;
|
|
|
|
return (
|
|
<TrueSheet
|
|
ref={sheetRef}
|
|
detents={["auto", 1] satisfies SheetDetent[]}
|
|
cornerRadius={20}
|
|
grabber
|
|
onDidDismiss={() => setIncoming(null)}
|
|
>
|
|
<View style={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 32 }}>
|
|
<View
|
|
style={{
|
|
width: 48,
|
|
height: 48,
|
|
borderRadius: 14,
|
|
backgroundColor: "rgba(0,122,255,0.12)",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
marginBottom: 14,
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name={platformIcon(incoming.newPlatform)}
|
|
size={24}
|
|
color="#007AFF"
|
|
/>
|
|
</View>
|
|
|
|
<Text
|
|
style={{
|
|
fontSize: 22,
|
|
color: colors.text,
|
|
fontFamily: "Nunito_700Bold",
|
|
marginBottom: 6,
|
|
}}
|
|
>
|
|
{t("device_approval.incoming_title")}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
color: colors.textMuted,
|
|
fontFamily: "Nunito_400Regular",
|
|
marginBottom: 20,
|
|
lineHeight: 20,
|
|
}}
|
|
>
|
|
{t("device_approval.incoming_subtitle", { device: newDeviceLabel })}
|
|
</Text>
|
|
|
|
{/* CODE BOX */}
|
|
<View
|
|
style={{
|
|
backgroundColor: colors.bg,
|
|
borderRadius: 14,
|
|
paddingVertical: 24,
|
|
alignItems: "center",
|
|
borderWidth: 1,
|
|
borderColor: "rgba(0,0,0,0.06)",
|
|
marginBottom: 16,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 11,
|
|
color: colors.textMuted,
|
|
fontFamily: "Nunito_600SemiBold",
|
|
letterSpacing: 1,
|
|
textTransform: "uppercase",
|
|
marginBottom: 8,
|
|
}}
|
|
>
|
|
{t("device_approval.code_label")}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 36,
|
|
fontFamily: "Nunito_700Bold",
|
|
letterSpacing: 8,
|
|
color: colors.text,
|
|
}}
|
|
>
|
|
{incoming.code}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 11,
|
|
color: colors.textMuted,
|
|
fontFamily: "Nunito_400Regular",
|
|
marginTop: 10,
|
|
textAlign: "center",
|
|
paddingHorizontal: 24,
|
|
}}
|
|
>
|
|
{t("device_approval.compare_hint")}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Eviction-Picker wenn Limit voll */}
|
|
{needsEviction ? (
|
|
<View style={{ marginBottom: 16 }}>
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
color: colors.textMuted,
|
|
fontFamily: "Nunito_600SemiBold",
|
|
marginBottom: 8,
|
|
textTransform: "uppercase",
|
|
letterSpacing: 0.5,
|
|
}}
|
|
>
|
|
{t("device_approval.replace_which")}
|
|
</Text>
|
|
{evictableDevices.length === 0 ? (
|
|
<Text
|
|
style={{
|
|
fontSize: 13,
|
|
color: colors.error,
|
|
fontFamily: "Nunito_400Regular",
|
|
}}
|
|
>
|
|
{t("device_approval.all_devices_locked")}
|
|
</Text>
|
|
) : (
|
|
evictableDevices.map((d) => {
|
|
const selected = evictId === d.id;
|
|
return (
|
|
<TouchableOpacity
|
|
key={d.id}
|
|
onPress={() => setEvictId(d.id)}
|
|
activeOpacity={0.7}
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 12,
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 12,
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
borderColor: selected ? "#007AFF" : "rgba(0,0,0,0.08)",
|
|
backgroundColor: selected
|
|
? "rgba(0,122,255,0.06)"
|
|
: "transparent",
|
|
marginBottom: 6,
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name={platformIcon(d.platform)}
|
|
size={18}
|
|
color={colors.text}
|
|
/>
|
|
<View style={{ flex: 1, minWidth: 0 }}>
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
color: colors.text,
|
|
fontFamily: "Nunito_600SemiBold",
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
{d.name ?? d.model ?? d.platform}
|
|
</Text>
|
|
{d.model && d.name ? (
|
|
<Text
|
|
style={{
|
|
fontSize: 11,
|
|
color: colors.textMuted,
|
|
fontFamily: "Nunito_400Regular",
|
|
marginTop: 1,
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
{d.model}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
{selected ? (
|
|
<Ionicons
|
|
name="checkmark-circle"
|
|
size={20}
|
|
color="#007AFF"
|
|
/>
|
|
) : (
|
|
<Ionicons
|
|
name="ellipse-outline"
|
|
size={20}
|
|
color={colors.textMuted}
|
|
/>
|
|
)}
|
|
</TouchableOpacity>
|
|
);
|
|
})
|
|
)}
|
|
</View>
|
|
) : null}
|
|
|
|
{error ? (
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
color: colors.error,
|
|
fontFamily: "Nunito_600SemiBold",
|
|
marginBottom: 12,
|
|
textAlign: "center",
|
|
}}
|
|
>
|
|
{error}
|
|
</Text>
|
|
) : null}
|
|
|
|
{/* Buttons */}
|
|
<View style={{ flexDirection: "row", gap: 10 }}>
|
|
<TouchableOpacity
|
|
onPress={handleReject}
|
|
disabled={busy !== null}
|
|
activeOpacity={0.7}
|
|
style={{
|
|
flex: 1,
|
|
paddingVertical: 14,
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
borderColor: "rgba(0,0,0,0.1)",
|
|
alignItems: "center",
|
|
opacity: busy === "reject" ? 0.5 : 1,
|
|
}}
|
|
>
|
|
{busy === "reject" ? (
|
|
<ActivityIndicator size="small" color={colors.text} />
|
|
) : (
|
|
<Text
|
|
style={{
|
|
fontSize: 15,
|
|
color: colors.text,
|
|
fontFamily: "Nunito_600SemiBold",
|
|
}}
|
|
>
|
|
{t("device_approval.reject")}
|
|
</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
onPress={() => handleApprove(incoming)}
|
|
disabled={busy !== null}
|
|
activeOpacity={0.7}
|
|
style={{
|
|
flex: 1,
|
|
paddingVertical: 14,
|
|
borderRadius: 12,
|
|
backgroundColor: "#007AFF",
|
|
alignItems: "center",
|
|
opacity: busy === "approve" ? 0.7 : 1,
|
|
}}
|
|
>
|
|
{busy === "approve" ? (
|
|
<ActivityIndicator size="small" color="#fff" />
|
|
) : (
|
|
<Text
|
|
style={{
|
|
fontSize: 15,
|
|
color: "#fff",
|
|
fontFamily: "Nunito_700Bold",
|
|
}}
|
|
>
|
|
{t("device_approval.approve")}
|
|
</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</TrueSheet>
|
|
);
|
|
}
|