From 6918ff48f8bb09592e69f7658bacb3ac286b6d38 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:29:48 +0000 Subject: [PATCH 1/6] wip(ui): the enrollment screen names a two-factor-off deployment in the user's language Claude-Session: https://claude.ai/code/session_01Utp6CYeUM6wKr7VK5xLHXn Co-authored-by: Claude --- .../src/console/RemediationOverlay.tsx | 28 ++++++++++++++-- ui/packages/auth/src/createAuthClient.ts | 33 ++++++++++++++++--- ui/packages/i18n/src/locales/ar.ts | 1 + ui/packages/i18n/src/locales/de.ts | 1 + ui/packages/i18n/src/locales/en.ts | 1 + ui/packages/i18n/src/locales/es.ts | 1 + ui/packages/i18n/src/locales/fr.ts | 1 + ui/packages/i18n/src/locales/ja.ts | 1 + ui/packages/i18n/src/locales/ko.ts | 1 + ui/packages/i18n/src/locales/pt.ts | 1 + ui/packages/i18n/src/locales/ru.ts | 1 + ui/packages/i18n/src/locales/zh.ts | 1 + 12 files changed, 65 insertions(+), 6 deletions(-) diff --git a/ui/packages/app-shell/src/console/RemediationOverlay.tsx b/ui/packages/app-shell/src/console/RemediationOverlay.tsx index 9ffc8308596..9872414857e 100644 --- a/ui/packages/app-shell/src/console/RemediationOverlay.tsx +++ b/ui/packages/app-shell/src/console/RemediationOverlay.tsx @@ -248,6 +248,30 @@ function TotpQr({ uri }: { uri: string }) { ); } +/** + * What the authenticator step says when the server refuses it, in the user's + * language where the refusal is one the console can name: + * + * - `404`: the `/two-factor/*` endpoints are not mounted, i.e. two-factor is off + * on this deployment, so no password or code the user types can succeed. The + * sentence says that and who can change it, instead of `… failed (404)`. + * - `INVALID_PASSWORD`: the password confirmation was wrong (the same sentence + * the password screen uses). + * + * Any other refusal keeps the server's own message; an error with none falls + * back to FALLBACK_KEY. + */ +function enrollRefusalMessage( + err: unknown, + t: ReturnType['t'], + fallbackKey: string, +): string { + const { status, code } = (err ?? {}) as { status?: unknown; code?: unknown }; + if (status === 404) return t('auth.remediation.mfa.unavailable'); + if (code === 'INVALID_PASSWORD') return t('auth.remediation.password.wrongPassword'); + return err instanceof Error && err.message ? err.message : t(fallbackKey); +} + function MfaEnrollForm({ message }: { message: string }) { const { enrollTotp, verifyTotp, setRemediationRequired } = useAuth(); const { t } = useObjectTranslation(); @@ -268,7 +292,7 @@ function MfaEnrollForm({ message }: { message: string }) { setBackupCodes(codes ?? []); setStep('verify'); } catch (err) { - setError(err instanceof Error ? err.message : t('auth.remediation.mfa.enrollFailed')); + setError(enrollRefusalMessage(err, t, 'auth.remediation.mfa.enrollFailed')); } finally { setBusy(false); } @@ -282,7 +306,7 @@ function MfaEnrollForm({ message }: { message: string }) { setRemediationRequired(null); window.location.reload(); } catch (err) { - setError(err instanceof Error ? err.message : t('auth.remediation.mfa.invalidCode')); + setError(enrollRefusalMessage(err, t, 'auth.remediation.mfa.invalidCode')); setBusy(false); } }; diff --git a/ui/packages/auth/src/createAuthClient.ts b/ui/packages/auth/src/createAuthClient.ts index 5d13a899216..d6ed4c2c39c 100644 --- a/ui/packages/auth/src/createAuthClient.ts +++ b/ui/packages/auth/src/createAuthClient.ts @@ -134,6 +134,31 @@ function toAuthError( return err; } +/** The error half of a better-auth `twoFactor.*` answer. */ +type TwoFactorWireError = { message?: string; status?: number; code?: string }; + +/** + * The error an enrollment call (`enrollTotp`, `verifyTotp`) rejects with: the + * server's `message` (`WHAT (STATUS)` when it sent none), plus the machine + * `code` and the HTTP `status`, so the console can say in the user's language + * what happened instead of echoing English text. A `404` is the answer that + * matters most: the `/two-factor/*` endpoints are mounted only while two-factor + * is on for the deployment, so it means enrollment does not exist there, not + * that the request was wrong. + */ +function twoFactorEnrollError( + error: TwoFactorWireError, + what: string, +): Error & { code?: string; status?: number } { + const err = new Error(error.message ?? `${what} (${error.status ?? '?'})`) as Error & { + code?: string; + status?: number; + }; + if (error.code) err.code = error.code; + if (typeof error.status === 'number') err.status = error.status; + return err; +} + /** * The wire boundary for invitations (objectui#3879). * @@ -814,26 +839,26 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { // plugin; cast through unknown to bridge loose better-auth client types. async enrollTotp(password: string) { type EnableFn = (opts: { password: string }) => - Promise<{ data?: { totpURI?: string; backupCodes?: string[] } | null; error?: { message?: string; status?: number } | null }>; + Promise<{ data?: { totpURI?: string; backupCodes?: string[] } | null; error?: TwoFactorWireError | null }>; const tf = (betterAuth as unknown as { twoFactor?: { enable?: EnableFn } }).twoFactor; if (!tf || typeof tf.enable !== 'function') { throw new Error('two-factor enrollment is not available on this auth backend'); } const { data, error } = await tf.enable({ password }); - if (error) throw new Error(error.message ?? `Two-factor enable failed (${error.status ?? '?'})`); + if (error) throw twoFactorEnrollError(error, 'Two-factor enable failed'); return { totpURI: data?.totpURI ?? '', backupCodes: data?.backupCodes ?? [] }; }, // ADR-0069 — verify the first TOTP code to activate enrollment. async verifyTotp(code: string) { type VerifyFn = (opts: { code: string }) => - Promise<{ error?: { message?: string; status?: number } | null }>; + Promise<{ error?: TwoFactorWireError | null }>; const tf = (betterAuth as unknown as { twoFactor?: { verifyTotp?: VerifyFn } }).twoFactor; if (!tf || typeof tf.verifyTotp !== 'function') { throw new Error('two-factor verification is not available on this auth backend'); } const { error } = await tf.verifyTotp({ code }); - if (error) throw new Error(error.message ?? `Two-factor verification failed (${error.status ?? '?'})`); + if (error) throw twoFactorEnrollError(error, 'Two-factor verification failed'); }, async setInitialPassword(newPassword: string) { diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index f7f66efbae5..64c528d893a 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -3208,6 +3208,7 @@ const ar = { continue: "متابعة", preparing: "جارٍ التحضير…", enrollFailed: "تعذّر بدء التسجيل.", + unavailable: "المصادقة الثنائية متوقفة في هذا النشر، لذا لا يمكن إعداد تطبيق مصادقة بعد. اطلب من المسؤول تفعيلها من الإعدادات ← المصادقة، ثم أعد المحاولة.", scanTitle: "امسح الرمز بتطبيق المصادقة", scanBody: "امسح رمز QR هذا باستخدام Google Authenticator أو 1Password أو Authy وغيرها، ثم أدخل الرمز المكوّن من 6 أرقام.", backupTitle: "احفظ رموز النسخ الاحتياطي", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index decad288cde..3f0794bda24 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2954,6 +2954,7 @@ const de = { continue: "Weiter", preparing: "Wird vorbereitet…", enrollFailed: "Registrierung konnte nicht gestartet werden.", + unavailable: "Die Zwei-Faktor-Authentifizierung ist in dieser Bereitstellung deaktiviert, daher kann noch keine Authenticator-App eingerichtet werden. Bitten Sie einen Administrator, sie unter Einstellungen → Authentifizierung zu aktivieren, und versuchen Sie es dann erneut.", scanTitle: "Mit Ihrer Authenticator-App scannen", scanBody: "Scannen Sie diesen QR-Code mit Google Authenticator, 1Password, Authy usw. und geben Sie dann den 6-stelligen Code ein.", backupTitle: "Backup-Codes speichern", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 9ba9c6ae612..e96bdcf327b 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -3525,6 +3525,7 @@ const en = { continue: 'Continue', preparing: 'Preparing…', enrollFailed: 'Could not start enrollment.', + unavailable: 'Two-factor authentication is turned off on this deployment, so an authenticator app cannot be set up yet. Ask an administrator to turn it on in Settings → Authentication, then try again.', scanTitle: 'Scan with your authenticator', scanBody: 'Scan this QR code with Google Authenticator, 1Password, Authy, etc., then enter the 6-digit code.', backupTitle: 'Save your backup codes', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index f22af5ce0c2..c08e506842e 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -3019,6 +3019,7 @@ const es = { continue: "Continuar", preparing: "Preparando…", enrollFailed: "No se pudo iniciar el registro.", + unavailable: "La autenticación de dos factores está desactivada en esta implementación, así que todavía no se puede configurar una app de autenticación. Pide a un administrador que la active en Configuración → Autenticación y vuelve a intentarlo.", scanTitle: "Escanee con su aplicación de autenticación", scanBody: "Escanee este código QR con Google Authenticator, 1Password, Authy, etc., y luego introduzca el código de 6 dígitos.", backupTitle: "Guarde sus códigos de respaldo", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index c216cdab8b9..278ab3e4778 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -3015,6 +3015,7 @@ const fr = { continue: "Continuer", preparing: "Préparation…", enrollFailed: "Impossible de démarrer l'enrôlement.", + unavailable: "L'authentification à deux facteurs est désactivée sur ce déploiement : aucune application d'authentification ne peut encore être configurée. Demandez à un administrateur de l'activer dans Paramètres → Authentification, puis réessayez.", scanTitle: "Scannez avec votre application d'authentification", scanBody: "Scannez ce QR code avec Google Authenticator, 1Password, Authy, etc., puis saisissez le code à 6 chiffres.", backupTitle: "Enregistrez vos codes de secours", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 2c2f9ba76a8..85711ce219b 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2956,6 +2956,7 @@ const ja = { continue: "続行", preparing: "準備しています…", enrollFailed: "登録を開始できませんでした。", + unavailable: "このデプロイでは二要素認証がオフになっているため、認証アプリをまだ登録できません。管理者に「設定 → 認証」でオンにしてもらってから、もう一度お試しください。", scanTitle: "認証アプリでスキャン", scanBody: "Google Authenticator、1Password、Authy などでこの QR コードをスキャンし、6 桁のコードを入力してください。", backupTitle: "バックアップコードを保存", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 530c8bb2d76..699f1d8b002 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2954,6 +2954,7 @@ const ko = { continue: "계속", preparing: "준비 중…", enrollFailed: "등록을 시작할 수 없습니다.", + unavailable: "이 배포에서는 2단계 인증이 꺼져 있어 아직 인증 앱을 등록할 수 없습니다. 관리자에게 설정 → 인증에서 켜 달라고 요청한 뒤 다시 시도하세요.", scanTitle: "인증 앱으로 스캔", scanBody: "Google Authenticator, 1Password, Authy 등으로 이 QR 코드를 스캔한 다음 6자리 코드를 입력하세요.", backupTitle: "백업 코드 저장", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index c9c734753f3..feecab06c78 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -3014,6 +3014,7 @@ const pt = { continue: "Continuar", preparing: "Preparando…", enrollFailed: "Não foi possível iniciar o registro.", + unavailable: "A autenticação de dois fatores está desativada nesta implantação, então ainda não é possível configurar um app autenticador. Peça a um administrador para ativá-la em Configurações → Autenticação e tente novamente.", scanTitle: "Escaneie com seu aplicativo autenticador", scanBody: "Escaneie este QR code com o Google Authenticator, 1Password, Authy etc. e digite o código de 6 dígitos.", backupTitle: "Salve seus códigos de backup", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index f251e5e8654..5ef883af442 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -3089,6 +3089,7 @@ const ru = { continue: "Продолжить", preparing: "Подготовка…", enrollFailed: "Не удалось начать привязку.", + unavailable: "Двухфакторная аутентификация в этом развёртывании отключена, поэтому приложение-аутентификатор пока нельзя привязать. Попросите администратора включить её в разделе «Настройки → Аутентификация», затем повторите попытку.", scanTitle: "Отсканируйте в приложении-аутентификаторе", scanBody: "Отсканируйте этот QR-код с помощью Google Authenticator, 1Password, Authy и т. п., затем введите 6-значный код.", backupTitle: "Сохраните резервные коды", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index c7f54ab82ad..81c7d82ab24 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -3032,6 +3032,7 @@ const zh = { continue: '继续', preparing: '正在准备…', enrollFailed: '无法开始绑定。', + unavailable: '此部署未开启双重验证,暂时无法绑定身份验证器。请联系管理员在“设置 → 认证”中开启后再试。', scanTitle: '使用身份验证器扫码', scanBody: '使用 Google Authenticator、1Password、Authy 等应用扫描此二维码,然后输入 6 位验证码。', backupTitle: '保存您的备用码', From c4ec9241c9cd4427a0ef859b4e508b11090ac36e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:42:20 +0000 Subject: [PATCH 2/6] wip(ui): the organization form says requiring MFA needs two-factor; an accepted save says who must enroll Claude-Session: https://claude.ai/code/session_01Utp6CYeUM6wKr7VK5xLHXn Co-authored-by: Claude --- .../app-shell/src/console/AppContent.tsx | 35 ++++- ...onOverlay.enrollUnavailable-30295.test.tsx | 83 ++++++++++++ .../organizationMfaNotices-30295.test.tsx | 91 +++++++++++++ .../src/console/organizationMfaNotices.ts | 122 ++++++++++++++++++ .../__tests__/enrollTotp-status-30295.test.ts | 72 +++++++++++ ui/packages/i18n/src/locales/ar.ts | 6 +- ui/packages/i18n/src/locales/de.ts | 6 +- ui/packages/i18n/src/locales/en.ts | 8 +- ui/packages/i18n/src/locales/es.ts | 4 + ui/packages/i18n/src/locales/fr.ts | 6 +- ui/packages/i18n/src/locales/ja.ts | 4 + ui/packages/i18n/src/locales/ko.ts | 4 + ui/packages/i18n/src/locales/pt.ts | 6 +- ui/packages/i18n/src/locales/ru.ts | 6 +- ui/packages/i18n/src/locales/zh.ts | 4 + 15 files changed, 449 insertions(+), 8 deletions(-) create mode 100644 ui/packages/app-shell/src/console/__tests__/RemediationOverlay.enrollUnavailable-30295.test.tsx create mode 100644 ui/packages/app-shell/src/console/__tests__/organizationMfaNotices-30295.test.tsx create mode 100644 ui/packages/app-shell/src/console/organizationMfaNotices.ts create mode 100644 ui/packages/auth/src/__tests__/enrollTotp-status-30295.test.ts diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx index 67652d81580..47fca35be0a 100644 --- a/ui/packages/app-shell/src/console/AppContent.tsx +++ b/ui/packages/app-shell/src/console/AppContent.tsx @@ -72,6 +72,7 @@ import { enterAppBundle, readAppObjectScope } from './appObjectScope.js'; import { preferLocal } from '../utils/preferLocal.js'; import { packagedAutomationStudioPath, setupMetadataRest, setupMetadataStudioPath } from '../views/studio-design/setupMetadataRedirect.js'; import { SkeletonDashboard, SkeletonDetail, SkeletonGrid } from '../skeletons/index.js'; +import { organizationMfaFormNotice, organizationMfaSavedNotice, useStoredRequireMfa } from './organizationMfaNotices.js'; // Route-based code splitting — lazy-load less-frequently-used routes. // @@ -964,6 +965,23 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = : currentObjectDef; const isChildFormTask = !!formObjectParam && formObjectParam !== currentObjectDef?.name; + // objectstack#30295 — an organization's `require_mfa`: before saving, the + // form says it needs two-factor on (when it is off); after a save that turned + // it on, the toast says who must enroll and when the block starts. The rules + // live in `organizationMfaNotices.ts`. + const formObjectFieldNames: string[] = formObjectDef?.fields + ? Array.isArray(formObjectDef.fields) + ? formObjectDef.fields.map((f: any) => (typeof f === 'string' ? f : f?.name)).filter(Boolean) + : Object.keys(formObjectDef.fields) + : []; + const organizationMfaNotice = organizationMfaFormNotice({ + objectName: formObjectDef?.name, + fields: formObjectFieldNames, + features, + t, + }); + const storedRequireMfa = useStoredRequireMfa(formObjectDef?.name, editingRecord?.id, isDialogOpen, dataSource); + // objectstack#27138 — the record the overlay edits may be held by a pending // approval that locks it. The record's field projection leaves that lock out // (the server judges it on the values a save sends), so the dialog reads it @@ -1038,7 +1056,19 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // the child's open related lists refetch on their own via the // invalidation bus (#2269): the dataSource write already emitted a // MutationEvent that the bridge fans out — no manual notify needed. - const feedback = answerWarningClaim.current?.take(saved); + const claimed = answerWarningClaim.current?.take(saved); + const mfaNotice = organizationMfaSavedNotice({ + objectName: formObjectDef?.name, + storedRequireMfa, + saved, + t, + }); + const feedback = mfaNotice + ? { + warnings: claimed?.warnings ?? [], + notice: claimed?.notice ? `${claimed.notice}\n${mfaNotice}` : mfaNotice, + } + : claimed; if (isChildFormTask) { toastRecordSave(toast, recordSaveMessage(saved), feedback); closeRecordForm(); @@ -1080,7 +1110,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = return; } navigate(target.url, { replace: true }); - }, [handleCrudSuccess, recordSaveMessage, isChildFormTask, editingRecord, currentObjectDef, appSegment, location.pathname, navigate, closeRecordForm, objectPluralLabel]); + }, [handleCrudSuccess, recordSaveMessage, isChildFormTask, editingRecord, currentObjectDef, appSegment, location.pathname, navigate, closeRecordForm, objectPluralLabel, formObjectDef?.name, storedRequireMfa, t]); // Track recent items on route change. The hook matches `appName` against the // pathname's app segment, so it takes the ROUTE SEGMENT: handed @@ -1718,6 +1748,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = title: editingRecord ? t('form.editTitle', { object: objectLabel(formObjectDef as any) }) : t('form.createTitle', { object: objectLabel(formObjectDef as any) }), + ...(organizationMfaNotice ? { description: organizationMfaNotice } : {}), open: isDialogOpen, onOpenChange: (open: boolean) => { if (!open) closeRecordForm(); }, // objectui#6514 — the same declared-key gate `RecordFormPage` diff --git a/ui/packages/app-shell/src/console/__tests__/RemediationOverlay.enrollUnavailable-30295.test.tsx b/ui/packages/app-shell/src/console/__tests__/RemediationOverlay.enrollUnavailable-30295.test.tsx new file mode 100644 index 00000000000..9877b7521e6 --- /dev/null +++ b/ui/packages/app-shell/src/console/__tests__/RemediationOverlay.enrollUnavailable-30295.test.tsx @@ -0,0 +1,83 @@ +/** + * The enrollment screen says, in the user's language, that two-factor is off + * on the deployment when the server answers 404 (objectstack#30295). + * + * `POST /two-factor/enable` is mounted only while two-factor is on, so a 404 + * means no password the user types can succeed. The screen used to show the + * client's English `Two-factor enable failed (404)`. A 404 now reads as a + * translated sentence saying who can turn it on; a wrong password reads as the + * password screen's sentence; any other refusal keeps the server's message. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; + +let refusal: Error & { code?: string; status?: number } = new Error('Two-factor enable failed (404)'); +const enrollTotp = vi.fn(async () => { throw refusal; }); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal()), + useAuth: () => ({ + remediationRequired: { code: 'MFA_REQUIRED', message: '' }, + setRemediationRequired: vi.fn(), + changePassword: vi.fn(), + enrollTotp, + verifyTotp: vi.fn(), + signOut: vi.fn(), + }), +})); + +import { RemediationOverlay } from '../RemediationOverlay'; + +const refused = (message: string, extra: { code?: string; status?: number }) => + Object.assign(new Error(message), extra); + +function submitPassword(language: string, label: string) { + render( + + + , + ); + fireEvent.change(screen.getByLabelText(label), { target: { value: 'admin123' } }); + fireEvent.submit(screen.getByLabelText(label).closest('form') as HTMLFormElement); +} + +beforeEach(() => { + enrollTotp.mockClear(); +}); +afterEach(cleanup); + +describe('RemediationOverlay — an enrollment refusal reads in the user’s language (#30295)', () => { + it('zh: a 404 says two-factor is off and who can turn it on, not “failed (404)”', async () => { + refusal = refused('Two-factor enable failed (404)', { status: 404 }); + submitPassword('zh', '确认您的密码'); + expect( + await screen.findByText('此部署未开启双重验证,暂时无法绑定身份验证器。请联系管理员在“设置 → 认证”中开启后再试。'), + ).toBeTruthy(); + expect(screen.queryByText('Two-factor enable failed (404)')).toBeNull(); + expect(enrollTotp).toHaveBeenCalledTimes(1); + }); + + it('en: a 404 shows the English sentence', async () => { + refusal = refused('Two-factor enable failed (404)', { status: 404 }); + submitPassword('en', 'Confirm your password'); + expect( + await screen.findByText( + 'Two-factor authentication is turned off on this deployment, so an authenticator app cannot be set up yet. Ask an administrator to turn it on in Setup → Authentication, then try again.', + ), + ).toBeTruthy(); + }); + + it('zh: INVALID_PASSWORD shows the password screen’s sentence', async () => { + refusal = refused('Invalid password', { status: 400, code: 'INVALID_PASSWORD' }); + submitPassword('zh', '确认您的密码'); + expect(await screen.findByText('你输入的密码不正确,请检查后重试。')).toBeTruthy(); + }); + + it('CONTROL: another refusal keeps the server’s message', async () => { + refusal = refused('Too many requests. Please try again later.', { status: 429 }); + submitPassword('en', 'Confirm your password'); + expect(await screen.findByText('Too many requests. Please try again later.')).toBeTruthy(); + }); +}); diff --git a/ui/packages/app-shell/src/console/__tests__/organizationMfaNotices-30295.test.tsx b/ui/packages/app-shell/src/console/__tests__/organizationMfaNotices-30295.test.tsx new file mode 100644 index 00000000000..a28059abbac --- /dev/null +++ b/ui/packages/app-shell/src/console/__tests__/organizationMfaNotices-30295.test.tsx @@ -0,0 +1,91 @@ +/** + * What the organization record form says around `require_mfa` + * (objectstack#30295): the notice before saving while two-factor is off, and + * the line an accepted save that turned it on adds to its toast. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { + organizationMfaFormNotice, + organizationMfaSavedNotice, + useStoredRequireMfa, +} from '../organizationMfaNotices'; + +const t = ((key: string) => key) as Parameters[0]['t']; +const FIELDS = ['name', 'require_mfa', 'timezone']; + +describe('organizationMfaFormNotice — before saving', () => { + it('two-factor off: the organization form says requiring MFA needs it', () => { + expect( + organizationMfaFormNotice({ objectName: 'sys_organization', fields: FIELDS, features: { twoFactor: false }, t }), + ).toBe('console.organizationMfa.needsTwoFactor'); + }); + + it('two-factor on, or not read yet: nothing', () => { + for (const features of [{ twoFactor: true }, {}]) { + expect(organizationMfaFormNotice({ objectName: 'sys_organization', fields: FIELDS, features, t })).toBeUndefined(); + } + }); + + it('another object, or a form without the field: nothing', () => { + expect( + organizationMfaFormNotice({ objectName: 'sys_team', fields: FIELDS, features: { twoFactor: false }, t }), + ).toBeUndefined(); + expect( + organizationMfaFormNotice({ objectName: 'sys_organization', fields: ['name'], features: { twoFactor: false }, t }), + ).toBeUndefined(); + }); +}); + +describe('organizationMfaSavedNotice — after an accepted save', () => { + const saved = { id: 'org_1', name: 'Acme', require_mfa: true }; + + it('a save that turned it on says who must enroll and when the block starts', () => { + expect(organizationMfaSavedNotice({ objectName: 'sys_organization', storedRequireMfa: false, saved, t })).toBe( + 'console.organizationMfa.nowRequired', + ); + // An unread stored value counts as off. + expect(organizationMfaSavedNotice({ objectName: 'sys_organization', storedRequireMfa: undefined, saved, t })).toBe( + 'console.organizationMfa.nowRequired', + ); + }); + + it('a row that already required it, a save that leaves it off, another object: nothing', () => { + expect(organizationMfaSavedNotice({ objectName: 'sys_organization', storedRequireMfa: true, saved, t })).toBeUndefined(); + expect( + organizationMfaSavedNotice({ objectName: 'sys_organization', storedRequireMfa: false, saved: { ...saved, require_mfa: false }, t }), + ).toBeUndefined(); + expect(organizationMfaSavedNotice({ objectName: 'sys_team', storedRequireMfa: false, saved, t })).toBeUndefined(); + }); +}); + +describe('useStoredRequireMfa — the stored value while the form is open', () => { + it('an edit reads the row once; a create is off; a closed form or another object reads nothing', async () => { + const findOne = vi.fn(async () => ({ id: 'org_1', require_mfa: true })); + const { result, rerender } = renderHook( + (p: { objectName: string; recordId?: string; open: boolean }) => + useStoredRequireMfa(p.objectName, p.recordId, p.open, { findOne }), + { initialProps: { objectName: 'sys_organization', recordId: 'org_1', open: true } }, + ); + await waitFor(() => expect(result.current).toBe(true)); + rerender({ objectName: 'sys_organization', recordId: 'org_1', open: true }); + expect(findOne).toHaveBeenCalledTimes(1); + expect(findOne).toHaveBeenCalledWith('sys_organization', 'org_1'); + + rerender({ objectName: 'sys_organization', recordId: undefined, open: true }); + expect(result.current).toBe(false); + rerender({ objectName: 'sys_organization', recordId: 'org_1', open: false }); + expect(result.current).toBeUndefined(); + rerender({ objectName: 'sys_team', recordId: 'team_1', open: true }); + expect(result.current).toBeUndefined(); + expect(findOne).toHaveBeenCalledTimes(1); + }); + + it('a failed read leaves it unread', async () => { + const findOne = vi.fn(async () => { throw new Error('403'); }); + const { result } = renderHook(() => useStoredRequireMfa('sys_organization', 'org_1', true, { findOne })); + await waitFor(() => expect(findOne).toHaveBeenCalledTimes(1)); + expect(result.current).toBeUndefined(); + }); +}); diff --git a/ui/packages/app-shell/src/console/organizationMfaNotices.ts b/ui/packages/app-shell/src/console/organizationMfaNotices.ts new file mode 100644 index 00000000000..55257bca729 --- /dev/null +++ b/ui/packages/app-shell/src/console/organizationMfaNotices.ts @@ -0,0 +1,122 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { useEffect, useState } from 'react'; +import type { useObjectTranslation } from '@object-ui/i18n'; + +/** + * What the record form says around an organization's `require_mfa` (ADR-0069 + * D3, the per-organization MFA requirement). + * + * The column is generically editable, so the organization edit form is the + * console's ordinary record form and the field's help is the object's own. Two + * facts that help cannot carry are the console's to say: + * + * - **Before saving**: the requirement can only be turned on while two-factor + * is on for the deployment (`features.twoFactor` in `/api/v1/auth/config`). + * While it is off the server refuses the write (`400 VALIDATION_FAILED` on + * `require_mfa`), and the form shows that refusal on the field. The notice + * says so before the administrator ticks the box, and where two-factor is + * turned on. + * - **After an accepted save that turned it on**: who is affected (every + * member without an authenticator app) and when the block starts (once the + * MFA grace period ends, counted from each member's next use). + * + * Both are judged only on what the console knows: an unread `features` says + * nothing before saving, and a save whose stored value was already on says + * nothing after it. + */ + +export const ORGANIZATION_OBJECT = 'sys_organization'; +export const REQUIRE_MFA_FIELD = 'require_mfa'; + +type Translate = ReturnType['t']; + +const isOn = (value: unknown): boolean => value === true || value === 1 || value === 'true'; + +/** + * The notice the organization form shows before saving: requiring MFA needs + * two-factor on, and it is off. `undefined` for any other object, a form that + * does not offer the field, or a deployment whose two-factor state is unread or + * on. + */ +export function organizationMfaFormNotice(input: { + objectName: string | undefined; + fields: readonly string[]; + features: Record; + t: Translate; +}): string | undefined { + if (input.objectName !== ORGANIZATION_OBJECT) return undefined; + if (!input.fields.includes(REQUIRE_MFA_FIELD)) return undefined; + if (input.features.twoFactor !== false) return undefined; + return input.t('console.organizationMfa.needsTwoFactor'); +} + +/** + * The line an accepted organization save adds to its toast when it turned the + * requirement on: who must enroll and when the block starts. `undefined` when + * the saved row does not require MFA, or the stored row already did + * (`storedRequireMfa === true`); an unread stored value (`undefined`) counts as + * off, since saying it once too often is cheaper than not saying it. + */ +export function organizationMfaSavedNotice(input: { + objectName: string | undefined; + storedRequireMfa: boolean | undefined; + saved: unknown; + t: Translate; +}): string | undefined { + if (input.objectName !== ORGANIZATION_OBJECT) return undefined; + if (input.storedRequireMfa === true) return undefined; + const saved = input.saved as Record | null | undefined; + if (!saved || !isOn(saved[REQUIRE_MFA_FIELD])) return undefined; + return input.t('console.organizationMfa.nowRequired'); +} + +/** The slice of the data source the stored-value read needs. */ +interface FindOneSource { + findOne(objectName: string, id: string): Promise; +} + +/** + * The organization row's stored `require_mfa` while its form is open: `false` + * for a create, the row's value for an edit, `undefined` while unread, when the + * read fails, or for any other object. Keyed on the primitives it reads, so a + * re-render never re-reads. + */ +export function useStoredRequireMfa( + objectName: string | undefined, + recordId: string | undefined, + open: boolean, + dataSource: FindOneSource | null | undefined, +): boolean | undefined { + const [stored, setStored] = useState<{ key: string; value: boolean } | null>(null); + const applies = open && objectName === ORGANIZATION_OBJECT; + const key = `${objectName ?? ''}:${recordId ?? 'new'}`; + useEffect(() => { + if (!applies || !recordId || !dataSource) return; + let cancelled = false; + dataSource + .findOne(ORGANIZATION_OBJECT, recordId) + .then((row) => { + if (cancelled) return; + const value = (row as Record | null | undefined)?.[REQUIRE_MFA_FIELD]; + setStored({ key, value: isOn(value) }); + }) + .catch(() => { + /* unread: the saved notice treats it as off */ + }); + return () => { + cancelled = true; + }; + // `dataSource` is read when the form opens; a new adapter mid-form does not re-read. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [applies, recordId, key]); + if (!applies) return undefined; + if (!recordId) return false; + return stored && stored.key === key ? stored.value : undefined; +} diff --git a/ui/packages/auth/src/__tests__/enrollTotp-status-30295.test.ts b/ui/packages/auth/src/__tests__/enrollTotp-status-30295.test.ts new file mode 100644 index 00000000000..9353f71ffa3 --- /dev/null +++ b/ui/packages/auth/src/__tests__/enrollTotp-status-30295.test.ts @@ -0,0 +1,72 @@ +/** + * objectstack#30295 — an enrollment refusal keeps its HTTP `status` and + * machine `code` through `enrollTotp` / `verifyTotp`. + * + * The `/two-factor/*` endpoints are mounted only while two-factor is on for the + * deployment, so `POST /two-factor/enable` answers 404 there. The client threw + * `new Error('Two-factor enable failed (404)')`, and the enrollment screen + * showed that English text: the status was in the message and nowhere else, so + * the screen had nothing to translate on. This drives the real better-auth + * client through a mocked `fetch`, as `changePassword-code-27869.test.ts` does. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { createAuthClient } from '../createAuthClient'; + +function rejectingFetch(pathFragment: string, status: number, body: unknown) { + return vi.fn(async (input: string | URL | Request) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes(pathFragment)) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); +} + +type Rejection = Error & { code?: string; status?: number }; + +async function rejectionOf(run: () => Promise): Promise { + return run().then( + () => { + throw new Error('expected the call to reject, but it resolved'); + }, + (err: unknown) => err as Rejection, + ); +} + +const clientWith = (fetchFn: ReturnType) => + createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: fetchFn as unknown as typeof fetch }); + +describe('#30295 — enrollment rejections carry the status and the code', () => { + it('enrollTotp against a deployment with two-factor off rejects with status 404', async () => { + const client = clientWith(rejectingFetch('/two-factor/enable', 404, {})); + const rejection = await rejectionOf(() => client.enrollTotp('admin123')); + expect(rejection.status).toBe(404); + // The message is unchanged for every other caller. + expect(rejection.message).toBe('Two-factor enable failed (404)'); + }); + + it('enrollTotp keeps a wrong password’s code beside its status', async () => { + const client = clientWith( + rejectingFetch('/two-factor/enable', 400, { message: 'Invalid password', code: 'INVALID_PASSWORD' }), + ); + const rejection = await rejectionOf(() => client.enrollTotp('wrong')); + expect(rejection.code).toBe('INVALID_PASSWORD'); + expect(rejection.status).toBe(400); + expect(rejection.message).toBe('Invalid password'); + }); + + it('verifyTotp against a deployment with two-factor off rejects with status 404', async () => { + const client = clientWith(rejectingFetch('/two-factor/verify-totp', 404, {})); + const rejection = await rejectionOf(() => client.verifyTotp('123456')); + expect(rejection.status).toBe(404); + expect(rejection.message).toBe('Two-factor verification failed (404)'); + }); +}); diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 64c528d893a..0fbd4b118fd 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -2011,6 +2011,10 @@ const ar = { }, }, console: { + organizationMfa: { + needsTwoFactor: "يتطلب فرض المصادقة متعددة العوامل تفعيل المصادقة الثنائية، وهي متوقفة في هذا النشر، لذا يُرفض الحفظ مع تحديد الخيار. فعّل المصادقة الثنائية أولًا: شغّل «طلب المصادقة متعددة العوامل» من الإعداد ← المصادقة (ينطبق على جميع المؤسسات)، أو عيّن OS_AUTH_TWO_FACTOR=true في النشر.", + nowRequired: "يجب الآن على كل عضو في هذه المؤسسة لم يُعدّ تطبيق مصادقة أن يفعل ذلك. يُمنع كل عضو من الوصول إلى البيانات عند انتهاء فترة السماح للمصادقة متعددة العوامل (7 أيام ما لم تُغيَّر من الإعداد ← المصادقة)، محسوبة من أول استخدام له بعد هذا التغيير. لا يتأثر الأعضاء الذين يستخدمون تطبيق مصادقة بالفعل.", + }, studio: { title: "Studio", accessRequired: "تحتاج إلى صلاحية الوصول إلى Studio لفتح Studio. يمكن للمسؤول منحها بمجموعة أذونات في الإعداد → إسناد مجموعات الأذونات.", @@ -3208,7 +3212,7 @@ const ar = { continue: "متابعة", preparing: "جارٍ التحضير…", enrollFailed: "تعذّر بدء التسجيل.", - unavailable: "المصادقة الثنائية متوقفة في هذا النشر، لذا لا يمكن إعداد تطبيق مصادقة بعد. اطلب من المسؤول تفعيلها من الإعدادات ← المصادقة، ثم أعد المحاولة.", + unavailable: "المصادقة الثنائية متوقفة في هذا النشر، لذا لا يمكن إعداد تطبيق مصادقة بعد. اطلب من المسؤول تفعيلها من الإعداد ← المصادقة، ثم أعد المحاولة.", scanTitle: "امسح الرمز بتطبيق المصادقة", scanBody: "امسح رمز QR هذا باستخدام Google Authenticator أو 1Password أو Authy وغيرها، ثم أدخل الرمز المكوّن من 6 أرقام.", backupTitle: "احفظ رموز النسخ الاحتياطي", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 3f0794bda24..dbf6847bba8 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -1804,6 +1804,10 @@ const de = { }, }, console: { + organizationMfa: { + needsTwoFactor: "Mehrstufige Authentifizierung erzwingen setzt die Zwei-Faktor-Authentifizierung voraus, die in dieser Bereitstellung deaktiviert ist; Speichern mit gesetztem Häkchen wird daher abgelehnt. Aktivieren Sie zuerst die Zwei-Faktor-Authentifizierung: Schalten Sie unter Einrichtung → Authentifizierung „Mehrstufige Authentifizierung verlangen“ ein (gilt für alle Organisationen), oder setzen Sie OS_AUTH_TWO_FACTOR=true in der Bereitstellung.", + nowRequired: "Alle Mitglieder dieser Organisation, die noch keine Authenticator-App eingerichtet haben, müssen das jetzt tun. Jedes wird vom Datenzugriff ausgeschlossen, sobald die MFA-Kulanzfrist endet (7 Tage, sofern unter Einrichtung → Authentifizierung nicht geändert), gerechnet ab der ersten Nutzung nach dieser Änderung. Mitglieder, die bereits einen Authenticator verwenden, sind nicht betroffen.", + }, studio: { title: "Studio", accessRequired: "Sie benötigen Studio-Zugriff, um Studio zu öffnen. Ein Administrator kann ihn unter Einrichtung → Berechtigungssatz-Zuweisung mit einem Berechtigungssatz erteilen.", @@ -2954,7 +2958,7 @@ const de = { continue: "Weiter", preparing: "Wird vorbereitet…", enrollFailed: "Registrierung konnte nicht gestartet werden.", - unavailable: "Die Zwei-Faktor-Authentifizierung ist in dieser Bereitstellung deaktiviert, daher kann noch keine Authenticator-App eingerichtet werden. Bitten Sie einen Administrator, sie unter Einstellungen → Authentifizierung zu aktivieren, und versuchen Sie es dann erneut.", + unavailable: "Die Zwei-Faktor-Authentifizierung ist in dieser Bereitstellung deaktiviert, daher kann noch keine Authenticator-App eingerichtet werden. Bitten Sie einen Administrator, sie unter Einrichtung → Authentifizierung zu aktivieren, und versuchen Sie es dann erneut.", scanTitle: "Mit Ihrer Authenticator-App scannen", scanBody: "Scannen Sie diesen QR-Code mit Google Authenticator, 1Password, Authy usw. und geben Sie dann den 6-stelligen Code ein.", backupTitle: "Backup-Codes speichern", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index e96bdcf327b..bb221d8424d 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -2196,6 +2196,12 @@ const en = { }, }, console: { + // What the organization record form says around `require_mfa`: before + // saving while two-factor is off, and after a save that turned it on. + organizationMfa: { + needsTwoFactor: 'Require Multi-Factor Auth needs two-factor authentication, which is off on this deployment, so saving with it ticked is refused. Turn two-factor on first: switch on Require multi-factor authentication in Setup → Authentication (it applies to every organization), or set OS_AUTH_TWO_FACTOR=true on the deployment.', + nowRequired: 'Every member of this organization who has not set up an authenticator app must now do so. Each one is blocked from data once the MFA grace period ends (7 days unless changed in Setup → Authentication), counted from the first time they use the app after this change. Members who already use an authenticator are not affected.', + }, // The fixed crumb `AppHeader`'s `studio` variant draws on the Studio front // door (objectui#11863), as `organizations.title` is the `orgs` variant's. // A product name, which every pack writes as is. @@ -3525,7 +3531,7 @@ const en = { continue: 'Continue', preparing: 'Preparing…', enrollFailed: 'Could not start enrollment.', - unavailable: 'Two-factor authentication is turned off on this deployment, so an authenticator app cannot be set up yet. Ask an administrator to turn it on in Settings → Authentication, then try again.', + unavailable: 'Two-factor authentication is turned off on this deployment, so an authenticator app cannot be set up yet. Ask an administrator to turn it on in Setup → Authentication, then try again.', scanTitle: 'Scan with your authenticator', scanBody: 'Scan this QR code with Google Authenticator, 1Password, Authy, etc., then enter the 6-digit code.', backupTitle: 'Save your backup codes', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index c08e506842e..7ed11d577ff 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -1858,6 +1858,10 @@ const es = { }, }, console: { + organizationMfa: { + needsTwoFactor: "Exigir autenticación multifactor necesita la autenticación de dos factores, que está desactivada en esta implementación, así que guardar con la casilla marcada se rechaza. Activa primero los dos factores: enciende Requerir autenticación multifactor en Configuración → Autenticación (se aplica a todas las organizaciones) o define OS_AUTH_TWO_FACTOR=true en la implementación.", + nowRequired: "Todos los miembros de esta organización que no hayan configurado una app de autenticación deben hacerlo ahora. A cada uno se le bloquea el acceso a los datos cuando termina el periodo de gracia de MFA (7 días salvo que se cambie en Configuración → Autenticación), contado desde la primera vez que usa la app después de este cambio. Los miembros que ya usan un autenticador no se ven afectados.", + }, studio: { title: "Studio", accessRequired: "Necesita acceso a Studio para abrir Studio. Un administrador puede concedérselo con un conjunto de permisos en Configuración → Asignación de conjuntos de permisos.", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 278ab3e4778..4bdae04c3c0 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -1854,6 +1854,10 @@ const fr = { }, }, console: { + organizationMfa: { + needsTwoFactor: "Exiger l'authentification multifacteur nécessite l'authentification à deux facteurs, désactivée sur ce déploiement : un enregistrement avec la case cochée est donc refusé. Activez d'abord les deux facteurs : activez « Exiger l'authentification multifacteur » dans Configuration → Authentification (cela s'applique à toutes les organisations), ou définissez OS_AUTH_TWO_FACTOR=true sur le déploiement.", + nowRequired: "Tous les membres de cette organisation qui n'ont pas configuré d'application d'authentification doivent maintenant le faire. Chacun perd l'accès aux données à la fin du délai de grâce MFA (7 jours sauf modification dans Configuration → Authentification), compté à partir de sa première utilisation après ce changement. Les membres qui utilisent déjà une application d'authentification ne sont pas concernés.", + }, studio: { title: "Studio", accessRequired: "Vous avez besoin de l'accès à Studio pour ouvrir Studio. Un administrateur peut vous l'accorder avec un ensemble d'autorisations dans Configuration → Attribution des ensembles d’autorisations.", @@ -3015,7 +3019,7 @@ const fr = { continue: "Continuer", preparing: "Préparation…", enrollFailed: "Impossible de démarrer l'enrôlement.", - unavailable: "L'authentification à deux facteurs est désactivée sur ce déploiement : aucune application d'authentification ne peut encore être configurée. Demandez à un administrateur de l'activer dans Paramètres → Authentification, puis réessayez.", + unavailable: "L'authentification à deux facteurs est désactivée sur ce déploiement : aucune application d'authentification ne peut encore être configurée. Demandez à un administrateur de l'activer dans Configuration → Authentification, puis réessayez.", scanTitle: "Scannez avec votre application d'authentification", scanBody: "Scannez ce QR code avec Google Authenticator, 1Password, Authy, etc., puis saisissez le code à 6 chiffres.", backupTitle: "Enregistrez vos codes de secours", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 85711ce219b..220931f765e 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -1804,6 +1804,10 @@ const ja = { }, }, console: { + organizationMfa: { + needsTwoFactor: "「多要素認証を必須化」には二要素認証が必要ですが、このデプロイではオフになっているため、チェックを入れて保存すると拒否されます。先に二要素認証をオンにしてください:「設定 → 認証」で「多要素認証を必須にする」をオンにする(すべての組織に適用されます)か、デプロイで OS_AUTH_TWO_FACTOR=true を設定します。", + nowRequired: "この組織で認証アプリをまだ登録していないメンバーは、全員登録が必要になりました。各メンバーは、この変更後に初めて利用した時点から MFA 猶予期間(変更しない限り 7 日、「設定 → 認証」で変更可能)が始まり、終了するとデータにアクセスできなくなります。すでに認証アプリを使っているメンバーには影響しません。", + }, studio: { title: "Studio", accessRequired: "Studio を開くには Studio へのアクセス権限が必要です。管理者が「設定 → 権限セットの割り当て」で権限セットを使って付与できます。", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 699f1d8b002..92d4a10f9da 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -1804,6 +1804,10 @@ const ko = { }, }, console: { + organizationMfa: { + needsTwoFactor: "다단계 인증 필수에는 2단계 인증이 필요하지만 이 배포에서는 꺼져 있으므로, 체크한 채로 저장하면 거부됩니다. 먼저 2단계 인증을 켜세요: 설정 → 인증에서 다단계 인증 필수를 켜거나(모든 조직에 적용됨), 배포에 OS_AUTH_TWO_FACTOR=true를 설정하세요.", + nowRequired: "인증 앱을 아직 설정하지 않은 이 조직의 모든 구성원은 이제 설정해야 합니다. 각 구성원은 이 변경 후 처음 앱을 사용할 때부터 MFA 유예 기간(변경하지 않으면 7일, 설정 → 인증에서 변경 가능)이 시작되며, 기간이 끝나면 데이터에 접근할 수 없습니다. 이미 인증 앱을 사용하는 구성원은 영향을 받지 않습니다.", + }, studio: { title: "Studio", accessRequired: "Studio를 열려면 Studio 접근 권한이 필요합니다. 관리자가 '설정 → 권한 집합 할당'에서 권한 세트로 부여할 수 있습니다.", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index feecab06c78..ca04febec6a 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -1853,6 +1853,10 @@ const pt = { }, }, console: { + organizationMfa: { + needsTwoFactor: "Exigir autenticação multifator precisa da autenticação de dois fatores, que está desativada nesta implantação, então salvar com a opção marcada é recusado. Ative os dois fatores primeiro: ligue Exigir autenticação multifator em Configuração → Autenticação (vale para todas as organizações) ou defina OS_AUTH_TWO_FACTOR=true na implantação.", + nowRequired: "Todos os membros desta organização que ainda não configuraram um app autenticador precisam fazê-lo agora. Cada um perde o acesso aos dados quando termina o período de carência de MFA (7 dias, a menos que alterado em Configuração → Autenticação), contado a partir do primeiro uso após esta alteração. Membros que já usam um autenticador não são afetados.", + }, studio: { title: "Studio", accessRequired: "Você precisa de acesso ao Studio para abrir o Studio. Um administrador pode concedê-lo com um conjunto de permissões em Configuração → Atribuição de conjuntos de permissões.", @@ -3014,7 +3018,7 @@ const pt = { continue: "Continuar", preparing: "Preparando…", enrollFailed: "Não foi possível iniciar o registro.", - unavailable: "A autenticação de dois fatores está desativada nesta implantação, então ainda não é possível configurar um app autenticador. Peça a um administrador para ativá-la em Configurações → Autenticação e tente novamente.", + unavailable: "A autenticação de dois fatores está desativada nesta implantação, então ainda não é possível configurar um app autenticador. Peça a um administrador para ativá-la em Configuração → Autenticação e tente novamente.", scanTitle: "Escaneie com seu aplicativo autenticador", scanBody: "Escaneie este QR code com o Google Authenticator, 1Password, Authy etc. e digite o código de 6 dígitos.", backupTitle: "Salve seus códigos de backup", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 5ef883af442..bf4f3123e82 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -1912,6 +1912,10 @@ const ru = { }, }, console: { + organizationMfa: { + needsTwoFactor: "Для параметра «Требовать многофакторную аутентификацию» нужна двухфакторная аутентификация, а в этом развёртывании она отключена, поэтому сохранение с отмеченным флажком будет отклонено. Сначала включите двухфакторную аутентификацию: включите «Требовать многофакторную аутентификацию» в разделе «Настройка → Аутентификация» (действует для всех организаций) или задайте OS_AUTH_TWO_FACTOR=true в развёртывании.", + nowRequired: "Все участники этой организации, ещё не привязавшие приложение-аутентификатор, теперь должны это сделать. Каждый теряет доступ к данным по окончании льготного периода MFA (7 дней, если не изменено в разделе «Настройка → Аутентификация»), который отсчитывается с первого использования после этого изменения. Участников, уже использующих аутентификатор, это не затрагивает.", + }, studio: { title: "Studio", accessRequired: "Чтобы открыть Studio, нужен доступ к Studio. Администратор может выдать его набором разрешений в разделе «Настройка → Назначение наборов разрешений».", @@ -3089,7 +3093,7 @@ const ru = { continue: "Продолжить", preparing: "Подготовка…", enrollFailed: "Не удалось начать привязку.", - unavailable: "Двухфакторная аутентификация в этом развёртывании отключена, поэтому приложение-аутентификатор пока нельзя привязать. Попросите администратора включить её в разделе «Настройки → Аутентификация», затем повторите попытку.", + unavailable: "Двухфакторная аутентификация в этом развёртывании отключена, поэтому приложение-аутентификатор пока нельзя привязать. Попросите администратора включить её в разделе «Настройка → Аутентификация», затем повторите попытку.", scanTitle: "Отсканируйте в приложении-аутентификаторе", scanBody: "Отсканируйте этот QR-код с помощью Google Authenticator, 1Password, Authy и т. п., затем введите 6-значный код.", backupTitle: "Сохраните резервные коды", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 81c7d82ab24..9d9a4afbc93 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1867,6 +1867,10 @@ const zh = { }, }, console: { + organizationMfa: { + needsTwoFactor: '“强制多因素认证”需要双重验证,而本部署未开启双重验证,因此勾选后保存会被拒绝。请先开启双重验证:在“设置 → 认证”中打开“要求多因素认证”(对所有组织生效),或在部署上设置 OS_AUTH_TWO_FACTOR=true。', + nowRequired: '本组织所有尚未绑定身份验证器的成员现在都必须绑定。每位成员从此次变更后第一次使用起开始计算 MFA 宽限期(默认 7 天,可在“设置 → 认证”中修改),宽限期结束后将无法访问数据,直到完成绑定。已使用身份验证器的成员不受影响。', + }, studio: { title: 'Studio', accessRequired: '你需要 Studio 访问权限才能打开 Studio。管理员可以在“设置 → 权限集分配”中通过权限集授予。', From 9b7299c970d0c031db59d4fa0d8b80249b9765bd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:43:42 +0000 Subject: [PATCH 3/6] wip: changeset for the organization MFA notice and the enrollment refusal Claude-Session: https://claude.ai/code/session_01Utp6CYeUM6wKr7VK5xLHXn Co-authored-by: Claude --- .../30295-org-mfa-notice-and-enroll-refusal.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/30295-org-mfa-notice-and-enroll-refusal.md diff --git a/.changeset/30295-org-mfa-notice-and-enroll-refusal.md b/.changeset/30295-org-mfa-notice-and-enroll-refusal.md new file mode 100644 index 00000000000..d7fa481f1ae --- /dev/null +++ b/.changeset/30295-org-mfa-notice-and-enroll-refusal.md @@ -0,0 +1,15 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/auth': patch +'@object-ui/i18n': patch +--- + +fix(app-shell,auth,i18n): the organization form says requiring MFA needs two-factor on, an accepted save says who must enroll and when, and the enrollment screen names a two-factor-off deployment in the user's language + +Clause-②: yes (widening) +Metadata-protocol: none + +- **Organization form, before saving.** While two-factor is off on the deployment (`features.twoFactor: false` in `/api/v1/auth/config`), the `sys_organization` edit form says, under its title, that Require Multi-Factor Auth needs two-factor, that a save with it ticked is refused, and how to turn two-factor on (Require multi-factor authentication in Setup → Authentication, or `OS_AUTH_TWO_FACTOR=true`). When the save is refused, the server's refusal shows on the field, as before. +- **Organization form, after an accepted save.** A save that turns `require_mfa` on for a row that stored it off adds a line to the saved toast: every member without an authenticator app must set one up, and is blocked from data once the MFA grace period ends (7 days unless changed in Setup → Authentication), counted from their first use after the change. A save of a row that already required it says nothing new. +- **Enrollment screen.** `enrollTotp` and `verifyTotp` of `@object-ui/auth` now reject with the HTTP `status` and the server's `code` on the error (the message is unchanged). The authenticator step reads a `404` (the `/two-factor/*` endpoints are not mounted while two-factor is off) as a translated sentence saying enrollment is not available and who can turn it on, instead of `Two-factor enable failed (404)`, and `INVALID_PASSWORD` as the password screen's sentence. Any other refusal keeps the server's message. +- **The widening.** `@object-ui/i18n` gains `auth.remediation.mfa.unavailable`, `console.organizationMfa.needsTwoFactor` and `console.organizationMfa.nowRequired` in all ten language packs. The two enrollment rejections gain the optional `status` and `code` properties. From 9fb2f487d22d37425bfdf4441ab79bff02ec7c85 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:50:04 +0000 Subject: [PATCH 4/6] wip(ui): the enrollment-status pins live in the createAuthClient harness Claude-Session: https://claude.ai/code/session_01Utp6CYeUM6wKr7VK5xLHXn Co-authored-by: Claude --- .../src/__tests__/createAuthClient.test.ts | 41 +++++++++++ .../__tests__/enrollTotp-status-30295.test.ts | 72 ------------------- 2 files changed, 41 insertions(+), 72 deletions(-) delete mode 100644 ui/packages/auth/src/__tests__/enrollTotp-status-30295.test.ts diff --git a/ui/packages/auth/src/__tests__/createAuthClient.test.ts b/ui/packages/auth/src/__tests__/createAuthClient.test.ts index 6d4b7495137..5606cd1baaf 100644 --- a/ui/packages/auth/src/__tests__/createAuthClient.test.ts +++ b/ui/packages/auth/src/__tests__/createAuthClient.test.ts @@ -575,3 +575,44 @@ describe('createAuthClient — signInWithProvider watchdog (#2626)', () => { } }); }); + +// objectstack#30295 — an enrollment refusal keeps its HTTP `status` and the +// server's `code`, so the enrollment screen can say in the user's language that +// two-factor is off (the `/two-factor/*` endpoints answer 404 then) instead of +// echoing `Two-factor enable failed (404)`. The message is unchanged. +describe('createAuthClient — enrollment rejections carry the status and the code', () => { + type Rejection = Error & { code?: string; status?: number }; + const rejectionOf = (run: () => Promise): Promise => + run().then( + () => { + throw new Error('expected the call to reject, but it resolved'); + }, + (err: unknown) => err as Rejection, + ); + const clientWith = (handlers: Record) => + createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: createMockFetch(handlers).mockFn }); + + it('enrollTotp against a deployment with two-factor off rejects with status 404', async () => { + const client = clientWith({ '/two-factor/enable': { status: 404, body: {} } }); + const rejection = await rejectionOf(() => client.enrollTotp('admin123')); + expect(rejection.status).toBe(404); + expect(rejection.message).toBe('Two-factor enable failed (404)'); + }); + + it('enrollTotp keeps a wrong password’s code beside its status', async () => { + const client = clientWith({ + '/two-factor/enable': { status: 400, body: { message: 'Invalid password', code: 'INVALID_PASSWORD' } }, + }); + const rejection = await rejectionOf(() => client.enrollTotp('wrong')); + expect(rejection.code).toBe('INVALID_PASSWORD'); + expect(rejection.status).toBe(400); + expect(rejection.message).toBe('Invalid password'); + }); + + it('verifyTotp against a deployment with two-factor off rejects with status 404', async () => { + const client = clientWith({ '/two-factor/verify-totp': { status: 404, body: {} } }); + const rejection = await rejectionOf(() => client.verifyTotp('123456')); + expect(rejection.status).toBe(404); + expect(rejection.message).toBe('Two-factor verification failed (404)'); + }); +}); diff --git a/ui/packages/auth/src/__tests__/enrollTotp-status-30295.test.ts b/ui/packages/auth/src/__tests__/enrollTotp-status-30295.test.ts deleted file mode 100644 index 9353f71ffa3..00000000000 --- a/ui/packages/auth/src/__tests__/enrollTotp-status-30295.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * objectstack#30295 — an enrollment refusal keeps its HTTP `status` and - * machine `code` through `enrollTotp` / `verifyTotp`. - * - * The `/two-factor/*` endpoints are mounted only while two-factor is on for the - * deployment, so `POST /two-factor/enable` answers 404 there. The client threw - * `new Error('Two-factor enable failed (404)')`, and the enrollment screen - * showed that English text: the status was in the message and nowhere else, so - * the screen had nothing to translate on. This drives the real better-auth - * client through a mocked `fetch`, as `changePassword-code-27869.test.ts` does. - */ - -import { describe, it, expect, vi } from 'vitest'; -import { createAuthClient } from '../createAuthClient'; - -function rejectingFetch(pathFragment: string, status: number, body: unknown) { - return vi.fn(async (input: string | URL | Request) => { - const url = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - if (url.includes(pathFragment)) { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }); - } - return new Response(JSON.stringify({}), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - }); -} - -type Rejection = Error & { code?: string; status?: number }; - -async function rejectionOf(run: () => Promise): Promise { - return run().then( - () => { - throw new Error('expected the call to reject, but it resolved'); - }, - (err: unknown) => err as Rejection, - ); -} - -const clientWith = (fetchFn: ReturnType) => - createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: fetchFn as unknown as typeof fetch }); - -describe('#30295 — enrollment rejections carry the status and the code', () => { - it('enrollTotp against a deployment with two-factor off rejects with status 404', async () => { - const client = clientWith(rejectingFetch('/two-factor/enable', 404, {})); - const rejection = await rejectionOf(() => client.enrollTotp('admin123')); - expect(rejection.status).toBe(404); - // The message is unchanged for every other caller. - expect(rejection.message).toBe('Two-factor enable failed (404)'); - }); - - it('enrollTotp keeps a wrong password’s code beside its status', async () => { - const client = clientWith( - rejectingFetch('/two-factor/enable', 400, { message: 'Invalid password', code: 'INVALID_PASSWORD' }), - ); - const rejection = await rejectionOf(() => client.enrollTotp('wrong')); - expect(rejection.code).toBe('INVALID_PASSWORD'); - expect(rejection.status).toBe(400); - expect(rejection.message).toBe('Invalid password'); - }); - - it('verifyTotp against a deployment with two-factor off rejects with status 404', async () => { - const client = clientWith(rejectingFetch('/two-factor/verify-totp', 404, {})); - const rejection = await rejectionOf(() => client.verifyTotp('123456')); - expect(rejection.status).toBe(404); - expect(rejection.message).toBe('Two-factor verification failed (404)'); - }); -}); From 30cfab992871f46993275995325b2074ba462be6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:52:30 +0000 Subject: [PATCH 5/6] wip(ui): type the stored-value hook test's props Claude-Session: https://claude.ai/code/session_01Utp6CYeUM6wKr7VK5xLHXn Co-authored-by: Claude --- .../__tests__/organizationMfaNotices-30295.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ui/packages/app-shell/src/console/__tests__/organizationMfaNotices-30295.test.tsx b/ui/packages/app-shell/src/console/__tests__/organizationMfaNotices-30295.test.tsx index a28059abbac..554033ccf17 100644 --- a/ui/packages/app-shell/src/console/__tests__/organizationMfaNotices-30295.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/organizationMfaNotices-30295.test.tsx @@ -63,10 +63,11 @@ describe('organizationMfaSavedNotice — after an accepted save', () => { describe('useStoredRequireMfa — the stored value while the form is open', () => { it('an edit reads the row once; a create is off; a closed form or another object reads nothing', async () => { const findOne = vi.fn(async () => ({ id: 'org_1', require_mfa: true })); + type Props = { objectName: string; recordId: string | undefined; open: boolean }; + const initialProps: Props = { objectName: 'sys_organization', recordId: 'org_1', open: true }; const { result, rerender } = renderHook( - (p: { objectName: string; recordId?: string; open: boolean }) => - useStoredRequireMfa(p.objectName, p.recordId, p.open, { findOne }), - { initialProps: { objectName: 'sys_organization', recordId: 'org_1', open: true } }, + (p: Props) => useStoredRequireMfa(p.objectName, p.recordId, p.open, { findOne }), + { initialProps }, ); await waitFor(() => expect(result.current).toBe(true)); rerender({ objectName: 'sys_organization', recordId: 'org_1', open: true }); From 52e235db670c791b48997cc317fc972d0545d68e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 08:34:32 +0000 Subject: [PATCH 6/6] test(i18n): count the de pack's new paired quote span from the organization MFA notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit console.organizationMfa.needsTwoFactor quotes the Setup switch it names („Mehrstufige Authentifizierung verlangen“), one more correctly paired span: 103 -> 104 in both pinned counts. Claude-Session: https://claude.ai/code/session_01Utp6CYeUM6wKr7VK5xLHXn Co-authored-by: Claude --- .../i18n/src/__tests__/de-quote-pairing-3876.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index c6afbfbf5d6..4b581f63464 100644 --- a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -385,7 +385,11 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // `marketplace.suggestedBindings.reopenedToast`, the toast after a dismissed // suggestion is re-opened, which quotes the set („{{set}}“) — one // interpolated span, runtime data. - expect(okSpans, 'correctly paired spans').toBe(103); + // 104 once objectstack-ai/objectstack#30295 added + // `console.organizationMfa.needsTwoFactor`, the organization form's notice + // that names the Setup switch to turn on („Mehrstufige Authentifizierung + // verlangen“) — one literal span, a UI label. + expect(okSpans, 'correctly paired spans').toBe(104); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -467,7 +471,9 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // dropped `connectAgent.vscode.body`, one matched pair fewer. 103 / 103 / 0 // after objectstack-ai/objectstack#30199 added // `marketplace.suggestedBindings.reopenedToast`, one more matched pair. - expect({ open, close, rdq }).toEqual({ open: 103, close: 103, rdq: 0 }); + // 104 / 104 / 0 after objectstack-ai/objectstack#30295 added + // `console.organizationMfa.needsTwoFactor`, one more matched pair. + expect({ open, close, rdq }).toEqual({ open: 104, close: 104, rdq: 0 }); // The durable shape: every „ closed by a “, every surplus “ an English // opener answered by a ”. Survived translating the two English values. expect(close).toBe(open + rdq);