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.
310 lines
9.2 KiB
TypeScript
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>
|
|
);
|
|
}
|