diff --git a/e2e/src/api/specs/api-key.e2e-spec.ts b/e2e/src/api/specs/api-key.e2e-spec.ts index e86edddcdf..ad03571869 100644 --- a/e2e/src/api/specs/api-key.e2e-spec.ts +++ b/e2e/src/api/specs/api-key.e2e-spec.ts @@ -143,7 +143,7 @@ describe('/api-keys', () => { const { apiKey } = await create(user.accessToken, [Permission.All]); const { status, body } = await request(app) .put(`/api-keys/${apiKey.id}`) - .send({ name: 'new name' }) + .send({ name: 'new name', permissions: [Permission.All] }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); expect(body).toEqual(errorDto.badRequest('API Key not found')); @@ -153,13 +153,16 @@ describe('/api-keys', () => { const { apiKey } = await create(user.accessToken, [Permission.All]); const { status, body } = await request(app) .put(`/api-keys/${apiKey.id}`) - .send({ name: 'new name' }) + .send({ + name: 'new name', + permissions: [Permission.ActivityCreate, Permission.ActivityRead, Permission.ActivityUpdate], + }) .set('Authorization', `Bearer ${user.accessToken}`); expect(status).toBe(200); expect(body).toEqual({ id: expect.any(String), name: 'new name', - permissions: [Permission.All], + permissions: [Permission.ActivityCreate, Permission.ActivityRead, Permission.ActivityUpdate], createdAt: expect.any(String), updatedAt: expect.any(String), }); diff --git a/i18n/en.json b/i18n/en.json index d6f31a65f0..56e38cf816 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1381,6 +1381,8 @@ "permanently_delete_assets_prompt": "Are you sure you want to permanently delete {count, plural, one {this asset?} other {these <b>#</b> assets?}} This will also remove {count, plural, one {it from its} other {them from their}} album(s).", "permanently_deleted_asset": "Permanently deleted asset", "permanently_deleted_assets_count": "Permanently deleted {count, plural, one {# asset} other {# assets}}", + "permission": "Permission", + "permission_empty": "Your permission shouldn't be empty", "permission_onboarding_back": "Back", "permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_get_started": "Get started", diff --git a/mobile/openapi/lib/model/api_key_update_dto.dart b/mobile/openapi/lib/model/api_key_update_dto.dart index 7295d1ea1f..60ac168fdb 100644 --- a/mobile/openapi/lib/model/api_key_update_dto.dart +++ b/mobile/openapi/lib/model/api_key_update_dto.dart @@ -14,25 +14,31 @@ class APIKeyUpdateDto { /// Returns a new [APIKeyUpdateDto] instance. APIKeyUpdateDto({ required this.name, + this.permissions = const [], }); String name; + List<Permission> permissions; + @override bool operator ==(Object other) => identical(this, other) || other is APIKeyUpdateDto && - other.name == name; + other.name == name && + _deepEquality.equals(other.permissions, permissions); @override int get hashCode => // ignore: unnecessary_parenthesis - (name.hashCode); + (name.hashCode) + + (permissions.hashCode); @override - String toString() => 'APIKeyUpdateDto[name=$name]'; + String toString() => 'APIKeyUpdateDto[name=$name, permissions=$permissions]'; Map<String, dynamic> toJson() { final json = <String, dynamic>{}; json[r'name'] = this.name; + json[r'permissions'] = this.permissions; return json; } @@ -46,6 +52,7 @@ class APIKeyUpdateDto { return APIKeyUpdateDto( name: mapValueOfType<String>(json, r'name')!, + permissions: Permission.listFromJson(json[r'permissions']), ); } return null; @@ -94,6 +101,7 @@ class APIKeyUpdateDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = <String>{ 'name', + 'permissions', }; } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f533b17b41..98382a382c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8294,10 +8294,18 @@ "properties": { "name": { "type": "string" + }, + "permissions": { + "items": { + "$ref": "#/components/schemas/Permission" + }, + "minItems": 1, + "type": "array" } }, "required": [ - "name" + "name", + "permissions" ], "type": "object" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index fbeb519bfc..0ce6f417b1 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -408,6 +408,7 @@ export type ApiKeyCreateResponseDto = { }; export type ApiKeyUpdateDto = { name: string; + permissions: Permission[]; }; export type AssetBulkDeleteDto = { force?: boolean; diff --git a/server/src/controllers/api-key.controller.spec.ts b/server/src/controllers/api-key.controller.spec.ts index 3246eb9b77..434fa2b7aa 100644 --- a/server/src/controllers/api-key.controller.spec.ts +++ b/server/src/controllers/api-key.controller.spec.ts @@ -1,4 +1,5 @@ import { APIKeyController } from 'src/controllers/api-key.controller'; +import { Permission } from 'src/enum'; import { ApiKeyService } from 'src/services/api-key.service'; import request from 'supertest'; import { factory } from 'test/small.factory'; @@ -52,7 +53,9 @@ describe(APIKeyController.name, () => { }); it('should require a valid uuid', async () => { - const { status, body } = await request(ctx.getHttpServer()).put(`/api-keys/123`).send({ name: 'new name' }); + const { status, body } = await request(ctx.getHttpServer()) + .put(`/api-keys/123`) + .send({ name: 'new name', permissions: [Permission.ALL] }); expect(status).toBe(400); expect(body).toEqual(factory.responses.badRequest(['id must be a UUID'])); }); diff --git a/server/src/dtos/api-key.dto.ts b/server/src/dtos/api-key.dto.ts index 7e81ce8c60..ac6dd25bcf 100644 --- a/server/src/dtos/api-key.dto.ts +++ b/server/src/dtos/api-key.dto.ts @@ -18,6 +18,11 @@ export class APIKeyUpdateDto { @IsString() @IsNotEmpty() name!: string; + + @IsEnum(Permission, { each: true }) + @ApiProperty({ enum: Permission, enumName: 'Permission', isArray: true }) + @ArrayMinSize(1) + permissions!: Permission[]; } export class APIKeyCreateResponseDto { diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index 784c944146..3448b4330f 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -69,7 +69,9 @@ describe(ApiKeyService.name, () => { mocks.apiKey.getById.mockResolvedValue(void 0); - await expect(sut.update(auth, id, { name: 'New Name' })).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.update(auth, id, { name: 'New Name', permissions: [Permission.ALL] })).rejects.toBeInstanceOf( + BadRequestException, + ); expect(mocks.apiKey.update).not.toHaveBeenCalledWith(id); }); @@ -82,9 +84,28 @@ describe(ApiKeyService.name, () => { mocks.apiKey.getById.mockResolvedValue(apiKey); mocks.apiKey.update.mockResolvedValue(apiKey); - await sut.update(auth, apiKey.id, { name: newName }); + await sut.update(auth, apiKey.id, { name: newName, permissions: [Permission.ALL] }); - expect(mocks.apiKey.update).toHaveBeenCalledWith(auth.user.id, apiKey.id, { name: newName }); + expect(mocks.apiKey.update).toHaveBeenCalledWith(auth.user.id, apiKey.id, { + name: newName, + permissions: [Permission.ALL], + }); + }); + + it('should update permissions', async () => { + const auth = factory.auth(); + const apiKey = factory.apiKey({ userId: auth.user.id }); + const newPermissions = [Permission.ACTIVITY_CREATE, Permission.ACTIVITY_READ, Permission.ACTIVITY_UPDATE]; + + mocks.apiKey.getById.mockResolvedValue(apiKey); + mocks.apiKey.update.mockResolvedValue(apiKey); + + await sut.update(auth, apiKey.id, { name: apiKey.name, permissions: newPermissions }); + + expect(mocks.apiKey.update).toHaveBeenCalledWith(auth.user.id, apiKey.id, { + name: apiKey.name, + permissions: newPermissions, + }); }); }); diff --git a/server/src/services/api-key.service.ts b/server/src/services/api-key.service.ts index 49d4183b01..82d4eabdfd 100644 --- a/server/src/services/api-key.service.ts +++ b/server/src/services/api-key.service.ts @@ -32,7 +32,7 @@ export class ApiKeyService extends BaseService { throw new BadRequestException('API Key not found'); } - const key = await this.apiKeyRepository.update(auth.user.id, id, { name: dto.name }); + const key = await this.apiKeyRepository.update(auth.user.id, id, { name: dto.name, permissions: dto.permissions }); return this.map(key); } diff --git a/web/src/lib/components/user-settings-page/user-api-key-grid.svelte b/web/src/lib/components/user-settings-page/user-api-key-grid.svelte new file mode 100644 index 0000000000..78f383a141 --- /dev/null +++ b/web/src/lib/components/user-settings-page/user-api-key-grid.svelte @@ -0,0 +1,57 @@ +<script lang="ts"> + import { Permission } from '@immich/sdk'; + import { Checkbox, Label } from '@immich/ui'; + + interface Props { + title: string; + subItems: Permission[]; + selectedItems: Permission[]; + handleSelectItems: (permissions: Permission[]) => void; + handleDeselectItems: (permissions: Permission[]) => void; + } + + let { title, subItems, selectedItems, handleSelectItems, handleDeselectItems }: Props = $props(); + + let selectAllSubItems = $derived(subItems.filter((item) => selectedItems.includes(item)).length === subItems.length); + + const handleSelectAllSubItems = () => { + if (selectAllSubItems) { + handleDeselectItems(subItems); + } else { + handleSelectItems(subItems); + } + }; + + const handleToggleItem = (permission: Permission) => { + if (selectedItems.includes(permission)) { + handleDeselectItems([permission]); + } else { + handleSelectItems([permission]); + } + }; +</script> + +<div class="mx-4 my-2 border bg-subtle dark:bg-black/30 dark:border-black p-4 rounded-2xl"> + <div class="flex items-center gap-2"> + <Checkbox + id="permission-{title}" + size="tiny" + checked={selectAllSubItems} + onCheckedChange={handleSelectAllSubItems} + /> + <Label label={title} for={title} class="font-mono text-primary text-lg" /> + </div> + <div class="mx-6 mt-3 grid grid-cols-3 gap-2"> + {#each subItems as item (item)} + <div class="flex items-center gap-2"> + <Checkbox + id="permission-{item}" + size="tiny" + checked={selectedItems.includes(item)} + onCheckedChange={() => handleToggleItem(item)} + /> + <Label label={item} for={item} class="text-sm font-mono" /> + </div> + {/each} + </div> +</div> diff --git a/web/src/lib/components/user-settings-page/user-api-key-list.svelte b/web/src/lib/components/user-settings-page/user-api-key-list.svelte index ccc1bdfe92..12b100826f 100644 --- a/web/src/lib/components/user-settings-page/user-api-key-list.svelte +++ b/web/src/lib/components/user-settings-page/user-api-key-list.svelte @@ -1,24 +1,17 @@ <script lang="ts"> import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; + import { dateFormats } from '$lib/constants'; import { modalManager } from '$lib/managers/modal-manager.svelte'; import ApiKeyModal from '$lib/modals/ApiKeyModal.svelte'; import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte'; import { locale } from '$lib/stores/preferences.store'; - import { - createApiKey, - deleteApiKey, - getApiKeys, - Permission, - updateApiKey, - type ApiKeyResponseDto, - } from '@immich/sdk'; + import { createApiKey, deleteApiKey, getApiKeys, updateApiKey, type ApiKeyResponseDto } from '@immich/sdk'; import { Button } from '@immich/ui'; import { mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; import { handleError } from '../../utils/handle-error'; import { notificationController, NotificationType } from '../shared-components/notification/notification'; - import { dateFormats } from '$lib/constants'; interface Props { keys: ApiKeyResponseDto[]; @@ -33,7 +26,7 @@ const handleCreate = async () => { const result = await modalManager.show(ApiKeyModal, { title: $t('new_api_key'), - apiKey: { name: 'API Key' }, + apiKey: { name: 'API Key', permissions: [] }, submitText: $t('create'), }); @@ -45,7 +38,7 @@ const { secret } = await createApiKey({ apiKeyCreateDto: { name: result.name, - permissions: [Permission.All], + permissions: result.permissions, }, }); @@ -69,7 +62,7 @@ } try { - await updateApiKey({ id: key.id, apiKeyUpdateDto: { name: result.name } }); + await updateApiKey({ id: key.id, apiKeyUpdateDto: { name: result.name, permissions: result.permissions } }); notificationController.show({ message: $t('saved_api_key'), type: NotificationType.Info, @@ -113,9 +106,10 @@ class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary" > <tr class="flex w-full place-items-center"> - <th class="w-1/3 text-center text-sm font-medium">{$t('name')}</th> - <th class="w-1/3 text-center text-sm font-medium">{$t('created')}</th> - <th class="w-1/3 text-center text-sm font-medium">{$t('action')}</th> + <th class="w-1/4 text-center text-sm font-medium">{$t('name')}</th> + <th class="w-1/4 text-center text-sm font-medium">{$t('permission')}</th> + <th class="w-1/4 text-center text-sm font-medium">{$t('created')}</th> + <th class="w-1/4 text-center text-sm font-medium">{$t('action')}</th> </tr> </thead> <tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray"> @@ -123,11 +117,15 @@ <tr class="flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80" > - <td class="w-1/3 text-ellipsis px-4 text-sm">{key.name}</td> - <td class="w-1/3 text-ellipsis px-4 text-sm" + <td class="w-1/4 text-ellipsis px-4 text-sm overflow-hidden">{key.name}</td> + <td + class="w-1/4 text-ellipsis px-4 text-xs overflow-hidden line-clamp-3 break-all font-mono" + title={JSON.stringify(key.permissions, undefined, 2)}>{key.permissions}</td + > + <td class="w-1/4 text-ellipsis px-4 text-sm overflow-hidden" >{new Date(key.createdAt).toLocaleDateString($locale, dateFormats.settings)} </td> - <td class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-1/3"> + <td class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-1/4"> <CircleIconButton color="primary" icon={mdiPencilOutline} diff --git a/web/src/lib/modals/ApiKeyModal.svelte b/web/src/lib/modals/ApiKeyModal.svelte index f5e1fb2a7e..4a1df0f981 100644 --- a/web/src/lib/modals/ApiKeyModal.svelte +++ b/web/src/lib/modals/ApiKeyModal.svelte @@ -3,28 +3,177 @@ notificationController, NotificationType, } from '$lib/components/shared-components/notification/notification'; - import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui'; + import ApiKeyGrid from '$lib/components/user-settings-page/user-api-key-grid.svelte'; + import { Permission } from '@immich/sdk'; + import { Button, Checkbox, Label, Modal, ModalBody, ModalFooter } from '@immich/ui'; import { mdiKeyVariant } from '@mdi/js'; + import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; interface Props { - apiKey: { name: string }; + apiKey: { name: string; permissions: Permission[] }; title: string; cancelText?: string; submitText?: string; - onClose: (apiKey?: { name: string }) => void; + onClose: (apiKey?: { name: string; permissions: Permission[] }) => void; } let { apiKey = $bindable(), title, cancelText = $t('cancel'), submitText = $t('save'), onClose }: Props = $props(); + let selectedItems: Permission[] = $state(apiKey.permissions); + let selectAllItems = $derived(selectedItems.length === Object.keys(Permission).length - 1); + + const permissions: Map<string, Permission[]> = new Map(); + + permissions.set('activity', [ + Permission.ActivityCreate, + Permission.ActivityRead, + Permission.ActivityUpdate, + Permission.ActivityDelete, + Permission.ActivityStatistics, + ]); + + permissions.set('api_key', [ + Permission.ApiKeyCreate, + Permission.ApiKeyRead, + Permission.ApiKeyUpdate, + Permission.ApiKeyDelete, + ]); + + permissions.set('asset', [ + Permission.AssetRead, + Permission.AssetUpdate, + Permission.AssetDelete, + Permission.AssetShare, + Permission.AssetView, + Permission.AssetDownload, + Permission.AssetUpload, + ]); + + permissions.set('album', [ + Permission.AlbumCreate, + Permission.AlbumRead, + Permission.AlbumUpdate, + Permission.AlbumDelete, + Permission.AlbumStatistics, + + Permission.AlbumAddAsset, + Permission.AlbumRemoveAsset, + Permission.AlbumShare, + Permission.AlbumDownload, + ]); + + permissions.set('auth_device', [Permission.AuthDeviceDelete]); + + permissions.set('archive', [Permission.ArchiveRead]); + + permissions.set('face', [Permission.FaceCreate, Permission.FaceRead, Permission.FaceUpdate, Permission.FaceDelete]); + + permissions.set('library', [ + Permission.LibraryCreate, + Permission.LibraryRead, + Permission.LibraryUpdate, + Permission.LibraryDelete, + Permission.LibraryStatistics, + ]); + + permissions.set('timeline', [Permission.TimelineRead, Permission.TimelineDownload]); + + permissions.set('memory', [ + Permission.MemoryCreate, + Permission.MemoryRead, + Permission.MemoryUpdate, + Permission.MemoryDelete, + ]); + + permissions.set('notification', [ + Permission.NotificationCreate, + Permission.NotificationRead, + Permission.NotificationUpdate, + Permission.NotificationDelete, + ]); + + permissions.set('partner', [ + Permission.PartnerCreate, + Permission.PartnerRead, + Permission.PartnerUpdate, + Permission.PartnerDelete, + ]); + + permissions.set('person', [ + Permission.PersonCreate, + Permission.PersonRead, + Permission.PersonUpdate, + Permission.PersonDelete, + Permission.PersonStatistics, + Permission.PersonMerge, + Permission.PersonReassign, + ]); + + permissions.set('session', [Permission.SessionRead, Permission.SessionUpdate, Permission.SessionDelete]); + + permissions.set('sharedLink', [ + Permission.SharedLinkCreate, + Permission.SharedLinkRead, + Permission.SharedLinkUpdate, + Permission.SharedLinkDelete, + ]); + + permissions.set('stack', [ + Permission.StackCreate, + Permission.StackRead, + Permission.StackUpdate, + Permission.StackDelete, + ]); + + permissions.set('systemConfig', [Permission.SystemConfigRead, Permission.SystemConfigUpdate]); + + permissions.set('systemMetadata', [Permission.SystemMetadataRead, Permission.SystemMetadataUpdate]); + + permissions.set('tag', [ + Permission.TagCreate, + Permission.TagRead, + Permission.TagUpdate, + Permission.TagDelete, + Permission.TagAsset, + ]); + + permissions.set('adminUser', [ + Permission.AdminUserCreate, + Permission.AdminUserRead, + Permission.AdminUserUpdate, + Permission.AdminUserDelete, + ]); + + const handleSelectItems = (permissions: Permission[]) => { + selectedItems = Array.from(new Set([...selectedItems, ...permissions])); + }; + + const handleDeselectItems = (permissions: Permission[]) => { + selectedItems = selectedItems.filter((item) => !permissions.includes(item)); + }; + + const handleSelectAllItems = () => { + selectedItems = selectAllItems ? [] : Object.values(Permission).filter((item) => item !== Permission.All); + }; + const handleSubmit = () => { - if (apiKey.name) { - onClose({ name: apiKey.name }); - } else { + if (!apiKey.name) { notificationController.show({ message: $t('api_key_empty'), type: NotificationType.Warning, }); + } else if (selectedItems.length === 0) { + notificationController.show({ + message: $t('permission_empty'), + type: NotificationType.Warning, + }); + } else { + if (selectAllItems) { + onClose({ name: apiKey.name, permissions: [Permission.All] }); + } else { + onClose({ name: apiKey.name, permissions: selectedItems }); + } } }; @@ -32,15 +181,34 @@ event.preventDefault(); handleSubmit(); }; + + onMount(() => { + if (apiKey.permissions.includes(Permission.All)) { + handleSelectAllItems(); + } + }); </script> -<Modal {title} icon={mdiKeyVariant} {onClose} size="small"> +<Modal {title} icon={mdiKeyVariant} {onClose} size="giant"> <ModalBody> <form {onsubmit} autocomplete="off" id="api-key-form"> <div class="mb-4 flex flex-col gap-2"> <label class="immich-form-label" for="name">{$t('name')}</label> <input class="immich-form-input" id="name" name="name" type="text" bind:value={apiKey.name} /> </div> + <label class="immich-form-label" for="permission">{$t('permission')}</label> + <div class="flex items-center gap-2 m-4" id="permission"> + <Checkbox + id="select-all-permissions" + size="tiny" + checked={selectAllItems} + onCheckedChange={handleSelectAllItems} + /> + <Label label={$t('select_all')} for="select-all-permissions" /> + </div> + {#each permissions as [title, subItems] (title)} + <ApiKeyGrid {title} {subItems} {selectedItems} {handleSelectItems} {handleDeselectItems} /> + {/each} </form> </ModalBody>