495 lines
15 KiB
TypeScript
495 lines
15 KiB
TypeScript
|
|
/**
|
||
|
|
* Organization API handlers
|
||
|
|
*/
|
||
|
|
|
||
|
|
import type { IApiContext, IApiResponse } from '../router.ts';
|
||
|
|
import { PermissionService } from '../../services/permission.service.ts';
|
||
|
|
import { AuditService } from '../../services/audit.service.ts';
|
||
|
|
import { Organization, OrganizationMember, User } from '../../models/index.ts';
|
||
|
|
import type { TOrganizationRole } from '../../interfaces/auth.interfaces.ts';
|
||
|
|
|
||
|
|
export class OrganizationApi {
|
||
|
|
private permissionService: PermissionService;
|
||
|
|
|
||
|
|
constructor(permissionService: PermissionService) {
|
||
|
|
this.permissionService = permissionService;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* GET /api/v1/organizations
|
||
|
|
*/
|
||
|
|
public async list(ctx: IApiContext): Promise<IApiResponse> {
|
||
|
|
if (!ctx.actor?.userId) {
|
||
|
|
return { status: 401, body: { error: 'Authentication required' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
// System admins see all orgs, others see only their orgs
|
||
|
|
let organizations: Organization[];
|
||
|
|
|
||
|
|
if (ctx.actor.user?.isSystemAdmin) {
|
||
|
|
organizations = await Organization.getInstances({});
|
||
|
|
} else {
|
||
|
|
organizations = await OrganizationMember.getUserOrganizations(ctx.actor.userId);
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
status: 200,
|
||
|
|
body: {
|
||
|
|
organizations: organizations.map((org) => ({
|
||
|
|
id: org.id,
|
||
|
|
name: org.name,
|
||
|
|
displayName: org.displayName,
|
||
|
|
description: org.description,
|
||
|
|
avatarUrl: org.avatarUrl,
|
||
|
|
isPublic: org.isPublic,
|
||
|
|
memberCount: org.memberCount,
|
||
|
|
createdAt: org.createdAt,
|
||
|
|
})),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error('[OrganizationApi] List error:', error);
|
||
|
|
return { status: 500, body: { error: 'Failed to list organizations' } };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* GET /api/v1/organizations/:id
|
||
|
|
*/
|
||
|
|
public async get(ctx: IApiContext): Promise<IApiResponse> {
|
||
|
|
const { id } = ctx.params;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const org = await Organization.findById(id);
|
||
|
|
if (!org) {
|
||
|
|
return { status: 404, body: { error: 'Organization not found' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check access - public orgs are visible to all authenticated users
|
||
|
|
if (!org.isPublic && ctx.actor?.userId) {
|
||
|
|
const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId);
|
||
|
|
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
|
||
|
|
return { status: 403, body: { error: 'Access denied' } };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
status: 200,
|
||
|
|
body: {
|
||
|
|
id: org.id,
|
||
|
|
name: org.name,
|
||
|
|
displayName: org.displayName,
|
||
|
|
description: org.description,
|
||
|
|
avatarUrl: org.avatarUrl,
|
||
|
|
website: org.website,
|
||
|
|
isPublic: org.isPublic,
|
||
|
|
memberCount: org.memberCount,
|
||
|
|
settings: ctx.actor?.user?.isSystemAdmin ? org.settings : undefined,
|
||
|
|
usedStorageBytes: org.usedStorageBytes,
|
||
|
|
createdAt: org.createdAt,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error('[OrganizationApi] Get error:', error);
|
||
|
|
return { status: 500, body: { error: 'Failed to get organization' } };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* POST /api/v1/organizations
|
||
|
|
*/
|
||
|
|
public async create(ctx: IApiContext): Promise<IApiResponse> {
|
||
|
|
if (!ctx.actor?.userId) {
|
||
|
|
return { status: 401, body: { error: 'Authentication required' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const body = await ctx.request.json();
|
||
|
|
const { name, displayName, description, isPublic } = body;
|
||
|
|
|
||
|
|
if (!name) {
|
||
|
|
return { status: 400, body: { error: 'Organization name is required' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate name format
|
||
|
|
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) {
|
||
|
|
return {
|
||
|
|
status: 400,
|
||
|
|
body: { error: 'Name must be lowercase alphanumeric with optional hyphens' },
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check if name is taken
|
||
|
|
const existing = await Organization.findByName(name);
|
||
|
|
if (existing) {
|
||
|
|
return { status: 409, body: { error: 'Organization name already taken' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create organization
|
||
|
|
const org = new Organization();
|
||
|
|
org.id = await Organization.getNewId();
|
||
|
|
org.name = name;
|
||
|
|
org.displayName = displayName || name;
|
||
|
|
org.description = description;
|
||
|
|
org.isPublic = isPublic ?? false;
|
||
|
|
org.memberCount = 1;
|
||
|
|
org.createdAt = new Date();
|
||
|
|
org.createdById = ctx.actor.userId;
|
||
|
|
|
||
|
|
await org.save();
|
||
|
|
|
||
|
|
// Add creator as owner
|
||
|
|
const membership = new OrganizationMember();
|
||
|
|
membership.id = await OrganizationMember.getNewId();
|
||
|
|
membership.organizationId = org.id;
|
||
|
|
membership.userId = ctx.actor.userId;
|
||
|
|
membership.role = 'owner';
|
||
|
|
membership.addedById = ctx.actor.userId;
|
||
|
|
membership.addedAt = new Date();
|
||
|
|
|
||
|
|
await membership.save();
|
||
|
|
|
||
|
|
// Audit log
|
||
|
|
await AuditService.withContext({
|
||
|
|
actorId: ctx.actor.userId,
|
||
|
|
actorType: 'user',
|
||
|
|
actorIp: ctx.ip,
|
||
|
|
}).logOrganizationCreated(org.id, org.name);
|
||
|
|
|
||
|
|
return {
|
||
|
|
status: 201,
|
||
|
|
body: {
|
||
|
|
id: org.id,
|
||
|
|
name: org.name,
|
||
|
|
displayName: org.displayName,
|
||
|
|
description: org.description,
|
||
|
|
isPublic: org.isPublic,
|
||
|
|
createdAt: org.createdAt,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error('[OrganizationApi] Create error:', error);
|
||
|
|
return { status: 500, body: { error: 'Failed to create organization' } };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* PUT /api/v1/organizations/:id
|
||
|
|
*/
|
||
|
|
public async update(ctx: IApiContext): Promise<IApiResponse> {
|
||
|
|
if (!ctx.actor?.userId) {
|
||
|
|
return { status: 401, body: { error: 'Authentication required' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
const { id } = ctx.params;
|
||
|
|
|
||
|
|
// Check admin permission
|
||
|
|
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||
|
|
if (!canManage) {
|
||
|
|
return { status: 403, body: { error: 'Admin access required' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const org = await Organization.findById(id);
|
||
|
|
if (!org) {
|
||
|
|
return { status: 404, body: { error: 'Organization not found' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
const body = await ctx.request.json();
|
||
|
|
const { displayName, description, avatarUrl, website, isPublic, settings } = body;
|
||
|
|
|
||
|
|
if (displayName !== undefined) org.displayName = displayName;
|
||
|
|
if (description !== undefined) org.description = description;
|
||
|
|
if (avatarUrl !== undefined) org.avatarUrl = avatarUrl;
|
||
|
|
if (website !== undefined) org.website = website;
|
||
|
|
if (isPublic !== undefined) org.isPublic = isPublic;
|
||
|
|
|
||
|
|
// Only system admins can change settings
|
||
|
|
if (settings && ctx.actor.user?.isSystemAdmin) {
|
||
|
|
org.settings = { ...org.settings, ...settings };
|
||
|
|
}
|
||
|
|
|
||
|
|
await org.save();
|
||
|
|
|
||
|
|
return {
|
||
|
|
status: 200,
|
||
|
|
body: {
|
||
|
|
id: org.id,
|
||
|
|
name: org.name,
|
||
|
|
displayName: org.displayName,
|
||
|
|
description: org.description,
|
||
|
|
avatarUrl: org.avatarUrl,
|
||
|
|
website: org.website,
|
||
|
|
isPublic: org.isPublic,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error('[OrganizationApi] Update error:', error);
|
||
|
|
return { status: 500, body: { error: 'Failed to update organization' } };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* DELETE /api/v1/organizations/:id
|
||
|
|
*/
|
||
|
|
public async delete(ctx: IApiContext): Promise<IApiResponse> {
|
||
|
|
if (!ctx.actor?.userId) {
|
||
|
|
return { status: 401, body: { error: 'Authentication required' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
const { id } = ctx.params;
|
||
|
|
|
||
|
|
// Only owners and system admins can delete
|
||
|
|
const membership = await OrganizationMember.findMembership(id, ctx.actor.userId);
|
||
|
|
if (membership?.role !== 'owner' && !ctx.actor.user?.isSystemAdmin) {
|
||
|
|
return { status: 403, body: { error: 'Owner access required' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const org = await Organization.findById(id);
|
||
|
|
if (!org) {
|
||
|
|
return { status: 404, body: { error: 'Organization not found' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
// TODO: Check for packages, repositories before deletion
|
||
|
|
// For now, just delete the organization and memberships
|
||
|
|
await org.delete();
|
||
|
|
|
||
|
|
return {
|
||
|
|
status: 200,
|
||
|
|
body: { message: 'Organization deleted successfully' },
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error('[OrganizationApi] Delete error:', error);
|
||
|
|
return { status: 500, body: { error: 'Failed to delete organization' } };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* GET /api/v1/organizations/:id/members
|
||
|
|
*/
|
||
|
|
public async listMembers(ctx: IApiContext): Promise<IApiResponse> {
|
||
|
|
if (!ctx.actor?.userId) {
|
||
|
|
return { status: 401, body: { error: 'Authentication required' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
const { id } = ctx.params;
|
||
|
|
|
||
|
|
// Check membership
|
||
|
|
const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId);
|
||
|
|
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
|
||
|
|
return { status: 403, body: { error: 'Access denied' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const members = await OrganizationMember.getOrgMembers(id);
|
||
|
|
|
||
|
|
// Fetch user details
|
||
|
|
const membersWithUsers = await Promise.all(
|
||
|
|
members.map(async (m) => {
|
||
|
|
const user = await User.findById(m.userId);
|
||
|
|
return {
|
||
|
|
userId: m.userId,
|
||
|
|
role: m.role,
|
||
|
|
addedAt: m.addedAt,
|
||
|
|
user: user
|
||
|
|
? {
|
||
|
|
username: user.username,
|
||
|
|
displayName: user.displayName,
|
||
|
|
avatarUrl: user.avatarUrl,
|
||
|
|
}
|
||
|
|
: null,
|
||
|
|
};
|
||
|
|
})
|
||
|
|
);
|
||
|
|
|
||
|
|
return {
|
||
|
|
status: 200,
|
||
|
|
body: { members: membersWithUsers },
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error('[OrganizationApi] List members error:', error);
|
||
|
|
return { status: 500, body: { error: 'Failed to list members' } };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* POST /api/v1/organizations/:id/members
|
||
|
|
*/
|
||
|
|
public async addMember(ctx: IApiContext): Promise<IApiResponse> {
|
||
|
|
if (!ctx.actor?.userId) {
|
||
|
|
return { status: 401, body: { error: 'Authentication required' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
const { id } = ctx.params;
|
||
|
|
|
||
|
|
// Check admin permission
|
||
|
|
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||
|
|
if (!canManage) {
|
||
|
|
return { status: 403, body: { error: 'Admin access required' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const body = await ctx.request.json();
|
||
|
|
const { userId, role } = body as { userId: string; role: TOrganizationRole };
|
||
|
|
|
||
|
|
if (!userId || !role) {
|
||
|
|
return { status: 400, body: { error: 'userId and role are required' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!['owner', 'admin', 'member'].includes(role)) {
|
||
|
|
return { status: 400, body: { error: 'Invalid role' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check user exists
|
||
|
|
const user = await User.findById(userId);
|
||
|
|
if (!user) {
|
||
|
|
return { status: 404, body: { error: 'User not found' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check if already a member
|
||
|
|
const existing = await OrganizationMember.findMembership(id, userId);
|
||
|
|
if (existing) {
|
||
|
|
return { status: 409, body: { error: 'User is already a member' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add member
|
||
|
|
const membership = new OrganizationMember();
|
||
|
|
membership.id = await OrganizationMember.getNewId();
|
||
|
|
membership.organizationId = id;
|
||
|
|
membership.userId = userId;
|
||
|
|
membership.role = role;
|
||
|
|
membership.addedById = ctx.actor.userId;
|
||
|
|
membership.addedAt = new Date();
|
||
|
|
|
||
|
|
await membership.save();
|
||
|
|
|
||
|
|
// Update member count
|
||
|
|
const org = await Organization.findById(id);
|
||
|
|
if (org) {
|
||
|
|
org.memberCount += 1;
|
||
|
|
await org.save();
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
status: 201,
|
||
|
|
body: {
|
||
|
|
userId: membership.userId,
|
||
|
|
role: membership.role,
|
||
|
|
addedAt: membership.addedAt,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error('[OrganizationApi] Add member error:', error);
|
||
|
|
return { status: 500, body: { error: 'Failed to add member' } };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* PUT /api/v1/organizations/:id/members/:userId
|
||
|
|
*/
|
||
|
|
public async updateMember(ctx: IApiContext): Promise<IApiResponse> {
|
||
|
|
if (!ctx.actor?.userId) {
|
||
|
|
return { status: 401, body: { error: 'Authentication required' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
const { id, userId } = ctx.params;
|
||
|
|
|
||
|
|
// Check admin permission
|
||
|
|
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||
|
|
if (!canManage) {
|
||
|
|
return { status: 403, body: { error: 'Admin access required' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const body = await ctx.request.json();
|
||
|
|
const { role } = body as { role: TOrganizationRole };
|
||
|
|
|
||
|
|
if (!role || !['owner', 'admin', 'member'].includes(role)) {
|
||
|
|
return { status: 400, body: { error: 'Valid role is required' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
const membership = await OrganizationMember.findMembership(id, userId);
|
||
|
|
if (!membership) {
|
||
|
|
return { status: 404, body: { error: 'Member not found' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Cannot change last owner
|
||
|
|
if (membership.role === 'owner' && role !== 'owner') {
|
||
|
|
const owners = await OrganizationMember.getOrgMembers(id);
|
||
|
|
const ownerCount = owners.filter((m) => m.role === 'owner').length;
|
||
|
|
if (ownerCount <= 1) {
|
||
|
|
return { status: 400, body: { error: 'Cannot remove the last owner' } };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
membership.role = role;
|
||
|
|
await membership.save();
|
||
|
|
|
||
|
|
return {
|
||
|
|
status: 200,
|
||
|
|
body: {
|
||
|
|
userId: membership.userId,
|
||
|
|
role: membership.role,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error('[OrganizationApi] Update member error:', error);
|
||
|
|
return { status: 500, body: { error: 'Failed to update member' } };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* DELETE /api/v1/organizations/:id/members/:userId
|
||
|
|
*/
|
||
|
|
public async removeMember(ctx: IApiContext): Promise<IApiResponse> {
|
||
|
|
if (!ctx.actor?.userId) {
|
||
|
|
return { status: 401, body: { error: 'Authentication required' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
const { id, userId } = ctx.params;
|
||
|
|
|
||
|
|
// Users can remove themselves, admins can remove others
|
||
|
|
if (userId !== ctx.actor.userId) {
|
||
|
|
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||
|
|
if (!canManage) {
|
||
|
|
return { status: 403, body: { error: 'Admin access required' } };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const membership = await OrganizationMember.findMembership(id, userId);
|
||
|
|
if (!membership) {
|
||
|
|
return { status: 404, body: { error: 'Member not found' } };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Cannot remove last owner
|
||
|
|
if (membership.role === 'owner') {
|
||
|
|
const owners = await OrganizationMember.getOrgMembers(id);
|
||
|
|
const ownerCount = owners.filter((m) => m.role === 'owner').length;
|
||
|
|
if (ownerCount <= 1) {
|
||
|
|
return { status: 400, body: { error: 'Cannot remove the last owner' } };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
await membership.delete();
|
||
|
|
|
||
|
|
// Update member count
|
||
|
|
const org = await Organization.findById(id);
|
||
|
|
if (org) {
|
||
|
|
org.memberCount = Math.max(0, org.memberCount - 1);
|
||
|
|
await org.save();
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
status: 200,
|
||
|
|
body: { message: 'Member removed successfully' },
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error('[OrganizationApi] Remove member error:', error);
|
||
|
|
return { status: 500, body: { error: 'Failed to remove member' } };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|