feat(org): add tenant user management + invite system + fix tenant display

- Backend: GET /api/v1/auth/my-org returns tenant info + member list
- Backend: GET /api/v1/auth/my-org/invites lists pending invites
- Backend: POST /api/v1/auth/my-org/invite creates invite link
- Frontend: /my-org page with member list and invite creation
- Frontend: add '用户管理' to tenant sidebar
- Frontend: add '套餐' (plans) to tenant billing section
- Frontend: admin layout initializes tenant store (fixes '租户:未选择')

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-07 08:50:39 -08:00
parent bd8d339424
commit 1e4aab378d
7 changed files with 328 additions and 4 deletions

View File

@ -4,19 +4,44 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Sidebar } from '@/presentation/components/layout/sidebar'; import { Sidebar } from '@/presentation/components/layout/sidebar';
import { TopBar } from '@/presentation/components/layout/top-bar'; import { TopBar } from '@/presentation/components/layout/top-bar';
import { useTenantStore } from '@/stores/zustand/tenant-store';
import { apiClient } from '@/infrastructure/api/api-client';
export default function AdminLayout({ children }: { children: React.ReactNode }) { export default function AdminLayout({ children }: { children: React.ReactNode }) {
const router = useRouter(); const router = useRouter();
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const setCurrentTenant = useTenantStore((s) => s.setCurrentTenant);
useEffect(() => { useEffect(() => {
const token = localStorage.getItem('access_token'); const token = localStorage.getItem('access_token');
if (!token) { if (!token) {
router.replace('/login'); router.replace('/login');
} else { return;
setReady(true);
} }
}, [router]); setReady(true);
// Initialize tenant store from localStorage first (instant)
try {
const raw = localStorage.getItem('current_tenant');
if (raw) {
const t = JSON.parse(raw);
if (t?.id) setCurrentTenant({ id: t.id, name: t.name || t.id, plan: t.plan || 'free' });
}
} catch { /* ignore */ }
// Then fetch real tenant info from backend
const user = (() => { try { return JSON.parse(localStorage.getItem('user') || '{}'); } catch { return {}; } })();
const isPlatformAdmin = Array.isArray(user.roles) &&
(user.roles.includes('platform_admin') || user.roles.includes('platform_super_admin'));
if (!isPlatformAdmin) {
apiClient<{ id: string; name: string; plan: string }>('/api/v1/auth/my-org')
.then((org) => {
setCurrentTenant({ id: org.id, name: org.name, plan: org.plan as any });
localStorage.setItem('current_tenant', JSON.stringify({ id: org.id, name: org.name, plan: org.plan }));
})
.catch(() => { /* ignore — token may be expired */ });
}
}, [router, setCurrentTenant]);
if (!ready) return null; if (!ready) return null;

View File

@ -0,0 +1,215 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/infrastructure/api/api-client';
interface OrgMember {
id: string;
email?: string;
phone?: string;
name: string;
role: string;
joinedAt: string;
}
interface OrgInfo {
id: string;
name: string;
slug: string;
plan: string;
maxUsers: number;
members: OrgMember[];
}
interface Invite {
id: string;
email: string;
role: string;
status: string;
token: string;
expiresAt: string;
createdAt: string;
}
const ROLE_LABELS: Record<string, string> = {
admin: '管理员',
operator: '操作员',
viewer: '只读',
};
export default function MyOrgPage() {
const queryClient = useQueryClient();
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState('viewer');
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [linkCopied, setLinkCopied] = useState(false);
const [inviteError, setInviteError] = useState<string | null>(null);
const { data: org, isLoading } = useQuery({
queryKey: ['my-org'],
queryFn: () => apiClient<OrgInfo>('/api/v1/auth/my-org'),
});
const { data: invites } = useQuery({
queryKey: ['my-org', 'invites'],
queryFn: () => apiClient<Invite[]>('/api/v1/auth/my-org/invites'),
});
const inviteMutation = useMutation({
mutationFn: (body: { email: string; role: string }) =>
apiClient<Invite>('/api/v1/auth/my-org/invite', { method: 'POST', body }),
onSuccess: (invite) => {
queryClient.invalidateQueries({ queryKey: ['my-org', 'invites'] });
const link = `${window.location.origin}/invite/${invite.token}`;
setInviteLink(link);
setInviteEmail('');
setInviteError(null);
},
onError: (err: Error) => {
setInviteError(err.message);
},
});
const handleInvite = () => {
if (!inviteEmail.trim()) { setInviteError('请填写邮箱'); return; }
inviteMutation.mutate({ email: inviteEmail.trim(), role: inviteRole });
};
const copyLink = () => {
if (!inviteLink) return;
navigator.clipboard.writeText(inviteLink).then(() => {
setLinkCopied(true);
setTimeout(() => setLinkCopied(false), 2000);
});
};
if (isLoading) return <div className="text-sm text-muted-foreground py-12 text-center">...</div>;
const members = Array.isArray(org?.members) ? org!.members : [];
const pendingInvites = Array.isArray(invites) ? invites.filter((i) => i.status === 'pending') : [];
return (
<div className="max-w-4xl space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground mt-1">
{org?.name} · {org?.plan?.toUpperCase()} · {members.length}/{org?.maxUsers === -1 ? '无限' : org?.maxUsers}
</p>
</div>
{/* Invite form */}
<div className="border rounded-lg p-5 space-y-4 bg-card">
<h2 className="text-base font-semibold"></h2>
<div className="flex gap-3">
<input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="输入邮箱地址"
className="flex-1 px-3 py-2 bg-input border rounded-md text-sm"
/>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value)}
className="px-3 py-2 bg-input border rounded-md text-sm"
>
<option value="admin"></option>
<option value="operator"></option>
<option value="viewer"></option>
</select>
<button
onClick={handleInvite}
disabled={inviteMutation.isPending}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-50 whitespace-nowrap"
>
{inviteMutation.isPending ? '发送中...' : '发送邀请'}
</button>
</div>
{inviteError && <p className="text-sm text-red-500">{inviteError}</p>}
{inviteLink && (
<div className="flex items-center gap-2 p-3 bg-muted rounded-md">
<span className="flex-1 text-xs font-mono truncate">{inviteLink}</span>
<button
onClick={copyLink}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent whitespace-nowrap"
>
{linkCopied ? '已复制!' : '复制链接'}
</button>
</div>
)}
</div>
{/* Pending invites */}
{pendingInvites.length > 0 && (
<div className="border rounded-lg overflow-hidden bg-card">
<div className="px-4 py-3 border-b bg-muted/30">
<h2 className="text-sm font-semibold"> ({pendingInvites.length})</h2>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/20">
<th className="text-left px-4 py-2 font-medium"></th>
<th className="text-left px-4 py-2 font-medium"></th>
<th className="text-left px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{pendingInvites.map((inv) => (
<tr key={inv.id} className="border-b last:border-b-0">
<td className="px-4 py-2">{inv.email}</td>
<td className="px-4 py-2">{ROLE_LABELS[inv.role] ?? inv.role}</td>
<td className="px-4 py-2 text-muted-foreground text-xs">
{new Date(inv.expiresAt).toLocaleDateString('zh-CN')}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Member list */}
<div className="border rounded-lg overflow-hidden bg-card">
<div className="px-4 py-3 border-b bg-muted/30">
<h2 className="text-sm font-semibold"> ({members.length})</h2>
</div>
{members.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10"></p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/20">
<th className="text-left px-4 py-2 font-medium"></th>
<th className="text-left px-4 py-2 font-medium"> / </th>
<th className="text-left px-4 py-2 font-medium"></th>
<th className="text-left px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{members.map((m) => (
<tr key={m.id} className="border-b last:border-b-0 hover:bg-muted/20">
<td className="px-4 py-2 font-medium">{m.name}</td>
<td className="px-4 py-2 text-muted-foreground">{m.email || m.phone || '--'}</td>
<td className="px-4 py-2">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
m.role === 'admin' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'
: m.role === 'operator' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-muted text-muted-foreground'
}`}>
{ROLE_LABELS[m.role] ?? m.role}
</span>
</td>
<td className="px-4 py-2 text-muted-foreground text-xs">
{new Date(m.joinedAt).toLocaleDateString('zh-CN')}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}

View File

@ -2,6 +2,7 @@
"appName": "iAgent Admin", "appName": "iAgent Admin",
"appSubtitle": "Operations Console", "appSubtitle": "Operations Console",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"myOrg": "Team Members",
"agentConfig": "Agent Config", "agentConfig": "Agent Config",
"enginePrompt": "Engine & Prompt", "enginePrompt": "Engine & Prompt",
"sdkConfig": "SDK Config", "sdkConfig": "SDK Config",

View File

@ -2,6 +2,7 @@
"appName": "我智能体", "appName": "我智能体",
"appSubtitle": "运维控制台", "appSubtitle": "运维控制台",
"dashboard": "仪表盘", "dashboard": "仪表盘",
"myOrg": "用户管理",
"agentConfig": "智能体配置", "agentConfig": "智能体配置",
"enginePrompt": "引擎与提示词", "enginePrompt": "引擎与提示词",
"sdkConfig": "SDK 配置", "sdkConfig": "SDK 配置",

View File

@ -124,6 +124,7 @@ export function Sidebar() {
const tenantNavItems: NavItem[] = useMemo(() => [ const tenantNavItems: NavItem[] = useMemo(() => [
{ key: 'dashboard', label: t('dashboard'), href: '/dashboard', icon: <LayoutDashboard className={iconClass} /> }, { key: 'dashboard', label: t('dashboard'), href: '/dashboard', icon: <LayoutDashboard className={iconClass} /> },
{ key: 'myOrg', label: t('myOrg'), href: '/my-org', icon: <Users className={iconClass} /> },
{ {
key: 'agentConfig', key: 'agentConfig',
label: t('agentConfig'), label: t('agentConfig'),
@ -189,6 +190,7 @@ export function Sidebar() {
href: '/billing', href: '/billing',
icon: <CreditCard className={iconClass} />, icon: <CreditCard className={iconClass} />,
children: [ children: [
{ label: t('billingPlans'), href: '/billing/plans' },
{ label: t('billingOverview'), href: '/billing' }, { label: t('billingOverview'), href: '/billing' },
{ label: t('billingInvoices'), href: '/billing/invoices' }, { label: t('billingInvoices'), href: '/billing/invoices' },
], ],

View File

@ -523,6 +523,55 @@ export class AuthService {
return this.generateTokens(this.mapRow(rows[0])); return this.generateTokens(this.mapRow(rows[0]));
} }
async getMyOrg(slug: string): Promise<{
id: string; name: string; slug: string; plan: string; status: string;
adminEmail: string; maxUsers: number; maxServers: number;
maxStandingOrders: number; maxAgentTokensPerMonth: number;
members: { id: string; email?: string; phone?: string; name: string; role: string; joinedAt: string }[];
}> {
const rows = await this.dataSource.query(
`SELECT id, name, slug, plan, status, admin_email, max_users, max_servers, max_standing_orders, max_agent_tokens_per_month
FROM public.tenants WHERE slug = $1 LIMIT 1`,
[slug],
);
if (!rows.length) throw new Error('Tenant not found');
const row = rows[0];
const schemaName = `it0_t_${slug}`;
let members: any[] = [];
try {
members = await this.dataSource.query(
`SELECT id, email, phone, name, roles, created_at FROM "${schemaName}".users WHERE is_active = true ORDER BY created_at ASC`,
);
} catch { /* schema may not exist yet */ }
const parseRole = (r: any) => {
if (Array.isArray(r)) return r[0] ?? 'viewer';
if (typeof r === 'string' && r.startsWith('{')) return r.slice(1, -1).split(',')[0] || 'viewer';
return 'viewer';
};
return {
id: row.id,
name: row.name,
slug: row.slug,
plan: row.plan,
status: row.status,
adminEmail: row.admin_email,
maxUsers: row.max_users,
maxServers: row.max_servers,
maxStandingOrders: row.max_standing_orders,
maxAgentTokensPerMonth: row.max_agent_tokens_per_month,
members: members.map((m) => ({
id: m.id,
email: m.email ?? undefined,
phone: m.phone ?? undefined,
name: m.name,
role: parseRole(m.roles),
joinedAt: m.created_at,
})),
};
}
async createApiKey( async createApiKey(
userId: string, userId: string,
tenantId: string, tenantId: string,

View File

@ -1,4 +1,4 @@
import { Controller, Post, Body, Get, Param, UseGuards, Request, Inject, BadRequestException } from '@nestjs/common'; import { Controller, Post, Body, Get, Param, UseGuards, Request, Inject, BadRequestException, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AuthService } from '../../../application/services/auth.service'; import { AuthService } from '../../../application/services/auth.service';
import { REDIS_CLIENT } from '../../../infrastructure/redis/redis.provider'; import { REDIS_CLIENT } from '../../../infrastructure/redis/redis.provider';
@ -120,4 +120,35 @@ export class AuthController {
body.name, body.name,
); );
} }
/** GET /api/v1/auth/my-org — tenant info + member list for the current user's tenant */
@Get('my-org')
@UseGuards(AuthGuard('jwt'))
async getMyOrg(@Request() req: any) {
const slug = req.user?.tenantId;
if (!slug) throw new UnauthorizedException('No tenant context');
return this.authService.getMyOrg(slug);
}
/** GET /api/v1/auth/my-org/invites */
@Get('my-org/invites')
@UseGuards(AuthGuard('jwt'))
async getMyOrgInvites(@Request() req: any) {
const slug = req.user?.tenantId;
if (!slug) throw new UnauthorizedException('No tenant context');
return this.authService.listInvites(slug);
}
/** POST /api/v1/auth/my-org/invite */
@Post('my-org/invite')
@UseGuards(AuthGuard('jwt'))
async createMyOrgInvite(
@Request() req: any,
@Body() body: { email: string; role?: string },
) {
const slug = req.user?.tenantId;
const userId = req.user?.sub || req.user?.id;
if (!slug) throw new UnauthorizedException('No tenant context');
return this.authService.createInvite(slug, body.email, body.role || 'viewer', userId);
}
} }