feat(hub+ops): user invite flow sends temp password via Mailjet + dev .env.example

A few connected fixes around the invite UI shipped in 81d61aa:

1. **Bug in 81d61aa**: `auth.js` referenced `erpFetch` without importing
   it, so every invite returned `erpnext.ok=false` with the silent
   "erpFetch is not defined" error in the catch. Imported it from
   ./helpers alongside the other helpers we already used.

2. **Authentik recovery flow not configured** (caught while smoke-testing):
   the brand `auth.targo.ca` has `flow_recovery=None` and no SMTP, so
   `POST /core/users/{pk}/recovery_email/` returned 400 "No recovery
   flow set." Rather than build out a full Authentik recovery flow
   via API (multiple stages, brand patch, SMTP env var changes), the
   hub now generates a strong-but-readable temp password
   (`X7K2-9NQB-4GHM-3RTW` style — no look-alike chars), POSTs it via
   `/core/users/{pk}/set_password/`, and emails it via the existing
   Mailjet SMTP (already wired into lib/email.js for invoice sends).
   Returns `{temp_password, password_set, email_sent}` so the admin
   has a fallback if Mailjet drops the message.

3. **Settings dialog** now shows a credentials panel after submit:
     • Green banner "✓ Courriel envoyé" when email_sent=true
     • Yellow "⚠ transmettez manuellement" when email_sent=false
     • The temp password as a copyable field either way
     • ERPNext User creation status

4. **Dev onboarding**: added `apps/ops/.env.example`,
   `services/targo-hub/.env.example`, and a top-level `docs/SETUP.md`
   that explains the local-dev flow (clone → cp .env.example .env →
   npm install → npx quasar dev). The example envs are commented
   per-section so a new dev knows which keys correspond to which
   external integration. None of the real secrets are checked in —
   the .gitignore already covers .env files.
This commit is contained in:
louispaulb 2026-05-05 19:50:06 -04:00
parent 81d61aa9d9
commit cbeb61e04e
6 changed files with 366 additions and 26 deletions

17
apps/ops/.env.example Normal file
View File

@ -0,0 +1,17 @@
# ─────────────────────────────────────────────────────────────────────────
# apps/ops — Vite/Quasar dev environment
# Copy to `apps/ops/.env`, fill in the values, then `npx quasar dev`.
# Vite only exposes vars prefixed with VITE_ to the browser bundle.
# ─────────────────────────────────────────────────────────────────────────
# ERPNext API token used by the SPA when calling /api/resource/*.
# Format: <api_key>:<api_secret>. Mint one in ERPNext Desk under
# User → API Access. Needs roles for the doctypes the page touches
# (Customer, Sales Invoice, Dispatch Job, Service Subscription, etc.).
VITE_ERP_TOKEN=changeme_apikey:changeme_apisecret
# Public URL of the targo-hub (Node/Express). The SPA hits this for
# dispatch routes, traccar passthrough, conversations, voice agent,
# /auth/users, etc. Must be HTTPS in production.
# Local dev: http://localhost:3300
VITE_HUB_URL=https://msg.gigafibre.ca

View File

