Add internal staff IAM: named users, roles, and management permissions.
Some checks are pending
offline / test (push) Waiting to run
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:
parent
2740d51446
commit
d299d245e8
41 changed files with 1337 additions and 52 deletions
|
|
@ -35,6 +35,7 @@ Each runtime piece is its **own git repo** on Forgejo (`git.georgelambert.org`,
|
|||
| **UI-Docs** | `packages/ui-docs` | Operator/staff/portal walkthrough, screenshots, review PDF |
|
||||
| **verae-staff-session** | `packages/verae-staff-session` | Shared staff cookie login for department HTML |
|
||||
| **verae-staff-ui** | `packages/verae-staff-ui` | Shared staff review HTML (CS + access-staff) |
|
||||
| **verae-staff-iam** | `packages/verae-staff-iam` | Internal staff users, roles, permissions, audit |
|
||||
| **zapier-docs-master** | `packages/docs-master` | Per-module `SUMMARY.md` + `NATS.md` |
|
||||
| **verae-ops** | `packages/verae-ops` | Docker, Proxmox, VMs, dedicated hardware, linking services |
|
||||
|
||||
|
|
|
|||
35
packages/verae-access-staff/src/iam-gate.js
Normal file
35
packages/verae-access-staff/src/iam-gate.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
export function iamBase() {
|
||||
return (process.env.STAFF_IAM_URL || '').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export async function iamCheck(req, permission) {
|
||||
const base = iamBase();
|
||||
if (!base) {
|
||||
if (process.env.STAFF_AUTH === '1') {
|
||||
const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
|
||||
const r = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null);
|
||||
return { ok: Boolean(r && r.ok) };
|
||||
}
|
||||
return { ok: true, skipped: true };
|
||||
}
|
||||
const q = permission ? `?permission=${encodeURIComponent(permission)}` : '';
|
||||
const r = await fetch(`${base}/check${q}`, {
|
||||
headers: { cookie: req.headers.cookie || '', authorization: req.headers.authorization || '' },
|
||||
}).catch(() => null);
|
||||
if (!r) return { ok: false, status: 502 };
|
||||
const body = await r.json().catch(() => ({}));
|
||||
return { ok: r.ok, status: r.status, ...body };
|
||||
}
|
||||
|
||||
export async function denyOrRedirect(req, res, json, { permission, html }) {
|
||||
const out = await iamCheck(req, permission);
|
||||
if (out.ok) return true;
|
||||
const login = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, '');
|
||||
if (html) {
|
||||
res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` });
|
||||
res.end();
|
||||
return false;
|
||||
}
|
||||
json(out.status === 403 ? 403 : 401, { error: out.reason || 'unauthorized', permission });
|
||||
return false;
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import path from 'node:path';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import { listCustomers, withCustomerName } from './names.js';
|
||||
import { staffPageHtml } from './staff-page.js';
|
||||
import { denyOrRedirect } from './iam-gate.js';
|
||||
|
||||
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||
const PORT = Number(process.env.PORT || 3025);
|
||||
|
|
@ -32,15 +33,7 @@ const server = http.createServer(async (req, res) => {
|
|||
};
|
||||
try {
|
||||
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
|
||||
if (process.env.STAFF_AUTH === '1') {
|
||||
const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
|
||||
const chk = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null);
|
||||
if (!chk || !chk.ok) {
|
||||
res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!(await denyOrRedirect(req, res, json, { permission: 'staff.plane', html: true }))) return;
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
res.end(
|
||||
await staffPageHtml(
|
||||
|
|
@ -62,6 +55,7 @@ const server = http.createServer(async (req, res) => {
|
|||
}
|
||||
const review = url.pathname.match(/^\/review\/([^/]+)$/);
|
||||
if (req.method === 'GET' && review) {
|
||||
if (!(await denyOrRedirect(req, res, json, { permission: 'staff.plane' }))) return;
|
||||
const id = decodeURIComponent(review[1]);
|
||||
const gate = await check('verae.billing.statement.get', { principal: id });
|
||||
if (!gate.allow) return json(403, gate);
|
||||
|
|
@ -70,6 +64,7 @@ const server = http.createServer(async (req, res) => {
|
|||
return json(r.status, body);
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/credits') {
|
||||
if (!(await denyOrRedirect(req, res, json, { permission: 'cs.credit' }))) return;
|
||||
const chunks = [];
|
||||
for await (const c of req) chunks.push(c);
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@
|
|||
"env": {
|
||||
"PORT": "3025",
|
||||
"AUTHZ_URL": "http://127.0.0.1:3020",
|
||||
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010"
|
||||
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010",
|
||||
"STAFF_IAM_URL": "http://127.0.0.1:3028"
|
||||
},
|
||||
"nats": { "in": [], "out": ["verae.access.authz.check"] }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@
|
|||
"PORT": "3013",
|
||||
"ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000",
|
||||
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010",
|
||||
"NATS_URL": "nats://127.0.0.1:4222"
|
||||
"NATS_URL": "nats://127.0.0.1:4222",
|
||||
"STAFF_IAM_URL": "http://127.0.0.1:3028"
|
||||
},
|
||||
"nats": { "in": [], "out": ["verae.billing.statement.get"] }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@
|
|||
"ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000",
|
||||
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010",
|
||||
"NATS_URL": "nats://127.0.0.1:4222",
|
||||
"AUTHZ_URL": "http://127.0.0.1:3020"
|
||||
"AUTHZ_URL": "http://127.0.0.1:3020",
|
||||
"STAFF_IAM_URL": "http://127.0.0.1:3028"
|
||||
},
|
||||
"nats": {
|
||||
"in": [],
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@
|
|||
"ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000",
|
||||
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010",
|
||||
"NATS_URL": "nats://127.0.0.1:4222",
|
||||
"AUTHZ_URL": "http://127.0.0.1:3020"
|
||||
"AUTHZ_URL": "http://127.0.0.1:3020",
|
||||
"STAFF_IAM_URL": "http://127.0.0.1:3028"
|
||||
},
|
||||
"nats": { "in": [], "out": ["verae.billing.statement.get"] }
|
||||
}
|
||||
|
|
|
|||
20
packages/verae-fleet/services/staff-iam.json
Normal file
20
packages/verae-fleet/services/staff-iam.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"id": "staff-iam",
|
||||
"title": "Internal staff IAM (users, roles, permissions)",
|
||||
"kind": "http",
|
||||
"package": "verae-staff-iam",
|
||||
"role": "staff-iam",
|
||||
"managed": true,
|
||||
"runtime": "HTTP :3028",
|
||||
"health": { "type": "http", "path": "/health", "timeoutMs": 2000 },
|
||||
"ports": { "healthBase": 3028 },
|
||||
"spawn": {
|
||||
"cwd": "../verae-staff-iam",
|
||||
"command": "node",
|
||||
"args": ["src/server.js"]
|
||||
},
|
||||
"env": {
|
||||
"PORT": "3028"
|
||||
},
|
||||
"nats": { "in": [], "out": [] }
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
},
|
||||
"env": {
|
||||
"PORT": "3000",
|
||||
"STAFF_IAM_URL": "http://127.0.0.1:3028",
|
||||
"BIND": "127.0.0.1",
|
||||
"ZAPPIER_UPSTREAM": "http://127.0.0.1:3100",
|
||||
"CS_SERVICE_URL": "http://127.0.0.1:3011",
|
||||
|
|
|
|||
35
packages/verae-fleet/src/iam-gate.js
Normal file
35
packages/verae-fleet/src/iam-gate.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
export function iamBase() {
|
||||
return (process.env.STAFF_IAM_URL || '').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export async function iamCheck(req, permission) {
|
||||
const base = iamBase();
|
||||
if (!base) {
|
||||
if (process.env.STAFF_AUTH === '1') {
|
||||
const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
|
||||
const r = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null);
|
||||
return { ok: Boolean(r && r.ok) };
|
||||
}
|
||||
return { ok: true, skipped: true };
|
||||
}
|
||||
const q = permission ? `?permission=${encodeURIComponent(permission)}` : '';
|
||||
const r = await fetch(`${base}/check${q}`, {
|
||||
headers: { cookie: req.headers.cookie || '', authorization: req.headers.authorization || '' },
|
||||
}).catch(() => null);
|
||||
if (!r) return { ok: false, status: 502 };
|
||||
const body = await r.json().catch(() => ({}));
|
||||
return { ok: r.ok, status: r.status, ...body };
|
||||
}
|
||||
|
||||
export async function denyOrRedirect(req, res, json, { permission, html }) {
|
||||
const out = await iamCheck(req, permission);
|
||||
if (out.ok) return true;
|
||||
const login = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, '');
|
||||
if (html) {
|
||||
res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` });
|
||||
res.end();
|
||||
return false;
|
||||
}
|
||||
json(out.status === 403 ? 403 : 401, { error: out.reason || 'unauthorized', permission });
|
||||
return false;
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import path from 'node:path';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import { listServices } from './load.js';
|
||||
import { ACTIONS, Simulator } from '../../verae-zapier-simulator/src/pipeline.js';
|
||||
import { denyOrRedirect } from './iam-gate.js';
|
||||
|
||||
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||
|
||||
|
|
@ -33,6 +34,16 @@ export function startControlServer(sup, mon) {
|
|||
if (req.method === 'GET' && url.pathname === '/health') {
|
||||
return json(res, 200, { ok: true, role: 'operator-console' });
|
||||
}
|
||||
const gated =
|
||||
url.pathname === '/' ||
|
||||
url.pathname === '/index.html' ||
|
||||
(url.pathname.startsWith('/api/') && req.method !== 'GET');
|
||||
if (gated) {
|
||||
const j = (code, obj) => json(res, code, obj);
|
||||
if (!(await denyOrRedirect(req, res, j, { permission: 'fleet.operate', html: url.pathname === '/' || url.pathname === '/index.html' }))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (req.method === 'GET' && url.pathname === '/api/sim/actions') {
|
||||
return json(res, 200, { actions: ACTIONS, state: sim.snapshot() });
|
||||
}
|
||||
|
|
|
|||
3
packages/verae-staff-iam/NATS.md
Normal file
3
packages/verae-staff-iam/NATS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# NATS
|
||||
|
||||
HTTP only. Department doors call `GET /check?permission=`. Authz stays plane-based; IAM is the people hop in front of the staff plane.
|
||||
32
packages/verae-staff-iam/README.md
Normal file
32
packages/verae-staff-iam/README.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# verae-staff-iam
|
||||
|
||||
Named **internal staff** accounts with **roles and permissions**. Source of truth for who may use CS, sales, accounting, the staff plane, the operator console, and the billing admin console.
|
||||
|
||||
**Forgejo:** https://git.georgelambert.org/marchon/verae-staff-iam
|
||||
|
||||
Port **`:3028`**. UI: sign-in, people, roles, audit.
|
||||
|
||||
## Seed lab users
|
||||
|
||||
| Username | Password | Roles |
|
||||
|----------|----------|--------|
|
||||
| `admin` | `admin-dev-key` | owner (all) |
|
||||
| `cs` | `cs-dev-key` | cs |
|
||||
| `sales` | `sales-dev-key` | sales |
|
||||
| `accounting` | `acct-dev-key` | accounting |
|
||||
| `operator` | `fleet-dev-key` | operator |
|
||||
|
||||
Override with `IAM_OWNER_PASSWORD`, `IAM_CS_PASSWORD`, etc. Persist: `STAFF_IAM_PATH`.
|
||||
|
||||
## Wire other doors
|
||||
|
||||
```bash
|
||||
STAFF_IAM_URL=http://127.0.0.1:3028
|
||||
STAFF_AUTH=1 # department HTML still redirects if check fails
|
||||
```
|
||||
|
||||
`GET /check?permission=cs.credit` — cookie or `Authorization: Bearer`. Cookie name remains `staff_session`. Multi-host: `STAFF_COOKIE_DOMAIN`.
|
||||
|
||||
## Roles
|
||||
|
||||
`owner`, `iam-admin`, `billing-admin`, `cs`, `sales`, `accounting`, `operator`, `viewer`. Permissions are listed on `/roles`.
|
||||
3
packages/verae-staff-iam/SUMMARY.md
Normal file
3
packages/verae-staff-iam/SUMMARY.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# verae-staff-iam
|
||||
|
||||
Internal staff directory: users, roles, permissions, sessions, audit. Not customer portal accounts.
|
||||
11
packages/verae-staff-iam/package.json
Normal file
11
packages/verae-staff-iam/package.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "verae-staff-iam",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Internal staff user management: named accounts, roles, permissions, audit",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"test": "node --test test/*.test.js"
|
||||
}
|
||||
}
|
||||
170
packages/verae-staff-iam/public/app.html
Normal file
170
packages/verae-staff-iam/public/app.html
Normal 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>
|
||||
42
packages/verae-staff-iam/public/login.html
Normal file
42
packages/verae-staff-iam/public/login.html
Normal 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>
|
||||
47
packages/verae-staff-iam/src/client.js
Normal file
47
packages/verae-staff-iam/src/client.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/** HTTP client used by department doors and the admin console. */
|
||||
|
||||
export function iamBase() {
|
||||
return (process.env.STAFF_IAM_URL || '').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export async function iamCheck(req, permission) {
|
||||
const base = iamBase();
|
||||
if (!base) {
|
||||
if (process.env.STAFF_AUTH === '1') {
|
||||
const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
|
||||
const r = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null);
|
||||
return { ok: Boolean(r && r.ok), skipped: false, legacy: true };
|
||||
}
|
||||
return { ok: true, skipped: true };
|
||||
}
|
||||
const q = permission ? `?permission=${encodeURIComponent(permission)}` : '';
|
||||
const r = await fetch(`${base}/check${q}`, {
|
||||
headers: {
|
||||
cookie: req.headers.cookie || '',
|
||||
authorization: req.headers.authorization || '',
|
||||
},
|
||||
}).catch(() => null);
|
||||
if (!r) return { ok: false, status: 502 };
|
||||
const body = await r.json().catch(() => ({}));
|
||||
return { ok: r.ok, status: r.status, ...body };
|
||||
}
|
||||
|
||||
export function iamLoginUrl(next) {
|
||||
const base = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, '');
|
||||
return `${base}/login?next=${encodeURIComponent(next)}`;
|
||||
}
|
||||
|
||||
export async function denyOrRedirect(req, res, { permission, html, json }) {
|
||||
const out = await iamCheck(req, permission);
|
||||
if (out.ok) return out;
|
||||
if (html) {
|
||||
res.writeHead(302, { location: iamLoginUrl(`http://${req.headers.host || '127.0.0.1'}/`) });
|
||||
res.end();
|
||||
return out;
|
||||
}
|
||||
json(out.status === 403 ? 403 : 401, {
|
||||
error: out.reason || 'unauthorized',
|
||||
permission: permission || undefined,
|
||||
});
|
||||
return out;
|
||||
}
|
||||
26
packages/verae-staff-iam/src/passwords.js
Normal file
26
packages/verae-staff-iam/src/passwords.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
const N = 16384;
|
||||
const R = 8;
|
||||
const P = 1;
|
||||
const KEY_LEN = 32;
|
||||
|
||||
export function hashPassword(password) {
|
||||
const salt = randomBytes(16);
|
||||
const hash = scryptSync(String(password), salt, KEY_LEN, { N, r: R, p: P });
|
||||
return `scrypt:${N}:${R}:${P}:${salt.toString('base64')}:${hash.toString('base64')}`;
|
||||
}
|
||||
|
||||
export function verifyPassword(password, stored) {
|
||||
const parts = String(stored || '').split(':');
|
||||
if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
|
||||
const [, n, r, p, saltB64, hashB64] = parts;
|
||||
const expected = Buffer.from(hashB64, 'base64');
|
||||
if (!expected.length) return false;
|
||||
const actual = scryptSync(String(password), Buffer.from(saltB64, 'base64'), expected.length, {
|
||||
N: Number(n),
|
||||
r: Number(r),
|
||||
p: Number(p),
|
||||
});
|
||||
return timingSafeEqual(actual, expected);
|
||||
}
|
||||
98
packages/verae-staff-iam/src/roles.js
Normal file
98
packages/verae-staff-iam/src/roles.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/** Internal staff roles → permissions. Default deny on unknown. */
|
||||
|
||||
export const PERMISSIONS = Object.freeze([
|
||||
'iam.users.read',
|
||||
'iam.users.write',
|
||||
'admin.pricing',
|
||||
'admin.tiers',
|
||||
'admin.customers',
|
||||
'admin.statement',
|
||||
'admin.invoices',
|
||||
'admin.reports',
|
||||
'admin.system',
|
||||
'cs.review',
|
||||
'cs.credit',
|
||||
'sales.review',
|
||||
'sales.quote',
|
||||
'accounting.review',
|
||||
'accounting.export',
|
||||
'staff.plane',
|
||||
'fleet.operate',
|
||||
]);
|
||||
|
||||
export const ROLES = Object.freeze({
|
||||
owner: {
|
||||
title: 'Owner',
|
||||
permissions: ['*'],
|
||||
},
|
||||
'iam-admin': {
|
||||
title: 'IAM admin',
|
||||
permissions: ['iam.users.read', 'iam.users.write'],
|
||||
},
|
||||
'billing-admin': {
|
||||
title: 'Billing admin',
|
||||
permissions: [
|
||||
'admin.pricing',
|
||||
'admin.tiers',
|
||||
'admin.customers',
|
||||
'admin.statement',
|
||||
'admin.invoices',
|
||||
'admin.reports',
|
||||
'admin.system',
|
||||
],
|
||||
},
|
||||
cs: {
|
||||
title: 'Customer service',
|
||||
permissions: ['cs.review', 'cs.credit', 'admin.statement', 'staff.plane'],
|
||||
},
|
||||
sales: {
|
||||
title: 'Sales',
|
||||
permissions: ['sales.review', 'sales.quote', 'admin.statement', 'staff.plane'],
|
||||
},
|
||||
accounting: {
|
||||
title: 'Accounting',
|
||||
permissions: ['accounting.review', 'accounting.export', 'admin.statement', 'admin.invoices', 'staff.plane'],
|
||||
},
|
||||
operator: {
|
||||
title: 'Fleet operator',
|
||||
permissions: ['fleet.operate'],
|
||||
},
|
||||
viewer: {
|
||||
title: 'Read-only staff',
|
||||
permissions: ['cs.review', 'sales.review', 'accounting.review', 'admin.statement', 'admin.reports', 'admin.system'],
|
||||
},
|
||||
});
|
||||
|
||||
export function expandRoles(roles) {
|
||||
const set = new Set();
|
||||
for (const id of roles || []) {
|
||||
const def = ROLES[id];
|
||||
if (!def) continue;
|
||||
for (const p of def.permissions) {
|
||||
if (p === '*') return ['*'];
|
||||
set.add(p);
|
||||
}
|
||||
}
|
||||
return [...set].sort();
|
||||
}
|
||||
|
||||
export function allows(permissions, need) {
|
||||
if (!need) return true;
|
||||
const list = permissions || [];
|
||||
if (list.includes('*')) return true;
|
||||
return list.includes(need);
|
||||
}
|
||||
|
||||
export function publicUser(u) {
|
||||
if (!u) return null;
|
||||
const roles = [...(u.roles || [])];
|
||||
return {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
name: u.name || u.username,
|
||||
active: u.active !== false,
|
||||
roles,
|
||||
permissions: expandRoles(roles),
|
||||
createdMs: u.createdMs,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue