From a4ea46dab6156ae51bd4432b47200a27f399f69f Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Mon, 30 Sep 2024 13:01:30 +0200 Subject: [PATCH] feat: add Unleash admins to API payload (#8299) Adds Unleash admins to the personal dashboard payload. Uses the access store (and a new method) to fetch admins and maps it to a new `MinimalUser` type. We already have a `User` class, but it contains a lot of information we don't care about here, such as `isAPI`, SCIM data etc. In the UI, admins will be shown to users who are not part of any projects. This is the default state for new viewer users, and can also happen for editors if you archive the default project, for instance. Tests in a follow-up PR --- src/lib/db/account-store.ts | 32 ++++++++++++++- .../createPersonalDashboardService.ts | 4 ++ .../fake-personal-dashboard-read-model.ts | 5 +++ .../personal-dashboard-controller.ts | 5 ++- .../personal-dashboard-service.ts | 10 ++++- .../openapi/spec/personal-dashboard-schema.ts | 41 +++++++++++++++++++ src/lib/types/stores/account-store.ts | 3 +- src/lib/types/user.ts | 5 +++ src/test/fixtures/fake-account-store.ts | 5 ++- 9 files changed, 104 insertions(+), 6 deletions(-) diff --git a/src/lib/db/account-store.ts b/src/lib/db/account-store.ts index e1a9b6aba3..c5389a0cdc 100644 --- a/src/lib/db/account-store.ts +++ b/src/lib/db/account-store.ts @@ -4,7 +4,7 @@ import User from '../types/user'; import NotFoundError from '../error/notfound-error'; import type { IUserLookup } from '../types/stores/user-store'; import type { IAdminCount } from '../types/stores/account-store'; -import type { IAccountStore } from '../types'; +import type { IAccountStore, MinimalUser } from '../types'; import type { Db } from './db'; const TABLE = 'users'; @@ -198,4 +198,34 @@ export class AccountStore implements IAccountStore { service: adminCount[0].service, }; } + + async getAdmins(): Promise { + const rowToAdminUser = (row) => { + return { + id: row.id, + name: emptify(row.name), + username: emptify(row.username), + email: emptify(row.email), + imageUrl: emptify(row.image_url), + }; + }; + + const admins = await this.activeAccounts() + .join('role_user as ru', 'users.id', 'ru.user_id') + .where( + 'ru.role_id', + '=', + this.db.raw('(SELECT id FROM roles WHERE name = ?)', ['Admin']), + ) + .andWhereNot('users.is_service', true) + .select( + 'users.id', + 'users.name', + 'users.username', + 'users.email', + 'users.image_url', + ); + + return admins.map(rowToAdminUser); + } } diff --git a/src/lib/features/personal-dashboard/createPersonalDashboardService.ts b/src/lib/features/personal-dashboard/createPersonalDashboardService.ts index 4b438c14c0..a376f2bf5d 100644 --- a/src/lib/features/personal-dashboard/createPersonalDashboardService.ts +++ b/src/lib/features/personal-dashboard/createPersonalDashboardService.ts @@ -12,6 +12,8 @@ import { FeatureEventFormatterMd } from '../../addons/feature-event-formatter-md import FakeEventStore from '../../../test/fixtures/fake-event-store'; import { FakePrivateProjectChecker } from '../private-project/fakePrivateProjectChecker'; import { PrivateProjectChecker } from '../private-project/privateProjectChecker'; +import { AccountStore } from '../../db/account-store'; +import { FakeAccountStore } from '../../../test/fixtures/fake-account-store'; export const createPersonalDashboardService = ( db: Db, @@ -28,6 +30,7 @@ export const createPersonalDashboardService = ( formatStyle: 'markdown', }), new PrivateProjectChecker(stores, config), + new AccountStore(db, config.getLogger), ); }; @@ -42,5 +45,6 @@ export const createFakePersonalDashboardService = (config: IUnleashConfig) => { formatStyle: 'markdown', }), new FakePrivateProjectChecker(), + new FakeAccountStore(), ); }; diff --git a/src/lib/features/personal-dashboard/fake-personal-dashboard-read-model.ts b/src/lib/features/personal-dashboard/fake-personal-dashboard-read-model.ts index 27f26c551a..36578f399b 100644 --- a/src/lib/features/personal-dashboard/fake-personal-dashboard-read-model.ts +++ b/src/lib/features/personal-dashboard/fake-personal-dashboard-read-model.ts @@ -1,3 +1,4 @@ +import type { IUser } from '../../server-impl'; import type { BasePersonalProject, IPersonalDashboardReadModel, @@ -14,4 +15,8 @@ export class FakePersonalDashboardReadModel async getPersonalProjects(userId: number): Promise { return []; } + + async getAdmins(): Promise { + return []; + } } diff --git a/src/lib/features/personal-dashboard/personal-dashboard-controller.ts b/src/lib/features/personal-dashboard/personal-dashboard-controller.ts index 8408945b6d..5594c39a54 100644 --- a/src/lib/features/personal-dashboard/personal-dashboard-controller.ts +++ b/src/lib/features/personal-dashboard/personal-dashboard-controller.ts @@ -83,17 +83,18 @@ export default class PersonalDashboardController extends Controller { ): Promise { const user = req.user; - const [flags, projects, projectOwners] = await Promise.all([ + const [flags, projects, projectOwners, admins] = await Promise.all([ this.personalDashboardService.getPersonalFeatures(user.id), this.personalDashboardService.getPersonalProjects(user.id), this.personalDashboardService.getProjectOwners(user.id), + this.personalDashboardService.getAdmins(), ]); this.openApiService.respondWithValidation( 200, res, personalDashboardSchema.$id, - { projects, flags, projectOwners }, + { projects, flags, projectOwners, admins }, ); } diff --git a/src/lib/features/personal-dashboard/personal-dashboard-service.ts b/src/lib/features/personal-dashboard/personal-dashboard-service.ts index 866aa01107..da5753724d 100644 --- a/src/lib/features/personal-dashboard/personal-dashboard-service.ts +++ b/src/lib/features/personal-dashboard/personal-dashboard-service.ts @@ -9,7 +9,7 @@ import type { } from './personal-dashboard-read-model-type'; import type { IProjectReadModel } from '../project/project-read-model-type'; import type { IPrivateProjectChecker } from '../private-project/privateProjectCheckerType'; -import type { IEventStore } from '../../types'; +import type { IAccountStore, IEventStore, MinimalUser } from '../../types'; import type { FeatureEventFormatter } from '../../addons/feature-event-formatter-md'; import { generateImageUrl } from '../../util'; @@ -35,6 +35,8 @@ export class PersonalDashboardService { private featureEventFormatter: FeatureEventFormatter; + private accountStore: IAccountStore; + constructor( personalDashboardReadModel: IPersonalDashboardReadModel, projectOwnersReadModel: IProjectOwnersReadModel, @@ -42,6 +44,7 @@ export class PersonalDashboardService { eventStore: IEventStore, featureEventFormatter: FeatureEventFormatter, privateProjectChecker: IPrivateProjectChecker, + accountStore: IAccountStore, ) { this.personalDashboardReadModel = personalDashboardReadModel; this.projectOwnersReadModel = projectOwnersReadModel; @@ -49,6 +52,7 @@ export class PersonalDashboardService { this.eventStore = eventStore; this.featureEventFormatter = featureEventFormatter; this.privateProjectChecker = privateProjectChecker; + this.accountStore = accountStore; } getPersonalFeatures(userId: number): Promise { @@ -105,4 +109,8 @@ export class PersonalDashboardService { return { latestEvents: formattedEvents }; } + + async getAdmins(): Promise { + return this.accountStore.getAdmins(); + } } diff --git a/src/lib/openapi/spec/personal-dashboard-schema.ts b/src/lib/openapi/spec/personal-dashboard-schema.ts index 7c4040a641..7199cc7161 100644 --- a/src/lib/openapi/spec/personal-dashboard-schema.ts +++ b/src/lib/openapi/spec/personal-dashboard-schema.ts @@ -7,6 +7,41 @@ export const personalDashboardSchema = { additionalProperties: false, required: ['projects', 'flags'], properties: { + admins: { + type: 'array', + description: 'Users with the admin role in Unleash.', + items: { + type: 'object', + required: ['id'], + properties: { + id: { + type: 'integer', + description: 'The user ID.', + example: 1, + }, + name: { + type: 'string', + description: "The user's name.", + example: 'Ash Ketchum', + }, + username: { + type: 'string', + description: "The user's username.", + example: 'pokémaster13', + }, + imageUrl: { + type: 'string', + nullable: true, + example: 'https://example.com/peek-at-you.jpg', + }, + email: { + type: 'string', + nullable: true, + example: 'user@example.com', + }, + }, + }, + }, projectOwners: { type: 'array', description: @@ -18,19 +53,25 @@ export const personalDashboardSchema = { ownerType: { type: 'string', enum: ['user'], + description: + 'The type of the owner; will always be `user`.', }, name: { type: 'string', example: 'User Name', + description: + "The name displayed for the user. Can be the user's name, username, or email, depending on what they have provided.", }, imageUrl: { type: 'string', nullable: true, + description: "The URL of the user's profile image.", example: 'https://example.com/image.jpg', }, email: { type: 'string', nullable: true, + description: "The user's email address.", example: 'user@example.com', }, }, diff --git a/src/lib/types/stores/account-store.ts b/src/lib/types/stores/account-store.ts index 2f4f6d6a49..5e29ca4a2d 100644 --- a/src/lib/types/stores/account-store.ts +++ b/src/lib/types/stores/account-store.ts @@ -1,4 +1,4 @@ -import type { IUser } from '../user'; +import type { IUser, MinimalUser } from '../user'; import type { Store } from './store'; export interface IUserLookup { @@ -22,4 +22,5 @@ export interface IAccountStore extends Store { getAccountByPersonalAccessToken(secret: string): Promise; markSeenAt(secrets: string[]): Promise; getAdminCount(): Promise; + getAdmins(): Promise; } diff --git a/src/lib/types/user.ts b/src/lib/types/user.ts index 5569daf856..c07e994cdd 100644 --- a/src/lib/types/user.ts +++ b/src/lib/types/user.ts @@ -33,6 +33,11 @@ export interface IUser { scimId?: string; } +export type MinimalUser = Pick< + IUser, + 'id' | 'name' | 'username' | 'email' | 'imageUrl' +>; + export interface IProjectUser extends IUser { addedAt: Date; } diff --git a/src/test/fixtures/fake-account-store.ts b/src/test/fixtures/fake-account-store.ts index ac689cd721..c08a8d5d41 100644 --- a/src/test/fixtures/fake-account-store.ts +++ b/src/test/fixtures/fake-account-store.ts @@ -15,7 +15,6 @@ export class FakeAccountStore implements IAccountStore { this.idSeq = 1; this.data = []; } - async hasAccount({ id, username, @@ -98,4 +97,8 @@ export class FakeAccountStore implements IAccountStore { async getAdminCount(): Promise { throw new Error('Not implemented'); } + + async getAdmins(): Promise { + return []; + } }