From ff01f8e859aa674616d77873aa61f18403d85e00 Mon Sep 17 00:00:00 2001 From: kolaente Date: Sat, 24 Jan 2026 19:08:23 +0100 Subject: [PATCH] feat(api-tokens): support title and scopes query parameters (#2143) This allows external integrations to link directly to the API token creation page with pre-selected title and permission scopes. URLs can now use `?title=Name&scopes=group:perm,group:perm` format to pre-populate the form. Example URL: ``` /user/settings/api-tokens?title=My%20Integration&scopes=tasks:create,tasks:delete,projects:read_all ``` --- .../src/helpers/parseScopesFromQuery.test.ts | 80 +++++++++++++++++++ frontend/src/helpers/parseScopesFromQuery.ts | 23 ++++++ .../src/views/user/settings/ApiTokens.vue | 51 ++++++++++-- frontend/tests/e2e/user/api-tokens.spec.ts | 70 ++++++++++++++++ 4 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 frontend/src/helpers/parseScopesFromQuery.test.ts create mode 100644 frontend/src/helpers/parseScopesFromQuery.ts create mode 100644 frontend/tests/e2e/user/api-tokens.spec.ts diff --git a/frontend/src/helpers/parseScopesFromQuery.test.ts b/frontend/src/helpers/parseScopesFromQuery.test.ts new file mode 100644 index 000000000..d58b6aed4 --- /dev/null +++ b/frontend/src/helpers/parseScopesFromQuery.test.ts @@ -0,0 +1,80 @@ +import {describe, it, expect} from 'vitest' +import {parseScopesFromQuery} from './parseScopesFromQuery' + +describe('parseScopesFromQuery', () => { + it('returns empty object for null input', () => { + expect(parseScopesFromQuery(null)).toEqual({}) + }) + + it('returns empty object for undefined input', () => { + expect(parseScopesFromQuery(undefined)).toEqual({}) + }) + + it('returns empty object for empty string', () => { + expect(parseScopesFromQuery('')).toEqual({}) + }) + + it('parses a single scope', () => { + expect(parseScopesFromQuery('tasks:read')).toEqual({ + tasks: ['read'], + }) + }) + + it('parses multiple scopes in the same group', () => { + expect(parseScopesFromQuery('tasks:read,tasks:write')).toEqual({ + tasks: ['read', 'write'], + }) + }) + + it('parses scopes across different groups', () => { + expect(parseScopesFromQuery('tasks:read,projects:write,labels:read')).toEqual({ + tasks: ['read'], + projects: ['write'], + labels: ['read'], + }) + }) + + it('handles whitespace around commas', () => { + expect(parseScopesFromQuery('tasks:read , projects:write')).toEqual({ + tasks: ['read'], + projects: ['write'], + }) + }) + + it('handles whitespace around colons', () => { + expect(parseScopesFromQuery('tasks : read')).toEqual({ + tasks: ['read'], + }) + }) + + it('ignores malformed entries without colons', () => { + expect(parseScopesFromQuery('tasks:read,invalidentry,projects:write')).toEqual({ + tasks: ['read'], + projects: ['write'], + }) + }) + + it('ignores entries with empty group', () => { + expect(parseScopesFromQuery(':read,tasks:write')).toEqual({ + tasks: ['write'], + }) + }) + + it('ignores entries with empty permission', () => { + expect(parseScopesFromQuery('tasks:,projects:write')).toEqual({ + projects: ['write'], + }) + }) + + it('handles trailing commas', () => { + expect(parseScopesFromQuery('tasks:read,')).toEqual({ + tasks: ['read'], + }) + }) + + it('handles leading commas', () => { + expect(parseScopesFromQuery(',tasks:read')).toEqual({ + tasks: ['read'], + }) + }) +}) diff --git a/frontend/src/helpers/parseScopesFromQuery.ts b/frontend/src/helpers/parseScopesFromQuery.ts new file mode 100644 index 000000000..fc3ef7756 --- /dev/null +++ b/frontend/src/helpers/parseScopesFromQuery.ts @@ -0,0 +1,23 @@ +/** + * Parses scopes from a query parameter string in the format "group:permission,group:permission" + * @param scopesParam - The raw scopes query parameter value + * @returns An object mapping group names to arrays of permissions + */ +export function parseScopesFromQuery(scopesParam: string | null | undefined): Record { + if (!scopesParam) return {} + + const result: Record = {} + const pairs = scopesParam.split(',').map(s => s.trim()).filter(Boolean) + + for (const pair of pairs) { + const [group, permission] = pair.split(':').map(s => s.trim()) + if (group && permission) { + if (!result[group]) { + result[group] = [] + } + result[group].push(permission) + } + } + + return result +} diff --git a/frontend/src/views/user/settings/ApiTokens.vue b/frontend/src/views/user/settings/ApiTokens.vue index 7fdd362d5..7a80274e4 100644 --- a/frontend/src/views/user/settings/ApiTokens.vue +++ b/frontend/src/views/user/settings/ApiTokens.vue @@ -1,6 +1,8 @@