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

71 lines
2.3 KiB
TypeScript

import { getApprovalRequest, markEmailSent } from "../../../../db/device-approvals";
import { getProfile } from "../../../../db/profile";
import { sendDeviceApprovalEmail } from "../../../../utils/device-approval-email";
/**
* POST /api/devices/approvals/:id/email
*
* Email-Fallback: das NEUE Gerät triggert den Versand der Approval-Mail an die
* User-Email. Nützlich wenn kein anderes Gerät online ist (User hat altes Phone
* verloren / Reinstall).
*
* Rate-Limit: 1 Mail pro Approval-Request (markEmailSent ist idempotent).
* skipDeviceCheck=true.
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event, { skipDeviceCheck: true });
const id = getRouterParam(event, "id");
if (!id) {
throw createError({ statusCode: 400, message: "id required" });
}
const before = await getApprovalRequest(id, user.id);
if (!before) {
throw createError({ statusCode: 404, message: "approval not found" });
}
if (before.status !== "pending") {
throw createError({
statusCode: 409,
message: `approval is ${before.status}`,
});
}
const updated = await markEmailSent(id, user.id);
if (!updated || !updated.emailToken) {
throw createError({ statusCode: 500, message: "failed to prepare email" });
}
// Bereits einmal gesendet → kein Resend (Rate-Limit)
if (before.emailSentAt) {
return { approval: updated, alreadySent: true };
}
const profile = await getProfile(user.id);
const config = useRuntimeConfig(event);
const resendApiKey = (config as any).resendApiKey as string | undefined;
const appBaseUrl =
((config as any).public?.appBaseUrl as string | undefined) ??
"https://app.rebreak.org";
const newDeviceLabel = [
updated.newName ?? updated.newModel ?? "Unbekanntes Gerät",
`(${updated.newPlatform})`,
].join(" ");
// fire-and-forget — Mail-Fehler blockiert nicht den Endpoint
sendDeviceApprovalEmail({
recipientNickname: profile?.nickname ?? "User",
recipientEmail: user.email ?? "",
code: updated.code,
emailToken: updated.emailToken,
newDeviceLabel,
expiresAt: updated.expiresAt,
resendApiKey: resendApiKey ?? "",
appBaseUrl,
}).catch((err) =>
console.error("[approvals/email] send failed:", err?.message ?? err),
);
return { approval: updated, alreadySent: false };
});