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

182 lines
4.9 KiB
TypeScript

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