rebreak-monorepo/apps/rebreak-native/components/DeviceApprovalIncomingSheet.tsx
chahinebrini 2e056c7257 feat(devices): Apple-style two-device approval flow + email fallback
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.
2026-06-01 02:36:28 +02:00

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>
);
}