mirror of
https://github.com/immich-app/immich.git
synced 2025-06-23 15:30:51 -04: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
@ -118,7 +118,7 @@ describe('/admin/users', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
it('should ignore `isAdmin`', async () => {
|
it('should accept `isAdmin`', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.post(`/admin/users`)
|
.post(`/admin/users`)
|
||||||
.send({
|
.send({
|
||||||
@ -130,7 +130,7 @@ describe('/admin/users', () => {
|
|||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
email: 'user5@immich.cloud',
|
email: 'user5@immich.cloud',
|
||||||
isAdmin: false,
|
isAdmin: true,
|
||||||
shouldChangePassword: true,
|
shouldChangePassword: true,
|
||||||
});
|
});
|
||||||
expect(status).toBe(201);
|
expect(status).toBe(201);
|
||||||
@ -163,14 +163,15 @@ describe('/admin/users', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
it('should not allow a non-admin to become an admin', async () => {
|
it('should allow a non-admin to become an admin', async () => {
|
||||||
|
const user = await utils.userSetup(admin.accessToken, createUserDto.create('admin2'));
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.put(`/admin/users/${nonAdmin.userId}`)
|
.put(`/admin/users/${user.userId}`)
|
||||||
.send({ isAdmin: true })
|
.send({ isAdmin: true })
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body).toMatchObject({ isAdmin: false });
|
expect(body).toMatchObject({ isAdmin: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores updates to profileImagePath', async () => {
|
it('ignores updates to profileImagePath', async () => {
|
||||||
|
@ -7,6 +7,44 @@ describe(`immich-admin`, () => {
|
|||||||
await utils.adminSetup();
|
await utils.adminSetup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('revoke-admin', () => {
|
||||||
|
it('should revoke admin privileges from a user', async () => {
|
||||||
|
const { child, promise } = immichAdmin(['revoke-admin']);
|
||||||
|
|
||||||
|
let data = '';
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
if (data.includes('Please enter the user email:')) {
|
||||||
|
child.stdin.end('admin@immich.cloud\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await promise;
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
|
expect(stdout).toContain('Admin access has been revoked from');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('grant-admin', () => {
|
||||||
|
it('should grant admin privileges to a user', async () => {
|
||||||
|
const { child, promise } = immichAdmin(['grant-admin']);
|
||||||
|
|
||||||
|
let data = '';
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
if (data.includes('Please enter the user email:')) {
|
||||||
|
child.stdin.end('admin@immich.cloud\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await promise;
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
|
expect(stdout).toContain('Admin access has been granted to');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('list-users', () => {
|
describe('list-users', () => {
|
||||||
it('should list the admin user', async () => {
|
it('should list the admin user', async () => {
|
||||||
const { stdout, exitCode } = await immichAdmin(['list-users']).promise;
|
const { stdout, exitCode } = await immichAdmin(['list-users']).promise;
|
||||||
|
89
e2e/src/web/specs/user-admin.e2e-spec.ts
Normal file
89
e2e/src/web/specs/user-admin.e2e-spec.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { getUserAdmin } from '@immich/sdk';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { asBearerAuth, utils } from 'src/utils';
|
||||||
|
|
||||||
|
test.describe('User Administration', () => {
|
||||||
|
test.beforeAll(() => {
|
||||||
|
utils.initSdk();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
await utils.resetDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validate admin/users link', async ({ context, page }) => {
|
||||||
|
const admin = await utils.adminSetup();
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
|
||||||
|
// Navigate to user management page and verify title and header
|
||||||
|
await page.goto(`/admin/users`);
|
||||||
|
await expect(page).toHaveTitle(/User Management/);
|
||||||
|
await expect(page.getByText('User Management')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create user', async ({ context, page }) => {
|
||||||
|
const admin = await utils.adminSetup();
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
|
||||||
|
// Create a new user
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
await page.getByRole('button', { name: 'Create user' }).click();
|
||||||
|
await page.getByLabel('Email').fill('user@immich.cloud');
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill('password');
|
||||||
|
await page.getByLabel('Confirm Password').fill('password');
|
||||||
|
await page.getByLabel('Name').fill('Immich User');
|
||||||
|
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||||
|
|
||||||
|
// Verify the user exists in the user list
|
||||||
|
await page.getByRole('row', { name: 'user@immich.cloud' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('promote to admin', async ({ context, page }) => {
|
||||||
|
const admin = await utils.adminSetup();
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
|
||||||
|
const user = await utils.userSetup(admin.accessToken, {
|
||||||
|
name: 'Admin 2',
|
||||||
|
email: 'admin2@immich.cloud',
|
||||||
|
password: 'password',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(user.isAdmin).toBe(false);
|
||||||
|
|
||||||
|
await page.goto(`/admin/users/${user.userId}`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Edit user' }).click();
|
||||||
|
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||||
|
await page.getByText('Admin User').click();
|
||||||
|
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||||
|
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||||
|
|
||||||
|
const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
|
||||||
|
expect(updated.isAdmin).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revoke admin access', async ({ context, page }) => {
|
||||||
|
const admin = await utils.adminSetup();
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
|
||||||
|
const user = await utils.userSetup(admin.accessToken, {
|
||||||
|
name: 'Admin 2',
|
||||||
|
email: 'admin2@immich.cloud',
|
||||||
|
password: 'password',
|
||||||
|
isAdmin: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(user.isAdmin).toBe(true);
|
||||||
|
|
||||||
|
await page.goto(`/admin/users/${user.userId}`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Edit user' }).click();
|
||||||
|
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||||
|
await page.getByText('Admin User').click();
|
||||||
|
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||||
|
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||||
|
|
||||||
|
const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
|
||||||
|
expect(updated.isAdmin).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
@ -34,6 +34,7 @@
|
|||||||
"added_to_favorites_count": "Added {count, number} to favorites",
|
"added_to_favorites_count": "Added {count, number} to favorites",
|
||||||
"admin": {
|
"admin": {
|
||||||
"add_exclusion_pattern_description": "Add exclusion patterns. Globbing using *, **, and ? is supported. To ignore all files in any directory named \"Raw\", use \"**/Raw/**\". To ignore all files ending in \".tif\", use \"**/*.tif\". To ignore an absolute path, use \"/path/to/ignore/**\".",
|
"add_exclusion_pattern_description": "Add exclusion patterns. Globbing using *, **, and ? is supported. To ignore all files in any directory named \"Raw\", use \"**/Raw/**\". To ignore all files ending in \".tif\", use \"**/*.tif\". To ignore an absolute path, use \"/path/to/ignore/**\".",
|
||||||
|
"admin_user": "Admin User",
|
||||||
"asset_offline_description": "This external library asset is no longer found on disk and has been moved to trash. If the file was moved within the library, check your timeline for the new corresponding asset. To restore this asset, please ensure that the file path below can be accessed by Immich and scan the library.",
|
"asset_offline_description": "This external library asset is no longer found on disk and has been moved to trash. If the file was moved within the library, check your timeline for the new corresponding asset. To restore this asset, please ensure that the file path below can be accessed by Immich and scan the library.",
|
||||||
"authentication_settings": "Authentication Settings",
|
"authentication_settings": "Authentication Settings",
|
||||||
"authentication_settings_description": "Manage password, OAuth, and other authentication settings",
|
"authentication_settings_description": "Manage password, OAuth, and other authentication settings",
|
||||||
|
19
mobile/openapi/lib/model/user_admin_create_dto.dart
generated
19
mobile/openapi/lib/model/user_admin_create_dto.dart
generated
@ -15,6 +15,7 @@ class UserAdminCreateDto {
|
|||||||
UserAdminCreateDto({
|
UserAdminCreateDto({
|
||||||
this.avatarColor,
|
this.avatarColor,
|
||||||
required this.email,
|
required this.email,
|
||||||
|
this.isAdmin,
|
||||||
required this.name,
|
required this.name,
|
||||||
this.notify,
|
this.notify,
|
||||||
required this.password,
|
required this.password,
|
||||||
@ -27,6 +28,14 @@ class UserAdminCreateDto {
|
|||||||
|
|
||||||
String email;
|
String email;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Please note: This property should have been non-nullable! Since the specification file
|
||||||
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
|
/// source code must fall back to having a nullable type.
|
||||||
|
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||||
|
///
|
||||||
|
bool? isAdmin;
|
||||||
|
|
||||||
String name;
|
String name;
|
||||||
|
|
||||||
///
|
///
|
||||||
@ -56,6 +65,7 @@ class UserAdminCreateDto {
|
|||||||
bool operator ==(Object other) => identical(this, other) || other is UserAdminCreateDto &&
|
bool operator ==(Object other) => identical(this, other) || other is UserAdminCreateDto &&
|
||||||
other.avatarColor == avatarColor &&
|
other.avatarColor == avatarColor &&
|
||||||
other.email == email &&
|
other.email == email &&
|
||||||
|
other.isAdmin == isAdmin &&
|
||||||
other.name == name &&
|
other.name == name &&
|
||||||
other.notify == notify &&
|
other.notify == notify &&
|
||||||
other.password == password &&
|
other.password == password &&
|
||||||
@ -68,6 +78,7 @@ class UserAdminCreateDto {
|
|||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(avatarColor == null ? 0 : avatarColor!.hashCode) +
|
(avatarColor == null ? 0 : avatarColor!.hashCode) +
|
||||||
(email.hashCode) +
|
(email.hashCode) +
|
||||||
|
(isAdmin == null ? 0 : isAdmin!.hashCode) +
|
||||||
(name.hashCode) +
|
(name.hashCode) +
|
||||||
(notify == null ? 0 : notify!.hashCode) +
|
(notify == null ? 0 : notify!.hashCode) +
|
||||||
(password.hashCode) +
|
(password.hashCode) +
|
||||||
@ -76,7 +87,7 @@ class UserAdminCreateDto {
|
|||||||
(storageLabel == null ? 0 : storageLabel!.hashCode);
|
(storageLabel == null ? 0 : storageLabel!.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'UserAdminCreateDto[avatarColor=$avatarColor, email=$email, name=$name, notify=$notify, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
|
String toString() => 'UserAdminCreateDto[avatarColor=$avatarColor, email=$email, isAdmin=$isAdmin, name=$name, notify=$notify, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
@ -86,6 +97,11 @@ class UserAdminCreateDto {
|
|||||||
// json[r'avatarColor'] = null;
|
// json[r'avatarColor'] = null;
|
||||||
}
|
}
|
||||||
json[r'email'] = this.email;
|
json[r'email'] = this.email;
|
||||||
|
if (this.isAdmin != null) {
|
||||||
|
json[r'isAdmin'] = this.isAdmin;
|
||||||
|
} else {
|
||||||
|
// json[r'isAdmin'] = null;
|
||||||
|
}
|
||||||
json[r'name'] = this.name;
|
json[r'name'] = this.name;
|
||||||
if (this.notify != null) {
|
if (this.notify != null) {
|
||||||
json[r'notify'] = this.notify;
|
json[r'notify'] = this.notify;
|
||||||
@ -122,6 +138,7 @@ class UserAdminCreateDto {
|
|||||||
return UserAdminCreateDto(
|
return UserAdminCreateDto(
|
||||||
avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']),
|
avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']),
|
||||||
email: mapValueOfType<String>(json, r'email')!,
|
email: mapValueOfType<String>(json, r'email')!,
|
||||||
|
isAdmin: mapValueOfType<bool>(json, r'isAdmin'),
|
||||||
name: mapValueOfType<String>(json, r'name')!,
|
name: mapValueOfType<String>(json, r'name')!,
|
||||||
notify: mapValueOfType<bool>(json, r'notify'),
|
notify: mapValueOfType<bool>(json, r'notify'),
|
||||||
password: mapValueOfType<String>(json, r'password')!,
|
password: mapValueOfType<String>(json, r'password')!,
|
||||||
|
19
mobile/openapi/lib/model/user_admin_update_dto.dart
generated
19
mobile/openapi/lib/model/user_admin_update_dto.dart
generated
@ -15,6 +15,7 @@ class UserAdminUpdateDto {
|
|||||||
UserAdminUpdateDto({
|
UserAdminUpdateDto({
|
||||||
this.avatarColor,
|
this.avatarColor,
|
||||||
this.email,
|
this.email,
|
||||||
|
this.isAdmin,
|
||||||
this.name,
|
this.name,
|
||||||
this.password,
|
this.password,
|
||||||
this.pinCode,
|
this.pinCode,
|
||||||
@ -33,6 +34,14 @@ class UserAdminUpdateDto {
|
|||||||
///
|
///
|
||||||
String? email;
|
String? email;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Please note: This property should have been non-nullable! Since the specification file
|
||||||
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
|
/// source code must fall back to having a nullable type.
|
||||||
|
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||||
|
///
|
||||||
|
bool? isAdmin;
|
||||||
|
|
||||||
///
|
///
|
||||||
/// Please note: This property should have been non-nullable! Since the specification file
|
/// Please note: This property should have been non-nullable! Since the specification file
|
||||||
/// does not include a default value (using the "default:" property), however, the generated
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
@ -68,6 +77,7 @@ class UserAdminUpdateDto {
|
|||||||
bool operator ==(Object other) => identical(this, other) || other is UserAdminUpdateDto &&
|
bool operator ==(Object other) => identical(this, other) || other is UserAdminUpdateDto &&
|
||||||
other.avatarColor == avatarColor &&
|
other.avatarColor == avatarColor &&
|
||||||
other.email == email &&
|
other.email == email &&
|
||||||
|
other.isAdmin == isAdmin &&
|
||||||
other.name == name &&
|
other.name == name &&
|
||||||
other.password == password &&
|
other.password == password &&
|
||||||
other.pinCode == pinCode &&
|
other.pinCode == pinCode &&
|
||||||
@ -80,6 +90,7 @@ class UserAdminUpdateDto {
|
|||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(avatarColor == null ? 0 : avatarColor!.hashCode) +
|
(avatarColor == null ? 0 : avatarColor!.hashCode) +
|
||||||
(email == null ? 0 : email!.hashCode) +
|
(email == null ? 0 : email!.hashCode) +
|
||||||
|
(isAdmin == null ? 0 : isAdmin!.hashCode) +
|
||||||
(name == null ? 0 : name!.hashCode) +
|
(name == null ? 0 : name!.hashCode) +
|
||||||
(password == null ? 0 : password!.hashCode) +
|
(password == null ? 0 : password!.hashCode) +
|
||||||
(pinCode == null ? 0 : pinCode!.hashCode) +
|
(pinCode == null ? 0 : pinCode!.hashCode) +
|
||||||
@ -88,7 +99,7 @@ class UserAdminUpdateDto {
|
|||||||
(storageLabel == null ? 0 : storageLabel!.hashCode);
|
(storageLabel == null ? 0 : storageLabel!.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'UserAdminUpdateDto[avatarColor=$avatarColor, email=$email, name=$name, password=$password, pinCode=$pinCode, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
|
String toString() => 'UserAdminUpdateDto[avatarColor=$avatarColor, email=$email, isAdmin=$isAdmin, name=$name, password=$password, pinCode=$pinCode, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
@ -102,6 +113,11 @@ class UserAdminUpdateDto {
|
|||||||
} else {
|
} else {
|
||||||
// json[r'email'] = null;
|
// json[r'email'] = null;
|
||||||
}
|
}
|
||||||
|
if (this.isAdmin != null) {
|
||||||
|
json[r'isAdmin'] = this.isAdmin;
|
||||||
|
} else {
|
||||||
|
// json[r'isAdmin'] = null;
|
||||||
|
}
|
||||||
if (this.name != null) {
|
if (this.name != null) {
|
||||||
json[r'name'] = this.name;
|
json[r'name'] = this.name;
|
||||||
} else {
|
} else {
|
||||||
@ -146,6 +162,7 @@ class UserAdminUpdateDto {
|
|||||||
return UserAdminUpdateDto(
|
return UserAdminUpdateDto(
|
||||||
avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']),
|
avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']),
|
||||||
email: mapValueOfType<String>(json, r'email'),
|
email: mapValueOfType<String>(json, r'email'),
|
||||||
|
isAdmin: mapValueOfType<bool>(json, r'isAdmin'),
|
||||||
name: mapValueOfType<String>(json, r'name'),
|
name: mapValueOfType<String>(json, r'name'),
|
||||||
password: mapValueOfType<String>(json, r'password'),
|
password: mapValueOfType<String>(json, r'password'),
|
||||||
pinCode: mapValueOfType<String>(json, r'pinCode'),
|
pinCode: mapValueOfType<String>(json, r'pinCode'),
|
||||||
|
@ -15131,6 +15131,9 @@
|
|||||||
"format": "email",
|
"format": "email",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"isAdmin": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@ -15281,6 +15284,9 @@
|
|||||||
"format": "email",
|
"format": "email",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"isAdmin": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
@ -109,6 +109,7 @@ export type UserAdminResponseDto = {
|
|||||||
export type UserAdminCreateDto = {
|
export type UserAdminCreateDto = {
|
||||||
avatarColor?: (UserAvatarColor) | null;
|
avatarColor?: (UserAvatarColor) | null;
|
||||||
email: string;
|
email: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
notify?: boolean;
|
notify?: boolean;
|
||||||
password: string;
|
password: string;
|
||||||
@ -122,6 +123,7 @@ export type UserAdminDeleteDto = {
|
|||||||
export type UserAdminUpdateDto = {
|
export type UserAdminUpdateDto = {
|
||||||
avatarColor?: (UserAvatarColor) | null;
|
avatarColor?: (UserAvatarColor) | null;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
pinCode?: string | null;
|
pinCode?: string | null;
|
||||||
|
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 { ListUsersCommand } from 'src/commands/list-users.command';
|
||||||
import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login';
|
import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login';
|
||||||
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login';
|
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login';
|
||||||
@ -7,10 +8,13 @@ import { VersionCommand } from 'src/commands/version.command';
|
|||||||
export const commands = [
|
export const commands = [
|
||||||
ResetAdminPasswordCommand,
|
ResetAdminPasswordCommand,
|
||||||
PromptPasswordQuestions,
|
PromptPasswordQuestions,
|
||||||
|
PromptEmailQuestion,
|
||||||
EnablePasswordLoginCommand,
|
EnablePasswordLoginCommand,
|
||||||
DisablePasswordLoginCommand,
|
DisablePasswordLoginCommand,
|
||||||
EnableOAuthLogin,
|
EnableOAuthLogin,
|
||||||
DisableOAuthLogin,
|
DisableOAuthLogin,
|
||||||
ListUsersCommand,
|
ListUsersCommand,
|
||||||
VersionCommand,
|
VersionCommand,
|
||||||
|
GrantAdminCommand,
|
||||||
|
RevokeAdminCommand,
|
||||||
];
|
];
|
||||||
|
@ -106,6 +106,10 @@ export class UserAdminCreateDto {
|
|||||||
@Optional()
|
@Optional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
notify?: boolean;
|
notify?: boolean;
|
||||||
|
|
||||||
|
@Optional()
|
||||||
|
@IsBoolean()
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserAdminUpdateDto {
|
export class UserAdminUpdateDto {
|
||||||
@ -145,6 +149,10 @@ export class UserAdminUpdateDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
quotaSizeInBytes?: number | null;
|
quotaSizeInBytes?: number | null;
|
||||||
|
|
||||||
|
@Optional()
|
||||||
|
@IsBoolean()
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserAdminDeleteDto {
|
export class UserAdminDeleteDto {
|
||||||
|
@ -37,6 +37,24 @@ export class CliService extends BaseService {
|
|||||||
await this.updateConfig(config);
|
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> {
|
async disableOAuthLogin(): Promise<void> {
|
||||||
const config = await this.getConfig({ withCache: false });
|
const config = await this.getConfig({ withCache: false });
|
||||||
config.oauth.enabled = false;
|
config.oauth.enabled = false;
|
||||||
|
@ -4,6 +4,7 @@ import { JobName, UserStatus } from 'src/enum';
|
|||||||
import { UserAdminService } from 'src/services/user-admin.service';
|
import { UserAdminService } from 'src/services/user-admin.service';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
import { userStub } from 'test/fixtures/user.stub';
|
import { userStub } from 'test/fixtures/user.stub';
|
||||||
|
import { factory } from 'test/small.factory';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
import { describe } from 'vitest';
|
import { describe } from 'vitest';
|
||||||
|
|
||||||
@ -116,7 +117,7 @@ describe(UserAdminService.name, () => {
|
|||||||
it('should throw error if user could not be found', async () => {
|
it('should throw error if user could not be found', async () => {
|
||||||
mocks.user.get.mockResolvedValue(void 0);
|
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();
|
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);
|
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should require the auth user be an admin', async () => {
|
it('should not allow deleting own account', async () => {
|
||||||
await expect(sut.delete(authStub.user1, authStub.admin.user.id, {})).rejects.toBeInstanceOf(ForbiddenException);
|
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();
|
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> {
|
async update(auth: AuthDto, id: string, dto: UserAdminUpdateDto): Promise<UserAdminResponseDto> {
|
||||||
const user = await this.findOrFail(id, {});
|
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) {
|
if (dto.quotaSizeInBytes && user.quotaSizeInBytes !== dto.quotaSizeInBytes) {
|
||||||
await this.userRepository.syncUsage(id);
|
await this.userRepository.syncUsage(id);
|
||||||
}
|
}
|
||||||
@ -89,9 +93,9 @@ export class UserAdminService extends BaseService {
|
|||||||
|
|
||||||
async delete(auth: AuthDto, id: string, dto: UserAdminDeleteDto): Promise<UserAdminResponseDto> {
|
async delete(auth: AuthDto, id: string, dto: UserAdminDeleteDto): Promise<UserAdminResponseDto> {
|
||||||
const { force } = dto;
|
const { force } = dto;
|
||||||
const { isAdmin } = await this.findOrFail(id, {});
|
await this.findOrFail(id, {});
|
||||||
if (isAdmin) {
|
if (auth.user.id === id) {
|
||||||
throw new ForbiddenException('Cannot delete admin user');
|
throw new ForbiddenException('Cannot delete your own account');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.albumRepository.softDeleteAll(id);
|
await this.albumRepository.softDeleteAll(id);
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
|
import { user as authUser } from '$lib/stores/user.store';
|
||||||
import { userInteraction } from '$lib/stores/user.svelte';
|
import { userInteraction } from '$lib/stores/user.svelte';
|
||||||
import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units';
|
import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk';
|
import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk';
|
||||||
import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
import { Button, Field, Modal, ModalBody, ModalFooter, Switch } from '@immich/ui';
|
||||||
import { mdiAccountEditOutline } from '@mdi/js';
|
import { mdiAccountEditOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
@ -15,6 +16,11 @@
|
|||||||
|
|
||||||
let { user, onClose }: Props = $props();
|
let { user, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let isAdmin = $derived(user.isAdmin);
|
||||||
|
let name = $derived(user.name);
|
||||||
|
let email = $derived(user.email);
|
||||||
|
let storageLabel = $derived(user.storageLabel || '');
|
||||||
|
|
||||||
let quotaSize = $state(user.quotaSizeInBytes === null ? null : convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB));
|
let quotaSize = $state(user.quotaSizeInBytes === null ? null : convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB));
|
||||||
|
|
||||||
const previousQuota = user.quotaSizeInBytes;
|
const previousQuota = user.quotaSizeInBytes;
|
||||||
@ -28,14 +34,14 @@
|
|||||||
|
|
||||||
const handleEditUser = async () => {
|
const handleEditUser = async () => {
|
||||||
try {
|
try {
|
||||||
const { id, email, name, storageLabel } = user;
|
|
||||||
const newUser = await updateUserAdmin({
|
const newUser = await updateUserAdmin({
|
||||||
id,
|
id: user.id,
|
||||||
userAdminUpdateDto: {
|
userAdminUpdateDto: {
|
||||||
email,
|
email,
|
||||||
name,
|
name,
|
||||||
storageLabel: storageLabel || '',
|
storageLabel,
|
||||||
quotaSizeInBytes: quotaSize === null ? null : convertToBytes(Number(quotaSize), ByteUnit.GiB),
|
quotaSizeInBytes: quotaSize === null ? null : convertToBytes(Number(quotaSize), ByteUnit.GiB),
|
||||||
|
isAdmin,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -56,12 +62,12 @@
|
|||||||
<form onsubmit={onSubmit} autocomplete="off" id="edit-user-form">
|
<form onsubmit={onSubmit} autocomplete="off" id="edit-user-form">
|
||||||
<div class="mb-4 flex flex-col gap-2">
|
<div class="mb-4 flex flex-col gap-2">
|
||||||
<label class="immich-form-label" for="email">{$t('email')}</label>
|
<label class="immich-form-label" for="email">{$t('email')}</label>
|
||||||
<input class="immich-form-input" id="email" name="email" type="email" bind:value={user.email} />
|
<input class="immich-form-input" id="email" name="email" type="email" bind:value={email} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-4 flex flex-col gap-2">
|
<div class="my-4 flex flex-col gap-2">
|
||||||
<label class="immich-form-label" for="name">{$t('name')}</label>
|
<label class="immich-form-label" for="name">{$t('name')}</label>
|
||||||
<input class="immich-form-input" id="name" name="name" type="text" required bind:value={user.name} />
|
<input class="immich-form-input" id="name" name="name" type="text" required bind:value={name} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-4 flex flex-col gap-2">
|
<div class="my-4 flex flex-col gap-2">
|
||||||
@ -89,7 +95,7 @@
|
|||||||
id="storage-label"
|
id="storage-label"
|
||||||
name="storage-label"
|
name="storage-label"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={user.storageLabel}
|
bind:value={storageLabel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
@ -99,6 +105,12 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if user.id !== $authUser.id}
|
||||||
|
<Field label={$t('admin.admin_user')}>
|
||||||
|
<Switch bind:checked={isAdmin} />
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
</form>
|
</form>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user