Skip to content

Commit 5b748df

Browse files
fix(app-shell): turning on Require MFA on an organization shows the grace period once (#30592)
Fixes #30565 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:组织记录勾选「强制 MFA」并保存成功后,提示里只出现服务端那句宽限期说明(天数取自部署设置);控制台自己那句写死「7 天」的说明不再追加在下面。 - 为什么改:服务端自 PR #30529 起会在保存回答里带上真实宽限期的说明,控制台再加一句就重复两遍,Setup 改成 3 天时两句还互相矛盾(在 main `27b0bc79` 上用单元复现:提示两行,一行 3 天、一行 7 天)。 - 风险与回滚:只看回答里的 notice 是否以服务端那句开头(四种语言);认不出时退回原样显示两句,不会两句都没有。没有 notice(旧服务端)照旧显示控制台那句。无数据迁移,回滚即还原本 PR。 - 先看:`ui/packages/app-shell/src/console/organizationMfaNotices.ts` 的 `SERVER_MFA_NOTICE_OPENINGS` 与 `isServerMfaNotice`;`AppContent.tsx` 的 `handleRecordFormSuccess` 里多传的 `answerNotice: claimed?.notice`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/console/__tests__/organizationMfaNotices-30295.test.tsx packages/app-shell/src/console/__tests__/AppContent.recordSaveOneToast-27999.test.tsx`,预期 26 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes - **`@object-ui/app-shell`, `organizationMfaSavedNotice`** (`console/organizationMfaNotices.ts`) takes one more optional input, `answerNotice`: the save answer's `notice` as the record form's claim took it. When that notice is the server's own MFA notice, the function returns `undefined`, so the console adds no `console.organizationMfa.nowRequired` line. Everything else it judged before (object, stored value, saved value) is unchanged. - **`AppContent.tsx`, `handleRecordFormSuccess`** passes `answerNotice: claimed?.notice` to it. The join below (`claimed.notice` + the console line) is unchanged: with no console line it hands the claim's feedback through, so the toast carries the server's notice once. - **The rule that tells the server's notice apart:** the notice contains the server copy's opening sentence in one of the four languages it is written in (`en`, `zh-CN`, `ja-JP`, `es-ES`; plugin-auth's `ORGANIZATION_MFA_REQUIRED_NOTICE_COPY`). The N-day and 0-day sentences open with the same one, so four constants cover all of them. A bare "a notice is there" check is not enough: the answer carries ONE notice, the last one a hook or a flow's `save_notice` node on the write set (`WRITE_ANSWER_NOTICE_DESCRIPTION` in the protocol, "when more than one call sets it, the LAST one wins"). The MFA guard sets its notice at `beforeInsert`/`beforeUpdate` priority 15, so any later hook replaces it. On that save a presence check would drop the only sentence that tells the administrator who will be blocked. The wrong direction for this rule is also the safe one: if the server's wording ever changes, the console shows both lines again, never neither. - No i18n change: the console sentence still serves an answer that carries no server notice. - **Changeset:** `.changeset/30565-organization-mfa-notice-once.md`, `@object-ui/app-shell` patch. ## Tests A new top-level `describe` in `ui/packages/app-shell/src/console/__tests__/organizationMfaNotices-30295.test.tsx` (this module's existing harness): - The answer carries the server notice: no console line. Five cases, the server's text verbatim: `en` 3 days, `en` 0 days, `zh-CN` 3 days, `ja-JP` 0 days, `es-ES` 3 days. Each fixture was checked byte-identical to `ORGANIZATION_MFA_REQUIRED_NOTICE_COPY[locale](days)` evaluated from the server source at this head. - No notice on the answer: the console line, as before. - Another hook's notice on the same save (`Sent to your manager for approval`): the console line too. A new top-level `describe` in `ui/packages/app-shell/src/console/__tests__/AppContent.recordSaveOneToast-27999.test.tsx` (the record-save toast harness, a real `AppContent` mount; the harness gains a `sys_organization` object, a `findOne` on its adapter stub answering the stored row with `require_mfa: false`, and `saveFrom` now checks the overlay edits the object the write names). An edit of organization `org_1` saves `require_mfa: true`: - The answer's `notice` is the server's English 3-day notice (the 30295 fixture): ONE success toast, `Organization "Acme" updated`, whose description is the server notice alone; the stored row was read (`findOne('sys_organization', 'org_1')`). - CONTROL: no notice on the answer: the description is the console line (`console.organizationMfa.nowRequired` in `en`, which names 7 days). - CONTROL: another hook's notice on the answer: the description is that notice, then the console line. ## Acceptance notes - **The two copies, compared.** The console line says: members without an authenticator app must enroll; they are blocked from data once the grace period ends; "7 days unless changed in Setup → Authentication"; counted from first use after the change; members who already use one are not affected. The server notice says all of that, with the real number of days and where it is set. Nothing the console line says is lost. - **Languages.** The server copy is written in four languages, and other caller locales get the English one (`copyLocaleFor`). The console ships ten locales. A console in `de`, `fr`, `ko`, `pt`, `ru` or `ar` used to show the English server notice followed by its own localized line. It now shows the English server notice only. Adding those six languages to the server copy belongs to the server lane (out of scope here). - **Mount-level pin.** The decision is pinned on the function, and the one wiring line in `AppContent.tsx` (`answerNotice: claimed?.notice`) is pinned on a mounted `AppContent` in the shared record-save toast harness (see Tests). Deleting that line reds the server-notice case (ablation 3). - The four opening sentences are a second copy of server text in the console, and no gate checks the two against each other. The test fixtures are the server's text at this head. A protocol-level code on the answer's notice would remove the copy, but that is a contract change, out of scope. ## Verification Base `27b0bc79`, head `9dee7aa4`. The fix is `8aa05756`; `9dee7aa4` adds only the mount-level pin. <details><summary>Premise, tests, ablation</summary> - **Premise, on the base** (a throwaway test, deleted before the commit, that runs `AppContent`'s join verbatim through `toastRecordSave` with the `en` catalogue and the server's 3-day notice): one success toast whose description has **2 lines**, containing both "3 days" and "7 days". The same probe on the fix: 1 line, "3 days" only. - New pins on the base: 5 failed (the five server-notice cases), 9 passed. - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/app-shell/src/console/__tests__/organizationMfaNotices-30295.test.tsx` on the head: Test Files 1 passed (1), Tests 14 passed (14). - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/app-shell/src/console/__tests__/AppContent.recordSaveOneToast-27999.test.tsx packages/app-shell/src/console/__tests__/organizationMfaNotices-30295.test.tsx` on `9dee7aa4`: Test Files 2 passed (2), Tests 26 passed (26). - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/app-shell/src/console/__tests__/` under the verify lock on `9dee7aa4`: Test Files 75 passed (75), Tests 619 passed (619), VERDICT command-exit 0 (616 on `8aa05756`). - **Ablation 1** (`scripts/ablation-replace.mjs`, delete `if (isServerMfaNotice(input.answerNotice)) return undefined;`, anchor x1 to x0): 5 failed / 9 passed, the five server-notice cases red, the no-notice and other-notice controls green. Restored: blob `63b87c8c` equals HEAD, `git diff HEAD` empty. - **Ablation 2** (the same line replaced by a presence check, `if (input.answerNotice) return undefined;`): 1 failed / 13 passed, the other-hook-notice case red. Restored: blob equals HEAD, `git diff HEAD` empty. - **Ablation 3** (on `9dee7aa4`, delete `answerNotice: claimed?.notice,` in `AppContent.tsx`, anchor x1 to x0, blob `b8669035` to `adc800b5`): 1 failed / 11 passed in the 27999 harness, the server-notice case red, both CONTROLs and the nine earlier cases green. Restored: blob `b8669035` equals HEAD, `git diff HEAD` empty. </details> <details><summary>Gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on head `9dee7aa4` (5 paths) derived the same 35 commands as on `8aa05756`; each was re-run on `9dee7aa4` and its exit code recorded before any pipe: - `node scripts/check-adr-0087-registration.mjs --base origin/main` :: exit 0 - `node scripts/check-adr-0087-registration.mjs --self-test` :: exit 0 - `node scripts/check-changeset-no-major.mjs --base origin/main` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test` :: exit 0 - `node scripts/check-comment-mask-corpus.mjs` :: exit 0 - `node scripts/check-empty-changeset.mjs --base origin/main` :: exit 0 - `node scripts/check-empty-changeset.mjs --self-test` :: exit 0 - `node scripts/check-sdui-manifest.mjs` :: exit 0 - `node scripts/check-sdui-manifest.mjs --self-test` :: exit 0 - `node scripts/pm/release-rehearsal-clone.mjs --self-test` :: exit 0 - `node scripts/release-pending-publish.mjs --self-test` :: exit 0 - `pnpm --dir ui exec vitest run scripts/` :: exit 0 - `pnpm --filter @objectstack/spec run check:duration-unit-keys` :: exit 0 - `pnpm check:changeset-gate-self-tests` :: exit 0 - `pnpm check:closed-ceremony-text` :: exit 0 - `pnpm check:cross-package-test-inputs` :: exit 0 - `pnpm check:dispatcher-error-vocabulary` :: exit 0 - `pnpm check:driver-memory-census` :: exit 0 - `pnpm check:engine-double-contract` :: exit 0 - `pnpm check:gitlink-declared` :: exit 0 - `pnpm check:nul-bytes` :: exit 0 - `pnpm check:published-files` :: exit 0 - `pnpm check:query-options-erasure` :: exit 0 - `pnpm check:refd-timer-probe` :: exit 0 - `pnpm check:registry-log-declared` :: exit 0 - `pnpm check:rest-log-spy-declared` :: exit 0 - `pnpm check:skill-identifier-liveness` :: exit 0 - `pnpm check:test-source-alias` :: exit 0 - `pnpm check:tier-file-adoption` :: exit 0 - `pnpm check:type-check-coverage` :: exit 0 - `pnpm check:type-check-debt` :: exit 0 - `pnpm check:undeclared-dep-imports` :: exit 0 - `pnpm check:watch-hint-literal` :: exit 0 - `pnpm check:where-matcher` :: exit 0 `node scripts/pm/dispatch-gates.mjs --ran ran.list --repo objectstack-ai/objectstack` on `9dee7aa4` :: exit 0, "35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN". Also: - `pnpm --dir ui run check:spec-symbols` :: exit 0 - `node scripts/check-new-cross-file-line-citations.mjs` (ui) :: exit 0, 0 new citations - `node scripts/check-control-bytes.mjs` (ui) :: exit 0 - `pnpm exec turbo run type-check --filter=@object-ui/app-shell` under the verify lock on `9dee7aa4` (cache miss on `@object-ui/app-shell:type-check`, both `tsc --noEmit` and `tsc -p tsconfig.test.json`, so the test files are compiled) :: VERDICT command-exit 0, 68 tasks successful - `eslint` on the changed source and test files :: exit 0, 0 errors (the 27999 harness: 0 problems) CI runs the rest. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (no schema touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint on the changed source and test files: 0 errors; its warnings are on `AppContent.tsx` lines this PR does not change) - [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: its generated artifacts are regenerated and committed. (not touched) - [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. (except the `维护者速读` block the os-dev contract asks for) --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgVfPeFZkN1FP93imKU)_ <!-- signed: domain:ui#4 · os-sales · session_01L6bhgVfPeFZkN1FP93imKU --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4831d50 commit 5b748df

5 files changed

Lines changed: 167 additions & 2 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): turning on Require MFA on an organization shows the grace period once, with the number Setup set
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
An accepted save that turns `require_mfa` on for an organization now gets a save notice from the server that names who must enroll and the MFA grace period in force. The console still added its own line under it, which says the same thing with the default "7 days". So the toast told the administrator the grace period twice, and when Setup → Authentication set another number, the two lines disagreed.
11+
12+
The record form now leaves its own line out when the save answer's notice is the server's MFA notice. It recognizes that notice by its opening sentence, in each of the four languages the server writes it in, and not just by a notice being there: the answer carries one notice, the last one a hook or flow on the write set, so another notice on the same save keeps the console line. A save whose answer has no notice (a server that does not send one) shows the console line as before.

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,10 +1058,13 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
10581058
// invalidation bus (#2269): the dataSource write already emitted a
10591059
// MutationEvent that the bridge fans out — no manual notify needed.
10601060
const claimed = answerWarningClaim.current?.take(saved);
1061+
// No console line when the answer's notice is the server's own MFA notice,
1062+
// which names the grace period in force (objectstack#30565).
10611063
const mfaNotice = organizationMfaSavedNotice({
10621064
objectName: formObjectDef?.name,
10631065
storedRequireMfa,
10641066
saved,
1067+
answerNotice: claimed?.notice,
10651068
t,
10661069
});
10671070
const feedback = mfaNotice

