Replaces the single shared ADMIN_PASSWORD (Phase 2B) with real accounts: instructors table (email/display_name/role/status), scrypt password hashing with a fresh random salt PER INSTRUCTOR (Phase 2B used one fixed salt because there was exactly one shared password — that assumption is gone), auth_identities table so a future OIDC login can map to the same instructor_id without a schema change. New POST /api/auth/instructor/login, POST /api/auth/logout, GET /api/auth/session, POST /api/auth/change-password (old password required, revokes other sessions). No account enumeration: identical response whether the email doesn't exist or the password is wrong. bootstrap-owner.js is the only supported way to create the first account — no manual database editing to stand up a fresh deployment. Refuses to run if an active owner already exists unless the target email is an existing instructor (zero-owners recovery path).
78 lines
3.0 KiB
JavaScript
78 lines
3.0 KiB
JavaScript
// Instructor account CRUD + the small set of invariants that protect the platform from
|
|
// locking itself out (never zero active owners). Shared by the auth routes (login/session) and
|
|
// the admin instructor-management routes.
|
|
|
|
const { hashPassword } = require('./auth/password');
|
|
|
|
function publicInstructor(row) {
|
|
if (!row) return null;
|
|
const { password_hash, ...rest } = row;
|
|
return rest;
|
|
}
|
|
|
|
async function getActiveInstructor(pool, instructorId) {
|
|
const { rows } = await pool.query(
|
|
`SELECT * FROM instructors WHERE instructor_id = $1 AND status = 'active'`, [instructorId]
|
|
);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
// Returns the row regardless of status — the login flow needs to tell "wrong password" apart
|
|
// from "correct password, but account is deactivated" (see instructor-auth-routes.js), which
|
|
// requires seeing inactive accounts too.
|
|
async function getInstructorByEmail(pool, email) {
|
|
const { rows } = await pool.query(`SELECT * FROM instructors WHERE email = $1`, [String(email).toLowerCase().trim()]);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
async function listInstructors(pool) {
|
|
const { rows } = await pool.query(`SELECT * FROM instructors ORDER BY created_at`);
|
|
return rows.map(publicInstructor);
|
|
}
|
|
|
|
async function countActiveOwners(pool, excludingId) {
|
|
const { rows } = await pool.query(
|
|
`SELECT count(*)::int AS n FROM instructors WHERE role = 'owner' AND status = 'active' AND instructor_id != $1`,
|
|
[excludingId || '00000000-0000-0000-0000-000000000000']
|
|
);
|
|
return rows[0].n;
|
|
}
|
|
|
|
async function createInstructor(pool, { email, displayName, role, password }) {
|
|
const emailLower = String(email).toLowerCase().trim();
|
|
const passwordHash = hashPassword(password);
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
const { rows } = await client.query(
|
|
`INSERT INTO instructors (email, display_name, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING *`,
|
|
[emailLower, displayName.trim(), passwordHash, role || 'instructor']
|
|
);
|
|
const instructor = rows[0];
|
|
await client.query(
|
|
`INSERT INTO auth_identities (instructor_id, provider, provider_subject, provider_email) VALUES ($1, 'local', $2, $3)`,
|
|
[instructor.instructor_id, String(instructor.instructor_id), emailLower]
|
|
);
|
|
await client.query('COMMIT');
|
|
return instructor;
|
|
} catch (e) {
|
|
await client.query('ROLLBACK');
|
|
throw e;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
// Guards the one invariant that matters: never let the platform end up with zero active
|
|
// owners. Called before any update that would deactivate an owner or demote their role.
|
|
async function assertNotLastOwner(pool, instructorId) {
|
|
const remaining = await countActiveOwners(pool, instructorId);
|
|
if (remaining === 0) {
|
|
const err = new Error('Cannot remove the last active owner.');
|
|
err.code = 'LAST_OWNER';
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
module.exports = { publicInstructor, getActiveInstructor, getInstructorByEmail, listInstructors, countActiveOwners, createInstructor, assertNotLastOwner };
|