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

@@ -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