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.
74 lines
2.7 KiB
TypeScript
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 };
|
|
});
|