diff --git a/packages/core/auth/src/auth-manager.ts b/packages/core/auth/src/auth-manager.ts index 40b53094ec..955e760f73 100644 --- a/packages/core/auth/src/auth-manager.ts +++ b/packages/core/auth/src/auth-manager.ts @@ -32,6 +32,7 @@ export type AuthManagerOptions = { type AuthConfig = { auth: AuthExtend; // The authentication class. title?: string; // The display name of the authentication type. + getPublicOptions?: (options: Record) => Record; // Get the public options. }; export class AuthManager { diff --git a/packages/plugins/@nocobase/plugin-auth/src/client/basic/Options.tsx b/packages/plugins/@nocobase/plugin-auth/src/client/basic/Options.tsx index 13fb2fe30e..262020716f 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client/basic/Options.tsx +++ b/packages/plugins/@nocobase/plugin-auth/src/client/basic/Options.tsx @@ -7,18 +7,151 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { SchemaComponent } from '@nocobase/client'; -import React from 'react'; +import { SchemaComponent, useCollectionManager, useRecord } from '@nocobase/client'; +import React, { useEffect, useMemo } from 'react'; import { lang, useAuthTranslation } from '../locale'; import { FormTab, ArrayTable } from '@formily/antd-v5'; import { Alert } from 'antd'; +import { uid } from '@formily/shared'; + +const SignupFormSettings = () => { + const record = useRecord(); + const cm = useCollectionManager(); + const userCollection = cm.getCollection('users'); + const fields = userCollection.fields.filter( + (field) => !field.hidden && !field.target && field.interface && !field.uiSchema?.['x-read-pretty'], + ); + const enumArr = fields.map((field) => ({ value: field.name, label: field.uiSchema?.title })); + const value = useMemo(() => { + const fieldValue = record.options?.public?.signupForm || []; + const newValue = fieldValue.filter((item: any) => fields.find((field) => field.name === item.field)); + for (const field of fields) { + const exist = newValue.find((item: any) => item.field === field.name); + if (!exist) { + newValue.push({ + field: field.name, + show: field.name === 'username', + required: field.name === 'username', + }); + } + } + return newValue; + }, [fields, record]); + useEffect(() => { + record.options = { + ...record.options, + public: { + ...record.options?.public, + signupForm: value, + }, + }; + }, [record, value]); + + return ( + { + const check = value?.some((item) => ['username', 'email'].includes(item.field) && item.show && item.required); + if (!check) { + return t('At least one of the username or email fields is required'); + } +} }}`, + default: value, + items: { + type: 'object', + 'x-decorator': 'ArrayItems.Item', + properties: { + column0: { + type: 'void', + 'x-component': 'ArrayTable.Column', + 'x-component-props': { width: 20, align: 'center' }, + properties: { + sort: { + type: 'void', + 'x-component': 'ArrayTable.SortHandle', + }, + }, + }, + column1: { + type: 'void', + 'x-component': 'ArrayTable.Column', + 'x-component-props': { width: 100, title: lang('Field') }, + properties: { + field: { + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'Select', + enum: enumArr, + 'x-read-pretty': true, + }, + }, + }, + column2: { + type: 'void', + 'x-component': 'ArrayTable.Column', + 'x-component-props': { width: 80, title: lang('Show') }, + properties: { + show: { + type: 'boolean', + 'x-decorator': 'FormItem', + 'x-component': 'Checkbox', + 'x-reactions': { + dependencies: ['.required'], + fulfill: { + state: { + value: '{{ $deps[0] || $self.value }}', + }, + }, + }, + }, + }, + }, + column3: { + type: 'void', + 'x-component': 'ArrayTable.Column', + 'x-component-props': { width: 80, title: lang('Required') }, + properties: { + required: { + type: 'boolean', + 'x-decorator': 'FormItem', + 'x-component': 'Checkbox', + 'x-reactions': { + dependencies: ['.show'], + fulfill: { + state: { + value: '{{ !$deps[0] ? false : $self.value }}', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }} + /> + ); +}; export const Options = () => { const { t } = useAuthTranslation(); return ( { 'x-component': 'Checkbox', default: true, }, - signupForm: { - title: '{{t("Sign up form")}}', - type: 'array', - 'x-decorator': 'FormItem', - 'x-component': 'ArrayTable', - 'x-component-props': { - bordered: false, - }, - 'x-validator': `{{ (value) => { - const field = value?.filter((item) => item.show && item.required); - if (!field?.length) { - return t('At least one field is required'); - } -} }}`, - default: [ - { - field: 'username', - show: true, - required: true, - }, - { - field: 'email', - show: false, - required: false, - }, - ], - items: { - type: 'object', - 'x-decorator': 'ArrayItems.Item', - properties: { - column0: { - type: 'void', - 'x-component': 'ArrayTable.Column', - 'x-component-props': { width: 20, align: 'center' }, - properties: { - sort: { - type: 'void', - 'x-component': 'ArrayTable.SortHandle', - }, - }, - }, - column1: { - type: 'void', - 'x-component': 'ArrayTable.Column', - 'x-component-props': { width: 100, title: lang('Field') }, - properties: { - field: { - type: 'string', - 'x-decorator': 'FormItem', - 'x-component': 'Select', - enum: [ - { - label: lang('Username'), - value: 'username', - }, - { - label: lang('Email'), - value: 'email', - }, - ], - 'x-read-pretty': true, - }, - }, - }, - column2: { - type: 'void', - 'x-component': 'ArrayTable.Column', - 'x-component-props': { width: 80, title: lang('Show') }, - properties: { - show: { - type: 'boolean', - 'x-decorator': 'FormItem', - 'x-component': 'Checkbox', - }, - }, - }, - column3: { - type: 'void', - 'x-component': 'ArrayTable.Column', - 'x-component-props': { width: 80, title: lang('Required') }, - properties: { - required: { - type: 'boolean', - 'x-decorator': 'FormItem', - 'x-component': 'Checkbox', - }, - }, - }, - }, - }, + [uid()]: { + type: 'void', + 'x-component': 'SignupFormSettings', }, }, }, diff --git a/packages/plugins/@nocobase/plugin-auth/src/client/basic/SignUpForm.tsx b/packages/plugins/@nocobase/plugin-auth/src/client/basic/SignUpForm.tsx index 010c99e1f3..5b83947f1b 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client/basic/SignUpForm.tsx +++ b/packages/plugins/@nocobase/plugin-auth/src/client/basic/SignUpForm.tsx @@ -26,23 +26,6 @@ export interface UseSignupProps { }; } -const schemas = { - username: { - type: 'string', - 'x-component': 'Input', - 'x-validator': { username: true }, - 'x-decorator': 'FormItem', - 'x-component-props': { placeholder: '{{t("Username")}}', style: {} }, - }, - email: { - type: 'string', - 'x-component': 'Input', - 'x-validator': 'email', - 'x-decorator': 'FormItem', - 'x-component-props': { placeholder: '{{t("Email")}}', style: {} }, - }, -}; - export const useSignUp = (props?: UseSignupProps) => { const navigate = useNavigate(); const form = useForm(); @@ -69,9 +52,10 @@ const getSignupPageSchema = (fieldSchemas: any): ISchema => ({ password: { type: 'string', required: true, + title: '{{t("Password")}}', 'x-component': 'Password', 'x-decorator': 'FormItem', - 'x-component-props': { placeholder: '{{t("Password")}}', checkStrength: true, style: {} }, + 'x-component-props': { checkStrength: true, style: {} }, 'x-reactions': [ { dependencies: ['.confirm_password'], @@ -88,7 +72,8 @@ const getSignupPageSchema = (fieldSchemas: any): ISchema => ({ required: true, 'x-component': 'Password', 'x-decorator': 'FormItem', - 'x-component-props': { placeholder: '{{t("Confirm password")}}', style: {} }, + title: '{{t("Confirm password")}}', + 'x-component-props': { style: {} }, 'x-reactions': [ { dependencies: ['.password'], @@ -140,22 +125,17 @@ export const SignUpForm = ({ authenticatorName: name }: { authenticatorName: str }; const authenticator = useAuthenticator(name); const { options } = authenticator; - let { signupForm } = options; - if (!(signupForm?.length && signupForm.some((item: any) => item.show && item.required))) { - signupForm = [{ field: 'username', show: true, required: true }]; - } + const { signupForm } = options; const fieldSchemas = useMemo(() => { return signupForm .filter((field: { show: boolean }) => field.show) - .reduce((prev: any, item: { field: string; required: boolean }) => { - const field = item.field; - if (schemas[field]) { - prev[field] = schemas[field]; - if (item.required) { - prev[field].required = true; - } - return prev; - } + .reduce((prev: any, item: { field: string; required: boolean; uiSchema: any }) => { + prev[item.field] = { + ...item.uiSchema, + required: item.required, + 'x-decorator': 'FormItem', + }; + return prev; }, {}); }, [signupForm]); if (!options?.allowSignUp) { diff --git a/packages/plugins/@nocobase/plugin-auth/src/locale/en-US.json b/packages/plugins/@nocobase/plugin-auth/src/locale/en-US.json index a9335f2252..70ce40becd 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/locale/en-US.json +++ b/packages/plugins/@nocobase/plugin-auth/src/locale/en-US.json @@ -28,5 +28,5 @@ "Show": "Show", "Sign up settings": "Sign up settings", "Sign up form": "Sign up form", - "At least one field is required": "At least one field is required" + "At least one of the username or email fields is required": "At least one of the username or email fields is required" } diff --git a/packages/plugins/@nocobase/plugin-auth/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-auth/src/locale/zh-CN.json index 599a9307cd..86e19bc222 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/locale/zh-CN.json +++ b/packages/plugins/@nocobase/plugin-auth/src/locale/zh-CN.json @@ -28,5 +28,5 @@ "Show": "显示", "Sign up settings": "注册设置", "Sign up form": "注册表单", - "At least one field is required": "至少需要设置一个必填字段" + "At least one of the username or email fields is required": "至少需要设置用户名或邮箱中的一个字段为必填字段" } diff --git a/packages/plugins/@nocobase/plugin-auth/src/server/__tests__/actions.test.ts b/packages/plugins/@nocobase/plugin-auth/src/server/__tests__/actions.test.ts index 1ec00affdf..444f502db0 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/server/__tests__/actions.test.ts +++ b/packages/plugins/@nocobase/plugin-auth/src/server/__tests__/actions.test.ts @@ -46,18 +46,30 @@ describe('actions', () => { }); it('should return enabled authenticators with public options', async () => { + app.authManager.registerTypes('testType1', { + auth: {} as any, + getPublicOptions: (options) => { + return { + text: 'custom public options', + }; + }, + }); await repo.destroy({ truncate: true, }); await repo.createMany({ records: [ { name: 'test', authType: 'testType', enabled: true, options: { public: { test: 1 }, private: { test: 2 } } }, + { name: 'test1', authType: 'testType1', enabled: true }, { name: 'test2', authType: 'testType' }, ], }); const res = await agent.resource('authenticators').publicList(); - expect(res.body.data.length).toBe(1); + expect(res.body.data.length).toBe(2); expect(res.body.data[0].name).toBe('test'); + expect(res.body.data[0].options).toMatchObject({ test: 1 }); + expect(res.body.data[1].name).toBe('test1'); + expect(res.body.data[1].options).toMatchObject({ text: 'custom public options' }); }); it('should keep at least one authenticator', async () => { @@ -260,16 +272,37 @@ describe('actions', () => { }); it('should check username when signing up', async () => { - const res1 = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({ - username: '', - }); - expect(res1.statusCode).toEqual(400); - expect(res1.error.text).toBe('Please enter a valid username'); const res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({ - username: '@@', + username: '', }); expect(res.statusCode).toEqual(400); expect(res.error.text).toBe('Please enter a valid username'); + const res1 = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({ + username: '@@', + }); + expect(res1.statusCode).toEqual(400); + expect(res1.error.text).toBe('Please enter a valid username'); + + const repo = db.getRepository('authenticators'); + await repo.update({ + filter: { + name: 'basic', + }, + values: { + options: { + public: { + allowSignUp: true, + signupForm: [{ field: 'nickname', show: true }], + }, + }, + }, + }); + + const res2 = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({ + nickname: 'test', + }); + expect(res2.statusCode).toEqual(400); + expect(res2.error.text).toBe('Please enter a valid username'); }); it('should check email when signing up', async () => { @@ -305,6 +338,31 @@ describe('actions', () => { expect(res3.statusCode).toEqual(200); }); + it('should check a required field when signing up', async () => { + const repo = db.getRepository('authenticators'); + await repo.update({ + filter: { + name: 'basic', + }, + values: { + options: { + public: { + allowSignUp: true, + signupForm: [ + { field: 'username', show: true, required: true }, + { field: 'nickname', show: true, required: true }, + ], + }, + }, + }, + }); + const res1 = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({ + username: 'test', + }); + expect(res1.statusCode).toEqual(400); + expect(res1.error.text).toBe('Please enter nickname'); + }); + it('should check password when signing up', async () => { const res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({ username: 'new', @@ -313,6 +371,41 @@ describe('actions', () => { expect(res.error.text).toBe('Please enter a password'); }); + it('should write correct user data when signing up', async () => { + const repo = db.getRepository('authenticators'); + await repo.update({ + filter: { + name: 'basic', + }, + values: { + options: { + public: { + allowSignUp: true, + signupForm: [ + { field: 'username', show: true, required: true }, + { field: 'nickname', show: true, required: true }, + ], + }, + }, + }, + }); + const res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({ + username: 'test', + nickname: 'Test', + phone: '12345678901', + password: '123456', + confirm_password: '123456', + }); + expect(res.statusCode).toEqual(200); + const user = await db.getRepository('users').findOne({ + filter: { + username: 'test', + }, + }); + expect(user.nickname).toBe('Test'); + expect(user.phone).toBeNull(); + }); + it('should sign user out when changing password', async () => { const userRepo = db.getRepository('users'); const user = await userRepo.create({ diff --git a/packages/plugins/@nocobase/plugin-auth/src/server/actions/authenticators.ts b/packages/plugins/@nocobase/plugin-auth/src/server/actions/authenticators.ts index 72ad82bd4a..a3d1efe74e 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/server/actions/authenticators.ts +++ b/packages/plugins/@nocobase/plugin-auth/src/server/actions/authenticators.ts @@ -49,7 +49,7 @@ export default { authType: authenticator.authType, authTypeTitle: authType?.title || '', title: authenticator.title, - options: authenticator.options?.public || {}, + options: authType?.getPublicOptions?.(authenticator.options) || authenticator.options?.public || {}, }; }); await next(); diff --git a/packages/plugins/@nocobase/plugin-auth/src/server/basic-auth.ts b/packages/plugins/@nocobase/plugin-auth/src/server/basic-auth.ts index 0c3185487e..193f95b7e4 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/server/basic-auth.ts +++ b/packages/plugins/@nocobase/plugin-auth/src/server/basic-auth.ts @@ -11,6 +11,7 @@ import { AuthConfig, BaseAuth } from '@nocobase/auth'; import { PasswordField } from '@nocobase/database'; import crypto from 'crypto'; import { namespace } from '../preset'; +import _ from 'lodash'; export class BasicAuth extends BaseAuth { constructor(config: AuthConfig) { @@ -50,20 +51,41 @@ export class BasicAuth extends BaseAuth { return user; } - private verfiySignupParams(values: any) { + private getSignupFormSettings() { const options = this.authenticator.options?.public || {}; - let { signupForm } = options; - if (!(signupForm?.length && signupForm.some((item: any) => item.show && item.required))) { - signupForm = [{ field: 'username', show: true, required: true }]; + let { signupForm = [] } = options; + signupForm = signupForm.filter((item: { show: boolean }) => item.show); + if ( + !( + signupForm.length && + signupForm.some( + (item: { field: string; show: boolean; required: boolean }) => + ['username', 'email'].includes(item.field) && item.show && item.required, + ) + ) + ) { + // At least one of the username or email fields is required + signupForm.push({ field: 'username', show: true, required: true }); } + return signupForm; + } + + private verfiySignupParams( + signupFormSettings: { + field: string; + show: boolean; + required: boolean; + }[], + values: any, + ) { const { username, email } = values; - const usernameSetting = signupForm.find((item: any) => item.field === 'username'); + const usernameSetting = signupFormSettings.find((item: any) => item.field === 'username'); if (usernameSetting && usernameSetting.show) { if ((username && !this.validateUsername(username)) || (usernameSetting.required && !username)) { throw new Error('Please enter a valid username'); } } - const emailSetting = signupForm.find((item: any) => item.field === 'email'); + const emailSetting = signupFormSettings.find((item: any) => item.field === 'email'); if (emailSetting && emailSetting.show) { if (email && !/^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/.test(email)) { throw new Error('Please enter a valid email address'); @@ -72,6 +94,13 @@ export class BasicAuth extends BaseAuth { throw new Error('Please enter a valid email address'); } } + + const requiredFields = signupFormSettings.filter((item: any) => item.show && item.required); + requiredFields.forEach((item: { field: string }) => { + if (!values[item.field]) { + throw new Error(`Please enter ${item.field}`); + } + }); } async signUp() { @@ -82,9 +111,10 @@ export class BasicAuth extends BaseAuth { } const User = ctx.db.getRepository('users'); const { values } = ctx.action.params; - const { username, email, password, confirm_password } = values; + const { password, confirm_password } = values; + const signupFormSettings = this.getSignupFormSettings(); try { - this.verfiySignupParams(values); + this.verfiySignupParams(signupFormSettings, values); } catch (error) { ctx.throw(400, this.ctx.t(error.message, { ns: namespace })); } @@ -94,7 +124,9 @@ export class BasicAuth extends BaseAuth { if (password !== confirm_password) { ctx.throw(400, ctx.t('The password is inconsistent, please re-enter', { ns: namespace })); } - const user = await User.create({ values: { username, email, password } }); + const fields = signupFormSettings.map((item: { field: string }) => item.field); + const userValues = _.pick(values, fields); + const user = await User.create({ values: { ...userValues, password } }); return user; } diff --git a/packages/plugins/@nocobase/plugin-auth/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-auth/src/server/plugin.ts index 98e7c66175..6cc42d42ea 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/server/plugin.ts +++ b/packages/plugins/@nocobase/plugin-auth/src/server/plugin.ts @@ -50,13 +50,46 @@ export class PluginAuthServer extends Plugin { this.app.authManager.registerTypes(presetAuthType, { auth: BasicAuth, title: tval('Password', { ns: namespace }), + getPublicOptions: (options) => { + const usersCollection = this.db.getCollection('users'); + let signupForm = options?.public?.signupForm || []; + signupForm = signupForm.filter((item: { show: boolean }) => item.show); + if ( + !( + signupForm.length && + signupForm.some( + (item: { field: string; show: boolean; required: boolean }) => + ['username', 'email'].includes(item.field) && item.show && item.required, + ) + ) + ) { + // At least one of the username or email fields is required + signupForm.unshift({ field: 'username', show: true, required: true }); + } + signupForm = signupForm + .filter((field: { show: boolean }) => field.show) + .map((item: { field: string; required: boolean }) => { + const field = usersCollection.getField(item.field); + return { + ...item, + uiSchema: { + ...field.options?.uiSchema, + required: item.required, + }, + }; + }); + return { + ...options?.public, + signupForm, + }; + }, }); // Register actions Object.entries(authActions).forEach( - ([action, handler]) => this.app.resourcer.getResource('auth')?.addAction(action, handler), + ([action, handler]) => this.app.resourceManager.getResource('auth')?.addAction(action, handler), ); Object.entries(authenticatorsActions).forEach(([action, handler]) => - this.app.resourcer.registerAction(`authenticators:${action}`, handler), + this.app.resourceManager.registerActionHandler(`authenticators:${action}`, handler), ); // Set up ACL ['check', 'signIn', 'signUp'].forEach((action) => this.app.acl.allow('auth', action));