From 3c7f2718074587a0eb0ccea46c5bba980c796409 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 10:36:40 +0000 Subject: [PATCH 1/6] feat(api,web): department CRUD, user assignment, and session enrichment - Add admin Departments API (create + list, scoped to an organization) - Add admin Users listing API (scoped to an organization) to support the assignment UI - Add admin UserDepartments API to assign a user to one or more departments via a set-replace PUT, with primary-department resolution and organization-scoped validation - Enrich GET /api/me with departmentIds/primaryDepartmentId resolved live from the UserDepartment table, so admin-driven assignment changes are reflected in the authenticated user's session without requiring a token refresh - Add Admin Web pages for departments (create/list) and user department assignment, with an organization picker shared across both - Add unit and integration test coverage for department CRUD, user listing, assignment (including primary-department edge cases), and session enrichment Co-authored-by: Andrea Mazzucchelli --- apps/api/src/__mocks__/db-client.mock.ts | 16 ++ apps/api/src/admin/admin.module.ts | 20 +- .../src/admin/departments/department.dto.ts | 11 + .../departments/department.service.spec.ts | 123 +++++++++ .../admin/departments/department.service.ts | 68 +++++ .../departments/departments.controller.ts | 59 +++++ .../departments.integration.spec.ts | 209 +++++++++++++++ .../user-departments/user-department.dto.ts | 17 ++ .../user-department.service.spec.ts | 245 ++++++++++++++++++ .../user-department.service.ts | 130 ++++++++++ .../user-departments.controller.ts | 67 +++++ .../user-departments.integration.spec.ts | 206 +++++++++++++++ .../admin/users/admin-user.service.spec.ts | 62 +++++ .../api/src/admin/users/admin-user.service.ts | 28 ++ .../src/admin/users/admin-users.controller.ts | 38 +++ apps/api/src/admin/users/user.dto.ts | 7 + apps/api/src/auth/auth.controller.ts | 18 +- apps/api/src/auth/auth.module.ts | 2 + apps/api/src/auth/auth.types.ts | 12 + apps/api/src/auth/session.integration.spec.ts | 144 ++++++++++ apps/api/src/auth/user.service.spec.ts | 42 +++ apps/api/src/auth/user.service.ts | 28 ++ apps/web/app/admin/_components/OrgPicker.tsx | 73 ++++++ apps/web/app/admin/departments/DeptForm.tsx | 116 +++++++++ apps/web/app/admin/departments/DeptTable.tsx | 71 +++++ apps/web/app/admin/departments/actions.ts | 40 +++ apps/web/app/admin/departments/api.ts | 67 +++++ apps/web/app/admin/departments/page.tsx | 129 +++++++++ apps/web/app/admin/departments/types.ts | 7 + apps/web/app/admin/organizations/OrgTable.tsx | 44 +++- .../app/admin/users/AssignDepartmentsForm.tsx | 174 +++++++++++++ apps/web/app/admin/users/UserTable.tsx | 87 +++++++ apps/web/app/admin/users/actions.ts | 44 ++++ apps/web/app/admin/users/api.ts | 85 ++++++ apps/web/app/admin/users/page.tsx | 184 +++++++++++++ apps/web/app/admin/users/types.ts | 18 ++ 36 files changed, 2678 insertions(+), 13 deletions(-) create mode 100644 apps/api/src/admin/departments/department.dto.ts create mode 100644 apps/api/src/admin/departments/department.service.spec.ts create mode 100644 apps/api/src/admin/departments/department.service.ts create mode 100644 apps/api/src/admin/departments/departments.controller.ts create mode 100644 apps/api/src/admin/departments/departments.integration.spec.ts create mode 100644 apps/api/src/admin/user-departments/user-department.dto.ts create mode 100644 apps/api/src/admin/user-departments/user-department.service.spec.ts create mode 100644 apps/api/src/admin/user-departments/user-department.service.ts create mode 100644 apps/api/src/admin/user-departments/user-departments.controller.ts create mode 100644 apps/api/src/admin/user-departments/user-departments.integration.spec.ts create mode 100644 apps/api/src/admin/users/admin-user.service.spec.ts create mode 100644 apps/api/src/admin/users/admin-user.service.ts create mode 100644 apps/api/src/admin/users/admin-users.controller.ts create mode 100644 apps/api/src/admin/users/user.dto.ts create mode 100644 apps/api/src/auth/session.integration.spec.ts create mode 100644 apps/web/app/admin/_components/OrgPicker.tsx create mode 100644 apps/web/app/admin/departments/DeptForm.tsx create mode 100644 apps/web/app/admin/departments/DeptTable.tsx create mode 100644 apps/web/app/admin/departments/actions.ts create mode 100644 apps/web/app/admin/departments/api.ts create mode 100644 apps/web/app/admin/departments/page.tsx create mode 100644 apps/web/app/admin/departments/types.ts create mode 100644 apps/web/app/admin/users/AssignDepartmentsForm.tsx create mode 100644 apps/web/app/admin/users/UserTable.tsx create mode 100644 apps/web/app/admin/users/actions.ts create mode 100644 apps/web/app/admin/users/api.ts create mode 100644 apps/web/app/admin/users/page.tsx create mode 100644 apps/web/app/admin/users/types.ts diff --git a/apps/api/src/__mocks__/db-client.mock.ts b/apps/api/src/__mocks__/db-client.mock.ts index 3f0096e..97af2c8 100644 --- a/apps/api/src/__mocks__/db-client.mock.ts +++ b/apps/api/src/__mocks__/db-client.mock.ts @@ -8,25 +8,41 @@ export const mockPrismaClient = { $connect: jest.fn().mockResolvedValue(undefined), $disconnect: jest.fn().mockResolvedValue(undefined), + $transaction: jest.fn(), user: { findUnique: jest.fn(), + findMany: jest.fn(), create: jest.fn(), upsert: jest.fn(), }, organization: { findUnique: jest.fn(), findFirst: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + department: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), }, userDepartment: { findFirst: jest.fn(), + findMany: jest.fn(), + updateMany: jest.fn(), + deleteMany: jest.fn(), + upsert: jest.fn(), }, }; export class PrismaClient { $connect = mockPrismaClient.$connect; $disconnect = mockPrismaClient.$disconnect; + $transaction = mockPrismaClient.$transaction; user = mockPrismaClient.user; organization = mockPrismaClient.organization; + department = mockPrismaClient.department; userDepartment = mockPrismaClient.userDepartment; } diff --git a/apps/api/src/admin/admin.module.ts b/apps/api/src/admin/admin.module.ts index 5c2e469..e094f93 100644 --- a/apps/api/src/admin/admin.module.ts +++ b/apps/api/src/admin/admin.module.ts @@ -2,10 +2,26 @@ import { Module } from "@nestjs/common"; import { PrismaModule } from "../prisma/prisma.module"; import { OrganizationsController } from "./organizations/organizations.controller"; import { OrganizationService } from "./organizations/organization.service"; +import { DepartmentsController } from "./departments/departments.controller"; +import { DepartmentService } from "./departments/department.service"; +import { AdminUsersController } from "./users/admin-users.controller"; +import { AdminUserService } from "./users/admin-user.service"; +import { UserDepartmentsController } from "./user-departments/user-departments.controller"; +import { UserDepartmentService } from "./user-departments/user-department.service"; @Module({ imports: [PrismaModule], - controllers: [OrganizationsController], - providers: [OrganizationService], + controllers: [ + OrganizationsController, + DepartmentsController, + AdminUsersController, + UserDepartmentsController, + ], + providers: [ + OrganizationService, + DepartmentService, + AdminUserService, + UserDepartmentService, + ], }) export class AdminModule {} diff --git a/apps/api/src/admin/departments/department.dto.ts b/apps/api/src/admin/departments/department.dto.ts new file mode 100644 index 0000000..8cf4e31 --- /dev/null +++ b/apps/api/src/admin/departments/department.dto.ts @@ -0,0 +1,11 @@ +export interface CreateDepartmentDto { + name: string; +} + +export interface DepartmentResponseDto { + id: string; + name: string; + organizationId: string; + createdAt: string; + updatedAt: string; +} diff --git a/apps/api/src/admin/departments/department.service.spec.ts b/apps/api/src/admin/departments/department.service.spec.ts new file mode 100644 index 0000000..f26287c --- /dev/null +++ b/apps/api/src/admin/departments/department.service.spec.ts @@ -0,0 +1,123 @@ +import { Test, type TestingModule } from "@nestjs/testing"; +import { ConflictException, NotFoundException } from "@nestjs/common"; +import { DepartmentService } from "./department.service"; +import { PrismaService } from "../../prisma/prisma.service"; + +const now = new Date("2024-01-01T00:00:00Z"); + +const mockOrg = { id: "org-1", name: "acme.com" }; + +const mockDept = { + id: "dept-1", + name: "Engineering", + organizationId: "org-1", + createdAt: now, + updatedAt: now, +}; + +const mockPrisma = { + organization: { + findUnique: jest.fn(), + }, + department: { + findMany: jest.fn(), + create: jest.fn(), + }, +}; + +describe("DepartmentService", () => { + let service: DepartmentService; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DepartmentService, + { provide: PrismaService, useValue: mockPrisma }, + ], + }).compile(); + + service = module.get(DepartmentService); + }); + + describe("findAllByOrganization", () => { + it("returns departments scoped to the organization, ordered by createdAt desc", async () => { + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + mockPrisma.department.findMany.mockResolvedValue([mockDept]); + + const result = await service.findAllByOrganization("org-1"); + + expect(result).toEqual([mockDept]); + expect(mockPrisma.department.findMany).toHaveBeenCalledWith({ + where: { organizationId: "org-1" }, + orderBy: { createdAt: "desc" }, + }); + }); + + it("returns an empty array when the organization has no departments", async () => { + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + mockPrisma.department.findMany.mockResolvedValue([]); + + const result = await service.findAllByOrganization("org-1"); + + expect(result).toEqual([]); + }); + + it("throws NotFoundException when the organization does not exist", async () => { + mockPrisma.organization.findUnique.mockResolvedValue(null); + + await expect( + service.findAllByOrganization("missing-org"), + ).rejects.toThrow(NotFoundException); + expect(mockPrisma.department.findMany).not.toHaveBeenCalled(); + }); + }); + + describe("create", () => { + it("creates and returns the department scoped to the organization", async () => { + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + mockPrisma.department.create.mockResolvedValue(mockDept); + + const result = await service.create("org-1", { name: "Engineering" }); + + expect(result).toEqual(mockDept); + expect(mockPrisma.department.create).toHaveBeenCalledWith({ + data: { name: "Engineering", organizationId: "org-1" }, + }); + }); + + it("throws NotFoundException when the organization does not exist", async () => { + mockPrisma.organization.findUnique.mockResolvedValue(null); + + await expect( + service.create("missing-org", { name: "Engineering" }), + ).rejects.toThrow(NotFoundException); + expect(mockPrisma.department.create).not.toHaveBeenCalled(); + }); + + it("throws ConflictException on P2002 unique constraint violation", async () => { + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + const p2002 = Object.assign(new Error("Unique constraint failed"), { + code: "P2002", + }); + mockPrisma.department.create.mockRejectedValue(p2002); + + await expect( + service.create("org-1", { name: "Engineering" }), + ).rejects.toThrow(ConflictException); + }); + + it("re-throws non-P2002 database errors", async () => { + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + const dbError = Object.assign(new Error("Connection refused"), { + code: "P1001", + }); + mockPrisma.department.create.mockRejectedValue(dbError); + + await expect( + service.create("org-1", { name: "Engineering" }), + ).rejects.toThrow("Connection refused"); + }); + }); +}); diff --git a/apps/api/src/admin/departments/department.service.ts b/apps/api/src/admin/departments/department.service.ts new file mode 100644 index 0000000..1fc02a0 --- /dev/null +++ b/apps/api/src/admin/departments/department.service.ts @@ -0,0 +1,68 @@ +import { + Injectable, + ConflictException, + NotFoundException, +} from "@nestjs/common"; +import { PrismaService } from "../../prisma/prisma.service"; +import type { Department } from "db/client"; +import type { CreateDepartmentDto } from "./department.dto"; + +function isPrismaUniqueConstraintError(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code: string }).code === "P2002" + ); +} + +@Injectable() +export class DepartmentService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Lists departments scoped to a single Organization. + * Throws NotFoundException up front so callers get a clear 404 instead + * of a silently empty list when the organizationId is bogus. + */ + async findAllByOrganization(organizationId: string): Promise { + await this.ensureOrganizationExists(organizationId); + return this.prisma.department.findMany({ + where: { organizationId }, + orderBy: { createdAt: "desc" }, + }); + } + + async create( + organizationId: string, + dto: CreateDepartmentDto, + ): Promise { + await this.ensureOrganizationExists(organizationId); + + try { + return await this.prisma.department.create({ + data: { name: dto.name, organizationId }, + }); + } catch (err) { + if (isPrismaUniqueConstraintError(err)) { + throw new ConflictException( + `Department with name "${dto.name}" already exists in this organization`, + ); + } + throw err; + } + } + + private async ensureOrganizationExists( + organizationId: string, + ): Promise { + const org = await this.prisma.organization.findUnique({ + where: { id: organizationId }, + }); + if (!org) { + throw new NotFoundException( + `Organization with id "${organizationId}" not found`, + ); + } + } +} diff --git a/apps/api/src/admin/departments/departments.controller.ts b/apps/api/src/admin/departments/departments.controller.ts new file mode 100644 index 0000000..263a98f --- /dev/null +++ b/apps/api/src/admin/departments/departments.controller.ts @@ -0,0 +1,59 @@ +import { + Controller, + Get, + Post, + Param, + Body, + UseGuards, + HttpCode, + HttpStatus, + BadRequestException, +} from "@nestjs/common"; +import { AdminRoleGuard } from "../guards/admin-role.guard"; +import { DepartmentService } from "./department.service"; +import type { + CreateDepartmentDto, + DepartmentResponseDto, +} from "./department.dto"; +import type { Department } from "db/client"; + +function toResponseDto(dept: Department): DepartmentResponseDto { + return { + id: dept.id, + name: dept.name, + organizationId: dept.organizationId, + createdAt: dept.createdAt.toISOString(), + updatedAt: dept.updatedAt.toISOString(), + }; +} + +@Controller("api/admin/organizations/:organizationId/departments") +@UseGuards(AdminRoleGuard) +export class DepartmentsController { + constructor(private readonly departmentService: DepartmentService) {} + + @Get() + @HttpCode(HttpStatus.OK) + async findAll( + @Param("organizationId") organizationId: string, + ): Promise { + const departments = + await this.departmentService.findAllByOrganization(organizationId); + return departments.map(toResponseDto); + } + + @Post() + @HttpCode(HttpStatus.CREATED) + async create( + @Param("organizationId") organizationId: string, + @Body() body: CreateDepartmentDto, + ): Promise { + if (!body.name || typeof body.name !== "string" || !body.name.trim()) { + throw new BadRequestException("name is required and must be a string"); + } + const dept = await this.departmentService.create(organizationId, { + name: body.name.trim(), + }); + return toResponseDto(dept); + } +} diff --git a/apps/api/src/admin/departments/departments.integration.spec.ts b/apps/api/src/admin/departments/departments.integration.spec.ts new file mode 100644 index 0000000..4c4d246 --- /dev/null +++ b/apps/api/src/admin/departments/departments.integration.spec.ts @@ -0,0 +1,209 @@ +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-departments-integration"; + +class MockGoogleStrategy { + name = "google"; +} + +const now = new Date("2024-06-01T12:00:00Z"); + +const mockOrg = { + id: "org-1", + name: "acme.com", + createdAt: now, + updatedAt: now, +}; + +const mockDept = { + id: "dept-1", + name: "Engineering", + organizationId: "org-1", + createdAt: now, + updatedAt: now, +}; + +const mockPrisma = { + $connect: jest.fn().mockResolvedValue(undefined), + $disconnect: jest.fn().mockResolvedValue(undefined), + user: { findUnique: jest.fn() }, + organization: { findUnique: jest.fn() }, + department: { + findMany: jest.fn(), + create: 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 Departments 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(); + }); + + describe("authorization", () => { + it("returns 401 when no token is provided", async () => { + await request(app.getHttpServer()) + .get("/api/admin/organizations/org-1/departments") + .expect(401); + }); + + it("returns 403 when authenticated user has member role", async () => { + const token = issueToken("member"); + + await request(app.getHttpServer()) + .get("/api/admin/organizations/org-1/departments") + .set("Authorization", `Bearer ${token}`) + .expect(403); + }); + }); + + describe("GET /api/admin/organizations/:organizationId/departments", () => { + it("returns 200 with department list scoped to the organization", async () => { + const token = issueToken("admin"); + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + mockPrisma.department.findMany.mockResolvedValue([mockDept]); + + const res = await request(app.getHttpServer()) + .get("/api/admin/organizations/org-1/departments") + .set("Authorization", `Bearer ${token}`) + .expect(200); + + expect(res.body).toHaveLength(1); + expect(res.body[0]).toMatchObject({ + id: mockDept.id, + name: mockDept.name, + organizationId: mockDept.organizationId, + }); + expect(mockPrisma.department.findMany).toHaveBeenCalledWith({ + where: { organizationId: "org-1" }, + orderBy: { createdAt: "desc" }, + }); + }); + + it("returns 404 when the organization does not exist", async () => { + const token = issueToken("admin"); + mockPrisma.organization.findUnique.mockResolvedValue(null); + + await request(app.getHttpServer()) + .get("/api/admin/organizations/missing-org/departments") + .set("Authorization", `Bearer ${token}`) + .expect(404); + }); + }); + + describe("POST /api/admin/organizations/:organizationId/departments", () => { + it("returns 201 with the new department on valid payload", async () => { + const token = issueToken("admin"); + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + mockPrisma.department.create.mockResolvedValue(mockDept); + + const res = await request(app.getHttpServer()) + .post("/api/admin/organizations/org-1/departments") + .set("Authorization", `Bearer ${token}`) + .send({ name: "Engineering" }) + .expect(201); + + expect(res.body).toMatchObject({ + id: mockDept.id, + name: "Engineering", + organizationId: "org-1", + }); + }); + + it("returns 400 when name is missing", async () => { + const token = issueToken("admin"); + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + + await request(app.getHttpServer()) + .post("/api/admin/organizations/org-1/departments") + .set("Authorization", `Bearer ${token}`) + .send({}) + .expect(400); + }); + + it("returns 404 when the organization does not exist", async () => { + const token = issueToken("admin"); + mockPrisma.organization.findUnique.mockResolvedValue(null); + + await request(app.getHttpServer()) + .post("/api/admin/organizations/missing-org/departments") + .set("Authorization", `Bearer ${token}`) + .send({ name: "Engineering" }) + .expect(404); + }); + + it("returns 409 when the department name already exists in the organization", async () => { + const token = issueToken("admin"); + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + const p2002 = Object.assign(new Error("Unique constraint failed"), { + code: "P2002", + }); + mockPrisma.department.create.mockRejectedValue(p2002); + + await request(app.getHttpServer()) + .post("/api/admin/organizations/org-1/departments") + .set("Authorization", `Bearer ${token}`) + .send({ name: "Engineering" }) + .expect(409); + }); + + it("trims whitespace from name before saving", async () => { + const token = issueToken("admin"); + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + mockPrisma.department.create.mockResolvedValue(mockDept); + + await request(app.getHttpServer()) + .post("/api/admin/organizations/org-1/departments") + .set("Authorization", `Bearer ${token}`) + .send({ name: " Engineering " }) + .expect(201); + + expect(mockPrisma.department.create).toHaveBeenCalledWith({ + data: { name: "Engineering", organizationId: "org-1" }, + }); + }); + }); +}); diff --git a/apps/api/src/admin/user-departments/user-department.dto.ts b/apps/api/src/admin/user-departments/user-department.dto.ts new file mode 100644 index 0000000..a3eeedb --- /dev/null +++ b/apps/api/src/admin/user-departments/user-department.dto.ts @@ -0,0 +1,17 @@ +export interface AssignUserDepartmentsDto { + departmentIds: string[]; + /** Must be one of departmentIds. Defaults to the existing primary (if still + * assigned) or the first entry in departmentIds. */ + primaryDepartmentId?: string; +} + +export interface UserDepartmentAssignmentDto { + departmentId: string; + name: string; + isPrimary: boolean; +} + +export interface UserDepartmentsResponseDto { + userId: string; + departments: UserDepartmentAssignmentDto[]; +} diff --git a/apps/api/src/admin/user-departments/user-department.service.spec.ts b/apps/api/src/admin/user-departments/user-department.service.spec.ts new file mode 100644 index 0000000..9b4e436 --- /dev/null +++ b/apps/api/src/admin/user-departments/user-department.service.spec.ts @@ -0,0 +1,245 @@ +import { Test, type TestingModule } from "@nestjs/testing"; +import { BadRequestException, NotFoundException } from "@nestjs/common"; +import { UserDepartmentService } from "./user-department.service"; +import { PrismaService } from "../../prisma/prisma.service"; + +const mockUser = { + id: "user-1", + email: "alice@acme.com", + googleSub: "google-sub-1", + role: "member", + organizationId: "org-1", + createdAt: new Date("2024-01-01T00:00:00Z"), + updatedAt: new Date("2024-01-01T00:00:00Z"), +}; + +const mockAssignmentRows = [ + { + departmentId: "dept-1", + isPrimary: true, + department: { name: "Engineering" }, + }, +]; + +const mockPrisma = { + user: { findUnique: jest.fn() }, + department: { findMany: jest.fn() }, + userDepartment: { + findMany: jest.fn(), + findFirst: jest.fn(), + updateMany: jest.fn(), + deleteMany: jest.fn(), + upsert: jest.fn(), + }, + $transaction: jest.fn(), +}; + +describe("UserDepartmentService", () => { + let service: UserDepartmentService; + + beforeEach(async () => { + jest.clearAllMocks(); + mockPrisma.$transaction.mockImplementation(async (ops: unknown[]) => + Promise.all(ops), + ); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UserDepartmentService, + { provide: PrismaService, useValue: mockPrisma }, + ], + }).compile(); + + service = module.get(UserDepartmentService); + }); + + describe("findForUser", () => { + it("returns department assignments joined with department name", async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + mockPrisma.userDepartment.findMany.mockResolvedValue(mockAssignmentRows); + + const result = await service.findForUser("user-1"); + + expect(result).toEqual(mockAssignmentRows); + expect(mockPrisma.userDepartment.findMany).toHaveBeenCalledWith({ + where: { userId: "user-1" }, + include: { department: { select: { name: true } } }, + orderBy: { createdAt: "asc" }, + }); + }); + + it("throws NotFoundException when the user does not exist", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect(service.findForUser("missing-user")).rejects.toThrow( + NotFoundException, + ); + expect(mockPrisma.userDepartment.findMany).not.toHaveBeenCalled(); + }); + }); + + describe("assign", () => { + beforeEach(() => { + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + mockPrisma.department.findMany.mockResolvedValue([ + { id: "dept-1" }, + { id: "dept-2" }, + ]); + mockPrisma.userDepartment.findFirst.mockResolvedValue(null); + mockPrisma.userDepartment.findMany.mockResolvedValue(mockAssignmentRows); + }); + + it("throws NotFoundException when the user does not exist", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect( + service.assign("missing-user", { departmentIds: ["dept-1"] }), + ).rejects.toThrow(NotFoundException); + expect(mockPrisma.$transaction).not.toHaveBeenCalled(); + }); + + it("throws BadRequestException when departmentIds is empty", async () => { + await expect( + service.assign("user-1", { departmentIds: [] }), + ).rejects.toThrow(BadRequestException); + expect(mockPrisma.$transaction).not.toHaveBeenCalled(); + }); + + it("throws BadRequestException when primaryDepartmentId is not in departmentIds", async () => { + await expect( + service.assign("user-1", { + departmentIds: ["dept-1"], + primaryDepartmentId: "dept-2", + }), + ).rejects.toThrow(BadRequestException); + expect(mockPrisma.$transaction).not.toHaveBeenCalled(); + }); + + it("throws BadRequestException when a departmentId does not belong to the user's organization", async () => { + mockPrisma.department.findMany.mockResolvedValue([{ id: "dept-1" }]); + + await expect( + service.assign("user-1", { departmentIds: ["dept-1", "dept-2"] }), + ).rejects.toThrow(BadRequestException); + expect(mockPrisma.$transaction).not.toHaveBeenCalled(); + }); + + it("scopes the department existence check to the user's organization", async () => { + await service.assign("user-1", { departmentIds: ["dept-1", "dept-2"] }); + + expect(mockPrisma.department.findMany).toHaveBeenCalledWith({ + where: { + id: { in: ["dept-1", "dept-2"] }, + organizationId: "org-1", + }, + select: { id: true }, + }); + }); + + it("dedupes repeated departmentIds before validating and writing", async () => { + mockPrisma.department.findMany.mockResolvedValue([{ id: "dept-1" }]); + + await service.assign("user-1", { + departmentIds: ["dept-1", "dept-1"], + }); + + expect(mockPrisma.department.findMany).toHaveBeenCalledWith({ + where: { id: { in: ["dept-1"] }, organizationId: "org-1" }, + select: { id: true }, + }); + }); + + it("uses the explicit primaryDepartmentId when provided", async () => { + await service.assign("user-1", { + departmentIds: ["dept-1", "dept-2"], + primaryDepartmentId: "dept-2", + }); + + expect(mockPrisma.userDepartment.upsert).toHaveBeenCalledWith({ + where: { + userId_departmentId: { userId: "user-1", departmentId: "dept-2" }, + }, + update: { isPrimary: true }, + create: { userId: "user-1", departmentId: "dept-2", isPrimary: true }, + }); + expect(mockPrisma.userDepartment.upsert).toHaveBeenCalledWith({ + where: { + userId_departmentId: { userId: "user-1", departmentId: "dept-1" }, + }, + update: { isPrimary: false }, + create: { userId: "user-1", departmentId: "dept-1", isPrimary: false }, + }); + }); + + it("keeps the existing primary department when it is still in the new set", async () => { + mockPrisma.userDepartment.findFirst.mockResolvedValue({ + departmentId: "dept-2", + }); + + await service.assign("user-1", { departmentIds: ["dept-1", "dept-2"] }); + + expect(mockPrisma.userDepartment.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId_departmentId: { userId: "user-1", departmentId: "dept-2" }, + }, + update: { isPrimary: true }, + }), + ); + }); + + it("defaults primary to the first departmentId when there is no existing primary", async () => { + mockPrisma.userDepartment.findFirst.mockResolvedValue(null); + + await service.assign("user-1", { departmentIds: ["dept-1", "dept-2"] }); + + expect(mockPrisma.userDepartment.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId_departmentId: { userId: "user-1", departmentId: "dept-1" }, + }, + update: { isPrimary: true }, + }), + ); + }); + + it("clears existing primary flags before applying the new assignment set", async () => { + mockPrisma.department.findMany.mockResolvedValue([{ id: "dept-1" }]); + + await service.assign("user-1", { departmentIds: ["dept-1"] }); + + expect(mockPrisma.userDepartment.updateMany).toHaveBeenCalledWith({ + where: { userId: "user-1" }, + data: { isPrimary: false }, + }); + }); + + it("deletes assignments for departments no longer in the set", async () => { + mockPrisma.department.findMany.mockResolvedValue([{ id: "dept-1" }]); + + await service.assign("user-1", { departmentIds: ["dept-1"] }); + + expect(mockPrisma.userDepartment.deleteMany).toHaveBeenCalledWith({ + where: { userId: "user-1", departmentId: { notIn: ["dept-1"] } }, + }); + }); + + it("runs the update, delete, and upserts inside a single transaction", async () => { + await service.assign("user-1", { departmentIds: ["dept-1", "dept-2"] }); + + expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1); + const ops = mockPrisma.$transaction.mock.calls[0]?.[0] as unknown[]; + expect(ops).toHaveLength(4); // updateMany + deleteMany + 2 upserts + }); + + it("returns the refreshed assignment list after writing", async () => { + mockPrisma.department.findMany.mockResolvedValue([{ id: "dept-1" }]); + + const result = await service.assign("user-1", { + departmentIds: ["dept-1"], + }); + + expect(result).toEqual(mockAssignmentRows); + }); + }); +}); diff --git a/apps/api/src/admin/user-departments/user-department.service.ts b/apps/api/src/admin/user-departments/user-department.service.ts new file mode 100644 index 0000000..5767497 --- /dev/null +++ b/apps/api/src/admin/user-departments/user-department.service.ts @@ -0,0 +1,130 @@ +import { + Injectable, + BadRequestException, + NotFoundException, +} from "@nestjs/common"; +import { PrismaService } from "../../prisma/prisma.service"; +import type { User } from "db/client"; +import type { AssignUserDepartmentsDto } from "./user-department.dto"; + +/** + * Narrow local shape for a UserDepartment row joined with its Department. + * Declared locally instead of importing Prisma's generated payload helper + * type to keep this module decoupled from Prisma's runtime type plumbing. + */ +export interface UserDepartmentWithDepartment { + departmentId: string; + isPrimary: boolean; + department: { + name: string; + }; +} + +@Injectable() +export class UserDepartmentService { + constructor(private readonly prisma: PrismaService) {} + + async findForUser(userId: string): Promise { + await this.ensureUserExists(userId); + + return this.prisma.userDepartment.findMany({ + where: { userId }, + include: { department: { select: { name: true } } }, + orderBy: { createdAt: "asc" }, + }); + } + + /** + * Replaces a user's department assignments with exactly the given set, + * designating one department as primary. This is a set-replace (not an + * additive merge) so admins can also remove a user from a department by + * omitting it from departmentIds. + * + * Primary resolution order: + * 1. dto.primaryDepartmentId, if provided (must be in departmentIds). + * 2. The user's current primary department, if it is still in the set. + * 3. The first entry in departmentIds. + * + * Writes run in a transaction that first clears every isPrimary flag for + * the user before setting the new one, so the partial unique index + * enforcing "one primary department per user" is never violated + * mid-transaction. + */ + async assign( + userId: string, + dto: AssignUserDepartmentsDto, + ): Promise { + const user = await this.ensureUserExists(userId); + + const departmentIds = Array.from(new Set(dto.departmentIds)); + if (departmentIds.length === 0) { + throw new BadRequestException( + "departmentIds must contain at least one department", + ); + } + + if ( + dto.primaryDepartmentId !== undefined && + !departmentIds.includes(dto.primaryDepartmentId) + ) { + throw new BadRequestException( + "primaryDepartmentId must be included in departmentIds", + ); + } + + const departments = await this.prisma.department.findMany({ + where: { id: { in: departmentIds }, organizationId: user.organizationId }, + select: { id: true }, + }); + if (departments.length !== departmentIds.length) { + throw new BadRequestException( + "One or more departmentIds do not exist in the user's organization", + ); + } + + const primaryDepartmentId = + dto.primaryDepartmentId ?? + (await this.resolveDefaultPrimary(userId, departmentIds)); + + await this.prisma.$transaction([ + this.prisma.userDepartment.updateMany({ + where: { userId }, + data: { isPrimary: false }, + }), + this.prisma.userDepartment.deleteMany({ + where: { userId, departmentId: { notIn: departmentIds } }, + }), + ...departmentIds.map((departmentId) => + this.prisma.userDepartment.upsert({ + where: { userId_departmentId: { userId, departmentId } }, + update: { isPrimary: departmentId === primaryDepartmentId }, + create: { + userId, + departmentId, + isPrimary: departmentId === primaryDepartmentId, + }, + }), + ), + ]); + + return this.findForUser(userId); + } + + private async resolveDefaultPrimary( + userId: string, + departmentIds: string[], + ): Promise { + const existingPrimary = await this.prisma.userDepartment.findFirst({ + where: { userId, isPrimary: true, departmentId: { in: departmentIds } }, + }); + return existingPrimary?.departmentId ?? departmentIds[0]; + } + + private async ensureUserExists(userId: string): Promise { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new NotFoundException(`User with id "${userId}" not found`); + } + return user; + } +} diff --git a/apps/api/src/admin/user-departments/user-departments.controller.ts b/apps/api/src/admin/user-departments/user-departments.controller.ts new file mode 100644 index 0000000..e19c805 --- /dev/null +++ b/apps/api/src/admin/user-departments/user-departments.controller.ts @@ -0,0 +1,67 @@ +import { + Controller, + Get, + Put, + Param, + Body, + UseGuards, + HttpCode, + HttpStatus, + BadRequestException, +} from "@nestjs/common"; +import { AdminRoleGuard } from "../guards/admin-role.guard"; +import { UserDepartmentService } from "./user-department.service"; +import type { UserDepartmentWithDepartment } from "./user-department.service"; +import type { + AssignUserDepartmentsDto, + UserDepartmentsResponseDto, +} from "./user-department.dto"; + +function toResponseDto( + userId: string, + rows: UserDepartmentWithDepartment[], +): UserDepartmentsResponseDto { + return { + userId, + departments: rows.map((row) => ({ + departmentId: row.departmentId, + name: row.department.name, + isPrimary: row.isPrimary, + })), + }; +} + +@Controller("api/admin/users/:userId/departments") +@UseGuards(AdminRoleGuard) +export class UserDepartmentsController { + constructor(private readonly userDepartmentService: UserDepartmentService) {} + + @Get() + @HttpCode(HttpStatus.OK) + async findForUser( + @Param("userId") userId: string, + ): Promise { + const rows = await this.userDepartmentService.findForUser(userId); + return toResponseDto(userId, rows); + } + + @Put() + @HttpCode(HttpStatus.OK) + async assign( + @Param("userId") userId: string, + @Body() body: AssignUserDepartmentsDto, + ): Promise { + if (!Array.isArray(body.departmentIds) || body.departmentIds.length === 0) { + throw new BadRequestException("departmentIds must be a non-empty array"); + } + if ( + body.primaryDepartmentId !== undefined && + typeof body.primaryDepartmentId !== "string" + ) { + throw new BadRequestException("primaryDepartmentId must be a string"); + } + + const rows = await this.userDepartmentService.assign(userId, body); + return toResponseDto(userId, rows); + } +} diff --git a/apps/api/src/admin/user-departments/user-departments.integration.spec.ts b/apps/api/src/admin/user-departments/user-departments.integration.spec.ts new file mode 100644 index 0000000..dd7a03f --- /dev/null +++ b/apps/api/src/admin/user-departments/user-departments.integration.spec.ts @@ -0,0 +1,206 @@ +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-user-departments-integration"; + +class MockGoogleStrategy { + name = "google"; +} + +const mockUser = { + id: "user-1", + email: "alice@acme.com", + googleSub: "google-sub-1", + role: "member", + organizationId: "org-1", +}; + +const mockAssignmentRows = [ + { + departmentId: "dept-1", + isPrimary: true, + department: { name: "Engineering" }, + }, +]; + +const mockPrisma = { + $connect: jest.fn().mockResolvedValue(undefined), + $disconnect: jest.fn().mockResolvedValue(undefined), + $transaction: jest.fn(async (ops: unknown[]) => Promise.all(ops)), + user: { findUnique: jest.fn() }, + organization: { findUnique: jest.fn() }, + department: { findMany: jest.fn() }, + userDepartment: { + findMany: jest.fn(), + findFirst: jest.fn(), + updateMany: jest.fn(), + deleteMany: jest.fn(), + upsert: jest.fn(), + }, +}; + +function issueToken(role: string, sub = "admin-user"): string { + return jwt.sign( + { sub, email: `${sub}@example.com`, organizationId: "org-1", role }, + TEST_JWT_SECRET, + { expiresIn: "1h" }, + ); +} + +describe("Admin UserDepartments 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(); + mockPrisma.$transaction.mockImplementation(async (ops: unknown[]) => + Promise.all(ops), + ); + }); + + describe("authorization", () => { + it("returns 401 when no token is provided", async () => { + await request(app.getHttpServer()) + .get("/api/admin/users/user-1/departments") + .expect(401); + }); + + it("returns 403 when authenticated user has member role", async () => { + const token = issueToken("member"); + + await request(app.getHttpServer()) + .put("/api/admin/users/user-1/departments") + .set("Authorization", `Bearer ${token}`) + .send({ departmentIds: ["dept-1"] }) + .expect(403); + }); + }); + + describe("GET /api/admin/users/:userId/departments", () => { + it("returns 200 with the user's department assignments", async () => { + const token = issueToken("admin"); + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + mockPrisma.userDepartment.findMany.mockResolvedValue(mockAssignmentRows); + + const res = await request(app.getHttpServer()) + .get("/api/admin/users/user-1/departments") + .set("Authorization", `Bearer ${token}`) + .expect(200); + + expect(res.body).toEqual({ + userId: "user-1", + departments: [ + { departmentId: "dept-1", name: "Engineering", isPrimary: true }, + ], + }); + }); + + it("returns 404 when the user does not exist", async () => { + const token = issueToken("admin"); + mockPrisma.user.findUnique.mockResolvedValue(null); + + await request(app.getHttpServer()) + .get("/api/admin/users/missing-user/departments") + .set("Authorization", `Bearer ${token}`) + .expect(404); + }); + }); + + describe("PUT /api/admin/users/:userId/departments", () => { + it("returns 200 with the updated assignment set on a valid payload", async () => { + const token = issueToken("admin"); + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + mockPrisma.department.findMany.mockResolvedValue([{ id: "dept-1" }]); + mockPrisma.userDepartment.findFirst.mockResolvedValue(null); + mockPrisma.userDepartment.findMany.mockResolvedValue(mockAssignmentRows); + + const res = await request(app.getHttpServer()) + .put("/api/admin/users/user-1/departments") + .set("Authorization", `Bearer ${token}`) + .send({ departmentIds: ["dept-1"] }) + .expect(200); + + expect(res.body).toEqual({ + userId: "user-1", + departments: [ + { departmentId: "dept-1", name: "Engineering", isPrimary: true }, + ], + }); + }); + + it("returns 400 when departmentIds is missing", async () => { + const token = issueToken("admin"); + + await request(app.getHttpServer()) + .put("/api/admin/users/user-1/departments") + .set("Authorization", `Bearer ${token}`) + .send({}) + .expect(400); + }); + + it("returns 400 when departmentIds is an empty array", async () => { + const token = issueToken("admin"); + + await request(app.getHttpServer()) + .put("/api/admin/users/user-1/departments") + .set("Authorization", `Bearer ${token}`) + .send({ departmentIds: [] }) + .expect(400); + }); + + it("returns 404 when the user does not exist", async () => { + const token = issueToken("admin"); + mockPrisma.user.findUnique.mockResolvedValue(null); + + await request(app.getHttpServer()) + .put("/api/admin/users/missing-user/departments") + .set("Authorization", `Bearer ${token}`) + .send({ departmentIds: ["dept-1"] }) + .expect(404); + }); + + it("returns 400 when a departmentId is outside the user's organization", async () => { + const token = issueToken("admin"); + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + mockPrisma.department.findMany.mockResolvedValue([]); + + await request(app.getHttpServer()) + .put("/api/admin/users/user-1/departments") + .set("Authorization", `Bearer ${token}`) + .send({ departmentIds: ["dept-from-other-org"] }) + .expect(400); + }); + }); +}); diff --git a/apps/api/src/admin/users/admin-user.service.spec.ts b/apps/api/src/admin/users/admin-user.service.spec.ts new file mode 100644 index 0000000..66701f2 --- /dev/null +++ b/apps/api/src/admin/users/admin-user.service.spec.ts @@ -0,0 +1,62 @@ +import { Test, type TestingModule } from "@nestjs/testing"; +import { NotFoundException } from "@nestjs/common"; +import { AdminUserService } from "./admin-user.service"; +import { PrismaService } from "../../prisma/prisma.service"; + +const mockOrg = { id: "org-1", name: "acme.com" }; + +const mockUser = { + id: "user-1", + email: "alice@acme.com", + googleSub: "google-sub-1", + role: "member", + organizationId: "org-1", + createdAt: new Date("2024-01-01T00:00:00Z"), + updatedAt: new Date("2024-01-01T00:00:00Z"), +}; + +const mockPrisma = { + organization: { findUnique: jest.fn() }, + user: { findMany: jest.fn() }, +}; + +describe("AdminUserService", () => { + let service: AdminUserService; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AdminUserService, + { provide: PrismaService, useValue: mockPrisma }, + ], + }).compile(); + + service = module.get(AdminUserService); + }); + + describe("findAllByOrganization", () => { + it("returns users scoped to the organization, ordered by email", async () => { + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + mockPrisma.user.findMany.mockResolvedValue([mockUser]); + + const result = await service.findAllByOrganization("org-1"); + + expect(result).toEqual([mockUser]); + expect(mockPrisma.user.findMany).toHaveBeenCalledWith({ + where: { organizationId: "org-1" }, + orderBy: { email: "asc" }, + }); + }); + + it("throws NotFoundException when the organization does not exist", async () => { + mockPrisma.organization.findUnique.mockResolvedValue(null); + + await expect( + service.findAllByOrganization("missing-org"), + ).rejects.toThrow(NotFoundException); + expect(mockPrisma.user.findMany).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/admin/users/admin-user.service.ts b/apps/api/src/admin/users/admin-user.service.ts new file mode 100644 index 0000000..76114f6 --- /dev/null +++ b/apps/api/src/admin/users/admin-user.service.ts @@ -0,0 +1,28 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { PrismaService } from "../../prisma/prisma.service"; +import type { User } from "db/client"; + +@Injectable() +export class AdminUserService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Lists users scoped to a single Organization, ordered by email so the + * admin picker in the assignment UI stays stable and predictable. + */ + async findAllByOrganization(organizationId: string): Promise { + const org = await this.prisma.organization.findUnique({ + where: { id: organizationId }, + }); + if (!org) { + throw new NotFoundException( + `Organization with id "${organizationId}" not found`, + ); + } + + return this.prisma.user.findMany({ + where: { organizationId }, + orderBy: { email: "asc" }, + }); + } +} diff --git a/apps/api/src/admin/users/admin-users.controller.ts b/apps/api/src/admin/users/admin-users.controller.ts new file mode 100644 index 0000000..30df1b9 --- /dev/null +++ b/apps/api/src/admin/users/admin-users.controller.ts @@ -0,0 +1,38 @@ +import { + Controller, + Get, + Param, + UseGuards, + HttpCode, + HttpStatus, +} from "@nestjs/common"; +import { AdminRoleGuard } from "../guards/admin-role.guard"; +import { AdminUserService } from "./admin-user.service"; +import type { AdminUserResponseDto } from "./user.dto"; +import type { User } from "db/client"; + +function toResponseDto(user: User): AdminUserResponseDto { + return { + id: user.id, + email: user.email, + role: user.role, + organizationId: user.organizationId, + createdAt: user.createdAt.toISOString(), + }; +} + +@Controller("api/admin/organizations/:organizationId/users") +@UseGuards(AdminRoleGuard) +export class AdminUsersController { + constructor(private readonly adminUserService: AdminUserService) {} + + @Get() + @HttpCode(HttpStatus.OK) + async findAll( + @Param("organizationId") organizationId: string, + ): Promise { + const users = + await this.adminUserService.findAllByOrganization(organizationId); + return users.map(toResponseDto); + } +} diff --git a/apps/api/src/admin/users/user.dto.ts b/apps/api/src/admin/users/user.dto.ts new file mode 100644 index 0000000..9037cb8 --- /dev/null +++ b/apps/api/src/admin/users/user.dto.ts @@ -0,0 +1,7 @@ +export interface AdminUserResponseDto { + id: string; + email: string; + role: string; + organizationId: string; + createdAt: string; +} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 18f9792..99f26ae 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -12,7 +12,7 @@ import { AuthService } from "./auth.service"; import { UserService } from "./user.service"; import { GoogleAuthGuard } from "./guards/google-auth.guard"; import { JwtAuthGuard } from "./guards/jwt-auth.guard"; -import type { AuthenticatedUser } from "./auth.types"; +import type { AuthenticatedUser, SessionResponseDto } from "./auth.types"; interface RequestWithUser extends Request { user: AuthenticatedUser & { googleSub: string }; @@ -67,11 +67,21 @@ export class AuthController { @Controller("api") export class MeController { - /** Returns the identity of the currently authenticated user. */ + constructor(private readonly userService: UserService) {} + + /** + * Returns the identity of the currently authenticated user, enriched with + * their current department assignments. This is the "authenticated + * user's session" view: JWT claims (id, email, organizationId, role) plus + * department scope resolved live from the UserDepartment table. + */ @Get("me") @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) - getMe(@Req() req: RequestWithJwtUser): AuthenticatedUser { - return req.user; + async getMe(@Req() req: RequestWithJwtUser): Promise { + const { departmentIds, primaryDepartmentId } = + await this.userService.getDepartmentAssignments(req.user.id); + + return { ...req.user, departmentIds, primaryDepartmentId }; } } diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 458d6ff..7ed6dbf 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -6,9 +6,11 @@ import { UserService } from "./user.service"; import { AuthController, MeController } from "./auth.controller"; import { GoogleStrategy } from "./strategies/google.strategy"; import { JwtStrategy } from "./strategies/jwt.strategy"; +import { PrismaModule } from "../prisma/prisma.module"; @Module({ imports: [ + PrismaModule, PassportModule.register({ defaultStrategy: "jwt" }), JwtModule.registerAsync({ useFactory: () => { diff --git a/apps/api/src/auth/auth.types.ts b/apps/api/src/auth/auth.types.ts index d8c9248..24bc912 100644 --- a/apps/api/src/auth/auth.types.ts +++ b/apps/api/src/auth/auth.types.ts @@ -19,3 +19,15 @@ export interface AuthenticatedUser { organizationId: string; role: string; } + +/** + * The `GET /api/me` session view: JWT claims enriched with the user's + * current department assignments. Department scope is resolved fresh from + * the UserDepartment table on every request (see UserService) rather than + * signed into the JWT, so admin reassignment takes effect without a token + * refresh. + */ +export interface SessionResponseDto extends AuthenticatedUser { + departmentIds: string[]; + primaryDepartmentId: string | null; +} diff --git a/apps/api/src/auth/session.integration.spec.ts b/apps/api/src/auth/session.integration.spec.ts new file mode 100644 index 0000000..fb3e1ce --- /dev/null +++ b/apps/api/src/auth/session.integration.spec.ts @@ -0,0 +1,144 @@ +import { Test } from "@nestjs/testing"; +import type { INestApplication } from "@nestjs/common"; +import request from "supertest"; +import * as jwt from "jsonwebtoken"; +import { AuthModule } from "./auth.module"; +import { PrismaService } from "../prisma/prisma.service"; +import { GoogleStrategy } from "./strategies/google.strategy"; + +const TEST_JWT_SECRET = "test-secret-for-session-integration"; + +class MockGoogleStrategy { + name = "google"; +} + +const mockPrisma = { + $connect: jest.fn().mockResolvedValue(undefined), + $disconnect: jest.fn().mockResolvedValue(undefined), + user: { findUnique: jest.fn(), create: jest.fn(), upsert: jest.fn() }, + organization: { findUnique: jest.fn() }, + userDepartment: { findMany: jest.fn() }, +}; + +function issueToken(payload: { + sub: string; + email: string; + organizationId: string; + role: string; +}): string { + return jwt.sign(payload, TEST_JWT_SECRET, { expiresIn: "1h" }); +} + +describe("Session Enrichment Integration (GET /api/me)", () => { + 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: [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(); + }); + + it("returns 401 when no token is provided", async () => { + await request(app.getHttpServer()).get("/api/me").expect(401); + }); + + it("returns JWT claims enriched with departmentIds and primaryDepartmentId", async () => { + const token = issueToken({ + sub: "user-1", + email: "alice@acme.com", + organizationId: "org-1", + role: "member", + }); + mockPrisma.userDepartment.findMany.mockResolvedValue([ + { departmentId: "dept-1", isPrimary: false }, + { departmentId: "dept-2", isPrimary: true }, + ]); + + const res = await request(app.getHttpServer()) + .get("/api/me") + .set("Authorization", `Bearer ${token}`) + .expect(200); + + expect(res.body).toEqual({ + id: "user-1", + email: "alice@acme.com", + organizationId: "org-1", + role: "member", + departmentIds: ["dept-1", "dept-2"], + primaryDepartmentId: "dept-2", + }); + }); + + it("reflects a newly assigned department immediately, without reissuing the token", async () => { + const token = issueToken({ + sub: "user-1", + email: "alice@acme.com", + organizationId: "org-1", + role: "member", + }); + + mockPrisma.userDepartment.findMany.mockResolvedValueOnce([]); + const before = await request(app.getHttpServer()) + .get("/api/me") + .set("Authorization", `Bearer ${token}`) + .expect(200); + expect(before.body.departmentIds).toEqual([]); + expect(before.body.primaryDepartmentId).toBeNull(); + + // Simulate an admin assigning the same user to a department in between + // requests, using the exact same (already-issued) token. + mockPrisma.userDepartment.findMany.mockResolvedValueOnce([ + { departmentId: "dept-new", isPrimary: true }, + ]); + const after = await request(app.getHttpServer()) + .get("/api/me") + .set("Authorization", `Bearer ${token}`) + .expect(200); + + expect(after.body.departmentIds).toEqual(["dept-new"]); + expect(after.body.primaryDepartmentId).toBe("dept-new"); + }); + + it("returns an empty departmentIds array and null primary for a user with no assignments", async () => { + const token = issueToken({ + sub: "user-2", + email: "bob@acme.com", + organizationId: "org-1", + role: "member", + }); + mockPrisma.userDepartment.findMany.mockResolvedValue([]); + + const res = await request(app.getHttpServer()) + .get("/api/me") + .set("Authorization", `Bearer ${token}`) + .expect(200); + + expect(res.body.departmentIds).toEqual([]); + expect(res.body.primaryDepartmentId).toBeNull(); + }); +}); diff --git a/apps/api/src/auth/user.service.spec.ts b/apps/api/src/auth/user.service.spec.ts index 2e34c59..3571d15 100644 --- a/apps/api/src/auth/user.service.spec.ts +++ b/apps/api/src/auth/user.service.spec.ts @@ -24,6 +24,9 @@ const mockPrisma = { organization: { findUnique: jest.fn(), }, + userDepartment: { + findMany: jest.fn(), + }, }; describe("UserService", () => { @@ -200,4 +203,43 @@ describe("UserService", () => { expect(mockPrisma.user.upsert).not.toHaveBeenCalled(); }); }); + + describe("getDepartmentAssignments", () => { + it("returns departmentIds and the primary department when one is set", async () => { + mockPrisma.userDepartment.findMany.mockResolvedValue([ + { departmentId: "dept-1", isPrimary: false }, + { departmentId: "dept-2", isPrimary: true }, + ]); + + const result = await service.getDepartmentAssignments("user-1"); + + expect(result).toEqual({ + departmentIds: ["dept-1", "dept-2"], + primaryDepartmentId: "dept-2", + }); + expect(mockPrisma.userDepartment.findMany).toHaveBeenCalledWith({ + where: { userId: "user-1" }, + select: { departmentId: true, isPrimary: true }, + }); + }); + + it("returns an empty departmentIds array and null primary when unassigned", async () => { + mockPrisma.userDepartment.findMany.mockResolvedValue([]); + + const result = await service.getDepartmentAssignments("user-1"); + + expect(result).toEqual({ departmentIds: [], primaryDepartmentId: null }); + }); + + it("returns null primary when no assignment is flagged primary", async () => { + mockPrisma.userDepartment.findMany.mockResolvedValue([ + { departmentId: "dept-1", isPrimary: false }, + ]); + + const result = await service.getDepartmentAssignments("user-1"); + + expect(result.primaryDepartmentId).toBeNull(); + expect(result.departmentIds).toEqual(["dept-1"]); + }); + }); }); diff --git a/apps/api/src/auth/user.service.ts b/apps/api/src/auth/user.service.ts index 443e40b..21850d4 100644 --- a/apps/api/src/auth/user.service.ts +++ b/apps/api/src/auth/user.service.ts @@ -11,6 +11,11 @@ interface UpsertUserInput { email: string; } +export interface DepartmentAssignments { + departmentIds: string[]; + primaryDepartmentId: string | null; +} + /** * Narrows an unknown thrown value to a Prisma unique-constraint error (P2002). * Avoids importing Prisma runtime types into the CommonJS API workspace. @@ -36,6 +41,29 @@ export class UserService { return this.prisma.user.findUnique({ where: { id } }); } + /** + * Resolves the department scope used to enrich the authenticated user's + * session view (`GET /api/me`). Reads directly from the UserDepartment + * junction rather than a token claim so that admin-driven department + * assignment changes are visible immediately, without waiting for the + * user's JWT to expire and be reissued. + */ + async getDepartmentAssignments( + userId: string, + ): Promise { + const assignments = await this.prisma.userDepartment.findMany({ + where: { userId }, + select: { departmentId: true, isPrimary: true }, + }); + + const primary = assignments.find((assignment) => assignment.isPrimary); + + return { + departmentIds: assignments.map((assignment) => assignment.departmentId), + primaryDepartmentId: primary?.departmentId ?? null, + }; + } + /** * Return an existing user (fast path) or provision a new one. * diff --git a/apps/web/app/admin/_components/OrgPicker.tsx b/apps/web/app/admin/_components/OrgPicker.tsx new file mode 100644 index 0000000..467cf9a --- /dev/null +++ b/apps/web/app/admin/_components/OrgPicker.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import type { CSSProperties } from "react"; +import type { Organization } from "../organizations/types"; + +interface OrgPickerProps { + organizations: Organization[]; + selectedOrgId?: string; + /** Base path to navigate to on selection, e.g. "/admin/departments". */ + basePath: string; +} + +/** + * Organization selector shared by department and user-assignment admin + * pages. Navigates via the `orgId` search param so the selection survives + * a page reload and can be linked to directly. + */ +export function OrgPicker({ + organizations, + selectedOrgId, + basePath, +}: OrgPickerProps) { + const router = useRouter(); + + return ( +
+ + +
+ ); +} + +const fieldStyle: CSSProperties = { + display: "flex", + flexDirection: "column", + gap: "4px", + maxWidth: "320px", + marginBottom: "24px", +}; + +const labelStyle: CSSProperties = { + fontSize: "14px", + fontWeight: 600, + color: "#374151", +}; + +const selectStyle: CSSProperties = { + padding: "8px 12px", + border: "1px solid #d1d5db", + borderRadius: "6px", + fontSize: "14px", + background: "#fff", +}; diff --git a/apps/web/app/admin/departments/DeptForm.tsx b/apps/web/app/admin/departments/DeptForm.tsx new file mode 100644 index 0000000..c476cd0 --- /dev/null +++ b/apps/web/app/admin/departments/DeptForm.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useActionState, useRef, type CSSProperties } from "react"; +import { createDepartmentAction } from "./actions"; + +interface CreateFormProps { + organizationId: string; +} + +type FormState = { error?: string }; + +const initialState: FormState = {}; + +export function CreateDeptForm({ organizationId }: CreateFormProps) { + const formRef = useRef(null); + + const boundAction = async ( + _prev: FormState, + formData: FormData, + ): Promise => { + const result = await createDepartmentAction(organizationId, formData); + if (!result.error) { + formRef.current?.reset(); + } + return result; + }; + + const [state, formAction, isPending] = useActionState( + boundAction, + initialState, + ); + + return ( +
+

Create department

+ + {state.error && ( +

+ {state.error} +

+ )} + +
+ + +
+ + +
+ ); +} + +const formStyle: CSSProperties = { + display: "flex", + flexDirection: "column", + gap: "12px", + padding: "20px", + border: "1px solid #e2e8f0", + borderRadius: "8px", + background: "#f8fafc", + maxWidth: "480px", +}; + +const fieldStyle: CSSProperties = { + display: "flex", + flexDirection: "column", + gap: "4px", +}; + +const labelStyle: CSSProperties = { + fontSize: "14px", + fontWeight: 600, + color: "#374151", +}; + +const inputStyle: CSSProperties = { + padding: "8px 12px", + border: "1px solid #d1d5db", + borderRadius: "6px", + fontSize: "14px", + outline: "none", +}; + +const btnStyle: CSSProperties = { + padding: "8px 16px", + background: "#2563eb", + color: "#fff", + border: "none", + borderRadius: "6px", + fontSize: "14px", + fontWeight: 600, + cursor: "pointer", + alignSelf: "flex-start", +}; + +const errorStyle: 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/departments/DeptTable.tsx b/apps/web/app/admin/departments/DeptTable.tsx new file mode 100644 index 0000000..ba23088 --- /dev/null +++ b/apps/web/app/admin/departments/DeptTable.tsx @@ -0,0 +1,71 @@ +import type { CSSProperties } from "react"; +import type { Department } from "./types"; + +interface DeptTableProps { + departments: Department[]; +} + +export function DeptTable({ departments }: DeptTableProps) { + if (departments.length === 0) { + return ( +

+ No departments yet. Create one below. +

+ ); + } + + return ( + + + + + + + + + + {departments.map((dept) => ( + + + + + + ))} + +
NameIDCreated
+ {dept.name} + + {dept.id} + + {new Date(dept.createdAt).toLocaleDateString()} +
+ ); +} + +const tableStyle: CSSProperties = { + width: "100%", + borderCollapse: "collapse", + fontSize: "14px", +}; + +const thStyle: CSSProperties = { + textAlign: "left", + padding: "10px 12px", + background: "#f1f5f9", + borderBottom: "1px solid #e2e8f0", + fontWeight: 600, + color: "#374151", +}; + +const tdStyle: CSSProperties = { + padding: "12px", + borderBottom: "1px solid #e2e8f0", + verticalAlign: "top", +}; diff --git a/apps/web/app/admin/departments/actions.ts b/apps/web/app/admin/departments/actions.ts new file mode 100644 index 0000000..be7092f --- /dev/null +++ b/apps/web/app/admin/departments/actions.ts @@ -0,0 +1,40 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { createDepartment } 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 createDepartmentAction( + organizationId: string, + formData: FormData, +): Promise<{ error?: string }> { + if (!organizationId) { + return { error: "Select an organization first" }; + } + + const name = getStringField(formData, "name"); + if (!name) { + return { error: "Department name is required" }; + } + + try { + const result = await createDepartment(organizationId, name); + if (result.error) { + return { error: result.error }; + } + } catch { + return { error: "Failed to create department. Please try again." }; + } + + revalidatePath("/admin/departments"); + return {}; +} diff --git a/apps/web/app/admin/departments/api.ts b/apps/web/app/admin/departments/api.ts new file mode 100644 index 0000000..b2ebdcc --- /dev/null +++ b/apps/web/app/admin/departments/api.ts @@ -0,0 +1,67 @@ +import type { Department } 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 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"] ?? ""; +} + +function authHeaders(): HeadersInit { + const token = getAdminToken(); + 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 fetchDepartments( + organizationId: string, +): Promise { + const res = await fetch( + `${API_URL}/api/admin/organizations/${encodeURIComponent(organizationId)}/departments`, + { + headers: authHeaders(), + cache: "no-store", + signal: AbortSignal.timeout(5000), + }, + ); + + if (!res.ok) { + throw new Error(`Failed to fetch departments: ${res.status}`); + } + + return res.json() as Promise; +} + +export async function createDepartment( + organizationId: string, + name: string, +): Promise<{ department?: Department; error?: string }> { + const res = await fetch( + `${API_URL}/api/admin/organizations/${encodeURIComponent(organizationId)}/departments`, + { + method: "POST", + headers: { ...authHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + signal: AbortSignal.timeout(5000), + }, + ); + + if (!res.ok) { + const errBody = await parseJsonSafe<{ message?: string }>(res); + return { error: errBody?.message ?? "Failed to create department" }; + } + + return { department: (await res.json()) as Department }; +} diff --git a/apps/web/app/admin/departments/page.tsx b/apps/web/app/admin/departments/page.tsx new file mode 100644 index 0000000..465b6bf --- /dev/null +++ b/apps/web/app/admin/departments/page.tsx @@ -0,0 +1,129 @@ +import { Suspense, type CSSProperties } from "react"; +import { fetchOrganizations } from "../organizations/api"; +import type { Organization } from "../organizations/types"; +import { OrgPicker } from "../_components/OrgPicker"; +import { fetchDepartments } from "./api"; +import { DeptTable } from "./DeptTable"; +import { CreateDeptForm } from "./DeptForm"; +import type { Department } from "./types"; + +export const metadata = { + title: "Departments — Cortex Admin", +}; + +function ErrorBanner({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +} + +async function DeptList({ organizationId }: { organizationId: string }) { + let departments: Department[] = []; + let fetchError: string | null = null; + + try { + departments = await fetchDepartments(organizationId); + } catch (err) { + fetchError = + err instanceof Error ? err.message : "Failed to load departments"; + } + + if (fetchError) { + return ; + } + + return ; +} + +interface PageProps { + searchParams: Promise<{ orgId?: string }>; +} + +export default async function DepartmentsPage({ searchParams }: PageProps) { + const { orgId } = await searchParams; + + let organizations: Organization[] = []; + let orgFetchError: string | null = null; + try { + organizations = await fetchOrganizations(); + } catch (err) { + orgFetchError = + err instanceof Error ? err.message : "Failed to load organizations"; + } + + return ( +
+
+

Departments

+

+ Policy scopes that group users by function within an organization. +

+
+ + {orgFetchError ? ( + + ) : ( + + )} + + {orgId ? ( + <> +
+

All departments

+ Loading…

}> + +
+
+ +
+

Create new department

+ +
+ + ) : ( +

+ Select an organization above to manage its departments. +

+ )} +
+ ); +} + +const pageStyle: CSSProperties = { + maxWidth: "900px", + margin: "0 auto", + padding: "32px 24px", + fontFamily: "system-ui, -apple-system, sans-serif", +}; + +const headerStyle: CSSProperties = { + marginBottom: "32px", + paddingBottom: "20px", + borderBottom: "1px solid #e2e8f0", +}; + +const sectionStyle: CSSProperties = { + marginBottom: "40px", +}; + +const sectionTitleStyle: CSSProperties = { + fontSize: "16px", + fontWeight: 600, + color: "#111827", + marginBottom: "16px", +}; + +const errorBannerStyle: CSSProperties = { + padding: "12px 16px", + background: "#fef2f2", + border: "1px solid #fecaca", + borderRadius: "6px", + color: "#dc2626", + marginBottom: "24px", +}; diff --git a/apps/web/app/admin/departments/types.ts b/apps/web/app/admin/departments/types.ts new file mode 100644 index 0000000..5574af7 --- /dev/null +++ b/apps/web/app/admin/departments/types.ts @@ -0,0 +1,7 @@ +export interface Department { + id: string; + name: string; + organizationId: string; + createdAt: string; + updatedAt: string; +} diff --git a/apps/web/app/admin/organizations/OrgTable.tsx b/apps/web/app/admin/organizations/OrgTable.tsx index 02e5901..ffabde7 100644 --- a/apps/web/app/admin/organizations/OrgTable.tsx +++ b/apps/web/app/admin/organizations/OrgTable.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, type CSSProperties } from "react"; +import Link from "next/link"; import type { Organization } from "./types"; import { EditOrgForm } from "./OrgForm"; @@ -35,29 +36,47 @@ export function OrgTable({ orgs }: OrgTableProps) { {editingId === org.id ? ( - setEditingId(null)} - /> + setEditingId(null)} /> ) : ( <> {org.name} - + {org.id} - + {new Date(org.createdAt).toLocaleDateString()} - + + + Departments + + + Users + )} @@ -99,3 +118,14 @@ const editBtnStyle: CSSProperties = { fontSize: "13px", cursor: "pointer", }; + +const linkBtnStyle: CSSProperties = { + padding: "4px 10px", + background: "transparent", + color: "#374151", + border: "1px solid #d1d5db", + borderRadius: "4px", + fontSize: "13px", + textDecoration: "none", + whiteSpace: "nowrap", +}; diff --git a/apps/web/app/admin/users/AssignDepartmentsForm.tsx b/apps/web/app/admin/users/AssignDepartmentsForm.tsx new file mode 100644 index 0000000..948d8a4 --- /dev/null +++ b/apps/web/app/admin/users/AssignDepartmentsForm.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useActionState, useState, type CSSProperties } from "react"; +import type { Department } from "../departments/types"; +import type { UserDepartmentAssignment } from "./types"; +import { assignDepartmentsAction } from "./actions"; + +interface AssignDepartmentsFormProps { + userId: string; + departments: Department[]; + currentAssignments: UserDepartmentAssignment[]; +} + +type FormState = { error?: string }; + +const initialState: FormState = {}; + +export function AssignDepartmentsForm({ + userId, + departments, + currentAssignments, +}: AssignDepartmentsFormProps) { + const currentIds = new Set(currentAssignments.map((a) => a.departmentId)); + const currentPrimary = + currentAssignments.find((a) => a.isPrimary)?.departmentId ?? null; + + const [checked, setChecked] = useState>(currentIds); + const [primary, setPrimary] = useState(currentPrimary); + + const boundAction = async ( + _prev: FormState, + formData: FormData, + ): Promise => assignDepartmentsAction(userId, formData); + + const [state, formAction, isPending] = useActionState( + boundAction, + initialState, + ); + + function toggleDept(id: string, isChecked: boolean): void { + setChecked((prev) => { + const next = new Set(prev); + if (isChecked) { + next.add(id); + } else { + next.delete(id); + } + return next; + }); + if (!isChecked && primary === id) { + setPrimary(null); + } + } + + return ( +
+

Assign departments

+ + {state.error && ( +

+ {state.error} +

+ )} + + {departments.length === 0 ? ( +

+ Create a department first before assigning users. +

+ ) : ( +
+ {departments.map((dept) => ( +
+ + +
+ ))} +
+ )} + + +
+ ); +} + +const formStyle: CSSProperties = { + display: "flex", + flexDirection: "column", + gap: "16px", + padding: "20px", + border: "1px solid #e2e8f0", + borderRadius: "8px", + background: "#f8fafc", + maxWidth: "480px", +}; + +const listStyle: CSSProperties = { + display: "flex", + flexDirection: "column", + gap: "8px", +}; + +const rowStyle: CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "16px", + padding: "8px 12px", + background: "#fff", + border: "1px solid #e2e8f0", + borderRadius: "6px", +}; + +const checkboxLabelStyle: CSSProperties = { + display: "flex", + alignItems: "center", + gap: "8px", + fontSize: "14px", +}; + +const radioLabelStyle: CSSProperties = { + display: "flex", + alignItems: "center", + gap: "6px", + fontSize: "12px", + color: "#6b7280", +}; + +const btnStyle: CSSProperties = { + padding: "8px 16px", + background: "#2563eb", + color: "#fff", + border: "none", + borderRadius: "6px", + fontSize: "14px", + fontWeight: 600, + cursor: "pointer", + alignSelf: "flex-start", +}; + +const errorStyle: 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/users/UserTable.tsx b/apps/web/app/admin/users/UserTable.tsx new file mode 100644 index 0000000..3063991 --- /dev/null +++ b/apps/web/app/admin/users/UserTable.tsx @@ -0,0 +1,87 @@ +import type { CSSProperties } from "react"; +import Link from "next/link"; +import type { AdminUser } from "./types"; + +interface UserTableProps { + users: AdminUser[]; + organizationId: string; + selectedUserId?: string; +} + +export function UserTable({ + users, + organizationId, + selectedUserId, +}: UserTableProps) { + if (users.length === 0) { + return ( +

+ No users in this organization yet. +

+ ); + } + + return ( + + + + + + + + + + {users.map((user) => ( + + + + + + ))} + +
EmailRoleActions
{user.email}{user.role} + + {user.id === selectedUserId ? "Editing…" : "Assign departments"} + +
+ ); +} + +const tableStyle: CSSProperties = { + width: "100%", + borderCollapse: "collapse", + fontSize: "14px", +}; + +const thStyle: CSSProperties = { + textAlign: "left", + padding: "10px 12px", + background: "#f1f5f9", + borderBottom: "1px solid #e2e8f0", + fontWeight: 600, + color: "#374151", +}; + +const tdStyle: CSSProperties = { + padding: "12px", + borderBottom: "1px solid #e2e8f0", + verticalAlign: "top", +}; + +const linkBtnStyle: CSSProperties = { + padding: "4px 10px", + background: "transparent", + color: "#2563eb", + border: "1px solid #2563eb", + borderRadius: "4px", + fontSize: "13px", + textDecoration: "none", +}; diff --git a/apps/web/app/admin/users/actions.ts b/apps/web/app/admin/users/actions.ts new file mode 100644 index 0000000..9857a12 --- /dev/null +++ b/apps/web/app/admin/users/actions.ts @@ -0,0 +1,44 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { assignUserDepartments } from "./api"; + +export async function assignDepartmentsAction( + userId: string, + formData: FormData, +): Promise<{ error?: string }> { + if (!userId) { + return { error: "Select a user first" }; + } + + const departmentIds = formData + .getAll("departmentIds") + .filter( + (value): value is string => typeof value === "string" && value.length > 0, + ); + if (departmentIds.length === 0) { + return { error: "Select at least one department" }; + } + + const rawPrimary = formData.get("primaryDepartmentId"); + const primaryDepartmentId = + typeof rawPrimary === "string" && rawPrimary.length > 0 + ? rawPrimary + : undefined; + + try { + const result = await assignUserDepartments( + userId, + departmentIds, + primaryDepartmentId, + ); + if (result.error) { + return { error: result.error }; + } + } catch { + return { error: "Failed to assign departments. Please try again." }; + } + + revalidatePath("/admin/users"); + return {}; +} diff --git a/apps/web/app/admin/users/api.ts b/apps/web/app/admin/users/api.ts new file mode 100644 index 0000000..cecf993 --- /dev/null +++ b/apps/web/app/admin/users/api.ts @@ -0,0 +1,85 @@ +import type { AdminUser, UserDepartmentsResponse } 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 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"] ?? ""; +} + +function authHeaders(): HeadersInit { + const token = getAdminToken(); + 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 fetchUsers(organizationId: string): Promise { + const res = await fetch( + `${API_URL}/api/admin/organizations/${encodeURIComponent(organizationId)}/users`, + { + headers: authHeaders(), + cache: "no-store", + signal: AbortSignal.timeout(5000), + }, + ); + + if (!res.ok) { + throw new Error(`Failed to fetch users: ${res.status}`); + } + + return res.json() as Promise; +} + +export async function fetchUserDepartments( + userId: string, +): Promise { + const res = await fetch( + `${API_URL}/api/admin/users/${encodeURIComponent(userId)}/departments`, + { + headers: authHeaders(), + cache: "no-store", + signal: AbortSignal.timeout(5000), + }, + ); + + if (!res.ok) { + throw new Error(`Failed to fetch user departments: ${res.status}`); + } + + return res.json() as Promise; +} + +export async function assignUserDepartments( + userId: string, + departmentIds: string[], + primaryDepartmentId?: string, +): Promise<{ result?: UserDepartmentsResponse; error?: string }> { + const res = await fetch( + `${API_URL}/api/admin/users/${encodeURIComponent(userId)}/departments`, + { + method: "PUT", + headers: { ...authHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ departmentIds, primaryDepartmentId }), + signal: AbortSignal.timeout(5000), + }, + ); + + if (!res.ok) { + const errBody = await parseJsonSafe<{ message?: string }>(res); + return { error: errBody?.message ?? "Failed to assign departments" }; + } + + return { result: (await res.json()) as UserDepartmentsResponse }; +} diff --git a/apps/web/app/admin/users/page.tsx b/apps/web/app/admin/users/page.tsx new file mode 100644 index 0000000..a97bf6b --- /dev/null +++ b/apps/web/app/admin/users/page.tsx @@ -0,0 +1,184 @@ +import { Suspense, type CSSProperties } from "react"; +import { fetchOrganizations } from "../organizations/api"; +import type { Organization } from "../organizations/types"; +import { OrgPicker } from "../_components/OrgPicker"; +import { fetchDepartments } from "../departments/api"; +import type { Department } from "../departments/types"; +import { fetchUsers, fetchUserDepartments } from "./api"; +import { UserTable } from "./UserTable"; +import { AssignDepartmentsForm } from "./AssignDepartmentsForm"; +import type { AdminUser, UserDepartmentAssignment } from "./types"; + +export const metadata = { + title: "Users — Cortex Admin", +}; + +function ErrorBanner({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +} + +async function UserList({ + organizationId, + selectedUserId, +}: { + organizationId: string; + selectedUserId?: string; +}) { + let users: AdminUser[] = []; + let fetchError: string | null = null; + + try { + users = await fetchUsers(organizationId); + } catch (err) { + fetchError = err instanceof Error ? err.message : "Failed to load users"; + } + + if (fetchError) { + return ; + } + + return ( + + ); +} + +async function AssignmentPanel({ + organizationId, + userId, +}: { + organizationId: string; + userId: string; +}) { + let departments: Department[] = []; + let currentAssignments: UserDepartmentAssignment[] = []; + let fetchError: string | null = null; + + try { + const [depts, assignment] = await Promise.all([ + fetchDepartments(organizationId), + fetchUserDepartments(userId), + ]); + departments = depts; + currentAssignments = assignment.departments; + } catch (err) { + fetchError = + err instanceof Error + ? err.message + : "Failed to load department assignment"; + } + + if (fetchError) { + return ; + } + + return ( + + ); +} + +interface PageProps { + searchParams: Promise<{ orgId?: string; userId?: string }>; +} + +export default async function UsersPage({ searchParams }: PageProps) { + const { orgId, userId } = await searchParams; + + let organizations: Organization[] = []; + let orgFetchError: string | null = null; + try { + organizations = await fetchOrganizations(); + } catch (err) { + orgFetchError = + err instanceof Error ? err.message : "Failed to load organizations"; + } + + return ( +
+
+

