feat(plugin-verification): migrate client to client-v2 (#9515)

This commit is contained in:
PiEgg
2026-05-21 20:33:46 +08:00
committed by GitHub
parent 01c18bc4e6
commit 1170f43988
70 changed files with 4530 additions and 959 deletions
@@ -432,7 +432,7 @@ export class PluginSettingsManager<TApp extends BaseApplication<any> = BaseAppli
return null;
}
const { title, aclSnippet, key, menuKey, name, ...others } = page;
const { title, aclSnippet, key, menuKey, name, icon, ...others } = page;
return {
...others,
@@ -443,6 +443,7 @@ export class PluginSettingsManager<TApp extends BaseApplication<any> = BaseAppli
name,
title,
label: title,
icon: this.renderIcon(icon),
path: this.getRoutePath(name),
sort: page.sort,
isAllow,
@@ -76,6 +76,25 @@ describe('PluginSettingsManager v2', () => {
expect(app.router.get('admin.settings.demo.advanced')).toMatchObject({ path: 'advanced' });
});
it('should render string icon on both menu and page tab via renderIcon', () => {
// Previously `renderPage` spread the raw `icon` string straight to the antd
// Menu item, which displayed "LockOutlinedTitle" as text. Both `renderMenuItem`
// and `renderPage` must coerce string icon names to React elements.
const app = createMockClient();
app.pluginSettingsManager.addMenuItem({ key: 'demo', title: 'Demo', icon: 'TeamOutlined' });
app.pluginSettingsManager.addPageTabItem({
menuKey: 'demo',
key: 'index',
title: 'Overview',
icon: 'LockOutlined',
});
const list = app.pluginSettingsManager.getList();
expect(React.isValidElement(list[0].icon)).toBe(true);
expect(React.isValidElement(list[0].children?.[0].icon)).toBe(true);
});
it('should support componentLoader on page item', () => {
const app = createMockClient();
const componentLoader = async () => ({
@@ -7,26 +7,26 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { createMockClient, Plugin } from '@nocobase/client-v2';
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { createMockClient } from '../MockApplication';
import { Plugin } from '../Plugin';
import PoweredBy from '../components/PoweredBy';
import PoweredByLite from '../components/PoweredByLite';
class PoweredByLiteRoutePlugin extends Plugin {
class PoweredByRoutePlugin extends Plugin {
async load() {
this.router.add('root', {
path: '/',
Component: PoweredByLite,
Component: PoweredBy,
});
}
}
class MockCustomBrandPlugin extends Plugin {}
const renderPoweredByLite = async (plugins: any[] = [], appInfoData: Record<string, any> = { version: '1.2.3' }) => {
const renderPoweredBy = async (plugins: any[] = [], appInfoData: Record<string, any> = { version: '1.2.3' }) => {
const app = createMockClient({
plugins: [PoweredByLiteRoutePlugin as any, ...plugins],
plugins: [PoweredByRoutePlugin as any, ...plugins],
});
app.apiMock.onGet('app:getInfo').reply(200, {
@@ -43,21 +43,24 @@ const renderPoweredByLite = async (plugins: any[] = [], appInfoData: Record<stri
return result;
};
describe('PoweredByLite', () => {
describe('PoweredBy', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should render the default brand when custom-brand is not installed', async () => {
const { container } = await renderPoweredByLite();
const { container } = await renderPoweredBy();
expect(screen.getByRole('link', { name: 'NocoBase' })).toHaveAttribute('href', 'https://www.nocobase.com');
expect(container).toHaveTextContent('Powered by NocoBase');
// The `.nb-brand` className is reserved for the custom-brand HTML branch
// so downstream stylesheets can selectively target customised content
// without leaking onto the default footer.
expect(container.querySelector('.nb-brand')).not.toBeInTheDocument();
});
it('should render custom-brand HTML and replace appVersion', async () => {
const { container } = await renderPoweredByLite([
const { container } = await renderPoweredBy([
[
MockCustomBrandPlugin,
{
@@ -77,7 +80,7 @@ describe('PoweredByLite', () => {
});
it('should not render undefined appVersion when app version is unavailable', async () => {
const { container } = await renderPoweredByLite(
const { container } = await renderPoweredBy(
[
[
MockCustomBrandPlugin,
@@ -98,7 +101,11 @@ describe('PoweredByLite', () => {
});
it('should escape custom-brand appVersion placeholder', async () => {
const { container } = await renderPoweredByLite(
// Defence in depth: even if the back-end ever returns a tampered
// `app:getInfo` payload, the version string must be HTML-escaped
// before being interpolated into the custom-brand template — never
// produce a live `<script>` node in the DOM.
const { container } = await renderPoweredBy(
[
[
MockCustomBrandPlugin,
@@ -39,30 +39,18 @@ describe('nocobase buildin plugin auth redirect', () => {
vi.restoreAllMocks();
});
it('should redirect unauthenticated admin access to v2 signin with replace', async () => {
const replace = vi.fn();
Object.defineProperty(globalThis.window, 'location', {
configurable: true,
value: {
...originalLocation,
pathname: '/v2/admin/7vu4c2sdk6h',
search: '',
hash: '',
replace,
},
});
it('should navigate to v2 signin when /auth:check returns no user', async () => {
// Aligns with v1: use react-router navigate (virtual) rather than
// `window.location.replace`, so a `window.location.href` queued elsewhere
// (e.g. 2FA's `code:302` response interceptor) can commit instead of being
// overridden.
const app = createMockClient({
publicPath: '/v2/',
plugins: [NocoBaseBuildInPlugin as any],
router: { type: 'memory', initialEntries: ['/v2/admin/7vu4c2sdk6h'] },
});
app.apiMock.onGet('app:getLang').reply(200, {
data: {
lang: 'en-US',
resources: { client: {} },
cron: {},
},
data: { lang: 'en-US', resources: { client: {} }, cron: {} },
});
app.apiMock.onGet('/auth:check').reply(200, { data: {} });
@@ -70,68 +58,63 @@ describe('nocobase buildin plugin auth redirect', () => {
render(<Root />);
await waitFor(() => {
expect(replace).toHaveBeenCalledWith('/v2/signin?redirect=%2Fv2%2Fadmin%2F7vu4c2sdk6h');
expect(app.router.router.state.location.pathname).toBe('/v2/signin');
expect(app.router.router.state.location.search).toBe('?redirect=%2Fv2%2Fadmin%2F7vu4c2sdk6h');
});
});
it('should redirect unauthenticated v2 root access to v2 signin with default admin redirect', async () => {
const replace = vi.fn();
Object.defineProperty(globalThis.window, 'location', {
configurable: true,
value: {
...originalLocation,
pathname: '/nocobase/v2/',
search: '',
hash: '',
replace,
},
});
const app = createMockClient({
publicPath: '/nocobase/v2/',
plugins: [NocoBaseBuildInPlugin as any],
router: { type: 'memory', initialEntries: ['/nocobase/v2/'] },
});
app.apiMock.onGet('app:getLang').reply(200, {
data: {
lang: 'en-US',
resources: { client: {} },
cron: {},
},
});
const Root = app.getRootComponent();
render(<Root />);
await waitFor(() => {
expect(replace).toHaveBeenCalledWith('/nocobase/v2/signin?redirect=%2Fnocobase%2Fv2%2Fadmin');
});
});
it('should render v2 admin root without redirecting away', async () => {
const replace = vi.fn();
Object.defineProperty(globalThis.window, 'location', {
configurable: true,
value: {
...originalLocation,
pathname: '/v2/admin',
search: '',
hash: '',
replace,
},
});
it('should short-circuit /auth:check when server returns code:302 instead of redirecting to signin', async () => {
// When the server signals an intermediate redirect (typically 2FA verify),
// CurrentUserProvider must NOT treat the missing `user.id` as "logged out"
// and race the 2FA response interceptor with its own signin redirect.
const app = createMockClient({
publicPath: '/v2/',
plugins: [NocoBaseBuildInPlugin as any],
router: { type: 'memory', initialEntries: ['/v2/admin'] },
});
app.apiMock.onGet('app:getLang').reply(200, {
data: {
lang: 'en-US',
resources: { client: {} },
cron: {},
},
data: { lang: 'en-US', resources: { client: {} }, cron: {} },
});
app.apiMock.onGet('/auth:check').reply(200, {
data: { code: 302, redirect: '/2fa?redirect=/admin' },
});
const Root = app.getRootComponent();
render(<Root />);
// Give CurrentUserProvider time to process the response.
await new Promise((resolve) => setTimeout(resolve, 50));
expect(app.router.router.state.location.pathname).toBe('/v2/admin');
expect(app.router.router.state.location.search).toBe('');
});
it('should redirect unauthenticated v2 root access to v2 signin via <Navigate />', async () => {
const app = createMockClient({
publicPath: '/nocobase/v2/',
plugins: [NocoBaseBuildInPlugin as any],
router: { type: 'memory', initialEntries: ['/nocobase/v2/'] },
});
app.apiMock.onGet('app:getLang').reply(200, {
data: { lang: 'en-US', resources: { client: {} }, cron: {} },
});
const Root = app.getRootComponent();
render(<Root />);
await waitFor(() => {
expect(app.router.router.state.location.pathname).toBe('/nocobase/v2/signin');
expect(app.router.router.state.location.search).toBe('?redirect=%2Fnocobase%2Fv2%2Fadmin');
});
});
it('should render v2 admin root without redirecting away', async () => {
const app = createMockClient({
publicPath: '/v2/',
plugins: [NocoBaseBuildInPlugin as any],
router: { type: 'memory', initialEntries: ['/v2/admin'] },
});
app.apiMock.onGet('app:getLang').reply(200, {
data: { lang: 'en-US', resources: { client: {} }, cron: {} },
});
app.apiMock.onGet('/auth:check').reply(200, { data: { id: 1 } });
app.apiMock.onGet('systemSettings:get').reply(200, { data: {} });
@@ -152,36 +135,20 @@ describe('nocobase buildin plugin auth redirect', () => {
await waitFor(() => {
expect(container.innerHTML).toContain('No pages yet, please configure first');
});
expect(replace).not.toHaveBeenCalled();
expect(app.router.router.state.location.pathname).toBe('/v2/admin');
expect(container.innerHTML).not.toContain('Legacy page');
});
it.each(['/v2/admin/legacy-page/tab/tab-1', '/v2/admin/legacy-page/view/detail'])(
'should show 404 for authenticated direct v1-style v2 page access: %s',
async (pathname) => {
const replace = vi.fn();
Object.defineProperty(globalThis.window, 'location', {
configurable: true,
value: {
...originalLocation,
pathname,
search: '?from=direct',
hash: '#dialog',
replace,
},
});
const app = createMockClient({
publicPath: '/v2/',
plugins: [NocoBaseBuildInPlugin as any],
router: { type: 'memory', initialEntries: [pathname] },
});
app.apiMock.onGet('app:getLang').reply(200, {
data: {
lang: 'en-US',
resources: { client: {} },
cron: {},
},
data: { lang: 'en-US', resources: { client: {} }, cron: {} },
});
app.apiMock.onGet('/auth:check').reply(200, { data: { id: 1 } });
app.apiMock.onGet('systemSettings:get').reply(200, { data: {} });
@@ -200,7 +167,7 @@ describe('nocobase buildin plugin auth redirect', () => {
render(<Root />);
expect(await screen.findByText('404')).toBeInTheDocument();
expect(replace).not.toHaveBeenCalled();
expect(app.router.router.state.location.pathname).toBe(pathname);
},
);
});
@@ -0,0 +1,100 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
import { renderHook } from '@testing-library/react';
import React from 'react';
import { describe, expect, it } from 'vitest';
import { ACLContext } from '../acl';
import { useCurrentRoles } from '../nocobase-buildin-plugin';
type AclContextValue = React.ContextType<typeof ACLContext>;
function makeAclValue(allowAnonymous: boolean): AclContextValue {
return {
loading: false,
data: {
data: { allowAnonymous },
meta: {},
},
refresh: async () => undefined,
};
}
function makeEngineWithUser(user: { roles?: Array<{ name: string; title?: string }> } | null): FlowEngine {
const engine = new FlowEngine();
if (user != null) {
engine.context.defineProperty('user', { value: user });
}
return engine;
}
function makeWrapper(opts: { engine: FlowEngine; acl: AclContextValue }) {
const Wrapper: React.FC = ({ children }) => (
<FlowEngineProvider engine={opts.engine}>
<ACLContext.Provider value={opts.acl}>{children}</ACLContext.Provider>
</FlowEngineProvider>
);
return Wrapper;
}
describe('useCurrentRoles', () => {
it('returns roles from flowEngine.context.user, dropping the synthetic __union__ entry', () => {
// `__union__` is a server-side marker for the merged-roles pseudo role and
// must never appear in user-facing role pickers — guards against a regression
// that broke role assignment in API Keys / SwitchRole pages.
const wrapper = makeWrapper({
engine: makeEngineWithUser({
roles: [
{ name: '__union__', title: 'Union' },
{ name: 'root', title: 'Root' },
{ name: 'member', title: 'Member' },
],
}),
acl: makeAclValue(false),
});
const { result } = renderHook(() => useCurrentRoles(), { wrapper });
expect(result.current).toEqual([
{ name: 'root', title: 'Root' },
{ name: 'member', title: 'Member' },
]);
});
it('appends an anonymous role when ACL allowAnonymous is true', () => {
const wrapper = makeWrapper({
engine: makeEngineWithUser({ roles: [{ name: 'root', title: 'Root' }] }),
acl: makeAclValue(true),
});
const { result } = renderHook(() => useCurrentRoles(), { wrapper });
expect(result.current).toEqual([
{ name: 'root', title: 'Root' },
{ name: 'anonymous', title: 'Anonymous' },
]);
});
it('compiles {{t(...)}} templates in role.title via flowEngine.context.t', () => {
const engine = makeEngineWithUser({ roles: [{ name: 'admin', title: '{{t("Admin")}}' }] });
engine.context.defineProperty('t', { value: () => 'Compiled Title' });
const wrapper = makeWrapper({ engine, acl: makeAclValue(false) });
const { result } = renderHook(() => useCurrentRoles(), { wrapper });
expect(result.current).toEqual([{ name: 'admin', title: 'Compiled Title' }]);
});
it('returns an empty array when no user has been written to engine.context', () => {
const wrapper = makeWrapper({ engine: makeEngineWithUser(null), acl: makeAclValue(false) });
const { result } = renderHook(() => useCurrentRoles(), { wrapper });
expect(result.current).toEqual([]);
});
it('returns just anonymous when no user is set but allowAnonymous is true', () => {
const wrapper = makeWrapper({ engine: makeEngineWithUser(null), acl: makeAclValue(true) });
const { result } = renderHook(() => useCurrentRoles(), { wrapper });
expect(result.current).toEqual([{ name: 'anonymous', title: 'Anonymous' }]);
});
});
@@ -0,0 +1,71 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { css, cx } from '@emotion/css';
import { parseHTML } from '@nocobase/utils/client';
import { theme } from 'antd';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useCurrentAppInfo } from '../hooks/useCurrentAppInfo';
import { usePlugin } from '../hooks/usePlugin';
import { getAppVersionHTML } from '../utils/appVersionHTML';
const homePageUrls: Record<string, string> = {
'en-US': 'https://www.nocobase.com',
'zh-CN': 'https://www.nocobase.com/cn/',
};
/**
* Footer brand rendered on auth pages and other layout entry points. Falls
* back to "Powered by NocoBase" when `@nocobase/plugin-custom-brand` is not
* installed; otherwise renders the plugin's HTML template with the
* `{{appVersion}}` placeholder substituted. The version is escaped via
* `getAppVersionHTML` so a malicious app version cannot inject script tags.
*/
export function PoweredBy() {
const { i18n } = useTranslation();
const { token } = theme.useToken();
const customBrandPlugin: any = usePlugin('@nocobase/plugin-custom-brand');
const appInfo = useCurrentAppInfo();
const appVersion = getAppVersionHTML(appInfo?.version);
const homePage = homePageUrls[i18n.language] || homePageUrls['en-US'];
const brandStyle = css`
text-align: center;
color: ${token.colorTextDescription};
a {
color: ${token.colorTextDescription};
&:hover {
color: ${token.colorText};
}
}
`;
const customBrand = customBrandPlugin?.options?.options?.brand;
if (customBrand) {
return (
<div
className={cx(brandStyle, 'nb-brand')}
dangerouslySetInnerHTML={{
__html: parseHTML(customBrand, { appVersion }),
}}
/>
);
}
return (
<div className={brandStyle}>
Powered by{' '}
<a href={homePage} target="_blank" rel="noreferrer">
NocoBase
</a>
</div>
);
}
export default PoweredBy;
@@ -0,0 +1,314 @@
# client-v2 components
This folder collects the React components that `@nocobase/client-v2` exposes to downstream plugins. Components are organized by directory — at the moment the main one is `form/`, which targets settings pages and form-shaped UIs.
Skim this before writing a new plugin so you don't reinvent the wheel. Components are mostly orthogonal — import only what you need.
## form/
Components under `form/` cover the "settings page + form" shape. The typical recipe: open a form container with `ctx.viewer.drawer` / `ctx.viewer.dialog`, host an antd `Form` + `Form.Item` tree inside, and pick standard field controls from this folder.
Grouped by purpose: form containers, form fields, data table, utilities.
### Form containers
#### DrawerFormLayout
Drawer-style form layout. Pair with `ctx.viewer.drawer({ content })`.
- Top: a close icon next to the title. Clicking close fires `onCancel` and dismisses the drawer
- Bottom: default Cancel / Submit buttons; override the whole footer with `footer`
- Middle: caller-supplied `<Form>` instance + fields
```tsx
import { DrawerFormLayout } from '@nocobase/client-v2';
ctx.viewer.drawer({
width: '50%',
content: () => (
<DrawerFormLayout
title={t('Add authenticator')}
onSubmit={handleSubmit}
submitting={submitting}
>
<Form form={form} layout="vertical">
{/* fields */}
</Form>
</DrawerFormLayout>
),
});
```
Key props:
- `title`: title node (rendered next to the close icon)
- `onCancel` / `onSubmit`: callbacks; the drawer closes automatically once they resolve. Throw from `onSubmit` to keep the drawer open (e.g. on a validation error)
- `submitting`: drives the Submit button's loading state
- `submitText` / `cancelText`: button labels
- `footer`: full override of the footer content (replaces the default Cancel + Submit pair)
#### DialogFormLayout
Dialog-style form layout, the centered counterpart of `DrawerFormLayout`. Pair with `ctx.viewer.dialog({ closable: true, content })`.
The only visual difference from the drawer version: the title is a bare string (no inline close icon), relying on antd Modal's native top-right X. Note that `viewer.dialog` disables antd's native X by default — you have to pass `closable: true` explicitly for it to appear.
```tsx
import { DialogFormLayout } from '@nocobase/client-v2';
ctx.viewer.dialog({
closable: true, // restore antd Modal's native top-right X
content: () => (
<DialogFormLayout title={t('Bind verifier')} onSubmit={handleSubmit}>
<Form form={form} layout="vertical">
{/* fields */}
</Form>
</DialogFormLayout>
),
});
```
When to pick which:
- **Drawer**: long forms with lots of fields that benefit from a full-height side panel (settings-page "Add / Edit")
- **Dialog**: short forms that ask for quick confirmation (bind, change password, two-factor verify)
Props are identical to `DrawerFormLayout` — they're drop-in replacements at the API level.
### Form fields
#### RemoteSelect
A Select bound to an async option source. Framework-level — it knows nothing about NocoBase business resources; the caller passes a `request` function that fetches whatever it needs.
```tsx
import { RemoteSelect } from '@nocobase/client-v2';
<Form.Item name="provider" label={t('Provider')}>
<RemoteSelect<{ name: string; title: string }>
request={async () => {
const response = await ctx.api.resource('smsOTPProviders').list();
return response?.data?.data || [];
}}
cacheKey="@nocobase/plugin-verification:smsOTPProviders:list"
mapOptions={(item) => ({ label: compileT(item.title), value: item.name })}
/>
</Form.Item>
```
Key props:
- `request: () => Promise`: fetch function, required. Returns either an array of items or an envelope object (combine with `selectItems` to pluck the array out)
- `selectItems`: extractor that takes the `request` result and returns the option array. Use when the response is `{ items, meta }`-shaped
- `fieldNames`: defaults to `{ label, value }` mapping; override with `mapOptions` when the raw item doesn't match
- `mapOptions: (item, index) => ({ label, value })`: full override of option mapping
- `cacheKey` / `refreshDeps` / `ready`: forwarded to ahooks `useRequest`; control caching and refresh timing
- `onLoaded: (items, response) => void`: fires after data arrives; receives both the mapped item array and the raw response
All other antd `Select` props (`mode` / `placeholder` / `disabled` / `value` / `onChange` / etc.) are passed through.
`showSearch` + `allowClear` are on by default; search is local (filters by label). For server-side search, drive the search input through external state and pass it via `refreshDeps`, then read it inside `request`.
#### EnvVariableInput
A variable input restricted to the `$env` namespace. Designed for secret / credential fields — supports environment-variable references and adds password masking for plain literal values.
```tsx
import { EnvVariableInput } from '@nocobase/client-v2';
<Form.Item name={['options', 'accessKeySecret']} label={t('Access Key Secret')}>
<EnvVariableInput password />
</Form.Item>
```
Key props:
- `password`: when enabled, non-variable literal values render through `Input.Password` so they're masked. Variable expressions like `{{ $env.X }}` stay visible and editable
- `placeholder` / `disabled` / `value` / `onChange`: standard controlled-input props
The persisted value is always a string: either a literal (`'literal'`) or a server-template reference (`'{{ $env.foo.bar }}'`). The server expands the reference at use time.
#### VariableInput / VariableTextArea
General-purpose variable inputs. Can reference any namespace registered on `flowEngine.context``$env`, `$user`, plus ad-hoc business namespaces like `$resetLink`.
The two differ in shape:
- `VariableInput`: single-line. Variables render as colored pills (compact "chips")
- `VariableTextArea`: multi-line. Variables stay as raw `{{ ... }}` text — better for email templates and other long-form content where the literal `{{ ... }}` is the intended display (the server expands them at render time)
```tsx
import { VariableInput, VariableTextArea } from '@nocobase/client-v2';
// Email subject — single line, pills
<Form.Item name={['options', 'emailSubject']} label={t('Subject')}>
<VariableInput
namespaces={['$env']}
extraNodes={[
{ name: '$resetLink', title: t('Reset password link'), type: 'string', paths: ['$resetLink'] },
]}
/>
</Form.Item>
// Email body — multi-line, literal
<Form.Item name={['options', 'emailContentHTML']} label={t('Content')}>
<VariableTextArea namespaces={['$env']} rows={10} />
</Form.Item>
```
Key props:
- `namespaces`: restrict the picker to specific top-level namespaces. Omit to expose every registered top-level property
- `extraNodes`: static leaves appended after the namespace-filtered nodes. Use for variables that only make sense in the current page (e.g. `$resetLink`)
- `converters`: override the default path ↔ string converters. `EnvVariableInput` uses this hook to lock its output to `$env`
- `value` / `onChange` / `placeholder` / `disabled`: standard controlled-input props
Under the hood `VariableInput` wraps `VariableHybridInput` (inline pills), `VariableTextArea` wraps `TextAreaWithContextSelector` (textarea + variable button). Both share the same MetaTree.
#### FileSizeInput
A byte-valued size input paired with a unit selector (Byte / KB / MB / GB). The persisted value is always in bytes; the displayed number is derived from the picked unit.
```tsx
import { FileSizeInput } from '@nocobase/client-v2';
<Form.Item name="maxFileSize" label={t('Max file size')}>
<FileSizeInput min={1} max={1024 * 1024 * 1024} defaultValue={20 * 1024 * 1024} />
</Form.Item>
```
Key props:
- `min` / `max`: allowed byte range; values out of range snap back on blur. Defaults: `min=1`, `max=Infinity`
- `defaultValue`: drives the initial unit when the field is empty (e.g. 20 MB starts in the "MB" unit)
- `value` / `onChange`: controlled-input contract; the value type is `number` (bytes)
#### PasswordInput
`antd Input.Password` plus an optional strength meter, ported from v1's
`Password` component. Use for any "set / change password" form when you want
to give the user the same visual signal they had in v1.
```tsx
import { PasswordInput } from '@nocobase/client-v2';
<Form.Item name="newPassword" label={t('New password')} rules={[{ required: true }]}>
<PasswordInput autoComplete="new-password" checkStrength />
</Form.Item>
```
Key props:
- `checkStrength`: render a strength bar beneath the input. Defaults to `false`. The score is bucketed `[20, 40, 60, 80, 100]` and shown via a clipped gradient (orange) inside a grey track, matching v1
- All other antd `Input.Password` props are passed through unchanged: `value` / `onChange` / `disabled` / `placeholder` / `autoComplete` / etc.
The strength meter is purely a UX hint, NOT validation. Submitting a weak password is still allowed unless the server (or a separately installed password-policy plugin) rejects it. Wire up real password rules through `Form.Item.rules` or — when the open-source ↔ commercial extension point lands — the project's shared password-validator hook.
#### JsonTextArea
JSON input. The stored value is a JS object (not a string) — parsing happens live while typing and is finalized on blur.
```tsx
import { JsonTextArea } from '@nocobase/client-v2';
<Form.Item name="customConfig" label={t('Custom config')}>
<JsonTextArea rows={6} json5 />
</Form.Item>
```
Key props:
- `space`: serialization indent. Defaults to `2`
- `json5`: parse with JSON5 (tolerates trailing commas, comments, single quotes, etc.). Defaults to `false`
- `showError`: render the parse error inline below the textarea. Defaults to `true`
- All other antd `Input.TextArea` props are passed through
`value` / `onChange` are typed as `unknown` because JSON values can be any shape. Tighten the contract with validators in `Form.Item.rules`.
### Data table
#### Table
The standard settings-page table, built on antd `Table` with two additions:
1. **Row index ↔ checkbox swap**: by default each row shows its ordinal ("1 / 2 / 3"); on hover or when selected the cell flips to a checkbox. The two elements are absolute-positioned in the same cell so neither steals layout space. Requires `rowSelection` to be present
2. **Drag-and-drop reorder**: pass `isDraggable` to enable. Each row gets a drag handle on the left; `onSortEnd` fires when a row is dropped. The component does NOT mutate `dataSource` — the caller persists the move (`resource.move(...)`) and `refresh()`s
```tsx
import { Table, DEFAULT_PAGE_SIZE } from '@nocobase/client-v2';
<Table<AuthenticatorRecord>
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.records || []}
isDraggable
onSortEnd={async (from, to) => {
await resource.move({ sourceId: from.id, targetId: to.id });
refresh();
}}
rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}
pagination={{
current: page,
pageSize,
total: data?.total || 0,
onChange: (next, nextSize) => { /* ... */ },
}}
/>
```
Key props:
- `rowKey`: required. Drag-sort and row-identity both depend on it
- `showIndex`: defaults to `true`; disable to keep the cell at checkbox-only
- `isDraggable`: drag-and-drop toggle. Defaults to `false` — when off the component is a thin antd `Table` superset
- `onSortEnd: (from, to) => void | Promise`: fired when a row is dropped. Caller persists
- `showSortHandle`: defaults to `true`; set false when you want the handle off (or embedded into a custom column via `<SortHandle />`)
- All other antd `Table` props are passed through
Companion exports:
- `DEFAULT_PAGE_SIZE` (value `50`): suggested default page size
- `PAGE_SIZE_OPTIONS`: suggested page-size dropdown values `[5, 10, 20, 50, 100, 200]`
- `SortHandle`: standalone handle component, exported from `@nocobase/client-v2` for embedding into custom columns
### Utilities
#### createFormRegistry
Factory for a namespaced "entry registry". Each call returns an independent registry instance backed by its own closure `Map`.
```ts
import { createFormRegistry, type FormRegistryEntry } from '@nocobase/client-v2';
interface StorageType extends FormRegistryEntry {
// FormRegistryEntry requires at least `name: string`
title: string;
Component: React.ComponentType;
}
const storageTypes = createFormRegistry<StorageType>('file-manager/storage-types');
storageTypes.register({ name: 'local', title: 'Local storage', Component: LocalStorageForm });
storageTypes.register({ name: 's3', title: 'Amazon S3', Component: S3StorageForm });
storageTypes.get('s3');
storageTypes.list();
storageTypes.has('local');
storageTypes.unregister('local');
```
Use this when a plugin needs an extension point for "same name + same shape + different implementation" things (the file-manager's storage types, the verification plugin's OTP providers, etc.). It's a thin wrapper around `Map` that adds a namespace label and an HMR-friendly overwrite warning.
Re-registering the same `name` overwrites the previous entry and emits a `console.warn` — HMR doesn't throw, and unintended duplicates surface in dev.
## When to add a new component here
- Two or more plugins need the same field or container shape — promote it to this folder
- Cross-plugin reusable, but the abstraction couples to a specific business domain (e.g. "pick a verifier", "pick a data source") — keep it inside the producing plugin and export from that plugin's `client-v2/`
- Before reaching for abstraction, check whether an existing component can be improved instead. `RemoteSelect.selectItems` is an example — it landed so envelope responses don't need their own component
Two follow-ups after adding a new component:
1. Add `export * from './XxxComponent'` to `form/index.tsx`
2. Document it here so the next plugin migration finds it
@@ -0,0 +1,312 @@
# client-v2 components
这里收纳 `@nocobase/client-v2` 暴露给业务插件复用的一组 React 组件。组件按目录组织——目前主要是 `form/`,给设置页和表单场景用。
写新插件前先翻一遍这份说明,能省下不少重复造轮子的功夫。组件之间互相耦合很少,按需 import 就行。
## form/
`form/` 目录下的组件围绕「设置页 + 表单」这一类场景。常见用法是配合 `ctx.viewer.drawer` / `ctx.viewer.dialog` 打开一个表单容器,里面放 antd 的 `Form` + `Form.Item`,字段用这里提供的标准控件。
下面按用途分四组:表单容器、表单字段、数据表、工具。
### 表单容器
#### DrawerFormLayout
抽屉形态的表单 layout。配合 `ctx.viewer.drawer({ content })` 用。
- 顶部 Header:左上角一个 close 图标 + 标题。点击 close 会触发 `onCancel` 然后关闭抽屉
- 底部 Footer:默认 Cancel / Submit 两个按钮;可以用 `footer` 完全替换
- 中间 children:调用方自己放 `<Form>` 实例 + 字段
```tsx
import { DrawerFormLayout } from '@nocobase/client-v2';
ctx.viewer.drawer({
width: '50%',
content: () => (
<DrawerFormLayout
title={t('添加认证器')}
onSubmit={handleSubmit}
submitting={submitting}
>
<Form form={form} layout="vertical">
{/* 字段 */}
</Form>
</DrawerFormLayout>
),
});
```
主要属性:
- `title`:标题节点(旁边带 close 图标)
- `onCancel` / `onSubmit`:回调,resolve 后会自动关闭抽屉。Submit 里 throw 可以让抽屉保持打开(比如校验失败)
- `submitting`:驱动 Submit 按钮的 loading
- `submitText` / `cancelText`:按钮文字
- `footer`:完全自定义 Footer 内容(覆盖默认两个按钮)
#### DialogFormLayout
弹窗形态的表单 layout,跟 `DrawerFormLayout` 是同源对偶。配合 `ctx.viewer.dialog({ closable: true, content })` 用。
跟 Drawer 版本的差异只有一点:title 是裸字符串(不带 close 图标),依赖 antd Modal 自带的右上角 X。注意 `viewer.dialog` 默认会禁用 antd 的原生 X——必须显式传 `closable: true` 才会出现。
```tsx
import { DialogFormLayout } from '@nocobase/client-v2';
ctx.viewer.dialog({
closable: true, // 关键:开启 antd Modal 原生右上角 X
content: () => (
<DialogFormLayout title={t('绑定验证码')} onSubmit={handleSubmit}>
<Form form={form} layout="vertical">
{/* 字段 */}
</Form>
</DialogFormLayout>
),
});
```
什么时候选哪个?
- **Drawer**:长表单、字段多、需要从一侧滑出占用整面(比如设置页的「添加 / 编辑」)
- **Dialog**:短表单、需要快速确认(比如绑定、修改密码、二次验证)
属性跟 `DrawerFormLayout` 完全一致,可以直接换。
### 表单字段
#### RemoteSelect
异步拉数据的 Select。框架级组件——不感知 NocoBase 业务,调用方传一个 `request` 函数自己拉数据。
```tsx
import { RemoteSelect } from '@nocobase/client-v2';
<Form.Item name="provider" label={t('服务商')}>
<RemoteSelect<{ name: string; title: string }>
request={async () => {
const response = await ctx.api.resource('smsOTPProviders').list();
return response?.data?.data || [];
}}
cacheKey="@nocobase/plugin-verification:smsOTPProviders:list"
mapOptions={(item) => ({ label: compileT(item.title), value: item.name })}
/>
</Form.Item>
```
主要属性:
- `request: () => Promise`:拉数据,必填。可以返回数组,也可以返回带元数据的对象(搭配 `selectItems` 取出数组)
- `selectItems`:从 `request` 返回值中抽出数组的函数。响应体是 `{ items, meta }` 形态时用
- `fieldNames`:默认按 `{ label, value }` 字段映射;不匹配的时候用 `mapOptions` 完全自定义
- `mapOptions: (item, index) => ({ label, value })`:完整覆盖映射逻辑
- `cacheKey` / `refreshDeps` / `ready`:透传给 ahooks `useRequest`,控制缓存和 refresh 时机
- `onLoaded: (items, response) => void`:拉到数据后的回调,能拿到原始响应
剩下的 antd `Select` props`mode` / `placeholder` / `disabled` / `value` / `onChange` 等)都原样透传。
默认开启 `showSearch` + `allowClear`,搜索是本地模式(按 label 匹配)。要做服务端搜索,把搜索词放进 `refreshDeps`,在 `request` 里读出来发请求。
#### EnvVariableInput
`$env` 命名空间的变量输入器。专门给「密钥 / 凭证」这类字段用——支持环境变量引用,同时给纯字面值加 password mask。
```tsx
import { EnvVariableInput } from '@nocobase/client-v2';
<Form.Item name={['options', 'accessKeySecret']} label={t('Access Key Secret')}>
<EnvVariableInput password />
</Form.Item>
```
主要属性:
- `password`:开启后,非变量的字面值会用 `Input.Password` 形态遮盖。变量表达式(比如 `{{ $env.X }}`)依然可见可编辑
- `placeholder` / `disabled` / `value` / `onChange`:标准受控字段属性
值的形态是字符串:`'literal'``'{{ $env.foo.bar }}'`。服务端在使用时再展开成实际值。
#### VariableInput / VariableTextArea
通用变量输入器。可以引用任意 `flowEngine.context` 注册的命名空间(`$env` / `$user` / 业务自定义的 `$resetLink` 等)。
两者差异:
- `VariableInput`:单行,变量渲染成彩色 pill(视觉上是「短标签」)
- `VariableTextArea`:多行,变量保留 `{{ ... }}` 字面——适合邮件模板这种「字面 + 变量」混排的长文本
```tsx
import { VariableInput, VariableTextArea } from '@nocobase/client-v2';
// 邮件主题:单行 pill 形态
<Form.Item name={['options', 'emailSubject']} label={t('主题')}>
<VariableInput
namespaces={['$env']}
extraNodes={[
{ name: '$resetLink', title: t('重置密码链接'), type: 'string', paths: ['$resetLink'] },
]}
/>
</Form.Item>
// 邮件正文:多行字面
<Form.Item name={['options', 'emailContentHTML']} label={t('正文')}>
<VariableTextArea namespaces={['$env']} rows={10} />
</Form.Item>
```
主要属性:
- `namespaces`:限定可选的顶层命名空间。不传就用 `flowEngine.context` 里全部已注册的
- `extraNodes`:在命名空间过滤后追加几条静态变量(用于 `$resetLink` 这类只在当前页面有意义的局部变量)
- `converters`:覆盖默认的 path ↔ string 转换器。`EnvVariableInput` 就是用这个钩子把输出锁定到 `$env`
- `value` / `onChange` / `placeholder` / `disabled`:标准受控字段属性
底层共用 `VariableHybridInput``VariableInput`)和 `TextAreaWithContextSelector``VariableTextArea`),用同一套 MetaTree 数据。
#### FileSizeInput
文件大小输入器。值统一存字节数,UI 上配一个单位选择器(Byte / KB / MB / GB)。
```tsx
import { FileSizeInput } from '@nocobase/client-v2';
<Form.Item name="maxFileSize" label={t('单文件大小上限')}>
<FileSizeInput min={1} max={1024 * 1024 * 1024} defaultValue={20 * 1024 * 1024} />
</Form.Item>
```
主要属性:
- `min` / `max`:允许的字节数区间,blur 时会自动 clamp 回界内。默认 `min=1``max=Infinity`
- `defaultValue`:用来决定初次显示的单位(比如默认 20 MB 就会以 MB 单位起始)
- `value` / `onChange`:受控字段,值类型是 `number`(字节)
#### PasswordInput
antd `Input.Password` 加一个可选的强度提示条,从 v1 的 `Password` 组件移植过来。用于「设置 / 修改密码」类表单——v1 → v2 迁移过来后视觉信号保持一致。
```tsx
import { PasswordInput } from '@nocobase/client-v2';
<Form.Item name="newPassword" label={t('新密码')} rules={[{ required: true }]}>
<PasswordInput autoComplete="new-password" checkStrength />
</Form.Item>
```
主要属性:
- `checkStrength`:在输入框下方渲染一条强度提示。默认 `false`。强度评分按 `[20, 40, 60, 80, 100]` 分桶,用裁剪的橙色渐变填充在灰色底条上,配色跟 v1 保持一致
- 其他 antd `Input.Password` 属性原样透传:`value` / `onChange` / `disabled` / `placeholder` / `autoComplete`
强度条只是 UX 提示,**不是表单校验**。弱密码仍然能提交,除非 server(或单独安装的 password-policy 商业插件)拒绝。真正的密码规则通过 `Form.Item.rules` 或——等开源 ↔ 商业的 extension point 落地之后——项目共享的 password validator hook 接入。
#### JsonTextArea
JSON 输入器。存的值是 JS 对象(不是字符串),编辑时实时解析、blur 时校验。
```tsx
import { JsonTextArea } from '@nocobase/client-v2';
<Form.Item name="customConfig" label={t('自定义配置')}>
<JsonTextArea rows={6} json5 />
</Form.Item>
```
主要属性:
- `space`:序列化缩进,默认 `2`
- `json5`:开启后用 JSON5 解析(容忍尾逗号、注释、单引号等)。默认关
- `showError`:解析失败时是否在下方显示错误消息。默认 `true`
- 其他 antd `Input.TextArea` 的属性都透传
`value` / `onChange` 的类型是 `unknown`——因为 JSON 可以是任意结构。调用方按业务约束在 `Form.Item.rules` 里加 validator 收紧类型。
### 数据表
#### Table
设置页表格的标准组件,基于 antd `Table` 扩展了两点:
1. **行索引和复选框切换**:默认状态显示「1 / 2 / 3」行号,悬停或选中时切换成 checkbox。两个元素绝对定位在同一格内,不会抢空间。需要 `rowSelection` 才生效
2. **拖拽排序**:传 `isDraggable` 开启,每行左侧出现拖拽手柄;放下时触发 `onSortEnd`。组件不动 `dataSource`,由调用方在回调里跑 `resource.move(...)``refresh()`
```tsx
import { Table, DEFAULT_PAGE_SIZE } from '@nocobase/client-v2';
<Table<AuthenticatorRecord>
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.records || []}
isDraggable
onSortEnd={async (from, to) => {
await resource.move({ sourceId: from.id, targetId: to.id });
refresh();
}}
rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}
pagination={{
current: page,
pageSize,
total: data?.total || 0,
onChange: (next, nextSize) => { /* ... */ },
}}
/>
```
主要属性:
- `rowKey`:必填。拖拽和行身份识别都依赖它
- `showIndex`:默认 `true`,关掉就只显示 checkbox
- `isDraggable`:开关拖拽。默认 `false`,关掉就是个加强版 antd Table
- `onSortEnd: (from, to) => void | Promise`:拖拽放下时触发。调用方负责持久化
- `showSortHandle`:默认 `true`,需要时可以隐藏手柄,自己在某列里嵌 `<SortHandle />`
- 其他 antd `Table` props 全部透传
附带导出:
- `DEFAULT_PAGE_SIZE`(值 `50`):建议的默认分页大小
- `PAGE_SIZE_OPTIONS`:建议的分页选项 `[5, 10, 20, 50, 100, 200]`
- `SortHandle`:从 `@nocobase/client-v2` 导出的独立手柄组件,可以嵌进自定义列
### 工具
#### createFormRegistry
带命名空间的「条目注册表」工厂。每次调用返回一个独立的 registry 实例,闭包持有自己的 `Map`
```ts
import { createFormRegistry, type FormRegistryEntry } from '@nocobase/client-v2';
interface StorageType extends FormRegistryEntry {
// FormRegistryEntry 要求至少有 `name: string`
title: string;
Component: React.ComponentType;
}
const storageTypes = createFormRegistry<StorageType>('file-manager/storage-types');
storageTypes.register({ name: 'local', title: '本地存储', Component: LocalStorageForm });
storageTypes.register({ name: 's3', title: 'Amazon S3', Component: S3StorageForm });
storageTypes.get('s3');
storageTypes.list();
storageTypes.has('local');
storageTypes.unregister('local');
```
主要用在:插件需要给「同名 + 同形 + 不同实现」的东西做扩展点(比如 file-manager 的存储类型、verification 的 OTP provider)。比 `Map` 多了 namespace 标识和 HMR 友好的覆盖警告。
`name` 重复注册会用新条目覆盖旧的,同时打 `console.warn`——HMR 时不抛错,开发期能看到意外的重复。
## 怎么决定加不加新组件
- 出现两个及以上插件需要同一形态的字段或容器——抽到这里
- 跨插件复用、但耦合到具体业务领域(比如「选一个 verifier」「选一个数据源」)——留在业务插件里,从插件的 `client-v2/` 自己 export
- 抽象前先看现有组件能不能改进:比如 `RemoteSelect``selectItems` 就是为了让带元数据的响应不需要再开新组件
新增组件后别忘记两件事:
1.`form/index.tsx` 加一行 `export * from './XxxComponent'`
2. 回来这份 README 补一节,方便后续插件迁移时找到
@@ -8,41 +8,41 @@
*/
import { TranslationOutlined } from '@ant-design/icons';
import { Dropdown } from 'antd';
import { Dropdown, theme } from 'antd';
import React from 'react';
import { useApp } from '@nocobase/client-v2';
import { useSystemSettings } from '@nocobase/client-v2';
import { useApp } from '../hooks/useApp';
import { useSystemSettings } from '../flow/system-settings';
import languageCodes from '../locale/languageCodes';
const languageLabels: Record<string, string> = {
'en-US': 'English',
'zh-CN': '简体中文',
};
export default function SwitchLanguage() {
export function SwitchLanguage() {
const app = useApp();
const { token } = theme.useToken();
const { data } = useSystemSettings() || {};
const enabledLanguages = data?.data?.enabledLanguages || [];
const enabledLanguages: string[] = data?.data?.enabledLanguages || [];
if (enabledLanguages.length <= 1) {
return null;
}
const items = enabledLanguages
.filter((code) => languageCodes[code])
.map((code) => ({ key: code, label: languageCodes[code].label }));
return (
<Dropdown
menu={{
selectable: true,
defaultSelectedKeys: [app.apiClient.auth.locale],
items: enabledLanguages.map((code: string) => ({
key: code,
label: languageLabels[code] || code,
})),
items,
onClick: ({ key }) => {
app.apiClient.auth.setLocale(String(key));
window.location.reload();
},
}}
>
<TranslationOutlined style={{ fontSize: 20, color: 'inherit' }} />
<TranslationOutlined style={{ fontSize: token.fontSizeXL, color: 'inherit' }} />
</Dropdown>
);
}
export default SwitchLanguage;
@@ -0,0 +1,111 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useFlowView } from '@nocobase/flow-engine';
import { Button, Space } from 'antd';
import React, { useCallback } from 'react';
export interface DialogFormLayoutProps {
/** Header title rendered in the dialog's title slot. */
title: React.ReactNode;
/** Form body — typically a `<Form>` wrapping `<Form.Item>` fields. */
children: React.ReactNode;
/**
* Called before the dialog is closed by the Cancel button or the
* top-right close (X) icon. Use for "discard changes" confirmations.
*/
onCancel?: () => void | Promise<void>;
/**
* Called when the Submit button is clicked. Caller owns validation
* + the actual API call; the dialog is closed automatically when
* `onSubmit` resolves. Throw from `onSubmit` to keep the dialog open
* (e.g. on a validation error).
*/
onSubmit?: () => void | Promise<void>;
/** Drives the Submit button's loading state. */
submitting?: boolean;
/** Override the Submit button label. Defaults to "Submit". */
submitText?: React.ReactNode;
/** Override the Cancel button label. Defaults to "Cancel". */
cancelText?: React.ReactNode;
/**
* Full override of the footer content. When provided, the default
* Cancel + Submit buttons are replaced. Useful for forms that need
* extra actions (e.g. Preview, Save draft).
*/
footer?: React.ReactNode;
}
/**
* Standard layout for dialog-hosted forms — the dialog counterpart of
* `DrawerFormLayout`. Title sits left-aligned in the dialog's native
* header (no inline close icon — the dialog provides its own X in the
* top-right when opened with `viewer.dialog({ closable: true, ... })`),
* the form body fills the middle, and a Cancel + Submit footer sits
* at the bottom.
*
* Why not just reuse `DrawerFormLayout`? `DrawerFormLayout` injects a
* `<CloseOutlined>` button next to the title — that's the drawer
* visual contract (close lives near the title in a side panel). In a
* centered dialog the native top-right close button is the expected
* affordance, so a separate layout keeps the visual contract clean.
*
* Callers own the `<Form>` instance, validation, and the actual API
* call. This component only handles the chrome and close behaviour.
*
* Example:
*
* ```tsx
* ctx.viewer.dialog({
* closable: true, // native top-right X
* content: () => (
* <DialogFormLayout
* title={t('Bind verifier')}
* onSubmit={handleSubmit}
* submitting={submitting}
* submitText={t('Bind')}
* >
* <Form form={form} layout="vertical">...</Form>
* </DialogFormLayout>
* ),
* });
* ```
*/
export function DialogFormLayout(props: DialogFormLayoutProps) {
const view = useFlowView();
const handleCancel = useCallback(async () => {
await props.onCancel?.();
await view.close();
}, [props, view]);
const handleSubmit = useCallback(async () => {
await props.onSubmit?.();
await view.close();
}, [props, view]);
return (
<div>
{view.Header ? <view.Header title={props.title} /> : null}
{props.children}
{view.Footer ? (
<view.Footer>
{props.footer ?? (
<Space>
<Button onClick={handleCancel}>{props.cancelText ?? 'Cancel'}</Button>
<Button type="primary" loading={props.submitting} onClick={handleSubmit}>
{props.submitText ?? 'Submit'}
</Button>
</Space>
)}
</view.Footer>
) : null}
</div>
);
}
@@ -7,22 +7,15 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { CloseOutlined } from '@ant-design/icons';
import { css } from '@emotion/css';
import { useFlowView } from '@nocobase/flow-engine';
import { Button, Space } from 'antd';
import React, { useCallback } from 'react';
export interface DrawerFormLayoutProps {
/** Header title rendered next to the close (X) button. */
/** Header title rendered next to antd Drawer's native close (X) icon. */
title: React.ReactNode;
/** Form body — typically a `<Form>` wrapping `<Form.Item>` fields. */
children: React.ReactNode;
/**
* Called before the drawer is closed by either the Cancel button or the
* header's X icon. Use for "discard changes" confirmations.
*/
onCancel?: () => void | Promise<void>;
/**
* Called when the Submit button is clicked. Caller owns validation + the
* actual API call; the drawer is closed automatically when `onSubmit`
@@ -44,29 +37,26 @@ export interface DrawerFormLayoutProps {
footer?: React.ReactNode;
}
const titleClassName = css`
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: -8px;
`;
/**
* Standard layout for drawer-hosted forms: a close-icon + title header on
* top, the caller-provided form body in the middle, and a Cancel + Submit
* footer at the bottom. Wraps `useFlowView()`'s `Header` / `Footer` slots
* so the drawer chrome stays consistent across plugins.
* Standard layout for drawer-hosted forms: a title-only header on top
* (caller must open the drawer with `viewer.drawer({ closable: true })`
* so antd Drawer renders its native left-side X next to the title),
* the caller-provided form body in the middle, and a Cancel + Submit
* footer at the bottom. Wraps `useFlowView()`'s `Header` / `Footer`
* slots so the drawer chrome stays consistent across plugins.
*
* To intercept close (e.g. dirty-form confirmation), use the lower-level
* `viewer.drawer({ preventClose, beforeClose, ... })` hooks — this
* layout no longer wraps a custom close handler.
*
* Callers own the `<Form>` instance, validation, and the actual API call.
* This component only handles the chrome and the close behaviour.
*/
export function DrawerFormLayout(props: DrawerFormLayoutProps) {
const view = useFlowView();
const handleCancel = useCallback(async () => {
await props.onCancel?.();
await view.close();
}, [props, view]);
}, [view]);
const handleSubmit = useCallback(async () => {
await props.onSubmit?.();
@@ -75,16 +65,7 @@ export function DrawerFormLayout(props: DrawerFormLayoutProps) {
return (
<div>
{view.Header ? (
<view.Header
title={
<span className={titleClassName}>
<Button type="text" size="small" icon={<CloseOutlined />} onClick={handleCancel} />
<span>{props.title}</span>
</span>
}
/>
) : null}
{view.Header ? <view.Header title={props.title} /> : null}
{props.children}
{view.Footer ? (
<view.Footer>
@@ -0,0 +1,211 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Input, type InputProps } from 'antd';
import type { PasswordProps as AntdPasswordProps } from 'antd/es/input';
import React from 'react';
// --- Strength scoring -------------------------------------------------------
// Pure scoring function ported from the v1 client's password utils. Returns a
// bucketed score in `[20, 40, 60, 80, 100]` based on character-class diversity,
// length, repeated / sequential / consecutive character penalties, and a
// "middle non-letter / non-symbol" bonus. No external dependencies — safe to
// run on any string.
//
// Kept private to this module on purpose. Callers consume the visual strength
// bar via `<PasswordInput checkStrength>`; they shouldn't need to compute the
// raw score themselves.
const isNum = (c: number) => c >= 48 && c <= 57;
const isLower = (c: number) => c >= 97 && c <= 122;
const isUpper = (c: number) => c >= 65 && c <= 90;
const isSymbol = (c: number) => !(isLower(c) || isUpper(c) || isNum(c));
const isLetter = (c: number) => isLower(c) || isUpper(c);
function getStrength(val: string): number {
if (!val) return 0;
let num = 0;
let lower = 0;
let upper = 0;
let symbol = 0;
let MNS = 0;
let rep = 0;
let repC = 0;
let consecutive = 0;
let sequential = 0;
const len = () => num + lower + upper + symbol;
const callMe = () => {
let re = num > 0 ? 1 : 0;
re += lower > 0 ? 1 : 0;
re += upper > 0 ? 1 : 0;
re += symbol > 0 ? 1 : 0;
return re > 2 && len() >= 8 ? re + 1 : 0;
};
for (let i = 0; i < val.length; i++) {
const c = val.charCodeAt(i);
if (isNum(c)) {
num++;
if (i !== 0 && i !== val.length - 1) MNS++;
if (i > 0 && isNum(val.charCodeAt(i - 1))) consecutive++;
} else if (isLower(c)) {
lower++;
if (i > 0 && isLower(val.charCodeAt(i - 1))) consecutive++;
} else if (isUpper(c)) {
upper++;
if (i > 0 && isUpper(val.charCodeAt(i - 1))) consecutive++;
} else {
symbol++;
if (i !== 0 && i !== val.length - 1) MNS++;
}
let exists = false;
for (let j = 0; j < val.length; j++) {
if (val[i] === val[j] && i !== j) {
exists = true;
repC += Math.abs(val.length / (j - i));
}
}
if (exists) {
rep++;
const unique = val.length - rep;
repC = unique ? Math.ceil(repC / unique) : Math.ceil(repC);
}
if (i > 1) {
const last1 = val.charCodeAt(i - 1);
const last2 = val.charCodeAt(i - 2);
if (isLetter(c)) {
if (isLetter(last1) && isLetter(last2)) {
const v = val.toLowerCase();
const vi = v.charCodeAt(i);
const vi1 = v.charCodeAt(i - 1);
const vi2 = v.charCodeAt(i - 2);
if (vi - vi1 === vi1 - vi2 && Math.abs(vi - vi1) === 1) sequential++;
}
} else if (isNum(c)) {
if (isNum(last1) && isNum(last2)) {
if (c - last1 === last1 - last2 && Math.abs(c - last1) === 1) sequential++;
}
} else if (isSymbol(last1) && isSymbol(last2)) {
if (c - last1 === last1 - last2 && Math.abs(c - last1) === 1) sequential++;
}
}
}
let sum = 0;
const length = len();
sum += 4 * length;
if (lower > 0) sum += 2 * (length - lower);
if (upper > 0) sum += 2 * (length - upper);
if (num !== length) sum += 4 * num;
sum += 6 * symbol;
sum += 2 * MNS;
sum += 2 * callMe();
if (length === lower + upper) sum -= length;
if (length === num) sum -= num;
sum -= repC;
sum -= 2 * consecutive;
sum -= 3 * sequential;
sum = Math.max(0, Math.min(100, sum));
if (sum >= 80) return 100;
if (sum >= 60) return 80;
if (sum >= 40) return 60;
if (sum >= 20) return 40;
return 20;
}
// --- Strength bar UI --------------------------------------------------------
// Colours and pixel sizes are intentionally kept identical to v1's
// `PasswordStrength` so the visual remains unchanged across the v1 → v2
// migration. When the design system formalises tokens for "strength signal"
// colours, swap these literals for the matching token expressions.
const segmentDividerStyle: React.CSSProperties = {
position: 'absolute',
zIndex: 1,
height: 8,
top: 0,
background: '#fff',
width: 1,
transform: 'translate(-50%, 0)',
};
function StrengthBar({ score }: { score: number }) {
return (
<div
style={{
background: '#e0e0e0',
marginBottom: 3,
position: 'relative',
}}
>
{/* Four white dividers split the bar into five strength brackets that
line up with the bucketed scoring in `getStrength`. */}
<div style={{ ...segmentDividerStyle, left: '20%' }} />
<div style={{ ...segmentDividerStyle, left: '40%' }} />
<div style={{ ...segmentDividerStyle, left: '60%' }} />
<div style={{ ...segmentDividerStyle, left: '80%' }} />
{/* The full gradient is always laid down, then `clip-path` trims it back
to the current score percentage — gives a smooth fill animation on
value change without re-painting the gradient on every render. */}
<div
style={{
position: 'relative',
backgroundImage: '-webkit-linear-gradient(left, #ff5500, #ff9300)',
transition: 'all 0.35s ease-in-out',
height: 8,
width: '100%',
marginTop: 5,
clipPath: `polygon(0 0,${score}% 0,${score}% 100%,0 100%)`,
}}
/>
</div>
);
}
// --- Public component -------------------------------------------------------
export interface PasswordInputProps extends AntdPasswordProps {
/**
* Render a visual strength bar beneath the input. Defaults to `false`. The
* score is computed locally — opting in does NOT add any form validation;
* use a separate `Form.Item.rules` entry for that (or wire the entry up to
* a cross-plugin password-validator extension point if your project
* provides one).
*/
checkStrength?: boolean;
}
/**
* `Input.Password` plus an optional strength meter, ported from the v1
* `Password` component. The strength scoring and bar UI are identical to v1,
* so users who switch from a v1 page to a v2 page see the same visual signal.
*
* The component is value-shape compatible with antd `Input.Password` — drop
* it into any existing `Form.Item<password>` and toggle the meter with
* `checkStrength`.
*
* Caveats:
*
* - Strength scoring is purely a UX hint, not validation. Submitting a weak
* password is still allowed unless the server (or a separately installed
* password-policy plugin) rejects it.
* - The meter swallows the gap between `<Input.Password>` and the next form
* element. If your `Form.Item` already adds vertical rhythm, the meter
* inherits it; no extra spacing is added.
*/
export function PasswordInput(props: PasswordInputProps) {
const { value, checkStrength, ...rest } = props;
return (
<span>
<Input.Password {...(rest as InputProps)} value={value} />
{checkStrength ? <StrengthBar score={getStrength(String(value || ''))} /> : null}
</span>
);
}
export default PasswordInput;
@@ -0,0 +1,137 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useRequest } from 'ahooks';
import { Select, type SelectProps } from 'antd';
import React, { useMemo } from 'react';
export interface RemoteSelectFieldNames {
label?: string;
value?: string;
}
export interface RemoteSelectProps<RawItem = any, Resp = RawItem[], V = any>
extends Omit<SelectProps<V>, 'options' | 'loading'> {
/**
* Fetch the option source. Receives no arguments; caller closes over the
* `ctx.api.resource(...)` (or any other source) it needs. May resolve
* with either an array of raw items (the common case) or an arbitrary
* envelope object — in the latter case, supply `selectItems` to pluck
* the array out.
*/
request: () => Promise<Resp | undefined>;
/**
* When `request` returns an envelope (object with metadata around the
* list), use this to extract the array of items that drives the
* dropdown. Defaults to identity, i.e. `request` itself returns the
* array.
*/
selectItems?: (response: Resp) => RawItem[] | undefined;
/**
* Names of the raw item properties that hold the display label and the
* persisted value. Defaults to `{ label: 'label', value: 'value' }`.
* Ignored when `mapOptions` is supplied.
*/
fieldNames?: RemoteSelectFieldNames;
/**
* Full custom mapping from a raw item to an antd `OptionType`. When
* provided, overrides `fieldNames`.
*/
mapOptions?: (item: RawItem, index: number) => { label: React.ReactNode; value: any };
/**
* Stable cache key for ahooks `useRequest` so the dropdown doesn't re-fetch
* on every re-mount. Pass a value tied to the request's effective inputs.
*/
cacheKey?: string;
/**
* Re-run the request when any of these values changes. Forwarded to
* `useRequest`'s `refreshDeps`.
*/
refreshDeps?: unknown[];
/**
* Skip the auto-fetch on mount when `false`. Defaults to `true`.
*/
ready?: boolean;
/**
* Notified once the request resolves. Receives both the mapped item
* array and the raw response envelope — useful when callers need to
* read sibling metadata (counts, availability hints, etc.) without
* issuing a second request.
*/
onLoaded?: (items: RawItem[], response: Resp) => void;
}
/**
* Generic settings-page Select bound to an async option source. The
* component itself stays framework-agnostic — it knows nothing about
* NocoBase resources, data sources, or Formily. Pass any async `request`
* that resolves with an array, and supply `fieldNames` (or `mapOptions`)
* to map raw items to antd option shape.
*
* Search is local-only (antd's default `optionFilterProp="label"`). For
* server-side search, drive `request` from external state and pass the
* search input via `refreshDeps`.
*/
export function RemoteSelect<RawItem = any, Resp = RawItem[], V = any>(props: RemoteSelectProps<RawItem, Resp, V>) {
const {
request,
selectItems,
fieldNames,
mapOptions,
cacheKey,
refreshDeps,
ready = true,
onLoaded,
showSearch = true,
allowClear = true,
...selectProps
} = props;
const { data: response, loading } = useRequest<Resp | undefined, []>(request, {
cacheKey,
refreshDeps,
ready,
onSuccess: (resp) => {
if (resp === undefined) return;
const items = (selectItems ? selectItems(resp) : (resp as unknown as RawItem[])) || [];
onLoaded?.(items, resp);
},
});
const items = useMemo<RawItem[]>(() => {
if (response === undefined) return [];
return (selectItems ? selectItems(response) : (response as unknown as RawItem[])) || [];
}, [response, selectItems]);
const labelKey = fieldNames?.label ?? 'label';
const valueKey = fieldNames?.value ?? 'value';
const options = useMemo(() => {
if (mapOptions) {
return items.map((item, index) => mapOptions(item, index));
}
return items.map((item: any) => ({
label: item?.[labelKey],
value: item?.[valueKey],
}));
}, [items, mapOptions, labelKey, valueKey]);
return (
<Select
{...selectProps}
showSearch={showSearch}
allowClear={allowClear}
optionFilterProp="label"
loading={loading}
options={options}
/>
);
}
export default RemoteSelect;
@@ -8,8 +8,11 @@
*/
export * from './createFormRegistry';
export * from './DialogFormLayout';
export * from './DrawerFormLayout';
export * from './EnvVariableInput';
export * from './FileSizeInput';
export * from './JsonTextArea';
export * from './PasswordInput';
export * from './RemoteSelect';
export * from './VariableInput';
@@ -19,7 +19,7 @@ import React, { useMemo, useState } from 'react';
import { SortableRow, SortHandle } from './dnd/SortableRow';
import { RowOverlayPreview } from './RowOverlayPreview';
import { SelectionCell } from './SelectionCell';
import { indexSwapClassName, selectionGutterClassName } from './styles';
import { indexSwapClassName, selectionGutterClassName, tableScrollClassName } from './styles';
import { readRowKey, snapshotSourceRow, type RowKey, type RowSnapshot } from './utils';
type RowSelectionRenderCellResult<RecordType> = React.ReactNode | RenderedCell<RecordType>;
@@ -227,6 +227,7 @@ export function Table<RecordType extends object = any>(props: TableProps<RecordT
const tableClassName = cx(
className,
tableScrollClassName,
showHandleInSelection && selectionGutterClassName,
showIndex && rowSelection && indexSwapClassName,
);
@@ -10,6 +10,25 @@
import { css } from '@emotion/css';
import { SORT_HANDLE_GUTTER } from './constants';
/**
* Restore horizontal scrolling on `.ant-table-content` so wide tables in
* narrow containers (drawer / settings panel) scroll their inner `<table>`
* instead of getting clipped or forcing the outer container to grow.
*
* `width: max-content` on the inner `<table>` lets columns size to their
* natural width; `min-width: 100%` keeps the table filling the viewport
* when total column width is smaller than the container.
*/
export const tableScrollClassName = css`
&.ant-table-wrapper .ant-table-content {
overflow: auto hidden;
}
&.ant-table-wrapper .ant-table-content > table {
width: max-content;
min-width: 100%;
}
`;
/**
* Reserve a `SORT_HANDLE_GUTTER`-wide gap on the left of the rowSelection
* column so the handle's `left:0` lands inside a `position:relative` cell.
@@ -12,5 +12,7 @@ export * from './BlankComponent';
export * from './form/table/dnd';
export * from './form';
export * from './Icon';
export * from './PoweredBy';
export * from './RouterContextCleaner';
export * from './SwitchLanguage';
export * from './form/table';
@@ -371,15 +371,12 @@ export const AdminLayoutComponent = observer((props: any) => {
const [allAccessRoutes, setAllAccessRoutes] = useState<NocoBaseDesktopRoute[]>(
() => flowEngine.context.routeRepository?.listAccessible?.() || [],
);
const screens = Grid.useBreakpoint();
const isMobileViewport =
screens.md === false || (screens.md === undefined && typeof window !== 'undefined' && window.innerWidth < 768);
const location = useLocation();
const { token } = antdTheme.useToken();
const customToken = token as CustomToken;
const isMobileLayout = !!adminLayoutModel?.isMobileLayout;
const menuRouteRefreshVersion = adminLayoutModel?.menuRouteRefreshVersion || 0;
const isMobileSider = isMobileLayout || isMobileViewport;
const isMobileSider = isMobileLayout;
const [collapsed, setCollapsed] = useState(isMobileSider);
const [preferredFlowSettingsEnabled, setPreferredFlowSettingsEnabled] = useState(() => readFlowSettingsPreference());
const [route, setRoute] = useState<{ path: string; children: AdminLayoutMenuNode[] }>({
@@ -7,11 +7,12 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { createCollectionContextMeta } from '@nocobase/flow-engine';
import React, { createContext, type FC, useEffect, useRef, useState } from 'react';
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { createCollectionContextMeta, useFlowEngine } from '@nocobase/flow-engine';
import React, { createContext, type FC, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useACLRoleContext } from '../acl';
import type { Application } from '../Application';
import { getCurrentV2RedirectPath, getDefaultV2AdminRedirectPath, redirectToV2Signin } from '../authRedirect';
import { getCurrentV2RedirectPath, getDefaultV2AdminRedirectPath } from '../authRedirect';
import { AppNotFound } from '../components';
import { PluginFlowEngine } from '../flow';
import { AdminLayoutMenuItemModel, AdminLayoutModel } from '../flow/admin-shell/admin-layout';
@@ -20,13 +21,18 @@ import { Plugin } from '../Plugin';
import { AdminSettingsLayoutModel } from '../settings-center';
import { LocalePlugin } from './plugins/LocalePlugin';
type CurrentUserState = {
export type CurrentUserState = {
data?: {
data?: any;
};
loading: boolean;
};
export type CurrentRoleOption = {
name: string;
title: string;
};
const AUTH_ROUTE_PREFIXES = ['/signin', '/signup', '/forgot-password', '/reset-password'];
function removeBasename(pathname: string, basename?: string) {
@@ -50,9 +56,42 @@ function isAdminRuntimeRoute(pathname: string, basename?: string) {
return normalizedPathname === '/admin' || normalizedPathname.startsWith('/admin/');
}
const CurrentUserContext = createContext<CurrentUserState | null>(null);
export const CurrentUserContext = createContext<CurrentUserState | null>(null);
CurrentUserContext.displayName = 'CurrentUserContext';
export function useCurrentUserContext() {
return useContext(CurrentUserContext);
}
/**
* 返回当前用户在 v2 应用上下文中可选的角色列表,等价于 v1 `useCurrentRoles`
* 从 FlowEngine 全局上下文 `engine.context.user.roles` 派生(CurrentUserProvider 在
* `/auth:check` 成功后通过 `defineProperty('user', { value })` 写入),按需追加匿名角色,
* 并去掉合并角色 `__union__`。v2 中角色 title 可能含有 `{{t('...')}}` 模板,因此用
* flowEngine.context.t 解析。
*
* 不读 React `CurrentUserContext`FlowEngine 的 dialog/drawer/popover 内容通过 `ctx.viewer`
* 渲染到独立的 ElementsHolder,部分场景会脱离原 Provider 树;FlowEngine 全局上下文是同一份
* 数据但不受 React 树位置影响。
*/
export function useCurrentRoles(): CurrentRoleOption[] {
const { allowAnonymous } = useACLRoleContext();
const engine = useFlowEngine();
const rolesRaw = engine?.context?.user?.roles as Array<{ name: string; title?: string }> | undefined;
return useMemo(() => {
const compile = (value: string | undefined): string =>
value == null ? '' : engine?.context?.t ? engine.context.t(value) : value;
const roles: CurrentRoleOption[] = (rolesRaw || [])
.filter((role) => role?.name !== '__union__')
.map((role) => ({ name: role.name, title: compile(role.title) }));
if (allowAnonymous) {
roles.push({ name: 'anonymous', title: 'Anonymous' });
}
return roles;
}, [allowAnonymous, engine, rolesRaw]);
}
const DataSourceBootstrapProvider: FC = ({ children }) => {
const app = useApp();
const location = useLocation();
@@ -115,6 +154,7 @@ const DataSourceBootstrapProvider: FC = ({ children }) => {
const CurrentUserProvider: FC = ({ children }) => {
const app = useApp();
const location = useLocation();
const navigate = useNavigate();
const [state, setState] = useState<CurrentUserState>({ loading: true });
const pathnameRef = useRef(location.pathname);
pathnameRef.current = location.pathname;
@@ -143,8 +183,23 @@ const CurrentUserProvider: FC = ({ children }) => {
});
const user = res?.data?.data;
// 服务端通过 `{ code: 302, redirect }` 通知客户端先去某个中间页(例如 2FA 验证页)。
// 这类响应没有 user.id,但也不能视为未登录——否则会和处理 302 的全局响应拦截器
// (例如 plugin-two-factor-authentication 注册的那一个)竞态,而 `window.location.replace`
// 会覆盖更早发出的 `window.location.href`,把用户错误地弹回登录页。让响应拦截器接管跳转。
if (user?.code === 302) {
if (mounted) {
setState({ loading: false });
}
return;
}
if (user?.id == null) {
redirectToV2Signin(app, getCurrentV2RedirectPath(app, locationRef.current), { replace: true });
// 用 react-router navigate (虚拟跳转)而不是 location.replace, 这样如果有其他响应拦截器
// 已经发起了 window.location.href 整页跳转(例如 2FA 插件接收到服务端 302 重定向),
// 真实跳转可以胜出 navigate, 不会被这里的 signin 重定向覆盖。
navigate(`/signin?redirect=${encodeURIComponent(getCurrentV2RedirectPath(app, locationRef.current))}`, {
replace: true,
});
return;
}
@@ -169,7 +224,9 @@ const CurrentUserProvider: FC = ({ children }) => {
} catch (error: any) {
const isAuthError = error?.response?.status === 401 || error?.status === 401;
if (isAuthError) {
redirectToV2Signin(app, getCurrentV2RedirectPath(app, locationRef.current), { replace: true });
navigate(`/signin?redirect=${encodeURIComponent(getCurrentV2RedirectPath(app, locationRef.current))}`, {
replace: true,
});
return;
}
if (mounted) {
@@ -184,7 +241,7 @@ const CurrentUserProvider: FC = ({ children }) => {
return () => {
mounted = false;
};
}, [app]);
}, [app, navigate]);
if (state.loading) {
return app.renderComponent('AppSpin');
@@ -196,15 +253,12 @@ const CurrentUserProvider: FC = ({ children }) => {
const RootRedirect: FC = () => {
const app = useApp();
const hasToken = !!app?.apiClient?.auth?.token;
useEffect(() => {
if (!hasToken) {
redirectToV2Signin(app, getDefaultV2AdminRedirectPath(app), { replace: true });
}
}, [app, hasToken]);
const targetPath = getDefaultV2AdminRedirectPath(app);
if (!hasToken) {
return app.renderComponent('AppSpin');
// 用 react-router <Navigate /> 而非 location.replace, 避免覆盖同时段其它响应拦截器
// 触发的 window.location.href (例如 2FA 接收到服务端 302 时设置的整页跳转)。
return <Navigate replace to={`/signin?redirect=${encodeURIComponent(targetPath)}`} />;
}
return <Navigate replace to="/admin" />;
@@ -104,3 +104,6 @@ export { isBeforeRenderFlow } from './flows';
// Module URL resolver
export { resolveModuleUrl, isCssFile } from './resolveModuleUrl';
// Random base36 identifier with optional semantic prefix
export { randomId } from './randomId';
@@ -0,0 +1,48 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
const CHARSET = '0123456789abcdefghijklmnopqrstuvwxyz';
/**
* Generate a random base36 identifier with an optional semantic prefix.
*
* Equivalent in shape to v1's `uid()` from `@formily/shared` (11 chars
* of `[0-9a-z]`), with an opt-in prefix appended at the front. v2 forbids
* direct `@formily/*` imports in `src/client-v2/`, so this helper is the
* single substitute the rest of the codebase should reach for.
*
* Common semantic prefixes observed across the codebase — pass the one
* that matches your domain rather than relying on a default, so the
* intent is explicit at the call site:
*
* - `s_` — service / settings record (authenticators, channels, …)
* - `v_` — verifier / variable / LLM service
* - `f_` — field
* - `t_` — through table
*
* Example:
*
* ```ts
* import { randomId } from '@nocobase/flow-engine';
*
* name: randomId('s_'), // → 's_keeoaui1ubi'
* name: randomId('v_'), // → 'v_a8f3kp2x9qm'
* name: randomId(), // → 'a8f3kp2x9qm'
* ```
*
* Not cryptographically secure — uses `Math.random()`. Good enough for
* unique form names / schema keys, NOT for security tokens.
*/
export function randomId(prefix = '', length = 11): string {
let id = '';
for (let i = 0; i < length; i++) {
id += CHARSET[(Math.random() * CHARSET.length) | 0];
}
return `${prefix}${id}`;
}
@@ -0,0 +1,2 @@
export * from './dist/client-v2';
export { default } from './dist/client-v2';
@@ -0,0 +1 @@
module.exports = require('./dist/client-v2/index.js');
@@ -29,7 +29,9 @@
"peerDependencies": {
"@nocobase/actions": "2.x",
"@nocobase/client": "2.x",
"@nocobase/client-v2": "2.x",
"@nocobase/database": "2.x",
"@nocobase/flow-engine": "2.x",
"@nocobase/resourcer": "2.x",
"@nocobase/server": "2.x",
"@nocobase/test": "2.x",
@@ -0,0 +1,111 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import dayjs from 'dayjs';
import { describe, expect, it, vi } from 'vitest';
import { type ApiKeyFormValues, type ApiKeyResource, deleteApiKey, submitApiKeyForm } from '../pages/ApiKeysPage';
import { diffToExpiresIn, formatExpiresReadOnly } from '../pages/ExpiresField';
function makeResource(overrides: Partial<ApiKeyResource> = {}): ApiKeyResource {
return {
create: vi.fn().mockResolvedValue({ data: { data: { token: 'tok_test' } } }),
destroy: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
describe('plugin-api-keys submit pipeline', () => {
it('should fire resource.create with the form values and surface the returned token', async () => {
const resource = makeResource();
const onCreated = vi.fn();
await submitApiKeyForm({
values: { name: 'k1', role: { name: 'admin' }, expiresIn: '30d' },
resource,
onCreated,
});
expect(resource.create).toHaveBeenCalledTimes(1);
expect(resource.create).toHaveBeenCalledWith({
values: { name: 'k1', role: { name: 'admin' }, expiresIn: '30d' },
});
// Token from `response.data.data.token` must reach the caller so the post-
// create success modal can show the one-time copyable token to the user.
expect(onCreated).toHaveBeenCalledTimes(1);
expect(onCreated).toHaveBeenCalledWith('tok_test');
});
it('should not invoke onCreated when resource.create rejects', async () => {
const resource = makeResource({ create: vi.fn().mockRejectedValue(new Error('boom')) });
const onCreated = vi.fn();
await expect(
submitApiKeyForm({
values: { name: 'k', expiresIn: '30d' } as ApiKeyFormValues,
resource,
onCreated,
}),
).rejects.toThrow(/boom/);
expect(onCreated).not.toHaveBeenCalled();
});
it('should fire resource.destroy with the numeric `id` as filterByTk on row delete', async () => {
// Pin the filterByTk source. `apiKeys` collection's primary key is the
// bigInt `id`, NOT `name` — guarding against the silent-fallthrough class
// of regression documented in references/settings-page-crud.md.
const resource = makeResource();
const onDeleted = vi.fn();
await deleteApiKey({ resource, filterByTk: 42, onDeleted });
expect(resource.destroy).toHaveBeenCalledTimes(1);
expect(resource.destroy).toHaveBeenCalledWith({ filterByTk: 42 });
expect(onDeleted).toHaveBeenCalledTimes(1);
});
it('should not invoke onDeleted when resource.destroy rejects', async () => {
const resource = makeResource({ destroy: vi.fn().mockRejectedValue(new Error('nope')) });
const onDeleted = vi.fn();
await expect(deleteApiKey({ resource, filterByTk: 1, onDeleted })).rejects.toThrow(/nope/);
expect(onDeleted).not.toHaveBeenCalled();
});
});
describe('plugin-api-keys ExpiresField helpers', () => {
it('formats `never` as the localized "Never expires" label', () => {
expect(formatExpiresReadOnly({ expiresIn: 'never', createdAt: '2026-01-01T00:00:00Z' }, 'Never expires')).toBe(
'Never expires',
);
});
it('adds the day delta to createdAt and formats it as YYYY-MM-DD HH:mm:ss', () => {
// 30 days after 2026-01-01 00:00:00 UTC.
const out = formatExpiresReadOnly({ expiresIn: '30d', createdAt: '2026-01-01T00:00:00Z' }, 'never');
expect(out).toBe(dayjs('2026-01-01T00:00:00Z').add(30, 'days').format('YYYY-MM-DD HH:mm:ss'));
});
it('returns "" when createdAt is missing (e.g. unsaved row in test fixtures)', () => {
expect(formatExpiresReadOnly({ expiresIn: '30d' }, 'never')).toBe('');
});
it('diffToExpiresIn converts a future dayjs date into "Xd" relative to now', () => {
const now = dayjs('2026-05-01T12:00:00Z');
const target = dayjs('2026-05-11T12:00:00Z');
expect(diffToExpiresIn(target, now)).toBe('10d');
});
it('diffToExpiresIn zeroes out second/ms to match the v1 day rounding rule', () => {
// Without the millisecond/second strip, dayjs.diff(unit:'d') would round
// sub-day deltas down differently depending on the wall clock at submit time.
const now = dayjs('2026-05-01T12:00:30.500Z');
const target = dayjs('2026-05-02T12:00:00.000Z');
expect(diffToExpiresIn(target, now)).toBe('1d');
});
});
@@ -0,0 +1,10 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export { default } from './plugin';
@@ -0,0 +1,21 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { tExpr as flowTExpr, useFlowEngine } from '@nocobase/flow-engine';
import { NAMESPACE } from '../constants';
export function useT() {
const engine = useFlowEngine();
return (key: string, options?: Record<string, any>) =>
engine.context.t(key, { ns: [NAMESPACE, 'client'], ...options });
}
export function tExpr(key: string) {
return flowTExpr(key, { ns: [NAMESPACE, 'client'] });
}
@@ -0,0 +1,307 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { Table, useCurrentRoles } from '@nocobase/client-v2';
import { useFlowContext, useFlowEngine, useFlowView } from '@nocobase/flow-engine';
import { useRequest } from 'ahooks';
import { Alert, App, Button, Card, Flex, Form, Input, Select, Space, Tag, theme, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import React, { useCallback, useMemo, useState } from 'react';
import { useT } from '../locale';
import { ExpiresEditor, formatExpiresReadOnly, useExpiresOptions } from './ExpiresField';
type Role = { name: string; title?: string };
/**
* Internal form shape: `role` is the selected role's name (string).
* On submit we wrap it into `{ name }` so the request body matches v1's payload
* (the server reads `values.role.name`).
*/
type ApiKeyFormState = {
name: string;
role?: string;
expiresIn: string;
};
export type ApiKeyFormValues = {
name: string;
role?: Role;
expiresIn: string;
};
export type ApiKeyRecord = {
id: number;
name: string;
role?: Role;
roleName?: string;
expiresIn: string;
createdAt?: string;
};
type CreateResponse = { data?: { data?: { token?: string } } };
type ListMeta = {
count?: number;
pageSize?: number;
page?: number;
};
type ListBody = {
data: ApiKeyRecord[];
meta?: ListMeta;
};
export type ApiKeyResource = {
create: (params: { values: ApiKeyFormValues }) => Promise<CreateResponse>;
destroy: (params: { filterByTk: number | number[] }) => Promise<unknown>;
};
/**
* Pure submit pipeline so tests can pin the resource call shape (create →
* extract token → onCreated) and a future refactor cannot silently skip the
* POST while still closing the drawer. Mirrors the prevention rule documented
* in references/settings-page-crud.md (`No silent fallthrough on submit branches`).
*/
export async function submitApiKeyForm(args: {
values: ApiKeyFormValues;
resource: ApiKeyResource;
onCreated: (token: string | undefined) => void;
}): Promise<void> {
const response = await args.resource.create({ values: args.values });
args.onCreated(response?.data?.data?.token);
}
export async function deleteApiKey(args: {
resource: ApiKeyResource;
filterByTk: number;
onDeleted: () => void;
}): Promise<void> {
await args.resource.destroy({ filterByTk: args.filterByTk });
args.onDeleted();
}
const ApiKeysPage: React.FC = () => {
const t = useT();
const ctx = useFlowContext();
const engine = useFlowEngine();
const { token } = theme.useToken();
const { modal } = App.useApp();
const resource = useMemo(() => ctx.api.resource('apiKeys') as ApiKeyResource, [ctx.api]);
const listRequest = useRequest(async (): Promise<ListBody> => {
const response = await ctx.api.request<ListBody>({
url: 'apiKeys:list',
method: 'get',
params: {
pageSize: 20,
appends: ['role'],
sort: ['-createdAt'],
},
skipNotify: true,
});
return response?.data ?? { data: [] };
});
const { data: listResp, loading } = listRequest;
const records: ApiKeyRecord[] = useMemo(() => {
const list = listResp?.data;
return Array.isArray(list) ? list : [];
}, [listResp]);
const pagination = useMemo(() => {
const meta = listResp?.meta;
if (!meta) return false as const;
return {
total: meta.count ?? records.length,
pageSize: meta.pageSize ?? 20,
current: meta.page ?? 1,
};
}, [listResp, records.length]);
const handleDelete = useCallback(
(record: ApiKeyRecord) => {
modal.confirm({
title: t('Delete API key'),
content: t('Are you sure you want to delete it?'),
async onOk() {
await deleteApiKey({
resource,
filterByTk: record.id,
onDeleted: () => listRequest.refresh(),
});
},
});
},
[listRequest, modal, resource, t],
);
const openCreateDialog = useCallback(() => {
ctx.viewer.dialog({
title: t('Add API key'),
width: 520,
maskClosable: false,
closable: true,
content: () => (
<CreateApiKeyForm
onCreated={(tokenValue) => {
listRequest.refresh();
modal.success({
title: t('API key created successfully'),
content: (
<Space direction="vertical" style={{ width: '100%' }}>
<Alert
type="warning"
message={t(
'Make sure to copy your personal access key now as you will not be able to see this again.',
)}
/>
<Typography.Text copyable>{tokenValue}</Typography.Text>
</Space>
),
});
}}
/>
),
});
}, [ctx.viewer, listRequest, modal, t]);
const neverLabel = t('Never expires');
const columns = useMemo<ColumnsType<ApiKeyRecord>>(
() => [
{ title: t('Key name'), dataIndex: 'name', ellipsis: true },
{
title: t('Role'),
dataIndex: ['role', 'title'],
// Role titles in the `roles` collection are stored as `{{t("Admin")}}` i18n
// macros — expand them through flowEngine.context.t before display.
// Wrap in <Tag> to match v1's read-pretty rendering of a belongsTo role.
render: (_: unknown, record) => {
const title = record.role?.title;
if (!title) return record.roleName ?? '-';
return <Tag>{engine.context.t(title)}</Tag>;
},
},
{
title: t('Expiration'),
render: (_: unknown, record) =>
formatExpiresReadOnly({ expiresIn: record.expiresIn, createdAt: record.createdAt }, neverLabel),
},
{
title: t('Created at'),
dataIndex: 'createdAt',
render: (value: string | undefined) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-'),
},
{
title: t('Actions'),
width: 160,
render: (_: unknown, record) => <a onClick={() => handleDelete(record)}>{t('Delete')}</a>,
},
],
[engine, handleDelete, neverLabel, t],
);
return (
<Card>
<Flex justify="flex-end" style={{ marginBottom: token.marginMD }}>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => listRequest.refresh()}>
{t('Refresh')}
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateDialog}>
{t('Add API key')}
</Button>
</Space>
</Flex>
<Table<ApiKeyRecord>
rowKey="id"
loading={loading}
dataSource={records}
columns={columns}
pagination={pagination}
/>
</Card>
);
};
function CreateApiKeyForm(props: { onCreated: (token: string | undefined) => void }) {
const { onCreated } = props;
const t = useT();
const ctx = useFlowContext();
const view = useFlowView();
const expiresOptions = useExpiresOptions();
const currentRoles = useCurrentRoles();
const [form] = Form.useForm<ApiKeyFormState>();
const [submitting, setSubmitting] = useState(false);
const resource = useMemo(() => ctx.api.resource('apiKeys') as ApiKeyResource, [ctx.api]);
const handleSubmit = useCallback(async () => {
const state = await form.validateFields();
setSubmitting(true);
try {
await submitApiKeyForm({
values: {
name: state.name,
role: state.role ? { name: state.role } : undefined,
expiresIn: state.expiresIn,
},
resource,
onCreated: (tokenValue) => {
form.resetFields();
onCreated(tokenValue);
},
});
await view.close();
} finally {
setSubmitting(false);
}
}, [form, onCreated, resource, view]);
return (
<>
<Form form={form} layout="vertical" initialValues={{ expiresIn: '30d' }}>
<Form.Item
name="name"
label={t('Key name')}
rules={[{ required: true, message: t('The field value is required') }]}
>
<Input />
</Form.Item>
<Form.Item
name="role"
label={t('Role')}
tooltip={t('Allow only your own roles to be selected')}
rules={[{ required: true, message: t('The field value is required') }]}
>
<Select fieldNames={{ label: 'title', value: 'name' }} options={currentRoles} />
</Form.Item>
<Form.Item
name="expiresIn"
label={t('Expiration')}
rules={[{ required: true, message: t('The field value is required') }]}
>
<ExpiresEditor options={expiresOptions} />
</Form.Item>
</Form>
{view.Footer ? (
<view.Footer>
<Space>
<Button onClick={async () => view.close()}>{t('Cancel')}</Button>
<Button type="primary" loading={submitting} onClick={handleSubmit}>
{t('Submit')}
</Button>
</Space>
</view.Footer>
) : null}
</>
);
}
export default ApiKeysPage;
@@ -0,0 +1,91 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useBoolean } from 'ahooks';
import { DatePicker, Select, Space } from 'antd';
import dayjs from 'dayjs';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
import React from 'react';
import { useT } from '../locale';
dayjs.extend(isSameOrBefore);
export type ExpiresOption = { label: string; value: string };
const TOMORROW = () => dayjs().add(1, 'days');
/**
* Convert a future `dayjs` date into the `Xd` shape persisted by the apiKeys
* collection. Mirrors the v1 ExpiresSelect: zero out seconds/milliseconds on
* both ends and report the day difference.
*/
export function diffToExpiresIn(target: dayjs.Dayjs, now: dayjs.Dayjs = dayjs()): string {
const targetNorm = target.millisecond(0).second(0);
const nowNorm = now.millisecond(0).second(0);
return `${targetNorm.diff(nowNorm, 'd')}d`;
}
export function formatExpiresReadOnly(record: { expiresIn?: string; createdAt?: string }, neverLabel: string): string {
if (record.expiresIn === 'never') return neverLabel;
const days = parseInt(String(record.expiresIn ?? '').replace('d', ''), 10) || 0;
if (!record.createdAt) return '';
return dayjs(record.createdAt).add(days, 'days').format('YYYY-MM-DD HH:mm:ss');
}
export function ExpiresEditor(props: { value?: string; onChange?: (next: string) => void; options: ExpiresOption[] }) {
const { value, onChange, options } = props;
const [isCustom, { toggle: toggleShowDatePicker, setFalse }] = useBoolean(false);
const onSelectChange = (v: string) => {
if (v === 'custom') {
onChange?.('1d');
toggleShowDatePicker();
return;
}
setFalse();
onChange?.(v);
};
const onDatePickerChange = (next: dayjs.Dayjs | null) => {
if (!next) return;
onChange?.(diffToExpiresIn(next));
};
return (
<Space style={{ width: '100%' }} styles={{ item: { flex: 1 } }}>
<Select
style={{ width: '100%' }}
options={options}
value={isCustom ? 'custom' : value}
onChange={onSelectChange}
/>
{isCustom ? (
<DatePicker
style={{ width: '100%' }}
disabledDate={(date) => date.isSameOrBefore(dayjs())}
defaultValue={TOMORROW()}
onChange={onDatePickerChange}
allowClear={false}
/>
) : null}
</Space>
);
}
export function useExpiresOptions(): ExpiresOption[] {
const t = useT();
return [
{ label: t('1 Day'), value: '1d' },
{ label: t('7 Days'), value: '7d' },
{ label: t('30 Days'), value: '30d' },
{ label: t('90 Days'), value: '90d' },
{ label: t('Custom'), value: 'custom' },
{ label: t('Never'), value: 'never' },
];
}
@@ -0,0 +1,33 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import type { Application } from '@nocobase/client-v2';
import { Plugin } from '@nocobase/client-v2';
export class PluginAPIKeysClientV2 extends Plugin<Record<string, never>, Application> {
async load() {
const title = this.t('API keys') as unknown as string;
this.pluginSettingsManager.addMenuItem({
key: 'api-keys',
title,
icon: 'KeyOutlined',
aclSnippet: 'pm.api-keys.configuration',
});
this.pluginSettingsManager.addPageTabItem({
menuKey: 'api-keys',
key: 'index',
title,
componentLoader: () => import('./pages/ApiKeysPage'),
});
}
}
export default PluginAPIKeysClientV2;
@@ -0,0 +1,2 @@
export * from './dist/client-v2';
export { default } from './dist/client-v2';
@@ -0,0 +1 @@
module.exports = require('./dist/client-v2/index.js');
@@ -22,7 +22,9 @@
"@nocobase/actions": "2.x",
"@nocobase/auth": "2.x",
"@nocobase/client": "2.x",
"@nocobase/client-v2": "2.x",
"@nocobase/database": "2.x",
"@nocobase/flow-engine": "2.x",
"@nocobase/plugin-auth": ">=0.17.0-alpha.7",
"@nocobase/plugin-verification": "2.x",
"@nocobase/server": "2.x",
@@ -0,0 +1,57 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import type { Authenticator } from '@nocobase/plugin-auth/client-v2';
import { describe, expect, it } from 'vitest';
import { pickSmsPublicOptions } from '../forms/SmsSignInForm';
function makeAuthenticator(options: Record<string, unknown>): Authenticator {
return {
name: 'sms',
authType: 'SMS',
authTypeTitle: 'SMS',
options,
};
}
describe('SmsSignInForm pickSmsPublicOptions', () => {
it('reads verifier directly off authenticator.options (the flattened public shape)', () => {
// `/authenticators:publicList` flattens server-side `options.public.*` into
// `options.*`. Reading from `options.public.verifier` here yields undefined
// and the form silently sends `verifier: ''` to `smsOTP:publicCreate`,
// making the login flow fail with the wrong verifier binding. Pin the
// source so a future refactor cannot regress to the deeper path.
const out = pickSmsPublicOptions(makeAuthenticator({ verifier: 'v_ciq3sc898pu', autoSignup: true }));
expect(out).toEqual({ verifier: 'v_ciq3sc898pu', autoSignup: true });
});
it('returns autoSignup=false when the option is missing or falsy', () => {
expect(pickSmsPublicOptions(makeAuthenticator({ verifier: 'v_x' })).autoSignup).toBe(false);
expect(pickSmsPublicOptions(makeAuthenticator({ verifier: 'v_x', autoSignup: 0 })).autoSignup).toBe(false);
});
it('returns verifier=undefined when the authenticator has no options at all', () => {
expect(pickSmsPublicOptions(null)).toEqual({ verifier: undefined, autoSignup: false });
expect(pickSmsPublicOptions(undefined)).toEqual({ verifier: undefined, autoSignup: false });
expect(pickSmsPublicOptions({ name: 'sms', authType: 'SMS', authTypeTitle: 'SMS' })).toEqual({
verifier: undefined,
autoSignup: false,
});
});
it('does NOT read from authenticator.options.public — that path is server-side storage only', () => {
// Explicit anti-regression: even if the deeper shape is provided, we
// ignore it. Only the flattened `options.*` from publicList is the API
// contract for the sign-in form.
const out = pickSmsPublicOptions(
makeAuthenticator({ public: { verifier: 'v_should_be_ignored', autoSignup: true } }),
);
expect(out).toEqual({ verifier: undefined, autoSignup: false });
});
});
@@ -0,0 +1,44 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { VerifierSelect } from '@nocobase/plugin-verification/client-v2';
import { Checkbox, Form } from 'antd';
import React from 'react';
import { useAuthSMSTranslation } from '../locale';
/**
* Admin-side configuration for an SMS authenticator. Rendered inside the
* Authenticators page drawer below the common fields (`name`, `authType`,
* `title`, `description`, `enabled`). Persists to
* `options.public.verifier` / `options.public.autoSignup`, matching the
* server-side reads in `sms-auth.ts` (`this.options.public?.verifier` /
* `this.authenticator.options?.public?.autoSignup`).
*/
export default function SmsAdminSettings() {
const { t } = useAuthSMSTranslation();
return (
<>
<Form.Item
name={['options', 'public', 'verifier']}
label={t('Verifier')}
rules={[{ required: true, message: t('Please select a verifier') }]}
>
<VerifierSelect scene="auth-sms" />
</Form.Item>
<Form.Item
name={['options', 'public', 'autoSignup']}
label={t('Sign up automatically when the user does not exist')}
valuePropName="checked"
>
<Checkbox />
</Form.Item>
</>
);
}
@@ -0,0 +1,96 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { type Authenticator, useSignIn } from '@nocobase/plugin-auth/client-v2';
import { VerificationCode } from '@nocobase/plugin-verification/client-v2';
import { Alert, Button, Form, Input, Typography } from 'antd';
import React, { useState } from 'react';
import { useAuthSMSTranslation } from '../locale';
export type SmsPublicOptions = {
verifier?: string;
autoSignup: boolean;
};
/**
* Extract sign-infacing options from an authenticator returned by
* `/authenticators:publicList`. That endpoint already flattens server-side
* `options.public.*` into `options.*` (see plugin-auth server
* actions/authenticators.ts `publicList`), so reading
* `authenticator.options.public.verifier` always yields `undefined` and silently
* sends `verifier: ''` to `smsOTP:publicCreate`. Centralising the unpacking
* here lets tests pin the contract.
*/
export function pickSmsPublicOptions(authenticator: Authenticator | null | undefined): SmsPublicOptions {
return {
verifier: authenticator?.options?.verifier,
autoSignup: !!authenticator?.options?.autoSignup,
};
}
/**
* SMS sign-in form rendered on the v2 `/signin` page when the user picks
* an SMS authenticator tab. Two fields — phone number + OTP code — paired
* with `<VerificationCode>` from `@nocobase/plugin-verification/client-v2`,
* which owns the "send code / countdown" button and talks to the
* `smsOTP:publicCreate` endpoint.
*
* Submission goes through `useSignIn` from `plugin-auth/client-v2`, the
* same hook the password sign-in form uses — `auth:signIn` plus the
* standard post-login redirect.
*/
export default function SmsSignInForm({ authenticator }: { authenticator: Authenticator }) {
const { t } = useAuthSMSTranslation();
const [form] = Form.useForm();
const [errorMessage, setErrorMessage] = useState('');
const [loading, setLoading] = useState(false);
const signIn = useSignIn(authenticator.name);
const { autoSignup, verifier } = pickSmsPublicOptions(authenticator);
const phone = Form.useWatch('uuid', form);
return (
<Form
form={form}
layout="vertical"
onFinish={async (values) => {
setErrorMessage('');
setLoading(true);
try {
await signIn.run(values);
} catch (error: any) {
setErrorMessage(error?.response?.data?.errors?.[0]?.message || error?.message || String(error));
} finally {
setLoading(false);
}
}}
>
{errorMessage ? <Alert style={{ marginBottom: 16 }} type="error" showIcon message={errorMessage} /> : null}
<Form.Item name="uuid" rules={[{ required: true, message: t('Please enter a phone number') }]}>
<Input autoComplete="tel" placeholder={t('Phone')} />
</Form.Item>
<Form.Item name="code" rules={[{ required: true, message: t('Please enter the verification code') }]}>
<VerificationCode
actionType="auth:signIn"
verifier={verifier ?? ''}
phone={phone}
placeholder={t('Verification code')}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button loading={loading} htmlType="submit" type="primary" block>
{t('Sign in')}
</Button>
</Form.Item>
{autoSignup ? (
<Typography.Text type="secondary">{t('User will be registered automatically if not exists.')}</Typography.Text>
) : null}
</Form>
);
}
@@ -0,0 +1,10 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export { default, PluginAuthSMSClientV2 } from './plugin';
@@ -0,0 +1,27 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useFlowEngine } from '@nocobase/flow-engine';
import { useTranslation } from 'react-i18next';
export const NAMESPACE = 'auth-sms';
export function useAuthSMSTranslation() {
return useTranslation([NAMESPACE, 'client'], { nsMode: 'fallback' });
}
/**
* v2-style translator. Routes through `flowEngine.context.t`, which natively
* expands legacy `{{t("…")}}` Schema templates and falls back to the `client`
* namespace if a key is not defined in `auth-sms`.
*/
export function useT() {
const engine = useFlowEngine();
return (key: string) => engine.context.t(key, { ns: [NAMESPACE, 'client'], nsMode: 'fallback' });
}
@@ -0,0 +1,24 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Plugin } from '@nocobase/client-v2';
import PluginAuthClientV2 from '@nocobase/plugin-auth/client-v2';
import { authType } from '../constants';
export class PluginAuthSMSClientV2 extends Plugin {
async load() {
const auth = this.app.pm.get(PluginAuthClientV2);
auth.registerType(authType, {
signInFormLoader: () => import('./forms/SmsSignInForm'),
adminSettingsFormLoader: () => import('./forms/SmsAdminSettings'),
});
}
}
export default PluginAuthSMSClientV2;
@@ -10,9 +10,14 @@
import { renderHook } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { useRedirect } from '../hooks';
import { useRedirect, useSignIn } from '../hooks';
const navigateMock = vi.fn();
const mockState = vi.hoisted(() => ({
basename: undefined as string | undefined,
signIn: vi.fn().mockResolvedValue(undefined) as ReturnType<typeof vi.fn>,
request: vi.fn().mockResolvedValue({ data: {} }) as ReturnType<typeof vi.fn>,
}));
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
@@ -22,19 +27,20 @@ vi.mock('react-router-dom', async () => {
};
});
describe('plugin-auth client-v2 useRedirect', () => {
const originalLocation = globalThis.window.location;
vi.mock('@nocobase/client-v2', () => ({
useApp: () => ({
router: { getBasename: () => mockState.basename },
apiClient: {
auth: { signIn: (...args: unknown[]) => mockState.signIn(...args) },
request: (...args: unknown[]) => mockState.request(...args),
},
}),
}));
describe('plugin-auth client-v2 useRedirect', () => {
beforeEach(() => {
navigateMock.mockReset();
});
afterEach(() => {
Object.defineProperty(globalThis.window, 'location', {
configurable: true,
value: originalLocation,
});
vi.restoreAllMocks();
mockState.basename = undefined;
});
function wrap(initialEntries: string[]) {
@@ -44,34 +50,102 @@ describe('plugin-auth client-v2 useRedirect', () => {
}
it('should navigate to default next when no redirect param is present', () => {
const replace = vi.fn();
Object.defineProperty(globalThis.window, 'location', {
configurable: true,
value: { ...originalLocation, replace },
});
mockState.basename = '/nocobase/v2';
const { result } = renderHook(() => useRedirect('/admin'), {
wrapper: wrap(['/signin']),
});
result.current();
expect(navigateMock).toHaveBeenCalledWith('/admin', { replace: true });
expect(replace).not.toHaveBeenCalled();
});
it('should hard-redirect with window.location.replace when redirect param is set', () => {
const replace = vi.fn();
Object.defineProperty(globalThis.window, 'location', {
configurable: true,
value: { ...originalLocation, replace },
});
it('should strip v2 basename from redirect param and navigate relative', () => {
// Aligns with v1: an in-app redirect target uses react-router navigate (virtual),
// not window.location.replace. The basename prefix carried on the redirect query
// (so the server can echo a root-relative path) is stripped before handing off
// to react-router, which will prepend it again on its own.
mockState.basename = '/nocobase/v2';
const { result } = renderHook(() => useRedirect('/admin'), {
wrapper: wrap(['/signin?redirect=%2Fnocobase%2Fv2%2Fadmin%2Fxyz']),
});
result.current();
expect(replace).toHaveBeenCalledWith('/nocobase/v2/admin/xyz');
expect(navigateMock).not.toHaveBeenCalled();
expect(navigateMock).toHaveBeenCalledWith('/admin/xyz', { replace: true });
});
it('should pass redirect param through as-is when not prefixed with basename', () => {
// Server's 2FA middleware returns a bare path like `/admin` in its redirect
// template; react-router navigate prepends the basename automatically.
mockState.basename = '/nocobase/v2';
const { result } = renderHook(() => useRedirect('/fallback'), {
wrapper: wrap(['/signin?redirect=%2Fadmin']),
});
result.current();
expect(navigateMock).toHaveBeenCalledWith('/admin', { replace: true });
});
it('should treat exact basename match as root', () => {
mockState.basename = '/nocobase/v2';
const { result } = renderHook(() => useRedirect('/fallback'), {
wrapper: wrap(['/signin?redirect=%2Fnocobase%2Fv2']),
});
result.current();
expect(navigateMock).toHaveBeenCalledWith('/', { replace: true });
});
it('should accept relative target when basename is unset', () => {
mockState.basename = undefined;
const { result } = renderHook(() => useRedirect('/admin'), {
wrapper: wrap(['/signin']),
});
result.current();
expect(navigateMock).toHaveBeenCalledWith('/admin', { replace: true });
});
});
describe('plugin-auth client-v2 useSignIn', () => {
beforeEach(() => {
mockState.basename = '/nocobase/v2';
mockState.signIn = vi.fn().mockResolvedValue(undefined);
mockState.request = vi.fn().mockResolvedValue({ data: {} });
navigateMock.mockReset();
});
it('should call signIn then yield to a /auth:check request before redirecting', async () => {
// Mirrors v1's `await refreshAsync()` after sign-in: the actual user data isn't
// consumed, but awaiting a real network round-trip gives the browser time to
// commit any queued `window.location.href` from response interceptors (e.g. the
// 2FA plugin's `code:302` handler). Without it, the synchronous `redirect()`
// virtual navigate would let the wrong page flash before the full reload.
const { result } = renderHook(() => useSignIn('basic'), {
wrapper: ({ children }) => <MemoryRouter initialEntries={['/signin']}>{children}</MemoryRouter>,
});
await result.current.run({ account: 'admin', password: 'admin' });
expect(mockState.signIn).toHaveBeenCalledWith({ account: 'admin', password: 'admin' }, 'basic');
expect(mockState.request).toHaveBeenCalledWith(
expect.objectContaining({ url: '/auth:check', skipAuth: true, skipNotify: true }),
);
// Order matters: signIn → request → redirect/navigate.
expect(mockState.signIn.mock.invocationCallOrder[0]).toBeLessThan(mockState.request.mock.invocationCallOrder[0]);
expect(mockState.request.mock.invocationCallOrder[0]).toBeLessThan(navigateMock.mock.invocationCallOrder[0]);
});
it('should swallow /auth:check rejection so redirect still runs', async () => {
// /auth:check may legitimately reject (e.g. server returns 401 mid-2FA flow).
// The redirect must still proceed — the catch keeps the chain alive.
mockState.request = vi.fn().mockRejectedValue(new Error('boom'));
const { result } = renderHook(() => useSignIn('basic'), {
wrapper: ({ children }) => <MemoryRouter initialEntries={['/signin']}>{children}</MemoryRouter>,
});
await expect(result.current.run({ account: 'admin', password: 'admin' })).resolves.toBeUndefined();
expect(mockState.request).toHaveBeenCalled();
expect(navigateMock).toHaveBeenCalled();
});
});
@@ -21,18 +21,12 @@ describe('plugin-auth client-v2', () => {
vi.restoreAllMocks();
});
it('should redirect runtime 401 to v2 signin with replace', async () => {
const replace = vi.fn();
Object.defineProperty(globalThis.window, 'location', {
configurable: true,
value: {
...originalLocation,
pathname: '/v2/admin/7vu4c2sdk6h',
search: '?tab=overview',
hash: '#panel',
replace,
},
});
it('should navigate to v2 signin on runtime 401 with EXPIRED_SESSION', async () => {
// Aligns with v1: use react-router (data router) `navigate` rather than
// `window.location.replace`, so a `window.location.href` queued by a sibling
// response interceptor (e.g. plugin-two-factor-authentication's `code:302`
// handler) can win the race instead of being clobbered.
const navigateSpy = vi.fn();
const app = createMockClient({
publicPath: '/v2/',
@@ -42,6 +36,7 @@ describe('plugin-auth client-v2', () => {
await app.load();
app.router.router = {
basename: '/v2',
navigate: navigateSpy,
state: {
location: {
pathname: '/v2/admin/7vu4c2sdk6h',
@@ -65,23 +60,48 @@ describe('plugin-auth client-v2', () => {
app.apiClient.axios.interceptors.response.handlers[0].rejected(error);
await vi.waitFor(() => {
expect(replace).toHaveBeenCalledWith('/v2/signin?redirect=%2Fv2%2Fadmin%2F7vu4c2sdk6h%3Ftab%3Doverview%23panel');
expect(navigateSpy).toHaveBeenCalledWith(
'/signin?redirect=%2Fv2%2Fadmin%2F7vu4c2sdk6h%3Ftab%3Doverview%23panel',
{
replace: true,
},
);
});
});
it('should clear auth token on runtime 401 with EXPIRED_SESSION', async () => {
// The redirect uses navigate (no full-page reload), so the auth token must be
// wiped explicitly — otherwise downstream requests on the in-memory page would
// re-send the now-invalid token before the signin page mounts.
const navigateSpy = vi.fn();
const app = createMockClient({
publicPath: '/v2/',
plugins: [PluginAuthClientV2 as any],
router: { type: 'memory', initialEntries: ['/v2/admin/anywhere'] },
});
await app.load();
app.apiClient.auth.setToken('stale-token');
app.router.router = {
basename: '/v2',
navigate: navigateSpy,
state: { location: { pathname: '/v2/admin/anywhere', search: '', hash: '' } },
} as any;
const error = {
response: { status: 401, data: { errors: [{ code: 'EXPIRED_SESSION' }] } },
config: {},
} as any;
// @ts-ignore
app.apiClient.axios.interceptors.response.handlers[0].rejected(error);
await vi.waitFor(() => {
expect(app.apiClient.auth.token).toBe('');
});
});
it('should not redirect skipped auth routes on runtime 401', async () => {
const replace = vi.fn();
Object.defineProperty(globalThis.window, 'location', {
configurable: true,
value: {
...originalLocation,
pathname: '/v2/signin',
search: '',
hash: '',
replace,
},
});
const navigateSpy = vi.fn();
const app = createMockClient({
publicPath: '/v2/',
plugins: [PluginAuthClientV2 as any],
@@ -90,6 +110,7 @@ describe('plugin-auth client-v2', () => {
await app.load();
app.router.router = {
basename: '/v2',
navigate: navigateSpy,
state: {
location: {
pathname: '/v2/signin',
@@ -116,6 +137,6 @@ describe('plugin-auth client-v2', () => {
} catch (thrownError) {
expect(thrownError).toBe(error);
}
expect(replace).not.toHaveBeenCalled();
expect(navigateSpy).not.toHaveBeenCalled();
});
});
@@ -1,45 +0,0 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { getAppVersionHTML, useCurrentAppInfo, usePlugin } from '@nocobase/client-v2';
import { parseHTML } from '@nocobase/utils/client';
import React from 'react';
import { theme as antdTheme } from 'antd';
import { useTranslation } from 'react-i18next';
export default function PoweredByLite() {
const { token } = antdTheme.useToken();
const { i18n } = useTranslation();
const customBrandPlugin: any = usePlugin('@nocobase/plugin-custom-brand');
const appInfo = useCurrentAppInfo();
const homePage = i18n.language === 'zh-CN' ? 'https://www.nocobase.com/cn/' : 'https://www.nocobase.com';
const customBrand = customBrandPlugin?.options?.options?.brand;
if (customBrand) {
const appVersion = getAppVersionHTML(appInfo?.version);
return (
<div
className="nb-brand"
dangerouslySetInnerHTML={{
__html: parseHTML(customBrand, { appVersion }),
}}
/>
);
}
return (
<div style={{ color: token.colorTextDescription }}>
Powered by{' '}
<a href={homePage} target="_blank" rel="noreferrer" style={{ color: token.colorTextDescription }}>
NocoBase
</a>
</div>
);
}
@@ -11,19 +11,38 @@ import { useCallback, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useApp } from '@nocobase/client-v2';
/**
* 把 `?redirect=` 上带 v2 basename 的目标(例如 `/nocobase/v2/admin`)规约成 react-router
* 接受的、相对 basename 的路径(`/admin`)。如果 target 已经是相对路径(不带 basename,
* 例如服务端 2FA 中间件返回的 `/admin`),原样返回——react-router `navigate` 会自动加上
* basename。
*/
function stripV2Basename(target: string, basename?: string): string {
if (!basename || basename === '/') {
return target.startsWith('/') ? target : `/${target}`;
}
const normalized = basename.endsWith('/') ? basename.slice(0, -1) : basename;
if (target === normalized) {
return '/';
}
if (target.startsWith(`${normalized}/`)) {
return target.slice(normalized.length) || '/';
}
// target 不在 v2 basename 下,当作相对路径,交给 react-router 自动 prepend basename。
return target;
}
export function useRedirect(next = '/admin') {
const app = useApp();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
return useCallback(() => {
const redirect = searchParams.get('redirect');
if (redirect) {
// redirect 是根相对完整路径,与 v2 router basename 直接相加会重复前缀,因此整页跳转
window.location.replace(redirect);
return;
}
navigate(next, { replace: true });
}, [navigate, next, searchParams]);
const target = redirect || next;
const basename = app.router.getBasename?.();
navigate(stripV2Basename(target, basename), { replace: true });
}, [app.router, navigate, next, searchParams]);
}
export function useDocumentTitle(title: string) {
@@ -39,6 +58,18 @@ export function useSignIn(authenticator: string) {
return {
async run(values: Record<string, any>) {
await app.apiClient.auth.signIn(values, authenticator);
// v1 在 signIn 之后会 `await refreshAsync()` 触发 `/auth:check`。表面上是刷新用户信息,
// 实际更关键的副作用:借这次网络往返让出 JS 任务,给浏览器机会提交其它响应拦截器(例如
// plugin-two-factor-authentication 收到 `code:302` 时)排队的 `window.location.href`
// 整页跳转。如果不等,下面 `redirect()` 会同步 react-router `navigate`,虽然不会覆盖
// `location.href`,但会让目标页(例如 /admin)闪现一下才被整页跳转替换。复用同款手法。
await app.apiClient
.request({
url: '/auth:check',
skipAuth: true,
skipNotify: true,
})
.catch(() => undefined);
redirect();
},
};
@@ -11,10 +11,8 @@ import { theme as antdTheme } from 'antd';
import React from 'react';
import { Outlet } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useSystemSettings } from '@nocobase/client-v2';
import { PoweredBy, SwitchLanguage, useSystemSettings } from '@nocobase/client-v2';
import AuthenticatorsContextProvider from '../providers/AuthenticatorsContextProvider';
import SwitchLanguage from '../components/SwitchLanguage';
import PoweredByLite from '../components/PoweredByLite';
export default function AuthLayout() {
const { token } = antdTheme.useToken();
@@ -48,7 +46,7 @@ export default function AuthLayout() {
backgroundColor: token.colorBgContainer,
}}
>
<PoweredByLite />
<PoweredBy />
</div>
</div>
);
@@ -9,7 +9,7 @@
import { CheckOutlined, DeleteOutlined, DownOutlined, PlusOutlined } from '@ant-design/icons';
import { DEFAULT_PAGE_SIZE, DrawerFormLayout, Table } from '@nocobase/client-v2';
import { useFlowContext } from '@nocobase/flow-engine';
import { randomId, useFlowContext } from '@nocobase/flow-engine';
import { useMemoizedFn, useRequest } from 'ahooks';
import { App, Button, Card, Checkbox, Dropdown, Form, Input, Select, Space, Spin, Tag, theme } from 'antd';
import type { ColumnsType } from 'antd/es/table';
@@ -31,10 +31,6 @@ type AuthenticatorRecord = {
type AuthTypeOption = { name: string; title?: string };
function createAuthenticatorName() {
return `s_${Math.random().toString(36).slice(2, 12)}`;
}
function recursiveTrim(value: any): any {
if (typeof value === 'string') return value.trim();
if (Array.isArray(value)) return value.map(recursiveTrim);
@@ -67,7 +63,7 @@ function AuthenticatorFormView(props: {
mode: 'create' | 'edit';
authType: string;
authTypeOptions: AuthTypeOption[];
adminSettingsFormLoader?: AuthOptions['adminSettingsFormLoader'];
plugin: PluginAuthClientV2;
record?: AuthenticatorRecord;
onSubmitted: () => void;
}) {
@@ -88,7 +84,7 @@ function AuthenticatorFormView(props: {
const initialValues = useMemo(() => {
if (props.mode === 'edit') return cloneDeep(props.record || {});
return {
name: createAuthenticatorName(),
name: randomId('s_'),
authType: props.authType,
enabled: false,
options: {},
@@ -99,11 +95,31 @@ function AuthenticatorFormView(props: {
form.setFieldsValue(initialValues);
}, [form, initialValues]);
const AdminSettingsBody = useMemo(
() => (props.adminSettingsFormLoader ? lazy(props.adminSettingsFormLoader) : null),
[props.adminSettingsFormLoader],
// Watch the form's authType so the admin-settings body swaps in sync with
// the Select. Falls back to props.authType for the first render before
// the form has settled (avoids a flash of "no settings body").
const watchedAuthType = Form.useWatch('authType', form);
const currentAuthType = watchedAuthType ?? props.authType;
const currentAdminSettingsFormLoader = useMemo<AuthOptions['adminSettingsFormLoader']>(
() => props.plugin.authTypes.get(currentAuthType)?.adminSettingsFormLoader,
[props.plugin, currentAuthType],
);
const AdminSettingsBody = useMemo(
() => (currentAdminSettingsFormLoader ? lazy(currentAdminSettingsFormLoader) : null),
[currentAdminSettingsFormLoader],
);
// Switching auth type discards the previous type's `options` payload —
// each auth type owns its own option shape (e.g. SMS needs `verifier`,
// OIDC needs `clientId/clientSecret`) and leaking unrelated keys into
// the new submission would either fail server validation or persist
// ghost config. v1 had the same reset behavior via Formily's onTypeChange.
const handleAuthTypeChange = useMemoizedFn(() => {
form.setFieldValue('options', {});
});
const handleSubmit = useMemoizedFn(async () => {
const raw = await form.validateFields();
const trimmedOptions = recursiveTrim(raw.options || {});
@@ -155,7 +171,7 @@ function AuthenticatorFormView(props: {
<Input disabled={props.mode === 'edit'} />
</Form.Item>
<Form.Item name="authType" label={t('Auth Type')} rules={[{ required: true }]}>
<Select options={compiledTypeOptions} disabled />
<Select options={compiledTypeOptions} onChange={handleAuthTypeChange} />
</Form.Item>
<Form.Item name="title" label={t('Title')}>
<Input />
@@ -233,15 +249,15 @@ export default function AuthenticatorsPage() {
const authTypeOptions = useMemo<AuthTypeOption[]>(() => authTypes || [], [authTypes]);
const openForm = useMemoizedFn((mode: 'create' | 'edit', authType: string, record?: AuthenticatorRecord) => {
const adminSettingsFormLoader = plugin.authTypes.get(authType)?.adminSettingsFormLoader;
ctx.viewer.drawer({
width: '50%',
closable: true,
content: () => (
<AuthenticatorFormView
mode={mode}
authType={authType}
authTypeOptions={authTypeOptions}
adminSettingsFormLoader={adminSettingsFormLoader}
plugin={plugin}
record={record}
onSubmitted={() => refresh()}
/>
@@ -9,13 +9,7 @@
import { Registry } from '@nocobase/utils/client';
import type { ComponentType } from 'react';
import {
getCurrentV2RedirectPath,
Plugin,
redirectToV2Signin,
UserCenterSelectItemModel,
languageCodes,
} from '@nocobase/client-v2';
import { getCurrentV2RedirectPath, Plugin, UserCenterSelectItemModel, languageCodes } from '@nocobase/client-v2';
import debounce from 'lodash/debounce';
import { presetAuthType } from '../preset';
import type { Authenticator as AuthenticatorType } from './authenticator';
@@ -221,7 +215,9 @@ export class PluginAuthClientV2 extends Plugin {
const redirectPath = getCurrentV2RedirectPath(this.app, locationLike);
debouncedRedirect(() => {
this.app.apiClient.auth.setToken('');
redirectToV2Signin(this.app, redirectPath, { replace: true });
// 用 react-router navigate (虚拟跳转)而不是 location.replace, 避免覆盖同时段其它
// 响应拦截器触发的 window.location.href 整页跳转 (例如 2FA 接收到服务端 302 时)。
this.app.router.navigate(`/signin?redirect=${encodeURIComponent(redirectPath)}`, { replace: true });
});
return new Promise<never>(() => undefined);
}
@@ -10,7 +10,7 @@
import { CheckOutlined, DeleteOutlined, DownOutlined, PlusOutlined } from '@ant-design/icons';
import { css } from '@emotion/css';
import { DEFAULT_PAGE_SIZE, DrawerFormLayout, Table } from '@nocobase/client-v2';
import { useFlowContext } from '@nocobase/flow-engine';
import { randomId, useFlowContext } from '@nocobase/flow-engine';
import { useMemoizedFn, useRequest } from 'ahooks';
import { App, Button, Card, Dropdown, Form, Input, Space, Spin, theme } from 'antd';
import type { ColumnsType } from 'antd/es/table';
@@ -47,10 +47,6 @@ function useStorageFormClassName() {
);
}
function createStorageName() {
return `s_${Math.random().toString(36).slice(2, 12)}`;
}
function getInitialValues(options: { mode: 'create' | 'edit'; storageType: StorageType; record?: StorageRecord }) {
if (options.mode === 'edit') {
return cloneDeep(options.record || {});
@@ -58,7 +54,7 @@ function getInitialValues(options: { mode: 'create' | 'edit'; storageType: Stora
return {
...cloneDeep(options.storageType.defaultValues || {}),
type: options.storageType.name,
name: createStorageName(),
name: randomId('s_'),
};
}
@@ -179,6 +175,7 @@ export default function FileStoragePage() {
(mode: 'create' | 'edit', storageType: StorageType, record?: StorageRecord) => {
ctx.viewer.drawer({
width: '50%',
closable: true,
content: () => (
<StorageFormView mode={mode} storageType={storageType} record={record} onSubmitted={() => refresh()} />
),
@@ -16,6 +16,7 @@ import {
UserCenterTextItemModel,
} from '@nocobase/client-v2';
import { usersLocaleResources } from './locale';
import { ChangePasswordItemModel } from './user-center/ChangePasswordItemModel';
class CurrentUserSummaryItemModel extends UserCenterTextItemModel {
static itemId = 'current-user-summary';
@@ -61,6 +62,7 @@ export class PluginUsersClientV2 extends Plugin {
});
this.app.flowEngine.registerModels({
ChangePasswordItemModel,
CurrentUserSummaryItemModel,
SignOutItemModel,
});
@@ -0,0 +1,134 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { DrawerFormLayout, PasswordInput, UserCenterActionItemModel } from '@nocobase/client-v2';
import { useFlowContext } from '@nocobase/flow-engine';
import { useMemoizedFn } from 'ahooks';
import { Alert, Form } from 'antd';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useUsersTranslation } from '../locale';
/**
* Drawer body for "Change password". Three antd `Form.Item`s, with
* `confirmPassword` cross-validating against `newPassword`. On success,
* the drawer closes and the user is redirected to `/signin` so they
* sign back in with the new credentials.
*
* Server-side validation (`auth:changePassword`) catches the heavier
* checks — mismatch / wrong old password / disabled by system setting —
* and the error message is surfaced inline via an antd `<Alert>`.
*/
function ChangePasswordDrawerContent() {
const { t } = useUsersTranslation();
const ctx = useFlowContext();
const navigate = useNavigate();
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const handleSubmit = useMemoizedFn(async () => {
const values = await form.validateFields();
setErrorMessage('');
setSubmitting(true);
try {
await ctx.api.resource('auth').changePassword({ values });
form.resetFields();
navigate('/signin');
} catch (error: any) {
const message = error?.response?.data?.errors?.[0]?.message || error?.message || String(error);
setErrorMessage(message);
throw error;
} finally {
setSubmitting(false);
}
});
return (
<DrawerFormLayout
title={t('Change password')}
onSubmit={handleSubmit}
submitting={submitting}
submitText={t('Submit')}
cancelText={t('Cancel')}
>
<Form form={form} layout="vertical">
{errorMessage ? <Alert type="error" showIcon message={errorMessage} style={{ marginBottom: 16 }} /> : null}
<Form.Item
name="oldPassword"
label={t('Old password')}
rules={[{ required: true, message: t('Please enter the old password') }]}
>
<PasswordInput autoComplete="current-password" />
</Form.Item>
<Form.Item
name="newPassword"
label={t('New password')}
rules={[{ required: true, message: t('Please enter the new password') }]}
>
<PasswordInput autoComplete="new-password" checkStrength />
</Form.Item>
<Form.Item
name="confirmPassword"
label={t('Confirm password')}
dependencies={['newPassword']}
rules={[
{ required: true, message: t('Please confirm the new password') },
// antd's validator with closure access to other field values —
// mirrors v1's `x-reactions` based cross-field comparison.
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('newPassword') === value) {
return Promise.resolve();
}
return Promise.reject(new Error(t('Password mismatch')));
},
}),
]}
>
<PasswordInput autoComplete="new-password" checkStrength />
</Form.Item>
</Form>
</DrawerFormLayout>
);
}
/**
* "Change password" entry in the User Center dropdown. Section `profile`
* with sort 100 puts it between `CurrentUserSummaryItemModel` (sort 0)
* and `SignOutItemModel` (sort 1000) — same neighborhood as v1.
*
* `prepare()` reads `systemSettings.enableChangePassword`; when the
* admin has explicitly disabled it, `ready = false` removes the entry
* from the dropdown. Undefined / true both leave the entry visible
* (matches v1: only an explicit `=== false` hides it).
*/
export class ChangePasswordItemModel extends UserCenterActionItemModel {
static itemId = 'change-password';
section = 'profile' as const;
sort = 100;
label = 'Change password';
async prepare() {
const systemSettings = await this.context.systemSettings.load();
const enableChangePassword = systemSettings?.data?.enableChangePassword;
this.ready = enableChangePassword !== false;
}
async onClick() {
this.context.viewer.drawer({
width: '50%',
closable: true,
content: () => <ChangePasswordDrawerContent />,
});
}
}
export default ChangePasswordItemModel;
@@ -0,0 +1,2 @@
export * from './dist/client-v2';
export { default } from './dist/client-v2';
@@ -0,0 +1 @@
module.exports = require('./dist/client-v2/index.js');
@@ -28,7 +28,9 @@
"peerDependencies": {
"@nocobase/actions": "2.x",
"@nocobase/client": "2.x",
"@nocobase/client-v2": "2.x",
"@nocobase/database": "2.x",
"@nocobase/flow-engine": "2.x",
"@nocobase/resourcer": "2.x",
"@nocobase/server": "2.x",
"@nocobase/test": "2.x",
@@ -0,0 +1,188 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { describe, expect, it, vi } from 'vitest';
import {
recursiveTrim,
submitVerifierForm,
type VerifierFormValues,
type VerifierResource,
} from '../pages/VerifiersPage';
function makeResource(overrides: Partial<VerifierResource> = {}): VerifierResource {
return {
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
function readUpdateArgs(resource: VerifierResource): { filterByTk: string; values: VerifierFormValues } {
const updateMock = resource.update as ReturnType<typeof vi.fn>;
return updateMock.mock.calls[0]?.[0] as { filterByTk: string; values: VerifierFormValues };
}
describe('plugin-verification VerifiersPage submit pipeline', () => {
it('should fire resource.create with trimmed options in create mode', async () => {
const resource = makeResource();
const onSubmitted = vi.fn();
await submitVerifierForm({
raw: {
name: 'v_new',
title: 'New verifier',
verificationType: 'sms-otp',
options: { provider: 'sms-aliyun', settings: { sign: ' foo ', endpoint: 'x' } },
},
mode: 'create',
resource,
onSubmitted,
});
expect(resource.create).toHaveBeenCalledTimes(1);
expect(resource.create).toHaveBeenCalledWith({
values: {
name: 'v_new',
title: 'New verifier',
verificationType: 'sms-otp',
// Whitespace around literal credentials is stripped to match v1; env-var
// references (e.g. `{{ $env.X }}`) survive untouched because they are
// matched verbatim by `recursiveTrim`.
options: { provider: 'sms-aliyun', settings: { sign: 'foo', endpoint: 'x' } },
},
});
expect(resource.update).not.toHaveBeenCalled();
expect(onSubmitted).toHaveBeenCalledTimes(1);
});
it('should fire resource.update with record.name as filterByTk in edit mode', async () => {
// Pin the filterByTk value so a regression to `record.id` (which is
// undefined for `verifiers` because the collection is `autoGenId: false`
// with `name` as PK) would fail loud here instead of silently no-op'ing.
const resource = makeResource();
const onSubmitted = vi.fn();
await submitVerifierForm({
raw: {
name: 'v_abc',
title: 'Edited',
verificationType: 'sms-otp',
options: {
provider: 'sms-aliyun',
settings: { sign: 'NewSign', endpoint: 'dysmsapi.aliyuncs.com' },
},
},
mode: 'edit',
record: {
name: 'v_abc',
title: 'Old',
verificationType: 'sms-otp',
// `extraInternalKey` lives at the top of `options` and is not part of
// any admin-settings form's declared paths — must survive the round
// trip via the shallow merge on `options`.
options: {
provider: 'sms-aliyun',
settings: { sign: 'OldSign', endpoint: 'old' },
extraInternalKey: 'keep-me',
},
},
resource,
onSubmitted,
});
expect(resource.update).toHaveBeenCalledTimes(1);
const updateArgs = readUpdateArgs(resource);
expect(updateArgs.filterByTk).toBe('v_abc');
const updatedOptions = updateArgs.values.options as Record<string, unknown>;
const updatedSettings = updatedOptions.settings as Record<string, unknown>;
// Top-level options keys not owned by the form are preserved.
expect(updatedOptions.extraInternalKey).toBe('keep-me');
// Fields the form owns are overwritten.
expect(updatedSettings.sign).toBe('NewSign');
expect(updatedSettings.endpoint).toBe('dysmsapi.aliyuncs.com');
expect(resource.create).not.toHaveBeenCalled();
expect(onSubmitted).toHaveBeenCalledTimes(1);
});
it('should throw rather than silently skip when edit mode has no record.name', async () => {
// Guards against the silent-fallthrough class of bug. Previously the
// submit handler had `else if (record?.id != null)` and would just
// `return` past the API call when record had no `id`, calling
// `onSubmitted()` anyway — the page LOOKED successful while no update
// request had fired.
const resource = makeResource();
const onSubmitted = vi.fn();
await expect(
submitVerifierForm({
raw: { title: 'Edited' },
mode: 'edit',
record: { title: 'Old' }, // no `name` field at all
resource,
onSubmitted,
}),
).rejects.toThrow(/Edit mode requires record\.name/);
expect(resource.create).not.toHaveBeenCalled();
expect(resource.update).not.toHaveBeenCalled();
expect(onSubmitted).not.toHaveBeenCalled();
});
it('should not invoke onSubmitted when resource.create rejects', async () => {
// Failure path: a failed API call must bubble up so the drawer stays open
// and the user can retry, instead of closing on a falsely-successful state.
const resource = makeResource({ create: vi.fn().mockRejectedValue(new Error('boom')) });
const onSubmitted = vi.fn();
await expect(
submitVerifierForm({
raw: { name: 'v', verificationType: 'sms-otp', options: {} },
mode: 'create',
resource,
onSubmitted,
}),
).rejects.toThrow(/boom/);
expect(onSubmitted).not.toHaveBeenCalled();
});
it('should not invoke onSubmitted when resource.update rejects', async () => {
const resource = makeResource({ update: vi.fn().mockRejectedValue(new Error('nope')) });
const onSubmitted = vi.fn();
await expect(
submitVerifierForm({
raw: { title: 'Edited' },
mode: 'edit',
record: { name: 'v_abc' },
resource,
onSubmitted,
}),
).rejects.toThrow(/nope/);
expect(onSubmitted).not.toHaveBeenCalled();
});
});
describe('plugin-verification recursiveTrim', () => {
it('trims string leaves', () => {
expect(recursiveTrim(' hello ')).toBe('hello');
});
it('walks objects and arrays', () => {
expect(recursiveTrim({ a: ' x ', b: [' y ', { c: ' z ' }] })).toEqual({
a: 'x',
b: ['y', { c: 'z' }],
});
});
it('leaves non-string primitives intact', () => {
expect(recursiveTrim(42)).toBe(42);
expect(recursiveTrim(true)).toBe(true);
expect(recursiveTrim(null)).toBe(null);
});
});
@@ -0,0 +1,130 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useFlowContext } from '@nocobase/flow-engine';
import { useMemoizedFn } from 'ahooks';
import { App, Button, Input, Space } from 'antd';
import React, { useEffect, useRef, useState } from 'react';
import { useVerificationTranslation } from '../locale';
export interface VerificationCodeProps {
/** Persisted OTP value. Controlled by the parent `Form.Item`. */
value?: string;
onChange?: (next: string) => void;
/**
* Server-side action the OTP grants (e.g. `auth:signIn`,
* `verifiers:bind`, `twoFactorAuth:verify`). Forwarded to the
* `smsOTP:*` endpoint so the OTP is bound to a specific action.
*/
actionType: string;
/** Verifier name (a row in the `verifiers` collection). */
verifier: string;
/**
* The phone number the OTP is sent to. Parent reads it via
* `Form.useWatch('uuid', form)` and forwards it here. Required at send
* time — the send button is disabled until it has a value.
*/
phone?: string;
/**
* Whether the user is already signed in. Drives the choice between
* `smsOTP:create` (logged-in) and `smsOTP:publicCreate` (anonymous).
* Defaults to `false` (anonymous).
*/
isLogged?: boolean;
disabled?: boolean;
placeholder?: string;
}
/**
* Verification-code input + "Send code / Retry in N s" button pair.
*
* Rewrite of v1's `VerificationCode`:
* - No `withDynamicSchemaProps`, no `useForm()` — the parent owns the
* phone value via `Form.useWatch` and passes it in as a prop.
* - Countdown starts from the server-reported `expiresAt` so the UI
* tracks the same window the server enforces.
* - Clearing the code on resend matches v1 behaviour so the user has
* to type the new code rather than reusing the stale one.
*/
export function VerificationCode(props: VerificationCodeProps) {
const { value, onChange, actionType, verifier, phone, isLogged, disabled, placeholder } = props;
const { t } = useVerificationTranslation();
const ctx = useFlowContext();
const { message, notification } = App.useApp();
const [count, setCount] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (count <= 0 && timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, [count]);
useEffect(() => {
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, []);
const onGetCode = useMemoizedFn(async () => {
if (count > 0) return;
if (!phone) {
message.error(t('Please enter a phone number first'));
return;
}
const method = isLogged ? 'create' : 'publicCreate';
try {
const response = await ctx.api.resource('smsOTP')[method]({
values: {
action: actionType,
verifier,
uuid: phone,
},
});
const data = response?.data?.data || {};
message.success(t('Operation succeeded'));
if (value) onChange?.('');
const expiresIn = data.expiresAt ? Math.max(1, Math.ceil((Date.parse(data.expiresAt) - Date.now()) / 1000)) : 60;
setCount(expiresIn);
timerRef.current = setInterval(() => {
setCount((c) => c - 1);
}, 1000);
} catch (error: any) {
// v1 surfaces SMS send failures through a top-right notification —
// the underlying provider (Aliyun / Tencent) commonly fails with a
// misconfigured sign / template / endpoint, and a silent console
// swallow leaves the user clicking "Send code" with no feedback.
const serverMessage = error?.response?.data?.errors?.[0]?.message || error?.message;
notification.error({
message: serverMessage || t('Verification send failed, please try later or contact to administrator'),
});
}
});
return (
<Space.Compact style={{ width: '100%' }}>
<Input
value={value}
onChange={(event) => onChange?.(event.target.value)}
disabled={disabled}
placeholder={placeholder}
/>
<Button onClick={onGetCode} disabled={disabled || count > 0}>
{count > 0 ? t('Retry after {{count}} seconds', { count }) : t('Send code')}
</Button>
</Space.Compact>
);
}
export default VerificationCode;
@@ -0,0 +1,102 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { RemoteSelect } from '@nocobase/client-v2';
import { useFlowContext } from '@nocobase/flow-engine';
import { Typography } from 'antd';
import React, { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useT, useVerificationTranslation } from '../locale';
export interface VerifierSelectProps {
/**
* Verification scene name passed to `verifiers:listByScene`. Each
* consumer plugin uses its own scene (e.g. `auth-sms`, `two-factor`,
* `unbind-verifier`) so different scenes can opt different verifier
* types in or out via server config.
*/
scene: string;
value?: string | string[];
onChange?: (next: string | string[]) => void;
/** Default false. */
multiple?: boolean;
placeholder?: string;
disabled?: boolean;
/**
* antd `Select` style passthrough. Keep this open in case a caller
* needs to size the dropdown explicitly inside a constrained form.
*/
style?: React.CSSProperties;
/**
* Suppress the helper text rendered under the Select. Defaults to
* `false`; pass `true` when the caller wants to host its own hint
* (e.g. inside a `Form.Item.extra` slot).
*/
hideHint?: boolean;
}
type VerifierItem = { name: string; title: string };
type VerifiersListResponse = {
verifiers?: VerifierItem[];
availableTypes?: Array<{ name: string; title?: string }>;
};
/**
* Domain wrapper around the framework-level `RemoteSelect`. Loads the
* verifiers configured for `scene` from `/verifiers:listByScene` and
* binds them to a single- or multi-select. The helper text underneath
* mirrors the v1 affordance: a list of available verifier types and a
* deep link into the admin settings page. The list of available types
* comes from the same response, so no extra request is needed.
*/
export function VerifierSelect(props: VerifierSelectProps) {
const { scene, value, onChange, multiple, placeholder, disabled, style, hideHint } = props;
const { t } = useVerificationTranslation();
const compileT = useT();
const ctx = useFlowContext();
const cacheKey = useMemo(() => `@nocobase/plugin-verification:verifiers:listByScene:${scene}`, [scene]);
const [availableTypeNames, setAvailableTypeNames] = useState<string[]>([]);
return (
<>
<RemoteSelect<VerifierItem, VerifiersListResponse, string | string[]>
request={async () => {
const response = await ctx.api.resource('verifiers').listByScene({ scene });
return (response?.data?.data ?? {}) as VerifiersListResponse;
}}
selectItems={(resp) => resp?.verifiers || []}
onLoaded={(_, resp) => {
setAvailableTypeNames((resp?.availableTypes || []).map((item) => compileT(item.title || item.name)));
}}
cacheKey={cacheKey}
refreshDeps={[scene]}
// Server titles can arrive as legacy `{{t("…")}}` Schema templates;
// useT() compiles them via flowEngine.context.t.
mapOptions={(item) => ({ label: compileT(item.title || item.name), value: item.name })}
mode={multiple ? 'multiple' : undefined}
value={value as any}
onChange={onChange as any}
placeholder={placeholder}
disabled={disabled}
style={style}
/>
{hideHint ? null : (
<Typography.Text type="secondary">
{t('The following types of verifiers are available:')} {availableTypeNames.join(', ')}
{'. '}
{t('Go to')} <Link to="/admin/settings/verification">{t('create verifiers')}</Link>
</Typography.Text>
)}
</>
);
}
export default VerifierSelect;
@@ -0,0 +1,20 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export { PROVIDER_TYPE_SMS_ALIYUN, PROVIDER_TYPE_SMS_TENCENT, SMS_OTP_VERIFICATION_TYPE } from '../constants';
export { default, PluginVerificationClientV2 } from './plugin';
export { NAMESPACE, useT, useVerificationTranslation } from './locale';
export { VerificationManager } from './verification-manager';
export type { BindFormProps, VerificationFormProps, VerificationTypeOptions } from './verification-manager';
export { SMSOTPProviderManager } from './otp-sms-provider-manager';
export type { SMSOTPProviderOptions } from './otp-sms-provider-manager';
export { VerifierSelect } from './components/VerifierSelect';
export type { VerifierSelectProps } from './components/VerifierSelect';
export { VerificationCode } from './components/VerificationCode';
export type { VerificationCodeProps } from './components/VerificationCode';
@@ -0,0 +1,35 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useFlowEngine } from '@nocobase/flow-engine';
import { useTranslation } from 'react-i18next';
import enUS from '../locale/en-US.json';
import zhCN from '../locale/zh-CN.json';
export const NAMESPACE = 'verification';
export const verificationLocaleResources = {
'en-US': enUS,
'zh-CN': zhCN,
};
export function useVerificationTranslation() {
return useTranslation([NAMESPACE, 'client'], { nsMode: 'fallback' });
}
/**
* v2-style translator. Routes through `flowEngine.context.t`, which natively
* expands legacy Formily Schema templates (e.g. `{{t("Phone")}}`) — useful
* when the value comes from a server payload that still contains the
* `{{t("…")}}` wrapper.
*/
export function useT() {
const engine = useFlowEngine();
return (key: string) => engine.context.t(key, { ns: [NAMESPACE, 'client'], nsMode: 'fallback' });
}
@@ -0,0 +1,43 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Registry } from '@nocobase/utils/client';
import type { ComponentType } from 'react';
type LoaderOf<P = Record<string, never>> = () => Promise<{ default: ComponentType<P> }>;
export type SMSOTPProviderOptions = {
components: {
/**
* Loader for the provider-specific admin-settings form. Stored as an
* async `() => import(...)` so third-party SMS providers contributed
* via `registerProvider()` come in as their own webpack chunk,
* fetched only when the corresponding provider is picked in the
* verifier configuration drawer.
*/
AdminSettingsFormLoader: LoaderOf;
};
};
/**
* Registry of SMS OTP providers (Aliyun, Tencent, …). Each entry only
* needs to contribute its admin settings form — the runtime side is
* handled server-side.
*/
export class SMSOTPProviderManager {
providers = new Registry<SMSOTPProviderOptions>();
registerProvider(type: string, options: SMSOTPProviderOptions) {
this.providers.register(type, options);
}
getProvider(type: string) {
return this.providers.get(type);
}
}
@@ -0,0 +1,73 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { RemoteSelect, useApp } from '@nocobase/client-v2';
import { useFlowContext } from '@nocobase/flow-engine';
import { Form } from 'antd';
import React, { lazy, Suspense, useMemo } from 'react';
import { useT, useVerificationTranslation } from '../../locale';
import PluginVerificationClientV2 from '../../plugin';
/**
* Admin settings form for an SMS-OTP verifier:
* 1. `options.provider` selects which configured SMS provider (Aliyun /
* Tencent / …) sends the OTP. The list is the server resource
* `smsOTPProviders:list`.
* 2. `options.settings.*` is the provider-specific configuration form,
* looked up from the plugin's `smsOTPProviderManager` at runtime so
* third-party providers contributed via `registerProvider()` slot in
* automatically.
*/
export function AdminSettingsForm() {
const { t } = useVerificationTranslation();
const compileT = useT();
const ctx = useFlowContext();
const app = useApp();
// Avoid a hard import of the plugin class to keep the SMS module
// tree-shake friendly; resolve it at runtime via the plugin registry.
const plugin = app.pm.get(PluginVerificationClientV2);
const form = Form.useFormInstance();
const providerType: string | undefined = Form.useWatch(['options', 'provider'], form);
const ProviderSettings = useMemo(() => {
if (!providerType) return null;
const loader = plugin?.smsOTPProviderManager.getProvider(providerType)?.components?.AdminSettingsFormLoader;
return loader ? lazy(loader) : null;
}, [plugin, providerType]);
return (
<>
<Form.Item
name={['options', 'provider']}
label={t('Provider')}
rules={[{ required: true, message: t('Please select a provider') }]}
>
<RemoteSelect<{ name: string; title: string }>
request={async () => {
const response = await ctx.api.resource('smsOTPProviders').list();
const data = response?.data?.data;
return Array.isArray(data) ? data : [];
}}
cacheKey="@nocobase/plugin-verification:smsOTPProviders:list"
// Server titles are stored as legacy schema templates
// (e.g. `{{t("Aliyun SMS", {"ns":"…"})}}`); useT() routes through
// flowEngine.context.t which expands those templates natively.
mapOptions={(item) => ({ label: compileT(item.title || item.name), value: item.name })}
/>
</Form.Item>
{ProviderSettings ? (
<Suspense fallback={null}>
<ProviderSettings />
</Suspense>
) : null}
</>
);
}
export default AdminSettingsForm;
@@ -0,0 +1,44 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Form, Input } from 'antd';
import React from 'react';
import { VerificationCode } from '../../components/VerificationCode';
import { useVerificationTranslation } from '../../locale';
import type { BindFormProps } from '../../verification-manager';
/**
* SMS-OTP bind form. Same shape as `VerificationForm` minus the bound
* publicInfo path — when binding, the user is always typing in a new
* phone number. Hosted inside the parent `<Form>` so `uuid` / `code`
* land on the parent's `form.values`.
*/
export function BindForm(props: BindFormProps) {
const { verifier, actionType, isLogged } = props;
const { t } = useVerificationTranslation();
const form = Form.useFormInstance();
const phone = Form.useWatch('uuid', form);
return (
<>
<Form.Item name="uuid" label={t('Phone')} rules={[{ required: true, message: t('Please enter a phone number') }]}>
<Input />
</Form.Item>
<Form.Item
name="code"
label={t('Verification code')}
rules={[{ required: true, message: t('Please enter the verification code') }]}
>
<VerificationCode actionType={actionType} verifier={verifier} phone={phone} isLogged={isLogged} />
</Form.Item>
</>
);
}
export default BindForm;
@@ -0,0 +1,50 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Form, Input } from 'antd';
import React from 'react';
import { VerificationCode } from '../../components/VerificationCode';
import { useVerificationTranslation } from '../../locale';
import type { VerificationFormProps } from '../../verification-manager';
/**
* SMS-OTP verify form: phone number (read-only when bound) + verification
* code field paired with a "send code" button. Hosted inside a parent
* `<Form>` — antd Form.Item paths land on `uuid` (phone) and `code`,
* matching the v1 schema so server-side handlers are unchanged.
*/
export function VerificationForm(props: VerificationFormProps) {
const { verifier, actionType, boundInfo, isLogged } = props;
const { t } = useVerificationTranslation();
const form = Form.useFormInstance();
const phone = Form.useWatch('uuid', form);
const isPhoneBound = !!boundInfo?.publicInfo;
return (
<>
<Form.Item
name="uuid"
label={t('Phone')}
rules={[{ required: true, message: t('Please enter a phone number') }]}
initialValue={boundInfo?.publicInfo}
>
<Input disabled={isPhoneBound} />
</Form.Item>
<Form.Item
name="code"
label={t('Verification code')}
rules={[{ required: true, message: t('Please enter the verification code') }]}
>
<VerificationCode actionType={actionType} verifier={verifier} phone={phone} isLogged={isLogged} />
</Form.Item>
</>
);
}
export default VerificationForm;
@@ -0,0 +1,28 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export const smsOTPVerificationOptions = {
components: {
VerificationFormLoader: () => import('./VerificationForm'),
AdminSettingsFormLoader: () => import('./AdminSettingsForm'),
BindFormLoader: () => import('./BindForm'),
},
};
export const smsAliyunProviderOptions = {
components: {
AdminSettingsFormLoader: () => import('./providers/AliyunSettings'),
},
};
export const smsTencentProviderOptions = {
components: {
AdminSettingsFormLoader: () => import('./providers/TencentSettings'),
},
};
@@ -0,0 +1,65 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { EnvVariableInput } from '@nocobase/client-v2';
import { Form } from 'antd';
import React from 'react';
import { useVerificationTranslation } from '../../../locale';
/**
* Aliyun SMS provider settings. Field names match the v1 schema 1:1, so
* the persisted shape on the server is unchanged. Each value can be a
* literal credential or an `{{ $env.X }}` reference; secret fields use
* `EnvVariableInput`'s `password` mode to mask non-variable values.
*/
export function AliyunSettings() {
const { t } = useVerificationTranslation();
return (
<>
<Form.Item
name={['options', 'settings', 'accessKeyId']}
label={t('Access Key ID')}
rules={[{ required: true, message: t('Please enter the Access Key ID') }]}
>
<EnvVariableInput />
</Form.Item>
<Form.Item
name={['options', 'settings', 'accessKeySecret']}
label={t('Access Key Secret')}
rules={[{ required: true, message: t('Please enter the Access Key Secret') }]}
>
<EnvVariableInput password />
</Form.Item>
<Form.Item
name={['options', 'settings', 'endpoint']}
label={t('Endpoint')}
rules={[{ required: true, message: t('Please enter the endpoint') }]}
>
<EnvVariableInput />
</Form.Item>
<Form.Item
name={['options', 'settings', 'sign']}
label={t('Sign')}
rules={[{ required: true, message: t('Please enter the sign') }]}
>
<EnvVariableInput />
</Form.Item>
<Form.Item
name={['options', 'settings', 'template']}
label={t('Template code')}
rules={[{ required: true, message: t('Please enter the template code') }]}
>
<EnvVariableInput />
</Form.Item>
</>
);
}
export default AliyunSettings;
@@ -0,0 +1,75 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { EnvVariableInput } from '@nocobase/client-v2';
import { Form } from 'antd';
import React from 'react';
import { useVerificationTranslation } from '../../../locale';
/**
* Tencent SMS provider settings. Mirror of v1 schema; persisted payload
* keys are unchanged. Secret credentials use `EnvVariableInput`'s
* `password` mode so literal values are masked but `{{ $env.X }}`
* references stay editable through the picker.
*/
export function TencentSettings() {
const { t } = useVerificationTranslation();
return (
<>
<Form.Item
name={['options', 'settings', 'secretId']}
label={t('Secret Id')}
rules={[{ required: true, message: t('Please enter the Secret Id') }]}
>
<EnvVariableInput />
</Form.Item>
<Form.Item
name={['options', 'settings', 'secretKey']}
label={t('Secret Key')}
rules={[{ required: true, message: t('Please enter the Secret Key') }]}
>
<EnvVariableInput password />
</Form.Item>
<Form.Item
name={['options', 'settings', 'region']}
label={t('Region')}
rules={[{ required: true, message: t('Please enter the region') }]}
>
<EnvVariableInput />
</Form.Item>
<Form.Item
name={['options', 'settings', 'endpoint']}
label={t('Endpoint')}
initialValue="sms.tencentcloudapi.com"
>
<EnvVariableInput />
</Form.Item>
<Form.Item name={['options', 'settings', 'SignName']} label={t('Sign name')}>
<EnvVariableInput />
</Form.Item>
<Form.Item
name={['options', 'settings', 'SmsSdkAppId']}
label={t('Sms sdk app id')}
rules={[{ required: true, message: t('Please enter the Sms sdk app id') }]}
>
<EnvVariableInput />
</Form.Item>
<Form.Item
name={['options', 'settings', 'TemplateId']}
label={t('Template Id')}
rules={[{ required: true, message: t('Please enter the Template Id') }]}
>
<EnvVariableInput />
</Form.Item>
</>
);
}
export default TencentSettings;
@@ -0,0 +1,415 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { DeleteOutlined, DownOutlined, PlusOutlined } from '@ant-design/icons';
import { DrawerFormLayout, Table, useApp } from '@nocobase/client-v2';
import { randomId, useFlowContext } from '@nocobase/flow-engine';
import { useMemoizedFn, useRequest } from 'ahooks';
import { App, Button, Card, Dropdown, Form, Input, Space, Tag, theme } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { cloneDeep } from 'lodash';
import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import { useT, useVerificationTranslation } from '../locale';
import PluginVerificationClientV2 from '../plugin';
export type VerifierOptions = Record<string, unknown>;
export type VerifierRecord = {
name?: string;
title?: string | null;
description?: string | null;
verificationType?: string;
options?: VerifierOptions;
};
export type VerifierFormValues = {
name?: string;
title?: string | null;
description?: string | null;
verificationType?: string;
options?: VerifierOptions;
};
type VerificationTypeOption = { name: string; title?: string };
const PAGE_SIZE = 50;
export function recursiveTrim<T>(value: T): T {
if (typeof value === 'string') return value.trim() as T;
if (Array.isArray(value)) {
return value.map((item) => recursiveTrim(item)) as unknown as T;
}
if (value && typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>).map(
([key, v]) => [key, recursiveTrim(v)] as const,
);
return Object.fromEntries(entries) as T;
}
return value;
}
/**
* Submit pipeline for the create/edit verifier drawer.
*
* Extracted from the React component so the request layer can be tested
* directly without spinning up the full FlowEngine + viewer stack.
*
* The fallthrough `else` is deliberate — it converts what used to be a
* silent no-op (when `record.name` was undefined because we read the
* wrong primary key) into a loud error. The `verifiers` collection uses
* `name` as its primary key (`autoGenId: false`), so `filterByTk` must
* be the name string.
*/
export type VerifierResource = {
create(params: { values: VerifierFormValues }): Promise<unknown>;
update(params: { filterByTk: string; values: VerifierFormValues }): Promise<unknown>;
};
/**
* Wider shape consumed by the page (list + destroy + listTypes). Kept
* separate from `VerifierResource` so the test surface for `submitVerifierForm`
* stays minimal — tests only mock `create`/`update`.
*/
type VerifiersResource = VerifierResource & {
list(params?: Record<string, unknown>): Promise<{ data?: { data?: VerifierRecord[]; meta?: ListMeta } }>;
// `filterByTk` accepts a single PK or a bulk-delete batch. Use React.Key
// here so the antd Table's `selectedRowKeys` (Key[] = (string|number)[])
// assigns without a cast — server-side it is still the verifier's `name`.
destroy(params: { filterByTk: React.Key | React.Key[] }): Promise<unknown>;
listTypes(): Promise<{ data?: { data?: VerificationTypeOption[] } | VerificationTypeOption[] }>;
};
export async function submitVerifierForm(args: {
raw: VerifierFormValues;
mode: 'create' | 'edit';
record?: VerifierRecord;
resource: VerifierResource;
onSubmitted: () => void;
}): Promise<void> {
const trimmedOptions = recursiveTrim(args.raw.options || {});
if (args.mode === 'create') {
await args.resource.create({ values: { ...args.raw, options: trimmedOptions } });
args.onSubmitted();
return;
}
if (args.record?.name != null) {
// antd Form.validateFields only returns DECLARED paths, so any options
// sub-keys the current admin-settings form doesn't render would silently
// disappear on update. Merge the original record's top-level fields and
// existing `options` so unrelated keys survive — only paths this form
// owns get overwritten.
const merged: VerifierFormValues = {
...cloneDeep(args.record),
...args.raw,
options: { ...(args.record.options || {}), ...trimmedOptions },
};
await args.resource.update({ filterByTk: args.record.name, values: merged });
args.onSubmitted();
return;
}
throw new Error(`Edit mode requires record.name; got ${JSON.stringify(args.record)}`);
}
function useVerifiersResource(): VerifiersResource {
const ctx = useFlowContext();
// `IResource` from the SDK is `{ [key: string]: ResourceAction }` — every
// action is typed as possibly-undefined under `tsc -d`. The `verifiers`
// resource is known to expose create/update/list/destroy/listTypes
// server-side, so we narrow once here instead of casting at every call site.
return ctx.api.resource('verifiers') as unknown as VerifiersResource;
}
function useVerificationTypesFromServer() {
const resource = useVerifiersResource();
return useRequest(
async () => {
const response = await resource.listTypes();
// Server has shipped two response shapes over time: the modern
// `{ data: [...] }` envelope and an older bare-array body. Narrow via
// `Array.isArray` so TS can index `.data` only when the body is the
// object form.
const body = response?.data;
const list = Array.isArray(body) ? body : body?.data;
return Array.isArray(list) ? list : [];
},
{
cacheKey: '@nocobase/plugin-verification:verifiers:listTypes',
},
);
}
function VerifierFormView(props: {
mode: 'create' | 'edit';
verificationType: string;
verificationTypeOptions: VerificationTypeOption[];
record?: VerifierRecord;
onSubmitted: () => void;
}) {
const { t } = useVerificationTranslation();
const compileT = useT();
const app = useApp();
const plugin = app.pm.get(PluginVerificationClientV2);
const resource = useVerifiersResource();
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const compiledTypeOptions = useMemo(
() =>
props.verificationTypeOptions.map((option) => ({
value: option.name,
label: compileT(option.title || option.name),
})),
[props.verificationTypeOptions, compileT],
);
const initialValues = useMemo(() => {
if (props.mode === 'edit') return cloneDeep(props.record || {});
return {
name: randomId('v_'),
verificationType: props.verificationType,
options: {},
};
}, [props.mode, props.record, props.verificationType]);
useEffect(() => {
form.setFieldsValue(initialValues);
}, [form, initialValues]);
// Type-specific admin settings are pulled from the registry at render
// time as an async loader, so third-party verification types contributed
// via `registerVerificationType()` come in as their own webpack chunk.
const AdminSettingsForm = useMemo(() => {
const loader = plugin?.verificationManager.getVerification(props.verificationType)?.components
?.AdminSettingsFormLoader;
return loader ? lazy(loader) : null;
}, [plugin, props.verificationType]);
const handleSubmit = useMemoizedFn(async () => {
const raw = await form.validateFields();
setSubmitting(true);
try {
await submitVerifierForm({
raw,
mode: props.mode,
record: props.record,
resource,
onSubmitted: props.onSubmitted,
});
} finally {
setSubmitting(false);
}
});
return (
<DrawerFormLayout
title={props.mode === 'create' ? t('Add new') : t('Edit')}
onSubmit={handleSubmit}
submitting={submitting}
submitText={t('Submit')}
cancelText={t('Cancel')}
>
<Form form={form} layout="vertical" initialValues={initialValues}>
<Form.Item
name="name"
label={t('UID')}
rules={[
{ required: true, message: t('Please enter a UID') },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: t('a-z, A-Z, 0-9, _, -') },
]}
>
<Input disabled={props.mode === 'edit'} />
</Form.Item>
<Form.Item name="title" label={t('Title')}>
<Input />
</Form.Item>
<Form.Item name="description" label={t('Description')}>
<Input.TextArea />
</Form.Item>
{/* Hidden so the value is included in the payload even though the
field is not user-editable. */}
<Form.Item name="verificationType" hidden>
<Input />
</Form.Item>
{props.mode === 'edit' ? (
<Form.Item label={t('Verification type')}>
<Tag>{compiledTypeOptions.find((option) => option.value === props.verificationType)?.label}</Tag>
</Form.Item>
) : null}
{AdminSettingsForm ? (
<Suspense fallback={null}>
<AdminSettingsForm />
</Suspense>
) : null}
</Form>
</DrawerFormLayout>
);
}
type ListMeta = { count?: number; total?: number };
type ListBody = { data?: VerifierRecord[] | { data?: VerifierRecord[]; meta?: ListMeta }; meta?: ListMeta };
type ListResponse = { data?: ListBody };
function normalizeListResponse(response: ListResponse | undefined): { records: VerifierRecord[]; total: number } {
const body = response?.data;
const payload = body?.data;
const records: VerifierRecord[] = Array.isArray(payload) ? payload : Array.isArray(payload?.data) ? payload.data : [];
const nestedMeta = !Array.isArray(payload) ? payload?.meta : undefined;
const meta = body?.meta || nestedMeta || {};
return {
records,
total: meta.count ?? meta.total ?? records.length,
};
}
/**
* Admin settings page for the Verification plugin. Lists all configured
* verifiers with create / edit / delete + bulk delete. Per-type fields
* are injected from `verificationManager` registered via the plugin
* registry so third-party verifier types (TOTP, future biometric, …)
* plug in without touching this page.
*/
export default function VerifiersPage() {
const { t } = useVerificationTranslation();
const compileT = useT();
const ctx = useFlowContext();
const { token } = theme.useToken();
const { modal } = App.useApp();
const resource = useVerifiersResource();
const [page, setPage] = useState(1);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const { data: typesData } = useVerificationTypesFromServer();
const verificationTypeOptions = useMemo<VerificationTypeOption[]>(() => typesData || [], [typesData]);
const { data, loading, refresh } = useRequest(
async () => {
const response = await resource.list({
page,
pageSize: PAGE_SIZE,
appends: [],
});
return normalizeListResponse(response);
},
{
refreshDeps: [page],
},
);
const openForm = useMemoizedFn((mode: 'create' | 'edit', verificationType: string, record?: VerifierRecord) => {
ctx.viewer.drawer({
width: '50%',
closable: true,
content: () => (
<VerifierFormView
mode={mode}
verificationType={verificationType}
verificationTypeOptions={verificationTypeOptions}
record={record}
onSubmitted={() => refresh()}
/>
),
});
});
const handleDelete = useMemoizedFn((filterByTk: React.Key | React.Key[]) => {
modal.confirm({
title: t('Delete'),
content: t('Are you sure you want to delete it?'),
async onOk() {
await resource.destroy({ filterByTk });
setSelectedRowKeys([]);
refresh();
},
});
});
const typeLabelOf = useMemoizedFn((typeName?: string) => {
const match = verificationTypeOptions.find((option) => option.name === typeName);
const raw = match?.title || typeName || '';
return compileT(raw);
});
const columns = useMemo<ColumnsType<VerifierRecord>>(
() => [
{ title: t('UID'), dataIndex: 'name' },
{ title: t('Title'), dataIndex: 'title' },
{
title: t('Verification type'),
dataIndex: 'verificationType',
render: (value) => (value ? <Tag>{typeLabelOf(value)}</Tag> : null),
},
{ title: t('Description'), dataIndex: 'description' },
{
title: t('Actions'),
width: 160,
render: (_, record) => (
<Space>
<a
onClick={() => {
if (!record.verificationType) return;
openForm('edit', record.verificationType, record);
}}
>
{t('Edit')}
</a>
<a onClick={() => record.name != null && handleDelete(record.name)}>{t('Delete')}</a>
</Space>
),
},
],
[handleDelete, openForm, t, typeLabelOf],
);
return (
<Card variant="borderless">
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: token.marginSM, marginBottom: token.margin }}>
<Button
icon={<DeleteOutlined />}
disabled={!selectedRowKeys.length}
onClick={() => handleDelete(selectedRowKeys)}
>
{t('Delete')}
</Button>
<Dropdown
menu={{
items: verificationTypeOptions.map((option) => ({
key: option.name,
label: compileT(option.title || option.name),
})),
onClick(info) {
openForm('create', info.key as string);
},
}}
disabled={!verificationTypeOptions.length}
>
<Button type="primary" icon={<PlusOutlined />}>
{t('Add new')} <DownOutlined />
</Button>
</Dropdown>
</div>
<Table<VerifierRecord>
rowKey="name"
loading={loading}
columns={columns}
dataSource={data?.records || []}
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
}}
pagination={{
current: page,
pageSize: PAGE_SIZE,
total: data?.total || 0,
showSizeChanger: false,
onChange: setPage,
}}
/>
</Card>
);
}
@@ -0,0 +1,71 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Plugin } from '@nocobase/client-v2';
import { PROVIDER_TYPE_SMS_ALIYUN, PROVIDER_TYPE_SMS_TENCENT, SMS_OTP_VERIFICATION_TYPE } from '../constants';
import { NAMESPACE, verificationLocaleResources } from './locale';
import { smsAliyunProviderOptions, smsOTPVerificationOptions, smsTencentProviderOptions } from './otp-verification/sms';
import { SMSOTPProviderManager } from './otp-sms-provider-manager';
import { VerificationUserCenterItemModel } from './user-center/VerificationUserCenterItemModel';
import { VerificationManager } from './verification-manager';
/**
* v2 entry for the Verification plugin. Mirrors the v1 surface
* (`verificationManager` and `smsOTPProviderManager` instances exposed
* to downstream plugins) but plugs into the v2 lifecycle:
*
* - `pluginSettingsManager.addMenuItem` / `addPageTabItem` register the
* admin settings page with a lazy `componentLoader`.
* - `flowEngine.registerModels` contributes the User Center entry.
* - The legacy `src/client/` entry is intentionally left in place so
* downstream v1-only plugins (TOTP authenticator, 2FA pro plugin)
* keep working until they migrate independently.
*/
export class PluginVerificationClientV2 extends Plugin {
verificationManager = new VerificationManager();
smsOTPProviderManager = new SMSOTPProviderManager();
async load() {
Object.entries(verificationLocaleResources).forEach(([lang, resource]) => {
this.app.i18n.addResources(lang, NAMESPACE, resource);
});
this.app.flowEngine.registerModels({ VerificationUserCenterItemModel });
this.registerSettingsPages();
// Built-in SMS-OTP verification type and its two stock providers.
// Third-party providers can call `smsOTPProviderManager.registerProvider`
// after `app.pm.get(...)` to slot in additional vendors.
this.verificationManager.registerVerificationType(SMS_OTP_VERIFICATION_TYPE, smsOTPVerificationOptions);
this.smsOTPProviderManager.registerProvider(PROVIDER_TYPE_SMS_ALIYUN, smsAliyunProviderOptions);
this.smsOTPProviderManager.registerProvider(PROVIDER_TYPE_SMS_TENCENT, smsTencentProviderOptions);
}
private registerSettingsPages() {
const t = (key: string) => this.app.i18n.t(key, { ns: NAMESPACE });
this.pluginSettingsManager.addMenuItem({
key: NAMESPACE,
title: t('Verification'),
icon: 'CheckCircleOutlined',
aclSnippet: 'pm.verification',
});
this.pluginSettingsManager.addPageTabItem({
menuKey: NAMESPACE,
key: 'index',
title: t('Verifiers'),
icon: 'CheckCircleOutlined',
aclSnippet: 'pm.verification.verifiers',
sort: 1,
componentLoader: () => import('./pages/VerifiersPage'),
});
}
}
export default PluginVerificationClientV2;
@@ -0,0 +1,305 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { DialogFormLayout, useApp } from '@nocobase/client-v2';
import { useFlowContext } from '@nocobase/flow-engine';
import { useMemoizedFn, useRequest } from 'ahooks';
import { Alert, App, Form, List, Spin, Tabs, Tag } from 'antd';
import React, { lazy, Suspense, useMemo, useState } from 'react';
import { useT, useVerificationTranslation } from '../locale';
import PluginVerificationClientV2 from '../plugin';
import type { BindFormProps, VerificationFormProps } from '../verification-manager';
/**
* `React.lazy` returns a brand-new component each call; calling it inside
* render would re-mount the Suspense boundary on every paint. Cache per
* verification type so the same lazy wrapper is reused across renders.
*/
function createLazyByType<P>() {
const cache = new Map<string, React.LazyExoticComponent<React.ComponentType<P>>>();
return (type: string, loader: () => Promise<{ default: React.ComponentType<P> }>) => {
const cached = cache.get(type);
if (cached) return cached;
const wrapped = lazy(loader);
cache.set(type, wrapped);
return wrapped;
};
}
type UserVerifier = {
name: string;
title?: string;
description?: string;
verificationType: string;
verificationTypeTitle?: string;
boundInfo?: {
bound?: boolean;
publicInfo?: string;
};
};
const lazyBindFormByType = createLazyByType<BindFormProps>();
function BindDialogContent(props: { verifier: UserVerifier; onSubmitted: () => void }) {
const { t } = useVerificationTranslation();
const compileT = useT();
const ctx = useFlowContext();
const app = useApp();
const plugin = app.pm.get(PluginVerificationClientV2);
const bindFormLoader = plugin?.verificationManager.getVerification(props.verifier.verificationType)?.components
?.BindFormLoader;
const BindForm = useMemo(
() => (bindFormLoader ? lazyBindFormByType(props.verifier.verificationType, bindFormLoader) : null),
[bindFormLoader, props.verifier.verificationType],
);
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
// Server returns titles as raw `{{ t("…", { ns: "…" }) }}` schema
// templates. Compile through FlowI18n so the dialog title shows the
// human-readable label instead of the literal expression.
const dialogTitle = compileT(props.verifier.title || '') || t('Bind');
const handleSubmit = useMemoizedFn(async () => {
const values = await form.validateFields();
setSubmitting(true);
try {
await ctx.api.resource('verifiers').bind({
values: {
verifier: props.verifier.name,
...values,
},
});
props.onSubmitted();
} finally {
setSubmitting(false);
}
});
// When the verifier's type has no v2-registered BindForm (e.g. TOTP
// — still v1-only at the time of writing), fall back to a friendly
// warning. Otherwise the drawer renders an empty body and the user
// sees nothing actionable.
if (!BindForm) {
return (
<DialogFormLayout
title={dialogTitle}
onSubmit={async () => undefined}
submitText={t('Close')}
cancelText={t('Cancel')}
>
<Alert
type="warning"
showIcon
message={t(
'This verifier type ({{type}}) is not yet supported in the new client. Please switch to the legacy client to bind it.',
{ type: props.verifier.verificationType },
)}
/>
</DialogFormLayout>
);
}
return (
<DialogFormLayout
title={dialogTitle}
onSubmit={handleSubmit}
submitting={submitting}
submitText={t('Bind')}
cancelText={t('Cancel')}
>
<Form form={form} layout="vertical">
<Suspense fallback={<Spin />}>
<BindForm verifier={props.verifier.name} actionType="verifiers:bind" isLogged />
</Suspense>
</Form>
</DialogFormLayout>
);
}
const lazyVerificationFormByType = createLazyByType<VerificationFormProps>();
function UnbindDialogContent(props: {
targetVerifier: UserVerifier;
availableVerifiers: UserVerifier[];
onSubmitted: () => void;
}) {
const compileT = useT();
const { t } = useVerificationTranslation();
const ctx = useFlowContext();
const app = useApp();
const plugin = app.pm.get(PluginVerificationClientV2);
// The user picks WHICH verifier to authenticate with on this drawer's
// tab strip — `selectedVerifier` is the auth source, while
// `targetVerifier` is the one being unbound.
const initial = props.availableVerifiers[0]?.name;
const [selectedVerifier, setSelectedVerifier] = useState<string | undefined>(initial);
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const handleSubmit = useMemoizedFn(async () => {
if (!selectedVerifier) return;
const values = await form.validateFields();
setSubmitting(true);
try {
await ctx.api.resource('verifiers').unbind({
values: {
verifier: selectedVerifier,
unbindVerifier: props.targetVerifier.name,
...values,
},
});
props.onSubmitted();
} finally {
setSubmitting(false);
}
});
const tabItems = props.availableVerifiers
.map((verifier) => {
const verificationFormLoader = plugin?.verificationManager.getVerification(verifier.verificationType)?.components
?.VerificationFormLoader;
if (!verificationFormLoader) return null;
const VerificationForm = lazyVerificationFormByType(verifier.verificationType, verificationFormLoader);
const tabTitle =
compileT(verifier.title || '') || compileT(verifier.verificationTypeTitle || verifier.verificationType);
return {
key: verifier.name,
label: tabTitle,
children: (
// The form instance is shared across tabs but values are reset
// when the user switches tabs (see onChange below). Each tab
// re-renders a fresh VerificationForm bound to its own verifier.
<Suspense fallback={<Spin />}>
<VerificationForm
verifier={verifier.name}
actionType="verifiers:unbind"
boundInfo={verifier.boundInfo}
isLogged
/>
</Suspense>
),
};
})
.filter(Boolean) as { key: string; label: React.ReactNode; children: React.ReactNode }[];
return (
<DialogFormLayout
title={t('Unbind verifier')}
onSubmit={handleSubmit}
submitting={submitting}
submitText={t('Unbind')}
cancelText={t('Cancel')}
>
<Form form={form} layout="vertical">
{tabItems.length ? (
<Tabs
activeKey={selectedVerifier}
onChange={(key) => {
form.resetFields();
setSelectedVerifier(key);
}}
destroyInactiveTabPane
items={tabItems}
/>
) : (
<span>{t('No verifier available to verify your identity.')}</span>
)}
</Form>
</DialogFormLayout>
);
}
function VerifierActions(props: { verifier: UserVerifier; onChanged: () => void }) {
const { t } = useVerificationTranslation();
const ctx = useFlowContext();
const bound = !!props.verifier.boundInfo?.bound;
// Match v1 UX: bind/unbind opens an antd Modal (dialog) with title
// left + native top-right X close. `closable: true` overrides the
// platform-level `closable={false}` default in `DialogComponent` so
// antd Modal's built-in close button is restored.
const openBind = useMemoizedFn(() => {
ctx.viewer.dialog({
closable: true,
content: () => <BindDialogContent verifier={props.verifier} onSubmitted={() => props.onChanged()} />,
});
});
const openUnbind = useMemoizedFn(async () => {
const response = await ctx.api.resource('verifiers').listForVerify({ scene: 'unbind-verifier' });
const verifiers: UserVerifier[] = response?.data?.data || [];
ctx.viewer.dialog({
closable: true,
content: () => (
<UnbindDialogContent
targetVerifier={props.verifier}
availableVerifiers={verifiers}
onSubmitted={() => props.onChanged()}
/>
),
});
});
return bound ? <a onClick={openUnbind}>{t('Unbind')}</a> : <a onClick={openBind}>{t('Bind')}</a>;
}
/**
* Drawer content for the User Center's "Verification" entry. Lists
* every verifier the current user can bind/unbind, with a Configured /
* Not configured tag and a per-row action. Bind opens a type-specific
* sub-drawer; Unbind opens a Tabs-based confirmation drawer that
* authenticates the user with any other bound verifier before removing
* the target.
*/
export function MyVerifiers() {
const compileT = useT();
const { t } = useVerificationTranslation();
const ctx = useFlowContext();
const { message } = App.useApp();
const { data, loading, refresh } = useRequest(async () => {
const response = await ctx.api.resource('verifiers').listByUser();
return (response?.data?.data || []) as UserVerifier[];
});
const onChanged = useMemoizedFn(() => {
refresh();
message.success(t('Operation succeeded'));
});
if (loading) {
return <Spin />;
}
return (
<List
bordered
dataSource={data || []}
renderItem={(item) => (
<List.Item actions={[<VerifierActions key="action" verifier={item} onChanged={onChanged} />]}>
<List.Item.Meta
title={
<>
{compileT(item.title || '')}{' '}
{item.boundInfo?.bound ? (
<Tag color="success">{t('Configured')}</Tag>
) : (
<Tag color="warning">{t('Not configured')}</Tag>
)}
</>
}
description={compileT(item.description || '')}
/>
<div>{item.boundInfo?.publicInfo}</div>
</List.Item>
)}
/>
);
}
export default MyVerifiers;
@@ -0,0 +1,53 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { UserCenterActionItemModel } from '@nocobase/client-v2';
import React from 'react';
import { NAMESPACE } from '../locale';
import { MyVerifiers } from './MyVerifiers';
const VERIFICATION_LABEL_NS = [NAMESPACE, 'client'];
/**
* User Center entry that opens the "My verifiers" drawer. Section is
* `profile` so it sits next to language / theme controls. Sort is
* tightened to 150 to match v1's ordering, where Verification lived
* between Profile (100) and Theme (200).
*
* The base `UserCenterTextItemView` renders `getLabelNode()`, which by
* default routes through `this.context.t(label)` with no namespace —
* that would never resolve the verification-namespaced key. Override
* the node directly so the verification i18n namespace is consulted.
*/
export class VerificationUserCenterItemModel extends UserCenterActionItemModel {
static itemId = 'verification';
section = 'profile' as const;
sort = 150;
label = 'Verification';
getLabelNode() {
return this.context.t('Verification', { ns: VERIFICATION_LABEL_NS, nsMode: 'fallback' });
}
async onClick() {
// `closable: true` overrides DrawerComponent's `closable={false}`
// default so antd Drawer renders its native left-side X next to the
// title — matches the v1 user-center drawer and the form-style
// drawers that inject a close icon via DrawerFormLayout.
this.context.viewer.drawer({
title: this.context.t('Verification', { ns: VERIFICATION_LABEL_NS, nsMode: 'fallback' }),
width: '50%',
closable: true,
content: () => <MyVerifiers />,
});
}
}
export default VerificationUserCenterItemModel;
@@ -0,0 +1,56 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Registry } from '@nocobase/utils/client';
import type { ComponentType } from 'react';
export type VerificationFormProps = {
verifier: string;
actionType: string;
boundInfo?: { bound?: boolean; publicInfo?: any };
isLogged?: boolean;
};
export type BindFormProps = {
verifier: string;
actionType: string;
isLogged?: boolean;
};
type LoaderOf<P = Record<string, never>> = () => Promise<{ default: ComponentType<P> }>;
export type VerificationTypeOptions = {
/**
* Async loaders for the type-specific forms. The manager stores loaders
* rather than direct component references so each verifier type
* contributes its own webpack chunk and is only fetched when a verifier
* of that type is actually shown.
*
* Consumers wrap each loader with `React.lazy` (cached via `useMemo` or
* a per-type cache to avoid re-creating the lazy wrapper) and render it
* under `<Suspense>`.
*/
components: {
AdminSettingsFormLoader?: LoaderOf;
VerificationFormLoader?: LoaderOf<VerificationFormProps>;
BindFormLoader?: LoaderOf<BindFormProps>;
};
};
export class VerificationManager {
verifications = new Registry<VerificationTypeOptions>();
registerVerificationType(type: string, options: VerificationTypeOptions) {
this.verifications.register(type, options);
}
getVerification(type: string) {
return this.verifications.get(type);
}
}
+42 -652
View File
File diff suppressed because it is too large Load Diff