rebreak-monorepo/apps/rebreak-native/hooks/useUserDevicesRealtime.ts
chahinebrini a95e66560d feat(magic): Hard-Lock + Geräte-UX (Push, Realtime, Detail-Sheet, Offline-Removal)
Devices/Magic:
- Offline-Profil-Enroll deaktiviert (410) — Lock-PW würde im Klartext im
  Download landen; stationärer Schutz läuft jetzt nur über Rebreak Magic
- Mac-DNS-Template: ProhibitDisablement (Filter nicht abschaltbar)
- Push "Neues Gerät verbunden" an mobile Geräte bei neuer Bindung
- Realtime auf user_devices → Settings aktualisiert Magic-Bindings live
- Geräte-Detail-Sheet (Tap auf Gerät): Status, verbunden-seit, Schutz-Donut

Hard-Lock (server-gehaltenes Removal-PW, User sieht es nie):
- magic_removal_password generiert/gespeichert + in Profil injiziert (Lazy-Backfill)
- Reveal NUR bei Account-Löschung (user/delete) + Kündigung (stripe webhook),
  per Resend-Mail + in-Response
- Signing config-gated (inaktiv ohne Cert; Lock greift auch unsigniert)

Migrations: user_devices-Realtime-Publication + magic_removal_password-Spalten

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:26:25 +02:00

72 lines
2.2 KiB
TypeScript

import { useEffect } from "react";
import { supabase } from "../lib/supabase";
import type { RealtimeChannel } from "@supabase/supabase-js";
import { useDevicesStore } from "../stores/devices";
/**
* Realtime-Subscription auf `rebreak.user_devices` für den eingeloggten User.
*
* Trigger-Fall: Die Magic-Desktop-App (Mac/Windows) ruft serverseitig
* /api/magic/register → neues UserDevice-INSERT. Diese Subscription lässt die
* Geräte-Liste in den Settings sofort nachladen, ohne dass der User pullt.
*
* Spiegelt [[useProtectedDevicesRealtime]]: publication-only (keine RLS), wir
* reagieren auf jedes Event und reloaden den Store — keine Abhängigkeit von
* payload.old (REPLICA IDENTITY irrelevant).
*/
export function useUserDevicesRealtime(enabled: boolean = true) {
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 || cancelled) return;
const userId = session.user.id;
channel = supabase
.channel(`user-devices:${userId}:${Date.now()}`)
.on(
"postgres_changes",
{
event: "*",
schema: "rebreak",
table: "user_devices",
filter: `user_id=eq.${userId}`,
},
() => {
useDevicesStore.getState().loadDevices();
},
)
.subscribe((status: string) => {
if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
cleanup();
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
if (!cancelled) subscribe();
}, 3000);
}
});
}
function cleanup() {
if (channel) {
supabase.removeChannel(channel);
channel = null;
}
}
subscribe();
return () => {
cancelled = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
cleanup();
};
}, [enabled]);
}