feat(web): add workspace management UI (M2 #12)

- Create workspace listing page at /settings/workspaces
  - List all user workspaces with role badges
  - Create new workspace functionality
  - Display member count per workspace

- Create workspace detail page at /settings/workspaces/[id]
  - Workspace settings (name, ID, created date)
  - Member management with role editing
  - Invite member functionality
  - Delete workspace (owner only)

- Add workspace components:
  - WorkspaceCard: Display workspace info with role badge
  - WorkspaceSettings: Edit workspace settings and delete
  - MemberList: Display and manage workspace members
  - InviteMember: Send invitations with role selection

- Add WorkspaceMemberWithUser type to shared package
- Follow existing app patterns for styling and structure
- Use mock data (ready for API integration)
This commit is contained in:
Jason Woltje
2026-01-29 16:59:26 -06:00
parent 287a0e2556
commit 5291fece26
43 changed files with 4152 additions and 99 deletions

View File

@@ -8,8 +8,18 @@
* @see docs/design/multi-tenant-rls.md for full documentation
*/
import { prisma } from '@mosaic/database';
import type { PrismaClient } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
// Global prisma instance for standalone usage
// Note: In NestJS controllers/services, inject PrismaService instead
let prisma: PrismaClient | null = null;
function getPrismaInstance(): PrismaClient {
if (!prisma) {
prisma = new PrismaClient();
}
return prisma;
}
/**
* Sets the current user ID for RLS policies.
@@ -26,9 +36,10 @@ import type { PrismaClient } from '@prisma/client';
*/
export async function setCurrentUser(
userId: string,
client: PrismaClient = prisma
client?: PrismaClient
): Promise<void> {
await client.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
const prismaClient = client || getPrismaInstance();
await prismaClient.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
}
/**
@@ -38,9 +49,10 @@ export async function setCurrentUser(
* @param client - Optional Prisma client (defaults to global prisma)
*/
export async function clearCurrentUser(
client: PrismaClient = prisma
client?: PrismaClient
): Promise<void> {
await client.$executeRaw`SET LOCAL app.current_user_id = NULL`;
const prismaClient = client || getPrismaInstance();
await prismaClient.$executeRaw`SET LOCAL app.current_user_id = NULL`;
}
/**
@@ -103,10 +115,11 @@ export async function withUserContext<T>(
*/
export async function withUserTransaction<T>(
userId: string,
fn: (tx: PrismaClient) => Promise<T>
fn: (tx: any) => Promise<T>
): Promise<T> {
return prisma.$transaction(async (tx) => {
await setCurrentUser(userId, tx);
const prismaClient = getPrismaInstance();
return prismaClient.$transaction(async (tx) => {
await setCurrentUser(userId, tx as PrismaClient);
return fn(tx);
});
}
@@ -155,8 +168,9 @@ export async function verifyWorkspaceAccess(
userId: string,
workspaceId: string
): Promise<boolean> {
const prismaClient = getPrismaInstance();
return withUserContext(userId, async () => {
const member = await prisma.workspaceMember.findUnique({
const member = await prismaClient.workspaceMember.findUnique({
where: {
workspaceId_userId: {
workspaceId,
@@ -181,8 +195,9 @@ export async function verifyWorkspaceAccess(
* ```
*/
export async function getUserWorkspaces(userId: string) {
const prismaClient = getPrismaInstance();
return withUserContext(userId, async () => {
return prisma.workspace.findMany({
return prismaClient.workspace.findMany({
include: {
members: {
where: { userId },
@@ -204,8 +219,9 @@ export async function isWorkspaceAdmin(
userId: string,
workspaceId: string
): Promise<boolean> {
const prismaClient = getPrismaInstance();
return withUserContext(userId, async () => {
const member = await prisma.workspaceMember.findUnique({
const member = await prismaClient.workspaceMember.findUnique({
where: {
workspaceId_userId: {
workspaceId,