Compare commits

...

10 Commits

Author SHA1 Message Date
wmantly 4e7e29b35f Merge pull request #81 from theta42/concept-docs
Add plain-language concept docs; fix docs viewer rendering; link API tokens
2026-07-17 21:51:40 -04:00
wmantly 4e5a2aa4f9 Add plain-language concept docs; fix docs viewer rendering; link API tokens
- New docs/concepts-{accounts,oauth-apps,api-tokens}.md -- plain-language
  guides aimed at less technical readers, each linking onward to the
  existing schema/protocol-level doc for anyone who wants that detail.
  Card help links (Users, Groups, OAuth cards, My groups, Members of
  <uid>'s group) now point here instead of straight at the technical
  docs; the LDAP-protocol-wiring cards (raw connection details for
  connecting a 3rd-party app) stay pointed at the technical ldap.md,
  since that's genuinely the right depth for that task.
- The "New API Token" card had no help link at all -- added, pointing to
  the new API Tokens doc.
- Fixed the in-app docs viewer rendering every docs/*.md page with a
  garbled heading + stray <hr> at the top: Jekyll front matter (meant
  only for the GitHub Pages build) was never stripped before being
  handed to the markdown renderer. Also fixed: cross-doc links
  (ldap.html, index.html, etc.) never resolved in-app, since this
  viewer serves docs at /docs/<slug> with no .html suffix -- rewritten
  to the correct in-app URL, same idea as the existing image-path fix.

Bumps to v1.1.12.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 21:49:27 -04:00
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
17 changed files with 663 additions and 16 deletions
+5
View File
@@ -1,5 +1,10 @@
# SSO Manager API Documentation
> Looking for a plainer explanation of what API tokens are and when you'd
> want one, instead of a full endpoint reference? See
> [API Tokens](/docs/api-tokens) (in-app) or
> [concepts-api-tokens.md](docs/concepts-api-tokens.md) (repo).
## Overview
API documentation for the SSO Manager Node application. Provides endpoints for authentication, user management, group management, token management, notifications, and OAuth 2.0 / OpenID Connect.
+41 -1
View File
@@ -6,6 +6,41 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased]
## [1.1.12] - 2026-07-17
### Added
- Three new plain-language docs aimed at less technical readers, replacing the schema-level LDAP/OAuth/API docs as the target of most card help links: **Accounts, Groups & Managers**, **Connecting Apps (SSO)**, and **API Tokens**. Each links onward to the deeper technical reference for readers who want it; the technical docs link back the other way too. The personal-access-token card (previously missed) now links to its own doc.
### Fixed
- The in-app docs viewer rendered every `docs/*.md` page with a garbled heading and a stray horizontal rule at the top — Jekyll front matter (meant only for the GitHub Pages build) was never stripped before being handed to the markdown renderer. Also fixed: cross-doc links (`ldap.html`, `index.html`, etc.) never resolved in-app, since this viewer serves docs at `/docs/<slug>` with no `.html` suffix — they're now rewritten to the correct in-app URL, the same way image paths already were.
Bumps to v1.1.12.
### 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
### Changed
@@ -61,7 +96,12 @@ 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.7...HEAD
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.12...HEAD
[1.1.12]: https://github.com/theta42/sso-manager-node/compare/v1.1.11...v1.1.12
[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.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
+102
View File
@@ -0,0 +1,102 @@
---
layout: default
title: Accounts, Groups & Managers
description: A plain-language guide to users, service accounts, personal groups, and managers in SSO Manager.
---
# Accounts, Groups & Managers
This page explains the concepts behind the Users and Groups pages in plain
language. If you want the technical schema/attribute-level detail instead,
see the [LDAP reference](ldap.html).
## What's an account?
Every person (or app) that can sign in through this SSO Manager has an
**account** — a username, a display name, maybe an email address, and a
password (or, for service accounts, no password at all — see below).
Accounts live in the directory this app manages, and any other app you've
connected (Gitea, Home Assistant, your Wi-Fi, whatever) checks against these
same accounts instead of keeping its own separate list of users and
passwords.
## Two kinds of account: people and service accounts
Most accounts belong to an actual person — check **Users → People** to see
them. But sometimes you need an account for something that *isn't* a
person: a media server, a backup script, a bind account another app uses to
look people up. These are **service accounts**, listed separately under
**Users → Service Accounts**, and they're different from a person's account
in two ways that matter:
- **No email required.** A service account doesn't need a mailbox, so the
form doesn't ask for one.
- **A password is optional.** If you leave it blank, nobody can log in as
that account — which is exactly what you want for something that only
ever gets used programmatically (a script authenticating with an API
token, or another app binding with a fixed, separately-configured
password you set yourself). Only give it a password if the account
genuinely needs to log in or bind somewhere as itself.
Aside from those two differences, a service account is a completely normal
account under the hood — it can belong to groups, have a manager, and so
on, just like anyone else's.
## Groups: who can do what
A **group** is just a named list of accounts, used to control access. This
app has a handful of built-in groups that grant admin powers (e.g. only
people in the `app_sso_admin` group can see the Users/Groups/Integrations
pages at all), but you can also make your own groups for any app you
connect — say, a group listing everyone who should be allowed into your
photo server. Once a group exists, add or remove members from the
**Groups** page, and point the other app's "who's allowed in" setting at
that group's name.
## Every account's personal group
Separately from the groups above, every single account — person or
service account — automatically gets its own small, personal group when
it's created, named after the account itself. Most of the time you'll
never think about this; it exists so that, on a Linux system connected to
this directory, each account "owns" its own files by default the same way
a normal Unix user account would.
Occasionally you'll want to share that ownership with someone else — for
example, letting a second account also have write access to files a
service account owns. That's what the **"Members of `<uid>`'s group"**
section on a profile page is for: add another account there, and the
underlying Linux permissions treat them as if they belong to that same
personal group too.
## What's a "manager"?
Every account has one or more **managers** — the people allowed to edit
that account's profile (phone number, SSH key, home directory, and so on)
without needing full admin rights. By default, whoever created an account
(the admin who added it, or whoever sent the invite) becomes its first
manager, but you can add or remove managers later from the account's Edit
form.
This is useful for service accounts especially: if a service account
belongs to a particular project or person, make them its manager so they
can maintain it — rotate its SSH key, adjust its description — without
needing to be a full SSO administrator.
## Inviting someone vs. adding them yourself
From the Users page you can either fill in someone's details yourself
("Add new user"), or send them an **invite** — an email (or a link you copy
and send however you like) that lets them pick their own username and
password. Either way, the resulting account is identical; invites are just
a convenience so you don't have to know someone's preferred username or
handle their password directly.
## Want more detail?
This page deliberately leaves out LDAP schema names, attribute types, and
protocol-level detail. If you're connecting a third-party app directly to
the LDAP directory, or you just want to know exactly what's stored where,
see the [LDAP reference](ldap.html).
[← Back to Home](index.html)
+59
View File
@@ -0,0 +1,59 @@
---
layout: default
title: API Tokens
description: A plain-language guide to personal access tokens in SSO Manager.
---
# API Tokens
This page explains what an API token is and when you'd want one. For the
full list of API endpoints a token can call, see the
[API reference](api.html).
## What's an API token, in plain terms?
Normally, you interact with this app by logging in through a web browser.
An **API token** (also called a personal access token, or PAT) is an
alternative way in — a long, random string that a script, a scheduled job,
or another program can use instead of a username and password, to act on
your behalf without a human typing a login in each time.
If you've ever set up a script to talk to GitHub, GitLab, or a similar
service using a "token" instead of your real password, this is the same
idea.
## When would you actually need one?
Most people never need to create one of these — you'll only want a token
if you're automating something, for example:
- A script that syncs users or groups from somewhere else into this SSO
Manager on a schedule.
- A backup or monitoring job that checks this app's health via its API.
- A CI/CD pipeline that needs to register or update an OAuth client
automatically.
If you're not doing any of that, you don't need an API token — just log in
normally through the web UI.
## How it works
Create a token from your Profile page, give it a name so you remember what
it's for later, and optionally an expiry. You'll be shown the token's
value **exactly once** — copy it somewhere safe immediately, because it
can't be viewed again afterward (only revoked or rotated). Whatever script
or tool you're using it with sends it along with each request, the same
way a browser sends your login session.
A token acts **as you**, with **your** permissions — if you're not an
admin, a token you create can't do admin-only things either. If you ever
suspect a token has leaked (ended up somewhere it shouldn't have, like a
public script or log file), revoke it immediately from your Profile page;
it stops working right away.
## Want more detail?
This page doesn't attempt to list every API endpoint or show request/
response examples — for that, see the full [API reference](api.html).
[← Back to Home](index.html)
+79
View File
@@ -0,0 +1,79 @@
---
layout: default
title: Connecting Apps (Single Sign-On)
description: A plain-language guide to OAuth/OIDC clients and single sign-on in SSO Manager.
---
# Connecting Apps (Single Sign-On)
This page explains, in plain language, what happens when you "connect" an
app to your SSO Manager so people can log into it with their existing
account. For the technical endpoint/token detail, see the
[OAuth reference](oauth.html).
## What does "single sign-on" actually mean?
Instead of every app you run having its own separate list of usernames and
passwords, they all check with this SSO Manager instead. You log in once,
here, and any connected app trusts that login — no separate password to
remember or manage for each one. If you ever need to lock someone out
everywhere at once, you do it in one place (deactivate their account here)
instead of hunting down every app individually.
The technology behind this is called **OAuth 2.0** and **OpenID Connect
(OIDC)** — you'll see both names used, often together, referring to the
same thing. You don't need to understand the protocol to use this page;
what matters practically is the handful of concepts below.
## What's a "client"?
Every app you connect is registered here as a **client** — a single entry
on the Integrations page representing that one app. Registering a client
gives you a **Client ID** and **Client Secret**: think of these like a
username and password, but for the *app itself* rather than for a person.
You paste them into the other app's own "Single Sign-On" or "OIDC" setup
screen, along with the discovery URL shown at the top of this page, and
that app is now able to ask this SSO Manager to authenticate people on its
behalf.
**Treat the Client Secret like a password** — anyone who has it can
impersonate that app when talking to your SSO Manager. If you ever suspect
it's leaked, rotate it from the client's card.
## What are "scopes"?
**Scopes** control what information a connected app is allowed to ask for
about the person logging in — their username, email, group memberships,
and so on. Most apps tell you exactly which scopes they need in their own
setup instructions; when in doubt, the default set (`openid`, `profile`,
`email`, `groups`) covers what nearly every app expects.
## "Restrict to Groups"
By default, *any* account with an SSO Manager login can sign into a
connected app. If that's not what you want — say, a home automation
dashboard that only certain family members should reach — set **Restrict
to Groups** on that client to one of your [groups](concepts-accounts.html).
Only members of that group will be allowed to log into that particular
app; everyone else gets turned away at the login step, even though their
SSO Manager account still works everywhere else.
## Redirect URIs
A **Redirect URI** is the exact web address the connected app wants people
sent back to once they've logged in here — it's a security measure so an
attacker can't trick the login flow into redirecting somewhere else. The
app's own setup instructions will tell you this value; copy it in exactly
as given. If the app is reachable via more than one hostname (for example,
because it sits behind [theta42/proxy](https://theta42.github.io/proxy/)),
this field supports wildcard patterns — see the inline help under the
field itself for the exact syntax.
## Want more detail?
This page intentionally skips the protocol-level detail (exact endpoint
URLs, token formats, claim names). If you're troubleshooting a connection
or building something against the API directly, see the
[OAuth reference](oauth.html).
[← Back to Home](index.html)
+17
View File
@@ -8,6 +8,10 @@ description: SSO Manager's bundled OpenLDAP directory — schema, service accoun
[← Back to Home](index.html)
> Looking for a plainer explanation of accounts, groups, and managers
> instead of schema/attribute detail? See
> [Accounts, Groups & Managers](concepts-accounts.html).
SSO Manager runs an OpenLDAP directory holding your users and groups. The app
authenticates against it over `localhost:389` (inside the all-in-one container)
and exposes **LDAPS** (`ldaps://…:636`, TLS) for legacy apps that bind LDAP
@@ -57,6 +61,19 @@ membership (`memberOf` on the user); `refint` keeps it consistent on
add/remove. **Admin permission checks read the group's `member` list**, not
`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 /
`install.sh`):
+4
View File
@@ -8,6 +8,10 @@ description: SSO Manager's OpenID Connect / OAuth 2.0 provider — discovery doc
[← Back to Home](index.html)
> Looking for a plainer explanation of clients/scopes/redirect URIs instead
> of endpoint-level detail? See
> [Connecting Apps (Single Sign-On)](concepts-oauth-apps.html).
SSO Manager is an **OpenID Connect / OAuth 2.0 provider**: it issues its own
access, refresh, and ID tokens that your apps can consume to authenticate
users and authorize API calls. It also runs a full OpenLDAP directory, so it
+61 -4
View File
@@ -210,10 +210,13 @@ 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);
// manager (COSINE, SUP distinguishedName) and memberOf (from the memberof
// overlay) are both multi-valued; ldapts returns a bare string for a
// single value and an array for multiple -- normalize both to always be
// 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;
}
@@ -348,6 +351,13 @@ User.get = async function(data, key) {
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
if (isLegacyMD5 && !verif.password_must_change) {
await verif.update({ password_must_change: true });
@@ -784,6 +794,53 @@ User.addSSHkey = async function(data) {
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 = {}){
try{
let token = await InviteToken.create({
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "t42-sso-manager",
"version": "1.1.7",
"version": "1.1.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-sso-manager",
"version": "1.1.7",
"version": "1.1.12",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.1.7",
"version": "1.1.12",
"private": true,
"author": [
{
+58 -2
View File
@@ -25,6 +25,14 @@ const values = {
// back at the root DEPLOYMENT.md (see docs/deployment.md itself), which is
// already covered by the "deployment" entry.
const DOCS = {
// Plain-language "what is this and why would I use it" guides -- linked
// directly from the relevant card in the UI (see the help icon on each
// card). Each links onward to the deeper technical doc below for readers
// who want the schema/protocol-level detail.
accounts: {title: 'Accounts, Groups & Managers', file: path.join(__dirname, '../../docs/concepts-accounts.md')},
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
deployment: {title: 'Deployment', file: path.join(__dirname, '../../DEPLOYMENT.md')},
@@ -46,24 +54,72 @@ function fixImagePaths(html) {
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
}
// Docs cross-link each other as "<slug>.html" (correct for the Jekyll/GitHub
// Pages build, which is what these same .md files also feed) and
// "index.html" for the docs home -- neither resolves here, where a doc lives
// at /docs/<slug> with no .html suffix. Rewrite known doc links to the
// in-app route, same idea as fixImagePaths() above. Only touches slugs that
// actually exist, so an unrelated "foo.html" link is left alone.
function fixDocLinks(html) {
return html
.replace(/href="index\.html"/g, 'href="/docs"')
.replace(/href="([a-z0-9-]+)\.html"/g, (match, slug) => DOCS[slug] ? `href="/docs/${slug}"` : match);
}
// docs/*.md files (not the repo-root README/CHANGELOG/API.md) carry Jekyll
// front matter for the GitHub Pages build and a "← Back to Home" link back
// to that site's index -- both meaningless here (this viewer has its own
// doc-list sidebar, docs_page.ejs) and, worse, marked() doesn't know front
// matter isn't regular markdown: it rendered as a garbled heading + stray
// <hr> at the top of every page. Strip both before rendering.
function stripJekyllCruft(content) {
return content
.replace(/^---\n[\s\S]*?\n---\n/, '')
.replace(/^\s*\[← Back to Home\]\([^)]*\)\s*\n/m, '');
}
router.use(rateLimit.docs);
router.get('/', function(req, res) {
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 = stripJekyllCruft(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) {
const doc = DOCS[req.params.slug];
if (!doc) return next({status: 404, message: 'Doc not found'});
try {
const content = fs.readFileSync(doc.file, 'utf8');
const content = stripJekyllCruft(fs.readFileSync(doc.file, 'utf8'));
res.render('docs_page', {
...values,
docs: docList,
currentSlug: req.params.slug,
docTitle: doc.title,
docHtml: fixImagePaths(marked(content)),
docHtml: fixDocLinks(fixImagePaths(marked(content))),
});
} catch (error) {
next(error);
+42 -1
View File
@@ -25,7 +25,7 @@ router.post('/', async function(req, res, next){
req.body.created_by = req.user.uid
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 updates = { password_must_change: true };
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 {
const group = await Group.get('app_sso_service_account');
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) {
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){
try{
let user;
+42 -1
View File
@@ -10,7 +10,12 @@
A local copy of this project's documentation, readable from the
running app -- no internet access required.
</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){ %>
<li class="list-group-item">
<a href="/docs/<%= doc.slug %>"><%= doc.title %></a>
@@ -21,4 +26,40 @@
</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') %>
+2
View File
@@ -142,6 +142,7 @@
<div class="card-header">
<i class="fa-solid fa-object-group"></i>
Add new group
<a href="/docs/accounts" 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-body">
@@ -167,6 +168,7 @@
<h5>
<i class="fa-solid fa-arrows-down-to-people"></i>
Group: {{ cn }}
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</h5>
<ul class="nav nav-tabs card-header-tabs" id="myTab" role="tablist">
<li class="nav-item">
+4
View File
@@ -250,6 +250,7 @@
<div class="card-header bg-info bg-opacity-10">
<i class="fa-solid fa-circle-info"></i>
OpenID Connect Endpoints
<a href="/docs/oauth-apps" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-body">
<p class="mb-2 text-muted small">
@@ -276,6 +277,7 @@
<div class="card-header">
<i class="fa-solid fa-plus"></i>
Register OAuth Client
<a href="/docs/oauth-apps" 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-body">
@@ -411,6 +413,7 @@
<div class="card shadow-lg">
<div class="card-header shadow">
<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 class="card-body">
<p class="text-muted small">
@@ -488,6 +491,7 @@
<div class="card shadow-lg">
<div class="card-header shadow">
<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 class="card-body">
<p class="text-muted small">
+140 -4
View File
@@ -18,12 +18,77 @@
async function renderUserGroups(user){
try{
let res = await app.api.get('group/?detail=true&member='+user.uid);
$.scope.mygroups.empty();
$.scope.mygroups.push(...res.results);
}catch(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(){
if(location.pathname.includes('/users/')){
let uid = location.pathname.replace('/users/', '');
@@ -94,6 +159,15 @@
renderProfile(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
// viewing someone else's profile via /users/:uid.
@@ -159,7 +233,7 @@
<div class="profile-body" jq-repeat="user">
<div class="card-body profile-body-{{uid}}">
<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>
{{#emailVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/emailVerified}}
<br />
@@ -247,7 +321,7 @@
<div class="mb-3">
<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 class="mb-3">
<label class="form-label">Home Directory</label>
@@ -277,12 +351,13 @@
<i class="fa-solid fa-users-viewfinder"></i>
My groups
<div class="float-end">
<a href="/docs/accounts" 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" style="padding-bottom:0">
<div class="card-body">
<div class="table-responsive">
<table class="table">
<thead>
@@ -292,19 +367,78 @@
<th>
Description
</th>
<th class="group-required group-required-app_sso_admin"></th>
</thead>
<tbody jq-repeat="mygroups">
<tr>
<td>{{cn}}</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>
</tbody>
</table>
</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/accounts" 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>
<!-- Token modal (shown once on create/rotate) -->
<div class="modal fade" id="secretModal" tabindex="-1">
@@ -478,7 +612,9 @@
</div>
<div class="col-md-4">
<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-tokens" 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-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>
+4
View File
@@ -126,6 +126,7 @@
<i class="fas fa-user-plus"></i>
Invite User
<span class="float-end">
<a href="/docs/accounts" 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>
</span>
</div>
@@ -151,6 +152,7 @@
<i class="fas fa-user-plus"></i>
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>
<a href="/docs/accounts" 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-body">
@@ -163,6 +165,7 @@
<div class="card-header">
<i class="fa-solid fa-users"></i>
User List
<a href="/docs/accounts" 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="table-responsive">
@@ -232,6 +235,7 @@
<i class="fa-solid fa-gears"></i>
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>
<a href="/docs/accounts" 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="table-responsive">