feat: RBAC Admin UI + internal permissions API + auth session enhancements
- admin-ui/: React + Tailwind SPA at /app/ (Dashboard, Users, Roles, Services, Audit) - rbac-routes.js: POST /api/internal/permissions/user (service-to-service, no auth) - server.js: /api/whoami endpoint for admin SPA auth via nginx X-Email - server.js: /auth/session now checks X-Email fallback for Google SSO users - server.js: SPA catch-all for /app/* routes - server.js: Trusted IP auth now sets X-Auth-Request-Email response header - public/index.html: Added Admin Panel link - 3 ecosystem users registered (Rolf, Victoria, Zaid)
This commit is contained in:
parent
0b0871ffea
commit
7dfb0eab66
31 changed files with 4652 additions and 3 deletions
13
admin-ui/index.html
Normal file
13
admin-ui/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Access Manager — Admin</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔐</text></svg>" />
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface-0 text-txt-primary">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2667
admin-ui/package-lock.json
generated
Normal file
2667
admin-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
admin-ui/package.json
Normal file
22
admin-ui/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"name": "access-manager-admin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.27.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"vite": "^5.4.21"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
admin-ui/postcss.config.js
Normal file
6
admin-ui/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
91
admin-ui/src/App.jsx
Normal file
91
admin-ui/src/App.jsx
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Routes, Route } from 'react-router-dom';
|
||||||
|
import Layout from './components/Layout';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import Users from './pages/Users';
|
||||||
|
import UserDetail from './pages/UserDetail';
|
||||||
|
import Roles from './pages/Roles';
|
||||||
|
import RoleDetail from './pages/RoleDetail';
|
||||||
|
import Services from './pages/Services';
|
||||||
|
import AuditLog from './pages/AuditLog';
|
||||||
|
|
||||||
|
function AccessDenied() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-surface-0 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-6xl mb-4">🚫</div>
|
||||||
|
<h1 className="text-2xl font-bold text-txt-primary mb-2">Access Denied</h1>
|
||||||
|
<p className="text-txt-muted mb-6">You don't have admin permissions to access this panel.</p>
|
||||||
|
<a href="/login.html" className="px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent-hover text-sm">
|
||||||
|
Back to Login
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-surface-0 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-4xl mb-4 animate-pulse">🔐</div>
|
||||||
|
<p className="text-txt-muted text-sm">Checking authentication...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [authState, setAuthState] = useState('loading'); // loading | authenticated | denied | unauthenticated
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/whoami', { credentials: 'include' })
|
||||||
|
.then(res => {
|
||||||
|
if (res.status === 401) throw new Error('Not authenticated');
|
||||||
|
if (!res.ok) throw new Error('Not authenticated');
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (!data.authenticated) throw new Error('Not authenticated');
|
||||||
|
const u = data;
|
||||||
|
if (!u?.email) throw new Error('No user data');
|
||||||
|
|
||||||
|
// Check admin access
|
||||||
|
const isAdmin = u.is_super || u.role === 'admin' ||
|
||||||
|
(u.permissions || []).some(p => p === '*.*.*' || p.endsWith('.admin'));
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
setAuthState('denied');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(u);
|
||||||
|
setAuthState('authenticated');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setAuthState('unauthenticated');
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (authState === 'loading') return <Loading />;
|
||||||
|
if (authState === 'unauthenticated') {
|
||||||
|
window.location.href = '/login.html';
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
if (authState === 'denied') return <AccessDenied />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout user={user}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/users" element={<Users />} />
|
||||||
|
<Route path="/users/:email" element={<UserDetail />} />
|
||||||
|
<Route path="/roles" element={<Roles />} />
|
||||||
|
<Route path="/roles/:name" element={<RoleDetail />} />
|
||||||
|
<Route path="/services" element={<Services />} />
|
||||||
|
<Route path="/audit" element={<AuditLog />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
admin-ui/src/components/DataTable.jsx
Normal file
75
admin-ui/src/components/DataTable.jsx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
|
||||||
|
export default function DataTable({ columns, data, onRowClick, emptyMessage = 'No data' }) {
|
||||||
|
const [sortKey, setSortKey] = useState(null);
|
||||||
|
const [sortDir, setSortDir] = useState('asc');
|
||||||
|
|
||||||
|
const handleSort = (key) => {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortKey(key);
|
||||||
|
setSortDir('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
if (!sortKey || !data) return data || [];
|
||||||
|
return [...data].sort((a, b) => {
|
||||||
|
const av = a[sortKey] ?? '';
|
||||||
|
const bv = b[sortKey] ?? '';
|
||||||
|
const cmp = typeof av === 'number' ? av - bv : String(av).localeCompare(String(bv));
|
||||||
|
return sortDir === 'asc' ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
}, [data, sortKey, sortDir]);
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-surface-2">
|
||||||
|
{columns.map(col => (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
className={`px-4 py-3 text-left text-xs font-medium text-txt-muted uppercase tracking-wide ${col.sortable !== false ? 'cursor-pointer hover:text-txt-secondary select-none' : ''}`}
|
||||||
|
onClick={() => col.sortable !== false && handleSort(col.key)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{col.label}
|
||||||
|
{sortKey === col.key && (
|
||||||
|
<span className="text-accent">{sortDir === 'asc' ? '↑' : '↓'}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{sorted.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length} className="px-4 py-8 text-center text-txt-muted">
|
||||||
|
{emptyMessage}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
sorted.map((row, i) => (
|
||||||
|
<tr
|
||||||
|
key={row.id || row.email || row.name || i}
|
||||||
|
className={`bg-surface-1 hover:bg-surface-2 transition-colors ${onRowClick ? 'cursor-pointer' : ''}`}
|
||||||
|
onClick={() => onRowClick?.(row)}
|
||||||
|
>
|
||||||
|
{columns.map(col => (
|
||||||
|
<td key={col.key} className="px-4 py-3 text-txt-secondary">
|
||||||
|
{col.render ? col.render(row[col.key], row) : (row[col.key] ?? '—')}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
admin-ui/src/components/Layout.jsx
Normal file
110
admin-ui/src/components/Layout.jsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { NavLink, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/', label: 'Dashboard', icon: '📊' },
|
||||||
|
{ path: '/users', label: 'Users', icon: '👥' },
|
||||||
|
{ path: '/roles', label: 'Roles', icon: '🛡️' },
|
||||||
|
{ path: '/services', label: 'Services', icon: '⚙️' },
|
||||||
|
{ path: '/audit', label: 'Audit', icon: '📋' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Layout({ children, user }) {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try { await fetch('/auth/logout', { method: 'POST', credentials: 'include' }); } catch {}
|
||||||
|
window.location.href = '/login.html';
|
||||||
|
};
|
||||||
|
|
||||||
|
const initials = (user?.email || 'A').split('@')[0].split(/[._-]+/).map(p => p[0]).join('').slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-surface-0">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-surface-1 border-b border-border sticky top-0 z-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-14">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
className="sm:hidden p-1 text-txt-secondary hover:text-txt-primary"
|
||||||
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={menuOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"} />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span className="text-lg font-semibold text-txt-primary cursor-pointer" onClick={() => navigate('/')}>
|
||||||
|
🔐 Access Manager
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Nav */}
|
||||||
|
<nav className="hidden sm:flex items-center gap-1">
|
||||||
|
{navItems.map(item => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
end={item.path === '/'}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-accent/10 text-accent'
|
||||||
|
: 'text-txt-secondary hover:text-txt-primary hover:bg-surface-2'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.icon} {item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User menu */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="hidden sm:block text-xs text-txt-muted">{user?.email}</div>
|
||||||
|
<div className="relative group">
|
||||||
|
<button className="w-8 h-8 rounded-full bg-accent/20 text-accent text-xs font-bold flex items-center justify-center">
|
||||||
|
{initials}
|
||||||
|
</button>
|
||||||
|
<div className="absolute right-0 top-full mt-1 w-40 bg-surface-2 border border-border rounded-lg shadow-xl opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-50">
|
||||||
|
<div className="p-2 text-xs text-txt-muted border-b border-border">{user?.email}</div>
|
||||||
|
<button onClick={handleLogout} className="w-full text-left px-3 py-2 text-sm text-danger hover:bg-surface-3 rounded-b-lg transition-colors">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Mobile Nav */}
|
||||||
|
{menuOpen && (
|
||||||
|
<nav className="sm:hidden bg-surface-1 border-b border-border px-4 py-2 space-y-1">
|
||||||
|
{navItems.map(item => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
end={item.path === '/'}
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block px-3 py-2 rounded-md text-sm ${
|
||||||
|
isActive ? 'bg-accent/10 text-accent' : 'text-txt-secondary hover:bg-surface-2'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.icon} {item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
admin-ui/src/components/Modal.jsx
Normal file
29
admin-ui/src/components/Modal.jsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function Modal({ open, onClose, title, children, wide }) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => { document.body.style.overflow = ''; window.removeEventListener('keydown', handler); };
|
||||||
|
}
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||||
|
<div className={`relative bg-surface-1 border border-border rounded-xl shadow-2xl ${wide ? 'max-w-2xl' : 'max-w-md'} w-full max-h-[85vh] overflow-y-auto`}>
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||||
|
<h2 className="text-lg font-semibold text-txt-primary">{title}</h2>
|
||||||
|
<button onClick={onClose} className="text-txt-muted hover:text-txt-primary text-xl leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
admin-ui/src/components/PermissionMatrix.jsx
Normal file
117
admin-ui/src/components/PermissionMatrix.jsx
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
|
||||||
|
export default function PermissionMatrix({ allPermissions, selectedIds, onChange, readOnly }) {
|
||||||
|
const [activeTab, setActiveTab] = useState('all');
|
||||||
|
|
||||||
|
// Group permissions by app → category → feature × action
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
if (!allPermissions) return {};
|
||||||
|
const map = {};
|
||||||
|
allPermissions.forEach(p => {
|
||||||
|
const app = p.app || '*';
|
||||||
|
const cat = p.category || 'General';
|
||||||
|
if (!map[app]) map[app] = {};
|
||||||
|
if (!map[app][cat]) map[app][cat] = {};
|
||||||
|
const feat = p.feature || 'general';
|
||||||
|
if (!map[app][cat][feat]) map[app][cat][feat] = {};
|
||||||
|
map[app][cat][feat][p.action] = p;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [allPermissions]);
|
||||||
|
|
||||||
|
const apps = useMemo(() => Object.keys(grouped).sort(), [grouped]);
|
||||||
|
const actions = ['read', 'write', 'delete', 'admin', 'execute', 'manage'];
|
||||||
|
|
||||||
|
const filteredApps = activeTab === 'all' ? apps : [activeTab];
|
||||||
|
|
||||||
|
const isChecked = (perm) => selectedIds.has(perm.id);
|
||||||
|
|
||||||
|
const toggle = (perm) => {
|
||||||
|
if (readOnly) return;
|
||||||
|
const next = new Set(selectedIds);
|
||||||
|
if (next.has(perm.id)) {
|
||||||
|
next.delete(perm.id);
|
||||||
|
} else {
|
||||||
|
next.add(perm.id);
|
||||||
|
}
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* App tabs */}
|
||||||
|
<div className="flex flex-wrap gap-1 mb-4 border-b border-border pb-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('all')}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||||
|
activeTab === 'all' ? 'bg-accent text-white' : 'bg-surface-2 text-txt-secondary hover:bg-surface-3'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All Apps
|
||||||
|
</button>
|
||||||
|
{apps.map(app => (
|
||||||
|
<button
|
||||||
|
key={app}
|
||||||
|
onClick={() => setActiveTab(app)}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||||
|
activeTab === app ? 'bg-accent text-white' : 'bg-surface-2 text-txt-secondary hover:bg-surface-3'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{app === '*' ? 'Global' : app}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permission grid */}
|
||||||
|
{filteredApps.map(app => (
|
||||||
|
<div key={app} className="mb-6">
|
||||||
|
{activeTab === 'all' && (
|
||||||
|
<h3 className="text-sm font-semibold text-txt-primary mb-2 flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-accent" />
|
||||||
|
{app === '*' ? 'Global (Wildcard)' : app}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
{Object.entries(grouped[app] || {}).sort(([a], [b]) => a.localeCompare(b)).map(([cat, features]) => (
|
||||||
|
<div key={cat} className="mb-4 bg-surface-1 rounded-lg border border-border overflow-hidden">
|
||||||
|
<div className="px-4 py-2 bg-surface-2 text-xs font-medium text-txt-muted uppercase tracking-wide">
|
||||||
|
{cat}
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<th className="px-4 py-2 text-left text-xs text-txt-muted font-medium w-1/3">Feature</th>
|
||||||
|
{actions.map(a => (
|
||||||
|
<th key={a} className="px-2 py-2 text-center text-xs text-txt-muted font-medium capitalize w-16">{a}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border/50">
|
||||||
|
{Object.entries(features).sort(([a], [b]) => a.localeCompare(b)).map(([feat, actMap]) => (
|
||||||
|
<tr key={feat} className="hover:bg-surface-2/50">
|
||||||
|
<td className="px-4 py-2 text-txt-secondary font-mono text-xs">{feat}</td>
|
||||||
|
{actions.map(a => {
|
||||||
|
const perm = actMap[a];
|
||||||
|
if (!perm) return <td key={a} className="px-2 py-2 text-center text-txt-muted/30">—</td>;
|
||||||
|
return (
|
||||||
|
<td key={a} className="px-2 py-2 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isChecked(perm)}
|
||||||
|
onChange={() => toggle(perm)}
|
||||||
|
disabled={readOnly}
|
||||||
|
className="w-4 h-4 rounded border-border cursor-pointer disabled:cursor-default disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
admin-ui/src/components/RoleBadge.jsx
Normal file
21
admin-ui/src/components/RoleBadge.jsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const roleColors = {
|
||||||
|
super_admin: 'bg-red-500/15 text-red-400 border-red-500/30',
|
||||||
|
admin: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
|
||||||
|
editor: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
|
||||||
|
user: 'bg-green-500/15 text-green-400 border-green-500/30',
|
||||||
|
viewer: 'bg-gray-500/15 text-gray-400 border-gray-500/30',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RoleBadge({ role, scope }) {
|
||||||
|
const color = roleColors[role] || 'bg-purple-500/15 text-purple-400 border-purple-500/30';
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${color}`}>
|
||||||
|
{role}
|
||||||
|
{scope && scope !== '*' && (
|
||||||
|
<span className="text-[10px] opacity-70">({scope})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
admin-ui/src/components/StatusBadge.jsx
Normal file
20
admin-ui/src/components/StatusBadge.jsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const statusColors = {
|
||||||
|
active: 'bg-green-500/15 text-green-400',
|
||||||
|
suspended: 'bg-yellow-500/15 text-yellow-400',
|
||||||
|
deactivated: 'bg-red-500/15 text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StatusBadge({ status }) {
|
||||||
|
const color = statusColors[status] || 'bg-gray-500/15 text-gray-400';
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${color}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full mr-1.5 ${
|
||||||
|
status === 'active' ? 'bg-green-400' :
|
||||||
|
status === 'suspended' ? 'bg-yellow-400' : 'bg-red-400'
|
||||||
|
}`} />
|
||||||
|
{status || 'active'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
admin-ui/src/hooks/useApi.js
Normal file
60
admin-ui/src/hooks/useApi.js
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
const BASE = '';
|
||||||
|
|
||||||
|
async function apiFetch(path, opts = {}) {
|
||||||
|
const res = await fetch(BASE + path, {
|
||||||
|
...opts,
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...opts.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.href = '/login.html';
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApi(path, deps = []) {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const refetch = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
apiFetch(path)
|
||||||
|
.then(setData)
|
||||||
|
.catch(e => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [path]);
|
||||||
|
|
||||||
|
useEffect(() => { refetch(); }, [refetch, ...deps]);
|
||||||
|
|
||||||
|
return { data, loading, error, refetch };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiGet(path) {
|
||||||
|
return apiFetch(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPost(path, body) {
|
||||||
|
return apiFetch(path, { method: 'POST', body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPut(path, body) {
|
||||||
|
return apiFetch(path, { method: 'PUT', body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiDelete(path) {
|
||||||
|
return apiFetch(path, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export { apiFetch };
|
||||||
56
admin-ui/src/index.css
Normal file
56
admin-ui/src/index.css
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* ── 5-Layer Dark Mode Surface System ── */
|
||||||
|
:root {
|
||||||
|
--surface-0: #0f1117;
|
||||||
|
--surface-1: #161822;
|
||||||
|
--surface-2: #1e2030;
|
||||||
|
--surface-3: #262840;
|
||||||
|
--surface-4: #303350;
|
||||||
|
--text-primary: #e2e4f0;
|
||||||
|
--text-secondary: #a0a4c0;
|
||||||
|
--text-muted: #6b6f8e;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent-hover: #818cf8;
|
||||||
|
--border: #2e3148;
|
||||||
|
--success: #34d399;
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--danger: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode override */
|
||||||
|
:root.light {
|
||||||
|
--surface-0: #f8f9fc;
|
||||||
|
--surface-1: #ffffff;
|
||||||
|
--surface-2: #f1f3f9;
|
||||||
|
--surface-3: #e6e9f0;
|
||||||
|
--surface-4: #d4d7e0;
|
||||||
|
--text-primary: #1a1d2e;
|
||||||
|
--text-secondary: #4a4f6a;
|
||||||
|
--text-muted: #9095ae;
|
||||||
|
--accent: #4f46e5;
|
||||||
|
--accent-hover: #6366f1;
|
||||||
|
--border: #e2e5ef;
|
||||||
|
--success: #10b981;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--danger: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Checkbox custom styling */
|
||||||
|
input[type="checkbox"] {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
13
admin-ui/src/main.jsx
Normal file
13
admin-ui/src/main.jsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter basename="/app">
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
167
admin-ui/src/pages/AuditLog.jsx
Normal file
167
admin-ui/src/pages/AuditLog.jsx
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import { apiGet } from '../hooks/useApi';
|
||||||
|
|
||||||
|
function timeAgo(d) {
|
||||||
|
if (!d) return '—';
|
||||||
|
const diff = Date.now() - new Date(d).getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return 'Just now';
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
if (hrs < 24) return `${hrs}h ago`;
|
||||||
|
return `${Math.floor(hrs / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditLog() {
|
||||||
|
const [events, setEvents] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actorFilter, setActorFilter] = useState('');
|
||||||
|
const [actionFilter, setActionFilter] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [expanded, setExpanded] = useState(null);
|
||||||
|
const limit = 50;
|
||||||
|
|
||||||
|
const fetchEvents = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||||
|
if (actorFilter) params.set('actor', actorFilter);
|
||||||
|
if (actionFilter) params.set('action', actionFilter);
|
||||||
|
const data = await apiGet(`/api/audit?${params}`);
|
||||||
|
setEvents(data.events || data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Audit fetch error:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [offset, actorFilter, actionFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchEvents(); }, [fetchEvents]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!search) return events;
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return events.filter(e =>
|
||||||
|
e.actor?.toLowerCase().includes(q) ||
|
||||||
|
e.action?.toLowerCase().includes(q) ||
|
||||||
|
e.target_type?.toLowerCase().includes(q) ||
|
||||||
|
e.target_id?.toLowerCase().includes(q) ||
|
||||||
|
JSON.stringify(e.detail || '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [events, search]);
|
||||||
|
|
||||||
|
const actors = useMemo(() => [...new Set(events.map(e => e.actor).filter(Boolean))], [events]);
|
||||||
|
const actions = useMemo(() => [...new Set(events.map(e => e.action).filter(Boolean))], [events]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-txt-primary mb-6">Audit Log</h1>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="flex-1 min-w-[200px] px-3 py-2 bg-surface-1 border border-border rounded-lg text-sm text-txt-primary placeholder:text-txt-muted focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={actorFilter}
|
||||||
|
onChange={e => { setActorFilter(e.target.value); setOffset(0); }}
|
||||||
|
className="px-3 py-2 bg-surface-1 border border-border rounded-lg text-sm text-txt-secondary focus:outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="">All Actors</option>
|
||||||
|
{actors.map(a => <option key={a} value={a}>{a}</option>)}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={actionFilter}
|
||||||
|
onChange={e => { setActionFilter(e.target.value); setOffset(0); }}
|
||||||
|
className="px-3 py-2 bg-surface-1 border border-border rounded-lg text-sm text-txt-secondary focus:outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="">All Actions</option>
|
||||||
|
{actions.map(a => <option key={a} value={a}>{a}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12 text-txt-muted">Loading audit events...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-surface-2">
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-txt-muted uppercase">Time</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-txt-muted uppercase">Actor</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-txt-muted uppercase">Action</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-txt-muted uppercase">Target</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-txt-muted uppercase">Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-4 py-8 text-center text-txt-muted">No audit events</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filtered.map((e, i) => (
|
||||||
|
<React.Fragment key={e.id || i}>
|
||||||
|
<tr
|
||||||
|
className="bg-surface-1 hover:bg-surface-2 cursor-pointer transition-colors"
|
||||||
|
onClick={() => setExpanded(expanded === i ? null : i)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 text-xs text-txt-muted whitespace-nowrap">{timeAgo(e.created_at || e.timestamp)}</td>
|
||||||
|
<td className="px-4 py-3 text-txt-secondary">{e.actor || '—'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-xs font-mono bg-surface-3 text-txt-secondary px-2 py-0.5 rounded">{e.action}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-txt-secondary text-xs">
|
||||||
|
{e.target_type}/{e.target_id}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-txt-muted truncate max-w-[200px]">
|
||||||
|
{typeof e.detail === 'string' ? e.detail : e.detail ? JSON.stringify(e.detail).slice(0, 60) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{expanded === i && e.detail && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-4 py-3 bg-surface-2">
|
||||||
|
<pre className="text-xs text-txt-secondary font-mono whitespace-pre-wrap break-all">
|
||||||
|
{typeof e.detail === 'string' ? e.detail : JSON.stringify(e.detail, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setOffset(Math.max(0, offset - limit))}
|
||||||
|
disabled={offset === 0}
|
||||||
|
className="px-3 py-1.5 bg-surface-2 text-txt-secondary text-sm rounded-lg hover:bg-surface-3 disabled:opacity-30 transition-colors"
|
||||||
|
>
|
||||||
|
← Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-txt-muted">
|
||||||
|
Showing {offset + 1}–{offset + filtered.length}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setOffset(offset + limit)}
|
||||||
|
disabled={events.length < limit}
|
||||||
|
className="px-3 py-1.5 bg-surface-2 text-txt-secondary text-sm rounded-lg hover:bg-surface-3 disabled:opacity-30 transition-colors"
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
admin-ui/src/pages/Dashboard.jsx
Normal file
112
admin-ui/src/pages/Dashboard.jsx
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useApi } from '../hooks/useApi';
|
||||||
|
|
||||||
|
function StatCard({ label, value, icon, onClick }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={`bg-surface-1 border border-border rounded-xl p-5 ${onClick ? 'cursor-pointer hover:border-accent/40 hover:bg-surface-2' : ''} transition-all`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-2xl">{icon}</span>
|
||||||
|
<span className="text-2xl font-bold text-txt-primary">{value ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-txt-muted">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(dateStr) {
|
||||||
|
if (!dateStr) return 'Never';
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return 'Just now';
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
if (hrs < 24) return `${hrs}h ago`;
|
||||||
|
const days = Math.floor(hrs / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: stats, loading: statsLoading } = useApi('/api/stats');
|
||||||
|
const { data: audit } = useApi('/api/audit?limit=10');
|
||||||
|
const { data: users } = useApi('/api/users');
|
||||||
|
|
||||||
|
const recentLogins = (users || [])
|
||||||
|
.filter(u => u.last_login)
|
||||||
|
.sort((a, b) => new Date(b.last_login) - new Date(a.last_login))
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
const recentAudit = (audit?.events || audit || []).slice(0, 8);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-txt-primary mb-6">Dashboard</h1>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4 mb-8">
|
||||||
|
<StatCard icon="👥" label="Users" value={stats?.users} onClick={() => navigate('/users')} />
|
||||||
|
<StatCard icon="🛡️" label="Roles" value={stats?.roles} onClick={() => navigate('/roles')} />
|
||||||
|
<StatCard icon="🔑" label="Permissions" value={stats?.permissions} />
|
||||||
|
<StatCard icon="⚙️" label="Services" value={stats?.services} onClick={() => navigate('/services')} />
|
||||||
|
<StatCard icon="🔗" label="Assignments" value={stats?.user_roles} />
|
||||||
|
<StatCard icon="📋" label="Audit Events" value={stats?.audit_events} onClick={() => navigate('/audit')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Recent Logins */}
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
||||||
|
<div className="px-4 py-3 bg-surface-2 border-b border-border">
|
||||||
|
<h2 className="text-sm font-semibold text-txt-primary">Recent Logins</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{recentLogins.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-center text-txt-muted text-sm">No recent logins</div>
|
||||||
|
) : (
|
||||||
|
recentLogins.map(u => (
|
||||||
|
<div
|
||||||
|
key={u.email}
|
||||||
|
className="px-4 py-3 flex items-center justify-between hover:bg-surface-2 cursor-pointer transition-colors"
|
||||||
|
onClick={() => navigate(`/users/${encodeURIComponent(u.email)}`)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-txt-primary">{u.email}</div>
|
||||||
|
<div className="text-xs text-txt-muted">{u.name || '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-txt-muted">{timeAgo(u.last_login)}</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Audit */}
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
||||||
|
<div className="px-4 py-3 bg-surface-2 border-b border-border">
|
||||||
|
<h2 className="text-sm font-semibold text-txt-primary">Recent Audit Events</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{recentAudit.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-center text-txt-muted text-sm">No audit events</div>
|
||||||
|
) : (
|
||||||
|
recentAudit.map((e, i) => (
|
||||||
|
<div key={e.id || i} className="px-4 py-3 hover:bg-surface-2 transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-mono bg-surface-3 text-txt-secondary px-2 py-0.5 rounded">{e.action}</span>
|
||||||
|
<span className="text-xs text-txt-muted">{timeAgo(e.created_at || e.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-txt-muted mt-1">
|
||||||
|
{e.actor} → {e.target_type}/{e.target_id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
194
admin-ui/src/pages/RoleDetail.jsx
Normal file
194
admin-ui/src/pages/RoleDetail.jsx
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useApi, apiPut } from '../hooks/useApi';
|
||||||
|
import PermissionMatrix from '../components/PermissionMatrix';
|
||||||
|
import RoleBadge from '../components/RoleBadge';
|
||||||
|
|
||||||
|
export default function RoleDetail() {
|
||||||
|
const { name } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data: role, loading, refetch } = useApi(`/api/roles/${name}`);
|
||||||
|
const { data: allPerms } = useApi('/api/permissions');
|
||||||
|
|
||||||
|
const [selectedIds, setSelectedIds] = useState(new Set());
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveMsg, setSaveMsg] = useState('');
|
||||||
|
|
||||||
|
// Editing state
|
||||||
|
const [editDisplay, setEditDisplay] = useState('');
|
||||||
|
const [editDesc, setEditDesc] = useState('');
|
||||||
|
const [editPriority, setEditPriority] = useState(100);
|
||||||
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (role) {
|
||||||
|
// Initialize selected permission IDs from role data
|
||||||
|
const ids = new Set();
|
||||||
|
if (role.permissions) {
|
||||||
|
role.permissions.forEach(p => {
|
||||||
|
if (p.id) ids.add(p.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setSelectedIds(ids);
|
||||||
|
setDirty(false);
|
||||||
|
setEditDisplay(role.display_name || '');
|
||||||
|
setEditDesc(role.description || '');
|
||||||
|
setEditPriority(role.priority || 100);
|
||||||
|
}
|
||||||
|
}, [role]);
|
||||||
|
|
||||||
|
const handlePermChange = (newIds) => {
|
||||||
|
setSelectedIds(newIds);
|
||||||
|
setDirty(true);
|
||||||
|
setSaveMsg('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSavePerms = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setSaveMsg('');
|
||||||
|
try {
|
||||||
|
await apiPut(`/api/roles/${name}/permissions`, {
|
||||||
|
permission_ids: [...selectedIds],
|
||||||
|
});
|
||||||
|
setSaveMsg('Permissions saved!');
|
||||||
|
setDirty(false);
|
||||||
|
refetch();
|
||||||
|
} catch (err) {
|
||||||
|
setSaveMsg(`Error: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveInfo = async () => {
|
||||||
|
try {
|
||||||
|
await apiPut(`/api/roles/${name}`, {
|
||||||
|
display_name: editDisplay,
|
||||||
|
description: editDesc,
|
||||||
|
priority: Number(editPriority),
|
||||||
|
});
|
||||||
|
setEditMode(false);
|
||||||
|
refetch();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flatten allPerms into the format PermissionMatrix expects
|
||||||
|
const flatPerms = useMemo(() => {
|
||||||
|
if (!allPerms) return [];
|
||||||
|
// allPerms could be an array directly or grouped by app
|
||||||
|
if (Array.isArray(allPerms)) return allPerms;
|
||||||
|
// If it's an object grouped by app
|
||||||
|
const result = [];
|
||||||
|
Object.entries(allPerms).forEach(([app, perms]) => {
|
||||||
|
if (Array.isArray(perms)) {
|
||||||
|
perms.forEach(p => result.push({ ...p, app: p.app || app }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}, [allPerms]);
|
||||||
|
|
||||||
|
if (loading) return <div className="text-center py-12 text-txt-muted">Loading...</div>;
|
||||||
|
if (!role) return <div className="text-center py-12 text-txt-muted">Role not found</div>;
|
||||||
|
|
||||||
|
const users = role.users || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => navigate('/roles')} className="text-sm text-accent hover:text-accent-hover mb-4 inline-block">← Back to Roles</button>
|
||||||
|
|
||||||
|
{/* Role Info */}
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl p-6 mb-6">
|
||||||
|
{editMode ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-txt-muted mb-1">Display Name</label>
|
||||||
|
<input value={editDisplay} onChange={e => setEditDisplay(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-txt-primary focus:outline-none focus:border-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-txt-muted mb-1">Description</label>
|
||||||
|
<textarea value={editDesc} onChange={e => setEditDesc(e.target.value)} rows={2}
|
||||||
|
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-txt-primary focus:outline-none focus:border-accent resize-none" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-txt-muted mb-1">Priority</label>
|
||||||
|
<input type="number" value={editPriority} onChange={e => setEditPriority(e.target.value)}
|
||||||
|
className="w-32 px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-txt-primary focus:outline-none focus:border-accent" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={handleSaveInfo} className="px-3 py-1.5 bg-accent text-white text-sm rounded-lg hover:bg-accent-hover">Save</button>
|
||||||
|
<button onClick={() => setEditMode(false)} className="px-3 py-1.5 text-sm text-txt-secondary hover:text-txt-primary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h1 className="text-xl font-bold text-txt-primary">{role.display_name || role.name}</h1>
|
||||||
|
<RoleBadge role={role.name} />
|
||||||
|
{role.is_system && <span className="text-xs bg-yellow-500/15 text-yellow-400 px-2 py-0.5 rounded">System</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-txt-muted font-mono mb-1">{role.name}</div>
|
||||||
|
{role.description && <p className="text-sm text-txt-secondary">{role.description}</p>}
|
||||||
|
<div className="text-xs text-txt-muted mt-2">Priority: {role.priority}</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setEditMode(true)} className="text-xs text-accent hover:text-accent-hover font-medium">Edit</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permission Matrix */}
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden mb-6">
|
||||||
|
<div className="px-4 py-3 bg-surface-2 border-b border-border flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-txt-primary">Permission Matrix</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{saveMsg && <span className={`text-xs ${saveMsg.startsWith('Error') ? 'text-danger' : 'text-green-400'}`}>{saveMsg}</span>}
|
||||||
|
{dirty && (
|
||||||
|
<button
|
||||||
|
onClick={handleSavePerms}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-1.5 bg-accent text-white text-sm rounded-lg hover:bg-accent-hover disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<PermissionMatrix
|
||||||
|
allPermissions={flatPerms}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onChange={handlePermChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Users with this role */}
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
||||||
|
<div className="px-4 py-3 bg-surface-2 border-b border-border">
|
||||||
|
<h2 className="text-sm font-semibold text-txt-primary">Users with this Role ({users.length})</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-center text-txt-muted text-sm">No users have this role</div>
|
||||||
|
) : (
|
||||||
|
users.map((u, i) => (
|
||||||
|
<div
|
||||||
|
key={u.email || i}
|
||||||
|
className="px-4 py-3 flex items-center justify-between hover:bg-surface-2 cursor-pointer transition-colors"
|
||||||
|
onClick={() => navigate(`/users/${encodeURIComponent(u.email)}`)}
|
||||||
|
>
|
||||||
|
<div className="text-sm text-txt-primary">{u.email}</div>
|
||||||
|
<div className="text-xs text-txt-muted">{u.scope === '*' ? 'Global' : u.scope}</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
admin-ui/src/pages/Roles.jsx
Normal file
158
admin-ui/src/pages/Roles.jsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useApi, apiPost } from '../hooks/useApi';
|
||||||
|
import DataTable from '../components/DataTable';
|
||||||
|
import Modal from '../components/Modal';
|
||||||
|
|
||||||
|
export default function Roles() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: roles, loading, refetch } = useApi('/api/roles');
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [newDisplay, setNewDisplay] = useState('');
|
||||||
|
const [newDesc, setNewDesc] = useState('');
|
||||||
|
const [newPriority, setNewPriority] = useState(100);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState('');
|
||||||
|
|
||||||
|
const handleCreate = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setCreating(true);
|
||||||
|
setCreateError('');
|
||||||
|
try {
|
||||||
|
await apiPost('/api/roles', {
|
||||||
|
name: newName.toLowerCase().replace(/\s+/g, '_'),
|
||||||
|
display_name: newDisplay,
|
||||||
|
description: newDesc,
|
||||||
|
priority: Number(newPriority),
|
||||||
|
});
|
||||||
|
setShowCreate(false);
|
||||||
|
setNewName('');
|
||||||
|
setNewDisplay('');
|
||||||
|
setNewDesc('');
|
||||||
|
setNewPriority(100);
|
||||||
|
refetch();
|
||||||
|
} catch (err) {
|
||||||
|
setCreateError(err.message);
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: 'Role',
|
||||||
|
render: (v, row) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{row.is_system && <span title="System role" className="text-txt-muted">🔒</span>}
|
||||||
|
<div>
|
||||||
|
<div className="text-txt-primary font-medium">{row.display_name || v}</div>
|
||||||
|
<div className="text-xs text-txt-muted font-mono">{v}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'is_system',
|
||||||
|
label: 'Type',
|
||||||
|
render: (v) => (
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded ${v ? 'bg-yellow-500/15 text-yellow-400' : 'bg-surface-3 text-txt-muted'}`}>
|
||||||
|
{v ? 'System' : 'Custom'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'user_count',
|
||||||
|
label: 'Users',
|
||||||
|
render: (v) => <span className="text-sm">{v ?? 0}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'permission_count',
|
||||||
|
label: 'Permissions',
|
||||||
|
render: (v) => <span className="text-sm">{v ?? 0}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'priority',
|
||||||
|
label: 'Priority',
|
||||||
|
render: (v) => <span className="text-xs text-txt-muted">{v}</span>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-txt-primary">Roles</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="px-4 py-2 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors"
|
||||||
|
>
|
||||||
|
+ Create Role
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12 text-txt-muted">Loading roles...</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={roles}
|
||||||
|
onRowClick={(row) => navigate(`/roles/${row.name}`)}
|
||||||
|
emptyMessage="No roles defined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal open={showCreate} onClose={() => setShowCreate(false)} title="Create Role">
|
||||||
|
<form onSubmit={handleCreate} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-txt-secondary mb-1">Name (slug)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={newName}
|
||||||
|
onChange={e => setNewName(e.target.value)}
|
||||||
|
placeholder="e.g. content_editor"
|
||||||
|
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-txt-primary focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-txt-secondary mb-1">Display Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={newDisplay}
|
||||||
|
onChange={e => setNewDisplay(e.target.value)}
|
||||||
|
placeholder="e.g. Content Editor"
|
||||||
|
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-txt-primary focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-txt-secondary mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={newDesc}
|
||||||
|
onChange={e => setNewDesc(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-txt-primary focus:outline-none focus:border-accent resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-txt-secondary mb-1">Priority</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={newPriority}
|
||||||
|
onChange={e => setNewPriority(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-txt-primary focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{createError && <div className="text-sm text-danger">{createError}</div>}
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button type="button" onClick={() => setShowCreate(false)} className="px-4 py-2 text-sm text-txt-secondary">Cancel</button>
|
||||||
|
<button type="submit" disabled={creating} className="px-4 py-2 bg-accent text-white text-sm rounded-lg hover:bg-accent-hover disabled:opacity-50">
|
||||||
|
{creating ? 'Creating...' : 'Create Role'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
admin-ui/src/pages/Services.jsx
Normal file
88
admin-ui/src/pages/Services.jsx
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useApi } from '../hooks/useApi';
|
||||||
|
import DataTable from '../components/DataTable';
|
||||||
|
|
||||||
|
export default function Services() {
|
||||||
|
const { data: services, loading } = useApi('/api/services/list');
|
||||||
|
const { data: features } = useApi('/api/features');
|
||||||
|
const [expanded, setExpanded] = useState(null);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: 'id',
|
||||||
|
label: 'Service ID',
|
||||||
|
render: (v) => <span className="font-mono text-txt-primary">{v}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'display_name',
|
||||||
|
label: 'Name',
|
||||||
|
render: (v, row) => <span className="text-txt-secondary">{v || row.name || row.id}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hostname',
|
||||||
|
label: 'Hostname',
|
||||||
|
render: (v) => v ? <span className="text-xs font-mono text-accent">{v}</span> : <span className="text-txt-muted">—</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'feature_count',
|
||||||
|
label: 'Features',
|
||||||
|
render: (_, row) => {
|
||||||
|
const count = features ? (Array.isArray(features) ? features : []).filter(f => f.app === row.id).length : 0;
|
||||||
|
return <span className="text-sm">{count}</span>;
|
||||||
|
},
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get features for a given service
|
||||||
|
const getServiceFeatures = (serviceId) => {
|
||||||
|
if (!features) return [];
|
||||||
|
const list = Array.isArray(features) ? features : [];
|
||||||
|
return list.filter(f => f.app === serviceId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-txt-primary">Services</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12 text-txt-muted">Loading services...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={services || []}
|
||||||
|
onRowClick={(row) => setExpanded(expanded === row.id ? null : row.id)}
|
||||||
|
emptyMessage="No services registered"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="mt-4 bg-surface-1 border border-border rounded-xl overflow-hidden">
|
||||||
|
<div className="px-4 py-3 bg-surface-2 border-b border-border">
|
||||||
|
<h2 className="text-sm font-semibold text-txt-primary">
|
||||||
|
Features for <span className="font-mono text-accent">{expanded}</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
{getServiceFeatures(expanded).length === 0 ? (
|
||||||
|
<div className="text-center text-txt-muted text-sm py-4">No features registered for this service</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||||
|
{getServiceFeatures(expanded).map((f, i) => (
|
||||||
|
<div key={f.id || i} className="px-3 py-2 bg-surface-2 rounded-lg">
|
||||||
|
<div className="text-sm font-mono text-txt-primary">{f.feature}.{f.action}</div>
|
||||||
|
<div className="text-xs text-txt-muted">{f.category || 'General'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
admin-ui/src/pages/UserDetail.jsx
Normal file
217
admin-ui/src/pages/UserDetail.jsx
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useApi, apiPost, apiDelete, apiPut } from '../hooks/useApi';
|
||||||
|
import RoleBadge from '../components/RoleBadge';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import Modal from '../components/Modal';
|
||||||
|
|
||||||
|
function timeAgo(d) {
|
||||||
|
if (!d) return 'Never';
|
||||||
|
const diff = Date.now() - new Date(d).getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
if (hrs < 24) return `${hrs}h ago`;
|
||||||
|
return `${Math.floor(hrs / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserDetail() {
|
||||||
|
const { email } = useParams();
|
||||||
|
const decodedEmail = decodeURIComponent(email);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data: users } = useApi('/api/users');
|
||||||
|
const { data: rolesData, refetch: refetchRoles } = useApi(`/api/users/${encodeURIComponent(decodedEmail)}/roles`);
|
||||||
|
const { data: permsData } = useApi(`/api/permissions/user/${encodeURIComponent(decodedEmail)}`);
|
||||||
|
const { data: auditData } = useApi(`/api/audit/user/${encodeURIComponent(decodedEmail)}`);
|
||||||
|
const { data: allRoles } = useApi('/api/roles');
|
||||||
|
|
||||||
|
const user = useMemo(() => (users || []).find(u => u.email === decodedEmail), [users, decodedEmail]);
|
||||||
|
|
||||||
|
const [showAssign, setShowAssign] = useState(false);
|
||||||
|
const [assignRole, setAssignRole] = useState('');
|
||||||
|
const [assignScope, setAssignScope] = useState('*');
|
||||||
|
const [assigning, setAssigning] = useState(false);
|
||||||
|
|
||||||
|
const initials = (decodedEmail || '?').split('@')[0].split(/[._-]+/).map(p => p[0]).join('').slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
const handleAssignRole = async () => {
|
||||||
|
if (!assignRole) return;
|
||||||
|
setAssigning(true);
|
||||||
|
try {
|
||||||
|
await apiPost(`/api/users/${encodeURIComponent(decodedEmail)}/roles`, { role: assignRole, scope: assignScope });
|
||||||
|
setShowAssign(false);
|
||||||
|
setAssignRole('');
|
||||||
|
setAssignScope('*');
|
||||||
|
refetchRoles();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
} finally {
|
||||||
|
setAssigning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveRole = async (role) => {
|
||||||
|
if (!confirm(`Remove role "${role}" from ${decodedEmail}?`)) return;
|
||||||
|
try {
|
||||||
|
await apiDelete(`/api/users/${encodeURIComponent(decodedEmail)}/roles/${role}`);
|
||||||
|
refetchRoles();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const userRoles = rolesData?.roles || rolesData || [];
|
||||||
|
const permissions = permsData?.permissions || [];
|
||||||
|
const auditEvents = auditData?.events || auditData || [];
|
||||||
|
|
||||||
|
// Group permissions by app
|
||||||
|
const permsByApp = useMemo(() => {
|
||||||
|
const map = {};
|
||||||
|
permissions.forEach(p => {
|
||||||
|
const parts = p.split('.');
|
||||||
|
const app = parts[0] || '*';
|
||||||
|
if (!map[app]) map[app] = [];
|
||||||
|
map[app].push(p);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [permissions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => navigate('/users')} className="text-sm text-accent hover:text-accent-hover mb-4 inline-block">← Back to Users</button>
|
||||||
|
|
||||||
|
{/* Profile Card */}
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl p-6 mb-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-accent/20 text-accent text-xl font-bold flex items-center justify-center flex-shrink-0">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h1 className="text-xl font-bold text-txt-primary">{user?.name || decodedEmail}</h1>
|
||||||
|
<div className="text-sm text-txt-muted mt-0.5">{decodedEmail}</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||||
|
<StatusBadge status={user?.status || 'active'} />
|
||||||
|
{user?.is_super ? <RoleBadge role="super_admin" /> : null}
|
||||||
|
<span className="text-xs text-txt-muted">Created: {user?.created_at ? new Date(user.created_at).toLocaleDateString() : '—'}</span>
|
||||||
|
<span className="text-xs text-txt-muted">Last login: {timeAgo(user?.last_login)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Roles */}
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
||||||
|
<div className="px-4 py-3 bg-surface-2 border-b border-border flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-txt-primary">Assigned Roles</h2>
|
||||||
|
<button onClick={() => setShowAssign(true)} className="text-xs text-accent hover:text-accent-hover font-medium">+ Assign Role</button>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{userRoles.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-center text-txt-muted text-sm">No roles assigned</div>
|
||||||
|
) : (
|
||||||
|
userRoles.map((r, i) => (
|
||||||
|
<div key={r.role || i} className="px-4 py-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RoleBadge role={r.role} scope={r.scope} />
|
||||||
|
{r.granted_at && <span className="text-xs text-txt-muted">{timeAgo(r.granted_at)}</span>}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => handleRemoveRole(r.role)} className="text-xs text-danger hover:text-red-300 font-medium">Remove</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Effective Permissions */}
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
||||||
|
<div className="px-4 py-3 bg-surface-2 border-b border-border">
|
||||||
|
<h2 className="text-sm font-semibold text-txt-primary">Effective Permissions ({permissions.length})</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 max-h-80 overflow-y-auto">
|
||||||
|
{permissions.length === 0 ? (
|
||||||
|
<div className="text-center text-txt-muted text-sm py-4">No permissions</div>
|
||||||
|
) : (
|
||||||
|
Object.entries(permsByApp).map(([app, perms]) => (
|
||||||
|
<div key={app} className="mb-3">
|
||||||
|
<div className="text-xs font-medium text-txt-muted uppercase mb-1">{app === '*' ? 'Global' : app}</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{perms.map(p => (
|
||||||
|
<span key={p} className="text-xs font-mono bg-surface-3 text-txt-secondary px-2 py-0.5 rounded">{p}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Audit Log */}
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden mt-6">
|
||||||
|
<div className="px-4 py-3 bg-surface-2 border-b border-border">
|
||||||
|
<h2 className="text-sm font-semibold text-txt-primary">Audit Log</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border max-h-96 overflow-y-auto">
|
||||||
|
{auditEvents.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-center text-txt-muted text-sm">No audit events</div>
|
||||||
|
) : (
|
||||||
|
auditEvents.map((e, i) => (
|
||||||
|
<div key={e.id || i} className="px-4 py-3 hover:bg-surface-2 transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-mono bg-surface-3 text-txt-secondary px-2 py-0.5 rounded">{e.action}</span>
|
||||||
|
<span className="text-xs text-txt-muted">{timeAgo(e.created_at || e.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-txt-muted mt-1">
|
||||||
|
{e.actor} → {e.target_type}/{e.target_id}
|
||||||
|
{e.detail && <span className="text-txt-muted/60 ml-2">— {typeof e.detail === 'string' ? e.detail : JSON.stringify(e.detail)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assign Role Modal */}
|
||||||
|
<Modal open={showAssign} onClose={() => setShowAssign(false)} title="Assign Role">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-txt-secondary mb-1">Role</label>
|
||||||
|
<select
|
||||||
|
value={assignRole}
|
||||||
|
onChange={e => setAssignRole(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-txt-primary focus:outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="">Select a role...</option>
|
||||||
|
{(allRoles || []).map(r => (
|
||||||
|
<option key={r.name} value={r.name}>{r.display_name || r.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-txt-secondary mb-1">Scope</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={assignScope}
|
||||||
|
onChange={e => setAssignScope(e.target.value)}
|
||||||
|
placeholder="* (global)"
|
||||||
|
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-txt-primary focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-txt-muted mt-1">Use * for global, or a specific app ID</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button onClick={() => setShowAssign(false)} className="px-4 py-2 text-sm text-txt-secondary">Cancel</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAssignRole}
|
||||||
|
disabled={!assignRole || assigning}
|
||||||
|
className="px-4 py-2 bg-accent text-white text-sm rounded-lg hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{assigning ? 'Assigning...' : 'Assign'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
admin-ui/src/pages/Users.jsx
Normal file
174
admin-ui/src/pages/Users.jsx
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useApi, apiPost } from '../hooks/useApi';
|
||||||
|
import DataTable from '../components/DataTable';
|
||||||
|
import RoleBadge from '../components/RoleBadge';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import Modal from '../components/Modal';
|
||||||
|
|
||||||
|
function timeAgo(d) {
|
||||||
|
if (!d) return 'Never';
|
||||||
|
const diff = Date.now() - new Date(d).getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
if (hrs < 24) return `${hrs}h ago`;
|
||||||
|
return `${Math.floor(hrs / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Users() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: users, loading, refetch } = useApi('/api/users');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [roleFilter, setRoleFilter] = useState('');
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [newEmail, setNewEmail] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState('');
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!users) return [];
|
||||||
|
return users.filter(u => {
|
||||||
|
if (search) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
if (!u.email?.toLowerCase().includes(q) && !u.name?.toLowerCase().includes(q)) return false;
|
||||||
|
}
|
||||||
|
if (roleFilter && u.role !== roleFilter) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [users, search, roleFilter]);
|
||||||
|
|
||||||
|
const roles = useMemo(() => {
|
||||||
|
if (!users) return [];
|
||||||
|
return [...new Set(users.map(u => u.role).filter(Boolean))];
|
||||||
|
}, [users]);
|
||||||
|
|
||||||
|
const handleCreate = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setCreating(true);
|
||||||
|
setCreateError('');
|
||||||
|
try {
|
||||||
|
await apiPost('/api/users', { email: newEmail, password: newPassword });
|
||||||
|
setShowCreate(false);
|
||||||
|
setNewEmail('');
|
||||||
|
setNewPassword('');
|
||||||
|
refetch();
|
||||||
|
} catch (err) {
|
||||||
|
setCreateError(err.message);
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: 'email',
|
||||||
|
label: 'User',
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-accent/20 text-accent text-xs font-bold flex items-center justify-center flex-shrink-0">
|
||||||
|
{(row.email || '?').split('@')[0].split(/[._-]+/).map(p => p[0]).join('').slice(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-txt-primary font-medium">{row.email}</div>
|
||||||
|
{row.name && <div className="text-xs text-txt-muted">{row.name}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'role',
|
||||||
|
label: 'Role',
|
||||||
|
render: (v) => <RoleBadge role={v} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
render: (v) => <StatusBadge status={v || 'active'} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'last_login',
|
||||||
|
label: 'Last Login',
|
||||||
|
render: (v) => <span className="text-xs">{timeAgo(v)}</span>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-txt-primary">Users</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="px-4 py-2 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors"
|
||||||
|
>
|
||||||
|
+ Add User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by email or name..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="flex-1 min-w-[200px] px-3 py-2 bg-surface-1 border border-border rounded-lg text-sm text-txt-primary placeholder:text-txt-muted focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={roleFilter}
|
||||||
|
onChange={e => setRoleFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 bg-surface-1 border border-border rounded-lg text-sm text-txt-secondary focus:outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="">All Roles</option>
|
||||||
|
{roles.map(r => <option key={r} value={r}>{r}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12 text-txt-muted">Loading users...</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={filtered}
|
||||||
|
onRowClick={(row) => navigate(`/users/${encodeURIComponent(row.email)}`)}
|
||||||
|
emptyMessage="No users found"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create User Modal */}
|
||||||
|
<Modal open={showCreate} onClose={() => setShowCreate(false)} title="Add User">
|
||||||
|
<form onSubmit={handleCreate} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-txt-secondary mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={newEmail}
|
||||||
|
onChange={e => setNewEmail(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-txt-primary focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-txt-secondary mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
value={newPassword}
|
||||||
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-txt-primary focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{createError && <div className="text-sm text-danger">{createError}</div>}
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button type="button" onClick={() => setShowCreate(false)} className="px-4 py-2 text-sm text-txt-secondary hover:text-txt-primary">Cancel</button>
|
||||||
|
<button type="submit" disabled={creating} className="px-4 py-2 bg-accent text-white text-sm rounded-lg hover:bg-accent-hover disabled:opacity-50">
|
||||||
|
{creating ? 'Creating...' : 'Create User'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
admin-ui/tailwind.config.js
Normal file
31
admin-ui/tailwind.config.js
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
surface: {
|
||||||
|
0: 'var(--surface-0)',
|
||||||
|
1: 'var(--surface-1)',
|
||||||
|
2: 'var(--surface-2)',
|
||||||
|
3: 'var(--surface-3)',
|
||||||
|
4: 'var(--surface-4)',
|
||||||
|
},
|
||||||
|
txt: {
|
||||||
|
primary: 'var(--text-primary)',
|
||||||
|
secondary: 'var(--text-secondary)',
|
||||||
|
muted: 'var(--text-muted)',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'var(--accent)',
|
||||||
|
hover: 'var(--accent-hover)',
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
DEFAULT: 'var(--border)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
18
admin-ui/vite.config.js
Normal file
18
admin-ui/vite.config.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: '/app/',
|
||||||
|
build: {
|
||||||
|
outDir: '../public/app',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://127.0.0.1:3030',
|
||||||
|
'/auth': 'http://127.0.0.1:3030',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -28,6 +28,6 @@
|
||||||
"intelligentageintpartners.com": "intelligent-agent-partners",
|
"intelligentageintpartners.com": "intelligent-agent-partners",
|
||||||
"www.intelligentagentpartners.com": "intelligent-agent-partners"
|
"www.intelligentagentpartners.com": "intelligent-agent-partners"
|
||||||
},
|
},
|
||||||
"synced_at": "2026-04-16T00:56:20.841Z",
|
"synced_at": "2026-04-17T00:55:52.196Z",
|
||||||
"count": 27
|
"count": 27
|
||||||
}
|
}
|
||||||
|
|
@ -52,5 +52,65 @@
|
||||||
"authMethods": [
|
"authMethods": [
|
||||||
"google"
|
"google"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": "rolf.heimes@incorta.com",
|
||||||
|
"name": "Rolf Heimes",
|
||||||
|
"role": "client",
|
||||||
|
"services": [
|
||||||
|
"eco-usecasegen"
|
||||||
|
],
|
||||||
|
"metadata": {},
|
||||||
|
"tags": [
|
||||||
|
"ecosystem",
|
||||||
|
"incorta"
|
||||||
|
],
|
||||||
|
"authMethods": [
|
||||||
|
"password"
|
||||||
|
],
|
||||||
|
"passwordHash": null,
|
||||||
|
"createdAt": "2026-04-16",
|
||||||
|
"createdBy": "scott@scottfelten.com",
|
||||||
|
"lastLogin": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": "victoria@forerunners.org",
|
||||||
|
"name": "Victoria Thompson",
|
||||||
|
"role": "client",
|
||||||
|
"services": [
|
||||||
|
"eco-usecasegen"
|
||||||
|
],
|
||||||
|
"metadata": {},
|
||||||
|
"tags": [
|
||||||
|
"ecosystem",
|
||||||
|
"forerunners"
|
||||||
|
],
|
||||||
|
"authMethods": [
|
||||||
|
"password"
|
||||||
|
],
|
||||||
|
"passwordHash": null,
|
||||||
|
"createdAt": "2026-04-16",
|
||||||
|
"createdBy": "scott@scottfelten.com",
|
||||||
|
"lastLogin": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": "zaid@forerunners.org",
|
||||||
|
"name": "Zaid Altalib",
|
||||||
|
"role": "client",
|
||||||
|
"services": [
|
||||||
|
"eco-usecasegen"
|
||||||
|
],
|
||||||
|
"metadata": {},
|
||||||
|
"tags": [
|
||||||
|
"ecosystem",
|
||||||
|
"forerunners"
|
||||||
|
],
|
||||||
|
"authMethods": [
|
||||||
|
"password"
|
||||||
|
],
|
||||||
|
"passwordHash": null,
|
||||||
|
"createdAt": "2026-04-16",
|
||||||
|
"createdBy": "scott@scottfelten.com",
|
||||||
|
"lastLogin": null
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
67
public/app/assets/index-CTQAz6zD.js
Normal file
67
public/app/assets/index-CTQAz6zD.js
Normal file
File diff suppressed because one or more lines are too long
1
public/app/assets/index-DnFhvBCy.css
Normal file
1
public/app/assets/index-DnFhvBCy.css
Normal file
File diff suppressed because one or more lines are too long
14
public/app/index.html
Normal file
14
public/app/index.html
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Access Manager — Admin</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔐</text></svg>" />
|
||||||
|
<script type="module" crossorigin src="/app/assets/index-CTQAz6zD.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/app/assets/index-DnFhvBCy.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface-0 text-txt-primary">
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -106,7 +106,7 @@
|
||||||
<a href="https://keys.scottfelten.com" class="btn btn-ghost btn-sm" style="text-decoration:none;" target="_blank">🔑 Keys</a>
|
<a href="https://keys.scottfelten.com" class="btn btn-ghost btn-sm" style="text-decoration:none;" target="_blank">🔑 Keys</a>
|
||||||
<a href="/docs.html" class="btn btn-ghost btn-sm" style="text-decoration:none;">? Help</a>
|
<a href="/docs.html" class="btn btn-ghost btn-sm" style="text-decoration:none;">? Help</a>
|
||||||
<button class="btn btn-ghost btn-sm" onclick="syncServices()" title="Refresh services from inventory">↻ Sync</button>
|
<button class="btn btn-ghost btn-sm" onclick="syncServices()" title="Refresh services from inventory">↻ Sync</button>
|
||||||
<button class="btn btn-primary" onclick="showAddModal()">+ Add User</button>
|
<a href="/app/" class="btn btn-primary" style="text-decoration:none;">🔐 Admin Panel</a> <button class="btn btn-primary" onclick="showAddModal()">+ Add User</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,16 @@ module.exports = function mountRbacRoutes(app, requireAdmin) {
|
||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Internal: Get user permissions (service-to-service, no auth) ───
|
||||||
|
app.post('/api/internal/permissions/user', (req, res) => {
|
||||||
|
const { email, app: appId } = req.body;
|
||||||
|
if (!email) return res.status(400).json({ error: 'email required' });
|
||||||
|
const result = appId
|
||||||
|
? rbac.getEffectivePermissionsByEmail(email, appId)
|
||||||
|
: rbac.getEffectivePermissionsByEmail(email);
|
||||||
|
res.json({ email, ...(appId ? { app: appId } : {}), ...result });
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/permissions/user/:email', requireAdmin, (req, res) => {
|
app.get('/api/permissions/user/:email', requireAdmin, (req, res) => {
|
||||||
const result = rbac.getEffectivePermissionsByEmail(req.params.email);
|
const result = rbac.getEffectivePermissionsByEmail(req.params.email);
|
||||||
res.json({ email: req.params.email, ...result });
|
res.json({ email: req.params.email, ...result });
|
||||||
|
|
|
||||||
40
server.js
40
server.js
|
|
@ -324,7 +324,10 @@ app.get('/auth/check', (req, res) => {
|
||||||
// Trusted IPs bypass (TARS agent browser on Mac Studio)
|
// Trusted IPs bypass (TARS agent browser on Mac Studio)
|
||||||
const TRUSTED_IPS = ['75.164.159.96'];
|
const TRUSTED_IPS = ['75.164.159.96'];
|
||||||
if (TRUSTED_IPS.includes(clientIp)) {
|
if (TRUSTED_IPS.includes(clientIp)) {
|
||||||
console.log(`[auth/check] Trusted IP ${clientIp} — auto-granting as scott@scottfelten.com`);
|
const trustedEmail = 'scott@scottfelten.com';
|
||||||
|
console.log(`[auth/check] Trusted IP ${clientIp} — auto-granting as ${trustedEmail}`);
|
||||||
|
res.setHeader('X-Auth-Request-User', trustedEmail);
|
||||||
|
res.setHeader('X-Auth-Request-Email', trustedEmail);
|
||||||
return res.status(200).send('OK');
|
return res.status(200).send('OK');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -568,11 +571,18 @@ app.post('/auth/password/reset', async (req, res) => {
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
app.get('/auth/session', (req, res) => {
|
app.get('/auth/session', (req, res) => {
|
||||||
|
// Check our own session cookie first
|
||||||
const amSession = req.cookies?.['_am_session'];
|
const amSession = req.cookies?.['_am_session'];
|
||||||
if (amSession) {
|
if (amSession) {
|
||||||
const session = verifySession(amSession);
|
const session = verifySession(amSession);
|
||||||
if (session) return res.json({ authenticated: true, ...session });
|
if (session) return res.json({ authenticated: true, ...session });
|
||||||
}
|
}
|
||||||
|
// Fall back to nginx-forwarded headers (oauth2-proxy / Google SSO)
|
||||||
|
const email = req.headers['x-email'] || req.headers['x-forwarded-email'] || '';
|
||||||
|
if (email) {
|
||||||
|
const user = findUser(email);
|
||||||
|
if (user) return res.json({ authenticated: true, email: user.email, name: user.name, role: user.role, services: user.services, method: 'google' });
|
||||||
|
}
|
||||||
res.json({ authenticated: false });
|
res.json({ authenticated: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -795,6 +805,34 @@ try {
|
||||||
console.error('[rbac] Failed to mount RBAC routes:', e.message);
|
console.error('[rbac] Failed to mount RBAC routes:', e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Whoami (for admin SPA — uses nginx X-Email header) ───
|
||||||
|
app.get('/api/whoami', (req, res) => {
|
||||||
|
// Check our own session cookie first
|
||||||
|
const amSession = req.cookies?.['_am_session'];
|
||||||
|
if (amSession) {
|
||||||
|
const session = verifySession(amSession);
|
||||||
|
if (session) {
|
||||||
|
const user = findUser(session.email);
|
||||||
|
return res.json({ authenticated: true, email: session.email, name: session.name || user?.name, role: user?.role || session.role, services: user?.services || session.services, method: session.method });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back to nginx-forwarded headers (oauth2-proxy / Google SSO)
|
||||||
|
const email = req.headers['x-email'] || req.headers['x-forwarded-email'] || '';
|
||||||
|
if (email) {
|
||||||
|
const user = findUser(email);
|
||||||
|
if (user) return res.json({ authenticated: true, email: user.email, name: user.name, role: user.role, services: user.services, method: 'google' });
|
||||||
|
// User authenticated via Google but not in our user store yet
|
||||||
|
return res.json({ authenticated: true, email, name: '', role: 'viewer', services: [], method: 'google' });
|
||||||
|
}
|
||||||
|
res.status(401).json({ authenticated: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── SPA catch-all for Admin UI ───
|
||||||
|
app.get("/app", (req, res) => res.redirect("/app/"));
|
||||||
|
app.get("/app/*", (req, res) => {
|
||||||
|
res.sendFile(require("path").join(__dirname, "public", "app", "index.html"));
|
||||||
|
});
|
||||||
|
|
||||||
app.listen(PORT, HOST, async () => {
|
app.listen(PORT, HOST, async () => {
|
||||||
console.log(`access-manager v3 listening on ${HOST}:${PORT}`);
|
console.log(`access-manager v3 listening on ${HOST}:${PORT}`);
|
||||||
console.log(`Users: ${loadUsers().length}`);
|
console.log(`Users: ${loadUsers().length}`);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue