1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-02-03 01:22:44 +01:00
This commit is contained in:
nosajthenitram 2025-01-28 19:48:30 +00:00 committed by GitHub
commit cb53dd767d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 244 additions and 7 deletions
e2e/src
i18n
mobile/openapi/lib/model
open-api
server/src
web/src
lib/components/forms
routes/admin/user-management

View file

@ -7,6 +7,44 @@ describe(`immich-admin`, () => {
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', () => {
it('should list the admin user', async () => {
const { stdout, exitCode } = await immichAdmin(['list-users']).promise;

View file

@ -0,0 +1,87 @@
import { expect, test } from '@playwright/test';
import { utils } from 'src/utils';
test.describe('User Administration', () => {
test.beforeAll(() => {
utils.initSdk();
});
test.beforeEach(async () => {
await utils.resetDatabase();
});
test('validate admin/user-management 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/user-management');
await expect(page).toHaveTitle('User Management');
await expect(page.locator('#user-page-header')).toHaveText('User Management');
});
test('create / edit user modal', async ({ context, page }) => {
const admin = await utils.adminSetup();
await utils.setAuthCookies(context, admin.accessToken);
// Create a new user
await page.goto('/admin/user-management');
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();
// Edit the created user
await page.getByRole('row', { name: 'user@immich.cloud' }).getByRole('button', { name: 'Edit user' }).click();
await expect(page.locator('#id-2-title')).toHaveText('Edit user');
await page.getByLabel('Name').fill('Updated Immich User');
await page.getByRole('button', { name: 'Confirm' }).click();
// Verify the user update
await page.reload();
// this test will fail unless the browser is restarted
await expect(page.getByRole('row', { name: 'user@immich.cloud' }).getByText('Updated Immich User')).toBeVisible();
});
test('toggle admin switch for user', async ({ context, page }) => {
const admin = await utils.adminSetup();
await utils.setAuthCookies(context, admin.accessToken);
// Create a new user
await page.goto('/admin/user-management');
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();
// Open the user edit modal for the new user
await page.getByRole('row', { name: 'user@immich.cloud' }).getByRole('button', { name: 'Edit user' }).click();
// Assert that the edit user form is visible
await expect(page.locator('#edit-user-form')).toBeVisible();
// Toggle admin switch on
await page.locator('#edit-user-form span').click();
await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click();
// Verify the admin switch is enabled
await page.reload();
await page.getByRole('row', { name: 'user@immich.cloud' }).getByRole('button', { name: 'Edit' }).click();
await expect(page.getByLabel('Admin User')).toBeChecked();
// Toggle admin switch off
await page.locator('#edit-user-form span').click();
await page.getByRole('button', { name: 'Confirm' }).click();
// Verify the admin switch is disabled
await page.getByRole('row', { name: 'user@immich.cloud' }).getByRole('button', { name: 'Edit' }).click();
await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click();
});
});

View file

@ -29,6 +29,7 @@
"added_to_favorites_count": "Added {count, number} to favorites",
"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/**\".",
"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.",
"authentication_settings": "Authentication Settings",
"authentication_settings_description": "Manage password, OAuth, and other authentication settings",
@ -1350,4 +1351,4 @@
"yes": "Yes",
"you_dont_have_any_shared_links": "You don't have any shared links",
"zoom_image": "Zoom Image"
}
}

Binary file not shown.

View file

@ -13013,6 +13013,10 @@
"format": "email",
"type": "string"
},
"isAdmin": {
"default": false,
"type": "boolean"
},
"name": {
"type": "string"
},

View file

