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.
71 lines
2.3 KiB
TypeScript
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 };
|
|
});
|