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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user