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.
53 lines
1.6 KiB
TypeScript
53 lines
1.6 KiB
TypeScript
import { approveRequest } from "../../../../db/device-approvals";
|
|
import { findUserDevice } from "../../../../db/devices";
|
|
|
|
/**
|
|
* POST /api/devices/approvals/:id/approve
|
|
*
|
|
* Aufgerufen von einem EXISTIERENDEN Gerät (mit x-device-id Header).
|
|
* Body: { evictDeviceRowId?: string }
|
|
*
|
|
* Wenn der User am Limit ist, MUSS der Client einen evictDeviceRowId mitschicken
|
|
* (das Gerät das ersetzt wird). Der Endpoint löscht atomar den UserDevice +
|
|
* markiert Approval als approved. Das NEUE Gerät kann danach `register` neu
|
|
* aufrufen — der Slot ist frei.
|
|
*/
|
|
export default defineEventHandler(async (event) => {
|
|
const user = await requireUser(event);
|
|
const id = getRouterParam(event, "id");
|
|
if (!id) {
|
|
throw createError({ statusCode: 400, message: "id required" });
|
|
}
|
|
|
|
const body = (await readBody(event).catch(() => ({}))) as {
|
|
evictDeviceRowId?: string;
|
|
};
|
|
|
|
// Map approving deviceId → UserDevice.id für Audit-Log
|
|
const approvingDeviceId = getHeader(event, "x-device-id");
|
|
let approvingRowId: string | null = null;
|
|
if (approvingDeviceId) {
|
|
const row = await findUserDevice(user.id, approvingDeviceId);
|
|
approvingRowId = row?.id ?? null;
|
|
}
|
|
|
|
const approval = await approveRequest({
|
|
approvalId: id,
|
|
userId: user.id,
|
|
approvedByDeviceRowId: approvingRowId,
|
|
evictDeviceRowId: body.evictDeviceRowId ?? null,
|
|
});
|
|
|
|
if (!approval) {
|
|
throw createError({ statusCode: 404, message: "approval not found" });
|
|
}
|
|
if (approval.status !== "approved") {
|
|
throw createError({
|
|
statusCode: 409,
|
|
message: `approval is ${approval.status}`,
|
|
data: { approval },
|
|
});
|
|
}
|
|
return { approval };
|
|
});
|