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 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent 1727918173
commit 8092c78264

@ -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

@ -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<LoginResult> {
const row = await db.get<StaffRow>(`SELECT * FROM staff_user WHERE email=? AND active=1`, email.toLowerCase())
export async function login(db: DB, identifier: string, password: string): Promise<LoginResult> {
// 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<StaffRow>(
`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.

@ -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`)
}

@ -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);
`,
},
]

@ -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<boolea
export async function createEmployee(db: DB, userId: string, input: {
email: string; displayName: string; role: EmployeeRole; password: string
username?: string // D25: login identifier; defaults to email when not given
}): Promise<Employee> {
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))!
})
}

Loading…
Cancel
Save