Compare commits

...

8 Commits

Author SHA1 Message Date
wmantly 51784f2f27 Merge pull request #80 from theta42/help-icon-relocate
Move help links from the global header onto each relevant card
2026-07-17 19:56:29 -04:00
wmantly 4c59b1fabb Move help links from the global header onto each relevant card
The single header-wide help icon (added last release) pointed at a
per-page doc guess, but a page can have several cards covering different
topics (e.g. Integrations has both OAuth and LDAP cards). Removed it and
added a small help icon directly to each card that has real corresponding
doc content, linking straight to that doc -- Invite User/Add new
user/User List/Service Accounts (users.ejs), group cards (groups.ejs),
OAuth Apps + LDAP connection cards (integrations.ejs), My groups/Members
of <uid>'s group/New API Token (profile.ejs).

Bumps to v1.1.11.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 19:54:19 -04:00
wmantly 099638057e Merge pull request #79 from theta42/docs-help-search
Add header help icon and in-app docs search
2026-07-17 19:24:33 -04:00
wmantly 077c41844d Add header help icon and in-app docs search
- A ? icon in the top-right header deep-links to the doc most relevant to
  the current page (client-side path mapping, same pattern already used
  for top-nav active-link highlighting -- no server-side "current section"
  local exists to key off of instead). Falls back to the docs index.
- GET /docs/search does a plain line-substring search over the existing
  allowlisted doc set. No new dependency, stays usable with no internet
  access.

Bumps to v1.1.10.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 19:22:27 -04:00
wmantly 96adf60cf7 Merge pull request #78 from theta42/personal-group-members
Add personal Unix group member management
2026-07-17 11:31:11 -04:00
wmantly 82f703f560 Add personal Unix group member management
Every account gets a personal posixGroup at creation (its primary GID
holder) but there was no way to manage its memberUid list -- add
add/remove endpoints and a profile-page UI (admin-only), reusing the
userSelect widget already built for the manager field.

Bumps to v1.1.9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 11:28:41 -04:00
wmantly 65b107d8ff Merge pull request #77 from theta42/fix-account-editing-bugs
Fix account-editing bugs; add editable group membership from profile
2026-07-17 11:03:05 -04:00
wmantly 5d7c0bd594 Fix account-editing bugs from real-world feedback, add editable group membership
- Edit form's Mobile Phone field was effectively required (stray validate
  attribute) -- removed.
- Service account profiles always showed the literal filler name "Service
  Account" -- hidden now, since it's not meaningful. Required computing
  isServiceAccount in User.get(), not just listDetail().
- Fresh service accounts could look uncategorized (missing from the
  Service Accounts tab, wrong isServiceAccount) for up to 5 minutes after
  creation, due to a cache-staleness race in the create route -- the user
  gets cached via User.get() before the route marks it as a service
  account. Cleared and re-fetched after marking.
- memberOf came back as a bare string instead of a one-element array for
  users in exactly one group, causing client-side permission checks to
  iterate character-by-character and incorrectly deny access -- normalized
  alongside the existing manager normalization.
- Added editable group membership on the profile page ("My groups"),
  admin-only, using the existing per-group member endpoints.

