Add org-scoped data isolation across all server actions to prevent cross-org data leakage. Add read-only demo mode with mutation guards on all write endpoints. Multi-tenancy: - org filter on executeDashboardQueries (all query types) - org boundary checks on getChannel, joinChannel - searchMentionableUsers derives org from session - getConversationUsage scoped to user, not org-wide for admins - organizations table, members, org switcher component Demo mode: - /demo route sets strict sameSite cookie - isDemoUser guards on all mutation server actions - demo banner, CTA dialog, and gate components - seed script for demo org data Also: exclude scripts/ from tsconfig (fixes build), add multi-tenancy architecture documentation. Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
196 lines
5.3 KiB
TypeScript
Executable File
196 lines
5.3 KiB
TypeScript
Executable File
"use server"
|
|
|
|
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
|
import { getDb } from "@/db"
|
|
import { organizations, organizationMembers, type Organization, type NewOrganization } from "@/db/schema"
|
|
import { getCurrentUser } from "@/lib/auth"
|
|
import { requirePermission } from "@/lib/permissions"
|
|
import { eq, and } from "drizzle-orm"
|
|
import { revalidatePath } from "next/cache"
|
|
import { cookies } from "next/headers"
|
|
import { isDemoUser } from "@/lib/demo"
|
|
|
|
export async function getOrganizations(): Promise<Organization[]> {
|
|
try {
|
|
const currentUser = await getCurrentUser()
|
|
if (!currentUser) return []
|
|
requirePermission(currentUser, "organization", "read")
|
|
|
|
const { env } = await getCloudflareContext()
|
|
if (!env?.DB) return []
|
|
|
|
const db = getDb(env.DB)
|
|
|
|
// filter to orgs the user is a member of
|
|
const userOrgs = await db
|
|
.select({
|
|
id: organizations.id,
|
|
name: organizations.name,
|
|
slug: organizations.slug,
|
|
type: organizations.type,
|
|
logoUrl: organizations.logoUrl,
|
|
isActive: organizations.isActive,
|
|
createdAt: organizations.createdAt,
|
|
updatedAt: organizations.updatedAt,
|
|
})
|
|
.from(organizations)
|
|
.innerJoin(organizationMembers, eq(organizations.id, organizationMembers.organizationId))
|
|
.where(and(eq(organizations.isActive, true), eq(organizationMembers.userId, currentUser.id)))
|
|
|
|
return userOrgs
|
|
} catch (error) {
|
|
console.error("Error fetching organizations:", error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
export async function createOrganization(
|
|
name: string,
|
|
slug: string,
|
|
type: "internal" | "client" | "personal" | "demo"
|
|
): Promise<{ success: boolean; error?: string; data?: Organization }> {
|
|
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")
|
|
|
|
const { env } = await getCloudflareContext()
|
|
if (!env?.DB) {
|
|
return { success: false, error: "Database not available" }
|
|
}
|
|
|
|
const db = getDb(env.DB)
|
|
const now = new Date().toISOString()
|
|
|
|
// check if slug already exists
|
|
const existing = await db
|
|
.select()
|
|
.from(organizations)
|
|
.where(eq(organizations.slug, slug))
|
|
.get()
|
|
|
|
if (existing) {
|
|
return { success: false, error: "Organization slug already exists" }
|
|
}
|
|
|
|
const newOrg: NewOrganization = {
|
|
id: crypto.randomUUID(),
|
|
name,
|
|
slug,
|
|
type,
|
|
logoUrl: null,
|
|
isActive: true,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
|
|
await db.insert(organizations).values(newOrg).run()
|
|
|
|
revalidatePath("/dashboard/people")
|
|
return { success: true, data: newOrg as Organization }
|
|
} catch (error) {
|
|
console.error("Error creating organization:", error)
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function getUserOrganizations(): Promise<
|
|
ReadonlyArray<{
|
|
readonly id: string
|
|
readonly name: string
|
|
readonly slug: string
|
|
readonly type: string
|
|
readonly role: string
|
|
}>
|
|
> {
|
|
try {
|
|
const currentUser = await getCurrentUser()
|
|
if (!currentUser) return []
|
|
|
|
const { env } = await getCloudflareContext()
|
|
if (!env?.DB) return []
|
|
|
|
const db = getDb(env.DB)
|
|
const results = await db
|
|
.select({
|
|
id: organizations.id,
|
|
name: organizations.name,
|
|
slug: organizations.slug,
|
|
type: organizations.type,
|
|
role: organizationMembers.role,
|
|
})
|
|
.from(organizationMembers)
|
|
.innerJoin(
|
|
organizations,
|
|
eq(organizations.id, organizationMembers.organizationId)
|
|
)
|
|
.where(eq(organizationMembers.userId, currentUser.id))
|
|
|
|
return results
|
|
} catch (error) {
|
|
console.error("Error fetching user organizations:", error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
export async function switchOrganization(
|
|
orgId: string
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const currentUser = await getCurrentUser()
|
|
if (!currentUser) {
|
|
return { success: false, error: "Not authenticated" }
|
|
}
|
|
|
|
const { env } = await getCloudflareContext()
|
|
if (!env?.DB) {
|
|
return { success: false, error: "Database not available" }
|
|
}
|
|
|
|
const db = getDb(env.DB)
|
|
|
|
// verify user is member of target org
|
|
const membership = await db
|
|
.select()
|
|
.from(organizationMembers)
|
|
.where(
|
|
and(
|
|
eq(organizationMembers.organizationId, orgId),
|
|
eq(organizationMembers.userId, currentUser.id)
|
|
)
|
|
)
|
|
.get()
|
|
|
|
if (!membership) {
|
|
return { success: false, error: "Not a member of this organization" }
|
|
}
|
|
|
|
// set compass-active-org cookie
|
|
const cookieStore = await cookies()
|
|
cookieStore.set("compass-active-org", orgId, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === "production",
|
|
sameSite: "lax",
|
|
path: "/",
|
|
maxAge: 60 * 60 * 24 * 365, // 1 year
|
|
})
|
|
|
|
revalidatePath("/dashboard")
|
|
return { success: true }
|
|
} catch (error) {
|
|
console.error("Error switching organization:", error)
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
}
|
|
}
|
|
}
|