/** * 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` — 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(() => { 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) => { 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>( () => [ { 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) => {fieldTitle(fieldName)}, }, { title: t('Show'), dataIndex: 'show', width: 80, align: 'center', render: (_, row) => ( updateRow(row.field, { show: event.target.checked })} /> ), }, { title: t('Required'), dataIndex: 'required', width: 80, align: 'center', render: (_, row) => ( 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 ( rowKey="field" pagination={false} columns={columns} dataSource={reconciled} isDraggable onSortEnd={handleSortEnd} sortHandleColumnWidth={32} showIndex={false} /> ); } function SignUpTab() { const { t } = useAuthTranslation(); return ( <> ); } 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 ( <> {enableResetPassword ? ( <> {t('1. Select notification channel')}