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:
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) {
|
||||
|
||||
Reference in New Issue
Block a user