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:
2026-10-11 02:02:44 +02:00
parent 3f9d3b4f11
commit 5a6a190e7f
14 changed files with 1407 additions and 24 deletions

View File

@@ -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']

View 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>

View 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>

View File

@@ -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 ?? ''"