- 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
419 lines
19 KiB
HTML
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>
|