From 2e056c7257ac40966ee932bcf49a54bb87de415e Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Mon, 1 Jun 2026 02:36:28 +0200 Subject: [PATCH] feat(devices): Apple-style two-device approval flow + email fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/rebreak-native/app/_layout.tsx | 16 + .../DeviceApprovalIncomingSheet.tsx | 384 ++++++++++++++++++ .../components/DeviceApprovalPendingSheet.tsx | 309 ++++++++++++++ .../components/DeviceLimitReachedSheet.tsx | 54 +++ .../hooks/useDeviceApprovalRealtime.ts | 87 ++++ apps/rebreak-native/locales/ar.json | 32 +- apps/rebreak-native/locales/de.json | 32 +- apps/rebreak-native/locales/en.json | 32 +- apps/rebreak-native/locales/fr.json | 32 +- apps/rebreak-native/stores/deviceApproval.ts | 181 +++++++++ .../migration.sql | 55 +++ backend/prisma/schema.prisma | 63 +++ .../server/api/devices/approvals/[id].get.ts | 22 + .../devices/approvals/[id]/approve.post.ts | 52 +++ .../api/devices/approvals/[id]/email.post.ts | 70 ++++ .../api/devices/approvals/[id]/reject.post.ts | 21 + .../devices/approvals/email/[token].post.ts | 73 ++++ .../server/api/devices/approvals/index.get.ts | 13 + .../api/devices/approvals/index.post.ts | 46 +++ backend/server/db/device-approvals.ts | 266 ++++++++++++ backend/server/utils/device-approval-email.ts | 110 +++++ 21 files changed, 1946 insertions(+), 4 deletions(-) create mode 100644 apps/rebreak-native/components/DeviceApprovalIncomingSheet.tsx create mode 100644 apps/rebreak-native/components/DeviceApprovalPendingSheet.tsx create mode 100644 apps/rebreak-native/hooks/useDeviceApprovalRealtime.ts create mode 100644 apps/rebreak-native/stores/deviceApproval.ts create mode 100644 backend/prisma/migrations/20260601_device_approval_requests/migration.sql create mode 100644 backend/server/api/devices/approvals/[id].get.ts create mode 100644 backend/server/api/devices/approvals/[id]/approve.post.ts create mode 100644 backend/server/api/devices/approvals/[id]/email.post.ts create mode 100644 backend/server/api/devices/approvals/[id]/reject.post.ts create mode 100644 backend/server/api/devices/approvals/email/[token].post.ts create mode 100644 backend/server/api/devices/approvals/index.get.ts create mode 100644 backend/server/api/devices/approvals/index.post.ts create mode 100644 backend/server/db/device-approvals.ts create mode 100644 backend/server/utils/device-approval-email.ts diff --git a/apps/rebreak-native/app/_layout.tsx b/apps/rebreak-native/app/_layout.tsx index 534a434..5578c7f 100644 --- a/apps/rebreak-native/app/_layout.tsx +++ b/apps/rebreak-native/app/_layout.tsx @@ -29,6 +29,10 @@ import { useAppLockStore } from '../stores/appLock'; import { useLyraVoiceStore } from '../stores/lyraVoice'; import { AppLockGate } from '../components/AppLockGate'; import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet'; +import { DeviceApprovalIncomingSheet } from '../components/DeviceApprovalIncomingSheet'; +import { DeviceApprovalPendingSheet } from '../components/DeviceApprovalPendingSheet'; +import { useDeviceApprovalRealtime } from '../hooks/useDeviceApprovalRealtime'; +import { useDevicesStore } from '../stores/devices'; import { OnlinePresenceProvider } from '../components/OnlinePresenceProvider'; import { usePushTokenRegistration } from '../hooks/usePushTokenRegistration'; import '../lib/i18n'; // i18next-Init via Side-Effect @@ -74,6 +78,11 @@ function RootLayoutInner() { // Push-Token-Registration nach Login (idempotent) usePushTokenRegistration(user?.id); + // Apple-Style Device-Approval Realtime — lauscht auf neue Approval-Requests + // für diesen User und zeigt das Incoming-Sheet wenn ein anderes Gerät + // sich anmelden möchte. + useDeviceApprovalRealtime(!!user?.id); + // Push-Tap-Deep-Link: User tippt Notification → navigate zu Chat useEffect(() => { const sub = Notifications.addNotificationResponseReceivedListener( @@ -134,6 +143,13 @@ function RootLayoutInner() { + + { + // Slot wurde freigegeben — register retry + useDevicesStore.getState().ensureRegistered().catch(() => {}); + }} + /> ["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(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([]); + const [max, setMax] = useState(0); + const [evictId, setEvictId] = useState(null); + const [busy, setBusy] = useState<"approve" | "reject" | null>(null); + const [error, setError] = useState(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 ( + setIncoming(null)} + > + + + + + + + {t("device_approval.incoming_title")} + + + {t("device_approval.incoming_subtitle", { device: newDeviceLabel })} + + + {/* CODE BOX */} + + + {t("device_approval.code_label")} + + + {incoming.code} + + + {t("device_approval.compare_hint")} + + + + {/* Eviction-Picker wenn Limit voll */} + {needsEviction ? ( + + + {t("device_approval.replace_which")} + + {evictableDevices.length === 0 ? ( + + {t("device_approval.all_devices_locked")} + + ) : ( + evictableDevices.map((d) => { + const selected = evictId === d.id; + return ( + 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, + }} + > + + + + {d.name ?? d.model ?? d.platform} + + {d.model && d.name ? ( + + {d.model} + + ) : null} + + {selected ? ( + + ) : ( + + )} + + ); + }) + )} + + ) : null} + + {error ? ( + + {error} + + ) : null} + + {/* Buttons */} + + + {busy === "reject" ? ( + + ) : ( + + {t("device_approval.reject")} + + )} + + 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" ? ( + + ) : ( + + {t("device_approval.approve")} + + )} + + + + + ); +} diff --git a/apps/rebreak-native/components/DeviceApprovalPendingSheet.tsx b/apps/rebreak-native/components/DeviceApprovalPendingSheet.tsx new file mode 100644 index 0000000..23bce62 --- /dev/null +++ b/apps/rebreak-native/components/DeviceApprovalPendingSheet.tsx @@ -0,0 +1,309 @@ +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(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 ( + clearOutgoing()} + > + + + + + + + {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")} + + + {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")} + + + {/* CODE BOX */} + {isPending ? ( + + + {t("device_approval.code_label")} + + + {outgoing.code} + + + + + {t("device_approval.waiting")} + + + + ) : null} + + {error ? ( + + {error} + + ) : null} + + {/* Email-Fallback */} + {isPending ? ( + + {sendingEmail ? ( + + ) : ( + + {emailSent + ? t("device_approval.email_already_sent") + : t("device_approval.send_email")} + + )} + + ) : null} + + {/* Close button (only if not pending) */} + {!isPending ? ( + { + sheetRef.current?.dismiss(); + clearOutgoing(); + }} + activeOpacity={0.7} + style={{ + paddingVertical: 14, + borderRadius: 12, + backgroundColor: isApproved ? "#22c55e" : "rgba(0,0,0,0.06)", + alignItems: "center", + }} + > + + {isApproved + ? t("device_approval.continue") + : t("device_approval.close")} + + + ) : ( + { + sheetRef.current?.dismiss(); + clearOutgoing(); + }} + activeOpacity={0.7} + style={{ + paddingVertical: 10, + alignItems: "center", + }} + > + + {t("device_approval.cancel")} + + + )} + + + ); +} diff --git a/apps/rebreak-native/components/DeviceLimitReachedSheet.tsx b/apps/rebreak-native/components/DeviceLimitReachedSheet.tsx index b50607c..e77285e 100644 --- a/apps/rebreak-native/components/DeviceLimitReachedSheet.tsx +++ b/apps/rebreak-native/components/DeviceLimitReachedSheet.tsx @@ -6,6 +6,8 @@ import { useTranslation } from 'react-i18next'; import { useColors } from '../lib/theme'; import { apiFetch } from '../lib/api'; import { useDeviceLimitStore, type DeviceLimitDevice } from '../stores/deviceLimit'; +import { useDeviceApprovalStore } from '../stores/deviceApproval'; +import { getDeviceInfo } from '../lib/deviceId'; function platformIcon( platform: string @@ -155,7 +157,9 @@ export function DeviceLimitReachedSheet() { const colors = useColors(); const sheetRef = useRef(null); const { visible, devices, max, plan, hide, removeDevice } = useDeviceLimitStore(); + const requestApproval = useDeviceApprovalStore((s) => s.requestApproval); const [removingId, setRemovingId] = useState(null); + const [requestingApproval, setRequestingApproval] = useState(false); useEffect(() => { if (visible) { @@ -163,6 +167,27 @@ export function DeviceLimitReachedSheet() { } }, [visible]); + async function handleRequestApproval() { + setRequestingApproval(true); + try { + const info = await getDeviceInfo(); + const approval = await requestApproval({ + deviceId: info.deviceId, + platform: info.platform, + name: info.name, + model: info.model, + osVersion: info.osVersion, + }); + if (approval) { + // Schließe das Limit-Sheet damit das Pending-Sheet sichtbar wird + sheetRef.current?.dismiss(); + hide(); + } + } finally { + setRequestingApproval(false); + } + } + async function handleRemove(id: string) { setRemovingId(id); try { @@ -264,6 +289,35 @@ export function DeviceLimitReachedSheet() { > {t('device_limit.hint')} + + {/* Apple-Style Approval: erlaube User auf anderem Gerät zu bestätigen */} + + {requestingApproval ? ( + + ) : ( + + {t('device_limit.approve_on_other')} + + )} + ); diff --git a/apps/rebreak-native/hooks/useDeviceApprovalRealtime.ts b/apps/rebreak-native/hooks/useDeviceApprovalRealtime.ts new file mode 100644 index 0000000..e8bad28 --- /dev/null +++ b/apps/rebreak-native/hooks/useDeviceApprovalRealtime.ts @@ -0,0 +1,87 @@ +import { useEffect } from "react"; +import { supabase } from "../lib/supabase"; +import { useDeviceApprovalStore } from "../stores/deviceApproval"; +import type { RealtimeChannel } from "@supabase/supabase-js"; + +/** + * Realtime-Subscription für Device-Approval-Requests. + * + * Lauscht auf INSERT/UPDATE auf rebreak.device_approval_requests gefiltert + * nach user_id. Wenn eine pending Row hereinkommt → triggert + * refreshIncomingFromServer() (holt die newest pending Row und zeigt das + * Approval-Sheet auf diesem existierenden Gerät). + * + * Wird global im _layout.tsx aufgerufen — IMMER aktiv für eingeloggte User. + */ +export function useDeviceApprovalRealtime(enabled: boolean = true) { + const refreshIncoming = useDeviceApprovalStore( + (s) => s.refreshIncomingFromServer, + ); + + useEffect(() => { + if (!enabled) return; + let channel: RealtimeChannel | null = null; + let cancelled = false; + let reconnectTimer: ReturnType | null = null; + + async function subscribe() { + const { data } = await supabase.auth.getSession(); + const session = data.session; + if (!session?.access_token) return; + if (cancelled) return; + + const myId = session.user.id; + + // Initial pull (in case we missed something while offline) + refreshIncoming(); + + channel = supabase + .channel(`device_approvals:${myId}:${Date.now()}`) + .on( + "postgres_changes", + { + event: "INSERT", + schema: "rebreak", + table: "device_approval_requests", + filter: `user_id=eq.${myId}`, + }, + () => refreshIncoming(), + ) + .on( + "postgres_changes", + { + event: "UPDATE", + schema: "rebreak", + table: "device_approval_requests", + filter: `user_id=eq.${myId}`, + }, + () => refreshIncoming(), + ) + .subscribe((status, err) => { + if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") { + console.warn("[approvalRealtime] error:", status, err ?? ""); + cleanup(); + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(() => { + if (!cancelled) subscribe(); + }, 3000); + } + }); + } + + function cleanup() { + if (channel) { + supabase.removeChannel(channel).catch(() => {}); + channel = null; + } + } + + subscribe(); + + return () => { + cancelled = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + cleanup(); + }; + }, [enabled, refreshIncoming]); +} diff --git a/apps/rebreak-native/locales/ar.json b/apps/rebreak-native/locales/ar.json index 1456888..b726ab8 100644 --- a/apps/rebreak-native/locales/ar.json +++ b/apps/rebreak-native/locales/ar.json @@ -492,6 +492,10 @@ "dialog_button_continue": "اضغط «متابعة»", "dialog_button_vpn_ok": "اضغط «موافق»", "dialog_button_a11y_toggle": "تفعيل المفتاح", + "android_restart_title": "يُنصح بإعادة تشغيل سريعة", + "android_restart_body": "على بعض أجهزة Samsung، لا تعمل الحماية الكاملة بشكل موثوق إلا بعد إعادة التشغيل. يرجى إعادة تشغيل الجهاز الآن مرة واحدة.", + "android_restart_now": "أعد التشغيل الآن", + "android_restart_later": "لاحقاً", "applock_failed_title": "فشل قفل التطبيق", "applock_failed_msg": "يمكنك المحاولة مرة أخرى أو تخطي هذه الخطوة — فلتر URL يعمل بالفعل.", "applock_skip": "تخطّي", @@ -835,7 +839,33 @@ "subtitle": "%{count} من %{max} أجهزة مشغولة (%{plan}) — أزل جهازاً للمتابعة", "hint": "الأجهزة التي تزيلها يمكنها التسجيل مجدداً عند الدخول التالي.", "remove_cta": "إزالة الجهاز", - "this_device": "هذا الجهاز" + "this_device": "هذا الجهاز", + "approve_on_other": "تأكيد على جهاز آخر" + }, + "device_approval": { + "incoming_title": "تأكيد جهاز جديد؟", + "incoming_subtitle": "%{device} يحاول تسجيل الدخول إلى حساب ReBreak الخاص بك.", + "code_label": "رمز التحقق", + "compare_hint": "قارن هذا الرمز بالرمز المعروض على الجهاز الجديد. إذا تطابقا، اضغط على سماح.", + "replace_which": "أي جهاز تريد استبداله؟", + "all_devices_locked": "جميع الأجهزة مرتبطة بخطة — يرجى تحرير واحد أولاً.", + "approve": "سماح", + "reject": "رفض", + "pick_to_replace": "يرجى اختيار جهاز لاستبداله.", + "pending_title": "يلزم التأكيد", + "pending_subtitle": "أكد هذا الرمز على جهاز آخر مسجل الدخول، أو عبر البريد الإلكتروني.", + "pending_approved_title": "تمت الموافقة", + "pending_approved_subtitle": "تم ربط جهازك الآن. يمكنك المتابعة.", + "pending_rejected_title": "تم الرفض", + "pending_rejected_subtitle": "تم رفض تسجيل الدخول على جهاز آخر.", + "pending_expired_title": "انتهت الصلاحية", + "pending_expired_subtitle": "انتهت صلاحية الرمز. يرجى المحاولة مجدداً.", + "waiting": "في انتظار التأكيد…", + "send_email": "إرسال عبر البريد الإلكتروني", + "email_already_sent": "تم إرسال البريد بالفعل", + "continue": "متابعة", + "close": "إغلاق", + "cancel": "إلغاء" }, "urge": { "title": "SOS — تمرين التنفس", diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 95e59da..1d70b54 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -557,6 +557,10 @@ "dialog_button_continue": "Tippe \"Fortfahren\"", "dialog_button_vpn_ok": "Tippe \"OK\"", "dialog_button_a11y_toggle": "Schalter aktivieren", + "android_restart_title": "Kurz neu starten empfohlen", + "android_restart_body": "Auf einigen Samsung-Geräten greift der volle Schutz erst nach einem Neustart zuverlässig. Bitte starte dein Gerät jetzt einmal neu.", + "android_restart_now": "Jetzt neu starten", + "android_restart_later": "Später", "applock_failed_title": "App-Schutz fehlgeschlagen", "applock_failed_msg": "Du kannst es nochmal versuchen oder den Schritt überspringen — der URL-Filter läuft schon.", "applock_skip": "Überspringen", @@ -900,7 +904,33 @@ "subtitle": "%{count} von %{max} Geräten belegt (%{plan}) — entferne ein Gerät um weiterzumachen", "hint": "Entfernte Geräte können sich beim nächsten Login wieder registrieren.", "remove_cta": "Gerät entfernen", - "this_device": "Dieses Gerät" + "this_device": "Dieses Gerät", + "approve_on_other": "Auf anderem Gerät bestätigen" + }, + "device_approval": { + "incoming_title": "Neues Gerät anmelden?", + "incoming_subtitle": "%{device} möchte sich bei deinem ReBreak-Account anmelden.", + "code_label": "Bestätigungs-Code", + "compare_hint": "Vergleiche diesen Code mit dem auf dem neuen Gerät. Stimmen sie überein, tippe auf Erlauben.", + "replace_which": "Welches Gerät soll ersetzt werden?", + "all_devices_locked": "Alle Geräte sind plan-gebunden — bitte erst eines freigeben.", + "approve": "Erlauben", + "reject": "Ablehnen", + "pick_to_replace": "Bitte wähle das Gerät das ersetzt werden soll.", + "pending_title": "Bestätigung nötig", + "pending_subtitle": "Bestätige diesen Code auf einem anderen angemeldeten Gerät oder per E-Mail.", + "pending_approved_title": "Bestätigt", + "pending_approved_subtitle": "Dein Gerät ist jetzt verknüpft. Du kannst weitermachen.", + "pending_rejected_title": "Abgelehnt", + "pending_rejected_subtitle": "Die Anmeldung wurde auf einem anderen Gerät abgelehnt.", + "pending_expired_title": "Abgelaufen", + "pending_expired_subtitle": "Der Code ist abgelaufen. Bitte versuche es erneut.", + "waiting": "Warte auf Bestätigung…", + "send_email": "Per E-Mail senden", + "email_already_sent": "E-Mail bereits gesendet", + "continue": "Weiter", + "close": "Schließen", + "cancel": "Abbrechen" }, "urge": { "title": "SOS — Atemübung", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 5a084a7..0c0ad83 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -557,6 +557,10 @@ "dialog_button_continue": "Tap \"Continue\"", "dialog_button_vpn_ok": "Tap \"OK\"", "dialog_button_a11y_toggle": "Toggle the switch", + "android_restart_title": "Quick restart recommended", + "android_restart_body": "On some Samsung devices, full protection only applies reliably after a reboot. Please restart your device once now.", + "android_restart_now": "Restart now", + "android_restart_later": "Later", "applock_failed_title": "App lock failed", "applock_failed_msg": "You can try again or skip this step — the URL filter is already running.", "applock_skip": "Skip", @@ -900,7 +904,33 @@ "subtitle": "%{count} of %{max} device slots used (%{plan}) — remove a device to continue", "hint": "Removed devices can re-register on next sign-in.", "remove_cta": "Remove device", - "this_device": "This device" + "this_device": "This device", + "approve_on_other": "Approve on another device" + }, + "device_approval": { + "incoming_title": "Approve new device?", + "incoming_subtitle": "%{device} is trying to sign in to your ReBreak account.", + "code_label": "Verification code", + "compare_hint": "Compare this code with the one shown on the new device. If they match, tap Allow.", + "replace_which": "Which device should be replaced?", + "all_devices_locked": "All devices are plan-locked — please release one first.", + "approve": "Allow", + "reject": "Reject", + "pick_to_replace": "Please pick a device to replace.", + "pending_title": "Approval required", + "pending_subtitle": "Confirm this code on another signed-in device, or via email.", + "pending_approved_title": "Approved", + "pending_approved_subtitle": "Your device is now linked. You can continue.", + "pending_rejected_title": "Rejected", + "pending_rejected_subtitle": "The sign-in was rejected on another device.", + "pending_expired_title": "Expired", + "pending_expired_subtitle": "The code has expired. Please try again.", + "waiting": "Waiting for approval…", + "send_email": "Send via email", + "email_already_sent": "Email already sent", + "continue": "Continue", + "close": "Close", + "cancel": "Cancel" }, "urge": { "title": "SOS — Breathing exercise", diff --git a/apps/rebreak-native/locales/fr.json b/apps/rebreak-native/locales/fr.json index 427d814..7dacc28 100644 --- a/apps/rebreak-native/locales/fr.json +++ b/apps/rebreak-native/locales/fr.json @@ -490,6 +490,10 @@ "dialog_button_continue": "Touche « Continuer »", "dialog_button_vpn_ok": "Touche « OK »", "dialog_button_a11y_toggle": "Activer l'interrupteur", + "android_restart_title": "Redémarrage rapide recommandé", + "android_restart_body": "Sur certains appareils Samsung, la protection complète ne s'applique de façon fiable qu'après un redémarrage. Redémarre ton appareil maintenant.", + "android_restart_now": "Redémarrer maintenant", + "android_restart_later": "Plus tard", "applock_failed_title": "Échec du verrou d'app", "applock_failed_msg": "Tu peux réessayer ou ignorer cette étape — le filtre URL est déjà actif.", "applock_skip": "Ignorer", @@ -822,7 +826,33 @@ "subtitle": "%{count} sur %{max} emplacements utilisés (%{plan}) — supprimez un appareil pour continuer", "hint": "Les appareils supprimés peuvent se réenregistrer à la prochaine connexion.", "remove_cta": "Supprimer un appareil", - "this_device": "Cet appareil" + "this_device": "Cet appareil", + "approve_on_other": "Approuver sur un autre appareil" + }, + "device_approval": { + "incoming_title": "Approuver le nouvel appareil ?", + "incoming_subtitle": "%{device} essaie de se connecter à votre compte ReBreak.", + "code_label": "Code de vérification", + "compare_hint": "Comparez ce code avec celui affiché sur le nouvel appareil. S'ils correspondent, appuyez sur Autoriser.", + "replace_which": "Quel appareil remplacer ?", + "all_devices_locked": "Tous les appareils sont verrouillés par le plan — libérez-en un d'abord.", + "approve": "Autoriser", + "reject": "Refuser", + "pick_to_replace": "Choisissez un appareil à remplacer.", + "pending_title": "Approbation requise", + "pending_subtitle": "Confirmez ce code sur un autre appareil connecté, ou par e-mail.", + "pending_approved_title": "Approuvé", + "pending_approved_subtitle": "Votre appareil est maintenant lié. Vous pouvez continuer.", + "pending_rejected_title": "Refusé", + "pending_rejected_subtitle": "La connexion a été refusée sur un autre appareil.", + "pending_expired_title": "Expiré", + "pending_expired_subtitle": "Le code a expiré. Veuillez réessayer.", + "waiting": "En attente d'approbation…", + "send_email": "Envoyer par e-mail", + "email_already_sent": "E-mail déjà envoyé", + "continue": "Continuer", + "close": "Fermer", + "cancel": "Annuler" }, "urge": { "title": "SOS — Exercice de respiration", diff --git a/apps/rebreak-native/stores/deviceApproval.ts b/apps/rebreak-native/stores/deviceApproval.ts new file mode 100644 index 0000000..e03e1b9 --- /dev/null +++ b/apps/rebreak-native/stores/deviceApproval.ts @@ -0,0 +1,181 @@ +import { create } from "zustand"; +import { apiFetch } from "../lib/api"; + +/** + * Apple-Style Device-Approval Store. + * + * Hält 2 Flows getrennt: + * + * 1. **outgoing** — Das AKTUELLE Gerät ist das NEUE Gerät und wartet auf + * Approval. UI: Modal mit Code + Spinner + "Per Email senden". + * + * 2. **incoming** — Das AKTUELLE Gerät ist ein EXISTIERENDES Gerät und kriegt + * eine Approval-Request push (realtime). UI: Sheet mit Code + Device-Picker + * + [Erlauben] / [Ablehnen]. + * + * Realtime: hooks/useDeviceApprovalRealtime.ts subscribet auf + * rebreak.device_approval_requests filtered by user_id und ruft + * setIncoming() / refresh(). + */ + +export type DeviceApprovalRecord = { + id: string; + userId: string; + newDeviceId: string; + newPlatform: string; + newModel: string | null; + newName: string | null; + newOsVersion: string | null; + code: string; + status: "pending" | "approved" | "rejected" | "expired"; + approvedByDeviceRowId: string | null; + approvedAt: string | null; + rejectedAt: string | null; + evictedDeviceRowId: string | null; + emailSentAt: string | null; + createdAt: string; + expiresAt: string; +}; + +type State = { + // Outgoing (this device is new, waiting for approval) + outgoing: DeviceApprovalRecord | null; + outgoingError: string | null; + outgoingEmailSent: boolean; + outgoingLoading: boolean; + + // Incoming (this device is existing, must approve/reject) + incoming: DeviceApprovalRecord | null; + + // Actions — outgoing + requestApproval: (deviceInfo: { + deviceId: string; + platform: string; + model?: string | null; + name?: string | null; + osVersion?: string | null; + }) => Promise; + pollOutgoing: () => Promise; + sendEmailFallback: () => Promise; + clearOutgoing: () => void; + + // Actions — incoming + setIncoming: (a: DeviceApprovalRecord | null) => void; + approveIncoming: (evictDeviceRowId: string | null) => Promise; + rejectIncoming: () => Promise; + refreshIncomingFromServer: () => Promise; +}; + +export const useDeviceApprovalStore = create((set, get) => ({ + outgoing: null, + outgoingError: null, + outgoingEmailSent: false, + outgoingLoading: false, + incoming: null, + + async requestApproval(deviceInfo) { + set({ outgoingLoading: true, outgoingError: null }); + try { + const { approval } = await apiFetch<{ approval: DeviceApprovalRecord }>( + "/api/devices/approvals", + { + method: "POST", + body: deviceInfo, + skipDeviceHeader: true, + } + ); + set({ outgoing: approval, outgoingLoading: false }); + return approval; + } catch (err: any) { + set({ + outgoingError: err?.message ?? "Fehler beim Anfordern", + outgoingLoading: false, + }); + return null; + } + }, + + async pollOutgoing() { + const cur = get().outgoing; + if (!cur || cur.status !== "pending") return; + try { + const { approval } = await apiFetch<{ approval: DeviceApprovalRecord }>( + `/api/devices/approvals/${cur.id}`, + { skipDeviceHeader: true } + ); + set({ outgoing: approval }); + } catch { + // Network error → just retry next tick + } + }, + + async sendEmailFallback() { + const cur = get().outgoing; + if (!cur) return; + set({ outgoingError: null }); + try { + const { approval, alreadySent } = await apiFetch<{ + approval: DeviceApprovalRecord; + alreadySent: boolean; + }>(`/api/devices/approvals/${cur.id}/email`, { + method: "POST", + skipDeviceHeader: true, + }); + set({ outgoing: approval, outgoingEmailSent: true }); + if (alreadySent) { + // schon einmal raus — User informieren + } + } catch (err: any) { + set({ outgoingError: err?.message ?? "E-Mail-Versand fehlgeschlagen" }); + } + }, + + clearOutgoing() { + set({ + outgoing: null, + outgoingError: null, + outgoingEmailSent: false, + outgoingLoading: false, + }); + }, + + setIncoming(a) { + set({ incoming: a }); + }, + + async approveIncoming(evictDeviceRowId) { + const cur = get().incoming; + if (!cur) return; + await apiFetch(`/api/devices/approvals/${cur.id}/approve`, { + method: "POST", + body: { evictDeviceRowId }, + }); + set({ incoming: null }); + }, + + async rejectIncoming() { + const cur = get().incoming; + if (!cur) return; + await apiFetch(`/api/devices/approvals/${cur.id}/reject`, { + method: "POST", + }); + set({ incoming: null }); + }, + + async refreshIncomingFromServer() { + try { + const { approvals } = await apiFetch<{ approvals: DeviceApprovalRecord[] }>( + "/api/devices/approvals" + ); + // Show the newest one + const newest = approvals.find((a) => a.status === "pending") ?? null; + // Nur setzen wenn keine andere gerade angezeigt wird ODER newer als die aktuelle + const cur = get().incoming; + if (!cur || (newest && newest.id !== cur.id)) { + set({ incoming: newest }); + } + } catch { + // ignore + } + }, +})); diff --git a/backend/prisma/migrations/20260601_device_approval_requests/migration.sql b/backend/prisma/migrations/20260601_device_approval_requests/migration.sql new file mode 100644 index 0000000..cb7088a --- /dev/null +++ b/backend/prisma/migrations/20260601_device_approval_requests/migration.sql @@ -0,0 +1,55 @@ +-- Apple-Style Two-Device-Approval (iCloud Sign-In pattern) +-- Tracks pending approval requests when a new device tries to register +-- but the user's plan device limit is reached. Code is shown on BOTH the +-- new and an existing active device for visual comparison. + +CREATE TABLE "rebreak"."device_approval_requests" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "user_id" UUID NOT NULL, + "new_device_id" TEXT NOT NULL, + "new_platform" TEXT NOT NULL, + "new_model" TEXT, + "new_name" TEXT, + "new_os_version" TEXT, + "code" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "approved_by_device_row_id" UUID, + "approved_at" TIMESTAMP(3), + "rejected_at" TIMESTAMP(3), + "evicted_device_row_id" UUID, + "email_sent_at" TIMESTAMP(3), + "email_token" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "device_approval_requests_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "device_approval_requests_email_token_key" + ON "rebreak"."device_approval_requests"("email_token"); + +CREATE INDEX "device_approval_requests_user_id_status_idx" + ON "rebreak"."device_approval_requests"("user_id", "status"); + +CREATE INDEX "device_approval_requests_user_id_created_at_idx" + ON "rebreak"."device_approval_requests"("user_id", "created_at" DESC); + +-- FK with cascade — user delete (DSGVO Art. 17) removes all approval requests +ALTER TABLE "rebreak"."device_approval_requests" + ADD CONSTRAINT "device_approval_requests_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- Enable Supabase Realtime so existing devices get instant push when a new +-- approval request appears (postgres_changes subscription on +-- user_id=eq.). +ALTER TABLE "rebreak"."device_approval_requests" REPLICA IDENTITY FULL; +ALTER PUBLICATION supabase_realtime ADD TABLE "rebreak"."device_approval_requests"; + +-- RLS: user can only see their own approval requests +ALTER TABLE "rebreak"."device_approval_requests" ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "device_approval_requests_select_own" + ON "rebreak"."device_approval_requests" + FOR SELECT + USING (auth.uid() = user_id); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 437ff07..357637a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1059,6 +1059,69 @@ model UserDevice { @@schema("rebreak") } +// Apple-Style Two-Device-Approval (iCloud-Sign-In Pattern): +// Wenn ein neues Gerät versucht sich zu registrieren UND das Device-Limit +// erreicht ist (oder User Approval explizit wünscht), erstellt das neue Gerät +// eine DeviceApprovalRequest mit 6-stelligem Code. Andere aktive Geräte des +// Users werden via supabase_realtime benachrichtigt und zeigen ein Sheet mit +// dem Code + [Erlauben] / [Ablehnen]. User vergleicht visuell den Code auf +// beiden Geräten (verhindert Code-Forwarding-Attacken). +// +// Email-Fallback: Wenn kein anderes Gerät online ODER User klickt "Per Email +// senden", verschickt der Server eine Mail mit dem Code + One-Click-Approval-Link. +// +// Flow: +// 1. Neues Device → register → 403 DEVICE_LIMIT_REACHED +// 2. User wählt "Auf anderem Gerät bestätigen" → POST /api/devices/approvals +// 3. Server erstellt Row, broadcastet via realtime +// 4. Existing Device → approve → Server marked approved + auto-evictiert das +// älteste Device (oder das vom User gewählte) + erstellt neuen UserDevice-Slot +// 5. Neues Device pollt GET /api/devices/approvals/:id → status=approved → retry register +model DeviceApprovalRequest { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + + // Info über das NEUE Gerät (das sich anmelden will) + newDeviceId String @map("new_device_id") + newPlatform String @map("new_platform") + newModel String? @map("new_model") + newName String? @map("new_name") + newOsVersion String? @map("new_os_version") + + /// 6-stelliger Code (z.B. "123456"). Wird auf BEIDEN Geräten gezeigt für + /// visuellen Vergleich (iCloud-Pattern). Plain-text gespeichert weil + /// kurzlebig (10min TTL) und an userId gebunden. + code String + + /// pending | approved | rejected | expired + status String @default("pending") + + /// Welches existing Device hat approved (für Audit-Log). + /// NULL wenn via Email-Link approved. + approvedByDeviceRowId String? @map("approved_by_device_row_id") @db.Uuid + approvedAt DateTime? @map("approved_at") + rejectedAt DateTime? @map("rejected_at") + + /// Welches Device wurde evictiert um Platz zu machen (UserDevice.id). + /// NULL wenn User keine Eviction nötig hatte (Slot frei). + evictedDeviceRowId String? @map("evicted_device_row_id") @db.Uuid + + /// Wann Email mit Approval-Link/Code verschickt wurde (Rate-Limit: 1x pro Request). + emailSentAt DateTime? @map("email_sent_at") + /// One-Time-Token für Approval via Email-Link (statt App-Approval). + /// 32-char hex. NULL bis email-fallback getriggert. + emailToken String? @unique @map("email_token") + + createdAt DateTime @default(now()) @map("created_at") + /// Approval läuft nach 10 Minuten ab. + expiresAt DateTime @map("expires_at") + + @@index([userId, status]) + @@index([userId, createdAt(sort: Desc)]) + @@map("device_approval_requests") + @@schema("rebreak") +} + // Multi-Device DNS-Schutz: Legend-User können bis zu 3 Geräte (Mac/iOS/Android/Windows) // mit einem per-Device DoH-Token schützen. Token wird in die mobileconfig/DoH-URL // eingebettet — ist die einzige Auth für den DoH-Server (Phase 2). diff --git a/backend/server/api/devices/approvals/[id].get.ts b/backend/server/api/devices/approvals/[id].get.ts new file mode 100644 index 0000000..ff985c8 --- /dev/null +++ b/backend/server/api/devices/approvals/[id].get.ts @@ -0,0 +1,22 @@ +import { getApprovalRequest } from "../../../db/device-approvals"; + +/** + * GET /api/devices/approvals/:id + * + * Status-Poll für das NEUE Gerät während es darauf wartet dass ein anderes + * Gerät / Email-Link approved. skipDeviceCheck=true. + * + * Frontend pollt alle 2-3s oder via realtime-subscription. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event, { skipDeviceCheck: true }); + const id = getRouterParam(event, "id"); + if (!id) { + throw createError({ statusCode: 400, message: "id required" }); + } + const approval = await getApprovalRequest(id, user.id); + if (!approval) { + throw createError({ statusCode: 404, message: "approval not found" }); + } + return { approval }; +}); diff --git a/backend/server/api/devices/approvals/[id]/approve.post.ts b/backend/server/api/devices/approvals/[id]/approve.post.ts new file mode 100644 index 0000000..ed68518 --- /dev/null +++ b/backend/server/api/devices/approvals/[id]/approve.post.ts @@ -0,0 +1,52 @@ +import { approveRequest } from "../../../../db/device-approvals"; +import { findUserDevice } from "../../../../db/devices"; + +/** + * POST /api/devices/approvals/:id/approve + * + * Aufgerufen von einem EXISTIERENDEN Gerät (mit x-device-id Header). + * Body: { evictDeviceRowId?: string } + * + * Wenn der User am Limit ist, MUSS der Client einen evictDeviceRowId mitschicken + * (das Gerät das ersetzt wird). Der Endpoint löscht atomar den UserDevice + + * markiert Approval als approved. Das NEUE Gerät kann danach `register` neu + * aufrufen — der Slot ist frei. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const id = getRouterParam(event, "id"); + if (!id) { + throw createError({ statusCode: 400, message: "id required" }); + } + + const body = (await readBody(event).catch(() => ({}))) as { + evictDeviceRowId?: string; + }; + + // Map approving deviceId → UserDevice.id für Audit-Log + const approvingDeviceId = getHeader(event, "x-device-id"); + let approvingRowId: string | null = null; + if (approvingDeviceId) { + const row = await findUserDevice(user.id, approvingDeviceId); + approvingRowId = row?.id ?? null; + } + + const approval = await approveRequest({ + approvalId: id, + userId: user.id, + approvedByDeviceRowId: approvingRowId, + evictDeviceRowId: body.evictDeviceRowId ?? null, + }); + + if (!approval) { + throw createError({ statusCode: 404, message: "approval not found" }); + } + if (approval.status !== "approved") { + throw createError({ + statusCode: 409, + message: `approval is ${approval.status}`, + data: { approval }, + }); + } + return { approval }; +}); diff --git a/backend/server/api/devices/approvals/[id]/email.post.ts b/backend/server/api/devices/approvals/[id]/email.post.ts new file mode 100644 index 0000000..777cf1d --- /dev/null +++ b/backend/server/api/devices/approvals/[id]/email.post.ts @@ -0,0 +1,70 @@ +import { getApprovalRequest, markEmailSent } from "../../../../db/device-approvals"; +import { getProfile } from "../../../../db/profile"; +import { sendDeviceApprovalEmail } from "../../../../utils/device-approval-email"; + +/** + * POST /api/devices/approvals/:id/email + * + * Email-Fallback: das NEUE Gerät triggert den Versand der Approval-Mail an die + * User-Email. Nützlich wenn kein anderes Gerät online ist (User hat altes Phone + * verloren / Reinstall). + * + * Rate-Limit: 1 Mail pro Approval-Request (markEmailSent ist idempotent). + * skipDeviceCheck=true. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event, { skipDeviceCheck: true }); + const id = getRouterParam(event, "id"); + if (!id) { + throw createError({ statusCode: 400, message: "id required" }); + } + + const before = await getApprovalRequest(id, user.id); + if (!before) { + throw createError({ statusCode: 404, message: "approval not found" }); + } + if (before.status !== "pending") { + throw createError({ + statusCode: 409, + message: `approval is ${before.status}`, + }); + } + + const updated = await markEmailSent(id, user.id); + if (!updated || !updated.emailToken) { + throw createError({ statusCode: 500, message: "failed to prepare email" }); + } + + // Bereits einmal gesendet → kein Resend (Rate-Limit) + if (before.emailSentAt) { + return { approval: updated, alreadySent: true }; + } + + const profile = await getProfile(user.id); + const config = useRuntimeConfig(event); + const resendApiKey = (config as any).resendApiKey as string | undefined; + const appBaseUrl = + ((config as any).public?.appBaseUrl as string | undefined) ?? + "https://app.rebreak.org"; + + const newDeviceLabel = [ + updated.newName ?? updated.newModel ?? "Unbekanntes Gerät", + `(${updated.newPlatform})`, + ].join(" "); + + // fire-and-forget — Mail-Fehler blockiert nicht den Endpoint + sendDeviceApprovalEmail({ + recipientNickname: profile?.nickname ?? "User", + recipientEmail: user.email ?? "", + code: updated.code, + emailToken: updated.emailToken, + newDeviceLabel, + expiresAt: updated.expiresAt, + resendApiKey: resendApiKey ?? "", + appBaseUrl, + }).catch((err) => + console.error("[approvals/email] send failed:", err?.message ?? err), + ); + + return { approval: updated, alreadySent: false }; +}); diff --git a/backend/server/api/devices/approvals/[id]/reject.post.ts b/backend/server/api/devices/approvals/[id]/reject.post.ts new file mode 100644 index 0000000..66a62f4 --- /dev/null +++ b/backend/server/api/devices/approvals/[id]/reject.post.ts @@ -0,0 +1,21 @@ +import { rejectRequest } from "../../../../db/device-approvals"; + +/** + * POST /api/devices/approvals/:id/reject + * + * Aufgerufen von einem existierenden Gerät wenn der User "Nicht ich" tippt. + * Markiert den Request als rejected. Das neue Gerät sieht beim nächsten Poll + * den finalen Status. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const id = getRouterParam(event, "id"); + if (!id) { + throw createError({ statusCode: 400, message: "id required" }); + } + const approval = await rejectRequest(id, user.id); + if (!approval) { + throw createError({ statusCode: 404, message: "approval not found" }); + } + return { approval }; +}); diff --git a/backend/server/api/devices/approvals/email/[token].post.ts b/backend/server/api/devices/approvals/email/[token].post.ts new file mode 100644 index 0000000..d808608 --- /dev/null +++ b/backend/server/api/devices/approvals/email/[token].post.ts @@ -0,0 +1,73 @@ +import { getApprovalByEmailToken, approveRequest } from "../../../../db/device-approvals"; + +/** + * POST /api/devices/approvals/email/:token + * + * Email-Magic-Link-Endpoint. KEIN Auth nötig — der token (32-char hex) ist das + * Secret. Wird vom App-Web-Frontend (/approve-device?token=...) aufgerufen. + * + * Es wird KEIN evictDeviceRowId-Parameter unterstützt — wenn das Limit voll + * ist, evictiert der Server das ältest-genutzte Device (lastSeenAt ASC). + * Das ist der Trade-Off für Email-Fallback ohne Auth. + */ +export default defineEventHandler(async (event) => { + const token = getRouterParam(event, "token"); + if (!token || token.length !== 64) { + throw createError({ statusCode: 400, message: "invalid token" }); + } + + const approval = await getApprovalByEmailToken(token); + if (!approval) { + throw createError({ statusCode: 404, message: "approval not found" }); + } + if (approval.status !== "pending") { + throw createError({ + statusCode: 409, + message: `approval is ${approval.status}`, + data: { approval }, + }); + } + + // Auto-evict: wenn User am Limit ist, ältest-gesehenes UNGEBUNDENES Device + // entfernen. Gebundene Pro/Legend-Devices werden NIE auto-evictiert (die + // brauchen den 24h-Release-Flow). Sicherheit: der token ist das Secret. + const { usePrisma } = await import("../../../../utils/prisma"); + const { getProfile } = await import("../../../../db/profile"); + const { getPlanLimits } = await import("../../../../utils/plan-features"); + const db = usePrisma(); + const profile = await getProfile(approval.userId); + const limits = getPlanLimits(profile?.plan ?? "free"); + const userDevices = await db.userDevice.findMany({ + where: { userId: approval.userId }, + orderBy: { lastSeenAt: "asc" }, + select: { id: true, boundToPlan: true, deviceId: true }, + }); + // Schon registriert? (Race wenn User mehrfach klickt) → kein evict nötig. + const alreadyRegistered = userDevices.some( + (d) => d.deviceId === approval.newDeviceId, + ); + let evictId: string | null = null; + if (!alreadyRegistered && userDevices.length >= limits.maxAppDevices) { + const oldestUnbound = userDevices.find((d) => !d.boundToPlan); + if (!oldestUnbound) { + throw createError({ + statusCode: 409, + message: "all_devices_locked", + data: { error: "Alle Geräte sind plan-gebunden — bitte erst freigeben" }, + }); + } + evictId = oldestUnbound.id; + } + + const result = await approveRequest({ + approvalId: approval.id, + userId: approval.userId, + approvedByDeviceRowId: null, + evictDeviceRowId: evictId, + }); + + if (!result) { + throw createError({ statusCode: 500, message: "failed to approve" }); + } + return { approval: result }; +}); diff --git a/backend/server/api/devices/approvals/index.get.ts b/backend/server/api/devices/approvals/index.get.ts new file mode 100644 index 0000000..9f1c85f --- /dev/null +++ b/backend/server/api/devices/approvals/index.get.ts @@ -0,0 +1,13 @@ +import { listPendingApprovals } from "../../../db/device-approvals"; + +/** + * GET /api/devices/approvals + * + * Listet alle pending Approval-Requests des Users. Existing Devices rufen das + * beim Start auf (in case realtime missed an event) und beim Realtime-Trigger. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const approvals = await listPendingApprovals(user.id); + return { approvals }; +}); diff --git a/backend/server/api/devices/approvals/index.post.ts b/backend/server/api/devices/approvals/index.post.ts new file mode 100644 index 0000000..ae935b6 --- /dev/null +++ b/backend/server/api/devices/approvals/index.post.ts @@ -0,0 +1,46 @@ +import { createApprovalRequest } from "../../../db/device-approvals"; + +/** + * POST /api/devices/approvals + * + * Aufgerufen vom NEUEN Gerät nachdem `register` mit DEVICE_LIMIT_REACHED + * fehlgeschlagen ist und der User "Auf anderem Gerät bestätigen" gewählt hat. + * + * Body: { deviceId, platform, model?, name?, osVersion? } + * + * Returnt das Approval-Record (mit code für UI-Anzeige + expiresAt). + * Existierende Geräte des Users werden via supabase_realtime (postgres_changes + * INSERT on device_approval_requests filtered by user_id) automatisch + * benachrichtigt — kein extra Push nötig. + * + * Bootstrap: skipDeviceCheck=true weil das Device noch nicht registriert ist. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event, { skipDeviceCheck: true }); + const body = await readBody(event); + const { deviceId, platform, model, name, osVersion } = body as { + deviceId?: string; + platform?: string; + model?: string; + name?: string; + osVersion?: string; + }; + + if (!deviceId || !platform) { + throw createError({ statusCode: 400, message: "deviceId + platform required" }); + } + if (!["ios", "android", "web"].includes(platform)) { + throw createError({ statusCode: 400, message: "invalid platform" }); + } + + const approval = await createApprovalRequest({ + userId: user.id, + newDeviceId: deviceId, + newPlatform: platform, + newModel: model ?? null, + newName: name ?? null, + newOsVersion: osVersion ?? null, + }); + + return { approval }; +}); diff --git a/backend/server/db/device-approvals.ts b/backend/server/db/device-approvals.ts new file mode 100644 index 0000000..cdd6a60 --- /dev/null +++ b/backend/server/db/device-approvals.ts @@ -0,0 +1,266 @@ +/** + * Apple-Style Device-Approval (iCloud Sign-In Pattern). + * + * Wenn ein neues Gerät registriert wird und das Device-Limit erreicht ist, + * kann der User auf einem bereits angemeldeten Gerät den Login bestätigen + * (Variante 2 — Code wird auf BEIDEN Geräten gezeigt für visuellen Vergleich). + * + * Fallback: Wenn kein anderes Device aktiv → Email mit One-Click-Approval-Link. + * + * TTL: 10 Minuten. Codes sind 6-stellig numerisch. + */ + +import crypto from "node:crypto"; +import { usePrisma } from "../utils/prisma"; + +const APPROVAL_TTL_MS = 10 * 60 * 1000; // 10 min +/** Devices die innerhalb dieser Zeit gesehen wurden gelten als "online genug für Push". */ +const ACTIVE_DEVICE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // 7d + +export interface DeviceApprovalRecord { + id: string; + userId: string; + newDeviceId: string; + newPlatform: string; + newModel: string | null; + newName: string | null; + newOsVersion: string | null; + code: string; + status: string; + approvedByDeviceRowId: string | null; + approvedAt: Date | null; + rejectedAt: Date | null; + evictedDeviceRowId: string | null; + emailSentAt: Date | null; + createdAt: Date; + expiresAt: Date; +} + +const APPROVAL_SELECT = { + id: true, + userId: true, + newDeviceId: true, + newPlatform: true, + newModel: true, + newName: true, + newOsVersion: true, + code: true, + status: true, + approvedByDeviceRowId: true, + approvedAt: true, + rejectedAt: true, + evictedDeviceRowId: true, + emailSentAt: true, + createdAt: true, + expiresAt: true, +} as const; + +/** Generiert einen 6-stelligen numerischen Code (000000-999999). */ +function generateCode(): string { + // 0..999999 cryptographically random + const n = crypto.randomInt(0, 1_000_000); + return n.toString().padStart(6, "0"); +} + +function generateEmailToken(): string { + return crypto.randomBytes(32).toString("hex"); +} + +/** Hat der User mind. 1 anderes Device das innerhalb des Active-Windows gesehen wurde? */ +export async function hasOtherActiveDevices( + userId: string, + excludeDeviceId: string, +): Promise { + const db = usePrisma(); + const cutoff = new Date(Date.now() - ACTIVE_DEVICE_WINDOW_MS); + const count = await db.userDevice.count({ + where: { + userId, + deviceId: { not: excludeDeviceId }, + lastSeenAt: { gte: cutoff }, + }, + }); + return count > 0; +} + +/** + * Erstellt einen neuen Approval-Request. Cancelt alle anderen pending Requests + * für dasselbe newDeviceId (verhindert Spam wenn User mehrfach klickt). + */ +export async function createApprovalRequest(opts: { + userId: string; + newDeviceId: string; + newPlatform: string; + newModel?: string | null; + newName?: string | null; + newOsVersion?: string | null; +}): Promise { + const db = usePrisma(); + + // Expire alte pending Requests für dieses Device (Cleanup). + await db.deviceApprovalRequest.updateMany({ + where: { + userId: opts.userId, + newDeviceId: opts.newDeviceId, + status: "pending", + }, + data: { status: "expired" }, + }); + + const code = generateCode(); + const expiresAt = new Date(Date.now() + APPROVAL_TTL_MS); + + return db.deviceApprovalRequest.create({ + data: { + userId: opts.userId, + newDeviceId: opts.newDeviceId, + newPlatform: opts.newPlatform, + newModel: opts.newModel ?? null, + newName: opts.newName ?? null, + newOsVersion: opts.newOsVersion ?? null, + code, + expiresAt, + }, + select: APPROVAL_SELECT, + }); +} + +/** Holt einen Approval-Request mit Ownership-Check. */ +export async function getApprovalRequest( + id: string, + userId: string, +): Promise { + const db = usePrisma(); + const row = await db.deviceApprovalRequest.findFirst({ + where: { id, userId }, + select: APPROVAL_SELECT, + }); + if (!row) return null; + return maybeExpire(row); +} + +/** Holt einen Approval-Request per Email-Token (für Email-Link-Approval). */ +export async function getApprovalByEmailToken( + token: string, +): Promise { + const db = usePrisma(); + const row = await db.deviceApprovalRequest.findUnique({ + where: { emailToken: token }, + select: APPROVAL_SELECT, + }); + if (!row) return null; + return maybeExpire(row); +} + +/** Listet pending Requests für einen User (für Banner auf existierenden Devices). */ +export async function listPendingApprovals( + userId: string, +): Promise { + const db = usePrisma(); + const rows = await db.deviceApprovalRequest.findMany({ + where: { + userId, + status: "pending", + expiresAt: { gt: new Date() }, + }, + orderBy: { createdAt: "desc" }, + select: APPROVAL_SELECT, + }); + return rows; +} + +/** + * Approve: marks request as approved + (atomically) registers the new device, + * optionally evicting an existing device if the user is at the limit. + * + * Returns the approved record. Caller (endpoint) is responsible for the + * actual UserDevice creation since registerDevice() handles merge-heuristic + * + slot-check anyway — but we DO evict here if evictDeviceRowId is given + * so registerDevice doesn't fail with DEVICE_LIMIT_REACHED again. + */ +export async function approveRequest(opts: { + approvalId: string; + userId: string; + approvedByDeviceRowId: string | null; // null = via email-link + evictDeviceRowId: string | null; +}): Promise { + const db = usePrisma(); + + const existing = await getApprovalRequest(opts.approvalId, opts.userId); + if (!existing) return null; + if (existing.status !== "pending") return existing; + + // Eviction transactional with status update. + return db.$transaction(async (tx) => { + if (opts.evictDeviceRowId) { + // Make sure the device-to-evict belongs to the user (defense in depth). + await tx.userDevice.deleteMany({ + where: { id: opts.evictDeviceRowId, userId: opts.userId }, + }); + } + return tx.deviceApprovalRequest.update({ + where: { id: opts.approvalId }, + data: { + status: "approved", + approvedAt: new Date(), + approvedByDeviceRowId: opts.approvedByDeviceRowId, + evictedDeviceRowId: opts.evictDeviceRowId, + }, + select: APPROVAL_SELECT, + }); + }); +} + +export async function rejectRequest( + approvalId: string, + userId: string, +): Promise { + const db = usePrisma(); + const existing = await getApprovalRequest(approvalId, userId); + if (!existing) return null; + if (existing.status !== "pending") return existing; + + return db.deviceApprovalRequest.update({ + where: { id: approvalId }, + data: { status: "rejected", rejectedAt: new Date() }, + select: APPROVAL_SELECT, + }); +} + +/** + * Markiert dass eine Email mit Approval-Link verschickt wurde. Generiert + * (idempotent) einen emailToken den der Link enthält. + * Rate-Limit: 1 Email pro Approval-Request. + */ +export async function markEmailSent( + approvalId: string, + userId: string, +): Promise { + const db = usePrisma(); + const existing = await getApprovalRequest(approvalId, userId); + if (!existing) return null; + if (existing.status !== "pending") return existing; + if (existing.emailSentAt) return existing; // already sent + + const token = existing.emailToken ?? generateEmailToken(); + return db.deviceApprovalRequest.update({ + where: { id: approvalId }, + data: { emailSentAt: new Date(), emailToken: token }, + select: APPROVAL_SELECT, + }); +} + +/** Wenn expiresAt überschritten und status noch pending → setze auf expired. */ +async function maybeExpire( + row: DeviceApprovalRecord, +): Promise { + if (row.status !== "pending") return row; + if (row.expiresAt.getTime() > Date.now()) return row; + const db = usePrisma(); + const updated = await db.deviceApprovalRequest.update({ + where: { id: row.id }, + data: { status: "expired" }, + select: APPROVAL_SELECT, + }); + return updated; +} diff --git a/backend/server/utils/device-approval-email.ts b/backend/server/utils/device-approval-email.ts new file mode 100644 index 0000000..64fe8ff --- /dev/null +++ b/backend/server/utils/device-approval-email.ts @@ -0,0 +1,110 @@ +/** + * Device-Approval Email (Fallback wenn kein anderes Device online). + * + * Sendet eine Mail an die User-Email mit: + * - 6-stelligem Code (zur Anzeige / visuellem Vergleich) + * - Magic-Link (https://app.rebreak.org/approve-device?token=XYZ) der den + * Approval direkt bestätigt — User muss nicht eingeloggt sein. + * + * Lyra-Voice: NICHT verwenden — strikt informational/sicherheitsneutral. + */ + +import { Resend } from "resend"; + +export interface DeviceApprovalEmailOpts { + recipientNickname: string; + recipientEmail: string; + code: string; + emailToken: string; + newDeviceLabel: string; // z.B. "iPhone 15 Pro (iOS)" + expiresAt: Date; + resendApiKey: string; + appBaseUrl?: string; +} + +export async function sendDeviceApprovalEmail( + opts: DeviceApprovalEmailOpts, +): Promise { + if (!opts.resendApiKey) { + console.warn("[device-approval-email] resendApiKey not provided — skipping"); + return; + } + + const resend = new Resend(opts.resendApiKey); + const baseUrl = opts.appBaseUrl ?? "https://app.rebreak.org"; + const approveUrl = `${baseUrl}/approve-device?token=${encodeURIComponent(opts.emailToken)}`; + const ttlMinutes = Math.max( + 1, + Math.round((opts.expiresAt.getTime() - Date.now()) / 60_000), + ); + + const subject = `ReBreak: Neues Gerät bestätigen (Code ${opts.code})`; + + const html = ` + + + + + + ${subject} + + + +
+
+

ReBreak — Neues Gerät bestätigen

+
+
+

Hallo ${opts.recipientNickname},

+

+ Es wurde versucht, sich auf ${opts.newDeviceLabel} bei deinem + ReBreak-Account anzumelden. Wenn du das warst, bestätige bitte unten. +

+
+
Code zum Vergleich
+
${opts.code}
+
+ Gültig für ${ttlMinutes} Minuten +
+
+

+ Vergleiche diesen Code mit dem, der auf deinem neuen Gerät angezeigt wird. + Wenn die Codes übereinstimmen, klicke auf den Button. +

+ Gerät bestätigen +
+ Wenn das nicht du warst: Ignoriere diese Mail. Ohne Bestätigung + bekommt das Gerät keinen Zugang. Der Versuch läuft in ${ttlMinutes} Minuten ab. +
+
+ +
+ + + `.trim(); + + try { + await resend.emails.send({ + from: "ReBreak ", + to: opts.recipientEmail, + subject, + html, + }); + } catch (err: any) { + console.error("[device-approval-email] Failed to send:", err?.message ?? err); + } +}