access-manager/public/index.html
TARS 0b0871ffea feat: Access Manager v3 — RBAC engine, SQLite, permission system
- SQLite database with full schema: users, roles, permissions,
  role_permissions, user_roles, services, audit_log
- RBAC engine with wildcard permission resolution (*.*.*)
- Automatic v2→v3 migration from JSON files
- 5 default roles: super_admin, admin, editor, user, viewer
- Feature registration for APP, GGL, FDX (119 permissions)
- 8 services seeded
- Full API: roles CRUD, permission check, user-role assignment,
  feature registration, audit log, stats
- Backward compatible with existing auth flows
2026-04-16 00:57:27 +00:00

419 lines
19 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Management — scottfelten.com</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0f172a; color: #e2e8f0; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
.container { max-width: 960px; margin: 0 auto; padding: 24px; }
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
.header h1 { font-size: 20px; font-weight: 700; color: white; }
.header-sub { font-size: 12px; color: #64748b; margin-top: 2px; }
.header-right { display: flex; align-items: center; gap: 12px; }
.user-count { font-size: 12px; color: #64748b; }
.status-bar { display: flex; gap: 16px; margin-bottom: 16px; font-size: 11px; color: #64748b; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; margin-right: 4px; }
.status-dot.green { background: #22c55e; }
.status-dot.yellow { background: #eab308; }
.status-dot.red { background: #ef4444; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; overflow: hidden; }
.card-row { display: grid; grid-template-columns: 1fr auto auto auto auto; align-items: center; gap: 12px; padding: 14px 20px; border-bottom: 1px solid #1e293b; }
.card-row:hover { background: rgba(30,41,59,0.5); }
.card-row:last-child { border-bottom: none; }
.card-header { background: rgba(15,23,42,0.5); padding: 10px 20px; border-bottom: 1px solid #334155; }
.card-header span { font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
.card-header-grid { display: grid; grid-template-columns: 1fr auto auto auto auto; gap: 12px; }
.user-name { font-weight: 600; font-size: 13px; color: white; }
.user-email { font-size: 11px; color: #64748b; margin-top: 1px; }
.user-meta { font-size: 11px; color: #475569; }
.pill { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 6px; font-size: 11px; font-weight: 600; white-space: nowrap; }
.pill-admin { background: rgba(124,58,237,0.15); color: #a78bfa; }
.pill-partner { background: rgba(37,99,235,0.15); color: #60a5fa; }
.pill-client { background: rgba(5,150,105,0.15); color: #34d399; }
.pill-service { background: rgba(51,65,85,0.5); color: #94a3b8; margin: 2px; font-weight: 500; }
.pill-all { background: rgba(124,58,237,0.1); color: #a78bfa; font-style: italic; }
.pill-auth { font-size: 10px; padding: 2px 7px; }
.pill-google { background: rgba(66,133,244,0.15); color: #60a5fa; }
.pill-magic { background: rgba(168,85,247,0.15); color: #c084fc; }
.pill-password { background: rgba(234,179,8,0.15); color: #fbbf24; }
.services-wrap { display: flex; flex-wrap: wrap; gap: 4px; max-width: 300px; }
.auth-wrap { display: flex; flex-wrap: wrap; gap: 3px; }
.btn { padding: 6px 14px; border-radius: 6px; font-weight: 600; font-size: 12px; cursor: pointer; border: none; transition: all 0.15s; }
.btn-primary { background: #3b82f6; color: white; }
.btn-primary:hover { background: #2563eb; }
.btn-ghost { background: transparent; color: #94a3b8; border: 1px solid rgba(100,100,140,0.3); }
.btn-ghost:hover { background: #334155; color: white; }
.btn-danger { background: transparent; color: #ef4444; border: 1px solid rgba(239,68,68,0.3); }
.btn-danger:hover { background: rgba(239,68,68,0.15); }
.btn-sm { padding: 4px 10px; font-size: 11px; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 100; display: flex; align-items: center; justify-content: center; }
.modal { background: #1e293b; border: 1px solid #334155; border-radius: 12px; width: 90%; max-width: 560px; padding: 20px; max-height: 85vh; overflow-y: auto; }
.modal h3 { font-size: 16px; font-weight: 700; color: white; }
.modal-sub { font-size: 11px; color: #64748b; margin-top: 2px; }
.field { margin-bottom: 14px; }
.field-label { display: block; font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }
.field-hint { color: #475569; font-weight: 400; text-transform: none; letter-spacing: normal; }
.input-field { width: 100%; background: #0f172a; border: 1px solid #334155; border-radius: 8px; padding: 10px 14px; font-size: 13px; color: #e2e8f0; outline: none; font-family: inherit; transition: border-color 0.15s; }
.input-field:focus { border-color: #3b82f6; }
.input-field::placeholder { color: #475569; }
.chip { display: inline-flex; align-items: center; padding: 5px 12px; border-radius: 6px; font-size: 11px; font-weight: 600; cursor: pointer; border: 1px solid rgba(100,100,140,0.3); transition: all 0.15s; color: #64748b; }
.chip:hover { border-color: #60a5fa; }
.chip.active { border-color: #60a5fa; background: rgba(96,165,250,0.15); color: #93c5fd; }
.svc-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 8px; }
.svc-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 6px; border: 1px solid #334155; cursor: pointer; transition: all 0.15s; font-size: 12px; color: #94a3b8; }
.svc-item:hover { border-color: #475569; }
.svc-item.active { border-color: #3b82f6; background: rgba(59,130,246,0.1); color: #60a5fa; }
.svc-item .check { width: 14px; height: 14px; border-radius: 3px; border: 1px solid #475569; display: flex; align-items: center; justify-content: center; font-size: 10px; flex-shrink: 0; }
.svc-item.active .check { background: #3b82f6; border-color: #3b82f6; color: white; }
.svc-all { grid-column: 1 / -1; background: rgba(124,58,237,0.05); border-color: rgba(124,58,237,0.2); }
.svc-all.active { background: rgba(124,58,237,0.15); border-color: #7c3aed; color: #a78bfa; }
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; padding-top: 14px; border-top: 1px solid #334155; }
.empty { text-align: center; color: #475569; padding: 40px 20px; font-size: 13px; }
.section-title { font-size: 12px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin: 20px 0 8px; }
@media (max-width: 640px) {
.card-row { grid-template-columns: 1fr; gap: 8px; }
.card-header-grid { grid-template-columns: 1fr; }
.svc-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div>
<h1>User Management</h1>
<div class="header-sub">scottfelten.com services</div>
</div>
<div class="header-right">
<span class="user-count" id="user-count"></span>
<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>
<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>
</div>
</div>
<div class="status-bar" id="status-bar">Loading...</div>
<div class="card">
<div class="card-header">
<div class="card-header-grid">
<span>User</span>
<span>Role</span>
<span>Auth</span>
<span>Services</span>
<span></span>
</div>
</div>
<div id="users-body">
<div class="empty">Loading...</div>
</div>
</div>
</div>
<div id="modalRoot"></div>
<script>
let allServices = [];
let allUsers = [];
let healthData = {};
async function load() {
const [usersRes, servicesRes, healthRes] = await Promise.all([
fetch('/api/users'),
fetch('/api/services'),
fetch('/health'),
]);
allUsers = await usersRes.json();
allServices = await servicesRes.json();
healthData = await healthRes.json();
render();
renderStatus();
}
function renderStatus() {
const emailDot = healthData.emailConfigured ? 'green' : 'yellow';
const emailLabel = healthData.emailConfigured ? 'Email configured' : 'Email: console mode';
document.getElementById('status-bar').innerHTML = `
<span><span class="status-dot green"></span> ${healthData.services || 0} services</span>
<span><span class="status-dot green"></span> ${healthData.users || 0} users</span>
<span><span class="status-dot ${emailDot}"></span> ${emailLabel}</span>
`;
}
function render() {
document.getElementById('user-count').textContent = `${allUsers.length} user${allUsers.length !== 1 ? 's' : ''}`;
const body = document.getElementById('users-body');
if (allUsers.length === 0) {
body.innerHTML = '<div class="empty">No users</div>';
return;
}
body.innerHTML = allUsers.map(u => {
const authMethods = u.authMethods || ['google'];
const authPills = authMethods.map(m =>
`<span class="pill pill-auth pill-${m}">${m === 'magic-link' ? 'magic' : m}</span>`
).join('');
return `
<div class="card-row">
<div>
<div class="user-name">${esc(u.name)}</div>
<div class="user-email">${esc(u.email)}</div>
</div>
<div><span class="pill pill-${u.role}">${u.role}</span></div>
<div class="auth-wrap">${authPills}</div>
<div class="services-wrap">
${u.services.includes('*')
? '<span class="pill pill-all">All Services</span>'
: u.services.slice(0, 3).map(s => `<span class="pill pill-service">${esc(s)}</span>`).join('')
+ (u.services.length > 3 ? `<span class="pill pill-service">+${u.services.length - 3}</span>` : '')
}
</div>
<div style="display:flex;gap:6px;justify-content:flex-end">
${u.hasPassword ? '' : `<button class="btn btn-ghost btn-sm" onclick="setPassword('${esc(u.email)}')" title="Set password">🔑</button>`}
<button class="btn btn-ghost" onclick="editUser('${esc(u.email)}')">Edit</button>
${u.role !== 'admin' ? `<button class="btn btn-danger" onclick="deleteUser('${esc(u.email)}')">Remove</button>` : ''}
</div>
</div>`;
}).join('');
}
async function syncServices() {
const res = await fetch('/api/services/sync', { method: 'POST' });
const data = await res.json();
allServices = data.services;
alert(`Synced ${data.count} service mappings`);
render();
}
function showAddModal() {
renderModal({ email: '', name: '', role: 'client', services: [], tags: '', authMethods: ['google'], password: '' }, false);
}
function editUser(email) {
const u = allUsers.find(x => x.email === email);
if (!u) return;
renderModal({
email: u.email,
name: u.name,
role: u.role,
services: [...u.services],
tags: (u.tags || []).join(', '),
authMethods: [...(u.authMethods || ['google'])],
password: '',
}, true);
}
function renderModal(data, isEdit) {
const isAll = data.services.includes('*');
const roles = ['client', 'partner', 'admin'];
const authOptions = ['google', 'magic-link', 'password'];
document.getElementById('modalRoot').innerHTML = `
<div class="modal-overlay" onclick="if(event.target===this)closeModal()">
<div class="modal">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px">
<div>
<h3>${isEdit ? 'Edit User' : 'Add User'}</h3>
${isEdit ? `<div class="modal-sub">${esc(data.email)}</div>` : ''}
</div>
<button onclick="closeModal()" style="background:none;border:none;color:#64748b;cursor:pointer;font-size:16px">✕</button>
</div>
${!isEdit ? `
<div class="field">
<label class="field-label">Email</label>
<input id="f-email" type="email" placeholder="user@example.com" value="${esc(data.email)}" class="input-field">
</div>` : ''}
<div class="field">
<label class="field-label">Name</label>
<input id="f-name" type="text" placeholder="Full Name" value="${esc(data.name)}" class="input-field">
</div>
<div class="field">
<label class="field-label">Role</label>
<div style="display:flex;gap:8px;margin-top:2px">
${roles.map(r => `<span class="chip ${data.role === r ? 'active' : ''}" onclick="modalSetRole('${r}')">${r}</span>`).join('')}
</div>
</div>
<div class="field">
<label class="field-label">Auth Methods <span class="field-hint">(how they can sign in)</span></label>
<div style="display:flex;gap:8px;margin-top:2px">
${authOptions.map(a => `<span class="chip ${data.authMethods.includes(a) ? 'active' : ''}" onclick="modalToggleAuth('${a}')">${a === 'magic-link' ? '✨ Magic Link' : a === 'google' ? '🔵 Google' : '🔑 Password'}</span>`).join('')}
</div>
</div>
${!isEdit && data.authMethods.includes('password') ? `
<div class="field">
<label class="field-label">Initial Password <span class="field-hint">(min 12 chars, or set later)</span></label>
<input id="f-password" type="password" placeholder="Optional — can set later" value="${esc(data.password)}" class="input-field">
</div>` : ''}
<div class="field">
<label class="field-label">Services <span class="field-hint">(click to toggle)</span></label>
<div class="svc-grid">
<div class="svc-item svc-all ${isAll ? 'active' : ''}" onclick="modalToggleAll()">
<div class="check">${isAll ? '✓' : ''}</div>
All Services (*)
</div>
${allServices.map(s => `
<div class="svc-item ${data.services.includes(s) || isAll ? 'active' : ''}" onclick="modalToggleSvc('${esc(s)}')" ${isAll ? 'style="opacity:0.4;pointer-events:none"' : ''}>
<div class="check">${data.services.includes(s) || isAll ? '✓' : ''}</div>
${esc(s)}
</div>
`).join('')}
</div>
</div>
<div class="field">
<label class="field-label">Tags <span class="field-hint">(comma-separated)</span></label>
<input id="f-tags" type="text" placeholder="partner, bpo-expert" value="${esc(data.tags)}" class="input-field">
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveUser(${isEdit})">${isEdit ? 'Save Changes' : 'Add User'}</button>
</div>
</div>
</div>`;
window._modalData = data;
window._modalIsEdit = isEdit;
}
function modalSetRole(role) {
captureInputs();
window._modalData.role = role;
renderModal(window._modalData, window._modalIsEdit);
}
function modalToggleAuth(method) {
captureInputs();
const d = window._modalData;
const idx = d.authMethods.indexOf(method);
if (idx >= 0) {
if (d.authMethods.length <= 1) return; // Must have at least one
d.authMethods.splice(idx, 1);
} else {
d.authMethods.push(method);
}
renderModal(d, window._modalIsEdit);
}
function modalToggleAll() {
captureInputs();
const d = window._modalData;
d.services = d.services.includes('*') ? [] : ['*'];
renderModal(d, window._modalIsEdit);
}
function modalToggleSvc(svc) {
captureInputs();
const d = window._modalData;
const idx = d.services.indexOf(svc);
if (idx >= 0) d.services.splice(idx, 1); else d.services.push(svc);
renderModal(d, window._modalIsEdit);
}
function captureInputs() {
const d = window._modalData;
const el = (id) => document.getElementById(id);
if (el('f-email')) d.email = el('f-email').value;
if (el('f-name')) d.name = el('f-name').value;
if (el('f-tags')) d.tags = el('f-tags').value;
if (el('f-password')) d.password = el('f-password').value;
}
function closeModal() { document.getElementById('modalRoot').innerHTML = ''; }
async function saveUser(isEdit) {
captureInputs();
const d = window._modalData;
const email = d.email.trim();
const name = d.name.trim();
const tags = d.tags.split(',').map(t => t.trim()).filter(Boolean);
if (!email || !name) return alert('Email and name are required');
try {
if (isEdit) {
const res = await fetch(`/api/users/${encodeURIComponent(email)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, role: d.role, services: d.services, tags, authMethods: d.authMethods }),
});
if (!res.ok) { const e = await res.json(); return alert(e.error); }
} else {
const body = { email, name, role: d.role, services: d.services, tags, authMethods: d.authMethods };
if (d.password && d.password.length >= 12) body.password = d.password;
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.status === 409) return alert('User already exists');
if (!res.ok) { const e = await res.json(); return alert(e.error); }
}
closeModal();
await load();
} catch (e) {
alert('Error: ' + e.message);
}
}
async function setPassword(email) {
const pw = prompt(`Set password for ${email}\n(minimum 12 characters)`);
if (!pw) return;
if (pw.length < 12) return alert('Password must be at least 12 characters');
const res = await fetch('/auth/password/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password: pw }),
});
if (res.ok) {
alert('Password set ✓');
await load();
} else {
const e = await res.json();
alert(e.error);
}
}
async function deleteUser(email) {
if (!confirm(`Remove ${email}?`)) return;
await fetch(`/api/users/${encodeURIComponent(email)}`, { method: 'DELETE' });
await load();
}
function esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
load();
</script>
</body>
</html>