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

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