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:
2
apps/api/src/common/guards/index.ts
Normal file
2
apps/api/src/common/guards/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./workspace.guard";
|
||||
export * from "./permission.guard";
|
||||
278
apps/api/src/common/guards/permission.guard.spec.ts
Normal file
278
apps/api/src/common/guards/permission.guard.spec.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { PermissionGuard } from "./permission.guard";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { Permission } from "../decorators/permissions.decorator";
|
||||
import { WorkspaceMemberRole } from "@prisma/client";
|
||||
|
||||
describe("PermissionGuard", () => {
|
||||
let guard: PermissionGuard;
|
||||
let reflector: Reflector;
|
||||
let prismaService: PrismaService;
|
||||
|
||||
const mockReflector = {
|
||||
getAllAndOverride: vi.fn(),
|
||||
};
|
||||
|
||||
const mockPrismaService = {
|
||||
workspaceMember: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PermissionGuard,
|
||||
{
|
||||
provide: Reflector,
|
||||
useValue: mockReflector,
|
||||
},
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = module.get<PermissionGuard>(PermissionGuard);
|
||||
reflector = module.get<Reflector>(Reflector);
|
||||
prismaService = module.get<PrismaService>(PrismaService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const createMockExecutionContext = (
|
||||
user: any,
|
||||
workspace: any
|
||||
): ExecutionContext => {
|
||||
const mockRequest = {
|
||||
user,
|
||||
workspace,
|
||||
};
|
||||
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => mockRequest,
|
||||
}),
|
||||
getHandler: vi.fn(),
|
||||
getClass: vi.fn(),
|
||||
} as any;
|
||||
};
|
||||
|
||||
describe("canActivate", () => {
|
||||
const userId = "user-123";
|
||||
const workspaceId = "workspace-456";
|
||||
|
||||
it("should allow access when no permission is required", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(undefined);
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow OWNER to access WORKSPACE_OWNER permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_OWNER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
});
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(context.switchToHttp().getRequest().user.workspaceRole).toBe(
|
||||
WorkspaceMemberRole.OWNER
|
||||
);
|
||||
});
|
||||
|
||||
it("should deny ADMIN access to WORKSPACE_OWNER permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_OWNER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow OWNER and ADMIN to access WORKSPACE_ADMIN permission", async () => {
|
||||
const context1 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context2 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_ADMIN);
|
||||
|
||||
// Test OWNER
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
});
|
||||
expect(await guard.canActivate(context1)).toBe(true);
|
||||
|
||||
// Test ADMIN
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
});
|
||||
expect(await guard.canActivate(context2)).toBe(true);
|
||||
});
|
||||
|
||||
it("should deny MEMBER access to WORKSPACE_ADMIN permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_ADMIN);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.MEMBER,
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow OWNER, ADMIN, and MEMBER to access WORKSPACE_MEMBER permission", async () => {
|
||||
const context1 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context2 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
const context3 = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
|
||||
// Test OWNER
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
});
|
||||
expect(await guard.canActivate(context1)).toBe(true);
|
||||
|
||||
// Test ADMIN
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
});
|
||||
expect(await guard.canActivate(context2)).toBe(true);
|
||||
|
||||
// Test MEMBER
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.MEMBER,
|
||||
});
|
||||
expect(await guard.canActivate(context3)).toBe(true);
|
||||
});
|
||||
|
||||
it("should deny GUEST access to WORKSPACE_MEMBER permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.GUEST,
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow any role (including GUEST) to access WORKSPACE_ANY permission", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_ANY);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
role: WorkspaceMemberRole.GUEST,
|
||||
});
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when user context is missing", async () => {
|
||||
const context = createMockExecutionContext(null, { id: workspaceId });
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when workspace context is missing", async () => {
|
||||
const context = createMockExecutionContext({ id: userId }, null);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when user is not a workspace member", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"You are not a member of this workspace"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle database errors gracefully", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ id: workspaceId }
|
||||
);
|
||||
|
||||
mockReflector.getAllAndOverride.mockReturnValue(Permission.WORKSPACE_MEMBER);
|
||||
mockPrismaService.workspaceMember.findUnique.mockRejectedValue(
|
||||
new Error("Database error")
|
||||
);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
165
apps/api/src/common/guards/permission.guard.ts
Normal file
165
apps/api/src/common/guards/permission.guard.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { PERMISSION_KEY, Permission } from "../decorators/permissions.decorator";
|
||||
import { WorkspaceMemberRole } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* PermissionGuard enforces role-based access control for workspace operations.
|
||||
*
|
||||
* This guard must be used after AuthGuard and WorkspaceGuard, as it depends on:
|
||||
* - request.user.id (set by AuthGuard)
|
||||
* - request.workspace.id (set by WorkspaceGuard)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Controller('workspaces')
|
||||
* @UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
* export class WorkspacesController {
|
||||
* @RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
* @Delete(':id')
|
||||
* async deleteWorkspace() {
|
||||
* // Only ADMIN or OWNER can execute this
|
||||
* }
|
||||
*
|
||||
* @RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
* @Get('tasks')
|
||||
* async getTasks() {
|
||||
* // Any workspace member can execute this
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
private readonly logger = new Logger(PermissionGuard.name);
|
||||
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly prisma: PrismaService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
// Get required permission from decorator
|
||||
const requiredPermission = this.reflector.getAllAndOverride<Permission>(
|
||||
PERMISSION_KEY,
|
||||
[context.getHandler(), context.getClass()]
|
||||
);
|
||||
|
||||
// If no permission is specified, allow access
|
||||
if (!requiredPermission) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const userId = request.user?.id;
|
||||
const workspaceId = request.workspace?.id;
|
||||
|
||||
if (!userId || !workspaceId) {
|
||||
this.logger.error(
|
||||
"PermissionGuard: Missing user or workspace context. Ensure AuthGuard and WorkspaceGuard are applied first."
|
||||
);
|
||||
throw new ForbiddenException(
|
||||
"Authentication and workspace context required"
|
||||
);
|
||||
}
|
||||
|
||||
// Get user's role in the workspace
|
||||
const userRole = await this.getUserWorkspaceRole(userId, workspaceId);
|
||||
|
||||
if (!userRole) {
|
||||
throw new ForbiddenException("You are not a member of this workspace");
|
||||
}
|
||||
|
||||
// Check if user's role meets the required permission
|
||||
const hasPermission = this.checkPermission(userRole, requiredPermission);
|
||||
|
||||
if (!hasPermission) {
|
||||
this.logger.warn(
|
||||
`Permission denied: User ${userId} with role ${userRole} attempted to access ${requiredPermission} in workspace ${workspaceId}`
|
||||
);
|
||||
throw new ForbiddenException(
|
||||
`Insufficient permissions. Required: ${requiredPermission}`
|
||||
);
|
||||
}
|
||||
|
||||
// Attach role to request for convenience
|
||||
request.user.workspaceRole = userRole;
|
||||
|
||||
this.logger.debug(
|
||||
`Permission granted: User ${userId} (${userRole}) → ${requiredPermission}`
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user's role in a workspace
|
||||
*/
|
||||
private async getUserWorkspaceRole(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
): Promise<WorkspaceMemberRole | null> {
|
||||
try {
|
||||
const member = await this.prisma.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
return member?.role ?? null;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to fetch user role: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user's role satisfies the required permission level
|
||||
*/
|
||||
private checkPermission(
|
||||
userRole: WorkspaceMemberRole,
|
||||
requiredPermission: Permission
|
||||
): boolean {
|
||||
switch (requiredPermission) {
|
||||
case Permission.WORKSPACE_OWNER:
|
||||
return userRole === WorkspaceMemberRole.OWNER;
|
||||
|
||||
case Permission.WORKSPACE_ADMIN:
|
||||
return (
|
||||
userRole === WorkspaceMemberRole.OWNER ||
|
||||
userRole === WorkspaceMemberRole.ADMIN
|
||||
);
|
||||
|
||||
case Permission.WORKSPACE_MEMBER:
|
||||
return (
|
||||
userRole === WorkspaceMemberRole.OWNER ||
|
||||
userRole === WorkspaceMemberRole.ADMIN ||
|
||||
userRole === WorkspaceMemberRole.MEMBER
|
||||
);
|
||||
|
||||
case Permission.WORKSPACE_ANY:
|
||||
// Any role including GUEST
|
||||
return true;
|
||||
|
||||
default:
|
||||
this.logger.error(`Unknown permission: ${requiredPermission}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
219
apps/api/src/common/guards/workspace.guard.spec.ts
Normal file
219
apps/api/src/common/guards/workspace.guard.spec.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { ExecutionContext, ForbiddenException, BadRequestException } from "@nestjs/common";
|
||||
import { WorkspaceGuard } from "./workspace.guard";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import * as dbContext from "../../lib/db-context";
|
||||
|
||||
// Mock the db-context module
|
||||
vi.mock("../../lib/db-context", () => ({
|
||||
setCurrentUser: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("WorkspaceGuard", () => {
|
||||
let guard: WorkspaceGuard;
|
||||
let prismaService: PrismaService;
|
||||
|
||||
const mockPrismaService = {
|
||||
workspaceMember: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
$executeRaw: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WorkspaceGuard,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = module.get<WorkspaceGuard>(WorkspaceGuard);
|
||||
prismaService = module.get<PrismaService>(PrismaService);
|
||||
|
||||
// Clear all mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const createMockExecutionContext = (
|
||||
user: any,
|
||||
headers: Record<string, string> = {},
|
||||
params: Record<string, string> = {},
|
||||
body: Record<string, any> = {}
|
||||
): ExecutionContext => {
|
||||
const mockRequest = {
|
||||
user,
|
||||
headers,
|
||||
params,
|
||||
body,
|
||||
};
|
||||
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => mockRequest,
|
||||
}),
|
||||
} as ExecutionContext;
|
||||
};
|
||||
|
||||
describe("canActivate", () => {
|
||||
const userId = "user-123";
|
||||
const workspaceId = "workspace-456";
|
||||
|
||||
it("should allow access when user is a workspace member (via header)", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ "x-workspace-id": workspaceId }
|
||||
);
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
workspaceId,
|
||||
userId,
|
||||
role: "MEMBER",
|
||||
});
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPrismaService.workspaceMember.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(dbContext.setCurrentUser).toHaveBeenCalledWith(userId, prismaService);
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
expect(request.workspace).toEqual({ id: workspaceId });
|
||||
expect(request.user.workspaceId).toBe(workspaceId);
|
||||
});
|
||||
|
||||
it("should allow access when user is a workspace member (via URL param)", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{},
|
||||
{ workspaceId }
|
||||
);
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
workspaceId,
|
||||
userId,
|
||||
role: "ADMIN",
|
||||
});
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow access when user is a workspace member (via body)", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{},
|
||||
{},
|
||||
{ workspaceId }
|
||||
);
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
workspaceId,
|
||||
userId,
|
||||
role: "OWNER",
|
||||
});
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should prioritize header over param and body", async () => {
|
||||
const headerWorkspaceId = "workspace-header";
|
||||
const paramWorkspaceId = "workspace-param";
|
||||
const bodyWorkspaceId = "workspace-body";
|
||||
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ "x-workspace-id": headerWorkspaceId },
|
||||
{ workspaceId: paramWorkspaceId },
|
||||
{ workspaceId: bodyWorkspaceId }
|
||||
);
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({
|
||||
workspaceId: headerWorkspaceId,
|
||||
userId,
|
||||
role: "MEMBER",
|
||||
});
|
||||
|
||||
await guard.canActivate(context);
|
||||
|
||||
expect(mockPrismaService.workspaceMember.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: headerWorkspaceId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when user is not authenticated", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
null,
|
||||
{ "x-workspace-id": workspaceId }
|
||||
);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"User not authenticated"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException when workspace ID is missing", async () => {
|
||||
const context = createMockExecutionContext({ id: userId });
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"Workspace ID is required"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when user is not a workspace member", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ "x-workspace-id": workspaceId }
|
||||
);
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
"You do not have access to this workspace"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle database errors gracefully", async () => {
|
||||
const context = createMockExecutionContext(
|
||||
{ id: userId },
|
||||
{ "x-workspace-id": workspaceId }
|
||||
);
|
||||
|
||||
mockPrismaService.workspaceMember.findUnique.mockRejectedValue(
|
||||
new Error("Database connection failed")
|
||||
);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
150
apps/api/src/common/guards/workspace.guard.ts
Normal file
150
apps/api/src/common/guards/workspace.guard.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { setCurrentUser } from "../../lib/db-context";
|
||||
|
||||
/**
|
||||
* WorkspaceGuard ensures that:
|
||||
* 1. A workspace is specified in the request (header, param, or body)
|
||||
* 2. The authenticated user is a member of that workspace
|
||||
* 3. The user context is set for Row-Level Security (RLS)
|
||||
*
|
||||
* This guard should be used in combination with AuthGuard:
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Controller('tasks')
|
||||
* @UseGuards(AuthGuard, WorkspaceGuard)
|
||||
* export class TasksController {
|
||||
* @Get()
|
||||
* async getTasks(@Workspace() workspaceId: string) {
|
||||
* // workspaceId is verified and available
|
||||
* // RLS context is automatically set
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* The workspace ID can be provided via:
|
||||
* - Header: `X-Workspace-Id`
|
||||
* - URL parameter: `:workspaceId`
|
||||
* - Request body: `workspaceId` field
|
||||
*
|
||||
* Priority: Header > Param > Body
|
||||
*/
|
||||
@Injectable()
|
||||
export class WorkspaceGuard implements CanActivate {
|
||||
private readonly logger = new Logger(WorkspaceGuard.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
if (!user || !user.id) {
|
||||
throw new ForbiddenException("User not authenticated");
|
||||
}
|
||||
|
||||
// Extract workspace ID from request
|
||||
const workspaceId = this.extractWorkspaceId(request);
|
||||
|
||||
if (!workspaceId) {
|
||||
throw new BadRequestException(
|
||||
"Workspace ID is required (via header X-Workspace-Id, URL parameter, or request body)"
|
||||
);
|
||||
}
|
||||
|
||||
// Verify user is a member of the workspace
|
||||
const isMember = await this.verifyWorkspaceMembership(
|
||||
user.id,
|
||||
workspaceId
|
||||
);
|
||||
|
||||
if (!isMember) {
|
||||
this.logger.warn(
|
||||
`Access denied: User ${user.id} is not a member of workspace ${workspaceId}`
|
||||
);
|
||||
throw new ForbiddenException(
|
||||
"You do not have access to this workspace"
|
||||
);
|
||||
}
|
||||
|
||||
// Set RLS context for this request
|
||||
await setCurrentUser(user.id, this.prisma);
|
||||
|
||||
// Attach workspace info to request for convenience
|
||||
request.workspace = {
|
||||
id: workspaceId,
|
||||
};
|
||||
|
||||
// Also attach workspaceId to user object for backward compatibility
|
||||
request.user.workspaceId = workspaceId;
|
||||
|
||||
this.logger.debug(
|
||||
`Workspace access granted: User ${user.id} → Workspace ${workspaceId}`
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts workspace ID from request in order of priority:
|
||||
* 1. X-Workspace-Id header
|
||||
* 2. :workspaceId URL parameter
|
||||
* 3. workspaceId in request body
|
||||
*/
|
||||
private extractWorkspaceId(request: any): string | undefined {
|
||||
// 1. Check header
|
||||
const headerWorkspaceId = request.headers["x-workspace-id"];
|
||||
if (headerWorkspaceId) {
|
||||
return headerWorkspaceId;
|
||||
}
|
||||
|
||||
// 2. Check URL params
|
||||
const paramWorkspaceId = request.params?.workspaceId;
|
||||
if (paramWorkspaceId) {
|
||||
return paramWorkspaceId;
|
||||
}
|
||||
|
||||
// 3. Check request body
|
||||
const bodyWorkspaceId = request.body?.workspaceId;
|
||||
if (bodyWorkspaceId) {
|
||||
return bodyWorkspaceId;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that a user is a member of the specified workspace
|
||||
*/
|
||||
private async verifyWorkspaceMembership(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const member = await this.prisma.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return member !== null;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to verify workspace membership: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user