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
```
This commit is contained in:
kolaente
2026-01-24 19:08:23 +01:00
committed by GitHub
parent 1731b03c22
commit ff01f8e859
4 changed files with 218 additions and 6 deletions

View File

@@ -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'],
})
})
})

View File

@@ -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<string, string[]> {
if (!scopesParam) return {}
const result: Record<string, string[]> = {}
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
}

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import ApiTokenService from '@/services/apiToken'
import {computed, onMounted, ref} from 'vue'
import {useRoute} from 'vue-router'
import {parseScopesFromQuery} from '@/helpers/parseScopesFromQuery'
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
import {formatDateSince, formatDisplayDate} from '@/helpers/time/formatDate'
import XButton from '@/components/input/Button.vue'
@@ -35,6 +37,8 @@ const tokenToDelete = ref<IApiToken>()
const {t} = useI18n()
const route = useRoute()
const now = new Date()
const flatPickerConfig = computed(() => ({
@@ -57,10 +61,13 @@ onMounted(async () => {
keys.forEach(key => {
routesAvailable[key] = allRoutes[key]
})
availableRoutes.value = routesAvailable
resetPermissions()
// Apply query parameters if present
applyQueryParams()
})
function resetPermissions() {
@@ -74,6 +81,38 @@ function resetPermissions() {
})
}
function applyQueryParams() {
// Normalize query params - they can be string, string[], or null
const titleParam = Array.isArray(route.query.title) ? route.query.title[0] : route.query.title
const scopesParam = Array.isArray(route.query.scopes) ? route.query.scopes[0] : route.query.scopes
if (titleParam || scopesParam) {
showCreateForm.value = true
}
if (titleParam) {
newToken.value.title = titleParam
newTokenTitleValid.value = true
}
if (scopesParam) {
const requestedScopes = parseScopesFromQuery(scopesParam)
// Apply requested scopes to the permissions checkboxes
for (const [group, permissions] of Object.entries(requestedScopes)) {
if (newTokenPermissions.value[group]) {
for (const permission of permissions) {
if (newTokenPermissions.value[group][permission] !== undefined) {
newTokenPermissions.value[group][permission] = true
}
}
// Update group checkbox if all permissions in group are selected
toggleGroupPermissionsFromChild(group, true)
}
}
}
}
async function deleteToken() {
await service.delete(tokenToDelete.value)
showDeleteModal.value = false
@@ -306,15 +345,15 @@ function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
<br>
</template>
<template
v-for="(paths, route) in routes"
:key="group+'-'+route"
v-for="(paths, permission) in routes"
:key="group+'-'+permission"
>
<FancyCheckbox
v-model="newTokenPermissions[group][route]"
v-model="newTokenPermissions[group][permission]"
class="mis-4 mie-2 is-capitalized"
@update:modelValue="checked => toggleGroupPermissionsFromChild(group, checked)"
>
{{ formatPermissionTitle(route) }}
{{ formatPermissionTitle(permission) }}
</FancyCheckbox>
<br>
</template>

View File

@@ -0,0 +1,70 @@
import {test, expect} from '../../support/fixtures'
test.describe('API Tokens', () => {
test('Pre-populates title from query parameter', async ({authenticatedPage: page}) => {
await page.goto('/user/settings/api-tokens?title=My%20Test%20Token')
await page.waitForLoadState('networkidle')
// Form should be visible automatically
const titleInput = page.locator('#apiTokenTitle')
await expect(titleInput).toBeVisible({timeout: 5000})
// Title should be pre-populated
await expect(titleInput).toHaveValue('My Test Token')
})
test('Pre-selects scopes from query parameter', async ({authenticatedPage: page}) => {
// Use actual scope names: tasks:create
await page.goto('/user/settings/api-tokens?scopes=tasks:create')
await page.waitForLoadState('networkidle')
// Form should be visible automatically when scopes are provided
const permissionsLabel = page.locator('label.label:has-text("Permissions")')
await expect(permissionsLabel).toBeVisible({timeout: 5000})
// The title input should be visible (form is shown)
const titleInput = page.locator('#apiTokenTitle')
await expect(titleInput).toBeVisible()
// Find the div containing the "tasks" group by looking for the exact checkbox name
const tasksGroupDiv = page.locator('.mbe-2').filter({
has: page.getByRole('checkbox', {name: 'Checkbox tasks', exact: true}),
})
await expect(tasksGroupDiv).toBeVisible()
// Within that group, find the specific "create" permission checkbox and verify it's checked
const createCheckbox = tasksGroupDiv.getByRole('checkbox', {name: 'Checkbox create'})
await expect(createCheckbox).toBeChecked()
})
test('Pre-populates both title and scopes from query parameters', async ({authenticatedPage: page}) => {
await page.goto('/user/settings/api-tokens?title=Integration%20Token&scopes=labels:create')
await page.waitForLoadState('networkidle')
// Form should be visible automatically
const titleInput = page.locator('#apiTokenTitle')
await expect(titleInput).toBeVisible({timeout: 5000})
await expect(titleInput).toHaveValue('Integration Token')
// Permissions section should be visible
const permissionsLabel = page.locator('label.label:has-text("Permissions")')
await expect(permissionsLabel).toBeVisible()
})
test('Shows create form without query parameters', async ({authenticatedPage: page}) => {
await page.goto('/user/settings/api-tokens')
await page.waitForLoadState('networkidle')
// Form should NOT be visible initially
const titleInput = page.locator('#apiTokenTitle')
await expect(titleInput).not.toBeVisible({timeout: 2000})
// Click the create button to show the form
const createButton = page.locator('button:has-text("Create a token")')
await expect(createButton).toBeVisible()
await createButton.click()
// Now the form should be visible
await expect(titleInput).toBeVisible()
})
})