diff --git a/e2e/src/api/specs/api-key.e2e-spec.ts b/e2e/src/api/specs/api-key.e2e-spec.ts index e86edddcd..ad0357186 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 d6f31a65f..56e38cf81 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 7295d1ea1..60ac168fd 100644 Binary files a/mobile/openapi/lib/model/api_key_update_dto.dart and b/mobile/openapi/lib/model/api_key_update_dto.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f533b17b4..98382a382 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 fbeb519bf..0ce6f417b 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 3246eb9b7..434fa2b7a 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 7e81ce8c6..ac6dd25bc 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 784c94414..3448b4330 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 49d4183b0..82d4eabdf 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 000000000..78f383a14 --- /dev/null +++ b/web/src/lib/components/user-settings-page/user-api-key-grid.svelte @@ -0,0 +1,57 @@ + + +