From a606b431a79a11b88ffde3db8f0d30e99a6945ee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 30 Jun 2026 20:02:51 +0000 Subject: [PATCH 1/2] feat(admin): add Organization CRUD API and admin web page - AdminModule with PrismaModule import - AdminRoleGuard: extends JwtAuthGuard, enforces role === 'admin' (403 otherwise) - OrganizationService: findAll, findOne, create, update with P2002 guard - OrganizationsController: GET/POST /api/admin/organizations, GET/PATCH /api/admin/organizations/:id - 29 tests: OrganizationService unit tests + integration tests covering create, read, auth rejection, role rejection, 404, 409 paths - Next.js admin page at /admin/organizations: server-rendered list + create form + inline edit via Server Actions Co-authored-by: Andrea Mazzucchelli --- apps/api/src/admin/admin.module.ts | 11 + apps/api/src/admin/guards/admin-role.guard.ts | 36 +++ .../admin/organizations/organization.dto.ts | 14 + .../organization.service.spec.ts | 183 +++++++++++ .../organizations/organization.service.ts | 72 +++++ .../organizations/organizations.controller.ts | 81 +++++ .../organizations.integration.spec.ts | 304 ++++++++++++++++++ apps/api/src/app.module.ts | 3 +- apps/web/app/admin/organizations/OrgForm.tsx | 185 +++++++++++ apps/web/app/admin/organizations/OrgTable.tsx | 101 ++++++ apps/web/app/admin/organizations/actions.ts | 43 +++ apps/web/app/admin/organizations/api.ts | 80 +++++ apps/web/app/admin/organizations/page.tsx | 91 ++++++ apps/web/app/admin/organizations/types.ts | 11 + 14 files changed, 1214 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/admin/admin.module.ts create mode 100644 apps/api/src/admin/guards/admin-role.guard.ts create mode 100644 apps/api/src/admin/organizations/organization.dto.ts create mode 100644 apps/api/src/admin/organizations/organization.service.spec.ts create mode 100644 apps/api/src/admin/organizations/organization.service.ts create mode 100644 apps/api/src/admin/organizations/organizations.controller.ts create mode 100644 apps/api/src/admin/organizations/organizations.integration.spec.ts create mode 100644 apps/web/app/admin/organizations/OrgForm.tsx create mode 100644 apps/web/app/admin/organizations/OrgTable.tsx create mode 100644 apps/web/app/admin/organizations/actions.ts create mode 100644 apps/web/app/admin/organizations/api.ts create mode 100644 apps/web/app/admin/organizations/page.tsx create mode 100644 apps/web/app/admin/organizations/types.ts diff --git a/apps/api/src/admin/admin.module.ts b/apps/api/src/admin/admin.module.ts new file mode 100644 index 0000000..5c2e469 --- /dev/null +++ b/apps/api/src/admin/admin.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { PrismaModule } from "../prisma/prisma.module"; +import { OrganizationsController } from "./organizations/organizations.controller"; +import { OrganizationService } from "./organizations/organization.service"; + +@Module({ + imports: [PrismaModule], + controllers: [OrganizationsController], + providers: [OrganizationService], +}) +export class AdminModule {} diff --git a/apps/api/src/admin/guards/admin-role.guard.ts b/apps/api/src/admin/guards/admin-role.guard.ts new file mode 100644 index 0000000..e1e12f9 --- /dev/null +++ b/apps/api/src/admin/guards/admin-role.guard.ts @@ -0,0 +1,36 @@ +import { + Injectable, + ExecutionContext, + ForbiddenException, +} from "@nestjs/common"; +import { JwtAuthGuard } from "../../auth/guards/jwt-auth.guard"; +import type { AuthenticatedUser } from "../../auth/auth.types"; + +interface RequestWithUser { + user: AuthenticatedUser; +} + +/** + * Combines JWT authentication with an admin role check. + * Requests from non-admin users receive 403 Forbidden after + * the JWT is validated, not 401 – the user is authenticated + * but not authorized. + */ +@Injectable() +export class AdminRoleGuard extends JwtAuthGuard { + override async canActivate(context: ExecutionContext): Promise { + await super.canActivate(context); + + const request = context + .switchToHttp() + .getRequest(); + + if (request.user.role !== "admin") { + throw new ForbiddenException( + "Admin role required to access this resource", + ); + } + + return true; + } +} diff --git a/apps/api/src/admin/organizations/organization.dto.ts b/apps/api/src/admin/organizations/organization.dto.ts new file mode 100644 index 0000000..03ec495 --- /dev/null +++ b/apps/api/src/admin/organizations/organization.dto.ts @@ -0,0 +1,14 @@ +export interface CreateOrganizationDto { + name: string; +} + +export interface UpdateOrganizationDto { + name?: string; +} + +export interface OrganizationResponseDto { + id: string; + name: string; + createdAt: string; + updatedAt: string; +} diff --git a/apps/api/src/admin/organizations/organization.service.spec.ts b/apps/api/src/admin/organizations/organization.service.spec.ts new file mode 100644 index 0000000..9090609 --- /dev/null +++ b/apps/api/src/admin/organizations/organization.service.spec.ts @@ -0,0 +1,183 @@ +import { Test, type TestingModule } from "@nestjs/testing"; +import { ConflictException, NotFoundException } from "@nestjs/common"; +import { OrganizationService } from "./organization.service"; +import { PrismaService } from "../../prisma/prisma.service"; + +const now = new Date("2024-01-01T00:00:00Z"); + +const mockOrg = { + id: "org-1", + name: "acme.com", + createdAt: now, + updatedAt: now, +}; + +const mockPrisma = { + organization: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, +}; + +describe("OrganizationService", () => { + let service: OrganizationService; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + OrganizationService, + { provide: PrismaService, useValue: mockPrisma }, + ], + }).compile(); + + service = module.get(OrganizationService); + }); + + describe("findAll", () => { + it("returns all organizations ordered by createdAt desc", async () => { + mockPrisma.organization.findMany.mockResolvedValue([mockOrg]); + + const result = await service.findAll(); + + expect(result).toEqual([mockOrg]); + expect(mockPrisma.organization.findMany).toHaveBeenCalledWith({ + orderBy: { createdAt: "desc" }, + }); + }); + + it("returns an empty array when no organizations exist", async () => { + mockPrisma.organization.findMany.mockResolvedValue([]); + + const result = await service.findAll(); + + expect(result).toEqual([]); + }); + }); + + describe("findOne", () => { + it("returns the organization when found", async () => { + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + + const result = await service.findOne("org-1"); + + expect(result).toEqual(mockOrg); + expect(mockPrisma.organization.findUnique).toHaveBeenCalledWith({ + where: { id: "org-1" }, + }); + }); + + it("throws NotFoundException when organization does not exist", async () => { + mockPrisma.organization.findUnique.mockResolvedValue(null); + + await expect(service.findOne("missing-id")).rejects.toThrow( + NotFoundException, + ); + }); + + it("includes the id in the NotFoundException message", async () => { + mockPrisma.organization.findUnique.mockResolvedValue(null); + + await expect(service.findOne("missing-id")).rejects.toThrow( + 'Organization with id "missing-id" not found', + ); + }); + }); + + describe("create", () => { + it("creates and returns the organization", async () => { + mockPrisma.organization.create.mockResolvedValue(mockOrg); + + const result = await service.create({ name: "acme.com" }); + + expect(result).toEqual(mockOrg); + expect(mockPrisma.organization.create).toHaveBeenCalledWith({ + data: { name: "acme.com" }, + }); + }); + + it("throws ConflictException on P2002 unique constraint violation", async () => { + const p2002 = Object.assign(new Error("Unique constraint failed"), { + code: "P2002", + }); + mockPrisma.organization.create.mockRejectedValue(p2002); + + await expect(service.create({ name: "acme.com" })).rejects.toThrow( + ConflictException, + ); + }); + + it("ConflictException message includes the duplicate name", async () => { + const p2002 = Object.assign(new Error("Unique constraint failed"), { + code: "P2002", + }); + mockPrisma.organization.create.mockRejectedValue(p2002); + + await expect(service.create({ name: "acme.com" })).rejects.toThrow( + 'Organization with name "acme.com" already exists', + ); + }); + + it("re-throws non-P2002 database errors", async () => { + const dbError = Object.assign(new Error("Connection refused"), { + code: "P1001", + }); + mockPrisma.organization.create.mockRejectedValue(dbError); + + await expect(service.create({ name: "acme.com" })).rejects.toThrow( + "Connection refused", + ); + }); + }); + + describe("update", () => { + it("updates and returns the organization", async () => { + const updated = { ...mockOrg, name: "newname.com" }; + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + mockPrisma.organization.update.mockResolvedValue(updated); + + const result = await service.update("org-1", { name: "newname.com" }); + + expect(result).toEqual(updated); + expect(mockPrisma.organization.update).toHaveBeenCalledWith({ + where: { id: "org-1" }, + data: { name: "newname.com" }, + }); + }); + + it("throws NotFoundException when the organization does not exist", async () => { + mockPrisma.organization.findUnique.mockResolvedValue(null); + + await expect( + service.update("missing-id", { name: "new" }), + ).rejects.toThrow(NotFoundException); + }); + + it("throws ConflictException on P2002 during update", async () => { + const p2002 = Object.assign(new Error("Unique constraint failed"), { + code: "P2002", + }); + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + mockPrisma.organization.update.mockRejectedValue(p2002); + + await expect( + service.update("org-1", { name: "taken.com" }), + ).rejects.toThrow(ConflictException); + }); + + it("applies no data update when dto is empty", async () => { + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + mockPrisma.organization.update.mockResolvedValue(mockOrg); + + await service.update("org-1", {}); + + expect(mockPrisma.organization.update).toHaveBeenCalledWith({ + where: { id: "org-1" }, + data: {}, + }); + }); + }); +}); diff --git a/apps/api/src/admin/organizations/organization.service.ts b/apps/api/src/admin/organizations/organization.service.ts new file mode 100644 index 0000000..77c8460 --- /dev/null +++ b/apps/api/src/admin/organizations/organization.service.ts @@ -0,0 +1,72 @@ +import { + Injectable, + ConflictException, + NotFoundException, +} from "@nestjs/common"; +import { PrismaService } from "../../prisma/prisma.service"; +import type { Organization } from "db/client"; +import type { + CreateOrganizationDto, + UpdateOrganizationDto, +} from "./organization.dto"; + +function isPrismaUniqueConstraintError(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code: string }).code === "P2002" + ); +} + +@Injectable() +export class OrganizationService { + constructor(private readonly prisma: PrismaService) {} + + async findAll(): Promise { + return this.prisma.organization.findMany({ + orderBy: { createdAt: "desc" }, + }); + } + + async findOne(id: string): Promise { + const org = await this.prisma.organization.findUnique({ where: { id } }); + if (!org) { + throw new NotFoundException(`Organization with id "${id}" not found`); + } + return org; + } + + async create(dto: CreateOrganizationDto): Promise { + try { + return await this.prisma.organization.create({ + data: { name: dto.name }, + }); + } catch (err) { + if (isPrismaUniqueConstraintError(err)) { + throw new ConflictException( + `Organization with name "${dto.name}" already exists`, + ); + } + throw err; + } + } + + async update(id: string, dto: UpdateOrganizationDto): Promise { + await this.findOne(id); + + try { + return await this.prisma.organization.update({ + where: { id }, + data: { ...(dto.name !== undefined && { name: dto.name }) }, + }); + } catch (err) { + if (isPrismaUniqueConstraintError(err)) { + throw new ConflictException( + `Organization with name "${dto.name}" already exists`, + ); + } + throw err; + } + } +} diff --git a/apps/api/src/admin/organizations/organizations.controller.ts b/apps/api/src/admin/organizations/organizations.controller.ts new file mode 100644 index 0000000..b1d7000 --- /dev/null +++ b/apps/api/src/admin/organizations/organizations.controller.ts @@ -0,0 +1,81 @@ +import { + Controller, + Get, + Post, + Patch, + Param, + Body, + UseGuards, + HttpCode, + HttpStatus, + BadRequestException, +} from "@nestjs/common"; +import { AdminRoleGuard } from "../guards/admin-role.guard"; +import { OrganizationService } from "./organization.service"; +import type { + CreateOrganizationDto, + OrganizationResponseDto, + UpdateOrganizationDto, +} from "./organization.dto"; +import type { Organization } from "db/client"; + +function toResponseDto(org: Organization): OrganizationResponseDto { + return { + id: org.id, + name: org.name, + createdAt: org.createdAt.toISOString(), + updatedAt: org.updatedAt.toISOString(), + }; +} + +@Controller("api/admin/organizations") +@UseGuards(AdminRoleGuard) +export class OrganizationsController { + constructor(private readonly organizationService: OrganizationService) {} + + @Get() + @HttpCode(HttpStatus.OK) + async findAll(): Promise { + const orgs = await this.organizationService.findAll(); + return orgs.map(toResponseDto); + } + + @Get(":id") + @HttpCode(HttpStatus.OK) + async findOne(@Param("id") id: string): Promise { + const org = await this.organizationService.findOne(id); + return toResponseDto(org); + } + + @Post() + @HttpCode(HttpStatus.CREATED) + async create( + @Body() body: CreateOrganizationDto, + ): Promise { + if (!body.name || typeof body.name !== "string" || !body.name.trim()) { + throw new BadRequestException("name is required and must be a string"); + } + const org = await this.organizationService.create({ + name: body.name.trim(), + }); + return toResponseDto(org); + } + + @Patch(":id") + @HttpCode(HttpStatus.OK) + async update( + @Param("id") id: string, + @Body() body: UpdateOrganizationDto, + ): Promise { + if ( + body.name !== undefined && + (typeof body.name !== "string" || !body.name.trim()) + ) { + throw new BadRequestException("name must be a non-empty string"); + } + const dto: UpdateOrganizationDto = + body.name !== undefined ? { name: body.name.trim() } : {}; + const org = await this.organizationService.update(id, dto); + return toResponseDto(org); + } +} diff --git a/apps/api/src/admin/organizations/organizations.integration.spec.ts b/apps/api/src/admin/organizations/organizations.integration.spec.ts new file mode 100644 index 0000000..cbbbfd0 --- /dev/null +++ b/apps/api/src/admin/organizations/organizations.integration.spec.ts @@ -0,0 +1,304 @@ +import { Test } from "@nestjs/testing"; +import type { INestApplication } from "@nestjs/common"; +import request from "supertest"; +import * as jwt from "jsonwebtoken"; +import { AdminModule } from "../admin.module"; +import { AuthModule } from "../../auth/auth.module"; +import { PrismaService } from "../../prisma/prisma.service"; +import { GoogleStrategy } from "../../auth/strategies/google.strategy"; + +const TEST_JWT_SECRET = "test-secret-for-admin-integration"; + +class MockGoogleStrategy { + name = "google"; +} + +const now = new Date("2024-06-01T12:00:00Z"); + +const mockOrg = { + id: "org-uuid-1", + name: "acme.com", + createdAt: now, + updatedAt: now, +}; + +const mockPrisma = { + $connect: jest.fn().mockResolvedValue(undefined), + $disconnect: jest.fn().mockResolvedValue(undefined), + user: { findUnique: jest.fn() }, + organization: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, +}; + +function issueToken(role: string, sub = "user-1"): string { + return jwt.sign( + { sub, email: `${sub}@example.com`, organizationId: "org-1", role }, + TEST_JWT_SECRET, + { expiresIn: "1h" }, + ); +} + +describe("Admin Organizations Integration", () => { + let app: INestApplication; + let previousJwtSecret: string | undefined; + + beforeAll(async () => { + previousJwtSecret = process.env["JWT_SECRET"]; + process.env["JWT_SECRET"] = TEST_JWT_SECRET; + + const moduleRef = await Test.createTestingModule({ + imports: [AdminModule, AuthModule], + }) + .overrideProvider(GoogleStrategy) + .useClass(MockGoogleStrategy) + .overrideProvider(PrismaService) + .useValue(mockPrisma) + .compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + if (previousJwtSecret === undefined) { + delete process.env["JWT_SECRET"]; + } else { + process.env["JWT_SECRET"] = previousJwtSecret; + } + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + // --------------------------------------------------------------------------- + // Auth / role enforcement + // --------------------------------------------------------------------------- + + describe("authorization", () => { + it("returns 401 when no token is provided", async () => { + await request(app.getHttpServer()) + .get("/api/admin/organizations") + .expect(401); + }); + + it("returns 401 when token is invalid", async () => { + await request(app.getHttpServer()) + .get("/api/admin/organizations") + .set("Authorization", "Bearer not.a.jwt") + .expect(401); + }); + + it("returns 403 when authenticated user has member role", async () => { + const token = issueToken("member"); + + await request(app.getHttpServer()) + .get("/api/admin/organizations") + .set("Authorization", `Bearer ${token}`) + .expect(403); + }); + + it("returns 403 on POST for non-admin role", async () => { + const token = issueToken("member"); + + await request(app.getHttpServer()) + .post("/api/admin/organizations") + .set("Authorization", `Bearer ${token}`) + .send({ name: "test.com" }) + .expect(403); + }); + }); + + // --------------------------------------------------------------------------- + // List (read) + // --------------------------------------------------------------------------- + + describe("GET /api/admin/organizations", () => { + it("returns 200 with org list for admin", async () => { + const token = issueToken("admin"); + mockPrisma.organization.findMany.mockResolvedValue([mockOrg]); + + const res = await request(app.getHttpServer()) + .get("/api/admin/organizations") + .set("Authorization", `Bearer ${token}`) + .expect(200); + + expect(Array.isArray(res.body)).toBe(true); + expect(res.body).toHaveLength(1); + expect(res.body[0]).toMatchObject({ + id: mockOrg.id, + name: mockOrg.name, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }); + }); + + it("returns an empty array when no organizations exist", async () => { + const token = issueToken("admin"); + mockPrisma.organization.findMany.mockResolvedValue([]); + + const res = await request(app.getHttpServer()) + .get("/api/admin/organizations") + .set("Authorization", `Bearer ${token}`) + .expect(200); + + expect(res.body).toEqual([]); + }); + }); + + // --------------------------------------------------------------------------- + // Get by ID (read) + // --------------------------------------------------------------------------- + + describe("GET /api/admin/organizations/:id", () => { + it("returns 200 with org details for valid id", async () => { + const token = issueToken("admin"); + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + + const res = await request(app.getHttpServer()) + .get(`/api/admin/organizations/${mockOrg.id}`) + .set("Authorization", `Bearer ${token}`) + .expect(200); + + expect(res.body).toMatchObject({ + id: mockOrg.id, + name: mockOrg.name, + }); + }); + + it("returns 404 when organization does not exist", async () => { + const token = issueToken("admin"); + mockPrisma.organization.findUnique.mockResolvedValue(null); + + await request(app.getHttpServer()) + .get("/api/admin/organizations/missing-id") + .set("Authorization", `Bearer ${token}`) + .expect(404); + }); + }); + + // --------------------------------------------------------------------------- + // Create (write) + // --------------------------------------------------------------------------- + + describe("POST /api/admin/organizations", () => { + it("returns 201 with new org on valid payload", async () => { + const token = issueToken("admin"); + mockPrisma.organization.create.mockResolvedValue(mockOrg); + + const res = await request(app.getHttpServer()) + .post("/api/admin/organizations") + .set("Authorization", `Bearer ${token}`) + .send({ name: "acme.com" }) + .expect(201); + + expect(res.body).toMatchObject({ + id: mockOrg.id, + name: mockOrg.name, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }); + }); + + it("returns 400 when name is missing", async () => { + const token = issueToken("admin"); + + await request(app.getHttpServer()) + .post("/api/admin/organizations") + .set("Authorization", `Bearer ${token}`) + .send({}) + .expect(400); + }); + + it("returns 400 when name is an empty string", async () => { + const token = issueToken("admin"); + + await request(app.getHttpServer()) + .post("/api/admin/organizations") + .set("Authorization", `Bearer ${token}`) + .send({ name: " " }) + .expect(400); + }); + + it("returns 409 when name already exists", async () => { + const token = issueToken("admin"); + const p2002 = Object.assign(new Error("Unique constraint failed"), { + code: "P2002", + }); + mockPrisma.organization.create.mockRejectedValue(p2002); + + await request(app.getHttpServer()) + .post("/api/admin/organizations") + .set("Authorization", `Bearer ${token}`) + .send({ name: "acme.com" }) + .expect(409); + }); + + it("trims whitespace from name before saving", async () => { + const token = issueToken("admin"); + mockPrisma.organization.create.mockResolvedValue(mockOrg); + + await request(app.getHttpServer()) + .post("/api/admin/organizations") + .set("Authorization", `Bearer ${token}`) + .send({ name: " acme.com " }) + .expect(201); + + expect(mockPrisma.organization.create).toHaveBeenCalledWith({ + data: { name: "acme.com" }, + }); + }); + }); + + // --------------------------------------------------------------------------- + // Update (write) + // --------------------------------------------------------------------------- + + describe("PATCH /api/admin/organizations/:id", () => { + it("returns 200 with updated org on valid payload", async () => { + const token = issueToken("admin"); + const updated = { ...mockOrg, name: "updated.com" }; + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + mockPrisma.organization.update.mockResolvedValue(updated); + + const res = await request(app.getHttpServer()) + .patch(`/api/admin/organizations/${mockOrg.id}`) + .set("Authorization", `Bearer ${token}`) + .send({ name: "updated.com" }) + .expect(200); + + expect(res.body.name).toBe("updated.com"); + }); + + it("returns 404 when organization does not exist", async () => { + const token = issueToken("admin"); + mockPrisma.organization.findUnique.mockResolvedValue(null); + + await request(app.getHttpServer()) + .patch("/api/admin/organizations/missing-id") + .set("Authorization", `Bearer ${token}`) + .send({ name: "new.com" }) + .expect(404); + }); + + it("returns 409 when new name conflicts with an existing org", async () => { + const token = issueToken("admin"); + const p2002 = Object.assign(new Error("Unique constraint failed"), { + code: "P2002", + }); + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + mockPrisma.organization.update.mockRejectedValue(p2002); + + await request(app.getHttpServer()) + .patch(`/api/admin/organizations/${mockOrg.id}`) + .set("Authorization", `Bearer ${token}`) + .send({ name: "other.com" }) + .expect(409); + }); + }); +}); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 32ce317..d3f6370 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -2,8 +2,9 @@ import { Module } from "@nestjs/common"; import { MCPModule } from "./mcp/mcp.module"; import { AuthModule } from "./auth/auth.module"; import { PrismaModule } from "./prisma/prisma.module"; +import { AdminModule } from "./admin/admin.module"; @Module({ - imports: [PrismaModule, AuthModule, MCPModule], + imports: [PrismaModule, AuthModule, MCPModule, AdminModule], }) export class AppModule {} diff --git a/apps/web/app/admin/organizations/OrgForm.tsx b/apps/web/app/admin/organizations/OrgForm.tsx new file mode 100644 index 0000000..4d78246 --- /dev/null +++ b/apps/web/app/admin/organizations/OrgForm.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useActionState, useRef } from "react"; +import type { Organization } from "./types"; +import { + createOrganizationAction, + updateOrganizationAction, +} from "./actions"; + +interface CreateFormProps { + onDone?: () => void; +} + +interface EditFormProps { + org: Organization; + onDone?: () => void; +} + +type FormState = { error?: string }; + +const initialState: FormState = {}; + +export function CreateOrgForm({ onDone }: CreateFormProps) { + const formRef = useRef(null); + + const boundAction = async ( + _prev: FormState, + formData: FormData, + ): Promise => { + const result = await createOrganizationAction(formData); + if (!result.error) { + formRef.current?.reset(); + onDone?.(); + } + return result; + }; + + const [state, formAction, isPending] = useActionState( + boundAction, + initialState, + ); + + return ( +
+

Create organization

+ + {state.error && ( +

+ {state.error} +

+ )} + +
+ + +
+ + +
+ ); +} + +export function EditOrgForm({ org, onDone }: EditFormProps) { + const boundAction = async ( + _prev: FormState, + formData: FormData, + ): Promise => { + const result = await updateOrganizationAction(org.id, formData); + if (!result.error) { + onDone?.(); + } + return result; + }; + + const [state, formAction, isPending] = useActionState( + boundAction, + initialState, + ); + + return ( +
+

Edit organization

+ + {state.error && ( +

+ {state.error} +

+ )} + +
+ + +
+ +
+ + {onDone && ( + + )} +
+
+ ); +} + +const formStyle: React.CSSProperties = { + display: "flex", + flexDirection: "column", + gap: "12px", + padding: "20px", + border: "1px solid #e2e8f0", + borderRadius: "8px", + background: "#f8fafc", +}; + +const fieldStyle: React.CSSProperties = { + display: "flex", + flexDirection: "column", + gap: "4px", +}; + +const labelStyle: React.CSSProperties = { + fontSize: "14px", + fontWeight: 600, + color: "#374151", +}; + +const inputStyle: React.CSSProperties = { + padding: "8px 12px", + border: "1px solid #d1d5db", + borderRadius: "6px", + fontSize: "14px", + outline: "none", +}; + +const btnStyle: React.CSSProperties = { + padding: "8px 16px", + background: "#2563eb", + color: "#fff", + border: "none", + borderRadius: "6px", + fontSize: "14px", + fontWeight: 600, + cursor: "pointer", +}; + +const secondaryBtnStyle: React.CSSProperties = { + ...btnStyle, + background: "#6b7280", +}; + +const errorStyle: React.CSSProperties = { + color: "#dc2626", + fontSize: "13px", + margin: 0, + padding: "8px 12px", + background: "#fef2f2", + border: "1px solid #fecaca", + borderRadius: "6px", +}; diff --git a/apps/web/app/admin/organizations/OrgTable.tsx b/apps/web/app/admin/organizations/OrgTable.tsx new file mode 100644 index 0000000..035f700 --- /dev/null +++ b/apps/web/app/admin/organizations/OrgTable.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useState } from "react"; +import type { Organization } from "./types"; +import { EditOrgForm } from "./OrgForm"; + +interface OrgTableProps { + orgs: Organization[]; +} + +export function OrgTable({ orgs }: OrgTableProps) { + const [editingId, setEditingId] = useState(null); + + if (orgs.length === 0) { + return ( +

+ No organizations yet. Create one below. +

+ ); + } + + return ( +
+ + + + + + + + + + + {orgs.map((org) => ( + + {editingId === org.id ? ( + + ) : ( + <> + + + + + + )} + + ))} + +
NameIDCreatedActions
+ setEditingId(null)} + /> + + {org.name} + + {org.id} + + {new Date(org.createdAt).toLocaleDateString()} + + +
+
+ ); +} + +const tableStyle: React.CSSProperties = { + width: "100%", + borderCollapse: "collapse", + fontSize: "14px", +}; + +const thStyle: React.CSSProperties = { + textAlign: "left", + padding: "10px 12px", + background: "#f1f5f9", + borderBottom: "1px solid #e2e8f0", + fontWeight: 600, + color: "#374151", +}; + +const tdStyle: React.CSSProperties = { + padding: "12px", + borderBottom: "1px solid #e2e8f0", + verticalAlign: "top", +}; + +const editBtnStyle: React.CSSProperties = { + padding: "4px 10px", + background: "transparent", + color: "#2563eb", + border: "1px solid #2563eb", + borderRadius: "4px", + fontSize: "13px", + cursor: "pointer", +}; diff --git a/apps/web/app/admin/organizations/actions.ts b/apps/web/app/admin/organizations/actions.ts new file mode 100644 index 0000000..b344149 --- /dev/null +++ b/apps/web/app/admin/organizations/actions.ts @@ -0,0 +1,43 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { createOrganization, updateOrganization } from "./api"; + +export async function createOrganizationAction( + formData: FormData, +): Promise<{ error?: string }> { + const name = (formData.get("name") as string | null)?.trim() ?? ""; + + if (!name) { + return { error: "Organization name is required" }; + } + + const result = await createOrganization(name); + + if (result.error) { + return { error: result.error }; + } + + revalidatePath("/admin/organizations"); + return {}; +} + +export async function updateOrganizationAction( + id: string, + formData: FormData, +): Promise<{ error?: string }> { + const name = (formData.get("name") as string | null)?.trim() ?? ""; + + if (!name) { + return { error: "Organization name is required" }; + } + + const result = await updateOrganization(id, name); + + if (result.error) { + return { error: result.error }; + } + + revalidatePath("/admin/organizations"); + return {}; +} diff --git a/apps/web/app/admin/organizations/api.ts b/apps/web/app/admin/organizations/api.ts new file mode 100644 index 0000000..0e7c448 --- /dev/null +++ b/apps/web/app/admin/organizations/api.ts @@ -0,0 +1,80 @@ +import type { Organization } from "./types"; + +const API_URL = process.env["CORTEX_API_URL"] ?? "http://localhost:3001"; + +/** + * Reads the admin token from an environment variable (server-side only). + * When the auth flow is wired into the web app, replace this with a call + * to `cookies()` to read the session JWT instead. + */ +function getAdminToken(): string { + return process.env["CORTEX_ADMIN_TOKEN"] ?? ""; +} + +function authHeaders(): HeadersInit { + const token = getAdminToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +export async function fetchOrganizations(): Promise { + const res = await fetch(`${API_URL}/api/admin/organizations`, { + headers: authHeaders(), + cache: "no-store", + }); + + if (!res.ok) { + throw new Error(`Failed to fetch organizations: ${res.status}`); + } + + return res.json() as Promise; +} + +export async function fetchOrganization(id: string): Promise { + const res = await fetch(`${API_URL}/api/admin/organizations/${id}`, { + headers: authHeaders(), + cache: "no-store", + }); + + if (!res.ok) { + throw new Error(`Failed to fetch organization: ${res.status}`); + } + + return res.json() as Promise; +} + +export async function createOrganization( + name: string, +): Promise<{ org?: Organization; error?: string }> { + const res = await fetch(`${API_URL}/api/admin/organizations`, { + method: "POST", + headers: { ...authHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + + const data = (await res.json()) as Organization & { message?: string }; + + if (!res.ok) { + return { error: data.message ?? "Failed to create organization" }; + } + + return { org: data }; +} + +export async function updateOrganization( + id: string, + name: string, +): Promise<{ org?: Organization; error?: string }> { + const res = await fetch(`${API_URL}/api/admin/organizations/${id}`, { + method: "PATCH", + headers: { ...authHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + + const data = (await res.json()) as Organization & { message?: string }; + + if (!res.ok) { + return { error: data.message ?? "Failed to update organization" }; + } + + return { org: data }; +} diff --git a/apps/web/app/admin/organizations/page.tsx b/apps/web/app/admin/organizations/page.tsx new file mode 100644 index 0000000..c5d2134 --- /dev/null +++ b/apps/web/app/admin/organizations/page.tsx @@ -0,0 +1,91 @@ +import { Suspense } from "react"; +import { fetchOrganizations } from "./api"; +import { OrgTable } from "./OrgTable"; +import { CreateOrgForm } from "./OrgForm"; +import type { Organization } from "./types"; + +export const metadata = { + title: "Organizations — Cortex Admin", +}; + +async function OrgList() { + let orgs: Organization[] = []; + let fetchError: string | null = null; + + try { + orgs = await fetchOrganizations(); + } catch (err) { + fetchError = + err instanceof Error ? err.message : "Failed to load organizations"; + } + + if (fetchError) { + return ( +
+ {fetchError} +
+ ); + } + + return ; +} + +export default function OrganizationsPage() { + return ( +
+
+

Organizations

+

+ Tenant boundaries that scope all downstream resources. +

+
+ +
+

All organizations

+ Loading…

}> + +
+
+ +
+

Create new organization

+
+ +
+
+
+ ); +} + +const pageStyle: React.CSSProperties = { + maxWidth: "900px", + margin: "0 auto", + padding: "32px 24px", + fontFamily: "system-ui, -apple-system, sans-serif", +}; + +const headerStyle: React.CSSProperties = { + marginBottom: "32px", + paddingBottom: "20px", + borderBottom: "1px solid #e2e8f0", +}; + +const sectionStyle: React.CSSProperties = { + marginBottom: "40px", +}; + +const sectionTitleStyle: React.CSSProperties = { + fontSize: "16px", + fontWeight: 600, + color: "#111827", + marginBottom: "16px", +}; diff --git a/apps/web/app/admin/organizations/types.ts b/apps/web/app/admin/organizations/types.ts new file mode 100644 index 0000000..3d1e0a8 --- /dev/null +++ b/apps/web/app/admin/organizations/types.ts @@ -0,0 +1,11 @@ +export interface Organization { + id: string; + name: string; + createdAt: string; + updatedAt: string; +} + +export interface ApiError { + message: string; + statusCode: number; +} From d3beaea1de92512a48cf46d8d22f119a5e69ccb6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 30 Jun 2026 20:47:54 +0000 Subject: [PATCH 2/2] fix(admin): address CodeRabbit and CI review findings - turbo.json: register CORTEX_API_URL and CORTEX_ADMIN_TOKEN as global env vars (fixes turbo/no-undeclared-env-vars lint warning) - api.ts: encodeURIComponent(id) in fetchOrganization and updateOrganization (fixes CodeQL SSRF / path-traversal alert) - api.ts: parse error bodies with safe JSON fallback to avoid SyntaxError on non-JSON error responses from the backend - api.ts: add AbortSignal.timeout(5000) to all four fetch calls to prevent indefinite blocking on a slow backend - actions.ts: use typeof guard before .trim() to handle FormData.get() returning File instead of string - actions.ts: wrap createOrganization/updateOrganization in try/catch so network errors surface as friendly { error } states instead of digest crashes - actions.ts: add id type guard in updateOrganizationAction - organization.service.ts: catch P2025 in update() catch block and convert to NotFoundException to handle concurrent-delete race between findOne and update - OrgForm.tsx, OrgTable.tsx, page.tsx: import CSSProperties from 'react' instead of referencing React.CSSProperties (React namespace not in scope) - organization.service.spec.ts: add P2025 race test for update() Co-authored-by: Andrea Mazzucchelli --- .../organization.service.spec.ts | 12 ++++ .../organizations/organization.service.ts | 12 ++++ apps/web/app/admin/organizations/OrgForm.tsx | 16 +++--- apps/web/app/admin/organizations/OrgTable.tsx | 10 ++-- apps/web/app/admin/organizations/actions.ts | 40 ++++++++++---- apps/web/app/admin/organizations/api.ts | 55 ++++++++++++------- apps/web/app/admin/organizations/page.tsx | 10 ++-- turbo.json | 4 +- 8 files changed, 111 insertions(+), 48 deletions(-) diff --git a/apps/api/src/admin/organizations/organization.service.spec.ts b/apps/api/src/admin/organizations/organization.service.spec.ts index 9090609..86db3e7 100644 --- a/apps/api/src/admin/organizations/organization.service.spec.ts +++ b/apps/api/src/admin/organizations/organization.service.spec.ts @@ -168,6 +168,18 @@ describe("OrganizationService", () => { ).rejects.toThrow(ConflictException); }); + it("throws NotFoundException on P2025 concurrent-delete race during update", async () => { + const p2025 = Object.assign(new Error("Record not found"), { + code: "P2025", + }); + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + mockPrisma.organization.update.mockRejectedValue(p2025); + + await expect(service.update("org-1", { name: "new.com" })).rejects.toThrow( + NotFoundException, + ); + }); + it("applies no data update when dto is empty", async () => { mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); mockPrisma.organization.update.mockResolvedValue(mockOrg); diff --git a/apps/api/src/admin/organizations/organization.service.ts b/apps/api/src/admin/organizations/organization.service.ts index 77c8460..5f79bdf 100644 --- a/apps/api/src/admin/organizations/organization.service.ts +++ b/apps/api/src/admin/organizations/organization.service.ts @@ -3,6 +3,15 @@ import { ConflictException, NotFoundException, } from "@nestjs/common"; + +function isPrismaNotFoundError(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code: string }).code === "P2025" + ); +} import { PrismaService } from "../../prisma/prisma.service"; import type { Organization } from "db/client"; import type { @@ -66,6 +75,9 @@ export class OrganizationService { `Organization with name "${dto.name}" already exists`, ); } + if (isPrismaNotFoundError(err)) { + throw new NotFoundException(`Organization with id "${id}" not found`); + } throw err; } } diff --git a/apps/web/app/admin/organizations/OrgForm.tsx b/apps/web/app/admin/organizations/OrgForm.tsx index 4d78246..f873398 100644 --- a/apps/web/app/admin/organizations/OrgForm.tsx +++ b/apps/web/app/admin/organizations/OrgForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useActionState, useRef } from "react"; +import { useActionState, useRef, type CSSProperties } from "react"; import type { Organization } from "./types"; import { createOrganizationAction, @@ -128,7 +128,7 @@ export function EditOrgForm({ org, onDone }: EditFormProps) { ); } -const formStyle: React.CSSProperties = { +const formStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: "12px", @@ -138,19 +138,19 @@ const formStyle: React.CSSProperties = { background: "#f8fafc", }; -const fieldStyle: React.CSSProperties = { +const fieldStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: "4px", }; -const labelStyle: React.CSSProperties = { +const labelStyle: CSSProperties = { fontSize: "14px", fontWeight: 600, color: "#374151", }; -const inputStyle: React.CSSProperties = { +const inputStyle: CSSProperties = { padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: "6px", @@ -158,7 +158,7 @@ const inputStyle: React.CSSProperties = { outline: "none", }; -const btnStyle: React.CSSProperties = { +const btnStyle: CSSProperties = { padding: "8px 16px", background: "#2563eb", color: "#fff", @@ -169,12 +169,12 @@ const btnStyle: React.CSSProperties = { cursor: "pointer", }; -const secondaryBtnStyle: React.CSSProperties = { +const secondaryBtnStyle: CSSProperties = { ...btnStyle, background: "#6b7280", }; -const errorStyle: React.CSSProperties = { +const errorStyle: CSSProperties = { color: "#dc2626", fontSize: "13px", margin: 0, diff --git a/apps/web/app/admin/organizations/OrgTable.tsx b/apps/web/app/admin/organizations/OrgTable.tsx index 035f700..02e5901 100644 --- a/apps/web/app/admin/organizations/OrgTable.tsx +++ b/apps/web/app/admin/organizations/OrgTable.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, type CSSProperties } from "react"; import type { Organization } from "./types"; import { EditOrgForm } from "./OrgForm"; @@ -69,13 +69,13 @@ export function OrgTable({ orgs }: OrgTableProps) { ); } -const tableStyle: React.CSSProperties = { +const tableStyle: CSSProperties = { width: "100%", borderCollapse: "collapse", fontSize: "14px", }; -const thStyle: React.CSSProperties = { +const thStyle: CSSProperties = { textAlign: "left", padding: "10px 12px", background: "#f1f5f9", @@ -84,13 +84,13 @@ const thStyle: React.CSSProperties = { color: "#374151", }; -const tdStyle: React.CSSProperties = { +const tdStyle: CSSProperties = { padding: "12px", borderBottom: "1px solid #e2e8f0", verticalAlign: "top", }; -const editBtnStyle: React.CSSProperties = { +const editBtnStyle: CSSProperties = { padding: "4px 10px", background: "transparent", color: "#2563eb", diff --git a/apps/web/app/admin/organizations/actions.ts b/apps/web/app/admin/organizations/actions.ts index b344149..d5be58c 100644 --- a/apps/web/app/admin/organizations/actions.ts +++ b/apps/web/app/admin/organizations/actions.ts @@ -3,19 +3,32 @@ import { revalidatePath } from "next/cache"; import { createOrganization, updateOrganization } from "./api"; +/** + * Safely extract a string field from FormData. + * FormData.get() can return a File object, so we guard with typeof before + * calling .trim() to avoid a runtime TypeError. + */ +function getStringField(formData: FormData, key: string): string { + const raw = formData.get(key); + return typeof raw === "string" ? raw.trim() : ""; +} + export async function createOrganizationAction( formData: FormData, ): Promise<{ error?: string }> { - const name = (formData.get("name") as string | null)?.trim() ?? ""; + const name = getStringField(formData, "name"); if (!name) { return { error: "Organization name is required" }; } - const result = await createOrganization(name); - - if (result.error) { - return { error: result.error }; + try { + const result = await createOrganization(name); + if (result.error) { + return { error: result.error }; + } + } catch { + return { error: "Failed to create organization. Please try again." }; } revalidatePath("/admin/organizations"); @@ -26,16 +39,23 @@ export async function updateOrganizationAction( id: string, formData: FormData, ): Promise<{ error?: string }> { - const name = (formData.get("name") as string | null)?.trim() ?? ""; + if (!id || typeof id !== "string") { + return { error: "Invalid organization id" }; + } + + const name = getStringField(formData, "name"); if (!name) { return { error: "Organization name is required" }; } - const result = await updateOrganization(id, name); - - if (result.error) { - return { error: result.error }; + try { + const result = await updateOrganization(id, name); + if (result.error) { + return { error: result.error }; + } + } catch { + return { error: "Failed to update organization. Please try again." }; } revalidatePath("/admin/organizations"); diff --git a/apps/web/app/admin/organizations/api.ts b/apps/web/app/admin/organizations/api.ts index 0e7c448..80a37d6 100644 --- a/apps/web/app/admin/organizations/api.ts +++ b/apps/web/app/admin/organizations/api.ts @@ -4,8 +4,8 @@ const API_URL = process.env["CORTEX_API_URL"] ?? "http://localhost:3001"; /** * Reads the admin token from an environment variable (server-side only). - * When the auth flow is wired into the web app, replace this with a call - * to `cookies()` to read the session JWT instead. + * When user auth is wired into the web app, replace this with a call to + * `cookies()` to read the session JWT and verify the role before forwarding. */ function getAdminToken(): string { return process.env["CORTEX_ADMIN_TOKEN"] ?? ""; @@ -16,10 +16,20 @@ function authHeaders(): HeadersInit { return token ? { Authorization: `Bearer ${token}` } : {}; } +/** Safe JSON parse that returns null instead of throwing on non-JSON bodies. */ +async function parseJsonSafe(res: Response): Promise { + try { + return (await res.json()) as T; + } catch { + return null; + } +} + export async function fetchOrganizations(): Promise { const res = await fetch(`${API_URL}/api/admin/organizations`, { headers: authHeaders(), cache: "no-store", + signal: AbortSignal.timeout(5000), }); if (!res.ok) { @@ -30,10 +40,14 @@ export async function fetchOrganizations(): Promise { } export async function fetchOrganization(id: string): Promise { - const res = await fetch(`${API_URL}/api/admin/organizations/${id}`, { - headers: authHeaders(), - cache: "no-store", - }); + const res = await fetch( + `${API_URL}/api/admin/organizations/${encodeURIComponent(id)}`, + { + headers: authHeaders(), + cache: "no-store", + signal: AbortSignal.timeout(5000), + }, + ); if (!res.ok) { throw new Error(`Failed to fetch organization: ${res.status}`); @@ -49,32 +63,35 @@ export async function createOrganization( method: "POST", headers: { ...authHeaders(), "Content-Type": "application/json" }, body: JSON.stringify({ name }), + signal: AbortSignal.timeout(5000), }); - const data = (await res.json()) as Organization & { message?: string }; - if (!res.ok) { - return { error: data.message ?? "Failed to create organization" }; + const errBody = await parseJsonSafe<{ message?: string }>(res); + return { error: errBody?.message ?? "Failed to create organization" }; } - return { org: data }; + return { org: (await res.json()) as Organization }; } export async function updateOrganization( id: string, name: string, ): Promise<{ org?: Organization; error?: string }> { - const res = await fetch(`${API_URL}/api/admin/organizations/${id}`, { - method: "PATCH", - headers: { ...authHeaders(), "Content-Type": "application/json" }, - body: JSON.stringify({ name }), - }); - - const data = (await res.json()) as Organization & { message?: string }; + const res = await fetch( + `${API_URL}/api/admin/organizations/${encodeURIComponent(id)}`, + { + method: "PATCH", + headers: { ...authHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + signal: AbortSignal.timeout(5000), + }, + ); if (!res.ok) { - return { error: data.message ?? "Failed to update organization" }; + const errBody = await parseJsonSafe<{ message?: string }>(res); + return { error: errBody?.message ?? "Failed to update organization" }; } - return { org: data }; + return { org: (await res.json()) as Organization }; } diff --git a/apps/web/app/admin/organizations/page.tsx b/apps/web/app/admin/organizations/page.tsx index c5d2134..3bb31cd 100644 --- a/apps/web/app/admin/organizations/page.tsx +++ b/apps/web/app/admin/organizations/page.tsx @@ -1,4 +1,4 @@ -import { Suspense } from "react"; +import { Suspense, type CSSProperties } from "react"; import { fetchOrganizations } from "./api"; import { OrgTable } from "./OrgTable"; import { CreateOrgForm } from "./OrgForm"; @@ -66,24 +66,24 @@ export default function OrganizationsPage() { ); } -const pageStyle: React.CSSProperties = { +const pageStyle: CSSProperties = { maxWidth: "900px", margin: "0 auto", padding: "32px 24px", fontFamily: "system-ui, -apple-system, sans-serif", }; -const headerStyle: React.CSSProperties = { +const headerStyle: CSSProperties = { marginBottom: "32px", paddingBottom: "20px", borderBottom: "1px solid #e2e8f0", }; -const sectionStyle: React.CSSProperties = { +const sectionStyle: CSSProperties = { marginBottom: "40px", }; -const sectionTitleStyle: React.CSSProperties = { +const sectionTitleStyle: CSSProperties = { fontSize: "16px", fontWeight: 600, color: "#111827", diff --git a/turbo.json b/turbo.json index ec876c5..46e45cd 100644 --- a/turbo.json +++ b/turbo.json @@ -5,7 +5,9 @@ "JWT_SECRET", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", - "GOOGLE_CALLBACK_URL" + "GOOGLE_CALLBACK_URL", + "CORTEX_API_URL", + "CORTEX_ADMIN_TOKEN" ], "tasks": { "build": {