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 ?? ''"
|
||||
|
||||
131
admin/tests/account/ApiTokensPanel.test.ts
Normal file
131
admin/tests/account/ApiTokensPanel.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
|
||||
import { API, mountApp, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
const TOKENS = `${API}/account/tokens`
|
||||
const SCOPES = `${API}/account/tokens/scopes`
|
||||
|
||||
const envelope = (data: unknown, status = 200) => ({ status, body: { data, meta: {} } })
|
||||
|
||||
const existingToken = {
|
||||
id: 11,
|
||||
name: 'Blog agent',
|
||||
hint: 'a1b2',
|
||||
scopes: [
|
||||
{
|
||||
name: 'admin:read',
|
||||
label: 'Read-only, whole admin',
|
||||
description: 'View everything',
|
||||
read_only: true,
|
||||
known: true,
|
||||
},
|
||||
],
|
||||
created_at: '2026-01-10T12:00:00Z',
|
||||
last_used_at: '',
|
||||
expires_at: '2026-04-10T12:00:00Z',
|
||||
revoked_at: '',
|
||||
expired: false,
|
||||
}
|
||||
|
||||
const createdToken = {
|
||||
...existingToken,
|
||||
id: 12,
|
||||
name: 'CI runner',
|
||||
hint: 'c3d4',
|
||||
token: 'g15_0123456789abcdef0123456789abcdef01234567',
|
||||
}
|
||||
|
||||
const scopes = [
|
||||
{
|
||||
name: '*',
|
||||
label: 'Full access',
|
||||
description: 'Everything',
|
||||
read_only: false,
|
||||
known: true,
|
||||
},
|
||||
{
|
||||
name: 'admin:read',
|
||||
label: 'Read-only, whole admin',
|
||||
description: 'View everything',
|
||||
read_only: true,
|
||||
known: true,
|
||||
},
|
||||
]
|
||||
|
||||
function tokenRoutes(overrides: Record<string, Route> = {}, list: unknown[] = [existingToken]): Record<string, Route> {
|
||||
let listed = list
|
||||
return {
|
||||
[`GET ${TOKENS}`]: () => envelope(listed),
|
||||
[`GET ${SCOPES}`]: envelope(scopes),
|
||||
[`POST ${TOKENS}`]: () => {
|
||||
listed = [createdToken, ...listed]
|
||||
return envelope(createdToken, 201)
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function openAccount(routes: Record<string, Route> = {}, list: unknown[] = [existingToken]) {
|
||||
return mountApp('/account', tokenRoutes(routes, list), { attach: true })
|
||||
}
|
||||
|
||||
const panel = () => document.body.querySelector<HTMLElement>('[data-api-tokens]')
|
||||
const dialog = () => document.body.querySelector<HTMLElement>('[data-token-dialog]')
|
||||
|
||||
async function click(selector: string, root: ParentNode = document.body): Promise<void> {
|
||||
const el = root.querySelector<HTMLElement>(selector)
|
||||
el?.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('ApiTokensPanel', () => {
|
||||
it('tracer: list, create, show once, hide', async () => {
|
||||
const { calls } = await openAccount()
|
||||
await flushPromises()
|
||||
expect(panel()).toBeTruthy()
|
||||
expect(panel()!.querySelector('[data-token-row="11"]')).toBeTruthy()
|
||||
|
||||
await click('[data-action="create-token"]', panel()!)
|
||||
expect(dialog()).toBeTruthy()
|
||||
|
||||
const nameInput = dialog()!.querySelector<HTMLInputElement>('#api-token-name')!
|
||||
await nameInput.focus()
|
||||
nameInput.value = 'CI runner'
|
||||
nameInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
const scope = dialog()!.querySelector<HTMLInputElement>('[data-scope="admin:read"] input')!
|
||||
scope.checked = true
|
||||
scope.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
const getsBeforeCreate = requestsTo(calls, 'GET', TOKENS).length
|
||||
await click('[data-action="create-token-submit"]', dialog()!)
|
||||
const [post] = requestsTo(calls, 'POST', TOKENS)
|
||||
expect(post).toBeTruthy()
|
||||
expect(await post!.clone().json()).toEqual({
|
||||
name: 'CI runner',
|
||||
expires: '90d',
|
||||
scopes: ['admin:read'],
|
||||
})
|
||||
|
||||
const plaintext = dialog()!.querySelector<HTMLInputElement>('[data-token-plaintext]')!
|
||||
expect(plaintext.value).toBe(createdToken.token)
|
||||
expect(requestsTo(calls, 'GET', TOKENS).length).toBeGreaterThan(getsBeforeCreate)
|
||||
|
||||
await click('[data-action="hide-token"]', dialog()!)
|
||||
expect(dialog()).toBeNull()
|
||||
expect(document.body.textContent).not.toContain(createdToken.token)
|
||||
})
|
||||
})
|
||||
197
admin/tests/fixtures/lang.json
vendored
197
admin/tests/fixtures/lang.json
vendored
@@ -1,5 +1,200 @@
|
||||
{
|
||||
"data": {
|
||||
"backend::lang.api_tokens.close_uncopied_confirm": {
|
||||
"other": "Zamknąć bez kopiowania? Tego tokenu nie będzie można już zobaczyć."
|
||||
},
|
||||
"backend::lang.api_tokens.close_uncopied_submit": {
|
||||
"other": "Zamknij i ukryj token"
|
||||
},
|
||||
"backend::lang.api_tokens.copied": {
|
||||
"other": "Skopiowano"
|
||||
},
|
||||
"backend::lang.api_tokens.copied_announce": {
|
||||
"other": "Skopiowano token do schowka."
|
||||
},
|
||||
"backend::lang.api_tokens.copy": {
|
||||
"other": "Kopiuj token"
|
||||
},
|
||||
"backend::lang.api_tokens.copy_failed": {
|
||||
"other": "Nie udało się skopiować automatycznie. Token jest zaznaczony: naciśnij Ctrl+C (Cmd+C na Macu), aby go skopiować."
|
||||
},
|
||||
"backend::lang.api_tokens.create": {
|
||||
"other": "Utwórz token"
|
||||
},
|
||||
"backend::lang.api_tokens.create_cancel": {
|
||||
"other": "Odrzuć token"
|
||||
},
|
||||
"backend::lang.api_tokens.create_description": {
|
||||
"other": "Wybierz nazwę, termin ważności i zakresy tokenu. Token nigdy nie ma szerszego dostępu niż Twoje konto."
|
||||
},
|
||||
"backend::lang.api_tokens.create_failed": {
|
||||
"other": "Nie udało się utworzyć tokenu. Spróbuj ponownie."
|
||||
},
|
||||
"backend::lang.api_tokens.create_submit": {
|
||||
"other": "Utwórz token"
|
||||
},
|
||||
"backend::lang.api_tokens.create_title": {
|
||||
"other": "Nowy token API"
|
||||
},
|
||||
"backend::lang.api_tokens.created_at": {
|
||||
"other": "Utworzono :date"
|
||||
},
|
||||
"backend::lang.api_tokens.created_notice": {
|
||||
"other": "Token jest widoczny tylko teraz. Skopiuj go i przechowuj w bezpiecznym miejscu. Jeśli go zgubisz, unieważnij go i utwórz nowy."
|
||||
},
|
||||
"backend::lang.api_tokens.created_title": {
|
||||
"other": "Skopiuj nowy token"
|
||||
},
|
||||
"backend::lang.api_tokens.creating": {
|
||||
"other": "Tworzenie…"
|
||||
},
|
||||
"backend::lang.api_tokens.done": {
|
||||
"other": "Gotowe, ukryj token"
|
||||
},
|
||||
"backend::lang.api_tokens.empty_body": {
|
||||
"other": "Utwórz token, aby agent lub integracja mogły wywoływać API panelu w Twoim imieniu."
|
||||
},
|
||||
"backend::lang.api_tokens.empty_title": {
|
||||
"other": "Nie masz jeszcze tokenów API"
|
||||
},
|
||||
"backend::lang.api_tokens.expired_at": {
|
||||
"other": "Wygasł :date"
|
||||
},
|
||||
"backend::lang.api_tokens.expires": {
|
||||
"other": "Ważność"
|
||||
},
|
||||
"backend::lang.api_tokens.expires_30": {
|
||||
"other": "30 dni"
|
||||
},
|
||||
"backend::lang.api_tokens.expires_365": {
|
||||
"other": "365 dni"
|
||||
},
|
||||
"backend::lang.api_tokens.expires_90": {
|
||||
"other": "90 dni"
|
||||
},
|
||||
"backend::lang.api_tokens.expires_at": {
|
||||
"other": "Wygasa :date"
|
||||
},
|
||||
"backend::lang.api_tokens.expires_invalid": {
|
||||
"other": "Wybierz poprawny termin ważności."
|
||||
},
|
||||
"backend::lang.api_tokens.expires_never": {
|
||||
"other": "Bez terminu"
|
||||
},
|
||||
"backend::lang.api_tokens.expires_never_note": {
|
||||
"other": "Ten token nie wygasa. Unieważnij go, gdy przestanie być potrzebny."
|
||||
},
|
||||
"backend::lang.api_tokens.expires_on": {
|
||||
"other": "Wygasa :date."
|
||||
},
|
||||
"backend::lang.api_tokens.gone": {
|
||||
"other": "Ten token już nie istnieje. Lista została odświeżona."
|
||||
},
|
||||
"backend::lang.api_tokens.hint": {
|
||||
"other": "Token kończący się na :last4"
|
||||
},
|
||||
"backend::lang.api_tokens.last_used": {
|
||||
"other": "Ostatnio użyto :date"
|
||||
},
|
||||
"backend::lang.api_tokens.load_failed": {
|
||||
"other": "Nie udało się wczytać tokenów. Odśwież stronę, aby spróbować ponownie."
|
||||
},
|
||||
"backend::lang.api_tokens.name": {
|
||||
"other": "Nazwa"
|
||||
},
|
||||
"backend::lang.api_tokens.name_comment": {
|
||||
"other": "Pomoże rozpoznać token na liście."
|
||||
},
|
||||
"backend::lang.api_tokens.name_placeholder": {
|
||||
"other": "np. Agent bloga"
|
||||
},
|
||||
"backend::lang.api_tokens.name_required": {
|
||||
"other": "Podaj nazwę tokenu."
|
||||
},
|
||||
"backend::lang.api_tokens.name_too_long": {
|
||||
"other": "Nazwa może mieć najwyżej 100 znaków."
|
||||
},
|
||||
"backend::lang.api_tokens.never_used": {
|
||||
"other": "Nieużywany"
|
||||
},
|
||||
"backend::lang.api_tokens.no_expiry": {
|
||||
"other": "Bez terminu ważności"
|
||||
},
|
||||
"backend::lang.api_tokens.read_only": {
|
||||
"other": "Tylko odczyt"
|
||||
},
|
||||
"backend::lang.api_tokens.revoke": {
|
||||
"other": "Unieważnij token"
|
||||
},
|
||||
"backend::lang.api_tokens.revoke_confirm": {
|
||||
"other": "Unieważnić token „:name”? Aplikacje i agenci, którzy go używają, natychmiast stracą dostęp. Tej operacji nie można cofnąć."
|
||||
},
|
||||
"backend::lang.api_tokens.revoke_failed": {
|
||||
"other": "Nie udało się unieważnić tokenu. Spróbuj ponownie."
|
||||
},
|
||||
"backend::lang.api_tokens.revoke_named": {
|
||||
"other": "Unieważnij token: :name"
|
||||
},
|
||||
"backend::lang.api_tokens.revoke_submit": {
|
||||
"other": "Unieważnij token"
|
||||
},
|
||||
"backend::lang.api_tokens.revoked": {
|
||||
"other": "Unieważniono token"
|
||||
},
|
||||
"backend::lang.api_tokens.revoked_at": {
|
||||
"other": "Unieważniono :date"
|
||||
},
|
||||
"backend::lang.api_tokens.scope_admin_read": {
|
||||
"other": "Tylko odczyt, cały panel"
|
||||
},
|
||||
"backend::lang.api_tokens.scope_admin_read_description": {
|
||||
"other": "Podgląd wszystkiego, co widzisz w panelu, bez wprowadzania zmian."
|
||||
},
|
||||
"backend::lang.api_tokens.scope_all": {
|
||||
"other": "Pełny dostęp"
|
||||
},
|
||||
"backend::lang.api_tokens.scope_all_description": {
|
||||
"other": "Wszystko, co możesz zrobić w panelu. Ustawienia konta i tokeny nadal wymagają zalogowania."
|
||||
},
|
||||
"backend::lang.api_tokens.scope_all_includes": {
|
||||
"other": "Pełny dostęp obejmuje wszystkie pozostałe zakresy."
|
||||
},
|
||||
"backend::lang.api_tokens.scope_unavailable": {
|
||||
"other": "Zakres :name nie jest dla Ciebie dostępny."
|
||||
},
|
||||
"backend::lang.api_tokens.scopes": {
|
||||
"other": "Zakresy"
|
||||
},
|
||||
"backend::lang.api_tokens.scopes_failed": {
|
||||
"other": "Nie udało się wczytać zakresów. Zamknij to okno i spróbuj ponownie."
|
||||
},
|
||||
"backend::lang.api_tokens.scopes_required": {
|
||||
"other": "Wybierz co najmniej jeden zakres."
|
||||
},
|
||||
"backend::lang.api_tokens.section": {
|
||||
"other": "Tokeny API"
|
||||
},
|
||||
"backend::lang.api_tokens.section_comment": {
|
||||
"other": "Tokeny pozwalają agentom i integracjom wywoływać API panelu w Twoim imieniu, w granicach wybranych zakresów. Traktuj token jak hasło."
|
||||
},
|
||||
"backend::lang.api_tokens.show_revoked": {
|
||||
"other": "Pokaż unieważnione tokeny"
|
||||
},
|
||||
"backend::lang.api_tokens.state_expired": {
|
||||
"other": "Wygasły"
|
||||
},
|
||||
"backend::lang.api_tokens.state_revoked": {
|
||||
"other": "Unieważniony"
|
||||
},
|
||||
"backend::lang.api_tokens.token_label": {
|
||||
"other": "Nowy token"
|
||||
},
|
||||
"backend::lang.api_tokens.unknown_scope": {
|
||||
"other": ":name (nieznany)"
|
||||
},
|
||||
"backend::lang.api_tokens.unknown_scope_hint": {
|
||||
"other": "Ten zakres nie jest już zarejestrowany i nic nie przyznaje."
|
||||
},
|
||||
"backend::lang.auth.invalid": {
|
||||
"other": "Nieprawidłowe dane logowania"
|
||||
},
|
||||
@@ -328,7 +523,7 @@
|
||||
"other": "Login"
|
||||
},
|
||||
"backend::lang.myaccount.menu_description": {
|
||||
"other": "Zmień imię, nazwisko, e-mail, podtytuł i hasło."
|
||||
"other": "Zmień imię, nazwisko, e-mail, podtytuł i hasło oraz zarządzaj tokenami API."
|
||||
},
|
||||
"backend::lang.myaccount.menu_label": {
|
||||
"other": "Moje konto"
|
||||
|
||||
@@ -25,7 +25,15 @@ const invalid = (details: Record<string, string[]>) => ({
|
||||
})
|
||||
|
||||
async function openAccount(user: Record<string, unknown> = signedIn, routes: Record<string, Route> = {}) {
|
||||
return mountApp('/account', { [`GET ${API}/auth/me`]: envelope(user), ...routes }, { attach: true })
|
||||
return mountApp(
|
||||
'/account',
|
||||
{
|
||||
[`GET ${API}/auth/me`]: envelope(user),
|
||||
[`GET ${API}/account/tokens`]: envelope([]),
|
||||
...routes,
|
||||
},
|
||||
{ attach: true },
|
||||
)
|
||||
}
|
||||
|
||||
function input(wrapper: VueWrapper, name: string) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
9
modules/boardwalk/dist/assets/index-DlYKDHwm.js
vendored
Normal file
9
modules/boardwalk/dist/assets/index-DlYKDHwm.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
modules/boardwalk/dist/assets/index-d9w3cFkl.css
vendored
Normal file
1
modules/boardwalk/dist/assets/index-d9w3cFkl.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
modules/boardwalk/dist/index.html
vendored
4
modules/boardwalk/dist/index.html
vendored
@@ -7,8 +7,8 @@
|
||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||
<link rel="icon" href="./favicon.ico" sizes="48x48" />
|
||||
<title>SummerCMS</title>
|
||||
<script type="module" crossorigin src="./assets/index-Bqh6wnN4.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DhIJo93I.css">
|
||||
<script type="module" crossorigin src="./assets/index-DlYKDHwm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-d9w3cFkl.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -232,18 +232,74 @@ permissioneditor:
|
||||
empty: No permissions are defined yet.
|
||||
other: Other
|
||||
api_tokens:
|
||||
section: API tokens
|
||||
section_comment: Tokens let agents and integrations call the admin API as you, limited to the scopes you choose. Treat a token like a password.
|
||||
create: Create token
|
||||
create_submit: Create token
|
||||
creating: Creating…
|
||||
done: Done, hide token
|
||||
empty_title: No API tokens yet
|
||||
empty_body: Create a token to let an agent or integration call the admin API as you.
|
||||
load_failed: The tokens could not be loaded. Refresh the page to try again.
|
||||
scopes_failed: The scopes could not be loaded. Close this window and try again.
|
||||
create_failed: The token could not be created. Please try again.
|
||||
revoke_failed: The token could not be revoked. Please try again.
|
||||
gone: This token no longer exists. The list has been refreshed.
|
||||
copy_failed: 'Could not copy automatically. The token is selected: press Ctrl+C (Cmd+C on a Mac) to copy it.'
|
||||
revoke: Revoke token
|
||||
revoke_named: 'Revoke token: :name'
|
||||
revoke_confirm: Revoke the token “:name”? Apps and agents using it lose access at once. This cannot be undone.
|
||||
revoke_submit: Revoke token
|
||||
revoked: Token revoked
|
||||
close_uncopied_confirm: Close without copying? You won't be able to see this token again.
|
||||
close_uncopied_submit: Close and hide token
|
||||
create_title: New API token
|
||||
create_description: Pick a name, an expiry and the scopes this token may use. It never gets more access than your own account.
|
||||
name: Name
|
||||
name_placeholder: e.g. Blog agent
|
||||
name_comment: Helps you recognise the token in the list.
|
||||
name_required: Enter a name for the token.
|
||||
name_too_long: The name may have at most 100 characters.
|
||||
expires: Expiration
|
||||
expires_30: 30 days
|
||||
expires_90: 90 days
|
||||
expires_365: 365 days
|
||||
expires_never: Never
|
||||
expires_on: Expires on :date.
|
||||
expires_never_note: This token never expires. Revoke it when you no longer need it.
|
||||
expires_invalid: Choose a valid expiry.
|
||||
scopes: Scopes
|
||||
scopes_required: Choose at least one scope.
|
||||
scope_all: Full access
|
||||
scope_all_description: Everything you can do in the admin. Your account settings and tokens still need a signed-in session.
|
||||
scope_admin_read: Read-only, whole admin
|
||||
scope_admin_read_description: View everything you can see in the admin, without making changes.
|
||||
name_required: Enter a name for the token.
|
||||
name_too_long: The name may have at most 100 characters.
|
||||
expires_invalid: Choose a valid expiry.
|
||||
scopes_required: Choose at least one scope.
|
||||
scope_unavailable: The scope :name is not available to you.
|
||||
read_only: Read-only
|
||||
scope_all_includes: Full access already includes every other scope.
|
||||
created_title: Copy your new token
|
||||
created_notice: This is the only time the token is shown. Copy it now and store it somewhere safe. If you lose it, revoke it and create a new one.
|
||||
token_label: New token
|
||||
copy: Copy token
|
||||
copied: Copied
|
||||
copied_announce: Token copied to the clipboard.
|
||||
create_cancel: Discard token
|
||||
show_revoked: Show revoked tokens
|
||||
created_at: Created :date
|
||||
last_used: Last used :date
|
||||
never_used: Never used
|
||||
expires_at: Expires :date
|
||||
expired_at: Expired :date
|
||||
no_expiry: No expiry
|
||||
revoked_at: Revoked :date
|
||||
state_expired: Expired
|
||||
state_revoked: Revoked
|
||||
unknown_scope: ':name (unknown)'
|
||||
unknown_scope_hint: This scope is no longer registered and grants nothing.
|
||||
hint: Token ending in :last4
|
||||
myaccount:
|
||||
menu_label: My account
|
||||
menu_description: Edit your name, email, subtitle and password.
|
||||
menu_description: Edit your name, email, subtitle and password, and manage your API tokens.
|
||||
profile: Profile
|
||||
login: Login
|
||||
first_name: First name
|
||||
|
||||
@@ -254,18 +254,74 @@ permissioneditor:
|
||||
empty: Nie zdefiniowano jeszcze żadnych uprawnień.
|
||||
other: Inne
|
||||
api_tokens:
|
||||
section: Tokeny API
|
||||
section_comment: Tokeny pozwalają agentom i integracjom wywoływać API panelu w Twoim imieniu, w granicach wybranych zakresów. Traktuj token jak hasło.
|
||||
create: Utwórz token
|
||||
create_submit: Utwórz token
|
||||
creating: Tworzenie…
|
||||
done: Gotowe, ukryj token
|
||||
empty_title: Nie masz jeszcze tokenów API
|
||||
empty_body: Utwórz token, aby agent lub integracja mogły wywoływać API panelu w Twoim imieniu.
|
||||
load_failed: Nie udało się wczytać tokenów. Odśwież stronę, aby spróbować ponownie.
|
||||
scopes_failed: Nie udało się wczytać zakresów. Zamknij to okno i spróbuj ponownie.
|
||||
create_failed: Nie udało się utworzyć tokenu. Spróbuj ponownie.
|
||||
revoke_failed: Nie udało się unieważnić tokenu. Spróbuj ponownie.
|
||||
gone: Ten token już nie istnieje. Lista została odświeżona.
|
||||
copy_failed: 'Nie udało się skopiować automatycznie. Token jest zaznaczony: naciśnij Ctrl+C (Cmd+C na Macu), aby go skopiować.'
|
||||
revoke: Unieważnij token
|
||||
revoke_named: 'Unieważnij token: :name'
|
||||
revoke_confirm: Unieważnić token „:name”? Aplikacje i agenci, którzy go używają, natychmiast stracą dostęp. Tej operacji nie można cofnąć.
|
||||
revoke_submit: Unieważnij token
|
||||
revoked: Unieważniono token
|
||||
close_uncopied_confirm: Zamknąć bez kopiowania? Tego tokenu nie będzie można już zobaczyć.
|
||||
close_uncopied_submit: Zamknij i ukryj token
|
||||
create_title: Nowy token API
|
||||
create_description: Wybierz nazwę, termin ważności i zakresy tokenu. Token nigdy nie ma szerszego dostępu niż Twoje konto.
|
||||
name: Nazwa
|
||||
name_placeholder: np. Agent bloga
|
||||
name_comment: Pomoże rozpoznać token na liście.
|
||||
name_required: Podaj nazwę tokenu.
|
||||
name_too_long: Nazwa może mieć najwyżej 100 znaków.
|
||||
expires: Ważność
|
||||
expires_30: 30 dni
|
||||
expires_90: 90 dni
|
||||
expires_365: 365 dni
|
||||
expires_never: Bez terminu
|
||||
expires_on: Wygasa :date.
|
||||
expires_never_note: Ten token nie wygasa. Unieważnij go, gdy przestanie być potrzebny.
|
||||
expires_invalid: Wybierz poprawny termin ważności.
|
||||
scopes: Zakresy
|
||||
scopes_required: Wybierz co najmniej jeden zakres.
|
||||
scope_all: Pełny dostęp
|
||||
scope_all_description: Wszystko, co możesz zrobić w panelu. Ustawienia konta i tokeny nadal wymagają zalogowania.
|
||||
scope_admin_read: Tylko odczyt, cały panel
|
||||
scope_admin_read_description: Podgląd wszystkiego, co widzisz w panelu, bez wprowadzania zmian.
|
||||
name_required: Podaj nazwę tokenu.
|
||||
name_too_long: Nazwa może mieć najwyżej 100 znaków.
|
||||
expires_invalid: Wybierz poprawny termin ważności.
|
||||
scopes_required: Wybierz co najmniej jeden zakres.
|
||||
scope_unavailable: Zakres :name nie jest dla Ciebie dostępny.
|
||||
read_only: Tylko odczyt
|
||||
scope_all_includes: Pełny dostęp obejmuje wszystkie pozostałe zakresy.
|
||||
created_title: Skopiuj nowy token
|
||||
created_notice: Token jest widoczny tylko teraz. Skopiuj go i przechowuj w bezpiecznym miejscu. Jeśli go zgubisz, unieważnij go i utwórz nowy.
|
||||
token_label: Nowy token
|
||||
copy: Kopiuj token
|
||||
copied: Skopiowano
|
||||
copied_announce: Skopiowano token do schowka.
|
||||
create_cancel: Odrzuć token
|
||||
show_revoked: Pokaż unieważnione tokeny
|
||||
created_at: Utworzono :date
|
||||
last_used: Ostatnio użyto :date
|
||||
never_used: Nieużywany
|
||||
expires_at: Wygasa :date
|
||||
expired_at: Wygasł :date
|
||||
no_expiry: Bez terminu ważności
|
||||
revoked_at: Unieważniono :date
|
||||
state_expired: Wygasły
|
||||
state_revoked: Unieważniony
|
||||
unknown_scope: ':name (nieznany)'
|
||||
unknown_scope_hint: Ten zakres nie jest już zarejestrowany i nic nie przyznaje.
|
||||
hint: Token kończący się na :last4
|
||||
myaccount:
|
||||
menu_label: Moje konto
|
||||
menu_description: Zmień imię, nazwisko, e-mail, podtytuł i hasło.
|
||||
menu_description: Zmień imię, nazwisko, e-mail, podtytuł i hasło oraz zarządzaj tokenami API.
|
||||
profile: Profil
|
||||
login: Login
|
||||
first_name: Imię
|
||||
|
||||
Reference in New Issue
Block a user