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
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user