Add internal staff IAM: named users, roles, and management permissions.
Some checks are pending
offline / test (push) Waiting to run

verae-staff-iam (:3028) is the people directory — owner, billing-admin,
cs, sales, accounting, operator, viewer — with scrypt passwords, sessions,
and an audit log. Admin console login uses it when STAFF_IAM_URL is set
and hides tabs the account cannot use. CS/sales/accounting/staff/fleet
check permissions such as cs.credit and fleet.operate. Shared staff key
remains only as a fallback when IAM is unset.
This commit is contained in:
George Lambert 2026-09-11 18:36:12 -04:00
parent 2740d51446
commit d299d245e8
41 changed files with 1337 additions and 52 deletions

View file

@ -0,0 +1,170 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Staff IAM</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
<style>
:root { --bg:#f4f5fb; --panel:#fff; --ink:#171a26; --muted:#6b7186; --line:#e5e7f0; --accent:#4f46e5; --ok:#047857; --danger:#dc2626; --radius:12px; --shadow:0 1px 2px rgba(23,26,38,.05), 0 8px 24px rgba(23,26,38,.06); }
* { box-sizing:border-box; }
body { margin:0; font:14px/1.45 -apple-system,"SF Pro Text","Segoe UI",sans-serif; background:var(--bg); color:var(--ink); }
header.app { background:linear-gradient(160deg,#312e81 0%,#4f46e5 60%,#7c74f0 100%); color:#eef0fe; padding:1.1rem 1.4rem; display:flex; justify-content:space-between; align-items:flex-end; gap:1rem; flex-wrap:wrap; }
.kicker { letter-spacing:.12em; text-transform:uppercase; font:700 10px system-ui; opacity:.75; }
header.app h1 { margin:.2rem 0 0; font-size:1.2rem; }
nav button { border:0; background:transparent; color:#c7c9ff; min-height:40px; padding:.55rem 1rem; border-radius:10px 10px 0 0; font:650 14px system-ui; cursor:pointer; }
nav button.on { background:var(--bg); color:var(--accent); }
main { max-width:1100px; margin:0 auto; padding:1.25rem 1.25rem 3rem; }
.card { background:#fff; border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:1.1rem 1.25rem; margin-bottom:1rem; }
.roles { display:flex; flex-wrap:wrap; gap:.35rem; }
.roles label { display:inline-flex; gap:.25rem; align-items:center; background:#eef0fe; border-radius:999px; padding:.2rem .55rem; font:650 11px system-ui; cursor:pointer; }
.pill { font:700 10px system-ui; letter-spacing:.05em; text-transform:uppercase; padding:.12rem .4rem; border-radius:999px; background:#eef0fe; color:var(--accent); }
.pill.off { background:#f1f5f9; color:#475569; }
table { width:100%; border-collapse:collapse; }
th { text-align:left; font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); border-bottom:1px solid var(--line); padding:.4rem .5rem; }
td { border-bottom:1px solid var(--line); padding:.5rem .5rem; vertical-align:top; }
input, select { padding:.4rem .55rem; border:1px solid var(--line); border-radius:8px; }
button.btn { border:0; border-radius:8px; padding:.42rem .85rem; background:var(--accent); color:#fff; font-weight:700; cursor:pointer; }
button.ghost { background:#fff; color:var(--ink); border:1px solid var(--line); }
.muted { color:var(--muted); font-size:12px; }
.row { display:flex; flex-wrap:wrap; gap:.6rem; align-items:end; }
</style>
</head>
<body>
<header class="app">
<div>
<div class="kicker">internal staff · permissions</div>
<h1>Staff IAM</h1>
<p class="muted" id="who" style="color:#e4e7ff;opacity:.9"></p>
</div>
<nav>
<button class="on" data-tab="users">People</button>
<button data-tab="audit">Audit</button>
<button data-tab="roles">Roles</button>
</nav>
</header>
<main>
<section id="users" class="panel"></section>
<section id="audit" class="panel" hidden></section>
<section id="roles" class="panel" hidden></section>
</main>
<script>
const $ = (id) => document.getElementById(id);
async function j(url, opts) {
const r = await fetch(url, { credentials: 'same-origin', ...opts, headers: { 'content-type': 'application/json', ...(opts && opts.headers) } });
const body = await r.json().catch(() => ({}));
if (r.status === 401) { location.href = '/login'; throw new Error('signed out'); }
if (!r.ok) throw new Error(body.error || body.reason || r.status);
return body;
}
let me = null;
let catalog = { roles: {}, permissions: [] };
function roleChecks(selected) {
return Object.entries(catalog.roles).map(([id, def]) =>
`<label><input type="checkbox" name="role" value="${id}" ${selected.includes(id) ? 'checked' : ''}/> ${def.title}</label>`
).join('');
}
async function drawUsers() {
const { users } = await j('/users');
$('users').innerHTML = `
<div class="card">
<h3 style="margin:.2rem 0 .8rem">People</h3>
<table><thead><tr><th>User</th><th>Roles</th><th>Permissions</th><th>Status</th><th></th></tr></thead>
<tbody>${users.map((u) => `<tr>
<td><b>${u.name}</b><div class="muted">${u.username}</div></td>
<td>${u.roles.map((r) => `<span class="pill">${r}</span>`).join(' ')}</td>
<td class="muted">${u.permissions.includes('*') ? 'all' : u.permissions.join(', ')}</td>
<td><span class="pill ${u.active ? '' : 'off'}">${u.active ? 'active' : 'inactive'}</span></td>
<td><button class="btn ghost" data-edit="${u.username}">Edit</button></td>
</tr>`).join('')}</tbody></table>
</div>
<div class="card" id="edit-card">
<h3 style="margin:.2rem 0 .8rem">Add staff user</h3>
<div class="row">
<div><label class="muted">Username</label><br><input id="new-user" autocomplete="off"/></div>
<div><label class="muted">Display name</label><br><input id="new-name"/></div>
<div><label class="muted">Password</label><br><input id="new-pass" type="password" autocomplete="new-password"/></div>
</div>
<p class="muted">Roles</p>
<div class="roles" id="new-roles">${roleChecks(['viewer'])}</div>
<p><button class="btn" id="add">Create</button></p>
</div>`;
$('add').onclick = async () => {
const roles = [...document.querySelectorAll('#new-roles input:checked')].map((i) => i.value);
await j('/users', { method: 'POST', body: JSON.stringify({
username: $('new-user').value.trim(),
name: $('new-name').value.trim(),
password: $('new-pass').value,
roles,
}) });
await drawUsers();
};
document.querySelectorAll('[data-edit]').forEach((b) => b.onclick = () => openEdit(users.find((u) => u.username === b.dataset.edit)));
}
function openEdit(u) {
$('edit-card').innerHTML = `
<h3 style="margin:.2rem 0 .8rem">Edit ${u.name}</h3>
<div class="row">
<div><label class="muted">Display name</label><br><input id="ed-name" value="${u.name}"/></div>
<div><label class="muted">New password (optional)</label><br><input id="ed-pass" type="password" autocomplete="new-password"/></div>
</div>
<p class="muted">Roles</p>
<div class="roles" id="ed-roles">${roleChecks(u.roles)}</div>
<p>
<button class="btn" id="save">Save</button>
<button class="btn ghost" id="tog">${u.active ? 'Deactivate' : 'Activate'}</button>
</p>`;
$('save').onclick = async () => {
await j('/users/' + encodeURIComponent(u.username), { method: 'PUT', body: JSON.stringify({
name: $('ed-name').value.trim(),
password: $('ed-pass').value || undefined,
roles: [...document.querySelectorAll('#ed-roles input:checked')].map((i) => i.value),
}) });
await drawUsers();
};
$('tog').onclick = async () => {
await j('/users/' + encodeURIComponent(u.username), { method: 'PUT', body: JSON.stringify({ active: !u.active }) });
await drawUsers();
};
}
async function drawAudit() {
const { audit } = await j('/audit');
$('audit').innerHTML = `<div class="card"><h3 style="margin:.2rem 0 .8rem">Audit</h3>
<table><thead><tr><th>When</th><th>Actor</th><th>Action</th><th>Target</th><th>Detail</th></tr></thead>
<tbody>${audit.map((a) => `<tr><td class="muted">${a.t}</td><td>${a.actor}</td><td>${a.action}</td><td>${a.target || ''}</td><td class="muted">${a.detail || ''}</td></tr>`).join('') || '<tr><td colspan="5" class="muted">None</td></tr>'}</tbody></table></div>`;
}
function drawRoles() {
$('roles').innerHTML = `<div class="card"><h3 style="margin:.2rem 0 .8rem">Role catalog</h3>
<table><thead><tr><th>Role</th><th>Title</th><th>Permissions</th></tr></thead>
<tbody>${Object.entries(catalog.roles).map(([id, def]) =>
`<tr><td class="pill">${id}</td><td>${def.title}</td><td class="muted">${def.permissions.join(', ')}</td></tr>`
).join('')}</tbody></table>
<p class="muted" style="margin-top:.8rem">Permissions: ${catalog.permissions.join(', ')}</p></div>`;
}
document.querySelectorAll('nav button').forEach((b) => b.onclick = () => {
document.querySelectorAll('nav button').forEach((x) => x.classList.toggle('on', x === b));
document.querySelectorAll('.panel').forEach((p) => { p.hidden = p.id !== b.dataset.tab; });
if (b.dataset.tab === 'audit') drawAudit().catch(console.error);
});
(async () => {
catalog = await j('/roles');
const { user } = await j('/me');
me = user;
$('who').textContent = `${user.name} · ${user.username} · ${user.roles.join(', ')}`;
if (!user.permissions.includes('*') && !user.permissions.includes('iam.users.read')) {
$('users').innerHTML = `<div class="card">Signed in as ${user.username}. You do not have iam.users.read — ask an owner to grant IAM admin.</div>`;
return;
}
await drawUsers();
drawRoles();
})().catch((err) => { $('users').innerHTML = `<div class="card">${err.message}</div>`; });
</script>
</body>
</html>

View file

@ -0,0 +1,42 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Staff sign-in</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
<style>
:root { --accent:#4f46e5; --bg:#f4f5fb; --ink:#171a26; --muted:#6b7186; --line:#e5e7f0; }
* { box-sizing:border-box; }
body { margin:0; font:14px/1.45 -apple-system,"SF Pro Text","Segoe UI",sans-serif; }
#auth { min-height:100vh; display:grid; place-items:center; background:linear-gradient(160deg,#312e81 0%,#4f46e5 55%,#7c74f0 100%); }
.card { width:380px; background:#fff; border-radius:16px; padding:2rem; box-shadow:0 24px 64px rgba(17,12,60,.35); }
h1 { margin:0 0 .25rem; font-size:1.3rem; }
p { color:var(--muted); margin:0 0 1.1rem; }
label { display:block; font-size:.8rem; font-weight:700; margin:.8rem 0 .3rem; }
input { width:100%; padding:.6rem .75rem; border:1px solid var(--line); border-radius:8px; }
button { width:100%; margin-top:1.2rem; padding:.65rem; border:0; border-radius:8px; background:var(--accent); color:#fff; font-weight:700; cursor:pointer; }
.err { color:#dc2626; min-height:1.2em; }
</style>
</head>
<body>
<section id="auth">
<form class="card" method="post" action="/login">
<h1>Staff sign-in</h1>
<p>Named account. Roles decide CS, sales, accounting, fleet, and billing admin.</p>
<input type="hidden" name="next" id="next"/>
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username" required/>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required/>
<p class="err" id="err"></p>
<button type="submit">Sign in</button>
</form>
</section>
<script>
const q = new URLSearchParams(location.search);
document.getElementById('next').value = q.get('next') || '/';
if (q.get('error')) document.getElementById('err').textContent = 'Invalid username or password.';
</script>
</body>
</html>