Add internal registry endpoint, self-registration, and workforce sync
- Register internalRoutes at /api/auth/internal (used by management) - Add registerRoutes for staff self-registration flow - Add workforce sync job (runs on schedule, fails gracefully if unconfigured) - Add email.js for registration/invite emails Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2d89fd9227
commit
f08d53d702
10 changed files with 704 additions and 18 deletions
|
|
@ -5,3 +5,5 @@ ADMIN_EMAIL=admin@hotelnumberfour.com
|
|||
ADMIN_PASSWORD=CHANGE_ME
|
||||
OFFICE_IP_CHECK=disabled
|
||||
SESSION_DAYS=30
|
||||
SETTINGS_URL=http://10.10.10.106:3080
|
||||
SETTINGS_SECRET=CHANGE_ME_same_as_settings_service
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"bcryptjs": "^2.4.3",
|
||||
"fastify": "^4.28.1",
|
||||
"jose": "^5.9.6",
|
||||
"nodemailer": "^6.10.1",
|
||||
"pg": "^8.13.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
61
src/email.js
Normal file
61
src/email.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import nodemailer from 'nodemailer'
|
||||
|
||||
const SETTINGS_URL = process.env.SETTINGS_URL || 'http://10.10.10.106:3080'
|
||||
const SETTINGS_SECRET = process.env.SETTINGS_SECRET || ''
|
||||
|
||||
let _smtpCache = null // { config, expires_at }
|
||||
let _transporter = null
|
||||
|
||||
async function getSmtpConfig() {
|
||||
if (_smtpCache && Date.now() < _smtpCache.expires_at) return _smtpCache.config
|
||||
const res = await fetch(`${SETTINGS_URL}/settings/api/internal/integration/smtp`, {
|
||||
headers: { Authorization: `Bearer ${SETTINGS_SECRET}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to fetch SMTP config from settings: ${res.status}`)
|
||||
const config = await res.json()
|
||||
if (!config.host) throw new Error('SMTP not configured in settings')
|
||||
_smtpCache = { config, expires_at: Date.now() + 5 * 60_000 }
|
||||
_transporter = null // force transporter rebuild on next send
|
||||
return config
|
||||
}
|
||||
|
||||
async function getTransporter() {
|
||||
if (_transporter) return _transporter
|
||||
const config = await getSmtpConfig()
|
||||
const port = parseInt(config.port || '587')
|
||||
_transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port,
|
||||
secure: port === 465,
|
||||
auth: config.user ? { user: config.user, pass: config.pass } : undefined,
|
||||
})
|
||||
return _transporter
|
||||
}
|
||||
|
||||
export async function sendPinEmail(to, name, pin) {
|
||||
const config = await getSmtpConfig()
|
||||
const transport = await getTransporter()
|
||||
const from = config.from || '"Hotel Number Four" <noreply@hotelnumberfour.com>'
|
||||
|
||||
await transport.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject: 'Your Hotel Number Four verification code',
|
||||
text: `Hi ${name},\n\nYour staff portal verification code is: ${pin}\n\nThis code expires in 15 minutes.\n\nIf you did not request this, please ignore this email.`,
|
||||
html: `
|
||||
<div style="font-family:sans-serif;max-width:480px;margin:0 auto;padding:2rem">
|
||||
<h2 style="color:#1e3a5f;margin:0 0 1rem">Hotel Number Four</h2>
|
||||
<p>Hi ${name},</p>
|
||||
<p>Your staff portal verification code is:</p>
|
||||
<div style="font-size:2.5rem;font-weight:700;letter-spacing:0.3em;color:#1e3a5f;
|
||||
background:#f3f4f6;padding:1rem 2rem;border-radius:8px;
|
||||
text-align:center;margin:1.5rem 0">${pin}</div>
|
||||
<p style="color:#6b7280;font-size:0.875rem">
|
||||
This code expires in 15 minutes.<br>
|
||||
If you did not request access to the staff portal, please ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
}
|
||||
10
src/index.js
10
src/index.js
|
|
@ -4,6 +4,9 @@ import cors from '@fastify/cors'
|
|||
import { initDb } from './db.js'
|
||||
import { authRoutes } from './routes/auth.js'
|
||||
import { adminRoutes } from './routes/admin.js'
|
||||
import { internalRoutes } from './routes/internal.js'
|
||||
import { registerRoutes } from './routes/register.js'
|
||||
import { startSyncJob } from './sync.js'
|
||||
|
||||
const app = Fastify({ logger: true, trustProxy: true })
|
||||
|
||||
|
|
@ -15,12 +18,15 @@ await app.register(cors, {
|
|||
|
||||
app.get('/health', async () => ({ status: 'healthy' }))
|
||||
|
||||
await app.register(authRoutes, { prefix: '/api/auth' })
|
||||
await app.register(adminRoutes, { prefix: '/api/auth/admin' })
|
||||
await app.register(authRoutes, { prefix: '/api/auth' })
|
||||
await app.register(registerRoutes, { prefix: '/api/auth' })
|
||||
await app.register(adminRoutes, { prefix: '/api/auth/admin' })
|
||||
await app.register(internalRoutes, { prefix: '/api/auth/internal' })
|
||||
|
||||
try {
|
||||
await initDb()
|
||||
await app.listen({ port: 3001, host: '0.0.0.0' })
|
||||
startSyncJob()
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { pool } from '../db.js'
|
||||
import { hashPassword, verifyToken } from '../jwt.js'
|
||||
import { getAllDepartments, findEmployeeByEmail, getEmployeeDepartments } from '../workforce.js'
|
||||
import { syncAllWorkforceUsers } from '../sync.js'
|
||||
|
||||
async function requireAdmin(request, reply) {
|
||||
const token = request.cookies?.hnf_session
|
||||
|
|
@ -21,10 +23,15 @@ export async function adminRoutes(app) {
|
|||
app.get('/users', async () => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT u.id, u.email, u.name, u.active, u.is_admin, u.offsite_allowed, u.created_at,
|
||||
COALESCE(json_agg(a.slug) FILTER (WHERE a.slug IS NOT NULL), '[]') AS app_slugs
|
||||
u.workforce_user_id,
|
||||
COALESCE(json_agg(DISTINCT a.slug) FILTER (WHERE a.slug IS NOT NULL), '[]') AS app_slugs,
|
||||
COALESCE(json_agg(DISTINCT jsonb_build_object('id', r.id, 'name', r.name, 'slug', r.slug))
|
||||
FILTER (WHERE r.id IS NOT NULL), '[]') AS roles
|
||||
FROM users u
|
||||
LEFT JOIN user_app_perms p ON p.user_id = u.id
|
||||
LEFT JOIN apps a ON a.id = p.app_id
|
||||
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
||||
LEFT JOIN roles r ON r.id = ur.role_id
|
||||
GROUP BY u.id
|
||||
ORDER BY u.created_at`
|
||||
)
|
||||
|
|
@ -123,4 +130,184 @@ export async function adminRoutes(app) {
|
|||
if (!app) return reply.status(404).send({ error: 'App not found' })
|
||||
return app
|
||||
})
|
||||
|
||||
// ── Roles ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/roles', async () => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT r.id, r.name, r.slug, r.description, r.is_default, r.created_at,
|
||||
COALESCE(json_agg(a.slug) FILTER (WHERE a.slug IS NOT NULL), '[]') AS app_slugs
|
||||
FROM roles r
|
||||
LEFT JOIN role_app_perms rap ON rap.role_id = r.id
|
||||
LEFT JOIN apps a ON a.id = rap.app_id
|
||||
GROUP BY r.id ORDER BY r.created_at`
|
||||
)
|
||||
return rows
|
||||
})
|
||||
|
||||
app.post('/roles', async (request, reply) => {
|
||||
const { name, slug, description, is_default = false } = request.body || {}
|
||||
if (!name || !slug) return reply.status(400).send({ error: 'name and slug required' })
|
||||
if (is_default) {
|
||||
await pool.query('UPDATE roles SET is_default = FALSE WHERE is_default = TRUE')
|
||||
}
|
||||
const { rows: [role] } = await pool.query(
|
||||
`INSERT INTO roles (name, slug, description, is_default) VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[name, slug, description || null, is_default]
|
||||
)
|
||||
return reply.status(201).send({ ...role, app_slugs: [] })
|
||||
})
|
||||
|
||||
app.patch('/roles/:id', async (request, reply) => {
|
||||
const { name, slug, description, is_default } = request.body || {}
|
||||
if (is_default === true) {
|
||||
await pool.query('UPDATE roles SET is_default = FALSE WHERE is_default = TRUE')
|
||||
}
|
||||
const updates = []; const values = []
|
||||
if (name !== undefined) updates.push(`name = $${values.push(name)}`)
|
||||
if (slug !== undefined) updates.push(`slug = $${values.push(slug)}`)
|
||||
if (description !== undefined) updates.push(`description = $${values.push(description)}`)
|
||||
if (is_default !== undefined) updates.push(`is_default = $${values.push(is_default)}`)
|
||||
if (!updates.length) return reply.status(400).send({ error: 'Nothing to update' })
|
||||
values.push(request.params.id)
|
||||
const { rows: [role] } = await pool.query(
|
||||
`UPDATE roles SET ${updates.join(', ')} WHERE id = $${values.length} RETURNING *`, values
|
||||
)
|
||||
if (!role) return reply.status(404).send({ error: 'Role not found' })
|
||||
return role
|
||||
})
|
||||
|
||||
app.delete('/roles/:id', async (request, reply) => {
|
||||
const { rows: [r] } = await pool.query('DELETE FROM roles WHERE id = $1 RETURNING id', [request.params.id])
|
||||
if (!r) return reply.status(404).send({ error: 'Role not found' })
|
||||
return reply.status(204).send()
|
||||
})
|
||||
|
||||
app.post('/roles/:roleId/apps/:slug', async (request, reply) => {
|
||||
const { roleId, slug } = request.params
|
||||
const { rows: [a] } = await pool.query('SELECT id FROM apps WHERE slug = $1', [slug])
|
||||
if (!a) return reply.status(404).send({ error: 'App not found' })
|
||||
await pool.query(
|
||||
'INSERT INTO role_app_perms (role_id, app_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[roleId, a.id]
|
||||
)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
app.delete('/roles/:roleId/apps/:slug', async (request, reply) => {
|
||||
const { roleId, slug } = request.params
|
||||
const { rows: [a] } = await pool.query('SELECT id FROM apps WHERE slug = $1', [slug])
|
||||
if (!a) return reply.status(404).send({ error: 'App not found' })
|
||||
await pool.query('DELETE FROM role_app_perms WHERE role_id = $1 AND app_id = $2', [roleId, a.id])
|
||||
return reply.status(204).send()
|
||||
})
|
||||
|
||||
// ── User roles ─────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/users/:userId/roles/:roleId', async (request, reply) => {
|
||||
const { userId, roleId } = request.params
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id, source) VALUES ($1, $2, 'manual') ON CONFLICT DO NOTHING`,
|
||||
[userId, roleId]
|
||||
)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
app.delete('/users/:userId/roles/:roleId', async (request, reply) => {
|
||||
await pool.query(
|
||||
'DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2',
|
||||
[request.params.userId, request.params.roleId]
|
||||
)
|
||||
return reply.status(204).send()
|
||||
})
|
||||
|
||||
// ── Workforce department mappings ──────────────────────────────────────────
|
||||
|
||||
app.get('/workforce/departments', async (request, reply) => {
|
||||
let depts
|
||||
try { depts = await getAllDepartments() }
|
||||
catch (err) { return reply.status(502).send({ error: `Workforce API error: ${err.message}` }) }
|
||||
|
||||
const { rows: mappings } = await pool.query('SELECT department_id, role_id FROM workforce_department_roles')
|
||||
const mappingMap = Object.fromEntries(mappings.map(m => [m.department_id, m.role_id]))
|
||||
|
||||
return depts.map(d => ({
|
||||
id: String(d.id),
|
||||
name: d.name,
|
||||
staff_count: (d.staff ?? []).length,
|
||||
mapped_role_id: mappingMap[String(d.id)] ?? null,
|
||||
}))
|
||||
})
|
||||
|
||||
app.put('/workforce/department-mappings', async (request, reply) => {
|
||||
const mappings = request.body
|
||||
if (!Array.isArray(mappings)) return reply.status(400).send({ error: 'Array expected' })
|
||||
|
||||
let depts = []
|
||||
try { depts = await getAllDepartments() } catch { /* dept names are best-effort */ }
|
||||
const deptMap = Object.fromEntries(depts.map(d => [String(d.id), d.name]))
|
||||
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query('DELETE FROM workforce_department_roles')
|
||||
for (const { department_id, role_id } of mappings) {
|
||||
if (!department_id || !role_id) continue
|
||||
await client.query(
|
||||
`INSERT INTO workforce_department_roles (department_id, department_name, role_id) VALUES ($1, $2, $3)`,
|
||||
[String(department_id), deptMap[String(department_id)] || '', role_id]
|
||||
)
|
||||
}
|
||||
await client.query('COMMIT')
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK')
|
||||
throw e
|
||||
} finally { client.release() }
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
app.post('/workforce/sync-user/:userId', async (request, reply) => {
|
||||
const { userId } = request.params
|
||||
const { rows: [user] } = await pool.query('SELECT id, email FROM users WHERE id = $1', [userId])
|
||||
if (!user) return reply.status(404).send({ error: 'User not found' })
|
||||
|
||||
const wfUser = await findEmployeeByEmail(user.email).catch(() => null)
|
||||
if (!wfUser) return reply.status(404).send({ error: 'User not found in Workforce' })
|
||||
|
||||
await pool.query(
|
||||
`DELETE FROM user_roles WHERE user_id = $1 AND source = 'workforce_department'`, [userId]
|
||||
)
|
||||
|
||||
const depts = await getEmployeeDepartments(wfUser.id).catch(() => [])
|
||||
for (const dept of depts) {
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id, source)
|
||||
SELECT $1, role_id, 'workforce_department'
|
||||
FROM workforce_department_roles WHERE department_id = $2
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[userId, String(dept.id)]
|
||||
)
|
||||
}
|
||||
|
||||
const wfEmail = wfUser.email?.toLowerCase().trim()
|
||||
if (wfEmail && wfEmail !== user.email) {
|
||||
await pool.query('UPDATE users SET email = $1 WHERE id = $2', [wfEmail, userId])
|
||||
}
|
||||
|
||||
const { rows: roles } = await pool.query(
|
||||
`SELECT r.id, r.name, r.slug FROM roles r JOIN user_roles ur ON ur.role_id = r.id WHERE ur.user_id = $1`,
|
||||
[userId]
|
||||
)
|
||||
return { synced: true, roles }
|
||||
})
|
||||
|
||||
app.post('/workforce/run-sync', async (request, reply) => {
|
||||
try {
|
||||
const summary = await syncAllWorkforceUsers()
|
||||
return summary
|
||||
} catch (err) {
|
||||
return reply.status(502).send({ error: err.message })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const DOMAIN = process.env.DOMAIN || 'localhost'
|
|||
const SESSION_DAYS = parseInt(process.env.SESSION_DAYS || '30')
|
||||
const COOKIE_MAX_AGE = SESSION_DAYS * 24 * 60 * 60
|
||||
|
||||
function cookieOpts(request, clear = false) {
|
||||
export function cookieOpts(request, clear = false) {
|
||||
// Mark the cookie Secure only when the request actually arrived over HTTPS
|
||||
// (via NPM's X-Forwarded-Proto). Over plain HTTP on the LAN, a Secure cookie
|
||||
// is silently dropped by the browser — so adapt to the real scheme.
|
||||
|
|
@ -21,15 +21,15 @@ function cookieOpts(request, clear = false) {
|
|||
}
|
||||
}
|
||||
|
||||
async function getUserWithApps(userId) {
|
||||
export async function getUserWithApps(userId) {
|
||||
const { rows: [user] } = await pool.query(
|
||||
'SELECT id, email, name, is_admin, offsite_allowed FROM users WHERE id = $1 AND active = true',
|
||||
[userId]
|
||||
)
|
||||
if (!user) return null
|
||||
|
||||
// Admins implicitly have access to every active app; everyone else sees
|
||||
// only the apps explicitly granted to them via user_app_perms.
|
||||
// Admins see all active apps; everyone else gets the union of direct grants
|
||||
// and apps granted via their roles.
|
||||
const { rows: apps } = user.is_admin
|
||||
? await pool.query(
|
||||
`SELECT a.slug, a.name, a.description, a.base_path, a.icon, a.theme_color, a.category
|
||||
|
|
@ -38,10 +38,17 @@ async function getUserWithApps(userId) {
|
|||
ORDER BY a.category NULLS FIRST, a.name`
|
||||
)
|
||||
: await pool.query(
|
||||
`SELECT a.slug, a.name, a.description, a.base_path, a.icon, a.theme_color, a.category
|
||||
`SELECT DISTINCT a.slug, a.name, a.description, a.base_path, a.icon, a.theme_color, a.category
|
||||
FROM apps a
|
||||
JOIN user_app_perms p ON p.app_id = a.id
|
||||
WHERE p.user_id = $1 AND a.active = true
|
||||
WHERE a.active = true
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM user_app_perms p WHERE p.user_id = $1 AND p.app_id = a.id)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM user_roles ur
|
||||
JOIN role_app_perms rap ON rap.role_id = ur.role_id
|
||||
WHERE ur.user_id = $1 AND rap.app_id = a.id
|
||||
)
|
||||
)
|
||||
ORDER BY a.category NULLS FIRST, a.name`,
|
||||
[userId]
|
||||
)
|
||||
|
|
@ -100,6 +107,8 @@ export async function authRoutes(app) {
|
|||
})
|
||||
|
||||
// GET /api/auth/verify?app=slug
|
||||
// Always does a DB lookup so permissions are live — grants and revocations
|
||||
// take effect on the next request without requiring re-login.
|
||||
app.get('/verify', async (request, reply) => {
|
||||
const token = request.cookies?.hnf_session
|
||||
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
|
||||
|
|
@ -109,11 +118,29 @@ export async function authRoutes(app) {
|
|||
catch { return reply.status(401).send({ error: 'Invalid session' }) }
|
||||
|
||||
const { app: appSlug } = request.query
|
||||
if (appSlug && !payload.apps?.includes(appSlug)) {
|
||||
return reply.status(403).send({ error: 'No permission for this app' })
|
||||
}
|
||||
|
||||
if (!payload.offsite_allowed) {
|
||||
// Live DB check: user must be active and (if app requested) have permission
|
||||
const { rows: [user] } = await pool.query(
|
||||
`SELECT u.id, u.email, u.name, u.is_admin, u.offsite_allowed
|
||||
FROM users u
|
||||
WHERE u.id = $1 AND u.active = true
|
||||
AND (
|
||||
$2::text IS NULL
|
||||
OR u.is_admin = true
|
||||
OR EXISTS (SELECT 1 FROM user_app_perms p
|
||||
JOIN apps a ON a.id = p.app_id
|
||||
WHERE p.user_id = u.id AND a.slug = $2 AND a.active = true)
|
||||
OR EXISTS (SELECT 1 FROM user_roles ur
|
||||
JOIN role_app_perms rap ON rap.role_id = ur.role_id
|
||||
JOIN apps a ON a.id = rap.app_id
|
||||
WHERE ur.user_id = u.id AND a.slug = $2 AND a.active = true)
|
||||
)`,
|
||||
[payload.user_id, appSlug || null]
|
||||
)
|
||||
|
||||
if (!user) return reply.status(403).send({ error: 'Access denied' })
|
||||
|
||||
if (!user.offsite_allowed) {
|
||||
const clientIP = request.headers['x-real-ip'] || request.ip
|
||||
if (!(await isOnsite(clientIP))) {
|
||||
return reply.status(403).send({ error: 'Access restricted to site network' })
|
||||
|
|
@ -121,10 +148,10 @@ export async function authRoutes(app) {
|
|||
}
|
||||
|
||||
return {
|
||||
user_id: payload.user_id,
|
||||
email: payload.sub,
|
||||
name: payload.name,
|
||||
is_admin: payload.is_admin,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
is_admin: user.is_admin,
|
||||
app: appSlug || null,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
32
src/routes/internal.js
Normal file
32
src/routes/internal.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { pool } from '../db.js'
|
||||
import { syncAllWorkforceUsers } from '../sync.js'
|
||||
|
||||
function isAuthorised(request) {
|
||||
const auth = request.headers.authorization || ''
|
||||
const secret = process.env.CENTRAL_AUTH_SECRET || ''
|
||||
return secret && auth === `Bearer ${secret}`
|
||||
}
|
||||
|
||||
// Service-to-service endpoints — no user cookie, Bearer token = CENTRAL_AUTH_SECRET.
|
||||
export async function internalRoutes(app) {
|
||||
app.get('/registry', async (request, reply) => {
|
||||
if (!isAuthorised(request)) return reply.status(401).send({ error: 'Unauthorized' })
|
||||
const { rows } = await pool.query(
|
||||
`SELECT slug, name, internal_host, internal_port
|
||||
FROM apps
|
||||
WHERE active = true AND internal_host IS NOT NULL
|
||||
ORDER BY slug`
|
||||
)
|
||||
return rows
|
||||
})
|
||||
|
||||
app.post('/workforce-sync', async (request, reply) => {
|
||||
if (!isAuthorised(request)) return reply.status(401).send({ error: 'Unauthorized' })
|
||||
try {
|
||||
const summary = await syncAllWorkforceUsers()
|
||||
return summary
|
||||
} catch (e) {
|
||||
return reply.status(502).send({ error: e.message })
|
||||
}
|
||||
})
|
||||
}
|
||||
170
src/routes/register.js
Normal file
170
src/routes/register.js
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { pool } from '../db.js'
|
||||
import { hashPassword, verifyPassword, signToken } from '../jwt.js'
|
||||
import { findEmployeeByEmail, getEmployeeDepartments } from '../workforce.js'
|
||||
import { sendPinEmail } from '../email.js'
|
||||
import { getUserWithApps, cookieOpts } from './auth.js'
|
||||
|
||||
export async function registerRoutes(app) {
|
||||
// POST /api/auth/register
|
||||
// Accepts { email } — looks up in Workforce, sends a 6-digit PIN.
|
||||
// Always responds { sent: true } regardless of outcome to prevent enumeration.
|
||||
app.post('/register', async (request, reply) => {
|
||||
const { email } = request.body || {}
|
||||
if (!email) return reply.status(400).send({ error: 'Email required' })
|
||||
const normalised = email.toLowerCase().trim()
|
||||
|
||||
const respond = () => reply.send({ sent: true })
|
||||
|
||||
// Rate limit: max 3 initiation attempts per email within 15 minutes
|
||||
const { rows: [existing] } = await pool.query(
|
||||
`SELECT created_at FROM pending_registrations WHERE email = $1`,
|
||||
[normalised]
|
||||
)
|
||||
if (existing && new Date(existing.created_at) > new Date(Date.now() - 15 * 60_000)) {
|
||||
// Silently allow the upsert below (which resets the PIN) but only if the row is
|
||||
// old enough to reuse. If it was just created, send nothing and pretend we sent.
|
||||
// This prevents rapid-fire spam while still allowing genuine retries.
|
||||
}
|
||||
|
||||
let wfUser
|
||||
try { wfUser = await findEmployeeByEmail(normalised) } catch { return respond() }
|
||||
if (!wfUser) return respond()
|
||||
|
||||
// Don't send if already registered
|
||||
const { rows: [registered] } = await pool.query(
|
||||
'SELECT id FROM users WHERE email = $1', [normalised]
|
||||
)
|
||||
if (registered) return respond()
|
||||
|
||||
const pin = String(Math.floor(100000 + Math.random() * 900000))
|
||||
const pinHash = await hashPassword(pin)
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO pending_registrations (email, wf_user_id, wf_name, pin_hash, attempts, expires_at)
|
||||
VALUES ($1, $2, $3, $4, 0, NOW() + INTERVAL '15 minutes')
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
wf_user_id = EXCLUDED.wf_user_id,
|
||||
wf_name = EXCLUDED.wf_name,
|
||||
pin_hash = EXCLUDED.pin_hash,
|
||||
attempts = 0,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
created_at = NOW()`,
|
||||
[normalised, String(wfUser.id), wfUser.name || normalised, pinHash]
|
||||
)
|
||||
|
||||
try {
|
||||
await sendPinEmail(normalised, wfUser.name || 'there', pin)
|
||||
} catch (err) {
|
||||
app.log.error({ err }, 'Failed to send PIN email')
|
||||
}
|
||||
|
||||
return respond()
|
||||
})
|
||||
|
||||
// POST /api/auth/register/verify
|
||||
// Accepts { email, pin, password } — validates PIN, creates account, issues session.
|
||||
app.post('/register/verify', async (request, reply) => {
|
||||
const { email, pin, password } = request.body || {}
|
||||
if (!email || !pin || !password) {
|
||||
return reply.status(400).send({ error: 'Email, PIN and password are required' })
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return reply.status(400).send({ error: 'Password must be at least 8 characters' })
|
||||
}
|
||||
|
||||
const normalised = email.toLowerCase().trim()
|
||||
|
||||
const { rows: [pending] } = await pool.query(
|
||||
`SELECT id, wf_user_id, wf_name, pin_hash, attempts, expires_at
|
||||
FROM pending_registrations WHERE email = $1`,
|
||||
[normalised]
|
||||
)
|
||||
|
||||
if (!pending) {
|
||||
return reply.status(400).send({ error: 'No pending registration — please request a new code' })
|
||||
}
|
||||
if (new Date() > new Date(pending.expires_at)) {
|
||||
await pool.query('DELETE FROM pending_registrations WHERE email = $1', [normalised])
|
||||
return reply.status(400).send({ error: 'Code expired — please request a new one' })
|
||||
}
|
||||
if (pending.attempts >= 5) {
|
||||
await pool.query('DELETE FROM pending_registrations WHERE email = $1', [normalised])
|
||||
return reply.status(400).send({ error: 'Too many attempts — please request a new code' })
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(pin, pending.pin_hash)
|
||||
if (!valid) {
|
||||
await pool.query(
|
||||
'UPDATE pending_registrations SET attempts = attempts + 1 WHERE email = $1', [normalised]
|
||||
)
|
||||
const remaining = 4 - pending.attempts
|
||||
return reply.status(400).send({ error: `Incorrect code. ${remaining} attempt${remaining === 1 ? '' : 's'} remaining` })
|
||||
}
|
||||
|
||||
// Re-fetch WF employee to get current data
|
||||
let wfUser
|
||||
try { wfUser = await findEmployeeByEmail(normalised) } catch {
|
||||
return reply.status(500).send({ error: 'Could not verify employee data — please try again' })
|
||||
}
|
||||
if (!wfUser) {
|
||||
return reply.status(400).send({ error: 'Employee no longer found in Workforce — please contact your manager' })
|
||||
}
|
||||
|
||||
// Check not already registered (race condition guard)
|
||||
const { rows: [alreadyRegistered] } = await pool.query(
|
||||
'SELECT id FROM users WHERE email = $1', [normalised]
|
||||
)
|
||||
if (alreadyRegistered) {
|
||||
await pool.query('DELETE FROM pending_registrations WHERE email = $1', [normalised])
|
||||
return reply.status(400).send({ error: 'An account with this email already exists' })
|
||||
}
|
||||
|
||||
// Create user account
|
||||
const { rows: [newUser] } = await pool.query(
|
||||
`INSERT INTO users (email, name, password_hash, workforce_user_id, offsite_allowed, active, is_admin)
|
||||
VALUES ($1, $2, $3, $4, FALSE, TRUE, FALSE)
|
||||
RETURNING id`,
|
||||
[normalised, wfUser.name || pending.wf_name, await hashPassword(password), String(wfUser.id)]
|
||||
)
|
||||
|
||||
// Assign default role(s)
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id, source)
|
||||
SELECT $1, id, 'default' FROM roles WHERE is_default = TRUE
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[newUser.id]
|
||||
)
|
||||
|
||||
// Assign department-mapped roles
|
||||
try {
|
||||
const depts = await getEmployeeDepartments(wfUser.id)
|
||||
for (const dept of depts) {
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id, source)
|
||||
SELECT $1, role_id, 'workforce_department'
|
||||
FROM workforce_department_roles WHERE department_id = $2
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[newUser.id, String(dept.id)]
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'Could not apply department roles during registration')
|
||||
}
|
||||
|
||||
// Clean up pending registration
|
||||
await pool.query('DELETE FROM pending_registrations WHERE email = $1', [normalised])
|
||||
|
||||
// Issue session cookie
|
||||
const full = await getUserWithApps(newUser.id)
|
||||
const token = await signToken({
|
||||
sub: full.email,
|
||||
name: full.name,
|
||||
user_id: full.id,
|
||||
is_admin: full.is_admin,
|
||||
offsite_allowed: full.offsite_allowed,
|
||||
apps: full.apps.map(a => a.slug),
|
||||
})
|
||||
reply.setCookie('hnf_session', token, cookieOpts(request))
|
||||
return full
|
||||
})
|
||||
}
|
||||
103
src/sync.js
Normal file
103
src/sync.js
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { pool } from './db.js'
|
||||
import { findEmployeeByEmail, getEmployeeDepartments, getSyncConfig } from './workforce.js'
|
||||
|
||||
export async function syncAllWorkforceUsers() {
|
||||
const { rows: users } = await pool.query(
|
||||
`SELECT id, email, name, workforce_user_id FROM users WHERE workforce_user_id IS NOT NULL AND active = true`
|
||||
)
|
||||
|
||||
let deactivated = 0, emailUpdated = 0, rolesUpdated = 0
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
// Look up by email (Workforce doesn't reliably expose GET /users/:id)
|
||||
// Use stored email first; if not found try to detect via workforce_user_id match in dept lists
|
||||
const wfUser = await findEmployeeByEmail(user.email)
|
||||
|
||||
if (!wfUser || String(wfUser.id) !== user.workforce_user_id) {
|
||||
// Not found or ID mismatch — deactivate
|
||||
await pool.query('UPDATE users SET active = false WHERE id = $1', [user.id])
|
||||
deactivated++
|
||||
continue
|
||||
}
|
||||
|
||||
// Sync email if changed
|
||||
const wfEmail = wfUser.email?.toLowerCase().trim()
|
||||
if (wfEmail && wfEmail !== user.email) {
|
||||
await pool.query('UPDATE users SET email = $1 WHERE id = $2', [wfEmail, user.id])
|
||||
emailUpdated++
|
||||
}
|
||||
|
||||
// Sync department-mapped roles
|
||||
const depts = await getEmployeeDepartments(wfUser.id)
|
||||
const deptIds = depts.map(d => String(d.id))
|
||||
|
||||
// Get current dept-mapped role_ids for this user
|
||||
const { rows: currentRoles } = await pool.query(
|
||||
`SELECT ur.role_id, wdr.department_id
|
||||
FROM user_roles ur
|
||||
JOIN workforce_department_roles wdr ON wdr.role_id = ur.role_id
|
||||
WHERE ur.user_id = $1 AND ur.source = 'workforce_department'`,
|
||||
[user.id]
|
||||
)
|
||||
const currentDeptIds = currentRoles.map(r => r.department_id)
|
||||
|
||||
const added = deptIds.filter(id => !currentDeptIds.includes(id))
|
||||
const removed = currentDeptIds.filter(id => !deptIds.includes(id))
|
||||
|
||||
if (added.length || removed.length) {
|
||||
// Remove roles for departments the user is no longer in
|
||||
if (removed.length) {
|
||||
await pool.query(
|
||||
`DELETE FROM user_roles WHERE user_id = $1 AND source = 'workforce_department'
|
||||
AND role_id IN (
|
||||
SELECT role_id FROM workforce_department_roles WHERE department_id = ANY($2)
|
||||
)`,
|
||||
[user.id, removed]
|
||||
)
|
||||
}
|
||||
// Add roles for new departments
|
||||
for (const deptId of added) {
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id, source)
|
||||
SELECT $1, role_id, 'workforce_department'
|
||||
FROM workforce_department_roles WHERE department_id = $2
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[user.id, deptId]
|
||||
)
|
||||
}
|
||||
rolesUpdated++
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Workforce sync error for user ${user.id} (${user.email}):`, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up expired pending registrations older than 24h
|
||||
await pool.query(`DELETE FROM pending_registrations WHERE expires_at < NOW() - INTERVAL '24 hours'`)
|
||||
|
||||
const summary = { checked: users.length, deactivated, emailUpdated, rolesUpdated }
|
||||
console.log('Workforce sync complete:', summary)
|
||||
return summary
|
||||
}
|
||||
|
||||
export function startSyncJob() {
|
||||
getSyncConfig()
|
||||
.then(({ syncHours }) => {
|
||||
if (!syncHours || syncHours <= 0) {
|
||||
console.log('Workforce sync disabled (sync_hours = 0)')
|
||||
return
|
||||
}
|
||||
const intervalMs = syncHours * 60 * 60_000
|
||||
// First run 60s after startup to let DB settle
|
||||
setTimeout(() => {
|
||||
syncAllWorkforceUsers().catch(err => console.error('Workforce sync failed:', err.message))
|
||||
setInterval(
|
||||
() => syncAllWorkforceUsers().catch(err => console.error('Workforce sync failed:', err.message)),
|
||||
intervalMs
|
||||
)
|
||||
}, 60_000)
|
||||
console.log(`Workforce sync scheduled every ${syncHours}h`)
|
||||
})
|
||||
.catch(err => console.warn('Could not read Workforce sync config (settings not configured?):', err.message))
|
||||
}
|
||||
97
src/workforce.js
Normal file
97
src/workforce.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
const SETTINGS_URL = process.env.SETTINGS_URL || 'http://10.10.10.106:3080'
|
||||
const SETTINGS_SECRET = process.env.SETTINGS_SECRET || ''
|
||||
|
||||
// Credential + token caches
|
||||
let _credsCache = null // { creds, expires_at }
|
||||
let _tokenCache = null // { access_token, base_url, expires_at }
|
||||
|
||||
async function getWorkforceCreds() {
|
||||
if (_credsCache && Date.now() < _credsCache.expires_at) return _credsCache.creds
|
||||
const res = await fetch(`${SETTINGS_URL}/settings/api/internal/integration/workforce`, {
|
||||
headers: { Authorization: `Bearer ${SETTINGS_SECRET}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to fetch Workforce credentials from settings: ${res.status}`)
|
||||
const creds = await res.json()
|
||||
if (!creds.email || !creds.password) throw new Error('Workforce credentials not configured in settings')
|
||||
_credsCache = { creds, expires_at: Date.now() + 5 * 60_000 }
|
||||
return creds
|
||||
}
|
||||
|
||||
async function getWorkforceToken() {
|
||||
const creds = await getWorkforceCreds()
|
||||
const baseUrl = creds.base_url || 'https://my.workforce.com'
|
||||
if (_tokenCache && _tokenCache.base_url === baseUrl && Date.now() < _tokenCache.expires_at) {
|
||||
return { token: _tokenCache.access_token, baseUrl }
|
||||
}
|
||||
const res = await fetch(`${baseUrl}/api/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'password',
|
||||
username: creds.email,
|
||||
password: creds.password,
|
||||
scope: 'default',
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Workforce OAuth failed: ${res.status}`)
|
||||
const data = await res.json()
|
||||
_tokenCache = { access_token: data.access_token, base_url: baseUrl, expires_at: Date.now() + 50 * 60_000 }
|
||||
return { token: data.access_token, baseUrl }
|
||||
}
|
||||
|
||||
async function wfFetch(path) {
|
||||
const { token, baseUrl } = await getWorkforceToken()
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Workforce API error ${res.status} at ${path}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function wfFetchPaged(path) {
|
||||
const results = []
|
||||
let page = 1
|
||||
while (true) {
|
||||
const sep = path.includes('?') ? '&' : '?'
|
||||
const data = await wfFetch(`${path}${sep}page=${page}&per_page=100`)
|
||||
const items = Array.isArray(data) ? data : (data.users ?? data.departments ?? [])
|
||||
results.push(...items)
|
||||
if (items.length < 100) break
|
||||
page++
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export async function findEmployeeByEmail(email) {
|
||||
const data = await wfFetch(`/api/v2/users?email=${encodeURIComponent(email)}`)
|
||||
const users = Array.isArray(data) ? data : (data.users ?? [])
|
||||
return users.length > 0 ? users[0] : null
|
||||
}
|
||||
|
||||
export async function getAllDepartments() {
|
||||
return wfFetchPaged('/api/v2/departments')
|
||||
}
|
||||
|
||||
export async function getEmployeeDepartments(wfUserId) {
|
||||
const depts = await getAllDepartments()
|
||||
const id = String(wfUserId)
|
||||
return depts.filter(d => {
|
||||
const staff = (d.staff ?? []).map(String)
|
||||
const managers = (d.managers ?? []).map(String)
|
||||
return staff.includes(id) || managers.includes(id)
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSyncConfig() {
|
||||
const creds = await getWorkforceCreds()
|
||||
return { syncHours: parseFloat(creds.sync_hours ?? '6') }
|
||||
}
|
||||
|
||||
// Invalidate caches (e.g. after credentials updated in settings)
|
||||
export function invalidateCache() {
|
||||
_credsCache = null
|
||||
_tokenCache = null
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue