feat: complete tenant member management (CRUD + delete tenant)

Backend: add 5 missing endpoints to TenantController:
- DELETE /tenants/:id (deprovision schema + cleanup)
- GET /tenants/:id/members (query tenant schema users)
- PATCH /tenants/:id/members/:memberId (change role)
- DELETE /tenants/:id/members/:memberId (remove member)
- PUT /tenants/:id (alias for frontend compatibility)

Frontend: add member actions to tenant detail page:
- Role column changed to dropdown selector
- Added remove member button with confirmation
- Added updateMember and removeMember mutations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-26 10:00:09 -08:00
parent bc7e32061a
commit 51b348e609
2 changed files with 302 additions and 67 deletions

View File

@ -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() {
<th className="text-left px-3 py-2 font-medium">{t('detail.membersTable.name')}</th>
<th className="text-left px-3 py-2 font-medium">{t('detail.membersTable.email')}</th>
<th className="text-left px-3 py-2 font-medium">{t('detail.membersTable.role')}</th>
<th className="text-right px-3 py-2 font-medium">{t('detail.membersTable.joined')}</th>
<th className="text-left px-3 py-2 font-medium">{t('detail.membersTable.joined')}</th>
<th className="text-right px-3 py-2 font-medium">{t('detail.invitesTable.actions')}</th>
</tr>
</thead>
<tbody>
@ -623,11 +644,38 @@ export default function TenantDetailPage() {
{member.email}
</td>
<td className="px-3 py-2">
<RoleBadge role={member.role} />
<select
value={member.role}
onChange={(e) =>
updateMemberMutation.mutate({
memberId: member.id,
role: e.target.value,
})
}
disabled={updateMemberMutation.isPending}
className="px-2 py-1 text-xs rounded-md border border-input bg-background"
>
<option value="admin">admin</option>
<option value="operator">operator</option>
<option value="viewer">viewer</option>
</select>
</td>
<td className="px-3 py-2 text-right text-muted-foreground text-xs">
<td className="px-3 py-2 text-muted-foreground text-xs">
{formatRelative(member.joinedAt)}
</td>
<td className="px-3 py-2 text-right">
<button
onClick={() => {
if (window.confirm(`Remove ${member.name} from this tenant?`)) {
removeMemberMutation.mutate(member.id);
}
}}
disabled={removeMemberMutation.isPending}
className="px-2 py-1 text-xs text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-50"
>
{tc('remove')}
</button>
</td>
</tr>
))}
</tbody>

View File

@ -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<Tenant>,
@InjectRepository(TenantInvite)
private readonly inviteRepository: Repository<TenantInvite>,
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<number> {
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<Tenant> {
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 };
}
}