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>
102 lines
3.4 KiB
JavaScript
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); });
|