feat(web): Added admin user config to user settings (#15380)

* feat(web): Added admin user config to user settings

* feat (web) - cleaned up the files and added tests

* feat (web) - added missing files

* feat (web) - updated per review comments

* feat (web) - e2e admin command test failures
This commit is contained in:
nosajthenitram 2025-06-11 21:11:13 -05:00 committed by GitHub
parent 22eef5f3c5
commit e5219f1f31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 308 additions and 20 deletions

View file

@ -0,0 +1,67 @@
import { Command, CommandRunner, InquirerService, Question, QuestionSet } from 'nest-commander';
import { CliService } from 'src/services/cli.service';
const prompt = (inquirer: InquirerService) => {
return function ask(): Promise<string> {
return inquirer.ask<{ email: string }>('prompt-email', {}).then(({ email }: { email: string }) => email);
};
};
@Command({
name: 'grant-admin',
description: 'Grant admin privileges to a user (by email)',
})
export class GrantAdminCommand extends CommandRunner {
constructor(
private service: CliService,
private inquirer: InquirerService,
) {
super();
}
async run(): Promise<void> {
try {
const email = await prompt(this.inquirer)();
await this.service.grantAdminAccess(email);
console.debug('Admin access has been granted to', email);
} catch (error) {
console.error(error);
console.error('Unable to grant admin access to user');
}
}
}
@Command({
name: 'revoke-admin',
description: 'Revoke admin privileges from a user (by email)',
})
export class RevokeAdminCommand extends CommandRunner {
constructor(
private service: CliService,
private inquirer: InquirerService,
) {
super();
}
async run(): Promise<void> {
try {
const email = await prompt(this.inquirer)();
await this.service.revokeAdminAccess(email);
console.debug('Admin access has been revoked from', email);
} catch (error) {
console.error(error);
console.error('Unable to revoke admin access from user');
}
}
}
@QuestionSet({ name: 'prompt-email' })
export class PromptEmailQuestion {
@Question({
message: 'Please enter the user email: ',
name: 'email',
})
parseEmail(value: string) {
return value;
}
}

View file

@ -1,3 +1,4 @@
import { GrantAdminCommand, PromptEmailQuestion, RevokeAdminCommand } from 'src/commands/grant-admin';
import { ListUsersCommand } from 'src/commands/list-users.command';
import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login';
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login';
@ -7,10 +8,13 @@ import { VersionCommand } from 'src/commands/version.command';
export const commands = [
ResetAdminPasswordCommand,
PromptPasswordQuestions,
PromptEmailQuestion,
EnablePasswordLoginCommand,
DisablePasswordLoginCommand,
EnableOAuthLogin,
DisableOAuthLogin,
ListUsersCommand,
VersionCommand,
GrantAdminCommand,
RevokeAdminCommand,
];

View file

@ -106,6 +106,10 @@ export class UserAdminCreateDto {
@Optional()
@IsBoolean()
notify?: boolean;
@Optional()
@IsBoolean()
isAdmin?: boolean;
}
export class UserAdminUpdateDto {
@ -145,6 +149,10 @@ export class UserAdminUpdateDto {
@Min(0)
@ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes?: number | null;
@Optional()
@IsBoolean()
isAdmin?: boolean;
}
export class UserAdminDeleteDto {

View file

@ -37,6 +37,24 @@ export class CliService extends BaseService {
await this.updateConfig(config);
}
async grantAdminAccess(email: string): Promise<void> {
const user = await this.userRepository.getByEmail(email);
if (!user) {
throw new Error('User does not exist');
}
await this.userRepository.update(user.id, { isAdmin: true });
}
async revokeAdminAccess(email: string): Promise<void> {
const user = await this.userRepository.getByEmail(email);
if (!user) {
throw new Error('User does not exist');
}
await this.userRepository.update(user.id, { isAdmin: false });
}
async disableOAuthLogin(): Promise<void> {
const config = await this.getConfig({ withCache: false });
config.oauth.enabled = false;

View file

@ -4,6 +4,7 @@ import { JobName, UserStatus } from 'src/enum';
import { UserAdminService } from 'src/services/user-admin.service';
import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
import { describe } from 'vitest';
@ -116,7 +117,7 @@ describe(UserAdminService.name, () => {
it('should throw error if user could not be found', async () => {
mocks.user.get.mockResolvedValue(void 0);
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toThrowError(BadRequestException);
await expect(sut.delete(authStub.admin, 'not-found', {})).rejects.toThrowError(BadRequestException);
expect(mocks.user.delete).not.toHaveBeenCalled();
});
@ -124,8 +125,11 @@ describe(UserAdminService.name, () => {
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toBeInstanceOf(ForbiddenException);
});
it('should require the auth user be an admin', async () => {
await expect(sut.delete(authStub.user1, authStub.admin.user.id, {})).rejects.toBeInstanceOf(ForbiddenException);
it('should not allow deleting own account', async () => {
const user = factory.userAdmin({ isAdmin: false });
const auth = factory.auth({ user });
mocks.user.get.mockResolvedValue(user);
await expect(sut.delete(auth, user.id, {})).rejects.toBeInstanceOf(ForbiddenException);
expect(mocks.user.delete).not.toHaveBeenCalled();
});

View file

@ -52,6 +52,10 @@ export class UserAdminService extends BaseService {
async update(auth: AuthDto, id: string, dto: UserAdminUpdateDto): Promise<UserAdminResponseDto> {
const user = await this.findOrFail(id, {});
if (dto.isAdmin !== undefined && dto.isAdmin !== auth.user.isAdmin && auth.user.id === id) {
throw new BadRequestException('Admin status can only be changed by another admin');
}
if (dto.quotaSizeInBytes && user.quotaSizeInBytes !== dto.quotaSizeInBytes) {
await this.userRepository.syncUsage(id);
}
@ -89,9 +93,9 @@ export class UserAdminService extends BaseService {
async delete(auth: AuthDto, id: string, dto: UserAdminDeleteDto): Promise<UserAdminResponseDto> {
const { force } = dto;
const { isAdmin } = await this.findOrFail(id, {});
if (isAdmin) {
throw new ForbiddenException('Cannot delete admin user');
await this.findOrFail(id, {});
if (auth.user.id === id) {
throw new ForbiddenException('Cannot delete your own account');
}
await this.albumRepository.softDeleteAll(id);