Skip to content

Commit 10ecafd

Browse files
fix(app-shell,auth,i18n): the organization form says requiring MFA needs two-factor on, and the enrollment screen names a two-factor-off deployment in the user's language (#30295) (#30369)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgEwzCoEjW7R3Z9w3U4hkBrX)_ domain:ui#3 · os-bill · session_01Utp6CbPN5UYMnBaNJPJWfG ## 维护者速读 - 改了什么:两步验证关着时,Setup 里编辑组织的表单在标题下直接说明「强制多因素认证」需要先开两步验证、勾上保存会被拒、去哪里开;两步验证开着时,保存成功把它打开后,提示里多一句:哪些成员要去绑定身份验证器、从什么时候起算宽限期(默认 7 天)。注册身份验证器的页面遇到 404(两步验证没开)时,显示本地化的「此部署未开启双重验证,请联系管理员在设置 → 认证中开启」,不再显示 `Two-factor enable failed (404)`。 - 为什么改:服务端半边 #30293 已经拒存并解除锁死,但界面上管理员事先看不到任何说明,被拒后只有一段英文;成员若被引到注册页,看到的是一句未翻译的 404。 - 风险与回滚:只改界面文案与两个认证调用的报错字段(多带 `status`、`code`,原 message 不变),新增 3 个翻译键、十个语言包齐全。没有服务端、元数据或权限改动。回滚即撤本 PR。 - 先看:`ui/packages/app-shell/src/console/organizationMfaNotices.ts`(何时提示、何时不提示);`ui/packages/app-shell/src/console/AppContent.tsx` 的组织表单挂接与保存成功 toast;`RemediationOverlay.tsx` 的 `enrollRefusalMessage`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/console/__tests__/organizationMfaNotices-30295.test.tsx packages/app-shell/src/console/__tests__/RemediationOverlay.enrollUnavailable-30295.test.tsx`,预期 11/11 通过。 - 需要你做的:无,席位落地。 Fixes #30295 Clause-②: yes (widening) Metadata-protocol: none ## What changes The three Wanted items of the card, on a zh-CN console against a local `app-showcase` backend carrying #30293: - **Before saving (item 1, first half).** While `GET /api/v1/auth/config` answers `features.twoFactor: false`, the `sys_organization` edit form (the Console's global record-form modal in `AppContent.tsx`; the object declares no `editMode: 'page'`) shows a notice as the dialog's description: Require Multi-Factor Auth needs two-factor, a save with it ticked is refused, and how to turn two-factor on (Require multi-factor authentication in Setup → Authentication, which applies to every organization, or `OS_AUTH_TWO_FACTOR=true`). The notice is judged by `organizationMfaFormNotice` in the new `organizationMfaNotices.ts`: only that object, only a form that offers `require_mfa`, and only when the flag reads `false` (an unread config says nothing). - **The refused save (item 1, second half): already held, no change.** Measured on the before tree: the generic form shows the server's `400 VALIDATION_FAILED` text on the `require_mfa` field, not a generic failure (the `fields[]` entry #30293 sends). See Acceptance notes for its language. - **The accepted save (item 2).** A save that turns `require_mfa` on for a row that stored it off adds a line to the saved toast (the existing `notice` slot of `toastRecordSave`): 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; members already enrolled are not affected. The stored value comes from one `findOne` when the organization form opens (`useStoredRequireMfa`, keyed on the object, the record id and the open flag); a create counts as off, an unread value as off, a row already on says nothing. - **The enrollment screen (item 3).** `enrollTotp` / `verifyTotp` in `@object-ui/auth` now reject with the HTTP `status` and the server's `code` beside the unchanged message (`twoFactorEnrollError`). The authenticator step of `RemediationOverlay` reads `404` (the `/two-factor/*` endpoints are mounted only while two-factor is on) as `auth.remediation.mfa.unavailable`, saying enrollment is off and that an administrator turns it on in Setup → Authentication, and `INVALID_PASSWORD` as the password screen's existing sentence. Any other refusal keeps the server's message. - **i18n (the widening).** Three new keys in all ten packs: `auth.remediation.mfa.unavailable`, `console.organizationMfa.needsTwoFactor`, `console.organizationMfa.nowRequired`. - Changeset `.changeset/30295-org-mfa-notice-and-enroll-refusal.md` (`patch` for `@object-ui/app-shell`, `@object-ui/auth`, `@object-ui/i18n`). ## Why #30293 made the server refuse `require_mfa = true` while two-factor is off and stopped the gate from holding anyone it cannot enroll. Nothing in the console said so: the form offered the checkbox with the object's help ("When true, every member … must enroll"), the administrator learned the dependency only from the refusal, a successful save said nothing about who is now held or when, and a member sent to enroll on a deployment without two-factor read `Two-factor enable failed (404)`. ## Tests - `ui/packages/app-shell/src/console/__tests__/organizationMfaNotices-30295.test.tsx` (new, 7): the notice's three conditions (two-factor off; on or unread; another object or no field), the saved line's (turned on; already on, left off, another object; unread counts as off), and the stored-value hook (one read per open edit, create is off, closed form or another object reads nothing, a failed read stays unread). - `ui/packages/app-shell/src/console/__tests__/RemediationOverlay.enrollUnavailable-30295.test.tsx` (new, 4): zh and en 404 sentences (and the English `failed (404)` gone), zh `INVALID_PASSWORD`, and a control where a 429 keeps the server's message. The other RemediationOverlay files each carry their own `useAuth` mock and no shared harness exists, so this is its own file. - `ui/packages/auth/src/__tests__/createAuthClient.test.ts`: a new top-level describe in the existing harness, three pins through the real better-auth client and a mocked fetch (404 on enable and on verify carries `status`, a wrong password carries `code` and `status`, messages unchanged). - **Ablations** with `scripts/ablation-replace.mjs`, each restore proven blob == HEAD (these suites import source, so no build sits between mutation and run). A, C and D ran on `c4ec9241`; B ran on `c4ec9241` and again on `9fb2f487`, after its pins moved into the harness file (3 red of 37): - A, the overlay's 404 branch deleted: 2 red (both 404 sentences). - B, `status` no longer copied onto the error: 3 red (`expected undefined to be 404`). - C, the notice's two-factor check deleted: 1 red (`expected 'console.organizationMfa.needsTwoFactor' to be undefined`). - D, the already-on check deleted: 1 red (`expected 'console.organizationMfa.nowRequired' to be undefined`). ## Acceptance notes - **The refusal under the field is the server's English.** In a zh-CN session the field shows #30293's English message (it names `OS_AUTH_TWO_FACTOR` and `auth.mfa_required`). It is server-authored text; this PR adds the localized notice above it rather than replacing it. Localizing that refusal belongs to the server's message. - **The grace period's number is the default.** The console has no read of `auth.mfa_grace_period_days` (`/api/v1/auth/config` does not publish it, `@objectstack/client` has no settings read, and the settings page is admin-only), so the saved line says "7 days unless changed in Setup → Authentication". The server knows the configured value; a write notice from the server would carry it. - **The page-mode host is not wired.** `RecordFormPage` would show neither line for an organization; `sys_organization` declares no `editMode: 'page'`, so the modal is the only host a user reaches today. - The object's field help (`packages/platform-objects`, server metadata) still says only "When true, every member … must enroll"; this PR leaves it alone (out of this card's file surface). - Two names for two switches: the field is 强制多因素认证 (`sys_organization.require_mfa`, every member of one organization) and the setting is 要求多因素认证 (`auth.mfa_required`, every organization, and it turns two-factor on). The notice names both in each pack's own spelling. ## Verification Suites, type-check and gates ran on `30cfab99` (main `9afcd6a6`, which carries #30293, plus this branch); the final head `fc268ce2` merges `main` again (no `ui/` file moved) and re-ran the scoped suites. <details><summary>Browser check (zh-CN, local app-showcase backend, Playwright)</summary> The screenshots are in the project files (`ui-seat3/30295/`); no image host is reachable from this session. What each shows: - `before-enroll.png` / `after-enroll.png`: the MFA overlay raised by answering one data read with the server's `403 MFA_REQUIRED` (two-factor is off, so the real gate no longer holds anyone), then the password step against the real backend, whose `POST /two-factor/enable` answered 404. Before: `Two-factor enable failed (404)`. After: 此部署未开启双重验证,暂时无法绑定身份验证器。请联系管理员在“设置 → 认证”中开启后再试。 - `before-form.png` / `after-form.png`: the 编辑组织 dialog with two-factor off. Before: no notice. After: the notice under the title. - `before-saved.png` / `after-saved.png`: the box ticked and 更新 pressed with two-factor off. Both show the server's refusal on the field; the row stays `require_mfa: false` (read back over REST). - `after-2fa-saved.png`: backend restarted with `OS_AUTH_TWO_FACTOR=true`, the box ticked and saved: the toast reads 组织“Default Organization”已更新 plus 本组织所有尚未绑定身份验证器的成员现在都必须绑定。每位成员从此次变更后第一次使用起开始计算 MFA 宽限期(默认 7 天,可在“设置 → 认证”中修改)… The row reads `require_mfa: true`. </details> <details><summary>Suites and type-check (under scripts/pm/os-verify-lock.sh)</summary> - `pnpm --dir ui exec vitest run` over the three changed test files plus the files importing the changed modules' behaviour (the five other `RemediationOverlay.*` files, `changePassword-code-27869`, and the i18n parity files `all-locales-key-parity`, `authRemediation-locale-parity`, `en-zh-key-parity`, `console-namespace-3546`): 14 files, 158 tests passed (the auth pins then moved into `createAuthClient.test.ts`: 37/37). - `pnpm exec turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/auth --filter=@object-ui/i18n --concurrency=2`: Tasks 70 successful, 70 total. VERDICT command-exit 0. </details> <details><summary>Gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `30cfab99`: 35 derived, each run with its exit code captured before any pipe: 35 exit 0 (`check:type-check-debt` under the lock). `node scripts/check-sdui-manifest.mjs` exit 0. `--ran` over the record (`COMMAND :: exit N`): 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN, exit 0 ("a DERIVED zero — all 35 recorded an exit code and none of them is 3"). The merge of `main` (`fc268ce2`) brought no file under `ui/` or `.changeset/`; the derivation on the merged head is byte-identical, and the scoped suites re-ran green there (13 files, 158 tests). `pnpm lint` is CI's run. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic (`packages/spec` not touched). - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` is CI's run. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is Chinese by the Communication rule). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CYeUM6wKr7VK5xLHXn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1e6f542 commit 10ecafd

19 files changed

Lines changed: 501 additions & 10 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/auth': patch
4+
'@object-ui/i18n': patch
5+
---
6+
7+
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
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
- **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.
13+
- **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.
14+
- **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.
15+
- **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.

‎ui/packages/app-shell/src/console/AppContent.tsx‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import { enterAppBundle, readAppObjectScope } from './appObjectScope.js';
7272
import { preferLocal } from '../utils/preferLocal.js';
7373
import { packagedAutomationStudioPath, setupMetadataRest, setupMetadataStudioPath } from '../views/studio-design/setupMetadataRedirect.js';
7474
import { SkeletonDashboard, SkeletonDetail, SkeletonGrid } from '../skeletons/index.js';
75+
import { organizationMfaFormNotice, organizationMfaSavedNotice, useStoredRequireMfa } from './organizationMfaNotices.js';
7576

7677
// Route-based code splitting — lazy-load less-frequently-used routes.
7778
//
@@ -964,6 +965,23 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
964965
: currentObjectDef;
965966
const isChildFormTask = !!formObjectParam && formObjectParam !== currentObjectDef?.name;
966967

968+
// objectstack#30295 — an organization's `require_mfa`: before saving, the
969+
// form says it needs two-factor on (when it is off); after a save that turned
970+
// it on, the toast says who must enroll and when the block starts. The rules
971+
// live in `organizationMfaNotices.ts`.
972+
const formObjectFieldNames: string[] = formObjectDef?.fields
973+
? Array.isArray(formObjectDef.fields)
974+
? formObjectDef.fields.map((f: any) => (typeof f === 'string' ? f : f?.name)).filter(Boolean)
975+
: Object.keys(formObjectDef.fields)
976+
: [];
977+
const organizationMfaNotice = organizationMfaFormNotice({
978+
objectName: formObjectDef?.name,
979+
fields: formObjectFieldNames,
980+
features,
981+
t,
982+
});
983+
const storedRequireMfa = useStoredRequireMfa(formObjectDef?.name, editingRecord?.id, isDialogOpen, dataSource);
984+
967985
// objectstack#27138 — the record the overlay edits may be held by a pending
968986
// approval that locks it. The record's field projection leaves that lock out
969987
// (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 =
10381056
// the child's open related lists refetch on their own via the
10391057
// invalidation bus (#2269): the dataSource write already emitted a
10401058
// MutationEvent that the bridge fans out — no manual notify needed.
1041-
const feedback = answerWarningClaim.current?.take(saved);
1059+
const claimed = answerWarningClaim.current?.take(saved);
1060+
const mfaNotice = organizationMfaSavedNotice({
1061+
objectName: formObjectDef?.name,
1062+
storedRequireMfa,
1063+
saved,
1064+
t,
1065+
});
1066+
const feedback = mfaNotice
1067+
? {
1068+
warnings: claimed?.warnings ?? [],
1069+
notice: claimed?.notice ? `${claimed.notice}\n${mfaNotice}` : mfaNotice,
1070+
}
1071+
: claimed;
10421072
if (isChildFormTask) {
10431073
toastRecordSave(toast, recordSaveMessage(saved), feedback);
10441074
closeRecordForm();
@@ -1080,7 +1110,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
10801110
return;
10811111
}
10821112
navigate(target.url, { replace: true });
1083-
}, [handleCrudSuccess, recordSaveMessage, isChildFormTask, editingRecord, currentObjectDef, appSegment, location.pathname, navigate, closeRecordForm, objectPluralLabel]);
1113+
}, [handleCrudSuccess, recordSaveMessage, isChildFormTask, editingRecord, currentObjectDef, appSegment, location.pathname, navigate, closeRecordForm, objectPluralLabel, formObjectDef?.name, storedRequireMfa, t]);
10841114

