mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-29 02:03:53 +08:00
f8b6bc07af
* feat(client-v2): add standalone settings SPA * style(client-v2): use primary header color * fix(plugin-ui-layout): hide mobile menu * style(client-v2): regroup settings action * fix(client-v2): match settings header color * fix(client-v2): group portal manager * fix(client-v2): group topbar managers * feat(multi-portal): add portal routes * fix(app): normalize settings dev root * feat(auth): add settings SPA sign-in * docs(auth): add settings sign-in plan * fix(client-v2): align settings embed layout * fix(client-v2): normalize settings app URLs * feat(multi-portal): unify client v2 portals * fix(ci): repair PR validation failures * fix(client-v2): flatten scoped settings URLs * fix(client-v2): redirect roots to settings * fix(cli): initialize AI portal env defaults * fix(cli): remove portal setup from init * fix(cli): skip unsupported portal registry sync * fix(cli): narrow init env config types * refactor(multi-portal): use fixed portal uids * fix(cli): skip portal registry sync during install * fix(plugin-multi-portal): allow reserved slugs * fix(client-v2): normalize settings app roots --------- Co-authored-by: chenos <chenlinxh@gmail.com>
413 lines
15 KiB
TypeScript
413 lines
15 KiB
TypeScript
/**
|
|
* 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 { Table, VariableInput, VariableTextArea } from '@nocobase/client-v2';
|
|
import { useFlowContext, type MetaTreeNode } from '@nocobase/flow-engine';
|
|
import { useMemoizedFn, useRequest } from 'ahooks';
|
|
import { Alert, Checkbox, Form, InputNumber, Radio, Select, Tabs, Tag, Typography } from 'antd';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
import React, { useMemo } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { useAuthTranslation, useT } from '../locale';
|
|
|
|
type SignupFieldRow = {
|
|
field: string;
|
|
show?: boolean;
|
|
required?: boolean;
|
|
};
|
|
|
|
/**
|
|
* Forgot-password email templates may reference these scopes. Server-side
|
|
* renderer expands them at delivery time — the picker only needs to surface
|
|
* them as selectable variables. `$env` is namespace-filtered from the global
|
|
* meta tree; the rest are static leaves passed as `extraNodes`.
|
|
*
|
|
* Note: nested `$user.*` / `$systemSettings.*` paths are hard-coded to match
|
|
* the legacy server template contract. When `$user` / `$systemSettings` are
|
|
* formally registered on the v2 flow context, this list can be replaced with
|
|
* a namespace filter.
|
|
*/
|
|
function useForgotPasswordExtraNodes(): MetaTreeNode[] {
|
|
const { t } = useAuthTranslation();
|
|
return useMemo(
|
|
() => [
|
|
{
|
|
name: '$user',
|
|
title: t('Current user'),
|
|
type: 'object',
|
|
paths: ['$user'],
|
|
children: [
|
|
{ name: 'username', title: t('Username'), type: 'string', paths: ['$user', 'username'] },
|
|
{ name: 'nickname', title: t('Nickname'), type: 'string', paths: ['$user', 'nickname'] },
|
|
{ name: 'email', title: t('Email'), type: 'string', paths: ['$user', 'email'] },
|
|
],
|
|
},
|
|
{
|
|
name: '$systemSettings',
|
|
title: t('System settings'),
|
|
type: 'object',
|
|
paths: ['$systemSettings'],
|
|
children: [
|
|
{ name: 'title', title: t('System title'), type: 'string', paths: ['$systemSettings', 'title'] },
|
|
{ name: 'logo', title: t('System logo'), type: 'string', paths: ['$systemSettings', 'logo'] },
|
|
],
|
|
},
|
|
{
|
|
name: '$resetLink',
|
|
title: t('Reset password link'),
|
|
type: 'string',
|
|
paths: ['$resetLink'],
|
|
},
|
|
{
|
|
name: '$resetLinkExpiration',
|
|
title: t('Reset link expiration (minutes)'),
|
|
type: 'string',
|
|
paths: ['$resetLinkExpiration'],
|
|
},
|
|
],
|
|
[t],
|
|
);
|
|
}
|
|
|
|
const FORGOT_PASSWORD_NAMESPACES = ['$env'];
|
|
|
|
function useUsersFields() {
|
|
const ctx = useFlowContext();
|
|
return useMemo(() => {
|
|
// v2 `Collection.fields` is a `Map<string, CollectionField>` — calling
|
|
// `.filter` on it throws. Use `getFields()` which returns an array
|
|
// (and also merges inherited collections, matching v1 behavior).
|
|
//
|
|
// `hidden` is a raw DB option in v2 (no getter on CollectionField), so
|
|
// read it through `.options`. The remaining checks use the typed getters.
|
|
const collection = ctx.dataSourceManager?.getCollection?.('main', 'users');
|
|
const fields = collection?.getFields?.() ?? [];
|
|
return fields.filter(
|
|
(field) =>
|
|
field && !field.options?.hidden && !field.target && field.interface && !field.uiSchema?.['x-read-pretty'],
|
|
);
|
|
}, [ctx]);
|
|
}
|
|
|
|
function SignupFormTable(props: { value?: SignupFieldRow[]; onChange?: (next: SignupFieldRow[]) => void }) {
|
|
const { t } = useAuthTranslation();
|
|
const compileT = useT();
|
|
const fields = useUsersFields();
|
|
// Stabilise the empty-array fallback so the `value` dep doesn't change every
|
|
// render and re-trigger the reconcile memo below.
|
|
const value = useMemo(() => props.value ?? [], [props.value]);
|
|
|
|
// Reconcile: drop entries whose backing field no longer exists, then append
|
|
// any new fields with sensible defaults (username/email visible & required).
|
|
const reconciled = useMemo<SignupFieldRow[]>(() => {
|
|
const fieldNames = new Set(fields.map((field) => field.name));
|
|
const kept = value.filter((row) => fieldNames.has(row.field));
|
|
const known = new Set(kept.map((row) => row.field));
|
|
for (const field of fields) {
|
|
if (!known.has(field.name)) {
|
|
kept.push({
|
|
field: field.name,
|
|
show: field.name === 'username',
|
|
required: field.name === 'username',
|
|
});
|
|
}
|
|
}
|
|
return kept;
|
|
}, [fields, value]);
|
|
|
|
// Sync the reconciled value back to the parent form on mount / collection
|
|
// changes so re-renders are stable. Guard against echoing identical arrays.
|
|
React.useEffect(() => {
|
|
const sameLength = value.length === reconciled.length;
|
|
const same =
|
|
sameLength &&
|
|
value.every(
|
|
(row, index) =>
|
|
row.field === reconciled[index].field &&
|
|
!!row.show === !!reconciled[index].show &&
|
|
!!row.required === !!reconciled[index].required,
|
|
);
|
|
if (!same) {
|
|
props.onChange?.(reconciled);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [reconciled]);
|
|
|
|
const updateRow = useMemoizedFn((fieldName: string, patch: Partial<SignupFieldRow>) => {
|
|
const next = reconciled.map((row) => {
|
|
if (row.field !== fieldName) return row;
|
|
const merged = { ...row, ...patch };
|
|
// Show is forced on whenever Required is on; Required is forced off when Show is off.
|
|
if (merged.required) merged.show = true;
|
|
if (!merged.show) merged.required = false;
|
|
return merged;
|
|
});
|
|
props.onChange?.(next);
|
|
});
|
|
|
|
// Field titles on the `users` collection are stored as legacy Schema
|
|
// templates (e.g. `{{t("Nickname")}}`). `useT()` routes through
|
|
// `flowEngine.context.t`, which natively expands those templates — no
|
|
// `Schema.compile` needed.
|
|
const fieldTitle = useMemoizedFn((fieldName: string) => {
|
|
const field = fields.find((f) => f.name === fieldName);
|
|
const raw = field?.uiSchema?.title || field?.options?.title || fieldName;
|
|
return compileT(raw);
|
|
});
|
|
|
|
const columns = useMemo<ColumnsType<SignupFieldRow>>(
|
|
() => [
|
|
{
|
|
title: t('Field'),
|
|
dataIndex: 'field',
|
|
width: 200,
|
|
// v1 wrapped field names in a bordered Tag — keep the same chip
|
|
// affordance so the row reads as a *field reference* and not free
|
|
// text. `bordered` is implicit on the default Tag.
|
|
render: (fieldName) => <Tag>{fieldTitle(fieldName)}</Tag>,
|
|
},
|
|
{
|
|
title: t('Show'),
|
|
dataIndex: 'show',
|
|
width: 80,
|
|
align: 'center',
|
|
render: (_, row) => (
|
|
<Checkbox checked={!!row.show} onChange={(event) => updateRow(row.field, { show: event.target.checked })} />
|
|
),
|
|
},
|
|
{
|
|
title: t('Required'),
|
|
dataIndex: 'required',
|
|
width: 80,
|
|
align: 'center',
|
|
render: (_, row) => (
|
|
<Checkbox
|
|
checked={!!row.required}
|
|
onChange={(event) => updateRow(row.field, { required: event.target.checked })}
|
|
/>
|
|
),
|
|
},
|
|
// Filler: antd Table stretches to fill its container, so without a
|
|
// trailing column every sized column gets the slack — including Field.
|
|
// An empty, unsized column absorbs the remainder instead.
|
|
{ title: '', dataIndex: '_filler', render: () => null },
|
|
],
|
|
[fieldTitle, t, updateRow],
|
|
);
|
|
|
|
const handleSortEnd = useMemoizedFn((from: SignupFieldRow, to: SignupFieldRow) => {
|
|
const fromIndex = reconciled.findIndex((row) => row.field === from.field);
|
|
const toIndex = reconciled.findIndex((row) => row.field === to.field);
|
|
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return;
|
|
const next = [...reconciled];
|
|
const [moved] = next.splice(fromIndex, 1);
|
|
next.splice(toIndex, 0, moved);
|
|
props.onChange?.(next);
|
|
});
|
|
|
|
return (
|
|
<Table<SignupFieldRow>
|
|
rowKey="field"
|
|
pagination={false}
|
|
columns={columns}
|
|
dataSource={reconciled}
|
|
isDraggable
|
|
onSortEnd={handleSortEnd}
|
|
sortHandleColumnWidth={32}
|
|
showIndex={false}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SignUpTab() {
|
|
const { t } = useAuthTranslation();
|
|
return (
|
|
<>
|
|
<Form.Item
|
|
name={['options', 'public', 'allowSignUp']}
|
|
label={t('Allow to sign up')}
|
|
valuePropName="checked"
|
|
initialValue
|
|
>
|
|
<Checkbox />
|
|
</Form.Item>
|
|
<Form.Item label={t('Sign up form')} name={['options', 'public', 'signupForm']} validateFirst>
|
|
<SignupFormTable />
|
|
</Form.Item>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function useNotificationChannels(enabled: boolean) {
|
|
const ctx = useFlowContext();
|
|
return useRequest(
|
|
async () => {
|
|
const response = await ctx.api.resource('notificationChannels').list({
|
|
filter: { notificationType: 'email' },
|
|
pageSize: 50,
|
|
});
|
|
const data = response?.data?.data ?? response?.data;
|
|
return Array.isArray(data) ? data : [];
|
|
},
|
|
{
|
|
ready: enabled,
|
|
cacheKey: '@nocobase/plugin-auth:notificationChannels:email',
|
|
refreshDeps: [enabled],
|
|
},
|
|
);
|
|
}
|
|
|
|
function ForgotPasswordTab() {
|
|
const { t } = useAuthTranslation();
|
|
const ctx = useFlowContext();
|
|
const form = Form.useFormInstance();
|
|
const enableResetPassword: boolean = Form.useWatch(['options', 'public', 'enableResetPassword'], form);
|
|
const emailContentType: 'html' | 'text' = Form.useWatch(['options', 'emailContentType'], form) || 'html';
|
|
const extraNodes = useForgotPasswordExtraNodes();
|
|
const { data: channels, loading: channelsLoading } = useNotificationChannels(!!enableResetPassword);
|
|
|
|
const channelOptions = useMemo(
|
|
() => (channels || []).map((channel: any) => ({ label: channel.title, value: channel.name })),
|
|
[channels],
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<Form.Item
|
|
name={['options', 'public', 'enableResetPassword']}
|
|
label={t('Enable forget password')}
|
|
valuePropName="checked"
|
|
initialValue={false}
|
|
>
|
|
<Checkbox />
|
|
</Form.Item>
|
|
{enableResetPassword ? (
|
|
<>
|
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
|
{t('1. Select notification channel')}
|
|
</Typography.Title>
|
|
<Form.Item
|
|
name={['options', 'notificationChannel']}
|
|
label={t('Notification channel (Email)')}
|
|
extra={t('The notification channel used to send the reset password email, only support email channel')}
|
|
rules={[{ required: true, message: t('Please select a notification channel') }]}
|
|
>
|
|
<Select
|
|
loading={channelsLoading}
|
|
options={channelOptions}
|
|
placeholder={t('Select')}
|
|
notFoundContent={
|
|
<span>
|
|
{t('No notification channels found. Please ')}
|
|
<Link to={ctx.app.pluginSettingsManager.getRoutePath('notification-manager.channels')}>
|
|
{t('add one first')}
|
|
</Link>
|
|
.
|
|
</span>
|
|
}
|
|
/>
|
|
</Form.Item>
|
|
<Typography.Title level={5}>{t('2. Configure reset email')}</Typography.Title>
|
|
<Form.Item
|
|
name={['options', 'emailSubject']}
|
|
label={t('Subject')}
|
|
rules={[{ required: true, message: t('Please enter the email subject') }]}
|
|
initialValue={t('defaultResetPasswordEmailSubject')}
|
|
>
|
|
<VariableInput namespaces={FORGOT_PASSWORD_NAMESPACES} extraNodes={extraNodes} />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name={['options', 'emailContentType']}
|
|
label={t('Content type')}
|
|
initialValue="html"
|
|
rules={[{ required: true }]}
|
|
>
|
|
<Radio.Group
|
|
options={[
|
|
{ label: 'HTML', value: 'html' },
|
|
{ label: t('Plain text'), value: 'text' },
|
|
]}
|
|
/>
|
|
</Form.Item>
|
|
{emailContentType === 'html' ? (
|
|
<Form.Item
|
|
key="html"
|
|
name={['options', 'emailContentHTML']}
|
|
label={t('Content')}
|
|
rules={[{ required: true, message: t('Please enter the email content') }]}
|
|
initialValue={t('defaultResetPasswordEmailContentHTML')}
|
|
>
|
|
<VariableTextArea
|
|
namespaces={FORGOT_PASSWORD_NAMESPACES}
|
|
extraNodes={extraNodes}
|
|
placeholder="Hi,"
|
|
rows={10}
|
|
/>
|
|
</Form.Item>
|
|
) : (
|
|
<Form.Item
|
|
key="text"
|
|
name={['options', 'emailContentText']}
|
|
label={t('Content')}
|
|
rules={[{ required: true, message: t('Please enter the email content') }]}
|
|
initialValue={t('defaultResetPasswordEmailContentText')}
|
|
>
|
|
<VariableTextArea namespaces={FORGOT_PASSWORD_NAMESPACES} extraNodes={extraNodes} rows={10} />
|
|
</Form.Item>
|
|
)}
|
|
<Form.Item
|
|
name={['options', 'resetTokenExpiresIn']}
|
|
label={t('Reset link expiration')}
|
|
initialValue={120}
|
|
rules={[{ required: true, message: t('Please enter the expiration in minutes') }]}
|
|
>
|
|
<InputNumber min={1} addonAfter={t('Minutes')} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
</>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* v2 rewrite of plugin-auth's preset `Options.tsx`. Rendered inside the
|
|
* Authenticators page drawer below the common fields (`name`, `authType`,
|
|
* `title`, `description`, `enabled`). Two tabs — sign up form configuration
|
|
* and forgot-password email template — match the v1 layout shown to admins.
|
|
*
|
|
* All `Form.Item` names live under the parent form's `['options', ...]`
|
|
* path, so submitted values land on the authenticator record as
|
|
* `options.public.allowSignUp` / `options.notificationChannel` / etc., the
|
|
* same shape the v1 schema produced and the server expects.
|
|
*/
|
|
export default function BasicAuthAdminSettings() {
|
|
const { t } = useAuthTranslation();
|
|
return (
|
|
<div>
|
|
<Alert
|
|
style={{ marginBottom: 16 }}
|
|
type="info"
|
|
showIcon
|
|
message={t('The authentication allows users to sign in via username or email.')}
|
|
/>
|
|
{/*
|
|
`forceRender: true` 让所有 tab 的内容首屏就 mount —— 否则非 active tab 里的
|
|
`Form.Item` 从未注册到父抽屉表单的 value store,提交时会把那些字段当成"用户清空",
|
|
覆盖掉服务端已有的值。v1 用 Formily `FormTab` 默认就是全 mount,所以没这个坑。
|
|
*/}
|
|
<Tabs
|
|
items={[
|
|
{ key: 'signup', label: t('Sign up settings'), forceRender: true, children: <SignUpTab /> },
|
|
{ key: 'forgot', label: t('Forgot password'), forceRender: true, children: <ForgotPasswordTab /> },
|
|
]}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|