Shareable invite codes (e.g. hps-k7m2x9) let anyone join an org after authenticating. Admins create/revoke links from Settings > Team. Public /join/[code] route handles acceptance with expiry and max-use limits.
360 lines
11 KiB
TypeScript
360 lines
11 KiB
TypeScript
"use server"
|
|
|
|
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
|
import { getDb } from "@/db"
|
|
import {
|
|
organizationInvites,
|
|
organizationMembers,
|
|
organizations,
|
|
users,
|
|
type NewOrganizationInvite,
|
|
} from "@/db/schema"
|
|
import { getCurrentUser } from "@/lib/auth"
|
|
import { requirePermission } from "@/lib/permissions"
|
|
import { isDemoUser } from "@/lib/demo"
|
|
import { eq, and, desc } from "drizzle-orm"
|
|
import { revalidatePath } from "next/cache"
|
|
import { cookies } from "next/headers"
|
|
|
|
// unambiguous charset — no 0/O/1/I/l
|
|
const CODE_CHARS = "23456789abcdefghjkmnpqrstuvwxyz"
|
|
|
|
function generateInviteCode(orgSlug: string): string {
|
|
const prefix = orgSlug.replace(/[^a-z0-9]/g, "").slice(0, 3)
|
|
const suffix = Array.from(
|
|
{ length: 6 },
|
|
() => CODE_CHARS[Math.floor(Math.random() * CODE_CHARS.length)]
|
|
).join("")
|
|
return `${prefix}-${suffix}`
|
|
}
|
|
|
|
// --- createInvite ---
|
|
|
|
export async function createInvite(
|
|
role: string,
|
|
maxUses?: number,
|
|
expiresAt?: string
|
|
): Promise<{ success: boolean; error?: string; data?: { code: string; url: string } }> {
|
|
try {
|
|
const currentUser = await getCurrentUser()
|
|
if (!currentUser) return { success: false, error: "Unauthorized" }
|
|
if (isDemoUser(currentUser.id)) return { success: false, error: "DEMO_READ_ONLY" }
|
|
requirePermission(currentUser, "organization", "create")
|
|
|
|
if (!currentUser.organizationId) {
|
|
return { success: false, error: "No active organization" }
|
|
}
|
|
|
|
const { env } = await getCloudflareContext()
|
|
if (!env?.DB) return { success: false, error: "Database not available" }
|
|
|
|
const db = getDb(env.DB)
|
|
|
|
const org = await db
|
|
.select({ slug: organizations.slug })
|
|
.from(organizations)
|
|
.where(eq(organizations.id, currentUser.organizationId))
|
|
.get()
|
|
|
|
if (!org) return { success: false, error: "Organization not found" }
|
|
|
|
const code = generateInviteCode(org.slug)
|
|
const now = new Date().toISOString()
|
|
|
|
const invite: NewOrganizationInvite = {
|
|
id: crypto.randomUUID(),
|
|
organizationId: currentUser.organizationId,
|
|
code,
|
|
role,
|
|
maxUses: maxUses ?? null,
|
|
useCount: 0,
|
|
expiresAt: expiresAt ?? null,
|
|
createdBy: currentUser.id,
|
|
isActive: true,
|
|
createdAt: now,
|
|
}
|
|
|
|
await db.insert(organizationInvites).values(invite).run()
|
|
|
|
revalidatePath("/dashboard/settings")
|
|
return {
|
|
success: true,
|
|
data: {
|
|
code,
|
|
url: `/join/${code}`,
|
|
},
|
|
}
|
|
} catch (error) {
|
|
console.error("Error creating invite:", error)
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- getOrgInvites ---
|
|
|
|
export async function getOrgInvites(): Promise<{
|
|
success: boolean
|
|
error?: string
|
|
data?: ReadonlyArray<{
|
|
readonly id: string
|
|
readonly code: string
|
|
readonly role: string
|
|
readonly maxUses: number | null
|
|
readonly useCount: number
|
|
readonly expiresAt: string | null
|
|
readonly isActive: boolean
|
|
readonly createdAt: string
|
|
readonly createdByName: string | null
|
|
}>
|
|
}> {
|
|
try {
|
|
const currentUser = await getCurrentUser()
|
|
if (!currentUser) return { success: false, error: "Unauthorized" }
|
|
requirePermission(currentUser, "organization", "read")
|
|
|
|
if (!currentUser.organizationId) {
|
|
return { success: false, error: "No active organization" }
|
|
}
|
|
|
|
const { env } = await getCloudflareContext()
|
|
if (!env?.DB) return { success: false, error: "Database not available" }
|
|
|
|
const db = getDb(env.DB)
|
|
|
|
const invites = await db
|
|
.select({
|
|
id: organizationInvites.id,
|
|
code: organizationInvites.code,
|
|
role: organizationInvites.role,
|
|
maxUses: organizationInvites.maxUses,
|
|
useCount: organizationInvites.useCount,
|
|
expiresAt: organizationInvites.expiresAt,
|
|
isActive: organizationInvites.isActive,
|
|
createdAt: organizationInvites.createdAt,
|
|
createdByName: users.displayName,
|
|
})
|
|
.from(organizationInvites)
|
|
.leftJoin(users, eq(organizationInvites.createdBy, users.id))
|
|
.where(eq(organizationInvites.organizationId, currentUser.organizationId))
|
|
.orderBy(desc(organizationInvites.createdAt))
|
|
|
|
return { success: true, data: invites }
|
|
} catch (error) {
|
|
console.error("Error fetching org invites:", error)
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- revokeInvite ---
|
|
|
|
export async function revokeInvite(
|
|
inviteId: string
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const currentUser = await getCurrentUser()
|
|
if (!currentUser) return { success: false, error: "Unauthorized" }
|
|
if (isDemoUser(currentUser.id)) return { success: false, error: "DEMO_READ_ONLY" }
|
|
requirePermission(currentUser, "organization", "update")
|
|
|
|
if (!currentUser.organizationId) {
|
|
return { success: false, error: "No active organization" }
|
|
}
|
|
|
|
const { env } = await getCloudflareContext()
|
|
if (!env?.DB) return { success: false, error: "Database not available" }
|
|
|
|
const db = getDb(env.DB)
|
|
|
|
// verify invite belongs to this org before revoking
|
|
const invite = await db
|
|
.select({ id: organizationInvites.id })
|
|
.from(organizationInvites)
|
|
.where(
|
|
and(
|
|
eq(organizationInvites.id, inviteId),
|
|
eq(organizationInvites.organizationId, currentUser.organizationId)
|
|
)
|
|
)
|
|
.get()
|
|
|
|
if (!invite) return { success: false, error: "Invite not found" }
|
|
|
|
await db
|
|
.update(organizationInvites)
|
|
.set({ isActive: false })
|
|
.where(eq(organizationInvites.id, inviteId))
|
|
.run()
|
|
|
|
revalidatePath("/dashboard/settings")
|
|
return { success: true }
|
|
} catch (error) {
|
|
console.error("Error revoking invite:", error)
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- getInviteByCode (public — no auth) ---
|
|
|
|
export async function getInviteByCode(code: string): Promise<{
|
|
success: boolean
|
|
error?: string
|
|
data?: {
|
|
readonly organizationName: string
|
|
readonly role: string
|
|
}
|
|
}> {
|
|
const INVALID = "This invite link is invalid or has expired"
|
|
try {
|
|
const { env } = await getCloudflareContext()
|
|
if (!env?.DB) return { success: false, error: INVALID }
|
|
|
|
const db = getDb(env.DB)
|
|
|
|
const row = await db
|
|
.select({
|
|
id: organizationInvites.id,
|
|
role: organizationInvites.role,
|
|
maxUses: organizationInvites.maxUses,
|
|
useCount: organizationInvites.useCount,
|
|
expiresAt: organizationInvites.expiresAt,
|
|
isActive: organizationInvites.isActive,
|
|
organizationName: organizations.name,
|
|
})
|
|
.from(organizationInvites)
|
|
.innerJoin(organizations, eq(organizationInvites.organizationId, organizations.id))
|
|
.where(eq(organizationInvites.code, code))
|
|
.get()
|
|
|
|
if (!row || !row.isActive) return { success: false, error: INVALID }
|
|
if (row.expiresAt && new Date(row.expiresAt) < new Date()) {
|
|
return { success: false, error: INVALID }
|
|
}
|
|
if (row.maxUses !== null && row.useCount >= row.maxUses) {
|
|
return { success: false, error: INVALID }
|
|
}
|
|
|
|
return { success: true, data: { organizationName: row.organizationName, role: row.role } }
|
|
} catch (error) {
|
|
console.error("Error looking up invite:", error)
|
|
return { success: false, error: INVALID }
|
|
}
|
|
}
|
|
|
|
// --- acceptInvite ---
|
|
|
|
export async function acceptInvite(code: string): Promise<{
|
|
success: boolean
|
|
error?: string
|
|
data?: { organizationId: string; organizationName: string }
|
|
}> {
|
|
const INVALID = "This invite link is invalid or has expired"
|
|
try {
|
|
const currentUser = await getCurrentUser()
|
|
if (!currentUser) return { success: false, error: "Unauthorized" }
|
|
if (isDemoUser(currentUser.id)) return { success: false, error: "DEMO_READ_ONLY" }
|
|
|
|
const { env } = await getCloudflareContext()
|
|
if (!env?.DB) return { success: false, error: "Database not available" }
|
|
|
|
const db = getDb(env.DB)
|
|
|
|
const invite = await db
|
|
.select({
|
|
id: organizationInvites.id,
|
|
organizationId: organizationInvites.organizationId,
|
|
role: organizationInvites.role,
|
|
maxUses: organizationInvites.maxUses,
|
|
useCount: organizationInvites.useCount,
|
|
expiresAt: organizationInvites.expiresAt,
|
|
isActive: organizationInvites.isActive,
|
|
organizationName: organizations.name,
|
|
})
|
|
.from(organizationInvites)
|
|
.innerJoin(organizations, eq(organizationInvites.organizationId, organizations.id))
|
|
.where(eq(organizationInvites.code, code))
|
|
.get()
|
|
|
|
if (!invite || !invite.isActive) return { success: false, error: INVALID }
|
|
if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) {
|
|
return { success: false, error: INVALID }
|
|
}
|
|
if (invite.maxUses !== null && invite.useCount >= invite.maxUses) {
|
|
return { success: false, error: INVALID }
|
|
}
|
|
|
|
// check user is not already a member
|
|
const existing = await db
|
|
.select({ id: organizationMembers.id })
|
|
.from(organizationMembers)
|
|
.where(
|
|
and(
|
|
eq(organizationMembers.organizationId, invite.organizationId),
|
|
eq(organizationMembers.userId, currentUser.id)
|
|
)
|
|
)
|
|
.get()
|
|
|
|
if (existing) {
|
|
return { success: false, error: "You are already a member of this organization" }
|
|
}
|
|
|
|
const now = new Date().toISOString()
|
|
|
|
await db
|
|
.insert(organizationMembers)
|
|
.values({
|
|
id: crypto.randomUUID(),
|
|
organizationId: invite.organizationId,
|
|
userId: currentUser.id,
|
|
role: invite.role,
|
|
joinedAt: now,
|
|
})
|
|
.run()
|
|
|
|
const newUseCount = invite.useCount + 1
|
|
const exhausted = invite.maxUses !== null && newUseCount >= invite.maxUses
|
|
|
|
await db
|
|
.update(organizationInvites)
|
|
.set({
|
|
useCount: newUseCount,
|
|
...(exhausted ? { isActive: false } : {}),
|
|
})
|
|
.where(eq(organizationInvites.id, invite.id))
|
|
.run()
|
|
|
|
const cookieStore = await cookies()
|
|
cookieStore.set("compass-active-org", invite.organizationId, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === "production",
|
|
sameSite: "lax",
|
|
path: "/",
|
|
maxAge: 60 * 60 * 24 * 365,
|
|
})
|
|
|
|
revalidatePath("/dashboard")
|
|
return {
|
|
success: true,
|
|
data: {
|
|
organizationId: invite.organizationId,
|
|
organizationName: invite.organizationName,
|
|
},
|
|
}
|
|
} catch (error) {
|
|
console.error("Error accepting invite:", error)
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
}
|
|
}
|
|
}
|