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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user