feat: 用户管理页加角色列和角色编辑弹窗
- 用户表新增「角色」列,显示超级管理员/管理员/用户徽章 - 编辑用户弹窗增加角色选择(USER/TENANT_ADMIN/SUPER_ADMIN) - 角色选择时同步显示该角色的权限预览 - 保存时自动调用 PATCH /tenants/:id/members/:userId 更新角色 - 新增 test-permission-flow.mjs 三层用户权限测试脚本 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = 'http://localhost:13001';
|
||||
const API = 'http://localhost:3001';
|
||||
|
||||
const USERS = [
|
||||
{ label: 'SUPER_ADMIN (admin)', username: 'admin', password: 'admin123', expectedPerms: 26 },
|
||||
{ label: 'TENANT_ADMIN (ta_admin)', username: 'ta_admin', password: 'pass123', expectedPerms: 21 },
|
||||
{ label: 'USER (user1)', username: 'user1', password: 'pass123', expectedPerms: 5 },
|
||||
];
|
||||
|
||||
async function login(page, username, password) {
|
||||
await page.goto(`${BASE}/login`, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(1000);
|
||||
await page.locator('input[type="text"]').first().fill(username);
|
||||
await page.locator('input[type="password"]').first().fill(password);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
await page.waitForURL('**/');
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
async function getApiKey(page) {
|
||||
return await page.evaluate(() => localStorage.getItem('kb_api_key') || '');
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const results = [];
|
||||
|
||||
for (const u of USERS) {
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log(`🧑💻 ${u.label}`);
|
||||
console.log(`${'='.repeat(60)}`);
|
||||
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
const r = { user: u.label, login: false, perms: 0, nav: [], settingsTabs: [], api: {} };
|
||||
|
||||
try {
|
||||
// Login
|
||||
await login(page, u.username, u.password);
|
||||
r.login = true;
|
||||
console.log(` ✅ 登录成功`);
|
||||
|
||||
// Get API key from page
|
||||
const apiKey = await getApiKey(page);
|
||||
console.log(` 🔑 API Key: ${apiKey.substring(0, 16)}...`);
|
||||
|
||||
// Check navigation sidebar
|
||||
r.nav = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll('aside, nav, [class*="sidebar"], [class*="navigation"]'))
|
||||
.flatMap(el => Array.from(el.querySelectorAll('a, button')))
|
||||
.map(el => (el.textContent || '').trim())
|
||||
.filter(Boolean)
|
||||
.filter(t => !t.startsWith('AuraK') && !t.startsWith('Admin'))
|
||||
.filter((v, i, a) => a.indexOf(v) === i);
|
||||
});
|
||||
console.log(` 📋 导航: ${r.nav.join(', ')}`);
|
||||
|
||||
// Check settings tabs
|
||||
await page.goto(`${BASE}/settings`, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
r.settingsTabs = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll('aside button, [class*="sidebar"] button'))
|
||||
.map(b => (b.textContent || '').trim())
|
||||
.filter(Boolean)
|
||||
.filter((v, i, a) => a.indexOf(v) === i);
|
||||
});
|
||||
console.log(` ⚙️ 设置Tab: ${r.settingsTabs.join(', ')}`);
|
||||
|
||||
// API permission checks
|
||||
const headers = { 'x-api-key': apiKey };
|
||||
|
||||
// /api/permissions/mine
|
||||
const permRes = await fetch(`${API}/api/permissions/mine`, { headers });
|
||||
const permData = await permRes.json();
|
||||
r.perms = (permData.permissions || []).length;
|
||||
console.log(` 🔒 权限数: ${r.perms} (期望: ${u.expectedPerms})`);
|
||||
|
||||
// /api/users (user:view)
|
||||
const usersRes = await fetch(`${API}/api/users`, { headers });
|
||||
r.api['user:view'] = usersRes.status;
|
||||
console.log(` GET /api/users: ${usersRes.ok ? '✅' : '❌'} (${usersRes.status})`);
|
||||
|
||||
// /api/roles (TENANT_ADMIN+)
|
||||
const rolesRes = await fetch(`${API}/api/roles`, { headers });
|
||||
r.api['roles'] = rolesRes.status;
|
||||
console.log(` GET /api/roles: ${rolesRes.ok ? '✅' : '❌'} (${rolesRes.status})`);
|
||||
|
||||
// Check assessment access
|
||||
await page.goto(`${BASE}/assessment`, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const assessOK = await page.evaluate(() => {
|
||||
const body = document.body.textContent || '';
|
||||
return body.includes('AI协作技巧') || body.includes('开始专业评估');
|
||||
});
|
||||
r.api['assessment'] = assessOK;
|
||||
console.log(` 📝 考核页可访问: ${assessOK ? '✅' : '❌'}`);
|
||||
|
||||
} catch (err) {
|
||||
console.error(` ❌ 错误: ${err.message}`);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
results.push(r);
|
||||
}
|
||||
|
||||
// Summary table
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log('📊 权限测试汇总');
|
||||
console.log(`${'='.repeat(60)}`);
|
||||
console.log(`用户 登录 权限 users roles 设置Tab`);
|
||||
console.log(`${'─'.repeat(60)}`);
|
||||
for (const r of results) {
|
||||
const tabStr = r.settingsTabs.filter(t => !['基本设置'].includes(t)).join('/') || '-';
|
||||
console.log(
|
||||
r.user.padEnd(22),
|
||||
r.login ? '✅' : '❌',
|
||||
String(r.perms).padStart(3),
|
||||
r.api['user:view'] === 200 ? ' ✅' : ' ❌',
|
||||
String(r.api['roles']).padStart(3),
|
||||
tabStr,
|
||||
);
|
||||
}
|
||||
console.log(`${'='.repeat(60)}`);
|
||||
console.log('✅ 测试完成');
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
run().catch(e => { console.error('❌', e.message); process.exit(1); });
|
||||
@@ -419,7 +419,15 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
const [passwordChangeUserData, setPasswordChangeUserData] = useState<{ userId: string, newPassword: string } | null>(null);
|
||||
|
||||
// --- Edit User State ---
|
||||
const [editUserData, setEditUserData] = useState<{ userId: string, username: string, displayName: string } | null>(null);
|
||||
const [editUserData, setEditUserData] = useState<{
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
memberId?: string;
|
||||
tenantId?: string;
|
||||
role?: string;
|
||||
isAdmin?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const handleToggleUserAdmin = async (userId: string, newAdminStatus: boolean) => {
|
||||
try {
|
||||
@@ -582,10 +590,26 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
const handleUpdateUser = async () => {
|
||||
if (!editUserData) return;
|
||||
try {
|
||||
// 更新基本信息
|
||||
await userService.updateUserInfo(editUserData.userId, {
|
||||
username: editUserData.username,
|
||||
displayName: editUserData.displayName
|
||||
displayName: editUserData.displayName,
|
||||
isAdmin: editUserData.role === 'SUPER_ADMIN',
|
||||
});
|
||||
|
||||
// 更新角色(通过 tenant member API)
|
||||
if (editUserData.memberId && editUserData.tenantId) {
|
||||
const res = await fetch(`/api/v1/tenants/${editUserData.tenantId}/members/${editUserData.userId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'x-api-key': authToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ role: editUserData.role }),
|
||||
});
|
||||
if (!res.ok) console.warn('Role update returned', res.status);
|
||||
}
|
||||
|
||||
showSuccess(t('featureUpdated'));
|
||||
setEditUserData(null);
|
||||
fetchUsers();
|
||||
@@ -1028,11 +1052,63 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="py-2.5 px-4 bg-indigo-50 rounded-2xl border border-indigo-100/50">
|
||||
<p className="text-[10px] font-black text-indigo-500 uppercase tracking-widest mb-1">{t('globalUserNote') || "Note"}</p>
|
||||
<p className="text-[11px] text-indigo-700/70 leading-relaxed font-medium">
|
||||
{t('roleManagedInOrg') || "Roles are managed within organizations."}
|
||||
{/* 角色选择 */}
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
|
||||
角色
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{['USER', 'TENANT_ADMIN', 'SUPER_ADMIN'].map(r => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => setEditUserData({ ...editUserData, role: r })}
|
||||
disabled={r === 'SUPER_ADMIN' && currentUser?.role !== 'SUPER_ADMIN'}
|
||||
className={`px-4 py-2.5 rounded-xl text-xs font-black uppercase tracking-wider transition-all border-2 ${
|
||||
editUserData.role === r
|
||||
? r === 'SUPER_ADMIN'
|
||||
? 'border-red-500 bg-red-50 text-red-700'
|
||||
: r === 'TENANT_ADMIN'
|
||||
? 'border-indigo-500 bg-indigo-50 text-indigo-700'
|
||||
: 'border-slate-300 bg-slate-50 text-slate-600'
|
||||
: 'border-transparent text-slate-400 hover:bg-slate-50'
|
||||
} ${
|
||||
r === 'SUPER_ADMIN' && currentUser?.role !== 'SUPER_ADMIN'
|
||||
? 'opacity-30 cursor-not-allowed'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
{r === 'SUPER_ADMIN' ? '超级管理员' : r === 'TENANT_ADMIN' ? '管理员' : '用户'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{editUserData.role === 'SUPER_ADMIN' && currentUser?.role !== 'SUPER_ADMIN' && (
|
||||
<p className="text-[10px] text-amber-600 mt-1">仅超级管理员可提升用户为超级管理员</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 权限预览 */}
|
||||
<div className="py-3 px-4 bg-slate-50 rounded-2xl border border-slate-100">
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2">
|
||||
该角色的权限 ({editUserData.role === 'SUPER_ADMIN' ? '26项' : editUserData.role === 'TENANT_ADMIN' ? '21项' : '5项'})
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{editUserData.role === 'SUPER_ADMIN' && (
|
||||
['全部权限:用户管理、租户管理、知识库、考核评估、模型配置、插件管理、系统设置'].map(p => (
|
||||
<span key={p} className="px-2 py-0.5 bg-indigo-50 text-indigo-600 text-[9px] font-bold rounded-md">{p}</span>
|
||||
))
|
||||
)}
|
||||
{editUserData.role === 'TENANT_ADMIN' && (
|
||||
['查看用户','创建用户','编辑用户','重置密码','管理知识库','管理考核','管理模型','管理插件'].map(p => (
|
||||
<span key={p} className="px-2 py-0.5 bg-indigo-50 text-indigo-600 text-[9px] font-bold rounded-md">{p}</span>
|
||||
))
|
||||
)}
|
||||
{editUserData.role === 'USER' && (
|
||||
['使用知识库','参与考核'].map(p => (
|
||||
<span key={p} className="px-2 py-0.5 bg-slate-100 text-slate-500 text-[9px] font-bold rounded-md">{p}</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 pt-4">
|
||||
@@ -1054,6 +1130,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('username')}</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('displayName') || t('name')}</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('organizations')}</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">角色</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('createdAt')}</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest text-right">{t('actions')}</th>
|
||||
</tr>
|
||||
@@ -1108,6 +1185,22 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
<span className="text-[10px] text-slate-400 italic">{t('noOrganization')}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{(() => {
|
||||
const tm = user.tenantMembers?.filter((m: any) => m.tenant?.name !== 'Default')?.[0];
|
||||
const role = user.isAdmin ? 'SUPER_ADMIN' : tm?.role || 'USER';
|
||||
const colors: Record<string, string> = {
|
||||
SUPER_ADMIN: 'bg-red-50 text-red-700 border-red-100',
|
||||
TENANT_ADMIN: 'bg-indigo-50 text-indigo-700 border-indigo-100',
|
||||
USER: 'bg-slate-50 text-slate-500 border-slate-100',
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-[9px] font-black rounded-md uppercase tracking-wider border ${colors[role] || colors.USER}`}>
|
||||
{role === 'SUPER_ADMIN' ? '超级管理员' : role === 'TENANT_ADMIN' ? '管理员' : '用户'}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<p className="text-[11px] font-medium text-slate-600">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
@@ -1121,10 +1214,15 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const tm = user.tenantMembers?.filter((m: any) => m.tenant?.name !== 'Default')?.[0];
|
||||
setEditUserData({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName || ''
|
||||
displayName: user.displayName || '',
|
||||
memberId: tm?.id,
|
||||
tenantId: tm?.tenantId,
|
||||
role: user.isAdmin ? 'SUPER_ADMIN' : tm?.role || 'USER',
|
||||
isAdmin: !!user.isAdmin,
|
||||
});
|
||||
}}
|
||||
className="p-2 rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-white shadow-sm transition-all"
|
||||
|
||||
Reference in New Issue
Block a user