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 {
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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' })
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user