feat(passkeys): surface last-used, rename, and an upgrade nudge

Stamp each passkey's last-used time on a successful assertion and return
it from the list, add a rename endpoint, and add a dismissible
add-a-passkey nudge for accounts that have none. Share the relying-party
configuration with the second-factor path.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:40:53 +02:00
1 parent 86ac1e1d12
commit 64ea8e4102
4 files changed
+93 -36

No files matched your search

@@ -0,0 +1,24 @@
/**
* POST /api/auth/me/passkey-nudge/dismiss
*
* Permanently dismiss the "add a passkey" upgrade nudge for the calling user.
* Cookie-only; idempotent. Sets `User.passkeyUpgradeNudgeDismissed = true`.
*/
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async () => {
const { user } = await requireCookieAuth();
await prisma.user.update({
where: { id: user.id },
data: { passkeyUpgradeNudgeDismissed: true },
});
annotate({ action: { name: "auth.passkey.nudge.dismiss" } });
return apiSuccess({ dismissed: true });
});
+54 -1
View File
@@ -1,9 +1,62 @@
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { webauthnKeyNameSchema } from "@/lib/validations/mfa";
import { z } from "zod/v4";
const passkeyRenameSchema = z.object({ name: webauthnKeyNameSchema });
export const PATCH = apiHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user } = await requireAuth();
const { id } = await params;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = passkeyRenameSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
}
const passkey = await prisma.passkey.findUnique({
where: { id },
select: { userId: true },
});
if (!passkey || passkey.userId !== user.id) {
return apiError("Passkey not found", 404);
}
const updated = await prisma.passkey.update({
where: { id },
data: { name: parsed.data.name },
select: {
id: true,
name: true,
credentialDeviceType: true,
credentialBackedUp: true,
createdAt: true,
lastUsedAt: true,
},
});
annotate({ action: { name: "auth.passkey.rename" } });
return apiSuccess(updated);
},
);
export const DELETE = apiHandler(
async (
+1
View File
@@ -16,6 +16,7 @@ export const GET = apiHandler(async () => {
credentialDeviceType: true,
credentialBackedUp: true,
createdAt: true,
lastUsedAt: true,
},
orderBy: { createdAt: "desc" },
});