mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
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:
parent
22eef5f3c5
commit
e5219f1f31
15 changed files with 308 additions and 20 deletions
67
server/src/commands/grant-admin.ts
Normal file
67
server/src/commands/grant-admin.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue