diff --git a/.env.example b/.env.example index 63e4c6b..fade4d8 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,10 @@ GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com" GOOGLE_CLIENT_SECRET="your-google-client-secret" GOOGLE_CALLBACK_URL="http://localhost:4000/auth/google/callback" +# Web app (Next.js admin UI) +CORTEX_WEB_URL="http://localhost:3000" +CORTEX_API_URL="http://localhost:4000" + # JWT # Use a long, random secret in production: `openssl rand -base64 64` JWT_SECRET="changeme-dev-secret" 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..3b23aca --- /dev/null +++ b/apps/api/src/admin/departments/department.service.ts @@ -0,0 +1,60 @@ +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"; +import { isPrismaUniqueConstraintError } from "../../common/prisma-errors"; + +@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..9c1a4a7 --- /dev/null +++ b/apps/api/src/admin/departments/departments.controller.ts @@ -0,0 +1,70 @@ +import { + Controller, + Get, + 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 { + 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( + @Req() req: RequestWithUser, + @Param("organizationId") organizationId: string, + ): Promise { + assertAdminOrganizationAccess(req.user.organizationId, organizationId); + const departments = + await this.departmentService.findAllByOrganization(organizationId); + return departments.map(toResponseDto); + } + + @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"); + } + 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..fb40cad --- /dev/null +++ b/apps/api/src/admin/departments/departments.integration.spec.ts @@ -0,0 +1,263 @@ +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 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"); + + await request(app.getHttpServer()) + .get("/api/admin/organizations/org-1/departments") + .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); + }); + + 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", () => { + 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/org-1/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 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); + + await request(app.getHttpServer()) + .post("/api/admin/organizations/org-1/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/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.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..dbddeb2 --- /dev/null +++ b/apps/api/src/admin/user-departments/user-department.service.spec.ts @@ -0,0 +1,259 @@ +import { Test, type TestingModule } from "@nestjs/testing"; +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", + 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", adminOrgId); + + 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", 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", () => { + 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", 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", 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", adminOrgId, { + 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", 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", adminOrgId, { 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", adminOrgId, { + 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", adminOrgId, { + 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", adminOrgId, { 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", adminOrgId, { 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", adminOrgId, { 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", adminOrgId, { 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", adminOrgId, { 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", adminOrgId, { + 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..c9e9b4a --- /dev/null +++ b/apps/api/src/admin/user-departments/user-department.service.ts @@ -0,0 +1,148 @@ +import { + Injectable, + BadRequestException, + NotFoundException, + ForbiddenException, +} 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, + adminOrganizationId: string, + ): Promise { + await this.ensureUserInOrganization(userId, adminOrganizationId); + + 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, + adminOrganizationId: string, + dto: AssignUserDepartmentsDto, + ): Promise { + const user = await this.ensureUserInOrganization(userId, adminOrganizationId); + + 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, adminOrganizationId); + } + + 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 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) { + 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..24c4676 --- /dev/null +++ b/apps/api/src/admin/user-departments/user-departments.controller.ts @@ -0,0 +1,89 @@ +import { + Controller, + Get, + Put, + Param, + Body, + Req, + 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"; +import type { AuthenticatedUser } from "../../auth/auth.types"; + +interface RequestWithUser { + user: AuthenticatedUser; +} + +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( + @Req() req: RequestWithUser, + @Param("userId") userId: string, + ): Promise { + 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" + ) { + throw new BadRequestException("primaryDepartmentId must be a string"); + } + + 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 new file mode 100644 index 0000000..9c5ed61 --- /dev/null +++ b/apps/api/src/admin/user-departments/user-departments.integration.spec.ts @@ -0,0 +1,295 @@ +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 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"); + + 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); + }); + + 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 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"); + + 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-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..1907a4a --- /dev/null +++ b/apps/api/src/admin/users/admin-users.controller.ts @@ -0,0 +1,47 @@ +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 { + 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( + @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 new file mode 100644 index 0000000..f2c2aca --- /dev/null +++ b/apps/api/src/admin/users/admin-users.integration.spec.ts @@ -0,0 +1,204 @@ +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); + }); + + 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", () => { + 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/org-1/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/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..3554334 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 }; @@ -48,30 +48,51 @@ export class AuthController { @Req() req: RequestWithUser, @Res() res: Response, ): Promise { - const { googleSub, email } = req.user; + const webUrl = process.env["CORTEX_WEB_URL"] ?? "http://localhost:3000"; + const callbackUrl = new URL("/auth/callback", webUrl); - const dbUser = await this.userService.findOrCreate({ googleSub, email }); + try { + const { googleSub, email } = req.user; - const authenticatedUser: AuthenticatedUser = { - id: dbUser.id, - email: dbUser.email, - organizationId: dbUser.organizationId, - role: dbUser.role, - }; + const dbUser = await this.userService.findOrCreate({ googleSub, email }); - const token = this.authService.issueToken(authenticatedUser); + const authenticatedUser: AuthenticatedUser = { + id: dbUser.id, + email: dbUser.email, + organizationId: dbUser.organizationId, + role: dbUser.role, + }; - res.json({ accessToken: token }); + const token = this.authService.issueToken(authenticatedUser); + + callbackUrl.searchParams.set("accessToken", token); + res.redirect(callbackUrl.toString()); + } catch (error) { + const message = + error instanceof Error ? error.message : "Authentication failed"; + callbackUrl.searchParams.set("error", message); + res.redirect(callbackUrl.toString()); + } } } @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..c1f1150 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,55 @@ 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"]); + }); + + 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"]); + }); + }); }); diff --git a/apps/api/src/auth/user.service.ts b/apps/api/src/auth/user.service.ts index 443e40b..1150011 100644 --- a/apps/api/src/auth/user.service.ts +++ b/apps/api/src/auth/user.service.ts @@ -11,19 +11,13 @@ interface UpsertUserInput { email: string; } -/** - * 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" - ); +export interface DepartmentAssignments { + departmentIds: string[]; + primaryDepartmentId: string | null; } +import { isPrismaUniqueConstraintError } from "../common/prisma-errors"; + @Injectable() export class UserService { constructor(private readonly prisma: PrismaService) {} @@ -36,6 +30,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/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 new file mode 100644 index 0000000..e42229b --- /dev/null +++ b/apps/web/app/admin/_components/OrgPicker.tsx @@ -0,0 +1,71 @@ +"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/_lib/admin-auth.ts b/apps/web/app/admin/_lib/admin-auth.ts new file mode 100644 index 0000000..f377f80 --- /dev/null +++ b/apps/web/app/admin/_lib/admin-auth.ts @@ -0,0 +1,72 @@ +import { cache } from "react"; +import { cookies } from "next/headers"; + +export const ACCESS_TOKEN_COOKIE = "cortex_access_token"; + +/** Matches JWT `expiresIn: "8h"` in the API auth module. */ +export const ACCESS_TOKEN_MAX_AGE_SECONDS = 8 * 60 * 60; + +const API_URL = process.env["CORTEX_API_URL"] ?? "http://localhost:3001"; + +export async function setAccessTokenCookie(token: string): Promise { + (await cookies()).set(ACCESS_TOKEN_COOKIE, token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: ACCESS_TOKEN_MAX_AGE_SECONDS, + }); +} + +export async function clearAccessTokenCookie(): Promise { + (await cookies()).delete(ACCESS_TOKEN_COOKIE); +} + +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 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."); + } + + 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.", + ); + } +}); + +export async function adminAuthHeaders(): Promise { + const token = await requireAdminAccess(); + return { Authorization: `Bearer ${token}` }; +} 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..fedf890 --- /dev/null +++ b/apps/web/app/admin/departments/api.ts @@ -0,0 +1,57 @@ +import { adminAuthHeaders } from "../_lib/admin-auth"; +import type { Department } from "./types"; + +const API_URL = process.env["CORTEX_API_URL"] ?? "http://localhost:3001"; + +/** 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: await adminAuthHeaders(), + 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: { + ...(await adminAuthHeaders()), + "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/layout.tsx b/apps/web/app/admin/layout.tsx new file mode 100644 index 0000000..18fb93d --- /dev/null +++ b/apps/web/app/admin/layout.tsx @@ -0,0 +1,47 @@ +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}

