feat(basic-auth): allows to add more fields in sign up form (#5717)

* feat(basic-auth): allows to add more fields in sign up form

* feat: backend

* feat: add reactions

* fix: bug
This commit is contained in:
YANG QIA 2024-11-27 11:42:35 +08:00 committed by GitHub
parent ede87a18da
commit ac80dd8a3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 315 additions and 140 deletions

View File

@ -32,6 +32,7 @@ export type AuthManagerOptions = {
type AuthConfig = {
auth: AuthExtend<Auth>; // The authentication class.
title?: string; // The display name of the authentication type.
getPublicOptions?: (options: Record<string, any>) => Record<string, any>; // Get the public options.
};
export class AuthManager {

View File

@ -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' || field.name === 'email',
required: field.name === 'username',
});
}
}
return newValue;
}, [fields, record]);
useEffect(() => {
record.options = {
...record.options,
public: {
...record.options?.public,
signupForm: value,
},
};
}, [record, value]);
return (
<SchemaComponent
components={{ ArrayTable }}
schema={{
type: 'void',
properties: {
signupForm: {
title: '{{t("Sign up form")}}',
type: 'array',
'x-decorator': 'FormItem',
'x-component': 'ArrayTable',
'x-component-props': {
bordered: false,
},
'x-validator': `{{ (value) => {
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 (
<SchemaComponent
scope={{ t }}
components={{ Alert, FormTab, ArrayTable }}
components={{ Alert, SignupFormSettings, FormTab }}
schema={{
type: 'object',
properties: {
@ -52,96 +185,9 @@ export const Options = () => {
'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',
},
},
},

View File

@ -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'],
@ -147,15 +132,13 @@ export const SignUpForm = ({ authenticatorName: name }: { authenticatorName: str
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) {

View File

@ -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"
}

View File

@ -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": "至少需要设置用户名或邮箱中的一个字段为必填字段"
}

View File

@ -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({

View File

@ -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();

View File

@ -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;
}

View File

@ -50,6 +50,26 @@ 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');
const signupForm =
options?.public?.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(