Files
RenewCMS/web/src/views/ApiKeysView.vue
T

396 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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'
import { usePermissionsStore } from '@/stores/permissions'
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 permissions = usePermissionsStore()
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 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
: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>