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.
88 lines
2.6 KiB
TypeScript
88 lines
2.6 KiB
TypeScript
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]);
|
|
}
|