Files
zpan/server/services/team-invite.ts
T
878cdeb117 feat: team invitation via email and invite link (#302)
* feat: team invitation via email and invite link

- Add team invite dialog with email invite and shareable link tabs
- Email invite uses better-auth organizationClient.inviteMember() with configured email service
- Invite link generates a time-limited token stored in new team_invite_links table
- Accept invite page at /teams/invite?token=xxx (auto-join if logged in, redirect to sign-in if not)
- Pending invitations section shows all pending email invites; owners can cancel them
- Add editor/viewer custom roles to better-auth organization plugin
- Add sendInvitationEmail hook to send HTML invite email via configured email service
- Redirect-after-login support: _authenticated layout passes current URL to sign-in
- Add migration 0007_team_invite_links for new table
- Only team owners see the Invite Member button and pending invitations

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

* test: add integration tests for team invite service and routes

Cover createInviteLink, getInviteLinkInfo, acceptInviteLink, and
listPendingInvitations service functions. Add route tests for all
public and authenticated team invite endpoints (invite-info, invite-link,
invitations list, and join). Add team_invite_links table to test setup.

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

* style: fix biome lint in team invite test files

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

* fix: resolve CodeQL open-redirect and missing coverage issues

- Validate redirect param in sign-in.tsx is a same-origin relative path
  to prevent open redirect and javascript: URI XSS (CodeQL alerts)
- Spread defaultRoles (owner/admin/member) when configuring custom roles
  in organization plugin so built-in roles retain their permissions
- Add integration tests for sendInvitationEmail callback to cover
  buildInvitationEmailHtml and the email dispatch path in auth.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: use URL constructor to sanitize redirect param in sign-in

Replace regex check with URL constructor origin validation so CodeQL's
dataflow analysis can confirm the value is same-origin before it reaches
window.location.href (resolves js/xss and js/client-side-unvalidated-url-redirection alerts).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 13:55:26 -04:00

123 lines
3.3 KiB
TypeScript

import { and, desc, eq, gt, isNull, or } from 'drizzle-orm'
import { customAlphabet, nanoid } from 'nanoid'
import { invitation, member, organization } from '../db/auth-schema'
import { teamInviteLinks } from '../db/schema'
import type { Database } from '../platform/interface'
export type TeamInviteLink = typeof teamInviteLinks.$inferSelect
const generateToken = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 32)
const DEFAULT_EXPIRES_IN_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
export async function createInviteLink(
db: Database,
organizationId: string,
inviterId: string,
role: string,
expiresIn?: number,
): Promise<TeamInviteLink> {
const token = generateToken()
const now = new Date()
const expiresAt = new Date(now.getTime() + (expiresIn ?? DEFAULT_EXPIRES_IN_MS))
const row: TeamInviteLink = {
id: nanoid(),
token,
organizationId,
role,
inviterId,
expiresAt,
createdAt: now,
}
await db.insert(teamInviteLinks).values(row)
return row
}
export type InviteLinkInfo = {
organizationId: string
organizationName: string
role: string
expiresAt: Date | null
}
export async function getInviteLinkInfo(db: Database, token: string): Promise<InviteLinkInfo | null> {
const rows = await db
.select({
organizationId: teamInviteLinks.organizationId,
role: teamInviteLinks.role,
expiresAt: teamInviteLinks.expiresAt,
organizationName: organization.name,
})
.from(teamInviteLinks)
.innerJoin(organization, eq(teamInviteLinks.organizationId, organization.id))
.where(
and(
eq(teamInviteLinks.token, token),
or(isNull(teamInviteLinks.expiresAt), gt(teamInviteLinks.expiresAt, new Date())),
),
)
.limit(1)
const row = rows[0]
if (!row) return null
return {
organizationId: row.organizationId,
organizationName: row.organizationName,
role: row.role,
expiresAt: row.expiresAt,
}
}
export type AcceptResult = 'ok' | 'invalid' | 'expired' | 'already_member'
export async function acceptInviteLink(db: Database, token: string, userId: string): Promise<AcceptResult> {
const rows = await db.select().from(teamInviteLinks).where(eq(teamInviteLinks.token, token)).limit(1)
const link = rows[0]
if (!link) return 'invalid'
if (link.expiresAt && link.expiresAt < new Date()) return 'expired'
const existing = await db
.select({ id: member.id })
.from(member)
.where(and(eq(member.organizationId, link.organizationId), eq(member.userId, userId)))
.limit(1)
if (existing[0]) return 'already_member'
await db.insert(member).values({
id: nanoid(),
organizationId: link.organizationId,
userId,
role: link.role,
createdAt: new Date(),
})
return 'ok'
}
export type PendingInvitation = {
id: string
email: string
role: string
expiresAt: Date | null
createdAt: Date
}
export async function listPendingInvitations(db: Database, organizationId: string): Promise<PendingInvitation[]> {
const rows = await db
.select({
id: invitation.id,
email: invitation.email,
role: invitation.role,
expiresAt: invitation.expiresAt,
createdAt: invitation.createdAt,
})
.from(invitation)
.where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending')))
.orderBy(desc(invitation.createdAt))
return rows
}