‎ui/packages/app-shell/src/console/__tests__/AppContent.recordSaveOneToast-27999.test.tsx‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ const dataSourceStub = {
9797
onConnectionStateChange: () => () => {},
9898
getConnectionState: () => 'connected',
9999
getObjectSchema: async () => ({ fields: {} as Record<string, { label?: string }> }),
100+
// The organization form reads the row's stored `require_mfa` (objectstack#30295): stored off.
101+
findOne: vi.fn(async (_objectName: string, id: string) => ({ id, require_mfa: false })),
100102
};
101103
vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({
102104
...(await importOriginal<Record<string, unknown>>()),
@@ -113,6 +115,13 @@ const OBJECTS = [
113115
},
114116
// The parent the child-form task opens over.
115117
{ name: 'showcase_project', label: 'Project', fields: { name: { type: 'text', label: 'Name' } } },
118+
// The organization whose `require_mfa` a save turns on (objectstack#30565).
119+
{
120+
name: 'sys_organization',
121+
label: 'Organization',
122+
nameField: 'name',
123+
fields: { name: { type: 'text', label: 'Name' }, require_mfa: { type: 'boolean', label: 'Require MFA' } },
124+
},
116125
];
117126

118127
const APPS = [{ name: 'crm', label: 'CRM', isDefault: true, navigation: [] }];
@@ -211,7 +220,8 @@ async function saveFrom(url: string, event: WriteWarningEvent, saved: Record<str
211220
// The overlay's claim is opened by an effect of the mounted, open dialog.
212221
await waitFor(() => expect(handlers.has('crud_success')).toBe(true));
213222
const schema = modalSchemas[modalSchemas.length - 1];
214-
expect(schema.objectName).toBe('showcase_task');
223+
// The overlay edits the object the write names.
224+
expect(schema.objectName).toBe(event.resource);
215225
const channel = emitWriteWarning(event, t, dataSourceStub as never, identityLabel, toast);
216226
await schema.onSuccess(saved);
217227
await channel;
@@ -326,3 +336,57 @@ describe('AppContent — the overlay save toast carries the hook notice (objects
326336
expect(toastInfo.mock.calls).toEqual([[NOTICE]]);
327337
});
328338
});
339+
340+
// objectstack#30565 — an organization save that turns `require_mfa` on (stored
341+
// off): the server answers a `notice` naming the grace period in force, so the
342+
// save toast carries it alone, without the console's own
343+
// `console.organizationMfa.nowRequired` line, which names the default 7 days.
344+
// With no notice, or another hook's notice, the console line stays.
345+
describe('AppContent — turning on Require MFA toasts the server notice once (objectstack#30565)', () => {
346+
const ORG_EDIT = '/apps/crm/sys_organization?form=org_1';
347+
const ORG_SAVED = { id: 'org_1', name: 'Acme', require_mfa: true };
348+
// The server's English notice for a 3-day grace period, verbatim (the same
349+
// fixture as `organizationMfaNotices-30295.test.tsx`).
350+
const SERVER_NOTICE =
351+
'Multi-factor authentication is now required for this organization. Every member who has not set up an ' +
352+
'authenticator app has 3 days to enroll one, counted from the first time they use the app while it is ' +
353+
'required; after that they are blocked from data until they do. Members who already use an authenticator ' +
354+
'app are not affected. The MFA grace period is set in Setup → Authentication.';
355+
const OTHER_NOTICE = 'Sent to your manager for approval';
356+
const CONSOLE_LINE = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false }).t(
357+
'console.organizationMfa.nowRequired',
358+
);
359+
const orgUpdate = (notice: string | undefined): WriteWarningEvent => ({
360+
operation: 'update',
361+
resource: 'sys_organization',
362+
id: 'org_1',
363+
droppedFields: [],
364+
notice,
365+
});
366+
367+
it('the answer carries the server notice: one toast, the server notice alone', async () => {
368+
await saveFrom(ORG_EDIT, orgUpdate(SERVER_NOTICE), ORG_SAVED);
369+
370+
expect(dataSourceStub.findOne).toHaveBeenCalledWith('sys_organization', 'org_1');
371+
expect(toastSuccess.mock.calls).toEqual([['Organization "Acme" updated', { description: SERVER_NOTICE }]]);
372+
expect(toastWarning).not.toHaveBeenCalled();
373+
expect(toastInfo).not.toHaveBeenCalled();
374+
});
375+
376+
it('CONTROL: no notice on the answer: the console line', async () => {
377+
expect(CONSOLE_LINE).toContain('7 days');
378+
await saveFrom(ORG_EDIT, orgUpdate(undefined), ORG_SAVED);
379+
380+
expect(toastSuccess.mock.calls).toEqual([['Organization "Acme" updated', { description: CONSOLE_LINE }]]);
381+
expect(toastInfo).not.toHaveBeenCalled();
382+
});
383+
384+
it("CONTROL: another hook's notice on the answer: both lines", async () => {
385+
await saveFrom(ORG_EDIT, orgUpdate(OTHER_NOTICE), ORG_SAVED);
386+
387+
expect(toastSuccess.mock.calls).toEqual([
388+
['Organization "Acme" updated', { description: `${OTHER_NOTICE}\n${CONSOLE_LINE}` }],
389+
]);
390+
expect(toastInfo).not.toHaveBeenCalled();
391+
});
392+
});

