Merge branch 'next' into develop

This commit is contained in:
nocobase[bot] 2024-11-27 06:25:20 +00:00
commit 049ddc95e9
9 changed files with 331 additions and 146 deletions

View File

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

View File

@ -7,18 +7,151 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { SchemaComponent } from '@nocobase/client'; import { SchemaComponent, useCollectionManager, useRecord } from '@nocobase/client';
import React from 'react'; import React, { useEffect, useMemo } from 'react';
import { lang, useAuthTranslation } from '../locale'; import { lang, useAuthTranslation } from '../locale';
import { FormTab, ArrayTable } from '@formily/antd-v5'; import { FormTab, ArrayTable } from '@formily/antd-v5';
import { Alert } from 'antd'; 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 (
<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 = () => { export const Options = () => {
const { t } = useAuthTranslation(); const { t } = useAuthTranslation();
return ( return (
<SchemaComponent <SchemaComponent
scope={{ t }} scope={{ t }}
components={{ Alert, FormTab, ArrayTable }} components={{ Alert, SignupFormSettings, FormTab }}
schema={{ schema={{
type: 'object', type: 'object',
properties: { properties: {
@ -52,96 +185,9 @@ export const Options = () => {
'x-component': 'Checkbox', 'x-component': 'Checkbox',
default: true, default: true,
}, },
signupForm: { [uid()]: {
title: '{{t("Sign up form")}}', type: 'void',
type: 'array', 'x-component': 'SignupFormSettings',
'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

@ -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) => { export const useSignUp = (props?: UseSignupProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const form = useForm(); const form = useForm();
@ -69,9 +52,10 @@ const getSignupPageSchema = (fieldSchemas: any): ISchema => ({
password: { password: {
type: 'string', type: 'string',
required: true, required: true,
title: '{{t("Password")}}',
'x-component': 'Password', 'x-component': 'Password',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component-props': { placeholder: '{{t("Password")}}', checkStrength: true, style: {} }, 'x-component-props': { checkStrength: true, style: {} },
'x-reactions': [ 'x-reactions': [
{ {
dependencies: ['.confirm_password'], dependencies: ['.confirm_password'],
@ -88,7 +72,8 @@ const getSignupPageSchema = (fieldSchemas: any): ISchema => ({
required: true, required: true,
'x-component': 'Password', 'x-component': 'Password',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component-props': { placeholder: '{{t("Confirm password")}}', style: {} }, title: '{{t("Confirm password")}}',
'x-component-props': { style: {} },
'x-reactions': [ 'x-reactions': [
{ {
dependencies: ['.password'], dependencies: ['.password'],
@ -140,22 +125,17 @@ export const SignUpForm = ({ authenticatorName: name }: { authenticatorName: str
}; };
const authenticator = useAuthenticator(name); const authenticator = useAuthenticator(name);
const { options } = authenticator; const { options } = authenticator;
let { signupForm } = options; const { signupForm } = options;
if (!(signupForm?.length && signupForm.some((item: any) => item.show && item.required))) {
signupForm = [{ field: 'username', show: true, required: true }];
}
const fieldSchemas = useMemo(() => { const fieldSchemas = useMemo(() => {
return signupForm return signupForm
.filter((field: { show: boolean }) => field.show) .filter((field: { show: boolean }) => field.show)
.reduce((prev: any, item: { field: string; required: boolean }) => { .reduce((prev: any, item: { field: string; required: boolean; uiSchema: any }) => {
const field = item.field; prev[item.field] = {
if (schemas[field]) { ...item.uiSchema,
prev[field] = schemas[field]; required: item.required,
if (item.required) { 'x-decorator': 'FormItem',
prev[field].required = true; };
} return prev;
return prev;
}
}, {}); }, {});
}, [signupForm]); }, [signupForm]);
if (!options?.allowSignUp) { if (!options?.allowSignUp) {

View File

@ -28,5 +28,5 @@
"Show": "Show", "Show": "Show",
"Sign up settings": "Sign up settings", "Sign up settings": "Sign up settings",
"Sign up form": "Sign up form", "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": "显示", "Show": "显示",
"Sign up settings": "注册设置", "Sign up settings": "注册设置",
"Sign up form": "注册表单", "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 () => { 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({ await repo.destroy({
truncate: true, truncate: true,
}); });
await repo.createMany({ await repo.createMany({
records: [ records: [
{ name: 'test', authType: 'testType', enabled: true, options: { public: { test: 1 }, private: { test: 2 } } }, { name: 'test', authType: 'testType', enabled: true, options: { public: { test: 1 }, private: { test: 2 } } },
{ name: 'test1', authType: 'testType1', enabled: true },
{ name: 'test2', authType: 'testType' }, { name: 'test2', authType: 'testType' },
], ],
}); });
const res = await agent.resource('authenticators').publicList(); 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].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 () => { it('should keep at least one authenticator', async () => {
@ -260,16 +272,37 @@ describe('actions', () => {
}); });
it('should check username when signing up', async () => { 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({ const res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({
username: '@@', username: '',
}); });
expect(res.statusCode).toEqual(400); expect(res.statusCode).toEqual(400);
expect(res.error.text).toBe('Please enter a valid username'); 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 () => { it('should check email when signing up', async () => {
@ -305,6 +338,31 @@ describe('actions', () => {
expect(res3.statusCode).toEqual(200); 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 () => { it('should check password when signing up', async () => {
const res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({ const res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({
username: 'new', username: 'new',
@ -313,6 +371,41 @@ describe('actions', () => {
expect(res.error.text).toBe('Please enter a password'); 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 () => { it('should sign user out when changing password', async () => {
const userRepo = db.getRepository('users'); const userRepo = db.getRepository('users');
const user = await userRepo.create({ const user = await userRepo.create({

View File

@ -49,7 +49,7 @@ export default {
authType: authenticator.authType, authType: authenticator.authType,
authTypeTitle: authType?.title || '', authTypeTitle: authType?.title || '',
title: authenticator.title, title: authenticator.title,
options: authenticator.options?.public || {}, options: authType?.getPublicOptions?.(authenticator.options) || authenticator.options?.public || {},
}; };
}); });
await next(); await next();

View File

@ -11,6 +11,7 @@ import { AuthConfig, BaseAuth } from '@nocobase/auth';
import { PasswordField } from '@nocobase/database'; import { PasswordField } from '@nocobase/database';
import crypto from 'crypto'; import crypto from 'crypto';
import { namespace } from '../preset'; import { namespace } from '../preset';
import _ from 'lodash';
export class BasicAuth extends BaseAuth { export class BasicAuth extends BaseAuth {
constructor(config: AuthConfig) { constructor(config: AuthConfig) {
@ -50,20 +51,41 @@ export class BasicAuth extends BaseAuth {
return user; return user;
} }
private verfiySignupParams(values: any) { private getSignupFormSettings() {
const options = this.authenticator.options?.public || {}; const options = this.authenticator.options?.public || {};
let { signupForm } = options; let { signupForm = [] } = options;
if (!(signupForm?.length && signupForm.some((item: any) => item.show && item.required))) { signupForm = signupForm.filter((item: { show: boolean }) => item.show);
signupForm = [{ field: 'username', show: true, required: true }]; 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 { 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 (usernameSetting && usernameSetting.show) {
if ((username && !this.validateUsername(username)) || (usernameSetting.required && !username)) { if ((username && !this.validateUsername(username)) || (usernameSetting.required && !username)) {
throw new Error('Please enter a valid 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 (emailSetting && emailSetting.show) {
if (email && !/^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/.test(email)) { if (email && !/^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/.test(email)) {
throw new Error('Please enter a valid email address'); 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'); 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() { async signUp() {
@ -82,9 +111,10 @@ export class BasicAuth extends BaseAuth {
} }
const User = ctx.db.getRepository('users'); const User = ctx.db.getRepository('users');
const { values } = ctx.action.params; const { values } = ctx.action.params;
const { username, email, password, confirm_password } = values; const { password, confirm_password } = values;
const signupFormSettings = this.getSignupFormSettings();
try { try {
this.verfiySignupParams(values); this.verfiySignupParams(signupFormSettings, values);
} catch (error) { } catch (error) {
ctx.throw(400, this.ctx.t(error.message, { ns: namespace })); ctx.throw(400, this.ctx.t(error.message, { ns: namespace }));
} }
@ -94,7 +124,9 @@ export class BasicAuth extends BaseAuth {
if (password !== confirm_password) { if (password !== confirm_password) {
ctx.throw(400, ctx.t('The password is inconsistent, please re-enter', { ns: namespace })); 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; return user;
} }

View File

@ -50,13 +50,46 @@ export class PluginAuthServer extends Plugin {
this.app.authManager.registerTypes(presetAuthType, { this.app.authManager.registerTypes(presetAuthType, {
auth: BasicAuth, auth: BasicAuth,
title: tval('Password', { ns: namespace }), 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 // Register actions
Object.entries(authActions).forEach( 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]) => Object.entries(authenticatorsActions).forEach(([action, handler]) =>
this.app.resourcer.registerAction(`authenticators:${action}`, handler), this.app.resourceManager.registerActionHandler(`authenticators:${action}`, handler),
); );
// Set up ACL // Set up ACL
['check', 'signIn', 'signUp'].forEach((action) => this.app.acl.allow('auth', action)); ['check', 'signIn', 'signUp'].forEach((action) => this.app.acl.allow('auth', action));