mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user