mirror of
https://github.com/immich-app/immich.git
synced 2025-02-03 01:22:44 +01:00
Compare commits
9 commits
6fa76c6653
...
cb53dd767d
Author | SHA1 | Date | |
---|---|---|---|
|
cb53dd767d | ||
|
060300de8a | ||
|
c2ba1cc202 | ||
|
08db77db23 | ||
|
d5a8cd25d9 | ||
|
cd55d50f6e | ||
|
5f7939fcc7 | ||
|
f0c7847d46 | ||
|
581c4c1fe1 |
19 changed files with 325 additions and 20 deletions
docs/src/components
e2e/src
i18n
mobile/openapi/lib/model
open-api
server/src
web
package-lock.jsonpackage.json
src
lib/components
asset-viewer
forms
routes
(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]
admin/user-management
|
@ -99,6 +99,11 @@ const projects: CommunityProjectProps[] = [
|
|||
description: 'Downloads a configurable number of random photos based on people or album ID.',
|
||||
url: 'https://github.com/jon6fingrs/immich-dl',
|
||||
},
|
||||
{
|
||||
title: 'Immich Upload Optimizer',
|
||||
description: 'Automatically optimize files uploaded to Immich in order to save storage space',
|
||||
url: 'https://github.com/miguelangel-nubla/immich-upload-optimizer',
|
||||
},
|
||||
];
|
||||
|
||||
function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element {
|
||||
|
|
|
@ -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;
|
||||
|
|
87
e2e/src/web/specs/useradmin.e2e-spec.ts
Normal file
87
e2e/src/web/specs/useradmin.e2e-spec.ts
Normal 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();
|
||||
});
|
||||
});
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
BIN
mobile/openapi/lib/model/user_admin_update_dto.dart
generated
BIN
mobile/openapi/lib/model/user_admin_update_dto.dart
generated
Binary file not shown.
|
@ -13013,6 +13013,10 @@
|
|||
"format": "email",
|
||||
"type": "string"
|
||||
},
|
||||
"isAdmin": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
@ -77,6 +77,7 @@ export type UserAdminDeleteDto = {
|
|||
};
|
||||
export type UserAdminUpdateDto = {
|
||||
email?: string;
|
||||
isAdmin?: boolean;
|
||||
name?: string;
|
||||
password?: string;
|
||||
quotaSizeInBytes?: number | 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 { 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,
|
||||
];
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
21
web/package-lock.json
generated
21
web/package-lock.json
generated
|
@ -16,6 +16,8 @@
|
|||
"@mdi/js": "^7.4.47",
|
||||
"@photo-sphere-viewer/core": "^5.11.5",
|
||||
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.11.5",
|
||||
"@photo-sphere-viewer/resolution-plugin": "^5.11.5",
|
||||
"@photo-sphere-viewer/settings-plugin": "^5.11.5",
|
||||
"@photo-sphere-viewer/video-plugin": "^5.11.5",
|
||||
"@zoom-image/svelte": "^0.3.0",
|
||||
"dom-to-image": "^2.6.0",
|
||||
|
@ -1669,6 +1671,25 @@
|
|||
"@photo-sphere-viewer/video-plugin": "5.11.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@photo-sphere-viewer/resolution-plugin": {
|
||||
"version": "5.11.5",
|
||||
"resolved": "https://registry.npmjs.org/@photo-sphere-viewer/resolution-plugin/-/resolution-plugin-5.11.5.tgz",
|
||||
"integrity": "sha512-Dbvp5bBtozD3IWt1Q0wORVaZBcB1bV9xUeoOS9A7F7b3EkQ2pkC5/jot/1AyM4wtU5wJ63NWHskQ1d7m6WWazQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@photo-sphere-viewer/core": "5.11.5",
|
||||
"@photo-sphere-viewer/settings-plugin": "5.11.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@photo-sphere-viewer/settings-plugin": {
|
||||
"version": "5.11.5",
|
||||
"resolved": "https://registry.npmjs.org/@photo-sphere-viewer/settings-plugin/-/settings-plugin-5.11.5.tgz",
|
||||
"integrity": "sha512-ZgYaWjiBMhsoRH5ddW3h+v4J4LPmofsT7BBRq5UCssWw2Fsrvv7mFFRi4UbZ1qzeKmvNUOr8BaFQgX1ZLvUWfQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@photo-sphere-viewer/core": "5.11.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@photo-sphere-viewer/video-plugin": {
|
||||
"version": "5.11.5",
|
||||
"resolved": "https://registry.npmjs.org/@photo-sphere-viewer/video-plugin/-/video-plugin-5.11.5.tgz",
|
||||
|
|
|
@ -72,6 +72,8 @@
|
|||
"@mdi/js": "^7.4.47",
|
||||
"@photo-sphere-viewer/core": "^5.11.5",
|
||||
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.11.5",
|
||||
"@photo-sphere-viewer/resolution-plugin": "^5.11.5",
|
||||
"@photo-sphere-viewer/settings-plugin": "^5.11.5",
|
||||
"@photo-sphere-viewer/video-plugin": "^5.11.5",
|
||||
"@zoom-image/svelte": "^0.3.0",
|
||||
"dom-to-image": "^2.6.0",
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
{:then [data, { default: PhotoSphereViewer }]}
|
||||
<PhotoSphereViewer
|
||||
panorama={data}
|
||||
originalImageUrl={isWebCompatibleImage(asset) ? getAssetOriginalUrl(asset.id) : undefined}
|
||||
originalPanorama={isWebCompatibleImage(asset) ? getAssetOriginalUrl(asset.id) : undefined}
|
||||
/>
|
||||
{:catch}
|
||||
{$t('errors.failed_to_load_asset')}
|
||||
|
|
|
@ -7,18 +7,21 @@
|
|||
type AdapterConstructor,
|
||||
type PluginConstructor,
|
||||
} from '@photo-sphere-viewer/core';
|
||||
import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin';
|
||||
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
|
||||
import '@photo-sphere-viewer/core/index.css';
|
||||
import '@photo-sphere-viewer/settings-plugin/index.css';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
panorama: string | { source: string };
|
||||
originalImageUrl?: string;
|
||||
originalPanorama?: string | { source: string };
|
||||
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
|
||||
plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
|
||||
navbar?: boolean;
|
||||
}
|
||||
|
||||
let { panorama, originalImageUrl, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
|
||||
let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
|
||||
|
||||
let container: HTMLDivElement | undefined = $state();
|
||||
let viewer: Viewer;
|
||||
|
@ -30,9 +33,33 @@
|
|||
|
||||
viewer = new Viewer({
|
||||
adapter,
|
||||
plugins,
|
||||
plugins: [
|
||||
SettingsPlugin,
|
||||
[
|
||||
ResolutionPlugin,
|
||||
{
|
||||
defaultResolution: $alwaysLoadOriginalFile && originalPanorama ? 'original' : 'default',
|
||||
resolutions: [
|
||||
{
|
||||
id: 'default',
|
||||
label: 'Default',
|
||||
panorama,
|
||||
},
|
||||
...(originalPanorama
|
||||
? [
|
||||
{
|
||||
id: 'original',
|
||||
label: 'Original',
|
||||
panorama: originalPanorama,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
],
|
||||
...plugins,
|
||||
],
|
||||
container,
|
||||
panorama,
|
||||
touchmoveTwoFingers: false,
|
||||
mousewheelCtrlKey: false,
|
||||
navbar,
|
||||
|
@ -40,15 +67,14 @@
|
|||
maxFov: 120,
|
||||
fisheye: false,
|
||||
});
|
||||
const resolutionPlugin = viewer.getPlugin(ResolutionPlugin) as ResolutionPlugin;
|
||||
|
||||
if (originalImageUrl && !$alwaysLoadOriginalFile) {
|
||||
if (originalPanorama && !$alwaysLoadOriginalFile) {
|
||||
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
|
||||
// zoomLevel range: [0, 100]
|
||||
if (Math.round(zoomLevel) >= 75) {
|
||||
// Replace the preview with the original
|
||||
viewer.setPanorama(originalImageUrl, { showLoader: false, speed: 150 }).catch(() => {
|
||||
viewer.setPanorama(panorama, { showLoader: false, speed: 0 }).catch(() => {});
|
||||
});
|
||||
void resolutionPlugin.setResolution('original');
|
||||
viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { getAssetOriginalUrl } from '$lib/utils';
|
||||
import { getAssetPlaybackUrl, getAssetOriginalUrl } from '$lib/utils';
|
||||
import { fade } from 'svelte/transition';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
@ -22,7 +22,13 @@
|
|||
{#await modules}
|
||||
<LoadingSpinner />
|
||||
{:then [PhotoSphereViewer, adapter, videoPlugin]}
|
||||
<PhotoSphereViewer panorama={{ source: getAssetOriginalUrl(assetId) }} plugins={[videoPlugin]} {adapter} navbar />
|
||||
<PhotoSphereViewer
|
||||
panorama={{ source: getAssetPlaybackUrl(assetId) }}
|
||||
originalPanorama={{ source: getAssetOriginalUrl(assetId) }}
|
||||
plugins={[videoPlugin]}
|
||||
{adapter}
|
||||
navbar
|
||||
/>
|
||||
{:catch}
|
||||
{$t('errors.failed_to_load_asset')}
|
||||
{/await}
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -92,6 +92,7 @@
|
|||
let personMerge1: PersonResponseDto | undefined = $state();
|
||||
let personMerge2: PersonResponseDto | undefined = $state();
|
||||
let potentialMergePeople: PersonResponseDto[] = $state([]);
|
||||
let isSuggestionSelectedByUser = $state(false);
|
||||
|
||||
let personName = '';
|
||||
let suggestedPeople: PersonResponseDto[] = $state([]);
|
||||
|
@ -233,15 +234,22 @@
|
|||
personName = person.name;
|
||||
personMerge1 = person;
|
||||
personMerge2 = person2;
|
||||
isSuggestionSelectedByUser = true;
|
||||
viewMode = PersonPageViewMode.SUGGEST_MERGE;
|
||||
};
|
||||
|
||||
const changeName = async () => {
|
||||
viewMode = PersonPageViewMode.VIEW_ASSETS;
|
||||
person.name = personName;
|
||||
try {
|
||||
isEditingName = false;
|
||||
isEditingName = false;
|
||||
|
||||
if (isSuggestionSelectedByUser) {
|
||||
// User canceled the merge
|
||||
isSuggestionSelectedByUser = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
person = await updatePerson({ id: person.id, personUpdateDto: { name: personName } });
|
||||
|
||||
notificationController.show({
|
||||
|
|
|
@ -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)}
|
||||
|
|
Loading…
Reference in a new issue