gigafibre-fsm/apps/ops/src/composables/useUserGroups.js
louispaulb cbeb61e04e 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.
2026-05-05 19:50:06 -04:00

248 lines
9.2 KiB
JavaScript

import { ref, reactive } from 'vue'
import { Notify } from 'quasar'
import { usePermissions } from './usePermissions'
export function useUserGroups ({ permGroups, loadGroupMembers: externalLoadGroupMembers } = {}) {
const { HUB_URL } = usePermissions()
// Users tab
const userSearch = ref('')
const userResults = ref([])
const userSearchLoading = ref(false)
const userSearchDone = ref(false)
const selectedUser = ref(null)
const savingGroups = ref(false)
// Groups tab
const selectedGroup = ref(null)
const groupMembers = ref(null)
const groupMembersLoading = ref(false)
const addMemberSearch = ref('')
const memberSearchResults = ref([])
const memberSearchLoading = ref(false)
let searchTimer = null
function debouncedSearchUsers () {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => searchUsers(), 300)
}
async function searchUsers () {
const q = (userSearch.value || '').trim()
userSearchLoading.value = true
userSearchDone.value = false
try {
const url = q.length >= 1
? HUB_URL + '/auth/users?search=' + encodeURIComponent(q)
: HUB_URL + '/auth/users?page=1'
const res = await fetch(url)
const data = await res.json()
userResults.value = data.users.map(u => ({
...u,
overrides: reactive({ ...u.overrides }),
}))
userSearchDone.value = true
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
userSearchLoading.value = false
}
}
function selectUser (u) {
selectedUser.value = selectedUser.value?.pk === u.pk ? null : u
}
async function toggleUserGroup (user, groupName) {
const idx = user.groups.indexOf(groupName)
if (idx >= 0) {
user.groups.splice(idx, 1)
} else {
user.groups.push(groupName)
}
savingGroups.value = true
try {
await fetch(HUB_URL + '/auth/users/' + encodeURIComponent(user.email) + '/groups', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ groups: user.groups }),
})
Notify.create({ type: 'positive', message: `Groupes mis a jour pour ${user.name || user.email}`, timeout: 1500 })
} catch (e) {
if (idx >= 0) user.groups.push(groupName)
else user.groups.splice(user.groups.indexOf(groupName), 1)
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
savingGroups.value = false
}
}
async function selectGroup (name) {
if (selectedGroup.value === name) {
selectedGroup.value = null
groupMembers.value = null
return
}
selectedGroup.value = name
addMemberSearch.value = ''
memberSearchResults.value = []
await loadGroupMembers(name)
}
async function loadGroupMembers (name) {
groupMembersLoading.value = true
try {
const res = await fetch(HUB_URL + '/auth/users?group=' + encodeURIComponent(name))
const data = await res.json()
groupMembers.value = data.users
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
groupMembersLoading.value = false
}
}
async function removeFromGroup (member, groupName) {
const newGroups = member.groups.filter(g => g !== groupName)
try {
await fetch(HUB_URL + '/auth/users/' + encodeURIComponent(member.email) + '/groups', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ groups: newGroups }),
})
groupMembers.value = groupMembers.value.filter(m => m.pk !== member.pk)
if (permGroups) {
const g = permGroups.value.find(g => g.name === groupName)
if (g) g.num_users = Math.max(0, g.num_users - 1)
}
Notify.create({ type: 'positive', message: `${member.name || member.email} retire de ${groupName}`, timeout: 1500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
}
}
let memberTimer = null
function debouncedMemberSearch () {
clearTimeout(memberTimer)
const q = (addMemberSearch.value || '').trim()
if (q.length < 2) { memberSearchResults.value = []; return }
memberTimer = setTimeout(() => searchMembersToAdd(q), 300)
}
async function searchMembersToAdd (q) {
memberSearchLoading.value = true
try {
const res = await fetch(HUB_URL + '/auth/users?search=' + encodeURIComponent(q))
const data = await res.json()
const existingPks = new Set((groupMembers.value || []).map(m => m.pk))
memberSearchResults.value = data.users.filter(u => !existingPks.has(u.pk))
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
memberSearchLoading.value = false
}
}
async function addUserToCurrentGroup (user) {
if (!selectedGroup.value) return
if (user.groups.includes(selectedGroup.value)) return
memberSearchResults.value = []
addMemberSearch.value = ''
try {
const newGroups = [...new Set([...user.groups, selectedGroup.value])]
await fetch(HUB_URL + '/auth/users/' + encodeURIComponent(user.email) + '/groups', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ groups: newGroups }),
})
user.groups = newGroups
groupMembers.value.push(user)
if (permGroups) {
const g = permGroups.value.find(g => g.name === selectedGroup.value)
if (g) g.num_users++
}
Notify.create({ type: 'positive', message: `${user.name || user.email} ajoute a ${selectedGroup.value}`, timeout: 1500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
}
}
// ── Invite flow ───────────────────────────────────────────────────────────
// POST /auth/users on the hub creates a user in BOTH Authentik (with
// recovery email) and ERPNext (System User, mapped to the Authentik
// social login). We surface the dialog state here so the Settings page
// can drive it without re-implementing the round-trip.
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 = ''
inviteForm.full_name = ''
inviteForm.groups = presetGroup ? [presetGroup] : ['sysadmin']
inviteForm.roles = ['Employee']
inviteForm.rolesText = 'Employee'
lastInviteResult.value = null
inviteOpen.value = true
}
async function submitInvite () {
const email = inviteForm.email.trim().toLowerCase()
const fullName = inviteForm.full_name.trim()
if (!email || !fullName) {
Notify.create({ type: 'warning', message: 'Email et nom requis' })
return
}
inviteSaving.value = true
try {
const r = await fetch(HUB_URL + '/auth/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, full_name: fullName, groups: inviteForm.groups, roles: inviteForm.roles }),
})
const data = await r.json()
if (!r.ok) throw new Error(data?.error || ('HTTP ' + r.status))
// 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 || {}) }) })
// 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: data.email_sent
? `${fullName} ajouté — courriel envoyé à ${email}`
: `${fullName} ajouté — copiez le mot de passe temporaire pour ${email}`,
})
// 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 {
inviteSaving.value = false
}
}
return {
userSearch, userResults, userSearchLoading, userSearchDone,
selectedUser, savingGroups,
selectedGroup, groupMembers, groupMembersLoading,
addMemberSearch, memberSearchResults, memberSearchLoading,
debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup,
selectGroup, loadGroupMembers, removeFromGroup,
debouncedMemberSearch, searchMembersToAdd, addUserToCurrentGroup,
inviteOpen, inviteSaving, inviteForm, lastInviteResult, openInviteDialog, submitInvite,
}
}