Bumps to v1.1.8.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 11:00:50 -04:00
12 changed files with 367 additions and 14 deletions
+32 -1
View File
@@ -6,6 +6,33 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased] ## [Unreleased]
## [1.1.11] - 2026-07-17
### Changed
- Moved the help (❓) link out of the global header and onto each relevant card individually (Invite User, Add new user, User List, Service Accounts, group cards, OAuth/LDAP integration cards, My groups, Members of `<uid>`'s group, New API Token) — each now deep-links straight to the doc that actually covers it, instead of one generic header icon.
Bumps to v1.1.11.
### Added
- A help icon (❓) in the top-right header now deep-links to the doc most relevant to the current page (falls back to the docs index elsewhere).
- The in-app docs viewer (`/docs`) is now searchable — a simple line-substring search over the same local doc set, no new dependency, still works with no internet access.
## [1.1.9] - 2026-07-17
### Added
- Every account's personal Unix group (its primary GID holder) can now have supplementary members managed from the account's profile page ("Members of `<uid>`'s group", admin-only) — e.g. to share write access to files owned by that group. Uses the standard `memberUid` attribute (RFC 2307 `posixGroup`).
## [1.1.8] - 2026-07-17
### Added
- Group membership is now editable directly from a user's profile page ("My groups" -- add via a group-name picker, remove with a button per row), instead of only from each group's own card on the Groups page. Admin-only, using the existing per-group member add/remove endpoints.
### Fixed
- The Edit Profile form's Mobile Phone field had a stray `validate=":9"` making it effectively required (submission was blocked with "Please fix the form errors" if left blank) -- it was always meant to be optional, matching the "Add user" form. Removed.
- A service account's profile always showed `Name: Service Account` -- every service account has the same literal filler given/last name (a schema-satisfying placeholder, not meant to be shown), making them indistinguishable by name. The Name line is now hidden for service accounts.
- The Users page's Service Accounts tab, and a freshly-created service account's own profile, could appear empty/not-a-service-account for up to 5 minutes right after creation. Creating a user caches it via `User.get()` *before* the route handler marks it as a service account (group membership), so the cached copy had `isServiceAccount` stuck wrong until the cache TTL expired. Now cleared and re-fetched immediately after marking.
- A user belonging to exactly one LDAP group had their `memberOf` attribute returned as a bare string instead of a one-element array (ldapts's normal behavior for single-valued attributes) -- client-side permission checks (`for(let group of user.memberOf)`) would then iterate the DN character-by-character instead of once, causing pages gated on that group (e.g. Groups) to incorrectly show "You do not have permission to be here." Normalized `memberOf` to always be an array, same fix already applied to `manager`.
## [1.1.7] - 2026-07-17 ## [1.1.7] - 2026-07-17
### Changed ### Changed
@@ -61,7 +88,11 @@ 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. - Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts.
- Merged OAuth Apps + LDAP Info into a single Integrations page. - Merged OAuth Apps + LDAP Info into a single Integrations page.
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.7...HEAD [Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.11...HEAD
[1.1.11]: https://github.com/theta42/sso-manager-node/compare/v1.1.10...v1.1.11
[1.1.10]: https://github.com/theta42/sso-manager-node/compare/v1.1.9...v1.1.10
[1.1.9]: https://github.com/theta42/sso-manager-node/compare/v1.1.8...v1.1.9
[1.1.8]: https://github.com/theta42/sso-manager-node/compare/v1.1.7...v1.1.8
[1.1.7]: https://github.com/theta42/sso-manager-node/compare/v1.1.6...v1.1.7 [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.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.5]: https://github.com/theta42/sso-manager-node/compare/v1.1.4...v1.1.5
+13
View File
@@ -57,6 +57,19 @@ membership (`memberOf` on the user); `refint` keeps it consistent on
add/remove. **Admin permission checks read the group's `member` list**, not add/remove. **Admin permission checks read the group's `member` list**, not
`memberOf` on the user. `memberOf` on the user.
### Personal groups
Every user (person or service account) also gets a **personal Unix group**
at creation — `cn=<uid>,ou=groups,<base>`, `objectClass: posixGroup` (RFC
2307), holding just `cn` and `gidNumber` (the user's primary GID). This is a
different schema than the `groupOfNames` groups above — its membership
attribute is `memberUid` (a bare username, not a DN), and unlike
`groupOfNames` it's valid with zero members. It's excluded from the
`/groups` page (which filters on `objectClass=groupOfNames`) and managed
instead from the owning user's own profile page ("Members of `<uid>`'s
group", admin-only) — add other accounts as supplementary members, e.g. to
share write access to files owned by this group.
The SSO requires three groups (seeded automatically by the entrypoint / The SSO requires three groups (seeded automatically by the entrypoint /
`install.sh`): `install.sh`):
+61 -4
View File
@@ -210,10 +210,13 @@ const user_parse = function(data){
data.isActive = data.pwdAccountLockedTime ? '' : 'active'; data.isActive = data.pwdAccountLockedTime ? '' : 'active';
data.isInactive = data.pwdAccountLockedTime ? 'inactive' : ''; data.isInactive = data.pwdAccountLockedTime ? 'inactive' : '';
// manager (COSINE, SUP distinguishedName) is multi-valued; ldapts returns // manager (COSINE, SUP distinguishedName) and memberOf (from the memberof
// a bare string for a single value and an array for multiple -- normalize // overlay) are both multi-valued; ldapts returns a bare string for a
// to always be an array of DNs. // single value and an array for multiple -- normalize both to always be
data.manager = [].concat(data.manager || []).filter(Boolean); // an array, or app-base.js's `for(let group of user.memberOf)` silently
// iterates a single DN string character-by-character instead of once.
data.manager = [].concat(data.manager || []).filter(Boolean);
data.memberOf = [].concat(data.memberOf || []).filter(Boolean);
return data; return data;
} }
@@ -348,6 +351,13 @@ User.get = async function(data, key) {
const verif = await UserVerification.getOrCreate(obj.uid); const verif = await UserVerification.getOrCreate(obj.uid);
// Same membership check as User.listDetail() -- see the comment there.
try{
const svcGroup = await Group.get('app_sso_service_account');
const serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase()));
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
}catch(error){ obj.isServiceAccount = ''; }
// Auto-flag legacy MD5 password users — persist so subsequent cache hits see it // Auto-flag legacy MD5 password users — persist so subsequent cache hits see it
if (isLegacyMD5 && !verif.password_must_change) { if (isLegacyMD5 && !verif.password_must_change) {
await verif.update({ password_must_change: true }); await verif.update({ password_must_change: true });
@@ -784,6 +794,53 @@ User.addSSHkey = async function(data) {
return result; return result;
}; };
// Every user gets a personal Unix group of the same name at creation (see
// addPosixGroup) -- just a GID holder, cn always equal to the user's uid.
// memberUid (RFC 2307, posixGroup) is a bare username, not a DN, unlike
// groupOfNames' `member` used by app_sso_* groups in group_ldap.js.
function personalGroupDN(uid){
return `cn=${uid},${conf.groupBase}`;
}
User.getPersonalGroupMembers = async function(uid) {
try {
return await withClient(async (client) => {
const res = await client.search(personalGroupDN(uid), {
scope: 'base',
filter: '(objectClass=posixGroup)',
attributes: ['memberUid'],
});
const entry = res.searchEntries[0];
return [].concat((entry && entry.memberUid) || []).filter(Boolean);
});
} catch(error) {
throw error;
}
};
User.addPersonalGroupMember = async function(uid, memberUid) {
await this.get(memberUid); // throws UserNotFound if the target uid doesn't exist
await withClient(async (client) => {
await client.modify(personalGroupDN(uid), [
new Change({
operation: 'add',
modification: new Attribute({ type: 'memberUid', values: [memberUid] }),
}),
]);
});
};
User.removePersonalGroupMember = async function(uid, memberUid) {
await withClient(async (client) => {
await client.modify(personalGroupDN(uid), [
new Change({
operation: 'delete',
modification: new Attribute({ type: 'memberUid', values: [memberUid] }),
}),
]);
});
};
User.invite = async function(data = {}){ User.invite = async function(data = {}){
try{ try{
let token = await InviteToken.create({ let token = await InviteToken.create({
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.1.7", "version": "1.1.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.1.7", "version": "1.1.11",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.1.7", "version": "1.1.11",
"private": true, "private": true,
"author": [ "author": [
{ {
+24
View File
@@ -52,6 +52,30 @@ router.get('/', function(req, res) {
res.render('docs_index', {...values, docs: docList}); res.render('docs_index', {...values, docs: docList});
}); });
// Plain, dependency-free line-substring search over the same allowlisted
// doc set -- no separate index to build/maintain, no new dependency, and it
// keeps working with no internet access (same reasoning as the rest of this
// route). Must be registered before the /:slug catch-all below, or "search"
// would be treated as a (nonexistent) doc slug and 404.
router.get('/search', function(req, res) {
const q = (req.query.q || '').trim();
if (!q) return res.json({results: []});
const qLower = q.toLowerCase();
const results = [];
for (const [slug, doc] of Object.entries(DOCS)) {
try {
const content = fs.readFileSync(doc.file, 'utf8');
const matchLine = content.split('\n').find(line => line.toLowerCase().includes(qLower));
if (matchLine) {
results.push({slug, title: doc.title, snippet: matchLine.trim().slice(0, 200)});
}
} catch (error) { /* unreadable doc file -- skip it */ }
}
res.json({results});
});
router.get('/:slug', function(req, res, next) { router.get('/:slug', function(req, res, next) {
const doc = DOCS[req.params.slug]; const doc = DOCS[req.params.slug];
if (!doc) return next({status: 404, message: 'Doc not found'}); if (!doc) return next({status: 404, message: 'Doc not found'});
+42 -1
View File
@@ -25,7 +25,7 @@ router.post('/', async function(req, res, next){
req.body.created_by = req.user.uid req.body.created_by = req.user.uid
req.body.manager = [req.user.dn]; req.body.manager = [req.user.dn];
const user = await User.add(req.body); let user = await User.add(req.body);
const verif = await UserVerification.getOrCreate(user.uid); const verif = await UserVerification.getOrCreate(user.uid);
const updates = { password_must_change: true }; const updates = { password_must_change: true };
if (req.body.tosAgree) updates.tos_accepted = true, updates.tos_accepted_at = Date.now(); if (req.body.tosAgree) updates.tos_accepted = true, updates.tos_accepted_at = Date.now();
@@ -38,6 +38,12 @@ router.post('/', async function(req, res, next){
try { try {
const group = await Group.get('app_sso_service_account'); const group = await Group.get('app_sso_service_account');
await group.addMember(user); await group.addMember(user);
// User.add() already cached `user` (via its own internal
// User.get()) before this group membership existed, so the
// cached isServiceAccount would be stuck wrong for 5 minutes
// (the cache TTL) without this -- re-fetch after clearing.
User.clearCache();
user = await User.get(user.uid);
} catch (error) { } catch (error) {
console.error(`user.add: failed to mark ${user.uid} as a service account:`, error.message); console.error(`user.add: failed to mark ${user.uid} as a service account:`, error.message);
} }
@@ -138,6 +144,41 @@ router.put('/:uid/active', async function(req, res, next){
} }
}); });
router.get('/:uid/group-members', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
return res.json({results: await User.getPersonalGroupMembers(req.params.uid)});
}catch(error){
next(error);
}
});
router.put('/:uid/group-member/:memberUid', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
await User.addPersonalGroupMember(req.params.uid, req.params.memberUid);
return res.json({
results: true,
message: `Added ${req.params.memberUid} to ${req.params.uid}'s group`
});
}catch(error){
next(error);
}
});
router.delete('/:uid/group-member/:memberUid', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
await User.removePersonalGroupMember(req.params.uid, req.params.memberUid);
return res.json({
results: true,
message: `Removed ${req.params.memberUid} from ${req.params.uid}'s group`
});
}catch(error){
next(error);
}
});
router.put('/:uid', async function(req, res, next){ router.put('/:uid', async function(req, res, next){
try{ try{
let user; let user;
+42 -1
View File
@@ -10,7 +10,12 @@
A local copy of this project's documentation, readable from the A local copy of this project's documentation, readable from the
running app -- no internet access required. running app -- no internet access required.
</p> </p>
<ul class="list-group"> <div class="input-group mb-3">
<span class="input-group-text"><i class="fa-solid fa-magnifying-glass"></i></span>
<input type="search" id="docs-search-input" class="form-control" placeholder="Search the docs…" oninput="docsSearch(this.value)">
</div>
<div id="docs-search-results" style="display:none"></div>
<ul id="docs-list" class="list-group">
<% docs.forEach(function(doc){ %> <% docs.forEach(function(doc){ %>
<li class="list-group-item"> <li class="list-group-item">
<a href="/docs/<%= doc.slug %>"><%= doc.title %></a> <a href="/docs/<%= doc.slug %>"><%= doc.title %></a>
@@ -21,4 +26,40 @@
</div> </div>
</div> </div>
</div> </div>
<script type="text/javascript">
var docsSearchTimer;
function docsSearch(q){
clearTimeout(docsSearchTimer);
docsSearchTimer = setTimeout(function(){ docsSearchRun(q); }, 200);
}
function docsSearchRun(q){
q = (q || '').trim();
var $results = $('#docs-search-results');
var $list = $('#docs-list');
if(!q){
$results.hide().empty();
$list.show();
return;
}
// Not app.api.get() -- routes/docs.js is mounted at /docs directly,
// not under /api, unlike the rest of this app's endpoints.
$.getJSON('/docs/search', {q: q}, function(data){
$list.hide();
$results.empty().show();
var hits = (data && data.results) || [];
if(!hits.length){
$results.append($('<p class="text-muted"></p>').text('No results for "' + q + '".'));
return;
}
var $ul = $('<ul class="list-group"></ul>');
hits.forEach(function(hit){
var $li = $('<li class="list-group-item"></li>');
$('<a></a>').attr('href', '/docs/' + hit.slug).text(hit.title).appendTo($li);
$('<div class="text-muted small"></div>').text(hit.snippet).appendTo($li);
$ul.append($li);
});
$results.append($ul);
});
}
</script>
<%- include('bottom') %> <%- include('bottom') %>
+2
View File
@@ -142,6 +142,7 @@
<div class="card-header"> <div class="card-header">
<i class="fa-solid fa-object-group"></i> <i class="fa-solid fa-object-group"></i>
Add new group Add new group
<a href="/docs/ldap" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div> </div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<div class="card-body"> <div class="card-body">
@@ -167,6 +168,7 @@
<h5> <h5>
<i class="fa-solid fa-arrows-down-to-people"></i> <i class="fa-solid fa-arrows-down-to-people"></i>
Group: {{ cn }} Group: {{ cn }}
<a href="/docs/ldap" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</h5> </h5>
<ul class="nav nav-tabs card-header-tabs" id="myTab" role="tablist"> <ul class="nav nav-tabs card-header-tabs" id="myTab" role="tablist">
<li class="nav-item"> <li class="nav-item">
+4
View File
@@ -250,6 +250,7 @@
<div class="card-header bg-info bg-opacity-10"> <div class="card-header bg-info bg-opacity-10">
<i class="fa-solid fa-circle-info"></i> <i class="fa-solid fa-circle-info"></i>
OpenID Connect Endpoints OpenID Connect Endpoints
<a href="/docs/oauth" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="mb-2 text-muted small"> <p class="mb-2 text-muted small">
@@ -276,6 +277,7 @@
<div class="card-header"> <div class="card-header">
<i class="fa-solid fa-plus"></i> <i class="fa-solid fa-plus"></i>
Register OAuth Client Register OAuth Client
<a href="/docs/oauth" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div> </div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<div class="card-body"> <div class="card-body">
@@ -411,6 +413,7 @@
<div class="card shadow-lg"> <div class="card shadow-lg">
<div class="card-header shadow"> <div class="card-header shadow">
<i class="fa-solid fa-circle-info"></i> Connection details <i class="fa-solid fa-circle-info"></i> Connection details
<a href="/docs/ldap" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="text-muted small"> <p class="text-muted small">
@@ -488,6 +491,7 @@
<div class="card shadow-lg"> <div class="card shadow-lg">
<div class="card-header shadow"> <div class="card-header shadow">
<i class="fa-solid fa-terminal"></i> Set up a Linux host (ldap-client) <i class="fa-solid fa-terminal"></i> Set up a Linux host (ldap-client)
<a href="/docs/ldap" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="text-muted small"> <p class="text-muted small">
+140 -4
View File
@@ -18,12 +18,77 @@
async function renderUserGroups(user){ async function renderUserGroups(user){
try{ try{
let res = await app.api.get('group/?detail=true&member='+user.uid); let res = await app.api.get('group/?detail=true&member='+user.uid);
$.scope.mygroups.empty();
$.scope.mygroups.push(...res.results); $.scope.mygroups.push(...res.results);
}catch(error){ }catch(error){
console.error('renderUserGroups error:', error) console.error('renderUserGroups error:', error)
} }
} }
async function removeFromGroup(cn, btn){
const $row = $(btn).closest('tr');
const confirmed = await app.util.actionConfirm(`Remove ${currentUser.uid} from "${cn}"?`, $row, 'warning');
if (!confirmed) return;
app.api.delete('group/' + encodeURIComponent(cn) + '/' + encodeURIComponent(currentUser.uid), function(error, data){
if(error){ app.util.actionMessage((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; }
$.scope.mygroups.remove('cn', cn);
});
}
var addGroupSelect;
async function addToGroups(btn){
const cns = addGroupSelect.get();
if(!cns.length) return;
const $card = $(btn).closest('.card-body');
for(const cn of cns){
await new Promise(function(resolve){
app.api.put('group/' + encodeURIComponent(cn) + '/' + encodeURIComponent(currentUser.uid), {}, function(error, data){
if(error) app.util.actionMessage((data && data.message) || `Failed to add to "${cn}"`, $card, 'danger');
resolve();
});
});
}
addGroupSelect.clear();
renderUserGroups(currentUser);
}
async function renderPersonalGroupMembers(user){
try{
let res = await app.api.get('user/' + user.uid + '/group-members');
$.scope.personalGroupMembers.empty();
$.scope.personalGroupMembers.push(...(res.results || []).map(uid => ({uid})));
}catch(error){
console.error('renderPersonalGroupMembers error:', error)
}
}
async function removePersonalGroupMember(memberUid, btn){
const $row = $(btn).closest('tr');
const confirmed = await app.util.actionConfirm(`Remove ${memberUid} from ${currentUser.uid}'s group?`, $row, 'warning');
if (!confirmed) return;
app.api.delete('user/' + encodeURIComponent(currentUser.uid) + '/group-member/' + encodeURIComponent(memberUid), function(error, data){
if(error){ app.util.actionMessage((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; }
$.scope.personalGroupMembers.remove('uid', memberUid);
});
}
var addPersonalGroupMemberSelect;
async function addPersonalGroupMembers(btn){
const uids = addPersonalGroupMemberSelect.get();
if(!uids.length) return;
const $card = $(btn).closest('.card-body');
for(const uid of uids){
await new Promise(function(resolve){
app.api.put('user/' + encodeURIComponent(currentUser.uid) + '/group-member/' + encodeURIComponent(uid), {}, function(error, data){
if(error) app.util.actionMessage((data && data.message) || `Failed to add "${uid}"`, $card, 'danger');
resolve();
});
});
}
addPersonalGroupMemberSelect.clear();
renderPersonalGroupMembers(currentUser);
}
async function determinUser(){ async function determinUser(){
if(location.pathname.includes('/users/')){ if(location.pathname.includes('/users/')){
let uid = location.pathname.replace('/users/', ''); let uid = location.pathname.replace('/users/', '');
@@ -94,6 +159,15 @@
renderProfile(currentUser); renderProfile(currentUser);
renderUserGroups(currentUser); renderUserGroups(currentUser);
renderPersonalGroupMembers(currentUser);
$('#personal-group-uid-label').text(currentUser.uid);
addGroupSelect = app.ui.groupSelect('#add-group-select', {
name: 'groups', values: [], placeholder: 'Type a group name…',
});
addPersonalGroupMemberSelect = app.ui.userSelect('#add-personal-group-member-select', {
name: 'members', values: [], placeholder: 'Type a username…',
});
// API Tokens are self-service only — never shown when an admin is // API Tokens are self-service only — never shown when an admin is
// viewing someone else's profile via /users/:uid. // viewing someone else's profile via /users/:uid.
@@ -159,7 +233,7 @@
<div class="profile-body" jq-repeat="user"> <div class="profile-body" jq-repeat="user">
<div class="card-body profile-body-{{uid}}"> <div class="card-body profile-body-{{uid}}">
<h2><i>User Name:</i> <b>{{uid}}</b></h2> <h2><i>User Name:</i> <b>{{uid}}</b></h2>
<i>Name:</i> <b>{{givenName}} {{sn}}</b><br /> {{^isServiceAccount}}<i>Name:</i> <b>{{givenName}} {{sn}}</b><br />{{/isServiceAccount}}
<i>Email:</i> <b>{{mail}}</b> <i>Email:</i> <b>{{mail}}</b>
{{#emailVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/emailVerified}} {{#emailVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/emailVerified}}
<br /> <br />
@@ -247,7 +321,7 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Mobile Phone</label> <label class="form-label">Mobile Phone</label>
<input type="text" class="form-control" name="mobile" placeholder="9175551234" validate=":9" value="{{mobile}}" /> <input type="text" class="form-control" name="mobile" placeholder="9175551234" value="{{mobile}}" />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Home Directory</label> <label class="form-label">Home Directory</label>
@@ -277,12 +351,13 @@
<i class="fa-solid fa-users-viewfinder"></i> <i class="fa-solid fa-users-viewfinder"></i>
My groups My groups
<div class="float-end"> <div class="float-end">
<a href="/docs/ldap" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<i class="fa-solid fa-arrows-up-down"></i> <i class="fa-solid fa-arrows-up-down"></i>
</div> </div>
</div> </div>
<div class="card-header shadow actionMessage" style="display:none"> <div class="card-header shadow actionMessage" style="display:none">
</div> </div>
<div class="card-body" style="padding-bottom:0"> <div class="card-body">
<div class="table-responsive"> <div class="table-responsive">
<table class="table"> <table class="table">
<thead> <thead>
@@ -292,19 +367,78 @@
<th> <th>
Description Description
</th> </th>
<th class="group-required group-required-app_sso_admin"></th>
</thead> </thead>
<tbody jq-repeat="mygroups"> <tbody jq-repeat="mygroups">
<tr> <tr>
<td>{{cn}}</td> <td>{{cn}}</td>
<td>{{description}}</td> <td>{{description}}</td>
<td class="text-end group-required group-required-app_sso_admin">
<button type="button" class="btn btn-sm btn-outline-danger" title="Remove from group" onclick="removeFromGroup('{{cn}}', this)">
<i class="fa-solid fa-xmark"></i>
</button>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="group-required group-required-app_sso_admin">
<label class="form-label small">Add to group</label>
<div class="d-flex gap-2 align-items-start">
<div id="add-group-select" class="flex-grow-1"></div>
<button type="button" class="btn btn-outline-dark" onclick="addToGroups(this)">Add</button>
</div>
</div>
</div>
</div>
<div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin">
<div class="card-header shadow">
<i class="fa-solid fa-people-group"></i>
Members of <span id="personal-group-uid-label"></span>'s group
<div class="float-end">
<a href="/docs/ldap" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<i class="fa-solid fa-arrows-up-down"></i>
</div>
</div>
<div class="card-header shadow actionMessage" style="display:none">
</div>
<div class="card-body">
<p class="text-muted small">
Every account gets a personal Unix group (its primary GID) — add
other accounts here as supplementary members (e.g. to share write
access to files owned by this group).
</p>
<div class="table-responsive">
<table class="table">
<thead>
<th>
Username
</th>
<th class="text-end"></th>
</thead>
<tbody jq-repeat="personalGroupMembers">
<tr>
<td>{{uid}}</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger" title="Remove from group" onclick="removePersonalGroupMember('{{uid}}', this)">
<i class="fa-solid fa-xmark"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<label class="form-label small">Add member</label>
<div class="d-flex gap-2 align-items-start">
<div id="add-personal-group-member-select" class="flex-grow-1"></div>
<button type="button" class="btn btn-outline-dark" onclick="addPersonalGroupMembers(this)">Add</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Token modal (shown once on create/rotate) --> <!-- Token modal (shown once on create/rotate) -->
<div class="modal fade" id="secretModal" tabindex="-1"> <div class="modal fade" id="secretModal" tabindex="-1">
@@ -478,7 +612,9 @@
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="card shadow-lg"> <div class="card shadow-lg">
<div class="card-header"><i class="fa-solid fa-plus"></i> New API Token</div> <div class="card-header"><i class="fa-solid fa-plus"></i> New API Token
<a href="/docs/api" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<div class="card-body"> <div class="card-body">
<p class="text-muted small">A personal access token lets scripts and services call the SSO management API as you, with your permissions. Treat it like a password.</p> <p class="text-muted small">A personal access token lets scripts and services call the SSO management API as you, with your permissions. Treat it like a password.</p>
+4
View File
@@ -126,6 +126,7 @@
<i class="fas fa-user-plus"></i> <i class="fas fa-user-plus"></i>
Invite User Invite User
<span class="float-end"> <span class="float-end">
<a href="/docs/ldap" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<i class="fa-solid fa-arrows-up-down"></i> <i class="fa-solid fa-arrows-up-down"></i>
</span> </span>
</div> </div>
@@ -151,6 +152,7 @@
<i class="fas fa-user-plus"></i> <i class="fas fa-user-plus"></i>
Add new user Add new user
<small class="text-muted">(check <b>This is a service account</b> below to create one — it'll show up under the Service Accounts tab)</small> <small class="text-muted">(check <b>This is a service account</b> below to create one — it'll show up under the Service Accounts tab)</small>
<a href="/docs/ldap" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div> </div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<div class="card-body"> <div class="card-body">
@@ -163,6 +165,7 @@
<div class="card-header"> <div class="card-header">
<i class="fa-solid fa-users"></i> <i class="fa-solid fa-users"></i>
User List User List
<a href="/docs/ldap" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div> </div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<div class="table-responsive"> <div class="table-responsive">
@@ -232,6 +235,7 @@
<i class="fa-solid fa-gears"></i> <i class="fa-solid fa-gears"></i>
Service Accounts Service Accounts
<small class="text-muted">— Unix/POSIX accounts something runs as, not a person. Create one from the People tab's "Add new user" form.</small> <small class="text-muted">— Unix/POSIX accounts something runs as, not a person. Create one from the People tab's "Add new user" form.</small>
<a href="/docs/ldap" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div> </div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<div class="table-responsive"> <div class="table-responsive">