feat(admin): 「租户 & 用户」页接真实成员管理(删掉 mock)

此前整页是硬编码 mock(假租户/假用户/假 API key/假日预算)。重写为真数据:
- 左:租户目录(listTenants:名称/slug/plan/成员数/积分余额)+ 建租户表单
  (名称/slug/可选 owner 邮箱)。
- 右:选定租户成员表(listMembers)——按邮箱加成员+选角色、行内改角色、移除;
  owner 受保护(不可改角色/移除)。api 加 listTenants/createTenant/listMembers/
  addMember/setMemberRole/removeMember。

live 验证(preview):14 个真实租户带成员数/余额;UI 加 demoB→Dexter「2 成员」、
成员表 owner 保护 + 成员可改角色/移除、移除后回 1 成员,全过;tsc + 41 测试全过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-08 10:20:11 +08:00
parent 0e47a827c0
commit aa10037511
2 changed files with 267 additions and 274 deletions
+62
View File
@@ -438,3 +438,65 @@ export async function adminUsage(params?: { tenant?: string; from?: string; to?:
const d = (await res.json()) as UsageReport;
return { ...d, trend: d.trend ?? [], tenants: d.tenants ?? [] }; // Go 空切片可能序列化为 null
}
// —— 多成员租户:租户目录 + 成员管理 ——
export interface TenantRow {
id: string;
name: string;
slug: string;
plan: string;
status: string;
credit_balance_micro: number;
members: number;
}
export interface Member {
user_id: string;
email: string;
name: string;
role: string; // owner / admin / member / viewer / billing_admin
status: string;
joined_at: string;
}
export async function listTenants(): Promise<TenantRow[]> {
const res = guard(await fetch(`${ADMIN}/tenants`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`list tenants failed: ${res.status}`);
return ((await res.json()) as { tenants?: TenantRow[] }).tenants ?? [];
}
export async function createTenant(name: string, slug: string, ownerEmail?: string): Promise<void> {
const res = guard(await fetch(`${ADMIN}/tenants`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ name, slug, owner_email: ownerEmail ?? "" }) }));
const d = (await res.json().catch(() => ({}))) as { error?: string; warn?: string };
if (!res.ok) throw new Error(d.error ?? `create tenant failed: ${res.status}`);
if (d.warn) throw new Error(d.warn); // 租户已建但指定 owner 失败 → 当提示
}
export async function listMembers(tenantId: string): Promise<Member[]> {
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/members`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`list members failed: ${res.status}`);
return ((await res.json()) as { members?: Member[] }).members ?? [];
}
export async function addMember(tenantId: string, email: string, role: string): Promise<void> {
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/members`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ email, role }) }));
if (!res.ok) {
const d = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(d.error ?? `add member failed: ${res.status}`);
}
}
export async function setMemberRole(tenantId: string, uid: string, role: string): Promise<void> {
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/members/${uid}`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify({ role }) }));
if (!res.ok) {
const d = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(d.error ?? `set role failed: ${res.status}`);
}
}
export async function removeMember(tenantId: string, uid: string): Promise<void> {
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/members/${uid}`, { method: "DELETE", headers: authHeaders() }));
if (!res.ok) {
const d = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(d.error ?? `remove member failed: ${res.status}`);
}
}
+205 -274
View File
@@ -1,311 +1,242 @@
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { listTenants, createTenant, listMembers, addMember, setMemberRole, removeMember, type TenantRow, type Member } from "../api";
// Mock 租户数据
const INITIAL_TENANTS = [
{ id: "tenant_1", name: "法研智能科技 (Acme Law)", code: "acme-law", dbStatus: "provisioned", usersCount: 3, createdAt: "2026-03-12" },
{ id: "tenant_2", name: "极客智造 (Beta Tech)", code: "beta-tech", dbStatus: "provisioned", usersCount: 8, createdAt: "2026-04-01" },
{ id: "tenant_3", name: "信诚医疗集团 (Medi Trust)", code: "medi-trust", dbStatus: "provisioned", usersCount: 5, createdAt: "2026-05-15" },
{ id: "tenant_4", name: "华泰证券研究部 (HT Securities)", code: "ht-sec", dbStatus: "pending", usersCount: 0, createdAt: "2026-06-25" }
];
// 管理端「租户 & 用户」= 多成员租户管理(平台运维口径):租户目录 + 每租户成员增改删。
// 全真数据:GET/POST /admin/tenants、/admin/tenants/:id/members。
// Mock 用户数据(属于 tenant_2 的用户)
const INITIAL_USERS = [
{ id: "usr_101", name: "Alice (管理员)", email: "alice@beta-tech.com", dailyBudget: 150000, dailyUsed: 34200, status: "active", apiKey: "sk_sdx_live_7c3aed06b6e4dd9a" },
{ id: "usr_102", name: "Bob", email: "bob@beta-tech.com", dailyBudget: 50000, dailyUsed: 49500, status: "active", apiKey: "sk_sdx_live_8bf926fed2662a1a" },
{ id: "usr_103", name: "David (已挂起)", email: "david@beta-tech.com", dailyBudget: 100000, dailyUsed: 0, status: "suspended", apiKey: "sk_sdx_live_594be6ad7cc7f5fa" },
];
const ROLES = ["owner", "admin", "member", "viewer", "billing_admin"];
const ROLE_CN: Record<string, string> = { owner: "所有者", admin: "管理员", member: "成员", viewer: "只读", billing_admin: "计费管理" };
const credits = (m: number) => (m / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
export function TenantsPage() {
const [tenants, setTenants] = useState(INITIAL_TENANTS);
const [selectedTenant, setSelectedTenant] = useState(INITIAL_TENANTS[1]); // 默认选 Beta Tech
const [users, setUsers] = useState(INITIAL_USERS);
const [tenants, setTenants] = useState<TenantRow[]>([]);
const [selId, setSelId] = useState<string>("");
const [members, setMembers] = useState<Member[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
// 租户创建表单
const [newTenantName, setNewTenantName] = useState("");
const [newTenantCode, setNewTenantCode] = useState("");
const [isCreating, setIsCreating] = useState(false);
// 租户表单
const [tName, setTName] = useState("");
const [tSlug, setTSlug] = useState("");
const [tOwner, setTOwner] = useState("");
const [creating, setCreating] = useState(false);
// 用户编辑/创建状态
const [showAddUser, setShowAddUser] = useState(false);
const [newUserName, setNewUserName] = useState("");
const [newUserEmail, setNewUserEmail] = useState("");
const [newUserBudget, setNewUserBudget] = useState(50000);
// 加成员表单
const [mEmail, setMEmail] = useState("");
const [mRole, setMRole] = useState("member");
const [adding, setAdding] = useState(false);
const [mErr, setMErr] = useState("");
// 创建租户
const handleCreateTenant = () => {
if (!newTenantName || !newTenantCode) return;
setIsCreating(true);
const selected = useMemo(() => tenants.find((t) => t.id === selId) ?? null, [tenants, selId]);
setTimeout(() => {
const newT = {
id: "tenant_" + (tenants.length + 1),
name: newTenantName,
code: newTenantCode.toLowerCase().replace(/\s+/g, "-"),
dbStatus: "provisioned",
usersCount: 0,
createdAt: new Date().toISOString().split("T")[0]
};
setTenants((prev) => [...prev, newT]);
setSelectedTenant(newT);
setUsers([]);
setNewTenantName("");
setNewTenantCode("");
setIsCreating(false);
}, 1200); // 模拟隔离数据库开辟延迟
const loadTenants = async (keepSel = true) => {
try {
const rows = await listTenants();
setTenants(rows);
if (!keepSel || !rows.find((t) => t.id === selId)) setSelId(rows[0]?.id ?? "");
setErr("");
} catch (e) {
setErr((e as Error).message);
} finally {
setLoading(false);
}
};
const loadMembers = async (id: string) => {
if (!id) {
setMembers([]);
return;
}
try {
setMembers(await listMembers(id));
} catch (e) {
setMErr((e as Error).message);
}
};
// 切换用户冻结状态
const toggleUserStatus = (id: string) => {
setUsers((prev) =>
prev.map((u) => (u.id === id ? { ...u, status: u.status === "active" ? "suspended" : "active" } : u))
);
useEffect(() => {
void loadTenants();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
void loadMembers(selId);
setMErr("");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selId]);
const onCreate = async () => {
if (!tName.trim() || !tSlug.trim()) return;
setCreating(true);
try {
await createTenant(tName.trim(), tSlug.trim(), tOwner.trim() || undefined);
setTName("");
setTSlug("");
setTOwner("");
await loadTenants();
} catch (e) {
setErr((e as Error).message);
} finally {
setCreating(false);
}
};
// 生成新 API Key
const regenerateApiKey = (id: string) => {
const chars = "abcdef0123456789";
let randStr = "";
for (let i = 0; i < 16; i++) randStr += chars[Math.floor(Math.random() * chars.length)];
setUsers((prev) =>
prev.map((u) => (u.id === id ? { ...u, apiKey: `sk_sdx_live_${randStr}` } : u))
);
const onAdd = async () => {
if (!mEmail.trim() || !selId) return;
setAdding(true);
setMErr("");
try {
await addMember(selId, mEmail.trim(), mRole);
setMEmail("");
await Promise.all([loadMembers(selId), loadTenants()]);
} catch (e) {
setMErr((e as Error).message);
} finally {
setAdding(false);
}
};
const onRole = async (uid: string, role: string) => {
try {
await setMemberRole(selId, uid, role);
await loadMembers(selId);
} catch (e) {
setMErr((e as Error).message);
}
};
const onRemove = async (uid: string, email: string) => {
if (!window.confirm(`确定从「${selected?.name}」移除 ${email}`)) return;
try {
await removeMember(selId, uid);
await Promise.all([loadMembers(selId), loadTenants()]);
} catch (e) {
setMErr((e as Error).message);
}
};
// 添加用户
const handleAddUser = () => {
if (!newUserName || !newUserEmail) return;
const chars = "abcdef0123456789";
let randStr = "";
for (let i = 0; i < 16; i++) randStr += chars[Math.floor(Math.random() * chars.length)];
const newUser = {
id: "usr_" + (100 + users.length + 1),
name: newUserName,
email: newUserEmail,
dailyBudget: Number(newUserBudget) || 50000,
dailyUsed: 0,
status: "active",
apiKey: `sk_sdx_live_${randStr}`
};
setUsers((prev) => [...prev, newUser]);
setTenants((prev) =>
prev.map((t) => (t.id === selectedTenant.id ? { ...t, usersCount: t.usersCount + 1 } : t))
);
setSelectedTenant((t) => ({ ...t, usersCount: t.usersCount + 1 }));
setNewUserName("");
setNewUserEmail("");
setShowAddUser(false);
};
if (loading) return <div className="text-sm text-gray-400"></div>;
return (
<div className="space-y-6">
<div className="space-y-4">
{err && <div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">{err}</div>}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* 左租户目录 */}
{/* 左租户目录 + 建租户 */}
<div className="space-y-6">
<section className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700"></h3>
<span className="text-[10px] text-gray-400"> · Schema</span>
<h3 className="text-sm font-semibold text-gray-700"></h3>
<span className="text-[10px] text-gray-400">{tenants.length} · tenant_id </span>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto pr-1">
{tenants.map((t) => {
const isSelected = selectedTenant.id === t.id;
return (
<div
key={t.id}
onClick={() => {
setSelectedTenant(t);
if (t.id === "tenant_2") setUsers(INITIAL_USERS);
else setUsers([]); // mock 其余为空
}}
className={`cursor-pointer rounded-lg border p-3 transition-all ${
isSelected
? "border-violet-500 bg-violet-50/40 shadow-sm"
: "border-gray-100 hover:bg-gray-50/60"
}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-gray-800">{t.name}</span>
<span className={`h-2 w-2 rounded-full ${t.dbStatus === "provisioned" ? "bg-emerald-500" : "bg-amber-400"}`} />
</div>
<div className="mt-2 flex items-center justify-between text-[10px] text-gray-400">
<span>: <span className="font-mono">{t.code}</span></span>
<span>: {t.usersCount}</span>
</div>
<div className="max-h-[420px] space-y-2 overflow-y-auto pr-1">
{tenants.map((t) => (
<button
key={t.id}
onClick={() => setSelId(t.id)}
className={`w-full rounded-lg border p-3 text-left transition-all ${selId === t.id ? "border-violet-500 bg-violet-50/40 shadow-sm" : "border-gray-100 hover:bg-gray-50/60"}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-gray-800">{t.name}</span>
<span className={`rounded px-1.5 py-0.5 text-[9px] font-medium ${t.plan === "free" ? "bg-gray-100 text-gray-500" : "bg-violet-100 text-violet-700"}`}>{t.plan}</span>
</div>
);
})}
<div className="mt-2 flex items-center justify-between text-[10px] text-gray-400">
<span>{t.members} · <span className="font-mono">{t.slug}</span></span>
<span className="tabular-nums text-gray-500">{credits(t.credit_balance_micro)} </span>
</div>
</button>
))}
</div>
</section>
{/* 新增租户表单 */}
<section className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<h3 className="text-sm font-semibold text-gray-700 mb-3"></h3>
<h3 className="mb-3 text-sm font-semibold text-gray-700"></h3>
<div className="space-y-3">
<label className="block text-xs text-gray-500">
<input
type="text"
className="mt-1 w-full rounded border px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none"
placeholder="如:华泰证券投行部"
value={newTenantName}
onChange={(e) => setNewTenantName(e.target.value)}
/>
</label>
<label className="block text-xs text-gray-500">
(Unique Domain Code)
<input
type="text"
className="mt-1 w-full rounded border px-2 py-1.5 text-xs font-mono focus:border-violet-500 focus:outline-none"
placeholder="如:ht-sec"
value={newTenantCode}
onChange={(e) => setNewTenantCode(e.target.value)}
/>
</label>
<button
onClick={handleCreateTenant}
disabled={isCreating || !newTenantName || !newTenantCode}
className="w-full rounded bg-violet-600 py-1.5 text-xs text-white hover:bg-violet-700 font-semibold disabled:opacity-40"
>
{isCreating ? "正在开辟隔离数据空间..." : "开辟租户空间"}
<input className="w-full rounded border px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none" placeholder="租户名称(如:华泰投行部)" value={tName} onChange={(e) => setTName(e.target.value)} />
<input className="w-full rounded border px-2 py-1.5 text-xs font-mono focus:border-violet-500 focus:outline-none" placeholder="唯一标识 slug(如 ht-ib" value={tSlug} onChange={(e) => setTSlug(e.target.value)} />
<input className="w-full rounded border px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none" placeholder="owner 邮箱(可选,须已注册)" value={tOwner} onChange={(e) => setTOwner(e.target.value)} />
<button onClick={() => void onCreate()} disabled={creating || !tName.trim() || !tSlug.trim()} className="w-full rounded bg-violet-600 py-1.5 text-xs font-semibold text-white hover:bg-violet-700 disabled:opacity-40">
{creating ? "创建中…" : "创建租户"}
</button>
</div>
</section>
</div>
{/* 右选定租户的用户管理详情 */}
<div className="space-y-6 lg:col-span-2">
<section className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm min-h-[400px]">
<div className="mb-4 flex items-center justify-between border-b pb-3">
<div>
<h4 className="text-sm font-semibold text-gray-700">{selectedTenant.name} · </h4>
<p className="text-[11px] text-gray-400"> token API </p>
</div>
<button
onClick={() => setShowAddUser(true)}
className="rounded bg-violet-600 px-3 py-1.5 text-xs text-white hover:bg-violet-700"
>
</button>
</div>
{/* 新建成员面板 */}
{showAddUser && (
<div className="mb-4 rounded-lg border border-violet-100 bg-violet-50/20 p-4 animate-fadeIn">
<h5 className="text-xs font-bold text-gray-800 mb-3"></h5>
<div className="grid grid-cols-2 gap-3 mb-3">
<label className="text-xs text-gray-500">
<input
type="text"
className="mt-1 w-full rounded border bg-white px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none"
placeholder="张三"
value={newUserName}
onChange={(e) => setNewUserName(e.target.value)}
/>
</label>
<label className="text-xs text-gray-500">
<input
type="email"
className="mt-1 w-full rounded border bg-white px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none"
placeholder="zhangsan@company.com"
value={newUserEmail}
onChange={(e) => setNewUserEmail(e.target.value)}
/>
</label>
<label className="col-span-2 text-xs text-gray-500">
Token ()
<input
type="number"
className="mt-1 w-full rounded border bg-white px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none"
value={newUserBudget}
onChange={(e) => setNewUserBudget(Number(e.target.value))}
/>
</label>
</div>
<div className="flex gap-2">
<button onClick={handleAddUser} className="rounded bg-violet-600 px-3 py-1.5 text-xs text-white"></button>
<button onClick={() => setShowAddUser(false)} className="rounded border bg-white px-3 py-1.5 text-xs text-gray-500"></button>
</div>
</div>
)}
{users.length === 0 ? (
<div className="flex h-64 items-center justify-center text-xs text-gray-400">
</div>
{/* 右选定租户的成员 */}
<div className="lg:col-span-2">
<section className="min-h-[400px] rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
{!selected ? (
<div className="flex h-64 items-center justify-center text-xs text-gray-400"></div>
) : (
<div className="space-y-4">
{users.map((u) => (
<div key={u.id} className="rounded-lg border border-gray-50 bg-gray-50/20 p-4 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-gray-800">{u.name}</span>
<span className="text-[10px] text-gray-400 font-mono">{u.email}</span>
<span className={`rounded-full px-1.5 py-0.2 text-[8px] font-bold uppercase ${
u.status === "active" ? "bg-emerald-100 text-emerald-800" : "bg-rose-100 text-rose-800"
}`}>
{u.status === "active" ? "正常" : "挂起"}
</span>
</div>
{/* Token 限额进度条 */}
<div className="mt-3 w-64">
<div className="flex justify-between text-[9px] text-gray-400 mb-0.5">
<span>: {u.dailyUsed.toLocaleString()}</span>
<span>: {u.dailyBudget.toLocaleString()}</span>
</div>
<div className="h-1.5 w-full bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
u.dailyUsed / u.dailyBudget >= 0.9 ? "bg-rose-500" :
u.dailyUsed / u.dailyBudget >= 0.7 ? "bg-amber-400" : "bg-violet-600"
}`}
style={{ width: `${Math.min(100, (u.dailyUsed / u.dailyBudget) * 100)}%` }}
/>
</div>
</div>
</div>
{/* API 密钥分配区 */}
<div className="flex flex-col items-end gap-2 shrink-0">
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-gray-400 font-mono bg-white border rounded px-2 py-0.5" title={u.apiKey}>
{u.apiKey.slice(0, 15)}...
</span>
<button
onClick={() => {
navigator.clipboard.writeText(u.apiKey);
alert("已复制 API 密钥到剪贴板!");
}}
className="text-[10px] text-violet-600 hover:underline"
>
</button>
</div>
<div className="flex gap-2">
<button
onClick={() => regenerateApiKey(u.id)}
className="rounded border bg-white px-2 py-1 text-[10px] text-gray-500 hover:bg-gray-50"
>
</button>
<button
onClick={() => toggleUserStatus(u.id)}
className={`rounded border px-2 py-1 text-[10px] ${
u.status === "active" ? "border-rose-100 text-rose-600 hover:bg-rose-50" : "border-emerald-100 text-emerald-600 hover:bg-emerald-50"
}`}
>
{u.status === "active" ? "挂起账户" : "恢复账户"}
</button>
</div>
</div>
<>
<div className="mb-4 flex items-center justify-between border-b pb-3">
<div>
<h4 className="text-sm font-semibold text-gray-700">{selected.name} · </h4>
<p className="text-[11px] text-gray-400"> · slug {selected.slug}</p>
</div>
))}
</div>
<span className="tabular-nums text-xs text-gray-500">{credits(selected.credit_balance_micro)} </span>
</div>
{/* 加成员 */}
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-violet-100 bg-violet-50/30 p-3">
<input className="flex-1 rounded border bg-white px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none" placeholder="按邮箱加入已注册用户" value={mEmail} onChange={(e) => setMEmail(e.target.value)} />
<select className="rounded border bg-white px-2 py-1.5 text-xs text-gray-700 focus:border-violet-500 focus:outline-none" value={mRole} onChange={(e) => setMRole(e.target.value)}>
{ROLES.filter((r) => r !== "owner").map((r) => (
<option key={r} value={r}>{ROLE_CN[r]}</option>
))}
</select>
<button onClick={() => void onAdd()} disabled={adding || !mEmail.trim()} className="rounded bg-violet-600 px-3 py-1.5 text-xs text-white hover:bg-violet-700 disabled:opacity-40">
{adding ? "添加中…" : "加入"}
</button>
</div>
{mErr && <div className="mb-3 text-[11px] text-rose-600">{mErr}</div>}
{members.length === 0 ? (
<div className="flex h-40 items-center justify-center text-xs text-gray-400"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-[11px] uppercase tracking-wide text-gray-400">
<th className="py-2 pr-3 font-medium"></th>
<th className="pr-3 font-medium"></th>
<th className="pr-3 font-medium"></th>
<th className="text-right font-medium"></th>
</tr>
</thead>
<tbody>
{members.map((m) => {
const isOwner = m.role === "owner";
return (
<tr key={m.user_id} className="border-b border-gray-50 last:border-0">
<td className="py-2 pr-3">
<div className="font-medium text-gray-800">{m.name || m.email}</div>
<div className="font-mono text-[10px] text-gray-400">{m.email}</div>
</td>
<td className="pr-3">
{isOwner ? (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700"></span>
) : (
<select value={m.role} onChange={(e) => void onRole(m.user_id, e.target.value)} className="rounded border bg-white px-1.5 py-0.5 text-xs text-gray-700 focus:border-violet-500 focus:outline-none">
{ROLES.filter((r) => r !== "owner").map((r) => (
<option key={r} value={r}>{ROLE_CN[r]}</option>
))}
</select>
)}
</td>
<td className="pr-3">
<span className="rounded-full bg-emerald-100 px-1.5 py-0.5 text-[9px] font-bold uppercase text-emerald-700">{m.status}</span>
</td>
<td className="text-right">
{isOwner ? (
<span className="text-[10px] text-gray-300"></span>
) : (
<button onClick={() => void onRemove(m.user_id, m.email)} className="rounded border border-rose-100 px-2 py-1 text-[10px] text-rose-600 hover:bg-rose-50"></button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</>
)}
</section>
</div>