diff --git a/src/lib/db/user-splash-store.ts b/src/lib/db/user-splash-store.ts index 6b867ee4d7..e2bc2311bb 100644 --- a/src/lib/db/user-splash-store.ts +++ b/src/lib/db/user-splash-store.ts @@ -38,7 +38,7 @@ export default class UserSplashStore implements IUserSplashStore { this.logger = getLogger('user-splash-store.ts'); } - async getAllUserSplashs(userId: number): Promise { + async getAllUserSplashes(userId: number): Promise { const userSplash = await this.db .table(TABLE) .select() diff --git a/src/lib/db/user-store.ts b/src/lib/db/user-store.ts index 65ff92087e..f643905f58 100644 --- a/src/lib/db/user-store.ts +++ b/src/lib/db/user-store.ts @@ -8,7 +8,6 @@ import NotFoundError from '../error/notfound-error'; import { ICreateUser, IUserLookup, - IUserSearch, IUserStore, IUserUpdateFields, } from '../types/stores/user-store'; @@ -116,7 +115,7 @@ class UserStore implements IUserStore { return users.map(rowToUser); } - async search(query: IUserSearch): Promise { + async search(query: string): Promise { const users = await this.db .select(USER_COLUMNS_PUBLIC) .from(TABLE) diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index c64cc6be4d..47c31e2713 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -8,6 +8,7 @@ import { contextFieldsSchema } from './spec/context-fields-schema'; import { createApiTokenSchema } from './spec/create-api-token-schema'; import { createFeatureSchema } from './spec/create-feature-schema'; import { createStrategySchema } from './spec/create-strategy-schema'; +import { createUserSchema } from './spec/create-user-schema'; import { environmentSchema } from './spec/environment-schema'; import { environmentsSchema } from './spec/environments-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 { healthReportSchema } from './spec/health-report-schema'; import { legalValueSchema } from './spec/legal-value-schema'; +import { idSchema } from './spec/id-schema'; import { mapValues } from '../util/map-values'; import { nameSchema } from './spec/name-schema'; +import { meSchema } from './spec/me-schema'; import { omitKeys } from '../util/omit-keys'; import { overrideSchema } from './spec/override-schema'; import { parametersSchema } from './spec/parameters-schema'; +import { passwordSchema } from './spec/password-schema'; import { patchSchema } from './spec/patch-schema'; import { patchesSchema } from './spec/patches-schema'; +import { permissionSchema } from './spec/permission-schema'; import { projectEnvironmentSchema } from './spec/project-environment-schema'; import { projectSchema } from './spec/project-schema'; import { projectsSchema } from './spec/projects-schema'; +import { roleSchema } from './spec/role-schema'; import { sortOrderSchema } from './spec/sort-order-schema'; import { splashSchema } from './spec/splash-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 { updateTagTypeSchema } from './spec/update-tag-type-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 { variantSchema } from './spec/variant-schema'; import { variantsSchema } from './spec/variants-schema'; @@ -57,7 +67,6 @@ import { applicationSchema } from './spec/application-schema'; import { applicationsSchema } from './spec/applications-schema'; import { tagWithVersionSchema } from './spec/tag-with-version-schema'; import { tokenUserSchema } from './spec/token-user-schema'; -import { roleDescriptionSchema } from './spec/role-description-schema'; import { changePasswordSchema } from './spec/change-password-schema'; import { validatePasswordSchema } from './spec/validate-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 { featureTagSchema } from './spec/feature-tag-schema'; import { exportParametersSchema } from './spec/export-parameters-schema'; +import { emailSchema } from './spec/email-schema'; // All schemas in `openapi/spec` should be listed here. export const schemas = { @@ -85,6 +95,8 @@ export const schemas = { createApiTokenSchema, createFeatureSchema, createStrategySchema, + createUserSchema, + emailSchema, environmentSchema, environmentsSchema, exportParametersSchema, @@ -103,15 +115,19 @@ export const schemas = { healthReportSchema, legalValueSchema, nameSchema, + idSchema, + meSchema, overrideSchema, parametersSchema, + passwordSchema, patchSchema, patchesSchema, + permissionSchema, projectEnvironmentSchema, projectSchema, projectsSchema, resetPasswordSchema, - roleDescriptionSchema, + roleSchema, segmentSchema, sortOrderSchema, splashSchema, @@ -131,6 +147,10 @@ export const schemas = { upsertContextFieldSchema, validatePasswordSchema, validateTagTypeSchema, + updateUserSchema, + userSchema, + usersSchema, + usersSearchSchema, variantSchema, variantsSchema, versionSchema, diff --git a/src/lib/openapi/spec/__snapshots__/reset-password-schema.test.ts.snap b/src/lib/openapi/spec/__snapshots__/email-schema.test.ts.snap similarity index 77% rename from src/lib/openapi/spec/__snapshots__/reset-password-schema.test.ts.snap rename to src/lib/openapi/spec/__snapshots__/email-schema.test.ts.snap index 892b501ad4..ff9ce36771 100644 --- a/src/lib/openapi/spec/__snapshots__/reset-password-schema.test.ts.snap +++ b/src/lib/openapi/spec/__snapshots__/email-schema.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`resetPasswordSchema empty 1`] = ` +exports[`emailSchema 1`] = ` Object { "data": Object {}, "errors": Array [ @@ -14,6 +14,6 @@ Object { "schemaPath": "#/required", }, ], - "schema": "#/components/schemas/resetPasswordSchema", + "schema": "#/components/schemas/emailSchema", } `; diff --git a/src/lib/openapi/spec/__snapshots__/me-schema.test.ts.snap b/src/lib/openapi/spec/__snapshots__/me-schema.test.ts.snap new file mode 100644 index 0000000000..59cd54e38d --- /dev/null +++ b/src/lib/openapi/spec/__snapshots__/me-schema.test.ts.snap @@ -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", +} +`; diff --git a/src/lib/openapi/spec/__snapshots__/role-description-schema.test.ts.snap b/src/lib/openapi/spec/__snapshots__/role-schema.test.ts.snap similarity index 54% rename from src/lib/openapi/spec/__snapshots__/role-description-schema.test.ts.snap rename to src/lib/openapi/spec/__snapshots__/role-schema.test.ts.snap index 5fbd61e9c6..01b1ffea7d 100644 --- a/src/lib/openapi/spec/__snapshots__/role-description-schema.test.ts.snap +++ b/src/lib/openapi/spec/__snapshots__/role-schema.test.ts.snap @@ -1,19 +1,19 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`roleDescriptionSchema empty 1`] = ` +exports[`roleSchema 1`] = ` Object { "data": Object {}, "errors": Array [ Object { "instancePath": "", "keyword": "required", - "message": "must have required property 'description'", + "message": "must have required property 'id'", "params": Object { - "missingProperty": "description", + "missingProperty": "id", }, "schemaPath": "#/required", }, ], - "schema": "#/components/schemas/roleDescriptionSchema", + "schema": "#/components/schemas/roleSchema", } `; diff --git a/src/lib/openapi/spec/create-user-schema.ts b/src/lib/openapi/spec/create-user-schema.ts new file mode 100644 index 0000000000..0f339440db --- /dev/null +++ b/src/lib/openapi/spec/create-user-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/email-schema.test.ts b/src/lib/openapi/spec/email-schema.test.ts new file mode 100644 index 0000000000..89c58950c0 --- /dev/null +++ b/src/lib/openapi/spec/email-schema.test.ts @@ -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(); +}); diff --git a/src/lib/openapi/spec/email-schema.ts b/src/lib/openapi/spec/email-schema.ts new file mode 100644 index 0000000000..4c79530619 --- /dev/null +++ b/src/lib/openapi/spec/email-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/id-schema.ts b/src/lib/openapi/spec/id-schema.ts new file mode 100644 index 0000000000..d9daebe1e3 --- /dev/null +++ b/src/lib/openapi/spec/id-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/me-schema.test.ts b/src/lib/openapi/spec/me-schema.test.ts new file mode 100644 index 0000000000..851d4b7905 --- /dev/null +++ b/src/lib/openapi/spec/me-schema.test.ts @@ -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(); +}); diff --git a/src/lib/openapi/spec/me-schema.ts b/src/lib/openapi/spec/me-schema.ts new file mode 100644 index 0000000000..15acf3109c --- /dev/null +++ b/src/lib/openapi/spec/me-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/password-schema.ts b/src/lib/openapi/spec/password-schema.ts new file mode 100644 index 0000000000..ff4118e398 --- /dev/null +++ b/src/lib/openapi/spec/password-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/permission-schema.ts b/src/lib/openapi/spec/permission-schema.ts new file mode 100644 index 0000000000..9340ef2920 --- /dev/null +++ b/src/lib/openapi/spec/permission-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/reset-password-schema.test.ts b/src/lib/openapi/spec/reset-password-schema.test.ts deleted file mode 100644 index fed93e4013..0000000000 --- a/src/lib/openapi/spec/reset-password-schema.test.ts +++ /dev/null @@ -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(); -}); diff --git a/src/lib/openapi/spec/reset-password-schema.ts b/src/lib/openapi/spec/reset-password-schema.ts index cdc5b1d4a7..ff9803f2dd 100644 --- a/src/lib/openapi/spec/reset-password-schema.ts +++ b/src/lib/openapi/spec/reset-password-schema.ts @@ -4,9 +4,9 @@ export const resetPasswordSchema = { $id: '#/components/schemas/resetPasswordSchema', type: 'object', additionalProperties: false, - required: ['email'], + required: ['resetPasswordUrl'], properties: { - email: { + resetPasswordUrl: { type: 'string', }, }, diff --git a/src/lib/openapi/spec/role-description-schema.test.ts b/src/lib/openapi/spec/role-description-schema.test.ts deleted file mode 100644 index 61dc118f7c..0000000000 --- a/src/lib/openapi/spec/role-description-schema.test.ts +++ /dev/null @@ -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(); -}); diff --git a/src/lib/openapi/spec/role-schema.test.ts b/src/lib/openapi/spec/role-schema.test.ts new file mode 100644 index 0000000000..1657d47a19 --- /dev/null +++ b/src/lib/openapi/spec/role-schema.test.ts @@ -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(); +}); diff --git a/src/lib/openapi/spec/role-description-schema.ts b/src/lib/openapi/spec/role-schema.ts similarity index 60% rename from src/lib/openapi/spec/role-description-schema.ts rename to src/lib/openapi/spec/role-schema.ts index 7eec103ca6..63bb94437b 100644 --- a/src/lib/openapi/spec/role-description-schema.ts +++ b/src/lib/openapi/spec/role-schema.ts @@ -1,22 +1,25 @@ import { FromSchema } from 'json-schema-to-ts'; -export const roleDescriptionSchema = { - $id: '#/components/schemas/roleDescriptionSchema', +export const roleSchema = { + $id: '#/components/schemas/roleSchema', type: 'object', additionalProperties: false, - required: ['description', 'name', 'type'], + required: ['id', 'type', 'name'], properties: { - description: { + id: { + type: 'number', + }, + type: { type: 'string', }, name: { type: 'string', }, - type: { + description: { type: 'string', }, }, components: {}, } as const; -export type RoleDescriptionSchema = FromSchema; +export type RoleSchema = FromSchema; diff --git a/src/lib/openapi/spec/token-user-schema.test.ts b/src/lib/openapi/spec/token-user-schema.test.ts index e63b1aa538..2cd3fae92e 100644 --- a/src/lib/openapi/spec/token-user-schema.test.ts +++ b/src/lib/openapi/spec/token-user-schema.test.ts @@ -6,6 +6,7 @@ test('tokenUserSchema', () => { createdBy: '', token: '', role: { + id: 1, description: '', name: '', type: '', diff --git a/src/lib/openapi/spec/token-user-schema.ts b/src/lib/openapi/spec/token-user-schema.ts index 769d801492..efafc3c8e3 100644 --- a/src/lib/openapi/spec/token-user-schema.ts +++ b/src/lib/openapi/spec/token-user-schema.ts @@ -1,5 +1,5 @@ import { FromSchema } from 'json-schema-to-ts'; -import { roleDescriptionSchema } from './role-description-schema'; +import { roleSchema } from './role-schema'; export const tokenUserSchema = { $id: '#/components/schemas/tokenUserSchema', @@ -14,12 +14,12 @@ export const tokenUserSchema = { type: 'string', }, role: { - $ref: '#/components/schemas/roleDescriptionSchema', + $ref: '#/components/schemas/roleSchema', }, }, components: { schemas: { - roleDescriptionSchema, + roleSchema, }, }, } as const; diff --git a/src/lib/openapi/spec/update-user-schema.ts b/src/lib/openapi/spec/update-user-schema.ts new file mode 100644 index 0000000000..663c2e965f --- /dev/null +++ b/src/lib/openapi/spec/update-user-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/user-schema.ts b/src/lib/openapi/spec/user-schema.ts new file mode 100644 index 0000000000..9e2f579fbe --- /dev/null +++ b/src/lib/openapi/spec/user-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/users-schema.test.ts b/src/lib/openapi/spec/users-schema.test.ts new file mode 100644 index 0000000000..a30fbcbe78 --- /dev/null +++ b/src/lib/openapi/spec/users-schema.test.ts @@ -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(); +}); diff --git a/src/lib/openapi/spec/users-schema.ts b/src/lib/openapi/spec/users-schema.ts new file mode 100644 index 0000000000..e28bb9a607 --- /dev/null +++ b/src/lib/openapi/spec/users-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/users-search-schema.test.ts b/src/lib/openapi/spec/users-search-schema.test.ts new file mode 100644 index 0000000000..a6bebf63de --- /dev/null +++ b/src/lib/openapi/spec/users-search-schema.test.ts @@ -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(); +}); diff --git a/src/lib/openapi/spec/users-search-schema.ts b/src/lib/openapi/spec/users-search-schema.ts new file mode 100644 index 0000000000..9a4084a275 --- /dev/null +++ b/src/lib/openapi/spec/users-search-schema.ts @@ -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; diff --git a/src/lib/routes/admin-api/user-admin.ts b/src/lib/routes/admin-api/user-admin.ts index 02d980efca..590d65b8ef 100644 --- a/src/lib/routes/admin-api/user-admin.ts +++ b/src/lib/routes/admin-api/user-admin.ts @@ -8,20 +8,29 @@ import { IUnleashConfig } from '../../types/option'; import { EmailService } from '../../services/email-service'; import ResetTokenService from '../../services/reset-token-service'; import { IUnleashServices } from '../../types/services'; -import SessionService from '../../services/session-service'; import { IAuthRequest } from '../unleash-types'; import SettingService from '../../services/setting-service'; import { IUser, SimpleAuthSettings } from '../../server-impl'; import { simpleAuthKey } from '../../types/settings/simple-auth-settings'; import { anonymise } from '../../util/anonymise'; - -interface ICreateUserBody { - username: string; - email: string; - name: string; - rootRole: number; - sendEmail: boolean; -} +import { OpenApiService } from '../../services/openapi-service'; +import { emptyResponse } from '../../openapi/spec/empty-response'; +import { createRequestSchema, createResponseSchema } from '../../openapi'; +import { userSchema, UserSchema } from '../../openapi/spec/user-schema'; +import { serializeDates } from '../../types/serialize-dates'; +import { usersSchema, UsersSchema } from '../../openapi/spec/users-schema'; +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 { private anonymise: boolean = false; @@ -36,10 +45,10 @@ export default class UserAdminController extends Controller { private resetTokenService: ResetTokenService; - private sessionService: SessionService; - private settingService: SettingService; + private openApiService: OpenApiService; + readonly unleashUrl: string; constructor( @@ -49,16 +58,16 @@ export default class UserAdminController extends Controller { accessService, emailService, resetTokenService, - sessionService, settingService, + openApiService, }: Pick< IUnleashServices, | 'userService' | 'accessService' | 'emailService' | 'resetTokenService' - | 'sessionService' | 'settingService' + | 'openApiService' >, ) { super(config); @@ -66,32 +75,165 @@ export default class UserAdminController extends Controller { this.accessService = accessService; this.emailService = emailService; this.resetTokenService = resetTokenService; - this.sessionService = sessionService; this.settingService = settingService; + this.openApiService = openApiService; this.logger = config.getLogger('routes/user-controller.ts'); this.unleashUrl = config.server.unleashUrl; this.anonymise = config.experimental?.anonymiseEventLog; - this.get('/', this.getUsers, ADMIN); - this.get('/search', this.search); - this.post('/', this.createUser, ADMIN); - this.post('/validate-password', this.validatePassword, NONE); - this.get('/:id', this.getUser, ADMIN); - this.put('/:id', this.updateUser, ADMIN); - this.post('/:id/change-password', this.changePassword, ADMIN); - this.delete('/:id', this.deleteUser, ADMIN); - this.post('/reset-password', this.resetPassword, ADMIN); + this.route({ + method: 'post', + path: '/validate-password', + handler: this.validatePassword, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['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 { + async resetPassword( + req: IAuthRequest, + res: Response, + ): Promise { const { user } = req; const receiver = req.body.id; const resetPasswordUrl = 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 { + async getUsers(req: Request, res: Response): Promise { const users = await this.userService.getAll(); const rootRoles = await this.accessService.getRootRoles(); const inviteLinks = await this.resetTokenService.getActiveInvitations(); @@ -101,7 +243,10 @@ export default class UserAdminController extends Controller { return { ...user, inviteLink }; }); - res.json({ users: usersWithInviteLinks, rootRoles }); + this.openApiService.respondWithValidation(200, res, usersSchema.$id, { + users: serializeDates(usersWithInviteLinks), + rootRoles, + }); } anonymiseUsers(users: IUser[]): IUser[] { @@ -113,118 +258,130 @@ export default class UserAdminController extends Controller { })); } - async search(req: Request, res: Response): Promise { - const { q } = req.query as any; - try { - let users = - q && q.length > 1 ? await this.userService.search(q) : []; - - if (this.anonymise) { - users = this.anonymiseUsers(users); - } - res.json(users); - } catch (error) { - this.logger.error(error); - res.status(500).send({ msg: 'server errors' }); + async searchUsers( + req: Request, + res: Response, + ): Promise { + const { q } = req.query; + let users = + typeof q === 'string' && q.length > 1 + ? await this.userService.search(q) + : []; + if (this.anonymise) { + users = this.anonymiseUsers(users); } + this.openApiService.respondWithValidation( + 200, + res, + usersSearchSchema.$id, + serializeDates(users), + ); } - async getUser(req: Request, res: Response): Promise { + async getUser(req: Request, res: Response): Promise { const { id } = req.params; const user = await this.userService.getUser(Number(id)); - res.json(user); + + this.openApiService.respondWithValidation( + 200, + res, + userSchema.$id, + serializeDates(user), + ); } async createUser( - req: IAuthRequest, - res: Response, + req: IAuthRequest, + res: Response, ): Promise { const { username, email, name, rootRole, sendEmail } = req.body; const { user } = req; - try { - const createdUser = await this.userService.createUser( - { - username, - email, - name, - rootRole, - }, - user, - ); - - const passwordAuthSettings = - await this.settingService.get( - simpleAuthKey, - ); - - let inviteLink: string; - if (!passwordAuthSettings?.disabled) { - const inviteUrl = await this.resetTokenService.createNewUserUrl( - createdUser.id, - user.email, - ); - inviteLink = inviteUrl.toString(); - } - - let emailSent = false; - const emailConfigured = this.emailService.configured(); - const reallySendEmail = - emailConfigured && (sendEmail !== undefined ? sendEmail : true); - if (reallySendEmail) { - try { - await this.emailService.sendGettingStartedMail( - createdUser.name, - createdUser.email, - this.unleashUrl, - inviteLink, - ); - emailSent = true; - } catch (e) { - this.logger.warn( - 'email was configured, but sending failed due to: ', - e, - ); - } - } else { - this.logger.warn( - 'email was not sent to the user because email configuration is lacking', - ); - } - - res.status(201).send({ - ...createdUser, - inviteLink: inviteLink || this.unleashUrl, - emailSent, + const createdUser = await this.userService.createUser( + { + username, + email, + name, rootRole, - }); - } catch (e) { - this.logger.warn(e.message); - res.status(400).send([{ msg: e.message }]); + }, + user, + ); + + const passwordAuthSettings = + await this.settingService.get(simpleAuthKey); + + let inviteLink: string; + if (!passwordAuthSettings?.disabled) { + const inviteUrl = await this.resetTokenService.createNewUserUrl( + createdUser.id, + user.email, + ); + inviteLink = inviteUrl.toString(); } + + let emailSent = false; + const emailConfigured = this.emailService.configured(); + const reallySendEmail = + emailConfigured && (sendEmail !== undefined ? sendEmail : true); + + if (reallySendEmail) { + try { + await this.emailService.sendGettingStartedMail( + createdUser.name, + createdUser.email, + this.unleashUrl, + inviteLink, + ); + emailSent = true; + } catch (e) { + this.logger.warn( + 'email was configured, but sending failed due to: ', + e, + ); + } + } else { + this.logger.warn( + 'email was not sent to the user because email configuration is lacking', + ); + } + + const responseData: UserSchema = { + ...serializeDates(createdUser), + inviteLink: inviteLink || this.unleashUrl, + emailSent, + rootRole, + }; + + this.openApiService.respondWithValidation( + 201, + res, + userSchema.$id, + responseData, + ); } - async updateUser(req: IAuthRequest, res: Response): Promise { + async updateUser( + req: IAuthRequest<{ id: string }, UserSchema, UpdateUserSchema>, + res: Response, + ): Promise { const { user, params, body } = req; - const { id } = params; const { name, email, rootRole } = body; - try { - const updateUser = await this.userService.updateUser( - { - id: Number(id), - name, - email, - rootRole, - }, - user, - ); - res.status(200).send({ ...updateUser, rootRole }); - } catch (e) { - this.logger.warn(e.message); - res.status(400).send([{ msg: e.message }]); - } + const updateUser = await this.userService.updateUser( + { + id: Number(id), + name, + email, + rootRole, + }, + user, + ); + + this.openApiService.respondWithValidation(200, res, userSchema.$id, { + ...serializeDates(updateUser), + rootRole, + }); } async deleteUser(req: IAuthRequest, res: Response): Promise { @@ -235,14 +392,20 @@ export default class UserAdminController extends Controller { res.status(200).send(); } - async validatePassword(req: IAuthRequest, res: Response): Promise { + async validatePassword( + req: IAuthRequest, + res: Response, + ): Promise { const { password } = req.body; this.userService.validatePassword(password); res.status(200).send(); } - async changePassword(req: IAuthRequest, res: Response): Promise { + async changePassword( + req: IAuthRequest<{ id: string }, unknown, PasswordSchema>, + res: Response, + ): Promise { const { id } = req.params; const { password } = req.body; diff --git a/src/lib/routes/admin-api/user.ts b/src/lib/routes/admin-api/user.ts index 3312def806..b1ceab2b84 100644 --- a/src/lib/routes/admin-api/user.ts +++ b/src/lib/routes/admin-api/user.ts @@ -8,11 +8,13 @@ import UserService from '../../services/user-service'; import UserFeedbackService from '../../services/user-feedback-service'; import UserSplashService from '../../services/user-splash-service'; import { ADMIN, NONE } from '../../types/permissions'; - -interface IChangeUserRequest { - password: string; - confirmPassword: string; -} +import { OpenApiService } from '../../services/openapi-service'; +import { createRequestSchema, createResponseSchema } from '../../openapi'; +import { emptyResponse } from '../../openapi/spec/empty-response'; +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 { private accessService: AccessService; @@ -23,6 +25,8 @@ class UserController extends Controller { private userSplashService: UserSplashService; + private openApiService: OpenApiService; + constructor( config: IUnleashConfig, { @@ -30,12 +34,14 @@ class UserController extends Controller { userService, userFeedbackService, userSplashService, + openApiService, }: Pick< IUnleashServices, | 'accessService' | 'userService' | 'userFeedbackService' | 'userSplashService' + | 'openApiService' >, ) { super(config); @@ -43,15 +49,45 @@ class UserController extends Controller { this.userService = userService; this.userFeedbackService = userFeedbackService; this.userSplashService = userSplashService; + this.openApiService = openApiService; - this.get('/', this.getUser); - this.post('/change-password', this.updateUserPass, NONE); + this.route({ + 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 { + async getMe(req: IAuthRequest, res: Response): Promise { res.setHeader('cache-control', 'no-store'); const { user } = req; - let permissions; + let permissions: IUserPermission[]; if (this.config.authentication.type === IAuthType.NONE) { permissions = [{ permission: ADMIN }]; } else { @@ -60,16 +96,25 @@ class UserController extends Controller { const feedback = await this.userFeedbackService.getAllUserFeedback( user, ); - const splash = await this.userSplashService.getAllUserSplashs(user); + const splash = await this.userSplashService.getAllUserSplashes(user); - return res - .status(200) - .json({ user, permissions, feedback, splash }) - .end(); + const responseData: MeSchema = { + user: serializeDates(user), + permissions, + feedback: serializeDates(feedback), + splash, + }; + + this.openApiService.respondWithValidation( + 200, + res, + meSchema.$id, + responseData, + ); } - async updateUserPass( - req: IAuthRequest, + async changeMyPassword( + req: IAuthRequest, res: Response, ): Promise { const { user } = req; diff --git a/src/lib/routes/auth/reset-password-controller.ts b/src/lib/routes/auth/reset-password-controller.ts index 71d81ba3cd..6a10972e06 100644 --- a/src/lib/routes/auth/reset-password-controller.ts +++ b/src/lib/routes/auth/reset-password-controller.ts @@ -12,6 +12,7 @@ import { tokenUserSchema, TokenUserSchema, } from '../../openapi/spec/token-user-schema'; +import { EmailSchema } from '../../openapi/spec/email-schema'; interface IValidateQuery { token: string; @@ -97,14 +98,17 @@ class ResetPasswordController extends Controller { openApiService.validPath({ tags: ['other'], operationId: 'sendResetPasswordEmail', - requestBody: createRequestSchema('resetPasswordSchema'), + requestBody: createRequestSchema('emailSchema'), responses: { 200: emptyResponse }, }), ], }); } - async sendResetPasswordEmail(req: Request, res: Response): Promise { + async sendResetPasswordEmail( + req: Request, + res: Response, + ): Promise { const { email } = req.body; await this.userService.createResetPasswordEmail(email); diff --git a/src/lib/services/access-service.ts b/src/lib/services/access-service.ts index 3511bf0691..8ea594d555 100644 --- a/src/lib/services/access-service.ts +++ b/src/lib/services/access-service.ts @@ -27,6 +27,7 @@ import { roleSchema } from '../schema/role-schema'; import { CUSTOM_ROLE_TYPE, ALL_PROJECTS, ALL_ENVS } from '../util/constants'; import { DEFAULT_PROJECT } from '../types/project'; import InvalidOperationError from '../error/invalid-operation-error'; +import BadDataError from '../error/bad-data-error'; const { ADMIN } = permissions; @@ -200,7 +201,7 @@ export class AccessService { ); } } else { - throw new Error(`Could not find rootRole=${role}`); + throw new BadDataError(`Could not find rootRole=${role}`); } } diff --git a/src/lib/services/user-service.ts b/src/lib/services/user-service.ts index 8226ba9392..19c3a83c21 100644 --- a/src/lib/services/user-service.ts +++ b/src/lib/services/user-service.ts @@ -1,4 +1,3 @@ -import assert from 'assert'; import bcrypt from 'bcryptjs'; import owasp from 'owasp-password-strength-test'; import Joi from 'joi'; @@ -19,13 +18,15 @@ import { IUnleashStores } from '../types/stores'; import PasswordUndefinedError from '../error/password-undefined'; import { USER_UPDATED, USER_CREATED, USER_DELETED } from '../types/events'; 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 SettingService from './setting-service'; import { SimpleAuthSettings } from '../server-impl'; import { simpleAuthKey } from '../types/settings/simple-auth-settings'; import DisabledError from '../error/disabled-error'; 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' }); @@ -54,11 +55,14 @@ export interface ILoginUserRequest { interface IUserWithRole extends IUser { rootRole: number; } + interface IRoleDescription { + id: number; description: string; name: string; type: string; } + interface ITokenUser extends IUpdateUser { createdBy: string; token: string; @@ -171,7 +175,7 @@ class UserService { return { ...user, rootRole: roleId }; } - async search(query: IUserSearch): Promise { + async search(query: string): Promise { return this.store.search(query); } @@ -183,7 +187,9 @@ class UserService { { username, email, name, password, rootRole }: ICreateUser, updatedBy?: User, ): Promise { - assert.ok(username || email, 'You must specify username or email'); + if (!username && !email) { + throw new BadDataError('You must specify username or email'); + } if (email) { Joi.assert(email, Joi.string().email(), 'Email'); @@ -236,15 +242,25 @@ class UserService { { id, name, email, rootRole }: IUpdateUser, updatedBy?: User, ): Promise { - Joi.assert(email, Joi.string().email(), 'Email'); - const preUser = await this.store.get(id); + if (email) { + Joi.assert(email, Joi.string().email(), 'Email'); + } + if (rootRole) { await this.accessService.setUserRootRole(id, rootRole); } - const user = await this.store.update(id, { name, email }); + const payload: Partial = { + 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({ type: USER_UPDATED, @@ -359,6 +375,7 @@ class UserService { name: user.name, id: user.id, role: { + id: user.rootRole, description: role.role.description, type: role.role.type, name: role.role.name, diff --git a/src/lib/services/user-splash-service.ts b/src/lib/services/user-splash-service.ts index 7d919c7dce..9bcd56a4af 100644 --- a/src/lib/services/user-splash-service.ts +++ b/src/lib/services/user-splash-service.ts @@ -20,13 +20,13 @@ export default class UserSplashService { this.logger = getLogger('services/user-splash-service.js'); } - async getAllUserSplashs(user: User): Promise { + async getAllUserSplashes(user: User): Promise> { if (user.isAPI) { - return []; + return {}; } try { - const splashs = ( - await this.userSplashStore.getAllUserSplashs(user.id) + return ( + await this.userSplashStore.getAllUserSplashes(user.id) ).reduce( (splashObject, splash) => ({ ...splashObject, @@ -34,10 +34,8 @@ export default class UserSplashService { }), {}, ); - return splashs; } catch (err) { this.logger.error(err); - return {}; } } diff --git a/src/lib/types/stores/user-splash-store.ts b/src/lib/types/stores/user-splash-store.ts index 75938981ef..6a6c922528 100644 --- a/src/lib/types/stores/user-splash-store.ts +++ b/src/lib/types/stores/user-splash-store.ts @@ -12,7 +12,7 @@ export interface IUserSplashKey { } export interface IUserSplashStore extends Store { - getAllUserSplashs(userId: number): Promise; + getAllUserSplashes(userId: number): Promise; getSplash(userId: number, splashId: string): Promise; updateSplash(splash: IUserSplash): Promise; } diff --git a/src/lib/types/stores/user-store.ts b/src/lib/types/stores/user-store.ts index 67a00c306d..32b3beaa83 100644 --- a/src/lib/types/stores/user-store.ts +++ b/src/lib/types/stores/user-store.ts @@ -14,12 +14,6 @@ export interface IUserLookup { email?: string; } -export interface IUserSearch { - name?: string; - username?: string; - email: string; -} - export interface IUserUpdateFields { name?: string; email?: string; @@ -30,7 +24,7 @@ export interface IUserStore extends Store { insert(user: ICreateUser): Promise; upsert(user: ICreateUser): Promise; hasUser(idQuery: IUserLookup): Promise; - search(query: IUserSearch): Promise; + search(query: string): Promise; getAllWithId(userIdList: number[]): Promise; getByQuery(idQuery: IUserLookup): Promise; getPasswordHash(userId: number): Promise; diff --git a/src/lib/util/isDefined.test.ts b/src/lib/util/isDefined.test.ts new file mode 100644 index 0000000000..ffd732771e --- /dev/null +++ b/src/lib/util/isDefined.test.ts @@ -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); +}); diff --git a/src/lib/util/isDefined.ts b/src/lib/util/isDefined.ts new file mode 100644 index 0000000000..a9a4a3839a --- /dev/null +++ b/src/lib/util/isDefined.ts @@ -0,0 +1,3 @@ +export const isDefined = (value: T | null | undefined): value is T => { + return value !== null && typeof value !== 'undefined'; +}; diff --git a/src/test/e2e/api/admin/user-admin.e2e.test.ts b/src/test/e2e/api/admin/user-admin.e2e.test.ts index eb0ed45b3c..948db2d757 100644 --- a/src/test/e2e/api/admin/user-admin.e2e.test.ts +++ b/src/test/e2e/api/admin/user-admin.e2e.test.ts @@ -11,6 +11,8 @@ import { IEventStore } from '../../../../lib/types/stores/event-store'; import { IUserStore } from '../../../../lib/types/stores/user-store'; import { RoleName } from '../../../../lib/types/model'; 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 db; @@ -133,6 +135,19 @@ test('requires known root role', async () => { .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 () => { const { body } = await app.request .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 () => { const { body } = await app.request .post('/api/admin/user-admin') diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index a189512997..9ff7ce4e7a 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -488,6 +488,45 @@ 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 { "additionalProperties": false, "properties": Object { @@ -953,6 +992,18 @@ Object { ], "type": "object", }, + "idSchema": Object { + "additionalProperties": false, + "properties": Object { + "id": Object { + "type": "string", + }, + }, + "required": Array [ + "id", + ], + "type": "object", + }, "legalValueSchema": Object { "additionalProperties": false, "properties": Object { @@ -968,6 +1019,39 @@ 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 { "additionalProperties": false, "properties": Object { @@ -1005,6 +1089,21 @@ Object { }, "type": "object", }, + "passwordSchema": Object { + "additionalProperties": false, + "properties": Object { + "confirmPassword": Object { + "type": "string", + }, + "password": Object { + "type": "string", + }, + }, + "required": Array [ + "password", + ], + "type": "object", + }, "patchSchema": Object { "properties": Object { "from": Object { @@ -1037,6 +1136,24 @@ Object { }, "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 { "additionalProperties": false, "properties": Object { @@ -1108,21 +1225,24 @@ Object { "resetPasswordSchema": Object { "additionalProperties": false, "properties": Object { - "email": Object { + "resetPasswordUrl": Object { "type": "string", }, }, "required": Array [ - "email", + "resetPasswordUrl", ], "type": "object", }, - "roleDescriptionSchema": Object { + "roleSchema": Object { "additionalProperties": false, "properties": Object { "description": Object { "type": "string", }, + "id": Object { + "type": "number", + }, "name": Object { "type": "string", }, @@ -1131,9 +1251,9 @@ Object { }, }, "required": Array [ - "description", - "name", + "id", "type", + "name", ], "type": "object", }, @@ -1385,7 +1505,7 @@ Object { "type": "string", }, "role": Object { - "$ref": "#/components/schemas/roleDescriptionSchema", + "$ref": "#/components/schemas/roleSchema", }, "token": Object { "type": "string", @@ -1550,6 +1670,21 @@ 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 { "properties": Object { "description": Object { @@ -1576,6 +1711,81 @@ 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 { "additionalProperties": false, "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 { "post": Object { "operationId": "changePassword", @@ -4129,11 +4634,11 @@ Object { "content": Object { "application/json": Object { "schema": Object { - "$ref": "#/components/schemas/resetPasswordSchema", + "$ref": "#/components/schemas/emailSchema", }, }, }, - "description": "resetPasswordSchema", + "description": "emailSchema", "required": true, }, "responses": Object { diff --git a/src/test/e2e/services/user-service.e2e.test.ts b/src/test/e2e/services/user-service.e2e.test.ts index cfabd66e2f..f438ed1c9d 100644 --- a/src/test/e2e/services/user-service.e2e.test.ts +++ b/src/test/e2e/services/user-service.e2e.test.ts @@ -188,16 +188,13 @@ test('updating a user without an email should not strip the email', async () => rootRole: adminRole.id, }); - try { - await userService.updateUser({ - id: user.id, - email: null, - name: 'some', - }); - } catch (e) {} + await userService.updateUser({ + id: user.id, + email: null, + name: 'some', + }); const updatedUser = await userService.getUser(user.id); - expect(updatedUser.email).toBe(email); }); diff --git a/src/test/e2e/stores/user-splash-store.e2e.test.ts b/src/test/e2e/stores/user-splash-store.e2e.test.ts index 73050f2c0b..60f02c0542 100644 --- a/src/test/e2e/stores/user-splash-store.e2e.test.ts +++ b/src/test/e2e/stores/user-splash-store.e2e.test.ts @@ -31,7 +31,9 @@ test('should create userSplash', async () => { userId: currentUser.id, seen: false, }); - const userSplashs = await userSplashStore.getAllUserSplashs(currentUser.id); + const userSplashs = await userSplashStore.getAllUserSplashes( + currentUser.id, + ); expect(userSplashs).toHaveLength(1); expect(userSplashs[0].splashId).toBe('some-id'); }); diff --git a/src/test/fixtures/fake-user-splash-store.ts b/src/test/fixtures/fake-user-splash-store.ts index 5b3c6e8331..28ab8cf174 100644 --- a/src/test/fixtures/fake-user-splash-store.ts +++ b/src/test/fixtures/fake-user-splash-store.ts @@ -6,7 +6,7 @@ import { export default class FakeUserSplashStore implements IUserSplashStore { // eslint-disable-next-line @typescript-eslint/no-unused-vars - getAllUserSplashs(userId: number): Promise { + getAllUserSplashes(userId: number): Promise { return Promise.resolve([]); } diff --git a/website/docs/api/admin/user-admin.md b/website/docs/api/admin/user-admin.md index b3b8358a51..e85a8d8067 100644 --- a/website/docs/api/admin/user-admin.md +++ b/website/docs/api/admin/user-admin.md @@ -202,7 +202,8 @@ Updates use with new fields **Notes** - `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}