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

+86
View File
@@ -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>
+117
View File
@@ -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>
+45
View File
@@ -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>
+190
View File
@@ -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>
+9 -1
View File
@@ -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',
+272
View File
@@ -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>
+21 -1
View File
@@ -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"
+54
View File
@@ -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 }
}
+65
View File
@@ -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)
}
})
})
+60
View File
@@ -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
+60
View File
@@ -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 || '—'
}
+5
View File
@@ -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'),
+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)