mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-02-01 14:44:05 +00:00
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:
80
frontend/src/helpers/parseScopesFromQuery.test.ts
Normal file
80
frontend/src/helpers/parseScopesFromQuery.test.ts
Normal 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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
23
frontend/src/helpers/parseScopesFromQuery.ts
Normal file
23
frontend/src/helpers/parseScopesFromQuery.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
70
frontend/tests/e2e/user/api-tokens.spec.ts
Normal file
70
frontend/tests/e2e/user/api-tokens.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user