feat(members): always return the company invite link so it can be shared without a mail provider (#1710) (#2020)

POST /api/company/members/invite now returns the accept link alongside email_sent and user_provisioned (the NODE_ENV=development gate from #153 is gone, so self-hosted Docker operators without Resend can invite colleagues). CompanyMembersSection ports the TeamPanel share-link pattern: an attention line with a copy action when the mail did not go out, a quiet line otherwise. Docs cover invites without Resend and the revoke-and-re-invite rule. Refs #1710 (SMTP delivery itself is #1746).
This commit is contained in:
Jakob Wennberg
2026-08-30 11:53:13 +02:00
committed by GitHub
parent 521f437072
commit 8ec12fefc7
8 changed files with 214 additions and 26 deletions
+85 -10
View File
@@ -36,6 +36,22 @@ interface CompanyInvitation {
created_at: string
}
/**
* The shareable accept link from the latest invite response. Raw tokens are
* never stored server-side (only their hash), so the link exists exactly
* once: here, until the next navigation. Kept visible so a failed or absent
* mail send (self-hosted without a mail provider, #1710) never dead-ends the
* inviter. There is no re-send for company invites: revoke and re-invite.
* provisioned = GoTrue created the account and sent its own invite mail
* (AUTH_SIGNUPS_DISABLED path), so email_sent=false is not a failure there.
*/
interface ShareableInvite {
email: string
url: string
sent: boolean
provisioned: boolean
}
export function CompanyMembersSection() {
const t = useTranslations('settings_company')
const errorLocale = useLocale() as ErrorLocale
@@ -65,6 +81,7 @@ export function CompanyMembersSection() {
const [removingId, setRemovingId] = useState<string | null>(null)
const [revokingId, setRevokingId] = useState<string | null>(null)
const [canInvite, setCanInvite] = useState(false)
const [shareInvite, setShareInvite] = useState<ShareableInvite | null>(null)
const fetchMembers = useCallback(async () => {
setLoadError(null)
@@ -132,14 +149,27 @@ export function CompanyMembersSection() {
return
}
if (data.data.inviteUrl) {
console.log('[DEV] Company invite URL:', data.data.inviteUrl)
const payload = (
data as {
data?: { email_sent?: boolean; user_provisioned?: boolean; inviteUrl?: string }
} | null
)?.data
const sent = payload?.email_sent !== false
const provisioned = payload?.user_provisioned === true
// Persist the shareable link next to the pending list: a failed or
// skipped send leaves the invitation valid, and the toast alone is too
// easy to miss, so the recovery path stays visible on the page.
if (payload?.inviteUrl) {
setShareInvite({ email, url: payload.inviteUrl, sent, provisioned })
}
// The title must not claim a send that never happened (self-hosted
// without a mail provider): 'created' when nothing was mailed.
toast({
title: t('members_invite_sent_title'),
description: data.data.inviteUrl
? t('members_invite_sent_dev_url')
: t('members_invite_sent_description', { email }),
title: sent || provisioned ? t('members_invite_sent_title') : t('members_invite_created_title'),
description:
sent || provisioned
? t('members_invite_sent_description', { email })
: t('members_invite_mail_not_sent'),
})
setInviteEmail('')
setInviteRole('viewer')
@@ -151,6 +181,15 @@ export function CompanyMembersSection() {
}
}
const handleCopyInviteLink = async (url: string) => {
try {
await navigator.clipboard.writeText(url)
toast({ title: t('members_invite_link_copied_toast') })
} catch {
toast({ title: t('members_invite_link_copy_failed'), variant: 'destructive' })
}
}
const handleRemoveMember = async (memberId: string) => {
setRemovingId(memberId)
try {
@@ -171,10 +210,10 @@ export function CompanyMembersSection() {
}
}
const handleRevokeInvite = async (inviteId: string) => {
setRevokingId(inviteId)
const handleRevokeInvite = async (invite: CompanyInvitation) => {
setRevokingId(invite.id)
try {
const res = await fetch(`/api/company/members/invite/${inviteId}`, { method: 'DELETE' })
const res = await fetch(`/api/company/members/invite/${invite.id}`, { method: 'DELETE' })
const data = await res.json()
if (!res.ok) {
@@ -182,6 +221,8 @@ export function CompanyMembersSection() {
return
}
// A revoked invitation's link is dead: never keep offering it.
setShareInvite((current) => (current?.email === invite.email ? null : current))
toast({ title: t('members_invite_revoked') })
fetchMembers()
} catch {
@@ -286,7 +327,7 @@ export function CompanyMembersSection() {
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
aria-label={t('members_revoke_aria')}
onClick={() => handleRevokeInvite(inv.id)}
onClick={() => handleRevokeInvite(inv)}
disabled={revokingId === inv.id}
>
{revokingId === inv.id ? (
@@ -299,6 +340,40 @@ export function CompanyMembersSection() {
</div>
))}
{/* Shareable accept link from the latest invite: one sentence, not a
banner. A send that failed or was skipped (no mail provider) is the
page's single attn line; a sent or GoTrue-provisioned invite is a
quiet muted line with the same copy action, since the link is a
legitimate share path either way. The live region stays mounted
(empty until a link exists) so the line is announced when it
appears, not merely inserted; same reason as the roster block. */}
{canInvite && (
<div className={shareInvite ? 'px-1 pt-3' : undefined} role="status" aria-live="polite">
{shareInvite &&
(shareInvite.sent || shareInvite.provisioned ? (
<p className="text-[12.5px] leading-5 text-muted-foreground">
{t('members_invite_link_sent', { email: shareInvite.email })}{' '}
<button
type="button"
onClick={() => void handleCopyInviteLink(shareInvite.url)}
className="underline underline-offset-2 hover:text-foreground"
>
{t('members_invite_link_copy_action')}
</button>
</p>
) : (
<AttnLine
action={{
label: t('members_invite_link_copy_action'),
onClick: () => void handleCopyInviteLink(shareInvite.url),
}}
>
{t('members_invite_link_not_sent', { email: shareInvite.email })}
</AttnLine>
))}
</div>
)}
{/* Inline invite: the list's own last row instead of a separate card. */}
{canInvite && (
<form onSubmit={handleInvite} className="flex flex-col gap-3 px-1 pt-3 sm:flex-row sm:items-center">