Compare commits
8 Commits
8388d49786
...
feat/ms21-
| Author | SHA1 | Date | |
|---|---|---|---|
| de6aa9c768 | |||
| 85d3f930f3 | |||
| 0e6734bdae | |||
| 5bcaaeddd9 | |||
| 676a2a288b | |||
| ac16d6ed88 | |||
| 62d9ac0e5a | |||
| 5ed0a859da |
@@ -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",
|
||||
|
||||
@@ -44,6 +44,7 @@ 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({
|
||||
@@ -111,6 +112,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
||||
PersonalitiesModule,
|
||||
WorkspacesModule,
|
||||
AdminModule,
|
||||
TeamsModule,
|
||||
],
|
||||
controllers: [AppController, CsrfController],
|
||||
providers: [
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
10
apps/api/src/auth/local/dto/local-login.dto.ts
Normal file
10
apps/api/src/auth/local/dto/local-login.dto.ts
Normal 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;
|
||||
}
|
||||
20
apps/api/src/auth/local/dto/local-setup.dto.ts
Normal file
20
apps/api/src/auth/local/dto/local-setup.dto.ts
Normal 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;
|
||||
}
|
||||
232
apps/api/src/auth/local/local-auth.controller.spec.ts
Normal file
232
apps/api/src/auth/local/local-auth.controller.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
81
apps/api/src/auth/local/local-auth.controller.ts
Normal file
81
apps/api/src/auth/local/local-auth.controller.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
15
apps/api/src/auth/local/local-auth.guard.ts
Normal file
15
apps/api/src/auth/local/local-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
389
apps/api/src/auth/local/local-auth.service.spec.ts
Normal file
389
apps/api/src/auth/local/local-auth.service.spec.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
230
apps/api/src/auth/local/local-auth.service.ts
Normal file
230
apps/api/src/auth/local/local-auth.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
13
apps/api/src/teams/dto/create-team.dto.ts
Normal file
13
apps/api/src/teams/dto/create-team.dto.ts
Normal 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;
|
||||
}
|
||||
11
apps/api/src/teams/dto/manage-team-member.dto.ts
Normal file
11
apps/api/src/teams/dto/manage-team-member.dto.ts
Normal 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;
|
||||
}
|
||||
150
apps/api/src/teams/teams.controller.spec.ts
Normal file
150
apps/api/src/teams/teams.controller.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
51
apps/api/src/teams/teams.controller.ts
Normal file
51
apps/api/src/teams/teams.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/teams/teams.module.ts
Normal file
13
apps/api/src/teams/teams.module.ts
Normal 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 {}
|
||||
286
apps/api/src/teams/teams.service.spec.ts
Normal file
286
apps/api/src/teams/teams.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
130
apps/api/src/teams/teams.service.ts
Normal file
130
apps/api/src/teams/teams.service.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
21
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user