Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47a9f6c3ec | |||
| f6552cb741 | |||
| 4e7e29b35f | |||
| 4e5a2aa4f9 | |||
| 51784f2f27 | |||
| 4c59b1fabb | |||
| 099638057e | |||
| 077c41844d | |||
| 96adf60cf7 | |||
| 82f703f560 | |||
| 65b107d8ff | |||
| 5d7c0bd594 |
@@ -1,5 +1,10 @@
|
|||||||
# SSO Manager API Documentation
|
# 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
|
## 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.
|
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.
|
||||||
|
|||||||
+47
-1
@@ -6,6 +6,46 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.1.13] - 2026-07-17
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The new concept docs' cross-links (`concepts-accounts.html` etc.) are the correct, working URL on the Jekyll/GitHub Pages build (where the page's URL is its filename stem) but didn't resolve in the in-app docs viewer, which serves docs at a separate short slug (`/docs/accounts`). The in-app renderer now also resolves a doc's real filename as a fallback, so one link written in a doc works on both targets.
|
||||||
|
|
||||||
|
Bumps to v1.1.13.
|
||||||
|
|
||||||
|
### 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
|
## [1.1.7] - 2026-07-17
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -61,7 +101,13 @@ 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.13...HEAD
|
||||||
|
[1.1.13]: https://github.com/theta42/sso-manager-node/compare/v1.1.12...v1.1.13
|
||||||
|
[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.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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -8,6 +8,10 @@ description: SSO Manager's bundled OpenLDAP directory — schema, service accoun
|
|||||||
|
|
||||||
[← Back to Home](index.html)
|
[← 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
|
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)
|
authenticates against it over `localhost:389` (inside the all-in-one container)
|
||||||
and exposes **LDAPS** (`ldaps://…:636`, TLS) for legacy apps that bind LDAP
|
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
|
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`):
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ description: SSO Manager's OpenID Connect / OAuth 2.0 provider — discovery doc
|
|||||||
|
|
||||||
[← Back to Home](index.html)
|
[← 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
|
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
|
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
|
users and authorize API calls. It also runs a full OpenLDAP directory, so it
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.1.7",
|
"version": "1.1.13",
|
||||||
"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.13",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.1.7",
|
"version": "1.1.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
|
|||||||
+70
-2
@@ -25,6 +25,14 @@ const values = {
|
|||||||
// back at the root DEPLOYMENT.md (see docs/deployment.md itself), which is
|
// back at the root DEPLOYMENT.md (see docs/deployment.md itself), which is
|
||||||
// already covered by the "deployment" entry.
|
// already covered by the "deployment" entry.
|
||||||
const DOCS = {
|
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')},
|
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||||
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
||||||
deployment: {title: 'Deployment', file: path.join(__dirname, '../../DEPLOYMENT.md')},
|
deployment: {title: 'Deployment', file: path.join(__dirname, '../../DEPLOYMENT.md')},
|
||||||
@@ -46,24 +54,84 @@ function fixImagePaths(html) {
|
|||||||
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
|
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.
|
||||||
|
// Docs are also linked by their real filename stem (e.g. "concepts-accounts.html"
|
||||||
|
// for docs/concepts-accounts.md) -- the correct, working link on the Jekyll/
|
||||||
|
// GitHub Pages build, where the URL IS the filename stem. That doesn't match
|
||||||
|
// this viewer's own short slugs (DOCS keys, e.g. "accounts"), so also resolve
|
||||||
|
// by filename as a fallback -- one link written in a doc works correctly on
|
||||||
|
// both targets, rather than needing two different link forms.
|
||||||
|
const slugByFilename = Object.fromEntries(
|
||||||
|
Object.entries(DOCS).map(([slug, d]) => [path.basename(d.file, '.md'), slug])
|
||||||
|
);
|
||||||
|
function fixDocLinks(html) {
|
||||||
|
return html
|
||||||
|
.replace(/href="index\.html"/g, 'href="/docs"')
|
||||||
|
.replace(/href="([a-z0-9-]+)\.html"/g, (match, name) => {
|
||||||
|
const slug = DOCS[name] ? name : slugByFilename[name];
|
||||||
|
return 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.use(rateLimit.docs);
|
||||||
|
|
||||||
router.get('/', function(req, res) {
|
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 = 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) {
|
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'});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(doc.file, 'utf8');
|
const content = stripJekyllCruft(fs.readFileSync(doc.file, 'utf8'));
|
||||||
res.render('docs_page', {
|
res.render('docs_page', {
|
||||||
...values,
|
...values,
|
||||||
docs: docList,
|
docs: docList,
|
||||||
currentSlug: req.params.slug,
|
currentSlug: req.params.slug,
|
||||||
docTitle: doc.title,
|
docTitle: doc.title,
|
||||||
docHtml: fixImagePaths(marked(content)),
|
docHtml: fixDocLinks(fixImagePaths(marked(content))),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
+42
-1
@@ -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;
|
||||||
|
|||||||
@@ -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') %>
|
||||||
|
|||||||
@@ -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/accounts" 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/accounts" 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">
|
||||||
|
|||||||
@@ -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-apps" 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-apps" 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
@@ -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/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>
|
<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/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>
|
</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-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-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>
|
||||||
|
|||||||
@@ -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/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>
|
<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/accounts" 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/accounts" 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/accounts" 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">
|
||||||
|
|||||||
Reference in New Issue
Block a user