mirror of
https://github.com/saltbo/zpan.git
synced 2026-10-29 18:05:37 +08:00
* 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>
123 lines
3.3 KiB
TypeScript
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
|
|
}
|