From f5a99e4c15a2472b5ce7bdc110b827a599d482cc Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Wed, 24 Jun 2026 13:17:48 -0700 Subject: [PATCH] fix(auth): preserve permission group names when creating API tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectToSnakeCase recursively transforms all object keys, including the permissions map. This mangles "time-entries" → "time_entries", causing the backend to reject the permission as invalid. Override autoTransformBeforePut and manually restore the original permissions map after the snake_case transform in beforeCreate. Co-Authored-By: Claude Opus 4.6 --- frontend/src/services/apiToken.test.ts | 35 ++++++++++++++++++++++++++ frontend/src/services/apiToken.ts | 18 +++++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 frontend/src/services/apiToken.test.ts diff --git a/frontend/src/services/apiToken.test.ts b/frontend/src/services/apiToken.test.ts new file mode 100644 index 000000000..c4be5d382 --- /dev/null +++ b/frontend/src/services/apiToken.test.ts @@ -0,0 +1,35 @@ +import {describe, it, expect} from 'vitest' + +import {objectToSnakeCase} from '@/helpers/case' + +// Regression test: objectToSnakeCase mangles hyphenated permission group +// names like "time-entries" → "time_entries". ApiTokenService.beforeCreate +// works around this by restoring the original permissions after the transform. +// This test ensures the underlying problem is documented and will tell us +// if the library behaviour changes. +describe('objectToSnakeCase on API token permissions', () => { + it('mangles time-entries to time_entries (the bug we work around)', () => { + const input = { + title: 'test', + expiresAt: '2099-01-01T00:00:00Z', + permissions: { + 'tasks': ['read_all', 'create'], + 'time-entries': ['read_all', 'create'], + 'tasks_assignees': ['create', 'delete'], + }, + } + + const result = objectToSnakeCase(input) + + // The outer key is fine + expect(result.expires_at).toBe('2099-01-01T00:00:00Z') + + // time-entries gets mangled — this is the bug + expect(result.permissions['time_entries']).toEqual(['read_all', 'create']) + expect(result.permissions['time-entries']).toBeUndefined() + + // Other groups survive + expect(result.permissions['tasks']).toEqual(['read_all', 'create']) + expect(result.permissions['tasks_assignees']).toEqual(['create', 'delete']) + }) +}) diff --git a/frontend/src/services/apiToken.ts b/frontend/src/services/apiToken.ts index 3f1914936..628dd91e0 100644 --- a/frontend/src/services/apiToken.ts +++ b/frontend/src/services/apiToken.ts @@ -1,6 +1,7 @@ import AbstractService from '@/services/abstractService' import type {IApiToken} from '@/modelTypes/IApiToken' import ApiTokenModel from '@/models/apiTokenModel' +import {objectToSnakeCase} from '@/helpers/case' export default class ApiTokenService extends AbstractService { constructor() { @@ -11,6 +12,19 @@ export default class ApiTokenService extends AbstractService { }) } + // Disable the default snake_case transform — beforeCreate handles it + // manually to preserve the permissions map keys (e.g. "time-entries"). + autoTransformBeforePut(): boolean { + return false + } + + beforeCreate(model: IApiToken) { + const permissions = model.permissions + const transformed = objectToSnakeCase(model) + transformed.permissions = permissions + return transformed + } + processModel(model: IApiToken) { return { ...model, @@ -18,11 +32,11 @@ export default class ApiTokenService extends AbstractService { created: new Date(model.created).toISOString(), } } - + modelFactory(data: Partial) { return new ApiTokenModel(data) } - + async getAvailableRoutes() { const cancel = this.setLoading()