adeo-maturity-tool/server/seed-users.js
DJP 8e969fe015 Add login + role-based access (admin/user) backed by Postgres
Adds a Postgres-backed user store with bcrypt + JWT cookie sessions,
login screen, role-gated UI, and Microsoft SSO scaffolding ready to
fill in.

Backend
- New `db` service (Postgres 16-alpine) in compose, healthcheck-gated
  app startup, free-port autodetect (5435-5499) like other apps.
- `server/db.js` runs versioned `.sql` migrations on boot.
- `server/auth.js`: bcrypt + JWT cookie (httpOnly, sameSite=strict,
  path-scoped to /adeo-maturity), rate-limited login (10/15min),
  dummy bcrypt-compare on missing users to defeat timing oracles.
- `requireAdmin` on all writes (POST/import/sync); `authenticate`
  on all reads. /api/health stays public.
- Microsoft SSO endpoints stubbed at /api/auth/msft/{login,callback}
  (return 501); DB has azure_oid column ready; comments document
  exactly how to wire @azure/msal-node.

Frontend
- Login screen with email/password + greyed-out "Sign in with
  Microsoft" button; init() checks /api/auth/me first.
- Logout button + user badge in header.
- body.role-user CSS hides .admin-only elements (Update tab, New
  Client cards). Server enforces regardless.

Deploy
- deploy.sh generates DB_PASSWORD and AUTH_SECRET on first run and
  persists to .env, then runs `seed-users.js seed-defaults` to
  create admin@oliver.agency + user@oliver.agency with random
  passwords printed once. Subsequent deploys skip seeding unless
  --reseed is passed.
- node server/seed-users.js set-password <email> <pw> for ad-hoc
  resets later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:14:19 -04:00

102 lines
3.4 KiB
JavaScript

'use strict';
// CLI: seed default users (admin + viewer) with random passwords, or set a
// password for an existing user.
//
// node server/seed-users.js seed-defaults [--force]
// node server/seed-users.js set-password <email> <password>
//
// "seed-defaults" is idempotent: it only creates users that don't exist
// (use --force to reset both passwords). Generated passwords are printed
// once to stdout — the deploy script captures and shows them after first
// deploy. After that, save them somewhere safe; we cannot recover them.
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const { pool, runMigrations } = require('./db');
const DEFAULT_SEEDS = [
{ email: 'admin@oliver.agency', role: 'admin' },
{ email: 'user@oliver.agency', role: 'user' },
];
function genPassword(bytes = 18) {
return crypto.randomBytes(bytes).toString('base64url');
}
async function upsertUser(email, role, password) {
const hash = await bcrypt.hash(password, 12);
const r = await pool.query(`
INSERT INTO users (email, password_hash, role)
VALUES ($1, $2, $3)
ON CONFLICT (lower(email)) DO UPDATE
SET password_hash = EXCLUDED.password_hash,
role = EXCLUDED.role,
updated_at = now()
RETURNING (xmax = 0) AS inserted
`, [email, hash, role]);
return r.rows[0].inserted;
}
async function seedDefaults(force) {
const created = [];
for (const { email, role } of DEFAULT_SEEDS) {
const existing = await pool.query('SELECT 1 FROM users WHERE lower(email) = lower($1)', [email]);
if (existing.rowCount && !force) {
console.log(`[seed] ${email} already exists — skipping (use --force to reset password)`);
continue;
}
const password = genPassword();
const inserted = await upsertUser(email, role, password);
created.push({ email, role, password, action: inserted ? 'created' : 'reset' });
}
if (created.length === 0) {
console.log('[seed] No new users seeded.');
return;
}
const banner = '═'.repeat(66);
console.log(`\n${banner}`);
console.log(' SEED CREDENTIALS — save these now, they will not be shown again');
console.log(banner);
for (const u of created) {
console.log(` ${u.role.toUpperCase().padEnd(5)} ${u.email} (${u.action})`);
console.log(` password: ${u.password}`);
}
console.log(`${banner}\n`);
}
async function setPassword(email, password) {
const r = await pool.query('SELECT role FROM users WHERE lower(email) = lower($1)', [email]);
if (!r.rowCount) {
console.error(`[seed] User ${email} not found`);
process.exit(1);
}
await upsertUser(email, r.rows[0].role, password);
console.log(`[seed] password updated for ${email}`);
}
async function main() {
const [cmd, ...args] = process.argv.slice(2);
await runMigrations();
if (cmd === 'seed-defaults') {
await seedDefaults(args.includes('--force'));
} else if (cmd === 'set-password') {
const [email, password] = args;
if (!email || !password) {
console.error('Usage: node server/seed-users.js set-password <email> <password>');
process.exit(2);
}
await setPassword(email, password);
} else {
console.error('Usage:');
console.error(' node server/seed-users.js seed-defaults [--force]');
console.error(' node server/seed-users.js set-password <email> <password>');
process.exit(2);
}
await pool.end();
}
main().catch(e => { console.error(e); process.exit(1); });