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:
parent
bd8d339424
commit
1e4aab378d
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
"appName": "我智能体",
|
"appName": "我智能体",
|
||||||
"appSubtitle": "运维控制台",
|
"appSubtitle": "运维控制台",
|
||||||
"dashboard": "仪表盘",
|
"dashboard": "仪表盘",
|
||||||
|
"myOrg": "用户管理",
|
||||||
"agentConfig": "智能体配置",
|
"agentConfig": "智能体配置",
|
||||||
"enginePrompt": "引擎与提示词",
|
"enginePrompt": "引擎与提示词",
|
||||||
"sdkConfig": "SDK 配置",
|
"sdkConfig": "SDK 配置",
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue