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.
82 lines
3.8 KiB
TypeScript
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
|
|
}
|