mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-28 17:43:07 +08:00
feat(plugin-verification): migrate client to client-v2 (#9515)
This commit is contained in:
@@ -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 () => ({
|
||||
|
||||
+19
-12
@@ -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 补一节,方便后续插件迁移时找到
|
||||
+15
-15
@@ -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",
|
||||
|
||||
+111
@@ -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",
|
||||
|
||||
+57
@@ -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-in–facing 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);
|
||||
}
|
||||
|
||||
+3
-6
@@ -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,
|
||||
});
|
||||
|
||||
+134
@@ -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",
|
||||
|
||||
+188
@@ -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);
|
||||
});
|
||||
});
|
||||
+130
@@ -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;
|
||||
+102
@@ -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' });
|
||||
}
|
||||
+43
@@ -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);
|
||||
}
|
||||
}
|
||||
+73
@@ -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;
|
||||
+44
@@ -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;
|
||||
+50
@@ -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;
|
||||
+28
@@ -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'),
|
||||
},
|
||||
};
|
||||
+65
@@ -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;
|
||||
+75
@@ -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;
|
||||
+305
@@ -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;
|
||||
+53
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user