From cbeb61e04e62f3294e6f888b9955ec0869a8d419 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Tue, 5 May 2026 19:50:06 -0400 Subject: [PATCH] feat(hub+ops): user invite flow sends temp password via Mailjet + dev .env.example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/ops/.env.example | 17 ++++ apps/ops/src/composables/useUserGroups.js | 25 +++++- apps/ops/src/pages/SettingsPage.vue | 76 ++++++++++++++-- docs/SETUP.md | 98 +++++++++++++++++++++ services/targo-hub/.env.example | 102 ++++++++++++++++++++++ services/targo-hub/lib/auth.js | 74 ++++++++++++---- 6 files changed, 366 insertions(+), 26 deletions(-) create mode 100644 apps/ops/.env.example create mode 100644 docs/SETUP.md create mode 100644 services/targo-hub/.env.example diff --git a/apps/ops/.env.example b/apps/ops/.env.example new file mode 100644 index 0000000..ea12de4 --- /dev/null +++ b/apps/ops/.env.example @@ -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: :. 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 diff --git a/apps/ops/src/composables/useUserGroups.js b/apps/ops/src/composables/useUserGroups.js index 393d2d9..a63a8e7 100644 --- a/apps/ops/src/composables/useUserGroups.js +++ b/apps/ops/src/composables/useUserGroups.js @@ -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, } } diff --git a/apps/ops/src/pages/SettingsPage.vue b/apps/ops/src/pages/SettingsPage.vue index cf3a69e..69a872a 100644 --- a/apps/ops/src/pages/SettingsPage.vue +++ b/apps/ops/src/pages/SettingsPage.vue @@ -54,10 +54,12 @@ - + +
- 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.
@@ -85,10 +87,60 @@ @update:model-value="v => inviteForm.roles = v.split(',').map(r => r.trim()).filter(Boolean)" />
+ + +
+ +
+
{{ lastInviteResult.full_name }} ajouté
+
{{ lastInviteResult.email }}
+
+
+ + +
+
+ ✓ Courriel envoyé à {{ lastInviteResult.email }} + ⚠️ Courriel non envoyé — transmettez le mot de passe manuellement +
+
+ Mot de passe temporaire (à changer à la première connexion) : +
+ + + +
+
+ ✗ Mot de passe non défini — vérifier les logs du hub +
+ +
+ ✓ Compte ERPNext System User créé +
+
+ ✗ ERPNext: {{ lastInviteResult.erpnext_error }} +
+
+ - - + + @@ -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, }) diff --git a/docs/SETUP.md b/docs/SETUP.md new file mode 100644 index 0000000..edeab5d --- /dev/null +++ b/docs/SETUP.md @@ -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//.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/.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//` | + +## 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. diff --git a/services/targo-hub/.env.example b/services/targo-hub/.env.example new file mode 100644 index 0000000..9b9a286 --- /dev/null +++ b/services/targo-hub/.env.example @@ -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 diff --git a/services/targo-hub/lib/auth.js b/services/targo-hub/lib/auth.js index 2337c2e..0d68c31 100644 --- a/services/targo-hub/lib/auth.js +++ b/services/targo-hub/lib/auth.js @@ -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: `

Bonjour ${fullName},

+

Un compte vient d'être créé pour vous. Pour vous connecter, utilisez :

+ +

À votre première connexion, changez votre mot de passe via votre profil.

+

Vous pouvez aussi passer par auth.targo.ca pour le SSO.

+
+

Cet email a été envoyé automatiquement par l'application interne Gigafibre.

`, + }) + 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: } 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, }) }