Skip to content
Merged
15 changes: 15 additions & 0 deletions .changeset/30295-org-mfa-notice-and-enroll-refusal.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 33 additions & 2 deletions ui/packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
28 changes: 26 additions & 2 deletions ui/packages/app-shell/src/console/RemediationOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useObjectTranslation>['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();
Expand All @@ -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);
}
Expand All @@ -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);
}
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof import('@object-ui/auth')>()),
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(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<RemediationOverlay />
</I18nProvider>,
);
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();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* 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<typeof organizationMfaFormNotice>[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 }));
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: 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 });
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();
});
});
Loading
Loading