Compare commits

...

10 Commits

Author SHA1 Message Date
de6aa9c768 feat(web): add teams API client (in progress)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Hit rate limit mid-flight.
2026-02-28 12:48:30 -06:00
85d3f930f3 chore: update TASKS.md — phases 1-3 complete, CI confirmed green (#565)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-28 18:39:14 +00:00
0e6734bdae feat(api): add team management module with CRUD endpoints (#564)
All checks were successful
ci/woodpecker/push/api Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-28 18:24:09 +00:00
5bcaaeddd9 fix(api): increase flaky test timeouts for CI (#562)
All checks were successful
ci/woodpecker/push/api Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-28 18:20:39 +00:00
676a2a288b Merge pull request 'ci: enable turborepo remote cache for all Node.js pipelines' (#527) from ci/turbo-remote-cache into main
Some checks are pending
ci/woodpecker/push/orchestrator Pipeline is pending
ci/woodpecker/push/coordinator Pipeline is running
ci/woodpecker/push/infra Pipeline is running
ci/woodpecker/push/api Pipeline is running
ci/woodpecker/push/web Pipeline was successful
Reviewed-on: #527
2026-02-28 18:07:05 +00:00
ac16d6ed88 feat(api): add break-glass local authentication module (#559)
Some checks failed
ci/woodpecker/push/orchestrator Pipeline failed
ci/woodpecker/push/api Pipeline failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-28 18:05:19 +00:00
8388d49786 feat(api): add workspace member management endpoints (#556)
Some checks are pending
ci/woodpecker/push/api Pipeline is running
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-28 18:01:05 +00:00
20f914ea85 feat(api): add AdminModule with user and workspace management endpoints (#555)
Some checks failed
ci/woodpecker/push/api Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-28 17:56:54 +00:00
62d9ac0e5a Merge branch 'main' into ci/turbo-remote-cache
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
2026-02-28 17:42:26 +00:00
5ed0a859da ci: enable turborepo remote cache for all Node.js pipelines
Some checks failed
ci/woodpecker/push/api Pipeline failed
ci/woodpecker/push/orchestrator Pipeline failed
ci/woodpecker/push/web Pipeline failed
Connect to self-hosted turbo cache at turbo.mosaicstack.dev.
Convert lint/typecheck/test/build steps to use pnpm turbo with
remote cache env vars, removing manual build-shared steps since
turbo handles the dependency graph automatically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:34:11 -06:00
43 changed files with 3755 additions and 159 deletions

View File

@@ -65,5 +65,16 @@
"completed_at": ""
}
],
"sessions": []
"sessions": [
{
"session_id": "sess-001",
"runtime": "unknown",
"started_at": "2026-02-28T17:48:51Z",
"ended_at": "",
"ended_reason": "",
"milestone_at_end": "",
"tasks_completed": [],
"last_task_id": ""
}
]
}

View File

@@ -0,0 +1,8 @@
{
"session_id": "sess-001",
"runtime": "unknown",
"pid": 2396592,
"started_at": "2026-02-28T17:48:51Z",
"project_path": "/tmp/ms21-api-003",
"milestone_id": ""
}

View File

@@ -52,6 +52,7 @@
"adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"axios": "^1.13.5",
"bcryptjs": "^3.0.3",
"better-auth": "^1.4.17",
"bullmq": "^5.67.2",
"class-transformer": "^0.5.1",
@@ -85,6 +86,7 @@
"@swc/core": "^1.10.18",
"@types/adm-zip": "^0.5.7",
"@types/archiver": "^7.0.0",
"@types/bcryptjs": "^3.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.1",
"@types/highlight.js": "^10.1.0",

View File

@@ -0,0 +1,258 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { AdminController } from "./admin.controller";
import { AdminService } from "./admin.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { AdminGuard } from "../auth/guards/admin.guard";
import { WorkspaceMemberRole } from "@prisma/client";
import type { ExecutionContext } from "@nestjs/common";
describe("AdminController", () => {
let controller: AdminController;
let service: AdminService;
const mockAdminService = {
listUsers: vi.fn(),
inviteUser: vi.fn(),
updateUser: vi.fn(),
deactivateUser: vi.fn(),
createWorkspace: vi.fn(),
updateWorkspace: vi.fn(),
};
const mockAuthGuard = {
canActivate: vi.fn((context: ExecutionContext) => {
const request = context.switchToHttp().getRequest();
request.user = {
id: "550e8400-e29b-41d4-a716-446655440001",
email: "admin@example.com",
name: "Admin User",
};
return true;
}),
};
const mockAdminGuard = {
canActivate: vi.fn(() => true),
};
const mockAdminId = "550e8400-e29b-41d4-a716-446655440001";
const mockUserId = "550e8400-e29b-41d4-a716-446655440002";
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440003";
const mockAdminUser = {
id: mockAdminId,
email: "admin@example.com",
name: "Admin User",
};
const mockUserResponse = {
id: mockUserId,
name: "Test User",
email: "test@example.com",
emailVerified: false,
image: null,
createdAt: new Date("2026-01-01"),
deactivatedAt: null,
isLocalAuth: false,
invitedAt: null,
invitedBy: null,
workspaceMemberships: [],
};
const mockWorkspaceResponse = {
id: mockWorkspaceId,
name: "Test Workspace",
ownerId: mockAdminId,
settings: {},
createdAt: new Date("2026-01-01"),
updatedAt: new Date("2026-01-01"),
memberCount: 1,
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AdminController],
providers: [
{
provide: AdminService,
useValue: mockAdminService,
},
],
})
.overrideGuard(AuthGuard)
.useValue(mockAuthGuard)
.overrideGuard(AdminGuard)
.useValue(mockAdminGuard)
.compile();
controller = module.get<AdminController>(AdminController);
service = module.get<AdminService>(AdminService);
vi.clearAllMocks();
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("listUsers", () => {
it("should return paginated users", async () => {
const paginatedResult = {
data: [mockUserResponse],
meta: { total: 1, page: 1, limit: 50, totalPages: 1 },
};
mockAdminService.listUsers.mockResolvedValue(paginatedResult);
const result = await controller.listUsers({ page: 1, limit: 50 });
expect(result).toEqual(paginatedResult);
expect(service.listUsers).toHaveBeenCalledWith(1, 50);
});
it("should use default pagination", async () => {
const paginatedResult = {
data: [],
meta: { total: 0, page: 1, limit: 50, totalPages: 0 },
};
mockAdminService.listUsers.mockResolvedValue(paginatedResult);
await controller.listUsers({});
expect(service.listUsers).toHaveBeenCalledWith(undefined, undefined);
});
});
describe("inviteUser", () => {
it("should invite a user", async () => {
const inviteDto = { email: "new@example.com" };
const invitationResponse = {
userId: "new-id",
invitationToken: "token",
email: "new@example.com",
invitedAt: new Date(),
};
mockAdminService.inviteUser.mockResolvedValue(invitationResponse);
const result = await controller.inviteUser(inviteDto, mockAdminUser);
expect(result).toEqual(invitationResponse);
expect(service.inviteUser).toHaveBeenCalledWith(inviteDto, mockAdminId);
});
it("should invite a user with workspace and role", async () => {
const inviteDto = {
email: "new@example.com",
workspaceId: mockWorkspaceId,
role: WorkspaceMemberRole.ADMIN,
};
mockAdminService.inviteUser.mockResolvedValue({
userId: "new-id",
invitationToken: "token",
email: "new@example.com",
invitedAt: new Date(),
});
await controller.inviteUser(inviteDto, mockAdminUser);
expect(service.inviteUser).toHaveBeenCalledWith(inviteDto, mockAdminId);
});
});
describe("updateUser", () => {
it("should update a user", async () => {
const updateDto = { name: "Updated Name" };
mockAdminService.updateUser.mockResolvedValue({
...mockUserResponse,
name: "Updated Name",
});
const result = await controller.updateUser(mockUserId, updateDto);
expect(result.name).toBe("Updated Name");
expect(service.updateUser).toHaveBeenCalledWith(mockUserId, updateDto);
});
it("should deactivate a user via update", async () => {
const deactivatedAt = "2026-02-28T00:00:00.000Z";
const updateDto = { deactivatedAt };
mockAdminService.updateUser.mockResolvedValue({
...mockUserResponse,
deactivatedAt: new Date(deactivatedAt),
});
const result = await controller.updateUser(mockUserId, updateDto);
expect(result.deactivatedAt).toEqual(new Date(deactivatedAt));
});
});
describe("deactivateUser", () => {
it("should soft-delete a user", async () => {
mockAdminService.deactivateUser.mockResolvedValue({
...mockUserResponse,
deactivatedAt: new Date(),
});
const result = await controller.deactivateUser(mockUserId);
expect(result.deactivatedAt).toBeDefined();
expect(service.deactivateUser).toHaveBeenCalledWith(mockUserId);
});
});
describe("createWorkspace", () => {
it("should create a workspace", async () => {
const createDto = { name: "New Workspace", ownerId: mockAdminId };
mockAdminService.createWorkspace.mockResolvedValue(mockWorkspaceResponse);
const result = await controller.createWorkspace(createDto);
expect(result).toEqual(mockWorkspaceResponse);
expect(service.createWorkspace).toHaveBeenCalledWith(createDto);
});
it("should create workspace with settings", async () => {
const createDto = {
name: "New Workspace",
ownerId: mockAdminId,
settings: { feature: true },
};
mockAdminService.createWorkspace.mockResolvedValue({
...mockWorkspaceResponse,
settings: { feature: true },
});
const result = await controller.createWorkspace(createDto);
expect(result.settings).toEqual({ feature: true });
});
});
describe("updateWorkspace", () => {
it("should update a workspace", async () => {
const updateDto = { name: "Updated Workspace" };
mockAdminService.updateWorkspace.mockResolvedValue({
...mockWorkspaceResponse,
name: "Updated Workspace",
});
const result = await controller.updateWorkspace(mockWorkspaceId, updateDto);
expect(result.name).toBe("Updated Workspace");
expect(service.updateWorkspace).toHaveBeenCalledWith(mockWorkspaceId, updateDto);
});
it("should update workspace settings", async () => {
const updateDto = { settings: { notifications: false } };
mockAdminService.updateWorkspace.mockResolvedValue({
...mockWorkspaceResponse,
settings: { notifications: false },
});
const result = await controller.updateWorkspace(mockWorkspaceId, updateDto);
expect(result.settings).toEqual({ notifications: false });
});
});
});

View File

@@ -0,0 +1,64 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from "@nestjs/common";
import { AdminService } from "./admin.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { AdminGuard } from "../auth/guards/admin.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
import type { AuthUser } from "@mosaic/shared";
import { InviteUserDto } from "./dto/invite-user.dto";
import { UpdateUserDto } from "./dto/update-user.dto";
import { CreateWorkspaceDto } from "./dto/create-workspace.dto";
import { UpdateWorkspaceDto } from "./dto/update-workspace.dto";
import { QueryUsersDto } from "./dto/query-users.dto";
@Controller("admin")
@UseGuards(AuthGuard, AdminGuard)
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@Get("users")
async listUsers(@Query() query: QueryUsersDto) {
return this.adminService.listUsers(query.page, query.limit);
}
@Post("users/invite")
async inviteUser(@Body() dto: InviteUserDto, @CurrentUser() user: AuthUser) {
return this.adminService.inviteUser(dto, user.id);
}
@Patch("users/:id")
async updateUser(
@Param("id", new ParseUUIDPipe({ version: "4" })) id: string,
@Body() dto: UpdateUserDto
) {
return this.adminService.updateUser(id, dto);
}
@Delete("users/:id")
async deactivateUser(@Param("id", new ParseUUIDPipe({ version: "4" })) id: string) {
return this.adminService.deactivateUser(id);
}
@Post("workspaces")
async createWorkspace(@Body() dto: CreateWorkspaceDto) {
return this.adminService.createWorkspace(dto);
}
@Patch("workspaces/:id")
async updateWorkspace(
@Param("id", new ParseUUIDPipe({ version: "4" })) id: string,
@Body() dto: UpdateWorkspaceDto
) {
return this.adminService.updateWorkspace(id, dto);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { AdminController } from "./admin.controller";
import { AdminService } from "./admin.service";
import { PrismaModule } from "../prisma/prisma.module";
import { AuthModule } from "../auth/auth.module";
@Module({
imports: [PrismaModule, AuthModule],
controllers: [AdminController],
providers: [AdminService],
exports: [AdminService],
})
export class AdminModule {}

View File

@@ -0,0 +1,471 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { AdminService } from "./admin.service";
import { PrismaService } from "../prisma/prisma.service";
import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
import { WorkspaceMemberRole } from "@prisma/client";
describe("AdminService", () => {
let service: AdminService;
const mockPrismaService = {
user: {
findMany: vi.fn(),
findUnique: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
workspace: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
workspaceMember: {
create: vi.fn(),
},
$transaction: vi.fn(),
};
const mockAdminId = "550e8400-e29b-41d4-a716-446655440001";
const mockUserId = "550e8400-e29b-41d4-a716-446655440002";
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440003";
const mockUser = {
id: mockUserId,
name: "Test User",
email: "test@example.com",
emailVerified: false,
image: null,
createdAt: new Date("2026-01-01"),
updatedAt: new Date("2026-01-01"),
deactivatedAt: null,
isLocalAuth: false,
passwordHash: null,
invitedBy: null,
invitationToken: null,
invitedAt: null,
authProviderId: null,
preferences: {},
workspaceMemberships: [
{
workspaceId: mockWorkspaceId,
userId: mockUserId,
role: WorkspaceMemberRole.MEMBER,
joinedAt: new Date("2026-01-01"),
workspace: { id: mockWorkspaceId, name: "Test Workspace" },
},
],
};
const mockWorkspace = {
id: mockWorkspaceId,
name: "Test Workspace",
ownerId: mockAdminId,
settings: {},
createdAt: new Date("2026-01-01"),
updatedAt: new Date("2026-01-01"),
matrixRoomId: null,
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AdminService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
service = module.get<AdminService>(AdminService);
vi.clearAllMocks();
mockPrismaService.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => {
return fn(mockPrismaService);
});
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("listUsers", () => {
it("should return paginated users with memberships", async () => {
mockPrismaService.user.findMany.mockResolvedValue([mockUser]);
mockPrismaService.user.count.mockResolvedValue(1);
const result = await service.listUsers(1, 50);
expect(result.data).toHaveLength(1);
expect(result.data[0]?.id).toBe(mockUserId);
expect(result.data[0]?.workspaceMemberships).toHaveLength(1);
expect(result.meta).toEqual({
total: 1,
page: 1,
limit: 50,
totalPages: 1,
});
});
it("should use default pagination when not provided", async () => {
mockPrismaService.user.findMany.mockResolvedValue([]);
mockPrismaService.user.count.mockResolvedValue(0);
await service.listUsers();
expect(mockPrismaService.user.findMany).toHaveBeenCalledWith(
expect.objectContaining({
skip: 0,
take: 50,
})
);
});
it("should calculate pagination correctly", async () => {
mockPrismaService.user.findMany.mockResolvedValue([]);
mockPrismaService.user.count.mockResolvedValue(150);
const result = await service.listUsers(3, 25);
expect(mockPrismaService.user.findMany).toHaveBeenCalledWith(
expect.objectContaining({
skip: 50,
take: 25,
})
);
expect(result.meta.totalPages).toBe(6);
});
});
describe("inviteUser", () => {
it("should create a user with invitation token", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
const createdUser = {
id: "new-user-id",
email: "new@example.com",
name: "new",
invitationToken: "some-token",
};
mockPrismaService.user.create.mockResolvedValue(createdUser);
const result = await service.inviteUser({ email: "new@example.com" }, mockAdminId);
expect(result.email).toBe("new@example.com");
expect(result.invitationToken).toBeDefined();
expect(result.userId).toBe("new-user-id");
expect(mockPrismaService.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
email: "new@example.com",
invitedBy: mockAdminId,
invitationToken: expect.any(String),
}),
})
);
});
it("should add user to workspace when workspaceId provided", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace);
const createdUser = { id: "new-user-id", email: "new@example.com", name: "new" };
mockPrismaService.user.create.mockResolvedValue(createdUser);
await service.inviteUser(
{
email: "new@example.com",
workspaceId: mockWorkspaceId,
role: WorkspaceMemberRole.ADMIN,
},
mockAdminId
);
expect(mockPrismaService.workspaceMember.create).toHaveBeenCalledWith({
data: {
workspaceId: mockWorkspaceId,
userId: "new-user-id",
role: WorkspaceMemberRole.ADMIN,
},
});
});
it("should throw ConflictException if email already exists", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
await expect(service.inviteUser({ email: "test@example.com" }, mockAdminId)).rejects.toThrow(
ConflictException
);
});
it("should throw NotFoundException if workspace does not exist", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
mockPrismaService.workspace.findUnique.mockResolvedValue(null);
await expect(
service.inviteUser({ email: "new@example.com", workspaceId: "non-existent" }, mockAdminId)
).rejects.toThrow(NotFoundException);
});
it("should use email prefix as default name", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
const createdUser = { id: "new-user-id", email: "jane.doe@example.com", name: "jane.doe" };
mockPrismaService.user.create.mockResolvedValue(createdUser);
await service.inviteUser({ email: "jane.doe@example.com" }, mockAdminId);
expect(mockPrismaService.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
name: "jane.doe",
}),
})
);
});
it("should use provided name when given", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
const createdUser = { id: "new-user-id", email: "j@example.com", name: "Jane Doe" };
mockPrismaService.user.create.mockResolvedValue(createdUser);
await service.inviteUser({ email: "j@example.com", name: "Jane Doe" }, mockAdminId);
expect(mockPrismaService.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
name: "Jane Doe",
}),
})
);
});
});
describe("updateUser", () => {
it("should update user fields", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.update.mockResolvedValue({
...mockUser,
name: "Updated Name",
});
const result = await service.updateUser(mockUserId, { name: "Updated Name" });
expect(result.name).toBe("Updated Name");
expect(mockPrismaService.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: mockUserId },
data: { name: "Updated Name" },
})
);
});
it("should set deactivatedAt when provided", async () => {
const deactivatedAt = "2026-02-28T00:00:00.000Z";
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.update.mockResolvedValue({
...mockUser,
deactivatedAt: new Date(deactivatedAt),
});
const result = await service.updateUser(mockUserId, { deactivatedAt });
expect(result.deactivatedAt).toEqual(new Date(deactivatedAt));
});
it("should clear deactivatedAt when set to null", async () => {
const deactivatedUser = { ...mockUser, deactivatedAt: new Date() };
mockPrismaService.user.findUnique.mockResolvedValue(deactivatedUser);
mockPrismaService.user.update.mockResolvedValue({
...deactivatedUser,
deactivatedAt: null,
});
const result = await service.updateUser(mockUserId, { deactivatedAt: null });
expect(result.deactivatedAt).toBeNull();
});
it("should throw NotFoundException if user does not exist", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
await expect(service.updateUser("non-existent", { name: "Test" })).rejects.toThrow(
NotFoundException
);
});
it("should update emailVerified", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.update.mockResolvedValue({
...mockUser,
emailVerified: true,
});
const result = await service.updateUser(mockUserId, { emailVerified: true });
expect(result.emailVerified).toBe(true);
});
it("should update preferences", async () => {
const prefs = { theme: "dark" };
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.update.mockResolvedValue({
...mockUser,
preferences: prefs,
});
await service.updateUser(mockUserId, { preferences: prefs });
expect(mockPrismaService.user.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ preferences: prefs }),
})
);
});
});
describe("deactivateUser", () => {
it("should set deactivatedAt on the user", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.update.mockResolvedValue({
...mockUser,
deactivatedAt: new Date(),
});
const result = await service.deactivateUser(mockUserId);
expect(result.deactivatedAt).toBeDefined();
expect(mockPrismaService.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: mockUserId },
data: { deactivatedAt: expect.any(Date) },
})
);
});
it("should throw NotFoundException if user does not exist", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
await expect(service.deactivateUser("non-existent")).rejects.toThrow(NotFoundException);
});
it("should throw BadRequestException if user is already deactivated", async () => {
mockPrismaService.user.findUnique.mockResolvedValue({
...mockUser,
deactivatedAt: new Date(),
});
await expect(service.deactivateUser(mockUserId)).rejects.toThrow(BadRequestException);
});
});
describe("createWorkspace", () => {
it("should create a workspace with owner membership", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.workspace.create.mockResolvedValue(mockWorkspace);
const result = await service.createWorkspace({
name: "New Workspace",
ownerId: mockAdminId,
});
expect(result.name).toBe("Test Workspace");
expect(result.memberCount).toBe(1);
expect(mockPrismaService.workspace.create).toHaveBeenCalled();
expect(mockPrismaService.workspaceMember.create).toHaveBeenCalledWith({
data: {
workspaceId: mockWorkspace.id,
userId: mockAdminId,
role: WorkspaceMemberRole.OWNER,
},
});
});
it("should throw NotFoundException if owner does not exist", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
await expect(
service.createWorkspace({ name: "New Workspace", ownerId: "non-existent" })
).rejects.toThrow(NotFoundException);
});
it("should pass settings when provided", async () => {
const settings = { theme: "dark", features: ["chat"] };
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.workspace.create.mockResolvedValue({
...mockWorkspace,
settings,
});
await service.createWorkspace({
name: "New Workspace",
ownerId: mockAdminId,
settings,
});
expect(mockPrismaService.workspace.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ settings }),
})
);
});
});
describe("updateWorkspace", () => {
it("should update workspace name", async () => {
mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace);
mockPrismaService.workspace.update.mockResolvedValue({
...mockWorkspace,
name: "Updated Workspace",
_count: { members: 3 },
});
const result = await service.updateWorkspace(mockWorkspaceId, {
name: "Updated Workspace",
});
expect(result.name).toBe("Updated Workspace");
expect(result.memberCount).toBe(3);
});
it("should update workspace settings", async () => {
const newSettings = { notifications: true };
mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace);
mockPrismaService.workspace.update.mockResolvedValue({
...mockWorkspace,
settings: newSettings,
_count: { members: 1 },
});
const result = await service.updateWorkspace(mockWorkspaceId, {
settings: newSettings,
});
expect(result.settings).toEqual(newSettings);
});
it("should throw NotFoundException if workspace does not exist", async () => {
mockPrismaService.workspace.findUnique.mockResolvedValue(null);
await expect(service.updateWorkspace("non-existent", { name: "Test" })).rejects.toThrow(
NotFoundException
);
});
it("should only update provided fields", async () => {
mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace);
mockPrismaService.workspace.update.mockResolvedValue({
...mockWorkspace,
_count: { members: 1 },
});
await service.updateWorkspace(mockWorkspaceId, { name: "Only Name" });
expect(mockPrismaService.workspace.update).toHaveBeenCalledWith(
expect.objectContaining({
data: { name: "Only Name" },
})
);
});
});
});

