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.
248 lines
9.2 KiB
JavaScript
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,
|
|
}
|
|
}
|