feat(SPA): users page and invite modal

This commit is contained in:
fsylvain committed 2026-09-29 08:35:14 +02:00
1 parent acdef18b37
commit 6afffc1984
20 files changed
+1880 -55

No files matched your search

+233
View File
@@ -0,0 +1,233 @@
<script setup lang="ts">
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 ? INVALID_EMAIL_MESSAGE : ''),
)
const alertMessage = computed(() => {
if (formAlert.value === 'invalid') {
return "Something in the form isn't valid. Check the highlighted fields."
}
if (formAlert.value === 'rateLimited') {
return 'Too many invites. Wait a moment, then try again.'
}
if (formAlert.value === 'failed') {
return "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 ? PENDING_INVITE_MESSAGE : 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="Invite a user"
description="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="Email"
name="email"
:error="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"
/>
{{ 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"
>
Cancel
</UButton>
<UButton
block
:ui="{ base: 'h-11 md:h-8' }"
class="sm:w-auto"
:loading="submitting"
:disabled="submitting"
@click="onSubmit"
>
{{ submitting ? 'Sending…' : 'Send invite' }}
</UButton>
</div>
</template>
</UModal>
</template>