396 lines
10 KiB
Vue
396 lines
10 KiB
Vue
<script setup lang="ts">
|
||
import { t } from '@/i18n'
|
||
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
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'
|
||
import { usePermissionsStore } from '@/stores/permissions'
|
||
import { useIsDesktop } from '@/composables/useIsDesktop'
|
||
|
||
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 permissions = usePermissionsStore()
|
||
const { listApiKeys, revokeApiKey } = useApiKeys()
|
||
const isDesktop = useIsDesktop()
|
||
|
||
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 seesEveryKey = computed(() => permissions.scopeOf('apikeys.view') === 'all')
|
||
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(() =>
|
||
seesEveryKey.value
|
||
? t("Keys act as the user who created them. 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
|
||
compact-padding
|
||
: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>
|
||
|
||
<UAlert
|
||
color="info"
|
||
variant="soft"
|
||
icon="i-lucide-info"
|
||
:close="false"
|
||
:title="t('Keys follow their creator\'s role')"
|
||
:description="
|
||
t(
|
||
'A key can do what its creator\'s role can do, today. If the person\'s role changes, their keys change with it.',
|
||
)
|
||
"
|
||
:ui="{ root: 'items-start gap-2.5 px-3.5 py-2.5', description: 'text-toned' }"
|
||
/>
|
||
|
||
<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="seesEveryKey"
|
||
:caller-id="session.session?.id"
|
||
:caller-role="permissions.role"
|
||
@revoke="confirmRevoke"
|
||
/>
|
||
<ApiKeyCardList
|
||
v-else
|
||
:keys="keys"
|
||
:loading="loadState === 'loading'"
|
||
:show-created-by="seesEveryKey"
|
||
:caller-id="session.session?.id"
|
||
:caller-role="permissions.role"
|
||
@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>
|