diff --git a/it0-web-admin/src/app/(admin)/tenants/[id]/page.tsx b/it0-web-admin/src/app/(admin)/tenants/[id]/page.tsx index 66e9d7b..557e1ed 100644 --- a/it0-web-admin/src/app/(admin)/tenants/[id]/page.tsx +++ b/it0-web-admin/src/app/(admin)/tenants/[id]/page.tsx @@ -317,6 +317,26 @@ export default function TenantDetailPage() { }, }); + const updateMemberMutation = useMutation({ + mutationFn: ({ memberId, ...body }: { memberId: string; role?: string; status?: string }) => + apiClient(`/api/v1/admin/tenants/${id}/members/${memberId}`, { + method: 'PATCH', + body, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [...queryKeys.tenants.detail(id), 'members'] }); + }, + }); + + const removeMemberMutation = useMutation({ + mutationFn: (memberId: string) => + apiClient(`/api/v1/admin/tenants/${id}/members/${memberId}`, { method: 'DELETE' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [...queryKeys.tenants.detail(id), 'members'] }); + queryClient.invalidateQueries({ queryKey: queryKeys.tenants.detail(id) }); + }, + }); + // Helpers -------------------------------------------------------------- const startEditing = useCallback(() => { if (!tenant) return; @@ -609,7 +629,8 @@ export default function TenantDetailPage() { {t('detail.membersTable.name')} {t('detail.membersTable.email')} {t('detail.membersTable.role')} - {t('detail.membersTable.joined')} + {t('detail.membersTable.joined')} + {t('detail.invitesTable.actions')} @@ -623,11 +644,38 @@ export default function TenantDetailPage() { {member.email} - + - + {formatRelative(member.joinedAt)} + + + ))} diff --git a/packages/services/auth-service/src/interfaces/rest/controllers/tenant.controller.ts b/packages/services/auth-service/src/interfaces/rest/controllers/tenant.controller.ts index bd55d14..dfbe43e 100644 --- a/packages/services/auth-service/src/interfaces/rest/controllers/tenant.controller.ts +++ b/packages/services/auth-service/src/interfaces/rest/controllers/tenant.controller.ts @@ -4,6 +4,7 @@ import { Post, Delete, Patch, + Put, Param, Body, UseGuards, @@ -12,10 +13,11 @@ import { Logger, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, DataSource } from 'typeorm'; import { RolesGuard, Roles } from '@it0/common'; import { TenantProvisioningService } from '@it0/database'; import { Tenant } from '../../../domain/entities/tenant.entity'; +import { TenantInvite } from '../../../domain/entities/tenant-invite.entity'; import { AuthService } from '../../../application/services/auth.service'; import * as crypto from 'crypto'; @@ -28,19 +30,50 @@ export class TenantController { constructor( @InjectRepository(Tenant) private readonly tenantRepository: Repository, + @InjectRepository(TenantInvite) + private readonly inviteRepository: Repository, private readonly tenantProvisioningService: TenantProvisioningService, private readonly authService: AuthService, + private readonly dataSource: DataSource, ) {} - private toDto(t: Tenant) { + /* ---- Helpers ---- */ + + private parseRole(roles: unknown): string { + if (Array.isArray(roles)) return roles[0] ?? 'viewer'; + if (typeof roles === 'string' && roles.startsWith('{')) { + return roles.slice(1, -1).split(',')[0] || 'viewer'; + } + return 'viewer'; + } + + private async getMemberCount(slug: string): Promise { + const schemaName = `it0_t_${slug}`; + const qr = this.dataSource.createQueryRunner(); + await qr.connect(); + try { + await qr.query(`SET search_path TO "${schemaName}", public`); + const result = await qr.query(`SELECT COUNT(*)::int AS count FROM users`); + return result[0]?.count ?? 0; + } catch { + return 0; + } finally { + await qr.release(); + } + } + + private toDto(t: Tenant, memberCount?: number) { + const count = memberCount ?? 0; return { id: t.id, name: t.name, slug: t.slug, + schemaName: `it0_t_${t.slug}`, plan: t.plan, status: t.status, adminEmail: t.adminEmail, - userCount: 0, + userCount: count, + memberCount: count, quota: { maxServers: t.maxServers, maxUsers: t.maxUsers, @@ -52,9 +85,18 @@ export class TenantController { }; } + private async findTenantOrFail(id: string): Promise { + const tenant = await this.tenantRepository.findOne({ where: { id } }); + if (!tenant) { + throw new NotFoundException(`Tenant with id "${id}" not found`); + } + return tenant; + } + + /* ---- Tenant CRUD ---- */ + /** * GET /api/v1/admin/tenants - * Returns all tenants from the public schema's tenants table. */ @Get() async listTenants() { @@ -62,22 +104,8 @@ export class TenantController { return tenants.map((t) => this.toDto(t)); } - /** - * GET /api/v1/admin/tenants/:id - * Returns a single tenant by ID. - */ - @Get(':id') - async getTenant(@Param('id') id: string) { - const tenant = await this.tenantRepository.findOne({ where: { id } }); - if (!tenant) { - throw new NotFoundException(`Tenant with id "${id}" not found`); - } - return this.toDto(tenant); - } - /** * POST /api/v1/admin/tenants - * Creates a new tenant and provisions its database schema. */ @Post() async createTenant( @@ -108,7 +136,6 @@ export class TenantController { const savedTenant = await this.tenantRepository.save(tenant); - // Provision the tenant's database schema try { await this.tenantProvisioningService.provisionTenant(savedTenant.slug); this.logger.log(`Schema provisioned for tenant "${savedTenant.slug}"`); @@ -117,51 +144,156 @@ export class TenantController { `Failed to provision schema for tenant "${savedTenant.slug}":`, err, ); - // The tenant record is already saved; schema provisioning can be retried } return this.toDto(savedTenant); } + /* ---- Member Management ---- */ + /** - * PATCH /api/v1/admin/tenants/:id - * Updates tenant metadata. + * GET /api/v1/admin/tenants/:id/members */ - @Patch(':id') - async updateTenant(@Param('id') id: string, @Body() body: any) { - const tenant = await this.tenantRepository.findOne({ where: { id } }); - if (!tenant) { - throw new NotFoundException(`Tenant with id "${id}" not found`); + @Get(':id/members') + async listMembers(@Param('id') id: string) { + const tenant = await this.findTenantOrFail(id); + const schemaName = `it0_t_${tenant.slug}`; + const qr = this.dataSource.createQueryRunner(); + await qr.connect(); + try { + await qr.query(`SET search_path TO "${schemaName}", public`); + const rows = await qr.query( + `SELECT id, email, name, roles, is_active, created_at FROM users ORDER BY created_at ASC`, + ); + const data = rows.map((row: any) => ({ + id: row.id, + email: row.email, + name: row.name, + role: this.parseRole(row.roles), + joinedAt: row.created_at, + })); + return { data, total: data.length }; + } finally { + await qr.release(); } + } - if (body.name !== undefined) tenant.name = body.name; - if (body.plan !== undefined) tenant.plan = body.plan; - if (body.status !== undefined) tenant.status = body.status; - if (body.adminEmail !== undefined) tenant.adminEmail = body.adminEmail; + /** + * PATCH /api/v1/admin/tenants/:id/members/:memberId + */ + @Patch(':id/members/:memberId') + async updateMember( + @Param('id') tenantId: string, + @Param('memberId') memberId: string, + @Body() body: { role?: string; status?: string }, + ) { + const tenant = await this.findTenantOrFail(tenantId); + const schemaName = `it0_t_${tenant.slug}`; + const qr = this.dataSource.createQueryRunner(); + await qr.connect(); + try { + await qr.query(`SET search_path TO "${schemaName}", public`); - // Support nested quota object from frontend - if (body.quota) { - if (body.quota.maxServers !== undefined) tenant.maxServers = body.quota.maxServers; - if (body.quota.maxUsers !== undefined) tenant.maxUsers = body.quota.maxUsers; - if (body.quota.maxStandingOrders !== undefined) tenant.maxStandingOrders = body.quota.maxStandingOrders; - if (body.quota.maxAgentTokensPerMonth !== undefined) tenant.maxAgentTokensPerMonth = body.quota.maxAgentTokensPerMonth; + const existing = await qr.query( + `SELECT id FROM users WHERE id = $1`, + [memberId], + ); + if (existing.length === 0) { + throw new NotFoundException(`Member "${memberId}" not found`); + } + + const updates: string[] = []; + const params: any[] = []; + let idx = 1; + + if (body.role) { + updates.push(`roles = $${idx}`); + params.push([body.role]); + idx++; + } + if (body.status !== undefined) { + updates.push(`is_active = $${idx}`); + params.push(body.status === 'active'); + idx++; + } + + if (updates.length > 0) { + updates.push('updated_at = NOW()'); + params.push(memberId); + await qr.query( + `UPDATE users SET ${updates.join(', ')} WHERE id = $${idx}`, + params, + ); + } + + const updated = await qr.query( + `SELECT id, email, name, roles, is_active, created_at FROM users WHERE id = $1`, + [memberId], + ); + const row = updated[0]; + return { + id: row.id, + email: row.email, + name: row.name, + role: this.parseRole(row.roles), + joinedAt: row.created_at, + }; + } finally { + await qr.release(); } + } - // Also support flat fields - if (body.maxServers !== undefined) tenant.maxServers = body.maxServers; - if (body.maxUsers !== undefined) tenant.maxUsers = body.maxUsers; - if (body.maxStandingOrders !== undefined) tenant.maxStandingOrders = body.maxStandingOrders; - if (body.maxAgentTokensPerMonth !== undefined) tenant.maxAgentTokensPerMonth = body.maxAgentTokensPerMonth; + /** + * DELETE /api/v1/admin/tenants/:id/members/:memberId + */ + @Delete(':id/members/:memberId') + async removeMember( + @Param('id') tenantId: string, + @Param('memberId') memberId: string, + ) { + const tenant = await this.findTenantOrFail(tenantId); + const schemaName = `it0_t_${tenant.slug}`; + const qr = this.dataSource.createQueryRunner(); + await qr.connect(); + try { + await qr.query(`SET search_path TO "${schemaName}", public`); - const saved = await this.tenantRepository.save(tenant); - return this.toDto(saved); + const existing = await qr.query( + `SELECT id FROM users WHERE id = $1`, + [memberId], + ); + if (existing.length === 0) { + throw new NotFoundException(`Member "${memberId}" not found`); + } + + await qr.query(`DELETE FROM users WHERE id = $1`, [memberId]); + return { success: true }; + } finally { + await qr.release(); + } } /* ---- Invitation Management ---- */ + /** + * GET /api/v1/admin/tenants/:id/invites + */ + @Get(':id/invites') + async listInvites(@Param('id') tenantId: string) { + const invites = await this.authService.listInvites(tenantId); + return invites.map((inv) => ({ + id: inv.id, + email: inv.email, + role: inv.role, + status: inv.status, + expiresAt: inv.expiresAt, + invitedBy: inv.invitedBy, + createdAt: inv.createdAt, + })); + } + /** * POST /api/v1/admin/tenants/:id/invites - * Send an invitation to join this tenant. */ @Post(':id/invites') async createInvite( @@ -186,27 +318,8 @@ export class TenantController { }; } - /** - * GET /api/v1/admin/tenants/:id/invites - * List all invitations for this tenant. - */ - @Get(':id/invites') - async listInvites(@Param('id') tenantId: string) { - const invites = await this.authService.listInvites(tenantId); - return invites.map((inv) => ({ - id: inv.id, - email: inv.email, - role: inv.role, - status: inv.status, - expiresAt: inv.expiresAt, - invitedBy: inv.invitedBy, - createdAt: inv.createdAt, - })); - } - /** * DELETE /api/v1/admin/tenants/:id/invites/:inviteId - * Revoke a pending invitation. */ @Delete(':id/invites/:inviteId') async revokeInvite( @@ -216,4 +329,78 @@ export class TenantController { await this.authService.revokeInvite(inviteId, tenantId); return { success: true }; } + + /* ---- Single Tenant Operations (less-specific routes last) ---- */ + + /** + * GET /api/v1/admin/tenants/:id + */ + @Get(':id') + async getTenant(@Param('id') id: string) { + const tenant = await this.findTenantOrFail(id); + const memberCount = await this.getMemberCount(tenant.slug); + return this.toDto(tenant, memberCount); + } + + /** + * PATCH /api/v1/admin/tenants/:id + */ + @Patch(':id') + async updateTenant(@Param('id') id: string, @Body() body: any) { + const tenant = await this.findTenantOrFail(id); + + if (body.name !== undefined) tenant.name = body.name; + if (body.plan !== undefined) tenant.plan = body.plan; + if (body.status !== undefined) tenant.status = body.status; + if (body.adminEmail !== undefined) tenant.adminEmail = body.adminEmail; + + // Support nested quota object from frontend + if (body.quota) { + if (body.quota.maxServers !== undefined) tenant.maxServers = body.quota.maxServers; + if (body.quota.maxUsers !== undefined) tenant.maxUsers = body.quota.maxUsers; + if (body.quota.maxStandingOrders !== undefined) tenant.maxStandingOrders = body.quota.maxStandingOrders; + if (body.quota.maxAgentTokensPerMonth !== undefined) tenant.maxAgentTokensPerMonth = body.quota.maxAgentTokensPerMonth; + } + + // Also support flat fields + if (body.maxServers !== undefined) tenant.maxServers = body.maxServers; + if (body.maxUsers !== undefined) tenant.maxUsers = body.maxUsers; + if (body.maxStandingOrders !== undefined) tenant.maxStandingOrders = body.maxStandingOrders; + if (body.maxAgentTokensPerMonth !== undefined) tenant.maxAgentTokensPerMonth = body.maxAgentTokensPerMonth; + + const saved = await this.tenantRepository.save(tenant); + return this.toDto(saved); + } + + /** + * PUT /api/v1/admin/tenants/:id (alias for PATCH — frontend detail page uses PUT) + */ + @Put(':id') + async updateTenantPut(@Param('id') id: string, @Body() body: any) { + return this.updateTenant(id, body); + } + + /** + * DELETE /api/v1/admin/tenants/:id + */ + @Delete(':id') + async deleteTenant(@Param('id') id: string) { + const tenant = await this.findTenantOrFail(id); + + // 1. Drop the tenant schema (all data CASCADE) + try { + await this.tenantProvisioningService.deprovisionTenant(tenant.slug); + this.logger.log(`Schema deprovisioned for tenant "${tenant.slug}"`); + } catch (err) { + this.logger.error(`Failed to deprovision schema for "${tenant.slug}":`, err); + } + + // 2. Delete all invites for this tenant + await this.inviteRepository.delete({ tenantId: id }); + + // 3. Delete the tenant record + await this.tenantRepository.remove(tenant); + + return { success: true }; + } }