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.
182 lines
4.9 KiB
TypeScript
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
|
|
}
|
|
},
|
|
}));
|