114 lines
3.5 KiB
TypeScript
114 lines
3.5 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { useForm } from 'react-hook-form'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router-dom'
|
|
import { z } from 'zod'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { TextField } from '@/components/ui/fields'
|
|
import { AuthLayout } from '@/components/layout/AuthLayout'
|
|
import { useAcceptInvite, useMe } from '@/hooks/useAuth'
|
|
import { describeApiError } from '@/lib/apiErrors'
|
|
|
|
interface AcceptInviteValues {
|
|
fullName: string
|
|
password: string
|
|
confirm: string
|
|
}
|
|
|
|
export function AcceptInvitePage() {
|
|
const { t } = useTranslation()
|
|
const [searchParams] = useSearchParams()
|
|
const token = searchParams.get('token') ?? ''
|
|
const accept = useAcceptInvite()
|
|
const me = useMe()
|
|
const navigate = useNavigate()
|
|
const [apiError, setApiError] = useState<string | null>(null)
|
|
|
|
const schema = useMemo(
|
|
() =>
|
|
z
|
|
.object({
|
|
fullName: z.string().min(1, t('common.requiredField')),
|
|
password: z.string().min(12, t('auth.passwordHint')),
|
|
confirm: z.string().min(1, t('common.requiredField')),
|
|
})
|
|
.refine((values) => values.password === values.confirm, {
|
|
path: ['confirm'],
|
|
message: t('auth.passwordMismatch'),
|
|
}),
|
|
[t],
|
|
)
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
formState: { errors },
|
|
} = useForm<AcceptInviteValues>({
|
|
resolver: zodResolver(schema),
|
|
defaultValues: { fullName: '', password: '', confirm: '' },
|
|
})
|
|
|
|
if (me.isSuccess) return <Navigate to="/accounts" replace />
|
|
|
|
const submit = handleSubmit((values) => {
|
|
setApiError(null)
|
|
accept.mutate(
|
|
{ token, fullName: values.fullName, password: values.password },
|
|
{
|
|
onSuccess: () => navigate('/accounts', { replace: true }),
|
|
onError: (error) => setApiError(describeApiError(error, t)),
|
|
},
|
|
)
|
|
})
|
|
|
|
return (
|
|
<AuthLayout
|
|
title={t('auth.inviteTitle')}
|
|
subtitle={t('auth.inviteSubtitle')}
|
|
context={t('authShell.invitation')}
|
|
>
|
|
{apiError ? (
|
|
<div className="banner banner-danger" role="alert">
|
|
{apiError}
|
|
</div>
|
|
) : null}
|
|
{token ? (
|
|
<form onSubmit={submit} noValidate className="form-grid">
|
|
<TextField
|
|
label={t('auth.fullName')}
|
|
autoComplete="name"
|
|
error={errors.fullName?.message}
|
|
{...register('fullName')}
|
|
/>
|
|
<TextField
|
|
label={t('auth.password')}
|
|
type="password"
|
|
autoComplete="new-password"
|
|
hint={t('auth.passwordHint')}
|
|
error={errors.password?.message}
|
|
{...register('password')}
|
|
/>
|
|
<TextField
|
|
label={t('auth.passwordConfirm')}
|
|
type="password"
|
|
autoComplete="new-password"
|
|
error={errors.confirm?.message}
|
|
{...register('confirm')}
|
|
/>
|
|
<Button type="submit" disabled={accept.isPending}>
|
|
{accept.isPending ? t('auth.accepting') : t('auth.accept')}
|
|
</Button>
|
|
</form>
|
|
) : (
|
|
<div className="banner banner-warn" role="alert">
|
|
{t('auth.inviteTokenMissing')}
|
|
</div>
|
|
)}
|
|
<p className="auth-alt">
|
|
<Link to="/login">{t('auth.backToLogin')}</Link>
|
|
</p>
|
|
</AuthLayout>
|
|
)
|
|
}
|