feat(ops/auth): invite-user UI in Settings — creates Authentik + ERPNext + recovery email

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.
This commit is contained in:
louispaulb 2026-05-05 15:29:18 -04:00
parent 66b358d568
commit 81d61aa9d9
3 changed files with 230 additions and 0 deletions

View File

@ -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 { return {
userSearch, userResults, userSearchLoading, userSearchDone, userSearch, userResults, userSearchLoading, userSearchDone,
selectedUser, savingGroups, selectedUser, savingGroups,
@ -175,5 +225,6 @@ export function useUserGroups ({ permGroups, loadGroupMembers: externalLoadGroup
debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup, debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup,
selectGroup, loadGroupMembers, removeFromGroup, selectGroup, loadGroupMembers, removeFromGroup,
debouncedMemberSearch, searchMembersToAdd, addUserToCurrentGroup, debouncedMemberSearch, searchMembersToAdd, addUserToCurrentGroup,
inviteOpen, inviteSaving, inviteForm, openInviteDialog, submitInvite,
} }
} }

View File

@ -38,8 +38,61 @@
<q-icon v-else-if="userSearch" name="close" class="cursor-pointer" @click="userSearch = ''; userResults = []; selectedUser = null" /> <q-icon v-else-if="userSearch" name="close" class="cursor-pointer" @click="userSearch = ''; userResults = []; selectedUser = null" />
</template> </template>
</q-input> </q-input>
<q-btn v-if="can('manage_users')" unelevated color="indigo-6" icon="person_add" label="Inviter"
@click="openInviteDialog()" no-caps />
</div> </div>
<!-- Invite dialog POST /auth/users on the hub creates the
user in BOTH Authentik (with recovery email) and ERPNext
in one round-trip. Defaults to sysadmin group + Employee
role; admin can adjust before sending. -->
<q-dialog v-model="inviteOpen" persistent>
<q-card style="min-width:420px;max-width:520px">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Inviter un utilisateur</div>
<q-space />
<q-btn flat round dense icon="close" v-close-popup :disable="inviteSaving" />
</q-card-section>
<q-card-section class="q-pt-sm">
<div class="text-caption text-grey-7 q-mb-md">
L'utilisateur recevra un courriel de récupération pour choisir son mot de passe,
et un compte System User ERPNext sera créé automatiquement.
</div>
<q-input v-model="inviteForm.full_name" label="Nom complet *" outlined dense class="q-mb-sm"
autofocus :disable="inviteSaving" />
<q-input v-model="inviteForm.email" label="Email *" type="email" outlined dense class="q-mb-md"
placeholder="prenom@targointernet.com" :disable="inviteSaving" />
<div class="text-subtitle2 q-mb-xs">Groupes Authentik</div>
<div class="row q-gutter-sm q-mb-md">
<q-chip v-for="g in ['admin','sysadmin','tech','support','comptabilite','facturation','dev']"
:key="g" clickable
:color="inviteForm.groups.includes(g) ? 'indigo-6' : 'grey-3'"
:text-color="inviteForm.groups.includes(g) ? 'white' : 'grey-7'"
:icon="inviteForm.groups.includes(g) ? 'check' : 'add'"
@click="inviteForm.groups.includes(g)
? inviteForm.groups.splice(inviteForm.groups.indexOf(g), 1)
: inviteForm.groups.push(g)"
size="sm">
{{ g }}
</q-chip>
</div>
<div class="text-subtitle2 q-mb-xs">Rôles ERPNext</div>
<q-input v-model="inviteForm.rolesText" outlined dense placeholder="Employee, Sales User, …"
hint="Séparés par des virgules"
@update:model-value="v => inviteForm.roles = v.split(',').map(r => r.trim()).filter(Boolean)" />
</q-card-section>
<q-card-actions align="right" class="q-pb-md q-pr-md">
<q-btn flat label="Annuler" v-close-popup :disable="inviteSaving" />
<q-btn unelevated color="indigo-6" label="Envoyer l'invitation" icon="send"
:loading="inviteSaving" @click="submitInvite()" />
</q-card-actions>
</q-card>
</q-dialog>
<div v-if="userResults.length" class="user-list"> <div v-if="userResults.length" class="user-list">
<div v-for="u in userResults" :key="u.pk" class="user-card" <div v-for="u in userResults" :key="u.pk" class="user-card"
:class="{ 'user-card--selected': selectedUser?.pk === u.pk }" :class="{ 'user-card--selected': selectedUser?.pk === u.pk }"
@ -685,6 +738,7 @@ const {
debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup, debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup,
selectGroup, loadGroupMembers, removeFromGroup, selectGroup, loadGroupMembers, removeFromGroup,
debouncedMemberSearch, addUserToCurrentGroup, debouncedMemberSearch, addUserToCurrentGroup,
inviteOpen, inviteSaving, inviteForm, openInviteDialog, submitInvite,
} = useUserGroups({ permGroups }) } = useUserGroups({ permGroups })
const { legacySyncing, showSyncDialog, syncResult, syncLegacy } = useLegacySync({ const { legacySyncing, showSyncDialog, syncResult, syncLegacy } = useLegacySync({

View File

@ -197,6 +197,131 @@ async function handle (req, res, method, path, url) {
return json(res, 200, { ok: true, email, groups: requestedGroups }) return json(res, 200, { ok: true, email, groups: requestedGroups })
} }
// POST /auth/users — create a new user in Authentik AND ERPNext.
// Body: { email, full_name, groups? = ['sysadmin'], roles? = ['Employee'] }
// Behaviour:
// 1. Authentik: create the user (random password), assign requested
// OPS_GROUPS, then trigger a recovery-link email so the user picks
// their own password on first login.
// 2. ERPNext: create the matching User record (System User by default,
// with Authentik as the Social Login source) so OAuth2 finds them
// on first SSO login. Sends the standard ERPNext welcome email.
// Returns the merged user record consumable by the Settings UI.
if (resource === 'users' && !parts[1] && method === 'POST') {
const body = await parseBody(req)
const email = (body.email || '').trim().toLowerCase()
const fullName = (body.full_name || '').trim()
const requestedGroups = Array.isArray(body.groups) ? body.groups.filter(g => 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: <Role Name>} 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 (resource === 'sync-legacy' && method === 'POST') {
if (!mysql) return json(res, 503, { error: 'mysql2 not installed' }) if (!mysql) return json(res, 503, { error: 'mysql2 not installed' })