rebreak-monorepo/apps/rebreak-native/components/DeviceApprovalPendingSheet.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

310 lines
9.2 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 { useDeviceApprovalStore } from "../stores/deviceApproval";
/**
* Sheet das auf dem NEUEN Gerät erscheint nachdem es Approval angefordert hat.
*
* Zeigt:
* - 6-stelligen Code (zum visuellen Vergleich mit dem anderen Gerät)
* - Status: "Warte auf Bestätigung..." / "Bestätigt ✓" / "Abgelehnt"
* - "Per E-Mail senden" Fallback-Button
*
* Pollt alle 2.5s den Status (Realtime ist nicht zuverlässig auf einem
* Gerät das noch keinen registrierten Device-Slot hat).
*
* Caller (DeviceLimitReachedSheet) füllt store.outgoing → dieses Sheet öffnet.
* Bei status="approved" → caller triggert register-Retry.
*/
export function DeviceApprovalPendingSheet(props: {
/** Wird aufgerufen wenn Approval = approved. Caller soll dann register retryen. */
onApproved?: () => void;
}) {
const { t } = useTranslation();
const colors = useColors();
const sheetRef = useRef<TrueSheet>(null);
const outgoing = useDeviceApprovalStore((s) => s.outgoing);
const emailSent = useDeviceApprovalStore((s) => s.outgoingEmailSent);
const error = useDeviceApprovalStore((s) => s.outgoingError);
const pollOutgoing = useDeviceApprovalStore((s) => s.pollOutgoing);
const sendEmailFallback = useDeviceApprovalStore((s) => s.sendEmailFallback);
const clearOutgoing = useDeviceApprovalStore((s) => s.clearOutgoing);
const [sendingEmail, setSendingEmail] = useState(false);
// Sheet open/close based on store
useEffect(() => {
if (outgoing) sheetRef.current?.present();
else sheetRef.current?.dismiss();
}, [outgoing]);
// Polling
useEffect(() => {
if (!outgoing || outgoing.status !== "pending") return;
const interval = setInterval(() => pollOutgoing(), 2500);
return () => clearInterval(interval);
}, [outgoing, pollOutgoing]);
// Trigger onApproved
useEffect(() => {
if (outgoing?.status === "approved") {
props.onApproved?.();
}
}, [outgoing?.status, props]);
if (!outgoing) return null;
const isPending = outgoing.status === "pending";
const isApproved = outgoing.status === "approved";
const isRejected = outgoing.status === "rejected";
const isExpired = outgoing.status === "expired";
async function handleSendEmail() {
setSendingEmail(true);
try {
await sendEmailFallback();
} finally {
setSendingEmail(false);
}
}
return (
<TrueSheet
ref={sheetRef}
detents={["auto", 1] satisfies SheetDetent[]}
cornerRadius={20}
grabber
onDidDismiss={() => clearOutgoing()}
>
<View style={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 32 }}>
<View
style={{
width: 48,
height: 48,
borderRadius: 14,
backgroundColor: isApproved
? "rgba(34,197,94,0.12)"
: isRejected || isExpired
? "rgba(239,68,68,0.12)"
: "rgba(0,122,255,0.12)",
alignItems: "center",
justifyContent: "center",
marginBottom: 14,
}}
>
<Ionicons
name={
isApproved
? "checkmark-circle"
: isRejected || isExpired
? "close-circle"
: "shield-checkmark-outline"
}
size={24}
color={
isApproved
? "#22c55e"
: isRejected || isExpired
? "#ef4444"
: "#007AFF"
}
/>
</View>
<Text
style={{
fontSize: 22,
color: colors.text,
fontFamily: "Nunito_700Bold",
marginBottom: 6,
}}
>
{isApproved
? t("device_approval.pending_approved_title")
: isRejected
? t("device_approval.pending_rejected_title")
: isExpired
? t("device_approval.pending_expired_title")
: t("device_approval.pending_title")}
</Text>
<Text
style={{
fontSize: 14,
color: colors.textMuted,
fontFamily: "Nunito_400Regular",
marginBottom: 20,
lineHeight: 20,
}}
>
{isApproved
? t("device_approval.pending_approved_subtitle")
: isRejected
? t("device_approval.pending_rejected_subtitle")
: isExpired
? t("device_approval.pending_expired_subtitle")
: t("device_approval.pending_subtitle")}
</Text>
{/* CODE BOX */}
{isPending ? (
<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,
}}
>
{outgoing.code}
</Text>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 6,
marginTop: 12,
}}
>
<ActivityIndicator size="small" color={colors.textMuted} />
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: "Nunito_400Regular",
}}
>
{t("device_approval.waiting")}
</Text>
</View>
</View>
) : null}
{error ? (
<Text
style={{
fontSize: 12,
color: colors.error,
fontFamily: "Nunito_600SemiBold",
marginBottom: 12,
textAlign: "center",
}}
>
{error}
</Text>
) : null}
{/* Email-Fallback */}
{isPending ? (
<TouchableOpacity
onPress={handleSendEmail}
disabled={sendingEmail || emailSent}
activeOpacity={0.7}
style={{
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
borderColor: "rgba(0,0,0,0.1)",
alignItems: "center",
marginBottom: 10,
opacity: emailSent ? 0.5 : 1,
}}
>
{sendingEmail ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<Text
style={{
fontSize: 14,
color: colors.text,
fontFamily: "Nunito_600SemiBold",
}}
>
{emailSent
? t("device_approval.email_already_sent")
: t("device_approval.send_email")}
</Text>
)}
</TouchableOpacity>
) : null}
{/* Close button (only if not pending) */}
{!isPending ? (
<TouchableOpacity
onPress={() => {
sheetRef.current?.dismiss();
clearOutgoing();
}}
activeOpacity={0.7}
style={{
paddingVertical: 14,
borderRadius: 12,
backgroundColor: isApproved ? "#22c55e" : "rgba(0,0,0,0.06)",
alignItems: "center",
}}
>
<Text
style={{
fontSize: 15,
color: isApproved ? "#fff" : colors.text,
fontFamily: "Nunito_700Bold",
}}
>
{isApproved
? t("device_approval.continue")
: t("device_approval.close")}
</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={() => {
sheetRef.current?.dismiss();
clearOutgoing();
}}
activeOpacity={0.7}
style={{
paddingVertical: 10,
alignItems: "center",
}}
>
<Text
style={{
fontSize: 13,
color: colors.textMuted,
fontFamily: "Nunito_400Regular",
}}
>
{t("device_approval.cancel")}
</Text>
</TouchableOpacity>
)}
</View>
</TrueSheet>
);
}