Nicholai 6a1afd7b49
feat(people): add people management system (#28)
* feat(schema): add auth, people, and financial tables

Add users, organizations, teams, groups, and project
members tables. Extend customers/vendors with netsuite
fields. Add netsuite schema for invoices, bills,
payments, and credit memos. Include all migrations,
seeds, new UI primitives, and config updates.

* feat(auth): add WorkOS authentication system

Add login, signup, password reset, email verification,
and invitation flows via WorkOS AuthKit. Includes auth
middleware, permission helpers, dev mode fallbacks,
and auth page components.

* feat(people): add people management system

Add user, team, group, and organization management
with CRUD actions, dashboard pages, invite dialog,
user drawer, and role-based filtering. Includes
WorkOS invitation integration.

* ci: retrigger build

* fix: add mobile-list-card dependency for people-table

---------

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
2026-02-04 16:28:43 -07:00

449 lines
12 KiB
TypeScript
Executable File

"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db"
import {
users,
organizationMembers,
projectMembers,
teamMembers,
groupMembers,
teams,
groups,
type User,
type NewUser,
} from "@/db/schema"
import { getCurrentUser } from "@/lib/auth"
import { requirePermission } from "@/lib/permissions"
import { eq, and } from "drizzle-orm"
import { revalidatePath } from "next/cache"
export type UserWithRelations = User & {
teams: { id: string; name: string }[]
groups: { id: string; name: string; color: string | null }[]
projectCount: number
organizationCount: number
}
export async function getUsers(): Promise<UserWithRelations[]> {
try {
const currentUser = await getCurrentUser()
requirePermission(currentUser, "user", "read")
const { env } = await getCloudflareContext()
if (!env?.DB) return []
const db = getDb(env.DB)
// get all active users
const allUsers = await db.select().from(users).where(eq(users.isActive, true))
// for each user, fetch their teams, groups, and counts
const usersWithRelations = await Promise.all(
allUsers.map(async (user) => {
// get teams
const userTeams = await db
.select({ id: teams.id, name: teams.name })
.from(teamMembers)
.innerJoin(teams, eq(teamMembers.teamId, teams.id))
.where(eq(teamMembers.userId, user.id))
// get groups
const userGroups = await db
.select({ id: groups.id, name: groups.name, color: groups.color })
.from(groupMembers)
.innerJoin(groups, eq(groupMembers.groupId, groups.id))
.where(eq(groupMembers.userId, user.id))
// get project count
const projectCount = await db
.select()
.from(projectMembers)
.where(eq(projectMembers.userId, user.id))
.then((r) => r.length)
// get organization count
const organizationCount = await db
.select()
.from(organizationMembers)
.where(eq(organizationMembers.userId, user.id))
.then((r) => r.length)
return {
...user,
teams: userTeams,
groups: userGroups,
projectCount,
organizationCount,
}
})
)
return usersWithRelations
} catch (error) {
console.error("Error fetching users:", error)
return []
}
}
export async function updateUserRole(
userId: string,
role: string
): Promise<{ success: boolean; error?: string }> {
try {
const currentUser = await getCurrentUser()
requirePermission(currentUser, "user", "update")
const { env } = await getCloudflareContext()
if (!env?.DB) {
return { success: false, error: "Database not available" }
}
const db = getDb(env.DB)
const now = new Date().toISOString()
await db
.update(users)
.set({ role, updatedAt: now })
.where(eq(users.id, userId))
.run()
revalidatePath("/dashboard/people")
return { success: true }
} catch (error) {
console.error("Error updating user role:", error)
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}
export async function deactivateUser(
userId: string
): Promise<{ success: boolean; error?: string }> {
try {
const currentUser = await getCurrentUser()
requirePermission(currentUser, "user", "delete")
const { env } = await getCloudflareContext()
if (!env?.DB) {
return { success: false, error: "Database not available" }
}
const db = getDb(env.DB)
const now = new Date().toISOString()
await db
.update(users)
.set({ isActive: false, updatedAt: now })
.where(eq(users.id, userId))
.run()
revalidatePath("/dashboard/people")
return { success: true }
} catch (error) {
console.error("Error deactivating user:", error)
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}
export async function assignUserToProject(
userId: string,
projectId: string,
role: string
): Promise<{ success: boolean; error?: string }> {
try {
const currentUser = await getCurrentUser()
requirePermission(currentUser, "project", "update")
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 already assigned
const existing = await db
.select()
.from(projectMembers)
.where(
and(
eq(projectMembers.userId, userId),
eq(projectMembers.projectId, projectId)
)
)
.get()
if (existing) {
// update role
await db
.update(projectMembers)
.set({ role })
.where(
and(
eq(projectMembers.userId, userId),
eq(projectMembers.projectId, projectId)
)
)
.run()
} else {
// insert new assignment
await db
.insert(projectMembers)
.values({
id: crypto.randomUUID(),
userId,
projectId,
role,
assignedAt: now,
})
.run()
}
revalidatePath("/dashboard/people")
revalidatePath("/dashboard/projects")
return { success: true }
} catch (error) {
console.error("Error assigning user to project:", error)
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}
export async function assignUserToTeam(
userId: string,
teamId: string
): Promise<{ success: boolean; error?: string }> {
try {
const currentUser = await getCurrentUser()
requirePermission(currentUser, "team", "update")
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 already assigned
const existing = await db
.select()
.from(teamMembers)
.where(
and(eq(teamMembers.userId, userId), eq(teamMembers.teamId, teamId))
)
.get()
if (existing) {
return { success: false, error: "User already in team" }
}
// insert new assignment
await db
.insert(teamMembers)
.values({
id: crypto.randomUUID(),
userId,
teamId,
joinedAt: now,
})
.run()
revalidatePath("/dashboard/people")
return { success: true }
} catch (error) {
console.error("Error assigning user to team:", error)
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}
export async function assignUserToGroup(
userId: string,
groupId: string
): Promise<{ success: boolean; error?: string }> {
try {
const currentUser = await getCurrentUser()
requirePermission(currentUser, "group", "update")
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 already assigned
const existing = await db
.select()
.from(groupMembers)
.where(
and(eq(groupMembers.userId, userId), eq(groupMembers.groupId, groupId))
)
.get()
if (existing) {
return { success: false, error: "User already in group" }
}
// insert new assignment
await db
.insert(groupMembers)
.values({
id: crypto.randomUUID(),
userId,
groupId,
joinedAt: now,
})
.run()
revalidatePath("/dashboard/people")
return { success: true }
} catch (error) {
console.error("Error assigning user to group:", error)
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}
export async function inviteUser(
email: string,
role: string,
organizationId?: string
): Promise<{ success: boolean; error?: string }> {
try {
const currentUser = await getCurrentUser()
requirePermission(currentUser, "user", "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 user already exists
const existing = await db.select().from(users).where(eq(users.email, email)).get()
if (existing) {
return { success: false, error: "User already exists" }
}
// check if workos is configured
const envRecord = env as unknown as Record<string, string>
const isWorkOSConfigured =
envRecord.WORKOS_API_KEY &&
envRecord.WORKOS_CLIENT_ID &&
!envRecord.WORKOS_API_KEY.includes("placeholder")
if (isWorkOSConfigured) {
// send invitation through workos
try {
const { WorkOS } = await import("@workos-inc/node")
const workos = new WorkOS(envRecord.WORKOS_API_KEY)
// send invitation via workos
// note: when user accepts, they'll be created in workos
// and on first login, ensureUserExists() will sync them to our db
const invitation = await workos.userManagement.sendInvitation({
email,
})
// create pending user record in our db
const newUser: NewUser = {
id: crypto.randomUUID(), // temporary until workos creates real user
email,
role,
isActive: false, // inactive until they accept invite
createdAt: now,
updatedAt: now,
firstName: null,
lastName: null,
displayName: email.split("@")[0],
avatarUrl: null,
lastLoginAt: null,
}
await db.insert(users).values(newUser).run()
// if organization specified, add to organization
if (organizationId) {
await db
.insert(organizationMembers)
.values({
id: crypto.randomUUID(),
organizationId,
userId: newUser.id,
role,
joinedAt: now,
})
.run()
}
revalidatePath("/dashboard/people")
return { success: true }
} catch (workosError) {
console.error("WorkOS invitation error:", workosError)
return {
success: false,
error: "Failed to send invitation via WorkOS",
}
}
} else {
// development mode: just create user in db without sending email
const newUser: NewUser = {
id: crypto.randomUUID(),
email,
role,
isActive: true, // active immediately in dev mode
createdAt: now,
updatedAt: now,
firstName: null,
lastName: null,
displayName: email.split("@")[0],
avatarUrl: null,
lastLoginAt: null,
}
await db.insert(users).values(newUser).run()
if (organizationId) {
await db
.insert(organizationMembers)
.values({
id: crypto.randomUUID(),
organizationId,
userId: newUser.id,
role,
joinedAt: now,
})
.run()
}
revalidatePath("/dashboard/people")
return { success: true }
}
} catch (error) {
console.error("Error inviting user:", error)
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}