10851115
// Track recent items on route change. The hook matches `appName` against the
10861116
// pathname's app segment, so it takes the ROUTE SEGMENT: handed
@@ -1718,6 +1748,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
17181748
title: editingRecord
17191749
? t('form.editTitle', { object: objectLabel(formObjectDef as any) })
17201750
: t('form.createTitle', { object: objectLabel(formObjectDef as any) }),
1751+
...(organizationMfaNotice ? { description: organizationMfaNotice } : {}),
17211752
open: isDialogOpen,
17221753
onOpenChange: (open: boolean) => { if (!open) closeRecordForm(); },
17231754
// objectui#6514 — the same declared-key gate `RecordFormPage`

‎ui/packages/app-shell/src/console/RemediationOverlay.tsx‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,30 @@ function TotpQr({ uri }: { uri: string }) {
248248
);
249249
}
250250

251+
/**
252+
* What the authenticator step says when the server refuses it, in the user's
253+
* language where the refusal is one the console can name:
254+
*
255+
* - `404`: the `/two-factor/*` endpoints are not mounted, i.e. two-factor is off
256+
* on this deployment, so no password or code the user types can succeed. The
257+
* sentence says that and who can change it, instead of `… failed (404)`.
258+
* - `INVALID_PASSWORD`: the password confirmation was wrong (the same sentence
259+
* the password screen uses).
260+
*
261+
* Any other refusal keeps the server's own message; an error with none falls
262+
* back to FALLBACK_KEY.
263+
*/
264+
function enrollRefusalMessage(
265+
err: unknown,
266+
t: ReturnType<typeof useObjectTranslation>['t'],
267+
fallbackKey: string,
268+
): string {
269+
const { status, code } = (err ?? {}) as { status?: unknown; code?: unknown };
270+
if (status === 404) return t('auth.remediation.mfa.unavailable');
271+
if (code === 'INVALID_PASSWORD') return t('auth.remediation.password.wrongPassword');
272+
return err instanceof Error && err.message ? err.message : t(fallbackKey);
273+
}
274+
251275
function MfaEnrollForm({ message }: { message: string }) {
252276
const { enrollTotp, verifyTotp, setRemediationRequired } = useAuth();
253277
const { t } = useObjectTranslation();
@@ -268,7 +292,7 @@ function MfaEnrollForm({ message }: { message: string }) {
268292
setBackupCodes(codes ?? []);
269293
setStep('verify');
270294
} catch (err) {
271-
setError(err instanceof Error ? err.message : t('auth.remediation.mfa.enrollFailed'));
295+
setError(enrollRefusalMessage(err, t, 'auth.remediation.mfa.enrollFailed'));
272296
} finally {
273297
setBusy(false);
274298
}
@@ -282,7 +306,7 @@ function MfaEnrollForm({ message }: { message: string }) {
282306
setRemediationRequired(null);
283307
window.location.reload();
284308
} catch (err) {
285-
setError(err instanceof Error ? err.message : t('auth.remediation.mfa.invalidCode'));
309+
setError(enrollRefusalMessage(err, t, 'auth.remediation.mfa.invalidCode'));
286310
setBusy(false);
287311
}
288312
};
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* The enrollment screen says, in the user's language, that two-factor is off
3+
* on the deployment when the server answers 404 (objectstack#30295).
4+
*
5+
* `POST /two-factor/enable` is mounted only while two-factor is on, so a 404
6+
* means no password the user types can succeed. The screen used to show the
7+
* client's English `Two-factor enable failed (404)`. A 404 now reads as a
8+
* translated sentence saying who can turn it on; a wrong password reads as the
9+
* password screen's sentence; any other refusal keeps the server's message.
10+
*/
11+
12+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
13+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
14+
import { I18nProvider } from '@object-ui/i18n';
15+
16+
let refusal: Error & { code?: string; status?: number } = new Error('Two-factor enable failed (404)');
17+
const enrollTotp = vi.fn(async () => { throw refusal; });
18+
19+
vi.mock('@object-ui/auth', async (importOriginal) => ({
20+
...(await importOriginal<typeof import('@object-ui/auth')>()),
21+
useAuth: () => ({
22+
remediationRequired: { code: 'MFA_REQUIRED', message: '' },
23+
setRemediationRequired: vi.fn(),
24+
changePassword: vi.fn(),
25+
enrollTotp,
26+
verifyTotp: vi.fn(),
27+
signOut: vi.fn(),
28+
}),
29+
}));
30+
31+
import { RemediationOverlay } from '../RemediationOverlay';
32+
33+
const refused = (message: string, extra: { code?: string; status?: number }) =>
34+
Object.assign(new Error(message), extra);
35+
36+
function submitPassword(language: string, label: string) {
37+
render(
38+
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
39+
<RemediationOverlay />
40+
</I18nProvider>,
41+
);
42+
fireEvent.change(screen.getByLabelText(label), { target: { value: 'admin123' } });
43+
fireEvent.submit(screen.getByLabelText(label).closest('form') as HTMLFormElement);
44+
}
45+
46+
beforeEach(() => {
47+
enrollTotp.mockClear();
48+
});
49+
afterEach(cleanup);
50+
51+
describe('RemediationOverlay — an enrollment refusal reads in the user’s language (#30295)', () => {
52+
it('zh: a 404 says two-factor is off and who can turn it on, not “failed (404)”', async () => {
53+
refusal = refused('Two-factor enable failed (404)', { status: 404 });
54+
submitPassword('zh', '确认您的密码');
55+
expect(
56+
await screen.findByText('此部署未开启双重验证,暂时无法绑定身份验证器。请联系管理员在“设置 → 认证”中开启后再试。'),
57+
).toBeTruthy();
58+
expect(screen.queryByText('Two-factor enable failed (404)')).toBeNull();
59+
expect(enrollTotp).toHaveBeenCalledTimes(1);
60+
});
61+
62+
it('en: a 404 shows the English sentence', async () => {
63+
refusal = refused('Two-factor enable failed (404)', { status: 404 });
64+
submitPassword('en', 'Confirm your password');
65+
expect(
66+
await screen.findByText(
67+
'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.',
68+
),
69+
).toBeTruthy();
70+
});
71+
72+
it('zh: INVALID_PASSWORD shows the password screen’s sentence', async () => {
73+
refusal = refused('Invalid password', { status: 400, code: 'INVALID_PASSWORD' });
74+
submitPassword('zh', '确认您的密码');
75+
expect(await screen.findByText('你输入的密码不正确,请检查后重试。')).toBeTruthy();
76+
});
77+
78+
it('CONTROL: another refusal keeps the server’s message', async () => {
79+
refusal = refused('Too many requests. Please try again later.', { status: 429 });
80+
submitPassword('en', 'Confirm your password');
81+
expect(await screen.findByText('Too many requests. Please try again later.')).toBeTruthy();
82+
});
83+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* What the organization record form says around `require_mfa`
3+
* (objectstack#30295): the notice before saving while two-factor is off, and
4+
* the line an accepted save that turned it on adds to its toast.
5+
*/
6+
7+
import { describe, it, expect, vi } from 'vitest';
8+
import { renderHook, waitFor } from '@testing-library/react';
9+
import {
10+
organizationMfaFormNotice,
11+
organizationMfaSavedNotice,
12+
useStoredRequireMfa,
13+
} from '../organizationMfaNotices';
14+
15+
const t = ((key: string) => key) as Parameters<typeof organizationMfaFormNotice>[0]['t'];
16+
const FIELDS = ['name', 'require_mfa', 'timezone'];
17+
18+
describe('organizationMfaFormNotice — before saving', () => {
19+
it('two-factor off: the organization form says requiring MFA needs it', () => {
20+
expect(
21+
organizationMfaFormNotice({ objectName: 'sys_organization', fields: FIELDS, features: { twoFactor: false }, t }),
22+
).toBe('console.organizationMfa.needsTwoFactor');
23+
});
24+
25+
it('two-factor on, or not read yet: nothing', () => {
26+
for (const features of [{ twoFactor: true }, {}]) {
27+
expect(organizationMfaFormNotice({ objectName: 'sys_organization', fields: FIELDS, features, t })).toBeUndefined();
28+
}
29+
});
30+
31+
it('another object, or a form without the field: nothing', () => {
32+
expect(
33+
organizationMfaFormNotice({ objectName: 'sys_team', fields: FIELDS, features: { twoFactor: false }, t }),
34+
).toBeUndefined();
35+
expect(
36+
organizationMfaFormNotice({ objectName: 'sys_organization', fields: ['name'], features: { twoFactor: false }, t }),
37+
).toBeUndefined();
38+
});
39+
});
40+
41+
describe('organizationMfaSavedNotice — after an accepted save', () => {
42+
const saved = { id: 'org_1', name: 'Acme', require_mfa: true };
43+
44+
it('a save that turned it on says who must enroll and when the block starts', () => {
45+
expect(organizationMfaSavedNotice({ objectName: 'sys_organization', storedRequireMfa: false, saved, t })).toBe(
46+
'console.organizationMfa.nowRequired',
47+
);
48+
// An unread stored value counts as off.
49+
expect(organizationMfaSavedNotice({ objectName: 'sys_organization', storedRequireMfa: undefined, saved, t })).toBe(
50+
'console.organizationMfa.nowRequired',
51+
);
52+
});
53+
54+
it('a row that already required it, a save that leaves it off, another object: nothing', () => {
55+
expect(organizationMfaSavedNotice({ objectName: 'sys_organization', storedRequireMfa: true, saved, t })).toBeUndefined();
56+
expect(
57+
organizationMfaSavedNotice({ objectName: 'sys_organization', storedRequireMfa: false, saved: { ...saved, require_mfa: false }, t }),
58+
).toBeUndefined();
59+
expect(organizationMfaSavedNotice({ objectName: 'sys_team', storedRequireMfa: false, saved, t })).toBeUndefined();
60+
});
61+
});
62+
63+
describe('useStoredRequireMfa — the stored value while the form is open', () => {
64+
it('an edit reads the row once; a create is off; a closed form or another object reads nothing', async () => {
65+
const findOne = vi.fn(async () => ({ id: 'org_1', require_mfa: true }));
66+
type Props = { objectName: string; recordId: string | undefined; open: boolean };
67+
const initialProps: Props = { objectName: 'sys_organization', recordId: 'org_1', open: true };
68+
const { result, rerender } = renderHook(
69+
(p: Props) => useStoredRequireMfa(p.objectName, p.recordId, p.open, { findOne }),
70+
{ initialProps },
71+
);
72+
await waitFor(() => expect(result.current).toBe(true));
73+
rerender({ objectName: 'sys_organization', recordId: 'org_1', open: true });
74+
expect(findOne).toHaveBeenCalledTimes(1);
75+
expect(findOne).toHaveBeenCalledWith('sys_organization', 'org_1');
76+
77+
rerender({ objectName: 'sys_organization', recordId: undefined, open: true });
78+
expect(result.current).toBe(false);
79+
rerender({ objectName: 'sys_organization', recordId: 'org_1', open: false });
80+
expect(result.current).toBeUndefined();
81+
rerender({ objectName: 'sys_team', recordId: 'team_1', open: true });
82+
expect(result.current).toBeUndefined();
83+
expect(findOne).toHaveBeenCalledTimes(1);
84+
});
85+
86+
it('a failed read leaves it unread', async () => {
87+
const findOne = vi.fn(async () => { throw new Error('403'); });
88+
const { result } = renderHook(() => useStoredRequireMfa('sys_organization', 'org_1', true, { findOne }));
89+
await waitFor(() => expect(findOne).toHaveBeenCalledTimes(1));
90+
expect(result.current).toBeUndefined();
91+
});
92+
});

0 commit comments

Comments
 (0)