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:
parent
66b358d568
commit
81d61aa9d9
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,8 +38,61 @@
|
|||
<q-icon v-else-if="userSearch" name="close" class="cursor-pointer" @click="userSearch = ''; userResults = []; selectedUser = null" />
|
||||
</template>
|
||||
</q-input>
|
||||
<q-btn v-if="can('manage_users')" unelevated color="indigo-6" icon="person_add" label="Inviter"
|
||||
@click="openInviteDialog()" no-caps />
|
||||
</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-for="u in userResults" :key="u.pk" class="user-card"
|
||||
:class="{ 'user-card--selected': selectedUser?.pk === u.pk }"
|
||||
|
|
@ -685,6 +738,7 @@ const {
|
|||
debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup,
|
||||
selectGroup, loadGroupMembers, removeFromGroup,
|
||||
debouncedMemberSearch, addUserToCurrentGroup,
|
||||
inviteOpen, inviteSaving, inviteForm, openInviteDialog, submitInvite,
|
||||
} = useUserGroups({ permGroups })
|
||||
|
||||
const { legacySyncing, showSyncDialog, syncResult, syncLegacy } = useLegacySync({
|
||||
|
|
|
|||
|
|
@ -197,6 +197,131 @@ async function handle (req, res, method, path, url) {
|
|||
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 (!mysql) return json(res, 503, { error: 'mysql2 not installed' })
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user