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:
parent
efca157969
commit
2e056c7257
@ -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,
|
||||
|
||||
384
apps/rebreak-native/components/DeviceApprovalIncomingSheet.tsx
Normal file
384
apps/rebreak-native/components/DeviceApprovalIncomingSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
309
apps/rebreak-native/components/DeviceApprovalPendingSheet.tsx
Normal file
309
apps/rebreak-native/components/DeviceApprovalPendingSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
87
apps/rebreak-native/hooks/useDeviceApprovalRealtime.ts
Normal file
87
apps/rebreak-native/hooks/useDeviceApprovalRealtime.ts
Normal 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]);
|
||||
}
|
||||
@ -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 — تمرين التنفس",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
181
apps/rebreak-native/stores/deviceApproval.ts
Normal file
181
apps/rebreak-native/stores/deviceApproval.ts
Normal 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
|
||||
}
|
||||
},
|
||||
}));
|
||||
@ -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);
|
||||
@ -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).
|
||||
|
||||
22
backend/server/api/devices/approvals/[id].get.ts
Normal file
22
backend/server/api/devices/approvals/[id].get.ts
Normal 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 };
|
||||
});
|
||||
52
backend/server/api/devices/approvals/[id]/approve.post.ts
Normal file
52
backend/server/api/devices/approvals/[id]/approve.post.ts
Normal 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 };
|
||||
});
|
||||
70
backend/server/api/devices/approvals/[id]/email.post.ts
Normal file
70
backend/server/api/devices/approvals/[id]/email.post.ts
Normal 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 };
|
||||
});
|
||||
21
backend/server/api/devices/approvals/[id]/reject.post.ts
Normal file
21
backend/server/api/devices/approvals/[id]/reject.post.ts
Normal 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 };
|
||||
});
|
||||
73
backend/server/api/devices/approvals/email/[token].post.ts
Normal file
73
backend/server/api/devices/approvals/email/[token].post.ts
Normal 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 };
|
||||
});
|
||||
13
backend/server/api/devices/approvals/index.get.ts
Normal file
13
backend/server/api/devices/approvals/index.get.ts
Normal 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 };
|
||||
});
|
||||
46
backend/server/api/devices/approvals/index.post.ts
Normal file
46
backend/server/api/devices/approvals/index.post.ts
Normal 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 };
|
||||
});
|
||||
266
backend/server/db/device-approvals.ts
Normal file
266
backend/server/db/device-approvals.ts
Normal 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;
|
||||
}
|
||||
110
backend/server/utils/device-approval-email.ts
Normal file
110
backend/server/utils/device-approval-email.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user