feat(auth): support signing in with username (#2376)
* feat(auth): support signing in with username * chore: compatibility && add INIT_ROOT_USERNAME * chore: add default username of root user * chore: add notice * fix: typo * chore: change rule of username * fix: sqlite add unique constraint --------- Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
parent
972b2be10e
commit
be6b949f55
@ -45,6 +45,7 @@ DB_TABLE_PREFIX=
|
|||||||
INIT_ROOT_EMAIL=admin@nocobase.com
|
INIT_ROOT_EMAIL=admin@nocobase.com
|
||||||
INIT_ROOT_PASSWORD=admin123
|
INIT_ROOT_PASSWORD=admin123
|
||||||
INIT_ROOT_NICKNAME=Super Admin
|
INIT_ROOT_NICKNAME=Super Admin
|
||||||
|
INIT_ROOT_USERNAME=nocobase
|
||||||
|
|
||||||
# local or ali-oss
|
# local or ali-oss
|
||||||
DEFAULT_STORAGE_TYPE=local
|
DEFAULT_STORAGE_TYPE=local
|
||||||
|
@ -29,6 +29,7 @@ DB_STORAGE=storage/db/nocobase-test.sqlite
|
|||||||
INIT_ROOT_EMAIL=admin@nocobase.com
|
INIT_ROOT_EMAIL=admin@nocobase.com
|
||||||
INIT_ROOT_PASSWORD=admin123
|
INIT_ROOT_PASSWORD=admin123
|
||||||
INIT_ROOT_NICKNAME=Super Admin
|
INIT_ROOT_NICKNAME=Super Admin
|
||||||
|
INIT_ROOT_USERNAME=nocobase
|
||||||
|
|
||||||
# local or ali-oss
|
# local or ali-oss
|
||||||
DEFAULT_STORAGE_TYPE=local
|
DEFAULT_STORAGE_TYPE=local
|
||||||
|
@ -17,6 +17,7 @@ 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.
|
||||||
};
|
};
|
||||||
|
|
||||||
export class AuthManager {
|
export class AuthManager {
|
||||||
@ -52,7 +53,10 @@ export class AuthManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
listTypes() {
|
listTypes() {
|
||||||
return Array.from(this.authTypes.getKeys());
|
return Array.from(this.authTypes.getEntities()).map(([authType, authConfig]) => ({
|
||||||
|
name: authType,
|
||||||
|
title: authConfig.title,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
getAuthConfig(authType: string) {
|
getAuthConfig(authType: string) {
|
||||||
|
@ -33,3 +33,4 @@ export * from './time';
|
|||||||
export * from './updatedAt';
|
export * from './updatedAt';
|
||||||
export * from './updatedBy';
|
export * from './updatedBy';
|
||||||
export * from './url';
|
export * from './url';
|
||||||
|
export * from './username';
|
||||||
|
@ -0,0 +1,40 @@
|
|||||||
|
import { i18n } from '../../i18n';
|
||||||
|
import { defaultProps, operators, unique } from './properties';
|
||||||
|
import { IField } from './types';
|
||||||
|
import { registerValidateRules } from '@formily/core';
|
||||||
|
|
||||||
|
registerValidateRules({
|
||||||
|
username(value) {
|
||||||
|
return /^[^@.<>"'/]{2,16}$/.test(value) || i18n.t('2 to 16 characters (excluding @.<>"\'/)');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const username: IField = {
|
||||||
|
name: 'username',
|
||||||
|
type: 'object',
|
||||||
|
group: 'basic',
|
||||||
|
order: 1,
|
||||||
|
title: '{{t("Username")}}',
|
||||||
|
sortable: true,
|
||||||
|
default: {
|
||||||
|
type: 'string',
|
||||||
|
// name,
|
||||||
|
uiSchema: {
|
||||||
|
type: 'string',
|
||||||
|
// title,
|
||||||
|
'x-component': 'Input',
|
||||||
|
'x-validator': { username: true },
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
availableTypes: ['string'],
|
||||||
|
hasDefaultValue: false,
|
||||||
|
properties: {
|
||||||
|
...defaultProps,
|
||||||
|
unique,
|
||||||
|
},
|
||||||
|
filterable: {
|
||||||
|
operators: operators.string,
|
||||||
|
},
|
||||||
|
titleUsable: true,
|
||||||
|
};
|
@ -247,6 +247,7 @@ export default {
|
|||||||
"Long text": "多行文本",
|
"Long text": "多行文本",
|
||||||
"Phone": "手机号码",
|
"Phone": "手机号码",
|
||||||
"Email": "电子邮箱",
|
"Email": "电子邮箱",
|
||||||
|
"Username": "用户名",
|
||||||
'Null': '空值',
|
'Null': '空值',
|
||||||
"Boolean": "逻辑值",
|
"Boolean": "逻辑值",
|
||||||
"Number": "数字",
|
"Number": "数字",
|
||||||
@ -472,6 +473,7 @@ export default {
|
|||||||
"Verification code": "验证码",
|
"Verification code": "验证码",
|
||||||
"Send code": "发送验证码",
|
"Send code": "发送验证码",
|
||||||
"Retry after {{count}} seconds": "{{count}} 秒后重试",
|
"Retry after {{count}} seconds": "{{count}} 秒后重试",
|
||||||
|
'2 to 16 characters (excluding @.<>"\'/)': '2到16个字符(不包含@.<>"\'/)',
|
||||||
|
|
||||||
"Roles": "角色",
|
"Roles": "角色",
|
||||||
"Add role": "添加角色",
|
"Add role": "添加角色",
|
||||||
|
@ -184,7 +184,7 @@ export const CurrentUser = () => {
|
|||||||
`}
|
`}
|
||||||
style={{ cursor: 'pointer', border: 0, padding: '16px', color: 'rgba(255, 255, 255, 0.65)' }}
|
style={{ cursor: 'pointer', border: 0, padding: '16px', color: 'rgba(255, 255, 255, 0.65)' }}
|
||||||
>
|
>
|
||||||
{data?.data?.nickname || data?.data?.email}
|
{data?.data?.nickname || data?.data?.username || data?.data?.email}
|
||||||
</span>
|
</span>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</DropdownVisibleContext.Provider>
|
</DropdownVisibleContext.Provider>
|
||||||
|
@ -70,6 +70,13 @@ const schema: ISchema = {
|
|||||||
title: "{{t('Nickname')}}",
|
title: "{{t('Nickname')}}",
|
||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
'x-component': 'Input',
|
'x-component': 'Input',
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
type: 'string',
|
||||||
|
title: '{{t("Username")}}',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Input',
|
||||||
|
'x-validator': { username: true },
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
email: {
|
email: {
|
||||||
@ -78,7 +85,6 @@ const schema: ISchema = {
|
|||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
'x-component': 'Input',
|
'x-component': 'Input',
|
||||||
'x-validator': 'email',
|
'x-validator': 'email',
|
||||||
required: true,
|
|
||||||
},
|
},
|
||||||
phone: {
|
phone: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@ -86,7 +92,6 @@ const schema: ISchema = {
|
|||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
'x-component': 'Input',
|
'x-component': 'Input',
|
||||||
'x-validator': 'phone',
|
'x-validator': 'phone',
|
||||||
required: true,
|
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
'x-component': 'Action.Drawer.Footer',
|
'x-component': 'Action.Drawer.Footer',
|
||||||
|
@ -14,3 +14,4 @@ DB_DIALECT={{{env.DB_DIALECT}}}
|
|||||||
INIT_ROOT_EMAIL=admin@nocobase.com
|
INIT_ROOT_EMAIL=admin@nocobase.com
|
||||||
INIT_ROOT_PASSWORD=admin123
|
INIT_ROOT_PASSWORD=admin123
|
||||||
INIT_ROOT_NICKNAME=Super Admin
|
INIT_ROOT_NICKNAME=Super Admin
|
||||||
|
INIT_ROOT_USERNAME=nocobase
|
||||||
|
@ -30,7 +30,7 @@ export const AuthProvider: FC = (props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<OptionsComponentProvider authType={presetAuthType} component={Options}>
|
<OptionsComponentProvider authType={presetAuthType} component={Options}>
|
||||||
<SigninPageProvider authType={presetAuthType} tabTitle={t('Sign in via email')} component={SigninPage}>
|
<SigninPageProvider authType={presetAuthType} tabTitle={t('Sign in via password')} component={SigninPage}>
|
||||||
<SignupPageProvider authType={presetAuthType} component={SignupPage}>
|
<SignupPageProvider authType={presetAuthType} component={SignupPage}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</SignupPageProvider>
|
</SignupPageProvider>
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import { SchemaComponent } from '@nocobase/client';
|
import { SchemaComponent } from '@nocobase/client';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useAuthTranslation } from '../locale';
|
import { useAuthTranslation } from '../locale';
|
||||||
|
import { Alert } from 'antd';
|
||||||
|
|
||||||
export const Options = () => {
|
export const Options = () => {
|
||||||
const { t } = useAuthTranslation();
|
const { t } = useAuthTranslation();
|
||||||
return (
|
return (
|
||||||
<SchemaComponent
|
<SchemaComponent
|
||||||
scope={{ t }}
|
scope={{ t }}
|
||||||
|
components={{ Alert }}
|
||||||
schema={{
|
schema={{
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@ -24,6 +26,14 @@ export const Options = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
notice: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Alert',
|
||||||
|
'x-component-props': {
|
||||||
|
showIcon: true,
|
||||||
|
message: '{{t("The authentication allows users to sign in via username or email.")}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -1,19 +1,30 @@
|
|||||||
import { ISchema } from '@formily/react';
|
import { ISchema } from '@formily/react';
|
||||||
import { Authenticator, SchemaComponent, SignupPageContext, useSignIn } from '@nocobase/client';
|
import { Authenticator, SchemaComponent, SignupPageContext, useSignIn } from '@nocobase/client';
|
||||||
import React, { useContext } from 'react';
|
import React, { useContext } from 'react';
|
||||||
|
import { useAuthTranslation } from '../locale';
|
||||||
|
|
||||||
const passwordForm: ISchema = {
|
const passwordForm: ISchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
name: 'passwordForm',
|
name: 'passwordForm',
|
||||||
'x-component': 'FormV2',
|
'x-component': 'FormV2',
|
||||||
properties: {
|
properties: {
|
||||||
email: {
|
account: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
required: true,
|
|
||||||
'x-component': 'Input',
|
'x-component': 'Input',
|
||||||
'x-validator': 'email',
|
'x-validator': `{{(value) => {
|
||||||
|
if (!value) {
|
||||||
|
return t("Please enter your username or email");
|
||||||
|
}
|
||||||
|
if (value.includes('@')) {
|
||||||
|
if (!/^[\\w-]+(\\.[\\w-]+)*@[\\w-]+(\\.[\\w-]+)+$/.test(value)) {
|
||||||
|
return t("Please enter a valid email");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return /^[^@.<>"'/]{2,16}$/.test(value) || t("Please enter a valid username");
|
||||||
|
}
|
||||||
|
}}}`,
|
||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
'x-component-props': { placeholder: '{{t("Email")}}', style: {} },
|
'x-component-props': { placeholder: '{{t("Username/Email")}}', style: {} },
|
||||||
},
|
},
|
||||||
password: {
|
password: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@ -52,6 +63,7 @@ const passwordForm: ISchema = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
export default (props: { authenticator: Authenticator }) => {
|
export default (props: { authenticator: Authenticator }) => {
|
||||||
|
const { t } = useAuthTranslation();
|
||||||
const authenticator = props.authenticator;
|
const authenticator = props.authenticator;
|
||||||
const { authType, name, options } = authenticator;
|
const { authType, name, options } = authenticator;
|
||||||
const signupPages = useContext(SignupPageContext);
|
const signupPages = useContext(SignupPageContext);
|
||||||
@ -61,5 +73,5 @@ export default (props: { authenticator: Authenticator }) => {
|
|||||||
const useBasicSignIn = () => {
|
const useBasicSignIn = () => {
|
||||||
return useSignIn(name);
|
return useSignIn(name);
|
||||||
};
|
};
|
||||||
return <SchemaComponent schema={passwordForm} scope={{ useBasicSignIn, allowSignUp, signupLink }} />;
|
return <SchemaComponent schema={passwordForm} scope={{ useBasicSignIn, allowSignUp, signupLink, t }} />;
|
||||||
};
|
};
|
||||||
|
@ -2,19 +2,20 @@ import { SchemaComponent, useSignup } from '@nocobase/client';
|
|||||||
import { ISchema } from '@formily/react';
|
import { ISchema } from '@formily/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { uid } from '@formily/shared';
|
import { uid } from '@formily/shared';
|
||||||
|
import { useAuthTranslation } from '../locale';
|
||||||
|
|
||||||
const signupPageSchema: ISchema = {
|
const signupPageSchema: ISchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
name: uid(),
|
name: uid(),
|
||||||
'x-component': 'FormV2',
|
'x-component': 'FormV2',
|
||||||
properties: {
|
properties: {
|
||||||
email: {
|
username: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
required: true,
|
required: true,
|
||||||
'x-component': 'Input',
|
'x-component': 'Input',
|
||||||
'x-validator': 'email',
|
'x-validator': { username: true },
|
||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
'x-component-props': { placeholder: '{{t("Email")}}', style: {} },
|
'x-component-props': { placeholder: '{{t("Username")}}', style: {} },
|
||||||
},
|
},
|
||||||
password: {
|
password: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@ -84,8 +85,9 @@ const signupPageSchema: ISchema = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default (props: { name: string }) => {
|
export default (props: { name: string }) => {
|
||||||
|
const { t } = useAuthTranslation();
|
||||||
const useBasicSignup = () => {
|
const useBasicSignup = () => {
|
||||||
return useSignup({ authenticator: props.name });
|
return useSignup({ authenticator: props.name });
|
||||||
};
|
};
|
||||||
return <SchemaComponent schema={signupPageSchema} scope={{ useBasicSignup }} />;
|
return <SchemaComponent schema={signupPageSchema} scope={{ useBasicSignup, t }} />;
|
||||||
};
|
};
|
||||||
|
@ -14,6 +14,7 @@ import { PlusOutlined, DownOutlined } from '@ant-design/icons';
|
|||||||
import { AuthTypeContext, AuthTypesContext, useAuthTypes } from './authType';
|
import { AuthTypeContext, AuthTypesContext, useAuthTypes } from './authType';
|
||||||
import { useValuesFromOptions, Options } from './Options';
|
import { useValuesFromOptions, Options } from './Options';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAuthTranslation } from '../locale';
|
||||||
|
|
||||||
const useCloseAction = () => {
|
const useCloseAction = () => {
|
||||||
const { setVisible } = useActionContext();
|
const { setVisible } = useActionContext();
|
||||||
@ -59,6 +60,7 @@ const useCanNotDelete = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Authenticator = () => {
|
export const Authenticator = () => {
|
||||||
|
const { t } = useAuthTranslation();
|
||||||
const [types, setTypes] = useState([]);
|
const [types, setTypes] = useState([]);
|
||||||
const api = useAPIClient();
|
const api = useAPIClient();
|
||||||
useRequest(
|
useRequest(
|
||||||
@ -68,10 +70,10 @@ export const Authenticator = () => {
|
|||||||
.listTypes()
|
.listTypes()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const types = res?.data?.data || [];
|
const types = res?.data?.data || [];
|
||||||
return types.map((type: string) => ({
|
return types.map((type: { name: string; title?: string }) => ({
|
||||||
key: type,
|
key: type.name,
|
||||||
label: type,
|
label: t(type.title || type.name),
|
||||||
value: type,
|
value: type.name,
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
@ -87,7 +89,7 @@ export const Authenticator = () => {
|
|||||||
<SchemaComponent
|
<SchemaComponent
|
||||||
schema={authenticatorsSchema}
|
schema={authenticatorsSchema}
|
||||||
components={{ AddNew, Options }}
|
components={{ AddNew, Options }}
|
||||||
scope={{ types, useValuesFromOptions, useCanNotDelete }}
|
scope={{ types, useValuesFromOptions, useCanNotDelete, t }}
|
||||||
/>
|
/>
|
||||||
</AuthTypesContext.Provider>
|
</AuthTypesContext.Provider>
|
||||||
</Card>
|
</Card>
|
||||||
|
@ -21,7 +21,7 @@ const collection = {
|
|||||||
name: 'name',
|
name: 'name',
|
||||||
uiSchema: {
|
uiSchema: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
title: '{{t("Name")}}',
|
title: '{{t("Auth UID")}}',
|
||||||
'x-component': 'Input',
|
'x-component': 'Input',
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
@ -32,8 +32,9 @@ const collection = {
|
|||||||
name: 'authType',
|
name: 'authType',
|
||||||
uiSchema: {
|
uiSchema: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
title: '{{t("Auth Type", {ns: "auth"})}}',
|
title: '{{t("Auth Type")}}',
|
||||||
'x-component': 'Select',
|
'x-component': 'Select',
|
||||||
|
dataSource: '{{ types }}',
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -251,14 +252,16 @@ export const authenticatorsSchema: ISchema = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
authType: {
|
authType: {
|
||||||
|
title: '{{t("Auth Type")}}',
|
||||||
type: 'void',
|
type: 'void',
|
||||||
'x-decorator': 'Table.Column.Decorator',
|
'x-decorator': 'Table.Column.Decorator',
|
||||||
'x-component': 'Table.Column',
|
'x-component': 'Table.Column',
|
||||||
properties: {
|
properties: {
|
||||||
authType: {
|
authType: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
'x-component': 'CollectionField',
|
'x-component': 'Select',
|
||||||
'x-read-pretty': true,
|
'x-read-pretty': true,
|
||||||
|
enum: '{{ types }}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -3,15 +3,23 @@ const locale = {
|
|||||||
Authenticators: '认证器',
|
Authenticators: '认证器',
|
||||||
Authentication: '用户认证',
|
Authentication: '用户认证',
|
||||||
'Sign in via email': '邮箱登录',
|
'Sign in via email': '邮箱登录',
|
||||||
|
'Sign in via password': '密码登录',
|
||||||
'Not allowed to sign up': '禁止注册',
|
'Not allowed to sign up': '禁止注册',
|
||||||
'Allow to sign up': '允许注册',
|
'Allow to sign up': '允许注册',
|
||||||
'The email is incorrect, please re-enter': '邮箱有误,请重新输入',
|
'The username or email is incorrect, please re-enter': '用户名或邮箱有误,请重新输入',
|
||||||
'Please fill in your email address': '请填写邮箱',
|
|
||||||
'The password is incorrect, please re-enter': '密码有误,请重新输入',
|
'The password is incorrect, please re-enter': '密码有误,请重新输入',
|
||||||
'Not a valid cellphone number, please re-enter': '不是有效的手机号,请重新输入',
|
'Not a valid cellphone number, please re-enter': '不是有效的手机号,请重新输入',
|
||||||
'The phone number has been registered, please login directly': '手机号已注册,请直接登录',
|
'The phone number has been registered, please login directly': '手机号已注册,请直接登录',
|
||||||
'The phone number is not registered, please register first': '手机号未注册,请先注册',
|
'The phone number is not registered, please register first': '手机号未注册,请先注册',
|
||||||
'Please keep and enable at least one authenticator': '请至少保留并启用一个认证器',
|
'Please keep and enable at least one authenticator': '请至少保留并启用一个认证器',
|
||||||
|
'Allow to sign in with': '允许使用以下方式登录',
|
||||||
|
'Please enter a valid username': '请输入有效的用户名',
|
||||||
|
'Please enter a valid email': '请输入有效的邮箱',
|
||||||
|
'Please enter your username or email': '请输入用户名或邮箱',
|
||||||
|
SMS: '短信',
|
||||||
|
'Username/Email': '用户名/邮箱',
|
||||||
|
'Auth UID': '认证标识',
|
||||||
|
'The authentication allows users to sign in via username or email.': '该认证方式支持用户通过用户名或邮箱登录。',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default locale;
|
export default locale;
|
||||||
|
@ -32,7 +32,12 @@ describe('actions', () => {
|
|||||||
|
|
||||||
it('should list authenticator types', async () => {
|
it('should list authenticator types', async () => {
|
||||||
const res = await agent.resource('authenticators').listTypes();
|
const res = await agent.resource('authenticators').listTypes();
|
||||||
expect(res.body.data).toEqual(['Email/Password']);
|
expect(res.body.data).toEqual([
|
||||||
|
{
|
||||||
|
name: 'Email/Password',
|
||||||
|
title: 'Password',
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return enabled authenticators with public options', async () => {
|
it('should return enabled authenticators with public options', async () => {
|
||||||
@ -82,6 +87,7 @@ describe('actions', () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = mockServer();
|
app = mockServer();
|
||||||
process.env.INIT_ROOT_EMAIL = 'test@nocobase.com';
|
process.env.INIT_ROOT_EMAIL = 'test@nocobase.com';
|
||||||
|
process.env.INT_ROOT_USERNAME = 'test';
|
||||||
process.env.INIT_ROOT_PASSWORD = '123456';
|
process.env.INIT_ROOT_PASSWORD = '123456';
|
||||||
process.env.INIT_ROOT_NICKNAME = 'Test';
|
process.env.INIT_ROOT_NICKNAME = 'Test';
|
||||||
app.plugin(AuthPlugin);
|
app.plugin(AuthPlugin);
|
||||||
@ -95,14 +101,17 @@ describe('actions', () => {
|
|||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should sign in with email and password', async () => {
|
it('should sign in with password', async () => {
|
||||||
let res = await agent.resource('auth').check();
|
let res = await agent.resource('auth').check();
|
||||||
expect(res.body.data.id).toBeUndefined();
|
expect(res.body.data.id).toBeUndefined();
|
||||||
|
|
||||||
res = await agent.post('/auth:signIn').set({ 'X-Authenticator': 'basic' }).send({
|
res = await agent
|
||||||
email: process.env.INIT_ROOT_EMAIL,
|
.post('/auth:signIn')
|
||||||
password: process.env.INIT_ROOT_PASSWORD,
|
.set({ 'X-Authenticator': 'basic' })
|
||||||
});
|
.send({
|
||||||
|
account: process.env.INIT_ROOT_USERNAME || process.env.INIT_ROOT_EMAIL,
|
||||||
|
password: process.env.INIT_ROOT_PASSWORD,
|
||||||
|
});
|
||||||
expect(res.statusCode).toEqual(200);
|
expect(res.statusCode).toEqual(200);
|
||||||
const data = res.body.data;
|
const data = res.body.data;
|
||||||
const token = data.token;
|
const token = data.token;
|
||||||
@ -114,7 +123,7 @@ describe('actions', () => {
|
|||||||
|
|
||||||
it('should disable sign up', async () => {
|
it('should disable sign up', async () => {
|
||||||
let res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({
|
let res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({
|
||||||
email: 'new',
|
username: 'new',
|
||||||
password: 'new',
|
password: 'new',
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toEqual(200);
|
expect(res.statusCode).toEqual(200);
|
||||||
@ -133,7 +142,7 @@ describe('actions', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({
|
res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({
|
||||||
email: process.env.INIT_ROOT_EMAIL,
|
username: process.env.INIT_ROOT_USERNAME,
|
||||||
password: process.env.INIT_ROOT_PASSWORD,
|
password: process.env.INIT_ROOT_PASSWORD,
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toEqual(403);
|
expect(res.statusCode).toEqual(403);
|
||||||
|
@ -11,23 +11,29 @@ export class BasicAuth extends BaseAuth {
|
|||||||
|
|
||||||
async validate() {
|
async validate() {
|
||||||
const ctx = this.ctx;
|
const ctx = this.ctx;
|
||||||
const { uniqueField = 'email', values } = ctx.action.params;
|
const {
|
||||||
|
values: {
|
||||||
|
account, // Username or email
|
||||||
|
email, // Old parameter, compatible with old api
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
} = ctx.action.params;
|
||||||
|
|
||||||
if (!values[uniqueField]) {
|
if (!account && !email) {
|
||||||
ctx.throw(400, ctx.t('Please fill in your email address', { ns: namespace }));
|
ctx.throw(400, ctx.t('Please enter your username or email', { ns: namespace }));
|
||||||
}
|
}
|
||||||
const user = await this.userRepository.findOne({
|
const user = await this.userRepository.findOne({
|
||||||
where: {
|
filter: {
|
||||||
[uniqueField]: values[uniqueField],
|
$or: [{ username: account || null }, { email: account || email }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
ctx.throw(401, ctx.t('The email is incorrect, please re-enter', { ns: namespace }));
|
ctx.throw(401, ctx.t('The username or email is incorrect, please re-enter', { ns: namespace }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const field = this.userCollection.getField<PasswordField>('password');
|
const field = this.userCollection.getField<PasswordField>('password');
|
||||||
const valid = await field.verify(values.password, user.password);
|
const valid = await field.verify(password, user.password);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
ctx.throw(401, ctx.t('The password is incorrect, please re-enter', { ns: namespace }));
|
ctx.throw(401, ctx.t('The password is incorrect, please re-enter', { ns: namespace }));
|
||||||
}
|
}
|
||||||
@ -42,6 +48,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 } = values;
|
||||||
|
if (!/^[^@.<>"'/]{2,16}$/.test(username)) {
|
||||||
|
ctx.throw(400, ctx.t('Please enter a valid username', { ns: namespace }));
|
||||||
|
}
|
||||||
const user = await User.create({ values });
|
const user = await User.create({ values });
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
export default {
|
export default {
|
||||||
'The email is incorrect, please re-enter': '邮箱有误,请重新输入',
|
'The username or email is incorrect, please re-enter': '用户名或邮箱有误,请重新输入',
|
||||||
'Please fill in your email address': '请填写邮箱',
|
|
||||||
'The password is incorrect, please re-enter': '密码有误,请重新输入',
|
'The password is incorrect, please re-enter': '密码有误,请重新输入',
|
||||||
'Not a valid cellphone number, please re-enter': '不是有效的手机号,请重新输入',
|
'Not a valid cellphone number, please re-enter': '不是有效的手机号,请重新输入',
|
||||||
'The phone number has been registered, please login directly': '手机号已注册,请直接登录',
|
'The phone number has been registered, please login directly': '手机号已注册,请直接登录',
|
||||||
'The phone number is not registered, please register first': '手机号未注册,请先注册',
|
'The phone number is not registered, please register first': '手机号未注册,请先注册',
|
||||||
'Please keep and enable at least one authenticator': '请至少保留并启用一个认证器',
|
'Please keep and enable at least one authenticator': '请至少保留并启用一个认证器',
|
||||||
|
'Please enter your username or email': '请输入用户名或邮箱',
|
||||||
|
'Please enter a valid username': '请输入有效的用户名',
|
||||||
};
|
};
|
||||||
|
@ -12,7 +12,7 @@ export default class AddBasicAuthMigration extends Migration {
|
|||||||
values: {
|
values: {
|
||||||
name: presetAuthenticator,
|
name: presetAuthenticator,
|
||||||
authType: presetAuthType,
|
authType: presetAuthType,
|
||||||
description: 'Sign in with email and password.',
|
description: 'Sign in with username/email.',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -47,6 +47,7 @@ export class AuthPlugin extends Plugin {
|
|||||||
|
|
||||||
this.app.authManager.registerTypes(presetAuthType, {
|
this.app.authManager.registerTypes(presetAuthType, {
|
||||||
auth: BasicAuth,
|
auth: BasicAuth,
|
||||||
|
title: 'Password',
|
||||||
});
|
});
|
||||||
// Register actions
|
// Register actions
|
||||||
Object.entries(authActions).forEach(([action, handler]) =>
|
Object.entries(authActions).forEach(([action, handler]) =>
|
||||||
@ -76,7 +77,7 @@ export class AuthPlugin extends Plugin {
|
|||||||
values: {
|
values: {
|
||||||
name: presetAuthenticator,
|
name: presetAuthenticator,
|
||||||
authType: presetAuthType,
|
authType: presetAuthType,
|
||||||
description: 'Sign in with email and password.',
|
description: 'Sign in with username/email.',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
options: {
|
options: {
|
||||||
public: {
|
public: {
|
||||||
|
@ -39,7 +39,6 @@ export class SMSAuth extends BaseAuth {
|
|||||||
}
|
}
|
||||||
// New data
|
// New data
|
||||||
const { autoSignup } = this.authenticator.options?.public || {};
|
const { autoSignup } = this.authenticator.options?.public || {};
|
||||||
console.log(autoSignup);
|
|
||||||
const authenticator = this.authenticator as AuthModel;
|
const authenticator = this.authenticator as AuthModel;
|
||||||
if (autoSignup) {
|
if (autoSignup) {
|
||||||
user = await authenticator.findOrCreateUser(phone, {
|
user = await authenticator.findOrCreateUser(phone, {
|
||||||
|
@ -33,6 +33,19 @@ export default {
|
|||||||
'x-component': 'Input',
|
'x-component': 'Input',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
interface: 'username',
|
||||||
|
type: 'string',
|
||||||
|
name: 'username',
|
||||||
|
unique: true,
|
||||||
|
uiSchema: {
|
||||||
|
type: 'string',
|
||||||
|
title: '{{t("Username")}}',
|
||||||
|
'x-component': 'Input',
|
||||||
|
'x-validator': { username: true },
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
interface: 'email',
|
interface: 'email',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
@ -0,0 +1,39 @@
|
|||||||
|
import { Migration } from '@nocobase/server';
|
||||||
|
|
||||||
|
export default class AddUserNameMigration extends Migration {
|
||||||
|
async up() {
|
||||||
|
const match = await this.app.version.satisfies('<=0.12.0-alpha.4');
|
||||||
|
if (!match) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const Field = this.context.db.getRepository('fields');
|
||||||
|
const existed = await Field.count({
|
||||||
|
filter: {
|
||||||
|
name: 'username',
|
||||||
|
collectionName: 'users',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!existed) {
|
||||||
|
await Field.create({
|
||||||
|
values: {
|
||||||
|
name: 'username',
|
||||||
|
collectionName: 'users',
|
||||||
|
type: 'string',
|
||||||
|
unique: true,
|
||||||
|
interface: 'input',
|
||||||
|
uiSchema: {
|
||||||
|
type: 'string',
|
||||||
|
title: '{{t("Username")}}',
|
||||||
|
'x-component': 'Input',
|
||||||
|
'x-validator': { username: true },
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// NOTE: to trigger hook
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async down() {}
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
import { Migration } from '@nocobase/server';
|
||||||
|
|
||||||
|
export default class UpdateUserNameMigration extends Migration {
|
||||||
|
async up() {
|
||||||
|
const match = await this.app.version.satisfies('<=0.12.0-alpha.4');
|
||||||
|
if (!match) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const repo = this.context.db.getRepository('users');
|
||||||
|
const user = await repo.findOne({
|
||||||
|
filter: {
|
||||||
|
email: 'admin@nocobase.com',
|
||||||
|
username: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (user) {
|
||||||
|
await repo.update({
|
||||||
|
values: {
|
||||||
|
username: process.env.INIT_ROOT_USERNAME || 'nocobase',
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async down() {}
|
||||||
|
}
|
@ -31,6 +31,7 @@ export default class UsersPlugin extends Plugin<UserPluginConfig> {
|
|||||||
this.app.i18n.addResources('en-US', namespace, enUS);
|
this.app.i18n.addResources('en-US', namespace, enUS);
|
||||||
const cmd = this.app.findCommand('install');
|
const cmd = this.app.findCommand('install');
|
||||||
if (cmd) {
|
if (cmd) {
|
||||||
|
cmd.requiredOption('-u, --root-username <rootUsername>', '', process.env.INIT_ROOT_USERNAME);
|
||||||
cmd.requiredOption('-e, --root-email <rootEmail>', '', process.env.INIT_ROOT_EMAIL);
|
cmd.requiredOption('-e, --root-email <rootEmail>', '', process.env.INIT_ROOT_EMAIL);
|
||||||
cmd.requiredOption('-p, --root-password <rootPassword>', '', process.env.INIT_ROOT_PASSWORD);
|
cmd.requiredOption('-p, --root-password <rootPassword>', '', process.env.INIT_ROOT_PASSWORD);
|
||||||
cmd.option('-n, --root-nickname <rootNickname>');
|
cmd.option('-n, --root-nickname <rootNickname>');
|
||||||
@ -137,21 +138,23 @@ export default class UsersPlugin extends Plugin<UserPluginConfig> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getInstallingData(options: any = {}) {
|
getInstallingData(options: any = {}) {
|
||||||
const { INIT_ROOT_NICKNAME, INIT_ROOT_PASSWORD, INIT_ROOT_EMAIL } = process.env;
|
const { INIT_ROOT_NICKNAME, INIT_ROOT_PASSWORD, INIT_ROOT_EMAIL, INIT_ROOT_USERNAME } = process.env;
|
||||||
const {
|
const {
|
||||||
rootEmail = INIT_ROOT_EMAIL,
|
rootEmail = INIT_ROOT_EMAIL,
|
||||||
rootPassword = INIT_ROOT_PASSWORD,
|
rootPassword = INIT_ROOT_PASSWORD,
|
||||||
rootNickname = INIT_ROOT_NICKNAME || 'Super Admin',
|
rootNickname = INIT_ROOT_NICKNAME || 'Super Admin',
|
||||||
|
rootUsername = INIT_ROOT_USERNAME || 'nocobase',
|
||||||
} = options.users || options?.cliArgs?.[0] || {};
|
} = options.users || options?.cliArgs?.[0] || {};
|
||||||
return {
|
return {
|
||||||
rootEmail,
|
rootEmail,
|
||||||
rootPassword,
|
rootPassword,
|
||||||
rootNickname,
|
rootNickname,
|
||||||
|
rootUsername,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async install(options) {
|
async install(options) {
|
||||||
const { rootNickname, rootPassword, rootEmail } = this.getInstallingData(options);
|
const { rootNickname, rootPassword, rootEmail, rootUsername } = this.getInstallingData(options);
|
||||||
const User = this.db.getCollection('users');
|
const User = this.db.getCollection('users');
|
||||||
if (await User.repository.findOne({ filter: { email: rootEmail } })) {
|
if (await User.repository.findOne({ filter: { email: rootEmail } })) {
|
||||||
return;
|
return;
|
||||||
@ -162,6 +165,7 @@ export default class UsersPlugin extends Plugin<UserPluginConfig> {
|
|||||||
email: rootEmail,
|
email: rootEmail,
|
||||||
password: rootPassword,
|
password: rootPassword,
|
||||||
nickname: rootNickname,
|
nickname: rootNickname,
|
||||||
|
username: rootUsername,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -83,6 +83,40 @@ export class PresetNocoBase extends Plugin {
|
|||||||
console.log(`The version number before upgrade is ${version}`);
|
console.log(`The version number before upgrade is ${version}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.app.on('beforeUpgrade', async () => {
|
||||||
|
if (!this.db.inDialect('sqlite')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.app.load({ method: 'upgrade' });
|
||||||
|
const Field = this.db.getRepository('fields');
|
||||||
|
const existed = await Field.count({
|
||||||
|
filter: {
|
||||||
|
name: 'username',
|
||||||
|
collectionName: 'users',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!existed) {
|
||||||
|
await this.db.getRepository('fields').create({
|
||||||
|
values: {
|
||||||
|
name: 'username',
|
||||||
|
collectionName: 'users',
|
||||||
|
type: 'string',
|
||||||
|
unique: true,
|
||||||
|
interface: 'input',
|
||||||
|
uiSchema: {
|
||||||
|
type: 'string',
|
||||||
|
title: '{{t("Username")}}',
|
||||||
|
'x-component': 'Input',
|
||||||
|
'x-validator': { username: true },
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// NOTE: to trigger hook
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.app.on('beforeUpgrade', async () => {
|
this.app.on('beforeUpgrade', async () => {
|
||||||
const result = await this.app.version.satisfies('<0.8.0-alpha.1');
|
const result = await this.app.version.satisfies('<0.8.0-alpha.1');
|
||||||
|
|
||||||
|
13
yarn.lock
13
yarn.lock
@ -9639,7 +9639,7 @@ commander@^10.0.0:
|
|||||||
version "10.0.1"
|
version "10.0.1"
|
||||||
resolved "https://registry.npmmirror.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06"
|
resolved "https://registry.npmmirror.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06"
|
||||||
|
|
||||||
commander@^2.19.0, commander@^2.20.0, commander@^2.8.1, commander@^2.9.0:
|
commander@^2.19.0, commander@^2.20.0, commander@^2.20.3, commander@^2.8.1, commander@^2.9.0:
|
||||||
version "2.20.3"
|
version "2.20.3"
|
||||||
resolved "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
|
resolved "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
|
||||||
|
|
||||||
@ -10351,6 +10351,10 @@ cssesc@^3.0.0:
|
|||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
|
resolved "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
|
||||||
|
|
||||||
|
cssfilter@0.0.10:
|
||||||
|
version "0.0.10"
|
||||||
|
resolved "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz#c6d2672632a2e5c83e013e6864a42ce8defd20ae"
|
||||||
|
|
||||||
cssnano-preset-default@^4.0.8:
|
cssnano-preset-default@^4.0.8:
|
||||||
version "4.0.8"
|
version "4.0.8"
|
||||||
resolved "https://registry.npmmirror.com/cssnano-preset-default/-/cssnano-preset-default-4.0.8.tgz#920622b1fc1e95a34e8838203f1397a504f2d3ff"
|
resolved "https://registry.npmmirror.com/cssnano-preset-default/-/cssnano-preset-default-4.0.8.tgz#920622b1fc1e95a34e8838203f1397a504f2d3ff"
|
||||||
@ -25996,6 +26000,13 @@ xregexp@2.0.0:
|
|||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.npmmirror.com/xregexp/-/xregexp-2.0.0.tgz#52a63e56ca0b84a7f3a5f3d61872f126ad7a5943"
|
resolved "https://registry.npmmirror.com/xregexp/-/xregexp-2.0.0.tgz#52a63e56ca0b84a7f3a5f3d61872f126ad7a5943"
|
||||||
|
|
||||||
|
xss@^1.0.14:
|
||||||
|
version "1.0.14"
|
||||||
|
resolved "https://registry.npmjs.org/xss/-/xss-1.0.14.tgz#4f3efbde75ad0d82e9921cc3c95e6590dd336694"
|
||||||
|
dependencies:
|
||||||
|
commander "^2.20.3"
|
||||||
|
cssfilter "0.0.10"
|
||||||
|
|
||||||
xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1:
|
xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1:
|
||||||
version "4.0.2"
|
version "4.0.2"
|
||||||
resolved "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
|
resolved "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
|
||||||
|
Loading…
Reference in New Issue
Block a user