242 lines
5.9 KiB
Vue
242 lines
5.9 KiB
Vue
<script setup lang="ts">
|
|
import { t, translate } from '@/i18n'
|
|
|
|
import { computed, nextTick, ref, watch } from 'vue'
|
|
import { ApiError } from '@/composables/useApi'
|
|
import { useUsers, type CreatedInvite } from '@/composables/useUsers'
|
|
|
|
const EMAIL_PLACEHOLDER = 'name@example.com'
|
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/
|
|
const INVALID_EMAIL_MESSAGE = 'Enter a full email address, like name@example.com.'
|
|
const HAS_ACCOUNT_MESSAGE = 'This address already has an account.'
|
|
const PENDING_INVITE_MESSAGE = "There's already a pending invite for this address."
|
|
|
|
const open = defineModel<boolean>('open', { default: false })
|
|
|
|
const emit = defineEmits<{ invited: [invite: CreatedInvite] }>()
|
|
|
|
const { inviteUser } = useUsers()
|
|
|
|
const email = ref('')
|
|
const touched = ref(false)
|
|
const focused = ref(false)
|
|
const submitting = ref(false)
|
|
const serverError = ref('')
|
|
const formAlert = ref<'' | 'invalid' | 'rateLimited' | 'failed'>('')
|
|
|
|
const emailValid = computed(() => EMAIL_PATTERN.test(email.value))
|
|
const emailError = computed(
|
|
() => serverError.value || (touched.value && !emailValid.value ? t(INVALID_EMAIL_MESSAGE) : ''),
|
|
)
|
|
|
|
const alertMessage = computed(() => {
|
|
if (formAlert.value === 'invalid') {
|
|
return t("Something in the form isn't valid. Check the highlighted fields.")
|
|
}
|
|
if (formAlert.value === 'rateLimited') {
|
|
return t('Too many invites. Wait a moment, then try again.')
|
|
}
|
|
if (formAlert.value === 'failed') {
|
|
return t("Couldn't send the invite. Try again in a moment.")
|
|
}
|
|
return ''
|
|
})
|
|
|
|
function resetForm() {
|
|
email.value = ''
|
|
touched.value = false
|
|
focused.value = false
|
|
submitting.value = false
|
|
serverError.value = ''
|
|
formAlert.value = ''
|
|
}
|
|
|
|
function emailInput() {
|
|
return document.querySelector<HTMLInputElement>(`input[placeholder="${EMAIL_PLACEHOLDER}"]`)
|
|
}
|
|
|
|
async function focusEmailField() {
|
|
await nextTick()
|
|
await nextTick()
|
|
emailInput()?.focus()
|
|
focused.value = true
|
|
}
|
|
|
|
watch(
|
|
open,
|
|
(isOpen) => {
|
|
if (!isOpen) {
|
|
return
|
|
}
|
|
resetForm()
|
|
focusEmailField()
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
watch(email, () => {
|
|
serverError.value = ''
|
|
})
|
|
|
|
function onBlur() {
|
|
touched.value = true
|
|
focused.value = false
|
|
}
|
|
|
|
function messageForConflict(error: ApiError) {
|
|
return error.body === PENDING_INVITE_MESSAGE
|
|
? t(PENDING_INVITE_MESSAGE)
|
|
: t(HAS_ACCOUNT_MESSAGE)
|
|
}
|
|
|
|
function showFailure(error: unknown) {
|
|
if (error instanceof ApiError && error.status === 409) {
|
|
serverError.value = messageForConflict(error)
|
|
return
|
|
}
|
|
if (error instanceof ApiError && error.status === 400) {
|
|
serverError.value = INVALID_EMAIL_MESSAGE
|
|
formAlert.value = 'invalid'
|
|
return
|
|
}
|
|
formAlert.value = error instanceof ApiError && error.status === 429 ? 'rateLimited' : 'failed'
|
|
}
|
|
|
|
async function onSubmit() {
|
|
if (submitting.value) {
|
|
return
|
|
}
|
|
touched.value = true
|
|
focused.value = false
|
|
|
|
if (!emailValid.value) {
|
|
emailInput()?.focus()
|
|
return
|
|
}
|
|
|
|
serverError.value = ''
|
|
formAlert.value = ''
|
|
submitting.value = true
|
|
try {
|
|
const invite = await inviteUser(email.value)
|
|
open.value = false
|
|
emit('invited', invite)
|
|
} catch (error) {
|
|
showFailure(error)
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
function onOpenChange(isOpen: boolean) {
|
|
if (!isOpen && submitting.value) {
|
|
return
|
|
}
|
|
open.value = isOpen
|
|
}
|
|
|
|
function onCancel() {
|
|
if (submitting.value) {
|
|
return
|
|
}
|
|
resetForm()
|
|
open.value = false
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<UModal
|
|
:open="open"
|
|
:title="t('Invite a user')"
|
|
:description="
|
|
t(
|
|
'We\'ll email them a link to choose a username and password. The link expires after 3 days.',
|
|
)
|
|
"
|
|
:dismissible="!submitting"
|
|
:close="{ color: 'neutral', variant: 'ghost', class: 'text-muted' }"
|
|
@update:open="onOpenChange"
|
|
:ui="{
|
|
content:
|
|
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-115 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: 'items-start gap-3 px-6 pt-5 pb-0 sm:px-6',
|
|
wrapper: 'pe-10',
|
|
close: 'top-5 end-6 size-7 justify-center p-0',
|
|
title: 'text-lg/6 font-semibold',
|
|
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
|
footer: 'px-6 py-5 sm:px-6',
|
|
}"
|
|
>
|
|
<template #body>
|
|
<div class="flex flex-col gap-4">
|
|
<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="{ email }" class="flex flex-col gap-4" @submit="onSubmit">
|
|
<UFormField
|
|
:label="t('Email')"
|
|
name="email"
|
|
:error="translate(emailError) || undefined"
|
|
:ui="{ error: 'mt-1.5 text-xs/5' }"
|
|
>
|
|
<template v-if="emailError" #error="{ error }">
|
|
<span class="flex items-center gap-1.5">
|
|
<UIcon
|
|
name="i-lucide-circle-alert"
|
|
class="size-3.5 shrink-0 text-error"
|
|
/>
|
|
{{ t(error) }}
|
|
</span>
|
|
</template>
|
|
<UInput
|
|
v-model="email"
|
|
type="email"
|
|
:placeholder="EMAIL_PLACEHOLDER"
|
|
autocomplete="off"
|
|
:disabled="submitting"
|
|
color="primary"
|
|
:highlight="focused || !!emailError"
|
|
:ui="{ base: 'h-11 text-lg md:h-10 md:text-base' }"
|
|
class="w-full"
|
|
@focus="focused = true"
|
|
@blur="onBlur"
|
|
/>
|
|
</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
|
|
:ui="{ base: 'h-11 md:h-8' }"
|
|
class="sm:w-auto"
|
|
:disabled="submitting"
|
|
@click="onCancel"
|
|
>
|
|
{{ t('Cancel') }}
|
|
</UButton>
|
|
<UButton
|
|
block
|
|
:ui="{ base: 'h-11 md:h-8' }"
|
|
class="sm:w-auto"
|
|
:loading="submitting"
|
|
:disabled="submitting"
|
|
@click="onSubmit"
|
|
>
|
|
{{ submitting ? t('Sending…') : t('Send invite') }}
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
</UModal>
|
|
</template>
|