feat(hq): staff auth with sessions and owner gate (HQ-1 task 3)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
89e468eeda
commit
ac7aa891db
@ -1,10 +1,17 @@
|
||||
import { Router } from 'express'
|
||||
import { login } from './auth'
|
||||
import type { DB } from './db'
|
||||
|
||||
export function apiRouter(_db: DB | null): Router {
|
||||
export function apiRouter(db: DB): Router {
|
||||
const r = Router()
|
||||
r.get('/health', (_req, res) => {
|
||||
res.json({ ok: true, service: 'sims-hq', version: '0.1.0' })
|
||||
})
|
||||
r.post('/auth/login', (req, res) => {
|
||||
const { email, password } = req.body as { email?: string; password?: string }
|
||||
const out = typeof email === 'string' && typeof password === 'string' ? login(db, email, password) : null
|
||||
if (!out) { res.status(401).json({ ok: false, error: 'Invalid email or password' }); return }
|
||||
res.json({ ok: true, ...out })
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
// apps/hq/src/auth.ts
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import type { RequestHandler } from 'express'
|
||||
import { hashPin, verifyPin } from '@sims/auth'
|
||||
import { uuidv7 } from '@sims/domain'
|
||||
import { writeAudit } from './audit'
|
||||
import type { DB } from './db'
|
||||
|
||||
const SESSION_DAYS = 14
|
||||
|
||||
export function createStaff(db: DB, input: {
|
||||
email: string; displayName: string; role: 'owner' | 'staff'; password: string
|
||||
}): { id: string } {
|
||||
if (input.password.length < 8) throw new Error('Password must be at least 8 characters')
|
||||
const id = uuidv7()
|
||||
const { salt, hash } = hashPin(input.password) // generic scrypt; PIN digit policy not applied
|
||||
db.prepare(
|
||||
`INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(id, input.email.toLowerCase(), input.displayName, input.role, salt, hash)
|
||||
writeAudit(db, 'system', 'create', 'staff_user', id, undefined, { email: input.email, role: input.role })
|
||||
return { id }
|
||||
}
|
||||
|
||||
interface StaffRow { id: string; email: string; display_name: string; role: string; pw_salt: string; pw_hash: string; active: number }
|
||||
|
||||
export function login(db: DB, email: string, password: string):
|
||||
{ token: string; staff: { id: string; displayName: string; role: string } } | null {
|
||||
const row = db.prepare(`SELECT * FROM staff_user WHERE email=? AND active=1`).get(email.toLowerCase()) as StaffRow | undefined
|
||||
if (!row || !verifyPin(password, { salt: row.pw_salt, hash: row.pw_hash })) return null
|
||||
const token = randomBytes(32).toString('hex')
|
||||
const expires = new Date(Date.now() + SESSION_DAYS * 86_400_000).toISOString()
|
||||
db.prepare(`INSERT INTO session (token, staff_id, expires_at) VALUES (?, ?, ?)`).run(token, row.id, expires)
|
||||
writeAudit(db, row.id, 'login', 'staff_user', row.id)
|
||||
return { token, staff: { id: row.id, displayName: row.display_name, role: row.role } }
|
||||
}
|
||||
|
||||
export function verifySession(db: DB, token: string): { id: string; role: string } | null {
|
||||
const row = db.prepare(
|
||||
`SELECT s.staff_id AS id, u.role FROM session s JOIN staff_user u ON u.id = s.staff_id
|
||||
WHERE s.token=? AND s.expires_at > ?`,
|
||||
).get(token, new Date().toISOString()) as { id: string; role: string } | undefined
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
export const requireAuth: RequestHandler = (req, res, next) => {
|
||||
const db = req.app.locals['db'] as DB
|
||||
const token = (req.headers.authorization ?? '').replace(/^Bearer /, '')
|
||||
const staff = token ? verifySession(db, token) : null
|
||||
if (!staff) { res.status(401).json({ ok: false, error: 'Not signed in' }); return }
|
||||
res.locals['staff'] = staff
|
||||
next()
|
||||
}
|
||||
|
||||
export const requireOwner: RequestHandler = (_req, res, next) => {
|
||||
if ((res.locals['staff'] as { role: string }).role !== 'owner') {
|
||||
res.status(403).json({ ok: false, error: 'Owner only' }); return
|
||||
}
|
||||
next()
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
// apps/hq/test/auth.test.ts
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { openDb } from '../src/db'
|
||||
import { createStaff, login, verifySession } from '../src/auth'
|
||||
|
||||
describe('hq auth', () => {
|
||||
it('logs in with correct password, rejects wrong one', () => {
|
||||
const db = openDb(':memory:')
|
||||
createStaff(db, { email: 'admin@tecnostac.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9' })
|
||||
expect(login(db, 'admin@tecnostac.com', 'wrong')).toBeNull()
|
||||
const ok = login(db, 'admin@tecnostac.com', 'let-me-in-9')
|
||||
expect(ok).not.toBeNull()
|
||||
const staff = verifySession(db, ok!.token)
|
||||
expect(staff).toMatchObject({ role: 'owner' })
|
||||
})
|
||||
it('rejects passwords under 8 chars at creation', () => {
|
||||
const db = openDb(':memory:')
|
||||
expect(() =>
|
||||
createStaff(db, { email: 'a@b.c', displayName: 'X', role: 'staff', password: 'short' }),
|
||||
).toThrow(/8/)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue