From 37734c9fd809fb94b04a635e75d189340171cf53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 14:00:18 +0100 Subject: [PATCH] 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. --- messages/de.json | 19 ++++++++-- messages/en.json | 19 ++++++++-- messages/es.json | 19 ++++++++-- messages/fr.json | 19 ++++++++-- messages/it.json | 19 ++++++++-- messages/ko.json | 19 ++++++++-- messages/nl.json | 19 ++++++++-- messages/pl.json | 19 ++++++++-- messages/pt-BR.json | 19 ++++++++-- messages/ru.json | 19 ++++++++-- .../api/auth/register/__tests__/route.test.ts | 7 +++- src/app/api/auth/register/route.ts | 12 +++++-- .../register/__tests__/page.browser.test.tsx | 31 ++++++++++++++-- src/lib/api/__tests__/localized-error.test.ts | 36 +++++++++++++++++++ src/lib/openapi/error-codes.ts | 2 ++ src/lib/openapi/routes/auth.ts | 4 +-- 16 files changed, 253 insertions(+), 29 deletions(-) diff --git a/messages/de.json b/messages/de.json index d53149d8a..931181e15 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 62b3b54d1..42f9d12f2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index cf7d035f6..f40f0d6cf 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 5d0a91f0a..ed1e2d7d8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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é", diff --git a/messages/it.json b/messages/it.json index c2f16d754..a4497e6bd 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index e89c585c2..d55264a31 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": "건강 리포트", diff --git a/messages/nl.json b/messages/nl.json index 742d09797..720ef174f 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index e76aaabaf..45f83ad43 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index f34cf7977..14a6ef2b7 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -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", diff --git a/messages/ru.json b/messages/ru.json index e18b00a52..37f833beb 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -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": "Отчёт о здоровье", diff --git a/src/app/api/auth/register/__tests__/route.test.ts b/src/app/api/auth/register/__tests__/route.test.ts index 3b435bf4b..b42a10889 100644 --- a/src/app/api/auth/register/__tests__/route.test.ts +++ b/src/app/api/auth/register/__tests__/route.test.ts @@ -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"); }); }); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index a50be77c2..eacdaa55e 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -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) — diff --git a/src/app/auth/register/__tests__/page.browser.test.tsx b/src/app/auth/register/__tests__/page.browser.test.tsx index 1a67ca9e4..749457462 100644 --- a/src/app/auth/register/__tests__/page.browser.test.tsx +++ b/src/app/auth/register/__tests__/page.browser.test.tsx @@ -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 () => { diff --git a/src/lib/api/__tests__/localized-error.test.ts b/src/lib/api/__tests__/localized-error.test.ts index 650bfe552..1d04f19ef 100644 --- a/src/lib/api/__tests__/localized-error.test.ts +++ b/src/lib/api/__tests__/localized-error.test.ts @@ -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", diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 8a1df35c2..411c355a1 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -93,6 +93,8 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "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", diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index d42f61ea1..1af5ecfac 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -2301,12 +2301,12 @@ export const authPaths: NonNullable = { }, "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,