rebreak-monorepo/apps/rebreak-native/hooks/useDeviceApprovalRealtime.ts
chahinebrini 2e056c7257 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.
2026-06-01 02:36:28 +02:00

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