Users

+

+ Assign users to one or more departments within an organization. +

+
+ + {orgFetchError ? ( + + ) : ( + + )} + + {orgId ? ( + <> +
+

All users

+ Loading…

}> + +
+
+ + {userId && ( +
+

Department assignment

+ Loading…

}> + +
+
+ )} + + ) : ( +

+ Select an organization above to manage its users. +

+ )} +
+ ); +} + +const pageStyle: CSSProperties = { + maxWidth: "900px", + margin: "0 auto", + padding: "32px 24px", + fontFamily: "system-ui, -apple-system, sans-serif", +}; + +const headerStyle: CSSProperties = { + marginBottom: "32px", + paddingBottom: "20px", + borderBottom: "1px solid #e2e8f0", +}; + +const sectionStyle: CSSProperties = { + marginBottom: "40px", +}; + +const sectionTitleStyle: CSSProperties = { + fontSize: "16px", + fontWeight: 600, + color: "#111827", + marginBottom: "16px", +}; + +const errorBannerStyle: CSSProperties = { + padding: "12px 16px", + background: "#fef2f2", + border: "1px solid #fecaca", + borderRadius: "6px", + color: "#dc2626", + marginBottom: "24px", +}; diff --git a/apps/web/app/admin/users/types.ts b/apps/web/app/admin/users/types.ts new file mode 100644 index 0000000..b55f2c0 --- /dev/null +++ b/apps/web/app/admin/users/types.ts @@ -0,0 +1,18 @@ +export interface AdminUser { + id: string; + email: string; + role: string; + organizationId: string; + createdAt: string; +} + +export interface UserDepartmentAssignment { + departmentId: string; + name: string; + isPrimary: boolean; +} + +export interface UserDepartmentsResponse { + userId: string; + departments: UserDepartmentAssignment[]; +} From b4eb8000ed268cba28e8824a55c99300b9dbe4ba Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:34:53 +0000 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=93=9D=20CodeRabbit=20Chat:=20Generat?= =?UTF-8?q?e=20Unit=20Tests=20for=20PR=20Changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../departments.integration.spec.ts | 43 ++++ .../user-departments.integration.spec.ts | 64 ++++++ .../users/admin-users.integration.spec.ts | 193 ++++++++++++++++++ apps/api/src/auth/user.service.spec.ts | 12 ++ 4 files changed, 312 insertions(+) create mode 100644 apps/api/src/admin/users/admin-users.integration.spec.ts diff --git a/apps/api/src/admin/departments/departments.integration.spec.ts b/apps/api/src/admin/departments/departments.integration.spec.ts index 4c4d246..cf04166 100644 --- a/apps/api/src/admin/departments/departments.integration.spec.ts +++ b/apps/api/src/admin/departments/departments.integration.spec.ts @@ -90,6 +90,13 @@ describe("Admin Departments Integration", () => { .expect(401); }); + it("returns 401 when the token is invalid", async () => { + await request(app.getHttpServer()) + .get("/api/admin/organizations/org-1/departments") + .set("Authorization", "Bearer not.a.jwt") + .expect(401); + }); + it("returns 403 when authenticated user has member role", async () => { const token = issueToken("member"); @@ -98,6 +105,16 @@ describe("Admin Departments Integration", () => { .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/org-1/departments") + .set("Authorization", `Bearer ${token}`) + .send({ name: "Engineering" }) + .expect(403); + }); }); describe("GET /api/admin/organizations/:organizationId/departments", () => { @@ -164,6 +181,32 @@ describe("Admin Departments Integration", () => { .expect(400); }); + it("returns 400 when name is an empty/whitespace-only string", async () => { + const token = issueToken("admin"); + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + + await request(app.getHttpServer()) + .post("/api/admin/organizations/org-1/departments") + .set("Authorization", `Bearer ${token}`) + .send({ name: " " }) + .expect(400); + + expect(mockPrisma.department.create).not.toHaveBeenCalled(); + }); + + it("returns 400 when name is not a string", async () => { + const token = issueToken("admin"); + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + + await request(app.getHttpServer()) + .post("/api/admin/organizations/org-1/departments") + .set("Authorization", `Bearer ${token}`) + .send({ name: 12345 }) + .expect(400); + + expect(mockPrisma.department.create).not.toHaveBeenCalled(); + }); + it("returns 404 when the organization does not exist", async () => { const token = issueToken("admin"); mockPrisma.organization.findUnique.mockResolvedValue(null); diff --git a/apps/api/src/admin/user-departments/user-departments.integration.spec.ts b/apps/api/src/admin/user-departments/user-departments.integration.spec.ts index dd7a03f..524205d 100644 --- a/apps/api/src/admin/user-departments/user-departments.integration.spec.ts +++ b/apps/api/src/admin/user-departments/user-departments.integration.spec.ts @@ -97,6 +97,13 @@ describe("Admin UserDepartments Integration", () => { .expect(401); }); + it("returns 401 when the token is invalid", async () => { + await request(app.getHttpServer()) + .get("/api/admin/users/user-1/departments") + .set("Authorization", "Bearer not.a.jwt") + .expect(401); + }); + it("returns 403 when authenticated user has member role", async () => { const token = issueToken("member"); @@ -202,5 +209,62 @@ describe("Admin UserDepartments Integration", () => { .send({ departmentIds: ["dept-from-other-org"] }) .expect(400); }); + + it("returns 400 when departmentIds is not an array", async () => { + const token = issueToken("admin"); + + await request(app.getHttpServer()) + .put("/api/admin/users/user-1/departments") + .set("Authorization", `Bearer ${token}`) + .send({ departmentIds: "dept-1" }) + .expect(400); + + expect(mockPrisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("returns 400 when primaryDepartmentId is not a string", async () => { + const token = issueToken("admin"); + + await request(app.getHttpServer()) + .put("/api/admin/users/user-1/departments") + .set("Authorization", `Bearer ${token}`) + .send({ departmentIds: ["dept-1"], primaryDepartmentId: 42 }) + .expect(400); + + expect(mockPrisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("returns 400 when primaryDepartmentId is not included in departmentIds", async () => { + const token = issueToken("admin"); + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + mockPrisma.department.findMany.mockResolvedValue([{ id: "dept-1" }]); + + await request(app.getHttpServer()) + .put("/api/admin/users/user-1/departments") + .set("Authorization", `Bearer ${token}`) + .send({ departmentIds: ["dept-1"], primaryDepartmentId: "dept-2" }) + .expect(400); + + expect(mockPrisma.$transaction).not.toHaveBeenCalled(); + }); + + it("deduplicates repeated departmentIds and returns 200", async () => { + const token = issueToken("admin"); + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + mockPrisma.department.findMany.mockResolvedValue([{ id: "dept-1" }]); + mockPrisma.userDepartment.findFirst.mockResolvedValue(null); + mockPrisma.userDepartment.findMany.mockResolvedValue(mockAssignmentRows); + + await request(app.getHttpServer()) + .put("/api/admin/users/user-1/departments") + .set("Authorization", `Bearer ${token}`) + .send({ departmentIds: ["dept-1", "dept-1"] }) + .expect(200); + + expect(mockPrisma.department.findMany).toHaveBeenCalledWith({ + where: { id: { in: ["dept-1"] }, organizationId: "org-1" }, + select: { id: true }, + }); + }); }); }); diff --git a/apps/api/src/admin/users/admin-users.integration.spec.ts b/apps/api/src/admin/users/admin-users.integration.spec.ts new file mode 100644 index 0000000..761ddd0 --- /dev/null +++ b/apps/api/src/admin/users/admin-users.integration.spec.ts @@ -0,0 +1,193 @@ +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-users-integration"; + +class MockGoogleStrategy { + name = "google"; +} + +const now = new Date("2024-06-01T12:00:00Z"); + +const mockOrg = { + id: "org-1", + name: "acme.com", + createdAt: now, + updatedAt: now, +}; + +const mockUser = { + id: "user-1", + email: "alice@acme.com", + googleSub: "google-sub-1", + role: "member", + organizationId: "org-1", + createdAt: now, + updatedAt: now, +}; + +const mockPrisma = { + $connect: jest.fn().mockResolvedValue(undefined), + $disconnect: jest.fn().mockResolvedValue(undefined), + user: { findUnique: jest.fn(), findMany: jest.fn() }, + organization: { findUnique: jest.fn() }, +}; + +function issueToken(role: string, sub = "admin-user"): string { + return jwt.sign( + { sub, email: `${sub}@example.com`, organizationId: "org-1", role }, + TEST_JWT_SECRET, + { expiresIn: "1h" }, + ); +} + +describe("Admin Users 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(); + }); + + describe("authorization", () => { + it("returns 401 when no token is provided", async () => { + await request(app.getHttpServer()) + .get("/api/admin/organizations/org-1/users") + .expect(401); + }); + + it("returns 401 when the token is invalid", async () => { + await request(app.getHttpServer()) + .get("/api/admin/organizations/org-1/users") + .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/org-1/users") + .set("Authorization", `Bearer ${token}`) + .expect(403); + }); + }); + + describe("GET /api/admin/organizations/:organizationId/users", () => { + it("returns 200 with the user list scoped to the organization", async () => { + const token = issueToken("admin"); + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + mockPrisma.user.findMany.mockResolvedValue([mockUser]); + + const res = await request(app.getHttpServer()) + .get("/api/admin/organizations/org-1/users") + .set("Authorization", `Bearer ${token}`) + .expect(200); + + expect(res.body).toHaveLength(1); + expect(res.body[0]).toEqual({ + id: mockUser.id, + email: mockUser.email, + role: mockUser.role, + organizationId: mockUser.organizationId, + createdAt: now.toISOString(), + }); + expect(mockPrisma.user.findMany).toHaveBeenCalledWith({ + where: { organizationId: "org-1" }, + orderBy: { email: "asc" }, + }); + }); + + it("omits internal fields (e.g. googleSub, updatedAt) from the response", async () => { + const token = issueToken("admin"); + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + mockPrisma.user.findMany.mockResolvedValue([mockUser]); + + const res = await request(app.getHttpServer()) + .get("/api/admin/organizations/org-1/users") + .set("Authorization", `Bearer ${token}`) + .expect(200); + + expect(res.body[0]).not.toHaveProperty("googleSub"); + expect(res.body[0]).not.toHaveProperty("updatedAt"); + }); + + it("returns an empty array when the organization has no users", async () => { + const token = issueToken("admin"); + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + mockPrisma.user.findMany.mockResolvedValue([]); + + const res = await request(app.getHttpServer()) + .get("/api/admin/organizations/org-1/users") + .set("Authorization", `Bearer ${token}`) + .expect(200); + + expect(res.body).toEqual([]); + }); + + it("returns 404 when the organization does not exist", async () => { + const token = issueToken("admin"); + mockPrisma.organization.findUnique.mockResolvedValue(null); + + await request(app.getHttpServer()) + .get("/api/admin/organizations/missing-org/users") + .set("Authorization", `Bearer ${token}`) + .expect(404); + + expect(mockPrisma.user.findMany).not.toHaveBeenCalled(); + }); + + it("returns multiple users in the order provided by the service", async () => { + const token = issueToken("admin"); + const secondUser = { + ...mockUser, + id: "user-2", + email: "bob@acme.com", + }; + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + mockPrisma.user.findMany.mockResolvedValue([mockUser, secondUser]); + + const res = await request(app.getHttpServer()) + .get("/api/admin/organizations/org-1/users") + .set("Authorization", `Bearer ${token}`) + .expect(200); + + expect(res.body.map((u: { email: string }) => u.email)).toEqual([ + "alice@acme.com", + "bob@acme.com", + ]); + }); + }); +}); \ No newline at end of file diff --git a/apps/api/src/auth/user.service.spec.ts b/apps/api/src/auth/user.service.spec.ts index 3571d15..c1f1150 100644 --- a/apps/api/src/auth/user.service.spec.ts +++ b/apps/api/src/auth/user.service.spec.ts @@ -241,5 +241,17 @@ describe("UserService", () => { expect(result.primaryDepartmentId).toBeNull(); expect(result.departmentIds).toEqual(["dept-1"]); }); + + it("resolves to the first primary-flagged row if more than one is (unexpectedly) marked primary", async () => { + mockPrisma.userDepartment.findMany.mockResolvedValue([ + { departmentId: "dept-1", isPrimary: true }, + { departmentId: "dept-2", isPrimary: true }, + ]); + + const result = await service.getDepartmentAssignments("user-1"); + + expect(result.primaryDepartmentId).toBe("dept-1"); + expect(result.departmentIds).toEqual(["dept-1", "dept-2"]); + }); }); }); From 201ce295da4c65ac1e799bc7d525bb10bccd175f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 20:44:15 +0000 Subject: [PATCH 3/6] fix(web): address PR review comments on admin users flow - Gate admin routes with session JWT verification via admin layout - Forward API calls with caller session token instead of env service token - Reset AssignDepartmentsForm state when userId changes (key prop) - Validate selected user belongs to org before showing assignment panel Co-authored-by: Andrea Mazzucchelli --- apps/web/app/admin/_lib/admin-auth.ts | 45 +++++++++++++++++++++++++ apps/web/app/admin/departments/api.ts | 22 ++++-------- apps/web/app/admin/layout.tsx | 36 ++++++++++++++++++++ apps/web/app/admin/organizations/api.ts | 29 ++++++---------- apps/web/app/admin/users/api.ts | 24 ++++--------- apps/web/app/admin/users/page.tsx | 12 ++++++- 6 files changed, 116 insertions(+), 52 deletions(-) create mode 100644 apps/web/app/admin/_lib/admin-auth.ts create mode 100644 apps/web/app/admin/layout.tsx diff --git a/apps/web/app/admin/_lib/admin-auth.ts b/apps/web/app/admin/_lib/admin-auth.ts new file mode 100644 index 0000000..a979fe6 --- /dev/null +++ b/apps/web/app/admin/_lib/admin-auth.ts @@ -0,0 +1,45 @@ +import { cookies } from "next/headers"; + +export const ACCESS_TOKEN_COOKIE = "cortex_access_token"; + +const API_URL = process.env["CORTEX_API_URL"] ?? "http://localhost:3001"; + +export class AdminAccessError extends Error { + constructor(message: string) { + super(message); + this.name = "AdminAccessError"; + } +} + +/** + * Verifies the caller has an admin session JWT before privileged API calls. + * Reads the token from the session cookie and validates role via GET /api/me. + */ +export async function requireAdminAccess(): Promise { + const token = (await cookies()).get(ACCESS_TOKEN_COOKIE)?.value; + if (!token) { + throw new AdminAccessError("Sign in required to access admin pages."); + } + + const res = await fetch(`${API_URL}/api/me`, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + signal: AbortSignal.timeout(5000), + }); + + if (!res.ok) { + throw new AdminAccessError("Invalid or expired session."); + } + + const user = (await res.json()) as { role: string }; + if (user.role !== "admin") { + throw new AdminAccessError("Admin role required."); + } + + return token; +} + +export async function adminAuthHeaders(): Promise { + const token = await requireAdminAccess(); + return { Authorization: `Bearer ${token}` }; +} diff --git a/apps/web/app/admin/departments/api.ts b/apps/web/app/admin/departments/api.ts index b2ebdcc..fedf890 100644 --- a/apps/web/app/admin/departments/api.ts +++ b/apps/web/app/admin/departments/api.ts @@ -1,21 +1,8 @@ +import { adminAuthHeaders } from "../_lib/admin-auth"; import type { Department } 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 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"] ?? ""; -} - -function authHeaders(): HeadersInit { - const token = getAdminToken(); - 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 { @@ -31,7 +18,7 @@ export async function fetchDepartments( const res = await fetch( `${API_URL}/api/admin/organizations/${encodeURIComponent(organizationId)}/departments`, { - headers: authHeaders(), + headers: await adminAuthHeaders(), cache: "no-store", signal: AbortSignal.timeout(5000), }, @@ -52,7 +39,10 @@ export async function createDepartment( `${API_URL}/api/admin/organizations/${encodeURIComponent(organizationId)}/departments`, { method: "POST", - headers: { ...authHeaders(), "Content-Type": "application/json" }, + headers: { + ...(await adminAuthHeaders()), + "Content-Type": "application/json", + }, body: JSON.stringify({ name }), signal: AbortSignal.timeout(5000), }, diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx new file mode 100644 index 0000000..f7d7446 --- /dev/null +++ b/apps/web/app/admin/layout.tsx @@ -0,0 +1,36 @@ +import type { CSSProperties, ReactNode } from "react"; +import { AdminAccessError, requireAdminAccess } from "./_lib/admin-auth"; + +export default async function AdminLayout({ + children, +}: { + children: ReactNode; +}) { + try { + await requireAdminAccess(); + } catch (err) { + if (err instanceof AdminAccessError) { + return ( +
+

