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
+17
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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