From cdc5d1528c1279a9aa69996f9e34fdf9343ec600 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Fri, 17 Jul 2026 00:32:19 -0400 Subject: [PATCH] Unify service accounts to one kind, add manager field, make homeDirectory/loginShell editable Removes the LDAP bind-only service account type in favor of a single Unix/POSIX account model, surfaced in a new Users > Service Accounts tab. Adds a multi-valued `manager` field to every account (defaults to the creator, editable, and grants edit rights on the accounts a person manages without needing app_sso_admin). homeDirectory and loginShell are now editable from the profile edit form. Bumps to v1.1.7. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C --- CHANGELOG.md | 10 +- docs/ldap.md | 61 ++++--- nodejs/app.js | 1 - nodejs/models/service_account.js | 114 ------------- nodejs/models/user_ldap.js | 55 +++++- nodejs/package-lock.json | 4 +- nodejs/package.json | 2 +- nodejs/public/js/app.js | 45 ++++- nodejs/routes/service_account.js | 54 ------ nodejs/routes/user.js | 11 +- nodejs/views/integrations.ejs | 110 +----------- nodejs/views/profile.ejs | 25 ++- nodejs/views/user_form.ejs | 8 + nodejs/views/users.ejs | 282 ++++++++++++++++++++----------- 14 files changed, 376 insertions(+), 406 deletions(-) delete mode 100644 nodejs/models/service_account.js delete mode 100644 nodejs/routes/service_account.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 204d37f..81d94ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`. ## [Unreleased] +## [1.1.7] - 2026-07-17 + +### Changed +- **Service accounts unified to one kind.** Removed the LDAP bind-only service account type (the Integrations → LDAP "Service Accounts" card, and its `/api/service-account` routes) -- every service account is now a real Unix/POSIX account with a UID, created from the new **Users → Service Accounts** tab. Email and password are both optional for service accounts; a blank password means no `userPassword` is set at all (the account simply can't bind). +- **Added a `manager` field to every account.** Multi-valued (a list of usernames), defaults to whoever created the account (the admin who added it, or whoever sent the invite), and reassignable from the account's Edit form. Anyone listed as a manager can edit that account -- same fields an admin can (mobile, description, SSH key, date of birth, home directory, login shell, manager list) -- without needing `app_sso_admin`. +- `homeDirectory` and `loginShell` are now editable from the Edit Profile form (previously view-only). + ## [1.1.6] - 2026-07-16 ### Changed @@ -54,7 +61,8 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up - Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts. - Merged OAuth Apps + LDAP Info into a single Integrations page. -[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.6...HEAD +[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.7...HEAD +[1.1.7]: https://github.com/theta42/sso-manager-node/compare/v1.1.6...v1.1.7 [1.1.6]: https://github.com/theta42/sso-manager-node/compare/v1.1.5...v1.1.6 [1.1.5]: https://github.com/theta42/sso-manager-node/compare/v1.1.4...v1.1.5 [1.1.4]: https://github.com/theta42/sso-manager-node/compare/v1.1.3...v1.1.4 diff --git a/docs/ldap.md b/docs/ldap.md index 64bf6a1..a61a2e0 100644 --- a/docs/ldap.md +++ b/docs/ldap.md @@ -35,6 +35,15 @@ User entries are `cn=,ou=people,` and carry the objectClasses: - `sudoRole` — per-user sudo rules (`sudoCommand`, `sudoHost`, `sudoUser`). - `theta42Person` (custom auxiliary; `dateOfBirth`). +Every user (person or service account) also carries a `manager` attribute +(the standard COSINE `manager`, `SUP distinguishedName`) — one or more DNs of +the people who created/administer that account. Set automatically to the +creator's DN on signup (whoever an admin was logged in as, or whoever sent +the invite), and reassignable later from the account's Edit form. Anyone +listed as a `manager` can edit that account (same fields an admin can: +mobile, description, SSH key, date of birth, home directory, login shell, +and the manager list itself) without needing `app_sso_admin`. + Passwords are stored as `{SSHA512}` (8-byte salt, sha512(pass+salt), base64), verified by the `pw-sha2` module. The app's `hashPasswordSSHA512` is the canonical hasher; if you provision users out-of-band, hash passwords the same @@ -94,33 +103,39 @@ The entrypoint leaves existing certs untouched (idempotent). ## Service accounts -There are two different kinds of "not a real person" account, and which one -you want depends on what's consuming it: +A service account is a normal `posixAccount` for something that isn't a +person: a media manager, a torrent client, a service like Emby, or a +read-only bind account an app uses to look users up — anything that needs a +real `uidNumber`/`gidNumber` to own files, or that other accounts join via a +group for write access (e.g. a `stuff_manager` group granting write rights +to a media library). There's only one kind — every account, person or +service, is a real `posixAccount` with a UID. -**LDAP bind-only** — for an app that just needs to bind LDAP to look users up -(its own "LDAP authentication" settings page, or the read-only account -`theta42/ldap-client` binds as). Not a `posixAccount` — no `uidNumber`, no -home directory, can't log into this UI. Create one from the -**Integrations → LDAP** tab's *Service Accounts* section (create, rotate -password, delete). theta-env's bootstrap creates `cn=ldapclient` this same -way automatically, and the proxy binds as it — don't reuse the admin DN for -this. +Create one from the **Users → Service Accounts** tab's "Add new user" form +with **This is a service account** checked — it skips the birthday/ +Terms-of-Service fields a real person's account needs and asks for just an +account name. It's flagged (via membership in the `app_sso_service_account` +group) so it's listed separately from real people and excluded from "all +users" notification broadcasts. -**Unix/POSIX** — for an account something actually *runs as* on a Linux -host: a media manager, a torrent client, a service like Emby — anything that -needs a real `uidNumber`/`gidNumber` to own files or that other accounts join -via a group for write access (e.g. a `stuff_manager` group granting write -rights to a media library). Create one from the **Users** page's "Add new -user" form with **This is a service account** checked — it skips the -birthday/Terms-of-Service fields a real person's account needs and asks for -just an account name. It's a normal `posixAccount`, just flagged (via -membership in the `app_sso_service_account` group) so it's visibly marked in -the Users list and excluded from "all users" notification broadcasts. +Email and password are both optional for a service account: -Either way: don't reuse the admin DN, and give it only the group memberships -it actually needs. +- No `mail` is set unless you give it one (it never needs a mailbox). +- Leaving the password blank is fine — no `userPassword` attribute is set at + all, and an entry with no `userPassword` simply can't bind with any + password (standard LDAP simple-bind behavior). Only set a password if the + account actually needs to authenticate as itself (e.g. a bind-only account + an app uses to look users up). -Example bind test (LDAP bind-only account): +theta-env's bootstrap creates its own `cn=ldapclient` bind account directly +against LDAP (independent of this app), and the proxy binds as it — that +account won't show up in the Service Accounts tab since it isn't managed +through this app, but it keeps working unchanged. + +Either way: don't reuse the admin DN, and give a service account only the +group memberships and `manager`s it actually needs. + +Example bind test (a service account with a password set): ```bash ldapsearch -x -H ldaps://sso.example.com:636 \ diff --git a/nodejs/app.js b/nodejs/app.js index 3d20421..7af3fd4 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -82,7 +82,6 @@ app.use('/api/user', middleware.auth, require('./routes/user')); app.use('/api/token', middleware.auth, require('./routes/token')); app.use('/api/group', middleware.auth, require('./routes/group')); -app.use('/api/service-account', middleware.auth, require('./routes/service_account')); app.use('/api/notification', middleware.auth, require('./routes/notification')); app.use('/api/update-check', middleware.auth, require('./routes/update_check')); app.use('/api/tos', middleware.auth, require('./routes/tos')); diff --git a/nodejs/models/service_account.js b/nodejs/models/service_account.js deleted file mode 100644 index 19b9780..0000000 --- a/nodejs/models/service_account.js +++ /dev/null @@ -1,114 +0,0 @@ -'use strict'; - -// Non-person "service" accounts under ou=people -- bind-only LDAP identities -// for things like theta-env's bootstrap-created cn=ldapclient (the proxy's -// direct-LDAP bind account) or any other app/host that needs its own -// dedicated read-only credential, as opposed to a real user who logs into -// the web UI. -// -// Deliberately NOT posixAccount/inetOrgPerson (the User model's shape) -- -// these can't log into the SSO Manager UI or get a home directory/uidNumber. -// objectClass matches exactly what theta-env's bootstrap.js already creates -// for cn=ldapclient, so this model recognizes and manages that account too, -// not just ones created through this UI. - -const { Client, Attribute, Change } = require('ldapts'); -const crypto = require('crypto'); -const conf = require('@simpleworkjs/conf').ldap; - -function hashPasswordSSHA512(password) { - const salt = crypto.randomBytes(8); - const hash = crypto.createHash('sha512').update(password).update(salt).digest(); - return '{SSHA512}' + Buffer.concat([hash, salt]).toString('base64'); -} - -function makeClient() { - return new Client({ url: conf.url }); -} - -async function withClient(fn) { - const client = makeClient(); - try { - await client.bind(conf.bindDN, conf.bindPassword); - return await fn(client); - } finally { - await client.unbind().catch(() => {}); - } -} - -const FILTER = '(&(objectClass=organizationalRole)(objectClass=simpleSecurityObject))'; -const CN_RE = /^[A-Za-z][A-Za-z0-9._-]{1,63}$/; - -var ServiceAccount = {}; - -ServiceAccount.list = async function(){ - return withClient(async (client) => { - const res = await client.search(conf.userBase, { - scope: 'sub', - filter: FILTER, - attributes: ['cn', 'description', 'createTimestamp', 'modifyTimestamp'], - }); - return res.searchEntries.map((entry) => ({ - cn: entry.cn, - dn: `cn=${entry.cn},${conf.userBase}`, - description: entry.description || '', - created_on: entry.createTimestamp || null, - modified_on: entry.modifyTimestamp || null, - })).sort((a, b) => a.cn.localeCompare(b.cn)); - }); -}; - -ServiceAccount.create = async function({cn, description}){ - if(!cn || !CN_RE.test(cn)){ - throw Object.assign(new Error('InvalidName'), {status: 400, message: 'Name must start with a letter and contain only letters, numbers, dot, dash, underscore.'}); - } - - const dn = `cn=${cn},${conf.userBase}`; - const password = crypto.randomBytes(24).toString('base64url'); - - await withClient(async (client) => { - let existing = true; - try{ - const res = await client.search(dn, {scope: 'base', filter: '(objectClass=*)', attributes: ['dn']}); - existing = res.searchEntries.length > 0; - }catch(error){ existing = false; } - if(existing){ - throw Object.assign(new Error('NameInUse'), {status: 409, message: `"${cn}" already exists under ${conf.userBase}.`}); - } - - await client.add(dn, { - objectClass: ['organizationalRole', 'simpleSecurityObject', 'top'], - cn, - description: description || '', - userPassword: hashPasswordSSHA512(password), - }); - }); - - return {cn, dn, description: description || '', password}; -}; - -ServiceAccount.setPassword = async function(cn, password){ - const dn = `cn=${cn},${conf.userBase}`; - const newPassword = password || crypto.randomBytes(24).toString('base64url'); - - await withClient(async (client) => { - await client.modify(dn, [ - new Change({ - operation: 'replace', - modification: new Attribute({type: 'userPassword', values: [hashPasswordSSHA512(newPassword)]}), - }), - ]); - }); - - return {cn, dn, password: newPassword}; -}; - -ServiceAccount.remove = async function(cn){ - const dn = `cn=${cn},${conf.userBase}`; - await withClient(async (client) => { - await client.del(dn); - }); - return true; -}; - -module.exports = {ServiceAccount}; diff --git a/nodejs/models/user_ldap.js b/nodejs/models/user_ldap.js index 0d43baa..331a61b 100644 --- a/nodejs/models/user_ldap.js +++ b/nodejs/models/user_ldap.js @@ -103,7 +103,6 @@ async function addPosixAccount(client, data){ givenName: data.givenName, loginShell: data.loginShell, homeDirectory: data.homeDirectory, - userPassword: data.userPassword, description: data.description || ' ', sudoHost: 'ALL', sudoCommand: 'ALL', @@ -131,6 +130,19 @@ async function addPosixAccount(client, data){ entry.dateOfBirth = data.dob; } + // userPassword is optional -- a service account with no password set + // simply can't bind (no special enforcement needed, that's the default + // LDAP simple-bind behavior for an entry lacking the attribute). + if (data.userPassword) { + entry.userPassword = data.userPassword; + } + + // manager (COSINE, SUP distinguishedName) is naturally multi-valued -- + // every account gets at least the DN of whoever created it. + if (data.manager && [].concat(data.manager).length) { + entry.manager = [].concat(data.manager); + } + await client.add(`cn=${data.cn},${conf.userBase}`, entry); return data @@ -151,9 +163,13 @@ async function addLdapUser(client, data){ data.uid = `${data.givenName[0]}${data.sn}`.toLowerCase(); } data.cn = data.uid; - data.loginShell = '/bin/bash'; - data.homeDirectory= `/home/${data.uid}`; - data.userPassword = hashPasswordSSHA512(data.userPassword); + data.loginShell = data.loginShell || '/bin/bash'; + data.homeDirectory = data.homeDirectory || `/home/${data.uid}`; + if (data.userPassword) { + data.userPassword = hashPasswordSSHA512(data.userPassword); + } else { + delete data.userPassword; + } console.log('addLdapUser', data) group = await addPosixGroup(client, data); @@ -194,6 +210,11 @@ const user_parse = function(data){ data.isActive = data.pwdAccountLockedTime ? '' : 'active'; data.isInactive = data.pwdAccountLockedTime ? 'inactive' : ''; + // manager (COSINE, SUP distinguishedName) is multi-valued; ldapts returns + // a bare string for a single value and an array for multiple -- normalize + // to always be an array of DNs. + data.manager = [].concat(data.manager || []).filter(Boolean); + return data; } @@ -242,6 +263,8 @@ User.listDetail = async function(){ serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase())); }catch(error){ /* group not seeded yet on an old deployment -- treat as none */ } + const dnToUid = new Map(searchEntries.map(e => [String(e.dn).toLowerCase(), e.uid])); + const users = await Promise.all(searchEntries.map(async (entry) => { const rawPassword = entry.userPassword ? entry.userPassword.toString() : ''; const isLegacyMD5 = rawPassword.toUpperCase().startsWith('{MD5}'); @@ -269,6 +292,7 @@ User.listDetail = async function(){ ].filter(Boolean); obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : ''; obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : ''; + obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn); return obj; })); @@ -421,7 +445,7 @@ User.update = async function(data){ } } - let editableFeilds = ['mobile', 'description']; + let editableFeilds = ['mobile', 'description', 'homeDirectory', 'loginShell']; await withClient(async (client) => { for(let field of editableFeilds){ @@ -469,6 +493,21 @@ User.update = async function(data){ ]); this.dateOfBirth = data.dateOfBirth; } + + if(data.manager !== undefined){ + // Client sends uids; resolve each to a DN before writing -- + // manager (COSINE, SUP distinguishedName) stores DNs, not uids. + const uids = [].concat(data.manager || []).filter(Boolean); + const managers = await Promise.all(uids.map(uid => User.get(uid))); + const dns = managers.map(u => u.dn); + await client.modify(this.dn, [ + new Change({ + operation: 'replace', + modification: new Attribute({ type: 'manager', values: dns }), + }), + ]); + this.manager = dns; + } }); cache.clear(); @@ -537,6 +576,12 @@ User.addByInvite = async function(data){ data.mail = token.mail; + // Default manager: whoever sent the invite. + try { + const inviter = await this.get(token.created_by); + data.manager = [inviter.dn]; + } catch(e) { /* inviter no longer exists -- leave manager unset */ } + const suggestions = await this.usernameSuggestions(data.givenName, data.sn, data.dob); if (!data.uid || !suggestions.includes(data.uid)) { const err = new Error('Invalid username selection'); diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index a7145c6..a23de5b 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "t42-sso-manager", - "version": "1.1.6", + "version": "1.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t42-sso-manager", - "version": "1.1.6", + "version": "1.1.7", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", diff --git a/nodejs/package.json b/nodejs/package.json index d075470..bb9ab67 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.1.6", + "version": "1.1.7", "private": true, "author": [ { diff --git a/nodejs/public/js/app.js b/nodejs/public/js/app.js index 43d2b5d..c77cb04 100755 --- a/nodejs/public/js/app.js +++ b/nodejs/public/js/app.js @@ -102,7 +102,15 @@ app.user = (function(app){ }); } - return {list, remove, createInvite, setActive}; + // A user DN's cn is always their uid (see models/user_ldap.js addLdapUser, + // `data.cn = data.uid`) -- pulling it straight out of the DN avoids an + // extra lookup just to display a manager list. + function dnToUid(dn){ + var m = /^cn=([^,]+)/i.exec(dn || ''); + return m ? m[1] : dn; + } + + return {list, remove, createInvite, setActive, dnToUid}; })(app); @@ -149,6 +157,21 @@ app.ui = (function(app){ // Drop the cache (e.g. after a group is created) so the next selector refetches. function refreshGroups(){ _groupsPromise = null; return loadGroups(); } + // All usernames, fetched once and shared across every user selector (e.g. manager pickers). + var _usersPromise = null; + function loadUsers(){ + if(!_usersPromise){ + _usersPromise = new Promise(function(resolve){ + app.user.list(function(error, data){ + if(error || !data || !data.results){ resolve([]); return; } + resolve(data.results.map(function(u){ return u.uid; }).filter(Boolean).sort()); + }); + }); + } + return _usersPromise; + } + function refreshUsers(){ _usersPromise = null; return loadUsers(); } + // opts: { values, options, freeSolo, placeholder, name, separator } // Returns a handle: { get, set, add, clear, setOptions, element }. function tagInput(mount, opts){ @@ -249,7 +272,25 @@ app.ui = (function(app){ return handle; } - return { tagInput: tagInput, groupSelect: groupSelect, loadGroups: loadGroups, refreshGroups: refreshGroups }; + // Universal user selector (e.g. picking managers). Preloads all usernames. + function userSelect(mount, opts){ + opts = opts || {}; + var handle = tagInput(mount, { + name: opts.name || 'manager', + values: opts.values || [], + options: [], + freeSolo: opts.freeSolo !== false, + separator: opts.separator != null ? opts.separator : '\n', + placeholder: opts.placeholder || 'Type a username…', + }); + loadUsers().then(function(users){ handle.setOptions(users); }); + return handle; + } + + return { + tagInput: tagInput, groupSelect: groupSelect, loadGroups: loadGroups, refreshGroups: refreshGroups, + userSelect: userSelect, loadUsers: loadUsers, refreshUsers: refreshUsers, + }; })(app); app.oauthClient = (function(app){ diff --git a/nodejs/routes/service_account.js b/nodejs/routes/service_account.js deleted file mode 100644 index b916106..0000000 --- a/nodejs/routes/service_account.js +++ /dev/null @@ -1,54 +0,0 @@ -'use strict'; - -const router = require('express').Router(); -const {ServiceAccount} = require('../models/service_account'); -const permission = require('../utils/permission'); - -const ADMIN_GROUP = 'app_sso_admin'; - -router.get('/', async function(req, res, next) { - try { - await permission.byGroup(req.user, [ADMIN_GROUP]); - return res.json({results: await ServiceAccount.list()}); - } catch(error) { - next(error); - } -}); - -router.post('/', async function(req, res, next) { - try { - await permission.byGroup(req.user, [ADMIN_GROUP]); - const result = await ServiceAccount.create({cn: req.body.cn, description: req.body.description}); - return res.json({ - results: result, - message: `Service account "${result.cn}" created. Save the password now — it will not be shown again.`, - }); - } catch(error) { - next(error); - } -}); - -router.put('/:cn/password', async function(req, res, next) { - try { - await permission.byGroup(req.user, [ADMIN_GROUP]); - const result = await ServiceAccount.setPassword(req.params.cn, req.body.password); - return res.json({ - results: result, - message: `Password rotated for "${req.params.cn}". Save it now — it will not be shown again.`, - }); - } catch(error) { - next(error); - } -}); - -router.delete('/:cn', async function(req, res, next) { - try { - await permission.byGroup(req.user, [ADMIN_GROUP]); - await ServiceAccount.remove(req.params.cn); - return res.json({message: `Service account "${req.params.cn}" deleted.`}); - } catch(error) { - next(error); - } -}); - -module.exports = router; diff --git a/nodejs/routes/user.js b/nodejs/routes/user.js index 0bf8fbb..154836a 100755 --- a/nodejs/routes/user.js +++ b/nodejs/routes/user.js @@ -23,6 +23,7 @@ router.post('/', async function(req, res, next){ await permission.byGroup(req.user, ['app_sso_admin']) req.body.created_by = req.user.uid + req.body.manager = [req.user.dn]; const user = await User.add(req.body); const verif = await UserVerification.getOrCreate(user.uid); @@ -145,7 +146,15 @@ router.put('/:uid', async function(req, res, next){ user = req.user; }else{ user = await User.get(req.params.uid); - await permission.byGroup(req.user, ['app_sso_admin']) + const isManager = (user.manager || []).includes(req.user.dn); + if(!isManager) await permission.byGroup(req.user, ['app_sso_admin']) + } + + // The manager picker is a tag widget backed by a single newline-separated + // hidden input (see public/js/app.js app.ui.userSelect), same convention + // as oauth_client.js's allowed_groups. + if (typeof req.body.manager === 'string') { + req.body.manager = req.body.manager.split('\n').map(s => s.trim()).filter(Boolean); } return res.json({ diff --git a/nodejs/views/integrations.ejs b/nodejs/views/integrations.ejs index e850e7e..2482b02 100644 --- a/nodejs/views/integrations.ejs +++ b/nodejs/views/integrations.ejs @@ -208,40 +208,8 @@ }); } - // ── Service accounts ────────────────────────────────────────────────── - async function svcTableAJAX(){ - let data = await app.api.get('service-account'); - $.scope.serviceAccountCard.empty(); - $.each(data.results, function(_, acct){ - $.scope.serviceAccountCard.push(acct); - }); - } - - async function rotateServiceAccountPassword(cn, btn){ - const $card = $(btn).closest('.card'); - const confirmed = await app.util.actionConfirm('Rotate the password for "' + cn + '"? Anything still using the old password will stop working immediately.', $card, 'warning'); - if (!confirmed) return; - app.api.put('service-account/' + encodeURIComponent(cn) + '/password', {}, function(error, data){ - if(error){ app.util.actionMessage('Error: ' + (data && data.message), $card, 'danger'); return; } - showSecret(data.results.password, 'Password for ' + cn); - }); - } - - async function deleteServiceAccount(cn, btn){ - const $card = $(btn).closest('.card'); - $card.addClass('table-warning'); - const confirmed = await app.util.actionConfirm('Delete service account "' + cn + '"? Anything binding as it will stop working immediately.', $card, 'warning'); - $card.removeClass('table-warning'); - if (!confirmed) return; - app.api.delete('service-account/' + encodeURIComponent(cn), function(error, data){ - if(error){ app.util.actionMessage('Error: ' + (data && data.message), $card, 'danger'); return; } - $.scope.serviceAccountCard.remove('cn', cn); - }); - } - $(document).ready(function(){ tableAJAX(); - svcTableAJAX(); // Initialise the create-form tag widgets. createScopes = app.ui.tagInput('#create-scopes', { @@ -256,9 +224,6 @@ $('form[action="oauth/client/"]').attr('evalAJAX', 'showSecret(data.client_secret, "Client Secret"); tableAJAX(); $form.trigger("reset"); createScopes.set(DEFAULT_SCOPES); createGroups.clear();' ); - $('form[action="service-account/"]').attr('evalAJAX', - 'showSecret(data.password, "Password for " + data.cn); svcTableAJAX(); $form.trigger("reset");' - ); }); @@ -509,8 +474,9 @@ - A read-only bind account — create one below under - Service Accounts (don't reuse a real person's login or the admin DN). + A read-only bind account — create one from + Users > Service Accounts (don't reuse a real + person's login or the admin DN). @@ -527,9 +493,10 @@

