feat(basic-auth): allows to configure sign up form (#5639)

* feat(auth): allows to configure signup form

* feat(basic-auth): allows to configure sign up form

* test: remove only
This commit is contained in:
YANG QIA 2024-11-13 15:16:52 +08:00 committed by GitHub
parent 31351f2d35
commit 6198c99706
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 246 additions and 32 deletions

View File

@ -9,7 +9,8 @@
import { SchemaComponent } from '@nocobase/client';
import React from 'react';
import { useAuthTranslation } from '../locale';
import { lang, useAuthTranslation } from '../locale';
import { FormTab, ArrayTable } from '@formily/antd-v5';
import { Alert } from 'antd';
export const Options = () => {
@ -17,30 +18,137 @@ export const Options = () => {
return (
<SchemaComponent
scope={{ t }}
components={{ Alert }}
components={{ Alert, FormTab, ArrayTable }}
schema={{
type: 'object',
properties: {
public: {
type: 'object',
properties: {
allowSignUp: {
'x-decorator': 'FormItem',
type: 'boolean',
title: '{{t("Allow to sign up")}}',
'x-component': 'Checkbox',
default: true,
},
},
},
notice: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': 'Alert',
'x-component-props': {
showIcon: true,
message: '{{t("The authentication allows users to sign in via username or email.")}}',
},
},
public: {
type: 'object',
properties: {
collapse: {
type: 'void',
'x-component': 'FormTab',
properties: {
basic: {
type: 'void',
'x-component': 'FormTab.TabPane',
'x-component-props': {
tab: lang('Sign up settings'),
},
properties: {
allowSignUp: {
'x-decorator': 'FormItem',
type: 'boolean',
title: '{{t("Allow to sign up")}}',
'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',
},
},
},
},
},
},
},
},
},
},
},
},
},
}}
/>

View File

@ -9,7 +9,7 @@
import { SchemaComponent } from '@nocobase/client';
import { ISchema } from '@formily/react';
import React from 'react';
import React, { useMemo } from 'react';
import { uid } from '@formily/shared';
import { useAuthTranslation } from '../locale';
import { useAPIClient } from '@nocobase/client';
@ -26,6 +26,23 @@ 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();
@ -43,19 +60,12 @@ export const useSignUp = (props?: UseSignupProps) => {
};
};
const signupPageSchema: ISchema = {
const getSignupPageSchema = (fieldSchemas: any): ISchema => ({
type: 'object',
name: uid(),
'x-component': 'FormV2',
properties: {
username: {
type: 'string',
required: true,
'x-component': 'Input',
'x-validator': { username: true },
'x-decorator': 'FormItem',
'x-component-props': { placeholder: '{{t("Username")}}', style: {} },
},
...fieldSchemas,
password: {
type: 'string',
required: true,
@ -121,7 +131,7 @@ const signupPageSchema: ISchema = {
},
},
},
};
});
export const SignUpForm = ({ authenticatorName: name }: { authenticatorName: string }) => {
const { t } = useAuthTranslation();
@ -130,8 +140,27 @@ 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 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;
}
}, {});
}, [signupForm]);
if (!options?.allowSignUp) {
return <Navigate to="/not-found" replace={true} />;
}
return <SchemaComponent schema={signupPageSchema} scope={{ useBasicSignUp, t }} />;
const schema = getSignupPageSchema(fieldSchemas);
return <SchemaComponent schema={schema} scope={{ useBasicSignUp, t }} />;
};

View File

@ -8,9 +8,14 @@
*/
import { useTranslation } from 'react-i18next';
import { i18n } from '@nocobase/client';
export const NAMESPACE = 'auth';
export function useAuthTranslation() {
return useTranslation([NAMESPACE, 'client'], { nsMode: 'fallback' });
}
export function lang(key: string) {
return i18n.t(key, { ns: [NAMESPACE, 'client'], nsMode: 'fallback' });
}

View File

@ -24,5 +24,9 @@
"The password is inconsistent, please re-enter": "The password is inconsistent, please re-enter",
"Sign-in": "Sign-in",
"Password": "Password",
"The username/email or password is incorrect, please re-enter": "The username/email or password is incorrect, please re-enter"
"The username/email or password is incorrect, please re-enter": "The username/email or password is incorrect, please re-enter",
"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"
}

View File

@ -24,5 +24,9 @@
"The password is inconsistent, please re-enter": "密码不一致,请重新输入",
"Sign-in": "登录",
"Password": "密码",
"The username/email or password is incorrect, please re-enter": "用户名/邮箱或密码有误,请重新输入"
"The username/email or password is incorrect, please re-enter": "用户名/邮箱或密码有误,请重新输入",
"Show": "显示",
"Sign up settings": "注册设置",
"Sign up form": "注册表单",
"At least one field is required": "至少需要设置一个必填字段"
}

View File

@ -260,6 +260,11 @@ 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: '@@',
});
@ -267,6 +272,39 @@ describe('actions', () => {
expect(res.error.text).toBe('Please enter a valid username');
});
it('should check email when signing up', async () => {
const repo = db.getRepository('authenticators');
await repo.update({
filter: {
name: 'basic',
},
values: {
options: {
public: {
allowSignUp: true,
signupForm: [{ field: 'email', show: true, required: true }],
},
},
},
});
const res1 = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({
email: '',
});
expect(res1.statusCode).toEqual(400);
expect(res1.error.text).toBe('Please enter a valid email address');
const res2 = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({
email: 'abc',
});
expect(res2.statusCode).toEqual(400);
expect(res2.error.text).toBe('Please enter a valid email address');
const res3 = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({
email: 'test1@nocobase.com',
password: '123',
confirm_password: '123',
});
expect(res3.statusCode).toEqual(200);
});
it('should check password when signing up', async () => {
const res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({
username: 'new',

View File

@ -50,6 +50,30 @@ export class BasicAuth extends BaseAuth {
return user;
}
private verfiySignupParams(values: any) {
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 }];
}
const { username, email } = values;
const usernameSetting = signupForm.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');
if (emailSetting && emailSetting.show) {
if (email && !/^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/.test(email)) {
throw new Error('Please enter a valid email address');
}
if (emailSetting.required && !email) {
throw new Error('Please enter a valid email address');
}
}
}
async signUp() {
const ctx = this.ctx;
const options = this.authenticator.options?.public || {};
@ -58,9 +82,11 @@ export class BasicAuth extends BaseAuth {
}
const User = ctx.db.getRepository('users');
const { values } = ctx.action.params;
const { username, password, confirm_password } = values;
if (!this.validateUsername(username)) {
ctx.throw(400, ctx.t('Please enter a valid username', { ns: namespace }));
const { username, email, password, confirm_password } = values;
try {
this.verfiySignupParams(values);
} catch (error) {
ctx.throw(400, this.ctx.t(error.message, { ns: namespace }));
}
if (!password) {
ctx.throw(400, ctx.t('Please enter a password', { ns: namespace }));
@ -68,7 +94,7 @@ 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, password } });
const user = await User.create({ values: { username, email, password } });
return user;
}