From 8092c782644fcb7af9ffec79ed309d19542dd3ce Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sat, 18 Jul 2026 02:08:30 +0530 Subject: [PATCH] feat(d25): login by username; email demoted to a plain user field - staff_user gains a unique username (both engines: migrate + pg 009); older rows and the seed owner backfill username=email so every existing login keeps working. login() matches username OR email (legacy fallback). - createStaff/createEmployee take an optional username (defaults to email); Employee/API expose it; POST /employees accepts username. Email stays a required contact field on the table (making it optional is a later table rebuild). Full backend suite 346 green. Co-Authored-By: Claude Fable 5 --- apps/hq/src/api.ts | 3 ++- apps/hq/src/auth.ts | 17 ++++++++++++----- apps/hq/src/db.ts | 10 +++++++++- apps/hq/src/migrations-pg.ts | 9 +++++++++ apps/hq/src/repos-employees.ts | 23 ++++++++++++++--------- 5 files changed, 46 insertions(+), 16 deletions(-) diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index b8101a4..c5c0c04 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -164,8 +164,9 @@ export function apiRouter( // Mutations are owner-only; the signed-in user is the audited actor. r.post('/employees', requireAuth, requireOwner, async (req, res) => { try { - const body = req.body as { email?: unknown; displayName?: unknown; role?: unknown; password?: unknown } + const body = req.body as { username?: unknown; email?: unknown; displayName?: unknown; role?: unknown; password?: unknown } const employee = await createEmployee(db, staffId(res), { + ...(typeof body.username === 'string' && body.username !== '' ? { username: body.username } : {}), email: typeof body.email === 'string' ? body.email : '', displayName: typeof body.displayName === 'string' ? body.displayName : '', role: (typeof body.role === 'string' ? body.role : '') as EmployeeRole, // repo validates the enum diff --git a/apps/hq/src/auth.ts b/apps/hq/src/auth.ts index 01271fa..86fb73f 100644 --- a/apps/hq/src/auth.ts +++ b/apps/hq/src/auth.ts @@ -17,15 +17,17 @@ const DUMMY_HASH = hashPin('x'.repeat(24)) export async function createStaff(db: DB, input: { email: string; displayName: string; role: 'owner' | 'manager' | 'staff'; password: string + username?: string // D25: login identifier; defaults to email so existing callers/tests are unchanged }): Promise<{ id: string }> { if (input.password.length < 8) throw new Error('Password must be at least 8 characters') const id = uuidv7() + const username = (input.username ?? input.email).trim().toLowerCase() const { salt, hash } = hashPin(input.password) // generic scrypt; PIN digit policy not applied await db.run( - `INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`, - id, input.email.toLowerCase(), input.displayName, input.role, salt, hash, + `INSERT INTO staff_user (id, username, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?, ?)`, + id, username, input.email.toLowerCase(), input.displayName, input.role, salt, hash, ) - await writeAudit(db, 'system', 'create', 'staff_user', id, undefined, { email: input.email, role: input.role }) + await writeAudit(db, 'system', 'create', 'staff_user', id, undefined, { username, email: input.email, role: input.role }) return { id } } @@ -46,8 +48,13 @@ export type LoginResult = * failures the account locks for LOCK_MINUTES; a success clears the counter. The generic * 'invalid' reason is returned for both wrong-password and unknown-email. */ -export async function login(db: DB, email: string, password: string): Promise { - const row = await db.get(`SELECT * FROM staff_user WHERE email=? AND active=1`, email.toLowerCase()) +export async function login(db: DB, identifier: string, password: string): Promise { + // D25: log in by username. Existing accounts backfilled username=email, so an email + // string still matches; a legacy email fallback keeps any un-backfilled row working. + const key = identifier.trim().toLowerCase() + const row = await db.get( + `SELECT * FROM staff_user WHERE (username=? OR email=?) AND active=1`, key, key, + ) const now = Date.now() // Locked? Still spend a scrypt op (constant time) before returning. diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index 6b7710b..c11c9fe 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -135,13 +135,17 @@ CREATE INDEX IF NOT EXISTS ix_recurring_active_run ON recurring_plan (active, ne CREATE INDEX IF NOT EXISTS ix_amc_client ON amc_contract (client_id); CREATE INDEX IF NOT EXISTS ix_aws_client ON aws_usage (client_id); CREATE INDEX IF NOT EXISTS ix_session_staff ON session (staff_id); +CREATE UNIQUE INDEX IF NOT EXISTS ix_staff_username ON staff_user (username); CREATE INDEX IF NOT EXISTS ix_audit_entity ON audit_log (entity, entity_id); CREATE INDEX IF NOT EXISTS ix_milestone_cm ON project_milestone (client_module_id); ` const SCHEMA = ` CREATE TABLE IF NOT EXISTS staff_user ( - id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, + -- D25: LOGIN is by username (unique). email is now just a contact field on the user, + -- not the login identifier. New DBs get username on insert; older DBs backfill + -- username = email in migrate() so existing logins keep working unchanged. + id TEXT PRIMARY KEY, username TEXT, email TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('owner','manager','staff')), phone TEXT, title TEXT, -- D18 WS-E: contact + designation shown on Employees/profile; nothing routes on them pw_salt TEXT NOT NULL, pw_hash TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1, @@ -395,6 +399,10 @@ function migrate(db: SqliteRaw): void { db.exec(`ALTER TABLE document ADD COLUMN due_date TEXT`) } const staffCols = db.prepare(`PRAGMA table_info(staff_user)`).all() as { name: string }[] + if (!staffCols.some((c) => c.name === 'username')) { + db.exec(`ALTER TABLE staff_user ADD COLUMN username TEXT`) + db.exec(`UPDATE staff_user SET username=email WHERE username IS NULL`) // login keeps working + } if (!staffCols.some((c) => c.name === 'phone')) { db.exec(`ALTER TABLE staff_user ADD COLUMN phone TEXT`) } diff --git a/apps/hq/src/migrations-pg.ts b/apps/hq/src/migrations-pg.ts index 7c71b45..efc00ea 100644 --- a/apps/hq/src/migrations-pg.ts +++ b/apps/hq/src/migrations-pg.ts @@ -285,4 +285,13 @@ CREATE INDEX IF NOT EXISTS ix_milestone_cm ON project_milestone (client_module_i id: '008-module-sort-order', sql: `ALTER TABLE module ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 100;`, }, + { + // D25: login by username. Backfill from email so existing accounts keep working. + id: '009-staff-username', + sql: ` +ALTER TABLE staff_user ADD COLUMN IF NOT EXISTS username text; +UPDATE staff_user SET username=email WHERE username IS NULL; +CREATE UNIQUE INDEX IF NOT EXISTS ix_staff_username ON staff_user (username); +`, + }, ] diff --git a/apps/hq/src/repos-employees.ts b/apps/hq/src/repos-employees.ts index ca90d18..6998763 100644 --- a/apps/hq/src/repos-employees.ts +++ b/apps/hq/src/repos-employees.ts @@ -11,21 +11,21 @@ export type EmployeeRole = 'owner' | 'manager' | 'staff' const ROLES: readonly EmployeeRole[] = ['owner', 'manager', 'staff'] as const export interface Employee { - id: string; email: string; displayName: string; role: EmployeeRole; active: boolean + id: string; username: string; email: string; displayName: string; role: EmployeeRole; active: boolean phone: string | null; title: string | null } interface EmployeeRow { - id: string; email: string; display_name: string; role: string; active: number + id: string; username: string | null; email: string; display_name: string; role: string; active: number phone: string | null; title: string | null } // Explicit column list — pw_salt/pw_hash must never be selected here. -const COLS = 'id, email, display_name, role, active, phone, title' +const COLS = 'id, username, email, display_name, role, active, phone, title' function toEmployee(r: EmployeeRow): Employee { return { - id: r.id, email: r.email, displayName: r.display_name, + id: r.id, username: r.username ?? r.email, email: r.email, displayName: r.display_name, role: r.role as EmployeeRole, active: r.active === 1, phone: r.phone, title: r.title, } @@ -61,25 +61,30 @@ async function otherActiveOwnerExists(db: DB, excludeId: string): Promise { assertRole(input.role) if (input.password.length < 8) throw new Error('Password must be at least 8 characters') const email = input.email.trim().toLowerCase() if (email === '') throw new Error('Email is required') if (input.displayName.trim() === '') throw new Error('Name is required') + const username = (input.username ?? input.email).trim().toLowerCase() + if (username === '') throw new Error('Username is required') const id = uuidv7() const { salt, hash } = hashPin(input.password) // generic scrypt; PIN digit policy not applied return db.transaction(async () => { - // Pre-check the UNIQUE(email) so the caller gets a friendly, engine-neutral - // message instead of raw driver text (SQLite and Postgres word it differently). + // Friendly, engine-neutral pre-checks (login is by username; email stays a field). + if (await db.get(`SELECT 1 FROM staff_user WHERE username=?`, username) !== undefined) { + throw new Error('Username already in use') + } if (await db.get(`SELECT 1 FROM staff_user WHERE email=?`, email) !== undefined) { throw new Error('Email already in use') } await db.run( - `INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`, - id, email, input.displayName, input.role, salt, hash, + `INSERT INTO staff_user (id, username, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?, ?)`, + id, username, email, input.displayName, input.role, salt, hash, ) - await writeAudit(db, userId, 'create', 'staff_user', id, undefined, { email, role: input.role }) + await writeAudit(db, userId, 'create', 'staff_user', id, undefined, { username, email, role: input.role }) return (await getEmployee(db, id))! }) }