feat(SPA): screen to manage API keys (PART 2/3)

This commit is contained in:
fsylvain committed 2026-10-05 00:05:12 +02:00
1 parent 844842fdae
commit 514a082c96
30 files changed
+3844 -83

No files matched your search

+378
View File
@@ -0,0 +1,378 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import ApiKeyCardList from '@/components/ApiKeyCardList.vue'
import ApiKeyReveal from '@/components/ApiKeyReveal.vue'
import ApiKeysTable from '@/components/ApiKeysTable.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import CreateApiKeyModal from '@/components/CreateApiKeyModal.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import { ApiError, NetworkError } from '@/composables/useApi'
import { useApiKeys, type ApiKey, type CreatedApiKey } from '@/composables/useApiKeys'
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
import { displayPrefix } from '@/lib/apiKeys'
import { useSessionStore } from '@/stores/session'
const DESKTOP_QUERY = '(min-width: 48rem)'
const AUTH_HANDLED_STATUSES = [401, 403]
const NOT_FOUND_STATUS = 404
const REVOKE_FAILED_COPY = "Couldn't revoke the key. Try again in a moment."
const CONNECTION_FAILED_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
type LoadState = 'loading' | 'ready' | 'error'
const toast = useLocalizedToast()
const overlay = useLocalizedOverlay()
const session = useSessionStore()
const { listApiKeys, revokeApiKey } = useApiKeys()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const loadState = ref<LoadState>('loading')
const keys = ref<ApiKey[]>([])
const createOpen = ref(false)
const revealed = ref<{ name: string; key: string } | null>(null)
const revokeDialogOpen = ref(false)
let opener: HTMLElement | null = null
const isOwner = computed(() => session.session?.role === 'owner')
const isEmpty = computed(() => loadState.value === 'ready' && keys.value.length === 0)
const showList = computed(
() => loadState.value === 'loading' || (loadState.value === 'ready' && keys.value.length > 0),
)
const showPinnedButton = computed(() => !isDesktop.value && !isEmpty.value)
const anyDialogOpen = computed(
() => createOpen.value || revealed.value !== null || revokeDialogOpen.value,
)
const description = computed(() =>
isOwner.value
? t("Keys act as the user who created them. As an owner you see everyone's keys.")
: t('Keys act as you, with your role. You only see your own keys.'),
)
const sessionName = computed(
() => session.session?.displayName?.trim() || session.session?.username || '',
)
function isHandledByApiClient(err: unknown) {
return err instanceof ApiError && AUTH_HANDLED_STATUSES.includes(err.status)
}
async function loadKeys() {
loadState.value = 'loading'
try {
keys.value = await listApiKeys()
loadState.value = 'ready'
} catch (err) {
if (!isHandledByApiClient(err)) {
loadState.value = 'error'
}
}
}
function rememberOpener() {
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
}
async function restoreFocus() {
await nextTick()
opener?.focus()
opener = null
}
function openCreateModal() {
rememberOpener()
createOpen.value = true
}
function onCreateOpenChange(isOpen: boolean) {
createOpen.value = isOpen
if (!isOpen && revealed.value === null) {
restoreFocus()
}
}
function onCreated(created: CreatedApiKey) {
revealed.value = { name: created.name, key: created.key }
keys.value = [
{
id: created.id,
user_id: session.session?.id ?? 0,
name: created.name,
prefix: created.prefix,
status: 'active',
created_at: created.created_at,
last_used_at: null,
expires_at: created.expires_at,
revoked_at: null,
created_by_name: sessionName.value,
},
...keys.value,
]
}
function onRevealDone() {
revealed.value = null
restoreFocus()
}
function markRevoked(id: number) {
const revokedAt = new Date().toISOString()
keys.value = keys.value.map((key) =>
key.id === id ? { ...key, status: 'revoked', revoked_at: revokedAt } : key,
)
}
function removeKey(id: number) {
keys.value = keys.value.filter((key) => key.id !== id)
}
function announceRevoked(key: ApiKey) {
toast.add(() => ({
id: `api-key-revoked-${key.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Key revoked')
},
get description() {
return t('"{0}" can no longer be used.', [key.name])
},
}))
}
function announceMissing(key: ApiKey) {
toast.add(() => ({
id: `api-key-missing-${key.id}`,
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return t('That key no longer exists.')
},
}))
}
async function revoke(key: ApiKey) {
try {
await revokeApiKey(key.id)
} catch (err) {
if (isHandledByApiClient(err)) {
return
}
if (err instanceof ApiError && err.status === NOT_FOUND_STATUS) {
removeKey(key.id)
announceMissing(key)
return
}
throw new Error(
err instanceof NetworkError ? t(CONNECTION_FAILED_COPY) : t(REVOKE_FAILED_COPY),
)
}
markRevoked(key.id)
announceRevoked(key)
}
function confirmRevoke(key: ApiKey) {
rememberOpener()
revokeDialogOpen.value = true
overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Revoke this key?')
},
get description() {
return t(
'"{0}" ({1}) will stop working right away. Requests that use it answer 401. This can\'t be undone.',
[key.name, displayPrefix(key.prefix)],
)
},
get confirmLabel() {
return t('Revoke key')
},
get cancelLabel() {
return t('Keep key')
},
get pendingLabel() {
return t('Revoking…')
},
tone: 'danger',
action: () => revoke(key),
},
})
.open()
.finally(() => {
revokeDialogOpen.value = false
restoreFocus()
})
}
watch(showPinnedButton, (visible) => (toastsAboveBulkBar.value = visible), {
immediate: true,
})
onBeforeUnmount(() => (toastsAboveBulkBar.value = false))
onMounted(loadKeys)
</script>
<template>
<DashboardShell
:mobile-title="t('API keys')"
back-to="/integration"
sticky-footer
:back-label="t('Back to Integration')"
>
<template #breadcrumb>
<UBreadcrumb
:items="[
{
get label() {
return t('Integration')
},
to: '/integration',
},
{
get label() {
return t('API keys')
},
},
]"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-1">
<UBreadcrumb
data-testid="api-keys-breadcrumb"
:items="[
{
get label() {
return t('Integration')
},
to: '/integration',
},
{
get label() {
return t('API keys')
},
},
]"
color="neutral"
:ui="{
link: 'text-sm font-normal text-muted',
linkLabel: 'text-muted',
list: 'gap-1',
separator: 'px-0',
}"
>
<template #separator>
<span aria-hidden="true" class="text-muted">›</span>
</template>
</UBreadcrumb>
<h1 class="text-xl font-semibold text-highlighted md:text-2xl">
{{ t('API keys') }}
</h1>
<p class="text-pretty text-muted">{{ description }}</p>
</div>
<UButton v-if="isDesktop" class="h-9 px-3.5" @click="openCreateModal">
{{ t('Create key') }}
</UButton>
</div>
<UAlert
v-if="loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="horizontal"
:close="false"
:title="t('Couldn\'t load your API keys')"
:description="t('Check your connection and try again.')"
:ui="{ title: 'font-bold', description: 'mt-0 text-error' }"
>
<template #actions>
<UButton
color="neutral"
variant="link"
class="p-0 font-medium text-highlighted"
@click="loadKeys"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<UEmpty
v-if="isEmpty"
variant="naked"
:title="t('No API keys yet')"
:description="t('Create a key to call the API from a script or service.')"
:ui="{
root: 'gap-2.5 border border-default bg-default px-6 py-12',
title: 'text-lg font-semibold',
description: 'max-w-90 text-sm text-toned text-pretty',
}"
>
<template #actions>
<UButton class="h-9 px-3.5" @click="openCreateModal">
{{ t('Create key') }}
</UButton>
</template>
</UEmpty>
<template v-if="showList">
<ApiKeysTable
v-if="isDesktop"
:keys="keys"
:loading="loadState === 'loading'"
:show-created-by="isOwner"
@revoke="confirmRevoke"
/>
<ApiKeyCardList
v-else
:keys="keys"
:loading="loadState === 'loading'"
:show-created-by="isOwner"
@revoke="confirmRevoke"
/>
</template>
<div class="flex gap-2.5">
<UIcon name="i-lucide-info" class="mt-0.75 size-4 shrink-0 text-info" />
<span class="text-base text-pretty text-toned">{{
t("Resetting a user's password revokes all of their keys.")
}}</span>
</div>
</div>
<div
v-if="showPinnedButton"
v-show="!anyDialogOpen"
class="sticky bottom-0 z-10 -mx-4 mt-4 border-t border-default bg-default p-4"
>
<UButton
class="h-12 w-full justify-center text-[0.9375rem]"
@click="openCreateModal"
>
{{ t('Create key') }}
</UButton>
</div>
<CreateApiKeyModal
:open="createOpen"
@update:open="onCreateOpenChange"
@created="onCreated"
/>
<ApiKeyReveal
v-if="revealed"
:open="true"
:name="revealed.name"
:api-key="revealed.key"
@done="onRevealDone"
/>
</DashboardShell>
</template>
+9
View File
@@ -150,6 +150,15 @@ const notes = computed(() => [
}}</span>
</div>
</div>
<UButton
to="/integration/api-keys"
variant="link"
trailing-icon="i-lucide-arrow-right"
class="self-start px-0"
>
{{ t('Manage API keys') }}
</UButton>
</div>
</DashboardShell>
</template>
File diff suppressed because it is too large. Load diff
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import UButton from '@nuxt/ui/components/Button.vue'
import EndpointCard from '@/components/EndpointCard.vue'
import IntegrationView from '../IntegrationView.vue'
@@ -102,6 +103,31 @@ describe("Default — the page loads with the app's own origin", () => {
})
})
describe('Manage API keys link', () => {
it('sits below the three notes as a link-variant button with a trailing arrow and navigates to the keys screen', async () => {
stubOrigin(STUB_ORIGIN)
const wrapper = await mountIntegration()
const anchor = wrapper.findAll('a').find((a) => a.text().trim() === 'Manage API keys')!
expect(anchor.attributes('href')).toBe('/integration/api-keys')
const button = wrapper
.findAllComponents(UButton)
.find((component) => component.element === anchor.element)!
expect(button.props('variant')).toBe('link')
expect(button.props('trailingIcon')).toBe('i-lucide-arrow-right')
const lastNote = wrapper
.findAll('span')
.find((span) => span.text().startsWith('Browsers can only call the API'))!
expect(
lastNote.element.compareDocumentPosition(anchor.element) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
await anchor.trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/integration/api-keys'))
})
})
describe('Copied — the Base URL copy button', () => {
it('writes the origin to the clipboard, shows Copied for 2s, then reverts', async () => {
stubOrigin(STUB_ORIGIN)