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 # 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 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 toJson() { final json = {}; json[r'name'] = this.name; + json[r'permissions'] = this.permissions; return json; } @@ -46,6 +52,7 @@ class APIKeyUpdateDto { return APIKeyUpdateDto( name: mapValueOfType(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 = { '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 @@ + + + + + + + + + {#each subItems as item (item)} + + handleToggleItem(item)} + /> + + + {/each} + + 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 @@ - + {$t('name')} + {$t('permission')} + + + + + {#each permissions as [title, subItems] (title)} + + {/each}