feat(SPA): screen to manage API keys (PART 2/3)
This commit is contained in:
1 parent
844842fdae
commit
514a082c96
30 files changed
+3844
-83
No files matched your search
@@ -1976,6 +1976,239 @@ const pickerStates: Record<string, VisualState> = {
|
||||
),
|
||||
}
|
||||
|
||||
const apiKeysFullKey = 'rcms_Kf3x9QwL0aBcT7nV2uYdE5sHjR8mPz4WqXg6C1oIyNe'
|
||||
const apiKeysTooLongName =
|
||||
'Nightly import of the new media library from the Brussels office file server'
|
||||
|
||||
const apiKeysOwnerList = [
|
||||
{
|
||||
id: 4,
|
||||
user_id: 1,
|
||||
name: 'Website build',
|
||||
prefix: 'rcms_Kf3x9Qw',
|
||||
status: 'active',
|
||||
created_at: '2026-03-12T09:00:00Z',
|
||||
last_used_at: '2026-10-03T09:00:00Z',
|
||||
expires_at: null,
|
||||
revoked_at: null,
|
||||
created_by_name: 'Marta Alves',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
user_id: 2,
|
||||
name: 'Nightly media import',
|
||||
prefix: 'rcms_Mq7bT2d',
|
||||
status: 'active',
|
||||
created_at: '2026-04-02T09:00:00Z',
|
||||
last_used_at: '2026-10-03T09:00:00Z',
|
||||
expires_at: '2026-12-31T12:00:00Z',
|
||||
revoked_at: null,
|
||||
created_by_name: 'Jonas',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
user_id: 1,
|
||||
name: 'Staging preview',
|
||||
prefix: 'rcms_Zt2dL8a',
|
||||
status: 'expired',
|
||||
created_at: '2026-06-14T09:00:00Z',
|
||||
last_used_at: '2026-08-20T09:00:00Z',
|
||||
expires_at: '2026-08-30T12:00:00Z',
|
||||
revoked_at: null,
|
||||
created_by_name: 'Marta Alves',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
user_id: 3,
|
||||
name: 'Old migration script',
|
||||
prefix: 'rcms_Hn5wR4c',
|
||||
status: 'revoked',
|
||||
created_at: '2026-04-03T09:00:00Z',
|
||||
last_used_at: '2026-05-09T09:00:00Z',
|
||||
expires_at: null,
|
||||
revoked_at: '2026-05-10T09:00:00Z',
|
||||
created_by_name: 'Inès Duarte',
|
||||
},
|
||||
]
|
||||
|
||||
const apiKeysAdminList = [
|
||||
{
|
||||
id: 2,
|
||||
user_id: 2,
|
||||
name: 'Nightly media import',
|
||||
prefix: 'rcms_Mq7bT2d',
|
||||
status: 'active',
|
||||
created_at: '2026-04-02T09:00:00Z',
|
||||
last_used_at: '2026-10-03T09:00:00Z',
|
||||
expires_at: '2026-12-31T12:00:00Z',
|
||||
revoked_at: null,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
user_id: 2,
|
||||
name: 'Old CI token',
|
||||
prefix: 'rcms_Lp9cE1x',
|
||||
status: 'revoked',
|
||||
created_at: '2026-01-18T09:00:00Z',
|
||||
last_used_at: '2026-02-02T09:00:00Z',
|
||||
expires_at: null,
|
||||
revoked_at: '2026-02-03T09:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
function apiKeysApi(
|
||||
list: ApiMock,
|
||||
overrides: Record<string, ApiMock> = {},
|
||||
session: ApiMock = verifiedSession,
|
||||
): Record<string, ApiMock> {
|
||||
return {
|
||||
'GET /v1/auth/me': session,
|
||||
'GET /v1/api-key': list,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const apiKeysOwnerApi = (overrides: Record<string, ApiMock> = {}) =>
|
||||
apiKeysApi({ status: 200, body: apiKeysOwnerList }, overrides)
|
||||
|
||||
const apiKeysCreated: ApiMock = {
|
||||
status: 201,
|
||||
body: {
|
||||
id: 5,
|
||||
name: 'Nightly media import',
|
||||
prefix: apiKeysFullKey.slice(0, 12),
|
||||
key: apiKeysFullKey,
|
||||
created_at: '2026-10-04T09:00:00Z',
|
||||
expires_at: null,
|
||||
},
|
||||
}
|
||||
|
||||
async function openApiKeyForm(page: Page) {
|
||||
await page.getByRole('button', { name: 'Create key', exact: true }).click()
|
||||
await page.getByRole('dialog').waitFor()
|
||||
}
|
||||
|
||||
async function fillApiKeyForm(page: Page, name: string, expiry: string) {
|
||||
await page.locator('input[name="name"]').fill(name)
|
||||
if (expiry) {
|
||||
await page.locator('input[name="expires_at"]').fill(expiry)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitApiKeyForm(page: Page) {
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Create key', exact: true }).click()
|
||||
}
|
||||
|
||||
async function settleApiKeyDialog(page: Page) {
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
async function revealApiKey(page: Page) {
|
||||
await openApiKeyForm(page)
|
||||
await fillApiKeyForm(page, 'Nightly media import', '')
|
||||
await submitApiKeyForm(page)
|
||||
await page.getByText('Your new API key').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
function nightlyRevokeButton(page: Page) {
|
||||
return page
|
||||
.locator('tbody tr, li')
|
||||
.filter({ hasText: 'Nightly media import' })
|
||||
.getByRole('button', { name: 'Revoke', exact: true })
|
||||
}
|
||||
|
||||
async function openApiKeyRevoke(page: Page) {
|
||||
await nightlyRevokeButton(page).click()
|
||||
await page.getByRole('alertdialog').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
async function confirmApiKeyRevoke(page: Page) {
|
||||
await openApiKeyRevoke(page)
|
||||
await page
|
||||
.getByRole('alertdialog')
|
||||
.getByRole('button', { name: 'Revoke key', exact: true })
|
||||
.click()
|
||||
await page.getByText('Key revoked').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
const apiKeysStates: Record<string, VisualState> = {
|
||||
'Owner: list': { path: '/integration/api-keys', api: apiKeysOwnerApi() },
|
||||
'Admin: own keys only': {
|
||||
path: '/integration/api-keys',
|
||||
api: apiKeysApi({ status: 200, body: apiKeysAdminList }, {}, adminSession),
|
||||
},
|
||||
Loading: {
|
||||
path: '/integration/api-keys',
|
||||
api: apiKeysApi({ status: 200, hang: true }),
|
||||
},
|
||||
Empty: { path: '/integration/api-keys', api: apiKeysApi({ status: 200, body: [] }) },
|
||||
Error: { path: '/integration/api-keys', api: apiKeysApi({ status: 500 }) },
|
||||
'Create key': {
|
||||
path: '/integration/api-keys',
|
||||
api: apiKeysOwnerApi(),
|
||||
steps: async (page) => {
|
||||
await openApiKeyForm(page)
|
||||
await fillApiKeyForm(page, 'Nightly media import', '2026-12-31')
|
||||
await settleApiKeyDialog(page)
|
||||
},
|
||||
},
|
||||
'Create key: validation': {
|
||||
path: '/integration/api-keys',
|
||||
api: apiKeysOwnerApi(),
|
||||
steps: async (page) => {
|
||||
await openApiKeyForm(page)
|
||||
await fillApiKeyForm(page, '', '2026-03-12')
|
||||
await submitApiKeyForm(page)
|
||||
await settleApiKeyDialog(page)
|
||||
},
|
||||
},
|
||||
'Create key: name too long': {
|
||||
path: '/integration/api-keys',
|
||||
api: apiKeysOwnerApi(),
|
||||
steps: async (page) => {
|
||||
await openApiKeyForm(page)
|
||||
await fillApiKeyForm(page, apiKeysTooLongName, '')
|
||||
await settleApiKeyDialog(page)
|
||||
},
|
||||
},
|
||||
'One-time reveal': {
|
||||
path: '/integration/api-keys',
|
||||
api: apiKeysOwnerApi({ 'POST /v1/api-key': apiKeysCreated }),
|
||||
steps: revealApiKey,
|
||||
},
|
||||
'One-time reveal: copied': {
|
||||
path: '/integration/api-keys',
|
||||
origin: integrationOrigin,
|
||||
api: apiKeysOwnerApi({ 'POST /v1/api-key': apiKeysCreated }),
|
||||
steps: async (page) => {
|
||||
await revealApiKey(page)
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'Copy', exact: true })
|
||||
.click()
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'Copied', exact: true })
|
||||
.waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Revoke: confirm': {
|
||||
path: '/integration/api-keys',
|
||||
api: apiKeysOwnerApi(),
|
||||
steps: openApiKeyRevoke,
|
||||
},
|
||||
Revoked: {
|
||||
path: '/integration/api-keys',
|
||||
api: apiKeysOwnerApi({ 'DELETE /v1/api-key/2': { status: 204 } }),
|
||||
steps: confirmApiKeyRevoke,
|
||||
},
|
||||
}
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
profile: {
|
||||
'Account menu': {
|
||||
@@ -2237,6 +2470,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
'Update already running (409)': { path: '/settings', skip: 'clock-sensitive' },
|
||||
},
|
||||
'integration-api-keys': apiKeysStates,
|
||||
integration: {
|
||||
Default: {
|
||||
path: '/integration',
|
||||
|
||||
Reference in new issue
Block a user