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:
parent
bc7e32061a
commit
51b348e609
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue