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:
TARS 2026-04-17 00:59:31 +00:00
parent 0b0871ffea
commit 7dfb0eab66
31 changed files with 4652 additions and 3 deletions

13
admin-ui/index.html Normal file
View 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

File diff suppressed because it is too large Load diff

22
admin-ui/package.json Normal file
View 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"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

91
admin-ui/src/App.jsx Normal file
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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">&times;</button>
</div>
<div className="p-4">
{children}
</div>
</div>
</div>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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>
);

View 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>
);
}

View 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>
);
}

View 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">&larr; 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>
);
}

View 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>
);
}

View 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>
);
}

View 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">&larr; 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>
);
}

View 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>
);
}

View 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
View 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',
},
},
});

View file

@ -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
} }

View file

@ -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
} }
] ]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

14
public/app/index.html Normal file
View 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>

View file

@ -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>

View file

@ -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 });

View file

@ -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}`);