For full host login, SSH keys, and sudo via LDAP (not just one app) — clone theta42/ldap-client - and run this on the host. Fill in a service account's password (create - one below) and, if you want this host's access/sudo groups - auto-registered, an API token from your Profile. + and run this on the host. Fill in a service account's password + (create one from Users > Service Accounts) and, + if you want this host's access/sudo groups auto-registered, an + API token from your Profile.

@@ -541,65 +508,6 @@
-
-
-
- Service Accounts -
-
-

- Bind-only LDAP identities for apps and hosts — not real people, can't log - into this UI, no home directory. theta-env's cn=ldapclient - bootstrap account (used by theta42/proxy) shows up here too, since it's - the same kind of account. -
- Need an account something actually runs as on a Linux host instead - (a media manager, a torrent client, ...) — with a real uidNumber - and a group other accounts join for write access? That's a Unix account, not - a bind-only one — create it from Users with - This is a service account checked. -

-
-
-
-
- - -
-
- - -
- -
-
-
-
- - - - - - - - - -
NameDescription
cn={{cn}},<%= userBase %>{{description}} - - -
-
-
-
-
-
-
@@ -613,8 +521,8 @@ 'export ldap_host="<%= ldapHost %>"', 'export ldap_base_dn="<%= baseDn %>"', '', - '# A read-only service account -- create one under Service Accounts', - '# above, then fill in its password below.', + '# A read-only service account -- create one under Users > Service', + '# Accounts, then fill in its password below.', 'export ldap_bind_dn="<%= exampleBindDn %>"', 'export ldap_bind_password="CHANGE-ME"', '', diff --git a/nodejs/views/profile.ejs b/nodejs/views/profile.ejs index d54f869..696cae0 100644 --- a/nodejs/views/profile.ejs +++ b/nodejs/views/profile.ejs @@ -9,6 +9,7 @@ // data.photo = unescape(encodeURIComponent(data.jpegPhoto)); user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow(); user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow(); + user.managerUids = (user.manager || []).map(app.user.dnToUid); $.scope.user.update(user); $.scope.passwordReset.update(user); @@ -42,8 +43,15 @@ $.scope.editProfile.update(user); // jq-repeat's update() is trailing-edge throttled (~50ms) as of 2.1.0 -- // wait for the throttle tick to land before sliding the updated card - // into view, or it can briefly show stale/empty data. + // into view, or it can briefly show stale/empty data. The manager + // picker is a JS widget, not a mustache-bound input, so it also has to + // wait for update() to (re-)render its empty mount div before attaching. setTimeout(function(){ + app.ui.userSelect('#edit-manager', { + name: 'manager', + values: user.managerUids || [], + placeholder: 'Type a username…', + }); $profileCard.slideUp(); $editCard.slideDown(); }, 60); @@ -161,6 +169,9 @@ LDAP DN: {{dn}}
Home Directory: {{homeDirectory}}
Login Shell: {{loginShell}}
+ Manager(s): + {{#managerUids}}{{.}}{{/managerUids}} +
Status: {{#isActive}}Active{{/isActive}} {{#isInactive}}Inactive{{/isInactive}} @@ -238,6 +249,18 @@ +
+ + +
+
+ + +
+
+ +
+
diff --git a/nodejs/views/user_form.ejs b/nodejs/views/user_form.ejs index 1b29a60..876e51c 100644 --- a/nodejs/views/user_form.ejs +++ b/nodejs/views/user_form.ejs @@ -59,6 +59,14 @@ async function fetchUsernameSuggestions() { $form.find('#personNameFields').toggle(!checked); $form.find('#serviceAccountNameField').toggle(checked); + // Service accounts aren't a person with a mailbox, and a blank + // password is fine (no userPassword attribute set -- the account + // simply can't bind). Disabling (not just hiding) keeps disabled + // fields out of both form serialization and validation. + $form.find('[name=mail]').prop('disabled', checked).closest('.mb-3').toggle(!checked); + $form.find('[name=userPassword]').prop('disabled', checked).closest('.mb-3').toggle(!checked); + $form.find('[name=passwordMatch]').prop('disabled', checked).closest('.mb-3').toggle(!checked); + if(checked){ // Filler values so the LDAP schema (inetOrgPerson requires sn) is // satisfied; not shown anywhere, the account name is what matters. diff --git a/nodejs/views/users.ejs b/nodejs/views/users.ejs index 31a7bcd..7fc5787 100755 --- a/nodejs/views/users.ejs +++ b/nodejs/views/users.ejs @@ -3,15 +3,17 @@ -