- Remove apps/dispatch/ (100% replaced by ops dispatch module, unmaintained) - Commit services/targo-hub/lib/ (24 modules, 6290 lines — was never tracked) - Commit services/docuseal + services/legacy-db docker-compose configs - Extract client app composables: useOTP, useAddressSearch, catalog data, format utils - Refactor CartPage.vue 630→175 lines, CatalogPage.vue 375→95 lines - Clean hardcoded credentials from config.js fallback values - Add client portal: catalog, cart, checkout, OTP verification, address search - Add ops: NetworkPage, AgentFlowsPage, ConversationPanel, UnifiedCreateModal - Add ops composables: useBestTech, useConversations, usePermissions, useScanner - Add field app: scanner composable, docker/nginx configs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
139 lines
5.6 KiB
JavaScript
139 lines
5.6 KiB
JavaScript
'use strict'
|
|
const crypto = require('crypto')
|
|
const cfg = require('./config')
|
|
const { log, json, parseBody } = require('./helpers')
|
|
|
|
// ── Lightweight JWT (no dependency needed) ─────────────────────────────────
|
|
// We use HMAC-SHA256 for signing — same as jsonwebtoken but zero deps.
|
|
|
|
function base64url (buf) {
|
|
return (typeof buf === 'string' ? Buffer.from(buf) : buf)
|
|
.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
|
|
}
|
|
|
|
function base64urlDecode (str) {
|
|
str = str.replace(/-/g, '+').replace(/_/g, '/')
|
|
while (str.length % 4) str += '='
|
|
return Buffer.from(str, 'base64')
|
|
}
|
|
|
|
const JWT_HEADER = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
|
|
|
function signJwt (payload) {
|
|
const body = base64url(JSON.stringify(payload))
|
|
const data = JWT_HEADER + '.' + body
|
|
const sig = base64url(crypto.createHmac('sha256', cfg.JWT_SECRET).update(data).digest())
|
|
return data + '.' + sig
|
|
}
|
|
|
|
function verifyJwt (token) {
|
|
const parts = token.split('.')
|
|
if (parts.length !== 3) return null
|
|
const data = parts[0] + '.' + parts[1]
|
|
const sig = base64url(crypto.createHmac('sha256', cfg.JWT_SECRET).update(data).digest())
|
|
if (sig !== parts[2]) return null
|
|
try {
|
|
const payload = JSON.parse(base64urlDecode(parts[1]).toString())
|
|
if (payload.exp && Date.now() / 1000 > payload.exp) return null
|
|
return payload
|
|
} catch { return null }
|
|
}
|
|
|
|
// ── Magic Link API ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Generate a magic link token for a technician + job.
|
|
* Default TTL: 72 hours.
|
|
*/
|
|
function generateToken (techId, jobId, ttlHours = 72) {
|
|
const payload = {
|
|
sub: techId,
|
|
job: jobId,
|
|
iat: Math.floor(Date.now() / 1000),
|
|
exp: Math.floor(Date.now() / 1000) + ttlHours * 3600,
|
|
}
|
|
return signJwt(payload)
|
|
}
|
|
|
|
/**
|
|
* Generate a magic link URL pointing to the field app.
|
|
*/
|
|
function generateLink (techId, jobId, ttlHours = 72) {
|
|
const token = generateToken(techId, jobId, ttlHours)
|
|
return `${cfg.FIELD_APP_URL}#/j/${token}`
|
|
}
|
|
|
|
/**
|
|
* Generate a tech-level token (access to all their jobs).
|
|
* Used in the schedule SMS — one link per tech for the day.
|
|
*/
|
|
function generateTechToken (techId, ttlHours = 72) {
|
|
const payload = {
|
|
sub: techId,
|
|
scope: 'all',
|
|
iat: Math.floor(Date.now() / 1000),
|
|
exp: Math.floor(Date.now() / 1000) + ttlHours * 3600,
|
|
}
|
|
return signJwt(payload)
|
|
}
|
|
|
|
function generateTechLink (techId, ttlHours = 72) {
|
|
const token = generateTechToken(techId, ttlHours)
|
|
return `${cfg.FIELD_APP_URL}#/j/${token}`
|
|
}
|
|
|
|
// ── HTTP handler ───────────────────────────────────────────────────────────
|
|
|
|
async function handle (req, res, method, path) {
|
|
// POST /magic-link/generate — create a magic link
|
|
if (path === '/magic-link/generate' && method === 'POST') {
|
|
const body = await parseBody(req)
|
|
const { tech_id, job_id, ttl_hours } = body
|
|
if (!tech_id) return json(res, 400, { error: 'tech_id required' })
|
|
if (job_id) {
|
|
const link = generateLink(tech_id, job_id, ttl_hours || 72)
|
|
log(`Magic link generated: tech=${tech_id} job=${job_id}`)
|
|
return json(res, 200, { ok: true, link, token: generateToken(tech_id, job_id, ttl_hours || 72) })
|
|
} else {
|
|
const link = generateTechLink(tech_id, ttl_hours || 72)
|
|
log(`Magic link generated (all jobs): tech=${tech_id}`)
|
|
return json(res, 200, { ok: true, link, token: generateTechToken(tech_id, ttl_hours || 72) })
|
|
}
|
|
}
|
|
|
|
// GET /magic-link/verify?token=xxx — validate a token
|
|
if (path === '/magic-link/verify' && method === 'GET') {
|
|
const url = new URL(req.url, `http://localhost:${cfg.PORT}`)
|
|
const token = url.searchParams.get('token')
|
|
if (!token) return json(res, 400, { error: 'token required' })
|
|
const payload = verifyJwt(token)
|
|
if (!payload) return json(res, 401, { error: 'invalid_or_expired', message: 'Ce lien a expiré. Un nouveau lien vous sera envoyé par SMS.' })
|
|
log(`Magic link verified: tech=${payload.sub} job=${payload.job || 'all'}`)
|
|
return json(res, 200, { ok: true, tech_id: payload.sub, job_id: payload.job || null, scope: payload.scope || 'job', exp: payload.exp })
|
|
}
|
|
|
|
// POST /magic-link/refresh — generate + send a new link via SMS
|
|
if (path === '/magic-link/refresh' && method === 'POST') {
|
|
const body = await parseBody(req)
|
|
const { tech_id } = body
|
|
if (!tech_id) return json(res, 400, { error: 'tech_id required' })
|
|
// Lookup tech phone from ERPNext
|
|
const { erpFetch } = require('./helpers')
|
|
const techRes = await erpFetch(`/api/resource/Dispatch%20Technician/${encodeURIComponent(tech_id)}`)
|
|
if (techRes.status !== 200) return json(res, 404, { error: 'Tech not found' })
|
|
const tech = techRes.data.data
|
|
if (!tech.phone) return json(res, 400, { error: 'No phone number for this technician' })
|
|
const link = generateTechLink(tech_id, 72)
|
|
const msg = `Voici votre nouveau lien pour accéder à vos tâches:\n${link}`
|
|
const { sendSmsInternal } = require('./twilio')
|
|
const sid = await sendSmsInternal(tech.phone, msg)
|
|
if (!sid) return json(res, 502, { error: 'SMS send failed' })
|
|
log(`Magic link refreshed + SMS sent: tech=${tech_id} phone=${tech.phone}`)
|
|
return json(res, 200, { ok: true, sent_to: tech.phone })
|
|
}
|
|
|
|
return json(res, 404, { error: 'Magic link endpoint not found' })
|
|
}
|
|
|
|
module.exports = { handle, generateToken, generateLink, generateTechToken, generateTechLink, verifyJwt, signJwt }
|