feat(16.3-02): tracer — API tokens card on My account with show-once create
Wire ApiTokensPanel after the profile form, add the two-step create dialog, phrase keys, smoke tracer, and rebuilt boardwalk dist. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -15,6 +15,14 @@ export type AdminProfile = Schemas['cabana.AdminProfile']
|
||||
export type AdminAvatar = Schemas['cabana.AdminAvatar']
|
||||
/** Body of PUT /account: the only keys My account may change. */
|
||||
export type AdminAccountRequest = Schemas['cabana.AdminAccountRequest']
|
||||
/** One personal API token row on My account (list and revoke). */
|
||||
export type AdminAPIToken = Schemas['cabana.AdminAPIToken']
|
||||
/** Create response: the list row plus the plaintext shown once. */
|
||||
export type AdminAPITokenCreated = Schemas['cabana.AdminAPITokenCreated']
|
||||
/** Body of POST /account/tokens. */
|
||||
export type AdminAPITokenRequest = Schemas['cabana.AdminAPITokenRequest']
|
||||
/** One scope offered by GET /account/tokens/scopes or stored on a token. */
|
||||
export type AdminAPITokenScope = Schemas['cabana.AdminAPITokenScope']
|
||||
export type NavigationEntry = Schemas['cabana.NavigationEntry']
|
||||
export type SettingsEntry = Schemas['cabana.SettingsEntry']
|
||||
export type SettingsResult = Schemas['cabana.SettingsResult']
|
||||
|
||||
614
admin/src/components/account/ApiTokenCreateDialog.vue
Normal file
614
admin/src/components/account/ApiTokenCreateDialog.vue
Normal file
@@ -0,0 +1,614 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
RadioGroupItem,
|
||||
RadioGroupRoot,
|
||||
} from 'reka-ui'
|
||||
import { Check, CircleAlert, Copy, Info, LoaderCircle, TriangleAlert, X } from '@lucide/vue'
|
||||
import { api } from '../../api/client'
|
||||
import type { AdminAPITokenScope } from '../../api/types'
|
||||
import { t } from '../../app/i18n'
|
||||
import { controlClass } from '../form/control'
|
||||
import { fieldErrors, focusField } from '../form/formState'
|
||||
import Button from '../ui/Button.vue'
|
||||
import { useConfirm } from '../ui/confirm'
|
||||
import { showToast } from '../../state/useToasts'
|
||||
|
||||
// Two-step create dialog (16.3 UI-SPEC §4-5): form, then show-once plaintext.
|
||||
// The plaintext lives only in a local ref and is cleared on close and unmount.
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
confirm: ReturnType<typeof useConfirm>
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:open': [open: boolean]
|
||||
created: []
|
||||
closed: []
|
||||
}>()
|
||||
|
||||
const ID_PREFIX = 'api-token'
|
||||
const EXPIRY_OPTIONS = [
|
||||
{ value: '30d', label: 'backend::lang.api_tokens.expires_30' },
|
||||
{ value: '90d', label: 'backend::lang.api_tokens.expires_90' },
|
||||
{ value: '365d', label: 'backend::lang.api_tokens.expires_365' },
|
||||
{ value: 'never', label: 'backend::lang.api_tokens.expires_never' },
|
||||
] as const
|
||||
|
||||
const step = ref<1 | 2>(1)
|
||||
const name = ref('')
|
||||
const expires = ref('90d')
|
||||
const checked = ref<Set<string>>(new Set())
|
||||
const scopes = ref<AdminAPITokenScope[]>([])
|
||||
const scopesLoading = ref(false)
|
||||
const scopesFailed = ref(false)
|
||||
const busy = ref(false)
|
||||
const errors = ref<Record<string, string[]>>({})
|
||||
const plaintext = ref('')
|
||||
const copied = ref(false)
|
||||
const copyFailed = ref(false)
|
||||
const copyFlash = ref(false)
|
||||
const announce = ref('')
|
||||
let copyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const nameInput = ref<HTMLInputElement | null>(null)
|
||||
const plaintextInput = ref<HTMLInputElement | null>(null)
|
||||
const copyButton = ref<HTMLElement | null>(null)
|
||||
|
||||
const starChecked = computed(() => checked.value.has('*'))
|
||||
const canSubmit = computed(() => !scopesLoading.value && !scopesFailed.value && scopes.value.length > 0 && !busy.value)
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
|
||||
function localDatePlus(days: number): string {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + days)
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
const expiryPreview = computed(() => {
|
||||
if (expires.value === 'never') {
|
||||
return t('backend::lang.api_tokens.expires_never_note')
|
||||
}
|
||||
const days = expires.value === '30d' ? 30 : expires.value === '365d' ? 365 : 90
|
||||
return t('backend::lang.api_tokens.expires_on', { date: localDatePlus(days) })
|
||||
})
|
||||
|
||||
function clearPlaintext(): void {
|
||||
plaintext.value = ''
|
||||
copied.value = false
|
||||
copyFailed.value = false
|
||||
copyFlash.value = false
|
||||
announce.value = ''
|
||||
if (copyTimer !== null) {
|
||||
clearTimeout(copyTimer)
|
||||
copyTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm(): void {
|
||||
step.value = 1
|
||||
name.value = ''
|
||||
expires.value = '90d'
|
||||
checked.value = new Set()
|
||||
errors.value = {}
|
||||
busy.value = false
|
||||
scopesFailed.value = false
|
||||
clearPlaintext()
|
||||
}
|
||||
|
||||
function setOpen(open: boolean): void {
|
||||
emit('update:open', open)
|
||||
}
|
||||
|
||||
async function loadScopes(): Promise<void> {
|
||||
scopesLoading.value = true
|
||||
scopesFailed.value = false
|
||||
scopes.value = []
|
||||
try {
|
||||
const result = await api.GET('/account/tokens/scopes')
|
||||
if (result.data) {
|
||||
const list = result.data.data ?? []
|
||||
scopes.value = list
|
||||
scopesFailed.value = list.length === 0
|
||||
return
|
||||
}
|
||||
scopesFailed.value = true
|
||||
} catch {
|
||||
scopesFailed.value = true
|
||||
} finally {
|
||||
scopesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
resetForm()
|
||||
void loadScopes()
|
||||
} else {
|
||||
clearPlaintext()
|
||||
step.value = 1
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function toggleScope(scopeName: string, on: boolean): void {
|
||||
const next = new Set(checked.value)
|
||||
if (scopeName === '*') {
|
||||
if (on) {
|
||||
next.clear()
|
||||
next.add('*')
|
||||
} else {
|
||||
next.delete('*')
|
||||
}
|
||||
} else if (!starChecked.value) {
|
||||
if (on) {
|
||||
next.add(scopeName)
|
||||
} else {
|
||||
next.delete(scopeName)
|
||||
}
|
||||
}
|
||||
checked.value = next
|
||||
if (errors.value.scopes) {
|
||||
const nextErrors = { ...errors.value }
|
||||
delete nextErrors.scopes
|
||||
errors.value = nextErrors
|
||||
}
|
||||
}
|
||||
|
||||
function scopeDisabled(scopeName: string): boolean {
|
||||
return starChecked.value && scopeName !== '*'
|
||||
}
|
||||
|
||||
function selectedScopes(): string[] {
|
||||
if (starChecked.value) {
|
||||
return ['*']
|
||||
}
|
||||
return scopes.value.map((scope) => scope.name).filter((scopeName) => checked.value.has(scopeName))
|
||||
}
|
||||
|
||||
function nameDescribedBy(): string | undefined {
|
||||
const ids = [
|
||||
`${ID_PREFIX}-name-comment`,
|
||||
errors.value.name ? `${ID_PREFIX}-name-error` : '',
|
||||
].filter(Boolean)
|
||||
return ids.length ? ids.join(' ') : undefined
|
||||
}
|
||||
|
||||
async function focusFirstInvalid(): Promise<void> {
|
||||
await nextTick()
|
||||
if (errors.value.name) {
|
||||
focusField(ID_PREFIX, 'name')
|
||||
return
|
||||
}
|
||||
if (errors.value.scopes) {
|
||||
const first = scopes.value.find((scope) => !scopeDisabled(scope.name))
|
||||
const el = first
|
||||
? document.querySelector<HTMLInputElement>(`[data-scope="${CSS.escape(first.name)}"] input[type="checkbox"]`)
|
||||
: null
|
||||
el?.focus()
|
||||
return
|
||||
}
|
||||
if (errors.value.expires) {
|
||||
document.querySelector<HTMLElement>('[data-expiry-segment]')?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
if (busy.value || !canSubmit.value || step.value !== 1) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
errors.value = {}
|
||||
try {
|
||||
const result = await api.POST('/account/tokens', {
|
||||
body: { name: name.value, expires: expires.value, scopes: selectedScopes() },
|
||||
})
|
||||
if (result.data) {
|
||||
plaintext.value = result.data.data.token
|
||||
step.value = 2
|
||||
emit('created')
|
||||
await nextTick()
|
||||
copyButton.value?.querySelector('button')?.focus() ?? copyButton.value?.focus()
|
||||
return
|
||||
}
|
||||
if (result.response.status === 422) {
|
||||
errors.value = fieldErrors(result.error?.error.details)
|
||||
await focusFirstInvalid()
|
||||
return
|
||||
}
|
||||
showToast(t('backend::lang.api_tokens.create_failed'), 'danger')
|
||||
} catch {
|
||||
showToast(t('backend::lang.api_tokens.create_failed'), 'danger')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function markCopied(): void {
|
||||
copied.value = true
|
||||
copyFailed.value = false
|
||||
copyFlash.value = true
|
||||
announce.value = t('backend::lang.api_tokens.copied_announce')
|
||||
if (copyTimer !== null) {
|
||||
clearTimeout(copyTimer)
|
||||
}
|
||||
copyTimer = setTimeout(() => {
|
||||
copyFlash.value = false
|
||||
copyTimer = null
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
async function onCopy(): Promise<void> {
|
||||
const value = plaintext.value
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
throw new Error('clipboard unavailable')
|
||||
}
|
||||
await navigator.clipboard.writeText(value)
|
||||
markCopied()
|
||||
} catch {
|
||||
copyFailed.value = true
|
||||
await nextTick()
|
||||
plaintextInput.value?.focus()
|
||||
plaintextInput.value?.select()
|
||||
}
|
||||
}
|
||||
|
||||
function onPlaintextCopy(): void {
|
||||
markCopied()
|
||||
}
|
||||
|
||||
function onPlaintextFocus(event: FocusEvent): void {
|
||||
;(event.target as HTMLInputElement).select()
|
||||
}
|
||||
|
||||
async function closeDialog(): Promise<void> {
|
||||
clearPlaintext()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
async function requestClose(): Promise<void> {
|
||||
if (busy.value) {
|
||||
return
|
||||
}
|
||||
if (step.value === 2 && plaintext.value && !copied.value) {
|
||||
const ok = await props.confirm.ask({
|
||||
message: t('backend::lang.api_tokens.close_uncopied_confirm'),
|
||||
confirmLabel: t('backend::lang.api_tokens.close_uncopied_submit'),
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) {
|
||||
return
|
||||
}
|
||||
}
|
||||
await closeDialog()
|
||||
}
|
||||
|
||||
function onOpenChange(open: boolean): void {
|
||||
if (!open) {
|
||||
void requestClose()
|
||||
return
|
||||
}
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
function onOpenAutoFocus(event: Event): void {
|
||||
event.preventDefault()
|
||||
if (step.value === 2) {
|
||||
copyButton.value?.querySelector('button')?.focus() ?? copyButton.value?.focus()
|
||||
return
|
||||
}
|
||||
nameInput.value?.focus()
|
||||
}
|
||||
|
||||
function onCloseAutoFocus(event: Event): void {
|
||||
event.preventDefault()
|
||||
emit('closed')
|
||||
}
|
||||
|
||||
function onPointerOutside(event: Event): void {
|
||||
if (step.value === 2 || busy.value) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
function onEscapeKey(event: Event): void {
|
||||
if (busy.value) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (step.value === 2) {
|
||||
event.preventDefault()
|
||||
void requestClose()
|
||||
}
|
||||
}
|
||||
|
||||
function onHide(): void {
|
||||
void closeDialog()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPlaintext()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot :open="open" @update:open="onOpenChange">
|
||||
<DialogPortal>
|
||||
<DialogOverlay data-dialog-overlay class="fixed inset-0 z-40 bg-overlay" />
|
||||
<DialogContent
|
||||
data-token-dialog
|
||||
data-dialog-content
|
||||
aria-modal="true"
|
||||
class="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100vh-32px)] w-[calc(100%-32px)] max-w-[560px] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-modal bg-surface p-6 text-text shadow-pop"
|
||||
@open-auto-focus="onOpenAutoFocus"
|
||||
@close-auto-focus="onCloseAutoFocus"
|
||||
@pointer-down-outside="onPointerOutside"
|
||||
@interact-outside="onPointerOutside"
|
||||
@escape-key-down="onEscapeKey"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<DialogTitle class="text-[20px] font-bold tracking-[-0.01em]">{{
|
||||
step === 1
|
||||
? t('backend::lang.api_tokens.create_title')
|
||||
: t('backend::lang.api_tokens.created_title')
|
||||
}}</DialogTitle>
|
||||
<DialogDescription v-if="step === 1" class="text-muted">{{
|
||||
t('backend::lang.api_tokens.create_description')
|
||||
}}</DialogDescription>
|
||||
<DialogDescription
|
||||
v-else
|
||||
data-token-notice
|
||||
role="note"
|
||||
class="flex items-start gap-2 rounded-inner bg-sel px-4 py-3.5 text-text"
|
||||
>
|
||||
<TriangleAlert :size="16" class="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.api_tokens.created_notice') }}</span>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-action="close"
|
||||
:disabled="busy"
|
||||
:aria-label="t('backend::lang.form.close')"
|
||||
class="flex size-[34px] shrink-0 items-center justify-center rounded-tab bg-subtle text-muted hover:bg-hover hover:text-text disabled:cursor-not-allowed disabled:opacity-60"
|
||||
@click="requestClose"
|
||||
>
|
||||
<X :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form v-if="step === 1" novalidate class="flex min-h-0 flex-col gap-4" @submit.prevent="onSubmit">
|
||||
<fieldset :disabled="busy" class="flex min-h-0 flex-col gap-[18px] overflow-y-auto">
|
||||
<div class="flex min-w-0 flex-col gap-1.5" data-field="name">
|
||||
<label :for="`${ID_PREFIX}-name`" class="font-semibold">{{
|
||||
t('backend::lang.api_tokens.name')
|
||||
}}</label>
|
||||
<input
|
||||
:id="`${ID_PREFIX}-name`"
|
||||
ref="nameInput"
|
||||
v-model="name"
|
||||
name="name"
|
||||
type="text"
|
||||
maxlength="100"
|
||||
autocomplete="off"
|
||||
:placeholder="t('backend::lang.api_tokens.name_placeholder')"
|
||||
:aria-invalid="errors.name ? 'true' : undefined"
|
||||
:aria-describedby="nameDescribedBy()"
|
||||
:class="controlClass(!!errors.name)"
|
||||
class="h-input"
|
||||
/>
|
||||
<p :id="`${ID_PREFIX}-name-comment`" class="text-[13px] text-muted">
|
||||
{{ t('backend::lang.api_tokens.name_comment') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="errors.name"
|
||||
:id="`${ID_PREFIX}-name-error`"
|
||||
class="flex flex-col gap-0.5 text-[13px] text-danger"
|
||||
>
|
||||
<p v-for="(text, index) in errors.name" :key="index" class="flex items-center gap-1.5">
|
||||
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ text }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-1.5" data-field="expires">
|
||||
<span id="api-token-expires-label" class="font-semibold">{{
|
||||
t('backend::lang.api_tokens.expires')
|
||||
}}</span>
|
||||
<RadioGroupRoot
|
||||
v-model="expires"
|
||||
orientation="horizontal"
|
||||
aria-labelledby="api-token-expires-label"
|
||||
class="inline-flex flex-wrap gap-1 rounded-inner border border-border bg-subtle p-1"
|
||||
>
|
||||
<RadioGroupItem
|
||||
v-for="option in EXPIRY_OPTIONS"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
data-expiry-segment
|
||||
class="inline-flex h-pager items-center rounded-tab px-4 text-[13px] whitespace-nowrap text-muted transition-colors duration-150 ease-out hover:text-text data-[state=checked]:bg-surface data-[state=checked]:text-text data-[state=checked]:shadow-tab data-[state=checked]:text-[14px] data-[state=checked]:font-semibold"
|
||||
>
|
||||
{{ t(option.label) }}
|
||||
</RadioGroupItem>
|
||||
</RadioGroupRoot>
|
||||
<p class="text-[13px] text-muted tabular-nums">{{ expiryPreview }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-1.5" data-field="scopes">
|
||||
<span id="api-token-scopes-label" class="font-semibold">{{
|
||||
t('backend::lang.api_tokens.scopes')
|
||||
}}</span>
|
||||
<div
|
||||
v-if="scopesLoading"
|
||||
role="group"
|
||||
aria-labelledby="api-token-scopes-label"
|
||||
data-scope-picker
|
||||
class="overflow-hidden rounded-inner border border-border"
|
||||
>
|
||||
<div
|
||||
v-for="index in 3"
|
||||
:key="index"
|
||||
class="flex min-h-[56px] items-center gap-3 border-t border-border px-4 py-3 first:border-t-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="size-5 shrink-0 rounded-[6px] bg-skel" />
|
||||
<span class="h-3 w-40 rounded-[6px] bg-skel" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="scopesFailed"
|
||||
role="alert"
|
||||
class="flex items-start gap-2 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger"
|
||||
>
|
||||
<CircleAlert :size="16" class="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.api_tokens.scopes_failed') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
role="group"
|
||||
aria-labelledby="api-token-scopes-label"
|
||||
data-scope-picker
|
||||
:aria-invalid="errors.scopes ? 'true' : undefined"
|
||||
:class="errors.scopes ? 'border-danger' : 'border-border'"
|
||||
class="overflow-hidden rounded-inner border"
|
||||
>
|
||||
<label
|
||||
v-for="scope in scopes"
|
||||
:key="scope.name"
|
||||
:data-scope="scope.name"
|
||||
:class="scopeDisabled(scope.name) ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
|
||||
class="flex min-h-[56px] items-start gap-3 border-t border-border px-4 py-3 first:border-t-0"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mt-0.5 size-5 shrink-0 rounded-[6px] accent-primary"
|
||||
:checked="checked.has(scope.name)"
|
||||
:disabled="scopeDisabled(scope.name)"
|
||||
@change="toggleScope(scope.name, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span class="flex flex-wrap items-center gap-1">
|
||||
<span class="font-semibold">{{ scope.label }}</span>
|
||||
<span class="font-mono text-[13px] text-muted">{{ scope.name }}</span>
|
||||
<span
|
||||
v-if="scope.read_only"
|
||||
class="inline-flex h-6 items-center rounded-pill bg-subtle px-2.5 text-[13px] font-semibold whitespace-nowrap text-muted"
|
||||
>{{ t('backend::lang.api_tokens.read_only') }}</span
|
||||
>
|
||||
</span>
|
||||
<span class="text-[13px] text-muted [overflow-wrap:anywhere]">{{ scope.description }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p
|
||||
v-if="starChecked"
|
||||
class="flex items-center gap-2 text-[13px] text-muted"
|
||||
>
|
||||
<Info :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.api_tokens.scope_all_includes') }}</span>
|
||||
</p>
|
||||
<div
|
||||
v-if="errors.scopes"
|
||||
:id="`${ID_PREFIX}-scopes-error`"
|
||||
class="flex flex-col gap-0.5 text-[13px] text-danger"
|
||||
>
|
||||
<p v-for="(text, index) in errors.scopes" :key="index" class="flex items-center gap-1.5">
|
||||
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ text }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="flex gap-2.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
data-action="create-token-cancel"
|
||||
:disabled="busy"
|
||||
@click="requestClose"
|
||||
>
|
||||
{{ t('backend::lang.api_tokens.create_cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
class="flex-1"
|
||||
data-action="create-token-submit"
|
||||
:disabled="!canSubmit"
|
||||
>
|
||||
<LoaderCircle v-if="busy" :size="16" class="animate-spin" aria-hidden="true" />
|
||||
{{ busy ? t('backend::lang.api_tokens.creating') : t('backend::lang.api_tokens.create_submit') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-else class="flex min-h-0 flex-col gap-4">
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<label :for="`${ID_PREFIX}-plaintext`" class="sr-only">{{
|
||||
t('backend::lang.api_tokens.token_label')
|
||||
}}</label>
|
||||
<input
|
||||
:id="`${ID_PREFIX}-plaintext`"
|
||||
ref="plaintextInput"
|
||||
:value="plaintext"
|
||||
type="text"
|
||||
readonly
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-token-plaintext
|
||||
:class="controlClass(false)"
|
||||
class="h-input bg-subtle font-mono text-[14px]"
|
||||
@focus="onPlaintextFocus"
|
||||
@click="onPlaintextFocus"
|
||||
@copy="onPlaintextCopy"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span ref="copyButton">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:icon="copyFlash ? Check : Copy"
|
||||
data-action="copy-token"
|
||||
@click="onCopy"
|
||||
>
|
||||
{{ copyFlash ? t('backend::lang.api_tokens.copied') : t('backend::lang.api_tokens.copy') }}
|
||||
</Button>
|
||||
</span>
|
||||
<p
|
||||
v-if="copyFailed"
|
||||
role="alert"
|
||||
class="flex items-center gap-1.5 text-[13px] text-danger"
|
||||
>
|
||||
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.api_tokens.copy_failed') }}</span>
|
||||
</p>
|
||||
<span class="sr-only" aria-live="polite">{{ announce }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<Button variant="primary" data-action="hide-token" @click="onHide">
|
||||
{{ t('backend::lang.api_tokens.done') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
312
admin/src/components/account/ApiTokensPanel.vue
Normal file
312
admin/src/components/account/ApiTokensPanel.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { Ban, CircleAlert, KeyRound, Plus } from '@lucide/vue'
|
||||
import { api } from '../../api/client'
|
||||
import type { AdminAPIToken } from '../../api/types'
|
||||
import { t } from '../../app/i18n'
|
||||
import Button from '../ui/Button.vue'
|
||||
import { useConfirm } from '../ui/confirm'
|
||||
import { showToast } from '../../state/useToasts'
|
||||
import ApiTokenCreateDialog from './ApiTokenCreateDialog.vue'
|
||||
|
||||
// My account API tokens card (16.3 UI-SPEC): list, create dialog, revoke.
|
||||
// Shares AccountView's useConfirm() so ConfirmDialog stays at z-[90].
|
||||
const props = defineProps<{
|
||||
confirm: ReturnType<typeof useConfirm>
|
||||
}>()
|
||||
|
||||
const tokens = ref<AdminAPIToken[]>([])
|
||||
const loading = ref(true)
|
||||
const firstLoad = ref(true)
|
||||
const loadFailed = ref(false)
|
||||
const showRevoked = ref(false)
|
||||
const createOpen = ref(false)
|
||||
const createButton = ref<HTMLElement | null>(null)
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
|
||||
function present(value: string | null | undefined): value is string {
|
||||
return typeof value === 'string' && value !== ''
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD in local time (list date cells). */
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD HH:mm in local time (list datetime cells). */
|
||||
function formatDateTime(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
return `${formatDate(value)} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
function metaLine(token: AdminAPIToken): string {
|
||||
const parts = [t('backend::lang.api_tokens.created_at', { date: formatDate(token.created_at) })]
|
||||
if (present(token.last_used_at)) {
|
||||
parts.push(t('backend::lang.api_tokens.last_used', { date: formatDateTime(token.last_used_at) }))
|
||||
} else {
|
||||
parts.push(t('backend::lang.api_tokens.never_used'))
|
||||
}
|
||||
if (token.expired && present(token.expires_at)) {
|
||||
parts.push(t('backend::lang.api_tokens.expired_at', { date: formatDate(token.expires_at) }))
|
||||
} else if (present(token.expires_at)) {
|
||||
parts.push(t('backend::lang.api_tokens.expires_at', { date: formatDate(token.expires_at) }))
|
||||
} else {
|
||||
parts.push(t('backend::lang.api_tokens.no_expiry'))
|
||||
}
|
||||
if (present(token.revoked_at)) {
|
||||
parts.push(t('backend::lang.api_tokens.revoked_at', { date: formatDateTime(token.revoked_at) }))
|
||||
}
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function isRevoked(token: AdminAPIToken): boolean {
|
||||
return present(token.revoked_at)
|
||||
}
|
||||
|
||||
async function loadTokens(): Promise<void> {
|
||||
loading.value = true
|
||||
loadFailed.value = false
|
||||
try {
|
||||
const result = await api.GET('/account/tokens', {
|
||||
params: { query: showRevoked.value ? { revoked: '1' } : {} },
|
||||
})
|
||||
if (result.data) {
|
||||
tokens.value = result.data.data ?? []
|
||||
return
|
||||
}
|
||||
loadFailed.value = true
|
||||
tokens.value = []
|
||||
} catch {
|
||||
loadFailed.value = true
|
||||
tokens.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
firstLoad.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onShowRevokedChange(event: Event): void {
|
||||
showRevoked.value = (event.target as HTMLInputElement).checked
|
||||
void loadTokens()
|
||||
}
|
||||
|
||||
function openCreate(): void {
|
||||
createOpen.value = true
|
||||
}
|
||||
|
||||
async function onDialogClosed(): Promise<void> {
|
||||
await nextTick()
|
||||
createButton.value?.querySelector('button')?.focus() ?? createButton.value?.focus()
|
||||
}
|
||||
|
||||
async function onCreated(): Promise<void> {
|
||||
await loadTokens()
|
||||
}
|
||||
|
||||
async function onRevoke(token: AdminAPIToken): Promise<void> {
|
||||
const ok = await props.confirm.ask(
|
||||
{
|
||||
message: t('backend::lang.api_tokens.revoke_confirm', { name: token.name }),
|
||||
confirmLabel: t('backend::lang.api_tokens.revoke_submit'),
|
||||
danger: true,
|
||||
},
|
||||
async () => {
|
||||
const result = await api.DELETE('/account/tokens/{id}', {
|
||||
params: { path: { id: token.id } },
|
||||
})
|
||||
if (result.data) {
|
||||
await loadTokens()
|
||||
showToast(t('backend::lang.api_tokens.revoked'))
|
||||
await nextTick()
|
||||
createButton.value?.querySelector('button')?.focus() ?? createButton.value?.focus()
|
||||
return
|
||||
}
|
||||
if (result.response.status === 404) {
|
||||
showToast(t('backend::lang.api_tokens.gone'), 'danger')
|
||||
await loadTokens()
|
||||
return
|
||||
}
|
||||
showToast(t('backend::lang.api_tokens.revoke_failed'), 'danger')
|
||||
},
|
||||
)
|
||||
void ok
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadTokens()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-api-tokens
|
||||
class="flex flex-col gap-4 rounded-card border border-border bg-surface p-7 shadow-card"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<h2 class="text-[18px] font-bold">{{ t('backend::lang.api_tokens.section') }}</h2>
|
||||
<p class="max-w-[640px] text-[13px] text-muted">{{ t('backend::lang.api_tokens.section_comment') }}</p>
|
||||
</div>
|
||||
<span ref="createButton">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:icon="Plus"
|
||||
data-action="create-token"
|
||||
:disabled="firstLoad && loading"
|
||||
@click="openCreate"
|
||||
>
|
||||
{{ t('backend::lang.api_tokens.create') }}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label
|
||||
v-if="!loadFailed && !firstLoad"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-show-revoked
|
||||
class="size-5 shrink-0 rounded-[6px] accent-primary"
|
||||
:checked="showRevoked"
|
||||
@change="onShowRevokedChange"
|
||||
/>
|
||||
<span>{{ t('backend::lang.api_tokens.show_revoked') }}</span>
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
role="list"
|
||||
data-token-list
|
||||
aria-busy="true"
|
||||
class="flex flex-col overflow-hidden rounded-inner border border-border"
|
||||
>
|
||||
<div
|
||||
v-for="index in 3"
|
||||
:key="index"
|
||||
role="listitem"
|
||||
class="flex flex-col gap-2 border-t border-border px-4 py-3.5 first:border-t-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="h-3 w-40 rounded-[6px] bg-skel" />
|
||||
<span class="h-3 w-64 rounded-[6px] bg-skel" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="loadFailed"
|
||||
role="alert"
|
||||
data-tokens-error
|
||||
class="flex items-start gap-2 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger"
|
||||
>
|
||||
<CircleAlert :size="16" class="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.api_tokens.load_failed') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="tokens.length === 0"
|
||||
data-tokens-empty
|
||||
class="flex flex-col items-center gap-2 rounded-inner border border-dashed border-border-strong px-6 py-8 text-center"
|
||||
>
|
||||
<span class="flex size-10 items-center justify-center rounded-full bg-subtle text-muted">
|
||||
<KeyRound :size="20" aria-hidden="true" />
|
||||
</span>
|
||||
<p class="font-semibold">{{ t('backend::lang.api_tokens.empty_title') }}</p>
|
||||
<p class="max-w-[420px] text-[13px] text-muted">{{ t('backend::lang.api_tokens.empty_body') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
role="list"
|
||||
data-token-list
|
||||
class="flex flex-col overflow-hidden rounded-inner border border-border"
|
||||
>
|
||||
<div
|
||||
v-for="token in tokens"
|
||||
:key="token.id"
|
||||
role="listitem"
|
||||
:data-token-row="token.id"
|
||||
class="flex items-start justify-between gap-4 border-t border-border px-4 py-3.5 first:border-t-0 max-sm:flex-col"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
:class="isRevoked(token) ? 'text-muted' : 'text-text'"
|
||||
class="font-semibold [overflow-wrap:anywhere]"
|
||||
>{{ token.name }}</span
|
||||
>
|
||||
<span
|
||||
class="font-mono text-[13px] text-muted"
|
||||
:aria-label="t('backend::lang.api_tokens.hint', { last4: token.hint })"
|
||||
>g15_…{{ token.hint }}</span
|
||||
>
|
||||
<span
|
||||
v-if="token.expired && !isRevoked(token)"
|
||||
class="inline-flex h-6 items-center rounded-pill bg-danger-soft px-2.5 text-[13px] font-semibold whitespace-nowrap text-danger"
|
||||
>{{ t('backend::lang.api_tokens.state_expired') }}</span
|
||||
>
|
||||
<span
|
||||
v-if="isRevoked(token)"
|
||||
class="inline-flex h-6 items-center rounded-pill border border-border-strong px-2.5 text-[13px] font-semibold whitespace-nowrap text-muted"
|
||||
>{{ t('backend::lang.api_tokens.state_revoked') }}</span
|
||||
>
|
||||
</div>
|
||||
<div data-token-scopes class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="scope in token.scopes"
|
||||
:key="scope.name"
|
||||
:title="scope.known ? scope.name : t('backend::lang.api_tokens.unknown_scope_hint')"
|
||||
:class="
|
||||
scope.known
|
||||
? 'bg-subtle text-text'
|
||||
: 'border border-dashed border-border-strong font-mono text-muted'
|
||||
"
|
||||
class="inline-flex h-6 items-center rounded-pill px-2.5 text-[13px] font-semibold whitespace-nowrap"
|
||||
>
|
||||
{{
|
||||
scope.known
|
||||
? scope.name === '*'
|
||||
? t('backend::lang.api_tokens.scope_all')
|
||||
: scope.label
|
||||
: t('backend::lang.api_tokens.unknown_scope', { name: scope.name })
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p :class="isRevoked(token) ? 'text-muted' : 'text-muted'" class="text-[13px] tabular-nums">
|
||||
{{ metaLine(token) }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
v-if="!isRevoked(token)"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
:icon="Ban"
|
||||
data-action="revoke-token"
|
||||
class="max-sm:self-start"
|
||||
:aria-label="t('backend::lang.api_tokens.revoke_named', { name: token.name })"
|
||||
@click="onRevoke(token)"
|
||||
>
|
||||
{{ t('backend::lang.api_tokens.revoke') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ApiTokenCreateDialog
|
||||
v-model:open="createOpen"
|
||||
:confirm="confirm"
|
||||
@created="onCreated"
|
||||
@closed="onDialogClosed"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,6 +7,7 @@ import { t } from '../app/i18n'
|
||||
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
|
||||
import { controlClass } from '../components/form/control'
|
||||
import { fieldErrors, focusField } from '../components/form/formState'
|
||||
import ApiTokensPanel from '../components/account/ApiTokensPanel.vue'
|
||||
import Button from '../components/ui/Button.vue'
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
|
||||
import { useConfirm } from '../components/ui/confirm'
|
||||
@@ -408,6 +409,8 @@ async function onSave(): Promise<void> {
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<ApiTokensPanel :confirm="confirm" />
|
||||
|
||||
<ConfirmDialog
|
||||
:open="confirm.request.value !== null"
|
||||
:message="confirm.request.value?.message ?? ''"
|
||||
|
||||
Reference in New Issue
Block a user