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:
YANG QIA 2023-08-19 22:02:26 +08:00 committed by GitHub
parent 972b2be10e
commit be6b949f55
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 289 additions and 47 deletions

View File

@ -45,6 +45,7 @@ DB_TABLE_PREFIX=
INIT_ROOT_EMAIL=admin@nocobase.com
INIT_ROOT_PASSWORD=admin123
INIT_ROOT_NICKNAME=Super Admin
INIT_ROOT_USERNAME=nocobase
# local or ali-oss
DEFAULT_STORAGE_TYPE=local

View File

@ -29,6 +29,7 @@ DB_STORAGE=storage/db/nocobase-test.sqlite
INIT_ROOT_EMAIL=admin@nocobase.com
INIT_ROOT_PASSWORD=admin123
INIT_ROOT_NICKNAME=Super Admin
INIT_ROOT_USERNAME=nocobase
# local or ali-oss
DEFAULT_STORAGE_TYPE=local

View File

@ -17,6 +17,7 @@ type AuthManagerOptions = {
type AuthConfig = {
auth: AuthExtend<Auth>; // The authentication class.
title?: string; // The display name of the authentication type.
};
export class AuthManager {
@ -52,7 +53,10 @@ export class AuthManager {
}
listTypes() {
return Array.from(this.authTypes.getKeys());
return Array.from(this.authTypes.getEntities()).map(([authType, authConfig]) => ({
name: authType,
title: authConfig.title,
}));
}
getAuthConfig(authType: string) {

View File

@ -33,3 +33,4 @@ export * from './time';
export * from './updatedAt';
export * from './updatedBy';
export * from './url';
export * from './username';

View File

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

View File

@ -247,6 +247,7 @@ export default {
"Long text": "多行文本",
"Phone": "手机号码",
"Email": "电子邮箱",
"Username": "用户名",
'Null': '空值',
"Boolean": "逻辑值",
"Number": "数字",
@ -472,6 +473,7 @@ export default {
"Verification code": "验证码",
"Send code": "发送验证码",
"Retry after {{count}} seconds": "{{count}} 秒后重试",
'2 to 16 characters (excluding @.<>"\'/)': '2到16个字符不包含@.<>"\'/',
"Roles": "角色",
"Add role": "添加角色",

View File

@ -184,7 +184,7 @@ export const CurrentUser = () => {
`}
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>
</Dropdown>
</DropdownVisibleContext.Provider>

View File

@ -70,6 +70,13 @@ const schema: ISchema = {
title: "{{t('Nickname')}}",
'x-decorator': 'FormItem',
'x-component': 'Input',
},
username: {
type: 'string',
title: '{{t("Username")}}',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-validator': { username: true },
required: true,
},
email: {
@ -78,7 +85,6 @@ const schema: ISchema = {
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-validator': 'email',
required: true,
},
phone: {
type: 'string',
@ -86,7 +92,6 @@ const schema: ISchema = {
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-validator': 'phone',
required: true,
},
footer: {
'x-component': 'Action.Drawer.Footer',

View File

@ -14,3 +14,4 @@ DB_DIALECT={{{env.DB_DIALECT}}}
INIT_ROOT_EMAIL=admin@nocobase.com
INIT_ROOT_PASSWORD=admin123
INIT_ROOT_NICKNAME=Super Admin
INIT_ROOT_USERNAME=nocobase

View File

@ -30,7 +30,7 @@ export const AuthProvider: FC = (props) => {
}}
>
<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}>
{props.children}
</SignupPageProvider>

View File

@ -1,12 +1,14 @@
import { SchemaComponent } from '@nocobase/client';
import React from 'react';
import { useAuthTranslation } from '../locale';
import { Alert } from 'antd';
export const Options = () => {
const { t } = useAuthTranslation();
return (
<SchemaComponent
scope={{ t }}
components={{ Alert }}
schema={{
type: 'object',
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.")}}',
},
},
},
}}
/>

View File

@ -1,19 +1,30 @@
import { ISchema } from '@formily/react';
import { Authenticator, SchemaComponent, SignupPageContext, useSignIn } from '@nocobase/client';
import React, { useContext } from 'react';
import { useAuthTranslation } from '../locale';
const passwordForm: ISchema = {
type: 'object',
name: 'passwordForm',
'x-component': 'FormV2',
properties: {
email: {
account: {
type: 'string',
required: true,
'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-component-props': { placeholder: '{{t("Email")}}', style: {} },
'x-component-props': { placeholder: '{{t("Username/Email")}}', style: {} },
},
password: {
type: 'string',
@ -52,6 +63,7 @@ const passwordForm: ISchema = {
},
};
export default (props: { authenticator: Authenticator }) => {
const { t } = useAuthTranslation();
const authenticator = props.authenticator;
const { authType, name, options } = authenticator;
const signupPages = useContext(SignupPageContext);
@ -61,5 +73,5 @@ export default (props: { authenticator: Authenticator }) => {
const useBasicSignIn = () => {
return useSignIn(name);
};
return <SchemaComponent schema={passwordForm} scope={{ useBasicSignIn, allowSignUp, signupLink }} />;
return <SchemaComponent schema={passwordForm} scope={{ useBasicSignIn, allowSignUp, signupLink, t }} />;
};

View File

@ -2,19 +2,20 @@ import { SchemaComponent, useSignup } from '@nocobase/client';
import { ISchema } from '@formily/react';
import React from 'react';
import { uid } from '@formily/shared';
import { useAuthTranslation } from '../locale';
const signupPageSchema: ISchema = {
type: 'object',
name: uid(),
'x-component': 'FormV2',
properties: {
email: {
username: {
type: 'string',
required: true,
'x-component': 'Input',
'x-validator': 'email',
'x-validator': { username: true },
'x-decorator': 'FormItem',
'x-component-props': { placeholder: '{{t("Email")}}', style: {} },
'x-component-props': { placeholder: '{{t("Username")}}', style: {} },
},
password: {
type: 'string',
@ -84,8 +85,9 @@ const signupPageSchema: ISchema = {
};
export default (props: { name: string }) => {
const { t } = useAuthTranslation();
const useBasicSignup = () => {
return useSignup({ authenticator: props.name });
};
return <SchemaComponent schema={signupPageSchema} scope={{ useBasicSignup }} />;
return <SchemaComponent schema={signupPageSchema} scope={{ useBasicSignup, t }} />;
};

View File

@ -14,6 +14,7 @@ import { PlusOutlined, DownOutlined } from '@ant-design/icons';
import { AuthTypeContext, AuthTypesContext, useAuthTypes } from './authType';
import { useValuesFromOptions, Options } from './Options';
import { useTranslation } from 'react-i18next';
import { useAuthTranslation } from '../locale';
const useCloseAction = () => {
const { setVisible } = useActionContext();
@ -59,6 +60,7 @@ const useCanNotDelete = () => {
};
export const Authenticator = () => {
const { t } = useAuthTranslation();
const [types, setTypes] = useState([]);
const api = useAPIClient();
useRequest(
@ -68,10 +70,10 @@ export const Authenticator = () => {
.listTypes()
.then((res) => {
const types = res?.data?.data || [];
return types.map((type: string) => ({
key: type,
label: type,
value: type,
return types.map((type: { name: string; title?: string }) => ({
key: type.name,
label: t(type.title || type.name),
value: type.name,
}));
}),
{
@ -87,7 +89,7 @@ export const Authenticator = () => {
<SchemaComponent
schema={authenticatorsSchema}
components={{ AddNew, Options }}
scope={{ types, useValuesFromOptions, useCanNotDelete }}
scope={{ types, useValuesFromOptions, useCanNotDelete, t }}
/>
</AuthTypesContext.Provider>
</Card>

View File

@ -21,7 +21,7 @@ const collection = {
name: 'name',
uiSchema: {
type: 'string',
title: '{{t("Name")}}',
title: '{{t("Auth UID")}}',
'x-component': 'Input',
required: true,
},
@ -32,8 +32,9 @@ const collection = {
name: 'authType',
uiSchema: {
type: 'string',
title: '{{t("Auth Type", {ns: "auth"})}}',
title: '{{t("Auth Type")}}',
'x-component': 'Select',
dataSource: '{{ types }}',
required: true,
},
},
@ -251,14 +252,16 @@ export const authenticatorsSchema: ISchema = {
},
},
authType: {
title: '{{t("Auth Type")}}',
type: 'void',
'x-decorator': 'Table.Column.Decorator',
'x-component': 'Table.Column',
properties: {
authType: {
type: 'string',
'x-component': 'CollectionField',
'x-component': 'Select',
'x-read-pretty': true,
enum: '{{ types }}',
},
},
},

View File

@ -3,15 +3,23 @@ const locale = {
Authenticators: '认证器',
Authentication: '用户认证',
'Sign in via email': '邮箱登录',
'Sign in via password': '密码登录',
'Not allowed to sign up': '禁止注册',
'Allow to sign up': '允许注册',
'The email is incorrect, please re-enter': '邮箱有误,请重新输入',
'Please fill in your email address': '请填写邮箱',
'The username or email is incorrect, please re-enter': '用户名或邮箱有误,请重新输入',
'The password is incorrect, please re-enter': '密码有误,请重新输入',
'Not a valid cellphone number, please re-enter': '不是有效的手机号,请重新输入',
'The phone number has been registered, please login directly': '手机号已注册,请直接登录',
'The phone number is not registered, please register first': '手机号未注册,请先注册',
'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;

View File

@ -32,7 +32,12 @@ describe('actions', () => {
it('should list authenticator types', async () => {
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 () => {
@ -82,6 +87,7 @@ describe('actions', () => {
beforeAll(async () => {
app = mockServer();
process.env.INIT_ROOT_EMAIL = 'test@nocobase.com';
process.env.INT_ROOT_USERNAME = 'test';
process.env.INIT_ROOT_PASSWORD = '123456';
process.env.INIT_ROOT_NICKNAME = 'Test';
app.plugin(AuthPlugin);
@ -95,12 +101,15 @@ describe('actions', () => {
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();
expect(res.body.data.id).toBeUndefined();
res = await agent.post('/auth:signIn').set({ 'X-Authenticator': 'basic' }).send({
email: process.env.INIT_ROOT_EMAIL,
res = await agent
.post('/auth:signIn')
.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);
@ -114,7 +123,7 @@ describe('actions', () => {
it('should disable sign up', async () => {
let res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({
email: 'new',
username: 'new',
password: 'new',
});
expect(res.statusCode).toEqual(200);
@ -133,7 +142,7 @@ describe('actions', () => {
},
});
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,
});
expect(res.statusCode).toEqual(403);

View File

@ -11,23 +11,29 @@ export class BasicAuth extends BaseAuth {
async validate() {
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]) {
ctx.throw(400, ctx.t('Please fill in your email address', { ns: namespace }));
if (!account && !email) {
ctx.throw(400, ctx.t('Please enter your username or email', { ns: namespace }));
}
const user = await this.userRepository.findOne({
where: {
[uniqueField]: values[uniqueField],
filter: {
$or: [{ username: account || null }, { email: account || email }],
},
});
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 valid = await field.verify(values.password, user.password);
const valid = await field.verify(password, user.password);
if (!valid) {
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 { 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 });
return user;
}

View File

@ -1,9 +1,10 @@
export default {
'The email is incorrect, please re-enter': '邮箱有误,请重新输入',
'Please fill in your email address': '请填写邮箱',
'The username or email is incorrect, please re-enter': '用户名或邮箱有误,请重新输入',
'The password is incorrect, please re-enter': '密码有误,请重新输入',
'Not a valid cellphone number, please re-enter': '不是有效的手机号,请重新输入',
'The phone number has been registered, please login directly': '手机号已注册,请直接登录',
'The phone number is not registered, please register first': '手机号未注册,请先注册',
'Please keep and enable at least one authenticator': '请至少保留并启用一个认证器',
'Please enter your username or email': '请输入用户名或邮箱',
'Please enter a valid username': '请输入有效的用户名',
};

View File

@ -12,7 +12,7 @@ export default class AddBasicAuthMigration extends Migration {
values: {
name: presetAuthenticator,
authType: presetAuthType,
description: 'Sign in with email and password.',
description: 'Sign in with username/email.',
enabled: true,
},
});

View File

@ -47,6 +47,7 @@ export class AuthPlugin extends Plugin {
this.app.authManager.registerTypes(presetAuthType, {
auth: BasicAuth,
title: 'Password',
});
// Register actions
Object.entries(authActions).forEach(([action, handler]) =>
@ -76,7 +77,7 @@ export class AuthPlugin extends Plugin {
values: {
name: presetAuthenticator,
authType: presetAuthType,
description: 'Sign in with email and password.',
description: 'Sign in with username/email.',
enabled: true,
options: {
public: {

View File

@ -39,7 +39,6 @@ export class SMSAuth extends BaseAuth {
}
// New data
const { autoSignup } = this.authenticator.options?.public || {};
console.log(autoSignup);
const authenticator = this.authenticator as AuthModel;
if (autoSignup) {
user = await authenticator.findOrCreateUser(phone, {

View File

@ -33,6 +33,19 @@ export default {
'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',
type: 'string',

View File

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

View File

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

View File

@ -31,6 +31,7 @@ export default class UsersPlugin extends Plugin<UserPluginConfig> {
this.app.i18n.addResources('en-US', namespace, enUS);
const cmd = this.app.findCommand('install');
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('-p, --root-password <rootPassword>', '', process.env.INIT_ROOT_PASSWORD);
cmd.option('-n, --root-nickname <rootNickname>');
@ -137,21 +138,23 @@ export default class UsersPlugin extends Plugin<UserPluginConfig> {
}
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 {
rootEmail = INIT_ROOT_EMAIL,
rootPassword = INIT_ROOT_PASSWORD,
rootNickname = INIT_ROOT_NICKNAME || 'Super Admin',
rootUsername = INIT_ROOT_USERNAME || 'nocobase',
} = options.users || options?.cliArgs?.[0] || {};
return {
rootEmail,
rootPassword,
rootNickname,
rootUsername,
};
}
async install(options) {
const { rootNickname, rootPassword, rootEmail } = this.getInstallingData(options);
const { rootNickname, rootPassword, rootEmail, rootUsername } = this.getInstallingData(options);
const User = this.db.getCollection('users');
if (await User.repository.findOne({ filter: { email: rootEmail } })) {
return;
@ -162,6 +165,7 @@ export default class UsersPlugin extends Plugin<UserPluginConfig> {
email: rootEmail,
password: rootPassword,
nickname: rootNickname,
username: rootUsername,
},
});

View File

@ -83,6 +83,40 @@ export class PresetNocoBase extends Plugin {
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 () => {
const result = await this.app.version.satisfies('<0.8.0-alpha.1');

View File

@ -9639,7 +9639,7 @@ commander@^10.0.0:
version "10.0.1"
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"
resolved "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
@ -10351,6 +10351,10 @@ cssesc@^3.0.0:
version "3.0.0"
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:
version "4.0.8"
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"
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:
version "4.0.2"
resolved "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"