From 81d61aa9d90af74556bdc4d61888ad363c0e44a3 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Tue, 5 May 2026 15:29:18 -0400 Subject: [PATCH] =?UTF-8?q?feat(ops/auth):=20invite-user=20UI=20in=20Setti?= =?UTF-8?q?ngs=20=E2=80=94=20creates=20Authentik=20+=20ERPNext=20+=20recov?= =?UTF-8?q?ery=20email?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces a "Inviter" button in Settings → Utilisateurs that, in one round-trip: 1. Creates the Authentik user (random password, requested OPS_GROUPS, auto username from local-part of email with collision suffix). 2. Triggers Authentik's recovery email so the user picks their own password on first login. If the Email stage isn't configured, falls back to /core/users/{pk}/recovery/ which returns a one-time URL the admin can copy + send via SMS or Slack. 3. Creates the matching ERPNext System User with the requested roles (default: Employee) and `social_logins=[{provider:authentik, userid:email}]` so OAuth2 finds them on first SSO login. send_welcome_email=1 also fires Frappe's invite mail. Idempotent on both sides: if the Authentik user already exists, we PATCH the requested groups; if the ERPNext User exists, we skip the POST and return existing=true. Lets the admin re-invite somebody after a botched first try without breaking anything. UI: • "Inviter" button next to the user search bar, gated by the `manage_users` capability (existing pattern). • q-dialog with full_name + email + chip-pickable Authentik groups (admin/sysadmin/tech/support/comptabilite/facturation/dev) + a comma-separated ERPNext roles input (defaults to Employee). • Optimistic insert into the visible list on success; the next search reconciles. --- apps/ops/src/composables/useUserGroups.js | 51 +++++++++ apps/ops/src/pages/SettingsPage.vue | 54 ++++++++++ services/targo-hub/lib/auth.js | 125 ++++++++++++++++++++++ 3 files changed, 230 insertions(+) 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
+ +
+ + + + + +
+
+
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' })