View File

@@ -0,0 +1,306 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { Prisma, WorkspaceMemberRole } from "@prisma/client";
import { randomUUID } from "node:crypto";
import { PrismaService } from "../prisma/prisma.service";
import type { InviteUserDto } from "./dto/invite-user.dto";
import type { UpdateUserDto } from "./dto/update-user.dto";
import type { CreateWorkspaceDto } from "./dto/create-workspace.dto";
import type {
AdminUserResponse,
AdminWorkspaceResponse,
InvitationResponse,
PaginatedResponse,
} from "./types/admin.types";
@Injectable()
export class AdminService {
private readonly logger = new Logger(AdminService.name);
constructor(private readonly prisma: PrismaService) {}
async listUsers(page = 1, limit = 50): Promise<PaginatedResponse<AdminUserResponse>> {
const skip = (page - 1) * limit;
const [users, total] = await Promise.all([
this.prisma.user.findMany({
include: {
workspaceMemberships: {
include: {
workspace: { select: { id: true, name: true } },
},
},
},
orderBy: { createdAt: "desc" },
skip,
take: limit,
}),
this.prisma.user.count(),
]);
return {
data: users.map((user) => ({
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified,
image: user.image,
createdAt: user.createdAt,
deactivatedAt: user.deactivatedAt,
isLocalAuth: user.isLocalAuth,
invitedAt: user.invitedAt,
invitedBy: user.invitedBy,
workspaceMemberships: user.workspaceMemberships.map((m) => ({
workspaceId: m.workspaceId,
workspaceName: m.workspace.name,
role: m.role,
joinedAt: m.joinedAt,
})),
})),
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async inviteUser(dto: InviteUserDto, inviterId: string): Promise<InvitationResponse> {
const existing = await this.prisma.user.findUnique({
where: { email: dto.email },
});
if (existing) {
throw new ConflictException(`User with email ${dto.email} already exists`);
}
if (dto.workspaceId) {
const workspace = await this.prisma.workspace.findUnique({
where: { id: dto.workspaceId },
});
if (!workspace) {
throw new NotFoundException(`Workspace ${dto.workspaceId} not found`);
}
}
const invitationToken = randomUUID();
const now = new Date();
const user = await this.prisma.$transaction(async (tx) => {
const created = await tx.user.create({
data: {
email: dto.email,
name: dto.name ?? dto.email.split("@")[0] ?? dto.email,
emailVerified: false,
invitedBy: inviterId,
invitationToken,
invitedAt: now,
},
});
if (dto.workspaceId) {
await tx.workspaceMember.create({
data: {
workspaceId: dto.workspaceId,
userId: created.id,
role: dto.role ?? WorkspaceMemberRole.MEMBER,
},
});
}
return created;
});
this.logger.log(`User invited: ${user.email} by ${inviterId}`);
return {
userId: user.id,
invitationToken,
email: user.email,
invitedAt: now,
};
}
async updateUser(id: string, dto: UpdateUserDto): Promise<AdminUserResponse> {
const existing = await this.prisma.user.findUnique({ where: { id } });
if (!existing) {
throw new NotFoundException(`User ${id} not found`);
}
const data: Prisma.UserUpdateInput = {};
if (dto.name !== undefined) {
data.name = dto.name;
}
if (dto.emailVerified !== undefined) {
data.emailVerified = dto.emailVerified;
}
if (dto.preferences !== undefined) {
data.preferences = dto.preferences as Prisma.InputJsonValue;
}
if (dto.deactivatedAt !== undefined) {
data.deactivatedAt = dto.deactivatedAt ? new Date(dto.deactivatedAt) : null;
}
const user = await this.prisma.user.update({
where: { id },
data,
include: {
workspaceMemberships: {
include: {
workspace: { select: { id: true, name: true } },
},
},
},
});
this.logger.log(`User updated: ${id}`);
return {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified,
image: user.image,
createdAt: user.createdAt,
deactivatedAt: user.deactivatedAt,
isLocalAuth: user.isLocalAuth,
invitedAt: user.invitedAt,
invitedBy: user.invitedBy,
workspaceMemberships: user.workspaceMemberships.map((m) => ({
workspaceId: m.workspaceId,
workspaceName: m.workspace.name,
role: m.role,
joinedAt: m.joinedAt,
})),
};
}
async deactivateUser(id: string): Promise<AdminUserResponse> {
const existing = await this.prisma.user.findUnique({ where: { id } });
if (!existing) {
throw new NotFoundException(`User ${id} not found`);
}
if (existing.deactivatedAt) {
throw new BadRequestException(`User ${id} is already deactivated`);
}
const user = await this.prisma.user.update({
where: { id },
data: { deactivatedAt: new Date() },
include: {
workspaceMemberships: {
include: {
workspace: { select: { id: true, name: true } },
},
},
},
});
this.logger.log(`User deactivated: ${id}`);
return {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified,
image: user.image,
createdAt: user.createdAt,
deactivatedAt: user.deactivatedAt,
isLocalAuth: user.isLocalAuth,
invitedAt: user.invitedAt,
invitedBy: user.invitedBy,
workspaceMemberships: user.workspaceMemberships.map((m) => ({
workspaceId: m.workspaceId,
workspaceName: m.workspace.name,
role: m.role,
joinedAt: m.joinedAt,
})),
};
}
async createWorkspace(dto: CreateWorkspaceDto): Promise<AdminWorkspaceResponse> {
const owner = await this.prisma.user.findUnique({ where: { id: dto.ownerId } });
if (!owner) {
throw new NotFoundException(`User ${dto.ownerId} not found`);
}
const workspace = await this.prisma.$transaction(async (tx) => {
const created = await tx.workspace.create({
data: {
name: dto.name,
ownerId: dto.ownerId,
settings: dto.settings ? (dto.settings as Prisma.InputJsonValue) : {},
},
});
await tx.workspaceMember.create({
data: {
workspaceId: created.id,
userId: dto.ownerId,
role: WorkspaceMemberRole.OWNER,
},
});
return created;
});
this.logger.log(`Workspace created: ${workspace.id} with owner ${dto.ownerId}`);
return {
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
settings: workspace.settings as Record<string, unknown>,
createdAt: workspace.createdAt,
updatedAt: workspace.updatedAt,
memberCount: 1,
};
}
async updateWorkspace(
id: string,
dto: { name?: string; settings?: Record<string, unknown> }
): Promise<AdminWorkspaceResponse> {
const existing = await this.prisma.workspace.findUnique({ where: { id } });
if (!existing) {
throw new NotFoundException(`Workspace ${id} not found`);
}
const data: Prisma.WorkspaceUpdateInput = {};
if (dto.name !== undefined) {
data.name = dto.name;
}
if (dto.settings !== undefined) {
data.settings = dto.settings as Prisma.InputJsonValue;
}
const workspace = await this.prisma.workspace.update({
where: { id },
data,
include: {
_count: { select: { members: true } },
},
});
this.logger.log(`Workspace updated: ${id}`);
return {
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
settings: workspace.settings as Record<string, unknown>,
createdAt: workspace.createdAt,
updatedAt: workspace.updatedAt,
memberCount: workspace._count.members,
};
}
}

View File

@@ -0,0 +1,15 @@
import { IsObject, IsOptional, IsString, IsUUID, MaxLength, MinLength } from "class-validator";
export class CreateWorkspaceDto {
@IsString({ message: "name must be a string" })
@MinLength(1, { message: "name must not be empty" })
@MaxLength(255, { message: "name must not exceed 255 characters" })
name!: string;
@IsUUID("4", { message: "ownerId must be a valid UUID" })
ownerId!: string;
@IsOptional()
@IsObject({ message: "settings must be an object" })
settings?: Record<string, unknown>;
}

View File

@@ -0,0 +1,20 @@
import { WorkspaceMemberRole } from "@prisma/client";
import { IsEmail, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from "class-validator";
export class InviteUserDto {
@IsEmail({}, { message: "email must be a valid email address" })
email!: string;
@IsOptional()
@IsString({ message: "name must be a string" })
@MaxLength(255, { message: "name must not exceed 255 characters" })
name?: string;
@IsOptional()
@IsUUID("4", { message: "workspaceId must be a valid UUID" })
workspaceId?: string;
@IsOptional()
@IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" })
role?: WorkspaceMemberRole;
}

View File

@@ -0,0 +1,15 @@
import { WorkspaceMemberRole } from "@prisma/client";
import { IsEnum, IsUUID } from "class-validator";
export class AddMemberDto {
@IsUUID("4", { message: "userId must be a valid UUID" })
userId!: string;
@IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" })
role!: WorkspaceMemberRole;
}
export class UpdateMemberRoleDto {
@IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" })
role!: WorkspaceMemberRole;
}

View File

@@ -0,0 +1,17 @@
import { IsInt, IsOptional, Max, Min } from "class-validator";
import { Type } from "class-transformer";
export class QueryUsersDto {
@IsOptional()
@Type(() => Number)
@IsInt({ message: "page must be an integer" })
@Min(1, { message: "page must be at least 1" })
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt({ message: "limit must be an integer" })
@Min(1, { message: "limit must be at least 1" })
@Max(100, { message: "limit must not exceed 100" })
limit?: number;
}

View File

@@ -0,0 +1,27 @@
import {
IsBoolean,
IsDateString,
IsObject,
IsOptional,
IsString,
MaxLength,
} from "class-validator";
export class UpdateUserDto {
@IsOptional()
@IsString({ message: "name must be a string" })
@MaxLength(255, { message: "name must not exceed 255 characters" })
name?: string;
@IsOptional()
@IsDateString({}, { message: "deactivatedAt must be a valid ISO 8601 date string" })
deactivatedAt?: string | null;
@IsOptional()
@IsBoolean({ message: "emailVerified must be a boolean" })
emailVerified?: boolean;
@IsOptional()
@IsObject({ message: "preferences must be an object" })
preferences?: Record<string, unknown>;
}

View File

@@ -0,0 +1,13 @@
import { IsObject, IsOptional, IsString, MaxLength, MinLength } from "class-validator";
export class UpdateWorkspaceDto {
@IsOptional()
@IsString({ message: "name must be a string" })
@MinLength(1, { message: "name must not be empty" })
@MaxLength(255, { message: "name must not exceed 255 characters" })
name?: string;
@IsOptional()
@IsObject({ message: "settings must be an object" })
settings?: Record<string, unknown>;
}

View File

@@ -0,0 +1,49 @@
import type { WorkspaceMemberRole } from "@prisma/client";
export interface AdminUserResponse {
id: string;
name: string;
email: string;
emailVerified: boolean;
image: string | null;
createdAt: Date;
deactivatedAt: Date | null;
isLocalAuth: boolean;
invitedAt: Date | null;
invitedBy: string | null;
workspaceMemberships: WorkspaceMembershipResponse[];
}
export interface WorkspaceMembershipResponse {
workspaceId: string;
workspaceName: string;
role: WorkspaceMemberRole;
joinedAt: Date;
}
export interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export interface InvitationResponse {
userId: string;
invitationToken: string;
email: string;
invitedAt: Date;
}
export interface AdminWorkspaceResponse {
id: string;
name: string;
ownerId: string;
settings: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
memberCount: number;
}

View File

@@ -43,6 +43,8 @@ import { DashboardModule } from "./dashboard/dashboard.module";
import { TerminalModule } from "./terminal/terminal.module";
import { PersonalitiesModule } from "./personalities/personalities.module";
import { WorkspacesModule } from "./workspaces/workspaces.module";
import { AdminModule } from "./admin/admin.module";
import { TeamsModule } from "./teams/teams.module";
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
@Module({
@@ -109,6 +111,8 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
TerminalModule,
PersonalitiesModule,
WorkspacesModule,
AdminModule,
TeamsModule,
],
controllers: [AppController, CsrfController],
providers: [

View File

@@ -3,11 +3,14 @@ import { PrismaModule } from "../prisma/prisma.module";
import { AuthService } from "./auth.service";
import { AuthController } from "./auth.controller";
import { AuthGuard } from "./guards/auth.guard";
import { LocalAuthController } from "./local/local-auth.controller";
import { LocalAuthService } from "./local/local-auth.service";
import { LocalAuthEnabledGuard } from "./local/local-auth.guard";
@Module({
imports: [PrismaModule],
controllers: [AuthController],
providers: [AuthService, AuthGuard],
controllers: [AuthController, LocalAuthController],
providers: [AuthService, AuthGuard, LocalAuthService, LocalAuthEnabledGuard],
exports: [AuthService, AuthGuard],
})
export class AuthModule {}

View File

@@ -0,0 +1,10 @@
import { IsEmail, IsString, MinLength } from "class-validator";
export class LocalLoginDto {
@IsEmail({}, { message: "email must be a valid email address" })
email!: string;
@IsString({ message: "password must be a string" })
@MinLength(1, { message: "password must not be empty" })
password!: string;
}

View File

@@ -0,0 +1,20 @@
import { IsEmail, IsString, MinLength, MaxLength } from "class-validator";
export class LocalSetupDto {
@IsEmail({}, { message: "email must be a valid email address" })
email!: string;
@IsString({ message: "name must be a string" })
@MinLength(1, { message: "name must not be empty" })
@MaxLength(255, { message: "name must not exceed 255 characters" })
name!: string;
@IsString({ message: "password must be a string" })
@MinLength(12, { message: "password must be at least 12 characters" })
@MaxLength(128, { message: "password must not exceed 128 characters" })
password!: string;
@IsString({ message: "setupToken must be a string" })
@MinLength(1, { message: "setupToken must not be empty" })
setupToken!: string;
}

View File

@@ -0,0 +1,232 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import {
NotFoundException,
ForbiddenException,
UnauthorizedException,
ConflictException,
} from "@nestjs/common";
import { LocalAuthController } from "./local-auth.controller";
import { LocalAuthService } from "./local-auth.service";
import { LocalAuthEnabledGuard } from "./local-auth.guard";
describe("LocalAuthController", () => {
let controller: LocalAuthController;
let localAuthService: LocalAuthService;
const mockLocalAuthService = {
setup: vi.fn(),
login: vi.fn(),
};
const mockRequest = {
headers: { "user-agent": "TestAgent/1.0" },
ip: "127.0.0.1",
socket: { remoteAddress: "127.0.0.1" },
};
const originalEnv = {
ENABLE_LOCAL_AUTH: process.env.ENABLE_LOCAL_AUTH,
};
beforeEach(async () => {
process.env.ENABLE_LOCAL_AUTH = "true";
const module: TestingModule = await Test.createTestingModule({
controllers: [LocalAuthController],
providers: [
{
provide: LocalAuthService,
useValue: mockLocalAuthService,
},
],
})
.overrideGuard(LocalAuthEnabledGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<LocalAuthController>(LocalAuthController);
localAuthService = module.get<LocalAuthService>(LocalAuthService);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
if (originalEnv.ENABLE_LOCAL_AUTH !== undefined) {
process.env.ENABLE_LOCAL_AUTH = originalEnv.ENABLE_LOCAL_AUTH;
} else {
delete process.env.ENABLE_LOCAL_AUTH;
}
});
describe("setup", () => {
const setupDto = {
email: "admin@example.com",
name: "Break Glass Admin",
password: "securePassword123!",
setupToken: "valid-token-123",
};
const mockSetupResult = {
user: {
id: "user-uuid-123",
email: "admin@example.com",
name: "Break Glass Admin",
isLocalAuth: true,
createdAt: new Date("2026-02-28T00:00:00Z"),
},
session: {
token: "session-token-abc",
expiresAt: new Date("2026-03-07T00:00:00Z"),
},
};
it("should create a break-glass user and return user data with session", async () => {
mockLocalAuthService.setup.mockResolvedValue(mockSetupResult);
const result = await controller.setup(setupDto, mockRequest as never);
expect(result).toEqual({
user: mockSetupResult.user,
session: mockSetupResult.session,
});
expect(mockLocalAuthService.setup).toHaveBeenCalledWith(
"admin@example.com",
"Break Glass Admin",
"securePassword123!",
"valid-token-123",
"127.0.0.1",
"TestAgent/1.0"
);
});
it("should extract client IP from x-forwarded-for header", async () => {
mockLocalAuthService.setup.mockResolvedValue(mockSetupResult);
const reqWithProxy = {
...mockRequest,
headers: {
...mockRequest.headers,
"x-forwarded-for": "203.0.113.50, 70.41.3.18",
},
};
await controller.setup(setupDto, reqWithProxy as never);
expect(mockLocalAuthService.setup).toHaveBeenCalledWith(
expect.any(String) as string,
expect.any(String) as string,
expect.any(String) as string,
expect.any(String) as string,
"203.0.113.50",
"TestAgent/1.0"
);
});
it("should propagate ForbiddenException from service", async () => {
mockLocalAuthService.setup.mockRejectedValue(new ForbiddenException("Invalid setup token"));
await expect(controller.setup(setupDto, mockRequest as never)).rejects.toThrow(
ForbiddenException
);
});
it("should propagate ConflictException from service", async () => {
mockLocalAuthService.setup.mockRejectedValue(
new ConflictException("A user with this email already exists")
);
await expect(controller.setup(setupDto, mockRequest as never)).rejects.toThrow(
ConflictException
);
});
});
describe("login", () => {
const loginDto = {
email: "admin@example.com",
password: "securePassword123!",
};
const mockLoginResult = {
user: {
id: "user-uuid-123",
email: "admin@example.com",
name: "Break Glass Admin",
},
session: {
token: "session-token-abc",
expiresAt: new Date("2026-03-07T00:00:00Z"),
},
};
it("should authenticate and return user data with session", async () => {
mockLocalAuthService.login.mockResolvedValue(mockLoginResult);
const result = await controller.login(loginDto, mockRequest as never);
expect(result).toEqual({
user: mockLoginResult.user,
session: mockLoginResult.session,
});
expect(mockLocalAuthService.login).toHaveBeenCalledWith(
"admin@example.com",
"securePassword123!",
"127.0.0.1",
"TestAgent/1.0"
);
});
it("should propagate UnauthorizedException from service", async () => {
mockLocalAuthService.login.mockRejectedValue(
new UnauthorizedException("Invalid email or password")
);
await expect(controller.login(loginDto, mockRequest as never)).rejects.toThrow(
UnauthorizedException
);
});
});
});
describe("LocalAuthEnabledGuard", () => {
let guard: LocalAuthEnabledGuard;
const originalEnv = process.env.ENABLE_LOCAL_AUTH;
beforeEach(() => {
guard = new LocalAuthEnabledGuard();
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env.ENABLE_LOCAL_AUTH = originalEnv;
} else {
delete process.env.ENABLE_LOCAL_AUTH;
}
});
it("should allow access when ENABLE_LOCAL_AUTH is true", () => {
process.env.ENABLE_LOCAL_AUTH = "true";
expect(guard.canActivate()).toBe(true);
});
it("should throw NotFoundException when ENABLE_LOCAL_AUTH is not set", () => {
delete process.env.ENABLE_LOCAL_AUTH;
expect(() => guard.canActivate()).toThrow(NotFoundException);
});
it("should throw NotFoundException when ENABLE_LOCAL_AUTH is false", () => {
process.env.ENABLE_LOCAL_AUTH = "false";
expect(() => guard.canActivate()).toThrow(NotFoundException);
});
it("should throw NotFoundException when ENABLE_LOCAL_AUTH is empty", () => {
process.env.ENABLE_LOCAL_AUTH = "";
expect(() => guard.canActivate()).toThrow(NotFoundException);
});
});

View File

@@ -0,0 +1,81 @@
import {
Controller,
Post,
Body,
UseGuards,
Req,
Logger,
HttpCode,
HttpStatus,
} from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import type { Request as ExpressRequest } from "express";
import { SkipCsrf } from "../../common/decorators/skip-csrf.decorator";
import { LocalAuthService } from "./local-auth.service";
import { LocalAuthEnabledGuard } from "./local-auth.guard";
import { LocalLoginDto } from "./dto/local-login.dto";
import { LocalSetupDto } from "./dto/local-setup.dto";
@Controller("auth/local")
@UseGuards(LocalAuthEnabledGuard)
export class LocalAuthController {
private readonly logger = new Logger(LocalAuthController.name);
constructor(private readonly localAuthService: LocalAuthService) {}
/**
* First-time break-glass user creation.
* Requires BREAKGLASS_SETUP_TOKEN from environment.
*/
@Post("setup")
@SkipCsrf()
@Throttle({ strict: { limit: 5, ttl: 60000 } })
async setup(@Body() dto: LocalSetupDto, @Req() req: ExpressRequest) {
const ipAddress = this.getClientIp(req);
const userAgent = req.headers["user-agent"];
this.logger.log(`Break-glass setup attempt from ${ipAddress}`);
const result = await this.localAuthService.setup(
dto.email,
dto.name,
dto.password,
dto.setupToken,
ipAddress,
userAgent
);
return {
user: result.user,
session: result.session,
};
}
/**
* Break-glass login with email + password.
*/
@Post("login")
@SkipCsrf()
@HttpCode(HttpStatus.OK)
@Throttle({ strict: { limit: 10, ttl: 60000 } })
async login(@Body() dto: LocalLoginDto, @Req() req: ExpressRequest) {
const ipAddress = this.getClientIp(req);
const userAgent = req.headers["user-agent"];
const result = await this.localAuthService.login(dto.email, dto.password, ipAddress, userAgent);
return {
user: result.user,
session: result.session,
};
}
private getClientIp(req: ExpressRequest): string {
const forwardedFor = req.headers["x-forwarded-for"];
if (forwardedFor) {
const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
return ips?.split(",")[0]?.trim() ?? "unknown";
}
return req.ip ?? req.socket.remoteAddress ?? "unknown";
}
}

View File

@@ -0,0 +1,15 @@
import { Injectable, CanActivate, NotFoundException } from "@nestjs/common";
/**
* Guard that checks if local authentication is enabled via ENABLE_LOCAL_AUTH env var.
* Returns 404 when disabled so endpoints are invisible to callers.
*/
@Injectable()
export class LocalAuthEnabledGuard implements CanActivate {
canActivate(): boolean {
if (process.env.ENABLE_LOCAL_AUTH !== "true") {
throw new NotFoundException();
}
return true;
}
}

View File

@@ -0,0 +1,389 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import {
ConflictException,
ForbiddenException,
InternalServerErrorException,
UnauthorizedException,
} from "@nestjs/common";
import { hash } from "bcryptjs";
import { LocalAuthService } from "./local-auth.service";
import { PrismaService } from "../../prisma/prisma.service";
describe("LocalAuthService", () => {
let service: LocalAuthService;
const mockTxSession = {
create: vi.fn(),
};
const mockTxWorkspace = {
findFirst: vi.fn(),
create: vi.fn(),
};
const mockTxWorkspaceMember = {
create: vi.fn(),
};
const mockTxUser = {
create: vi.fn(),
findUnique: vi.fn(),
};
const mockTx = {
user: mockTxUser,
workspace: mockTxWorkspace,
workspaceMember: mockTxWorkspaceMember,
session: mockTxSession,
};
const mockPrismaService = {
user: {
findUnique: vi.fn(),
},
session: {
create: vi.fn(),
},
$transaction: vi
.fn()
.mockImplementation((fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx)),
};
const originalEnv = {
BREAKGLASS_SETUP_TOKEN: process.env.BREAKGLASS_SETUP_TOKEN,
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
LocalAuthService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
service = module.get<LocalAuthService>(LocalAuthService);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
if (originalEnv.BREAKGLASS_SETUP_TOKEN !== undefined) {
process.env.BREAKGLASS_SETUP_TOKEN = originalEnv.BREAKGLASS_SETUP_TOKEN;
} else {
delete process.env.BREAKGLASS_SETUP_TOKEN;
}
});
describe("setup", () => {
const validSetupArgs = {
email: "admin@example.com",
name: "Break Glass Admin",
password: "securePassword123!",
setupToken: "valid-token-123",
};
const mockCreatedUser = {
id: "user-uuid-123",
email: "admin@example.com",
name: "Break Glass Admin",
isLocalAuth: true,
createdAt: new Date("2026-02-28T00:00:00Z"),
};
const mockWorkspace = {
id: "workspace-uuid-123",
};
beforeEach(() => {
process.env.BREAKGLASS_SETUP_TOKEN = "valid-token-123";
mockPrismaService.user.findUnique.mockResolvedValue(null);
mockTxUser.create.mockResolvedValue(mockCreatedUser);
mockTxWorkspace.findFirst.mockResolvedValue(mockWorkspace);
mockTxWorkspaceMember.create.mockResolvedValue({});
mockTxSession.create.mockResolvedValue({});
});
it("should create a local auth user with hashed password", async () => {
const result = await service.setup(
validSetupArgs.email,
validSetupArgs.name,
validSetupArgs.password,
validSetupArgs.setupToken
);
expect(result.user).toEqual(mockCreatedUser);
expect(result.session.token).toBeDefined();
expect(result.session.token.length).toBeGreaterThan(0);
expect(result.session.expiresAt).toBeInstanceOf(Date);
expect(result.session.expiresAt.getTime()).toBeGreaterThan(Date.now());
expect(mockTxUser.create).toHaveBeenCalledWith({
data: expect.objectContaining({
email: "admin@example.com",
name: "Break Glass Admin",
isLocalAuth: true,
emailVerified: true,
passwordHash: expect.any(String) as string,
}),
select: {
id: true,
email: true,
name: true,
isLocalAuth: true,
createdAt: true,
},
});
});
it("should assign OWNER role on default workspace", async () => {
await service.setup(
validSetupArgs.email,
validSetupArgs.name,
validSetupArgs.password,
validSetupArgs.setupToken
);
expect(mockTxWorkspaceMember.create).toHaveBeenCalledWith({
data: {
workspaceId: "workspace-uuid-123",
userId: "user-uuid-123",
role: "OWNER",
},
});
});
it("should create a new workspace if none exists", async () => {
mockTxWorkspace.findFirst.mockResolvedValue(null);
mockTxWorkspace.create.mockResolvedValue({ id: "new-workspace-uuid" });
await service.setup(
validSetupArgs.email,
validSetupArgs.name,
validSetupArgs.password,
validSetupArgs.setupToken
);
expect(mockTxWorkspace.create).toHaveBeenCalledWith({
data: {
name: "Default Workspace",
ownerId: "user-uuid-123",
settings: {},
},
select: { id: true },
});
expect(mockTxWorkspaceMember.create).toHaveBeenCalledWith({
data: {
workspaceId: "new-workspace-uuid",
userId: "user-uuid-123",
role: "OWNER",
},
});
});
it("should create a BetterAuth-compatible session", async () => {
await service.setup(
validSetupArgs.email,
validSetupArgs.name,
validSetupArgs.password,
validSetupArgs.setupToken,
"192.168.1.1",
"TestAgent/1.0"
);
expect(mockTxSession.create).toHaveBeenCalledWith({
data: {
userId: "user-uuid-123",
token: expect.any(String) as string,
expiresAt: expect.any(Date) as Date,
ipAddress: "192.168.1.1",
userAgent: "TestAgent/1.0",
},
});
});
it("should reject when BREAKGLASS_SETUP_TOKEN is not set", async () => {
delete process.env.BREAKGLASS_SETUP_TOKEN;
await expect(
service.setup(
validSetupArgs.email,
validSetupArgs.name,
validSetupArgs.password,
validSetupArgs.setupToken
)
).rejects.toThrow(ForbiddenException);
});
it("should reject when BREAKGLASS_SETUP_TOKEN is empty", async () => {
process.env.BREAKGLASS_SETUP_TOKEN = "";
await expect(
service.setup(
validSetupArgs.email,
validSetupArgs.name,
validSetupArgs.password,
validSetupArgs.setupToken
)
).rejects.toThrow(ForbiddenException);
});
it("should reject when setup token does not match", async () => {
await expect(
service.setup(
validSetupArgs.email,
validSetupArgs.name,
validSetupArgs.password,
"wrong-token"
)
).rejects.toThrow(ForbiddenException);
});
it("should reject when email already exists", async () => {
mockPrismaService.user.findUnique.mockResolvedValue({
id: "existing-user",
email: "admin@example.com",
});
await expect(
service.setup(
validSetupArgs.email,
validSetupArgs.name,
validSetupArgs.password,
validSetupArgs.setupToken
)
).rejects.toThrow(ConflictException);
});
it("should return session token and expiry", async () => {
const result = await service.setup(
validSetupArgs.email,
validSetupArgs.name,
validSetupArgs.password,
validSetupArgs.setupToken
);
expect(typeof result.session.token).toBe("string");
expect(result.session.token.length).toBe(64); // 32 bytes hex
expect(result.session.expiresAt).toBeInstanceOf(Date);
});
});
describe("login", () => {
const validPasswordHash = "$2a$12$LJ3m4ys3Lz/YgP7xYz5k5uU6b5F6X1234567890abcdefghijkl";
beforeEach(async () => {
// Create a real bcrypt hash for testing
const realHash = await hash("securePassword123!", 4); // Low rounds for test speed
mockPrismaService.user.findUnique.mockResolvedValue({
id: "user-uuid-123",
email: "admin@example.com",
name: "Break Glass Admin",
isLocalAuth: true,
passwordHash: realHash,
deactivatedAt: null,
});
mockPrismaService.session.create.mockResolvedValue({});
});
it("should authenticate a valid local auth user", async () => {
const result = await service.login("admin@example.com", "securePassword123!");
expect(result.user).toEqual({
id: "user-uuid-123",
email: "admin@example.com",
name: "Break Glass Admin",
});
expect(result.session.token).toBeDefined();
expect(result.session.expiresAt).toBeInstanceOf(Date);
});
it("should create a session with ip and user agent", async () => {
await service.login("admin@example.com", "securePassword123!", "10.0.0.1", "Mozilla/5.0");
expect(mockPrismaService.session.create).toHaveBeenCalledWith({
data: {
userId: "user-uuid-123",
token: expect.any(String) as string,
expiresAt: expect.any(Date) as Date,
ipAddress: "10.0.0.1",
userAgent: "Mozilla/5.0",
},
});
});
it("should reject when user does not exist", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
await expect(service.login("nonexistent@example.com", "password123456")).rejects.toThrow(
UnauthorizedException
);
});
it("should reject when user is not a local auth user", async () => {
mockPrismaService.user.findUnique.mockResolvedValue({
id: "user-uuid-123",
email: "admin@example.com",
name: "OIDC User",
isLocalAuth: false,
passwordHash: null,
deactivatedAt: null,
});
await expect(service.login("admin@example.com", "password123456")).rejects.toThrow(
UnauthorizedException
);
});
it("should reject when user is deactivated", async () => {
const realHash = await hash("securePassword123!", 4);
mockPrismaService.user.findUnique.mockResolvedValue({
id: "user-uuid-123",
email: "admin@example.com",
name: "Deactivated User",
isLocalAuth: true,
passwordHash: realHash,
deactivatedAt: new Date("2026-01-01"),
});
await expect(service.login("admin@example.com", "securePassword123!")).rejects.toThrow(
new UnauthorizedException("Account has been deactivated")
);
});
it("should reject when password is incorrect", async () => {
await expect(service.login("admin@example.com", "wrongPassword123!")).rejects.toThrow(
UnauthorizedException
);
});
it("should throw InternalServerError when local auth user has no password hash", async () => {
mockPrismaService.user.findUnique.mockResolvedValue({
id: "user-uuid-123",
email: "admin@example.com",
name: "Broken User",
isLocalAuth: true,
passwordHash: null,
deactivatedAt: null,
});
await expect(service.login("admin@example.com", "securePassword123!")).rejects.toThrow(
InternalServerErrorException
);
});
it("should not reveal whether email exists in error messages", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
try {
await service.login("nonexistent@example.com", "password123456");
} catch (error) {
expect(error).toBeInstanceOf(UnauthorizedException);
expect((error as UnauthorizedException).message).toBe("Invalid email or password");
}
});
});
});

View File

@@ -0,0 +1,230 @@
import {
Injectable,
Logger,
ForbiddenException,
UnauthorizedException,
ConflictException,
InternalServerErrorException,
} from "@nestjs/common";
import { WorkspaceMemberRole } from "@prisma/client";
import { hash, compare } from "bcryptjs";
import { randomBytes, timingSafeEqual } from "crypto";
import { PrismaService } from "../../prisma/prisma.service";
const BCRYPT_ROUNDS = 12;
/** Session expiry: 7 days (matches BetterAuth config in auth.config.ts) */
const SESSION_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
interface SetupResult {
user: {
id: string;
email: string;
name: string;
isLocalAuth: boolean;
createdAt: Date;
};
session: {
token: string;
expiresAt: Date;
};
}
interface LoginResult {
user: {
id: string;
email: string;
name: string;
};
session: {
token: string;
expiresAt: Date;
};
}
@Injectable()
export class LocalAuthService {
private readonly logger = new Logger(LocalAuthService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* First-time break-glass user creation.
* Validates the setup token, creates a local auth user with bcrypt-hashed password,
* and assigns OWNER role on the default workspace.
*/
async setup(
email: string,
name: string,
password: string,
setupToken: string,
ipAddress?: string,
userAgent?: string
): Promise<SetupResult> {
this.validateSetupToken(setupToken);
const existing = await this.prisma.user.findUnique({ where: { email } });
if (existing) {
throw new ConflictException("A user with this email already exists");
}
const passwordHash = await hash(password, BCRYPT_ROUNDS);
const result = await this.prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
email,
name,
isLocalAuth: true,
passwordHash,
emailVerified: true,
},
select: {
id: true,
email: true,
name: true,
isLocalAuth: true,
createdAt: true,
},
});
// Find or create a default workspace and assign OWNER role
await this.assignDefaultWorkspace(tx, user.id);
// Create a BetterAuth-compatible session
const session = await this.createSession(tx, user.id, ipAddress, userAgent);
return { user, session };
});
this.logger.log(`Break-glass user created: ${email}`);
return result;
}
/**
* Break-glass login: verify email + password against bcrypt hash.
* Only works for users with isLocalAuth=true.
*/
async login(
email: string,
password: string,
ipAddress?: string,
userAgent?: string
): Promise<LoginResult> {
const user = await this.prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
name: true,
isLocalAuth: true,
passwordHash: true,
deactivatedAt: true,
},
});
if (!user?.isLocalAuth) {
throw new UnauthorizedException("Invalid email or password");
}
if (user.deactivatedAt) {
throw new UnauthorizedException("Account has been deactivated");
}
if (!user.passwordHash) {
this.logger.error(`Local auth user ${email} has no password hash`);
throw new InternalServerErrorException("Account configuration error");
}
const passwordValid = await compare(password, user.passwordHash);
if (!passwordValid) {
throw new UnauthorizedException("Invalid email or password");
}
const session = await this.createSession(this.prisma, user.id, ipAddress, userAgent);
this.logger.log(`Break-glass login: ${email}`);
return {
user: { id: user.id, email: user.email, name: user.name },
session,
};
}
/**
* Validate the setup token against the environment variable.
*/
private validateSetupToken(token: string): void {
const expectedToken = process.env.BREAKGLASS_SETUP_TOKEN;
if (!expectedToken || expectedToken.trim() === "") {
throw new ForbiddenException(
"Break-glass setup is not configured. Set BREAKGLASS_SETUP_TOKEN environment variable."
);
}
const tokenBuffer = Buffer.from(token);
const expectedBuffer = Buffer.from(expectedToken);
if (
tokenBuffer.length !== expectedBuffer.length ||
!timingSafeEqual(tokenBuffer, expectedBuffer)
) {
this.logger.warn("Invalid break-glass setup token attempt");
throw new ForbiddenException("Invalid setup token");
}
}
/**
* Find the first workspace or create a default one, then assign OWNER role.
*/
private async assignDefaultWorkspace(
tx: Parameters<Parameters<PrismaService["$transaction"]>[0]>[0],
userId: string
): Promise<void> {
let workspace = await tx.workspace.findFirst({
orderBy: { createdAt: "asc" },
select: { id: true },
});
workspace ??= await tx.workspace.create({
data: {
name: "Default Workspace",
ownerId: userId,
settings: {},
},
select: { id: true },
});
await tx.workspaceMember.create({
data: {
workspaceId: workspace.id,
userId,
role: WorkspaceMemberRole.OWNER,
},
});
}
/**
* Create a BetterAuth-compatible session record.
*/
private async createSession(
tx: { session: { create: typeof PrismaService.prototype.session.create } },
userId: string,
ipAddress?: string,
userAgent?: string
): Promise<{ token: string; expiresAt: Date }> {
const token = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
await tx.session.create({
data: {
userId,
token,
expiresAt,
ipAddress: ipAddress ?? null,
userAgent: userAgent ?? null,
},
});
return { token, expiresAt };
}
}

