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.
This commit is contained in:
chahinebrini 2026-06-01 02:36:28 +02:00
parent efca157969
commit 2e056c7257
21 changed files with 1946 additions and 4 deletions

View File

@ -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() {
<AppLockGate>
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
<DeviceLimitReachedSheet />
<DeviceApprovalIncomingSheet />
<DeviceApprovalPendingSheet
onApproved={() => {
// Slot wurde freigegeben — register retry
useDevicesStore.getState().ensureRegistered().catch(() => {});
}}
/>
<Stack
screenOptions={{
headerShown: false,

View File

@ -0,0 +1,384 @@
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>
);
}

View File

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

View File

@ -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<TrueSheet>(null);
const { visible, devices, max, plan, hide, removeDevice } = useDeviceLimitStore();
const requestApproval = useDeviceApprovalStore((s) => s.requestApproval);
const [removingId, setRemovingId] = useState<string | null>(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')}
</Text>
{/* Apple-Style Approval: erlaube User auf anderem Gerät zu bestätigen */}
<TouchableOpacity
onPress={handleRequestApproval}
disabled={requestingApproval}
activeOpacity={0.7}
style={{
marginTop: 16,
paddingVertical: 14,
borderRadius: 12,
backgroundColor: '#007AFF',
alignItems: 'center',
opacity: requestingApproval ? 0.7 : 1,
}}
>
{requestingApproval ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text
style={{
fontSize: 15,
color: '#fff',
fontFamily: 'Nunito_700Bold',
}}
>
{t('device_limit.approve_on_other')}
</Text>
)}
</TouchableOpacity>
</View>
</TrueSheet>
);

View File

@ -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<typeof setTimeout> | 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]);
}

View File

@ -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 — تمرين التنفس",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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<DeviceApprovalRecord | null>;
pollOutgoing: () => Promise<void>;
sendEmailFallback: () => Promise<void>;
clearOutgoing: () => void;
// Actions — incoming
setIncoming: (a: DeviceApprovalRecord | null) => void;
approveIncoming: (evictDeviceRowId: string | null) => Promise<void>;
rejectIncoming: () => Promise<void>;
refreshIncomingFromServer: () => Promise<void>;
};
export const useDeviceApprovalStore = create<State>((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
}
},
}));

View File

@ -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.<currentUser>).
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);

View File

@ -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).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<boolean> {
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<DeviceApprovalRecord> {
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<DeviceApprovalRecord | null> {
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<DeviceApprovalRecord | null> {
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<DeviceApprovalRecord[]> {
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<DeviceApprovalRecord | null> {
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<DeviceApprovalRecord | null> {
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<DeviceApprovalRecord | null> {
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<DeviceApprovalRecord> {
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;
}

View File

@ -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<void> {
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 = `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #1a1a1a; background: #f5f5f7; margin: 0; padding: 0; }
.container { max-width: 560px; margin: 32px auto; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.header { background: #1a1a1a; padding: 24px 32px; }
.header h1 { color: #fff; font-size: 18px; font-weight: 600; margin: 0; letter-spacing: -0.3px; }
.body { padding: 28px 32px; }
.body p { font-size: 15px; line-height: 1.6; color: #3a3a3a; margin: 0 0 16px; }
.code-box { background: #f5f5f7; border-radius: 8px; padding: 20px; margin: 24px 0; text-align: center; }
.code { font-family: 'SF Mono', Monaco, monospace; font-size: 32px; font-weight: 600; letter-spacing: 8px; color: #1a1a1a; }
.btn { display: block; text-align: center; padding: 14px 20px; border-radius: 8px; font-size: 15px; font-weight: 500; text-decoration: none; background: #1a1a1a; color: #fff !important; margin: 24px 0; }
.footer { padding: 16px 32px; font-size: 12px; color: #888; border-top: 1px solid #f0f0f0; }
.warning { background: #fff8e1; border-left: 3px solid #f59e0b; padding: 12px 16px; margin: 16px 0; font-size: 13px; color: #555; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>ReBreak Neues Gerät bestätigen</h1>
</div>
<div class="body">
<p>Hallo ${opts.recipientNickname},</p>
<p>
Es wurde versucht, sich auf <strong>${opts.newDeviceLabel}</strong> bei deinem
ReBreak-Account anzumelden. Wenn du das warst, bestätige bitte unten.
</p>
<div class="code-box">
<div style="font-size:12px;color:#888;margin-bottom:8px;">Code zum Vergleich</div>
<div class="code">${opts.code}</div>
<div style="font-size:12px;color:#888;margin-top:12px;">
Gültig für ${ttlMinutes} Minuten
</div>
</div>
<p>
Vergleiche diesen Code mit dem, der auf deinem neuen Gerät angezeigt wird.
Wenn die Codes übereinstimmen, klicke auf den Button.
</p>
<a href="${approveUrl}" class="btn">Gerät bestätigen</a>
<div class="warning">
<strong>Wenn das nicht du warst:</strong> Ignoriere diese Mail. Ohne Bestätigung
bekommt das Gerät keinen Zugang. Der Versuch läuft in ${ttlMinutes} Minuten ab.
</div>
</div>
<div class="footer">
Diese Mail wurde automatisch verschickt. Fragen? support@rebreak.org
</div>
</div>
</body>
</html>
`.trim();
try {
await resend.emails.send({
from: "ReBreak <noreply@rebreak.org>",
to: opts.recipientEmail,
subject,
html,
});
} catch (err: any) {
console.error("[device-approval-email] Failed to send:", err?.message ?? err);
}
}