fix(auth): say why a registration or passkey sign-in was refused

Once the sign-in and registration pages read codes instead of the
server's prose, a taken username or address said only "Registration
failed", an expired invitation said "You don't have permission for
this", and a passkey this server does not know, a passkey sign-in that
took too long, an unreadable passkey answer, a single-sign-on-only
server and an already signed-in visitor all said "Login failed".

The registration route now names its refusals (auth.register.taken for
both 409s, auth.register.invite_invalid for a refused invitation), and
those codes, the three passkey refusals, oidc_only and
auth.already_authenticated get a sentence in every language.
This commit is contained in:
Marc-André Bombeck committed 2026-11-12 14:00:18 +01:00
1 parent 94ea48d723
commit 37734c9fd8
16 files changed
+253 -29

No files matched your search

@@ -226,8 +226,13 @@ describe("POST /api/auth/register — first-admin atomicity (M1)", () => {
const res = await postPathThrough(validBody);
expect(res.status).toBe(409);
const body = (await res.json()) as { error: string };
const body = (await res.json()) as {
error: string;
meta?: { errorCode?: string };
};
expect(body.error).toBe("Username or email already taken");
// The code the registration page turns into "already in use".
expect(body.meta?.errorCode).toBe("auth.register.taken");
});
});
+9 -3
View File
@@ -144,7 +144,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
prisma.user.findUnique({ where: { username } }),
]);
if (existingEmail || existingUsername) {
return apiError("Username or email already taken", 409);
return apiError("Username or email already taken", 409, {
errorCode: "auth.register.taken",
});
}
// Validate password strength (locale-aware feedback)
@@ -196,7 +198,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
if (!registrationEnabled) {
return apiError("Invalid or expired invite", 403);
return apiError("Invalid or expired invite", 403, {
errorCode: "auth.register.invite_invalid",
});
}
}
}
@@ -249,7 +253,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (user === null) {
return apiError("Username or email already taken", 409);
return apiError("Username or email already taken", 409, {
errorCode: "auth.register.taken",
});
}
// Create session immediately. v1.4.22 W5 reconcile (Sr-H1) —
@@ -125,14 +125,39 @@ describe("registration refusals", () => {
return screen;
}
it("says the registration failed instead of the server's words", async () => {
it("says what to change when the name or address is taken", async () => {
const screen = await register({
status: 409,
error: "Username or email already taken",
meta: { errorCode: "http.conflict" },
meta: { errorCode: "auth.register.taken" },
});
await expect
.element(
screen.getByText("That username or email address is already in use."),
)
.toBeVisible();
expect(document.body.textContent).not.toContain("already taken");
});
it("tells an invitee their link no longer works", async () => {
const screen = await register({
status: 403,
error: "Invalid or expired invite",
meta: { errorCode: "auth.register.invite_invalid" },
});
await expect
.element(screen.getByText(/This invitation is no longer valid/))
.toBeVisible();
});
it("says the registration failed for a refusal it has no words for", async () => {
const screen = await register({
status: 500,
error: "Internal error",
meta: { errorCode: "http.internal" },
});
await expect.element(screen.getByText("Registration failed")).toBeVisible();
expect(document.body.textContent).not.toContain("already taken");
expect(document.body.textContent).not.toContain("Internal error");
});
it("shows the password feedback, which the server words for the reader", async () => {
@@ -268,6 +268,42 @@ describe("localizedSignInError", () => {
expect(sentence).not.toBe("auth.signInRateLimited");
});
it.each([
["de", de],
["en", en],
["es", es],
["fr", fr],
["it", itCatalog],
["ko", ko],
["nl", nl],
["pl", pl],
["pt-BR", ptBR],
["ru", ru],
])(
"names the passkey, single sign-on and registration refusals in %s",
(_, locale) => {
const localT = translator(locale as Catalog);
for (const [status, code] of [
[401, "passkey.challenge.expired"],
[404, "passkey.unknown"],
[422, "passkey.response.invalid"],
[403, "oidc_only"],
[409, "auth.already_authenticated"],
[409, "auth.register.taken"],
[403, "auth.register.invite_invalid"],
] as const) {
const error = new ApiError("raw English server prose", status, {
errorCode: code,
});
expect(
localizedSignInError(error, localT, "auth.loginFailed"),
code,
).toBe(localT(`apiErrors.${code}`));
expect(localT(`apiErrors.${code}`), code).not.toBe(`apiErrors.${code}`);
}
},
);
it("keeps the sentence of a code the step names itself", () => {
const error = new ApiError("raw English server prose", 401, {
errorCode: "auth.reproof.failed",
+2
View File
@@ -93,6 +93,8 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"auth.refresh.invalid",
"auth.refresh.reuse",
"auth.refresh.revoked",
"auth.register.invite_invalid",
"auth.register.taken",
"auth.reproof.failed",
"auth.reproof.required",
"auth.reproof.too_weak",
+2 -2
View File
@@ -2301,12 +2301,12 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"403": {
description:
"Refused before any work. Either the operator runs `OIDC_ONLY=true` (`meta.errorCode` = `oidc_only`), or self-registration is closed and the request carried no invite, or the invite it carried was invalid, expired or exhausted. The three are distinguishable by prose but not by code.",
"Refused before any work. Either the operator runs `OIDC_ONLY=true` (`meta.errorCode` = `oidc_only`), or self-registration is closed and the request carried no invite, or the invite it carried was invalid, expired or exhausted (`auth.register.invite_invalid`).",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description:
"The email or the username is taken. Deliberately one message for both, so this is not an account-existence oracle. Also the answer when two concurrent signups race past the pre-check and the database unique index catches the second.",
"The email or the username is taken (`auth.register.taken`). Deliberately one message and one code for both, so this is not an account-existence oracle. Also the answer when two concurrent signups race past the pre-check and the database unique index catches the second. `auth.already_authenticated` when the request carries a signed-in session.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,