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:
parent
81d61aa9d9
commit
cbeb61e04e
17
apps/ops/.env.example
Normal file
17
apps/ops/.env.example
Normal 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
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
98
docs/SETUP.md
Normal 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.
|
||||
102
services/targo-hub/.env.example
Normal file
102
services/targo-hub/.env.example
Normal 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
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user