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,86 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import ApiKeyStatusBadge from '@/components/ApiKeyStatusBadge.vue'
|
||||
import { API_KEY_SKELETON_ROWS, type ApiKey } from '@/composables/useApiKeys'
|
||||
import {
|
||||
creatorLabel,
|
||||
displayPrefix,
|
||||
expiresLabel,
|
||||
formatKeyDate,
|
||||
lastUsedLabel,
|
||||
} from '@/lib/apiKeys'
|
||||
|
||||
defineProps<{
|
||||
keys: ApiKey[]
|
||||
loading: boolean
|
||||
showCreatedBy: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ revoke: [key: ApiKey] }>()
|
||||
|
||||
function createdText(key: ApiKey, showCreatedBy: boolean) {
|
||||
const date = formatKeyDate(key.created_at, '')
|
||||
return showCreatedBy
|
||||
? t('Created {0} by {1}', [date, creatorLabel(key)])
|
||||
: t('Created {0}', [date])
|
||||
}
|
||||
|
||||
function detailsText(key: ApiKey, showCreatedBy: boolean) {
|
||||
return [
|
||||
createdText(key, showCreatedBy),
|
||||
t('Last used {0}', [lastUsedLabel(key)]),
|
||||
t('Expires {0}', [expiresLabel(key)]),
|
||||
].join(' · ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="-mx-4 border-y border-default bg-default" :aria-busy="loading ? 'true' : undefined">
|
||||
<template v-if="loading">
|
||||
<li
|
||||
v-for="n in API_KEY_SKELETON_ROWS"
|
||||
:key="n"
|
||||
class="flex flex-col gap-1.5 border-b border-default px-4 py-3"
|
||||
>
|
||||
<USkeleton class="h-3.5 w-[55%] rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-[40%] rounded-none bg-accented" />
|
||||
</li>
|
||||
</template>
|
||||
<template v-else>
|
||||
<li
|
||||
v-for="key in keys"
|
||||
:key="key.id"
|
||||
data-testid="api-key-card"
|
||||
class="flex flex-col gap-1.5 border-b border-default px-4 py-3"
|
||||
:class="key.status === 'revoked' ? 'bg-muted' : 'bg-default'"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-[0.9375rem]/5 font-medium"
|
||||
:class="key.status === 'revoked' ? 'text-muted' : 'text-highlighted'"
|
||||
>{{ key.name }}</span
|
||||
>
|
||||
<ApiKeyStatusBadge :status="key.status" />
|
||||
</div>
|
||||
<span
|
||||
class="font-mono text-xs leading-[normal]"
|
||||
:class="key.status === 'revoked' ? 'text-muted' : 'text-toned'"
|
||||
>{{ displayPrefix(key.prefix) }}</span
|
||||
>
|
||||
<span :class="key.status === 'revoked' ? 'text-muted' : 'text-toned'">{{
|
||||
detailsText(key, showCreatedBy)
|
||||
}}</span>
|
||||
<UButton
|
||||
v-if="key.status !== 'revoked'"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 self-start border border-accented bg-default px-3.5 text-sm font-medium text-highlighted ring-0"
|
||||
@click="emit('revoke', key)"
|
||||
>
|
||||
{{ t('Revoke') }}
|
||||
</UButton>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
apiKey: string
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const emit = defineEmits<{ done: [] }>()
|
||||
|
||||
const { copied, copy } = useCopyToClipboard()
|
||||
const copyButton = ref<{ $el: HTMLElement } | null>(null)
|
||||
|
||||
const description = computed(() =>
|
||||
t('"{0}" is ready. Copy the key and store it somewhere safe.', [props.name]),
|
||||
)
|
||||
const copyLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
|
||||
const copyIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
|
||||
|
||||
watch(
|
||||
open,
|
||||
async (isOpen) => {
|
||||
if (!isOpen) {
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
copyButton.value?.$el?.focus()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
emit('done')
|
||||
}
|
||||
}
|
||||
|
||||
function keepOpenOnOutsideInteraction(event: Event) {
|
||||
event.preventDefault()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
:open="open"
|
||||
:title="t('Your new API key')"
|
||||
:description="description"
|
||||
:close="false"
|
||||
:content="{
|
||||
onPointerDownOutside: keepOpenOnOutsideInteraction,
|
||||
onInteractOutside: keepOpenOnOutsideInteraction,
|
||||
}"
|
||||
:ui="{
|
||||
content:
|
||||
'divide-y-0 border border-default shadow-(--shadow-modal) ring-0 sm:top-30 sm:max-w-120 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
|
||||
header: 'min-h-0 px-5 pt-5 pb-0 sm:px-5',
|
||||
title: 'text-[1.125rem]/6.5 font-semibold',
|
||||
description: 'mt-1 text-base/5.5 text-toned text-pretty',
|
||||
body: 'px-5 pt-3.5 pb-0 sm:px-5 sm:pt-3.5 sm:pb-0',
|
||||
footer: 'px-5 pt-3.5 pb-5 sm:px-5',
|
||||
}"
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-3.5">
|
||||
<UAlert
|
||||
role="alert"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
icon="i-lucide-triangle-alert"
|
||||
:title="t('You won\'t be able to see this key again.')"
|
||||
:ui="{ root: 'gap-2.5 px-3 py-2.5', title: 'text-sm font-medium' }"
|
||||
/>
|
||||
<div class="flex flex-col border border-accented bg-elevated md:flex-row">
|
||||
<span
|
||||
class="min-w-0 flex-1 px-3 py-2.5 font-mono text-code/5 break-all text-highlighted"
|
||||
>{{ apiKey }}</span
|
||||
>
|
||||
<UButton
|
||||
ref="copyButton"
|
||||
:icon="copyIcon"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
aria-live="polite"
|
||||
class="h-11 justify-center gap-1.5 rounded-none border-t border-accented px-3.5 font-medium md:border-t-0 md:border-l"
|
||||
:class="copied ? 'text-success' : 'text-highlighted'"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="copy(apiKey)"
|
||||
>
|
||||
{{ copyLabel }}
|
||||
</UButton>
|
||||
</div>
|
||||
<span class="text-muted">{{
|
||||
t('Closing this window discards the key from the screen. Copy it first.')
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-col sm:flex-row sm:justify-end">
|
||||
<UButton
|
||||
block
|
||||
class="h-11 justify-center sm:w-auto sm:px-3.5"
|
||||
@click="emit('done')"
|
||||
>
|
||||
{{ t('Done') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { ApiKeyStatus } from '@/composables/useApiKeys'
|
||||
|
||||
const props = defineProps<{ status: ApiKeyStatus }>()
|
||||
|
||||
const badges = {
|
||||
active: {
|
||||
get label() {
|
||||
return t('active')
|
||||
},
|
||||
color: 'success',
|
||||
class: 'bg-(--rc-success-soft) text-success',
|
||||
},
|
||||
expired: {
|
||||
get label() {
|
||||
return t('expired')
|
||||
},
|
||||
color: 'warning',
|
||||
class: 'bg-(--rc-warning-soft) text-warning',
|
||||
},
|
||||
revoked: {
|
||||
get label() {
|
||||
return t('revoked')
|
||||
},
|
||||
color: 'neutral',
|
||||
class: 'bg-elevated text-muted',
|
||||
},
|
||||
} as const
|
||||
|
||||
const badge = computed(() => badges[props.status])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UBadge
|
||||
:color="badge.color"
|
||||
variant="soft"
|
||||
:class="badge.class"
|
||||
:ui="{ base: 'h-5 px-1.5 py-0 text-xs' }"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</UBadge>
|
||||
</template>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import ApiKeyStatusBadge from '@/components/ApiKeyStatusBadge.vue'
|
||||
import { API_KEY_SKELETON_ROWS, type ApiKey } from '@/composables/useApiKeys'
|
||||
import {
|
||||
creatorLabel,
|
||||
displayPrefix,
|
||||
expiresLabel,
|
||||
formatKeyDate,
|
||||
lastUsedLabel,
|
||||
} from '@/lib/apiKeys'
|
||||
|
||||
const props = defineProps<{
|
||||
keys: ApiKey[]
|
||||
loading: boolean
|
||||
showCreatedBy: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ revoke: [key: ApiKey] }>()
|
||||
|
||||
type Row = ApiKey | { skeleton: true; id: number }
|
||||
|
||||
const skeletonRows: Row[] = Array.from({ length: API_KEY_SKELETON_ROWS }, (_, index) => ({
|
||||
skeleton: true,
|
||||
id: index,
|
||||
}))
|
||||
|
||||
const data = computed<Row[]>(() => (props.loading ? skeletonRows : props.keys))
|
||||
|
||||
function fixed(width: string) {
|
||||
return { meta: { class: { th: width, td: width } } }
|
||||
}
|
||||
|
||||
const columns = computed(() => [
|
||||
{
|
||||
id: 'name',
|
||||
get header() {
|
||||
return t('Name')
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'prefix',
|
||||
get header() {
|
||||
return t('Key')
|
||||
},
|
||||
...fixed('w-30.5'),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
get header() {
|
||||
return t('Status')
|
||||
},
|
||||
...fixed('w-22'),
|
||||
},
|
||||
...(props.showCreatedBy
|
||||
? [
|
||||
{
|
||||
id: 'created_by',
|
||||
get header() {
|
||||
return t('Created by')
|
||||
},
|
||||
...fixed('w-54.75'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'created_at',
|
||||
get header() {
|
||||
return t('Created')
|
||||
},
|
||||
...fixed('w-28'),
|
||||
},
|
||||
{
|
||||
id: 'last_used_at',
|
||||
get header() {
|
||||
return t('Last used')
|
||||
},
|
||||
...fixed('w-28'),
|
||||
},
|
||||
{
|
||||
id: 'expires_at',
|
||||
get header() {
|
||||
return t('Expires')
|
||||
},
|
||||
...fixed('w-28'),
|
||||
},
|
||||
{ id: 'actions', header: '', ...fixed('w-24.5') },
|
||||
])
|
||||
|
||||
const meta = {
|
||||
class: {
|
||||
tr: (row: { original: Row }) => {
|
||||
const original = row.original
|
||||
return 'skeleton' in original || original.status !== 'revoked' ? '' : 'bg-muted'
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function key(row: { original: Row }) {
|
||||
return row.original as ApiKey
|
||||
}
|
||||
|
||||
function isSkeleton(row: { original: Row }) {
|
||||
return 'skeleton' in row.original
|
||||
}
|
||||
|
||||
function isRevoked(row: { original: Row }) {
|
||||
return !isSkeleton(row) && key(row).status === 'revoked'
|
||||
}
|
||||
|
||||
function mutedClass(row: { original: Row }) {
|
||||
return isRevoked(row) ? 'text-muted' : 'text-toned'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border border-default bg-default" :aria-busy="loading ? 'true' : undefined">
|
||||
<span v-if="loading" role="status" class="sr-only">{{ t('Loading API keys') }}</span>
|
||||
<UTable
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:meta="meta"
|
||||
:ui="{
|
||||
base: 'table-fixed',
|
||||
thead: 'bg-elevated [&>tr]:border-t-0',
|
||||
separator: 'hidden',
|
||||
th: 'h-9 px-1.5 py-0 first:pl-3 last:pr-3',
|
||||
td: 'h-12 px-1.5 py-0 first:pl-3 last:pr-3',
|
||||
tbody: 'divide-y-0',
|
||||
tr: 'border-t border-default',
|
||||
}"
|
||||
>
|
||||
<template #name-cell="{ row }">
|
||||
<USkeleton v-if="isSkeleton(row)" class="h-3 w-[60%] rounded-none bg-accented" />
|
||||
<span
|
||||
v-else
|
||||
class="block truncate font-medium"
|
||||
:class="isRevoked(row) ? 'text-muted' : 'text-highlighted'"
|
||||
>{{ key(row).name }}</span
|
||||
>
|
||||
</template>
|
||||
<template #prefix-cell="{ row }">
|
||||
<USkeleton v-if="isSkeleton(row)" class="h-3 w-[70%] rounded-none bg-accented" />
|
||||
<span v-else class="font-mono text-xs" :class="mutedClass(row)">{{
|
||||
displayPrefix(key(row).prefix)
|
||||
}}</span>
|
||||
</template>
|
||||
<template #status-cell="{ row }">
|
||||
<USkeleton v-if="isSkeleton(row)" class="h-5 w-14 rounded-none bg-accented" />
|
||||
<ApiKeyStatusBadge v-else :status="key(row).status" />
|
||||
</template>
|
||||
<template #created_by-cell="{ row }">
|
||||
<USkeleton v-if="isSkeleton(row)" class="h-3 w-[60%] rounded-none bg-accented" />
|
||||
<span v-else class="block truncate" :class="mutedClass(row)">{{
|
||||
creatorLabel(key(row))
|
||||
}}</span>
|
||||
</template>
|
||||
<template #created_at-cell="{ row }">
|
||||
<span v-if="!isSkeleton(row)" :class="mutedClass(row)">{{
|
||||
formatKeyDate(key(row).created_at, '')
|
||||
}}</span>
|
||||
</template>
|
||||
<template #last_used_at-cell="{ row }">
|
||||
<span v-if="!isSkeleton(row)" :class="mutedClass(row)">{{
|
||||
lastUsedLabel(key(row))
|
||||
}}</span>
|
||||
</template>
|
||||
<template #expires_at-cell="{ row }">
|
||||
<span v-if="!isSkeleton(row)" :class="mutedClass(row)">{{
|
||||
expiresLabel(key(row))
|
||||
}}</span>
|
||||
</template>
|
||||
<template #actions-cell="{ row }">
|
||||
<span v-if="!isSkeleton(row) && !isRevoked(row)" class="flex justify-end">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
class="h-7 border border-accented bg-default px-2.5 text-xs font-medium text-highlighted ring-0"
|
||||
@click="emit('revoke', key(row))"
|
||||
>
|
||||
{{ t('Revoke') }}
|
||||
</UButton>
|
||||
</span>
|
||||
</template>
|
||||
</UTable>
|
||||
</div>
|
||||
</template>
|
||||
@@ -80,6 +80,13 @@ async function onConfirm() {
|
||||
}
|
||||
}
|
||||
|
||||
function onOpenChange(value: boolean) {
|
||||
if (!value && pending.value) {
|
||||
return
|
||||
}
|
||||
open.value = value
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
if (pending.value) {
|
||||
return
|
||||
@@ -90,10 +97,11 @@ function onCancel() {
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
v-model:open="open"
|
||||
:open="open"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:dismissible="!pending"
|
||||
@update:open="onOpenChange"
|
||||
:close="false"
|
||||
:content="{
|
||||
role: 'alertdialog',
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import CharacterCounter from '@/components/CharacterCounter.vue'
|
||||
import { ApiError, NetworkError } from '@/composables/useApi'
|
||||
import { useApiKeys, type CreateApiKeyInput, type CreatedApiKey } from '@/composables/useApiKeys'
|
||||
import { expiryInstant, isFutureExpiry } from '@/lib/apiKeys'
|
||||
|
||||
const NAME_MAX_LENGTH = 60
|
||||
const fieldUi = { help: 'mt-1.5 text-sm/5 text-muted', error: 'mt-1.5 text-sm/5' }
|
||||
const OPTIONAL_SUFFIX = /\s*\([^)]*\)$/
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const emit = defineEmits<{ created: [created: CreatedApiKey] }>()
|
||||
|
||||
const { createApiKey } = useApiKeys()
|
||||
|
||||
const name = ref('')
|
||||
const expiry = ref('')
|
||||
const submitted = ref(false)
|
||||
const focused = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formAlert = ref<'' | 'invalid' | 'rateLimited' | 'failed' | 'network'>('')
|
||||
|
||||
const nameLength = computed(() => [...name.value].length)
|
||||
const trimmedName = computed(() => name.value.trim())
|
||||
|
||||
const nameError = computed(() => {
|
||||
if (nameLength.value > NAME_MAX_LENGTH) {
|
||||
return t('Use 60 characters or fewer.')
|
||||
}
|
||||
if (submitted.value && !trimmedName.value) {
|
||||
return t('Enter a name.')
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const expiryError = computed(() =>
|
||||
submitted.value && expiry.value && !isFutureExpiry(expiry.value)
|
||||
? t('Choose a date in the future.')
|
||||
: '',
|
||||
)
|
||||
|
||||
const expiryLabel = computed(() => t('Expiry date (optional)').replace(OPTIONAL_SUFFIX, ''))
|
||||
const expiryOptional = computed(
|
||||
() => OPTIONAL_SUFFIX.exec(t('Expiry date (optional)'))?.[0].trim() ?? '',
|
||||
)
|
||||
|
||||
const alertMessage = computed(() => {
|
||||
switch (formAlert.value) {
|
||||
case 'invalid':
|
||||
return t("Something in the form isn't valid. Check the highlighted fields.")
|
||||
case 'rateLimited':
|
||||
return t('Too many requests. Wait a moment, then try again.')
|
||||
case 'network':
|
||||
return t("Couldn't reach RenewCMS. Check your connection and try again.")
|
||||
case 'failed':
|
||||
return t("Couldn't create the key. Try again in a moment.")
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
name.value = ''
|
||||
expiry.value = ''
|
||||
submitted.value = false
|
||||
focused.value = false
|
||||
submitting.value = false
|
||||
formAlert.value = ''
|
||||
}
|
||||
|
||||
async function focusNameField() {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
document.querySelector<HTMLInputElement>('input[name="name"]')?.focus()
|
||||
focused.value = true
|
||||
}
|
||||
|
||||
watch(
|
||||
open,
|
||||
(isOpen) => {
|
||||
if (!isOpen) {
|
||||
return
|
||||
}
|
||||
resetForm()
|
||||
focusNameField()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function showFailure(error: unknown) {
|
||||
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
||||
return
|
||||
}
|
||||
if (error instanceof ApiError && error.status === 400) {
|
||||
formAlert.value = 'invalid'
|
||||
} else if (error instanceof ApiError && error.status === 429) {
|
||||
formAlert.value = 'rateLimited'
|
||||
} else if (error instanceof NetworkError) {
|
||||
formAlert.value = 'network'
|
||||
} else {
|
||||
formAlert.value = 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (submitting.value) {
|
||||
return
|
||||
}
|
||||
submitted.value = true
|
||||
focused.value = false
|
||||
if (!trimmedName.value || nameLength.value > NAME_MAX_LENGTH || expiryError.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const input: CreateApiKeyInput = { name: trimmedName.value }
|
||||
if (expiry.value) {
|
||||
input.expires_at = expiryInstant(expiry.value)
|
||||
}
|
||||
formAlert.value = ''
|
||||
submitting.value = true
|
||||
try {
|
||||
const created = await createApiKey(input)
|
||||
emit('created', created)
|
||||
open.value = false
|
||||
resetForm()
|
||||
} catch (error) {
|
||||
showFailure(error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
if (submitting.value) {
|
||||
return
|
||||
}
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen && submitting.value) {
|
||||
return
|
||||
}
|
||||
open.value = isOpen
|
||||
}
|
||||
|
||||
function keepOpenOnOutsideInteraction(event: Event) {
|
||||
event.preventDefault()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
:open="open"
|
||||
:title="t('Create API key')"
|
||||
:description="t('The key acts as you, with your role. It is shown in full once.')"
|
||||
:dismissible="!submitting"
|
||||
:close="false"
|
||||
:content="{
|
||||
onPointerDownOutside: keepOpenOnOutsideInteraction,
|
||||
onInteractOutside: keepOpenOnOutsideInteraction,
|
||||
}"
|
||||
:ui="{
|
||||
content:
|
||||
'divide-y-0 border border-default shadow-(--shadow-modal) ring-0 sm:top-30 sm:max-w-120 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
|
||||
header: 'min-h-0 px-5 pt-5 pb-0 sm:px-5',
|
||||
title: 'text-[1.125rem]/6.5 font-semibold',
|
||||
description: 'mt-1 text-base/5.5 text-toned text-pretty',
|
||||
body: 'px-5 pt-3.5 pb-0 sm:px-5 sm:pt-3.5 sm:pb-0',
|
||||
footer: 'px-5 pt-3.5 pb-5 sm:px-5',
|
||||
}"
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-3.5">
|
||||
<UAlert
|
||||
v-if="alertMessage"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:ui="{ root: 'gap-2 px-3 py-2.5' }"
|
||||
:description="alertMessage"
|
||||
/>
|
||||
<UForm :state="{ name, expiry }" class="flex flex-col gap-3.5" @submit="onSubmit">
|
||||
<UFormField
|
||||
:label="t('Name')"
|
||||
name="name"
|
||||
:error="nameError || undefined"
|
||||
:help="t('Required. Up to 60 characters.')"
|
||||
:ui="fieldUi"
|
||||
>
|
||||
<template #hint>
|
||||
<CharacterCounter
|
||||
:value="nameLength"
|
||||
:max="NAME_MAX_LENGTH"
|
||||
variant="field"
|
||||
/>
|
||||
</template>
|
||||
<UInput
|
||||
v-model="name"
|
||||
name="name"
|
||||
autocomplete="off"
|
||||
:disabled="submitting"
|
||||
color="primary"
|
||||
:highlight="focused || !!nameError"
|
||||
:ui="{ base: 'h-10' }"
|
||||
class="w-full"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField
|
||||
name="expires_at"
|
||||
:ui="fieldUi"
|
||||
:error="expiryError || undefined"
|
||||
:help="
|
||||
t(
|
||||
'Optional. Must be in the future. Leave it empty for a key that does not expire.',
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #label>
|
||||
{{ expiryLabel }}
|
||||
<span v-if="expiryOptional" class="font-normal text-muted">{{
|
||||
expiryOptional
|
||||
}}</span>
|
||||
</template>
|
||||
<UInput
|
||||
v-model="expiry"
|
||||
type="date"
|
||||
name="expires_at"
|
||||
:disabled="submitting"
|
||||
color="primary"
|
||||
:highlight="!!expiryError"
|
||||
:ui="{ base: 'h-10' }"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</UForm>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
class="h-11 justify-center border border-accented bg-default text-highlighted ring-0 sm:w-auto sm:px-3.5"
|
||||
:disabled="submitting"
|
||||
@click="onCancel"
|
||||
>
|
||||
{{ t('Cancel') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
block
|
||||
class="h-11 justify-center sm:w-auto sm:px-3.5"
|
||||
:loading="submitting"
|
||||
:disabled="submitting"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ submitting ? t('Creating…') : t('Create key') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -18,6 +18,9 @@ import UserMenu from '@/components/UserMenu.vue'
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
mobileTitle?: string
|
||||
backTo?: string
|
||||
backLabel?: string
|
||||
stickyFooter?: boolean
|
||||
fullBleed?: boolean
|
||||
compactPadding?: boolean
|
||||
renderWhileChecking?: boolean
|
||||
@@ -26,6 +29,9 @@ const props = withDefaults(
|
||||
}>(),
|
||||
{
|
||||
mobileTitle: undefined,
|
||||
backTo: undefined,
|
||||
backLabel: undefined,
|
||||
stickyFooter: false,
|
||||
offlineMessage: undefined,
|
||||
offlineIcon: undefined,
|
||||
fullBleed: false,
|
||||
@@ -36,11 +42,15 @@ const props = withDefaults(
|
||||
|
||||
const COMPACT_BODY = 'gap-0 px-4 pt-4 pb-22 sm:gap-0 sm:px-4 sm:pt-4 sm:pb-22 lg:p-6'
|
||||
const DEFAULT_BODY = 'gap-0 px-4 pt-5 pb-22 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-22 lg:p-8'
|
||||
const STICKY_FOOTER_BODY = 'gap-0 px-4 pt-5 pb-0 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-0 lg:p-8'
|
||||
|
||||
const bodyUi = computed(() => {
|
||||
if (props.fullBleed) {
|
||||
return 'gap-0 p-0 sm:gap-0 sm:p-0 lg:p-0'
|
||||
}
|
||||
if (props.stickyFooter) {
|
||||
return STICKY_FOOTER_BODY
|
||||
}
|
||||
return props.compactPadding ? COMPACT_BODY : DEFAULT_BODY
|
||||
})
|
||||
|
||||
@@ -229,7 +239,17 @@ async function onLogout() {
|
||||
}"
|
||||
>
|
||||
<template #toggle>
|
||||
<span class="relative lg:hidden">
|
||||
<UButton
|
||||
v-if="backTo"
|
||||
:to="backTo"
|
||||
icon="i-lucide-arrow-left"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="size-11 justify-center lg:hidden"
|
||||
:ui="{ leadingIcon: 'size-5' }"
|
||||
:aria-label="backLabel"
|
||||
/>
|
||||
<span v-else class="relative lg:hidden">
|
||||
<UDashboardSidebarToggle
|
||||
data-slot="toggle"
|
||||
color="neutral"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useApi } from '@/composables/useApi'
|
||||
|
||||
export type ApiKeyStatus = 'active' | 'expired' | 'revoked'
|
||||
|
||||
export interface ApiKey {
|
||||
id: number
|
||||
user_id: number
|
||||
name: string
|
||||
prefix: string
|
||||
status: ApiKeyStatus
|
||||
created_at: string
|
||||
last_used_at: string | null
|
||||
expires_at: string | null
|
||||
revoked_at: string | null
|
||||
created_by_name?: string
|
||||
}
|
||||
|
||||
export interface CreatedApiKey {
|
||||
id: number
|
||||
name: string
|
||||
prefix: string
|
||||
key: string
|
||||
created_at: string
|
||||
expires_at: string | null
|
||||
}
|
||||
|
||||
export interface CreateApiKeyInput {
|
||||
name: string
|
||||
expires_at?: string
|
||||
}
|
||||
|
||||
export const API_KEY_SKELETON_ROWS = 4
|
||||
|
||||
export function useApiKeys() {
|
||||
const { request } = useApi()
|
||||
|
||||
function listApiKeys(): Promise<ApiKey[]> {
|
||||
return request<ApiKey[]>('/v1/api-key')
|
||||
}
|
||||
|
||||
function createApiKey(input: CreateApiKeyInput): Promise<CreatedApiKey> {
|
||||
return request<CreatedApiKey>('/v1/api-key', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
}
|
||||
|
||||
function revokeApiKey(id: number): Promise<void> {
|
||||
return request<void>(`/v1/api-key/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
return { listApiKeys, createApiKey, revokeApiKey }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { messages } from './messages'
|
||||
|
||||
const API_KEYS_MESSAGES = [
|
||||
'API keys',
|
||||
'Create key',
|
||||
'Key',
|
||||
'Created by',
|
||||
'Last used',
|
||||
'Expires',
|
||||
'active',
|
||||
'expired',
|
||||
'revoked',
|
||||
'Revoke',
|
||||
'None',
|
||||
'Never',
|
||||
'Created {0}',
|
||||
'Created {0} by {1}',
|
||||
'Last used {0}',
|
||||
'Expires {0}',
|
||||
"Keys act as the user who created them. As an owner you see everyone's keys.",
|
||||
'Keys act as you, with your role. You only see your own keys.',
|
||||
"Resetting a user's password revokes all of their keys.",
|
||||
'No API keys yet',
|
||||
'Create a key to call the API from a script or service.',
|
||||
"Couldn't load your API keys",
|
||||
'Create API key',
|
||||
'The key acts as you, with your role. It is shown in full once.',
|
||||
'Required. Up to 60 characters.',
|
||||
'Expiry date (optional)',
|
||||
'Optional. Must be in the future. Leave it empty for a key that does not expire.',
|
||||
'Enter a name.',
|
||||
'Use 60 characters or fewer.',
|
||||
'Choose a date in the future.',
|
||||
'Your new API key',
|
||||
'"{0}" is ready. Copy the key and store it somewhere safe.',
|
||||
"You won't be able to see this key again.",
|
||||
'Closing this window discards the key from the screen. Copy it first.',
|
||||
'Done',
|
||||
'Revoke this key?',
|
||||
'"{0}" ({1}) will stop working right away. Requests that use it answer 401. This can\'t be undone.',
|
||||
'Keep key',
|
||||
'Revoke key',
|
||||
'Key revoked',
|
||||
'"{0}" can no longer be used.',
|
||||
'Revoking…',
|
||||
"Couldn't create the key. Try again in a moment.",
|
||||
"Couldn't reach RenewCMS. Check your connection and try again.",
|
||||
'Too many requests. Wait a moment, then try again.',
|
||||
"Something in the form isn't valid. Check the highlighted fields.",
|
||||
"Couldn't revoke the key. Try again in a moment.",
|
||||
'That key no longer exists.',
|
||||
'Manage API keys',
|
||||
'Loading API keys',
|
||||
]
|
||||
|
||||
describe('API keys screen messages', () => {
|
||||
it.each(API_KEYS_MESSAGES)('has a French entry for "%s"', (key) => {
|
||||
const french = (messages as Record<string, string>)[key]
|
||||
expect(french).toBeTruthy()
|
||||
if (key !== 'active') {
|
||||
expect(french).not.toBe(key)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -293,6 +293,7 @@ export const messages = {
|
||||
"Titles can't be changed after an article is created. To use a different title, create a new article and delete this one.":
|
||||
'Les titres ne peuvent pas être modifiés après la création d’un article. Pour changer de titre, créez un nouvel article et supprimez celui-ci.',
|
||||
'Back to Articles': 'Retour aux articles',
|
||||
'Back to Integration': 'Retour à l’intégration',
|
||||
'Your changes to "{0}" haven\'t been saved.':
|
||||
'Vos modifications de « {0} » n’ont pas été enregistrées.',
|
||||
'Shorten the text or remove an image. Formatting counts too.':
|
||||
@@ -824,6 +825,65 @@ export const messages = {
|
||||
'images, {0} selected': 'images, {0} sélectionnées',
|
||||
'Used in 1 article': 'Utilisée dans 1 article',
|
||||
'Used in {0} articles': 'Utilisée dans {0} articles',
|
||||
'API keys': 'Clés d’API',
|
||||
'Create key': 'Créer une clé',
|
||||
Key: 'Clé',
|
||||
'Created by': 'Créée par',
|
||||
'Last used': 'Dernière utilisation',
|
||||
Expires: 'Expire le',
|
||||
active: 'active',
|
||||
expired: 'expirée',
|
||||
revoked: 'révoquée',
|
||||
Revoke: 'Révoquer',
|
||||
None: 'Aucune',
|
||||
Never: 'Jamais',
|
||||
'Created {0}': 'Créée le {0}',
|
||||
'Created {0} by {1}': 'Créée le {0} par {1}',
|
||||
'Last used {0}': 'Dernière utilisation : {0}',
|
||||
'Expires {0}': 'Expire : {0}',
|
||||
"Keys act as the user who created them. As an owner you see everyone's keys.":
|
||||
'Les clés agissent comme l’utilisateur qui les a créées. En tant que propriétaire, vous voyez les clés de tout le monde.',
|
||||
'Keys act as you, with your role. You only see your own keys.':
|
||||
'Les clés agissent en votre nom, avec votre rôle. Vous ne voyez que vos propres clés.',
|
||||
"Resetting a user's password revokes all of their keys.":
|
||||
'Réinitialiser le mot de passe d’un utilisateur révoque toutes ses clés.',
|
||||
'No API keys yet': 'Aucune clé d’API pour l’instant',
|
||||
'Create a key to call the API from a script or service.':
|
||||
'Créez une clé pour appeler l’API depuis un script ou un service.',
|
||||
"Couldn't load your API keys": 'Impossible de charger vos clés d’API',
|
||||
'Create API key': 'Créer une clé d’API',
|
||||
'The key acts as you, with your role. It is shown in full once.':
|
||||
'La clé agit en votre nom, avec votre rôle. Elle n’est affichée en entier qu’une seule fois.',
|
||||
'Required. Up to 60 characters.': 'Obligatoire. 60 caractères maximum.',
|
||||
'Expiry date (optional)': 'Date d’expiration (facultative)',
|
||||
'Optional. Must be in the future. Leave it empty for a key that does not expire.':
|
||||
'Facultative. Doit être dans le futur. Laissez vide pour une clé qui n’expire pas.',
|
||||
'Enter a name.': 'Saisissez un nom.',
|
||||
'Choose a date in the future.': 'Choisissez une date dans le futur.',
|
||||
'Your new API key': 'Votre nouvelle clé d’API',
|
||||
'"{0}" is ready. Copy the key and store it somewhere safe.':
|
||||
'« {0} » est prête. Copiez la clé et conservez-la en lieu sûr.',
|
||||
"You won't be able to see this key again.": 'Vous ne pourrez plus revoir cette clé.',
|
||||
'Closing this window discards the key from the screen. Copy it first.':
|
||||
'Fermer cette fenêtre supprime la clé de l’écran. Copiez-la d’abord.',
|
||||
Done: 'Terminé',
|
||||
'Revoke this key?': 'Révoquer cette clé ?',
|
||||
'"{0}" ({1}) will stop working right away. Requests that use it answer 401. This can\'t be undone.':
|
||||
'« {0} » ({1}) cessera de fonctionner immédiatement. Les requêtes qui l’utilisent recevront une réponse 401. Cette action est irréversible.',
|
||||
'Keep key': 'Garder la clé',
|
||||
'Revoke key': 'Révoquer la clé',
|
||||
'Key revoked': 'Clé révoquée',
|
||||
'"{0}" can no longer be used.': '« {0} » ne peut plus être utilisée.',
|
||||
'Revoking…': 'Révocation…',
|
||||
"Couldn't create the key. Try again in a moment.":
|
||||
'Impossible de créer la clé. Réessayez dans un instant.',
|
||||
'Too many requests. Wait a moment, then try again.':
|
||||
'Trop de requêtes. Patientez un instant, puis réessayez.',
|
||||
"Couldn't revoke the key. Try again in a moment.":
|
||||
'Impossible de révoquer la clé. Réessayez dans un instant.',
|
||||
'That key no longer exists.': 'Cette clé n’existe plus.',
|
||||
'Manage API keys': 'Gérer les clés d’API',
|
||||
'Loading API keys': 'Chargement des clés d’API',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -0,0 +1,60 @@
|
||||
import { t } from '@/i18n'
|
||||
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
||||
import type { ApiKey } from '@/composables/useApiKeys'
|
||||
|
||||
const VISIBLE_PREFIX_LENGTH = 9
|
||||
const END_OF_DAY = { hours: 23, minutes: 59, seconds: 59 }
|
||||
const MINUTES_PER_HOUR = 60
|
||||
|
||||
export function displayPrefix(prefix: string) {
|
||||
return `${prefix.slice(0, VISIBLE_PREFIX_LENGTH)}…`
|
||||
}
|
||||
|
||||
function twoDigits(value: number) {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
|
||||
function localEndOfDay(dateValue: string) {
|
||||
const [year, month, day] = dateValue.split('-').map(Number)
|
||||
return new Date(
|
||||
year!,
|
||||
month! - 1,
|
||||
day,
|
||||
END_OF_DAY.hours,
|
||||
END_OF_DAY.minutes,
|
||||
END_OF_DAY.seconds,
|
||||
)
|
||||
}
|
||||
|
||||
function localOffset(date: Date) {
|
||||
const totalMinutes = -date.getTimezoneOffset()
|
||||
const sign = totalMinutes < 0 ? '-' : '+'
|
||||
const absolute = Math.abs(totalMinutes)
|
||||
return `${sign}${twoDigits(Math.floor(absolute / MINUTES_PER_HOUR))}:${twoDigits(absolute % MINUTES_PER_HOUR)}`
|
||||
}
|
||||
|
||||
export function expiryInstant(dateValue: string) {
|
||||
const date = localEndOfDay(dateValue)
|
||||
const day = `${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())}`
|
||||
return `${day}T${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}:${twoDigits(date.getSeconds())}${localOffset(date)}`
|
||||
}
|
||||
|
||||
export function isFutureExpiry(dateValue: string, now: Date = new Date()) {
|
||||
return localEndOfDay(dateValue).getTime() > now.getTime()
|
||||
}
|
||||
|
||||
export function formatKeyDate(iso: string | null, emptyLabel: string) {
|
||||
return iso ? formatAbsoluteDateFromIso(iso) : emptyLabel
|
||||
}
|
||||
|
||||
export function lastUsedLabel(key: ApiKey) {
|
||||
return formatKeyDate(key.last_used_at, t('Never'))
|
||||
}
|
||||
|
||||
export function expiresLabel(key: ApiKey) {
|
||||
return formatKeyDate(key.expires_at, t('None'))
|
||||
}
|
||||
|
||||
export function creatorLabel(key: ApiKey) {
|
||||
return key.created_by_name || '—'
|
||||
}
|
||||
@@ -91,6 +91,11 @@ const router = createRouter({
|
||||
component: () => import('@/views/IntegrationView.vue'),
|
||||
meta: { auth: 'verified', title: 'Integration' },
|
||||
},
|
||||
{
|
||||
path: '/integration/api-keys',
|
||||
component: () => import('@/views/ApiKeysView.vue'),
|
||||
meta: { auth: 'verified', title: 'Integration' },
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: () => import('@/views/SettingsView.vue'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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