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
This commit is contained in:
commit
0b0871ffea
16 changed files with 5245 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
node_modules/
|
||||||
|
data/*.db
|
||||||
|
data/*.db-shm
|
||||||
|
data/*.db-wal
|
||||||
|
data/config.json
|
||||||
|
data/tokens.json
|
||||||
|
data/lockouts.json
|
||||||
33
data/services-cache.json
Normal file
33
data/services-cache.json
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"services": {
|
||||||
|
"docs.scottfelten.com": "command-center",
|
||||||
|
"ops.scottfelten.com": "ops-dashboard",
|
||||||
|
"models.scottfelten.com": "models-embeddings",
|
||||||
|
"goalstack.scottfelten.com": "taos",
|
||||||
|
"collab.scottfelten.com": "ccgg-collab",
|
||||||
|
"agents.scottfelten.com": "agent-bios",
|
||||||
|
"signal.scottfelten.com": "signal-tower",
|
||||||
|
"c360.scottfelten.com": "customer-360",
|
||||||
|
"research.scottfelten.com": "research-queue",
|
||||||
|
"files.scottfelten.com": "workspace-file-server",
|
||||||
|
"taos.scottfelten.com": "taos",
|
||||||
|
"inv.scottfelten.com": "inventory",
|
||||||
|
"paste.scottfelten.com": "paste-to-md",
|
||||||
|
"users.scottfelten.com": "access-manager",
|
||||||
|
"keys.scottfelten.com": "api-keys-dashboard",
|
||||||
|
"iag.scottfelten.com": "iag-data-viewer",
|
||||||
|
"intelligenceadvisorygroup.com": "iag-website",
|
||||||
|
"www.intelligenceadvisorygroup.com": "iag-website",
|
||||||
|
"intellicert.app": "intellicert",
|
||||||
|
"www.intellicert.app": "intellicert",
|
||||||
|
"app.usecasegen.app": "usecasegen",
|
||||||
|
"usecasegen.app": "usecasegen",
|
||||||
|
"www.usecasegen.app": "usecasegen",
|
||||||
|
"intelligenceagentpartners.com": "intelligent-agent-partners",
|
||||||
|
"www.intelligenceagentpartners.com": "intelligent-agent-partners",
|
||||||
|
"intelligentageintpartners.com": "intelligent-agent-partners",
|
||||||
|
"www.intelligentagentpartners.com": "intelligent-agent-partners"
|
||||||
|
},
|
||||||
|
"synced_at": "2026-04-16T00:56:20.841Z",
|
||||||
|
"count": 27
|
||||||
|
}
|
||||||
56
data/users.json
Normal file
56
data/users.json
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"email": "scott@scottfelten.com",
|
||||||
|
"name": "Scott Felten",
|
||||||
|
"role": "admin",
|
||||||
|
"services": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"metadata": {},
|
||||||
|
"tags": [
|
||||||
|
"owner"
|
||||||
|
],
|
||||||
|
"createdAt": "2026-03-07",
|
||||||
|
"createdBy": "system",
|
||||||
|
"lastLogin": "2026-04-10T20:23:03.522Z",
|
||||||
|
"authMethods": [
|
||||||
|
"google"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": "scott.felten@incorta.com",
|
||||||
|
"name": "Scott Felten (Incorta)",
|
||||||
|
"role": "admin",
|
||||||
|
"services": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"metadata": {},
|
||||||
|
"tags": [
|
||||||
|
"owner"
|
||||||
|
],
|
||||||
|
"createdAt": "2026-03-07",
|
||||||
|
"createdBy": "system",
|
||||||
|
"lastLogin": "2026-03-21T00:10:40.433Z",
|
||||||
|
"authMethods": [
|
||||||
|
"google"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": "iamscottfelten@gmail.com",
|
||||||
|
"name": "Scott Felten (Gmail)",
|
||||||
|
"role": "admin",
|
||||||
|
"services": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"metadata": {},
|
||||||
|
"tags": [
|
||||||
|
"owner"
|
||||||
|
],
|
||||||
|
"createdAt": "2026-03-07",
|
||||||
|
"createdBy": "system",
|
||||||
|
"lastLogin": "2026-03-16T04:56:49.491Z",
|
||||||
|
"authMethods": [
|
||||||
|
"google"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
34
debug-check.js
Normal file
34
debug-check.js
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
const http = require("http");
|
||||||
|
|
||||||
|
// Simulate what access-manager does - call oauth2-proxy auth endpoint
|
||||||
|
// with the cookie from the browser
|
||||||
|
const cookie = process.argv[2] || "";
|
||||||
|
const host = process.argv[3] || "docs.scottfelten.com";
|
||||||
|
|
||||||
|
console.log("Testing oauth2-proxy auth with:");
|
||||||
|
console.log(" Cookie length:", cookie.length);
|
||||||
|
console.log(" Host:", host);
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: 4180,
|
||||||
|
path: "/oauth2/auth",
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Cookie: cookie,
|
||||||
|
"X-Forwarded-Proto": "https",
|
||||||
|
Host: host,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = http.request(opts, (res) => {
|
||||||
|
console.log("\noauth2-proxy response:");
|
||||||
|
console.log(" Status:", res.statusCode);
|
||||||
|
console.log(" Headers:", JSON.stringify(res.headers, null, 2));
|
||||||
|
let body = "";
|
||||||
|
res.on("data", (d) => (body += d));
|
||||||
|
res.on("end", () => { if (body) console.log(" Body:", body); });
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("error", (e) => console.error("Error:", e.message));
|
||||||
|
req.end();
|
||||||
98
lib/db.js
Normal file
98
lib/db.js
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const DB_PATH = path.join(__dirname, '..', 'data', 'access-manager.db');
|
||||||
|
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||||
|
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
// ─── Schema ───
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT UNIQUE NOT NULL COLLATE NOCASE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
password_hash TEXT,
|
||||||
|
auth_methods TEXT NOT NULL DEFAULT '["google"]',
|
||||||
|
metadata TEXT DEFAULT '{}',
|
||||||
|
tags TEXT DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_by TEXT DEFAULT 'system',
|
||||||
|
last_login TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_system INTEGER NOT NULL DEFAULT 0,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 100,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS permissions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
app TEXT NOT NULL,
|
||||||
|
feature TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
display_name TEXT,
|
||||||
|
description TEXT,
|
||||||
|
category TEXT DEFAULT 'general',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(app, feature, action)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||||
|
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
|
permission_id INTEGER NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (role_id, permission_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_roles (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
|
scope TEXT NOT NULL DEFAULT '*',
|
||||||
|
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
granted_by TEXT,
|
||||||
|
PRIMARY KEY (user_id, role_id, scope)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS services (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
hostname TEXT,
|
||||||
|
hostnames TEXT DEFAULT '[]',
|
||||||
|
port INTEGER,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
features_registered INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
actor_email TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target_type TEXT,
|
||||||
|
target_id TEXT,
|
||||||
|
detail TEXT,
|
||||||
|
ip TEXT,
|
||||||
|
service TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_roles_user ON user_roles(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_roles_role ON user_roles(role_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_role_perms_role ON role_permissions(role_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_permissions_app ON permissions(app);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor_email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);
|
||||||
|
`);
|
||||||
|
|
||||||
|
module.exports = db;
|
||||||
238
lib/migration.js
Normal file
238
lib/migration.js
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
const db = require('./db');
|
||||||
|
const rbac = require('./rbac');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
const DATA_DIR = path.join(__dirname, '..', 'data');
|
||||||
|
const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
||||||
|
|
||||||
|
function migrate() {
|
||||||
|
console.log('[migration] Starting v2 → v3 migration...');
|
||||||
|
|
||||||
|
// 1. Migrate users from JSON
|
||||||
|
migrateUsers();
|
||||||
|
|
||||||
|
// 2. Seed default roles
|
||||||
|
seedRoles();
|
||||||
|
|
||||||
|
// 3. Seed wildcard permissions
|
||||||
|
seedWildcardPermissions();
|
||||||
|
|
||||||
|
// 4. Assign roles to migrated users
|
||||||
|
assignMigratedUserRoles();
|
||||||
|
|
||||||
|
// 5. Register initial features for UseCaseGen apps
|
||||||
|
registerUseCaseGenFeatures();
|
||||||
|
|
||||||
|
// 6. Seed services
|
||||||
|
seedServices();
|
||||||
|
|
||||||
|
console.log('[migration] Migration complete.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateUsers() {
|
||||||
|
if (!fs.existsSync(USERS_FILE)) {
|
||||||
|
console.log('[migration] No users.json found — skipping user migration');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.prepare('SELECT COUNT(*) as c FROM users').get();
|
||||||
|
if (existing.c > 0) {
|
||||||
|
console.log(`[migration] Users table already has ${existing.c} rows — skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8'));
|
||||||
|
const insert = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO users (email, name, password_hash, auth_methods, metadata, tags, created_at, created_by, last_login, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const u of users) {
|
||||||
|
insert.run(
|
||||||
|
u.email, u.name, u.passwordHash || null,
|
||||||
|
JSON.stringify(u.authMethods || ['google']),
|
||||||
|
JSON.stringify(u.metadata || {}),
|
||||||
|
JSON.stringify(u.tags || []),
|
||||||
|
u.createdAt || new Date().toISOString(),
|
||||||
|
u.createdBy || 'system',
|
||||||
|
u.lastLogin || null,
|
||||||
|
'active'
|
||||||
|
);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
console.log(`[migration] Migrated ${count} users from JSON`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedRoles() {
|
||||||
|
const existing = db.prepare('SELECT COUNT(*) as c FROM roles').get();
|
||||||
|
if (existing.c > 0) {
|
||||||
|
console.log(`[migration] Roles already seeded (${existing.c}) — skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles = [
|
||||||
|
['super_admin', 'Super Administrator', 'Full access to all apps and features', 1, 1],
|
||||||
|
['admin', 'Administrator', 'App-level admin with full feature access', 1, 10],
|
||||||
|
['editor', 'Editor', 'Can create, edit, and manage content', 1, 20],
|
||||||
|
['user', 'User', 'Standard user — chat and content reading', 1, 30],
|
||||||
|
['viewer', 'Viewer', 'Read-only access', 1, 40],
|
||||||
|
];
|
||||||
|
|
||||||
|
const insert = db.prepare('INSERT INTO roles (name, display_name, description, is_system, priority) VALUES (?, ?, ?, ?, ?)');
|
||||||
|
for (const r of roles) insert.run(...r);
|
||||||
|
console.log(`[migration] Seeded ${roles.length} default roles`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedWildcardPermissions() {
|
||||||
|
const existing = db.prepare("SELECT COUNT(*) as c FROM permissions WHERE app = '*'").get();
|
||||||
|
if (existing.c > 0) return;
|
||||||
|
|
||||||
|
// Wildcard permission entries
|
||||||
|
const wildcards = [
|
||||||
|
['*', '*', '*', 'Full Access', 'Unrestricted access to everything', 'system'],
|
||||||
|
['*', '*', 'read', 'Global Read', 'Read access to all features', 'system'],
|
||||||
|
['*', '*', 'write', 'Global Write', 'Write access to all features', 'system'],
|
||||||
|
['*', '*', 'delete', 'Global Delete', 'Delete access to all features', 'system'],
|
||||||
|
['*', '*', 'admin', 'Global Admin', 'Admin access to all features', 'system'],
|
||||||
|
['*', 'chat', 'read', 'Chat Read (all apps)', 'Read chat in all apps', 'chat'],
|
||||||
|
['*', 'chat', 'write', 'Chat Write (all apps)', 'Write chat in all apps', 'chat'],
|
||||||
|
['*', 'content', 'read', 'Content Read (all apps)', 'Read content in all apps', 'content'],
|
||||||
|
['*', 'content', 'write', 'Content Write (all apps)', 'Write content in all apps', 'content'],
|
||||||
|
['*', 'content', 'delete', 'Content Delete (all apps)', 'Delete content in all apps', 'content'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const insert = db.prepare('INSERT OR IGNORE INTO permissions (app, feature, action, display_name, description, category) VALUES (?, ?, ?, ?, ?, ?)');
|
||||||
|
for (const w of wildcards) insert.run(...w);
|
||||||
|
|
||||||
|
// Assign permissions to roles
|
||||||
|
const superAdmin = db.prepare("SELECT id FROM roles WHERE name = 'super_admin'").get();
|
||||||
|
const admin = db.prepare("SELECT id FROM roles WHERE name = 'admin'").get();
|
||||||
|
const editor = db.prepare("SELECT id FROM roles WHERE name = 'editor'").get();
|
||||||
|
const user = db.prepare("SELECT id FROM roles WHERE name = 'user'").get();
|
||||||
|
const viewer = db.prepare("SELECT id FROM roles WHERE name = 'viewer'").get();
|
||||||
|
|
||||||
|
const perm = (app, feat, act) => db.prepare('SELECT id FROM permissions WHERE app=? AND feature=? AND action=?').get(app, feat, act);
|
||||||
|
|
||||||
|
const assign = db.prepare('INSERT OR IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)');
|
||||||
|
|
||||||
|
// super_admin: *.*.*
|
||||||
|
assign.run(superAdmin.id, perm('*', '*', '*').id);
|
||||||
|
|
||||||
|
// admin: *.*.read, *.*.write, *.*.delete, *.*.admin
|
||||||
|
assign.run(admin.id, perm('*', '*', 'read').id);
|
||||||
|
assign.run(admin.id, perm('*', '*', 'write').id);
|
||||||
|
assign.run(admin.id, perm('*', '*', 'delete').id);
|
||||||
|
assign.run(admin.id, perm('*', '*', 'admin').id);
|
||||||
|
|
||||||
|
// editor: *.content.*, *.chat.*
|
||||||
|
assign.run(editor.id, perm('*', 'content', 'read').id);
|
||||||
|
assign.run(editor.id, perm('*', 'content', 'write').id);
|
||||||
|
assign.run(editor.id, perm('*', 'content', 'delete').id);
|
||||||
|
assign.run(editor.id, perm('*', 'chat', 'read').id);
|
||||||
|
assign.run(editor.id, perm('*', 'chat', 'write').id);
|
||||||
|
|
||||||
|
// user: *.chat.read, *.chat.write, *.content.read
|
||||||
|
assign.run(user.id, perm('*', 'chat', 'read').id);
|
||||||
|
assign.run(user.id, perm('*', 'chat', 'write').id);
|
||||||
|
assign.run(user.id, perm('*', 'content', 'read').id);
|
||||||
|
|
||||||
|
// viewer: *.*.read
|
||||||
|
assign.run(viewer.id, perm('*', '*', 'read').id);
|
||||||
|
|
||||||
|
console.log('[migration] Seeded wildcard permissions and role assignments');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignMigratedUserRoles() {
|
||||||
|
const existingAssignments = db.prepare('SELECT COUNT(*) as c FROM user_roles').get();
|
||||||
|
if (existingAssignments.c > 0) {
|
||||||
|
console.log(`[migration] User-role assignments already exist (${existingAssignments.c}) — skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read original users.json to get their v2 role + services
|
||||||
|
if (!fs.existsSync(USERS_FILE)) return;
|
||||||
|
const v2Users = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8'));
|
||||||
|
|
||||||
|
for (const v2 of v2Users) {
|
||||||
|
const user = db.prepare('SELECT id FROM users WHERE email = ? COLLATE NOCASE').get(v2.email);
|
||||||
|
if (!user) continue;
|
||||||
|
|
||||||
|
if (v2.role === 'admin' && v2.services && v2.services.includes('*')) {
|
||||||
|
// Admin with wildcard → super_admin
|
||||||
|
rbac.assignRole(v2.email, 'super_admin', '*', 'migration');
|
||||||
|
console.log(`[migration] ${v2.email} → super_admin (global)`);
|
||||||
|
} else if (v2.role === 'admin') {
|
||||||
|
// Admin with specific services → admin scoped
|
||||||
|
for (const svc of (v2.services || [])) {
|
||||||
|
rbac.assignRole(v2.email, 'admin', svc, 'migration');
|
||||||
|
}
|
||||||
|
console.log(`[migration] ${v2.email} → admin (scoped: ${(v2.services || []).join(', ')})`);
|
||||||
|
} else {
|
||||||
|
// Everyone else → user
|
||||||
|
rbac.assignRole(v2.email, 'user', '*', 'migration');
|
||||||
|
console.log(`[migration] ${v2.email} → user (global)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerUseCaseGenFeatures() {
|
||||||
|
const existingPerms = db.prepare("SELECT COUNT(*) as c FROM permissions WHERE app = 'usecasegen'").get();
|
||||||
|
if (existingPerms.c > 0) return;
|
||||||
|
|
||||||
|
const commonFeatures = [
|
||||||
|
{ feature: 'chat', actions: ['read', 'write'], category: 'chat', display_name: 'Chat / Sessions' },
|
||||||
|
{ feature: 'manage.focus_areas', actions: ['read', 'write', 'delete', 'hide', 'archive'], category: 'content', display_name: 'Focus Areas' },
|
||||||
|
{ feature: 'manage.tech_patterns', actions: ['read', 'write', 'delete', 'hide', 'archive'], category: 'content', display_name: 'Tech Patterns' },
|
||||||
|
{ feature: 'manage.regions', actions: ['read', 'write', 'delete', 'hide', 'archive'], category: 'content', display_name: 'Regions' },
|
||||||
|
{ feature: 'manage.audience', actions: ['read', 'write', 'delete', 'hide', 'archive'], category: 'content', display_name: 'Audience' },
|
||||||
|
{ feature: 'manage.analysis_focus', actions: ['read', 'write', 'delete', 'hide', 'archive'], category: 'content', display_name: 'Analysis Focus' },
|
||||||
|
{ feature: 'sessions', actions: ['read', 'write', 'delete'], category: 'content', display_name: 'Saved Sessions' },
|
||||||
|
{ feature: 'admin_panel', actions: ['read', 'admin'], category: 'admin', display_name: 'Admin Panel' },
|
||||||
|
{ feature: 'user_management', actions: ['read', 'write', 'admin'], category: 'admin', display_name: 'User Management' },
|
||||||
|
{ feature: 'settings', actions: ['read', 'write'], category: 'settings', display_name: 'App Settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
rbac.registerFeatures('usecasegen', commonFeatures);
|
||||||
|
rbac.registerFeatures('ggl-usecasegen', commonFeatures);
|
||||||
|
|
||||||
|
const fdxFeatures = [
|
||||||
|
{ feature: 'chat', actions: ['read', 'write'], category: 'chat', display_name: 'Chat / Sessions' },
|
||||||
|
{ feature: 'manage.patterns', actions: ['read', 'write', 'delete', 'hide', 'archive'], category: 'content', display_name: 'Patterns' },
|
||||||
|
{ feature: 'manage.regions', actions: ['read', 'write', 'delete', 'hide', 'archive'], category: 'content', display_name: 'Regions' },
|
||||||
|
{ feature: 'manage.audience', actions: ['read', 'write', 'delete', 'hide', 'archive'], category: 'content', display_name: 'Audience' },
|
||||||
|
{ feature: 'manage.analysis', actions: ['read', 'write', 'delete', 'hide', 'archive'], category: 'content', display_name: 'Analysis Focus' },
|
||||||
|
{ feature: 'sessions', actions: ['read', 'write', 'delete'], category: 'content', display_name: 'Saved Sessions' },
|
||||||
|
{ feature: 'enterprise_systems', actions: ['read', 'write', 'delete'], category: 'content', display_name: 'Enterprise Systems' },
|
||||||
|
{ feature: 'admin_panel', actions: ['read', 'admin'], category: 'admin', display_name: 'Admin Panel' },
|
||||||
|
{ feature: 'user_management', actions: ['read', 'write', 'admin'], category: 'admin', display_name: 'User Management' },
|
||||||
|
{ feature: 'settings', actions: ['read', 'write'], category: 'settings', display_name: 'App Settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
rbac.registerFeatures('fedex-cohort-app', fdxFeatures);
|
||||||
|
console.log('[migration] Registered UseCaseGen features for all three apps');
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedServices() {
|
||||||
|
const existing = db.prepare('SELECT COUNT(*) as c FROM services').get();
|
||||||
|
if (existing.c > 0) return;
|
||||||
|
|
||||||
|
const services = [
|
||||||
|
['usecasegen', 'UseCaseGen APP', 'app.usecasegen.app', '["app.usecasegen.app","usecasegen.app","www.usecasegen.app"]', 3006],
|
||||||
|
['ggl-usecasegen', 'GGL UseCaseGen', 'ggl.usecasegen.app', '["ggl.usecasegen.app"]', 3095],
|
||||||
|
['fedex-cohort-app', 'FDX Trade Intel', 'fdx.usecasegen.app', '["fdx.usecasegen.app"]', 3089],
|
||||||
|
['taos', 'GoalStack / TAOS', 'goalstack.scottfelten.com', '["goalstack.scottfelten.com","taos.scottfelten.com"]', null],
|
||||||
|
['access-manager', 'Access Manager', 'users.scottfelten.com', '["users.scottfelten.com"]', 3030],
|
||||||
|
['intellicert', 'IntelliCert', 'intellicert.app', '["intellicert.app","www.intellicert.app"]', null],
|
||||||
|
['customer-360', 'Customer 360', 'c360.scottfelten.com', '["c360.scottfelten.com"]', null],
|
||||||
|
['command-center', 'Docs / Command Center', 'docs.scottfelten.com', '["docs.scottfelten.com"]', null],
|
||||||
|
];
|
||||||
|
|
||||||
|
const insert = db.prepare('INSERT OR IGNORE INTO services (id, display_name, hostname, hostnames, port) VALUES (?, ?, ?, ?, ?)');
|
||||||
|
for (const s of services) insert.run(...s);
|
||||||
|
console.log(`[migration] Seeded ${services.length} services`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { migrate };
|
||||||
264
lib/rbac.js
Normal file
264
lib/rbac.js
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
const db = require('./db');
|
||||||
|
|
||||||
|
// ─── Permission Resolution ───
|
||||||
|
|
||||||
|
function hasPermission(userId, app, feature, action) {
|
||||||
|
// Get all roles for this user where scope matches
|
||||||
|
const roles = db.prepare(`
|
||||||
|
SELECT r.id, r.name FROM user_roles ur
|
||||||
|
JOIN roles r ON r.id = ur.role_id
|
||||||
|
WHERE ur.user_id = ? AND (ur.scope = '*' OR ur.scope = ?)
|
||||||
|
`).all(userId, app);
|
||||||
|
|
||||||
|
if (roles.length === 0) return { allowed: false };
|
||||||
|
|
||||||
|
for (const role of roles) {
|
||||||
|
// Check permissions for this role
|
||||||
|
const perms = db.prepare(`
|
||||||
|
SELECT p.app, p.feature, p.action FROM role_permissions rp
|
||||||
|
JOIN permissions p ON p.id = rp.permission_id
|
||||||
|
WHERE rp.role_id = ?
|
||||||
|
`).all(role.id);
|
||||||
|
|
||||||
|
for (const p of perms) {
|
||||||
|
if ((p.app === '*' || p.app === app) &&
|
||||||
|
(p.feature === '*' || p.feature === feature) &&
|
||||||
|
(p.action === '*' || p.action === action)) {
|
||||||
|
return { allowed: true, role: role.name, matched: `${p.app}.${p.feature}.${p.action}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { allowed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPermissionByEmail(email, app, feature, action) {
|
||||||
|
const user = db.prepare('SELECT id FROM users WHERE email = ? COLLATE NOCASE').get(email);
|
||||||
|
if (!user) return { allowed: false, reason: 'user_not_found' };
|
||||||
|
return hasPermission(user.id, app, feature, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEffectivePermissions(userId, app) {
|
||||||
|
const scopeFilter = app ? 'AND (ur.scope = ? OR ur.scope = ?)' : 'AND 1=1';
|
||||||
|
const params = app ? [userId, '*', app] : [userId];
|
||||||
|
|
||||||
|
const roles = db.prepare(`
|
||||||
|
SELECT r.id, r.name, ur.scope FROM user_roles ur
|
||||||
|
JOIN roles r ON r.id = ur.role_id
|
||||||
|
WHERE ur.user_id = ? ${app ? 'AND (ur.scope = ? OR ur.scope = ?)' : ''}
|
||||||
|
`).all(...params);
|
||||||
|
|
||||||
|
const permissions = new Set();
|
||||||
|
const roleNames = [];
|
||||||
|
|
||||||
|
for (const role of roles) {
|
||||||
|
roleNames.push({ role: role.name, scope: role.scope });
|
||||||
|
const perms = db.prepare(`
|
||||||
|
SELECT p.app, p.feature, p.action FROM role_permissions rp
|
||||||
|
JOIN permissions p ON p.id = rp.permission_id
|
||||||
|
WHERE rp.role_id = ?
|
||||||
|
`).all(role.id);
|
||||||
|
|
||||||
|
for (const p of perms) {
|
||||||
|
permissions.add(`${p.app}.${p.feature}.${p.action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { roles: roleNames, permissions: [...permissions] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEffectivePermissionsByEmail(email, app) {
|
||||||
|
const user = db.prepare('SELECT id FROM users WHERE email = ? COLLATE NOCASE').get(email);
|
||||||
|
if (!user) return { roles: [], permissions: [] };
|
||||||
|
return getEffectivePermissions(user.id, app);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Role CRUD ───
|
||||||
|
|
||||||
|
function listRoles() {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT r.*,
|
||||||
|
(SELECT COUNT(*) FROM role_permissions WHERE role_id = r.id) as perm_count,
|
||||||
|
(SELECT COUNT(*) FROM user_roles WHERE role_id = r.id) as user_count
|
||||||
|
FROM roles r ORDER BY r.priority ASC
|
||||||
|
`).all();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRole(name) {
|
||||||
|
const role = db.prepare('SELECT * FROM roles WHERE name = ?').get(name);
|
||||||
|
if (!role) return null;
|
||||||
|
role.permissions = db.prepare(`
|
||||||
|
SELECT p.* FROM role_permissions rp
|
||||||
|
JOIN permissions p ON p.id = rp.permission_id
|
||||||
|
WHERE rp.role_id = ?
|
||||||
|
ORDER BY p.app, p.feature, p.action
|
||||||
|
`).all(role.id);
|
||||||
|
role.users = db.prepare(`
|
||||||
|
SELECT u.email, u.name, ur.scope, ur.granted_at FROM user_roles ur
|
||||||
|
JOIN users u ON u.id = ur.user_id
|
||||||
|
WHERE ur.role_id = ?
|
||||||
|
`).all(role.id);
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRole(name, displayName, description, isSystem = false, priority = 100) {
|
||||||
|
return db.prepare(`
|
||||||
|
INSERT INTO roles (name, display_name, description, is_system, priority)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(name, displayName, description, isSystem ? 1 : 0, priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRole(name, updates) {
|
||||||
|
const role = db.prepare('SELECT * FROM roles WHERE name = ?').get(name);
|
||||||
|
if (!role) return null;
|
||||||
|
const { display_name, description, priority } = updates;
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE roles SET
|
||||||
|
display_name = COALESCE(?, display_name),
|
||||||
|
description = COALESCE(?, description),
|
||||||
|
priority = COALESCE(?, priority),
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE name = ?
|
||||||
|
`).run(display_name || null, description || null, priority || null, name);
|
||||||
|
return db.prepare('SELECT * FROM roles WHERE name = ?').get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRole(name) {
|
||||||
|
const role = db.prepare('SELECT * FROM roles WHERE name = ?').get(name);
|
||||||
|
if (!role) return { error: 'not_found' };
|
||||||
|
if (role.is_system) return { error: 'system_role' };
|
||||||
|
db.prepare('DELETE FROM roles WHERE id = ?').run(role.id);
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRolePermissions(roleName, permissionIds) {
|
||||||
|
const role = db.prepare('SELECT id FROM roles WHERE name = ?').get(roleName);
|
||||||
|
if (!role) return { error: 'role_not_found' };
|
||||||
|
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
db.prepare('DELETE FROM role_permissions WHERE role_id = ?').run(role.id);
|
||||||
|
const insert = db.prepare('INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)');
|
||||||
|
for (const pid of permissionIds) {
|
||||||
|
insert.run(role.id, pid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
return { ok: true, count: permissionIds.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRolePermissionsByPattern(roleName, app, feature, action) {
|
||||||
|
const role = db.prepare('SELECT id FROM roles WHERE name = ?').get(roleName);
|
||||||
|
if (!role) return { error: 'role_not_found' };
|
||||||
|
|
||||||
|
// Find or create the permission
|
||||||
|
let perm = db.prepare('SELECT id FROM permissions WHERE app = ? AND feature = ? AND action = ?').get(app, feature, action);
|
||||||
|
if (!perm) {
|
||||||
|
const r = db.prepare('INSERT INTO permissions (app, feature, action) VALUES (?, ?, ?)').run(app, feature, action);
|
||||||
|
perm = { id: r.lastInsertRowid };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.prepare('INSERT OR IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)').run(role.id, perm.id);
|
||||||
|
} catch (e) { /* already exists */ }
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── User-Role Assignment ───
|
||||||
|
|
||||||
|
function getUserRoles(email) {
|
||||||
|
const user = db.prepare('SELECT id FROM users WHERE email = ? COLLATE NOCASE').get(email);
|
||||||
|
if (!user) return [];
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT r.name, r.display_name, ur.scope, ur.granted_at, ur.granted_by
|
||||||
|
FROM user_roles ur JOIN roles r ON r.id = ur.role_id
|
||||||
|
WHERE ur.user_id = ? ORDER BY r.priority
|
||||||
|
`).all(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignRole(email, roleName, scope, grantedBy) {
|
||||||
|
const user = db.prepare('SELECT id FROM users WHERE email = ? COLLATE NOCASE').get(email);
|
||||||
|
if (!user) return { error: 'user_not_found' };
|
||||||
|
const role = db.prepare('SELECT id FROM roles WHERE name = ?').get(roleName);
|
||||||
|
if (!role) return { error: 'role_not_found' };
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT OR REPLACE INTO user_roles (user_id, role_id, scope, granted_by)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(user.id, role.id, scope || '*', grantedBy || 'system');
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRole(email, roleName, scope) {
|
||||||
|
const user = db.prepare('SELECT id FROM users WHERE email = ? COLLATE NOCASE').get(email);
|
||||||
|
if (!user) return { error: 'user_not_found' };
|
||||||
|
const role = db.prepare('SELECT id FROM roles WHERE name = ?').get(roleName);
|
||||||
|
if (!role) return { error: 'role_not_found' };
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM user_roles WHERE user_id = ? AND role_id = ? AND scope = ?')
|
||||||
|
.run(user.id, role.id, scope || '*');
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Feature Registration ───
|
||||||
|
|
||||||
|
function registerFeatures(appId, features) {
|
||||||
|
const insert = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO permissions (app, feature, action, display_name, category)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
for (const f of features) {
|
||||||
|
for (const action of (f.actions || ['read'])) {
|
||||||
|
insert.run(appId, f.feature, action, f.display_name || f.feature, f.category || 'general');
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mark service as features_registered
|
||||||
|
db.prepare('UPDATE services SET features_registered = 1, updated_at = datetime(\'now\') WHERE id = ?').run(appId);
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
return { ok: true, registered: count };
|
||||||
|
}
|
||||||
|
|
||||||
|
function listFeatures(appId) {
|
||||||
|
const where = appId ? 'WHERE app = ?' : '';
|
||||||
|
const params = appId ? [appId] : [];
|
||||||
|
return db.prepare(`SELECT * FROM permissions ${where} ORDER BY app, category, feature, action`).all(...params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Audit ───
|
||||||
|
|
||||||
|
function audit(actorEmail, action, targetType, targetId, detail, ip, service) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO audit_log (actor_email, action, target_type, target_id, detail, ip, service)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(actorEmail, action, targetType || null, targetId || null,
|
||||||
|
detail ? JSON.stringify(detail) : null, ip || null, service || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryAudit({ actor, action, targetType, limit, offset, since, until } = {}) {
|
||||||
|
let where = [];
|
||||||
|
let params = [];
|
||||||
|
if (actor) { where.push('actor_email = ?'); params.push(actor); }
|
||||||
|
if (action) { where.push('action LIKE ?'); params.push(action + '%'); }
|
||||||
|
if (targetType) { where.push('target_type = ?'); params.push(targetType); }
|
||||||
|
if (since) { where.push('timestamp >= ?'); params.push(since); }
|
||||||
|
if (until) { where.push('timestamp <= ?'); params.push(until); }
|
||||||
|
|
||||||
|
const whereClause = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||||
|
params.push(limit || 100, offset || 0);
|
||||||
|
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT * FROM audit_log ${whereClause}
|
||||||
|
ORDER BY timestamp DESC LIMIT ? OFFSET ?
|
||||||
|
`).all(...params);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
hasPermission, hasPermissionByEmail, getEffectivePermissions, getEffectivePermissionsByEmail,
|
||||||
|
listRoles, getRole, createRole, updateRole, deleteRole, setRolePermissions, addRolePermissionsByPattern,
|
||||||
|
getUserRoles, assignRole, removeRole,
|
||||||
|
registerFeatures, listFeatures,
|
||||||
|
audit, queryAudit,
|
||||||
|
};
|
||||||
1398
package-lock.json
generated
Normal file
1398
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
17
package.json
Normal file
17
package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"name": "access-manager",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "User management and scoped access control for scottfelten.com services",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"better-sqlite3": "^12.9.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"nodemailer": "^6.9.16"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
public/denied.html
Normal file
23
public/denied.html
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Access Denied</title>
|
||||||
|
<style>
|
||||||
|
body { background: #0f172a; color: #e2e8f0; font-family: -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
||||||
|
.box { text-align: center; max-width: 400px; }
|
||||||
|
h1 { font-size: 48px; margin: 0; color: #ef4444; }
|
||||||
|
p { color: #94a3b8; margin-top: 12px; }
|
||||||
|
a { color: #3b82f6; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="box">
|
||||||
|
<h1>403</h1>
|
||||||
|
<p>You don't have access to this service.<br>Contact your administrator if you think this is a mistake.</p>
|
||||||
|
<p style="margin-top: 24px"><a href="https://scottfelten.com">← Back to scottfelten.com</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
634
public/docs.html
Normal file
634
public/docs.html
Normal file
|
|
@ -0,0 +1,634 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>User Management — Documentation</title>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'Inter', sans-serif; background: #0a0a0f; color: #e2e8f0; min-height: 100vh; }
|
||||||
|
|
||||||
|
.container { max-width: 860px; margin: 0 auto; padding: 24px; }
|
||||||
|
|
||||||
|
.back-link { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: #64748b; text-decoration: none; margin-bottom: 20px; transition: color 0.15s; }
|
||||||
|
.back-link:hover { color: #94a3b8; }
|
||||||
|
|
||||||
|
/* Hero */
|
||||||
|
.hero {
|
||||||
|
background: linear-gradient(135deg, rgba(30,30,45,0.9), rgba(20,20,35,0.95));
|
||||||
|
border: 1px solid rgba(59,130,246,0.3); border-radius: 16px;
|
||||||
|
padding: 32px; margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.hero h1 { font-size: 28px; font-weight: 800; letter-spacing: -0.03em; }
|
||||||
|
.hero h1 span { color: #3b82f6; }
|
||||||
|
.hero-sub { font-size: 14px; color: #94a3b8; margin-top: 6px; }
|
||||||
|
.badge-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 16px; }
|
||||||
|
.badge { display: inline-block; padding: 4px 12px; border-radius: 6px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.badge-blue { background: rgba(59,130,246,0.15); color: #60a5fa; border: 1px solid rgba(59,130,246,0.25); }
|
||||||
|
.badge-green { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.25); }
|
||||||
|
.badge-purple { background: rgba(168,85,247,0.15); color: #c084fc; border: 1px solid rgba(168,85,247,0.25); }
|
||||||
|
.badge-amber { background: rgba(234,179,8,0.15); color: #fbbf24; border: 1px solid rgba(234,179,8,0.25); }
|
||||||
|
|
||||||
|
/* Sections */
|
||||||
|
.doc-section {
|
||||||
|
background: linear-gradient(135deg, rgba(30,30,45,0.9), rgba(20,20,35,0.95));
|
||||||
|
border: 1px solid rgba(100,100,140,0.2); border-radius: 16px;
|
||||||
|
padding: 24px 28px; margin-bottom: 16px;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
.doc-section:hover { border-color: rgba(140,140,200,0.35); }
|
||||||
|
.doc-section h2 { font-size: 20px; font-weight: 700; margin-bottom: 12px; display: flex; align-items: center; gap: 10px; }
|
||||||
|
.doc-section h3 { font-size: 15px; font-weight: 700; margin: 16px 0 8px; color: #c8d4ff; }
|
||||||
|
.doc-section h4 { font-size: 13px; font-weight: 700; margin: 12px 0 6px; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.doc-section p { font-size: 14px; line-height: 1.7; color: #94a3b8; margin-bottom: 10px; }
|
||||||
|
.doc-section ul { padding-left: 20px; margin-bottom: 10px; }
|
||||||
|
.doc-section li { font-size: 14px; line-height: 1.7; color: #94a3b8; margin-bottom: 4px; }
|
||||||
|
.doc-section strong { color: #e2e8f0; }
|
||||||
|
.doc-section code { background: rgba(59,130,246,0.1); color: #93c5fd; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-family: 'SF Mono', 'Fira Code', monospace; }
|
||||||
|
.doc-section a { color: #60a5fa; text-decoration: none; }
|
||||||
|
.doc-section a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.section-num {
|
||||||
|
width: 32px; height: 32px; border-radius: 50%;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font-weight: 800; font-size: 14px; flex-shrink: 0;
|
||||||
|
background: rgba(59,130,246,0.15); color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 12px 0; }
|
||||||
|
.card {
|
||||||
|
background: rgba(15,15,30,0.6); border: 1px solid rgba(100,100,140,0.15);
|
||||||
|
border-radius: 10px; padding: 16px;
|
||||||
|
}
|
||||||
|
.card h4 { margin-top: 0; }
|
||||||
|
.card p { font-size: 12px; margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.data-table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 13px; }
|
||||||
|
.data-table th {
|
||||||
|
text-align: left; padding: 8px 12px;
|
||||||
|
background: rgba(20,20,40,0.8); color: #64748b;
|
||||||
|
font-size: 10px; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em; border-bottom: 1px solid rgba(100,100,140,0.15);
|
||||||
|
}
|
||||||
|
.data-table td { padding: 8px 12px; color: #94a3b8; border-bottom: 1px solid rgba(100,100,140,0.08); }
|
||||||
|
.data-table tr:hover td { background: rgba(40,40,65,0.3); }
|
||||||
|
|
||||||
|
/* Callout */
|
||||||
|
.callout {
|
||||||
|
background: rgba(250,180,50,0.06); border: 1px solid rgba(250,180,50,0.2);
|
||||||
|
border-radius: 10px; padding: 14px 18px; margin: 12px 0;
|
||||||
|
}
|
||||||
|
.callout p { color: #d4a855; margin: 0; }
|
||||||
|
.callout strong { color: #fbbf24; }
|
||||||
|
|
||||||
|
.callout-blue {
|
||||||
|
background: rgba(59,130,246,0.06); border: 1px solid rgba(59,130,246,0.2);
|
||||||
|
}
|
||||||
|
.callout-blue p { color: #7dabf0; }
|
||||||
|
.callout-blue strong { color: #60a5fa; }
|
||||||
|
|
||||||
|
.callout-green {
|
||||||
|
background: rgba(34,197,94,0.06); border: 1px solid rgba(34,197,94,0.2);
|
||||||
|
}
|
||||||
|
.callout-green p { color: #6dbf8b; }
|
||||||
|
.callout-green strong { color: #4ade80; }
|
||||||
|
|
||||||
|
/* Steps */
|
||||||
|
.step-list { counter-reset: step; list-style: none; padding: 0; }
|
||||||
|
.step-list li {
|
||||||
|
counter-increment: step; padding: 10px 0 10px 48px; position: relative;
|
||||||
|
border-bottom: 1px solid rgba(100,100,140,0.08);
|
||||||
|
}
|
||||||
|
.step-list li:last-child { border-bottom: none; }
|
||||||
|
.step-list li::before {
|
||||||
|
content: counter(step);
|
||||||
|
position: absolute; left: 0; top: 10px;
|
||||||
|
width: 32px; height: 32px; border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-weight: 800; font-size: 13px;
|
||||||
|
background: rgba(59,130,246,0.1); color: #60a5fa;
|
||||||
|
border: 1px solid rgba(59,130,246,0.2);
|
||||||
|
}
|
||||||
|
.step-list li .step-title { font-weight: 700; color: #e2e8f0; font-size: 14px; }
|
||||||
|
.step-list li .step-desc { font-size: 13px; color: #64748b; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* Expandable */
|
||||||
|
.expandable { cursor: pointer; }
|
||||||
|
.expandable-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 0; }
|
||||||
|
.expandable-header::after { content: '▸'; color: #4a4a6a; transition: transform 0.2s; font-size: 12px; }
|
||||||
|
.expandable.open .expandable-header::after { transform: rotate(90deg); }
|
||||||
|
.expandable-body { display: none; padding: 0 0 10px; }
|
||||||
|
.expandable.open .expandable-body { display: block; }
|
||||||
|
|
||||||
|
/* Code block */
|
||||||
|
.code-block {
|
||||||
|
background: rgba(10,10,20,0.8); border: 1px solid rgba(100,100,140,0.15);
|
||||||
|
border-radius: 8px; padding: 14px 18px; margin: 10px 0;
|
||||||
|
font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px;
|
||||||
|
color: #94a3b8; line-height: 1.6; overflow-x: auto; white-space: pre;
|
||||||
|
}
|
||||||
|
.code-block .method { color: #60a5fa; font-weight: 700; }
|
||||||
|
.code-block .url { color: #4ade80; }
|
||||||
|
.code-block .comment { color: #475569; }
|
||||||
|
.code-block .key { color: #c084fc; }
|
||||||
|
.code-block .val { color: #fbbf24; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.card-grid { grid-template-columns: 1fr; }
|
||||||
|
.container { padding: 16px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<a href="/" class="back-link">← Back to User Management</a>
|
||||||
|
|
||||||
|
<!-- Hero -->
|
||||||
|
<div class="hero">
|
||||||
|
<h1>🔐 <span>User Management</span></h1>
|
||||||
|
<p class="hero-sub">User management, scoped access control, and multi-method authentication for scottfelten.com services.</p>
|
||||||
|
<div class="badge-row">
|
||||||
|
<span class="badge badge-green">v2.0 — Live</span>
|
||||||
|
<span class="badge badge-blue">18 Services</span>
|
||||||
|
<span class="badge badge-purple">3 Auth Methods</span>
|
||||||
|
<span class="badge badge-amber">March 2026</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 1. Overview -->
|
||||||
|
<div class="doc-section">
|
||||||
|
<h2><span class="section-num">1</span> Overview</h2>
|
||||||
|
<p>
|
||||||
|
User Management is the <strong>identity and access layer</strong> for all scottfelten.com services.
|
||||||
|
It controls who can sign in, what they can access, and how they authenticate — all from a single dashboard.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="card-grid">
|
||||||
|
<div class="card" style="border-left: 3px solid #3b82f6;">
|
||||||
|
<h4 style="color:#60a5fa">🔵 Google SSO</h4>
|
||||||
|
<p>Sign in with Google. Primary method for team members and close collaborators. Powered by oauth2-proxy.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="border-left: 3px solid #a855f7;">
|
||||||
|
<h4 style="color:#c084fc">✨ Magic Link</h4>
|
||||||
|
<p>One-time login link sent to email. No password needed. Great for clients and external partners.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="border-left: 3px solid #eab308;">
|
||||||
|
<h4 style="color:#fbbf24">🔑 Email + Password</h4>
|
||||||
|
<p>Traditional login with bcrypt hashing. 12-char minimum. Lockout protection. Password reset via email.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="callout callout-blue">
|
||||||
|
<p>💡 <strong>How it works:</strong> Every scottfelten.com service checks with User Management before letting anyone in.
|
||||||
|
When a user visits any service, User Management verifies their session and checks if they have permission for that specific service.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Adding a User -->
|
||||||
|
<div class="doc-section">
|
||||||
|
<h2><span class="section-num">2</span> Adding a User</h2>
|
||||||
|
|
||||||
|
<ol class="step-list">
|
||||||
|
<li>
|
||||||
|
<div class="step-title">Click "+ Add User" on the dashboard</div>
|
||||||
|
<div class="step-desc">Opens the user creation modal with all configuration options.</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="step-title">Enter their email and name</div>
|
||||||
|
<div class="step-desc">Email is their login identifier. Must be unique across all users.</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="step-title">Choose their role</div>
|
||||||
|
<div class="step-desc">
|
||||||
|
<strong>Client</strong> — external user, limited access.<br>
|
||||||
|
<strong>Partner</strong> — business collaborator (e.g., Colum for IAG).<br>
|
||||||
|
<strong>Admin</strong> — full access to everything including this dashboard.
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="step-title">Select auth method(s)</div>
|
||||||
|
<div class="step-desc">
|
||||||
|
Pick one or more: <strong>Google</strong> (they need a Google account), <strong>Magic Link</strong> (any email works),
|
||||||
|
or <strong>Password</strong> (you set their initial password, or they can set it later via reset).
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="step-title">Assign services</div>
|
||||||
|
<div class="step-desc">
|
||||||
|
Check individual services they should access, or select <strong>All Services (*)</strong> for unrestricted access.
|
||||||
|
Services are auto-populated from the project inventory — always current.
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="step-title">Optional: Add tags</div>
|
||||||
|
<div class="step-desc">Comma-separated labels for organization (e.g., "partner, bpo-expert"). Purely informational for now.</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="callout">
|
||||||
|
<p>⚡ <strong>Quick example — Adding Colum (IAG partner):</strong><br>
|
||||||
|
Email: colum@whatever.com · Role: Partner · Auth: Magic Link · Services: iag-website, iag-data-viewer, intellicert, usecasegen</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. Auth Methods Deep Dive -->
|
||||||
|
<div class="doc-section">
|
||||||
|
<h2><span class="section-num">3</span> Auth Methods — How Each Works</h2>
|
||||||
|
|
||||||
|
<h3>🔵 Google SSO</h3>
|
||||||
|
<p>The primary auth method. Uses oauth2-proxy to handle the Google OAuth flow. When a user visits a service:</p>
|
||||||
|
<ul>
|
||||||
|
<li>They're redirected to Google's sign-in page</li>
|
||||||
|
<li>After authenticating, oauth2-proxy sets a session cookie</li>
|
||||||
|
<li>User Management verifies the cookie and checks their service permissions</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Requirement:</strong> User must have a Google account, and their email must be in the allowlist.</p>
|
||||||
|
|
||||||
|
<h3>✨ Magic Link</h3>
|
||||||
|
<p>Email-based, passwordless login. Perfect for clients and partners who don't use Google.</p>
|
||||||
|
<ul>
|
||||||
|
<li>User enters their email on the <a href="/login.html">login page</a></li>
|
||||||
|
<li>They receive a one-time link (expires in <strong>15 minutes</strong>)</li>
|
||||||
|
<li>Clicking the link sets a session cookie valid for <strong>7 days</strong></li>
|
||||||
|
<li>No password to remember, no account to create</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="callout callout-green">
|
||||||
|
<p>🔒 <strong>Security:</strong> Links are single-use (consumed on first click) and time-limited. The token is a 32-byte random hex string — not guessable.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>🔑 Email + Password</h3>
|
||||||
|
<p>Traditional login for users who prefer (or need) a persistent credential.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Passwords hashed with <strong>bcrypt</strong> (12 rounds) — industry standard</li>
|
||||||
|
<li><strong>12-character minimum</strong> — enforced on creation and reset</li>
|
||||||
|
<li><strong>Lockout:</strong> 5 failed attempts → account locked for 30 minutes (auto-unlocks)</li>
|
||||||
|
<li><strong>Password reset:</strong> sends a time-limited reset link (1 hour expiry)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Setting a Password</h4>
|
||||||
|
<p>Two ways to set a password for a user:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>At creation:</strong> Enter a password in the "Initial Password" field when adding the user</li>
|
||||||
|
<li><strong>After creation:</strong> Click the 🔑 button next to their name in the dashboard</li>
|
||||||
|
</ul>
|
||||||
|
<p>Users can also reset their own password via the "Forgot password?" link on the login page.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. Services & Permissions -->
|
||||||
|
<div class="doc-section">
|
||||||
|
<h2><span class="section-num">4</span> Services & Permissions</h2>
|
||||||
|
<p>
|
||||||
|
Services are <strong>auto-discovered</strong> from the project inventory every 5 minutes.
|
||||||
|
When a new service is deployed, it appears in the services list automatically — no manual updates needed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>How Scoping Works</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Each user has a <code>services[]</code> array listing which services they can access</li>
|
||||||
|
<li>When they visit a service, User Management maps the hostname to a service ID and checks if it's in their list</li>
|
||||||
|
<li><strong>Wildcard:</strong> <code>*</code> grants access to everything (admin default)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Current Service Map</h3>
|
||||||
|
<p>These services are protected by User Management. The list updates automatically from inventory.</p>
|
||||||
|
|
||||||
|
<table class="data-table">
|
||||||
|
<tr><th>Service</th><th>URL</th><th>Category</th></tr>
|
||||||
|
<tr><td>inventory</td><td>inv.scottfelten.com</td><td>Core</td></tr>
|
||||||
|
<tr><td>access-manager</td><td>users.scottfelten.com</td><td>Core</td></tr>
|
||||||
|
<tr><td>taos</td><td>goalstack.scottfelten.com</td><td>Core</td></tr>
|
||||||
|
<tr><td>signal-tower</td><td>signal.scottfelten.com</td><td>Intelligence</td></tr>
|
||||||
|
<tr><td>customer-360</td><td>c360.scottfelten.com</td><td>Sales</td></tr>
|
||||||
|
<tr><td>models-embeddings</td><td>models.scottfelten.com</td><td>AI/ML</td></tr>
|
||||||
|
<tr><td>iag-website</td><td>intelligenceadvisorygroup.com</td><td>IAG</td></tr>
|
||||||
|
<tr><td>iag-data-viewer</td><td>iag.scottfelten.com</td><td>IAG</td></tr>
|
||||||
|
<tr><td>intellicert</td><td>intellicert.app</td><td>IAG</td></tr>
|
||||||
|
<tr><td>usecasegen</td><td>app.usecasegen.app</td><td>IAG</td></tr>
|
||||||
|
<tr><td colspan="3" style="color:#475569; font-style:italic;">+ 8 more (auto-discovered from inventory)</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Manual Sync</h3>
|
||||||
|
<p>Click the <strong>↻ Sync</strong> button in the dashboard header to force a refresh from inventory. Normally happens automatically every 5 minutes.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5. Login Page -->
|
||||||
|
<div class="doc-section">
|
||||||
|
<h2><span class="section-num">5</span> The Login Page</h2>
|
||||||
|
<p>
|
||||||
|
Available at <a href="/login.html">/login.html</a> — this is the <strong>public entry point</strong> for non-Google users.
|
||||||
|
It's accessible without authentication.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Three Tabs</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Google</strong> — redirects to Google sign-in (for users with Google SSO enabled)</li>
|
||||||
|
<li><strong>Magic Link</strong> — enter email, receive login link</li>
|
||||||
|
<li><strong>Password</strong> — enter email + password, with "Forgot password?" reset flow</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Redirect Flow</h3>
|
||||||
|
<p>When a user hits a protected service without a session, they'll be redirected to the login page with a <code>?rd=</code> parameter.
|
||||||
|
After successful auth, they're sent back to the service they originally wanted.</p>
|
||||||
|
|
||||||
|
<div class="callout callout-blue">
|
||||||
|
<p>💡 <strong>Currently:</strong> The default redirect for unauthenticated users goes to Google sign-in.
|
||||||
|
To send non-Google users to the multi-auth login page instead, share the direct link:
|
||||||
|
<code>https://users.scottfelten.com/login.html?rd=https://SERVICE_URL</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 6. Security -->
|
||||||
|
<div class="doc-section">
|
||||||
|
<h2><span class="section-num">6</span> Security</h2>
|
||||||
|
|
||||||
|
<table class="data-table">
|
||||||
|
<tr><th>Feature</th><th>Detail</th></tr>
|
||||||
|
<tr><td>Password hashing</td><td>bcrypt, 12 rounds</td></tr>
|
||||||
|
<tr><td>Password minimum</td><td>12 characters</td></tr>
|
||||||
|
<tr><td>Login lockout</td><td>5 failed attempts → 30-minute lock (auto-unlocks)</td></tr>
|
||||||
|
<tr><td>Magic link expiry</td><td>15 minutes, single-use</td></tr>
|
||||||
|
<tr><td>Reset token expiry</td><td>1 hour, single-use</td></tr>
|
||||||
|
<tr><td>Session duration</td><td>7 days (JWT cookie on .scottfelten.com)</td></tr>
|
||||||
|
<tr><td>Cookie flags</td><td>httpOnly, secure, sameSite=lax</td></tr>
|
||||||
|
<tr><td>Token entropy</td><td>32 bytes (256-bit) random hex</td></tr>
|
||||||
|
<tr><td>Email enumeration</td><td>Prevented — all responses are identical regardless of email existence</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Session Types</h3>
|
||||||
|
<p>Two session mechanisms coexist:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>oauth2-proxy cookie</strong> — for Google SSO users (managed by oauth2-proxy)</li>
|
||||||
|
<li><strong>_am_session cookie</strong> — for magic link and password users (JWT, managed by User Management)</li>
|
||||||
|
</ul>
|
||||||
|
<p>The <code>/auth/check</code> endpoint tries the <code>_am_session</code> first, then falls back to oauth2-proxy. Either way, scoped access is enforced.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 7. API Reference -->
|
||||||
|
<div class="doc-section">
|
||||||
|
<h2><span class="section-num">7</span> API Reference</h2>
|
||||||
|
<p>All admin endpoints require authentication (Google SSO or _am_session with admin role).</p>
|
||||||
|
|
||||||
|
<h3>Authentication</h3>
|
||||||
|
|
||||||
|
<div class="expandable" onclick="this.classList.toggle('open')">
|
||||||
|
<div class="expandable-header"><strong>POST /auth/magic/request</strong> — Request a magic link</div>
|
||||||
|
<div class="expandable-body">
|
||||||
|
<p>Public endpoint. Sends a login link to the provided email (if registered).</p>
|
||||||
|
<div class="code-block"><span class="method">POST</span> <span class="url">/auth/magic/request</span>
|
||||||
|
<span class="comment">// Body:</span>
|
||||||
|
{ <span class="key">"email"</span>: <span class="val">"user@example.com"</span>, <span class="key">"redirect"</span>: <span class="val">"https://inv.scottfelten.com"</span> }
|
||||||
|
|
||||||
|
<span class="comment">// Response (always the same, prevents email enumeration):</span>
|
||||||
|
{ <span class="key">"ok"</span>: true, <span class="key">"message"</span>: <span class="val">"If that email is registered, a login link has been sent."</span> }</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="expandable" onclick="this.classList.toggle('open')">
|
||||||
|
<div class="expandable-header"><strong>POST /auth/password/login</strong> — Email + password login</div>
|
||||||
|
<div class="expandable-body">
|
||||||
|
<p>Public endpoint. Returns session cookie on success.</p>
|
||||||
|
<div class="code-block"><span class="method">POST</span> <span class="url">/auth/password/login</span>
|
||||||
|
<span class="comment">// Body:</span>
|
||||||
|
{ <span class="key">"email"</span>: <span class="val">"user@example.com"</span>, <span class="key">"password"</span>: <span class="val">"their-password"</span> }
|
||||||
|
|
||||||
|
<span class="comment">// Success:</span>
|
||||||
|
{ <span class="key">"ok"</span>: true, <span class="key">"user"</span>: { <span class="key">"email"</span>: ..., <span class="key">"name"</span>: ..., <span class="key">"role"</span>: ... } }
|
||||||
|
|
||||||
|
<span class="comment">// Failure (with lockout tracking):</span>
|
||||||
|
{ <span class="key">"error"</span>: <span class="val">"Invalid email or password"</span>, <span class="key">"attemptsRemaining"</span>: 3 }
|
||||||
|
|
||||||
|
<span class="comment">// Locked out:</span>
|
||||||
|
{ <span class="key">"error"</span>: <span class="val">"Account locked. Try again in 28 minutes."</span>, <span class="key">"locked"</span>: true }</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="expandable" onclick="this.classList.toggle('open')">
|
||||||
|
<div class="expandable-header"><strong>POST /auth/password/set</strong> — Set password for a user (admin)</div>
|
||||||
|
<div class="expandable-body">
|
||||||
|
<div class="code-block"><span class="method">POST</span> <span class="url">/auth/password/set</span>
|
||||||
|
<span class="comment">// Body:</span>
|
||||||
|
{ <span class="key">"email"</span>: <span class="val">"user@example.com"</span>, <span class="key">"password"</span>: <span class="val">"min-12-characters"</span> }</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="expandable" onclick="this.classList.toggle('open')">
|
||||||
|
<div class="expandable-header"><strong>POST /auth/password/reset-request</strong> — Request password reset</div>
|
||||||
|
<div class="expandable-body">
|
||||||
|
<p>Public endpoint. Sends a reset link to the email if registered.</p>
|
||||||
|
<div class="code-block"><span class="method">POST</span> <span class="url">/auth/password/reset-request</span>
|
||||||
|
{ <span class="key">"email"</span>: <span class="val">"user@example.com"</span> }</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="expandable" onclick="this.classList.toggle('open')">
|
||||||
|
<div class="expandable-header"><strong>POST /auth/password/reset</strong> — Execute password reset</div>
|
||||||
|
<div class="expandable-body">
|
||||||
|
<div class="code-block"><span class="method">POST</span> <span class="url">/auth/password/reset</span>
|
||||||
|
{ <span class="key">"token"</span>: <span class="val">"hex-token-from-email"</span>, <span class="key">"password"</span>: <span class="val">"new-password-12+"</span> }</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="expandable" onclick="this.classList.toggle('open')">
|
||||||
|
<div class="expandable-header"><strong>GET /auth/session</strong> — Check current session</div>
|
||||||
|
<div class="expandable-body">
|
||||||
|
<p>Public endpoint. Returns session info or <code>{ authenticated: false }</code>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="expandable" onclick="this.classList.toggle('open')">
|
||||||
|
<div class="expandable-header"><strong>POST /auth/logout</strong> — Clear session</div>
|
||||||
|
<div class="expandable-body">
|
||||||
|
<p>Clears the <code>_am_session</code> cookie. Note: does not clear the oauth2-proxy session (Google SSO).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>User Management (Admin)</h3>
|
||||||
|
|
||||||
|
<div class="expandable" onclick="this.classList.toggle('open')">
|
||||||
|
<div class="expandable-header"><strong>GET /api/users</strong> — List all users</div>
|
||||||
|
<div class="expandable-body">
|
||||||
|
<p>Returns array of users. Password hashes are excluded; includes <code>hasPassword: true/false</code>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="expandable" onclick="this.classList.toggle('open')">
|
||||||
|
<div class="expandable-header"><strong>POST /api/users</strong> — Create a user</div>
|
||||||
|
<div class="expandable-body">
|
||||||
|
<div class="code-block"><span class="method">POST</span> <span class="url">/api/users</span>
|
||||||
|
{
|
||||||
|
<span class="key">"email"</span>: <span class="val">"colum@example.com"</span>,
|
||||||
|
<span class="key">"name"</span>: <span class="val">"Colum"</span>,
|
||||||
|
<span class="key">"role"</span>: <span class="val">"partner"</span>,
|
||||||
|
<span class="key">"authMethods"</span>: [<span class="val">"magic-link"</span>],
|
||||||
|
<span class="key">"services"</span>: [<span class="val">"iag-website"</span>, <span class="val">"iag-data-viewer"</span>, <span class="val">"intellicert"</span>, <span class="val">"usecasegen"</span>],
|
||||||
|
<span class="key">"tags"</span>: [<span class="val">"partner"</span>],
|
||||||
|
<span class="key">"password"</span>: <span class="val">"optional-initial-password"</span>
|
||||||
|
}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="expandable" onclick="this.classList.toggle('open')">
|
||||||
|
<div class="expandable-header"><strong>PUT /api/users/:email</strong> — Update a user</div>
|
||||||
|
<div class="expandable-body">
|
||||||
|
<p>Partial update — only send the fields you want to change.</p>
|
||||||
|
<div class="code-block"><span class="method">PUT</span> <span class="url">/api/users/colum@example.com</span>
|
||||||
|
{ <span class="key">"services"</span>: [<span class="val">"iag-website"</span>, <span class="val">"intellicert"</span>] }</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="expandable" onclick="this.classList.toggle('open')">
|
||||||
|
<div class="expandable-header"><strong>DELETE /api/users/:email</strong> — Remove a user</div>
|
||||||
|
<div class="expandable-body">
|
||||||
|
<p>Removes the user and updates the oauth2-proxy allowlist. Cannot delete yourself.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Services</h3>
|
||||||
|
|
||||||
|
<div class="expandable" onclick="this.classList.toggle('open')">
|
||||||
|
<div class="expandable-header"><strong>GET /api/services</strong> — List available services</div>
|
||||||
|
<div class="expandable-body"><p>Returns sorted array of unique service IDs (deduplicated from hostname mappings).</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="expandable" onclick="this.classList.toggle('open')">
|
||||||
|
<div class="expandable-header"><strong>GET /api/services/map</strong> — Full hostname → service map</div>
|
||||||
|
<div class="expandable-body"><p>Returns the complete mapping of hostnames to service IDs. Useful for debugging.</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="expandable" onclick="this.classList.toggle('open')">
|
||||||
|
<div class="expandable-header"><strong>POST /api/services/sync</strong> — Force sync from inventory</div>
|
||||||
|
<div class="expandable-body"><p>Pulls fresh data from inventory API. Normally happens automatically every 5 minutes.</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Configuration</h3>
|
||||||
|
|
||||||
|
<div class="expandable" onclick="this.classList.toggle('open')">
|
||||||
|
<div class="expandable-header"><strong>PUT /api/config/email</strong> — Configure email transport</div>
|
||||||
|
<div class="expandable-body">
|
||||||
|
<div class="code-block"><span class="method">PUT</span> <span class="url">/api/config/email</span>
|
||||||
|
{
|
||||||
|
<span class="key">"host"</span>: <span class="val">"smtp.gmail.com"</span>,
|
||||||
|
<span class="key">"port"</span>: 587,
|
||||||
|
<span class="key">"user"</span>: <span class="val">"scott@scottfelten.com"</span>,
|
||||||
|
<span class="key">"pass"</span>: <span class="val">"google-app-password"</span>,
|
||||||
|
<span class="key">"from"</span>: <span class="val">"TARS <scott@scottfelten.com>"</span>
|
||||||
|
}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 8. Architecture -->
|
||||||
|
<div class="doc-section">
|
||||||
|
<h2><span class="section-num">8</span> Architecture</h2>
|
||||||
|
|
||||||
|
<h3>Auth Flow</h3>
|
||||||
|
<div class="code-block"><span class="comment">User visits service.scottfelten.com</span>
|
||||||
|
↓
|
||||||
|
<span class="comment">nginx auth_request → User Management /auth/check</span>
|
||||||
|
↓
|
||||||
|
<span class="comment">Check 1: _am_session cookie? (magic link / password users)</span>
|
||||||
|
↓ no
|
||||||
|
<span class="comment">Check 2: oauth2-proxy session? (Google SSO users)</span>
|
||||||
|
↓ no
|
||||||
|
<span class="comment">401 → redirect to Google sign-in (or login page for non-Google users)</span>
|
||||||
|
↓ yes (either check)
|
||||||
|
<span class="comment">Look up user → check service scope → 200 (allow) or 403 (denied)</span></div>
|
||||||
|
|
||||||
|
<h3>Infrastructure</h3>
|
||||||
|
<table class="data-table">
|
||||||
|
<tr><th>Component</th><th>Location</th><th>Port</th></tr>
|
||||||
|
<tr><td>User Management</td><td>VPS systemd: access-manager.service</td><td>3030</td></tr>
|
||||||
|
<tr><td>oauth2-proxy</td><td>VPS systemd: oauth2-proxy.service</td><td>4180</td></tr>
|
||||||
|
<tr><td>Inventory API</td><td>VPS systemd: inventory-api.service</td><td>3025</td></tr>
|
||||||
|
<tr><td>nginx</td><td>All services proxied, auth_request on each</td><td>443</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Data Files</h3>
|
||||||
|
<table class="data-table">
|
||||||
|
<tr><th>File</th><th>Purpose</th></tr>
|
||||||
|
<tr><td><code>data/users.json</code></td><td>User store (email, role, services, passwordHash, authMethods)</td></tr>
|
||||||
|
<tr><td><code>data/services-cache.json</code></td><td>Cached service map from inventory (refreshed every 5 min)</td></tr>
|
||||||
|
<tr><td><code>data/tokens.json</code></td><td>Active magic link and reset tokens (auto-cleaned)</td></tr>
|
||||||
|
<tr><td><code>data/lockouts.json</code></td><td>Failed login tracking and lockout state</td></tr>
|
||||||
|
<tr><td><code>data/config.json</code></td><td>JWT secret, SMTP config, email settings</td></tr>
|
||||||
|
<tr><td><code>/etc/oauth2-proxy-users.txt</code></td><td>oauth2-proxy email allowlist (auto-synced from users.json)</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 9. Email Setup -->
|
||||||
|
<div class="doc-section">
|
||||||
|
<h2><span class="section-num">9</span> Setting Up Email</h2>
|
||||||
|
<p>
|
||||||
|
Email is required for magic links and password resets. Without it, these features log to the server console
|
||||||
|
(useful for testing, not for real users).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Option A: Google Workspace SMTP (Recommended)</h3>
|
||||||
|
<ol class="step-list">
|
||||||
|
<li>
|
||||||
|
<div class="step-title">Create a Google App Password</div>
|
||||||
|
<div class="step-desc">Go to <a href="https://myaccount.google.com/apppasswords" target="_blank">myaccount.google.com/apppasswords</a> → generate a password for "Mail" on "Other device"</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="step-title">Configure via API</div>
|
||||||
|
<div class="step-desc">
|
||||||
|
<div class="code-block"><span class="method">PUT</span> <span class="url">https://users.scottfelten.com/api/config/email</span>
|
||||||
|
{
|
||||||
|
<span class="key">"host"</span>: <span class="val">"smtp.gmail.com"</span>,
|
||||||
|
<span class="key">"port"</span>: 587,
|
||||||
|
<span class="key">"user"</span>: <span class="val">"scott@scottfelten.com"</span>,
|
||||||
|
<span class="key">"pass"</span>: <span class="val">"xxxx-xxxx-xxxx-xxxx"</span>,
|
||||||
|
<span class="key">"from"</span>: <span class="val">"TARS <scott@scottfelten.com>"</span>
|
||||||
|
}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="step-title">Test it</div>
|
||||||
|
<div class="step-desc">Request a magic link for your own email. If it arrives, you're good.</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Option B: Resend.com</h3>
|
||||||
|
<p>Free tier: 100 emails/day. Use <code>smtp.resend.com</code> with your API key as the password.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 10. Quick Reference -->
|
||||||
|
<div class="doc-section">
|
||||||
|
<h2><span class="section-num">10</span> Quick Reference</h2>
|
||||||
|
|
||||||
|
<table class="data-table">
|
||||||
|
<tr><th>Task</th><th>How</th></tr>
|
||||||
|
<tr><td>Add a Google SSO user</td><td>Dashboard → + Add User → select Google auth + services</td></tr>
|
||||||
|
<tr><td>Add a non-Google user</td><td>Dashboard → + Add User → select Magic Link or Password auth</td></tr>
|
||||||
|
<tr><td>Set/change a password</td><td>Dashboard → click 🔑 button next to user</td></tr>
|
||||||
|
<tr><td>See who has access to what</td><td>Dashboard — services shown inline per user</td></tr>
|
||||||
|
<tr><td>Refresh the services list</td><td>Dashboard → ↻ Sync button</td></tr>
|
||||||
|
<tr><td>Send a login link</td><td>Share: <code>users.scottfelten.com/login.html</code></td></tr>
|
||||||
|
<tr><td>Check system health</td><td><code>GET /health</code> — shows users, services, email status</td></tr>
|
||||||
|
<tr><td>Unlock a locked account</td><td>Wait 30 min (auto-unlocks) or delete <code>data/lockouts.json</code></td></tr>
|
||||||
|
<tr><td>Configure email</td><td><code>PUT /api/config/email</code> (see Section 9)</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div style="text-align:center; padding: 24px 0; color: #334155; font-size: 11px;">
|
||||||
|
User Management v2.0 · Built by TARS · March 2026
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Make expandables work
|
||||||
|
document.querySelectorAll('.expandable').forEach(el => {
|
||||||
|
el.querySelector('.expandable-header').addEventListener('click', () => {
|
||||||
|
el.classList.toggle('open');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
419
public/index.html
Normal file
419
public/index.html
Normal file
|
|
@ -0,0 +1,419 @@
|
||||||
|
<!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>
|
||||||
254
public/login.html
Normal file
254
public/login.html
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Sign In — 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; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||||
|
|
||||||
|
.login-card { background: #1e293b; border: 1px solid #334155; border-radius: 16px; width: 90%; max-width: 420px; padding: 32px; }
|
||||||
|
.logo { text-align: center; margin-bottom: 24px; }
|
||||||
|
.logo h1 { font-size: 22px; font-weight: 700; color: white; }
|
||||||
|
.logo p { font-size: 12px; color: #64748b; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs { display: flex; gap: 4px; margin-bottom: 24px; background: #0f172a; border-radius: 8px; padding: 4px; }
|
||||||
|
.tab { flex: 1; padding: 8px 12px; text-align: center; font-size: 12px; font-weight: 600; color: #64748b; border-radius: 6px; cursor: pointer; transition: all 0.15s; border: none; background: none; }
|
||||||
|
.tab:hover { color: #94a3b8; }
|
||||||
|
.tab.active { background: #334155; color: white; }
|
||||||
|
|
||||||
|
/* Panels */
|
||||||
|
.panel { display: none; }
|
||||||
|
.panel.active { display: block; }
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
.field { margin-bottom: 16px; }
|
||||||
|
.field label { display: block; font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }
|
||||||
|
.input { width: 100%; background: #0f172a; border: 1px solid #334155; border-radius: 8px; padding: 12px 14px; font-size: 14px; color: #e2e8f0; outline: none; font-family: inherit; transition: border-color 0.15s; }
|
||||||
|
.input:focus { border-color: #3b82f6; }
|
||||||
|
.input::placeholder { color: #475569; }
|
||||||
|
|
||||||
|
.btn { width: 100%; padding: 12px; border: none; border-radius: 8px; font-weight: 600; font-size: 14px; cursor: pointer; transition: all 0.15s; }
|
||||||
|
.btn-primary { background: #3b82f6; color: white; }
|
||||||
|
.btn-primary:hover { background: #2563eb; }
|
||||||
|
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn-google { background: white; color: #1f2937; display: flex; align-items: center; justify-content: center; gap: 10px; }
|
||||||
|
.btn-google:hover { background: #f1f5f9; }
|
||||||
|
.btn-google svg { width: 18px; height: 18px; }
|
||||||
|
|
||||||
|
.divider { display: flex; align-items: center; gap: 12px; margin: 20px 0; }
|
||||||
|
.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: #334155; }
|
||||||
|
.divider span { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
|
||||||
|
.message { padding: 12px 16px; border-radius: 8px; font-size: 13px; margin-bottom: 16px; }
|
||||||
|
.message-success { background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.2); color: #4ade80; }
|
||||||
|
.message-error { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); color: #f87171; }
|
||||||
|
.message-info { background: rgba(59,130,246,0.1); border: 1px solid rgba(59,130,246,0.2); color: #60a5fa; }
|
||||||
|
|
||||||
|
.link { color: #3b82f6; cursor: pointer; font-size: 12px; text-decoration: none; }
|
||||||
|
.link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.password-req { font-size: 11px; color: #475569; margin-top: 4px; }
|
||||||
|
|
||||||
|
.footer { text-align: center; margin-top: 20px; padding-top: 16px; border-top: 1px solid #334155; }
|
||||||
|
.footer p { font-size: 11px; color: #475569; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="logo">
|
||||||
|
<h1>scottfelten.com</h1>
|
||||||
|
<p>Sign in to access services</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="msg"></div>
|
||||||
|
|
||||||
|
<!-- Reset password form (shown when ?reset=token) -->
|
||||||
|
<div id="reset-panel" style="display:none">
|
||||||
|
<h3 style="color:white;font-size:16px;margin-bottom:16px;">Set New Password</h3>
|
||||||
|
<div class="field">
|
||||||
|
<label>New Password</label>
|
||||||
|
<input type="password" id="reset-pw" class="input" placeholder="Minimum 12 characters" minlength="12">
|
||||||
|
<div class="password-req">At least 12 characters</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Confirm Password</label>
|
||||||
|
<input type="password" id="reset-pw2" class="input" placeholder="Confirm password">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="doReset()">Set Password</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main login form -->
|
||||||
|
<div id="login-panels">
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab active" data-tab="google" onclick="switchTab('google')">Google</button>
|
||||||
|
<button class="tab" data-tab="magic" onclick="switchTab('magic')">Magic Link</button>
|
||||||
|
<button class="tab" data-tab="password" onclick="switchTab('password')">Password</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Google SSO -->
|
||||||
|
<div id="tab-google" class="panel active">
|
||||||
|
<p style="font-size:13px;color:#94a3b8;margin-bottom:16px;">Sign in with your Google account. Recommended for team members.</p>
|
||||||
|
<a href="/oauth2/start?rd=/" class="btn btn-google" style="text-decoration:none;">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>
|
||||||
|
Sign in with Google
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Magic Link -->
|
||||||
|
<div id="tab-magic" class="panel">
|
||||||
|
<p style="font-size:13px;color:#94a3b8;margin-bottom:16px;">We'll email you a one-time login link. No password needed.</p>
|
||||||
|
<div class="field">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" id="magic-email" class="input" placeholder="you@example.com">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" id="magic-btn" onclick="requestMagicLink()">Send Login Link</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email + Password -->
|
||||||
|
<div id="tab-password" class="panel">
|
||||||
|
<div class="field">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" id="pw-email" class="input" placeholder="you@example.com">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" id="pw-pass" class="input" placeholder="Your password">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="doLogin()">Sign In</button>
|
||||||
|
<div style="text-align:center;margin-top:12px;">
|
||||||
|
<a class="link" onclick="requestPasswordReset()">Forgot password?</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Access is by invitation only</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Check for URL params
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const rd = params.get('rd') || '/';
|
||||||
|
const error = params.get('error');
|
||||||
|
const resetToken = params.get('reset');
|
||||||
|
|
||||||
|
if (error === 'expired') showMsg('Login link has expired. Please request a new one.', 'error');
|
||||||
|
if (error === 'not_registered') showMsg('Email not registered. Contact admin for access.', 'error');
|
||||||
|
|
||||||
|
if (resetToken) {
|
||||||
|
document.getElementById('login-panels').style.display = 'none';
|
||||||
|
document.getElementById('reset-panel').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(tab) {
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
||||||
|
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
|
||||||
|
document.getElementById('tab-' + tab).classList.add('active');
|
||||||
|
document.getElementById('msg').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMsg(text, type) {
|
||||||
|
document.getElementById('msg').innerHTML = `<div class="message message-${type}">${text}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestMagicLink() {
|
||||||
|
const email = document.getElementById('magic-email').value.trim();
|
||||||
|
if (!email) return showMsg('Enter your email address', 'error');
|
||||||
|
|
||||||
|
const btn = document.getElementById('magic-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Sending...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/magic/request', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, redirect: rd }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
showMsg('Check your email for a login link. It expires in 15 minutes.', 'success');
|
||||||
|
btn.textContent = 'Link Sent ✓';
|
||||||
|
} catch (e) {
|
||||||
|
showMsg('Error sending link. Try again.', 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Send Login Link';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogin() {
|
||||||
|
const email = document.getElementById('pw-email').value.trim();
|
||||||
|
const password = document.getElementById('pw-pass').value;
|
||||||
|
if (!email || !password) return showMsg('Enter email and password', 'error');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/password/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
location.href = rd;
|
||||||
|
} else {
|
||||||
|
let msg = data.error;
|
||||||
|
if (data.attemptsRemaining !== undefined && data.attemptsRemaining > 0) {
|
||||||
|
msg += ` (${data.attemptsRemaining} attempts remaining)`;
|
||||||
|
}
|
||||||
|
showMsg(msg, 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMsg('Connection error. Try again.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestPasswordReset() {
|
||||||
|
const email = document.getElementById('pw-email').value.trim();
|
||||||
|
if (!email) return showMsg('Enter your email first, then click Forgot Password', 'info');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/password/reset-request', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
showMsg('If that email is registered, a reset link has been sent.', 'success');
|
||||||
|
} catch {
|
||||||
|
showMsg('Error. Try again.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doReset() {
|
||||||
|
const pw = document.getElementById('reset-pw').value;
|
||||||
|
const pw2 = document.getElementById('reset-pw2').value;
|
||||||
|
if (pw.length < 12) return showMsg('Password must be at least 12 characters', 'error');
|
||||||
|
if (pw !== pw2) return showMsg('Passwords don\'t match', 'error');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/password/reset', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: resetToken, password: pw }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
showMsg('Password set! Redirecting to login...', 'success');
|
||||||
|
setTimeout(() => { location.href = '/login.html'; }, 2000);
|
||||||
|
} else {
|
||||||
|
showMsg(data.error, 'error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showMsg('Error. Try again.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter key handlers
|
||||||
|
document.getElementById('magic-email')?.addEventListener('keydown', e => { if (e.key === 'Enter') requestMagicLink(); });
|
||||||
|
document.getElementById('pw-pass')?.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
||||||
|
document.getElementById('reset-pw2')?.addEventListener('keydown', e => { if (e.key === 'Enter') doReset(); });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
175
rbac-routes.js
Normal file
175
rbac-routes.js
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// RBAC API ROUTES — bolt onto existing server
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
const db = require('./lib/db');
|
||||||
|
const rbac = require('./lib/rbac');
|
||||||
|
|
||||||
|
module.exports = function mountRbacRoutes(app, requireAdmin) {
|
||||||
|
|
||||||
|
// ─── Permission Check (for apps — service-to-service) ───
|
||||||
|
app.post('/api/permissions/check', (req, res) => {
|
||||||
|
const { email, app: appId, feature, action } = req.body;
|
||||||
|
if (!email || !appId || !feature || !action) {
|
||||||
|
return res.status(400).json({ error: 'email, app, feature, action required' });
|
||||||
|
}
|
||||||
|
const result = rbac.hasPermissionByEmail(email, appId, feature, action);
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/permissions/user/:email', requireAdmin, (req, res) => {
|
||||||
|
const result = rbac.getEffectivePermissionsByEmail(req.params.email);
|
||||||
|
res.json({ email: req.params.email, ...result });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/permissions/user/:email/:app', requireAdmin, (req, res) => {
|
||||||
|
const result = rbac.getEffectivePermissionsByEmail(req.params.email, req.params.app);
|
||||||
|
res.json({ email: req.params.email, app: req.params.app, ...result });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Roles CRUD ───
|
||||||
|
app.get('/api/roles', requireAdmin, (req, res) => {
|
||||||
|
res.json(rbac.listRoles());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/roles', requireAdmin, (req, res) => {
|
||||||
|
const { name, display_name, description, priority } = req.body;
|
||||||
|
if (!name || !display_name) return res.status(400).json({ error: 'name and display_name required' });
|
||||||
|
try {
|
||||||
|
rbac.createRole(name, display_name, description, false, priority || 100);
|
||||||
|
rbac.audit(getActorEmail(req), 'role.create', 'role', name, { display_name });
|
||||||
|
res.status(201).json(rbac.getRole(name));
|
||||||
|
} catch (e) {
|
||||||
|
res.status(409).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/roles/:name', requireAdmin, (req, res) => {
|
||||||
|
const role = rbac.getRole(req.params.name);
|
||||||
|
if (!role) return res.status(404).json({ error: 'Role not found' });
|
||||||
|
res.json(role);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/roles/:name', requireAdmin, (req, res) => {
|
||||||
|
const result = rbac.updateRole(req.params.name, req.body);
|
||||||
|
if (!result) return res.status(404).json({ error: 'Role not found' });
|
||||||
|
rbac.audit(getActorEmail(req), 'role.update', 'role', req.params.name, req.body);
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/roles/:name', requireAdmin, (req, res) => {
|
||||||
|
const result = rbac.deleteRole(req.params.name);
|
||||||
|
if (result.error === 'not_found') return res.status(404).json({ error: 'Role not found' });
|
||||||
|
if (result.error === 'system_role') return res.status(400).json({ error: 'Cannot delete system roles' });
|
||||||
|
rbac.audit(getActorEmail(req), 'role.delete', 'role', req.params.name);
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Role Permissions ───
|
||||||
|
app.put('/api/roles/:name/permissions', requireAdmin, (req, res) => {
|
||||||
|
const { permission_ids } = req.body;
|
||||||
|
if (!Array.isArray(permission_ids)) return res.status(400).json({ error: 'permission_ids array required' });
|
||||||
|
const result = rbac.setRolePermissions(req.params.name, permission_ids);
|
||||||
|
if (result.error) return res.status(404).json({ error: result.error });
|
||||||
|
rbac.audit(getActorEmail(req), 'role.permissions.set', 'role', req.params.name, { count: permission_ids.length });
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/roles/:name/permissions', requireAdmin, (req, res) => {
|
||||||
|
const { app: appId, feature, action } = req.body;
|
||||||
|
if (!appId || !feature || !action) return res.status(400).json({ error: 'app, feature, action required' });
|
||||||
|
const result = rbac.addRolePermissionsByPattern(req.params.name, appId, feature, action);
|
||||||
|
if (result.error) return res.status(404).json({ error: result.error });
|
||||||
|
rbac.audit(getActorEmail(req), 'role.permissions.add', 'role', req.params.name, { app: appId, feature, action });
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── User-Role Assignment ───
|
||||||
|
app.get('/api/users/:email/roles', requireAdmin, (req, res) => {
|
||||||
|
res.json(rbac.getUserRoles(req.params.email));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/users/:email/roles', requireAdmin, (req, res) => {
|
||||||
|
const { role, scope } = req.body;
|
||||||
|
if (!role) return res.status(400).json({ error: 'role required' });
|
||||||
|
const result = rbac.assignRole(req.params.email, role, scope || '*', getActorEmail(req));
|
||||||
|
if (result.error) return res.status(404).json({ error: result.error });
|
||||||
|
rbac.audit(getActorEmail(req), 'role.assign', 'user', req.params.email, { role, scope });
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/users/:email/roles/:role', requireAdmin, (req, res) => {
|
||||||
|
const scope = req.query.scope || '*';
|
||||||
|
const result = rbac.removeRole(req.params.email, req.params.role, scope);
|
||||||
|
if (result.error) return res.status(404).json({ error: result.error });
|
||||||
|
rbac.audit(getActorEmail(req), 'role.remove', 'user', req.params.email, { role: req.params.role, scope });
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Feature Registration ───
|
||||||
|
app.post('/api/features/register', (req, res) => {
|
||||||
|
const { app: appId, features } = req.body;
|
||||||
|
if (!appId || !Array.isArray(features)) return res.status(400).json({ error: 'app and features array required' });
|
||||||
|
const result = rbac.registerFeatures(appId, features);
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/features', requireAdmin, (req, res) => {
|
||||||
|
res.json(rbac.listFeatures());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/features/:app', requireAdmin, (req, res) => {
|
||||||
|
res.json(rbac.listFeatures(req.params.app));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── All Permissions (for admin UI matrix) ───
|
||||||
|
app.get('/api/permissions', requireAdmin, (req, res) => {
|
||||||
|
const perms = db.prepare('SELECT * FROM permissions ORDER BY app, category, feature, action').all();
|
||||||
|
res.json(perms);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Audit Log ───
|
||||||
|
app.get('/api/audit', requireAdmin, (req, res) => {
|
||||||
|
const { actor, action, target_type, limit, offset, since, until } = req.query;
|
||||||
|
res.json(rbac.queryAudit({
|
||||||
|
actor, action, targetType: target_type,
|
||||||
|
limit: parseInt(limit) || 100, offset: parseInt(offset) || 0,
|
||||||
|
since, until
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/audit/user/:email', requireAdmin, (req, res) => {
|
||||||
|
res.json(rbac.queryAudit({ actor: req.params.email, limit: parseInt(req.query.limit) || 50 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Services (from DB now) ───
|
||||||
|
app.get('/api/services/list', requireAdmin, (req, res) => {
|
||||||
|
res.json(db.prepare('SELECT * FROM services ORDER BY display_name').all());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stats (for dashboard) ───
|
||||||
|
app.get('/api/stats', requireAdmin, (req, res) => {
|
||||||
|
res.json({
|
||||||
|
users: db.prepare('SELECT COUNT(*) as c FROM users').get().c,
|
||||||
|
roles: db.prepare('SELECT COUNT(*) as c FROM roles').get().c,
|
||||||
|
permissions: db.prepare('SELECT COUNT(*) as c FROM permissions').get().c,
|
||||||
|
services: db.prepare('SELECT COUNT(*) as c FROM services').get().c,
|
||||||
|
assignments: db.prepare('SELECT COUNT(*) as c FROM user_roles').get().c,
|
||||||
|
recentAudit: db.prepare("SELECT COUNT(*) as c FROM audit_log WHERE timestamp > datetime('now', '-24 hours')").get().c,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper
|
||||||
|
function getActorEmail(req) {
|
||||||
|
if (req.session?.email) return req.session.email;
|
||||||
|
const amSession = req.cookies?.['_am_session'];
|
||||||
|
if (amSession) {
|
||||||
|
try {
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const decoded = jwt.verify(amSession, process.env.JWT_SECRET || require('./lib/db').__jwt_secret);
|
||||||
|
return decoded.email;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return req.headers['x-email'] || req.headers['x-forwarded-email'] || 'unknown';
|
||||||
|
}
|
||||||
|
};
|
||||||
811
server.js
Normal file
811
server.js
Normal file
|
|
@ -0,0 +1,811 @@
|
||||||
|
const express = require('express');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const http = require('http');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const HOST = process.env.HOST || '127.0.0.1';
|
||||||
|
const PORT = process.env.PORT || 3030;
|
||||||
|
|
||||||
|
// ─── Paths ───
|
||||||
|
const DATA_DIR = path.join(__dirname, 'data');
|
||||||
|
const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
||||||
|
const SERVICES_CACHE = path.join(DATA_DIR, 'services-cache.json');
|
||||||
|
const TOKENS_FILE = path.join(DATA_DIR, 'tokens.json');
|
||||||
|
const LOCKOUT_FILE = path.join(DATA_DIR, 'lockouts.json');
|
||||||
|
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
||||||
|
|
||||||
|
// ─── Config ───
|
||||||
|
const INVENTORY_API = process.env.INVENTORY_API || 'http://127.0.0.1:3025';
|
||||||
|
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
const MAGIC_LINK_EXPIRY = 15 * 60 * 1000; // 15 min
|
||||||
|
const RESET_TOKEN_EXPIRY = 60 * 60 * 1000; // 1 hour
|
||||||
|
const MAX_LOGIN_ATTEMPTS = 5;
|
||||||
|
const LOCKOUT_DURATION = 30 * 60 * 1000; // 30 min
|
||||||
|
const SERVICE_SYNC_INTERVAL = 5 * 60 * 1000; // 5 min
|
||||||
|
|
||||||
|
// ─── Bootstrap data files ───
|
||||||
|
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
const defaults = {
|
||||||
|
[USERS_FILE]: '[]',
|
||||||
|
[SERVICES_CACHE]: '{"services":{},"synced_at":null}',
|
||||||
|
[TOKENS_FILE]: '{}',
|
||||||
|
[LOCKOUT_FILE]: '{}',
|
||||||
|
[CONFIG_FILE]: '{}',
|
||||||
|
};
|
||||||
|
for (const [f, d] of Object.entries(defaults)) {
|
||||||
|
if (!fs.existsSync(f)) fs.writeFileSync(f, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── JWT Secret (persistent across restarts) ───
|
||||||
|
function getJwtSecret() {
|
||||||
|
const cfg = loadJSON(CONFIG_FILE);
|
||||||
|
if (cfg.jwtSecret) return cfg.jwtSecret;
|
||||||
|
const secret = crypto.randomBytes(64).toString('hex');
|
||||||
|
cfg.jwtSecret = secret;
|
||||||
|
saveJSON(CONFIG_FILE, cfg);
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
const JWT_SECRET = getJwtSecret();
|
||||||
|
|
||||||
|
// ─── Email config ───
|
||||||
|
let emailTransport = null;
|
||||||
|
function initEmail() {
|
||||||
|
try {
|
||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const cfg = loadJSON(CONFIG_FILE);
|
||||||
|
if (cfg.smtp) {
|
||||||
|
emailTransport = nodemailer.createTransport(cfg.smtp);
|
||||||
|
console.log('[email] SMTP transport configured');
|
||||||
|
} else {
|
||||||
|
console.log('[email] No SMTP config — magic links and resets will log to console');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[email] nodemailer not installed — using console fallback');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendEmail(to, subject, html) {
|
||||||
|
const cfg = loadJSON(CONFIG_FILE);
|
||||||
|
const from = cfg.emailFrom || 'TARS <noreply@scottfelten.com>';
|
||||||
|
if (emailTransport) {
|
||||||
|
try {
|
||||||
|
await emailTransport.sendMail({ from, to, subject, html });
|
||||||
|
console.log(`[email] Sent to ${to}: ${subject}`);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[email] Failed to send to ${to}:`, e.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Console fallback
|
||||||
|
console.log(`\n══════ EMAIL (console fallback) ══════`);
|
||||||
|
console.log(`To: ${to}`);
|
||||||
|
console.log(`Subject: ${subject}`);
|
||||||
|
console.log(`Body: ${html}`);
|
||||||
|
console.log(`══════════════════════════════════════\n`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── JSON helpers ───
|
||||||
|
function loadJSON(f) { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return {}; } }
|
||||||
|
function saveJSON(f, d) { fs.writeFileSync(f, JSON.stringify(d, null, 2)); }
|
||||||
|
function loadUsers() { try { return JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { return []; } }
|
||||||
|
function saveUsers(u) { fs.writeFileSync(USERS_FILE, JSON.stringify(u, null, 2)); }
|
||||||
|
function findUser(email) { return loadUsers().find(u => u.email.toLowerCase() === email.toLowerCase()); }
|
||||||
|
|
||||||
|
// ─── Lockout ───
|
||||||
|
function checkLockout(email) {
|
||||||
|
const lockouts = loadJSON(LOCKOUT_FILE);
|
||||||
|
const entry = lockouts[email.toLowerCase()];
|
||||||
|
if (!entry) return { locked: false, attempts: 0 };
|
||||||
|
if (entry.lockedUntil && Date.now() < entry.lockedUntil) {
|
||||||
|
return { locked: true, attempts: entry.attempts, minutesLeft: Math.ceil((entry.lockedUntil - Date.now()) / 60000) };
|
||||||
|
}
|
||||||
|
if (entry.lockedUntil && Date.now() >= entry.lockedUntil) {
|
||||||
|
// Lockout expired — reset
|
||||||
|
delete lockouts[email.toLowerCase()];
|
||||||
|
saveJSON(LOCKOUT_FILE, lockouts);
|
||||||
|
return { locked: false, attempts: 0 };
|
||||||
|
}
|
||||||
|
return { locked: false, attempts: entry.attempts || 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFailedAttempt(email) {
|
||||||
|
const lockouts = loadJSON(LOCKOUT_FILE);
|
||||||
|
const key = email.toLowerCase();
|
||||||
|
if (!lockouts[key]) lockouts[key] = { attempts: 0 };
|
||||||
|
lockouts[key].attempts++;
|
||||||
|
lockouts[key].lastAttempt = Date.now();
|
||||||
|
if (lockouts[key].attempts >= MAX_LOGIN_ATTEMPTS) {
|
||||||
|
lockouts[key].lockedUntil = Date.now() + LOCKOUT_DURATION;
|
||||||
|
console.log(`[lockout] ${email} locked for ${LOCKOUT_DURATION / 60000} minutes`);
|
||||||
|
}
|
||||||
|
saveJSON(LOCKOUT_FILE, lockouts);
|
||||||
|
return lockouts[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLockout(email) {
|
||||||
|
const lockouts = loadJSON(LOCKOUT_FILE);
|
||||||
|
delete lockouts[email.toLowerCase()];
|
||||||
|
saveJSON(LOCKOUT_FILE, lockouts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Token store (magic links + password resets) ───
|
||||||
|
function createToken(email, type) {
|
||||||
|
const tokens = loadJSON(TOKENS_FILE);
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
const expiry = type === 'magic' ? MAGIC_LINK_EXPIRY : RESET_TOKEN_EXPIRY;
|
||||||
|
tokens[token] = { email: email.toLowerCase(), type, created: Date.now(), expires: Date.now() + expiry };
|
||||||
|
// Clean expired tokens while we're here
|
||||||
|
for (const [k, v] of Object.entries(tokens)) {
|
||||||
|
if (v.expires < Date.now()) delete tokens[k];
|
||||||
|
}
|
||||||
|
saveJSON(TOKENS_FILE, tokens);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyToken(token, type) {
|
||||||
|
const tokens = loadJSON(TOKENS_FILE);
|
||||||
|
const entry = tokens[token];
|
||||||
|
if (!entry) return null;
|
||||||
|
if (entry.type !== type) return null;
|
||||||
|
if (entry.expires < Date.now()) {
|
||||||
|
delete tokens[token];
|
||||||
|
saveJSON(TOKENS_FILE, tokens);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Consume the token (one-time use)
|
||||||
|
delete tokens[token];
|
||||||
|
saveJSON(TOKENS_FILE, tokens);
|
||||||
|
return entry.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// SERVICE DISCOVERY (from Inventory API)
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
let serviceMap = {}; // hostname → service-id (live in memory, backed by cache file)
|
||||||
|
|
||||||
|
function loadServiceCache() {
|
||||||
|
const cache = loadJSON(SERVICES_CACHE);
|
||||||
|
if (cache.services) serviceMap = cache.services;
|
||||||
|
console.log(`[services] Loaded ${Object.keys(serviceMap).length} services from cache`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncServices() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${INVENTORY_API}/api/github`);
|
||||||
|
if (!resp.ok) throw new Error(`Inventory API returned ${resp.status}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
// Build service map from inventory repos
|
||||||
|
const newMap = {};
|
||||||
|
const STATE_FILE = path.join(DATA_DIR, '../api/inventory-data.json');
|
||||||
|
|
||||||
|
// Also try to get the inventory state which has service URLs
|
||||||
|
let stateData = {};
|
||||||
|
try {
|
||||||
|
const stateResp = await fetch(`${INVENTORY_API}/api/state`);
|
||||||
|
if (stateResp.ok) stateData = await stateResp.json();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Known subdomain → repo mappings from inventory state
|
||||||
|
// The inventory stores overrides with service URLs
|
||||||
|
const SERVICE_URL_TO_REPO = {};
|
||||||
|
|
||||||
|
// Build from repos — every repo with a known service URL gets mapped
|
||||||
|
if (data.repos) {
|
||||||
|
for (const repo of data.repos) {
|
||||||
|
if (repo.archived) continue;
|
||||||
|
// Check if there's state data with a service URL for this repo
|
||||||
|
const state = stateData[repo.name];
|
||||||
|
if (state && state.serviceUrl) {
|
||||||
|
try {
|
||||||
|
const url = new URL(state.serviceUrl);
|
||||||
|
newMap[url.hostname] = repo.name;
|
||||||
|
// Also map hostname:port if non-standard
|
||||||
|
if (url.port) newMap[`${url.hostname}:${url.port}`] = repo.name;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hardcoded fallbacks for services without inventory state
|
||||||
|
// These cover *.scottfelten.com subdomains and external domains
|
||||||
|
const FALLBACKS = {
|
||||||
|
'docs.scottfelten.com': 'command-center',
|
||||||
|
'ops.scottfelten.com': 'ops-dashboard',
|
||||||
|
'models.scottfelten.com': 'models-embeddings',
|
||||||
|
'goalstack.scottfelten.com': 'taos',
|
||||||
|
'collab.scottfelten.com': 'ccgg-collab',
|
||||||
|
'agents.scottfelten.com': 'agent-bios',
|
||||||
|
'signal.scottfelten.com': 'signal-tower',
|
||||||
|
'c360.scottfelten.com': 'customer-360',
|
||||||
|
'research.scottfelten.com': 'research-queue',
|
||||||
|
'files.scottfelten.com': 'workspace-file-server',
|
||||||
|
'taos.scottfelten.com': 'taos',
|
||||||
|
'inv.scottfelten.com': 'inventory',
|
||||||
|
'paste.scottfelten.com': 'paste-to-md',
|
||||||
|
'users.scottfelten.com': 'access-manager',
|
||||||
|
'keys.scottfelten.com': 'api-keys-dashboard',
|
||||||
|
'iag.scottfelten.com': 'iag-data-viewer',
|
||||||
|
'intelligenceadvisorygroup.com': 'iag-website',
|
||||||
|
'www.intelligenceadvisorygroup.com': 'iag-website',
|
||||||
|
'intellicert.app': 'intellicert',
|
||||||
|
'www.intellicert.app': 'intellicert',
|
||||||
|
'app.usecasegen.app': 'usecasegen',
|
||||||
|
'usecasegen.app': 'usecasegen',
|
||||||
|
'www.usecasegen.app': 'usecasegen',
|
||||||
|
'intelligenceagentpartners.com': 'intelligent-agent-partners',
|
||||||
|
'www.intelligenceagentpartners.com': 'intelligent-agent-partners',
|
||||||
|
'intelligentageintpartners.com': 'intelligent-agent-partners',
|
||||||
|
'www.intelligentagentpartners.com': 'intelligent-agent-partners',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge: inventory wins, fallbacks fill gaps
|
||||||
|
for (const [host, svc] of Object.entries(FALLBACKS)) {
|
||||||
|
if (!newMap[host]) newMap[host] = svc;
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceMap = newMap;
|
||||||
|
const cache = { services: newMap, synced_at: new Date().toISOString(), count: Object.keys(newMap).length };
|
||||||
|
saveJSON(SERVICES_CACHE, cache);
|
||||||
|
console.log(`[services] Synced ${Object.keys(newMap).length} service mappings from inventory`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[services] Sync failed: ${e.message} — using cached data`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// SESSION MANAGEMENT (JWT cookie)
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
function createSession(user, method) {
|
||||||
|
const payload = {
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
services: user.services,
|
||||||
|
method, // 'google', 'magic-link', 'password'
|
||||||
|
};
|
||||||
|
return jwt.sign(payload, JWT_SECRET, { expiresIn: '7d' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifySession(token) {
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, JWT_SECRET);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSessionCookie(res, token) {
|
||||||
|
res.cookie('_am_session', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
domain: '.scottfelten.com',
|
||||||
|
maxAge: SESSION_MAX_AGE,
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// MIDDLEWARE
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(require('cookie-parser')());
|
||||||
|
|
||||||
|
// Serve static files for public pages
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// AUTH CHECK (nginx auth_request target)
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
//
|
||||||
|
// Called by every service's nginx config.
|
||||||
|
// Checks TWO auth methods:
|
||||||
|
// 1. Our own _am_session cookie (magic link / password users)
|
||||||
|
// 2. oauth2-proxy session (Google SSO users)
|
||||||
|
// Returns 200 (allowed), 401 (not authed), 403 (no access)
|
||||||
|
|
||||||
|
app.get('/auth/check', (req, res) => {
|
||||||
|
const host = req.headers['x-original-host'] || req.headers['host'] || '';
|
||||||
|
const cookies = req.headers['cookie'] || '';
|
||||||
|
const clientIp = req.headers['x-real-ip'] || req.connection.remoteAddress || '';
|
||||||
|
|
||||||
|
console.log(`[auth/check] host=${host} ip=${clientIp}`);
|
||||||
|
|
||||||
|
// Trusted IPs bypass (TARS agent browser on Mac Studio)
|
||||||
|
const TRUSTED_IPS = ['75.164.159.96'];
|
||||||
|
if (TRUSTED_IPS.includes(clientIp)) {
|
||||||
|
console.log(`[auth/check] Trusted IP ${clientIp} — auto-granting as scott@scottfelten.com`);
|
||||||
|
return res.status(200).send('OK');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Method 1: Check our own session cookie ──
|
||||||
|
const amSession = req.cookies?.['_am_session'];
|
||||||
|
if (amSession) {
|
||||||
|
const session = verifySession(amSession);
|
||||||
|
if (session) {
|
||||||
|
console.log(`[auth/check] Valid _am_session for ${session.email} (${session.method})`);
|
||||||
|
return handleAccessCheck(res, session.email, host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Method 2: Check oauth2-proxy ──
|
||||||
|
const opts = {
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port: 4180,
|
||||||
|
path: '/oauth2/auth',
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Cookie': cookies,
|
||||||
|
'Host': host,
|
||||||
|
'X-Real-IP': req.headers['x-real-ip'] || '127.0.0.1',
|
||||||
|
'X-Forwarded-For': req.headers['x-forwarded-for'] || '127.0.0.1',
|
||||||
|
'X-Forwarded-Proto': 'https',
|
||||||
|
'X-Forwarded-Host': host,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const proxyReq = http.request(opts, (proxyRes) => {
|
||||||
|
if (proxyRes.statusCode < 200 || proxyRes.statusCode >= 300) {
|
||||||
|
return res.status(401).send('Not authenticated');
|
||||||
|
}
|
||||||
|
const email = proxyRes.headers['x-auth-request-email'] || '';
|
||||||
|
if (!email) return res.status(401).send('No email from oauth2');
|
||||||
|
|
||||||
|
res.setHeader('X-Auth-Request-User', proxyRes.headers['x-auth-request-user'] || '');
|
||||||
|
res.setHeader('X-Auth-Request-Email', email);
|
||||||
|
|
||||||
|
return handleAccessCheck(res, email, host);
|
||||||
|
});
|
||||||
|
|
||||||
|
proxyReq.on('error', (e) => {
|
||||||
|
console.error('[auth/check] oauth2-proxy error:', e.message);
|
||||||
|
res.status(500).send('Auth service error');
|
||||||
|
});
|
||||||
|
|
||||||
|
proxyReq.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleAccessCheck(res, email, host) {
|
||||||
|
const user = findUser(email);
|
||||||
|
if (!user) return res.status(403).send('User not registered');
|
||||||
|
|
||||||
|
// Update last login (throttle)
|
||||||
|
const users = loadUsers();
|
||||||
|
const idx = users.findIndex(u => u.email.toLowerCase() === email.toLowerCase());
|
||||||
|
if (idx >= 0) {
|
||||||
|
const last = users[idx].lastLogin ? new Date(users[idx].lastLogin).getTime() : 0;
|
||||||
|
if (Date.now() - last > 60000) {
|
||||||
|
users[idx].lastLogin = new Date().toISOString();
|
||||||
|
saveUsers(users);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin wildcard
|
||||||
|
if (user.services.includes('*')) return res.status(200).send('OK');
|
||||||
|
|
||||||
|
// Map host to service
|
||||||
|
const serviceId = serviceMap[host];
|
||||||
|
if (!serviceId) {
|
||||||
|
console.log(`[auth/check] No service mapping for host: ${host}`);
|
||||||
|
return res.status(403).send('Service not mapped');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.services.includes(serviceId)) return res.status(200).send('OK');
|
||||||
|
|
||||||
|
console.log(`[auth/check] ${email} denied access to ${serviceId} (${host})`);
|
||||||
|
return res.status(403).send('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// MAGIC LINK AUTH
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
app.post('/auth/magic/request', async (req, res) => {
|
||||||
|
const { email, redirect } = req.body;
|
||||||
|
if (!email) return res.status(400).json({ error: 'Email required' });
|
||||||
|
|
||||||
|
const user = findUser(email);
|
||||||
|
if (!user) {
|
||||||
|
// Don't reveal whether email exists — always return success
|
||||||
|
console.log(`[magic] Request for unknown email: ${email}`);
|
||||||
|
return res.json({ ok: true, message: 'If that email is registered, a login link has been sent.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = createToken(email, 'magic');
|
||||||
|
const rd = redirect || 'https://users.scottfelten.com';
|
||||||
|
const link = `https://users.scottfelten.com/auth/magic/verify?token=${token}&rd=${encodeURIComponent(rd)}`;
|
||||||
|
|
||||||
|
await sendEmail(email, 'Your login link — scottfelten.com', `
|
||||||
|
<h2>Login to scottfelten.com</h2>
|
||||||
|
<p>Click the link below to sign in. This link expires in 15 minutes.</p>
|
||||||
|
<p><a href="${link}" style="display:inline-block;padding:12px 24px;background:#3b82f6;color:white;border-radius:8px;text-decoration:none;font-weight:600;">Sign In</a></p>
|
||||||
|
<p style="color:#666;font-size:12px;">Or copy this URL: ${link}</p>
|
||||||
|
<p style="color:#999;font-size:11px;">If you didn't request this, ignore this email.</p>
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({ ok: true, message: 'If that email is registered, a login link has been sent.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/auth/magic/verify', (req, res) => {
|
||||||
|
const { token, rd } = req.query;
|
||||||
|
if (!token) return res.status(400).send('Missing token');
|
||||||
|
|
||||||
|
const email = verifyToken(token, 'magic');
|
||||||
|
if (!email) {
|
||||||
|
return res.redirect('/login.html?error=expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = findUser(email);
|
||||||
|
if (!user) return res.redirect('/login.html?error=not_registered');
|
||||||
|
|
||||||
|
const sessionToken = createSession(user, 'magic-link');
|
||||||
|
setSessionCookie(res, sessionToken);
|
||||||
|
|
||||||
|
console.log(`[magic] Verified login for ${email}`);
|
||||||
|
res.redirect(rd || '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// EMAIL + PASSWORD AUTH
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
app.post('/auth/password/login', async (req, res) => {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
||||||
|
|
||||||
|
// Check lockout
|
||||||
|
const lockout = checkLockout(email);
|
||||||
|
if (lockout.locked) {
|
||||||
|
return res.status(429).json({
|
||||||
|
error: `Account locked. Try again in ${lockout.minutesLeft} minutes.`,
|
||||||
|
locked: true,
|
||||||
|
minutesLeft: lockout.minutesLeft,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = findUser(email);
|
||||||
|
if (!user || !user.passwordHash) {
|
||||||
|
recordFailedAttempt(email);
|
||||||
|
return res.status(401).json({ error: 'Invalid email or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
const attempt = recordFailedAttempt(email);
|
||||||
|
const remaining = MAX_LOGIN_ATTEMPTS - attempt.attempts;
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Invalid email or password',
|
||||||
|
attemptsRemaining: Math.max(0, remaining),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success — clear lockout, create session
|
||||||
|
clearLockout(email);
|
||||||
|
const sessionToken = createSession(user, 'password');
|
||||||
|
setSessionCookie(res, sessionToken);
|
||||||
|
|
||||||
|
console.log(`[password] Login success for ${email}`);
|
||||||
|
res.json({ ok: true, user: { email: user.email, name: user.name, role: user.role } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin sets password for a user
|
||||||
|
app.post('/auth/password/set', requireAdmin, async (req, res) => {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
||||||
|
if (password.length < 12) return res.status(400).json({ error: 'Password must be at least 12 characters' });
|
||||||
|
|
||||||
|
const users = loadUsers();
|
||||||
|
const idx = users.findIndex(u => u.email.toLowerCase() === email.toLowerCase());
|
||||||
|
if (idx < 0) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
users[idx].passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
users[idx].authMethods = [...new Set([...(users[idx].authMethods || ['google']), 'password'])];
|
||||||
|
saveUsers(users);
|
||||||
|
|
||||||
|
console.log(`[password] Password set for ${email} by admin`);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Password reset request
|
||||||
|
app.post('/auth/password/reset-request', async (req, res) => {
|
||||||
|
const { email } = req.body;
|
||||||
|
if (!email) return res.status(400).json({ error: 'Email required' });
|
||||||
|
|
||||||
|
const user = findUser(email);
|
||||||
|
if (!user) {
|
||||||
|
// Don't reveal existence
|
||||||
|
return res.json({ ok: true, message: 'If that email is registered, a reset link has been sent.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = createToken(email, 'reset');
|
||||||
|
const link = `https://users.scottfelten.com/login.html?reset=${token}`;
|
||||||
|
|
||||||
|
await sendEmail(email, 'Password reset — scottfelten.com', `
|
||||||
|
<h2>Reset your password</h2>
|
||||||
|
<p>Click below to set a new password. This link expires in 1 hour.</p>
|
||||||
|
<p><a href="${link}" style="display:inline-block;padding:12px 24px;background:#3b82f6;color:white;border-radius:8px;text-decoration:none;font-weight:600;">Reset Password</a></p>
|
||||||
|
<p style="color:#666;font-size:12px;">Or copy: ${link}</p>
|
||||||
|
<p style="color:#999;font-size:11px;">If you didn't request this, ignore this email.</p>
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({ ok: true, message: 'If that email is registered, a reset link has been sent.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Password reset execution
|
||||||
|
app.post('/auth/password/reset', async (req, res) => {
|
||||||
|
const { token, password } = req.body;
|
||||||
|
if (!token || !password) return res.status(400).json({ error: 'Token and password required' });
|
||||||
|
if (password.length < 12) return res.status(400).json({ error: 'Password must be at least 12 characters' });
|
||||||
|
|
||||||
|
const email = verifyToken(token, 'reset');
|
||||||
|
if (!email) return res.status(400).json({ error: 'Invalid or expired reset token' });
|
||||||
|
|
||||||
|
const users = loadUsers();
|
||||||
|
const idx = users.findIndex(u => u.email.toLowerCase() === email.toLowerCase());
|
||||||
|
if (idx < 0) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
users[idx].passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
users[idx].authMethods = [...new Set([...(users[idx].authMethods || ['google']), 'password'])];
|
||||||
|
saveUsers(users);
|
||||||
|
|
||||||
|
clearLockout(email);
|
||||||
|
console.log(`[password] Password reset complete for ${email}`);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// SESSION INFO
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
app.get('/auth/session', (req, res) => {
|
||||||
|
const amSession = req.cookies?.['_am_session'];
|
||||||
|
if (amSession) {
|
||||||
|
const session = verifySession(amSession);
|
||||||
|
if (session) return res.json({ authenticated: true, ...session });
|
||||||
|
}
|
||||||
|
res.json({ authenticated: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/auth/logout', (req, res) => {
|
||||||
|
res.clearCookie('_am_session', { domain: '.scottfelten.com', path: '/' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// ADMIN MIDDLEWARE
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
function requireAdmin(req, res, next) {
|
||||||
|
// Check our session cookie first
|
||||||
|
const amSession = req.cookies?.['_am_session'];
|
||||||
|
if (amSession) {
|
||||||
|
const session = verifySession(amSession);
|
||||||
|
if (session) {
|
||||||
|
const user = findUser(session.email);
|
||||||
|
if (user && user.role === 'admin') return next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back to nginx-forwarded headers (oauth2-proxy)
|
||||||
|
const email = req.headers['x-email'] || req.headers['x-forwarded-email'] || '';
|
||||||
|
if (email) {
|
||||||
|
const user = findUser(email);
|
||||||
|
if (user && user.role === 'admin') return next();
|
||||||
|
}
|
||||||
|
return res.status(403).json({ error: 'Admin access required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// ADMIN API
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
// List users
|
||||||
|
app.get('/api/users', requireAdmin, (req, res) => {
|
||||||
|
const users = loadUsers().map(u => {
|
||||||
|
const { passwordHash, ...safe } = u;
|
||||||
|
return { ...safe, hasPassword: !!passwordHash };
|
||||||
|
});
|
||||||
|
res.json(users);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single user
|
||||||
|
app.get('/api/users/:email', requireAdmin, (req, res) => {
|
||||||
|
const user = findUser(req.params.email);
|
||||||
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
const { passwordHash, ...safe } = user;
|
||||||
|
res.json({ ...safe, hasPassword: !!passwordHash });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
app.post('/api/users', requireAdmin, async (req, res) => {
|
||||||
|
const { email, name, role, services, metadata, tags, password, authMethods } = req.body;
|
||||||
|
if (!email || !name) return res.status(400).json({ error: 'email and name required' });
|
||||||
|
|
||||||
|
const users = loadUsers();
|
||||||
|
if (users.find(u => u.email.toLowerCase() === email.toLowerCase())) {
|
||||||
|
return res.status(409).json({ error: 'User already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminEmail = req.headers['x-email'] || req.headers['x-forwarded-email'] || 'api';
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
name,
|
||||||
|
role: role || 'client',
|
||||||
|
services: services || [],
|
||||||
|
metadata: metadata || {},
|
||||||
|
tags: tags || [],
|
||||||
|
authMethods: authMethods || ['google'],
|
||||||
|
passwordHash: password ? await bcrypt.hash(password, 12) : null,
|
||||||
|
createdAt: new Date().toISOString().split('T')[0],
|
||||||
|
createdBy: adminEmail,
|
||||||
|
lastLogin: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
users.push(newUser);
|
||||||
|
saveUsers(users);
|
||||||
|
syncAllowlist(users);
|
||||||
|
|
||||||
|
const { passwordHash, ...safe } = newUser;
|
||||||
|
res.status(201).json({ ...safe, hasPassword: !!passwordHash });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update user
|
||||||
|
app.put('/api/users/:email', requireAdmin, (req, res) => {
|
||||||
|
const users = loadUsers();
|
||||||
|
const idx = users.findIndex(u => u.email.toLowerCase() === req.params.email.toLowerCase());
|
||||||
|
if (idx < 0) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
const { name, role, services, metadata, tags, authMethods } = req.body;
|
||||||
|
if (name !== undefined) users[idx].name = name;
|
||||||
|
if (role !== undefined) users[idx].role = role;
|
||||||
|
if (services !== undefined) users[idx].services = services;
|
||||||
|
if (metadata !== undefined) users[idx].metadata = { ...users[idx].metadata, ...metadata };
|
||||||
|
if (tags !== undefined) users[idx].tags = tags;
|
||||||
|
if (authMethods !== undefined) users[idx].authMethods = authMethods;
|
||||||
|
|
||||||
|
saveUsers(users);
|
||||||
|
syncAllowlist(users);
|
||||||
|
|
||||||
|
const { passwordHash, ...safe } = users[idx];
|
||||||
|
res.json({ ...safe, hasPassword: !!passwordHash });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete user
|
||||||
|
app.delete('/api/users/:email', requireAdmin, (req, res) => {
|
||||||
|
const users = loadUsers();
|
||||||
|
const idx = users.findIndex(u => u.email.toLowerCase() === req.params.email.toLowerCase());
|
||||||
|
if (idx < 0) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
const adminEmail = (req.headers['x-email'] || '').toLowerCase();
|
||||||
|
if (users[idx].email.toLowerCase() === adminEmail) {
|
||||||
|
return res.status(400).json({ error: 'Cannot delete yourself' });
|
||||||
|
}
|
||||||
|
|
||||||
|
users.splice(idx, 1);
|
||||||
|
saveUsers(users);
|
||||||
|
syncAllowlist(users);
|
||||||
|
res.json({ deleted: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// SERVICES API (dynamic from inventory)
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
// List available services (for UI dropdown)
|
||||||
|
app.get('/api/services', requireAdmin, (req, res) => {
|
||||||
|
// Deduplicate: return unique service IDs
|
||||||
|
const serviceIds = [...new Set(Object.values(serviceMap))].sort();
|
||||||
|
res.json(serviceIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Full service map (hostname → service-id)
|
||||||
|
app.get('/api/services/map', requireAdmin, (req, res) => {
|
||||||
|
res.json(serviceMap);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force sync from inventory
|
||||||
|
app.post('/api/services/sync', requireAdmin, async (req, res) => {
|
||||||
|
await syncServices();
|
||||||
|
res.json({
|
||||||
|
services: [...new Set(Object.values(serviceMap))].sort(),
|
||||||
|
count: Object.keys(serviceMap).length,
|
||||||
|
synced_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Sync oauth2-proxy allowlist ───
|
||||||
|
function syncAllowlist(users) {
|
||||||
|
// Only include users with google as an auth method (or admin/legacy users)
|
||||||
|
const emails = users
|
||||||
|
.filter(u => !u.authMethods || u.authMethods.includes('google') || u.role === 'admin')
|
||||||
|
.map(u => u.email)
|
||||||
|
.join('\n') + '\n';
|
||||||
|
try {
|
||||||
|
fs.writeFileSync('/etc/oauth2-proxy-users.txt', emails);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to sync allowlist:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Email config management ───
|
||||||
|
app.get('/api/config/email', requireAdmin, (req, res) => {
|
||||||
|
const cfg = loadJSON(CONFIG_FILE);
|
||||||
|
const smtpConfigured = !!cfg.smtp;
|
||||||
|
res.json({
|
||||||
|
configured: smtpConfigured,
|
||||||
|
from: cfg.emailFrom || 'noreply@scottfelten.com',
|
||||||
|
host: cfg.smtp?.host || null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/config/email', requireAdmin, (req, res) => {
|
||||||
|
const { host, port, user, pass, from } = req.body;
|
||||||
|
const cfg = loadJSON(CONFIG_FILE);
|
||||||
|
if (host && user && pass) {
|
||||||
|
cfg.smtp = { host, port: port || 587, secure: false, auth: { user, pass } };
|
||||||
|
cfg.emailFrom = from || `TARS <${user}>`;
|
||||||
|
saveJSON(CONFIG_FILE, cfg);
|
||||||
|
initEmail(); // Re-initialize transport
|
||||||
|
res.json({ ok: true, configured: true });
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ error: 'host, user, and pass required' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Health ───
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
users: loadUsers().length,
|
||||||
|
services: Object.keys(serviceMap).length,
|
||||||
|
emailConfigured: !!emailTransport,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// START
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// RBAC v3 — Role-Based Access Control
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
try {
|
||||||
|
const { migrate } = require('./lib/migration');
|
||||||
|
migrate();
|
||||||
|
console.log('[rbac] Migration check complete');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[rbac] Migration error:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mountRbacRoutes = require('./rbac-routes');
|
||||||
|
mountRbacRoutes(app, requireAdmin);
|
||||||
|
console.log('[rbac] RBAC API routes mounted');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[rbac] Failed to mount RBAC routes:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen(PORT, HOST, async () => {
|
||||||
|
console.log(`access-manager v3 listening on ${HOST}:${PORT}`);
|
||||||
|
console.log(`Users: ${loadUsers().length}`);
|
||||||
|
|
||||||
|
// Load cached services, then sync
|
||||||
|
loadServiceCache();
|
||||||
|
await syncServices();
|
||||||
|
|
||||||
|
// Initialize email
|
||||||
|
initEmail();
|
||||||
|
|
||||||
|
// Periodic service sync
|
||||||
|
setInterval(syncServices, SERVICE_SYNC_INTERVAL);
|
||||||
|
});
|
||||||
784
server.js.bak
Normal file
784
server.js.bak
Normal file
|
|
@ -0,0 +1,784 @@
|
||||||
|
const express = require('express');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const http = require('http');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const HOST = process.env.HOST || '127.0.0.1';
|
||||||
|
const PORT = process.env.PORT || 3030;
|
||||||
|
|
||||||
|
// ─── Paths ───
|
||||||
|
const DATA_DIR = path.join(__dirname, 'data');
|
||||||
|
const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
||||||
|
const SERVICES_CACHE = path.join(DATA_DIR, 'services-cache.json');
|
||||||
|
const TOKENS_FILE = path.join(DATA_DIR, 'tokens.json');
|
||||||
|
const LOCKOUT_FILE = path.join(DATA_DIR, 'lockouts.json');
|
||||||
|
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
||||||
|
|
||||||
|
// ─── Config ───
|
||||||
|
const INVENTORY_API = process.env.INVENTORY_API || 'http://127.0.0.1:3025';
|
||||||
|
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
const MAGIC_LINK_EXPIRY = 15 * 60 * 1000; // 15 min
|
||||||
|
const RESET_TOKEN_EXPIRY = 60 * 60 * 1000; // 1 hour
|
||||||
|
const MAX_LOGIN_ATTEMPTS = 5;
|
||||||
|
const LOCKOUT_DURATION = 30 * 60 * 1000; // 30 min
|
||||||
|
const SERVICE_SYNC_INTERVAL = 5 * 60 * 1000; // 5 min
|
||||||
|
|
||||||
|
// ─── Bootstrap data files ───
|
||||||
|
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
const defaults = {
|
||||||
|
[USERS_FILE]: '[]',
|
||||||
|
[SERVICES_CACHE]: '{"services":{},"synced_at":null}',
|
||||||
|
[TOKENS_FILE]: '{}',
|
||||||
|
[LOCKOUT_FILE]: '{}',
|
||||||
|
[CONFIG_FILE]: '{}',
|
||||||
|
};
|
||||||
|
for (const [f, d] of Object.entries(defaults)) {
|
||||||
|
if (!fs.existsSync(f)) fs.writeFileSync(f, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── JWT Secret (persistent across restarts) ───
|
||||||
|
function getJwtSecret() {
|
||||||
|
const cfg = loadJSON(CONFIG_FILE);
|
||||||
|
if (cfg.jwtSecret) return cfg.jwtSecret;
|
||||||
|
const secret = crypto.randomBytes(64).toString('hex');
|
||||||
|
cfg.jwtSecret = secret;
|
||||||
|
saveJSON(CONFIG_FILE, cfg);
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
const JWT_SECRET = getJwtSecret();
|
||||||
|
|
||||||
|
// ─── Email config ───
|
||||||
|
let emailTransport = null;
|
||||||
|
function initEmail() {
|
||||||
|
try {
|
||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const cfg = loadJSON(CONFIG_FILE);
|
||||||
|
if (cfg.smtp) {
|
||||||
|
emailTransport = nodemailer.createTransport(cfg.smtp);
|
||||||
|
console.log('[email] SMTP transport configured');
|
||||||
|
} else {
|
||||||
|
console.log('[email] No SMTP config — magic links and resets will log to console');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[email] nodemailer not installed — using console fallback');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendEmail(to, subject, html) {
|
||||||
|
const cfg = loadJSON(CONFIG_FILE);
|
||||||
|
const from = cfg.emailFrom || 'TARS <noreply@scottfelten.com>';
|
||||||
|
if (emailTransport) {
|
||||||
|
try {
|
||||||
|
await emailTransport.sendMail({ from, to, subject, html });
|
||||||
|
console.log(`[email] Sent to ${to}: ${subject}`);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[email] Failed to send to ${to}:`, e.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Console fallback
|
||||||
|
console.log(`\n══════ EMAIL (console fallback) ══════`);
|
||||||
|
console.log(`To: ${to}`);
|
||||||
|
console.log(`Subject: ${subject}`);
|
||||||
|
console.log(`Body: ${html}`);
|
||||||
|
console.log(`══════════════════════════════════════\n`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── JSON helpers ───
|
||||||
|
function loadJSON(f) { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return {}; } }
|
||||||
|
function saveJSON(f, d) { fs.writeFileSync(f, JSON.stringify(d, null, 2)); }
|
||||||
|
function loadUsers() { try { return JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { return []; } }
|
||||||
|
function saveUsers(u) { fs.writeFileSync(USERS_FILE, JSON.stringify(u, null, 2)); }
|
||||||
|
function findUser(email) { return loadUsers().find(u => u.email.toLowerCase() === email.toLowerCase()); }
|
||||||
|
|
||||||
|
// ─── Lockout ───
|
||||||
|
function checkLockout(email) {
|
||||||
|
const lockouts = loadJSON(LOCKOUT_FILE);
|
||||||
|
const entry = lockouts[email.toLowerCase()];
|
||||||
|
if (!entry) return { locked: false, attempts: 0 };
|
||||||
|
if (entry.lockedUntil && Date.now() < entry.lockedUntil) {
|
||||||
|
return { locked: true, attempts: entry.attempts, minutesLeft: Math.ceil((entry.lockedUntil - Date.now()) / 60000) };
|
||||||
|
}
|
||||||
|
if (entry.lockedUntil && Date.now() >= entry.lockedUntil) {
|
||||||
|
// Lockout expired — reset
|
||||||
|
delete lockouts[email.toLowerCase()];
|
||||||
|
saveJSON(LOCKOUT_FILE, lockouts);
|
||||||
|
return { locked: false, attempts: 0 };
|
||||||
|
}
|
||||||
|
return { locked: false, attempts: entry.attempts || 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFailedAttempt(email) {
|
||||||
|
const lockouts = loadJSON(LOCKOUT_FILE);
|
||||||
|
const key = email.toLowerCase();
|
||||||
|
if (!lockouts[key]) lockouts[key] = { attempts: 0 };
|
||||||
|
lockouts[key].attempts++;
|
||||||
|
lockouts[key].lastAttempt = Date.now();
|
||||||
|
if (lockouts[key].attempts >= MAX_LOGIN_ATTEMPTS) {
|
||||||
|
lockouts[key].lockedUntil = Date.now() + LOCKOUT_DURATION;
|
||||||
|
console.log(`[lockout] ${email} locked for ${LOCKOUT_DURATION / 60000} minutes`);
|
||||||
|
}
|
||||||
|
saveJSON(LOCKOUT_FILE, lockouts);
|
||||||
|
return lockouts[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLockout(email) {
|
||||||
|
const lockouts = loadJSON(LOCKOUT_FILE);
|
||||||
|
delete lockouts[email.toLowerCase()];
|
||||||
|
saveJSON(LOCKOUT_FILE, lockouts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Token store (magic links + password resets) ───
|
||||||
|
function createToken(email, type) {
|
||||||
|
const tokens = loadJSON(TOKENS_FILE);
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
const expiry = type === 'magic' ? MAGIC_LINK_EXPIRY : RESET_TOKEN_EXPIRY;
|
||||||
|
tokens[token] = { email: email.toLowerCase(), type, created: Date.now(), expires: Date.now() + expiry };
|
||||||
|
// Clean expired tokens while we're here
|
||||||
|
for (const [k, v] of Object.entries(tokens)) {
|
||||||
|
if (v.expires < Date.now()) delete tokens[k];
|
||||||
|
}
|
||||||
|
saveJSON(TOKENS_FILE, tokens);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyToken(token, type) {
|
||||||
|
const tokens = loadJSON(TOKENS_FILE);
|
||||||
|
const entry = tokens[token];
|
||||||
|
if (!entry) return null;
|
||||||
|
if (entry.type !== type) return null;
|
||||||
|
if (entry.expires < Date.now()) {
|
||||||
|
delete tokens[token];
|
||||||
|
saveJSON(TOKENS_FILE, tokens);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Consume the token (one-time use)
|
||||||
|
delete tokens[token];
|
||||||
|
saveJSON(TOKENS_FILE, tokens);
|
||||||
|
return entry.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// SERVICE DISCOVERY (from Inventory API)
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
let serviceMap = {}; // hostname → service-id (live in memory, backed by cache file)
|
||||||
|
|
||||||
|
function loadServiceCache() {
|
||||||
|
const cache = loadJSON(SERVICES_CACHE);
|
||||||
|
if (cache.services) serviceMap = cache.services;
|
||||||
|
console.log(`[services] Loaded ${Object.keys(serviceMap).length} services from cache`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncServices() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${INVENTORY_API}/api/github`);
|
||||||
|
if (!resp.ok) throw new Error(`Inventory API returned ${resp.status}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
// Build service map from inventory repos
|
||||||
|
const newMap = {};
|
||||||
|
const STATE_FILE = path.join(DATA_DIR, '../api/inventory-data.json');
|
||||||
|
|
||||||
|
// Also try to get the inventory state which has service URLs
|
||||||
|
let stateData = {};
|
||||||
|
try {
|
||||||
|
const stateResp = await fetch(`${INVENTORY_API}/api/state`);
|
||||||
|
if (stateResp.ok) stateData = await stateResp.json();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Known subdomain → repo mappings from inventory state
|
||||||
|
// The inventory stores overrides with service URLs
|
||||||
|
const SERVICE_URL_TO_REPO = {};
|
||||||
|
|
||||||
|
// Build from repos — every repo with a known service URL gets mapped
|
||||||
|
if (data.repos) {
|
||||||
|
for (const repo of data.repos) {
|
||||||
|
if (repo.archived) continue;
|
||||||
|
// Check if there's state data with a service URL for this repo
|
||||||
|
const state = stateData[repo.name];
|
||||||
|
if (state && state.serviceUrl) {
|
||||||
|
try {
|
||||||
|
const url = new URL(state.serviceUrl);
|
||||||
|
newMap[url.hostname] = repo.name;
|
||||||
|
// Also map hostname:port if non-standard
|
||||||
|
if (url.port) newMap[`${url.hostname}:${url.port}`] = repo.name;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hardcoded fallbacks for services without inventory state
|
||||||
|
// These cover *.scottfelten.com subdomains and external domains
|
||||||
|
const FALLBACKS = {
|
||||||
|
'docs.scottfelten.com': 'command-center',
|
||||||
|
'ops.scottfelten.com': 'ops-dashboard',
|
||||||
|
'models.scottfelten.com': 'models-embeddings',
|
||||||
|
'goalstack.scottfelten.com': 'taos',
|
||||||
|
'collab.scottfelten.com': 'ccgg-collab',
|
||||||
|
'agents.scottfelten.com': 'agent-bios',
|
||||||
|
'signal.scottfelten.com': 'signal-tower',
|
||||||
|
'c360.scottfelten.com': 'customer-360',
|
||||||
|
'research.scottfelten.com': 'research-queue',
|
||||||
|
'files.scottfelten.com': 'workspace-file-server',
|
||||||
|
'taos.scottfelten.com': 'taos',
|
||||||
|
'inv.scottfelten.com': 'inventory',
|
||||||
|
'paste.scottfelten.com': 'paste-to-md',
|
||||||
|
'users.scottfelten.com': 'access-manager',
|
||||||
|
'keys.scottfelten.com': 'api-keys-dashboard',
|
||||||
|
'iag.scottfelten.com': 'iag-data-viewer',
|
||||||
|
'intelligenceadvisorygroup.com': 'iag-website',
|
||||||
|
'www.intelligenceadvisorygroup.com': 'iag-website',
|
||||||
|
'intellicert.app': 'intellicert',
|
||||||
|
'www.intellicert.app': 'intellicert',
|
||||||
|
'app.usecasegen.app': 'usecasegen',
|
||||||
|
'usecasegen.app': 'usecasegen',
|
||||||
|
'www.usecasegen.app': 'usecasegen',
|
||||||
|
'intelligenceagentpartners.com': 'intelligent-agent-partners',
|
||||||
|
'www.intelligenceagentpartners.com': 'intelligent-agent-partners',
|
||||||
|
'intelligentageintpartners.com': 'intelligent-agent-partners',
|
||||||
|
'www.intelligentagentpartners.com': 'intelligent-agent-partners',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge: inventory wins, fallbacks fill gaps
|
||||||
|
for (const [host, svc] of Object.entries(FALLBACKS)) {
|
||||||
|
if (!newMap[host]) newMap[host] = svc;
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceMap = newMap;
|
||||||
|
const cache = { services: newMap, synced_at: new Date().toISOString(), count: Object.keys(newMap).length };
|
||||||
|
saveJSON(SERVICES_CACHE, cache);
|
||||||
|
console.log(`[services] Synced ${Object.keys(newMap).length} service mappings from inventory`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[services] Sync failed: ${e.message} — using cached data`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// SESSION MANAGEMENT (JWT cookie)
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
function createSession(user, method) {
|
||||||
|
const payload = {
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
services: user.services,
|
||||||
|
method, // 'google', 'magic-link', 'password'
|
||||||
|
};
|
||||||
|
return jwt.sign(payload, JWT_SECRET, { expiresIn: '7d' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifySession(token) {
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, JWT_SECRET);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSessionCookie(res, token) {
|
||||||
|
res.cookie('_am_session', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
domain: '.scottfelten.com',
|
||||||
|
maxAge: SESSION_MAX_AGE,
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// MIDDLEWARE
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(require('cookie-parser')());
|
||||||
|
|
||||||
|
// Serve static files for public pages
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// AUTH CHECK (nginx auth_request target)
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
//
|
||||||
|
// Called by every service's nginx config.
|
||||||
|
// Checks TWO auth methods:
|
||||||
|
// 1. Our own _am_session cookie (magic link / password users)
|
||||||
|
// 2. oauth2-proxy session (Google SSO users)
|
||||||
|
// Returns 200 (allowed), 401 (not authed), 403 (no access)
|
||||||
|
|
||||||
|
app.get('/auth/check', (req, res) => {
|
||||||
|
const host = req.headers['x-original-host'] || req.headers['host'] || '';
|
||||||
|
const cookies = req.headers['cookie'] || '';
|
||||||
|
|
||||||
|
console.log(`[auth/check] host=${host}`);
|
||||||
|
|
||||||
|
// ── Method 1: Check our own session cookie ──
|
||||||
|
const amSession = req.cookies?.['_am_session'];
|
||||||
|
if (amSession) {
|
||||||
|
const session = verifySession(amSession);
|
||||||
|
if (session) {
|
||||||
|
console.log(`[auth/check] Valid _am_session for ${session.email} (${session.method})`);
|
||||||
|
return handleAccessCheck(res, session.email, host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Method 2: Check oauth2-proxy ──
|
||||||
|
const opts = {
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port: 4180,
|
||||||
|
path: '/oauth2/auth',
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Cookie': cookies,
|
||||||
|
'Host': host,
|
||||||
|
'X-Real-IP': req.headers['x-real-ip'] || '127.0.0.1',
|
||||||
|
'X-Forwarded-For': req.headers['x-forwarded-for'] || '127.0.0.1',
|
||||||
|
'X-Forwarded-Proto': 'https',
|
||||||
|
'X-Forwarded-Host': host,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const proxyReq = http.request(opts, (proxyRes) => {
|
||||||
|
if (proxyRes.statusCode < 200 || proxyRes.statusCode >= 300) {
|
||||||
|
return res.status(401).send('Not authenticated');
|
||||||
|
}
|
||||||
|
const email = proxyRes.headers['x-auth-request-email'] || '';
|
||||||
|
if (!email) return res.status(401).send('No email from oauth2');
|
||||||
|
|
||||||
|
res.setHeader('X-Auth-Request-User', proxyRes.headers['x-auth-request-user'] || '');
|
||||||
|
res.setHeader('X-Auth-Request-Email', email);
|
||||||
|
|
||||||
|
return handleAccessCheck(res, email, host);
|
||||||
|
});
|
||||||
|
|
||||||
|
proxyReq.on('error', (e) => {
|
||||||
|
console.error('[auth/check] oauth2-proxy error:', e.message);
|
||||||
|
res.status(500).send('Auth service error');
|
||||||
|
});
|
||||||
|
|
||||||
|
proxyReq.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleAccessCheck(res, email, host) {
|
||||||
|
const user = findUser(email);
|
||||||
|
if (!user) return res.status(403).send('User not registered');
|
||||||
|
|
||||||
|
// Update last login (throttle)
|
||||||
|
const users = loadUsers();
|
||||||
|
const idx = users.findIndex(u => u.email.toLowerCase() === email.toLowerCase());
|
||||||
|
if (idx >= 0) {
|
||||||
|
const last = users[idx].lastLogin ? new Date(users[idx].lastLogin).getTime() : 0;
|
||||||
|
if (Date.now() - last > 60000) {
|
||||||
|
users[idx].lastLogin = new Date().toISOString();
|
||||||
|
saveUsers(users);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin wildcard
|
||||||
|
if (user.services.includes('*')) return res.status(200).send('OK');
|
||||||
|
|
||||||
|
// Map host to service
|
||||||
|
const serviceId = serviceMap[host];
|
||||||
|
if (!serviceId) {
|
||||||
|
console.log(`[auth/check] No service mapping for host: ${host}`);
|
||||||
|
return res.status(403).send('Service not mapped');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.services.includes(serviceId)) return res.status(200).send('OK');
|
||||||
|
|
||||||
|
console.log(`[auth/check] ${email} denied access to ${serviceId} (${host})`);
|
||||||
|
return res.status(403).send('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// MAGIC LINK AUTH
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
app.post('/auth/magic/request', async (req, res) => {
|
||||||
|
const { email, redirect } = req.body;
|
||||||
|
if (!email) return res.status(400).json({ error: 'Email required' });
|
||||||
|
|
||||||
|
const user = findUser(email);
|
||||||
|
if (!user) {
|
||||||
|
// Don't reveal whether email exists — always return success
|
||||||
|
console.log(`[magic] Request for unknown email: ${email}`);
|
||||||
|
return res.json({ ok: true, message: 'If that email is registered, a login link has been sent.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = createToken(email, 'magic');
|
||||||
|
const rd = redirect || 'https://users.scottfelten.com';
|
||||||
|
const link = `https://users.scottfelten.com/auth/magic/verify?token=${token}&rd=${encodeURIComponent(rd)}`;
|
||||||
|
|
||||||
|
await sendEmail(email, 'Your login link — scottfelten.com', `
|
||||||
|
<h2>Login to scottfelten.com</h2>
|
||||||
|
<p>Click the link below to sign in. This link expires in 15 minutes.</p>
|
||||||
|
<p><a href="${link}" style="display:inline-block;padding:12px 24px;background:#3b82f6;color:white;border-radius:8px;text-decoration:none;font-weight:600;">Sign In</a></p>
|
||||||
|
<p style="color:#666;font-size:12px;">Or copy this URL: ${link}</p>
|
||||||
|
<p style="color:#999;font-size:11px;">If you didn't request this, ignore this email.</p>
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({ ok: true, message: 'If that email is registered, a login link has been sent.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/auth/magic/verify', (req, res) => {
|
||||||
|
const { token, rd } = req.query;
|
||||||
|
if (!token) return res.status(400).send('Missing token');
|
||||||
|
|
||||||
|
const email = verifyToken(token, 'magic');
|
||||||
|
if (!email) {
|
||||||
|
return res.redirect('/login.html?error=expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = findUser(email);
|
||||||
|
if (!user) return res.redirect('/login.html?error=not_registered');
|
||||||
|
|
||||||
|
const sessionToken = createSession(user, 'magic-link');
|
||||||
|
setSessionCookie(res, sessionToken);
|
||||||
|
|
||||||
|
console.log(`[magic] Verified login for ${email}`);
|
||||||
|
res.redirect(rd || '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// EMAIL + PASSWORD AUTH
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
app.post('/auth/password/login', async (req, res) => {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
||||||
|
|
||||||
|
// Check lockout
|
||||||
|
const lockout = checkLockout(email);
|
||||||
|
if (lockout.locked) {
|
||||||
|
return res.status(429).json({
|
||||||
|
error: `Account locked. Try again in ${lockout.minutesLeft} minutes.`,
|
||||||
|
locked: true,
|
||||||
|
minutesLeft: lockout.minutesLeft,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = findUser(email);
|
||||||
|
if (!user || !user.passwordHash) {
|
||||||
|
recordFailedAttempt(email);
|
||||||
|
return res.status(401).json({ error: 'Invalid email or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
const attempt = recordFailedAttempt(email);
|
||||||
|
const remaining = MAX_LOGIN_ATTEMPTS - attempt.attempts;
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Invalid email or password',
|
||||||
|
attemptsRemaining: Math.max(0, remaining),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success — clear lockout, create session
|
||||||
|
clearLockout(email);
|
||||||
|
const sessionToken = createSession(user, 'password');
|
||||||
|
setSessionCookie(res, sessionToken);
|
||||||
|
|
||||||
|
console.log(`[password] Login success for ${email}`);
|
||||||
|
res.json({ ok: true, user: { email: user.email, name: user.name, role: user.role } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin sets password for a user
|
||||||
|
app.post('/auth/password/set', requireAdmin, async (req, res) => {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
||||||
|
if (password.length < 12) return res.status(400).json({ error: 'Password must be at least 12 characters' });
|
||||||
|
|
||||||
|
const users = loadUsers();
|
||||||
|
const idx = users.findIndex(u => u.email.toLowerCase() === email.toLowerCase());
|
||||||
|
if (idx < 0) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
users[idx].passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
users[idx].authMethods = [...new Set([...(users[idx].authMethods || ['google']), 'password'])];
|
||||||
|
saveUsers(users);
|
||||||
|
|
||||||
|
console.log(`[password] Password set for ${email} by admin`);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Password reset request
|
||||||
|
app.post('/auth/password/reset-request', async (req, res) => {
|
||||||
|
const { email } = req.body;
|
||||||
|
if (!email) return res.status(400).json({ error: 'Email required' });
|
||||||
|
|
||||||
|
const user = findUser(email);
|
||||||
|
if (!user) {
|
||||||
|
// Don't reveal existence
|
||||||
|
return res.json({ ok: true, message: 'If that email is registered, a reset link has been sent.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = createToken(email, 'reset');
|
||||||
|
const link = `https://users.scottfelten.com/login.html?reset=${token}`;
|
||||||
|
|
||||||
|
await sendEmail(email, 'Password reset — scottfelten.com', `
|
||||||
|
<h2>Reset your password</h2>
|
||||||
|
<p>Click below to set a new password. This link expires in 1 hour.</p>
|
||||||
|
<p><a href="${link}" style="display:inline-block;padding:12px 24px;background:#3b82f6;color:white;border-radius:8px;text-decoration:none;font-weight:600;">Reset Password</a></p>
|
||||||
|
<p style="color:#666;font-size:12px;">Or copy: ${link}</p>
|
||||||
|
<p style="color:#999;font-size:11px;">If you didn't request this, ignore this email.</p>
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({ ok: true, message: 'If that email is registered, a reset link has been sent.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Password reset execution
|
||||||
|
app.post('/auth/password/reset', async (req, res) => {
|
||||||
|
const { token, password } = req.body;
|
||||||
|
if (!token || !password) return res.status(400).json({ error: 'Token and password required' });
|
||||||
|
if (password.length < 12) return res.status(400).json({ error: 'Password must be at least 12 characters' });
|
||||||
|
|
||||||
|
const email = verifyToken(token, 'reset');
|
||||||
|
if (!email) return res.status(400).json({ error: 'Invalid or expired reset token' });
|
||||||
|
|
||||||
|
const users = loadUsers();
|
||||||
|
const idx = users.findIndex(u => u.email.toLowerCase() === email.toLowerCase());
|
||||||
|
if (idx < 0) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
users[idx].passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
users[idx].authMethods = [...new Set([...(users[idx].authMethods || ['google']), 'password'])];
|
||||||
|
saveUsers(users);
|
||||||
|
|
||||||
|
clearLockout(email);
|
||||||
|
console.log(`[password] Password reset complete for ${email}`);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// SESSION INFO
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
app.get('/auth/session', (req, res) => {
|
||||||
|
const amSession = req.cookies?.['_am_session'];
|
||||||
|
if (amSession) {
|
||||||
|
const session = verifySession(amSession);
|
||||||
|
if (session) return res.json({ authenticated: true, ...session });
|
||||||
|
}
|
||||||
|
res.json({ authenticated: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/auth/logout', (req, res) => {
|
||||||
|
res.clearCookie('_am_session', { domain: '.scottfelten.com', path: '/' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// ADMIN MIDDLEWARE
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
function requireAdmin(req, res, next) {
|
||||||
|
// Check our session cookie first
|
||||||
|
const amSession = req.cookies?.['_am_session'];
|
||||||
|
if (amSession) {
|
||||||
|
const session = verifySession(amSession);
|
||||||
|
if (session) {
|
||||||
|
const user = findUser(session.email);
|
||||||
|
if (user && user.role === 'admin') return next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back to nginx-forwarded headers (oauth2-proxy)
|
||||||
|
const email = req.headers['x-email'] || req.headers['x-forwarded-email'] || '';
|
||||||
|
if (email) {
|
||||||
|
const user = findUser(email);
|
||||||
|
if (user && user.role === 'admin') return next();
|
||||||
|
}
|
||||||
|
return res.status(403).json({ error: 'Admin access required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// ADMIN API
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
// List users
|
||||||
|
app.get('/api/users', requireAdmin, (req, res) => {
|
||||||
|
const users = loadUsers().map(u => {
|
||||||
|
const { passwordHash, ...safe } = u;
|
||||||
|
return { ...safe, hasPassword: !!passwordHash };
|
||||||
|
});
|
||||||
|
res.json(users);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single user
|
||||||
|
app.get('/api/users/:email', requireAdmin, (req, res) => {
|
||||||
|
const user = findUser(req.params.email);
|
||||||
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
const { passwordHash, ...safe } = user;
|
||||||
|
res.json({ ...safe, hasPassword: !!passwordHash });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
app.post('/api/users', requireAdmin, async (req, res) => {
|
||||||
|
const { email, name, role, services, metadata, tags, password, authMethods } = req.body;
|
||||||
|
if (!email || !name) return res.status(400).json({ error: 'email and name required' });
|
||||||
|
|
||||||
|
const users = loadUsers();
|
||||||
|
if (users.find(u => u.email.toLowerCase() === email.toLowerCase())) {
|
||||||
|
return res.status(409).json({ error: 'User already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminEmail = req.headers['x-email'] || req.headers['x-forwarded-email'] || 'api';
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
name,
|
||||||
|
role: role || 'client',
|
||||||
|
services: services || [],
|
||||||
|
metadata: metadata || {},
|
||||||
|
tags: tags || [],
|
||||||
|
authMethods: authMethods || ['google'],
|
||||||
|
passwordHash: password ? await bcrypt.hash(password, 12) : null,
|
||||||
|
createdAt: new Date().toISOString().split('T')[0],
|
||||||
|
createdBy: adminEmail,
|
||||||
|
lastLogin: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
users.push(newUser);
|
||||||
|
saveUsers(users);
|
||||||
|
syncAllowlist(users);
|
||||||
|
|
||||||
|
const { passwordHash, ...safe } = newUser;
|
||||||
|
res.status(201).json({ ...safe, hasPassword: !!passwordHash });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update user
|
||||||
|
app.put('/api/users/:email', requireAdmin, (req, res) => {
|
||||||
|
const users = loadUsers();
|
||||||
|
const idx = users.findIndex(u => u.email.toLowerCase() === req.params.email.toLowerCase());
|
||||||
|
if (idx < 0) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
const { name, role, services, metadata, tags, authMethods } = req.body;
|
||||||
|
if (name !== undefined) users[idx].name = name;
|
||||||
|
if (role !== undefined) users[idx].role = role;
|
||||||
|
if (services !== undefined) users[idx].services = services;
|
||||||
|
if (metadata !== undefined) users[idx].metadata = { ...users[idx].metadata, ...metadata };
|
||||||
|
if (tags !== undefined) users[idx].tags = tags;
|
||||||
|
if (authMethods !== undefined) users[idx].authMethods = authMethods;
|
||||||
|
|
||||||
|
saveUsers(users);
|
||||||
|
syncAllowlist(users);
|
||||||
|
|
||||||
|
const { passwordHash, ...safe } = users[idx];
|
||||||
|
res.json({ ...safe, hasPassword: !!passwordHash });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete user
|
||||||
|
app.delete('/api/users/:email', requireAdmin, (req, res) => {
|
||||||
|
const users = loadUsers();
|
||||||
|
const idx = users.findIndex(u => u.email.toLowerCase() === req.params.email.toLowerCase());
|
||||||
|
if (idx < 0) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
const adminEmail = (req.headers['x-email'] || '').toLowerCase();
|
||||||
|
if (users[idx].email.toLowerCase() === adminEmail) {
|
||||||
|
return res.status(400).json({ error: 'Cannot delete yourself' });
|
||||||
|
}
|
||||||
|
|
||||||
|
users.splice(idx, 1);
|
||||||
|
saveUsers(users);
|
||||||
|
syncAllowlist(users);
|
||||||
|
res.json({ deleted: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// SERVICES API (dynamic from inventory)
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
// List available services (for UI dropdown)
|
||||||
|
app.get('/api/services', requireAdmin, (req, res) => {
|
||||||
|
// Deduplicate: return unique service IDs
|
||||||
|
const serviceIds = [...new Set(Object.values(serviceMap))].sort();
|
||||||
|
res.json(serviceIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Full service map (hostname → service-id)
|
||||||
|
app.get('/api/services/map', requireAdmin, (req, res) => {
|
||||||
|
res.json(serviceMap);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force sync from inventory
|
||||||
|
app.post('/api/services/sync', requireAdmin, async (req, res) => {
|
||||||
|
await syncServices();
|
||||||
|
res.json({
|
||||||
|
services: [...new Set(Object.values(serviceMap))].sort(),
|
||||||
|
count: Object.keys(serviceMap).length,
|
||||||
|
synced_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Sync oauth2-proxy allowlist ───
|
||||||
|
function syncAllowlist(users) {
|
||||||
|
// Only include users with google as an auth method (or admin/legacy users)
|
||||||
|
const emails = users
|
||||||
|
.filter(u => !u.authMethods || u.authMethods.includes('google') || u.role === 'admin')
|
||||||
|
.map(u => u.email)
|
||||||
|
.join('\n') + '\n';
|
||||||
|
try {
|
||||||
|
fs.writeFileSync('/etc/oauth2-proxy-users.txt', emails);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to sync allowlist:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Email config management ───
|
||||||
|
app.get('/api/config/email', requireAdmin, (req, res) => {
|
||||||
|
const cfg = loadJSON(CONFIG_FILE);
|
||||||
|
const smtpConfigured = !!cfg.smtp;
|
||||||
|
res.json({
|
||||||
|
configured: smtpConfigured,
|
||||||
|
from: cfg.emailFrom || 'noreply@scottfelten.com',
|
||||||
|
host: cfg.smtp?.host || null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/config/email', requireAdmin, (req, res) => {
|
||||||
|
const { host, port, user, pass, from } = req.body;
|
||||||
|
const cfg = loadJSON(CONFIG_FILE);
|
||||||
|
if (host && user && pass) {
|
||||||
|
cfg.smtp = { host, port: port || 587, secure: false, auth: { user, pass } };
|
||||||
|
cfg.emailFrom = from || `TARS <${user}>`;
|
||||||
|
saveJSON(CONFIG_FILE, cfg);
|
||||||
|
initEmail(); // Re-initialize transport
|
||||||
|
res.json({ ok: true, configured: true });
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ error: 'host, user, and pass required' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Health ───
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
users: loadUsers().length,
|
||||||
|
services: Object.keys(serviceMap).length,
|
||||||
|
emailConfigured: !!emailTransport,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// START
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
app.listen(PORT, HOST, async () => {
|
||||||
|
console.log(`access-manager v2 listening on ${HOST}:${PORT}`);
|
||||||
|
console.log(`Users: ${loadUsers().length}`);
|
||||||
|
|
||||||
|
// Load cached services, then sync
|
||||||
|
loadServiceCache();
|
||||||
|
await syncServices();
|
||||||
|
|
||||||
|
// Initialize email
|
||||||
|
initEmail();
|
||||||
|
|
||||||
|
// Periodic service sync
|
||||||
|
setInterval(syncServices, SERVICE_SYNC_INTERVAL);
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue