feat(hq): employee foundation — manager role, employee repo, session hardening (phase 1)
- rebuildTable helper: guarded, txn-wrapped CHECK-constraint rebuild (SQLite-only path) - staff_user.role CHECK widened to owner|manager|staff (SCHEMA + migrate for old DBs) - repos-employees.ts: list/get/create/update/setPassword/deactivate/reactivate, audited in-txn, password columns never selected, last-active-owner + self guards - verifySession requires active=1; deactivation purges sessions in the same txn - 9 tests (apps/hq/test/employees.test.ts); suite now 186 green Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
02306b1088
commit
1af7b87aab
@ -0,0 +1 @@
|
||||
65052
|
||||
@ -0,0 +1 @@
|
||||
e0f352cd87dfab994f7622c297edfe2cb1118222bec860b092fa39c362e84090
|
||||
@ -0,0 +1,8 @@
|
||||
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh;text-align:center">
|
||||
<div>
|
||||
<div style="font-size:44px;margin-bottom:10px">🐝</div>
|
||||
<h2>Design swarm is running…</h2>
|
||||
<p class="subtitle">9 agents: 5 designing each piece in parallel → 3 stress-testing → 1 synthesizing the spec + roadmap.<br>Back in the terminal in a few minutes with the full design for your approval.</p>
|
||||
<p class="subtitle" style="opacity:.6;margin-top:18px">Locked: employee foundation · chase-list pipeline · module roster · escalating quote reminders · one-click proforma→invoice</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1 @@
|
||||
{"type":"server-started","port":65052,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:65052/?key=e0f352cd87dfab994f7622c297edfe2cb1118222bec860b092fa39c362e84090","screen_dir":"C:\\SiMS\\hq\\.superpowers\\brainstorm\\4011-1784227603\\content","state_dir":"C:\\SiMS\\hq\\.superpowers\\brainstorm\\4011-1784227603\\state","idle_timeout_ms":14400000}
|
||||
@ -0,0 +1 @@
|
||||
65410f13a14f7a21cd6975494bda8c56b77ef1fdd0b00415
|
||||
@ -0,0 +1 @@
|
||||
4027
|
||||
@ -0,0 +1,145 @@
|
||||
// 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 } 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; email: string; displayName: string; role: EmployeeRole; active: boolean
|
||||
}
|
||||
|
||||
interface EmployeeRow { id: string; email: string; display_name: string; role: string; active: number }
|
||||
|
||||
// Explicit column list — pw_salt/pw_hash must never be selected here.
|
||||
const COLS = 'id, email, display_name, role, active'
|
||||
|
||||
function toEmployee(r: EmployeeRow): Employee {
|
||||
return {
|
||||
id: r.id, email: r.email, displayName: r.display_name,
|
||||
role: r.role as EmployeeRole, active: r.active === 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 function listEmployees(db: DB): Employee[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT ${COLS} FROM staff_user ORDER BY display_name`,
|
||||
).all() as EmployeeRow[]
|
||||
return rows.map(toEmployee)
|
||||
}
|
||||
|
||||
export function getEmployee(db: DB, id: string): Employee | null {
|
||||
const row = db.prepare(`SELECT ${COLS} FROM staff_user WHERE id=?`).get(id) as EmployeeRow | undefined
|
||||
return row ? toEmployee(row) : null
|
||||
}
|
||||
|
||||
/** True when at least one ACTIVE owner other than `excludeId` exists. */
|
||||
function otherActiveOwnerExists(db: DB, excludeId: string): boolean {
|
||||
const row = db.prepare(
|
||||
`SELECT COUNT(*) AS n FROM staff_user WHERE role='owner' AND active=1 AND id != ?`,
|
||||
).get(excludeId) as { n: number }
|
||||
return row.n > 0
|
||||
}
|
||||
|
||||
export function createEmployee(db: DB, userId: string, input: {
|
||||
email: string; displayName: string; role: EmployeeRole; password: string
|
||||
}): 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 id = uuidv7()
|
||||
const { salt, hash } = hashPin(input.password) // generic scrypt; PIN digit policy not applied
|
||||
return db.transaction(() => {
|
||||
db.prepare(
|
||||
`INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(id, email, input.displayName, input.role, salt, hash)
|
||||
writeAudit(db, userId, 'create', 'staff_user', id, undefined, { email, role: input.role })
|
||||
return getEmployee(db, id)!
|
||||
})()
|
||||
}
|
||||
|
||||
export function updateEmployee(db: DB, userId: string, id: string, patch: {
|
||||
displayName?: string; role?: EmployeeRole
|
||||
}): Employee {
|
||||
return db.transaction(() => {
|
||||
const before = 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' && !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) }
|
||||
if (sets.length > 0) {
|
||||
args.push(id)
|
||||
db.prepare(`UPDATE staff_user SET ${sets.join(', ')} WHERE id=?`).run(...args)
|
||||
}
|
||||
const after = getEmployee(db, id)!
|
||||
writeAudit(db, userId, 'update', 'staff_user', id, before, after)
|
||||
return after
|
||||
})()
|
||||
}
|
||||
|
||||
/** Reset an employee's password. Audits the action; never logs the hash. */
|
||||
export function setEmployeePassword(db: DB, userId: string, id: string, password: string): void {
|
||||
if (password.length < 8) throw new Error('Password must be at least 8 characters')
|
||||
const { salt, hash } = hashPin(password)
|
||||
db.transaction(() => {
|
||||
const before = getEmployee(db, id)
|
||||
if (!before) throw new Error('Employee not found')
|
||||
db.prepare(`UPDATE staff_user SET pw_salt=?, pw_hash=? WHERE id=?`).run(salt, hash, id)
|
||||
writeAudit(db, userId, 'reset_password', 'staff_user', id)
|
||||
})()
|
||||
}
|
||||
|
||||
/** Deactivation takes effect immediately: sessions are purged in the same transaction. */
|
||||
export function deactivateEmployee(db: DB, userId: string, id: string): Employee {
|
||||
if (id === userId) throw new Error('Cannot deactivate yourself')
|
||||
return db.transaction(() => {
|
||||
const before = getEmployee(db, id)
|
||||
if (!before) throw new Error('Employee not found')
|
||||
if (!before.active) return before // already inactive — idempotent
|
||||
if (before.role === 'owner' && !otherActiveOwnerExists(db, id)) {
|
||||
throw new Error('Cannot deactivate the last active owner')
|
||||
}
|
||||
db.prepare(`UPDATE staff_user SET active=0 WHERE id=?`).run(id)
|
||||
db.prepare(`DELETE FROM session WHERE staff_id=?`).run(id)
|
||||
const after = getEmployee(db, id)!
|
||||
writeAudit(db, userId, 'deactivate', 'staff_user', id, before, after)
|
||||
return after
|
||||
})()
|
||||
}
|
||||
|
||||
export function reactivateEmployee(db: DB, userId: string, id: string): Employee {
|
||||
return db.transaction(() => {
|
||||
const before = getEmployee(db, id)
|
||||
if (!before) throw new Error('Employee not found')
|
||||
if (before.active) return before // already active — idempotent
|
||||
db.prepare(`UPDATE staff_user SET active=1 WHERE id=?`).run(id)
|
||||
const after = getEmployee(db, id)!
|
||||
writeAudit(db, userId, 'reactivate', 'staff_user', id, before, after)
|
||||
return after
|
||||
})()
|
||||
}
|
||||
@ -0,0 +1,130 @@
|
||||
// apps/hq/test/employees.test.ts — Phase 1: employee foundation (D16)
|
||||
import Database from 'better-sqlite3'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { openDb, rebuildStaffUserRoleCheck, type DB } from '../src/db'
|
||||
import { createStaff, login, verifySession } from '../src/auth'
|
||||
import {
|
||||
createEmployee, deactivateEmployee, getEmployee, listEmployees,
|
||||
reactivateEmployee, setEmployeePassword, updateEmployee,
|
||||
} from '../src/repos-employees'
|
||||
import { listAudit } from '../src/audit'
|
||||
|
||||
function withOwner(): { db: DB; ownerId: string } {
|
||||
const db = openDb(':memory:')
|
||||
const { id } = createStaff(db, {
|
||||
email: 'admin@tecnostac.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9',
|
||||
})
|
||||
return { db, ownerId: id }
|
||||
}
|
||||
|
||||
describe('employee foundation', () => {
|
||||
it('creates manager and staff; list never exposes password columns', () => {
|
||||
const { db, ownerId } = withOwner()
|
||||
createEmployee(db, ownerId, { email: 'M@x.co', displayName: 'Mgr', role: 'manager', password: 'password1' })
|
||||
createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
|
||||
const all = listEmployees(db)
|
||||
expect(all).toHaveLength(3)
|
||||
expect(all.map((e) => e.role).sort()).toEqual(['manager', 'owner', 'staff'])
|
||||
expect(all.find((e) => e.displayName === 'Mgr')!.email).toBe('m@x.co') // lowercased
|
||||
for (const e of all) {
|
||||
expect(e).not.toHaveProperty('pw_salt')
|
||||
expect(e).not.toHaveProperty('pw_hash')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a bad role and a short password in the repo', () => {
|
||||
const { db, ownerId } = withOwner()
|
||||
expect(() =>
|
||||
createEmployee(db, ownerId, { email: 'x@x.co', displayName: 'X', role: 'admin' as never, password: 'password1' }),
|
||||
).toThrow(/role/i)
|
||||
expect(() =>
|
||||
createEmployee(db, ownerId, { email: 'x@x.co', displayName: 'X', role: 'staff', password: 'short' }),
|
||||
).toThrow(/8/)
|
||||
})
|
||||
|
||||
it('deactivation kills the live session immediately and purges its rows', () => {
|
||||
const { db, ownerId } = withOwner()
|
||||
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
|
||||
const session = login(db, 's@x.co', 'password2')!
|
||||
expect(verifySession(db, session.token)).toMatchObject({ id: emp.id })
|
||||
deactivateEmployee(db, ownerId, emp.id)
|
||||
expect(verifySession(db, session.token)).toBeNull()
|
||||
const rows = db.prepare(`SELECT COUNT(*) AS n FROM session WHERE staff_id=?`).get(emp.id) as { n: number }
|
||||
expect(rows.n).toBe(0)
|
||||
})
|
||||
|
||||
it('guards: last active owner cannot be demoted or deactivated; self-deactivation rejected', () => {
|
||||
const { db, ownerId } = withOwner()
|
||||
expect(() => updateEmployee(db, ownerId, ownerId, { role: 'staff' })).toThrow(/last active owner/)
|
||||
expect(() => deactivateEmployee(db, ownerId, ownerId)).toThrow(/yourself/)
|
||||
// second owner unlocks the demotion of the first
|
||||
const second = createEmployee(db, ownerId, { email: 'o2@x.co', displayName: 'O2', role: 'owner', password: 'password3' })
|
||||
expect(updateEmployee(db, second.id, ownerId, { role: 'manager' }).role).toBe('manager')
|
||||
})
|
||||
|
||||
it('deactivate/reactivate round-trips and login respects active', () => {
|
||||
const { db, ownerId } = withOwner()
|
||||
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
|
||||
deactivateEmployee(db, ownerId, emp.id)
|
||||
expect(login(db, 's@x.co', 'password2')).toBeNull()
|
||||
expect(getEmployee(db, emp.id)!.active).toBe(false)
|
||||
reactivateEmployee(db, ownerId, emp.id)
|
||||
expect(login(db, 's@x.co', 'password2')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('password reset works and is audited without the hash', () => {
|
||||
const { db, ownerId } = withOwner()
|
||||
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
|
||||
setEmployeePassword(db, ownerId, emp.id, 'new-secret-9')
|
||||
expect(login(db, 's@x.co', 'password2')).toBeNull()
|
||||
expect(login(db, 's@x.co', 'new-secret-9')).not.toBeNull()
|
||||
const audit = listAudit(db).find((a) => a.action === 'reset_password' && a.entity_id === emp.id)
|
||||
expect(audit).toBeDefined()
|
||||
expect(audit!.before_json ?? '').not.toMatch(/hash/)
|
||||
expect(audit!.after_json ?? '').not.toMatch(/hash/)
|
||||
})
|
||||
|
||||
it('every employee mutation writes an audit row', () => {
|
||||
const { db, ownerId } = withOwner()
|
||||
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
|
||||
updateEmployee(db, ownerId, emp.id, { displayName: 'Renamed' })
|
||||
deactivateEmployee(db, ownerId, emp.id)
|
||||
reactivateEmployee(db, ownerId, emp.id)
|
||||
const actions = listAudit(db).filter((a) => a.entity === 'staff_user' && a.entity_id === emp.id).map((a) => a.action)
|
||||
expect(actions).toEqual(expect.arrayContaining(['create', 'update', 'deactivate', 'reactivate']))
|
||||
})
|
||||
|
||||
it('fresh DBs accept manager directly (SCHEMA born correct)', () => {
|
||||
const { db, ownerId } = withOwner()
|
||||
const m = createEmployee(db, ownerId, { email: 'm@x.co', displayName: 'M', role: 'manager', password: 'password1' })
|
||||
expect(getEmployee(db, m.id)!.role).toBe('manager')
|
||||
})
|
||||
|
||||
it('rebuild migrates an old-CHECK DB preserving rows, and is idempotent', () => {
|
||||
// Simulate a DB created before the employee slice: old two-role CHECK + one row.
|
||||
const raw = new Database(':memory:') as DB
|
||||
raw.exec(`CREATE TABLE staff_user (
|
||||
id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('owner','staff')),
|
||||
pw_salt TEXT NOT NULL, pw_hash TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1
|
||||
)`)
|
||||
raw.prepare(
|
||||
`INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run('u1', 'a@b.c', 'A', 'owner', 's', 'h')
|
||||
// Old CHECK rejects manager
|
||||
expect(() => raw.prepare(
|
||||
`INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run('u2', 'm@b.c', 'M', 'manager', 's', 'h')).toThrow()
|
||||
rebuildStaffUserRoleCheck(raw)
|
||||
// Data preserved, manager now accepted
|
||||
const kept = raw.prepare(`SELECT id, email, role FROM staff_user WHERE id='u1'`).get() as { id: string }
|
||||
expect(kept).toMatchObject({ id: 'u1', email: 'a@b.c', role: 'owner' })
|
||||
raw.prepare(
|
||||
`INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run('u2', 'm@b.c', 'M', 'manager', 's', 'h')
|
||||
// Idempotent: second run is a no-op and loses nothing
|
||||
rebuildStaffUserRoleCheck(raw)
|
||||
const n = raw.prepare(`SELECT COUNT(*) AS n FROM staff_user`).get() as { n: number }
|
||||
expect(n.n).toBe(2)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue