1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-19 01:17:18 +02:00

refactor: add schemas to user admin controller (#1692)

* refactor: add schemas to user admin controller

* refactor: remove unused SessionService

* refactor: fix search query type confusion

* refactor: add schemas to user controller (#1693)

* refactor: add schemas to user controller

* refactor: fix getAllUserSplashes method name

* refactor: name and email should not be required on create

* refactor: only some user fields may be updated

* refactor: should not require any fields on user update  (#1730)

* refactor: send 400 instead of 500 on missing username and email

* refactor: should not require any fields for user update

* refactor: note that earlier versions required name or email

* refactor: merge roleDescriptionSchema and roleSchema
This commit is contained in:
olav 2022-06-22 14:55:43 +02:00 committed by GitHub
parent cecca59f65
commit ab75d4085e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 1425 additions and 240 deletions

View File

@ -38,7 +38,7 @@ export default class UserSplashStore implements IUserSplashStore {
this.logger = getLogger('user-splash-store.ts'); this.logger = getLogger('user-splash-store.ts');
} }
async getAllUserSplashs(userId: number): Promise<IUserSplash[]> { async getAllUserSplashes(userId: number): Promise<IUserSplash[]> {
const userSplash = await this.db const userSplash = await this.db
.table<IUserSplashTable>(TABLE) .table<IUserSplashTable>(TABLE)
.select() .select()

View File

@ -8,7 +8,6 @@ import NotFoundError from '../error/notfound-error';
import { import {
ICreateUser, ICreateUser,
IUserLookup, IUserLookup,
IUserSearch,
IUserStore, IUserStore,
IUserUpdateFields, IUserUpdateFields,
} from '../types/stores/user-store'; } from '../types/stores/user-store';
@ -116,7 +115,7 @@ class UserStore implements IUserStore {
return users.map(rowToUser); return users.map(rowToUser);
} }
async search(query: IUserSearch): Promise<User[]> { async search(query: string): Promise<User[]> {
const users = await this.db const users = await this.db
.select(USER_COLUMNS_PUBLIC) .select(USER_COLUMNS_PUBLIC)
.from(TABLE) .from(TABLE)

View File

@ -8,6 +8,7 @@ import { contextFieldsSchema } from './spec/context-fields-schema';
import { createApiTokenSchema } from './spec/create-api-token-schema'; import { createApiTokenSchema } from './spec/create-api-token-schema';
import { createFeatureSchema } from './spec/create-feature-schema'; import { createFeatureSchema } from './spec/create-feature-schema';
import { createStrategySchema } from './spec/create-strategy-schema'; import { createStrategySchema } from './spec/create-strategy-schema';
import { createUserSchema } from './spec/create-user-schema';
import { environmentSchema } from './spec/environment-schema'; import { environmentSchema } from './spec/environment-schema';
import { environmentsSchema } from './spec/environments-schema'; import { environmentsSchema } from './spec/environments-schema';
import { featureEnvironmentSchema } from './spec/feature-environment-schema'; import { featureEnvironmentSchema } from './spec/feature-environment-schema';
@ -22,16 +23,21 @@ import { healthCheckSchema } from './spec/health-check-schema';
import { healthOverviewSchema } from './spec/health-overview-schema'; import { healthOverviewSchema } from './spec/health-overview-schema';
import { healthReportSchema } from './spec/health-report-schema'; import { healthReportSchema } from './spec/health-report-schema';
import { legalValueSchema } from './spec/legal-value-schema'; import { legalValueSchema } from './spec/legal-value-schema';
import { idSchema } from './spec/id-schema';
import { mapValues } from '../util/map-values'; import { mapValues } from '../util/map-values';
import { nameSchema } from './spec/name-schema'; import { nameSchema } from './spec/name-schema';
import { meSchema } from './spec/me-schema';
import { omitKeys } from '../util/omit-keys'; import { omitKeys } from '../util/omit-keys';
import { overrideSchema } from './spec/override-schema'; import { overrideSchema } from './spec/override-schema';
import { parametersSchema } from './spec/parameters-schema'; import { parametersSchema } from './spec/parameters-schema';
import { passwordSchema } from './spec/password-schema';
import { patchSchema } from './spec/patch-schema'; import { patchSchema } from './spec/patch-schema';
import { patchesSchema } from './spec/patches-schema'; import { patchesSchema } from './spec/patches-schema';
import { permissionSchema } from './spec/permission-schema';
import { projectEnvironmentSchema } from './spec/project-environment-schema'; import { projectEnvironmentSchema } from './spec/project-environment-schema';
import { projectSchema } from './spec/project-schema'; import { projectSchema } from './spec/project-schema';
import { projectsSchema } from './spec/projects-schema'; import { projectsSchema } from './spec/projects-schema';
import { roleSchema } from './spec/role-schema';
import { sortOrderSchema } from './spec/sort-order-schema'; import { sortOrderSchema } from './spec/sort-order-schema';
import { splashSchema } from './spec/splash-schema'; import { splashSchema } from './spec/splash-schema';
import { strategySchema } from './spec/strategy-schema'; import { strategySchema } from './spec/strategy-schema';
@ -45,6 +51,10 @@ import { updateStrategySchema } from './spec/update-strategy-schema';
import { updateApiTokenSchema } from './spec/update-api-token-schema'; import { updateApiTokenSchema } from './spec/update-api-token-schema';
import { updateTagTypeSchema } from './spec/update-tag-type-schema'; import { updateTagTypeSchema } from './spec/update-tag-type-schema';
import { upsertContextFieldSchema } from './spec/upsert-context-field-schema'; import { upsertContextFieldSchema } from './spec/upsert-context-field-schema';
import { updateUserSchema } from './spec/update-user-schema';
import { userSchema } from './spec/user-schema';
import { usersSchema } from './spec/users-schema';
import { usersSearchSchema } from './spec/users-search-schema';
import { validateTagTypeSchema } from './spec/validate-tag-type-schema'; import { validateTagTypeSchema } from './spec/validate-tag-type-schema';
import { variantSchema } from './spec/variant-schema'; import { variantSchema } from './spec/variant-schema';
import { variantsSchema } from './spec/variants-schema'; import { variantsSchema } from './spec/variants-schema';
@ -57,7 +67,6 @@ import { applicationSchema } from './spec/application-schema';
import { applicationsSchema } from './spec/applications-schema'; import { applicationsSchema } from './spec/applications-schema';
import { tagWithVersionSchema } from './spec/tag-with-version-schema'; import { tagWithVersionSchema } from './spec/tag-with-version-schema';
import { tokenUserSchema } from './spec/token-user-schema'; import { tokenUserSchema } from './spec/token-user-schema';
import { roleDescriptionSchema } from './spec/role-description-schema';
import { changePasswordSchema } from './spec/change-password-schema'; import { changePasswordSchema } from './spec/change-password-schema';
import { validatePasswordSchema } from './spec/validate-password-schema'; import { validatePasswordSchema } from './spec/validate-password-schema';
import { resetPasswordSchema } from './spec/reset-password-schema'; import { resetPasswordSchema } from './spec/reset-password-schema';
@ -66,6 +75,7 @@ import { segmentSchema } from './spec/segment-schema';
import { stateSchema } from './spec/state-schema'; import { stateSchema } from './spec/state-schema';
import { featureTagSchema } from './spec/feature-tag-schema'; import { featureTagSchema } from './spec/feature-tag-schema';
import { exportParametersSchema } from './spec/export-parameters-schema'; import { exportParametersSchema } from './spec/export-parameters-schema';
import { emailSchema } from './spec/email-schema';
// All schemas in `openapi/spec` should be listed here. // All schemas in `openapi/spec` should be listed here.
export const schemas = { export const schemas = {
@ -85,6 +95,8 @@ export const schemas = {
createApiTokenSchema, createApiTokenSchema,
createFeatureSchema, createFeatureSchema,
createStrategySchema, createStrategySchema,
createUserSchema,
emailSchema,
environmentSchema, environmentSchema,
environmentsSchema, environmentsSchema,
exportParametersSchema, exportParametersSchema,
@ -103,15 +115,19 @@ export const schemas = {
healthReportSchema, healthReportSchema,
legalValueSchema, legalValueSchema,
nameSchema, nameSchema,
idSchema,
meSchema,
overrideSchema, overrideSchema,
parametersSchema, parametersSchema,
passwordSchema,
patchSchema, patchSchema,
patchesSchema, patchesSchema,
permissionSchema,
projectEnvironmentSchema, projectEnvironmentSchema,
projectSchema, projectSchema,
projectsSchema, projectsSchema,
resetPasswordSchema, resetPasswordSchema,
roleDescriptionSchema, roleSchema,
segmentSchema, segmentSchema,
sortOrderSchema, sortOrderSchema,
splashSchema, splashSchema,
@ -131,6 +147,10 @@ export const schemas = {
upsertContextFieldSchema, upsertContextFieldSchema,
validatePasswordSchema, validatePasswordSchema,
validateTagTypeSchema, validateTagTypeSchema,
updateUserSchema,
userSchema,
usersSchema,
usersSearchSchema,
variantSchema, variantSchema,
variantsSchema, variantsSchema,
versionSchema, versionSchema,

View File

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`resetPasswordSchema empty 1`] = ` exports[`emailSchema 1`] = `
Object { Object {
"data": Object {}, "data": Object {},
"errors": Array [ "errors": Array [
@ -14,6 +14,6 @@ Object {
"schemaPath": "#/required", "schemaPath": "#/required",
}, },
], ],
"schema": "#/components/schemas/resetPasswordSchema", "schema": "#/components/schemas/emailSchema",
} }
`; `;

View File

@ -0,0 +1,65 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`meSchema empty 1`] = `
Object {
"data": Object {},
"errors": Array [
Object {
"instancePath": "",
"keyword": "required",
"message": "must have required property 'user'",
"params": Object {
"missingProperty": "user",
},
"schemaPath": "#/required",
},
],
"schema": "#/components/schemas/meSchema",
}
`;
exports[`meSchema missing permissions 1`] = `
Object {
"data": Object {
"user": Object {
"id": 1,
},
},
"errors": Array [
Object {
"instancePath": "",
"keyword": "required",
"message": "must have required property 'permissions'",
"params": Object {
"missingProperty": "permissions",
},
"schemaPath": "#/required",
},
],
"schema": "#/components/schemas/meSchema",
}
`;
exports[`meSchema missing splash 1`] = `
Object {
"data": Object {
"feedback": Array [],
"permissions": Array [],
"user": Object {
"id": 1,
},
},
"errors": Array [
Object {
"instancePath": "",
"keyword": "required",
"message": "must have required property 'splash'",
"params": Object {
"missingProperty": "splash",
},
"schemaPath": "#/required",
},
],
"schema": "#/components/schemas/meSchema",
}
`;

View File

@ -1,19 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`roleDescriptionSchema empty 1`] = ` exports[`roleSchema 1`] = `
Object { Object {
"data": Object {}, "data": Object {},
"errors": Array [ "errors": Array [
Object { Object {
"instancePath": "", "instancePath": "",
"keyword": "required", "keyword": "required",
"message": "must have required property 'description'", "message": "must have required property 'id'",
"params": Object { "params": Object {
"missingProperty": "description", "missingProperty": "id",
}, },
"schemaPath": "#/required", "schemaPath": "#/required",
}, },
], ],
"schema": "#/components/schemas/roleDescriptionSchema", "schema": "#/components/schemas/roleSchema",
} }
`; `;

View File

@ -0,0 +1,31 @@
import { FromSchema } from 'json-schema-to-ts';
export const createUserSchema = {
$id: '#/components/schemas/createUserSchema',
type: 'object',
additionalProperties: false,
required: ['rootRole'],
properties: {
username: {
type: 'string',
},
email: {
type: 'string',
},
name: {
type: 'string',
},
password: {
type: 'string',
},
rootRole: {
type: 'number',
},
sendEmail: {
type: 'boolean',
},
},
components: {},
} as const;
export type CreateUserSchema = FromSchema<typeof createUserSchema>;

View File

@ -0,0 +1,16 @@
import { validateSchema } from '../validate';
import { EmailSchema } from './email-schema';
test('emailSchema', () => {
const data: EmailSchema = {
email: '',
};
expect(
validateSchema('#/components/schemas/emailSchema', data),
).toBeUndefined();
expect(
validateSchema('#/components/schemas/emailSchema', {}),
).toMatchSnapshot();
});

View File

@ -0,0 +1,16 @@
import { FromSchema } from 'json-schema-to-ts';
export const emailSchema = {
$id: '#/components/schemas/emailSchema',
type: 'object',
additionalProperties: false,
required: ['email'],
properties: {
email: {
type: 'string',
},
},
components: {},
} as const;
export type EmailSchema = FromSchema<typeof emailSchema>;

View File

@ -0,0 +1,16 @@
import { FromSchema } from 'json-schema-to-ts';
export const idSchema = {
$id: '#/components/schemas/idSchema',
type: 'object',
additionalProperties: false,
required: ['id'],
properties: {
id: {
type: 'string',
},
},
components: {},
} as const;
export type IdSchema = FromSchema<typeof idSchema>;

View File

@ -0,0 +1,37 @@
import { validateSchema } from '../validate';
import { MeSchema } from './me-schema';
test('meSchema', () => {
const data: MeSchema = {
user: { id: 1 },
permissions: [{ permission: 'a' }],
feedback: [{ userId: 1, feedbackId: 'a', neverShow: false }],
splash: { a: true },
};
expect(
validateSchema('#/components/schemas/meSchema', data),
).toBeUndefined();
});
test('meSchema empty', () => {
expect(
validateSchema('#/components/schemas/meSchema', {}),
).toMatchSnapshot();
});
test('meSchema missing permissions', () => {
expect(
validateSchema('#/components/schemas/meSchema', { user: { id: 1 } }),
).toMatchSnapshot();
});
test('meSchema missing splash', () => {
expect(
validateSchema('#/components/schemas/meSchema', {
user: { id: 1 },
permissions: [],
feedback: [],
}),
).toMatchSnapshot();
});

View File

@ -0,0 +1,43 @@
import { FromSchema } from 'json-schema-to-ts';
import { userSchema } from './user-schema';
import { permissionSchema } from './permission-schema';
import { feedbackSchema } from './feedback-schema';
export const meSchema = {
$id: '#/components/schemas/meSchema',
type: 'object',
additionalProperties: false,
required: ['user', 'permissions', 'feedback', 'splash'],
properties: {
user: {
$ref: '#/components/schemas/userSchema',
},
permissions: {
type: 'array',
items: {
$ref: '#/components/schemas/permissionSchema',
},
},
feedback: {
type: 'array',
items: {
$ref: '#/components/schemas/feedbackSchema',
},
},
splash: {
type: 'object',
additionalProperties: {
type: 'boolean',
},
},
},
components: {
schemas: {
userSchema,
permissionSchema,
feedbackSchema,
},
},
} as const;
export type MeSchema = FromSchema<typeof meSchema>;

View File

@ -0,0 +1,19 @@
import { FromSchema } from 'json-schema-to-ts';
export const passwordSchema = {
$id: '#/components/schemas/passwordSchema',
type: 'object',
additionalProperties: false,
required: ['password'],
properties: {
password: {
type: 'string',
},
confirmPassword: {
type: 'string',
},
},
components: {},
} as const;
export type PasswordSchema = FromSchema<typeof passwordSchema>;

View File

@ -0,0 +1,22 @@
import { FromSchema } from 'json-schema-to-ts';
export const permissionSchema = {
$id: '#/components/schemas/permissionSchema',
type: 'object',
additionalProperties: false,
required: ['permission'],
properties: {
permission: {
type: 'string',
},
project: {
type: 'string',
},
environment: {
type: 'string',
},
},
components: {},
} as const;
export type PermissionSchema = FromSchema<typeof permissionSchema>;

View File

@ -1,18 +0,0 @@
import { validateSchema } from '../validate';
import { ResetPasswordSchema } from './reset-password-schema';
test('resetPasswordSchema', () => {
const data: ResetPasswordSchema = {
email: '',
};
expect(
validateSchema('#/components/schemas/resetPasswordSchema', data),
).toBeUndefined();
});
test('resetPasswordSchema empty', () => {
expect(
validateSchema('#/components/schemas/resetPasswordSchema', {}),
).toMatchSnapshot();
});

View File

@ -4,9 +4,9 @@ export const resetPasswordSchema = {
$id: '#/components/schemas/resetPasswordSchema', $id: '#/components/schemas/resetPasswordSchema',
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
required: ['email'], required: ['resetPasswordUrl'],
properties: { properties: {
email: { resetPasswordUrl: {
type: 'string', type: 'string',
}, },
}, },

View File

@ -1,20 +0,0 @@
import { validateSchema } from '../validate';
import { RoleDescriptionSchema } from './role-description-schema';
test('roleDescriptionSchema', () => {
const data: RoleDescriptionSchema = {
description: '',
name: '',
type: '',
};
expect(
validateSchema('#/components/schemas/roleDescriptionSchema', data),
).toBeUndefined();
});
test('roleDescriptionSchema empty', () => {
expect(
validateSchema('#/components/schemas/roleDescriptionSchema', {}),
).toMatchSnapshot();
});

View File

@ -0,0 +1,19 @@
import { validateSchema } from '../validate';
import { RoleSchema } from './role-schema';
test('roleSchema', () => {
const data: RoleSchema = {
id: 1,
description: '',
name: '',
type: '',
};
expect(
validateSchema('#/components/schemas/roleSchema', data),
).toBeUndefined();
expect(
validateSchema('#/components/schemas/roleSchema', {}),
).toMatchSnapshot();
});

View File

@ -1,22 +1,25 @@
import { FromSchema } from 'json-schema-to-ts'; import { FromSchema } from 'json-schema-to-ts';
export const roleDescriptionSchema = { export const roleSchema = {
$id: '#/components/schemas/roleDescriptionSchema', $id: '#/components/schemas/roleSchema',
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
required: ['description', 'name', 'type'], required: ['id', 'type', 'name'],
properties: { properties: {
description: { id: {
type: 'number',
},
type: {
type: 'string', type: 'string',
}, },
name: { name: {
type: 'string', type: 'string',
}, },
type: { description: {
type: 'string', type: 'string',
}, },
}, },
components: {}, components: {},
} as const; } as const;
export type RoleDescriptionSchema = FromSchema<typeof roleDescriptionSchema>; export type RoleSchema = FromSchema<typeof roleSchema>;

View File

@ -6,6 +6,7 @@ test('tokenUserSchema', () => {
createdBy: '', createdBy: '',
token: '', token: '',
role: { role: {
id: 1,
description: '', description: '',
name: '', name: '',
type: '', type: '',

View File

@ -1,5 +1,5 @@
import { FromSchema } from 'json-schema-to-ts'; import { FromSchema } from 'json-schema-to-ts';
import { roleDescriptionSchema } from './role-description-schema'; import { roleSchema } from './role-schema';
export const tokenUserSchema = { export const tokenUserSchema = {
$id: '#/components/schemas/tokenUserSchema', $id: '#/components/schemas/tokenUserSchema',
@ -14,12 +14,12 @@ export const tokenUserSchema = {
type: 'string', type: 'string',
}, },
role: { role: {
$ref: '#/components/schemas/roleDescriptionSchema', $ref: '#/components/schemas/roleSchema',
}, },
}, },
components: { components: {
schemas: { schemas: {
roleDescriptionSchema, roleSchema,
}, },
}, },
} as const; } as const;

View File

@ -0,0 +1,21 @@
import { FromSchema } from 'json-schema-to-ts';
export const updateUserSchema = {
$id: '#/components/schemas/updateUserSchema',
type: 'object',
additionalProperties: false,
properties: {
email: {
type: 'string',
},
name: {
type: 'string',
},
rootRole: {
type: 'number',
},
},
components: {},
} as const;
export type UpdateUserSchema = FromSchema<typeof updateUserSchema>;

View File

@ -0,0 +1,52 @@
import { FromSchema } from 'json-schema-to-ts';
export const userSchema = {
$id: '#/components/schemas/userSchema',
type: 'object',
additionalProperties: false,
required: ['id'],
properties: {
id: {
type: 'number',
},
isAPI: {
type: 'boolean',
},
name: {
type: 'string',
},
email: {
type: 'string',
},
username: {
type: 'string',
},
imageUrl: {
type: 'string',
},
inviteLink: {
type: 'string',
},
loginAttempts: {
type: 'number',
},
emailSent: {
type: 'boolean',
},
rootRole: {
type: 'number',
},
seenAt: {
type: 'string',
format: 'date-time',
nullable: true,
},
createdAt: {
type: 'string',
format: 'date-time',
},
},
components: {},
} as const;
export type UserSchema = FromSchema<typeof userSchema>;

View File

@ -0,0 +1,13 @@
import { validateSchema } from '../validate';
import { UsersSchema } from './users-schema';
test('usersSchema', () => {
const data: UsersSchema = {
users: [{ id: 1 }],
rootRoles: [{ id: 1, type: 'a', name: 'b' }],
};
expect(
validateSchema('#/components/schemas/usersSchema', data),
).toBeUndefined();
});

View File

@ -0,0 +1,32 @@
import { FromSchema } from 'json-schema-to-ts';
import { userSchema } from './user-schema';
import { roleSchema } from './role-schema';
export const usersSchema = {
$id: '#/components/schemas/usersSchema',
type: 'object',
additionalProperties: false,
required: ['users'],
properties: {
users: {
type: 'array',
items: {
$ref: '#/components/schemas/userSchema',
},
},
rootRoles: {
type: 'array',
items: {
$ref: '#/components/schemas/roleSchema',
},
},
},
components: {
schemas: {
userSchema,
roleSchema,
},
},
} as const;
export type UsersSchema = FromSchema<typeof usersSchema>;

View File

@ -0,0 +1,10 @@
import { validateSchema } from '../validate';
import { UsersSearchSchema } from './users-search-schema';
test('usersSchema', () => {
const data: UsersSearchSchema = [{ id: 1 }];
expect(
validateSchema('#/components/schemas/usersSearchSchema', data),
).toBeUndefined();
});

View File

@ -0,0 +1,17 @@
import { FromSchema } from 'json-schema-to-ts';
import { userSchema } from './user-schema';
export const usersSearchSchema = {
$id: '#/components/schemas/usersSearchSchema',
type: 'array',
items: {
$ref: '#/components/schemas/userSchema',
},
components: {
schemas: {
userSchema,
},
},
} as const;
export type UsersSearchSchema = FromSchema<typeof usersSearchSchema>;

View File

@ -8,20 +8,29 @@ import { IUnleashConfig } from '../../types/option';
import { EmailService } from '../../services/email-service'; import { EmailService } from '../../services/email-service';
import ResetTokenService from '../../services/reset-token-service'; import ResetTokenService from '../../services/reset-token-service';
import { IUnleashServices } from '../../types/services'; import { IUnleashServices } from '../../types/services';
import SessionService from '../../services/session-service';
import { IAuthRequest } from '../unleash-types'; import { IAuthRequest } from '../unleash-types';
import SettingService from '../../services/setting-service'; import SettingService from '../../services/setting-service';
import { IUser, SimpleAuthSettings } from '../../server-impl'; import { IUser, SimpleAuthSettings } from '../../server-impl';
import { simpleAuthKey } from '../../types/settings/simple-auth-settings'; import { simpleAuthKey } from '../../types/settings/simple-auth-settings';
import { anonymise } from '../../util/anonymise'; import { anonymise } from '../../util/anonymise';
import { OpenApiService } from '../../services/openapi-service';
interface ICreateUserBody { import { emptyResponse } from '../../openapi/spec/empty-response';
username: string; import { createRequestSchema, createResponseSchema } from '../../openapi';
email: string; import { userSchema, UserSchema } from '../../openapi/spec/user-schema';
name: string; import { serializeDates } from '../../types/serialize-dates';
rootRole: number; import { usersSchema, UsersSchema } from '../../openapi/spec/users-schema';
sendEmail: boolean; import {
} usersSearchSchema,
UsersSearchSchema,
} from '../../openapi/spec/users-search-schema';
import { CreateUserSchema } from '../../openapi/spec/create-user-schema';
import { UpdateUserSchema } from '../../openapi/spec/update-user-schema';
import { PasswordSchema } from '../../openapi/spec/password-schema';
import { IdSchema } from '../../openapi/spec/id-schema';
import {
resetPasswordSchema,
ResetPasswordSchema,
} from '../../openapi/spec/reset-password-schema';
export default class UserAdminController extends Controller { export default class UserAdminController extends Controller {
private anonymise: boolean = false; private anonymise: boolean = false;
@ -36,10 +45,10 @@ export default class UserAdminController extends Controller {
private resetTokenService: ResetTokenService; private resetTokenService: ResetTokenService;
private sessionService: SessionService;
private settingService: SettingService; private settingService: SettingService;
private openApiService: OpenApiService;
readonly unleashUrl: string; readonly unleashUrl: string;
constructor( constructor(
@ -49,16 +58,16 @@ export default class UserAdminController extends Controller {
accessService, accessService,
emailService, emailService,
resetTokenService, resetTokenService,
sessionService,
settingService, settingService,
openApiService,
}: Pick< }: Pick<
IUnleashServices, IUnleashServices,
| 'userService' | 'userService'
| 'accessService' | 'accessService'
| 'emailService' | 'emailService'
| 'resetTokenService' | 'resetTokenService'
| 'sessionService'
| 'settingService' | 'settingService'
| 'openApiService'
>, >,
) { ) {
super(config); super(config);
@ -66,32 +75,165 @@ export default class UserAdminController extends Controller {
this.accessService = accessService; this.accessService = accessService;
this.emailService = emailService; this.emailService = emailService;
this.resetTokenService = resetTokenService; this.resetTokenService = resetTokenService;
this.sessionService = sessionService;
this.settingService = settingService; this.settingService = settingService;
this.openApiService = openApiService;
this.logger = config.getLogger('routes/user-controller.ts'); this.logger = config.getLogger('routes/user-controller.ts');
this.unleashUrl = config.server.unleashUrl; this.unleashUrl = config.server.unleashUrl;
this.anonymise = config.experimental?.anonymiseEventLog; this.anonymise = config.experimental?.anonymiseEventLog;
this.get('/', this.getUsers, ADMIN); this.route({
this.get('/search', this.search); method: 'post',
this.post('/', this.createUser, ADMIN); path: '/validate-password',
this.post('/validate-password', this.validatePassword, NONE); handler: this.validatePassword,
this.get('/:id', this.getUser, ADMIN); permission: NONE,
this.put('/:id', this.updateUser, ADMIN); middleware: [
this.post('/:id/change-password', this.changePassword, ADMIN); openApiService.validPath({
this.delete('/:id', this.deleteUser, ADMIN); tags: ['admin'],
this.post('/reset-password', this.resetPassword, ADMIN); operationId: 'validatePassword',
requestBody: createRequestSchema('passwordSchema'),
responses: { 200: emptyResponse },
}),
],
});
this.route({
method: 'post',
path: '/:id/change-password',
handler: this.changePassword,
permission: ADMIN,
middleware: [
openApiService.validPath({
tags: ['admin'],
operationId: 'changePassword',
requestBody: createRequestSchema('passwordSchema'),
responses: { 200: emptyResponse },
}),
],
});
this.route({
method: 'post',
path: '/reset-password',
handler: this.resetPassword,
permission: ADMIN,
middleware: [
openApiService.validPath({
tags: ['admin'],
operationId: 'resetPassword',
requestBody: createRequestSchema('idSchema'),
responses: {
200: createResponseSchema('resetPasswordSchema'),
},
}),
],
});
this.route({
method: 'get',
path: '',
handler: this.getUsers,
permission: ADMIN,
middleware: [
openApiService.validPath({
tags: ['admin'],
operationId: 'getUsers',
responses: { 200: createResponseSchema('usersSchema') },
}),
],
});
this.route({
method: 'get',
path: '/search',
handler: this.searchUsers,
permission: ADMIN,
middleware: [
openApiService.validPath({
tags: ['admin'],
operationId: 'searchUsers',
responses: { 200: createResponseSchema('usersSchema') },
}),
],
});
this.route({
method: 'post',
path: '',
handler: this.createUser,
permission: ADMIN,
middleware: [
openApiService.validPath({
tags: ['admin'],
operationId: 'createUser',
requestBody: createRequestSchema('createUserSchema'),
responses: { 200: createResponseSchema('userSchema') },
}),
],
});
this.route({
method: 'get',
path: '/:id',
handler: this.getUser,
permission: ADMIN,
middleware: [
openApiService.validPath({
tags: ['admin'],
operationId: 'getUser',
responses: { 200: createResponseSchema('userSchema') },
}),
],
});
this.route({
method: 'put',
path: '/:id',
handler: this.updateUser,
permission: ADMIN,
middleware: [
openApiService.validPath({
tags: ['admin'],
operationId: 'updateUser',
requestBody: createRequestSchema('updateUserSchema'),
responses: { 200: createResponseSchema('userSchema') },
}),
],
});
this.route({
method: 'delete',
path: '/:id',
acceptAnyContentType: true,
handler: this.deleteUser,
permission: ADMIN,
middleware: [
openApiService.validPath({
tags: ['admin'],
operationId: 'deleteUser',
responses: { 200: emptyResponse },
}),
],
});
} }
async resetPassword(req: IAuthRequest, res: Response): Promise<void> { async resetPassword(
req: IAuthRequest<unknown, ResetPasswordSchema, IdSchema>,
res: Response<ResetPasswordSchema>,
): Promise<void> {
const { user } = req; const { user } = req;
const receiver = req.body.id; const receiver = req.body.id;
const resetPasswordUrl = const resetPasswordUrl =
await this.userService.createResetPasswordEmail(receiver, user); await this.userService.createResetPasswordEmail(receiver, user);
res.json({ resetPasswordUrl });
this.openApiService.respondWithValidation(
200,
res,
resetPasswordSchema.$id,
{ resetPasswordUrl: resetPasswordUrl.toString() },
);
} }
async getUsers(req: Request, res: Response): Promise<void> { async getUsers(req: Request, res: Response<UsersSchema>): Promise<void> {
const users = await this.userService.getAll(); const users = await this.userService.getAll();
const rootRoles = await this.accessService.getRootRoles(); const rootRoles = await this.accessService.getRootRoles();
const inviteLinks = await this.resetTokenService.getActiveInvitations(); const inviteLinks = await this.resetTokenService.getActiveInvitations();
@ -101,7 +243,10 @@ export default class UserAdminController extends Controller {
return { ...user, inviteLink }; return { ...user, inviteLink };
}); });
res.json({ users: usersWithInviteLinks, rootRoles }); this.openApiService.respondWithValidation(200, res, usersSchema.$id, {
users: serializeDates(usersWithInviteLinks),
rootRoles,
});
} }
anonymiseUsers(users: IUser[]): IUser[] { anonymiseUsers(users: IUser[]): IUser[] {
@ -113,36 +258,45 @@ export default class UserAdminController extends Controller {
})); }));
} }
async search(req: Request, res: Response): Promise<void> { async searchUsers(
const { q } = req.query as any; req: Request,
try { res: Response<UsersSearchSchema>,
): Promise<void> {
const { q } = req.query;
let users = let users =
q && q.length > 1 ? await this.userService.search(q) : []; typeof q === 'string' && q.length > 1
? await this.userService.search(q)
: [];
if (this.anonymise) { if (this.anonymise) {
users = this.anonymiseUsers(users); users = this.anonymiseUsers(users);
} }
res.json(users); this.openApiService.respondWithValidation(
} catch (error) { 200,
this.logger.error(error); res,
res.status(500).send({ msg: 'server errors' }); usersSearchSchema.$id,
} serializeDates(users),
);
} }
async getUser(req: Request, res: Response): Promise<void> { async getUser(req: Request, res: Response<UserSchema>): Promise<void> {
const { id } = req.params; const { id } = req.params;
const user = await this.userService.getUser(Number(id)); const user = await this.userService.getUser(Number(id));
res.json(user);
this.openApiService.respondWithValidation(
200,
res,
userSchema.$id,
serializeDates(user),
);
} }
async createUser( async createUser(
req: IAuthRequest<any, any, ICreateUserBody, any>, req: IAuthRequest<unknown, unknown, CreateUserSchema>,
res: Response, res: Response<UserSchema>,
): Promise<void> { ): Promise<void> {
const { username, email, name, rootRole, sendEmail } = req.body; const { username, email, name, rootRole, sendEmail } = req.body;
const { user } = req; const { user } = req;
try {
const createdUser = await this.userService.createUser( const createdUser = await this.userService.createUser(
{ {
username, username,
@ -154,9 +308,7 @@ export default class UserAdminController extends Controller {
); );
const passwordAuthSettings = const passwordAuthSettings =
await this.settingService.get<SimpleAuthSettings>( await this.settingService.get<SimpleAuthSettings>(simpleAuthKey);
simpleAuthKey,
);
let inviteLink: string; let inviteLink: string;
if (!passwordAuthSettings?.disabled) { if (!passwordAuthSettings?.disabled) {
@ -171,6 +323,7 @@ export default class UserAdminController extends Controller {
const emailConfigured = this.emailService.configured(); const emailConfigured = this.emailService.configured();
const reallySendEmail = const reallySendEmail =
emailConfigured && (sendEmail !== undefined ? sendEmail : true); emailConfigured && (sendEmail !== undefined ? sendEmail : true);
if (reallySendEmail) { if (reallySendEmail) {
try { try {
await this.emailService.sendGettingStartedMail( await this.emailService.sendGettingStartedMail(
@ -192,25 +345,29 @@ export default class UserAdminController extends Controller {
); );
} }
res.status(201).send({ const responseData: UserSchema = {
...createdUser, ...serializeDates(createdUser),
inviteLink: inviteLink || this.unleashUrl, inviteLink: inviteLink || this.unleashUrl,
emailSent, emailSent,
rootRole, rootRole,
}); };
} catch (e) {
this.logger.warn(e.message); this.openApiService.respondWithValidation(
res.status(400).send([{ msg: e.message }]); 201,
} res,
userSchema.$id,
responseData,
);
} }
async updateUser(req: IAuthRequest, res: Response): Promise<void> { async updateUser(
req: IAuthRequest<{ id: string }, UserSchema, UpdateUserSchema>,
res: Response<UserSchema>,
): Promise<void> {
const { user, params, body } = req; const { user, params, body } = req;
const { id } = params; const { id } = params;
const { name, email, rootRole } = body; const { name, email, rootRole } = body;
try {
const updateUser = await this.userService.updateUser( const updateUser = await this.userService.updateUser(
{ {
id: Number(id), id: Number(id),
@ -220,11 +377,11 @@ export default class UserAdminController extends Controller {
}, },
user, user,
); );
res.status(200).send({ ...updateUser, rootRole });
} catch (e) { this.openApiService.respondWithValidation(200, res, userSchema.$id, {
this.logger.warn(e.message); ...serializeDates(updateUser),
res.status(400).send([{ msg: e.message }]); rootRole,
} });
} }
async deleteUser(req: IAuthRequest, res: Response): Promise<void> { async deleteUser(req: IAuthRequest, res: Response): Promise<void> {
@ -235,14 +392,20 @@ export default class UserAdminController extends Controller {
res.status(200).send(); res.status(200).send();
} }
async validatePassword(req: IAuthRequest, res: Response): Promise<void> { async validatePassword(
req: IAuthRequest<unknown, unknown, PasswordSchema>,
res: Response,
): Promise<void> {
const { password } = req.body; const { password } = req.body;
this.userService.validatePassword(password); this.userService.validatePassword(password);
res.status(200).send(); res.status(200).send();
} }
async changePassword(req: IAuthRequest, res: Response): Promise<void> { async changePassword(
req: IAuthRequest<{ id: string }, unknown, PasswordSchema>,
res: Response,
): Promise<void> {
const { id } = req.params; const { id } = req.params;
const { password } = req.body; const { password } = req.body;

View File

@ -8,11 +8,13 @@ import UserService from '../../services/user-service';
import UserFeedbackService from '../../services/user-feedback-service'; import UserFeedbackService from '../../services/user-feedback-service';
import UserSplashService from '../../services/user-splash-service'; import UserSplashService from '../../services/user-splash-service';
import { ADMIN, NONE } from '../../types/permissions'; import { ADMIN, NONE } from '../../types/permissions';
import { OpenApiService } from '../../services/openapi-service';
interface IChangeUserRequest { import { createRequestSchema, createResponseSchema } from '../../openapi';
password: string; import { emptyResponse } from '../../openapi/spec/empty-response';
confirmPassword: string; import { meSchema, MeSchema } from '../../openapi/spec/me-schema';
} import { serializeDates } from '../../types/serialize-dates';
import { IUserPermission } from '../../types/stores/access-store';
import { PasswordSchema } from '../../openapi/spec/password-schema';
class UserController extends Controller { class UserController extends Controller {
private accessService: AccessService; private accessService: AccessService;
@ -23,6 +25,8 @@ class UserController extends Controller {
private userSplashService: UserSplashService; private userSplashService: UserSplashService;
private openApiService: OpenApiService;
constructor( constructor(
config: IUnleashConfig, config: IUnleashConfig,
{ {
@ -30,12 +34,14 @@ class UserController extends Controller {
userService, userService,
userFeedbackService, userFeedbackService,
userSplashService, userSplashService,
openApiService,
}: Pick< }: Pick<
IUnleashServices, IUnleashServices,
| 'accessService' | 'accessService'
| 'userService' | 'userService'
| 'userFeedbackService' | 'userFeedbackService'
| 'userSplashService' | 'userSplashService'
| 'openApiService'
>, >,
) { ) {
super(config); super(config);
@ -43,15 +49,45 @@ class UserController extends Controller {
this.userService = userService; this.userService = userService;
this.userFeedbackService = userFeedbackService; this.userFeedbackService = userFeedbackService;
this.userSplashService = userSplashService; this.userSplashService = userSplashService;
this.openApiService = openApiService;
this.get('/', this.getUser); this.route({
this.post('/change-password', this.updateUserPass, NONE); method: 'get',
path: '',
handler: this.getMe,
permission: NONE,
middleware: [
openApiService.validPath({
tags: ['admin'],
operationId: 'getMe',
responses: { 200: createResponseSchema('meSchema') },
}),
],
});
this.route({
method: 'post',
path: '/change-password',
handler: this.changeMyPassword,
permission: NONE,
middleware: [
openApiService.validPath({
tags: ['admin'],
operationId: 'changeMyPassword',
requestBody: createRequestSchema('passwordSchema'),
responses: {
200: emptyResponse,
400: { description: 'passwordMismatch' },
},
}),
],
});
} }
async getUser(req: IAuthRequest, res: Response): Promise<void> { async getMe(req: IAuthRequest, res: Response<MeSchema>): Promise<void> {
res.setHeader('cache-control', 'no-store'); res.setHeader('cache-control', 'no-store');
const { user } = req; const { user } = req;
let permissions; let permissions: IUserPermission[];
if (this.config.authentication.type === IAuthType.NONE) { if (this.config.authentication.type === IAuthType.NONE) {
permissions = [{ permission: ADMIN }]; permissions = [{ permission: ADMIN }];
} else { } else {
@ -60,16 +96,25 @@ class UserController extends Controller {
const feedback = await this.userFeedbackService.getAllUserFeedback( const feedback = await this.userFeedbackService.getAllUserFeedback(
user, user,
); );
const splash = await this.userSplashService.getAllUserSplashs(user); const splash = await this.userSplashService.getAllUserSplashes(user);
return res const responseData: MeSchema = {
.status(200) user: serializeDates(user),
.json({ user, permissions, feedback, splash }) permissions,
.end(); feedback: serializeDates(feedback),
splash,
};
this.openApiService.respondWithValidation(
200,
res,
meSchema.$id,
responseData,
);
} }
async updateUserPass( async changeMyPassword(
req: IAuthRequest<any, any, IChangeUserRequest, any>, req: IAuthRequest<unknown, unknown, PasswordSchema>,
res: Response, res: Response,
): Promise<void> { ): Promise<void> {
const { user } = req; const { user } = req;

View File

@ -12,6 +12,7 @@ import {
tokenUserSchema, tokenUserSchema,
TokenUserSchema, TokenUserSchema,
} from '../../openapi/spec/token-user-schema'; } from '../../openapi/spec/token-user-schema';
import { EmailSchema } from '../../openapi/spec/email-schema';
interface IValidateQuery { interface IValidateQuery {
token: string; token: string;
@ -97,14 +98,17 @@ class ResetPasswordController extends Controller {
openApiService.validPath({ openApiService.validPath({
tags: ['other'], tags: ['other'],
operationId: 'sendResetPasswordEmail', operationId: 'sendResetPasswordEmail',
requestBody: createRequestSchema('resetPasswordSchema'), requestBody: createRequestSchema('emailSchema'),
responses: { 200: emptyResponse }, responses: { 200: emptyResponse },
}), }),
], ],
}); });
} }
async sendResetPasswordEmail(req: Request, res: Response): Promise<void> { async sendResetPasswordEmail(
req: Request<unknown, unknown, EmailSchema>,
res: Response,
): Promise<void> {
const { email } = req.body; const { email } = req.body;
await this.userService.createResetPasswordEmail(email); await this.userService.createResetPasswordEmail(email);

View File

@ -27,6 +27,7 @@ import { roleSchema } from '../schema/role-schema';
import { CUSTOM_ROLE_TYPE, ALL_PROJECTS, ALL_ENVS } from '../util/constants'; import { CUSTOM_ROLE_TYPE, ALL_PROJECTS, ALL_ENVS } from '../util/constants';
import { DEFAULT_PROJECT } from '../types/project'; import { DEFAULT_PROJECT } from '../types/project';
import InvalidOperationError from '../error/invalid-operation-error'; import InvalidOperationError from '../error/invalid-operation-error';
import BadDataError from '../error/bad-data-error';
const { ADMIN } = permissions; const { ADMIN } = permissions;
@ -200,7 +201,7 @@ export class AccessService {
); );
} }
} else { } else {
throw new Error(`Could not find rootRole=${role}`); throw new BadDataError(`Could not find rootRole=${role}`);
} }
} }

View File

@ -1,4 +1,3 @@
import assert from 'assert';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import owasp from 'owasp-password-strength-test'; import owasp from 'owasp-password-strength-test';
import Joi from 'joi'; import Joi from 'joi';
@ -19,13 +18,15 @@ import { IUnleashStores } from '../types/stores';
import PasswordUndefinedError from '../error/password-undefined'; import PasswordUndefinedError from '../error/password-undefined';
import { USER_UPDATED, USER_CREATED, USER_DELETED } from '../types/events'; import { USER_UPDATED, USER_CREATED, USER_DELETED } from '../types/events';
import { IEventStore } from '../types/stores/event-store'; import { IEventStore } from '../types/stores/event-store';
import { IUserSearch, IUserStore } from '../types/stores/user-store'; import { IUserStore } from '../types/stores/user-store';
import { RoleName } from '../types/model'; import { RoleName } from '../types/model';
import SettingService from './setting-service'; import SettingService from './setting-service';
import { SimpleAuthSettings } from '../server-impl'; import { SimpleAuthSettings } from '../server-impl';
import { simpleAuthKey } from '../types/settings/simple-auth-settings'; import { simpleAuthKey } from '../types/settings/simple-auth-settings';
import DisabledError from '../error/disabled-error'; import DisabledError from '../error/disabled-error';
import PasswordMismatch from '../error/password-mismatch'; import PasswordMismatch from '../error/password-mismatch';
import BadDataError from '../error/bad-data-error';
import { isDefined } from '../util/isDefined';
const systemUser = new User({ id: -1, username: 'system' }); const systemUser = new User({ id: -1, username: 'system' });
@ -54,11 +55,14 @@ export interface ILoginUserRequest {
interface IUserWithRole extends IUser { interface IUserWithRole extends IUser {
rootRole: number; rootRole: number;
} }
interface IRoleDescription { interface IRoleDescription {
id: number;
description: string; description: string;
name: string; name: string;
type: string; type: string;
} }
interface ITokenUser extends IUpdateUser { interface ITokenUser extends IUpdateUser {
createdBy: string; createdBy: string;
token: string; token: string;
@ -171,7 +175,7 @@ class UserService {
return { ...user, rootRole: roleId }; return { ...user, rootRole: roleId };
} }
async search(query: IUserSearch): Promise<IUser[]> { async search(query: string): Promise<IUser[]> {
return this.store.search(query); return this.store.search(query);
} }
@ -183,7 +187,9 @@ class UserService {
{ username, email, name, password, rootRole }: ICreateUser, { username, email, name, password, rootRole }: ICreateUser,
updatedBy?: User, updatedBy?: User,
): Promise<IUser> { ): Promise<IUser> {
assert.ok(username || email, 'You must specify username or email'); if (!username && !email) {
throw new BadDataError('You must specify username or email');
}
if (email) { if (email) {
Joi.assert(email, Joi.string().email(), 'Email'); Joi.assert(email, Joi.string().email(), 'Email');
@ -236,15 +242,25 @@ class UserService {
{ id, name, email, rootRole }: IUpdateUser, { id, name, email, rootRole }: IUpdateUser,
updatedBy?: User, updatedBy?: User,
): Promise<IUser> { ): Promise<IUser> {
Joi.assert(email, Joi.string().email(), 'Email');
const preUser = await this.store.get(id); const preUser = await this.store.get(id);
if (email) {
Joi.assert(email, Joi.string().email(), 'Email');
}
if (rootRole) { if (rootRole) {
await this.accessService.setUserRootRole(id, rootRole); await this.accessService.setUserRootRole(id, rootRole);
} }
const user = await this.store.update(id, { name, email }); const payload: Partial<IUser> = {
name: name || preUser.name,
email: email || preUser.email,
};
// Empty updates will throw, so make sure we have something to update.
const user = Object.values(payload).some(isDefined)
? await this.store.update(id, payload)
: preUser;
await this.eventStore.store({ await this.eventStore.store({
type: USER_UPDATED, type: USER_UPDATED,
@ -359,6 +375,7 @@ class UserService {
name: user.name, name: user.name,
id: user.id, id: user.id,
role: { role: {
id: user.rootRole,
description: role.role.description, description: role.role.description,
type: role.role.type, type: role.role.type,
name: role.role.name, name: role.role.name,

View File

@ -20,13 +20,13 @@ export default class UserSplashService {
this.logger = getLogger('services/user-splash-service.js'); this.logger = getLogger('services/user-splash-service.js');
} }
async getAllUserSplashs(user: User): Promise<Object> { async getAllUserSplashes(user: User): Promise<Record<string, boolean>> {
if (user.isAPI) { if (user.isAPI) {
return []; return {};
} }
try { try {
const splashs = ( return (
await this.userSplashStore.getAllUserSplashs(user.id) await this.userSplashStore.getAllUserSplashes(user.id)
).reduce( ).reduce(
(splashObject, splash) => ({ (splashObject, splash) => ({
...splashObject, ...splashObject,
@ -34,10 +34,8 @@ export default class UserSplashService {
}), }),
{}, {},
); );
return splashs;
} catch (err) { } catch (err) {
this.logger.error(err); this.logger.error(err);
return {}; return {};
} }
} }

View File

@ -12,7 +12,7 @@ export interface IUserSplashKey {
} }
export interface IUserSplashStore extends Store<IUserSplash, IUserSplashKey> { export interface IUserSplashStore extends Store<IUserSplash, IUserSplashKey> {
getAllUserSplashs(userId: number): Promise<IUserSplash[]>; getAllUserSplashes(userId: number): Promise<IUserSplash[]>;
getSplash(userId: number, splashId: string): Promise<IUserSplash>; getSplash(userId: number, splashId: string): Promise<IUserSplash>;
updateSplash(splash: IUserSplash): Promise<IUserSplash>; updateSplash(splash: IUserSplash): Promise<IUserSplash>;
} }

View File

@ -14,12 +14,6 @@ export interface IUserLookup {
email?: string; email?: string;
} }
export interface IUserSearch {
name?: string;
username?: string;
email: string;
}
export interface IUserUpdateFields { export interface IUserUpdateFields {
name?: string; name?: string;
email?: string; email?: string;
@ -30,7 +24,7 @@ export interface IUserStore extends Store<IUser, number> {
insert(user: ICreateUser): Promise<IUser>; insert(user: ICreateUser): Promise<IUser>;
upsert(user: ICreateUser): Promise<IUser>; upsert(user: ICreateUser): Promise<IUser>;
hasUser(idQuery: IUserLookup): Promise<number | undefined>; hasUser(idQuery: IUserLookup): Promise<number | undefined>;
search(query: IUserSearch): Promise<IUser[]>; search(query: string): Promise<IUser[]>;
getAllWithId(userIdList: number[]): Promise<IUser[]>; getAllWithId(userIdList: number[]): Promise<IUser[]>;
getByQuery(idQuery: IUserLookup): Promise<IUser>; getByQuery(idQuery: IUserLookup): Promise<IUser>;
getPasswordHash(userId: number): Promise<string>; getPasswordHash(userId: number): Promise<string>;

View File

@ -0,0 +1,8 @@
import { isDefined } from './isDefined';
test('isDefined', () => {
expect(isDefined(null)).toEqual(false);
expect(isDefined(undefined)).toEqual(false);
expect(isDefined(0)).toEqual(true);
expect(isDefined(false)).toEqual(true);
});

View File

@ -0,0 +1,3 @@
export const isDefined = <T>(value: T | null | undefined): value is T => {
return value !== null && typeof value !== 'undefined';
};

View File

@ -11,6 +11,8 @@ import { IEventStore } from '../../../../lib/types/stores/event-store';
import { IUserStore } from '../../../../lib/types/stores/user-store'; import { IUserStore } from '../../../../lib/types/stores/user-store';
import { RoleName } from '../../../../lib/types/model'; import { RoleName } from '../../../../lib/types/model';
import { IRoleStore } from 'lib/types/stores/role-store'; import { IRoleStore } from 'lib/types/stores/role-store';
import { randomId } from '../../../../lib/util/random-id';
import { omitKeys } from '../../../../lib/util/omit-keys';
let stores; let stores;
let db; let db;
@ -133,6 +135,19 @@ test('requires known root role', async () => {
.expect(400); .expect(400);
}); });
test('should require username or email on create', async () => {
await app.request
.post('/api/admin/user-admin')
.send({ rootRole: adminRole.id })
.set('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body.details[0].message).toEqual(
'You must specify username or email',
);
});
});
test('update user name', async () => { test('update user name', async () => {
const { body } = await app.request const { body } = await app.request
.post('/api/admin/user-admin') .post('/api/admin/user-admin')
@ -157,6 +172,24 @@ test('update user name', async () => {
}); });
}); });
test('should not require any fields on update', async () => {
const { body: created } = await app.request
.post('/api/admin/user-admin')
.send({ email: `${randomId()}@example.com`, rootRole: editorRole.id })
.set('Content-Type', 'application/json')
.expect(201);
const { body: updated } = await app.request
.put(`/api/admin/user-admin/${created.id}`)
.send({})
.set('Content-Type', 'application/json')
.expect(200);
expect(updated).toEqual(
omitKeys(created, 'emailSent', 'inviteLink', 'rootRole'),
);
});
test('get a single user', async () => { test('get a single user', async () => {
const { body } = await app.request const { body } = await app.request
.post('/api/admin/user-admin') .post('/api/admin/user-admin')

View File

@ -488,6 +488,45 @@ Object {
], ],
"type": "object", "type": "object",
}, },
"createUserSchema": Object {
"additionalProperties": false,
"properties": Object {
"email": Object {
"type": "string",
},
"name": Object {
"type": "string",
},
"password": Object {
"type": "string",
},
"rootRole": Object {
"type": "number",
},
"sendEmail": Object {
"type": "boolean",
},
"username": Object {
"type": "string",
},
},
"required": Array [
"rootRole",
],
"type": "object",
},
"emailSchema": Object {
"additionalProperties": false,
"properties": Object {
"email": Object {
"type": "string",
},
},
"required": Array [
"email",
],
"type": "object",
},
"environmentSchema": Object { "environmentSchema": Object {
"additionalProperties": false, "additionalProperties": false,
"properties": Object { "properties": Object {
@ -953,6 +992,18 @@ Object {
], ],
"type": "object", "type": "object",
}, },
"idSchema": Object {
"additionalProperties": false,
"properties": Object {
"id": Object {
"type": "string",
},
},
"required": Array [
"id",
],
"type": "object",
},
"legalValueSchema": Object { "legalValueSchema": Object {
"additionalProperties": false, "additionalProperties": false,
"properties": Object { "properties": Object {
@ -968,6 +1019,39 @@ Object {
], ],
"type": "object", "type": "object",
}, },
"meSchema": Object {
"additionalProperties": false,
"properties": Object {
"feedback": Object {
"items": Object {
"$ref": "#/components/schemas/feedbackSchema",
},
"type": "array",
},
"permissions": Object {
"items": Object {
"$ref": "#/components/schemas/permissionSchema",
},
"type": "array",
},
"splash": Object {
"additionalProperties": Object {
"type": "boolean",
},
"type": "object",
},
"user": Object {
"$ref": "#/components/schemas/userSchema",
},
},
"required": Array [
"user",
"permissions",
"feedback",
"splash",
],
"type": "object",
},
"nameSchema": Object { "nameSchema": Object {
"additionalProperties": false, "additionalProperties": false,
"properties": Object { "properties": Object {
@ -1005,6 +1089,21 @@ Object {
}, },
"type": "object", "type": "object",
}, },
"passwordSchema": Object {
"additionalProperties": false,
"properties": Object {
"confirmPassword": Object {
"type": "string",
},
"password": Object {
"type": "string",
},
},
"required": Array [
"password",
],
"type": "object",
},
"patchSchema": Object { "patchSchema": Object {
"properties": Object { "properties": Object {
"from": Object { "from": Object {
@ -1037,6 +1136,24 @@ Object {
}, },
"type": "array", "type": "array",
}, },
"permissionSchema": Object {
"additionalProperties": false,
"properties": Object {
"environment": Object {
"type": "string",
},
"permission": Object {
"type": "string",
},
"project": Object {
"type": "string",
},
},
"required": Array [
"permission",
],
"type": "object",
},
"projectEnvironmentSchema": Object { "projectEnvironmentSchema": Object {
"additionalProperties": false, "additionalProperties": false,
"properties": Object { "properties": Object {
@ -1108,21 +1225,24 @@ Object {
"resetPasswordSchema": Object { "resetPasswordSchema": Object {
"additionalProperties": false, "additionalProperties": false,
"properties": Object { "properties": Object {
"email": Object { "resetPasswordUrl": Object {
"type": "string", "type": "string",
}, },
}, },
"required": Array [ "required": Array [
"email", "resetPasswordUrl",
], ],
"type": "object", "type": "object",
}, },
"roleDescriptionSchema": Object { "roleSchema": Object {
"additionalProperties": false, "additionalProperties": false,
"properties": Object { "properties": Object {
"description": Object { "description": Object {
"type": "string", "type": "string",
}, },
"id": Object {
"type": "number",
},
"name": Object { "name": Object {
"type": "string", "type": "string",
}, },
@ -1131,9 +1251,9 @@ Object {
}, },
}, },
"required": Array [ "required": Array [
"description", "id",
"name",
"type", "type",
"name",
], ],
"type": "object", "type": "object",
}, },
@ -1385,7 +1505,7 @@ Object {
"type": "string", "type": "string",
}, },
"role": Object { "role": Object {
"$ref": "#/components/schemas/roleDescriptionSchema", "$ref": "#/components/schemas/roleSchema",
}, },
"token": Object { "token": Object {
"type": "string", "type": "string",
@ -1550,6 +1670,21 @@ Object {
}, },
"type": "object", "type": "object",
}, },
"updateUserSchema": Object {
"additionalProperties": false,
"properties": Object {
"email": Object {
"type": "string",
},
"name": Object {
"type": "string",
},
"rootRole": Object {
"type": "number",
},
},
"type": "object",
},
"upsertContextFieldSchema": Object { "upsertContextFieldSchema": Object {
"properties": Object { "properties": Object {
"description": Object { "description": Object {
@ -1576,6 +1711,81 @@ Object {
], ],
"type": "object", "type": "object",
}, },
"userSchema": Object {
"additionalProperties": false,
"properties": Object {
"createdAt": Object {
"format": "date-time",
"type": "string",
},
"email": Object {
"type": "string",
},
"emailSent": Object {
"type": "boolean",
},
"id": Object {
"type": "number",
},
"imageUrl": Object {
"type": "string",
},
"inviteLink": Object {
"type": "string",
},
"isAPI": Object {
"type": "boolean",
},
"loginAttempts": Object {
"type": "number",
},
"name": Object {
"type": "string",
},
"rootRole": Object {
"type": "number",
},
"seenAt": Object {
"format": "date-time",
"nullable": true,
"type": "string",
},
"username": Object {
"type": "string",
},
},
"required": Array [
"id",
],
"type": "object",
},
"usersSchema": Object {
"additionalProperties": false,
"properties": Object {
"rootRoles": Object {
"items": Object {
"$ref": "#/components/schemas/roleSchema",
},
"type": "array",
},
"users": Object {
"items": Object {
"$ref": "#/components/schemas/userSchema",
},
"type": "array",
},
},
"required": Array [
"users",
],
"type": "object",
},
"usersSearchSchema": Object {
"items": Object {
"$ref": "#/components/schemas/userSchema",
},
"type": "array",
},
"validatePasswordSchema": Object { "validatePasswordSchema": Object {
"additionalProperties": false, "additionalProperties": false,
"properties": Object { "properties": Object {
@ -4098,6 +4308,301 @@ Object {
], ],
}, },
}, },
"/api/admin/user": Object {
"get": Object {
"operationId": "getMe",
"responses": Object {
"200": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/meSchema",
},
},
},
"description": "meSchema",
},
},
"tags": Array [
"admin",
],
},
},
"/api/admin/user-admin": Object {
"get": Object {
"operationId": "getUsers",
"responses": Object {
"200": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/usersSchema",
},
},
},
"description": "usersSchema",
},
},
"tags": Array [
"admin",
],
},
"post": Object {
"operationId": "createUser",
"requestBody": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/createUserSchema",
},
},
},
"description": "createUserSchema",
"required": true,
},
"responses": Object {
"200": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/userSchema",
},
},
},
"description": "userSchema",
},
},
"tags": Array [
"admin",
],
},
},
"/api/admin/user-admin/reset-password": Object {
"post": Object {
"operationId": "resetPassword",
"requestBody": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/idSchema",
},
},
},
"description": "idSchema",
"required": true,
},
"responses": Object {
"200": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/resetPasswordSchema",
},
},
},
"description": "resetPasswordSchema",
},
},
"tags": Array [
"admin",
],
},
},
"/api/admin/user-admin/search": Object {
"get": Object {
"operationId": "searchUsers",
"responses": Object {
"200": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/usersSchema",
},
},
},
"description": "usersSchema",
},
},
"tags": Array [
"admin",
],
},
},
"/api/admin/user-admin/validate-password": Object {
"post": Object {
"operationId": "validatePassword",
"requestBody": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/passwordSchema",
},
},
},
"description": "passwordSchema",
"required": true,
},
"responses": Object {
"200": Object {
"description": "emptyResponse",
},
},
"tags": Array [
"admin",
],
},
},
"/api/admin/user-admin/{id}": Object {
"delete": Object {
"operationId": "deleteUser",
"parameters": Array [
Object {
"in": "path",
"name": "id",
"required": true,
"schema": Object {
"type": "string",
},
},
],
"responses": Object {
"200": Object {
"description": "emptyResponse",
},
},
"tags": Array [
"admin",
],
},
"get": Object {
"operationId": "getUser",
"parameters": Array [
Object {
"in": "path",
"name": "id",
"required": true,
"schema": Object {
"type": "string",
},
},
],
"responses": Object {
"200": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/userSchema",
},
},
},
"description": "userSchema",
},
},
"tags": Array [
"admin",
],
},
"put": Object {
"operationId": "updateUser",
"parameters": Array [
Object {
"in": "path",
"name": "id",
"required": true,
"schema": Object {
"type": "string",
},
},
],
"requestBody": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/updateUserSchema",
},
},
},
"description": "updateUserSchema",
"required": true,
},
"responses": Object {
"200": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/userSchema",
},
},
},
"description": "userSchema",
},
},
"tags": Array [
"admin",
],
},
},
"/api/admin/user-admin/{id}/change-password": Object {
"post": Object {
"operationId": "changePassword",
"parameters": Array [
Object {
"in": "path",
"name": "id",
"required": true,
"schema": Object {
"type": "string",
},
},
],
"requestBody": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/passwordSchema",
},
},
},
"description": "passwordSchema",
"required": true,
},
"responses": Object {
"200": Object {
"description": "emptyResponse",
},
},
"tags": Array [
"admin",
],
},
},
"/api/admin/user/change-password": Object {
"post": Object {
"operationId": "changeMyPassword",
"requestBody": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/passwordSchema",
},
},
},
"description": "passwordSchema",
"required": true,
},
"responses": Object {
"200": Object {
"description": "emptyResponse",
},
"400": Object {
"description": "passwordMismatch",
},
},
"tags": Array [
"admin",
],
},
},
"/auth/reset/password": Object { "/auth/reset/password": Object {
"post": Object { "post": Object {
"operationId": "changePassword", "operationId": "changePassword",
@ -4129,11 +4634,11 @@ Object {
"content": Object { "content": Object {
"application/json": Object { "application/json": Object {
"schema": Object { "schema": Object {
"$ref": "#/components/schemas/resetPasswordSchema", "$ref": "#/components/schemas/emailSchema",
}, },
}, },
}, },
"description": "resetPasswordSchema", "description": "emailSchema",
"required": true, "required": true,
}, },
"responses": Object { "responses": Object {

View File

@ -188,16 +188,13 @@ test('updating a user without an email should not strip the email', async () =>
rootRole: adminRole.id, rootRole: adminRole.id,
}); });
try {
await userService.updateUser({ await userService.updateUser({
id: user.id, id: user.id,
email: null, email: null,
name: 'some', name: 'some',
}); });
} catch (e) {}
const updatedUser = await userService.getUser(user.id); const updatedUser = await userService.getUser(user.id);
expect(updatedUser.email).toBe(email); expect(updatedUser.email).toBe(email);
}); });