View File

@@ -270,7 +270,7 @@ describe("sanitizeForLogging", () => {
const duration = Date.now() - start;
expect(result.password).toBe("[REDACTED]");
expect(duration).toBeLessThan(100); // Should complete in under 100ms
expect(duration).toBeLessThan(500); // Should complete in under 500ms
});
});

View File

@@ -245,7 +245,7 @@ describe("CoordinatorIntegrationController - Rate Limiting", () => {
.set("X-API-Key", "test-coordinator-key");
expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
});
}, 30000);
});
describe("Per-API-Key Rate Limiting", () => {

View File

@@ -0,0 +1,13 @@
import { IsOptional, IsString, MaxLength, MinLength } from "class-validator";
export class CreateTeamDto {
@IsString({ message: "name must be a string" })
@MinLength(1, { message: "name must not be empty" })
@MaxLength(255, { message: "name must not exceed 255 characters" })
name!: string;
@IsOptional()
@IsString({ message: "description must be a string" })
@MaxLength(10000, { message: "description must not exceed 10000 characters" })
description?: string;
}

View File

@@ -0,0 +1,11 @@
import { TeamMemberRole } from "@prisma/client";
import { IsEnum, IsOptional, IsUUID } from "class-validator";
export class ManageTeamMemberDto {
@IsUUID("4", { message: "userId must be a valid UUID" })
userId!: string;
@IsOptional()
@IsEnum(TeamMemberRole, { message: "role must be a valid TeamMemberRole" })
role?: TeamMemberRole;
}

View File

@@ -0,0 +1,150 @@
import { Test, TestingModule } from "@nestjs/testing";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { TeamMemberRole } from "@prisma/client";
import { AuthGuard } from "../auth/guards/auth.guard";
import { PermissionGuard, WorkspaceGuard } from "../common/guards";
import { TeamsController } from "./teams.controller";
import { TeamsService } from "./teams.service";
describe("TeamsController", () => {
let controller: TeamsController;
let service: TeamsService;
const mockTeamsService = {
create: vi.fn(),
findAll: vi.fn(),
addMember: vi.fn(),
removeMember: vi.fn(),
remove: vi.fn(),
};
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001";
const mockTeamId = "550e8400-e29b-41d4-a716-446655440002";
const mockUserId = "550e8400-e29b-41d4-a716-446655440003";
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [TeamsController],
providers: [
{
provide: TeamsService,
useValue: mockTeamsService,
},
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: vi.fn(() => true) })
.overrideGuard(WorkspaceGuard)
.useValue({ canActivate: vi.fn(() => true) })
.overrideGuard(PermissionGuard)
.useValue({ canActivate: vi.fn(() => true) })
.compile();
controller = module.get<TeamsController>(TeamsController);
service = module.get<TeamsService>(TeamsService);
vi.clearAllMocks();
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("create", () => {
it("should create a team in a workspace", async () => {
const createDto = {
name: "Platform Team",
description: "Owns platform services",
};
const createdTeam = {
id: mockTeamId,
workspaceId: mockWorkspaceId,
name: createDto.name,
description: createDto.description,
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
};
mockTeamsService.create.mockResolvedValue(createdTeam);
const result = await controller.create(createDto, mockWorkspaceId);
expect(result).toEqual(createdTeam);
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto);
});
});
describe("findAll", () => {
it("should list teams in a workspace", async () => {
const teams = [
{
id: mockTeamId,
workspaceId: mockWorkspaceId,
name: "Platform Team",
description: "Owns platform services",
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
_count: { members: 2 },
},
];
mockTeamsService.findAll.mockResolvedValue(teams);
const result = await controller.findAll(mockWorkspaceId);
expect(result).toEqual(teams);
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId);
});
});
describe("addMember", () => {
it("should add a member to a team", async () => {
const dto = {
userId: mockUserId,
role: TeamMemberRole.ADMIN,
};
const createdTeamMember = {
teamId: mockTeamId,
userId: mockUserId,
role: TeamMemberRole.ADMIN,
joinedAt: new Date(),
user: {
id: mockUserId,
name: "Test User",
email: "test@example.com",
},
};
mockTeamsService.addMember.mockResolvedValue(createdTeamMember);
const result = await controller.addMember(mockTeamId, dto, mockWorkspaceId);
expect(result).toEqual(createdTeamMember);
expect(service.addMember).toHaveBeenCalledWith(mockWorkspaceId, mockTeamId, dto);
});
});
describe("removeMember", () => {
it("should remove a member from a team", async () => {
mockTeamsService.removeMember.mockResolvedValue(undefined);
await controller.removeMember(mockTeamId, mockUserId, mockWorkspaceId);
expect(service.removeMember).toHaveBeenCalledWith(mockWorkspaceId, mockTeamId, mockUserId);
});
});
describe("remove", () => {
it("should delete a team", async () => {
mockTeamsService.remove.mockResolvedValue(undefined);
await controller.remove(mockTeamId, mockWorkspaceId);
expect(service.remove).toHaveBeenCalledWith(mockWorkspaceId, mockTeamId);
});
});
});

