eeff292
import { Alert } from '@app/components'
import { AuthCard, AuthShell, fetchSessionUser, IconBadge } from '@app/domains/session'
import { acceptSpaceInvite } from '@app/domains/space/management'
import { useT } from '@app/i18n'
import { ApiFailure, meKey, useAsync } from '@app/lib'
import { Button } from '@app/ui'
import {
IconArrowRight as ArrowRight,
IconLoader2 as Loader2,
IconMailQuestion as MailQuestionMark,
IconAlertTriangle as TriangleAlert,
} from '@tabler/icons-react'
import { useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
type Phase = 'joining' | 'signin' | 'failed' | 'invalid'
type Failure = { code?: number; message?: string }
/** Codes that mean the link itself is spent — retrying cannot change the answer. */
const SPENT = new Set([403, 404, 409])
/**
* Capability link from the invite email:
* `{web_base_url}/spaces/invites/accept?code=…&space=…`.
*
* A signed-in visitor accepts straight away and lands inside the space they
* just joined (the `space` hint); an anonymous one is asked to sign in first —
* the code stays in the URL, so the same link keeps working after logging in.
*/
export function SpaceInvitePage() {
const { t } = useT()
const navigate = useNavigate()
const location = useLocation()
const [params] = useSearchParams()
const code = params.get('code') ?? ''
const space = params.get('space') ?? ''
const [tick, setTick] = useState(0)
const me = useAsync(fetchSessionUser, [tick], { cacheKey: meKey() })
const [acceptFailure, setAcceptFailure] = useState<Failure | null>(null)
const started = useRef(-1)
// A refused session (401/403) outranks a cached identity: the visitor has to
// sign in as the account the invitation was addressed to.
const refused = me.error instanceof ApiFailure && (me.error.code === 401 || me.error.code === 403)
const probeFailure = !me.loading && !me.data && !refused && Boolean(me.error)
useEffect(() => {
// `started` keeps the accept single-shot per attempt: the effect re-runs as
// caches refresh, and a consumed code must never be posted twice — only the
// retry button bumps `tick` and unlocks another post.
if (!code || me.loading || !me.data || started.current === tick) return
started.current = tick
acceptSpaceInvite(code).then(
() => navigate(space ? `/app/${encodeURIComponent(space)}` : '/app', { replace: true }),
(err: unknown) => {
setAcceptFailure(
err instanceof ApiFailure
? { code: err.code, message: err.message }
: { message: err instanceof Error ? err.message : undefined },
)
},
)
}, [tick, code, space, me.loading, me.data, navigate])
const failure: Failure | null =
acceptFailure ?? (probeFailure ? { message: (me.error as Error | undefined)?.message } : null)
const phase: Phase = !code ? 'invalid' : failure ? 'failed' : me.data ? 'joining' : me.loading ? 'joining' : 'signin'
const failureText =
failure?.code === 404
? t('space.invite.accept.notFound')
: failure?.code === 403
? t('space.invite.accept.mismatch')
: failure?.code === 409
? t('space.invite.accept.used')
: failure?.message || t('space.invite.accept.failed')
const badge =
phase === 'signin' ? (
<IconBadge>
<MailQuestionMark size={20} aria-hidden />
</IconBadge>
) : phase === 'joining' ? (
<IconBadge>
<Loader2 size={20} className="animate-spin" aria-hidden />
</IconBadge>
) : (
<IconBadge>
<TriangleAlert size={20} aria-hidden />
</IconBadge>
)
return (
<AuthShell
current={t('space.invite.accept.crumb').toUpperCase()}
hint={t('space.invite.accept.hint').toUpperCase()}
>
<AuthCard
badge={badge}
heading={space ? t('space.invite.accept.headingSpace', { space }) : t('space.invite.accept.heading')}
subheading={t('space.invite.accept.sub')}
>
{phase === 'joining' ? (
<p className="flex items-center justify-center gap-2 text-[13px] text-muted-foreground" role="status">
<Loader2 size={15} className="animate-spin" aria-hidden />
{t('space.invite.accept.joining')}
</p>
) : null}
{phase === 'signin' ? (
<div className="flex w-full flex-col gap-3">
<Alert tone="info">{t('space.invite.accept.signInNote')}</Alert>
<Button
size="lg"
className="w-full"
onClick={() =>
navigate(`/auth/login?redirect=${encodeURIComponent(location.pathname + location.search)}`)
}
>
{t('space.invite.accept.login')}
<ArrowRight size={15} aria-hidden />
</Button>
<Button
variant="secondary"
size="lg"
className="w-full"
onClick={() =>
navigate(`/auth/signup?redirect=${encodeURIComponent(location.pathname + location.search)}`)
}
>
{t('space.invite.accept.signup')}
</Button>
</div>
) : null}
{phase === 'failed' ? (
<div className="flex w-full flex-col gap-3">
<Alert tone="error">{failureText}</Alert>
<div className="flex w-full gap-2">
{SPENT.has(failure?.code ?? 0) ? null : (
<Button
className="flex-1"
onClick={() => {
setAcceptFailure(null)
setTick((value) => value + 1)
}}
>
{t('space.invite.accept.retry')}
</Button>
)}
<Button variant="secondary" className="flex-1" onClick={() => navigate('/app')}>
{t('space.invite.accept.home')}
</Button>
</div>
</div>
) : null}
{phase === 'invalid' ? (
<div className="flex w-full flex-col gap-3">
<Alert tone="error">{t('space.invite.accept.invalid')}</Alert>
<Button variant="secondary" size="lg" className="w-full" onClick={() => navigate('/app')}>
{t('space.invite.accept.home')}
</Button>
</div>
) : null}
</AuthCard>
</AuthShell>
)
}