@ -175,6 +175,9 @@ export function useUserGroups ({ permGroups, loadGroupMembers: externalLoadGroup
const inviteOpen = ref(false)
const inviteSaving = ref(false)
const inviteForm = reactive({ email: '', full_name: '', groups: ['sysadmin'], roles: ['Employee'], rolesText: 'Employee' })
// Holds the result of the last successful invite so the dialog can show
// the recovery URL the admin needs to forward to the new user.
const lastInviteResult = ref(null)
function openInviteDialog (presetGroup) {
inviteForm.email = ''
@ -182,6 +185,7 @@ export function useUserGroups ({ permGroups, loadGroupMembers: externalLoadGroup
inviteForm.groups = presetGroup ? [presetGroup] : ['sysadmin']
inviteForm.roles = ['Employee']
inviteForm.rolesText = 'Employee'
lastInviteResult.value = null
inviteOpen.value = true
}
@ -204,12 +208,25 @@ export function useUserGroups ({ permGroups, loadGroupMembers: externalLoadGroup
// Optimistic insert into the visible list so the admin sees it
// immediately. A subsequent search refresh will reconcile.
userResults.value.unshift({ ...data.user, overrides: reactive({ ...(data.user.overrides || {}) }) })
const erpMsg = data.erpnext?.ok ? '' : ' (ERPNext: ' + (data.erpnext?.error || 'erreur') + ')'
// Hub generates a temp password and emails it via Mailjet.
// We surface BOTH the password (so admin can copy/relay) and
// whether the email actually went out (so admin knows if they
// need to relay it manually via Slack/SMS).
lastInviteResult.value = {
full_name: fullName,
email,
temp_password: data.temp_password || null,
email_sent: !!data.email_sent,
erpnext_ok: data.erpnext?.ok !== false,
erpnext_error: data.erpnext?.error || null,
}
Notify.create({
type: 'positive', timeout: 4500,
message: `${fullName} invité — un courriel de récupération a été envoyé à ${email}${erpMsg}`,
message: data.email_sent
? `${fullName} ajouté — courriel envoyé à ${email}`
: `${fullName} ajouté — copiez le mot de passe temporaire pour ${email}`,
})
inviteOpen.value = false
// Don't auto-close so the admin can grab the recovery URL.
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur invitation: ' + e.message, timeout: 5000 })
} finally {
@ -225,6 +242,6 @@ export function useUserGroups ({ permGroups, loadGroupMembers: externalLoadGroup
debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup,
selectGroup, loadGroupMembers, removeFromGroup,
debouncedMemberSearch, searchMembersToAdd, addUserToCurrentGroup,
inviteOpen, inviteSaving, inviteForm, openInviteDialog, submitInvite,
inviteOpen, inviteSaving, inviteForm, lastInviteResult, openInviteDialog, submitInvite,
}
}

View File

@ -54,10 +54,12 @@
<q-btn flat round dense icon="close" v-close-popup :disable="inviteSaving" />
</q-card-section>
<q-card-section class="q-pt-sm">
<!-- Form (hidden after success we replace it with the credentials panel) -->
<q-card-section v-if="!lastInviteResult" class="q-pt-sm">
<div class="text-caption text-grey-7 q-mb-md">
L'utilisateur recevra un courriel de récupération pour choisir son mot de passe,
et un compte System User ERPNext sera créé automatiquement.
Un mot de passe temporaire sera généré, envoyé par courriel,
et affiché ici en cas de problème de livraison.
L'utilisateur pourra le changer après sa première connexion.
</div>
<q-input v-model="inviteForm.full_name" label="Nom complet *" outlined dense class="q-mb-sm"
autofocus :disable="inviteSaving" />
@ -85,10 +87,60 @@
@update:model-value="v => inviteForm.roles = v.split(',').map(r => r.trim()).filter(Boolean)" />
</q-card-section>
<!-- Success panel shown once the user has been created.
Big takeaway: the recovery URL the admin must forward. -->
<q-card-section v-else class="q-pt-sm">
<div class="row items-center q-mb-md">
<q-icon name="check_circle" color="green-7" size="32px" class="q-mr-sm" />
<div>
<div class="text-weight-bold">{{ lastInviteResult.full_name }} ajouté</div>
<div class="text-caption text-grey-7">{{ lastInviteResult.email }}</div>
</div>
</div>
<!-- Credentials panel: shows the temp password + delivery status. -->
<div v-if="lastInviteResult.temp_password" class="q-pa-sm q-mb-sm"
:style="lastInviteResult.email_sent
? 'background:#ecfdf5;border-left:3px solid #10b981;border-radius:6px'
: 'background:#fef3c7;border-left:3px solid #d97706;border-radius:6px'">
<div class="text-caption text-weight-bold q-mb-xs">
<span v-if="lastInviteResult.email_sent"> Courriel envoyé à {{ lastInviteResult.email }}</span>
<span v-else> Courriel non envoyé transmettez le mot de passe manuellement</span>
</div>
<div class="text-caption text-grey-8 q-mb-sm">
Mot de passe temporaire (à changer à la première connexion) :
</div>
<q-input :model-value="lastInviteResult.temp_password" readonly outlined dense class="q-mb-sm"
input-class="text-mono">
<template #append>
<q-btn flat dense round icon="content_copy" color="indigo-6" @click="copyTempPassword">
<q-tooltip>Copier</q-tooltip>
</q-btn>
</template>
</q-input>
</div>
<div v-else class="text-caption text-negative q-mb-sm">
Mot de passe non défini vérifier les logs du hub
</div>
<div v-if="lastInviteResult.erpnext_ok" class="text-caption text-grey-7">
Compte ERPNext System User créé
</div>
<div v-else class="text-caption text-negative">
ERPNext: {{ lastInviteResult.erpnext_error }}
</div>
</q-card-section>
<q-card-actions align="right" class="q-pb-md q-pr-md">
<q-btn flat label="Annuler" v-close-popup :disable="inviteSaving" />
<q-btn unelevated color="indigo-6" label="Envoyer l'invitation" icon="send"
:loading="inviteSaving" @click="submitInvite()" />
<template v-if="!lastInviteResult">
<q-btn flat label="Annuler" v-close-popup :disable="inviteSaving" />
<q-btn unelevated color="indigo-6" label="Créer l'utilisateur" icon="person_add"
:loading="inviteSaving" @click="submitInvite()" />
</template>
<template v-else>
<q-btn flat label="Inviter un autre" @click="lastInviteResult = null; openInviteDialog()" />
<q-btn unelevated color="indigo-6" label="Terminé" v-close-popup />
</template>
</q-card-actions>
</q-card>
</q-dialog>
@ -738,9 +790,19 @@ const {
debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup,
selectGroup, loadGroupMembers, removeFromGroup,
debouncedMemberSearch, addUserToCurrentGroup,
inviteOpen, inviteSaving, inviteForm, openInviteDialog, submitInvite,
inviteOpen, inviteSaving, inviteForm, lastInviteResult, openInviteDialog, submitInvite,
} = useUserGroups({ permGroups })
async function copyTempPassword () {
if (!lastInviteResult.value?.temp_password) return
try {
await navigator.clipboard.writeText(lastInviteResult.value.temp_password)
Notify.create({ type: 'positive', message: 'Mot de passe copié', timeout: 1500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Impossible de copier: ' + e.message })
}
}
const { legacySyncing, showSyncDialog, syncResult, syncLegacy } = useLegacySync({
loadPerms, loadGroupMembers, selectedGroup,
})

98
docs/SETUP.md Normal file
View File

@ -0,0 +1,98 @@
# Dev setup — gigafibre-fsm
Quick reference for getting the stack running on a new machine.
## 1. Clone
```bash
git clone https://git.targo.ca/louis/gigafibre-fsm.git
cd gigafibre-fsm
```
## 2. Env files
The actual `.env` files are gitignored (they hold secrets). Each component
ships a `.env.example` with placeholder values + comments. Copy and fill in:
```bash
cp apps/ops/.env.example apps/ops/.env
cp services/targo-hub/.env.example services/targo-hub/.env
```
Ask the team for the real values (or copy from `/opt/<service>/.env` on the
prod box if you have access). The hub `.env` is the long one — most fields
correspond to one external integration (Stripe, Twilio, Authentik, etc.).
Anything left blank disables that feature gracefully.
## 3. Run the apps
### `apps/ops` (Vue 3 + Quasar SPA)
```bash
cd apps/ops
npm install
npx quasar dev # dev server at http://localhost:9000
npx quasar build # production bundle in dist/spa/
```
Notes:
- The SPA expects to find ERPNext at the same origin in production
(`erp.gigafibre.ca/ops/` is served from `/opt/ops-app/` via the
ERPNext nginx). In dev, set `VITE_HUB_URL` to the local hub or the
prod hub for backend calls.
- Authentik SSO redirects only work behind a real domain — dev mode
uses the API token (`VITE_ERP_TOKEN`) for direct ERPNext calls.
### `services/targo-hub` (Node 20+)
```bash
cd services/targo-hub
npm install --production
node server.js # listens on :3300
```
In production this runs in a Docker container under `/opt/targo-hub/` with
the host's `.env` file mounted.
### Other services
The `services/` and `apps/` directories also contain Docker compose stacks
that run on the prod server (ERPNext, Authentik, Traccar proxy, Fonoster,
DocuSeal, …). Reproducing them locally is rarely needed — the hub talks
to ERPNext + Authentik over the network and that's enough for most
front-end work.
## 4. Common tasks
| Task | Command |
| --- | --- |
| Build + deploy ops SPA to prod | `cd apps/ops && npx quasar build && scp -r dist/spa/* root@96.125.196.67:/opt/ops-app/` |
| Push hub code change | `scp services/targo-hub/lib/<file>.js root@96.125.196.67:/opt/targo-hub/lib/` then `ssh root@... 'docker restart targo-hub'` |
| Tail prod logs | `ssh root@96.125.196.67 'docker logs -f targo-hub --tail 50'` |
| Re-build after changing daemon.json or compose | `docker compose up -d --force-recreate` from the relevant `/opt/<service>/` |
## 5. Where things live
```
apps/ops/ Quasar SPA — main internal tool (dispatch, clients, …)
apps/ops/src/pages/ Top-level pages (DispatchPage, ClientDetailPage, …)
apps/ops/src/composables/ Shared logic (useMap, useResourceFilter, …)
apps/ops/src/components/shared/detail-sections/ Per-doctype detail panels
services/targo-hub/ Node middleware between SPA / ERPNext / 3rd parties
services/targo-hub/lib/ One module per integration (auth, dispatch, ai, …)
services/targo-hub/server.js Top-level HTTP router
docs/ This file + future runbooks
```
## 6. Auth quirks (fyi)
- **Authentik staff instance** = `auth.targo.ca` (admin token in
`AUTHENTIK_TOKEN`). ERPNext uses it as an OAuth provider.
- **Authentik client instance** = `id.gigafibre.ca` (separate stack,
for customer portal — uses `/opt/authentik-client/`).
- Inviting a user via ops Settings → Utilisateurs hits
`POST /auth/users` on the hub, which (a) creates the Authentik user,
(b) sets a temp password, (c) emails it via Mailjet, (d) creates the
matching ERPNext System User.
- The Authentik recovery email flow isn't configured (no `flow_recovery`
on the brand) — the hub sends the credentials itself instead.

View File

@ -0,0 +1,102 @@
# ─────────────────────────────────────────────────────────────────────────
# services/targo-hub — Node/Express middleware
# Copy to `services/targo-hub/.env`, fill in the values, then
# `node server.js` or `docker compose up -d` from /opt/targo-hub.
#
# Most of these are integration secrets; ask the team for the real
# values. Anything left blank disables the corresponding feature
# (e.g. no STRIPE_SECRET_KEY → Stripe billing endpoints return 503).
# ─────────────────────────────────────────────────────────────────────────
# ─── Core ────────────────────────────────────────────────────────────────
PORT=3300
HUB_PUBLIC_URL=https://msg.gigafibre.ca
HUB_INTERNAL_TOKEN= # shared secret for hub→hub & flow auth
JWT_SECRET= # 32+ chars random — signs magic links
# ─── ERPNext ─────────────────────────────────────────────────────────────
ERP_URL=http://erpnext-backend:8000
ERP_TOKEN= # api_key:api_secret with admin scope
MAIL_FROM=noreply@gigafibre.ca
# ─── Authentik (SSO) ─────────────────────────────────────────────────────
# Used by /auth/users (invite, list, group sync) on the staff instance.
AUTHENTIK_URL=https://auth.targo.ca
AUTHENTIK_TOKEN= # API token, scope: write:user write:group
# ─── Authentication adapters ─────────────────────────────────────────────
# Used by /auth/sync-legacy to pull staff from the old MySQL DB.
LEGACY_DB_HOST=
LEGACY_DB_USER=
LEGACY_DB_PASS=
LEGACY_DB_NAME=
# ─── Email (Mailjet via SMTP) ────────────────────────────────────────────
# Mailjet relay — same creds as ERPNext. Used for invites, voice-agent
# transcripts, quotation sends, etc.
SMTP_HOST=in-v3.mailjet.com
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
# ─── Twilio (SMS + voice) ────────────────────────────────────────────────
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_API_KEY=
TWILIO_API_SECRET=
TWILIO_TWIML_APP_SID=
TWILIO_FROM=+14382313838 # the Targo SMS number
# ─── Stripe ──────────────────────────────────────────────────────────────
STRIPE_SECRET_KEY= # sk_live_… or sk_test_…
STRIPE_WEBHOOK_SECRET= # whsec_… for /payments/webhook signature
# ─── Traccar (GPS) ───────────────────────────────────────────────────────
# Hub uses TRACCAR_TOKEN as Bearer; falls back to user/pass basic auth.
TRACCAR_URL=https://gps.targo.ca
TRACCAR_TOKEN=
TRACCAR_USER=
# ─── DocuSeal (e-signature) ──────────────────────────────────────────────
DOCUSEAL_URL=https://docs.gigafibre.ca
DOCUSEAL_API_KEY=
# ─── Fonoster (voice/PBX) ────────────────────────────────────────────────
FONOSTER_API_URL=https://fn.targo.ca
FONOSTER_EMAIL=
FONOSTER_PASSWORD=
FONOSTER_ALLOW_INSECURE=false
FNIDENTITY_DB_URL=postgres://... # internal Fonoster identity DB
ROUTR_DB_URL=postgres://... # internal Routr DB
# ─── 3CX PBX poller (legacy, usually disabled) ───────────────────────────
PBX_ENABLED=0
PBX_URL=
PBX_USER=
PBX_PASS=
PBX_POLL_INTERVAL=120
# ─── OLT SNMP poller ─────────────────────────────────────────────────────
# Comma-separated list of "name:ip:vendor" entries (vendor = tplink|raisecom)
OLT_LIST=
OLT_POLL_INTERVAL=300
# ─── AI / voice agent ────────────────────────────────────────────────────
# Anthropic / OpenAI / Groq — pick one. AI_API_KEY is read by lib/ai.js.
AI_API_KEY=
AI_MODEL=claude-sonnet-4-5
DEFAULT_MODEM_PASS= # default password for ONT remote admin
# ─── Oktopus (decommissioned) ────────────────────────────────────────────
# Set to 1 (default) to keep the integration disabled. Flip to 0 only
# if you re-deploy the Oktopus stack and want the hub to talk to it.
OKTOPUS_DISABLED=1
OKTOPUS_URL=
OKTOPUS_USER=
OKTOPUS_PASS=
# ─── Customer portal ─────────────────────────────────────────────────────
CLIENT_PORTAL_URL=https://targo.lovable.app
# ─── Cache redis (optional) ──────────────────────────────────────────────
CACHE_DB_URL=redis://localhost:6379

View File

@ -1,6 +1,19 @@
'use strict'
const cfg = require('./config')
const { log, json, parseBody, httpRequest } = require('./helpers')
const crypto = require('crypto')
const { log, json, parseBody, httpRequest, erpFetch } = require('./helpers')
const { sendEmail } = require('./email')
// Strong-but-readable password: 4 base32 chunks separated by dashes,
// e.g. "X7K2-9NQB-4GHM-3RTW". Avoids look-alike chars (0/O, 1/I/L) so
// the user can copy it from a Slack/SMS without guessing case.
function generateInvitePassword () {
const alphabet = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' // no 0/O/1/I/L
const out = []
const buf = crypto.randomBytes(16)
for (let i = 0; i < 16; i++) out.push(alphabet[buf[i] % alphabet.length])
return `${out.slice(0,4).join('')}-${out.slice(4,8).join('')}-${out.slice(8,12).join('')}-${out.slice(12,16).join('')}`
}
let mysql = null
try { mysql = require('mysql2/promise') } catch (e) { /* optional */ }
@ -267,19 +280,45 @@ async function handle (req, res, method, path, url) {
}
}
// 1e. Trigger a recovery email — Authentik mails the user a one-time
// link so they set their own password. Only effective if the
// `Email` stage is configured globally in the recovery flow.
let recoveryUrl = null
// 1e. Set a temporary password + email it via the hub's own SMTP.
// We tried Authentik's recovery_email/ first but the brand has
// no flow_recovery configured ("No recovery flow set"), and
// Authentik's global SMTP is also unset. Rather than build out
// a full recovery flow via API right now, we generate a
// readable temp password on our side, set it via /set_password/
// and mail it to the user via Mailjet (already wired into the
// hub for ERPNext invoice emails). The user can change it on
// first login. Password is also returned to the admin so they
// can hand it over via Slack/SMS if the email gets stuck.
const tempPassword = generateInvitePassword()
let passwordSet = false
try {
const rec = await akFetch('/core/users/' + akUser.pk + '/recovery_email/', 'POST', {})
if (rec.status >= 400) {
// Fallback: get a recovery URL we can hand back to the admin
// (e.g. to send via SMS or Slack ourselves).
const link = await akFetch('/core/users/' + akUser.pk + '/recovery/', 'POST', {})
if (link.status === 200) recoveryUrl = link.data?.link || null
}
} catch (e) { log('recovery email failed:', e.message) }
const sp = await akFetch('/core/users/' + akUser.pk + '/set_password/', 'POST', { password: tempPassword })
passwordSet = sp.status >= 200 && sp.status < 300
if (!passwordSet) log('set_password failed:', sp.status, JSON.stringify(sp.data).slice(0, 200))
} catch (e) { log('set_password threw:', e.message) }
let emailSent = false
if (passwordSet) {
try {
await sendEmail({
to: email,
subject: 'Bienvenue chez Gigafibre — votre accès',
html: `<p>Bonjour ${fullName},</p>
<p>Un compte vient d'être créé pour vous. Pour vous connecter, utilisez :</p>
<ul>
<li><b>URL ERPNext</b> : <a href="https://erp.gigafibre.ca/">https://erp.gigafibre.ca/</a></li>
<li><b>Identifiant</b> : ${email}</li>
<li><b>Mot de passe temporaire</b> : <code style="background:#f3f4f6;padding:4px 8px;border-radius:4px;font-size:1.05em">${tempPassword}</code></li>
</ul>
<p>À votre première connexion, changez votre mot de passe via votre profil.</p>
<p>Vous pouvez aussi passer par <a href="https://auth.targo.ca/">auth.targo.ca</a> pour le SSO.</p>
<hr/>
<p style="font-size:0.85em;color:#6b7280">Cet email a été envoyé automatiquement par l'application interne Gigafibre.</p>`,
})
emailSent = true
} catch (e) { log('invite email failed:', e.message) }
}
// 2. Create the matching ERPNext User. Frappe wants `roles` as a child
// table of {role: <Role Name>} rows; the Authentik OAuth2 login
@ -310,7 +349,7 @@ async function handle (req, res, method, path, url) {
}
} catch (e) { log('ERPNext User flow failed:', e.message); erpResult = { ok: false, error: e.message } }
log(`Auth: invited ${email} (groups=[${requestedGroups.join(',')}], erp=${erpResult?.ok ? 'ok' : 'failed'})`)
log(`Auth: invited ${email} (groups=[${requestedGroups.join(',')}], erp=${erpResult?.ok ? 'ok' : 'failed'}, email=${emailSent ? 'sent' : 'failed'})`)
return json(res, 200, {
ok: true,
user: {
@ -318,7 +357,12 @@ async function handle (req, res, method, path, url) {
is_active: akUser.is_active, groups: requestedGroups, overrides: {},
},
erpnext: erpResult,
recovery_url: recoveryUrl,
// The temp password is the canonical credential for this invite.
// Returned even when emailSent=true so the admin can verify or
// hand it over manually if the user can't find the email.
temp_password: passwordSet ? tempPassword : null,
password_set: passwordSet,
email_sent: emailSent,
})
}