@ -77,6 +77,7 @@ export type UserAdminDeleteDto = {
};
export type UserAdminUpdateDto = {
email?: string;
isAdmin?: boolean;
name?: string;
password?: string;
quotaSizeInBytes?: number | null;

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';
@ -6,9 +7,12 @@ import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands
export const commands = [
ResetAdminPasswordCommand,
PromptPasswordQuestions,
PromptEmailQuestion,
EnablePasswordLoginCommand,
DisablePasswordLoginCommand,
EnableOAuthLogin,
DisableOAuthLogin,
ListUsersCommand,
GrantAdminCommand,
RevokeAdminCommand,
];

View file

@ -117,6 +117,11 @@ export class UserAdminUpdateDto {
@IsPositive()
@ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes?: number | null;
@Optional({ nullable: false })
@ApiProperty({ default: false })
@IsBoolean()
isAdmin?: boolean;
}
export class UserAdminDeleteDto {

View file

@ -37,6 +37,26 @@ 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

@ -1,5 +1,6 @@
<script lang="ts">
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { AppRoute } from '$lib/constants';
import { userInteraction } from '$lib/stores/user.svelte';
import { handleError } from '$lib/utils/handle-error';
@ -17,6 +18,7 @@
onClose: () => void;
onResetPasswordSuccess: () => void;
onEditSuccess: () => void;
isAdminDisabled?: boolean;
}
let {
@ -26,11 +28,13 @@
onClose,
onResetPasswordSuccess,
onEditSuccess,
isAdminDisabled,
}: Props = $props();
let quotaSize = $state(user.quotaSizeInBytes ? convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB) : null);
const previousQutoa = user.quotaSizeInBytes;
const disabled = isAdminDisabled;
let quotaSizeWarning = $derived(
previousQutoa !== convertToBytes(Number(quotaSize), ByteUnit.GiB) &&
@ -41,7 +45,7 @@
const editUser = async () => {
try {
const { id, email, name, storageLabel } = user;
const { id, email, name, storageLabel, isAdmin } = user;
await updateUserAdmin({
id,
userAdminUpdateDto: {
@ -49,6 +53,7 @@
name,
storageLabel: storageLabel || '',
quotaSizeInBytes: quotaSize ? convertToBytes(Number(quotaSize), ByteUnit.GiB) : null,
isAdmin,
},
});
@ -109,28 +114,28 @@
<FullScreenModal title={$t('edit_user')} icon={mdiAccountEditOutline} {onClose}>
<form onsubmit={onSubmit} autocomplete="off" id="edit-user-form">
<div class="my-4 flex flex-col gap-2">
<div class="flex flex-col gap-2 my-4">
<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} />
</div>
<div class="my-4 flex flex-col gap-2">
<div class="flex flex-col gap-2 my-4">
<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} />
</div>
<div class="my-4 flex flex-col gap-2">
<div class="flex flex-col gap-2 my-4">
<label class="flex items-center gap-2 immich-form-label" for="quotaSize">
{$t('admin.quota_size_gib')}
{#if quotaSizeWarning}
<p class="text-red-400 text-sm">{$t('errors.quota_higher_than_disk_size')}</p>
<p class="text-sm text-red-400">{$t('errors.quota_higher_than_disk_size')}</p>
{/if}</label
>
<input class="immich-form-input" id="quotaSize" name="quotaSize" type="number" min="0" bind:value={quotaSize} />
<p>{$t('admin.note_unlimited_quota')}</p>
</div>
<div class="my-4 flex flex-col gap-2">
<div class="flex flex-col gap-2 my-4">
<label class="immich-form-label" for="storage-label">{$t('storage_label')}</label>
<input
class="immich-form-input"
@ -147,6 +152,10 @@
</a>
</p>
</div>
<div class="flex flex-col gap-2 my-4">
<SettingSwitch title={$t('admin.admin_user')} {disabled} bind:checked={user.isAdmin} />
</div>
</form>
{#snippet stickyBottom()}

View file

@ -124,6 +124,7 @@
user={selectedUser}
bind:newPassword
canResetPassword={selectedUser?.id !== $user.id}
isAdminDisabled={selectedUser?.id === $user.id}
onEditSuccess={onEditUserSuccess}
onResetPasswordSuccess={onEditPasswordSuccess}
onClose={() => (shouldShowEditUserForm = false)}