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:
314
apps/api/src/common/README.md
Normal file
314
apps/api/src/common/README.md
Normal file
@@ -0,0 +1,314 @@
|
||||
# Common Guards and Decorators
|
||||
|
||||
This directory contains shared guards and decorators for workspace-based permission management in the Mosaic Stack API.
|
||||
|
||||
## Overview
|
||||
|
||||
The permission system provides:
|
||||
- **Workspace isolation** via Row-Level Security (RLS)
|
||||
- **Role-based access control** (RBAC) using workspace member roles
|
||||
- **Declarative permission requirements** using decorators
|
||||
|
||||
## Guards
|
||||
|
||||
### AuthGuard
|
||||
|
||||
Located in `../auth/guards/auth.guard.ts`
|
||||
|
||||
Verifies user authentication and attaches user data to the request.
|
||||
|
||||
**Sets on request:**
|
||||
- `request.user` - Authenticated user object
|
||||
- `request.session` - User session data
|
||||
|
||||
### WorkspaceGuard
|
||||
|
||||
Validates workspace access and sets up RLS context.
|
||||
|
||||
**Responsibilities:**
|
||||
1. Extracts workspace ID from request (header, param, or body)
|
||||
2. Verifies user is a member of the workspace
|
||||
3. Sets the current user context for RLS policies
|
||||
4. Attaches workspace context to the request
|
||||
|
||||
**Sets on request:**
|
||||
- `request.workspace.id` - Validated workspace ID
|
||||
- `request.user.workspaceId` - Workspace ID (for backward compatibility)
|
||||
|
||||
**Workspace ID Sources (in priority order):**
|
||||
1. `X-Workspace-Id` header
|
||||
2. `:workspaceId` URL parameter
|
||||
3. `workspaceId` in request body
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
@Controller('tasks')
|
||||
@UseGuards(AuthGuard, WorkspaceGuard)
|
||||
export class TasksController {
|
||||
@Get()
|
||||
async getTasks(@Workspace() workspaceId: string) {
|
||||
// workspaceId is validated and RLS context is set
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PermissionGuard
|
||||
|
||||
Enforces role-based access control using workspace member roles.
|
||||
|
||||
**Responsibilities:**
|
||||
1. Reads required permission from `@RequirePermission()` decorator
|
||||
2. Fetches user's role in the workspace
|
||||
3. Checks if role satisfies the required permission
|
||||
4. Attaches role to request for convenience
|
||||
|
||||
**Sets on request:**
|
||||
- `request.user.workspaceRole` - User's role in the workspace
|
||||
|
||||
**Must be used after AuthGuard and WorkspaceGuard.**
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
@Controller('admin')
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class AdminController {
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
@Delete('data')
|
||||
async deleteData() {
|
||||
// Only ADMIN or OWNER can execute
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Decorators
|
||||
|
||||
### @RequirePermission(permission: Permission)
|
||||
|
||||
Specifies the minimum permission level required for a route.
|
||||
|
||||
**Permission Levels:**
|
||||
|
||||
| Permission | Allowed Roles | Use Case |
|
||||
|------------|--------------|----------|
|
||||
| `WORKSPACE_OWNER` | OWNER | Critical operations (delete workspace, transfer ownership) |
|
||||
| `WORKSPACE_ADMIN` | OWNER, ADMIN | Administrative functions (manage members, settings) |
|
||||
| `WORKSPACE_MEMBER` | OWNER, ADMIN, MEMBER | Standard operations (create/edit content) |
|
||||
| `WORKSPACE_ANY` | All roles including GUEST | Read-only or basic access |
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
@Post('invite')
|
||||
async inviteMember(@Body() inviteDto: InviteDto) {
|
||||
// Only admins can invite members
|
||||
}
|
||||
```
|
||||
|
||||
### @Workspace()
|
||||
|
||||
Parameter decorator to extract the validated workspace ID.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
@Get()
|
||||
async getTasks(@Workspace() workspaceId: string) {
|
||||
// workspaceId is guaranteed to be valid
|
||||
}
|
||||
```
|
||||
|
||||
### @WorkspaceContext()
|
||||
|
||||
Parameter decorator to extract the full workspace context.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
@Get()
|
||||
async getTasks(@WorkspaceContext() workspace: { id: string }) {
|
||||
console.log(workspace.id);
|
||||
}
|
||||
```
|
||||
|
||||
### @CurrentUser()
|
||||
|
||||
Located in `../auth/decorators/current-user.decorator.ts`
|
||||
|
||||
Extracts the authenticated user from the request.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
@Post()
|
||||
async create(@CurrentUser() user: any, @Body() dto: CreateDto) {
|
||||
// user contains authenticated user data
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Patterns
|
||||
|
||||
### Basic Controller Setup
|
||||
|
||||
```typescript
|
||||
import { Controller, Get, Post, UseGuards } from "@nestjs/common";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
|
||||
@Controller('resources')
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class ResourcesController {
|
||||
@Get()
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async list(@Workspace() workspaceId: string) {
|
||||
// All members can list
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async create(
|
||||
@Workspace() workspaceId: string,
|
||||
@CurrentUser() user: any,
|
||||
@Body() dto: CreateDto
|
||||
) {
|
||||
// Members and above can create
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
async delete(@Param('id') id: string) {
|
||||
// Only admins can delete
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Mixed Permissions
|
||||
|
||||
Different endpoints can have different permission requirements:
|
||||
|
||||
```typescript
|
||||
@Controller('projects')
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class ProjectsController {
|
||||
@Get()
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async list() { /* Anyone can view */ }
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async create() { /* Members can create */ }
|
||||
|
||||
@Patch('settings')
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
async updateSettings() { /* Only admins */ }
|
||||
|
||||
@Delete()
|
||||
@RequirePermission(Permission.WORKSPACE_OWNER)
|
||||
async deleteProject() { /* Only owner */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Workspace ID in Request
|
||||
|
||||
The workspace ID can be provided in multiple ways:
|
||||
|
||||
**Via Header (Recommended for SPAs):**
|
||||
```typescript
|
||||
// Frontend
|
||||
fetch('/api/tasks', {
|
||||
headers: {
|
||||
'Authorization': 'Bearer <token>',
|
||||
'X-Workspace-Id': 'workspace-uuid',
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Via URL Parameter:**
|
||||
```typescript
|
||||
@Get(':workspaceId/tasks')
|
||||
async getTasks(@Param('workspaceId') workspaceId: string) {
|
||||
// workspaceId extracted from URL
|
||||
}
|
||||
```
|
||||
|
||||
**Via Request Body:**
|
||||
```typescript
|
||||
@Post()
|
||||
async create(@Body() dto: { workspaceId: string; name: string }) {
|
||||
// workspaceId extracted from body
|
||||
}
|
||||
```
|
||||
|
||||
## Row-Level Security (RLS)
|
||||
|
||||
When `WorkspaceGuard` is applied, it automatically:
|
||||
1. Calls `setCurrentUser(userId)` to set the RLS context
|
||||
2. All subsequent database queries are automatically filtered by RLS policies
|
||||
3. Users can only access data in workspaces they're members of
|
||||
|
||||
**See:** `docs/design/multi-tenant-rls.md` for full RLS documentation.
|
||||
|
||||
## Testing
|
||||
|
||||
Tests are provided for both guards:
|
||||
- `workspace.guard.spec.ts` - WorkspaceGuard tests
|
||||
- `permission.guard.spec.ts` - PermissionGuard tests
|
||||
|
||||
**Run tests:**
|
||||
```bash
|
||||
npm test -- workspace.guard.spec
|
||||
npm test -- permission.guard.spec
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### WorkspaceGuard Errors
|
||||
|
||||
- `ForbiddenException("User not authenticated")` - No authenticated user
|
||||
- `BadRequestException("Workspace ID is required...")` - No workspace ID provided
|
||||
- `ForbiddenException("You do not have access to this workspace")` - User is not a workspace member
|
||||
|
||||
### PermissionGuard Errors
|
||||
|
||||
- `ForbiddenException("Authentication and workspace context required")` - Missing user or workspace context
|
||||
- `ForbiddenException("You are not a member of this workspace")` - User not found in workspace
|
||||
- `ForbiddenException("Insufficient permissions. Required: ...")` - User role doesn't meet requirements
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Before (Manual Checks):
|
||||
|
||||
```typescript
|
||||
@Get()
|
||||
async getTasks(@Request() req: any) {
|
||||
const workspaceId = req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Authentication required");
|
||||
}
|
||||
return this.tasksService.findAll(workspaceId);
|
||||
}
|
||||
```
|
||||
|
||||
### After (Guard-Based):
|
||||
|
||||
```typescript
|
||||
@Get()
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async getTasks(@Workspace() workspaceId: string) {
|
||||
return this.tasksService.findAll(workspaceId);
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
✅ **Declarative** - Permission requirements are clear from decorators
|
||||
✅ **DRY** - No repetitive auth/workspace checks in every handler
|
||||
✅ **Type-safe** - Workspace ID is guaranteed to exist when using `@Workspace()`
|
||||
✅ **Secure** - RLS context automatically set, defense in depth
|
||||
✅ **Testable** - Guards are independently testable
|
||||
✅ **Maintainable** - Permission changes in one place
|
||||
|
||||
## Related Files
|
||||
|
||||
- `apps/api/src/lib/db-context.ts` - RLS utility functions
|
||||
- `docs/design/multi-tenant-rls.md` - RLS architecture documentation
|
||||
- `apps/api/prisma/schema.prisma` - Database schema with role definitions
|
||||
Reference in New Issue
Block a user