273 lines
6.9 KiB
Vue
273 lines
6.9 KiB
Vue
<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>
|