diff --git a/apps/ops/src/composables/useUserGroups.js b/apps/ops/src/composables/useUserGroups.js
index 0616b3d..393d2d9 100644
--- a/apps/ops/src/composables/useUserGroups.js
+++ b/apps/ops/src/composables/useUserGroups.js
@@ -167,6 +167,56 @@ export function useUserGroups ({ permGroups, loadGroupMembers: externalLoadGroup
}
}
+ // ── 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' })
+
+ function openInviteDialog (presetGroup) {
+ inviteForm.email = ''
+ inviteForm.full_name = ''
+ inviteForm.groups = presetGroup ? [presetGroup] : ['sysadmin']
+ inviteForm.roles = ['Employee']
+ inviteForm.rolesText = 'Employee'
+ 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 || {}) }) })
+ const erpMsg = data.erpnext?.ok ? '' : ' (ERPNext: ' + (data.erpnext?.error || 'erreur') + ')'
+ Notify.create({
+ type: 'positive', timeout: 4500,
+ message: `${fullName} invité — un courriel de récupération a été envoyé à ${email}${erpMsg}`,
+ })
+ inviteOpen.value = false
+ } catch (e) {
+ Notify.create({ type: 'negative', message: 'Erreur invitation: ' + e.message, timeout: 5000 })
+ } finally {
+ inviteSaving.value = false
+ }
+ }
+
return {
userSearch, userResults, userSearchLoading, userSearchDone,
selectedUser, savingGroups,
@@ -175,5 +225,6 @@ export function useUserGroups ({ permGroups, loadGroupMembers: externalLoadGroup
debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup,
selectGroup, loadGroupMembers, removeFromGroup,
debouncedMemberSearch, searchMembersToAdd, addUserToCurrentGroup,
+ inviteOpen, inviteSaving, inviteForm, openInviteDialog, submitInvite,
}
}
diff --git a/apps/ops/src/pages/SettingsPage.vue b/apps/ops/src/pages/SettingsPage.vue
index 98e2d76..cf3a69e 100644
--- a/apps/ops/src/pages/SettingsPage.vue
+++ b/apps/ops/src/pages/SettingsPage.vue
@@ -38,8 +38,61 @@
+
+
+
+
+
+ Inviter un utilisateur
+
+
+
+
+
+
+ L'utilisateur recevra un courriel de récupération pour choisir son mot de passe,
+ et un compte System User ERPNext sera créé automatiquement.
+
+
+
+
+ Groupes Authentik
+
+
+ {{ g }}
+
+
+
+ Rôles ERPNext
+ inviteForm.roles = v.split(',').map(r => r.trim()).filter(Boolean)" />
+
+
+
+
+
+
+
+
+
OPS_GROUPS.includes(g)) : ['sysadmin']
+ const roles = Array.isArray(body.roles) && body.roles.length ? body.roles : ['Employee']
+ if (!email || !fullName) return json(res, 400, { error: 'email + full_name required' })
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return json(res, 400, { error: 'invalid email' })
+
+ // 1a. Pre-flight: did this user already exist on either side? Don't
+ // fail outright — the dispatcher might be re-inviting somebody
+ // after a botched first try; re-send the recovery + re-create
+ // ERPNext record if missing.
+ const existing = await akFetch('/core/users/?search=' + encodeURIComponent(email) + '&page_size=5')
+ let akUser = existing.data?.results?.find(u => u.email?.toLowerCase() === email)
+
+ // 1b. Username derivation — Authentik usernames are commonly the
+ // local part of the email; collisions get a numeric suffix.
+ const baseUser = email.split('@')[0].replace(/[^a-z0-9._-]/gi, '').toLowerCase() || 'user'
+ let username = baseUser
+ if (!akUser) {
+ let suffix = 0
+ // eslint-disable-next-line no-constant-condition
+ while (true) {
+ const probe = await akFetch('/core/users/?username=' + encodeURIComponent(username))
+ if (!probe.data?.results?.length) break
+ suffix++; username = baseUser + suffix
+ if (suffix > 50) return json(res, 500, { error: 'Could not allocate Authentik username' })
+ }
+ }
+
+ // 1c. Resolve requested group names → Authentik group PKs.
+ const allGroups = await fetchGroups()
+ const groupPks = requestedGroups
+ .map(name => allGroups.find(g => g.name === name)?.pk)
+ .filter(Boolean)
+
+ // 1d. Create OR patch the Authentik user. Always end with the requested
+ // groups attached; resetting if necessary keeps the call idempotent.
+ if (!akUser) {
+ const create = await akFetch('/core/users/', 'POST', {
+ username, name: fullName, email,
+ is_active: true, groups: groupPks, attributes: {},
+ })
+ if (create.status >= 400) {
+ log('Authentik user create failed:', create.status, JSON.stringify(create.data))
+ return json(res, 502, { error: 'Authentik create failed', detail: create.data })
+ }
+ akUser = create.data
+ } else {
+ const patch = await akFetch('/core/users/' + akUser.pk + '/', 'PATCH', {
+ name: fullName, is_active: true, groups: groupPks,
+ })
+ if (patch.status !== 200) {
+ log('Authentik user patch failed:', patch.status, JSON.stringify(patch.data))
+ } else {
+ akUser = patch.data
+ }
+ }
+
+ // 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
+ 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) }
+
+ // 2. Create the matching ERPNext User. Frappe wants `roles` as a child
+ // table of {role: } rows; the Authentik OAuth2 login
+ // matches by `email` so this record is what gets logged in to.
+ let erpResult = null
+ try {
+ // Skip if already exists.
+ const probe = await erpFetch('/api/resource/User/' + encodeURIComponent(email))
+ if (probe.status === 200) {
+ erpResult = { ok: true, existing: true, name: email }
+ } else {
+ const erpBody = {
+ email, first_name: fullName.split(' ')[0],
+ last_name: fullName.split(' ').slice(1).join(' '),
+ full_name: fullName,
+ user_type: 'System User', enabled: 1,
+ send_welcome_email: 1,
+ roles: roles.map(r => ({ role: r })),
+ social_logins: [{ provider: 'authentik', userid: email }],
+ }
+ const create = await erpFetch('/api/resource/User', { method: 'POST', body: JSON.stringify(erpBody) })
+ if (create.status >= 400) {
+ log('ERPNext User create failed:', create.status, JSON.stringify(create.data).slice(0, 400))
+ erpResult = { ok: false, error: 'ERPNext create failed: ' + create.status }
+ } else {
+ erpResult = { ok: true, existing: false, name: create.data?.data?.name || email }
+ }
+ }
+ } 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'})`)
+ return json(res, 200, {
+ ok: true,
+ user: {
+ pk: akUser.pk, username: akUser.username, email: akUser.email, name: akUser.name,
+ is_active: akUser.is_active, groups: requestedGroups, overrides: {},
+ },
+ erpnext: erpResult,
+ recovery_url: recoveryUrl,
+ })
+ }
+
if (resource === 'sync-legacy' && method === 'POST') {
if (!mysql) return json(res, 503, { error: 'mysql2 not installed' })