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

+17 -2
View File
@@ -151,7 +151,12 @@
},
"mfa": {
"already_active": "Für dieses Konto ist bereits eine Authenticator-App eingerichtet. Deaktiviere sie zuerst, um eine neue einzurichten."
}
},
"register": {
"taken": "Dieser Nutzername oder diese E-Mail-Adresse wird bereits verwendet.",
"invite_invalid": "Diese Einladung gilt nicht mehr. Bitte die Person, die dich eingeladen hat, um einen neuen Link."
},
"already_authenticated": "Du bist bereits angemeldet. Melde dich zuerst ab und versuche es dann erneut."
},
"biomarkers": {
"unit": {
@@ -234,7 +239,17 @@
"budget": {
"exceeded": "Das KI-Budget für heute ist aufgebraucht, daher wurde nichts gesendet. Morgen steht es wieder bereit."
}
}
},
"passkey": {
"unknown": "Dieser Passkey ist hier nicht registriert. Melde dich auf anderem Weg an.",
"challenge": {
"expired": "Die Anmeldung hat zu lange gedauert oder wurde schon verwendet. Versuche es erneut."
},
"response": {
"invalid": "Die Antwort des Passkeys konnte nicht gelesen werden. Versuche es erneut."
}
},
"oidc_only": "Dieser Server erlaubt nur Single Sign-on. Fahre stattdessen mit deinem Identitätsanbieter fort."
},
"doctorReport": {
"title": "Gesundheitsbericht",
+17 -2
View File
@@ -151,7 +151,12 @@
},
"mfa": {
"already_active": "An authenticator app is already set up for this account. Disable it first to set up a new one."
}
},
"register": {
"taken": "That username or email address is already in use.",
"invite_invalid": "This invitation is no longer valid. Ask the person who invited you for a new link."
},
"already_authenticated": "You are already signed in. Sign out first, then try again."
},
"biomarkers": {
"unit": {
@@ -234,7 +239,17 @@
"budget": {
"exceeded": "Today's AI budget is used up, so nothing was sent. It renews tomorrow."
}
}
},
"passkey": {
"unknown": "This passkey is not registered here. Sign in another way.",
"challenge": {
"expired": "The sign-in took too long or was already used. Try again."
},
"response": {
"invalid": "The passkey's answer could not be read. Try again."
}
},
"oidc_only": "This server only allows single sign-on. Continue with your identity provider instead."
},
"doctorReport": {
"title": "Health Report",
+17 -2
View File
@@ -151,7 +151,12 @@
},
"mfa": {
"already_active": "Esta cuenta ya tiene una aplicación de autenticación configurada. Desactívala primero para configurar una nueva."
}
},
"register": {
"taken": "Ese nombre de usuario o correo electrónico ya está en uso.",
"invite_invalid": "Esta invitación ya no es válida. Pide un enlace nuevo a la persona que te invitó."
},
"already_authenticated": "Ya has iniciado sesión. Cierra la sesión primero y vuelve a intentarlo."
},
"biomarkers": {
"unit": {
@@ -234,7 +239,17 @@
"budget": {
"exceeded": "El presupuesto de IA de hoy se ha agotado, así que no se ha enviado nada. Se renueva mañana."
}
}
},
"passkey": {
"unknown": "Esta passkey no está registrada aquí. Inicia sesión de otra forma.",
"challenge": {
"expired": "El inicio de sesión tardó demasiado o ya se usó. Vuelve a intentarlo."
},
"response": {
"invalid": "No se pudo leer la respuesta de la passkey. Vuelve a intentarlo."
}
},
"oidc_only": "Este servidor solo permite el inicio de sesión único. Continúa con tu proveedor de identidad."
},
"doctorReport": {
"title": "Informe de salud",
+17 -2
View File
@@ -151,7 +151,12 @@
},
"mfa": {
"already_active": "Une application d'authentification est déjà configurée pour ce compte. Désactivez-la d'abord pour en configurer une nouvelle."
}
},
"register": {
"taken": "Ce nom d’utilisateur ou cette adresse e-mail est déjà utilisé.",
"invite_invalid": "Cette invitation n’est plus valable. Demandez un nouveau lien à la personne qui vous a invité."
},
"already_authenticated": "Vous êtes déjà connecté. Déconnectez-vous d’abord, puis réessayez."
},
"biomarkers": {
"unit": {
@@ -234,7 +239,17 @@
"budget": {
"exceeded": "Le budget IA du jour est épuisé, rien n’a donc été envoyé. Il se renouvelle demain."
}
}
},
"passkey": {
"unknown": "Cette passkey n’est pas enregistrée ici. Connectez-vous d’une autre manière.",
"challenge": {
"expired": "La connexion a pris trop de temps ou a déjà été utilisée. Réessayez."
},
"response": {
"invalid": "La réponse de la passkey n’a pas pu être lue. Réessayez."
}
},
"oidc_only": "Ce serveur n’autorise que l’authentification unique. Continuez plutôt avec votre fournisseur d’identité."
},
"doctorReport": {
"title": "Rapport de santé",
+17 -2
View File
@@ -151,7 +151,12 @@
},
"mfa": {
"already_active": "Per questo account è già configurata un'app di autenticazione. Disattivala prima di configurarne una nuova."
}
},
"register": {
"taken": "Questo nome utente o indirizzo e-mail è già in uso.",
"invite_invalid": "Questo invito non è più valido. Chiedi un nuovo link alla persona che ti ha invitato."
},
"already_authenticated": "Hai già effettuato l’accesso. Prima esci, poi riprova."
},
"biomarkers": {
"unit": {
@@ -234,7 +239,17 @@
"budget": {
"exceeded": "Il budget IA di oggi è esaurito, quindi non è stato inviato nulla. Si rinnova domani."
}
}
},
"passkey": {
"unknown": "Questa passkey non è registrata qui. Accedi in un altro modo.",
"challenge": {
"expired": "L’accesso ha richiesto troppo tempo o è già stato usato. Riprova."
},
"response": {
"invalid": "Non è stato possibile leggere la risposta della passkey. Riprova."
}
},
"oidc_only": "Questo server consente solo il single sign-on. Continua invece con il tuo provider di identità."
},
"doctorReport": {
"title": "Rapporto sanitario",
+17 -2
View File
@@ -151,7 +151,12 @@
},
"mfa": {
"already_active": "이 계정에는 이미 인증 앱이 설정되어 있어요. 새로 설정하려면 먼저 해제하세요."
}
},
"register": {
"taken": "이미 사용 중인 사용자명 또는 이메일 주소예요.",
"invite_invalid": "이 초대는 더 이상 유효하지 않아요. 초대한 분께 새 링크를 요청해 주세요."
},
"already_authenticated": "이미 로그인되어 있어요. 먼저 로그아웃한 다음 다시 시도해 주세요."
},
"biomarkers": {
"unit": {
@@ -234,7 +239,17 @@
"budget": {
"exceeded": "오늘의 AI 예산을 모두 사용해서 아무것도 보내지 않았어요. 내일 다시 채워져요."
}
}
},
"passkey": {
"unknown": "이 패스키는 여기에 등록되어 있지 않아요. 다른 방법으로 로그인해 주세요.",
"challenge": {
"expired": "로그인이 너무 오래 걸렸거나 이미 사용되었어요. 다시 시도해 주세요."
},
"response": {
"invalid": "패스키의 응답을 읽을 수 없어요. 다시 시도해 주세요."
}
},
"oidc_only": "이 서버는 통합 로그인(SSO)만 허용해요. 대신 ID 공급자로 계속해 주세요."
},
"doctorReport": {
"title": "건강 리포트",
+17 -2
View File
@@ -151,7 +151,12 @@
},
"mfa": {
"already_active": "Voor dit account is al een authenticator-app ingesteld. Schakel die eerst uit om een nieuwe in te stellen."
}
},
"register": {
"taken": "Deze gebruikersnaam of dit e-mailadres is al in gebruik.",
"invite_invalid": "Deze uitnodiging is niet meer geldig. Vraag de persoon die je heeft uitgenodigd om een nieuwe link."
},
"already_authenticated": "Je bent al ingelogd. Log eerst uit en probeer het dan opnieuw."
},
"biomarkers": {
"unit": {
@@ -234,7 +239,17 @@
"budget": {
"exceeded": "Het AI-budget voor vandaag is op, dus er is niets verzonden. Morgen is het er weer."
}
}
},
"passkey": {
"unknown": "Deze passkey is hier niet geregistreerd. Log op een andere manier in.",
"challenge": {
"expired": "Het inloggen duurde te lang of is al gebruikt. Probeer het opnieuw."
},
"response": {
"invalid": "Het antwoord van de passkey kon niet worden gelezen. Probeer het opnieuw."
}
},
"oidc_only": "Deze server staat alleen single sign-on toe. Ga in plaats daarvan verder met je identiteitsprovider."
},
"doctorReport": {
"title": "Gezondheidsrapport",
+17 -2
View File
@@ -151,7 +151,12 @@
},
"mfa": {
"already_active": "To konto ma już skonfigurowaną aplikację uwierzytelniającą. Najpierw ją wyłącz, aby skonfigurować nową."
}
},
"register": {
"taken": "Ta nazwa użytkownika lub adres e-mail jest już zajęty.",
"invite_invalid": "To zaproszenie jest już nieważne. Poproś osobę, która cię zaprosiła, o nowy link."
},
"already_authenticated": "Masz już zalogowaną sesję. Najpierw się wyloguj, a potem spróbuj ponownie."
},
"biomarkers": {
"unit": {
@@ -234,7 +239,17 @@
"budget": {
"exceeded": "Dzisiejszy budżet AI został wykorzystany, więc nic nie zostało wysłane. Odnowi się jutro."
}
}
},
"passkey": {
"unknown": "Ten passkey nie jest tu zarejestrowany. Zaloguj się w inny sposób.",
"challenge": {
"expired": "Logowanie trwało zbyt długo lub zostało już użyte. Spróbuj ponownie."
},
"response": {
"invalid": "Nie udało się odczytać odpowiedzi passkey. Spróbuj ponownie."
}
},
"oidc_only": "Ten serwer pozwala tylko na logowanie jednokrotne (SSO). Kontynuuj u swojego dostawcy tożsamości."
},
"doctorReport": {
"title": "Raport zdrowotny",
+17 -2
View File
@@ -151,7 +151,12 @@
},
"mfa": {
"already_active": "Esta conta já tem um aplicativo autenticador configurado. Desative-o primeiro para configurar um novo."
}
},
"register": {
"taken": "Esse nome de usuário ou endereço de e-mail já está em uso.",
"invite_invalid": "Este convite não é mais válido. Peça um novo link à pessoa que convidou você."
},
"already_authenticated": "Você já está conectado. Saia primeiro e tente novamente."
},
"biomarkers": {
"unit": {
@@ -234,7 +239,17 @@
"budget": {
"exceeded": "O orçamento de IA de hoje acabou, então nada foi enviado. Ele é renovado amanhã."
}
}
},
"passkey": {
"unknown": "Esta chave de acesso não está registrada aqui. Entre de outra forma.",
"challenge": {
"expired": "O login demorou demais ou já foi usado. Tente novamente."
},
"response": {
"invalid": "Não foi possível ler a resposta da chave de acesso. Tente novamente."
}
},
"oidc_only": "Este servidor permite apenas login único. Continue com seu provedor de identidade."
},
"doctorReport": {
"title": "Relatório de Saúde",
+17 -2
View File
@@ -151,7 +151,12 @@
},
"mfa": {
"already_active": "Для этого аккаунта уже настроено приложение-аутентификатор. Сначала выключите его, чтобы настроить новое."
}
},
"register": {
"taken": "Это имя пользователя или адрес электронной почты уже занят.",
"invite_invalid": "Это приглашение больше не действует. Попросите у пригласившего новую ссылку."
},
"already_authenticated": "Вы уже вошли. Сначала выйдите, затем попробуйте снова."
},
"biomarkers": {
"unit": {
@@ -234,7 +239,17 @@
"budget": {
"exceeded": "Бюджет ИИ на сегодня исчерпан, поэтому ничего не отправлено. Он обновится завтра."
}
}
},
"passkey": {
"unknown": "Этот ключ доступа здесь не зарегистрирован. Войдите другим способом.",
"challenge": {
"expired": "Вход занял слишком много времени или уже был использован. Попробуйте снова."
},
"response": {
"invalid": "Не удалось прочитать ответ ключа доступа. Попробуйте снова."
}
},
"oidc_only": "Этот сервер допускает только единый вход. Продолжите через своего провайдера идентификации."
},
"doctorReport": {
"title": "Отчёт о здоровье",
@@ -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,