View File

@@ -0,0 +1,51 @@
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from "@nestjs/common";
import { AuthGuard } from "../auth/guards/auth.guard";
import { PermissionGuard, WorkspaceGuard } from "../common/guards";
import { Permission, RequirePermission, Workspace } from "../common/decorators";
import { CreateTeamDto } from "./dto/create-team.dto";
import { ManageTeamMemberDto } from "./dto/manage-team-member.dto";
import { TeamsService } from "./teams.service";
@Controller("workspaces/:workspaceId/teams")
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
export class TeamsController {
constructor(private readonly teamsService: TeamsService) {}
@Post()
@RequirePermission(Permission.WORKSPACE_ADMIN)
async create(@Body() createTeamDto: CreateTeamDto, @Workspace() workspaceId: string) {
return this.teamsService.create(workspaceId, createTeamDto);
}
@Get()
@RequirePermission(Permission.WORKSPACE_ANY)
async findAll(@Workspace() workspaceId: string) {
return this.teamsService.findAll(workspaceId);
}
@Post(":teamId/members")
@RequirePermission(Permission.WORKSPACE_ADMIN)
async addMember(
@Param("teamId") teamId: string,
@Body() dto: ManageTeamMemberDto,
@Workspace() workspaceId: string
) {
return this.teamsService.addMember(workspaceId, teamId, dto);
}
@Delete(":teamId/members/:userId")
@RequirePermission(Permission.WORKSPACE_ADMIN)
async removeMember(
@Param("teamId") teamId: string,
@Param("userId") userId: string,
@Workspace() workspaceId: string
) {
return this.teamsService.removeMember(workspaceId, teamId, userId);
}
@Delete(":teamId")
@RequirePermission(Permission.WORKSPACE_ADMIN)
async remove(@Param("teamId") teamId: string, @Workspace() workspaceId: string) {
return this.teamsService.remove(workspaceId, teamId);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { PrismaModule } from "../prisma/prisma.module";
import { TeamsController } from "./teams.controller";
import { TeamsService } from "./teams.service";
@Module({
imports: [PrismaModule, AuthModule],
controllers: [TeamsController],
providers: [TeamsService],
exports: [TeamsService],
})
export class TeamsModule {}

View File

@@ -0,0 +1,286 @@
import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { TeamMemberRole } from "@prisma/client";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PrismaService } from "../prisma/prisma.service";
import { TeamsService } from "./teams.service";
describe("TeamsService", () => {
let service: TeamsService;
let prisma: PrismaService;
const mockPrismaService = {
team: {
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
deleteMany: vi.fn(),
},
workspaceMember: {
findUnique: vi.fn(),
},
teamMember: {
findUnique: vi.fn(),
create: vi.fn(),
deleteMany: vi.fn(),
},
};
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001";
const mockTeamId = "550e8400-e29b-41d4-a716-446655440002";
const mockUserId = "550e8400-e29b-41d4-a716-446655440003";
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TeamsService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
service = module.get<TeamsService>(TeamsService);
prisma = module.get<PrismaService>(PrismaService);
vi.clearAllMocks();
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("create", () => {
it("should create a team", async () => {
const createDto = {
name: "Platform Team",
description: "Owns platform services",
};
const createdTeam = {
id: mockTeamId,
workspaceId: mockWorkspaceId,
name: createDto.name,
description: createDto.description,
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
};
mockPrismaService.team.create.mockResolvedValue(createdTeam);
const result = await service.create(mockWorkspaceId, createDto);
expect(result).toEqual(createdTeam);
expect(prisma.team.create).toHaveBeenCalledWith({
data: {
workspaceId: mockWorkspaceId,
name: createDto.name,
description: createDto.description,
},
});
});
});
describe("findAll", () => {
it("should list teams for a workspace", async () => {
const teams = [
{
id: mockTeamId,
workspaceId: mockWorkspaceId,
name: "Platform Team",
description: "Owns platform services",
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
_count: { members: 1 },
},
];
mockPrismaService.team.findMany.mockResolvedValue(teams);
const result = await service.findAll(mockWorkspaceId);
expect(result).toEqual(teams);
expect(prisma.team.findMany).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId },
include: {
_count: {
select: { members: true },
},
},
orderBy: { createdAt: "asc" },
});
});
});
describe("addMember", () => {
it("should add a workspace member to a team", async () => {
const dto = {
userId: mockUserId,
role: TeamMemberRole.ADMIN,
};
const createdTeamMember = {
teamId: mockTeamId,
userId: mockUserId,
role: TeamMemberRole.ADMIN,
joinedAt: new Date(),
user: {
id: mockUserId,
name: "Test User",
email: "test@example.com",
},
};
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({ userId: mockUserId });
mockPrismaService.teamMember.findUnique.mockResolvedValue(null);
mockPrismaService.teamMember.create.mockResolvedValue(createdTeamMember);
const result = await service.addMember(mockWorkspaceId, mockTeamId, dto);
expect(result).toEqual(createdTeamMember);
expect(prisma.team.findFirst).toHaveBeenCalledWith({
where: {
id: mockTeamId,
workspaceId: mockWorkspaceId,
},
select: { id: true },
});
expect(prisma.workspaceMember.findUnique).toHaveBeenCalledWith({
where: {
workspaceId_userId: {
workspaceId: mockWorkspaceId,
userId: mockUserId,
},
},
select: { userId: true },
});
expect(prisma.teamMember.create).toHaveBeenCalledWith({
data: {
teamId: mockTeamId,
userId: mockUserId,
role: TeamMemberRole.ADMIN,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
});
it("should use MEMBER role when role is omitted", async () => {
const dto = { userId: mockUserId };
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({ userId: mockUserId });
mockPrismaService.teamMember.findUnique.mockResolvedValue(null);
mockPrismaService.teamMember.create.mockResolvedValue({
teamId: mockTeamId,
userId: mockUserId,
role: TeamMemberRole.MEMBER,
joinedAt: new Date(),
});
await service.addMember(mockWorkspaceId, mockTeamId, dto);
expect(prisma.teamMember.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
role: TeamMemberRole.MEMBER,
}),
})
);
});
it("should throw when team does not belong to workspace", async () => {
mockPrismaService.team.findFirst.mockResolvedValue(null);
await expect(
service.addMember(mockWorkspaceId, mockTeamId, { userId: mockUserId })
).rejects.toThrow(NotFoundException);
expect(prisma.workspaceMember.findUnique).not.toHaveBeenCalled();
});
it("should throw when user is not a workspace member", async () => {
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
mockPrismaService.workspaceMember.findUnique.mockResolvedValue(null);
await expect(
service.addMember(mockWorkspaceId, mockTeamId, { userId: mockUserId })
).rejects.toThrow(BadRequestException);
});
it("should throw when user is already in the team", async () => {
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({ userId: mockUserId });
mockPrismaService.teamMember.findUnique.mockResolvedValue({ userId: mockUserId });
await expect(
service.addMember(mockWorkspaceId, mockTeamId, { userId: mockUserId })
).rejects.toThrow(ConflictException);
});
});
describe("removeMember", () => {
it("should remove a member from a team", async () => {
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
mockPrismaService.teamMember.deleteMany.mockResolvedValue({ count: 1 });
await service.removeMember(mockWorkspaceId, mockTeamId, mockUserId);
expect(prisma.teamMember.deleteMany).toHaveBeenCalledWith({
where: {
teamId: mockTeamId,
userId: mockUserId,
},
});
});
it("should throw when team does not belong to workspace", async () => {
mockPrismaService.team.findFirst.mockResolvedValue(null);
await expect(service.removeMember(mockWorkspaceId, mockTeamId, mockUserId)).rejects.toThrow(
NotFoundException
);
expect(prisma.teamMember.deleteMany).not.toHaveBeenCalled();
});
it("should throw when user is not in the team", async () => {
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
mockPrismaService.teamMember.deleteMany.mockResolvedValue({ count: 0 });
await expect(service.removeMember(mockWorkspaceId, mockTeamId, mockUserId)).rejects.toThrow(
NotFoundException
);
});
});
describe("remove", () => {
it("should delete a team", async () => {
mockPrismaService.team.deleteMany.mockResolvedValue({ count: 1 });
await service.remove(mockWorkspaceId, mockTeamId);
expect(prisma.team.deleteMany).toHaveBeenCalledWith({
where: {
id: mockTeamId,
workspaceId: mockWorkspaceId,
},
});
});
it("should throw when team is not found", async () => {
mockPrismaService.team.deleteMany.mockResolvedValue({ count: 0 });
await expect(service.remove(mockWorkspaceId, mockTeamId)).rejects.toThrow(NotFoundException);
});
});
});

