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, } }