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.
sims-hq/apps/hq/src/auth.ts

82 lines
3.8 KiB
TypeScript

// 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 async function createStaff(db: DB, input: {
email: string; displayName: string; role: 'owner' | 'manager' | 'staff'; password: string
}): Promise<{ 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
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,
)
await 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 async function login(db: DB, email: string, password: string):
Promise<{ token: string; staff: { id: string; displayName: string; role: string } } | null> {
const row = await db.get<StaffRow>(`SELECT * FROM staff_user WHERE email=? AND active=1`, email.toLowerCase())
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()
await db.run(`INSERT INTO session (token, staff_id, expires_at) VALUES (?, ?, ?)`, token, row.id, expires)
await writeAudit(db, row.id, 'login', 'staff_user', row.id)
return { token, staff: { id: row.id, displayName: row.display_name, role: row.role } }
}
export async function verifySession(db: DB, token: string): Promise<{ id: string; role: string } | null> {
// u.active=1: a deactivated employee's unexpired token must die immediately.
const row = await db.get<{ id: string; role: string }>(
`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 > ? AND u.active = 1`,
token, new Date().toISOString(),
)
return row ?? null
}
export const requireAuth: RequestHandler = (req, res, next) => {
void (async () => {
const db = req.app.locals['db'] as DB
const token = (req.headers.authorization ?? '').replace(/^Bearer /, '')
const staff = token ? await 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()
}
/** True for the managerial roles (owner|manager) allowed to see/act beyond their own rows. */
export const isManagerial = (role: string): boolean => role === 'owner' || role === 'manager'
/**
* The shared server-side role gate for owner-scoped queries. Input comes from
* res.locals.staff — never from the request body. Staff are forced to their own
* rows and any widening param is ignored; owner/manager are unfiltered by default
* and may narrow via `requestedOwnerId`. Returns the employee id every owner-scoped
* query must filter by, or undefined for no filter.
*/
export function ownerScope(
viewer: { id: string; role: string }, requestedOwnerId?: string,
): string | undefined {
if (!isManagerial(viewer.role)) return viewer.id
return requestedOwnerId !== undefined && requestedOwnerId !== '' ? requestedOwnerId : undefined
}