+

+ + Sign in with Google + +

+
+ ); + } + 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", +}; + +const linkStyle: CSSProperties = { + color: "#2563eb", + textDecoration: "none", + fontWeight: 500, +}; 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/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/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..595815f --- /dev/null +++ b/apps/web/app/admin/users/api.ts @@ -0,0 +1,75 @@ +import { adminAuthHeaders } from "../_lib/admin-auth"; +import type { AdminUser, UserDepartmentsResponse } from "./types"; + +const API_URL = process.env["CORTEX_API_URL"] ?? "http://localhost:3001"; + +/** 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: await adminAuthHeaders(), + 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: await adminAuthHeaders(), + 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: { + ...(await adminAuthHeaders()), + "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..34c4b03 --- /dev/null +++ b/apps/web/app/admin/users/page.tsx @@ -0,0 +1,194 @@ +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, 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) { + 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[]; +} diff --git a/apps/web/app/auth/callback/route.ts b/apps/web/app/auth/callback/route.ts new file mode 100644 index 0000000..072dd99 --- /dev/null +++ b/apps/web/app/auth/callback/route.ts @@ -0,0 +1,21 @@ +import { redirect } from "next/navigation"; +import { setAccessTokenCookie } from "../../admin/_lib/admin-auth"; + +export async function GET(request: Request): Promise { + const { searchParams } = new URL(request.url); + const accessToken = searchParams.get("accessToken"); + const error = searchParams.get("error"); + + if (error) { + redirect( + `/admin/organizations?auth_error=${encodeURIComponent(error)}`, + ); + } + + if (!accessToken) { + redirect("/auth/login"); + } + + await setAccessTokenCookie(accessToken); + redirect("/admin/organizations"); +} diff --git a/apps/web/app/auth/login/route.ts b/apps/web/app/auth/login/route.ts new file mode 100644 index 0000000..17d44da --- /dev/null +++ b/apps/web/app/auth/login/route.ts @@ -0,0 +1,8 @@ +import { redirect } from "next/navigation"; + +const API_URL = process.env["CORTEX_API_URL"] ?? "http://localhost:3001"; + +/** Starts Google OAuth on the API; callback redirects back to set the session cookie. */ +export function GET(): never { + redirect(`${API_URL}/auth/google`); +} diff --git a/apps/web/app/auth/logout/route.ts b/apps/web/app/auth/logout/route.ts new file mode 100644 index 0000000..b379509 --- /dev/null +++ b/apps/web/app/auth/logout/route.ts @@ -0,0 +1,7 @@ +import { redirect } from "next/navigation"; +import { clearAccessTokenCookie } from "../../admin/_lib/admin-auth"; + +export async function GET(): Promise { + await clearAccessTokenCookie(); + redirect("/admin/organizations"); +} diff --git a/turbo.json b/turbo.json index 46e45cd..1a1085c 100644 --- a/turbo.json +++ b/turbo.json @@ -7,7 +7,9 @@ "GOOGLE_CLIENT_SECRET", "GOOGLE_CALLBACK_URL", "CORTEX_API_URL", - "CORTEX_ADMIN_TOKEN" + "CORTEX_WEB_URL", + "CORTEX_ADMIN_TOKEN", + "NODE_ENV" ], "tasks": { "build": {