You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
202 lines
9.8 KiB
TypeScript
202 lines
9.8 KiB
TypeScript
// apps/hq/src/repos-employees.ts — the Employee entity (backed by staff_user; D16).
|
|
// staff_user IS the employee table: renaming it would churn session.staff_id,
|
|
// interaction.staff_id and audit entity names for zero gain. "Employee" lives in
|
|
// the repo/API/UI vocabulary only. Password columns never leave this module.
|
|
import { hashPin, verifyPin } from '@sims/auth'
|
|
import { uuidv7 } from '@sims/domain'
|
|
import { writeAudit } from './audit'
|
|
import type { DB } from './db'
|
|
|
|
export type EmployeeRole = 'owner' | 'manager' | 'staff'
|
|
const ROLES: readonly EmployeeRole[] = ['owner', 'manager', 'staff'] as const
|
|
|
|
export interface Employee {
|
|
id: string; username: string; email: string; displayName: string; role: EmployeeRole; active: boolean
|
|
phone: string | null; title: string | null
|
|
/** D34: true while the employee still has their invited default password (pending first change). */
|
|
mustChangePassword: boolean
|
|
}
|
|
|
|
interface EmployeeRow {
|
|
id: string; username: string | null; email: string; display_name: string; role: string; active: number
|
|
phone: string | null; title: string | null; must_change_password: number
|
|
}
|
|
|
|
// Explicit column list — pw_salt/pw_hash must never be selected here.
|
|
const COLS = 'id, username, email, display_name, role, active, phone, title, must_change_password'
|
|
|
|
function toEmployee(r: EmployeeRow): Employee {
|
|
return {
|
|
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, mustChangePassword: r.must_change_password === 1,
|
|
}
|
|
}
|
|
|
|
function assertRole(role: string): asserts role is EmployeeRole {
|
|
if (!ROLES.includes(role as EmployeeRole)) {
|
|
throw new Error(`Invalid role '${role}' — must be one of ${ROLES.join(', ')}`)
|
|
}
|
|
}
|
|
|
|
/** Console users are a small bounded set (not a growable list) — returned whole, with a count for the UI. */
|
|
export async function listEmployees(db: DB): Promise<Employee[]> {
|
|
const rows = await db.all<EmployeeRow>(
|
|
`SELECT ${COLS} FROM staff_user ORDER BY display_name`,
|
|
)
|
|
return rows.map(toEmployee)
|
|
}
|
|
|
|
export async function getEmployee(db: DB, id: string): Promise<Employee | null> {
|
|
const row = await db.get<EmployeeRow>(`SELECT ${COLS} FROM staff_user WHERE id=?`, id)
|
|
return row ? toEmployee(row) : null
|
|
}
|
|
|
|
/** True when at least one ACTIVE owner other than `excludeId` exists. */
|
|
async function otherActiveOwnerExists(db: DB, excludeId: string): Promise<boolean> {
|
|
const row = (await db.get<{ n: number }>(
|
|
`SELECT COUNT(*) AS n FROM staff_user WHERE role='owner' AND active=1 AND id != ?`,
|
|
excludeId,
|
|
))!
|
|
return row.n > 0
|
|
}
|
|
|
|
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
|
|
phone?: string; title?: string
|
|
mustChangePassword?: boolean // D34: invited with a default password to change
|
|
}): 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
|
|
const mcp = input.mustChangePassword === true ? 1 : 0
|
|
return db.transaction(async () => {
|
|
// 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, username, email, display_name, role, phone, title, pw_salt, pw_hash, must_change_password)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
id, username, email, input.displayName, input.role,
|
|
input.phone?.trim() || null, input.title?.trim() || null, salt, hash, mcp,
|
|
)
|
|
await writeAudit(db, userId, 'create', 'staff_user', id, undefined, { username, email, role: input.role })
|
|
return (await getEmployee(db, id))!
|
|
})
|
|
}
|
|
|
|
export async function updateEmployee(db: DB, userId: string, id: string, patch: {
|
|
displayName?: string; role?: EmployeeRole; phone?: string; title?: string
|
|
}): Promise<Employee> {
|
|
return db.transaction(async () => {
|
|
const before = await getEmployee(db, id)
|
|
if (!before) throw new Error('Employee not found')
|
|
if (patch.role !== undefined) {
|
|
assertRole(patch.role)
|
|
// Demoting the last active owner would lock everyone out of owner-gated routes.
|
|
if (before.role === 'owner' && before.active && patch.role !== 'owner' && !(await otherActiveOwnerExists(db, id))) {
|
|
throw new Error('Cannot demote the last active owner')
|
|
}
|
|
}
|
|
const sets: string[] = []
|
|
const args: unknown[] = []
|
|
if (patch.displayName !== undefined) {
|
|
if (patch.displayName.trim() === '') throw new Error('Name is required')
|
|
sets.push('display_name=?'); args.push(patch.displayName)
|
|
}
|
|
if (patch.role !== undefined) { sets.push('role=?'); args.push(patch.role) }
|
|
// Contact fields are optional text; an emptied field stores NULL, not ''.
|
|
if (patch.phone !== undefined) { sets.push('phone=?'); args.push(patch.phone.trim() === '' ? null : patch.phone.trim()) }
|
|
if (patch.title !== undefined) { sets.push('title=?'); args.push(patch.title.trim() === '' ? null : patch.title.trim()) }
|
|
// An empty patch is a caller error — succeeding silently would write a
|
|
// no-op audit row and tell the caller a change landed when nothing did.
|
|
if (sets.length === 0) throw new Error('Nothing to update')
|
|
args.push(id)
|
|
await db.run(`UPDATE staff_user SET ${sets.join(', ')} WHERE id=?`, ...args)
|
|
const after = (await getEmployee(db, id))!
|
|
await writeAudit(db, userId, 'update', 'staff_user', id, before, after)
|
|
return after
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Self-service password change (D18 WS-E): requires the CURRENT password, re-hashes,
|
|
* and kills every OTHER session in the same transaction — the session performing the
|
|
* change survives (currentToken), everything else dies with the old credential.
|
|
* Audited as change_own_password; hashes never leave this module or reach the audit.
|
|
*/
|
|
export async function changeOwnPassword(
|
|
db: DB, userId: string, currentPassword: string, newPassword: string, currentToken: string,
|
|
): Promise<void> {
|
|
if (newPassword.length < 8) throw new Error('Password must be at least 8 characters')
|
|
const row = await db.get<{ pw_salt: string; pw_hash: string }>(
|
|
`SELECT pw_salt, pw_hash FROM staff_user WHERE id=? AND active=1`, userId)
|
|
if (row === undefined) throw new Error('Employee not found')
|
|
if (!verifyPin(currentPassword, { salt: row.pw_salt, hash: row.pw_hash })) {
|
|
throw new Error('Current password is incorrect')
|
|
}
|
|
const { salt, hash } = hashPin(newPassword)
|
|
await db.transaction(async () => {
|
|
// Clearing must_change_password: the invited default password has now been replaced.
|
|
await db.run(`UPDATE staff_user SET pw_salt=?, pw_hash=?, must_change_password=0 WHERE id=?`, salt, hash, userId)
|
|
await db.run(`DELETE FROM session WHERE staff_id=? AND token != ?`, userId, currentToken)
|
|
await writeAudit(db, userId, 'change_own_password', 'staff_user', userId)
|
|
})
|
|
}
|
|
|
|
/** Reset an employee's password. Audits the action; never logs the hash.
|
|
* Existing sessions are purged in the same transaction — a reset is the natural
|
|
* "lock out the old credential" action, so a stolen token must not outlive it. */
|
|
export async function setEmployeePassword(db: DB, userId: string, id: string, password: string): Promise<void> {
|
|
if (password.length < 8) throw new Error('Password must be at least 8 characters')
|
|
const { salt, hash } = hashPin(password)
|
|
await db.transaction(async () => {
|
|
const before = await getEmployee(db, id)
|
|
if (!before) throw new Error('Employee not found')
|
|
await db.run(`UPDATE staff_user SET pw_salt=?, pw_hash=? WHERE id=?`, salt, hash, id)
|
|
await db.run(`DELETE FROM session WHERE staff_id=?`, id)
|
|
await writeAudit(db, userId, 'reset_password', 'staff_user', id)
|
|
})
|
|
}
|
|
|
|
/** Deactivation takes effect immediately: sessions are purged in the same transaction. */
|
|
export async function deactivateEmployee(db: DB, userId: string, id: string): Promise<Employee> {
|
|
if (id === userId) throw new Error('Cannot deactivate yourself')
|
|
return db.transaction(async () => {
|
|
const before = await getEmployee(db, id)
|
|
if (!before) throw new Error('Employee not found')
|
|
if (!before.active) return before // already inactive — idempotent
|
|
if (before.role === 'owner' && !(await otherActiveOwnerExists(db, id))) {
|
|
throw new Error('Cannot deactivate the last active owner')
|
|
}
|
|
await db.run(`UPDATE staff_user SET active=0 WHERE id=?`, id)
|
|
await db.run(`DELETE FROM session WHERE staff_id=?`, id)
|
|
const after = (await getEmployee(db, id))!
|
|
await writeAudit(db, userId, 'deactivate', 'staff_user', id, before, after)
|
|
return after
|
|
})
|
|
}
|
|
|
|
export async function reactivateEmployee(db: DB, userId: string, id: string): Promise<Employee> {
|
|
return db.transaction(async () => {
|
|
const before = await getEmployee(db, id)
|
|
if (!before) throw new Error('Employee not found')
|
|
if (before.active) return before // already active — idempotent
|
|
await db.run(`UPDATE staff_user SET active=1 WHERE id=?`, id)
|
|
const after = (await getEmployee(db, id))!
|
|
await writeAudit(db, userId, 'reactivate', 'staff_user', id, before, after)
|
|
return after
|
|
})
|
|
}
|