View File

@ -31,7 +31,9 @@ test('should create userSplash', async () => {
userId: currentUser.id, userId: currentUser.id,
seen: false, seen: false,
}); });
const userSplashs = await userSplashStore.getAllUserSplashs(currentUser.id); const userSplashs = await userSplashStore.getAllUserSplashes(
currentUser.id,
);
expect(userSplashs).toHaveLength(1); expect(userSplashs).toHaveLength(1);
expect(userSplashs[0].splashId).toBe('some-id'); expect(userSplashs[0].splashId).toBe('some-id');
}); });

View File

@ -6,7 +6,7 @@ import {
export default class FakeUserSplashStore implements IUserSplashStore { export default class FakeUserSplashStore implements IUserSplashStore {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
getAllUserSplashs(userId: number): Promise<IUserSplash[]> { getAllUserSplashes(userId: number): Promise<IUserSplash[]> {
return Promise.resolve([]); return Promise.resolve([]);
} }

View File

@ -202,7 +202,8 @@ Updates use with new fields
**Notes** **Notes**
- `userId` is required as a url path parameter. - `userId` is required as a url path parameter.
- You must provide _at least_ either `name` or `email`. All other fields are entirely optional. Only provided fields are updated. - All fields are optional. Only provided fields are updated.
- Note that earlier versions of Unleash required either `name` or `email` to be set.
### Delete a user {#delete-a-user} ### Delete a user {#delete-a-user}