View File

@@ -0,0 +1,130 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { TeamMemberRole } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { CreateTeamDto } from "./dto/create-team.dto";
import { ManageTeamMemberDto } from "./dto/manage-team-member.dto";
@Injectable()
export class TeamsService {
constructor(private readonly prisma: PrismaService) {}
async create(workspaceId: string, createTeamDto: CreateTeamDto) {
return this.prisma.team.create({
data: {
workspaceId,
name: createTeamDto.name,
description: createTeamDto.description ?? null,
},
});
}
async findAll(workspaceId: string) {
return this.prisma.team.findMany({
where: { workspaceId },
include: {
_count: {
select: { members: true },
},
},
orderBy: { createdAt: "asc" },
});
}
async addMember(workspaceId: string, teamId: string, dto: ManageTeamMemberDto) {
await this.ensureTeamInWorkspace(workspaceId, teamId);
const workspaceMember = await this.prisma.workspaceMember.findUnique({
where: {
workspaceId_userId: {
workspaceId,
userId: dto.userId,
},
},
select: { userId: true },
});
if (!workspaceMember) {
throw new BadRequestException(
`User ${dto.userId} must be a workspace member before being added to a team`
);
}
const existingTeamMember = await this.prisma.teamMember.findUnique({
where: {
teamId_userId: {
teamId,
userId: dto.userId,
},
},
select: { userId: true },
});
if (existingTeamMember) {
throw new ConflictException(`User ${dto.userId} is already a member of team ${teamId}`);
}
return this.prisma.teamMember.create({
data: {
teamId,
userId: dto.userId,
role: dto.role ?? TeamMemberRole.MEMBER,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
}
async removeMember(workspaceId: string, teamId: string, userId: string): Promise<void> {
await this.ensureTeamInWorkspace(workspaceId, teamId);
const result = await this.prisma.teamMember.deleteMany({
where: {
teamId,
userId,
},
});
if (result.count === 0) {
throw new NotFoundException(`User ${userId} is not a member of team ${teamId}`);
}
}
async remove(workspaceId: string, teamId: string): Promise<void> {
const result = await this.prisma.team.deleteMany({
where: {
id: teamId,
workspaceId,
},
});
if (result.count === 0) {
throw new NotFoundException(`Team with ID ${teamId} not found`);
}
}
private async ensureTeamInWorkspace(workspaceId: string, teamId: string): Promise<void> {
const team = await this.prisma.team.findFirst({
where: {
id: teamId,
workspaceId,
},
select: { id: true },
});
if (!team) {
throw new NotFoundException(`Team with ID ${teamId} not found`);
}
}
}

View File

@@ -0,0 +1,13 @@
import { WorkspaceMemberRole } from "@prisma/client";
import { IsEnum, IsUUID } from "class-validator";
/**
* DTO for adding a user to a workspace.
*/
export class AddMemberDto {
@IsUUID("4", { message: "userId must be a valid UUID" })
userId!: string;
@IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" })
role!: WorkspaceMemberRole;
}

View File

@@ -1 +1,3 @@
export { AddMemberDto } from "./add-member.dto";
export { UpdateMemberRoleDto } from "./update-member-role.dto";
export { WorkspaceResponseDto } from "./workspace-response.dto";

View File

@@ -0,0 +1,10 @@
import { WorkspaceMemberRole } from "@prisma/client";
import { IsEnum } from "class-validator";
/**
* DTO for updating a workspace member's role.
*/
export class UpdateMemberRoleDto {
@IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" })
role!: WorkspaceMemberRole;
}

View File

@@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing";
import { WorkspacesController } from "./workspaces.controller";
import { WorkspacesService } from "./workspaces.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { WorkspaceMemberRole } from "@prisma/client";
import type { AuthUser } from "@mosaic/shared";
@@ -12,6 +13,9 @@ describe("WorkspacesController", () => {
const mockWorkspacesService = {
getUserWorkspaces: vi.fn(),
addMember: vi.fn(),
updateMemberRole: vi.fn(),
removeMember: vi.fn(),
};
const mockUser: AuthUser = {
@@ -32,6 +36,10 @@ describe("WorkspacesController", () => {
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(WorkspaceGuard)
.useValue({ canActivate: () => true })
.overrideGuard(PermissionGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<WorkspacesController>(WorkspacesController);
@@ -72,4 +80,70 @@ describe("WorkspacesController", () => {
await expect(controller.getUserWorkspaces(mockUser)).rejects.toThrow("Database error");
});
});
describe("POST /api/workspaces/:id/members", () => {
it("should call service with workspace id, actor id, and add member dto", async () => {
const workspaceId = "ws-1";
const addMemberDto = {
userId: "user-2",
role: WorkspaceMemberRole.MEMBER,
};
const mockMember = {
workspaceId,
userId: "user-2",
role: WorkspaceMemberRole.MEMBER,
joinedAt: new Date("2026-02-01"),
};
mockWorkspacesService.addMember.mockResolvedValueOnce(mockMember);
const result = await controller.addMember(workspaceId, addMemberDto, mockUser);
expect(result).toEqual(mockMember);
expect(service.addMember).toHaveBeenCalledWith(workspaceId, mockUser.id, addMemberDto);
});
});
describe("PATCH /api/workspaces/:id/members/:userId", () => {
it("should call service with workspace id, actor id, target user id, and role dto", async () => {
const workspaceId = "ws-1";
const targetUserId = "user-2";
const updateRoleDto = {
role: WorkspaceMemberRole.ADMIN,
};
const mockMember = {
workspaceId,
userId: targetUserId,
role: WorkspaceMemberRole.ADMIN,
joinedAt: new Date("2026-02-01"),
};
mockWorkspacesService.updateMemberRole.mockResolvedValueOnce(mockMember);
const result = await controller.updateMemberRole(
workspaceId,
targetUserId,
updateRoleDto,
mockUser
);
expect(result).toEqual(mockMember);
expect(service.updateMemberRole).toHaveBeenCalledWith(
workspaceId,
mockUser.id,
targetUserId,
updateRoleDto
);
});
});
describe("DELETE /api/workspaces/:id/members/:userId", () => {
it("should call service with workspace id, actor id, and target user id", async () => {
const workspaceId = "ws-1";
const targetUserId = "user-2";
mockWorkspacesService.removeMember.mockResolvedValueOnce(undefined);
await controller.removeMember(workspaceId, targetUserId, mockUser);
expect(service.removeMember).toHaveBeenCalledWith(workspaceId, mockUser.id, targetUserId);
});
});
});

View File

@@ -1,9 +1,12 @@
import { Controller, Get, UseGuards } from "@nestjs/common";
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
import { WorkspacesService } from "./workspaces.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
import type { AuthUser } from "@mosaic/shared";
import type { WorkspaceResponseDto } from "./dto";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { Permission, RequirePermission } from "../common/decorators";
import type { WorkspaceMember } from "@prisma/client";
import type { AuthenticatedUser } from "../common/types/user.types";
import type { AddMemberDto, UpdateMemberRoleDto, WorkspaceResponseDto } from "./dto";
/**
* User-scoped workspace operations.
@@ -22,7 +25,61 @@ export class WorkspacesController {
* Auto-provisions a default workspace if the user has none.
*/
@Get()
async getUserWorkspaces(@CurrentUser() user: AuthUser): Promise<WorkspaceResponseDto[]> {
async getUserWorkspaces(@CurrentUser() user: AuthenticatedUser): Promise<WorkspaceResponseDto[]> {
return this.workspacesService.getUserWorkspaces(user.id);
}
/**
* POST /api/workspaces/:workspaceId/members
* Add a member to a workspace with the specified role.
* Requires: ADMIN role or higher.
*/
@Post(":workspaceId/members")
@UseGuards(WorkspaceGuard, PermissionGuard)
@RequirePermission(Permission.WORKSPACE_ADMIN)
async addMember(
@Param("workspaceId") workspaceId: string,
@Body() addMemberDto: AddMemberDto,
@CurrentUser() user: AuthenticatedUser
): Promise<WorkspaceMember> {
return this.workspacesService.addMember(workspaceId, user.id, addMemberDto);
}
/**
* PATCH /api/workspaces/:workspaceId/members/:userId
* Change a member role in a workspace.
* Requires: ADMIN role or higher.
*/
@Patch(":workspaceId/members/:userId")
@UseGuards(WorkspaceGuard, PermissionGuard)
@RequirePermission(Permission.WORKSPACE_ADMIN)
async updateMemberRole(
@Param("workspaceId") workspaceId: string,
@Param("userId") targetUserId: string,
@Body() updateMemberRoleDto: UpdateMemberRoleDto,
@CurrentUser() user: AuthenticatedUser
): Promise<WorkspaceMember> {
return this.workspacesService.updateMemberRole(
workspaceId,
user.id,
targetUserId,
updateMemberRoleDto
);
}
/**
* DELETE /api/workspaces/:workspaceId/members/:userId
* Remove a member from a workspace.
* Requires: ADMIN role or higher.
*/
@Delete(":workspaceId/members/:userId")
@UseGuards(WorkspaceGuard, PermissionGuard)
@RequirePermission(Permission.WORKSPACE_ADMIN)
async removeMember(
@Param("workspaceId") workspaceId: string,
@Param("userId") targetUserId: string,
@CurrentUser() user: AuthenticatedUser
): Promise<void> {
await this.workspacesService.removeMember(workspaceId, user.id, targetUserId);
}
}

View File

@@ -3,11 +3,19 @@ import { Test, TestingModule } from "@nestjs/testing";
import { WorkspacesService } from "./workspaces.service";
import { PrismaService } from "../prisma/prisma.service";
import { WorkspaceMemberRole } from "@prisma/client";
import {
BadRequestException,
ConflictException,
ForbiddenException,
NotFoundException,
} from "@nestjs/common";
describe("WorkspacesService", () => {
let service: WorkspacesService;
const mockUserId = "550e8400-e29b-41d4-a716-446655440001";
const mockAdminUserId = "550e8400-e29b-41d4-a716-446655440010";
const mockMemberUserId = "550e8400-e29b-41d4-a716-446655440011";
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440002";
const mockWorkspace = {
@@ -36,11 +44,18 @@ describe("WorkspacesService", () => {
const mockPrismaService = {
workspaceMember: {
findMany: vi.fn(),
findUnique: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
workspace: {
create: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
$transaction: vi.fn(),
};
@@ -58,6 +73,11 @@ describe("WorkspacesService", () => {
service = module.get<WorkspacesService>(WorkspacesService);
vi.clearAllMocks();
mockPrismaService.$transaction.mockImplementation(
async (fn: (tx: typeof mockPrismaService) => Promise<unknown>) =>
fn(mockPrismaService as unknown as typeof mockPrismaService)
);
});
describe("getUserWorkspaces", () => {
@@ -226,4 +246,271 @@ describe("WorkspacesService", () => {
);
});
});
describe("addMember", () => {
const addMemberDto = {
userId: mockMemberUserId,
role: WorkspaceMemberRole.MEMBER,
};
it("should add a new member to the workspace", async () => {
const createdMembership = {
workspaceId: mockWorkspaceId,
userId: mockMemberUserId,
role: WorkspaceMemberRole.MEMBER,
joinedAt: new Date("2026-02-02"),
};
mockPrismaService.user.findUnique.mockResolvedValueOnce({ id: mockMemberUserId });
mockPrismaService.workspaceMember.findUnique
.mockResolvedValueOnce({
workspaceId: mockWorkspaceId,
userId: mockAdminUserId,
role: WorkspaceMemberRole.ADMIN,
joinedAt: new Date("2026-01-01"),
})
.mockResolvedValueOnce(null);
mockPrismaService.workspaceMember.create.mockResolvedValueOnce(createdMembership);
const result = await service.addMember(mockWorkspaceId, mockAdminUserId, addMemberDto);
expect(result).toEqual(createdMembership);
expect(mockPrismaService.workspaceMember.create).toHaveBeenCalledWith({
data: {
workspaceId: mockWorkspaceId,
userId: mockMemberUserId,
role: WorkspaceMemberRole.MEMBER,
},
});
});
it("should throw NotFoundException when user does not exist", async () => {
mockPrismaService.workspaceMember.findUnique.mockResolvedValueOnce({
workspaceId: mockWorkspaceId,
userId: mockAdminUserId,
role: WorkspaceMemberRole.ADMIN,
joinedAt: new Date("2026-01-01"),
});
mockPrismaService.user.findUnique.mockResolvedValueOnce(null);
await expect(
service.addMember(mockWorkspaceId, mockAdminUserId, addMemberDto)
).rejects.toThrow(NotFoundException);
});
it("should throw ConflictException when user is already a member", async () => {
mockPrismaService.workspaceMember.findUnique.mockResolvedValueOnce({
workspaceId: mockWorkspaceId,
userId: mockAdminUserId,
role: WorkspaceMemberRole.ADMIN,
joinedAt: new Date("2026-01-01"),
});
mockPrismaService.user.findUnique.mockResolvedValueOnce({ id: mockMemberUserId });
mockPrismaService.workspaceMember.findUnique.mockResolvedValueOnce({
workspaceId: mockWorkspaceId,
userId: mockMemberUserId,
role: WorkspaceMemberRole.MEMBER,
joinedAt: new Date("2026-01-02"),
});
await expect(
service.addMember(mockWorkspaceId, mockAdminUserId, addMemberDto)
).rejects.toThrow(ConflictException);
});
it("should throw ForbiddenException when admin tries to assign OWNER role", async () => {
mockPrismaService.workspaceMember.findUnique.mockResolvedValueOnce({
workspaceId: mockWorkspaceId,
userId: mockAdminUserId,
role: WorkspaceMemberRole.ADMIN,
joinedAt: new Date("2026-01-01"),
});
await expect(
service.addMember(mockWorkspaceId, mockAdminUserId, {
userId: mockMemberUserId,
role: WorkspaceMemberRole.OWNER,
})
).rejects.toThrow(ForbiddenException);
});
});
describe("updateMemberRole", () => {
it("should update a member role", async () => {
const updatedMembership = {
workspaceId: mockWorkspaceId,
userId: mockMemberUserId,
role: WorkspaceMemberRole.ADMIN,
joinedAt: new Date("2026-01-02"),
};
mockPrismaService.workspaceMember.findUnique
.mockResolvedValueOnce({
workspaceId: mockWorkspaceId,
userId: mockUserId,
role: WorkspaceMemberRole.OWNER,
joinedAt: new Date("2026-01-01"),
})
.mockResolvedValueOnce({
workspaceId: mockWorkspaceId,
userId: mockMemberUserId,
role: WorkspaceMemberRole.MEMBER,
joinedAt: new Date("2026-01-02"),
});
mockPrismaService.workspaceMember.update.mockResolvedValueOnce(updatedMembership);
const result = await service.updateMemberRole(mockWorkspaceId, mockUserId, mockMemberUserId, {
role: WorkspaceMemberRole.ADMIN,
});
expect(result).toEqual(updatedMembership);
expect(mockPrismaService.workspaceMember.update).toHaveBeenCalledWith({
where: {
workspaceId_userId: {
workspaceId: mockWorkspaceId,
userId: mockMemberUserId,
},
},
data: {
role: WorkspaceMemberRole.ADMIN,
},
});
});
it("should throw NotFoundException when target member does not exist", async () => {
mockPrismaService.workspaceMember.findUnique
.mockResolvedValueOnce({
workspaceId: mockWorkspaceId,
userId: mockUserId,
role: WorkspaceMemberRole.OWNER,
joinedAt: new Date("2026-01-01"),
})
.mockResolvedValueOnce(null);
await expect(
service.updateMemberRole(mockWorkspaceId, mockUserId, mockMemberUserId, {
role: WorkspaceMemberRole.ADMIN,
})
).rejects.toThrow(NotFoundException);
});
it("should throw BadRequestException when sole owner attempts self-demotion", async () => {
mockPrismaService.workspaceMember.findUnique
.mockResolvedValueOnce({
workspaceId: mockWorkspaceId,
userId: mockUserId,
role: WorkspaceMemberRole.OWNER,
joinedAt: new Date("2026-01-01"),
})
.mockResolvedValueOnce({
workspaceId: mockWorkspaceId,
userId: mockUserId,
role: WorkspaceMemberRole.OWNER,
joinedAt: new Date("2026-01-01"),
});
mockPrismaService.workspaceMember.count.mockResolvedValueOnce(1);
await expect(
service.updateMemberRole(mockWorkspaceId, mockUserId, mockUserId, {
role: WorkspaceMemberRole.ADMIN,
})
).rejects.toThrow(BadRequestException);
});
it("should throw ForbiddenException when actor tries to change role of higher-ranked member", async () => {
mockPrismaService.workspaceMember.findUnique
.mockResolvedValueOnce({
workspaceId: mockWorkspaceId,
userId: mockAdminUserId,
role: WorkspaceMemberRole.ADMIN,
joinedAt: new Date("2026-01-01"),
})
.mockResolvedValueOnce({
workspaceId: mockWorkspaceId,
userId: mockUserId,
role: WorkspaceMemberRole.OWNER,
joinedAt: new Date("2026-01-01"),
});
await expect(
service.updateMemberRole(mockWorkspaceId, mockAdminUserId, mockUserId, {
role: WorkspaceMemberRole.MEMBER,
})
).rejects.toThrow(ForbiddenException);
});
});
describe("removeMember", () => {
it("should remove a workspace member", async () => {
mockPrismaService.workspaceMember.findUnique
.mockResolvedValueOnce({
workspaceId: mockWorkspaceId,
userId: mockUserId,
role: WorkspaceMemberRole.OWNER,
joinedAt: new Date("2026-01-01"),
})
.mockResolvedValueOnce({
workspaceId: mockWorkspaceId,
userId: mockMemberUserId,
role: WorkspaceMemberRole.MEMBER,
joinedAt: new Date("2026-01-02"),
});
mockPrismaService.workspaceMember.delete.mockResolvedValueOnce({
workspaceId: mockWorkspaceId,
userId: mockMemberUserId,
role: WorkspaceMemberRole.MEMBER,
joinedAt: new Date("2026-01-02"),
});
await service.removeMember(mockWorkspaceId, mockUserId, mockMemberUserId);
expect(mockPrismaService.workspaceMember.delete).toHaveBeenCalledWith({
where: {
workspaceId_userId: {
workspaceId: mockWorkspaceId,
userId: mockMemberUserId,
},
},
});
});
it("should throw BadRequestException when trying to remove the last owner", async () => {
mockPrismaService.workspaceMember.findUnique
.mockResolvedValueOnce({
workspaceId: mockWorkspaceId,
userId: mockUserId,
role: WorkspaceMemberRole.OWNER,
joinedAt: new Date("2026-01-01"),
})
.mockResolvedValueOnce({
workspaceId: mockWorkspaceId,
userId: mockUserId,
role: WorkspaceMemberRole.OWNER,
joinedAt: new Date("2026-01-01"),
});
mockPrismaService.workspaceMember.count.mockResolvedValueOnce(1);
await expect(service.removeMember(mockWorkspaceId, mockUserId, mockUserId)).rejects.toThrow(
BadRequestException
);
});
it("should throw ForbiddenException when admin attempts to remove an owner", async () => {
mockPrismaService.workspaceMember.findUnique
.mockResolvedValueOnce({
workspaceId: mockWorkspaceId,
userId: mockAdminUserId,
role: WorkspaceMemberRole.ADMIN,
joinedAt: new Date("2026-01-01"),
})
.mockResolvedValueOnce({
workspaceId: mockWorkspaceId,
userId: mockUserId,
role: WorkspaceMemberRole.OWNER,
joinedAt: new Date("2026-01-01"),
});
await expect(
service.removeMember(mockWorkspaceId, mockAdminUserId, mockUserId)
).rejects.toThrow(ForbiddenException);
});
});
});

View File

@@ -1,7 +1,22 @@
import { Injectable, Logger } from "@nestjs/common";
import { WorkspaceMemberRole } from "@prisma/client";
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { Prisma, WorkspaceMemberRole } from "@prisma/client";
import type { WorkspaceMember } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import type { WorkspaceResponseDto } from "./dto";
import type { AddMemberDto, UpdateMemberRoleDto, WorkspaceResponseDto } from "./dto";
const WORKSPACE_ROLE_RANK: Record<WorkspaceMemberRole, number> = {
[WorkspaceMemberRole.GUEST]: 1,
[WorkspaceMemberRole.MEMBER]: 2,
[WorkspaceMemberRole.ADMIN]: 3,
[WorkspaceMemberRole.OWNER]: 4,
};
@Injectable()
export class WorkspacesService {
@@ -94,4 +109,237 @@ export class WorkspacesService {
},
];
}
/**
* Add a member to a workspace.
*/
async addMember(
workspaceId: string,
actorUserId: string,
addMemberDto: AddMemberDto
): Promise<WorkspaceMember> {
const actorMembership = await this.prisma.workspaceMember.findUnique({
where: {
workspaceId_userId: {
workspaceId,
userId: actorUserId,
},
},
select: {
role: true,
},
});
if (!actorMembership) {
throw new ForbiddenException("You are not a member of this workspace");
}
this.assertCanAssignRole(actorMembership.role, addMemberDto.role);
const user = await this.prisma.user.findUnique({
where: { id: addMemberDto.userId },
select: { id: true },
});
if (!user) {
throw new NotFoundException(`User with ID ${addMemberDto.userId} not found`);
}
const existingMembership = await this.prisma.workspaceMember.findUnique({
where: {
workspaceId_userId: {
workspaceId,
userId: addMemberDto.userId,
},
},
select: {
workspaceId: true,
userId: true,
},
});
if (existingMembership) {
throw new ConflictException("User is already a member of this workspace");
}
try {
return await this.prisma.workspaceMember.create({
data: {
workspaceId,
userId: addMemberDto.userId,
role: addMemberDto.role,
},
});
} catch (error) {
if (this.isUniqueConstraintError(error)) {
throw new ConflictException("User is already a member of this workspace");
}
throw error;
}
}
/**
* Update the role of an existing workspace member.
*/
async updateMemberRole(
workspaceId: string,
actorUserId: string,
targetUserId: string,
updateMemberRoleDto: UpdateMemberRoleDto
): Promise<WorkspaceMember> {
return this.prisma.$transaction(async (tx) => {
const actorMembership = await tx.workspaceMember.findUnique({
where: {
workspaceId_userId: {
workspaceId,
userId: actorUserId,
},
},
select: {
role: true,
},
});
if (!actorMembership) {
throw new ForbiddenException("You are not a member of this workspace");
}
const targetMembership = await tx.workspaceMember.findUnique({
where: {
workspaceId_userId: {
workspaceId,
userId: targetUserId,
},
},
select: {
role: true,
},
});
if (!targetMembership) {
throw new NotFoundException(`User ${targetUserId} is not a member of this workspace`);
}
this.assertCanManageTargetMember(actorMembership.role, targetMembership.role);
this.assertCanAssignRole(actorMembership.role, updateMemberRoleDto.role);
if (targetMembership.role === WorkspaceMemberRole.OWNER) {
const isDemotion = updateMemberRoleDto.role !== WorkspaceMemberRole.OWNER;
if (isDemotion) {
const ownerCount = await tx.workspaceMember.count({
where: {
workspaceId,
role: WorkspaceMemberRole.OWNER,
},
});
if (ownerCount <= 1) {
if (actorUserId === targetUserId) {
throw new BadRequestException("Cannot self-demote if you are the sole owner");
}
throw new BadRequestException("Cannot remove the last owner from a workspace");
}
}
}
return tx.workspaceMember.update({
where: {
workspaceId_userId: {
workspaceId,
userId: targetUserId,
},
},
data: {
role: updateMemberRoleDto.role,
},
});
});
}
/**
* Remove a member from a workspace.
*/
async removeMember(
workspaceId: string,
actorUserId: string,
targetUserId: string
): Promise<void> {
await this.prisma.$transaction(async (tx) => {
const actorMembership = await tx.workspaceMember.findUnique({
where: {
workspaceId_userId: {
workspaceId,
userId: actorUserId,
},
},
select: {
role: true,
},
});
if (!actorMembership) {
throw new ForbiddenException("You are not a member of this workspace");
}
const targetMembership = await tx.workspaceMember.findUnique({
where: {
workspaceId_userId: {
workspaceId,
userId: targetUserId,
},
},
select: {
role: true,
},
});
if (!targetMembership) {
throw new NotFoundException(`User ${targetUserId} is not a member of this workspace`);
}
this.assertCanManageTargetMember(actorMembership.role, targetMembership.role);
if (targetMembership.role === WorkspaceMemberRole.OWNER) {
const ownerCount = await tx.workspaceMember.count({
where: {
workspaceId,
role: WorkspaceMemberRole.OWNER,
},
});
if (ownerCount <= 1) {
throw new BadRequestException("Cannot remove the last owner from a workspace");
}
}
await tx.workspaceMember.delete({
where: {
workspaceId_userId: {
workspaceId,
userId: targetUserId,
},
},
});
});
}
private assertCanAssignRole(
actorRole: WorkspaceMemberRole,
requestedRole: WorkspaceMemberRole
): void {
if (WORKSPACE_ROLE_RANK[actorRole] < WORKSPACE_ROLE_RANK[requestedRole]) {
throw new ForbiddenException("You cannot assign a role higher than your own");
}
}
private assertCanManageTargetMember(
actorRole: WorkspaceMemberRole,
targetRole: WorkspaceMemberRole
): void {
if (WORKSPACE_ROLE_RANK[actorRole] < WORKSPACE_ROLE_RANK[targetRole]) {
throw new ForbiddenException("You cannot manage a member with a higher role");
}
}
private isUniqueConstraintError(error: unknown): error is Prisma.PrismaClientKnownRequestError {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002";
}
}

View File

@@ -1,14 +1,29 @@
/**
* Teams API Client
* Handles team-related API requests
*/
import type { Team, TeamMember, User } from "@mosaic/shared";
import type {
Team,
TeamMember,
User,
WorkspaceMemberRole,
} from "@mosaic/shared";
import { TeamMemberRole } from "@mosaic/shared";
import { apiGet, apiPost, apiPatch, apiDelete, type ApiResponse } from "./client";
import { apiDelete, apiGet, apiPost, type ApiResponse } from "./client";
export interface TeamMemberWithUser extends TeamMember {
user: Pick<User, "id" | "name" | "email" | "image">;
}
export interface TeamWithMembers extends Team {
members: (TeamMember & { user: User })[];
members?: TeamMemberWithUser[];
_count?: {
members: number;
};
}
export interface WorkspaceMemberWithUser {
workspaceId: string;
userId: string;
role: WorkspaceMemberRole;
joinedAt: string | Date;
user: Pick<User, "id" | "name" | "email" | "image">;
}
export interface CreateTeamDto {
@@ -16,108 +31,81 @@ export interface CreateTeamDto {
description?: string;
}
export interface UpdateTeamDto {
name?: string;
description?: string;
}
export interface AddTeamMemberDto {
userId: string;
role?: TeamMemberRole;
}
/**
* Fetch all teams for a workspace
*/
export async function fetchTeams(workspaceId: string): Promise<Team[]> {
const response = await apiGet<ApiResponse<Team[]>>(`/api/workspaces/${workspaceId}/teams`);
return response.data;
type ApiPayload<T> = T | ApiResponse<T>;
function isApiResponse<T>(payload: ApiPayload<T>): payload is ApiResponse<T> {
return typeof payload === "object" && payload !== null && "data" in payload;
}
/**
* Fetch a single team with members
*/
export async function fetchTeam(workspaceId: string, teamId: string): Promise<TeamWithMembers> {
const response = await apiGet<ApiResponse<TeamWithMembers>>(
`/api/workspaces/${workspaceId}/teams/${teamId}`
function unwrapPayload<T>(payload: ApiPayload<T>): T {
return isApiResponse(payload) ? payload.data : payload;
}
export function getTeamMemberCount(team: TeamWithMembers): number {
if (Array.isArray(team.members)) {
return team.members.length;
}
return team._count?.members ?? 0;
}
export async function fetchTeams(workspaceId: string): Promise<TeamWithMembers[]> {
const payload = await apiGet<ApiPayload<TeamWithMembers[]>>(
`/api/workspaces/${workspaceId}/teams`,
workspaceId
);
return response.data;
return unwrapPayload(payload);
}
/**
* Create a new team
*/
export async function createTeam(workspaceId: string, data: CreateTeamDto): Promise<Team> {
const response = await apiPost<ApiResponse<Team>>(`/api/workspaces/${workspaceId}/teams`, data);
return response.data;
}
/**
* Update a team
*/
export async function updateTeam(
workspaceId: string,
teamId: string,
data: UpdateTeamDto
): Promise<Team> {
const response = await apiPatch<ApiResponse<Team>>(
`/api/workspaces/${workspaceId}/teams/${teamId}`,
data
export async function createTeam(workspaceId: string, data: CreateTeamDto): Promise<TeamWithMembers> {
const payload = await apiPost<ApiPayload<TeamWithMembers>>(
`/api/workspaces/${workspaceId}/teams`,
data,
workspaceId
);
return response.data;
return unwrapPayload(payload);
}
/**
* Delete a team
*/
export async function deleteTeam(workspaceId: string, teamId: string): Promise<void> {
await apiDelete(`/api/workspaces/${workspaceId}/teams/${teamId}`);
await apiDelete<void>(`/api/workspaces/${workspaceId}/teams/${teamId}`, workspaceId);
}
/**
* Add a member to a team
*/
export async function addTeamMember(
workspaceId: string,
teamId: string,
data: AddTeamMemberDto
): Promise<TeamMember> {
const response = await apiPost<ApiResponse<TeamMember>>(
): Promise<TeamMemberWithUser> {
const payload = await apiPost<ApiPayload<TeamMemberWithUser>>(
`/api/workspaces/${workspaceId}/teams/${teamId}/members`,
data
data,
workspaceId
);
return response.data;
return unwrapPayload(payload);
}
/**
* Remove a member from a team
*/
export async function removeTeamMember(
workspaceId: string,
teamId: string,
userId: string
): Promise<void> {
await apiDelete(`/api/workspaces/${workspaceId}/teams/${teamId}/members/${userId}`);
await apiDelete<void>(`/api/workspaces/${workspaceId}/teams/${teamId}/members/${userId}`, workspaceId);
}
/**
* Update a team member's role
*/
export async function updateTeamMemberRole(
workspaceId: string,
teamId: string,
userId: string,
role: TeamMemberRole
): Promise<TeamMember> {
const response = await apiPatch<ApiResponse<TeamMember>>(
`/api/workspaces/${workspaceId}/teams/${teamId}/members/${userId}`,
{ role }
export async function fetchWorkspaceMembers(workspaceId: string): Promise<WorkspaceMemberWithUser[]> {
const payload = await apiGet<ApiPayload<WorkspaceMemberWithUser[]>>(
`/api/workspaces/${workspaceId}/members`,
workspaceId
);
return response.data;
return unwrapPayload(payload);
}
/**
* Mock teams for development (until backend endpoints are ready)
* Mock teams for development in legacy routes under /app/settings.
*/
export const mockTeams: Team[] = [
{
@@ -133,7 +121,7 @@ export const mockTeams: Team[] = [
id: "team-2",
workspaceId: "workspace-1",
name: "Design",
description: "UI/UX design team",
description: "UI and UX design team",
metadata: {},
createdAt: new Date("2026-01-22"),
updatedAt: new Date("2026-01-22"),
@@ -149,24 +137,16 @@ export const mockTeams: Team[] = [
},
];
/**
* Mock team with members for development
*/
const baseTeam = mockTeams[0];
if (!baseTeam) {
throw new Error("Mock team not found");
const [defaultMockTeam] = mockTeams;
if (!defaultMockTeam) {
throw new Error("Mock team was not found");
}
export const mockTeamWithMembers: TeamWithMembers = {
id: baseTeam.id,
workspaceId: baseTeam.workspaceId,
name: baseTeam.name,
description: baseTeam.description,
metadata: baseTeam.metadata,
createdAt: baseTeam.createdAt,
updatedAt: baseTeam.updatedAt,
...defaultMockTeam,
members: [
{
teamId: "team-1",
teamId: defaultMockTeam.id,
userId: "user-1",
role: TeamMemberRole.OWNER,
joinedAt: new Date("2026-01-20"),
@@ -174,22 +154,11 @@ export const mockTeamWithMembers: TeamWithMembers = {
id: "user-1",
email: "john@example.com",
name: "John Doe",
emailVerified: true,
image: null,
authProviderId: null,
preferences: {},
deactivatedAt: null,
isLocalAuth: false,
passwordHash: null,
invitedBy: null,
invitationToken: null,
invitedAt: null,
createdAt: new Date("2026-01-15"),
updatedAt: new Date("2026-01-15"),
},
},
{
teamId: "team-1",
teamId: defaultMockTeam.id,
userId: "user-2",
role: TeamMemberRole.MEMBER,
joinedAt: new Date("2026-01-21"),
@@ -197,18 +166,7 @@ export const mockTeamWithMembers: TeamWithMembers = {
id: "user-2",
email: "jane@example.com",
name: "Jane Smith",
emailVerified: true,
image: null,
authProviderId: null,
preferences: {},
deactivatedAt: null,
isLocalAuth: false,
passwordHash: null,
invitedBy: null,
invitationToken: null,
invitedAt: null,
createdAt: new Date("2026-01-16"),
updatedAt: new Date("2026-01-16"),
},
},
],

View File

@@ -2,36 +2,37 @@
> Single-writer: orchestrator (Jarvis/OpenClaw) only. Workers read but never modify.
| id | status | milestone | description | pr | agent | notes |
| ------------- | ----------- | --------- | ------------------------------------------------------------------------------------------------------------------- | --- | ------------ | ------------------------------- |
| MS21-PLAN-001 | done | phase-1 | Write PRD, init mission, populate TASKS.md | — | orchestrator | PRD at docs/PRD-MS21.md |
| MS21-DB-001 | not-started | phase-1 | Prisma migration: add deactivatedAt, isLocalAuth, passwordHash, invitedBy, invitationToken, invitedAt to User model | — | — | Schema changes for auth + admin |
| MS21-API-001 | not-started | phase-1 | AdminModule: admin.module.ts, admin.service.ts, admin.controller.ts with AdminGuard | — | — | Full CRUD for user management |
| MS21-API-002 | not-started | phase-1 | Admin user endpoints: GET /admin/users, POST /admin/users/invite, PATCH /admin/users/:id, DELETE /admin/users/:id | — | — | Requires MS21-DB-001 |
| MS21-API-003 | not-started | phase-1 | Workspace member management: POST/PATCH/DELETE /workspaces/:id/members endpoints | — | — | Role hierarchy enforcement |
| MS21-API-004 | not-started | phase-1 | Team management: POST /workspaces/:id/teams, team member CRUD | — | — | Extends existing Team model |
| MS21-API-005 | not-started | phase-1 | Admin workspace endpoints: POST/PATCH /admin/workspaces with owner assignment | — | — | |
| MS21-TEST-001 | not-started | phase-1 | Unit tests for AdminService and AdminController (spec files) | — | — | Minimum coverage: 85% |
| MS21-AUTH-001 | not-started | phase-2 | LocalAuthModule: local-auth.controller.ts, local-auth.service.ts | — | — | bcrypt password hashing |
| MS21-AUTH-002 | not-started | phase-2 | Break-glass setup endpoint: /api/auth/local/setup with BREAKGLASS_SETUP_TOKEN validation | — | — | First-time admin creation |
| MS21-AUTH-003 | not-started | phase-2 | Break-glass login endpoint: /api/auth/local/login with session creation | — | — | BetterAuth session compat |
| MS21-AUTH-004 | not-started | phase-2 | Deactivation session invalidation: deactivating user kills all active sessions | — | — | Security requirement |
| MS21-TEST-002 | not-started | phase-2 | Unit tests for LocalAuthService and LocalAuthController | — | — | |
| MS21-MIG-001 | not-started | phase-3 | Migration script: scripts/migrate-brain.ts — read jarvis-brain data files | — | — | v2.0 format parsing |
| MS21-MIG-002 | not-started | phase-3 | Migration mapping: status/priority/domain mapping + metadata preservation | — | — | See PRD field mapping |
| MS21-MIG-003 | not-started | phase-3 | Migration execution: dry-run + apply modes, idempotent, activity logging | — | — | |
| MS21-MIG-004 | not-started | phase-3 | Import API endpoints: POST /api/import/tasks, POST /api/import/projects | — | — | For future bulk imports |
| MS21-TEST-003 | not-started | phase-3 | Migration script tests: validate dry-run output, mapping accuracy | — | — | |
| MS21-UI-001 | not-started | phase-4 | Settings/users page: user management table with search, sort, filter | — | — | |
| MS21-UI-002 | not-started | phase-4 | User detail/edit dialog and invite user dialog | — | — | |
| MS21-UI-003 | not-started | phase-4 | Settings/workspaces page: workspace list, member counts, detail view | — | — | |
| MS21-UI-004 | not-started | phase-4 | Workspace member management: add/remove dialog with role picker | — | — | |
| MS21-UI-005 | not-started | phase-4 | Settings/teams page: team list, create dialog, member management | — | — | |
| MS21-TEST-004 | not-started | phase-4 | Frontend component tests for admin pages | — | — | |
| MS21-RBAC-001 | not-started | phase-5 | Sidebar navigation: show/hide admin items based on user role | — | — | |
| MS21-RBAC-002 | not-started | phase-5 | Settings pages: restrict access to admin-only routes | — | — | |
| MS21-RBAC-003 | not-started | phase-5 | Action buttons: disable/hide based on permission level | — | — | |
| MS21-RBAC-004 | not-started | phase-5 | User profile: show current role and workspace memberships | — | — | |
| MS21-VER-001 | not-started | phase-6 | Full quality gate pass: pnpm lint && pnpm build && pnpm test | — | — | All 4772+ tests + new |
| MS21-VER-002 | not-started | phase-6 | Deploy to mosaic.woltje.com, smoke test all pages | — | — | |
| MS21-VER-003 | not-started | phase-6 | Tag v0.0.21, update PRD status to complete | — | — | |
| id | status | milestone | description | pr | agent | notes |
|----|--------|-----------|-------------|----|-------|-------|
| MS21-PLAN-001 | done | phase-1 | Write PRD, init mission, populate TASKS.md | #552 | orchestrator | CI: #552 green |
| MS21-DB-001 | done | phase-1 | Prisma migration: add user fields | #553 | claude-worker-1 | CI: #684 green |
| MS21-API-001 | done | phase-1 | AdminModule with user/workspace admin endpoints | #555 | claude-worker-2 | CI: #689 green |
| MS21-API-002 | done | phase-1 | Admin user endpoints (list, invite, update, deactivate) | #555 | claude-worker-2 | Combined with API-001 |
| MS21-API-003 | done | phase-1 | Workspace member management endpoints | #556 | codex-worker-1 | CI: #700 green |
| MS21-API-004 | done | phase-1 | Team management module | #564 | codex-worker-2 | CI: #707 green |
| MS21-API-005 | done | phase-1 | Admin workspace endpoints | #555 | claude-worker-2 | Combined with API-001 |
| MS21-TEST-001 | done | phase-1 | Unit tests for AdminService and AdminController | #555 | claude-worker-2 | 26 tests included |
| MS21-AUTH-001 | done | phase-2 | LocalAuthModule: break-glass auth | #559 | claude-worker-3 | CI: #691 green |
| MS21-AUTH-002 | done | phase-2 | Break-glass setup endpoint | #559 | claude-worker-3 | Combined with AUTH-001 |
| MS21-AUTH-003 | done | phase-2 | Break-glass login endpoint | #559 | claude-worker-3 | Combined with AUTH-001 |
| MS21-AUTH-004 | not-started | phase-2 | Deactivation session invalidation | — | — | Deferred |
| MS21-TEST-002 | done | phase-2 | Unit tests for LocalAuth | #559 | claude-worker-3 | 27 tests included |
| MS21-MIG-001 | done | phase-3 | Migration script: scripts/migrate-brain.ts | #554 | codex-worker-1 | CI: #688 (test flaky, code clean) |
| MS21-MIG-002 | done | phase-3 | Migration mapping: status/priority/domain mapping | #554 | codex-worker-1 | Included in MIG-001 |
| MS21-MIG-003 | not-started | phase-3 | Migration execution: run on production database | — | — | Needs deploy |
| MS21-MIG-004 | not-started | phase-3 | Import API endpoints | — | — | |
| MS21-TEST-003 | not-started | phase-3 | Migration script tests | — | — | |
| MS21-UI-001 | not-started | phase-4 | Settings/users page | — | — | |
| MS21-UI-002 | not-started | phase-4 | User detail/edit and invite dialogs | — | — | |
| MS21-UI-003 | not-started | phase-4 | Settings/workspaces page (wire to real API) | — | — | Mock data exists |
| MS21-UI-004 | not-started | phase-4 | Workspace member management UI | — | — | Components exist |
| MS21-UI-005 | not-started | phase-4 | Settings/teams page | — | — | |
| MS21-TEST-004 | not-started | phase-4 | Frontend component tests | — | — | |
| MS21-RBAC-001 | not-started | phase-5 | Sidebar navigation role gating | — | — | |
| MS21-RBAC-002 | not-started | phase-5 | Settings page access restriction | — | — | |
| MS21-RBAC-003 | not-started | phase-5 | Action button permission gating | — | — | |
| MS21-RBAC-004 | not-started | phase-5 | User profile role display | — | — | |
| MS21-VER-001 | not-started | phase-6 | Full quality gate pass | — | — | |
| MS21-VER-002 | not-started | phase-6 | Deploy and smoke test | — | — | |
| MS21-VER-003 | not-started | phase-6 | Tag v0.0.21 | — | — | |
| MS21-FIX-001 | done | phase-1 | Fix flaky CI tests (rate limit timeout + log sanitizer) | #562 | codex-worker-3 | CI: #705 green |

21
pnpm-lock.yaml generated
View File

@@ -149,6 +149,9 @@ importers:
axios:
specifier: ^1.13.5
version: 1.13.5
bcryptjs:
specifier: ^3.0.3
version: 3.0.3
better-auth:
specifier: ^1.4.17
version: 1.4.17(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
@@ -243,6 +246,9 @@ importers:
'@types/archiver':
specifier: ^7.0.0
version: 7.0.0
'@types/bcryptjs':
specifier: ^3.0.0
version: 3.0.0
'@types/cookie-parser':
specifier: ^1.4.10
version: 1.4.10(@types/express@5.0.6)
@@ -1597,7 +1603,6 @@ packages:
'@mosaicstack/telemetry-client@0.1.1':
resolution: {integrity: sha512-1udg6p4cs8rhQgQ2pKCfi7EpRlJieRRhA5CIqthRQ6HQZLgQ0wH+632jEulov3rlHSM1iplIQ+AAe5DWrvSkEA==, tarball: https://git.mosaicstack.dev/api/packages/mosaic/npm/%40mosaicstack%2Ftelemetry-client/-/0.1.1/telemetry-client-0.1.1.tgz}
engines: {node: '>=18'}
'@mrleebo/prisma-ast@0.13.1':
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
@@ -3053,6 +3058,10 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/bcryptjs@3.0.0':
resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==}
deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
@@ -3789,6 +3798,10 @@ packages:
bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
bcryptjs@3.0.3:
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
hasBin: true
better-auth@1.4.17:
resolution: {integrity: sha512-VmHGQyKsEahkEs37qguROKg/6ypYpNF13D7v/lkbO7w7Aivz0Bv2h+VyUkH4NzrGY0QBKXi1577mGhDCVwp0ew==}
peerDependencies:
@@ -10354,6 +10367,10 @@ snapshots:
dependencies:
'@babel/types': 7.28.6
'@types/bcryptjs@3.0.0':
dependencies:
bcryptjs: 3.0.3
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
@@ -11274,6 +11291,8 @@ snapshots:
dependencies:
tweetnacl: 0.14.5
bcryptjs@3.0.3: {}
better-auth@1.4.17(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
'@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0)