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

74 lines
2.7 KiB
TypeScript

import { getApprovalByEmailToken, approveRequest } from "../../../../db/device-approvals";
/**
* POST /api/devices/approvals/email/:token
*
* Email-Magic-Link-Endpoint. KEIN Auth nötig — der token (32-char hex) ist das
* Secret. Wird vom App-Web-Frontend (/approve-device?token=...) aufgerufen.
*
* Es wird KEIN evictDeviceRowId-Parameter unterstützt — wenn das Limit voll
* ist, evictiert der Server das ältest-genutzte Device (lastSeenAt ASC).
* Das ist der Trade-Off für Email-Fallback ohne Auth.
*/
export default defineEventHandler(async (event) => {
const token = getRouterParam(event, "token");
if (!token || token.length !== 64) {
throw createError({ statusCode: 400, message: "invalid token" });
}
const approval = await getApprovalByEmailToken(token);
if (!approval) {
throw createError({ statusCode: 404, message: "approval not found" });
}
if (approval.status !== "pending") {
throw createError({
statusCode: 409,
message: `approval is ${approval.status}`,
data: { approval },
});
}
// Auto-evict: wenn User am Limit ist, ältest-gesehenes UNGEBUNDENES Device
// entfernen. Gebundene Pro/Legend-Devices werden NIE auto-evictiert (die
// brauchen den 24h-Release-Flow). Sicherheit: der token ist das Secret.
const { usePrisma } = await import("../../../../utils/prisma");
const { getProfile } = await import("../../../../db/profile");
const { getPlanLimits } = await import("../../../../utils/plan-features");
const db = usePrisma();
const profile = await getProfile(approval.userId);
const limits = getPlanLimits(profile?.plan ?? "free");
const userDevices = await db.userDevice.findMany({
where: { userId: approval.userId },
orderBy: { lastSeenAt: "asc" },
select: { id: true, boundToPlan: true, deviceId: true },
});
// Schon registriert? (Race wenn User mehrfach klickt) → kein evict nötig.
const alreadyRegistered = userDevices.some(
(d) => d.deviceId === approval.newDeviceId,
);
let evictId: string | null = null;
if (!alreadyRegistered && userDevices.length >= limits.maxAppDevices) {
const oldestUnbound = userDevices.find((d) => !d.boundToPlan);
if (!oldestUnbound) {
throw createError({
statusCode: 409,
message: "all_devices_locked",
data: { error: "Alle Geräte sind plan-gebunden — bitte erst freigeben" },
});
}
evictId = oldestUnbound.id;
}
const result = await approveRequest({
approvalId: approval.id,
userId: approval.userId,
approvedByDeviceRowId: null,
evictDeviceRowId: evictId,
});
if (!result) {
throw createError({ statusCode: 500, message: "failed to approve" });
}
return { approval: result };
});