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 };
+ }
}