From 47c5a3dbf6d3eef7fa9284c5cd1b2d4b5ae31276 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 20 Aug 2026 11:23:12 -0400 Subject: [PATCH] refactor: api key response dto (#30887) --- e2e/src/specs/server/api/api-key.e2e-spec.ts | 51 +++++++++++--------- e2e/src/specs/server/cli/login.e2e-spec.ts | 8 +-- e2e/src/utils.ts | 6 +-- open-api/immich-openapi-specs.json | 51 +++++++++++++++++++- packages/sdk/src/fetch-client.ts | 10 ++++ server/src/dtos/api-key.dto.ts | 4 +- server/src/services/api-key.service.ts | 7 +-- web/src/lib/services/api-key.service.ts | 4 +- 8 files changed, 104 insertions(+), 37 deletions(-) diff --git a/e2e/src/specs/server/api/api-key.e2e-spec.ts b/e2e/src/specs/server/api/api-key.e2e-spec.ts index 8ffcf1f52..ec481cee0 100644 --- a/e2e/src/specs/server/api/api-key.e2e-spec.ts +++ b/e2e/src/specs/server/api/api-key.e2e-spec.ts @@ -34,14 +34,14 @@ describe('/api-keys', () => { permissions: [Permission.ApiKeyRead], }); expect(body).toEqual({ + id: expect.any(String), + name: 'API Key', + permissions: [Permission.ApiKeyRead], + createdAt: expect.any(String), + updatedAt: expect.any(String), secret: expect.any(String), - apiKey: { - id: expect.any(String), - name: 'API Key', - permissions: [Permission.ApiKeyRead], - createdAt: expect.any(String), - updatedAt: expect.any(String), - }, + // TODO: remove in v4 + apiKey: expect.any(Object), }); expect(status).toBe(201); }); @@ -72,14 +72,14 @@ describe('/api-keys', () => { .send({ name: 'API Key', permissions: [Permission.All] }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toEqual({ - apiKey: { - id: expect.any(String), - name: 'API Key', - permissions: [Permission.All], - createdAt: expect.any(String), - updatedAt: expect.any(String), - }, + id: expect.any(String), + name: 'API Key', + permissions: [Permission.All], + createdAt: expect.any(String), + updatedAt: expect.any(String), secret: expect.any(String), + // TODO: remove in v4 + apiKey: expect.any(Object), }); expect(status).toEqual(201); }); @@ -93,23 +93,30 @@ describe('/api-keys', () => { }); it('should return a list of api keys', async () => { - const [{ apiKey: apiKey1 }, { apiKey: apiKey2 }, { apiKey: apiKey3 }] = await Promise.all([ + const [apiKey1, apiKey2, apiKey3] = await Promise.all([ create(admin.accessToken, [Permission.All]), create(admin.accessToken, [Permission.All]), create(admin.accessToken, [Permission.All]), ]); + const { status, body } = await request(app).get('/api-keys').set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toHaveLength(3); - expect(body).toEqual(expect.arrayContaining([apiKey1, apiKey2, apiKey3])); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: apiKey1.id }), + expect.objectContaining({ id: apiKey2.id }), + expect.objectContaining({ id: apiKey3.id }), + ]), + ); expect(status).toEqual(200); }); }); describe('GET /api-keys/:id', () => { it('should get api key details', async () => { - const { apiKey } = await create(user.accessToken, [Permission.All]); + const { id } = await create(user.accessToken, [Permission.All]); const { status, body } = await request(app) - .get(`/api-keys/${apiKey.id}`) + .get(`/api-keys/${id}`) .set('Authorization', `Bearer ${user.accessToken}`); expect(status).toBe(200); expect(body).toEqual({ @@ -124,9 +131,9 @@ describe('/api-keys', () => { describe('PUT /api-keys/:id', () => { it('should update api key details', async () => { - const { apiKey } = await create(user.accessToken, [Permission.All]); + const { id } = await create(user.accessToken, [Permission.All]); const { status, body } = await request(app) - .put(`/api-keys/${apiKey.id}`) + .put(`/api-keys/${id}`) .send({ name: 'new name', permissions: [Permission.ActivityCreate, Permission.ActivityRead, Permission.ActivityUpdate], @@ -145,9 +152,9 @@ describe('/api-keys', () => { describe('DELETE /api-keys/:id', () => { it('should delete an api key', async () => { - const { apiKey } = await create(user.accessToken, [Permission.All]); + const { id } = await create(user.accessToken, [Permission.All]); const { status } = await request(app) - .delete(`/api-keys/${apiKey.id}`) + .delete(`/api-keys/${id}`) .set('Authorization', `Bearer ${user.accessToken}`); expect(status).toBe(204); }); diff --git a/e2e/src/specs/server/cli/login.e2e-spec.ts b/e2e/src/specs/server/cli/login.e2e-spec.ts index caf5550b6..98a28be44 100644 --- a/e2e/src/specs/server/cli/login.e2e-spec.ts +++ b/e2e/src/specs/server/cli/login.e2e-spec.ts @@ -30,8 +30,8 @@ describe(`immich login`, () => { it('should login and save auth.yml with 600', async () => { const admin = await utils.adminSetup(); - const key = await utils.createApiKey(admin.accessToken, [Permission.All]); - const { stdout, stderr, exitCode } = await immichCli(['login', app, key.secret]); + const apiKey = await utils.createApiKey(admin.accessToken, [Permission.All]); + const { stdout, stderr, exitCode } = await immichCli(['login', app, apiKey.secret]); expect(stdout.split('\n')).toEqual([ 'Logging in to http://127.0.0.1:2285/api', 'Logged in as admin@immich.cloud', @@ -47,8 +47,8 @@ describe(`immich login`, () => { it('should login without /api in the url', async () => { const admin = await utils.adminSetup(); - const key = await utils.createApiKey(admin.accessToken, [Permission.All]); - const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), key.secret]); + const apiKey = await utils.createApiKey(admin.accessToken, [Permission.All]); + const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), apiKey.secret]); expect(stdout.split('\n')).toEqual([ 'Logging in to http://127.0.0.1:2285', 'Discovered API at http://127.0.0.1:2285/api', diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 3124dd060..6490806bf 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -675,9 +675,9 @@ export const utils = { }, cliLogin: async (accessToken: string) => { - const key = await utils.createApiKey(accessToken, [Permission.All]); - await immichCli(['login', app, key.secret]); - return key.secret; + const { secret } = await utils.createApiKey(accessToken, [Permission.All]); + await immichCli(['login', app, secret]); + return secret; }, scan: async (accessToken: string, id: string) => { diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 60623a4a8..8b6727168 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -16979,16 +16979,63 @@ "ApiKeyCreateResponseDto": { "properties": { "apiKey": { - "$ref": "#/components/schemas/ApiKeyResponseDto" + "$ref": "#/components/schemas/ApiKeyResponseDto", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" + }, + "createdAt": { + "description": "Creation date", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "id": { + "description": "API key ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "name": { + "description": "API key name", + "type": "string" + }, + "permissions": { + "description": "List of permissions", + "items": { + "$ref": "#/components/schemas/Permission" + }, + "type": "array" }, "secret": { "description": "API key secret (only shown once)", "type": "string" + }, + "updatedAt": { + "description": "Last update date", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" } }, "required": [ "apiKey", - "secret" + "createdAt", + "id", + "name", + "permissions", + "secret", + "updatedAt" ], "type": "object" }, diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 9a1f93cb3..7e0fb963f 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -631,8 +631,18 @@ export type ApiKeyCreateDto = { }; export type ApiKeyCreateResponseDto = { apiKey: ApiKeyResponseDto; + /** Creation date */ + createdAt: string; + /** API key ID */ + id: string; + /** API key name */ + name: string; + /** List of permissions */ + permissions: Permission[]; /** API key secret (only shown once) */ secret: string; + /** Last update date */ + updatedAt: string; }; export type ApiKeyUpdateDto = { /** API key name */ diff --git a/server/src/dtos/api-key.dto.ts b/server/src/dtos/api-key.dto.ts index 470808e43..8c933e06b 100644 --- a/server/src/dtos/api-key.dto.ts +++ b/server/src/dtos/api-key.dto.ts @@ -1,4 +1,5 @@ import { createZodDto } from 'nestjs-zod'; +import { HistoryBuilder } from 'src/decorators'; import { Permission } from 'src/enum'; import { isoDatetimeToDate } from 'src/validation'; import z from 'zod'; @@ -31,8 +32,9 @@ const ApiKeyResponseSchema = z const ApiKeyCreateResponseSchema = z .object({ + ...ApiKeyResponseSchema.shape, secret: z.string().describe('API key secret (only shown once)'), - apiKey: ApiKeyResponseSchema, + apiKey: ApiKeyResponseSchema.meta({ ...new HistoryBuilder().added('v1').deprecated('v3.2.0').getExtensions() }), }) .meta({ id: 'ApiKeyCreateResponseDto' }); diff --git a/server/src/services/api-key.service.ts b/server/src/services/api-key.service.ts index 2b9e1a814..d65ebc4d9 100644 --- a/server/src/services/api-key.service.ts +++ b/server/src/services/api-key.service.ts @@ -23,8 +23,9 @@ export class ApiKeyService extends BaseService { userId: auth.user.id, permissions: dto.permissions, }); + const apiKey = this.map(entity); - return { secret: token, apiKey: this.map(entity) }; + return { ...apiKey, secret: token, apiKey }; } async update(auth: AuthDto, id: string, dto: ApiKeyUpdateDto): Promise { @@ -58,10 +59,10 @@ export class ApiKeyService extends BaseService { const token = this.cryptoRepository.randomBytesAsText(32); const hashed = this.cryptoRepository.hashSha256(token); - const newKey = await this.apiKeyRepository.update(auth.user.id, id, { key: hashed }); + const apiKey = this.map(newKey); - return { secret: token, apiKey: this.map(newKey) }; + return { ...apiKey, secret: token, apiKey }; } async delete(auth: AuthDto, id: string): Promise { diff --git a/web/src/lib/services/api-key.service.ts b/web/src/lib/services/api-key.service.ts index e5346627f..5dc990af9 100644 --- a/web/src/lib/services/api-key.service.ts +++ b/web/src/lib/services/api-key.service.ts @@ -64,7 +64,7 @@ export const handleCreateApiKey = async (dto: ApiKeyCreateDto) => { } const response = await createApiKey({ apiKeyCreateDto: dto }); - eventManager.emit('ApiKeyCreate', response.apiKey); + eventManager.emit('ApiKeyCreate', response); return response; } catch (error) { @@ -105,7 +105,7 @@ export const handleRotateApiKey = async (apiKey: ApiKeyResponseDto) => { try { const response = await rotateApiKey({ id: apiKey.id }); - eventManager.emit('ApiKeyUpdate', response.apiKey); + eventManager.emit('ApiKeyUpdate', response); await modalManager.show(ApiKeySecretModal, { secret: response.secret }); } catch (error) { handleError(error, $t('errors.something_went_wrong'));