diff --git a/.env.example b/.env.example index 1700b65..36dbd73 100644 --- a/.env.example +++ b/.env.example @@ -79,7 +79,7 @@ OIDC_CLIENT_ID=your-client-id-here OIDC_CLIENT_SECRET=your-client-secret-here # Redirect URI must match what's configured in Authentik # Development: http://localhost:3001/auth/oauth2/callback/authentik -# Production: https://api.mosaicstack.dev/auth/oauth2/callback/authentik +# Production: https://mosaic-api.woltje.com/auth/oauth2/callback/authentik OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik # Authentik PostgreSQL Database @@ -314,17 +314,19 @@ COORDINATOR_ENABLED=true # TTL is in seconds, limits are per TTL window # Global rate limit (applies to all endpoints unless overridden) -RATE_LIMIT_TTL=60 # Time window in seconds -RATE_LIMIT_GLOBAL_LIMIT=100 # Requests per window +# Time window in seconds +RATE_LIMIT_TTL=60 +# Requests per window +RATE_LIMIT_GLOBAL_LIMIT=100 -# Webhook endpoints (/stitcher/webhook, /stitcher/dispatch) -RATE_LIMIT_WEBHOOK_LIMIT=60 # Requests per minute +# Webhook endpoints (/stitcher/webhook, /stitcher/dispatch) — requests per minute +RATE_LIMIT_WEBHOOK_LIMIT=60 -# Coordinator endpoints (/coordinator/*) -RATE_LIMIT_COORDINATOR_LIMIT=100 # Requests per minute +# Coordinator endpoints (/coordinator/*) — requests per minute +RATE_LIMIT_COORDINATOR_LIMIT=100 -# Health check endpoints (/coordinator/health) -RATE_LIMIT_HEALTH_LIMIT=300 # Requests per minute (higher for monitoring) +# Health check endpoints (/coordinator/health) — requests per minute (higher for monitoring) +RATE_LIMIT_HEALTH_LIMIT=300 # Storage backend for rate limiting (redis or memory) # redis: Uses Valkey for distributed rate limiting (recommended for production) @@ -359,17 +361,17 @@ RATE_LIMIT_STORAGE=redis # a single workspace. MATRIX_HOMESERVER_URL=http://synapse:8008 MATRIX_ACCESS_TOKEN= -MATRIX_BOT_USER_ID=@mosaic-bot:matrix.example.com -MATRIX_SERVER_NAME=matrix.example.com -# MATRIX_CONTROL_ROOM_ID=!roomid:matrix.example.com +MATRIX_BOT_USER_ID=@mosaic-bot:matrix.woltje.com +MATRIX_SERVER_NAME=matrix.woltje.com +# MATRIX_CONTROL_ROOM_ID=!roomid:matrix.woltje.com # MATRIX_WORKSPACE_ID=your-workspace-uuid # ====================== # Matrix / Synapse Deployment # ====================== # Domains for Traefik routing to Matrix services -MATRIX_DOMAIN=matrix.example.com -ELEMENT_DOMAIN=chat.example.com +MATRIX_DOMAIN=matrix.woltje.com +ELEMENT_DOMAIN=chat.woltje.com # Synapse database (created automatically by synapse-db-init in the swarm compose) SYNAPSE_POSTGRES_DB=synapse diff --git a/.mosaic/orchestrator/mission.json b/.mosaic/orchestrator/mission.json index 9175d8a..8b95e42 100644 --- a/.mosaic/orchestrator/mission.json +++ b/.mosaic/orchestrator/mission.json @@ -1,14 +1,69 @@ { "schema_version": 1, - "mission_id": "prd-implementation-20260222", - "name": "PRD implementation", - "description": "", + "mission_id": "ms21-multi-tenant-rbac-data-migration-20260228", + "name": "MS21 Multi-Tenant RBAC Data Migration", + "description": "Build multi-tenant user/workspace/team management, break-glass auth, RBAC UI enforcement, and migrate jarvis-brain data into Mosaic Stack", "project_path": "/home/jwoltje/src/mosaic-stack", - "created_at": "2026-02-23T03:20:55Z", + "created_at": "2026-02-28T17:10:22Z", "status": "active", - "task_prefix": "", - "quality_gates": "", - "milestone_version": "0.0.20", - "milestones": [], + "task_prefix": "MS21", + "quality_gates": "pnpm lint && pnpm build && pnpm test", + "milestone_version": "0.0.21", + "milestones": [ + { + "id": "phase-1", + "name": "Schema and Admin API", + "status": "pending", + "branch": "schema-and-admin-api", + "issue_ref": "", + "started_at": "", + "completed_at": "" + }, + { + "id": "phase-2", + "name": "Break-Glass Authentication", + "status": "pending", + "branch": "break-glass-authentication", + "issue_ref": "", + "started_at": "", + "completed_at": "" + }, + { + "id": "phase-3", + "name": "Data Migration", + "status": "pending", + "branch": "data-migration", + "issue_ref": "", + "started_at": "", + "completed_at": "" + }, + { + "id": "phase-4", + "name": "Admin UI", + "status": "pending", + "branch": "admin-ui", + "issue_ref": "", + "started_at": "", + "completed_at": "" + }, + { + "id": "phase-5", + "name": "RBAC UI Enforcement", + "status": "pending", + "branch": "rbac-ui-enforcement", + "issue_ref": "", + "started_at": "", + "completed_at": "" + }, + { + "id": "phase-6", + "name": "Verification", + "status": "pending", + "branch": "verification", + "issue_ref": "", + "started_at": "", + "completed_at": "" + } + ], "sessions": [] } diff --git a/apps/api/prisma/migrations/20260227000000_add_personality_tone_formality/migration.sql b/apps/api/prisma/migrations/20260227000000_add_personality_tone_formality/migration.sql new file mode 100644 index 0000000..fd88954 --- /dev/null +++ b/apps/api/prisma/migrations/20260227000000_add_personality_tone_formality/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable: add tone and formality_level columns to personalities +ALTER TABLE "personalities" ADD COLUMN "tone" TEXT NOT NULL DEFAULT 'neutral'; +ALTER TABLE "personalities" ADD COLUMN "formality_level" "FormalityLevel" NOT NULL DEFAULT 'NEUTRAL'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 833e0c6..35d474b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -3,6 +3,7 @@ generator client { provider = "prisma-client-js" + binaryTargets = ["native", "debian-openssl-3.0.x"] previewFeatures = ["postgresqlExtensions"] } @@ -1067,6 +1068,10 @@ model Personality { displayName String @map("display_name") description String? @db.Text + // Tone and formality + tone String @default("neutral") + formalityLevel FormalityLevel @default(NEUTRAL) @map("formality_level") + // System prompt systemPrompt String @map("system_prompt") @db.Text diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 945f6e4..994d191 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -41,6 +41,8 @@ import { MosaicTelemetryModule } from "./mosaic-telemetry"; import { SpeechModule } from "./speech/speech.module"; import { DashboardModule } from "./dashboard/dashboard.module"; import { TerminalModule } from "./terminal/terminal.module"; +import { PersonalitiesModule } from "./personalities/personalities.module"; +import { WorkspacesModule } from "./workspaces/workspaces.module"; import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor"; @Module({ @@ -105,6 +107,8 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce SpeechModule, DashboardModule, TerminalModule, + PersonalitiesModule, + WorkspacesModule, ], controllers: [AppController, CsrfController], providers: [ diff --git a/apps/api/src/auth/auth.controller.spec.ts b/apps/api/src/auth/auth.controller.spec.ts index 8f54a32..3740338 100644 --- a/apps/api/src/auth/auth.controller.spec.ts +++ b/apps/api/src/auth/auth.controller.spec.ts @@ -361,16 +361,13 @@ describe("AuthController", () => { }); describe("getProfile", () => { - it("should return complete user profile with workspace fields", () => { + it("should return complete user profile with identity fields", () => { const mockUser: AuthUser = { id: "user-123", email: "test@example.com", name: "Test User", image: "https://example.com/avatar.jpg", emailVerified: true, - workspaceId: "workspace-123", - currentWorkspaceId: "workspace-456", - workspaceRole: "admin", }; const result = controller.getProfile(mockUser); @@ -381,13 +378,10 @@ describe("AuthController", () => { name: mockUser.name, image: mockUser.image, emailVerified: mockUser.emailVerified, - workspaceId: mockUser.workspaceId, - currentWorkspaceId: mockUser.currentWorkspaceId, - workspaceRole: mockUser.workspaceRole, }); }); - it("should return user profile with optional fields undefined", () => { + it("should return user profile with only required fields", () => { const mockUser: AuthUser = { id: "user-123", email: "test@example.com", @@ -400,12 +394,11 @@ describe("AuthController", () => { id: mockUser.id, email: mockUser.email, name: mockUser.name, - image: undefined, - emailVerified: undefined, - workspaceId: undefined, - currentWorkspaceId: undefined, - workspaceRole: undefined, }); + // Workspace fields are not included — served by GET /api/workspaces + expect(result).not.toHaveProperty("workspaceId"); + expect(result).not.toHaveProperty("currentWorkspaceId"); + expect(result).not.toHaveProperty("workspaceRole"); }); }); diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index b8802f7..f0bd96b 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -72,15 +72,10 @@ export class AuthController { if (user.emailVerified !== undefined) { profile.emailVerified = user.emailVerified; } - if (user.workspaceId !== undefined) { - profile.workspaceId = user.workspaceId; - } - if (user.currentWorkspaceId !== undefined) { - profile.currentWorkspaceId = user.currentWorkspaceId; - } - if (user.workspaceRole !== undefined) { - profile.workspaceRole = user.workspaceRole; - } + + // Workspace context is served by GET /api/workspaces, not the auth profile. + // The deprecated workspaceId/currentWorkspaceId/workspaceRole fields on + // AuthUser are never populated by BetterAuth and are omitted here. return profile; } diff --git a/apps/api/src/common/guards/workspace.guard.ts b/apps/api/src/common/guards/workspace.guard.ts index 75d065f..058441a 100644 --- a/apps/api/src/common/guards/workspace.guard.ts +++ b/apps/api/src/common/guards/workspace.guard.ts @@ -110,10 +110,10 @@ export class WorkspaceGuard implements CanActivate { return paramWorkspaceId; } - // 3. Check request body - const bodyWorkspaceId = request.body.workspaceId; - if (typeof bodyWorkspaceId === "string") { - return bodyWorkspaceId; + // 3. Check request body (body may be undefined for GET requests despite Express typings) + const body = request.body as Record | undefined; + if (body && typeof body.workspaceId === "string") { + return body.workspaceId; } // 4. Check query string (backward compatibility for existing clients) diff --git a/apps/api/src/knowledge/dto/entry-query.dto.ts b/apps/api/src/knowledge/dto/entry-query.dto.ts index 5a5f97b..c455838 100644 --- a/apps/api/src/knowledge/dto/entry-query.dto.ts +++ b/apps/api/src/knowledge/dto/entry-query.dto.ts @@ -1,6 +1,6 @@ -import { IsOptional, IsEnum, IsString, IsInt, Min, Max } from "class-validator"; +import { IsOptional, IsEnum, IsString, IsInt, IsIn, Min, Max } from "class-validator"; import { Type } from "class-transformer"; -import { EntryStatus } from "@prisma/client"; +import { EntryStatus, Visibility } from "@prisma/client"; /** * DTO for querying knowledge entries (list endpoint) @@ -10,10 +10,28 @@ export class EntryQueryDto { @IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" }) status?: EntryStatus; + @IsOptional() + @IsEnum(Visibility, { message: "visibility must be a valid Visibility" }) + visibility?: Visibility; + @IsOptional() @IsString({ message: "tag must be a string" }) tag?: string; + @IsOptional() + @IsString({ message: "search must be a string" }) + search?: string; + + @IsOptional() + @IsIn(["updatedAt", "createdAt", "title"], { + message: "sortBy must be updatedAt, createdAt, or title", + }) + sortBy?: "updatedAt" | "createdAt" | "title"; + + @IsOptional() + @IsIn(["asc", "desc"], { message: "sortOrder must be asc or desc" }) + sortOrder?: "asc" | "desc"; + @IsOptional() @Type(() => Number) @IsInt({ message: "page must be an integer" }) diff --git a/apps/api/src/knowledge/knowledge.service.ts b/apps/api/src/knowledge/knowledge.service.ts index f004d91..e1ef04c 100644 --- a/apps/api/src/knowledge/knowledge.service.ts +++ b/apps/api/src/knowledge/knowledge.service.ts @@ -48,6 +48,10 @@ export class KnowledgeService { where.status = query.status; } + if (query.visibility) { + where.visibility = query.visibility; + } + if (query.tag) { where.tags = { some: { @@ -58,6 +62,20 @@ export class KnowledgeService { }; } + if (query.search) { + where.OR = [ + { title: { contains: query.search, mode: "insensitive" } }, + { content: { contains: query.search, mode: "insensitive" } }, + ]; + } + + // Build orderBy + const sortField = query.sortBy ?? "updatedAt"; + const sortDirection = query.sortOrder ?? "desc"; + const orderBy: Prisma.KnowledgeEntryOrderByWithRelationInput = { + [sortField]: sortDirection, + }; + // Get total count const total = await this.prisma.knowledgeEntry.count({ where }); @@ -71,9 +89,7 @@ export class KnowledgeService { }, }, }, - orderBy: { - updatedAt: "desc", - }, + orderBy, skip, take: limit, }); diff --git a/apps/api/src/personalities/dto/create-personality.dto.ts b/apps/api/src/personalities/dto/create-personality.dto.ts index 81cb86e..cc006d7 100644 --- a/apps/api/src/personalities/dto/create-personality.dto.ts +++ b/apps/api/src/personalities/dto/create-personality.dto.ts @@ -1,59 +1,38 @@ -import { - IsString, - IsOptional, - IsBoolean, - IsNumber, - IsInt, - IsUUID, - MinLength, - MaxLength, - Min, - Max, -} from "class-validator"; +import { FormalityLevel } from "@prisma/client"; +import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator"; /** - * DTO for creating a new personality/assistant configuration + * DTO for creating a new personality + * Field names match the frontend API contract from @mosaic/shared Personality type. */ export class CreatePersonalityDto { - @IsString() - @MinLength(1) - @MaxLength(100) - name!: string; // unique identifier slug - - @IsString() - @MinLength(1) - @MaxLength(200) - displayName!: string; // human-readable name + @IsString({ message: "name must be a string" }) + @MinLength(1, { message: "name must not be empty" }) + @MaxLength(255, { message: "name must not exceed 255 characters" }) + name!: string; @IsOptional() - @IsString() - @MaxLength(1000) + @IsString({ message: "description must be a string" }) + @MaxLength(2000, { message: "description must not exceed 2000 characters" }) description?: string; - @IsString() - @MinLength(10) - systemPrompt!: string; + @IsString({ message: "tone must be a string" }) + @MinLength(1, { message: "tone must not be empty" }) + @MaxLength(100, { message: "tone must not exceed 100 characters" }) + tone!: string; + + @IsEnum(FormalityLevel, { message: "formalityLevel must be a valid FormalityLevel" }) + formalityLevel!: FormalityLevel; + + @IsString({ message: "systemPromptTemplate must be a string" }) + @MinLength(1, { message: "systemPromptTemplate must not be empty" }) + systemPromptTemplate!: string; @IsOptional() - @IsNumber() - @Min(0) - @Max(2) - temperature?: number; // null = use provider default - - @IsOptional() - @IsInt() - @Min(1) - maxTokens?: number; // null = use provider default - - @IsOptional() - @IsUUID("4") - llmProviderInstanceId?: string; // FK to LlmProviderInstance - - @IsOptional() - @IsBoolean() + @IsBoolean({ message: "isDefault must be a boolean" }) isDefault?: boolean; @IsOptional() - @IsBoolean() - isEnabled?: boolean; + @IsBoolean({ message: "isActive must be a boolean" }) + isActive?: boolean; } diff --git a/apps/api/src/personalities/dto/index.ts b/apps/api/src/personalities/dto/index.ts index b33be96..aca0d0b 100644 --- a/apps/api/src/personalities/dto/index.ts +++ b/apps/api/src/personalities/dto/index.ts @@ -1,2 +1,3 @@ export * from "./create-personality.dto"; export * from "./update-personality.dto"; +export * from "./personality-query.dto"; diff --git a/apps/api/src/personalities/dto/personality-query.dto.ts b/apps/api/src/personalities/dto/personality-query.dto.ts new file mode 100644 index 0000000..786ac64 --- /dev/null +++ b/apps/api/src/personalities/dto/personality-query.dto.ts @@ -0,0 +1,12 @@ +import { IsBoolean, IsOptional } from "class-validator"; +import { Transform } from "class-transformer"; + +/** + * DTO for querying/filtering personalities + */ +export class PersonalityQueryDto { + @IsOptional() + @IsBoolean({ message: "isActive must be a boolean" }) + @Transform(({ value }) => value === "true" || value === true) + isActive?: boolean; +} diff --git a/apps/api/src/personalities/dto/update-personality.dto.ts b/apps/api/src/personalities/dto/update-personality.dto.ts index 4098592..937ab4a 100644 --- a/apps/api/src/personalities/dto/update-personality.dto.ts +++ b/apps/api/src/personalities/dto/update-personality.dto.ts @@ -1,62 +1,42 @@ -import { - IsString, - IsOptional, - IsBoolean, - IsNumber, - IsInt, - IsUUID, - MinLength, - MaxLength, - Min, - Max, -} from "class-validator"; +import { FormalityLevel } from "@prisma/client"; +import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator"; /** - * DTO for updating an existing personality/assistant configuration + * DTO for updating an existing personality + * All fields are optional; only provided fields are updated. */ export class UpdatePersonalityDto { @IsOptional() - @IsString() - @MinLength(1) - @MaxLength(100) - name?: string; // unique identifier slug + @IsString({ message: "name must be a string" }) + @MinLength(1, { message: "name must not be empty" }) + @MaxLength(255, { message: "name must not exceed 255 characters" }) + name?: string; @IsOptional() - @IsString() - @MinLength(1) - @MaxLength(200) - displayName?: string; // human-readable name - - @IsOptional() - @IsString() - @MaxLength(1000) + @IsString({ message: "description must be a string" }) + @MaxLength(2000, { message: "description must not exceed 2000 characters" }) description?: string; @IsOptional() - @IsString() - @MinLength(10) - systemPrompt?: string; + @IsString({ message: "tone must be a string" }) + @MinLength(1, { message: "tone must not be empty" }) + @MaxLength(100, { message: "tone must not exceed 100 characters" }) + tone?: string; @IsOptional() - @IsNumber() - @Min(0) - @Max(2) - temperature?: number; // null = use provider default + @IsEnum(FormalityLevel, { message: "formalityLevel must be a valid FormalityLevel" }) + formalityLevel?: FormalityLevel; @IsOptional() - @IsInt() - @Min(1) - maxTokens?: number; // null = use provider default + @IsString({ message: "systemPromptTemplate must be a string" }) + @MinLength(1, { message: "systemPromptTemplate must not be empty" }) + systemPromptTemplate?: string; @IsOptional() - @IsUUID("4") - llmProviderInstanceId?: string; // FK to LlmProviderInstance - - @IsOptional() - @IsBoolean() + @IsBoolean({ message: "isDefault must be a boolean" }) isDefault?: boolean; @IsOptional() - @IsBoolean() - isEnabled?: boolean; + @IsBoolean({ message: "isActive must be a boolean" }) + isActive?: boolean; } diff --git a/apps/api/src/personalities/entities/personality.entity.ts b/apps/api/src/personalities/entities/personality.entity.ts index e685121..5ef146d 100644 --- a/apps/api/src/personalities/entities/personality.entity.ts +++ b/apps/api/src/personalities/entities/personality.entity.ts @@ -1,20 +1,24 @@ -import type { Personality as PrismaPersonality } from "@prisma/client"; +import type { FormalityLevel } from "@prisma/client"; /** - * Personality entity representing an assistant configuration + * Personality response entity + * Maps Prisma Personality fields to the frontend API contract. + * + * Field mapping (Prisma -> API): + * systemPrompt -> systemPromptTemplate + * isEnabled -> isActive + * (tone, formalityLevel are identical in both) */ -export class Personality implements PrismaPersonality { - id!: string; - workspaceId!: string; - name!: string; // unique identifier slug - displayName!: string; // human-readable name - description!: string | null; - systemPrompt!: string; - temperature!: number | null; // null = use provider default - maxTokens!: number | null; // null = use provider default - llmProviderInstanceId!: string | null; // FK to LlmProviderInstance - isDefault!: boolean; - isEnabled!: boolean; - createdAt!: Date; - updatedAt!: Date; +export interface PersonalityResponse { + id: string; + workspaceId: string; + name: string; + description: string | null; + tone: string; + formalityLevel: FormalityLevel; + systemPromptTemplate: string; + isDefault: boolean; + isActive: boolean; + createdAt: Date; + updatedAt: Date; } diff --git a/apps/api/src/personalities/personalities.controller.spec.ts b/apps/api/src/personalities/personalities.controller.spec.ts index 8e1dc23..ef767c1 100644 --- a/apps/api/src/personalities/personalities.controller.spec.ts +++ b/apps/api/src/personalities/personalities.controller.spec.ts @@ -2,36 +2,32 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { PersonalitiesController } from "./personalities.controller"; import { PersonalitiesService } from "./personalities.service"; -import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto"; +import type { CreatePersonalityDto } from "./dto/create-personality.dto"; +import type { UpdatePersonalityDto } from "./dto/update-personality.dto"; import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard, PermissionGuard } from "../common/guards"; +import { FormalityLevel } from "@prisma/client"; describe("PersonalitiesController", () => { let controller: PersonalitiesController; let service: PersonalitiesService; const mockWorkspaceId = "workspace-123"; - const mockUserId = "user-123"; const mockPersonalityId = "personality-123"; + /** API response shape (frontend field names) */ const mockPersonality = { id: mockPersonalityId, workspaceId: mockWorkspaceId, name: "professional-assistant", - displayName: "Professional Assistant", description: "A professional communication assistant", - systemPrompt: "You are a professional assistant who helps with tasks.", - temperature: 0.7, - maxTokens: 2000, - llmProviderInstanceId: "provider-123", + tone: "professional", + formalityLevel: FormalityLevel.FORMAL, + systemPromptTemplate: "You are a professional assistant who helps with tasks.", isDefault: true, - isEnabled: true, - createdAt: new Date(), - updatedAt: new Date(), - }; - - const mockRequest = { - user: { id: mockUserId }, - workspaceId: mockWorkspaceId, + isActive: true, + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-01"), }; const mockPersonalitiesService = { @@ -57,46 +53,43 @@ describe("PersonalitiesController", () => { }) .overrideGuard(AuthGuard) .useValue({ canActivate: () => true }) + .overrideGuard(WorkspaceGuard) + .useValue({ + canActivate: (ctx: { + switchToHttp: () => { getRequest: () => { workspaceId: string } }; + }) => { + const req = ctx.switchToHttp().getRequest(); + req.workspaceId = mockWorkspaceId; + return true; + }, + }) + .overrideGuard(PermissionGuard) + .useValue({ canActivate: () => true }) .compile(); controller = module.get(PersonalitiesController); service = module.get(PersonalitiesService); - // Reset mocks vi.clearAllMocks(); }); describe("findAll", () => { - it("should return all personalities", async () => { - const mockPersonalities = [mockPersonality]; - mockPersonalitiesService.findAll.mockResolvedValue(mockPersonalities); + it("should return success response with personalities list", async () => { + const mockList = [mockPersonality]; + mockPersonalitiesService.findAll.mockResolvedValue(mockList); - const result = await controller.findAll(mockRequest); + const result = await controller.findAll(mockWorkspaceId, {}); - expect(result).toEqual(mockPersonalities); - expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId); + expect(result).toEqual({ success: true, data: mockList }); + expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, {}); }); - }); - describe("findOne", () => { - it("should return a personality by id", async () => { - mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality); + it("should pass isActive query filter to service", async () => { + mockPersonalitiesService.findAll.mockResolvedValue([mockPersonality]); - const result = await controller.findOne(mockRequest, mockPersonalityId); + await controller.findAll(mockWorkspaceId, { isActive: true }); - expect(result).toEqual(mockPersonality); - expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId); - }); - }); - - describe("findByName", () => { - it("should return a personality by name", async () => { - mockPersonalitiesService.findByName.mockResolvedValue(mockPersonality); - - const result = await controller.findByName(mockRequest, "professional-assistant"); - - expect(result).toEqual(mockPersonality); - expect(service.findByName).toHaveBeenCalledWith(mockWorkspaceId, "professional-assistant"); + expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, { isActive: true }); }); }); @@ -104,32 +97,40 @@ describe("PersonalitiesController", () => { it("should return the default personality", async () => { mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality); - const result = await controller.findDefault(mockRequest); + const result = await controller.findDefault(mockWorkspaceId); expect(result).toEqual(mockPersonality); expect(service.findDefault).toHaveBeenCalledWith(mockWorkspaceId); }); }); + describe("findOne", () => { + it("should return a personality by id", async () => { + mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality); + + const result = await controller.findOne(mockWorkspaceId, mockPersonalityId); + + expect(result).toEqual(mockPersonality); + expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId); + }); + }); + describe("create", () => { it("should create a new personality", async () => { const createDto: CreatePersonalityDto = { name: "casual-helper", - displayName: "Casual Helper", description: "A casual helper", - systemPrompt: "You are a casual assistant.", - temperature: 0.8, - maxTokens: 1500, + tone: "casual", + formalityLevel: FormalityLevel.CASUAL, + systemPromptTemplate: "You are a casual assistant.", }; - mockPersonalitiesService.create.mockResolvedValue({ - ...mockPersonality, - ...createDto, - }); + const created = { ...mockPersonality, ...createDto, isActive: true, isDefault: false }; + mockPersonalitiesService.create.mockResolvedValue(created); - const result = await controller.create(mockRequest, createDto); + const result = await controller.create(mockWorkspaceId, createDto); - expect(result).toMatchObject(createDto); + expect(result).toMatchObject({ name: createDto.name, tone: createDto.tone }); expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto); }); }); @@ -138,15 +139,13 @@ describe("PersonalitiesController", () => { it("should update a personality", async () => { const updateDto: UpdatePersonalityDto = { description: "Updated description", - temperature: 0.9, + tone: "enthusiastic", }; - mockPersonalitiesService.update.mockResolvedValue({ - ...mockPersonality, - ...updateDto, - }); + const updated = { ...mockPersonality, ...updateDto }; + mockPersonalitiesService.update.mockResolvedValue(updated); - const result = await controller.update(mockRequest, mockPersonalityId, updateDto); + const result = await controller.update(mockWorkspaceId, mockPersonalityId, updateDto); expect(result).toMatchObject(updateDto); expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto); @@ -157,7 +156,7 @@ describe("PersonalitiesController", () => { it("should delete a personality", async () => { mockPersonalitiesService.delete.mockResolvedValue(undefined); - await controller.delete(mockRequest, mockPersonalityId); + await controller.delete(mockWorkspaceId, mockPersonalityId); expect(service.delete).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId); }); @@ -165,12 +164,10 @@ describe("PersonalitiesController", () => { describe("setDefault", () => { it("should set a personality as default", async () => { - mockPersonalitiesService.setDefault.mockResolvedValue({ - ...mockPersonality, - isDefault: true, - }); + const updated = { ...mockPersonality, isDefault: true }; + mockPersonalitiesService.setDefault.mockResolvedValue(updated); - const result = await controller.setDefault(mockRequest, mockPersonalityId); + const result = await controller.setDefault(mockWorkspaceId, mockPersonalityId); expect(result).toMatchObject({ isDefault: true }); expect(service.setDefault).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId); diff --git a/apps/api/src/personalities/personalities.controller.ts b/apps/api/src/personalities/personalities.controller.ts index 79714de..ba1f2ef 100644 --- a/apps/api/src/personalities/personalities.controller.ts +++ b/apps/api/src/personalities/personalities.controller.ts @@ -6,105 +6,122 @@ import { Delete, Body, Param, + Query, UseGuards, - Req, HttpCode, HttpStatus, } from "@nestjs/common"; import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard, PermissionGuard } from "../common/guards"; +import { Workspace, Permission, RequirePermission } from "../common/decorators"; import { PersonalitiesService } from "./personalities.service"; -import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto"; -import { Personality } from "./entities/personality.entity"; - -interface AuthenticatedRequest { - user: { id: string }; - workspaceId: string; -} +import { CreatePersonalityDto } from "./dto/create-personality.dto"; +import { UpdatePersonalityDto } from "./dto/update-personality.dto"; +import { PersonalityQueryDto } from "./dto/personality-query.dto"; +import type { PersonalityResponse } from "./entities/personality.entity"; /** - * Controller for managing personality/assistant configurations + * Controller for personality CRUD endpoints. + * Route: /api/personalities + * + * Guards applied in order: + * 1. AuthGuard - verifies the user is authenticated + * 2. WorkspaceGuard - validates workspace access + * 3. PermissionGuard - checks role-based permissions */ -@Controller("personality") -@UseGuards(AuthGuard) +@Controller("personalities") +@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard) export class PersonalitiesController { constructor(private readonly personalitiesService: PersonalitiesService) {} /** - * List all personalities for the workspace + * GET /api/personalities + * List all personalities for the workspace. + * Supports ?isActive=true|false filter. */ @Get() - async findAll(@Req() req: AuthenticatedRequest): Promise { - return this.personalitiesService.findAll(req.workspaceId); + @RequirePermission(Permission.WORKSPACE_ANY) + async findAll( + @Workspace() workspaceId: string, + @Query() query: PersonalityQueryDto + ): Promise<{ success: true; data: PersonalityResponse[] }> { + const data = await this.personalitiesService.findAll(workspaceId, query); + return { success: true, data }; } /** - * Get the default personality for the workspace + * GET /api/personalities/default + * Get the default personality for the workspace. + * Must be declared before :id to avoid route conflicts. */ @Get("default") - async findDefault(@Req() req: AuthenticatedRequest): Promise { - return this.personalitiesService.findDefault(req.workspaceId); + @RequirePermission(Permission.WORKSPACE_ANY) + async findDefault(@Workspace() workspaceId: string): Promise { + return this.personalitiesService.findDefault(workspaceId); } /** - * Get a personality by its unique name - */ - @Get("by-name/:name") - async findByName( - @Req() req: AuthenticatedRequest, - @Param("name") name: string - ): Promise { - return this.personalitiesService.findByName(req.workspaceId, name); - } - - /** - * Get a personality by ID + * GET /api/personalities/:id + * Get a single personality by ID. */ @Get(":id") - async findOne(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise { - return this.personalitiesService.findOne(req.workspaceId, id); + @RequirePermission(Permission.WORKSPACE_ANY) + async findOne( + @Workspace() workspaceId: string, + @Param("id") id: string + ): Promise { + return this.personalitiesService.findOne(workspaceId, id); } /** - * Create a new personality + * POST /api/personalities + * Create a new personality. */ @Post() @HttpCode(HttpStatus.CREATED) + @RequirePermission(Permission.WORKSPACE_MEMBER) async create( - @Req() req: AuthenticatedRequest, + @Workspace() workspaceId: string, @Body() dto: CreatePersonalityDto - ): Promise { - return this.personalitiesService.create(req.workspaceId, dto); + ): Promise { + return this.personalitiesService.create(workspaceId, dto); } /** - * Update a personality + * PATCH /api/personalities/:id + * Update an existing personality. */ @Patch(":id") + @RequirePermission(Permission.WORKSPACE_MEMBER) async update( - @Req() req: AuthenticatedRequest, + @Workspace() workspaceId: string, @Param("id") id: string, @Body() dto: UpdatePersonalityDto - ): Promise { - return this.personalitiesService.update(req.workspaceId, id, dto); + ): Promise { + return this.personalitiesService.update(workspaceId, id, dto); } /** - * Delete a personality + * DELETE /api/personalities/:id + * Delete a personality. */ @Delete(":id") @HttpCode(HttpStatus.NO_CONTENT) - async delete(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise { - return this.personalitiesService.delete(req.workspaceId, id); + @RequirePermission(Permission.WORKSPACE_MEMBER) + async delete(@Workspace() workspaceId: string, @Param("id") id: string): Promise { + return this.personalitiesService.delete(workspaceId, id); } /** - * Set a personality as the default + * POST /api/personalities/:id/set-default + * Convenience endpoint to set a personality as the default. */ @Post(":id/set-default") + @RequirePermission(Permission.WORKSPACE_MEMBER) async setDefault( - @Req() req: AuthenticatedRequest, + @Workspace() workspaceId: string, @Param("id") id: string - ): Promise { - return this.personalitiesService.setDefault(req.workspaceId, id); + ): Promise { + return this.personalitiesService.setDefault(workspaceId, id); } } diff --git a/apps/api/src/personalities/personalities.service.spec.ts b/apps/api/src/personalities/personalities.service.spec.ts index b0e1b20..2cc1c02 100644 --- a/apps/api/src/personalities/personalities.service.spec.ts +++ b/apps/api/src/personalities/personalities.service.spec.ts @@ -2,8 +2,10 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { PersonalitiesService } from "./personalities.service"; import { PrismaService } from "../prisma/prisma.service"; -import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto"; +import type { CreatePersonalityDto } from "./dto/create-personality.dto"; +import type { UpdatePersonalityDto } from "./dto/update-personality.dto"; import { NotFoundException, ConflictException } from "@nestjs/common"; +import { FormalityLevel } from "@prisma/client"; describe("PersonalitiesService", () => { let service: PersonalitiesService; @@ -11,22 +13,39 @@ describe("PersonalitiesService", () => { const mockWorkspaceId = "workspace-123"; const mockPersonalityId = "personality-123"; - const mockProviderId = "provider-123"; - const mockPersonality = { + /** Raw Prisma record shape (uses Prisma field names) */ + const mockPrismaRecord = { id: mockPersonalityId, workspaceId: mockWorkspaceId, name: "professional-assistant", displayName: "Professional Assistant", description: "A professional communication assistant", + tone: "professional", + formalityLevel: FormalityLevel.FORMAL, systemPrompt: "You are a professional assistant who helps with tasks.", temperature: 0.7, maxTokens: 2000, - llmProviderInstanceId: mockProviderId, + llmProviderInstanceId: "provider-123", isDefault: true, isEnabled: true, - createdAt: new Date(), - updatedAt: new Date(), + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-01"), + }; + + /** Expected API response shape (uses frontend field names) */ + const mockResponse = { + id: mockPersonalityId, + workspaceId: mockWorkspaceId, + name: "professional-assistant", + description: "A professional communication assistant", + tone: "professional", + formalityLevel: FormalityLevel.FORMAL, + systemPromptTemplate: "You are a professional assistant who helps with tasks.", + isDefault: true, + isActive: true, + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-01"), }; const mockPrismaService = { @@ -37,9 +56,7 @@ describe("PersonalitiesService", () => { create: vi.fn(), update: vi.fn(), delete: vi.fn(), - count: vi.fn(), }, - $transaction: vi.fn((callback) => callback(mockPrismaService)), }; beforeEach(async () => { @@ -56,44 +73,54 @@ describe("PersonalitiesService", () => { service = module.get(PersonalitiesService); prisma = module.get(PrismaService); - // Reset mocks vi.clearAllMocks(); }); describe("create", () => { const createDto: CreatePersonalityDto = { name: "casual-helper", - displayName: "Casual Helper", description: "A casual communication helper", - systemPrompt: "You are a casual assistant.", - temperature: 0.8, - maxTokens: 1500, - llmProviderInstanceId: mockProviderId, + tone: "casual", + formalityLevel: FormalityLevel.CASUAL, + systemPromptTemplate: "You are a casual assistant.", + isDefault: false, + isActive: true, }; - it("should create a new personality", async () => { + const createdRecord = { + ...mockPrismaRecord, + name: createDto.name, + description: createDto.description, + tone: createDto.tone, + formalityLevel: createDto.formalityLevel, + systemPrompt: createDto.systemPromptTemplate, + isDefault: false, + isEnabled: true, + id: "new-personality-id", + }; + + it("should create a new personality and return API response shape", async () => { mockPrismaService.personality.findFirst.mockResolvedValue(null); - mockPrismaService.personality.create.mockResolvedValue({ - ...mockPersonality, - ...createDto, - id: "new-personality-id", - isDefault: false, - isEnabled: true, - }); + mockPrismaService.personality.create.mockResolvedValue(createdRecord); const result = await service.create(mockWorkspaceId, createDto); - expect(result).toMatchObject(createDto); + expect(result.name).toBe(createDto.name); + expect(result.tone).toBe(createDto.tone); + expect(result.formalityLevel).toBe(createDto.formalityLevel); + expect(result.systemPromptTemplate).toBe(createDto.systemPromptTemplate); + expect(result.isActive).toBe(true); + expect(result.isDefault).toBe(false); + expect(prisma.personality.create).toHaveBeenCalledWith({ data: { workspaceId: mockWorkspaceId, name: createDto.name, - displayName: createDto.displayName, + displayName: createDto.name, description: createDto.description ?? null, - systemPrompt: createDto.systemPrompt, - temperature: createDto.temperature ?? null, - maxTokens: createDto.maxTokens ?? null, - llmProviderInstanceId: createDto.llmProviderInstanceId ?? null, + tone: createDto.tone, + formalityLevel: createDto.formalityLevel, + systemPrompt: createDto.systemPromptTemplate, isDefault: false, isEnabled: true, }, @@ -101,68 +128,73 @@ describe("PersonalitiesService", () => { }); it("should throw ConflictException when name already exists", async () => { - mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality); + mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord); await expect(service.create(mockWorkspaceId, createDto)).rejects.toThrow(ConflictException); }); it("should unset other defaults when creating a new default personality", async () => { - const createDefaultDto = { ...createDto, isDefault: true }; - // First call to findFirst checks for name conflict (should be null) - // Second call to findFirst finds the existing default personality + const createDefaultDto: CreatePersonalityDto = { ...createDto, isDefault: true }; + const otherDefault = { ...mockPrismaRecord, id: "other-id" }; + mockPrismaService.personality.findFirst - .mockResolvedValueOnce(null) // No name conflict - .mockResolvedValueOnce(mockPersonality); // Existing default - mockPrismaService.personality.update.mockResolvedValue({ - ...mockPersonality, - isDefault: false, - }); + .mockResolvedValueOnce(null) // name conflict check + .mockResolvedValueOnce(otherDefault); // existing default lookup + mockPrismaService.personality.update.mockResolvedValue({ ...otherDefault, isDefault: false }); mockPrismaService.personality.create.mockResolvedValue({ - ...mockPersonality, - ...createDefaultDto, + ...createdRecord, + isDefault: true, }); await service.create(mockWorkspaceId, createDefaultDto); expect(prisma.personality.update).toHaveBeenCalledWith({ - where: { id: mockPersonalityId }, + where: { id: "other-id" }, data: { isDefault: false }, }); }); }); describe("findAll", () => { - it("should return all personalities for a workspace", async () => { - const mockPersonalities = [mockPersonality]; - mockPrismaService.personality.findMany.mockResolvedValue(mockPersonalities); + it("should return mapped response list for a workspace", async () => { + mockPrismaService.personality.findMany.mockResolvedValue([mockPrismaRecord]); const result = await service.findAll(mockWorkspaceId); - expect(result).toEqual(mockPersonalities); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(mockResponse); expect(prisma.personality.findMany).toHaveBeenCalledWith({ where: { workspaceId: mockWorkspaceId }, orderBy: [{ isDefault: "desc" }, { name: "asc" }], }); }); + + it("should filter by isActive when provided", async () => { + mockPrismaService.personality.findMany.mockResolvedValue([mockPrismaRecord]); + + await service.findAll(mockWorkspaceId, { isActive: true }); + + expect(prisma.personality.findMany).toHaveBeenCalledWith({ + where: { workspaceId: mockWorkspaceId, isEnabled: true }, + orderBy: [{ isDefault: "desc" }, { name: "asc" }], + }); + }); }); describe("findOne", () => { - it("should return a personality by id", async () => { - mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality); + it("should return a mapped personality response by id", async () => { + mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord); const result = await service.findOne(mockWorkspaceId, mockPersonalityId); - expect(result).toEqual(mockPersonality); - expect(prisma.personality.findUnique).toHaveBeenCalledWith({ - where: { - id: mockPersonalityId, - workspaceId: mockWorkspaceId, - }, + expect(result).toEqual(mockResponse); + expect(prisma.personality.findFirst).toHaveBeenCalledWith({ + where: { id: mockPersonalityId, workspaceId: mockWorkspaceId }, }); }); it("should throw NotFoundException when personality not found", async () => { - mockPrismaService.personality.findUnique.mockResolvedValue(null); + mockPrismaService.personality.findFirst.mockResolvedValue(null); await expect(service.findOne(mockWorkspaceId, mockPersonalityId)).rejects.toThrow( NotFoundException @@ -171,17 +203,14 @@ describe("PersonalitiesService", () => { }); describe("findByName", () => { - it("should return a personality by name", async () => { - mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality); + it("should return a mapped personality response by name", async () => { + mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord); const result = await service.findByName(mockWorkspaceId, "professional-assistant"); - expect(result).toEqual(mockPersonality); + expect(result).toEqual(mockResponse); expect(prisma.personality.findFirst).toHaveBeenCalledWith({ - where: { - workspaceId: mockWorkspaceId, - name: "professional-assistant", - }, + where: { workspaceId: mockWorkspaceId, name: "professional-assistant" }, }); }); @@ -196,11 +225,11 @@ describe("PersonalitiesService", () => { describe("findDefault", () => { it("should return the default personality", async () => { - mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality); + mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord); const result = await service.findDefault(mockWorkspaceId); - expect(result).toEqual(mockPersonality); + expect(result).toEqual(mockResponse); expect(prisma.personality.findFirst).toHaveBeenCalledWith({ where: { workspaceId: mockWorkspaceId, isDefault: true, isEnabled: true }, }); @@ -216,41 +245,45 @@ describe("PersonalitiesService", () => { describe("update", () => { const updateDto: UpdatePersonalityDto = { description: "Updated description", - temperature: 0.9, + tone: "formal", + isActive: false, }; - it("should update a personality", async () => { - mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality); - mockPrismaService.personality.findFirst.mockResolvedValue(null); - mockPrismaService.personality.update.mockResolvedValue({ - ...mockPersonality, - ...updateDto, - }); + it("should update a personality and return mapped response", async () => { + const updatedRecord = { + ...mockPrismaRecord, + description: updateDto.description, + tone: updateDto.tone, + isEnabled: false, + }; + + mockPrismaService.personality.findFirst + .mockResolvedValueOnce(mockPrismaRecord) // findOne check + .mockResolvedValueOnce(null); // name conflict check (no dto.name here) + mockPrismaService.personality.update.mockResolvedValue(updatedRecord); const result = await service.update(mockWorkspaceId, mockPersonalityId, updateDto); - expect(result).toMatchObject(updateDto); - expect(prisma.personality.update).toHaveBeenCalledWith({ - where: { id: mockPersonalityId }, - data: updateDto, - }); + expect(result.description).toBe(updateDto.description); + expect(result.tone).toBe(updateDto.tone); + expect(result.isActive).toBe(false); }); it("should throw NotFoundException when personality not found", async () => { - mockPrismaService.personality.findUnique.mockResolvedValue(null); + mockPrismaService.personality.findFirst.mockResolvedValue(null); await expect(service.update(mockWorkspaceId, mockPersonalityId, updateDto)).rejects.toThrow( NotFoundException ); }); - it("should throw ConflictException when updating to existing name", async () => { - const updateNameDto = { name: "existing-name" }; - mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality); - mockPrismaService.personality.findFirst.mockResolvedValue({ - ...mockPersonality, - id: "different-id", - }); + it("should throw ConflictException when updating to an existing name", async () => { + const updateNameDto: UpdatePersonalityDto = { name: "existing-name" }; + const conflictRecord = { ...mockPrismaRecord, id: "different-id" }; + + mockPrismaService.personality.findFirst + .mockResolvedValueOnce(mockPrismaRecord) // findOne check + .mockResolvedValueOnce(conflictRecord); // name conflict await expect( service.update(mockWorkspaceId, mockPersonalityId, updateNameDto) @@ -258,14 +291,16 @@ describe("PersonalitiesService", () => { }); it("should unset other defaults when setting as default", async () => { - const updateDefaultDto = { isDefault: true }; - const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true }; + const updateDefaultDto: UpdatePersonalityDto = { isDefault: true }; + const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true }; + const updatedRecord = { ...mockPrismaRecord, isDefault: true }; - mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality); - mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality); // Existing default from unsetOtherDefaults + mockPrismaService.personality.findFirst + .mockResolvedValueOnce(mockPrismaRecord) // findOne check + .mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup mockPrismaService.personality.update - .mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default - .mockResolvedValueOnce({ ...mockPersonality, isDefault: true }); // Set new default + .mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) + .mockResolvedValueOnce(updatedRecord); await service.update(mockWorkspaceId, mockPersonalityId, updateDefaultDto); @@ -273,16 +308,12 @@ describe("PersonalitiesService", () => { where: { id: "other-id" }, data: { isDefault: false }, }); - expect(prisma.personality.update).toHaveBeenNthCalledWith(2, { - where: { id: mockPersonalityId }, - data: updateDefaultDto, - }); }); }); describe("delete", () => { it("should delete a personality", async () => { - mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality); + mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord); mockPrismaService.personality.delete.mockResolvedValue(undefined); await service.delete(mockWorkspaceId, mockPersonalityId); @@ -293,7 +324,7 @@ describe("PersonalitiesService", () => { }); it("should throw NotFoundException when personality not found", async () => { - mockPrismaService.personality.findUnique.mockResolvedValue(null); + mockPrismaService.personality.findFirst.mockResolvedValue(null); await expect(service.delete(mockWorkspaceId, mockPersonalityId)).rejects.toThrow( NotFoundException @@ -303,30 +334,27 @@ describe("PersonalitiesService", () => { describe("setDefault", () => { it("should set a personality as default", async () => { - const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true }; - const updatedPersonality = { ...mockPersonality, isDefault: true }; + const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true }; + const updatedRecord = { ...mockPrismaRecord, isDefault: true }; - mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality); - mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality); + mockPrismaService.personality.findFirst + .mockResolvedValueOnce(mockPrismaRecord) // findOne check + .mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup mockPrismaService.personality.update - .mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default - .mockResolvedValueOnce(updatedPersonality); // Set new default + .mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) + .mockResolvedValueOnce(updatedRecord); const result = await service.setDefault(mockWorkspaceId, mockPersonalityId); - expect(result).toMatchObject({ isDefault: true }); - expect(prisma.personality.update).toHaveBeenNthCalledWith(1, { - where: { id: "other-id" }, - data: { isDefault: false }, - }); - expect(prisma.personality.update).toHaveBeenNthCalledWith(2, { + expect(result.isDefault).toBe(true); + expect(prisma.personality.update).toHaveBeenCalledWith({ where: { id: mockPersonalityId }, data: { isDefault: true }, }); }); it("should throw NotFoundException when personality not found", async () => { - mockPrismaService.personality.findUnique.mockResolvedValue(null); + mockPrismaService.personality.findFirst.mockResolvedValue(null); await expect(service.setDefault(mockWorkspaceId, mockPersonalityId)).rejects.toThrow( NotFoundException diff --git a/apps/api/src/personalities/personalities.service.ts b/apps/api/src/personalities/personalities.service.ts index e766c8a..cf5a7f0 100644 --- a/apps/api/src/personalities/personalities.service.ts +++ b/apps/api/src/personalities/personalities.service.ts @@ -1,10 +1,17 @@ import { Injectable, NotFoundException, ConflictException, Logger } from "@nestjs/common"; +import type { FormalityLevel, Personality } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; -import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto"; -import { Personality } from "./entities/personality.entity"; +import type { CreatePersonalityDto } from "./dto/create-personality.dto"; +import type { UpdatePersonalityDto } from "./dto/update-personality.dto"; +import type { PersonalityQueryDto } from "./dto/personality-query.dto"; +import type { PersonalityResponse } from "./entities/personality.entity"; /** - * Service for managing personality/assistant configurations + * Service for managing personality/assistant configurations. + * + * Field mapping: + * Prisma `systemPrompt` <-> API/frontend `systemPromptTemplate` + * Prisma `isEnabled` <-> API/frontend `isActive` */ @Injectable() export class PersonalitiesService { @@ -12,11 +19,30 @@ export class PersonalitiesService { constructor(private readonly prisma: PrismaService) {} + /** + * Map a Prisma Personality record to the API response shape. + */ + private toResponse(personality: Personality): PersonalityResponse { + return { + id: personality.id, + workspaceId: personality.workspaceId, + name: personality.name, + description: personality.description, + tone: personality.tone, + formalityLevel: personality.formalityLevel, + systemPromptTemplate: personality.systemPrompt, + isDefault: personality.isDefault, + isActive: personality.isEnabled, + createdAt: personality.createdAt, + updatedAt: personality.updatedAt, + }; + } + /** * Create a new personality */ - async create(workspaceId: string, dto: CreatePersonalityDto): Promise { - // Check for duplicate name + async create(workspaceId: string, dto: CreatePersonalityDto): Promise { + // Check for duplicate name within workspace const existing = await this.prisma.personality.findFirst({ where: { workspaceId, name: dto.name }, }); @@ -25,7 +51,7 @@ export class PersonalitiesService { throw new ConflictException(`Personality with name "${dto.name}" already exists`); } - // If creating a default personality, unset other defaults + // If creating as default, unset other defaults first if (dto.isDefault) { await this.unsetOtherDefaults(workspaceId); } @@ -34,36 +60,43 @@ export class PersonalitiesService { data: { workspaceId, name: dto.name, - displayName: dto.displayName, + displayName: dto.name, // use name as displayName since frontend doesn't send displayName separately description: dto.description ?? null, - systemPrompt: dto.systemPrompt, - temperature: dto.temperature ?? null, - maxTokens: dto.maxTokens ?? null, - llmProviderInstanceId: dto.llmProviderInstanceId ?? null, + tone: dto.tone, + formalityLevel: dto.formalityLevel, + systemPrompt: dto.systemPromptTemplate, isDefault: dto.isDefault ?? false, - isEnabled: dto.isEnabled ?? true, + isEnabled: dto.isActive ?? true, }, }); this.logger.log(`Created personality ${personality.id} for workspace ${workspaceId}`); - return personality; + return this.toResponse(personality); } /** - * Find all personalities for a workspace + * Find all personalities for a workspace with optional active filter */ - async findAll(workspaceId: string): Promise { - return this.prisma.personality.findMany({ - where: { workspaceId }, + async findAll(workspaceId: string, query?: PersonalityQueryDto): Promise { + const where: { workspaceId: string; isEnabled?: boolean } = { workspaceId }; + + if (query?.isActive !== undefined) { + where.isEnabled = query.isActive; + } + + const personalities = await this.prisma.personality.findMany({ + where, orderBy: [{ isDefault: "desc" }, { name: "asc" }], }); + + return personalities.map((p) => this.toResponse(p)); } /** * Find a specific personality by ID */ - async findOne(workspaceId: string, id: string): Promise { - const personality = await this.prisma.personality.findUnique({ + async findOne(workspaceId: string, id: string): Promise { + const personality = await this.prisma.personality.findFirst({ where: { id, workspaceId }, }); @@ -71,13 +104,13 @@ export class PersonalitiesService { throw new NotFoundException(`Personality with ID ${id} not found`); } - return personality; + return this.toResponse(personality); } /** - * Find a personality by name + * Find a personality by name slug */ - async findByName(workspaceId: string, name: string): Promise { + async findByName(workspaceId: string, name: string): Promise { const personality = await this.prisma.personality.findFirst({ where: { workspaceId, name }, }); @@ -86,13 +119,13 @@ export class PersonalitiesService { throw new NotFoundException(`Personality with name "${name}" not found`); } - return personality; + return this.toResponse(personality); } /** - * Find the default personality for a workspace + * Find the default (and enabled) personality for a workspace */ - async findDefault(workspaceId: string): Promise { + async findDefault(workspaceId: string): Promise { const personality = await this.prisma.personality.findFirst({ where: { workspaceId, isDefault: true, isEnabled: true }, }); @@ -101,14 +134,18 @@ export class PersonalitiesService { throw new NotFoundException(`No default personality found for workspace ${workspaceId}`); } - return personality; + return this.toResponse(personality); } /** * Update an existing personality */ - async update(workspaceId: string, id: string, dto: UpdatePersonalityDto): Promise { - // Check existence + async update( + workspaceId: string, + id: string, + dto: UpdatePersonalityDto + ): Promise { + // Verify existence await this.findOne(workspaceId, id); // Check for duplicate name if updating name @@ -127,20 +164,43 @@ export class PersonalitiesService { await this.unsetOtherDefaults(workspaceId, id); } + // Build update data with field mapping + const updateData: { + name?: string; + displayName?: string; + description?: string; + tone?: string; + formalityLevel?: FormalityLevel; + systemPrompt?: string; + isDefault?: boolean; + isEnabled?: boolean; + } = {}; + + if (dto.name !== undefined) { + updateData.name = dto.name; + updateData.displayName = dto.name; + } + if (dto.description !== undefined) updateData.description = dto.description; + if (dto.tone !== undefined) updateData.tone = dto.tone; + if (dto.formalityLevel !== undefined) updateData.formalityLevel = dto.formalityLevel; + if (dto.systemPromptTemplate !== undefined) updateData.systemPrompt = dto.systemPromptTemplate; + if (dto.isDefault !== undefined) updateData.isDefault = dto.isDefault; + if (dto.isActive !== undefined) updateData.isEnabled = dto.isActive; + const personality = await this.prisma.personality.update({ where: { id }, - data: dto, + data: updateData, }); this.logger.log(`Updated personality ${id} for workspace ${workspaceId}`); - return personality; + return this.toResponse(personality); } /** * Delete a personality */ async delete(workspaceId: string, id: string): Promise { - // Check existence + // Verify existence await this.findOne(workspaceId, id); await this.prisma.personality.delete({ @@ -151,23 +211,22 @@ export class PersonalitiesService { } /** - * Set a personality as the default + * Set a personality as the default (convenience endpoint) */ - async setDefault(workspaceId: string, id: string): Promise { - // Check existence + async setDefault(workspaceId: string, id: string): Promise { + // Verify existence await this.findOne(workspaceId, id); // Unset other defaults await this.unsetOtherDefaults(workspaceId, id); - // Set this one as default const personality = await this.prisma.personality.update({ where: { id }, data: { isDefault: true }, }); this.logger.log(`Set personality ${id} as default for workspace ${workspaceId}`); - return personality; + return this.toResponse(personality); } /** @@ -178,7 +237,7 @@ export class PersonalitiesService { where: { workspaceId, isDefault: true, - ...(excludeId && { id: { not: excludeId } }), + ...(excludeId !== undefined && { id: { not: excludeId } }), }, }); diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts index 66cfbfd..6721734 100644 --- a/apps/api/src/prisma/prisma.service.ts +++ b/apps/api/src/prisma/prisma.service.ts @@ -140,8 +140,11 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul workspaceId: string, client: PrismaClient = this ): Promise { - await client.$executeRaw`SET LOCAL app.current_user_id = ${userId}`; - await client.$executeRaw`SET LOCAL app.current_workspace_id = ${workspaceId}`; + // Use set_config() instead of SET LOCAL so values are safely parameterized. + // SET LOCAL with Prisma's tagged template produces invalid SQL (bind parameter $1 + // is not supported in SET statements by PostgreSQL). + await client.$executeRaw`SELECT set_config('app.current_user_id', ${userId}, true)`; + await client.$executeRaw`SELECT set_config('app.current_workspace_id', ${workspaceId}, true)`; } /** @@ -151,8 +154,8 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul * @param client - Optional Prisma client (uses 'this' if not provided) */ async clearWorkspaceContext(client: PrismaClient = this): Promise { - await client.$executeRaw`SET LOCAL app.current_user_id = NULL`; - await client.$executeRaw`SET LOCAL app.current_workspace_id = NULL`; + await client.$executeRaw`SELECT set_config('app.current_user_id', '', true)`; + await client.$executeRaw`SELECT set_config('app.current_workspace_id', '', true)`; } /** diff --git a/apps/api/src/users/preferences.controller.spec.ts b/apps/api/src/users/preferences.controller.spec.ts new file mode 100644 index 0000000..1ca27fa --- /dev/null +++ b/apps/api/src/users/preferences.controller.spec.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { UnauthorizedException } from "@nestjs/common"; +import { PreferencesController } from "./preferences.controller"; +import { PreferencesService } from "./preferences.service"; +import type { UpdatePreferencesDto, PreferencesResponseDto } from "./dto"; +import type { AuthenticatedRequest } from "../common/types/user.types"; + +describe("PreferencesController", () => { + let controller: PreferencesController; + let service: PreferencesService; + + const mockPreferencesService = { + getPreferences: vi.fn(), + updatePreferences: vi.fn(), + }; + + const mockUserId = "user-uuid-123"; + + const mockPreferencesResponse: PreferencesResponseDto = { + id: "pref-uuid-456", + userId: mockUserId, + theme: "system", + locale: "en", + timezone: null, + settings: {}, + updatedAt: new Date("2026-01-01T00:00:00Z"), + }; + + function makeRequest(userId?: string): AuthenticatedRequest { + return { + user: userId ? { id: userId } : undefined, + } as unknown as AuthenticatedRequest; + } + + beforeEach(() => { + service = mockPreferencesService as unknown as PreferencesService; + controller = new PreferencesController(service); + vi.clearAllMocks(); + }); + + describe("GET /api/users/me/preferences", () => { + it("should return preferences for authenticated user", async () => { + mockPreferencesService.getPreferences.mockResolvedValue(mockPreferencesResponse); + + const result = await controller.getPreferences(makeRequest(mockUserId)); + + expect(result).toEqual(mockPreferencesResponse); + expect(mockPreferencesService.getPreferences).toHaveBeenCalledWith(mockUserId); + }); + + it("should throw UnauthorizedException when user is not authenticated", async () => { + await expect(controller.getPreferences(makeRequest())).rejects.toThrow(UnauthorizedException); + expect(mockPreferencesService.getPreferences).not.toHaveBeenCalled(); + }); + }); + + describe("PUT /api/users/me/preferences", () => { + const updateDto: UpdatePreferencesDto = { + theme: "dark", + locale: "fr", + timezone: "Europe/Paris", + }; + + it("should update and return preferences for authenticated user", async () => { + const updatedResponse: PreferencesResponseDto = { + ...mockPreferencesResponse, + theme: "dark", + locale: "fr", + timezone: "Europe/Paris", + }; + mockPreferencesService.updatePreferences.mockResolvedValue(updatedResponse); + + const result = await controller.updatePreferences(updateDto, makeRequest(mockUserId)); + + expect(result).toEqual(updatedResponse); + expect(mockPreferencesService.updatePreferences).toHaveBeenCalledWith(mockUserId, updateDto); + }); + + it("should throw UnauthorizedException when user is not authenticated", async () => { + await expect(controller.updatePreferences(updateDto, makeRequest())).rejects.toThrow( + UnauthorizedException + ); + expect(mockPreferencesService.updatePreferences).not.toHaveBeenCalled(); + }); + }); + + describe("PATCH /api/users/me/preferences", () => { + const patchDto: UpdatePreferencesDto = { + theme: "light", + }; + + it("should partially update and return preferences for authenticated user", async () => { + const patchedResponse: PreferencesResponseDto = { + ...mockPreferencesResponse, + theme: "light", + }; + mockPreferencesService.updatePreferences.mockResolvedValue(patchedResponse); + + const result = await controller.patchPreferences(patchDto, makeRequest(mockUserId)); + + expect(result).toEqual(patchedResponse); + expect(mockPreferencesService.updatePreferences).toHaveBeenCalledWith(mockUserId, patchDto); + }); + + it("should throw UnauthorizedException when user is not authenticated", async () => { + await expect(controller.patchPreferences(patchDto, makeRequest())).rejects.toThrow( + UnauthorizedException + ); + expect(mockPreferencesService.updatePreferences).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/users/preferences.controller.ts b/apps/api/src/users/preferences.controller.ts index 166d50c..25ab701 100644 --- a/apps/api/src/users/preferences.controller.ts +++ b/apps/api/src/users/preferences.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Put, + Patch, Body, UseGuards, Request, @@ -38,7 +39,7 @@ export class PreferencesController { /** * PUT /api/users/me/preferences - * Update current user's preferences + * Full replace of current user's preferences */ @Put() async updatePreferences( @@ -53,4 +54,22 @@ export class PreferencesController { return this.preferencesService.updatePreferences(userId, updatePreferencesDto); } + + /** + * PATCH /api/users/me/preferences + * Partial update of current user's preferences + */ + @Patch() + async patchPreferences( + @Body() updatePreferencesDto: UpdatePreferencesDto, + @Request() req: AuthenticatedRequest + ) { + const userId = req.user?.id; + + if (!userId) { + throw new UnauthorizedException("Authentication required"); + } + + return this.preferencesService.updatePreferences(userId, updatePreferencesDto); + } } diff --git a/apps/api/src/users/preferences.service.spec.ts b/apps/api/src/users/preferences.service.spec.ts new file mode 100644 index 0000000..48a96ba --- /dev/null +++ b/apps/api/src/users/preferences.service.spec.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { PreferencesService } from "./preferences.service"; +import type { PrismaService } from "../prisma/prisma.service"; +import type { UpdatePreferencesDto } from "./dto"; + +describe("PreferencesService", () => { + let service: PreferencesService; + + const mockPrisma = { + userPreference: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + }; + + const mockUserId = "user-uuid-123"; + + const mockDbPreference = { + id: "pref-uuid-456", + userId: mockUserId, + theme: "system", + locale: "en", + timezone: null, + settings: {}, + updatedAt: new Date("2026-01-01T00:00:00Z"), + }; + + beforeEach(() => { + service = new PreferencesService(mockPrisma as unknown as PrismaService); + vi.clearAllMocks(); + }); + + describe("getPreferences", () => { + it("should return existing preferences", async () => { + mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference); + + const result = await service.getPreferences(mockUserId); + + expect(result).toMatchObject({ + id: mockDbPreference.id, + userId: mockUserId, + theme: "system", + locale: "en", + timezone: null, + settings: {}, + }); + expect(mockPrisma.userPreference.findUnique).toHaveBeenCalledWith({ + where: { userId: mockUserId }, + }); + expect(mockPrisma.userPreference.create).not.toHaveBeenCalled(); + }); + + it("should create default preferences when none exist", async () => { + mockPrisma.userPreference.findUnique.mockResolvedValue(null); + mockPrisma.userPreference.create.mockResolvedValue(mockDbPreference); + + const result = await service.getPreferences(mockUserId); + + expect(result).toMatchObject({ + id: mockDbPreference.id, + userId: mockUserId, + theme: "system", + locale: "en", + }); + expect(mockPrisma.userPreference.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: mockUserId, + theme: "system", + locale: "en", + }), + }); + }); + }); + + describe("updatePreferences", () => { + it("should update existing preferences", async () => { + const updateDto: UpdatePreferencesDto = { theme: "dark", locale: "fr" }; + const updatedPreference = { ...mockDbPreference, theme: "dark", locale: "fr" }; + + mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference); + mockPrisma.userPreference.update.mockResolvedValue(updatedPreference); + + const result = await service.updatePreferences(mockUserId, updateDto); + + expect(result).toMatchObject({ theme: "dark", locale: "fr" }); + expect(mockPrisma.userPreference.update).toHaveBeenCalledWith({ + where: { userId: mockUserId }, + data: expect.objectContaining({ theme: "dark", locale: "fr" }), + }); + expect(mockPrisma.userPreference.create).not.toHaveBeenCalled(); + }); + + it("should create preferences when updating non-existent record", async () => { + const updateDto: UpdatePreferencesDto = { theme: "light" }; + const createdPreference = { ...mockDbPreference, theme: "light" }; + + mockPrisma.userPreference.findUnique.mockResolvedValue(null); + mockPrisma.userPreference.create.mockResolvedValue(createdPreference); + + const result = await service.updatePreferences(mockUserId, updateDto); + + expect(result).toMatchObject({ theme: "light" }); + expect(mockPrisma.userPreference.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: mockUserId, + theme: "light", + }), + }); + expect(mockPrisma.userPreference.update).not.toHaveBeenCalled(); + }); + + it("should handle timezone update", async () => { + const updateDto: UpdatePreferencesDto = { timezone: "America/New_York" }; + const updatedPreference = { ...mockDbPreference, timezone: "America/New_York" }; + + mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference); + mockPrisma.userPreference.update.mockResolvedValue(updatedPreference); + + const result = await service.updatePreferences(mockUserId, updateDto); + + expect(result.timezone).toBe("America/New_York"); + expect(mockPrisma.userPreference.update).toHaveBeenCalledWith({ + where: { userId: mockUserId }, + data: expect.objectContaining({ timezone: "America/New_York" }), + }); + }); + + it("should handle settings update", async () => { + const updateDto: UpdatePreferencesDto = { settings: { notifications: true } }; + const updatedPreference = { ...mockDbPreference, settings: { notifications: true } }; + + mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference); + mockPrisma.userPreference.update.mockResolvedValue(updatedPreference); + + const result = await service.updatePreferences(mockUserId, updateDto); + + expect(result.settings).toEqual({ notifications: true }); + }); + }); +}); diff --git a/apps/api/src/websocket/websocket.gateway.ts b/apps/api/src/websocket/websocket.gateway.ts index 79caa61..f5b385c 100644 --- a/apps/api/src/websocket/websocket.gateway.ts +++ b/apps/api/src/websocket/websocket.gateway.ts @@ -7,6 +7,7 @@ import { import { Logger } from "@nestjs/common"; import { Server, Socket } from "socket.io"; import { AuthService } from "../auth/auth.service"; +import { getTrustedOrigins } from "../auth/auth.config"; import { PrismaService } from "../prisma/prisma.service"; interface AuthenticatedSocket extends Socket { @@ -77,7 +78,7 @@ interface StepOutputData { */ @WSGateway({ cors: { - origin: process.env.WEB_URL ?? "http://localhost:3000", + origin: getTrustedOrigins(), credentials: true, }, }) @@ -167,17 +168,36 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec } /** - * @description Extract authentication token from Socket.IO handshake + * @description Extract authentication token from Socket.IO handshake. + * + * Checks sources in order: + * 1. handshake.auth.token — explicit token (e.g. from API clients) + * 2. handshake.headers.cookie — session cookie sent by browser via withCredentials + * 3. query.token — URL query parameter fallback + * 4. Authorization header — Bearer token fallback + * * @param client - The socket client * @returns The token string or undefined if not found */ private extractTokenFromHandshake(client: Socket): string | undefined { - // Check handshake.auth.token (preferred method) + // Check handshake.auth.token (preferred method for non-browser clients) const authToken = client.handshake.auth.token as unknown; if (typeof authToken === "string" && authToken.length > 0) { return authToken; } + // Fallback: parse session cookie from request headers. + // Browsers send httpOnly cookies automatically when withCredentials: true is set + // on the socket.io client. BetterAuth uses one of these cookie names depending + // on whether the connection is HTTPS (Secure prefix) or HTTP (dev). + const cookieHeader = client.handshake.headers.cookie; + if (typeof cookieHeader === "string" && cookieHeader.length > 0) { + const cookieToken = this.extractTokenFromCookieHeader(cookieHeader); + if (cookieToken) { + return cookieToken; + } + } + // Fallback: check query parameters const queryToken = client.handshake.query.token as unknown; if (typeof queryToken === "string" && queryToken.length > 0) { @@ -197,6 +217,45 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec return undefined; } + /** + * @description Parse the BetterAuth session token from a raw Cookie header string. + * + * BetterAuth names the session cookie differently based on the security context: + * - `__Secure-better-auth.session_token` — HTTPS with Secure flag + * - `better-auth.session_token` — HTTP (development) + * - `__Host-better-auth.session_token` — HTTPS with Host prefix + * + * @param cookieHeader - The raw Cookie header value + * @returns The session token value or undefined if no matching cookie found + */ + private extractTokenFromCookieHeader(cookieHeader: string): string | undefined { + const SESSION_COOKIE_NAMES = [ + "__Secure-better-auth.session_token", + "better-auth.session_token", + "__Host-better-auth.session_token", + ] as const; + + // Parse the Cookie header into a key-value map + const cookies = Object.fromEntries( + cookieHeader.split(";").map((pair) => { + const eqIndex = pair.indexOf("="); + if (eqIndex === -1) { + return [pair.trim(), ""]; + } + return [pair.slice(0, eqIndex).trim(), pair.slice(eqIndex + 1).trim()]; + }) + ); + + for (const name of SESSION_COOKIE_NAMES) { + const value = cookies[name]; + if (typeof value === "string" && value.length > 0) { + return value; + } + } + + return undefined; + } + /** * @description Handle client disconnect by leaving the workspace room. * @param client - The socket client containing workspaceId in data. diff --git a/apps/api/src/widgets/widgets.controller.ts b/apps/api/src/widgets/widgets.controller.ts index c90c33d..fb4bd5a 100644 --- a/apps/api/src/widgets/widgets.controller.ts +++ b/apps/api/src/widgets/widgets.controller.ts @@ -1,22 +1,14 @@ -import { - Controller, - Get, - Post, - Body, - Param, - UseGuards, - Request, - UnauthorizedException, -} from "@nestjs/common"; +import { Controller, Get, Post, Body, Param, UseGuards, Request } from "@nestjs/common"; import { WidgetsService } from "./widgets.service"; import { WidgetDataService } from "./widget-data.service"; import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard } from "../common/guards/workspace.guard"; import type { StatCardQueryDto, ChartQueryDto, ListQueryDto, CalendarPreviewQueryDto } from "./dto"; -import type { AuthenticatedRequest } from "../common/types/user.types"; +import type { RequestWithWorkspace } from "../common/types/user.types"; /** * Controller for widget definition and data endpoints - * All endpoints require authentication + * All endpoints require authentication; data endpoints also require workspace context */ @Controller("widgets") @UseGuards(AuthGuard) @@ -51,12 +43,9 @@ export class WidgetsController { * Get stat card widget data */ @Post("data/stat-card") - async getStatCardData(@Request() req: AuthenticatedRequest, @Body() query: StatCardQueryDto) { - const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId; - if (!workspaceId) { - throw new UnauthorizedException("Workspace ID required"); - } - return this.widgetDataService.getStatCardData(workspaceId, query); + @UseGuards(WorkspaceGuard) + async getStatCardData(@Request() req: RequestWithWorkspace, @Body() query: StatCardQueryDto) { + return this.widgetDataService.getStatCardData(req.workspace.id, query); } /** @@ -64,12 +53,9 @@ export class WidgetsController { * Get chart widget data */ @Post("data/chart") - async getChartData(@Request() req: AuthenticatedRequest, @Body() query: ChartQueryDto) { - const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId; - if (!workspaceId) { - throw new UnauthorizedException("Workspace ID required"); - } - return this.widgetDataService.getChartData(workspaceId, query); + @UseGuards(WorkspaceGuard) + async getChartData(@Request() req: RequestWithWorkspace, @Body() query: ChartQueryDto) { + return this.widgetDataService.getChartData(req.workspace.id, query); } /** @@ -77,12 +63,9 @@ export class WidgetsController { * Get list widget data */ @Post("data/list") - async getListData(@Request() req: AuthenticatedRequest, @Body() query: ListQueryDto) { - const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId; - if (!workspaceId) { - throw new UnauthorizedException("Workspace ID required"); - } - return this.widgetDataService.getListData(workspaceId, query); + @UseGuards(WorkspaceGuard) + async getListData(@Request() req: RequestWithWorkspace, @Body() query: ListQueryDto) { + return this.widgetDataService.getListData(req.workspace.id, query); } /** @@ -90,15 +73,12 @@ export class WidgetsController { * Get calendar preview widget data */ @Post("data/calendar-preview") + @UseGuards(WorkspaceGuard) async getCalendarPreviewData( - @Request() req: AuthenticatedRequest, + @Request() req: RequestWithWorkspace, @Body() query: CalendarPreviewQueryDto ) { - const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId; - if (!workspaceId) { - throw new UnauthorizedException("Workspace ID required"); - } - return this.widgetDataService.getCalendarPreviewData(workspaceId, query); + return this.widgetDataService.getCalendarPreviewData(req.workspace.id, query); } /** @@ -106,12 +86,9 @@ export class WidgetsController { * Get active projects widget data */ @Post("data/active-projects") - async getActiveProjectsData(@Request() req: AuthenticatedRequest) { - const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId; - if (!workspaceId) { - throw new UnauthorizedException("Workspace ID required"); - } - return this.widgetDataService.getActiveProjectsData(workspaceId); + @UseGuards(WorkspaceGuard) + async getActiveProjectsData(@Request() req: RequestWithWorkspace) { + return this.widgetDataService.getActiveProjectsData(req.workspace.id); } /** @@ -119,11 +96,8 @@ export class WidgetsController { * Get agent chains widget data (active agent sessions) */ @Post("data/agent-chains") - async getAgentChainsData(@Request() req: AuthenticatedRequest) { - const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId; - if (!workspaceId) { - throw new UnauthorizedException("Workspace ID required"); - } - return this.widgetDataService.getAgentChainsData(workspaceId); + @UseGuards(WorkspaceGuard) + async getAgentChainsData(@Request() req: RequestWithWorkspace) { + return this.widgetDataService.getAgentChainsData(req.workspace.id); } } diff --git a/apps/api/src/workspaces/dto/index.ts b/apps/api/src/workspaces/dto/index.ts new file mode 100644 index 0000000..32bb49a --- /dev/null +++ b/apps/api/src/workspaces/dto/index.ts @@ -0,0 +1 @@ +export { WorkspaceResponseDto } from "./workspace-response.dto"; diff --git a/apps/api/src/workspaces/dto/workspace-response.dto.ts b/apps/api/src/workspaces/dto/workspace-response.dto.ts new file mode 100644 index 0000000..b89d294 --- /dev/null +++ b/apps/api/src/workspaces/dto/workspace-response.dto.ts @@ -0,0 +1,12 @@ +import type { WorkspaceMemberRole } from "@prisma/client"; + +/** + * Response DTO for a workspace the authenticated user belongs to. + */ +export class WorkspaceResponseDto { + id!: string; + name!: string; + ownerId!: string; + role!: WorkspaceMemberRole; + createdAt!: Date; +} diff --git a/apps/api/src/workspaces/index.ts b/apps/api/src/workspaces/index.ts new file mode 100644 index 0000000..402915b --- /dev/null +++ b/apps/api/src/workspaces/index.ts @@ -0,0 +1,3 @@ +export { WorkspacesModule } from "./workspaces.module"; +export { WorkspacesService } from "./workspaces.service"; +export { WorkspacesController } from "./workspaces.controller"; diff --git a/apps/api/src/workspaces/workspaces.controller.spec.ts b/apps/api/src/workspaces/workspaces.controller.spec.ts new file mode 100644 index 0000000..31a04ee --- /dev/null +++ b/apps/api/src/workspaces/workspaces.controller.spec.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { WorkspacesController } from "./workspaces.controller"; +import { WorkspacesService } from "./workspaces.service"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceMemberRole } from "@prisma/client"; +import type { AuthUser } from "@mosaic/shared"; + +describe("WorkspacesController", () => { + let controller: WorkspacesController; + let service: WorkspacesService; + + const mockWorkspacesService = { + getUserWorkspaces: vi.fn(), + }; + + const mockUser: AuthUser = { + id: "user-1", + email: "test@example.com", + name: "Test User", + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [WorkspacesController], + providers: [ + { + provide: WorkspacesService, + useValue: mockWorkspacesService, + }, + ], + }) + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(WorkspacesController); + service = module.get(WorkspacesService); + + vi.clearAllMocks(); + }); + + describe("GET /api/workspaces", () => { + it("should call service with authenticated user id", async () => { + mockWorkspacesService.getUserWorkspaces.mockResolvedValueOnce([]); + + await controller.getUserWorkspaces(mockUser); + + expect(service.getUserWorkspaces).toHaveBeenCalledWith("user-1"); + }); + + it("should return workspace list from service", async () => { + const mockWorkspaces = [ + { + id: "ws-1", + name: "My Workspace", + ownerId: "user-1", + role: WorkspaceMemberRole.OWNER, + createdAt: new Date("2026-01-01"), + }, + ]; + mockWorkspacesService.getUserWorkspaces.mockResolvedValueOnce(mockWorkspaces); + + const result = await controller.getUserWorkspaces(mockUser); + + expect(result).toEqual(mockWorkspaces); + }); + + it("should propagate service errors", async () => { + mockWorkspacesService.getUserWorkspaces.mockRejectedValueOnce(new Error("Database error")); + + await expect(controller.getUserWorkspaces(mockUser)).rejects.toThrow("Database error"); + }); + }); +}); diff --git a/apps/api/src/workspaces/workspaces.controller.ts b/apps/api/src/workspaces/workspaces.controller.ts new file mode 100644 index 0000000..d23f825 --- /dev/null +++ b/apps/api/src/workspaces/workspaces.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, UseGuards } from "@nestjs/common"; +import { WorkspacesService } from "./workspaces.service"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { CurrentUser } from "../auth/decorators/current-user.decorator"; +import type { AuthUser } from "@mosaic/shared"; +import type { WorkspaceResponseDto } from "./dto"; + +/** + * User-scoped workspace operations. + * + * Intentionally does NOT use WorkspaceGuard — these routes operate across all + * workspaces the user belongs to, not within a single workspace context. + */ +@Controller("workspaces") +@UseGuards(AuthGuard) +export class WorkspacesController { + constructor(private readonly workspacesService: WorkspacesService) {} + + /** + * GET /api/workspaces + * Returns workspaces the authenticated user is a member of. + * Auto-provisions a default workspace if the user has none. + */ + @Get() + async getUserWorkspaces(@CurrentUser() user: AuthUser): Promise { + return this.workspacesService.getUserWorkspaces(user.id); + } +} diff --git a/apps/api/src/workspaces/workspaces.module.ts b/apps/api/src/workspaces/workspaces.module.ts new file mode 100644 index 0000000..e9157fa --- /dev/null +++ b/apps/api/src/workspaces/workspaces.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { WorkspacesController } from "./workspaces.controller"; +import { WorkspacesService } from "./workspaces.service"; +import { PrismaModule } from "../prisma/prisma.module"; +import { AuthModule } from "../auth/auth.module"; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [WorkspacesController], + providers: [WorkspacesService], + exports: [WorkspacesService], +}) +export class WorkspacesModule {} diff --git a/apps/api/src/workspaces/workspaces.service.spec.ts b/apps/api/src/workspaces/workspaces.service.spec.ts new file mode 100644 index 0000000..2c87b0c --- /dev/null +++ b/apps/api/src/workspaces/workspaces.service.spec.ts @@ -0,0 +1,229 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { WorkspacesService } from "./workspaces.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { WorkspaceMemberRole } from "@prisma/client"; + +describe("WorkspacesService", () => { + let service: WorkspacesService; + + const mockUserId = "550e8400-e29b-41d4-a716-446655440001"; + const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440002"; + + const mockWorkspace = { + id: mockWorkspaceId, + name: "Test Workspace", + ownerId: mockUserId, + settings: {}, + matrixRoomId: null, + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-01"), + }; + + const mockMembership = { + workspaceId: mockWorkspaceId, + userId: mockUserId, + role: WorkspaceMemberRole.OWNER, + joinedAt: new Date("2026-01-01"), + workspace: { + id: mockWorkspaceId, + name: "Test Workspace", + ownerId: mockUserId, + createdAt: new Date("2026-01-01"), + }, + }; + + const mockPrismaService = { + workspaceMember: { + findMany: vi.fn(), + create: vi.fn(), + }, + workspace: { + create: vi.fn(), + }, + $transaction: vi.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkspacesService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(WorkspacesService); + + vi.clearAllMocks(); + }); + + describe("getUserWorkspaces", () => { + it("should return all workspaces user is a member of", async () => { + mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([mockMembership]); + + const result = await service.getUserWorkspaces(mockUserId); + + expect(result).toEqual([ + { + id: mockWorkspaceId, + name: "Test Workspace", + ownerId: mockUserId, + role: WorkspaceMemberRole.OWNER, + createdAt: mockMembership.workspace.createdAt, + }, + ]); + expect(mockPrismaService.workspaceMember.findMany).toHaveBeenCalledWith({ + where: { userId: mockUserId }, + include: { + workspace: { + select: { id: true, name: true, ownerId: true, createdAt: true }, + }, + }, + orderBy: { joinedAt: "asc" }, + }); + }); + + it("should return multiple workspaces ordered by joinedAt", async () => { + const secondWorkspace = { + ...mockMembership, + workspaceId: "ws-2", + role: WorkspaceMemberRole.MEMBER, + joinedAt: new Date("2026-02-01"), + workspace: { + id: "ws-2", + name: "Second Workspace", + ownerId: "other-user", + createdAt: new Date("2026-02-01"), + }, + }; + + mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([ + mockMembership, + secondWorkspace, + ]); + + const result = await service.getUserWorkspaces(mockUserId); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe(mockWorkspaceId); + expect(result[1].id).toBe("ws-2"); + expect(result[1].role).toBe(WorkspaceMemberRole.MEMBER); + }); + + it("should auto-provision a default workspace when user has no memberships", async () => { + mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([]); + mockPrismaService.$transaction.mockImplementationOnce( + async (fn: (tx: typeof mockPrismaService) => Promise) => { + const txMock = { + workspaceMember: { + findFirst: vi.fn().mockResolvedValueOnce(null), + create: vi.fn().mockResolvedValueOnce({}), + }, + workspace: { + create: vi.fn().mockResolvedValueOnce(mockWorkspace), + }, + }; + return fn(txMock as unknown as typeof mockPrismaService); + } + ); + + const result = await service.getUserWorkspaces(mockUserId); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe("Test Workspace"); + expect(result[0].role).toBe(WorkspaceMemberRole.OWNER); + expect(mockPrismaService.$transaction).toHaveBeenCalledTimes(1); + }); + + it("should return existing workspace if one was created between initial check and transaction", async () => { + // Simulates a race condition: initial findMany returns [], but inside the + // transaction another request already created a workspace. + mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([]); + mockPrismaService.$transaction.mockImplementationOnce( + async (fn: (tx: typeof mockPrismaService) => Promise) => { + const txMock = { + workspaceMember: { + findFirst: vi.fn().mockResolvedValueOnce(mockMembership), + }, + workspace: { + create: vi.fn(), + }, + }; + return fn(txMock as unknown as typeof mockPrismaService); + } + ); + + const result = await service.getUserWorkspaces(mockUserId); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(mockWorkspaceId); + expect(result[0].name).toBe("Test Workspace"); + }); + + it("should create workspace with correct data during auto-provisioning", async () => { + mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([]); + + let capturedWorkspaceData: unknown; + let capturedMemberData: unknown; + + mockPrismaService.$transaction.mockImplementationOnce( + async (fn: (tx: typeof mockPrismaService) => Promise) => { + const txMock = { + workspaceMember: { + findFirst: vi.fn().mockResolvedValueOnce(null), + create: vi.fn().mockImplementation((args: unknown) => { + capturedMemberData = args; + return {}; + }), + }, + workspace: { + create: vi.fn().mockImplementation((args: unknown) => { + capturedWorkspaceData = args; + return mockWorkspace; + }), + }, + }; + return fn(txMock as unknown as typeof mockPrismaService); + } + ); + + await service.getUserWorkspaces(mockUserId); + + expect(capturedWorkspaceData).toEqual({ + data: { + name: "My Workspace", + ownerId: mockUserId, + settings: {}, + }, + }); + expect(capturedMemberData).toEqual({ + data: { + workspaceId: mockWorkspaceId, + userId: mockUserId, + role: WorkspaceMemberRole.OWNER, + }, + }); + }); + + it("should not auto-provision when user already has workspaces", async () => { + mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([mockMembership]); + + await service.getUserWorkspaces(mockUserId); + + expect(mockPrismaService.$transaction).not.toHaveBeenCalled(); + }); + + it("should propagate database errors", async () => { + mockPrismaService.workspaceMember.findMany.mockRejectedValueOnce( + new Error("Database connection failed") + ); + + await expect(service.getUserWorkspaces(mockUserId)).rejects.toThrow( + "Database connection failed" + ); + }); + }); +}); diff --git a/apps/api/src/workspaces/workspaces.service.ts b/apps/api/src/workspaces/workspaces.service.ts new file mode 100644 index 0000000..247932e --- /dev/null +++ b/apps/api/src/workspaces/workspaces.service.ts @@ -0,0 +1,97 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { WorkspaceMemberRole } from "@prisma/client"; +import { PrismaService } from "../prisma/prisma.service"; +import type { WorkspaceResponseDto } from "./dto"; + +@Injectable() +export class WorkspacesService { + private readonly logger = new Logger(WorkspacesService.name); + + constructor(private readonly prisma: PrismaService) {} + + /** + * Get all workspaces the user is a member of. + * + * Auto-provisioning: if the user has no workspace memberships (e.g. fresh + * signup via BetterAuth), a default workspace is created atomically and + * returned. This is the only call site for workspace bootstrapping. + */ + async getUserWorkspaces(userId: string): Promise { + const memberships = await this.prisma.workspaceMember.findMany({ + where: { userId }, + include: { + workspace: { + select: { id: true, name: true, ownerId: true, createdAt: true }, + }, + }, + orderBy: { joinedAt: "asc" }, + }); + + if (memberships.length > 0) { + return memberships.map((m) => ({ + id: m.workspace.id, + name: m.workspace.name, + ownerId: m.workspace.ownerId, + role: m.role, + createdAt: m.workspace.createdAt, + })); + } + + // Auto-provision a default workspace for new users. + // Re-query inside the transaction to guard against concurrent requests + // both seeing zero memberships and creating duplicate workspaces. + this.logger.log(`Auto-provisioning default workspace for user ${userId}`); + + const workspace = await this.prisma.$transaction(async (tx) => { + const existing = await tx.workspaceMember.findFirst({ + where: { userId }, + include: { + workspace: { + select: { id: true, name: true, ownerId: true, createdAt: true }, + }, + }, + }); + if (existing) { + return { ...existing.workspace, alreadyExisted: true as const }; + } + + const created = await tx.workspace.create({ + data: { + name: "My Workspace", + ownerId: userId, + settings: {}, + }, + }); + await tx.workspaceMember.create({ + data: { + workspaceId: created.id, + userId, + role: WorkspaceMemberRole.OWNER, + }, + }); + return { ...created, alreadyExisted: false as const }; + }); + + if (workspace.alreadyExisted) { + return [ + { + id: workspace.id, + name: workspace.name, + ownerId: workspace.ownerId, + role: WorkspaceMemberRole.OWNER, + createdAt: workspace.createdAt, + }, + ]; + } + + return [ + { + id: workspace.id, + name: workspace.name, + ownerId: workspace.ownerId, + role: WorkspaceMemberRole.OWNER, + createdAt: workspace.createdAt, + }, + ]; + } +} diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index d5c09e5..467b647 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -326,7 +326,7 @@ function LoginPageContent(): ReactElement {
- +
diff --git a/apps/web/src/app/(authenticated)/profile/page.tsx b/apps/web/src/app/(authenticated)/profile/page.tsx index 52bef2c..9ae73fc 100644 --- a/apps/web/src/app/(authenticated)/profile/page.tsx +++ b/apps/web/src/app/(authenticated)/profile/page.tsx @@ -103,7 +103,7 @@ export default function ProfilePage(): ReactElement { setPrefsError(null); try { - const data = await apiGet("/users/me/preferences"); + const data = await apiGet("/api/users/me/preferences"); setPreferences(data); } catch (err: unknown) { const message = err instanceof Error ? err.message : "Could not load preferences"; @@ -265,23 +265,8 @@ export default function ProfilePage(): ReactElement {

)} - {user?.workspaceRole && ( - - {user.workspaceRole} - - )} + {/* Workspace role badge — placeholder until workspace context API + provides role data via GET /api/workspaces */} diff --git a/apps/web/src/app/(authenticated)/settings/appearance/page.tsx b/apps/web/src/app/(authenticated)/settings/appearance/page.tsx index 3149d9a..6728ea6 100644 --- a/apps/web/src/app/(authenticated)/settings/appearance/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/appearance/page.tsx @@ -240,7 +240,7 @@ export default function AppearanceSettingsPage(): ReactElement { setLocalTheme(themeId); setSaving(true); try { - await apiPatch("/users/me/preferences", { theme: themeId }); + await apiPatch("/api/users/me/preferences", { theme: themeId }); } catch { // Theme is still applied locally even if API save fails } finally { diff --git a/apps/web/src/app/(authenticated)/settings/credentials/audit/page.tsx b/apps/web/src/app/(authenticated)/settings/credentials/audit/page.tsx index 4eb77d4..271df01 100644 --- a/apps/web/src/app/(authenticated)/settings/credentials/audit/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/credentials/audit/page.tsx @@ -14,6 +14,7 @@ import { SelectValue, } from "@/components/ui/select"; import { fetchCredentialAuditLog, type AuditLogEntry } from "@/lib/api/credentials"; +import { useWorkspaceId } from "@/lib/hooks"; const ACTIVITY_ACTIONS = [ { value: "CREDENTIAL_CREATED", label: "Created" }, @@ -39,17 +40,17 @@ export default function CredentialAuditPage(): React.ReactElement { const [filters, setFilters] = useState({}); const [hasFilters, setHasFilters] = useState(false); - // TODO: Get workspace ID from context/auth - const workspaceId = "default-workspace-id"; // Placeholder + const workspaceId = useWorkspaceId(); useEffect(() => { - void loadLogs(); - }, [page, filters]); + if (!workspaceId) return; + void loadLogs(workspaceId); + }, [workspaceId, page, filters]); - async function loadLogs(): Promise { + async function loadLogs(wsId: string): Promise { try { setIsLoading(true); - const response = await fetchCredentialAuditLog(workspaceId, { + const response = await fetchCredentialAuditLog(wsId, { ...filters, page, limit, diff --git a/apps/web/src/app/(authenticated)/settings/credentials/page.tsx b/apps/web/src/app/(authenticated)/settings/credentials/page.tsx index 5df0c66..e0f3af1 100644 --- a/apps/web/src/app/(authenticated)/settings/credentials/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/credentials/page.tsx @@ -1,27 +1,827 @@ "use client"; -import { useState, useEffect } from "react"; -import { Plus, History } from "lucide-react"; +import { useState, useEffect, type SyntheticEvent } from "react"; +import { Plus, History, Key, RotateCw, Trash2, Eye, EyeOff } from "lucide-react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { fetchCredentials, type Credential } from "@/lib/api/credentials"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + fetchCredentials, + createCredential, + deleteCredential, + rotateCredential, + CredentialType, + CredentialScope, + type Credential, + type CreateCredentialDto, +} from "@/lib/api/credentials"; +import { useWorkspaceId } from "@/lib/hooks"; + +/* --------------------------------------------------------------------------- + Constants + --------------------------------------------------------------------------- */ + +const CREDENTIAL_TYPE_LABELS: Record = { + [CredentialType.API_KEY]: "API Key", + [CredentialType.OAUTH_TOKEN]: "OAuth Token", + [CredentialType.ACCESS_TOKEN]: "Access Token", + [CredentialType.SECRET]: "Secret", + [CredentialType.PASSWORD]: "Password", + [CredentialType.CUSTOM]: "Custom", +}; + +const CREDENTIAL_SCOPE_LABELS: Record = { + [CredentialScope.USER]: "User", + [CredentialScope.WORKSPACE]: "Workspace", + [CredentialScope.SYSTEM]: "System", +}; + +/* --------------------------------------------------------------------------- + Add Credential Dialog + --------------------------------------------------------------------------- */ + +interface AddCredentialDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (data: CreateCredentialDto) => Promise; + isSubmitting: boolean; +} + +function AddCredentialDialog({ + open, + onOpenChange, + onSubmit, + isSubmitting, +}: AddCredentialDialogProps): React.ReactElement | null { + const [name, setName] = useState(""); + const [provider, setProvider] = useState(""); + const [type, setType] = useState(CredentialType.API_KEY); + const [scope, setScope] = useState(CredentialScope.WORKSPACE); + const [value, setValue] = useState(""); + const [description, setDescription] = useState(""); + const [showValue, setShowValue] = useState(false); + const [formError, setFormError] = useState(null); + + function resetForm(): void { + setName(""); + setProvider(""); + setType(CredentialType.API_KEY); + setScope(CredentialScope.WORKSPACE); + setValue(""); + setDescription(""); + setShowValue(false); + setFormError(null); + } + + async function handleSubmit(e: SyntheticEvent): Promise { + e.preventDefault(); + setFormError(null); + + const trimmedName = name.trim(); + if (!trimmedName) { + setFormError("Name is required."); + return; + } + + const trimmedProvider = provider.trim(); + if (!trimmedProvider) { + setFormError("Provider is required."); + return; + } + + const trimmedValue = value.trim(); + if (!trimmedValue) { + setFormError("Credential value is required."); + return; + } + + try { + const payload: CreateCredentialDto = { + name: trimmedName, + provider: trimmedProvider, + type, + scope, + value: trimmedValue, + }; + + const trimmedDesc = description.trim(); + if (trimmedDesc) { + payload.description = trimmedDesc; + } + + await onSubmit(payload); + resetForm(); + } catch (err: unknown) { + setFormError(err instanceof Error ? err.message : "Failed to create credential."); + } + } + + if (!open) return null; + + return ( +
+ {/* Backdrop */} +
{ + if (!isSubmitting) { + resetForm(); + onOpenChange(false); + } + }} + /> + + {/* Dialog */} +
+

+ Add Credential +

+

+ Securely store an API key, token, or secret. +

+ +
{ + void handleSubmit(e); + }} + > + {/* Name */} +
+ + ) => { + setName(e.target.value); + }} + placeholder="e.g. GitHub Personal Access Token" + maxLength={255} + autoFocus + /> +
+ + {/* Provider */} +
+ + ) => { + setProvider(e.target.value); + }} + placeholder="e.g. github, openai, custom" + maxLength={100} + /> +

+ The service or system this credential belongs to. +

+
+ + {/* Type */} +
+ + +
+ + {/* Scope */} +
+ + +
+ + {/* Value */} +
+ +
+ ) => { + setValue(e.target.value); + }} + placeholder="Paste your secret value here" + style={{ paddingRight: "40px" }} + /> + +
+

+ The value is encrypted at rest and never returned in plaintext. +

+
+ + {/* Description */} +
+ +