+ Admin access required +

+

{err.message}

+
+ ); + } + throw err; + } + + return children; +} + +const gateStyle: CSSProperties = { + maxWidth: "480px", + margin: "64px auto", + padding: "24px", + fontFamily: "system-ui, -apple-system, sans-serif", + border: "1px solid #e2e8f0", + borderRadius: "8px", + background: "#f8fafc", +}; diff --git a/apps/web/app/admin/organizations/api.ts b/apps/web/app/admin/organizations/api.ts index 80a37d6..88f315a 100644 --- a/apps/web/app/admin/organizations/api.ts +++ b/apps/web/app/admin/organizations/api.ts @@ -1,21 +1,8 @@ +import { adminAuthHeaders } from "../_lib/admin-auth"; 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 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"] ?? ""; -} - -function authHeaders(): HeadersInit { - const token = getAdminToken(); - 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 { @@ -27,7 +14,7 @@ async function parseJsonSafe(res: Response): Promise { export async function fetchOrganizations(): Promise { const res = await fetch(`${API_URL}/api/admin/organizations`, { - headers: authHeaders(), + headers: await adminAuthHeaders(), cache: "no-store", signal: AbortSignal.timeout(5000), }); @@ -43,7 +30,7 @@ export async function fetchOrganization(id: string): Promise { const res = await fetch( `${API_URL}/api/admin/organizations/${encodeURIComponent(id)}`, { - headers: authHeaders(), + headers: await adminAuthHeaders(), cache: "no-store", signal: AbortSignal.timeout(5000), }, @@ -61,7 +48,10 @@ export async function createOrganization( ): Promise<{ org?: Organization; error?: string }> { const res = await fetch(`${API_URL}/api/admin/organizations`, { method: "POST", - headers: { ...authHeaders(), "Content-Type": "application/json" }, + headers: { + ...(await adminAuthHeaders()), + "Content-Type": "application/json", + }, body: JSON.stringify({ name }), signal: AbortSignal.timeout(5000), }); @@ -82,7 +72,10 @@ export async function updateOrganization( `${API_URL}/api/admin/organizations/${encodeURIComponent(id)}`, { method: "PATCH", - headers: { ...authHeaders(), "Content-Type": "application/json" }, + headers: { + ...(await adminAuthHeaders()), + "Content-Type": "application/json", + }, body: JSON.stringify({ name }), signal: AbortSignal.timeout(5000), }, diff --git a/apps/web/app/admin/users/api.ts b/apps/web/app/admin/users/api.ts index cecf993..595815f 100644 --- a/apps/web/app/admin/users/api.ts +++ b/apps/web/app/admin/users/api.ts @@ -1,21 +1,8 @@ +import { adminAuthHeaders } from "../_lib/admin-auth"; import type { AdminUser, UserDepartmentsResponse } 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 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"] ?? ""; -} - -function authHeaders(): HeadersInit { - const token = getAdminToken(); - 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 { @@ -29,7 +16,7 @@ export async function fetchUsers(organizationId: string): Promise { const res = await fetch( `${API_URL}/api/admin/organizations/${encodeURIComponent(organizationId)}/users`, { - headers: authHeaders(), + headers: await adminAuthHeaders(), cache: "no-store", signal: AbortSignal.timeout(5000), }, @@ -48,7 +35,7 @@ export async function fetchUserDepartments( const res = await fetch( `${API_URL}/api/admin/users/${encodeURIComponent(userId)}/departments`, { - headers: authHeaders(), + headers: await adminAuthHeaders(), cache: "no-store", signal: AbortSignal.timeout(5000), }, @@ -70,7 +57,10 @@ export async function assignUserDepartments( `${API_URL}/api/admin/users/${encodeURIComponent(userId)}/departments`, { method: "PUT", - headers: { ...authHeaders(), "Content-Type": "application/json" }, + headers: { + ...(await adminAuthHeaders()), + "Content-Type": "application/json", + }, body: JSON.stringify({ departmentIds, primaryDepartmentId }), signal: AbortSignal.timeout(5000), }, diff --git a/apps/web/app/admin/users/page.tsx b/apps/web/app/admin/users/page.tsx index a97bf6b..34c4b03 100644 --- a/apps/web/app/admin/users/page.tsx +++ b/apps/web/app/admin/users/page.tsx @@ -62,10 +62,19 @@ async function AssignmentPanel({ let fetchError: string | null = null; try { - const [depts, assignment] = await Promise.all([ + const [depts, users, assignment] = await Promise.all([ fetchDepartments(organizationId), + fetchUsers(organizationId), fetchUserDepartments(userId), ]); + + const userInOrg = users.some((u) => u.id === userId); + if (!userInOrg) { + return ( + + ); + } + departments = depts; currentAssignments = assignment.departments; } catch (err) { @@ -81,6 +90,7 @@ async function AssignmentPanel({ return ( Date: Mon, 3 Aug 2026 20:54:39 +0000 Subject: [PATCH 4/6] fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 2 unresolved review comments. Co-authored-by: CodeRabbit --- apps/web/app/admin/_lib/admin-auth.ts | 44 ++++++++++++++++----------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/apps/web/app/admin/_lib/admin-auth.ts b/apps/web/app/admin/_lib/admin-auth.ts index a979fe6..5d946de 100644 --- a/apps/web/app/admin/_lib/admin-auth.ts +++ b/apps/web/app/admin/_lib/admin-auth.ts @@ -1,3 +1,4 @@ +import { cache } from "react"; import { cookies } from "next/headers"; export const ACCESS_TOKEN_COOKIE = "cortex_access_token"; @@ -15,29 +16,38 @@ export class AdminAccessError extends Error { * Verifies the caller has an admin session JWT before privileged API calls. * Reads the token from the session cookie and validates role via GET /api/me. */ -export async function requireAdminAccess(): Promise { +export const requireAdminAccess = cache(async (): Promise => { const token = (await cookies()).get(ACCESS_TOKEN_COOKIE)?.value; if (!token) { throw new AdminAccessError("Sign in required to access admin pages."); } - const res = await fetch(`${API_URL}/api/me`, { - headers: { Authorization: `Bearer ${token}` }, - cache: "no-store", - signal: AbortSignal.timeout(5000), - }); - - if (!res.ok) { - throw new AdminAccessError("Invalid or expired session."); - } - - const user = (await res.json()) as { role: string }; - if (user.role !== "admin") { - throw new AdminAccessError("Admin role required."); + try { + const res = await fetch(`${API_URL}/api/me`, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + signal: AbortSignal.timeout(5000), + }); + + if (!res.ok) { + throw new AdminAccessError("Invalid or expired session."); + } + + const user = (await res.json()) as { role: string }; + if (user.role !== "admin") { + throw new AdminAccessError("Admin role required."); + } + + return token; + } catch (error) { + if (error instanceof AdminAccessError) { + throw error; + } + throw new AdminAccessError( + "Failed to verify session. Please try again or sign in.", + ); } - - return token; -} +}); export async function adminAuthHeaders(): Promise { const token = await requireAdminAccess(); From 71b70ae9e5165b3b988aee4691d08cd263077238 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 21:07:12 +0000 Subject: [PATCH 5/6] fix: address PR review comments on admin auth and validation - Extract shared isPrismaUniqueConstraintError helper - Enforce org-scoped admin access on department/user endpoints - Validate departmentIds elements are non-empty strings - Allow clearing organization selection in OrgPicker - Add integration tests for cross-org and invalid payloads Co-authored-by: Andrea Mazzucchelli --- .../admin/departments/department.service.ts | 10 +--- .../departments/departments.controller.ts | 11 +++++ .../departments.integration.spec.ts | 15 +++++- .../admin/guards/assert-admin-organization.ts | 15 ++++++ .../organizations/organization.service.ts | 9 +--- .../user-department.service.spec.ts | 46 ++++++++++++------- .../user-department.service.ts | 26 +++++++++-- .../user-departments.controller.ts | 26 ++++++++++- .../user-departments.integration.spec.ts | 25 ++++++++++ .../src/admin/users/admin-users.controller.ts | 9 ++++ .../users/admin-users.integration.spec.ts | 13 +++++- apps/api/src/auth/user.service.ts | 13 +----- apps/api/src/common/prisma-errors.ts | 12 +++++ apps/web/app/admin/_components/OrgPicker.tsx | 4 +- 14 files changed, 177 insertions(+), 57 deletions(-) create mode 100644 apps/api/src/admin/guards/assert-admin-organization.ts create mode 100644 apps/api/src/common/prisma-errors.ts diff --git a/apps/api/src/admin/departments/department.service.ts b/apps/api/src/admin/departments/department.service.ts index 1fc02a0..3b23aca 100644 --- a/apps/api/src/admin/departments/department.service.ts +++ b/apps/api/src/admin/departments/department.service.ts @@ -6,15 +6,7 @@ import { import { PrismaService } from "../../prisma/prisma.service"; import type { Department } from "db/client"; import type { CreateDepartmentDto } from "./department.dto"; - -function isPrismaUniqueConstraintError(err: unknown): boolean { - return ( - typeof err === "object" && - err !== null && - "code" in err && - (err as { code: string }).code === "P2002" - ); -} +import { isPrismaUniqueConstraintError } from "../../common/prisma-errors"; @Injectable() export class DepartmentService { diff --git a/apps/api/src/admin/departments/departments.controller.ts b/apps/api/src/admin/departments/departments.controller.ts index 263a98f..9c1a4a7 100644 --- a/apps/api/src/admin/departments/departments.controller.ts +++ b/apps/api/src/admin/departments/departments.controller.ts @@ -4,18 +4,25 @@ import { Post, Param, Body, + Req, UseGuards, HttpCode, HttpStatus, BadRequestException, } from "@nestjs/common"; import { AdminRoleGuard } from "../guards/admin-role.guard"; +import { assertAdminOrganizationAccess } from "../guards/assert-admin-organization"; import { DepartmentService } from "./department.service"; import type { CreateDepartmentDto, DepartmentResponseDto, } from "./department.dto"; import type { Department } from "db/client"; +import type { AuthenticatedUser } from "../../auth/auth.types"; + +interface RequestWithUser { + user: AuthenticatedUser; +} function toResponseDto(dept: Department): DepartmentResponseDto { return { @@ -35,8 +42,10 @@ export class DepartmentsController { @Get() @HttpCode(HttpStatus.OK) async findAll( + @Req() req: RequestWithUser, @Param("organizationId") organizationId: string, ): Promise { + assertAdminOrganizationAccess(req.user.organizationId, organizationId); const departments = await this.departmentService.findAllByOrganization(organizationId); return departments.map(toResponseDto); @@ -45,9 +54,11 @@ export class DepartmentsController { @Post() @HttpCode(HttpStatus.CREATED) async create( + @Req() req: RequestWithUser, @Param("organizationId") organizationId: string, @Body() body: CreateDepartmentDto, ): Promise { + assertAdminOrganizationAccess(req.user.organizationId, organizationId); if (!body.name || typeof body.name !== "string" || !body.name.trim()) { throw new BadRequestException("name is required and must be a string"); } diff --git a/apps/api/src/admin/departments/departments.integration.spec.ts b/apps/api/src/admin/departments/departments.integration.spec.ts index cf04166..fb40cad 100644 --- a/apps/api/src/admin/departments/departments.integration.spec.ts +++ b/apps/api/src/admin/departments/departments.integration.spec.ts @@ -115,6 +115,17 @@ describe("Admin Departments Integration", () => { .send({ name: "Engineering" }) .expect(403); }); + + it("returns 403 when admin requests another organization's departments", async () => { + const token = issueToken("admin"); + + await request(app.getHttpServer()) + .get("/api/admin/organizations/org-2/departments") + .set("Authorization", `Bearer ${token}`) + .expect(403); + + expect(mockPrisma.organization.findUnique).not.toHaveBeenCalled(); + }); }); describe("GET /api/admin/organizations/:organizationId/departments", () => { @@ -145,7 +156,7 @@ describe("Admin Departments Integration", () => { mockPrisma.organization.findUnique.mockResolvedValue(null); await request(app.getHttpServer()) - .get("/api/admin/organizations/missing-org/departments") + .get("/api/admin/organizations/org-1/departments") .set("Authorization", `Bearer ${token}`) .expect(404); }); @@ -212,7 +223,7 @@ describe("Admin Departments Integration", () => { mockPrisma.organization.findUnique.mockResolvedValue(null); await request(app.getHttpServer()) - .post("/api/admin/organizations/missing-org/departments") + .post("/api/admin/organizations/org-1/departments") .set("Authorization", `Bearer ${token}`) .send({ name: "Engineering" }) .expect(404); diff --git a/apps/api/src/admin/guards/assert-admin-organization.ts b/apps/api/src/admin/guards/assert-admin-organization.ts new file mode 100644 index 0000000..3b7952f --- /dev/null +++ b/apps/api/src/admin/guards/assert-admin-organization.ts @@ -0,0 +1,15 @@ +import { ForbiddenException } from "@nestjs/common"; + +/** + * Ensures an admin only accesses resources within their own organization. + */ +export function assertAdminOrganizationAccess( + adminOrganizationId: string, + requestedOrganizationId: string, +): void { + if (adminOrganizationId !== requestedOrganizationId) { + throw new ForbiddenException( + "Admin cannot access resources outside their organization", + ); + } +} diff --git a/apps/api/src/admin/organizations/organization.service.ts b/apps/api/src/admin/organizations/organization.service.ts index 5f79bdf..57d30a8 100644 --- a/apps/api/src/admin/organizations/organization.service.ts +++ b/apps/api/src/admin/organizations/organization.service.ts @@ -19,14 +19,7 @@ import type { UpdateOrganizationDto, } from "./organization.dto"; -function isPrismaUniqueConstraintError(err: unknown): boolean { - return ( - typeof err === "object" && - err !== null && - "code" in err && - (err as { code: string }).code === "P2002" - ); -} +import { isPrismaUniqueConstraintError } from "../../common/prisma-errors"; @Injectable() export class OrganizationService { diff --git a/apps/api/src/admin/user-departments/user-department.service.spec.ts b/apps/api/src/admin/user-departments/user-department.service.spec.ts index 9b4e436..dbddeb2 100644 --- a/apps/api/src/admin/user-departments/user-department.service.spec.ts +++ b/apps/api/src/admin/user-departments/user-department.service.spec.ts @@ -1,8 +1,10 @@ import { Test, type TestingModule } from "@nestjs/testing"; -import { BadRequestException, NotFoundException } from "@nestjs/common"; +import { BadRequestException, ForbiddenException, NotFoundException } from "@nestjs/common"; import { UserDepartmentService } from "./user-department.service"; import { PrismaService } from "../../prisma/prisma.service"; +const adminOrgId = "org-1"; + const mockUser = { id: "user-1", email: "alice@acme.com", @@ -58,7 +60,7 @@ describe("UserDepartmentService", () => { mockPrisma.user.findUnique.mockResolvedValue(mockUser); mockPrisma.userDepartment.findMany.mockResolvedValue(mockAssignmentRows); - const result = await service.findForUser("user-1"); + const result = await service.findForUser("user-1", adminOrgId); expect(result).toEqual(mockAssignmentRows); expect(mockPrisma.userDepartment.findMany).toHaveBeenCalledWith({ @@ -71,11 +73,23 @@ describe("UserDepartmentService", () => { it("throws NotFoundException when the user does not exist", async () => { mockPrisma.user.findUnique.mockResolvedValue(null); - await expect(service.findForUser("missing-user")).rejects.toThrow( + await expect(service.findForUser("missing-user", adminOrgId)).rejects.toThrow( NotFoundException, ); expect(mockPrisma.userDepartment.findMany).not.toHaveBeenCalled(); }); + + it("throws ForbiddenException when the user belongs to another organization", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + ...mockUser, + organizationId: "org-2", + }); + + await expect(service.findForUser("user-1", adminOrgId)).rejects.toThrow( + ForbiddenException, + ); + expect(mockPrisma.userDepartment.findMany).not.toHaveBeenCalled(); + }); }); describe("assign", () => { @@ -93,21 +107,21 @@ describe("UserDepartmentService", () => { mockPrisma.user.findUnique.mockResolvedValue(null); await expect( - service.assign("missing-user", { departmentIds: ["dept-1"] }), + service.assign("missing-user", adminOrgId, { departmentIds: ["dept-1"] }), ).rejects.toThrow(NotFoundException); expect(mockPrisma.$transaction).not.toHaveBeenCalled(); }); it("throws BadRequestException when departmentIds is empty", async () => { await expect( - service.assign("user-1", { departmentIds: [] }), + service.assign("user-1", adminOrgId, { departmentIds: [] }), ).rejects.toThrow(BadRequestException); expect(mockPrisma.$transaction).not.toHaveBeenCalled(); }); it("throws BadRequestException when primaryDepartmentId is not in departmentIds", async () => { await expect( - service.assign("user-1", { + service.assign("user-1", adminOrgId, { departmentIds: ["dept-1"], primaryDepartmentId: "dept-2", }), @@ -119,13 +133,13 @@ describe("UserDepartmentService", () => { mockPrisma.department.findMany.mockResolvedValue([{ id: "dept-1" }]); await expect( - service.assign("user-1", { departmentIds: ["dept-1", "dept-2"] }), + service.assign("user-1", adminOrgId, { departmentIds: ["dept-1", "dept-2"] }), ).rejects.toThrow(BadRequestException); expect(mockPrisma.$transaction).not.toHaveBeenCalled(); }); it("scopes the department existence check to the user's organization", async () => { - await service.assign("user-1", { departmentIds: ["dept-1", "dept-2"] }); + await service.assign("user-1", adminOrgId, { departmentIds: ["dept-1", "dept-2"] }); expect(mockPrisma.department.findMany).toHaveBeenCalledWith({ where: { @@ -139,7 +153,7 @@ describe("UserDepartmentService", () => { it("dedupes repeated departmentIds before validating and writing", async () => { mockPrisma.department.findMany.mockResolvedValue([{ id: "dept-1" }]); - await service.assign("user-1", { + await service.assign("user-1", adminOrgId, { departmentIds: ["dept-1", "dept-1"], }); @@ -150,7 +164,7 @@ describe("UserDepartmentService", () => { }); it("uses the explicit primaryDepartmentId when provided", async () => { - await service.assign("user-1", { + await service.assign("user-1", adminOrgId, { departmentIds: ["dept-1", "dept-2"], primaryDepartmentId: "dept-2", }); @@ -176,7 +190,7 @@ describe("UserDepartmentService", () => { departmentId: "dept-2", }); - await service.assign("user-1", { departmentIds: ["dept-1", "dept-2"] }); + await service.assign("user-1", adminOrgId, { departmentIds: ["dept-1", "dept-2"] }); expect(mockPrisma.userDepartment.upsert).toHaveBeenCalledWith( expect.objectContaining({ @@ -191,7 +205,7 @@ describe("UserDepartmentService", () => { it("defaults primary to the first departmentId when there is no existing primary", async () => { mockPrisma.userDepartment.findFirst.mockResolvedValue(null); - await service.assign("user-1", { departmentIds: ["dept-1", "dept-2"] }); + await service.assign("user-1", adminOrgId, { departmentIds: ["dept-1", "dept-2"] }); expect(mockPrisma.userDepartment.upsert).toHaveBeenCalledWith( expect.objectContaining({ @@ -206,7 +220,7 @@ describe("UserDepartmentService", () => { it("clears existing primary flags before applying the new assignment set", async () => { mockPrisma.department.findMany.mockResolvedValue([{ id: "dept-1" }]); - await service.assign("user-1", { departmentIds: ["dept-1"] }); + await service.assign("user-1", adminOrgId, { departmentIds: ["dept-1"] }); expect(mockPrisma.userDepartment.updateMany).toHaveBeenCalledWith({ where: { userId: "user-1" }, @@ -217,7 +231,7 @@ describe("UserDepartmentService", () => { it("deletes assignments for departments no longer in the set", async () => { mockPrisma.department.findMany.mockResolvedValue([{ id: "dept-1" }]); - await service.assign("user-1", { departmentIds: ["dept-1"] }); + await service.assign("user-1", adminOrgId, { departmentIds: ["dept-1"] }); expect(mockPrisma.userDepartment.deleteMany).toHaveBeenCalledWith({ where: { userId: "user-1", departmentId: { notIn: ["dept-1"] } }, @@ -225,7 +239,7 @@ describe("UserDepartmentService", () => { }); it("runs the update, delete, and upserts inside a single transaction", async () => { - await service.assign("user-1", { departmentIds: ["dept-1", "dept-2"] }); + await service.assign("user-1", adminOrgId, { departmentIds: ["dept-1", "dept-2"] }); expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1); const ops = mockPrisma.$transaction.mock.calls[0]?.[0] as unknown[]; @@ -235,7 +249,7 @@ describe("UserDepartmentService", () => { it("returns the refreshed assignment list after writing", async () => { mockPrisma.department.findMany.mockResolvedValue([{ id: "dept-1" }]); - const result = await service.assign("user-1", { + const result = await service.assign("user-1", adminOrgId, { departmentIds: ["dept-1"], }); diff --git a/apps/api/src/admin/user-departments/user-department.service.ts b/apps/api/src/admin/user-departments/user-department.service.ts index 5767497..c9e9b4a 100644 --- a/apps/api/src/admin/user-departments/user-department.service.ts +++ b/apps/api/src/admin/user-departments/user-department.service.ts @@ -2,6 +2,7 @@ import { Injectable, BadRequestException, NotFoundException, + ForbiddenException, } from "@nestjs/common"; import { PrismaService } from "../../prisma/prisma.service"; import type { User } from "db/client"; @@ -24,8 +25,11 @@ export interface UserDepartmentWithDepartment { export class UserDepartmentService { constructor(private readonly prisma: PrismaService) {} - async findForUser(userId: string): Promise { - await this.ensureUserExists(userId); + async findForUser( + userId: string, + adminOrganizationId: string, + ): Promise { + await this.ensureUserInOrganization(userId, adminOrganizationId); return this.prisma.userDepartment.findMany({ where: { userId }, @@ -52,9 +56,10 @@ export class UserDepartmentService { */ async assign( userId: string, + adminOrganizationId: string, dto: AssignUserDepartmentsDto, ): Promise { - const user = await this.ensureUserExists(userId); + const user = await this.ensureUserInOrganization(userId, adminOrganizationId); const departmentIds = Array.from(new Set(dto.departmentIds)); if (departmentIds.length === 0) { @@ -107,7 +112,7 @@ export class UserDepartmentService { ), ]); - return this.findForUser(userId); + return this.findForUser(userId, adminOrganizationId); } private async resolveDefaultPrimary( @@ -120,6 +125,19 @@ export class UserDepartmentService { return existingPrimary?.departmentId ?? departmentIds[0]; } + private async ensureUserInOrganization( + userId: string, + adminOrganizationId: string, + ): Promise { + const user = await this.ensureUserExists(userId); + if (user.organizationId !== adminOrganizationId) { + throw new ForbiddenException( + "Admin cannot access users outside their organization", + ); + } + return user; + } + private async ensureUserExists(userId: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) { diff --git a/apps/api/src/admin/user-departments/user-departments.controller.ts b/apps/api/src/admin/user-departments/user-departments.controller.ts index e19c805..24c4676 100644 --- a/apps/api/src/admin/user-departments/user-departments.controller.ts +++ b/apps/api/src/admin/user-departments/user-departments.controller.ts @@ -4,6 +4,7 @@ import { Put, Param, Body, + Req, UseGuards, HttpCode, HttpStatus, @@ -16,6 +17,11 @@ import type { AssignUserDepartmentsDto, UserDepartmentsResponseDto, } from "./user-department.dto"; +import type { AuthenticatedUser } from "../../auth/auth.types"; + +interface RequestWithUser { + user: AuthenticatedUser; +} function toResponseDto( userId: string, @@ -39,21 +45,33 @@ export class UserDepartmentsController { @Get() @HttpCode(HttpStatus.OK) async findForUser( + @Req() req: RequestWithUser, @Param("userId") userId: string, ): Promise { - const rows = await this.userDepartmentService.findForUser(userId); + const rows = await this.userDepartmentService.findForUser( + userId, + req.user.organizationId, + ); return toResponseDto(userId, rows); } @Put() @HttpCode(HttpStatus.OK) async assign( + @Req() req: RequestWithUser, @Param("userId") userId: string, @Body() body: AssignUserDepartmentsDto, ): Promise { if (!Array.isArray(body.departmentIds) || body.departmentIds.length === 0) { throw new BadRequestException("departmentIds must be a non-empty array"); } + if ( + body.departmentIds.some((id) => typeof id !== "string" || id.length === 0) + ) { + throw new BadRequestException( + "departmentIds must contain only non-empty strings", + ); + } if ( body.primaryDepartmentId !== undefined && typeof body.primaryDepartmentId !== "string" @@ -61,7 +79,11 @@ export class UserDepartmentsController { throw new BadRequestException("primaryDepartmentId must be a string"); } - const rows = await this.userDepartmentService.assign(userId, body); + const rows = await this.userDepartmentService.assign( + userId, + req.user.organizationId, + body, + ); return toResponseDto(userId, rows); } } diff --git a/apps/api/src/admin/user-departments/user-departments.integration.spec.ts b/apps/api/src/admin/user-departments/user-departments.integration.spec.ts index 524205d..9c5ed61 100644 --- a/apps/api/src/admin/user-departments/user-departments.integration.spec.ts +++ b/apps/api/src/admin/user-departments/user-departments.integration.spec.ts @@ -222,6 +222,31 @@ describe("Admin UserDepartments Integration", () => { expect(mockPrisma.user.findUnique).not.toHaveBeenCalled(); }); + it("returns 400 when departmentIds contains non-string elements", async () => { + const token = issueToken("admin"); + + await request(app.getHttpServer()) + .put("/api/admin/users/user-1/departments") + .set("Authorization", `Bearer ${token}`) + .send({ departmentIds: [123] }) + .expect(400); + + expect(mockPrisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("returns 403 when the target user belongs to another organization", async () => { + const token = issueToken("admin"); + mockPrisma.user.findUnique.mockResolvedValue({ + ...mockUser, + organizationId: "org-2", + }); + + await request(app.getHttpServer()) + .get("/api/admin/users/user-1/departments") + .set("Authorization", `Bearer ${token}`) + .expect(403); + }); + it("returns 400 when primaryDepartmentId is not a string", async () => { const token = issueToken("admin"); diff --git a/apps/api/src/admin/users/admin-users.controller.ts b/apps/api/src/admin/users/admin-users.controller.ts index 30df1b9..1907a4a 100644 --- a/apps/api/src/admin/users/admin-users.controller.ts +++ b/apps/api/src/admin/users/admin-users.controller.ts @@ -2,14 +2,21 @@ import { Controller, Get, Param, + Req, UseGuards, HttpCode, HttpStatus, } from "@nestjs/common"; import { AdminRoleGuard } from "../guards/admin-role.guard"; +import { assertAdminOrganizationAccess } from "../guards/assert-admin-organization"; import { AdminUserService } from "./admin-user.service"; import type { AdminUserResponseDto } from "./user.dto"; import type { User } from "db/client"; +import type { AuthenticatedUser } from "../../auth/auth.types"; + +interface RequestWithUser { + user: AuthenticatedUser; +} function toResponseDto(user: User): AdminUserResponseDto { return { @@ -29,8 +36,10 @@ export class AdminUsersController { @Get() @HttpCode(HttpStatus.OK) async findAll( + @Req() req: RequestWithUser, @Param("organizationId") organizationId: string, ): Promise { + assertAdminOrganizationAccess(req.user.organizationId, organizationId); const users = await this.adminUserService.findAllByOrganization(organizationId); return users.map(toResponseDto); diff --git a/apps/api/src/admin/users/admin-users.integration.spec.ts b/apps/api/src/admin/users/admin-users.integration.spec.ts index 761ddd0..f2c2aca 100644 --- a/apps/api/src/admin/users/admin-users.integration.spec.ts +++ b/apps/api/src/admin/users/admin-users.integration.spec.ts @@ -103,6 +103,17 @@ describe("Admin Users Integration", () => { .set("Authorization", `Bearer ${token}`) .expect(403); }); + + it("returns 403 when admin requests another organization's users", async () => { + const token = issueToken("admin"); + + await request(app.getHttpServer()) + .get("/api/admin/organizations/org-2/users") + .set("Authorization", `Bearer ${token}`) + .expect(403); + + expect(mockPrisma.organization.findUnique).not.toHaveBeenCalled(); + }); }); describe("GET /api/admin/organizations/:organizationId/users", () => { @@ -162,7 +173,7 @@ describe("Admin Users Integration", () => { mockPrisma.organization.findUnique.mockResolvedValue(null); await request(app.getHttpServer()) - .get("/api/admin/organizations/missing-org/users") + .get("/api/admin/organizations/org-1/users") .set("Authorization", `Bearer ${token}`) .expect(404); diff --git a/apps/api/src/auth/user.service.ts b/apps/api/src/auth/user.service.ts index 21850d4..1150011 100644 --- a/apps/api/src/auth/user.service.ts +++ b/apps/api/src/auth/user.service.ts @@ -16,18 +16,7 @@ export interface DepartmentAssignments { primaryDepartmentId: string | null; } -/** - * Narrows an unknown thrown value to a Prisma unique-constraint error (P2002). - * Avoids importing Prisma runtime types into the CommonJS API workspace. - */ -function isPrismaUniqueConstraintError(err: unknown): boolean { - return ( - typeof err === "object" && - err !== null && - "code" in err && - (err as { code: string }).code === "P2002" - ); -} +import { isPrismaUniqueConstraintError } from "../common/prisma-errors"; @Injectable() export class UserService { diff --git a/apps/api/src/common/prisma-errors.ts b/apps/api/src/common/prisma-errors.ts new file mode 100644 index 0000000..1bfc5df --- /dev/null +++ b/apps/api/src/common/prisma-errors.ts @@ -0,0 +1,12 @@ +/** + * Narrows an unknown thrown value to a Prisma unique-constraint error (P2002). + * Avoids importing Prisma runtime types into the CommonJS API workspace. + */ +export function isPrismaUniqueConstraintError(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code: string }).code === "P2002" + ); +} diff --git a/apps/web/app/admin/_components/OrgPicker.tsx b/apps/web/app/admin/_components/OrgPicker.tsx index 467cf9a..e42229b 100644 --- a/apps/web/app/admin/_components/OrgPicker.tsx +++ b/apps/web/app/admin/_components/OrgPicker.tsx @@ -37,9 +37,7 @@ export function OrgPicker({ }} style={selectStyle} > - + {organizations.map((org) => (