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
@@ -100,6 +100,7 @@ beforeEach(() => {
})
afterEach(() => {
vi.unstubAllEnvs()
delete process.env.AUTH_SIGNUPS_DISABLED
if (originalAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL
else process.env.NEXT_PUBLIC_APP_URL = originalAppUrl
@@ -154,7 +155,7 @@ describe('POST /api/company/members/invite', () => {
enqueue({ data: null }) // insert invitation
const { status, body } = await parseJsonResponse<{
data: { email: string; email_sent: boolean }
data: { email: string; email_sent: boolean; inviteUrl: string }
}>(await post({ email: 'Client@Example.com', role: 'viewer' }))
expect(status).toBe(200)
@@ -163,6 +164,10 @@ describe('POST /api/company/members/invite', () => {
expect(sendEmailMock).toHaveBeenCalledWith(
expect.objectContaining({ to: 'client@example.com' })
)
// The accept link is always returned (vitest runs with NODE_ENV=test,
// so this pins the removal of the old development-only gate): the
// inviter can share it directly even when the mail went out.
expect(body.data.inviteUrl).toBe('https://app.accounted.test/invite/tok-plain')
})
it('sends the branded invite: brand link base, brand appName, brand sender', async () => {
@@ -180,7 +185,7 @@ describe('POST /api/company/members/invite', () => {
enqueue({ data: { name: 'Kund AB' } })
enqueue({ data: null })
const { status } = await parseJsonResponse(
const { status, body } = await parseJsonResponse<{ data: { inviteUrl: string } }>(
await post({ email: 'client@example.com' })
)
@@ -191,6 +196,8 @@ describe('POST /api/company/members/invite', () => {
appName: 'Siffra',
})
)
// The returned link follows the brand base too, same as the mailed one.
expect(body.data.inviteUrl).toBe('https://app.siffra.se/invite/tok-plain')
expect(sendEmailMock).toHaveBeenCalledWith(
expect.objectContaining({
fromName: 'Siffra',
@@ -233,12 +240,54 @@ describe('POST /api/company/members/invite', () => {
sendEmailMock.mockResolvedValue({ success: false, error: 'smtp down' })
const { status, body } = await parseJsonResponse<{
data: { email_sent: boolean; status: string }
data: { email_sent: boolean; status: string; inviteUrl: string }
}>(await post({ email: 'client@example.com' }))
expect(status).toBe(200)
expect(body.data.status).toBe('pending')
expect(body.data.email_sent).toBe(false)
// The link is the inviter's recovery path when the mail bounced.
expect(body.data.inviteUrl).toBe('https://app.accounted.test/invite/tok-plain')
})
it('no mail provider configured: invite created, link returned, nothing sent, warn logged (#1710)', async () => {
// NODE_ENV=production is the Docker image's setting and exactly the case
// the old development-only gate hid the link from. It also lets the
// logger emit (info/warn are suppressed under NODE_ENV=test).
vi.stubEnv('NODE_ENV', 'production')
isConfiguredMock.mockReturnValue(false)
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
enqueue({ data: { role: 'owner' } })
enqueue({ data: [] })
enqueue({ data: null })
enqueue({ data: { name: 'Acme AB' } })
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{
data: {
email_sent: boolean
user_provisioned: boolean
status: string
inviteUrl: string
}
}>(await post({ email: 'client@example.com' }))
expect(status).toBe(200)
expect(body.data.status).toBe('pending')
expect(body.data.email_sent).toBe(false)
expect(body.data.user_provisioned).toBe(false)
// Self-hosted without Resend: the in-band link is the ONLY way the
// invitee can ever accept, so it must be present outside development.
expect(body.data.inviteUrl).toBe('https://app.accounted.test/invite/tok-plain')
expect(sendEmailMock).not.toHaveBeenCalled()
const warned = consoleWarnSpy.mock.calls
.flat()
.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg)))
.join(' ')
expect(warned).toContain('email service not configured')
// The raw token never reaches the log: it is stored hashed at rest.
expect(warned).not.toContain('tok-plain')
consoleWarnSpy.mockRestore()
})
it('uses a registered white-label request host in the invitation email', async () => {
@@ -466,4 +515,33 @@ describe('POST /api/company/members/invite: AUTH_SIGNUPS_DISABLED provisioning',
expect(body.data.user_provisioned).toBe(false)
expect(body.data.email_sent).toBe(true)
})
it('flag on + no account + no mail provider: GoTrue provisions and mails, app sends nothing, link still returned', async () => {
process.env.AUTH_SIGNUPS_DISABLED = 'true'
isConfiguredMock.mockReturnValue(false)
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
enqueue({ data: { role: 'owner' } }) // caller membership
enqueue({ data: [] }) // existing members
enqueue({ data: null }) // existing invite
enqueue({ data: { name: 'Acme AB' } }) // company name
enqueue({ data: false }) // rpc check_email_exists -> no account
enqueue({ data: null }) // insert invitation
const { status, body } = await parseJsonResponse<{
data: { email_sent: boolean; user_provisioned: boolean; inviteUrl: string }
}>(await post({ email: 'client@example.com' }))
expect(status).toBe(200)
expect(inviteUserByEmailMock).toHaveBeenCalledWith('client@example.com', {
redirectTo: 'https://app.accounted.test/invite/tok-plain',
})
// email_sent reports the APP's mail only: GoTrue sent its own invite
// mail via the Supabase SMTP settings, so the UI must not call this a
// failed send (it branches on email_sent === false && !user_provisioned).
expect(body.data.user_provisioned).toBe(true)
expect(body.data.email_sent).toBe(false)
expect(sendEmailMock).not.toHaveBeenCalled()
expect(body.data.inviteUrl).toBe('https://app.accounted.test/invite/tok-plain')
consoleWarnSpy.mockRestore()
})
})
+15 -8
View File
@@ -43,6 +43,14 @@ function maskEmail(email: string): string {
* POST /api/company/members/invite
* Invite a user to the current company (e.g., a client as viewer).
* Only company owners and admins can invite.
*
* The accept link (inviteUrl) is ALWAYS returned in the response, whether or
* not the invitation email went out, so the inviter can share it directly.
* This is the only place the raw link exists: tokens are stored hashed
* (lib/auth/invite-tokens.ts), so a self-hosted operator without a mail
* provider (#1710) or an inviter whose mail bounced would otherwise hold an
* invitation nobody can accept. There is no re-send for company invites:
* revoke and invite again to get a fresh link.
*/
export const POST = withRouteContext(
'company_members.invite',
@@ -50,7 +58,7 @@ export const POST = withRouteContext(
const { companyId, user, log } = ctx
const serviceClient = await createServiceClient()
// Check caller has permission (owner/admin — stricter than requireWrite)
// Check caller has permission (owner/admin: stricter than requireWrite)
const { data: callerMembership } = await serviceClient
.from('company_members')
.select('role')
@@ -220,11 +228,10 @@ export const POST = withRouteContext(
// before.
const sender = await getSenderForCompany(companyId)
const appUrl = sender.brand ? getBaseUrlForBrand(sender.brand) : appOrigin
const inviteUrl = `${appUrl}/invite/${token}`
const emailService = getEmailService()
let emailSent = false
if (emailService.isConfigured()) {
const inviteUrl = `${appUrl}/invite/${token}`
const emailData = {
companyName: company?.name || 'Företag',
inviterEmail: user.email || '',
@@ -252,17 +259,17 @@ export const POST = withRouteContext(
log.warn('email service not configured: invite email skipped', { to: email })
}
// In development, return the invite URL directly (no email service)
const isDev = process.env.NODE_ENV === 'development'
const devInviteUrl = isDev ? `${appUrl}/invite/${token}` : undefined
return NextResponse.json({
data: {
email,
status: 'pending',
email_sent: emailSent,
user_provisioned: userProvisioned,
...(isDev && { inviteUrl: devInviteUrl }),
// The accept link is always returned so the inviter can share it
// directly, e.g. when the mail bounced or no mail provider is
// configured (self-hosted without Resend). Same contract as
// POST /api/team/invite.
inviteUrl,
},
})
},