diff --git a/messages/de.json b/messages/de.json index 13bcb27aa..c96aea84d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -142,7 +142,8 @@ "auth": { "reproof": { "required": "Bestätige dein aktuelles Passwort, um das zu ändern.", - "failed": "Die Bestätigung ist fehlgeschlagen. Es wurde nichts geändert." + "failed": "Die Bestätigung ist fehlgeschlagen. Es wurde nichts geändert.", + "too_weak": "Bestätige mit deinem zweiten Faktor oder einem Passkey. Bei diesem Konto reicht das Passwort allein nicht." }, "missing": "Deine Sitzung ist abgelaufen, daher wurde nichts geändert. Melde dich erneut an, um weiterzumachen.", "passkey": { @@ -222,6 +223,14 @@ "payload_too_large": "Die Trainingsdaten sind zu groß, um sie auf einmal zu speichern.", "source_not_permitted": "Diese Verbindung darf keine Trainings unter dieser Quelle speichern." } + }, + "ai": { + "provider": { + "none": "Es ist kein KI-Anbieter eingerichtet, daher wurde nichts gesendet. Richte einen in den KI-Einstellungen ein." + }, + "budget": { + "exceeded": "Das KI-Budget für heute ist aufgebraucht, daher wurde nichts gesendet. Morgen steht es wieder bereit." + } } }, "doctorReport": { diff --git a/messages/en.json b/messages/en.json index 6ba260ae3..cf62fc3bd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -142,7 +142,8 @@ "auth": { "reproof": { "required": "Confirm your current password to change this.", - "failed": "That did not verify. Nothing was changed." + "failed": "That did not verify. Nothing was changed.", + "too_weak": "Confirm with your second factor or a passkey. On this account a password alone is not enough." }, "missing": "Your session has ended, so nothing was changed. Sign in again to continue.", "passkey": { @@ -222,6 +223,14 @@ "payload_too_large": "The workout data is too large to save in one go.", "source_not_permitted": "This connection may not save workouts under that source." } + }, + "ai": { + "provider": { + "none": "No AI provider is set up, so nothing was sent to one. Set one up in the AI settings." + }, + "budget": { + "exceeded": "Today's AI budget is used up, so nothing was sent. It renews tomorrow." + } } }, "doctorReport": { diff --git a/messages/es.json b/messages/es.json index 62128c707..fd4716bf1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -142,7 +142,8 @@ "auth": { "reproof": { "required": "Confirma tu contraseña actual para cambiar esto.", - "failed": "La verificación ha fallado. No se ha cambiado nada." + "failed": "La verificación ha fallado. No se ha cambiado nada.", + "too_weak": "Confírmalo con tu segundo factor o una clave de acceso. En esta cuenta no basta con la contraseña." }, "missing": "Tu sesión ha caducado, así que no se ha cambiado nada. Inicia sesión de nuevo para continuar.", "passkey": { @@ -222,6 +223,14 @@ "payload_too_large": "Los datos del entrenamiento son demasiado grandes para guardarlos de una vez.", "source_not_permitted": "Esta conexión no puede guardar entrenamientos con esa fuente." } + }, + "ai": { + "provider": { + "none": "No hay ningún proveedor de IA configurado, así que no se ha enviado nada. Configura uno en los ajustes de IA." + }, + "budget": { + "exceeded": "El presupuesto de IA de hoy se ha agotado, así que no se ha enviado nada. Se renueva mañana." + } } }, "doctorReport": { diff --git a/messages/fr.json b/messages/fr.json index f9885e3c5..7c3d96f50 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -142,7 +142,8 @@ "auth": { "reproof": { "required": "Confirmez votre mot de passe actuel pour modifier ceci.", - "failed": "La vérification a échoué. Rien n'a été modifié." + "failed": "La vérification a échoué. Rien n'a été modifié.", + "too_weak": "Confirmez avec votre second facteur ou une clé d’accès. Sur ce compte, le mot de passe seul ne suffit pas." }, "missing": "Votre session a expiré, rien n'a donc été modifié. Reconnectez-vous pour continuer.", "passkey": { @@ -222,6 +223,14 @@ "payload_too_large": "Les données d'entraînement sont trop volumineuses pour être enregistrées en une fois.", "source_not_permitted": "Cette connexion n'a pas le droit d'enregistrer des entraînements sous cette source." } + }, + "ai": { + "provider": { + "none": "Aucun fournisseur d’IA n’est configuré, rien n’a donc été envoyé. Configurez-en un dans les réglages IA." + }, + "budget": { + "exceeded": "Le budget IA du jour est épuisé, rien n’a donc été envoyé. Il se renouvelle demain." + } } }, "doctorReport": { diff --git a/messages/it.json b/messages/it.json index f78033c05..fa0fca028 100644 --- a/messages/it.json +++ b/messages/it.json @@ -142,7 +142,8 @@ "auth": { "reproof": { "required": "Conferma la password attuale per modificarlo.", - "failed": "La verifica non è riuscita. Non è stato modificato nulla." + "failed": "La verifica non è riuscita. Non è stato modificato nulla.", + "too_weak": "Conferma con il secondo fattore o una passkey. Per questo account la sola password non basta." }, "missing": "La tua sessione è scaduta, quindi non è stato modificato nulla. Accedi di nuovo per continuare.", "passkey": { @@ -222,6 +223,14 @@ "payload_too_large": "I dati dell'allenamento sono troppo grandi per salvarli in una volta.", "source_not_permitted": "Questa connessione non può salvare allenamenti con questa fonte." } + }, + "ai": { + "provider": { + "none": "Nessun provider di IA è configurato, quindi non è stato inviato nulla. Configurane uno nelle impostazioni IA." + }, + "budget": { + "exceeded": "Il budget IA di oggi è esaurito, quindi non è stato inviato nulla. Si rinnova domani." + } } }, "doctorReport": { diff --git a/messages/ko.json b/messages/ko.json index c62750df2..8d8f43157 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -142,7 +142,8 @@ "auth": { "reproof": { "required": "변경하려면 현재 비밀번호를 확인하세요.", - "failed": "확인에 실패했습니다. 아무것도 변경되지 않았습니다." + "failed": "확인에 실패했습니다. 아무것도 변경되지 않았습니다.", + "too_weak": "2단계 인증 수단이나 패스키로 확인하세요. 이 계정에서는 비밀번호만으로는 충분하지 않아요." }, "missing": "세션이 만료되어 아무것도 변경되지 않았어요. 계속하려면 다시 로그인해 주세요.", "passkey": { @@ -222,6 +223,14 @@ "payload_too_large": "운동 데이터가 너무 커서 한 번에 저장할 수 없어요.", "source_not_permitted": "이 연결로는 해당 출처의 운동을 저장할 수 없어요." } + }, + "ai": { + "provider": { + "none": "설정된 AI 제공자가 없어 아무것도 보내지 않았어요. AI 설정에서 하나를 설정하세요." + }, + "budget": { + "exceeded": "오늘의 AI 예산을 모두 사용해서 아무것도 보내지 않았어요. 내일 다시 채워져요." + } } }, "doctorReport": { diff --git a/messages/nl.json b/messages/nl.json index da1aa2e27..2997c7662 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -142,7 +142,8 @@ "auth": { "reproof": { "required": "Bevestig je huidige wachtwoord om dit te wijzigen.", - "failed": "De verificatie is mislukt. Er is niets gewijzigd." + "failed": "De verificatie is mislukt. Er is niets gewijzigd.", + "too_weak": "Bevestig met je tweede factor of een passkey. Voor dit account is alleen je wachtwoord niet genoeg." }, "missing": "Je sessie is verlopen, dus er is niets gewijzigd. Meld je opnieuw aan om verder te gaan.", "passkey": { @@ -222,6 +223,14 @@ "payload_too_large": "De trainingsgegevens zijn te groot om in één keer op te slaan.", "source_not_permitted": "Deze koppeling mag geen trainingen onder die bron opslaan." } + }, + "ai": { + "provider": { + "none": "Er is geen AI-provider ingesteld, dus er is niets verzonden. Stel er een in bij de AI-instellingen." + }, + "budget": { + "exceeded": "Het AI-budget voor vandaag is op, dus er is niets verzonden. Morgen is het er weer." + } } }, "doctorReport": { diff --git a/messages/pl.json b/messages/pl.json index 854b541fe..d76523f14 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -142,7 +142,8 @@ "auth": { "reproof": { "required": "Potwierdź obecne hasło, aby to zmienić.", - "failed": "Weryfikacja nie powiodła się. Nic nie zostało zmienione." + "failed": "Weryfikacja nie powiodła się. Nic nie zostało zmienione.", + "too_weak": "Potwierdź drugim składnikiem lub kluczem dostępu. Na tym koncie samo hasło nie wystarczy." }, "missing": "Twoja sesja wygasła, więc nic nie zostało zmienione. Zaloguj się ponownie, aby kontynuować.", "passkey": { @@ -222,6 +223,14 @@ "payload_too_large": "Dane treningu są za duże, by zapisać je za jednym razem.", "source_not_permitted": "To połączenie nie może zapisywać treningów pod tym źródłem." } + }, + "ai": { + "provider": { + "none": "Nie skonfigurowano dostawcy AI, więc nic nie zostało wysłane. Skonfiguruj go w ustawieniach AI." + }, + "budget": { + "exceeded": "Dzisiejszy budżet AI został wykorzystany, więc nic nie zostało wysłane. Odnowi się jutro." + } } }, "doctorReport": { diff --git a/messages/pt-BR.json b/messages/pt-BR.json index bf5265f6e..aefc823cc 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -142,7 +142,8 @@ "auth": { "reproof": { "required": "Confirme sua senha atual para alterar isto.", - "failed": "A verificação não foi concluída. Nada foi alterado." + "failed": "A verificação não foi concluída. Nada foi alterado.", + "too_weak": "Confirme com seu segundo fator ou uma chave de acesso. Nesta conta, só a senha não basta." }, "missing": "Sua sessão terminou, então nada foi alterado. Entre novamente para continuar.", "passkey": { @@ -222,6 +223,14 @@ "payload_too_large": "Os dados do treino são grandes demais para salvar de uma vez.", "source_not_permitted": "Esta conexão não pode salvar treinos com essa origem." } + }, + "ai": { + "provider": { + "none": "Nenhum provedor de IA está configurado, então nada foi enviado. Configure um nas configurações de IA." + }, + "budget": { + "exceeded": "O orçamento de IA de hoje acabou, então nada foi enviado. Ele é renovado amanhã." + } } }, "doctorReport": { diff --git a/messages/ru.json b/messages/ru.json index d207f54f6..a63019d8f 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -142,7 +142,8 @@ "auth": { "reproof": { "required": "Подтвердите текущий пароль, чтобы изменить это.", - "failed": "Не подтверждено. Ничего не изменено." + "failed": "Не подтверждено. Ничего не изменено.", + "too_weak": "Подтвердите вторым фактором или ключом доступа. Для этого аккаунта одного пароля недостаточно." }, "missing": "Сеанс завершён, поэтому ничего не изменено. Войдите снова, чтобы продолжить.", "passkey": { @@ -222,6 +223,14 @@ "payload_too_large": "Данные тренировки слишком велики, чтобы сохранить их за один раз.", "source_not_permitted": "Это подключение не может сохранять тренировки с таким источником." } + }, + "ai": { + "provider": { + "none": "Поставщик ИИ не настроен, поэтому ничего не отправлено. Настройте его в настройках ИИ." + }, + "budget": { + "exceeded": "Бюджет ИИ на сегодня исчерпан, поэтому ничего не отправлено. Он обновится завтра." + } } }, "doctorReport": { diff --git a/src/app/api/auth/passkey/register-options/route.ts b/src/app/api/auth/passkey/register-options/route.ts index 968d9948a..e725a37bf 100644 --- a/src/app/api/auth/passkey/register-options/route.ts +++ b/src/app/api/auth/passkey/register-options/route.ts @@ -6,6 +6,7 @@ import { import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn"; import { RECENT_PROOF_MAX_AGE_MS, + REPROOF_FAILED_CODE, REPROOF_REQUIRED_CODE, isSecondFactorProof, recordReproofFailure, @@ -29,8 +30,12 @@ import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { stepUpMintSchema } from "@/lib/validations/step-up"; +// A wrong or missing proof, not an ended session: the code keeps the web from +// reading the bare 401 as "sign in again". const proofRequired = () => - apiError("Fresh existing-factor proof required", 401); + apiError("Fresh existing-factor proof required", 401, { + errorCode: REPROOF_FAILED_CODE, + }); export const POST = apiHandler(async (request: NextRequest) => { const { user, session } = await requireCookieAuth(); diff --git a/src/app/api/auth/passkey/register-verify/route.ts b/src/app/api/auth/passkey/register-verify/route.ts index e50fc106c..62ba82f4f 100644 --- a/src/app/api/auth/passkey/register-verify/route.ts +++ b/src/app/api/auth/passkey/register-verify/route.ts @@ -13,6 +13,7 @@ import { import { NextRequest } from "next/server"; import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { REPROOF_FAILED_CODE } from "@/lib/auth/existing-factor-proof"; export const POST = apiHandler(async (request: NextRequest) => { const { user, session } = await requireCookieAuth(); @@ -40,7 +41,9 @@ export const POST = apiHandler(async (request: NextRequest) => { (freshSession.mfaVerifiedAt && freshSession.mfaVerifiedAt.getTime() < Date.now() - 5 * 60 * 1000) ) { - return apiError("Fresh reauthentication proof required", 401); + return apiError("Fresh reauthentication proof required", 401, { + errorCode: REPROOF_FAILED_CODE, + }); } const claim = await consumeRegistrationChallenge({ @@ -49,12 +52,11 @@ export const POST = apiHandler(async (request: NextRequest) => { sessionId: session.id, }); if (!claim.ok) { - return apiError( - claim.reason === "wrong_context" - ? "Fresh reauthentication proof required" - : "Registration challenge invalid or expired", - claim.reason === "wrong_context" ? 401 : 400, - ); + return claim.reason === "wrong_context" + ? apiError("Fresh reauthentication proof required", 401, { + errorCode: REPROOF_FAILED_CODE, + }) + : apiError("Registration challenge invalid or expired", 400); } let verification; diff --git a/src/app/api/auth/step-up/route.ts b/src/app/api/auth/step-up/route.ts index 6592d81c2..e0ee2c1ea 100644 --- a/src/app/api/auth/step-up/route.ts +++ b/src/app/api/auth/step-up/route.ts @@ -40,6 +40,7 @@ import { rateLimitHeaders, } from "@/lib/rate-limit"; import { + REPROOF_FAILED_CODE, REPROOF_LIMIT, REPROOF_WINDOW_MS, reproofBucket, @@ -132,7 +133,9 @@ export const POST = apiHandler(async (request: NextRequest) => { action: { name: "auth.stepup.mint.failed" }, meta: { method: parsed.data.method, reason: failure }, }); - return apiError("Verification failed", 401); + return apiError("Verification failed", 401, { + errorCode: REPROOF_FAILED_CODE, + }); } const { token, expiresAt } = await mintStepUpElevation({ diff --git a/src/lib/ai/__tests__/provider.test.ts b/src/lib/ai/__tests__/provider.test.ts index a0490dd41..46a0ad60e 100644 --- a/src/lib/ai/__tests__/provider.test.ts +++ b/src/lib/ai/__tests__/provider.test.ts @@ -352,6 +352,21 @@ describe("resolveProviderForTest — empty override resolves via the chain", () expect(provider.type).not.toBe("none"); expect(provider).toBeInstanceOf(OpenAIClient); }); + + it("answers no provider, which the route names, when the chain resolves empty", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + aiProvider: null, + aiModel: null, + aiBaseUrl: null, + aiAnthropicKeyEncrypted: null, + aiLocalKeyEncrypted: null, + aiOpenaiKeyEncrypted: null, + aiProviderChain: [{ providerType: "openai", priority: 1, enabled: true }], + } as never); + + const provider = await resolveProviderForTest("user-123", {}); + expect(provider.type).toBe("none"); + }); }); describe("resolveProviderAvailability", () => { diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 45d600f9a..d51551682 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -1359,10 +1359,10 @@ async function resolveProviderForTestUnbound( // fallback used to probe a different provider than generation ran // (observed: test→admin-key while generation→codex), so a green test gave // no signal about whether overnight generation would work. + // Nothing resolving is the route's `ai.provider.none`, not a refused entry. if (!provider) { const chain = await resolveProviderChain(userId); - if (chain.length > 0) return chain[0].instance; - throw new AITestConfigError(422, "No AI provider configured"); + return chain.length > 0 ? chain[0].instance : new NoProvider(); } switch (provider) { diff --git a/src/lib/auth/__tests__/enrollment-proof-second-factor.test.ts b/src/lib/auth/__tests__/enrollment-proof-second-factor.test.ts index a6dac826c..20ca150e3 100644 --- a/src/lib/auth/__tests__/enrollment-proof-second-factor.test.ts +++ b/src/lib/auth/__tests__/enrollment-proof-second-factor.test.ts @@ -247,6 +247,20 @@ describe("POST /api/auth/passkey/register-options", () => { expect(res.status).toBe(200); expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:user-1"); }); + + it("names a mistyped password as a failed proof, not an ended session", async () => { + vi.mocked(requireCookieAuth).mockResolvedValue({ + user: stored(PLAIN), + session: { id: "sess-1" }, + } as never); + vi.mocked(verifyPassword).mockResolvedValue(false); + const res = await PASSKEY_REGISTER_OPTIONS( + passkeyReq({ method: "password", password: "typo" }) as never, + ); + expect(res.status).toBe(401); + expect((await res.json()).meta.errorCode).toBe("auth.reproof.failed"); + expect(createRegistrationOptions).not.toHaveBeenCalled(); + }); }); describe("changing the email address", () => {