‎ui/packages/app-shell/src/console/__tests__/organizationMfaNotices-30295.test.tsx‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,57 @@ describe('organizationMfaSavedNotice — after an accepted save', () => {
6060
});
6161
});
6262

63+
// objectstack#30565 — the server answers an accepted turn-on with its own save
64+
// notice (objectstack `plugin-auth`'s `ORGANIZATION_MFA_REQUIRED_NOTICE_COPY`),
65+
// which names the grace period in force. The console's line would repeat it,
66+
// with the default 7 days where Setup set another number.
67+
describe('organizationMfaSavedNotice — the server already said it (objectstack#30565)', () => {
68+
const saved = { id: 'org_1', name: 'Acme', require_mfa: true };
69+
const turnOn = (answerNotice: string | undefined) =>
70+
organizationMfaSavedNotice({ objectName: 'sys_organization', storedRequireMfa: false, saved, answerNotice, t });
71+
72+
// The server's notice as it answers it, in each language its copy is written in,
73+
// for a grace period of N days and of 0 days (two different sentences).
74+
const SERVER_NOTICES = {
75+
'en, 3 days':
76+
'Multi-factor authentication is now required for this organization. Every member who has not set up an ' +
77+
'authenticator app has 3 days to enroll one, counted from the first time they use the app while it is ' +
78+
'required; after that they are blocked from data until they do. Members who already use an authenticator ' +
79+
'app are not affected. The MFA grace period is set in Setup → Authentication.',
80+
'en, 0 days':
81+
'Multi-factor authentication is now required for this organization. Every member who has not set up an ' +
82+
'authenticator app is blocked from data the next time they use the app, until they enroll one: the MFA ' +
83+
'grace period is 0 days. Members who already use an authenticator app are not affected. The MFA grace ' +
84+
'period is set in Setup → Authentication.',
85+
'zh-CN, 3 days':
86+
'本组织现已强制多因素认证。尚未绑定身份验证器应用的成员有 3 天时间完成绑定,从要求生效后他们第一次使用应用时起算;' +
87+
'期满后,未绑定的成员将无法访问数据,直到完成绑定。已使用身份验证器应用的成员不受影响。MFA 宽限期可在“设置 → 认证”中修改。',
88+
'ja-JP, 0 days':
89+
'この組織では多要素認証が必須になりました。認証アプリをまだ登録していないメンバーは、次にアプリを利用した時点から' +
90+
'登録するまでデータにアクセスできなくなります(MFA 猶予期間は 0 日です)。すでに認証アプリを使っているメンバーには' +
91+
'影響しません。MFA 猶予期間は「設定 → 認証」で変更できます。',
92+
'es-ES, 3 days':
93+
'La autenticación multifactor ahora es obligatoria en esta organización. Cada miembro que no haya configurado ' +
94+
'una app de autenticación tiene 3 días para registrarla, a contar desde la primera vez que usa la app mientras ' +
95+
'sea obligatoria; después se le bloquea el acceso a los datos hasta que lo haga. Los miembros que ya usan un ' +
96+
'autenticador no se ven afectados. El periodo de gracia de MFA se configura en Configuración → Autenticación.',
97+
};
98+
99+
it.each(Object.entries(SERVER_NOTICES))('the answer carries the server notice (%s): no console line', (_case, notice) => {
100+
expect(turnOn(notice)).toBeUndefined();
101+
});
102+
103+
it('no notice on the answer (a server that does not send one): the console line, as before', () => {
104+
expect(turnOn(undefined)).toBe('console.organizationMfa.nowRequired');
105+
});
106+
107+
it("another hook's notice on the same save: the console line still says who must enroll", () => {
108+
// The answer carries ONE notice, the last a hook or flow on the write set,
109+
// so a later one replaces the server's MFA notice.
110+
expect(turnOn('Sent to your manager for approval')).toBe('console.organizationMfa.nowRequired');
111+
});
112+
});
113+
63114
describe('useStoredRequireMfa — the stored value while the form is open', () => {
64115
it('an edit reads the row once; a create is off; a closed form or another object reads nothing', async () => {
65116
const findOne = vi.fn(async () => ({ id: 'org_1', require_mfa: true }));

‎ui/packages/app-shell/src/console/organizationMfaNotices.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ import type { useObjectTranslation } from '@object-ui/i18n';
2525
* turned on.
2626
* - **After an accepted save that turned it on**: who is affected (every
2727
* member without an authenticator app) and when the block starts (once the
28-
* MFA grace period ends, counted from each member's next use).
28+
* MFA grace period ends, counted from each member's next use). The server
29+
* now says this itself, with the grace period in force, as the save answer's
30+
* `notice` (objectstack#30411); the console's line stands in only when the
31+
* answer does not carry that notice (objectstack#30565).
2932
*
3033
* Both are judged only on what the console knows: an unread `features` says
3134
* nothing before saving, and a save whose stored value was already on says
@@ -57,23 +60,55 @@ export function organizationMfaFormNotice(input: {
5760
return input.t('console.organizationMfa.needsTwoFactor');
5861
}
5962

63+
/**
64+
* The opening sentence of the notice the server answers an accepted turn-on
65+
* with (objectstack `plugin-auth`'s `ORGANIZATION_MFA_REQUIRED_NOTICE_COPY`), in
66+
* each of the four languages that copy is written in. Its sentence for N days
67+
* and its sentence for 0 days both open with it; a server language the copy
68+
* lacks answers the English one.
69+
*/
70+
const SERVER_MFA_NOTICE_OPENINGS: readonly string[] = [
71+
'Multi-factor authentication is now required for this organization.',
72+
'本组织现已强制多因素认证。',
73+
'この組織では多要素認証が必須になりました。',
74+
'La autenticación multifactor ahora es obligatoria en esta organización.',
75+
];
76+
77+
/**
78+
* True when a save answer's `notice` is the server's own MFA notice. Told by
79+
* its opening sentence, not by a notice being there: the answer carries ONE
80+
* notice, the last one a hook or flow on the write set, so another notice on
81+
* the same save means the server's was replaced, and an older server's answer
82+
* can carry another hook's notice. Should the server's wording change, this
83+
* answers false and the toast shows both lines, never neither.
84+
*/
85+
const isServerMfaNotice = (notice: string | undefined): boolean =>
86+
typeof notice === 'string' && SERVER_MFA_NOTICE_OPENINGS.some((opening) => notice.includes(opening));
87+
6088
/**
6189
* The line an accepted organization save adds to its toast when it turned the
6290
* requirement on: who must enroll and when the block starts. `undefined` when
6391
* the saved row does not require MFA, or the stored row already did
6492
* (`storedRequireMfa === true`); an unread stored value (`undefined`) counts as
6593
* off, since saying it once too often is cheaper than not saying it.
94+
*
95+
* `undefined` too when the save answer's notice (`answerNotice`) is the
96+
* server's MFA notice: it says all of this with the grace period in force,
97+
* where the console's line can only name the default 7 days.
6698
*/
6799
export function organizationMfaSavedNotice(input: {
68100
objectName: string | undefined;
69101
storedRequireMfa: boolean | undefined;
70102
saved: unknown;
103+
/** The save answer's `notice`, as the record form's claim took it. */
104+
answerNotice?: string;
71105
t: Translate;
72106
}): string | undefined {
73107
if (input.objectName !== ORGANIZATION_OBJECT) return undefined;
74108
if (input.storedRequireMfa === true) return undefined;
75109
const saved = input.saved as Record<string, unknown> | null | undefined;
76110
if (!saved || !isOn(saved[REQUIRE_MFA_FIELD])) return undefined;
111+
if (isServerMfaNotice(input.answerNotice)) return undefined;
77112
return input.t('console.organizationMfa.nowRequired');
78113
}
79114

0 commit comments

Comments
 (0)