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

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -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ę