mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-02-01 22:47:40 +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">
|
<script setup lang="ts">
|
||||||
import ApiTokenService from '@/services/apiToken'
|
import ApiTokenService from '@/services/apiToken'
|
||||||
import {computed, onMounted, ref} from 'vue'
|
import {computed, onMounted, ref} from 'vue'
|
||||||
|
import {useRoute} from 'vue-router'
|
||||||
|
import {parseScopesFromQuery} from '@/helpers/parseScopesFromQuery'
|
||||||
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
||||||
import {formatDateSince, formatDisplayDate} from '@/helpers/time/formatDate'
|
import {formatDateSince, formatDisplayDate} from '@/helpers/time/formatDate'
|
||||||
import XButton from '@/components/input/Button.vue'
|
import XButton from '@/components/input/Button.vue'
|
||||||
@@ -35,6 +37,8 @@ const tokenToDelete = ref<IApiToken>()
|
|||||||
|
|
||||||
const {t} = useI18n()
|
const {t} = useI18n()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
const flatPickerConfig = computed(() => ({
|
const flatPickerConfig = computed(() => ({
|
||||||
@@ -57,10 +61,13 @@ onMounted(async () => {
|
|||||||
keys.forEach(key => {
|
keys.forEach(key => {
|
||||||
routesAvailable[key] = allRoutes[key]
|
routesAvailable[key] = allRoutes[key]
|
||||||
})
|
})
|
||||||
|
|
||||||
availableRoutes.value = routesAvailable
|
availableRoutes.value = routesAvailable
|
||||||
|
|
||||||
resetPermissions()
|
resetPermissions()
|
||||||
|
|
||||||
|
// Apply query parameters if present
|
||||||
|
applyQueryParams()
|
||||||
})
|
})
|
||||||
|
|
||||||
function resetPermissions() {
|
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() {
|
async function deleteToken() {
|
||||||
await service.delete(tokenToDelete.value)
|
await service.delete(tokenToDelete.value)
|
||||||
showDeleteModal.value = false
|
showDeleteModal.value = false
|
||||||
@@ -306,15 +345,15 @@ function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
|
|||||||
<br>
|
<br>
|
||||||
</template>
|
</template>
|
||||||
<template
|
<template
|
||||||
v-for="(paths, route) in routes"
|
v-for="(paths, permission) in routes"
|
||||||
:key="group+'-'+route"
|
:key="group+'-'+permission"
|
||||||
>
|
>
|
||||||
<FancyCheckbox
|
<FancyCheckbox
|
||||||
v-model="newTokenPermissions[group][route]"
|
v-model="newTokenPermissions[group][permission]"
|
||||||
class="mis-4 mie-2 is-capitalized"
|
class="mis-4 mie-2 is-capitalized"
|
||||||
@update:modelValue="checked => toggleGroupPermissionsFromChild(group, checked)"
|
@update:modelValue="checked => toggleGroupPermissionsFromChild(group, checked)"
|
||||||
>
|
>
|
||||||
{{ formatPermissionTitle(route) }}
|
{{ formatPermissionTitle(permission) }}
|
||||||
</FancyCheckbox>
|
</FancyCheckbox>
|
||||||
<br>
|
<br>
|
||||||
</template>
|
</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