mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
fix(errors): name the refusals behind the AI test and a passkey proof
Three refusals answered without an error code, so the web printed the status's generic sentence. A connection test with nothing configured read "Some of the entries were not accepted", and a mistyped password while adding a passkey read "Your session has ended. Sign in again", which sent people to sign out for nothing. An empty chain now resolves to no provider, which the test route answers with `ai.provider.none`. The passkey registration and the step-up mint answer a failed or stale proof with `auth.reproof.failed`. `ai.provider.none`, `ai.budget.exceeded` and `auth.reproof.too_weak` get sentences in every locale.
This commit is contained in:
1 parent
4be724fefb
commit
0c77eaa085
16 files changed
+150
-21
No files matched your search
+10
-1
@@ -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": {
|
||||
|
||||
+10
-1
@@ -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": {
|
||||
|
||||
+10
-1
@@ -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": {
|
||||
|
||||
+10
-1
@@ -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": {
|
||||
|
||||
+10
-1
@@ -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": {
|
||||
|
||||
+10
-1
@@ -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": {
|
||||
|
||||
+10
-1
@@ -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": {
|
||||
|
||||
+10
-1
@@ -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": {
|
||||
|
||||
+10
-1
@@ -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": {
|
||||
|
||||
+10
-1
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user