Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ad817767a | |||
| cdc5d1528c | |||
| 5fc65d6fb3 | |||
| ea65a85aa9 | |||
| f8cf68b85f | |||
| cedef0ed09 | |||
| 0e31320964 | |||
| f12ce8c600 | |||
| b358e3b0b0 | |||
| 88387f3117 |
+21
-1
@@ -6,6 +6,23 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.1.7] - 2026-07-17
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Service accounts unified to one kind.** Removed the LDAP bind-only service account type (the Integrations → LDAP "Service Accounts" card, and its `/api/service-account` routes) -- every service account is now a real Unix/POSIX account with a UID, created from the new **Users → Service Accounts** tab. Email and password are both optional for service accounts; a blank password means no `userPassword` is set at all (the account simply can't bind).
|
||||||
|
- **Added a `manager` field to every account.** Multi-valued (a list of usernames), defaults to whoever created the account (the admin who added it, or whoever sent the invite), and reassignable from the account's Edit form. Anyone listed as a manager can edit that account -- same fields an admin can (mobile, description, SSH key, date of birth, home directory, login shell, manager list) -- without needing `app_sso_admin`.
|
||||||
|
- `homeDirectory` and `loginShell` are now editable from the Edit Profile form (previously view-only).
|
||||||
|
|
||||||
|
## [1.1.6] - 2026-07-16
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Redesigned the GitHub Pages docs site to match the app's own look (dark navbar/footer, Bootstrap 5, Font Awesome) instead of the generic `jekyll-theme-cayman` theme, added a real cross-page nav, SEO (`jekyll-seo-tag` + `jekyll-sitemap`, per-page descriptions, OG/Twitter tags, sitemap.xml, robots.txt), and mobile-responsive layout.
|
||||||
|
|
||||||
|
## [1.1.5] - 2026-07-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Bumped `jq-repeat` 2.0.1 -> 2.1.0. `update()` is now trailing-edge throttled (~50ms) even on the first call; `profile.ejs`'s edit-profile flow updated a scope and immediately slid the same element into view, which could briefly show stale/empty data. Deferred the slide by 60ms.
|
||||||
|
|
||||||
## [1.1.4] - 2026-07-16
|
## [1.1.4] - 2026-07-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -44,7 +61,10 @@ 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.4...HEAD
|
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.7...HEAD
|
||||||
|
[1.1.7]: https://github.com/theta42/sso-manager-node/compare/v1.1.6...v1.1.7
|
||||||
|
[1.1.6]: https://github.com/theta42/sso-manager-node/compare/v1.1.5...v1.1.6
|
||||||
|
[1.1.5]: https://github.com/theta42/sso-manager-node/compare/v1.1.4...v1.1.5
|
||||||
[1.1.4]: https://github.com/theta42/sso-manager-node/compare/v1.1.3...v1.1.4
|
[1.1.4]: https://github.com/theta42/sso-manager-node/compare/v1.1.3...v1.1.4
|
||||||
[1.1.3]: https://github.com/theta42/sso-manager-node/compare/v1.1.2...v1.1.3
|
[1.1.3]: https://github.com/theta42/sso-manager-node/compare/v1.1.2...v1.1.3
|
||||||
[1.1.2]: https://github.com/theta42/sso-manager-node/compare/v1.1.1...v1.1.2
|
[1.1.2]: https://github.com/theta42/sso-manager-node/compare/v1.1.1...v1.1.2
|
||||||
|
|||||||
+39
-4
@@ -1,9 +1,44 @@
|
|||||||
title: SSO Manager
|
title: SSO Manager
|
||||||
description: A self-hosted OpenID Connect provider with an OpenLDAP directory and a web management UI
|
description: A self-hosted OpenID Connect provider with a bundled OpenLDAP directory and a web management UI, for home labs and small businesses that want their own identity provider.
|
||||||
theme: jekyll-theme-cayman
|
url: "https://theta42.github.io"
|
||||||
show_downloads: false
|
baseurl: "/sso-manager-node"
|
||||||
|
logo: /assets/img/theta42.svg
|
||||||
|
lang: en_US
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
- jekyll-seo-tag
|
||||||
|
- jekyll-sitemap
|
||||||
|
|
||||||
github:
|
github:
|
||||||
repository_url: https://github.com/theta42/sso-manager-node
|
repository_url: https://github.com/theta42/sso-manager-node
|
||||||
zip_url: https://github.com/theta42/sso-manager-node/archive/refs/heads/master.zip
|
zip_url: https://github.com/theta42/sso-manager-node/archive/refs/heads/master.zip
|
||||||
tar_url: https://github.com/theta42/sso-manager-node/archive/refs/heads/master.tar.gz
|
tar_url: https://github.com/theta42/sso-manager-node/archive/refs/heads/master.tar.gz
|
||||||
repository_name: theta42/sso-manager-node
|
repository_name: theta42/sso-manager-node
|
||||||
|
|
||||||
|
nav:
|
||||||
|
- title: Home
|
||||||
|
page: /
|
||||||
|
icon: fa-house
|
||||||
|
- title: Deployment
|
||||||
|
page: /deployment.html
|
||||||
|
icon: fa-server
|
||||||
|
- title: Configuration
|
||||||
|
page: /configuration.html
|
||||||
|
icon: fa-gears
|
||||||
|
- title: OAuth
|
||||||
|
page: /oauth.html
|
||||||
|
icon: fa-key
|
||||||
|
- title: LDAP
|
||||||
|
page: /ldap.html
|
||||||
|
icon: fa-address-book
|
||||||
|
- title: Changelog
|
||||||
|
url: https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md
|
||||||
|
icon: fa-list
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
- scope:
|
||||||
|
path: ""
|
||||||
|
type: "pages"
|
||||||
|
values:
|
||||||
|
layout: default
|
||||||
|
image: /assets/img/theta42.svg
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ '/assets/img/theta42.svg' | relative_url }}">
|
||||||
|
|
||||||
|
{% seo title=false %}
|
||||||
|
<title>{% if page.title %}{{ page.title }} · {% endif %}{{ site.title }}</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}">
|
||||||
|
</head>
|
||||||
|
<body class="d-flex flex-column min-vh-100">
|
||||||
|
|
||||||
|
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
|
||||||
|
<div class="container-fluid px-3">
|
||||||
|
<a class="navbar-brand d-flex align-items-center" href="{{ '/' | relative_url }}">
|
||||||
|
<img src="{{ '/assets/img/theta42.svg' | relative_url }}" height="28" class="me-2" alt="">
|
||||||
|
{{ site.title }}
|
||||||
|
</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse justify-content-end" id="navMain">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
{% for item in site.nav %}
|
||||||
|
<li class="nav-item">
|
||||||
|
{% if item.page %}
|
||||||
|
<a class="nav-link{% if page.url == item.page %} active{% endif %}" href="{{ item.page | relative_url }}">
|
||||||
|
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="nav-link" href="{{ item.url }}" target="_blank" rel="noopener">
|
||||||
|
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="flex-grow-1" style="margin-top: 4.5rem;">
|
||||||
|
<div class="container-fluid py-4 py-md-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-12 col-lg-10 col-xl-8">
|
||||||
|
<div class="card shadow-lg">
|
||||||
|
<div class="card-body p-4 p-md-5 site-content">
|
||||||
|
{{ content }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="py-3 bg-dark text-light mt-auto">
|
||||||
|
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2 px-3">
|
||||||
|
<span class="d-flex align-items-center gap-2">
|
||||||
|
<a href="https://theta42.com" target="_blank" rel="noopener">
|
||||||
|
<img width="40" src="{{ '/assets/img/theta42.svg' | relative_url }}" alt="theta42">
|
||||||
|
</a>
|
||||||
|
© {{ 'now' | date: '%Y' }} theta42 ·
|
||||||
|
<a href="{{ site.github.repository_url }}/blob/master/LICENSE" target="_blank" rel="noopener" class="text-light">MIT License</a>
|
||||||
|
</span>
|
||||||
|
<span class="d-flex align-items-center gap-3">
|
||||||
|
<a href="{{ site.github.repository_url }}" target="_blank" rel="noopener" class="text-light text-decoration-none">
|
||||||
|
<i class="fa-brands fa-github"></i> GitHub
|
||||||
|
</a>
|
||||||
|
<a href="{{ site.github.repository_url }}/blob/master/CHANGELOG.md" target="_blank" rel="noopener" class="text-light text-decoration-none">
|
||||||
|
<i class="fa-solid fa-list"></i> Changelog
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/* theta42 docs site — shares the in-app dark navbar/footer + card look
|
||||||
|
(Bootstrap 5 + Font Awesome, same as the running apps) rather than a
|
||||||
|
generic Jekyll theme. */
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #f4f5f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand img {
|
||||||
|
filter: drop-shadow(0 0 2px rgba(0, 0, 0, .4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav .nav-link.active {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown content typography, scoped to the card body so it doesn't leak
|
||||||
|
into the nav/footer. */
|
||||||
|
.site-content h1:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content h1,
|
||||||
|
.site-content h2,
|
||||||
|
.site-content h3 {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content h2 {
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
padding-bottom: .4rem;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content h3 {
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content a {
|
||||||
|
color: #a3671f;
|
||||||
|
text-decoration-color: rgba(163, 103, 31, .35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content a:hover {
|
||||||
|
color: #8a5a16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content pre {
|
||||||
|
background-color: #212529;
|
||||||
|
color: #f8f9fa;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-radius: .375rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content code {
|
||||||
|
color: #a3671f;
|
||||||
|
background-color: #f4f0e8;
|
||||||
|
padding: .15em .4em;
|
||||||
|
border-radius: .25rem;
|
||||||
|
font-size: .875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content pre code {
|
||||||
|
color: inherit;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content table {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content table th,
|
||||||
|
.site-content table td {
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content table th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content blockquote {
|
||||||
|
border-left: 4px solid #C59341;
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
background-color: #f8f6f1;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screenshot grids in the markdown use width="49%" inline attrs for a
|
||||||
|
two-up desktop layout -- stack them on narrow screens instead of
|
||||||
|
squeezing to illegibility. */
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.site-content img[width] {
|
||||||
|
width: 100% !important;
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content hr {
|
||||||
|
margin: 2rem 0;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gold-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#C59341" />
|
||||||
|
<stop offset="20%" stop-color="#E4B869" />
|
||||||
|
<stop offset="40%" stop-color="#FBF0B9" />
|
||||||
|
<stop offset="60%" stop-color="#DFB260" />
|
||||||
|
<stop offset="80%" stop-color="#BC8837" />
|
||||||
|
<stop offset="100%" stop-color="#A36F28" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="text-grad" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#FFFFFF" />
|
||||||
|
<stop offset="40%" stop-color="#F5E3B5" />
|
||||||
|
<stop offset="70%" stop-color="#D4A343" />
|
||||||
|
<stop offset="100%" stop-color="#8A5A16" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#000000" flood-opacity="0.4"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g filter="url(#drop-shadow)">
|
||||||
|
<g fill="url(#gold-grad)">
|
||||||
|
<path d="M 200,40
|
||||||
|
C 290,40 350,110 350,200
|
||||||
|
C 350,290 290,360 200,360
|
||||||
|
C 110,360 50,290 50,200
|
||||||
|
C 50,110 110,40 200,40 Z
|
||||||
|
M 200,75
|
||||||
|
C 130,75 88,130 88,200
|
||||||
|
C 88,270 130,325 200,325
|
||||||
|
C 270,325 312,270 312,200
|
||||||
|
C 312,130 270,75 200,75 Z"
|
||||||
|
fill-rule="evenodd" />
|
||||||
|
|
||||||
|
<path d="M 88,190 L 140,190 C 140,190 142,210 140,210 L 88,210 Z" />
|
||||||
|
|
||||||
|
<path d="M 260,190 L 312,190 C 312,190 310,210 260,210 Z" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="200" y="222"
|
||||||
|
font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||||
|
font-size="78"
|
||||||
|
font-weight="900"
|
||||||
|
fill="url(#text-grad)"
|
||||||
|
text-anchor="middle"
|
||||||
|
letter-spacing="-2">42</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: Configuration
|
title: Configuration
|
||||||
|
description: SSO Manager's config layers — conf/base.js defaults, secrets.js overrides, and app_* environment variables.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: Deployment
|
title: Deployment
|
||||||
|
description: Deploying SSO Manager — the all-in-one Docker image, bare-metal install, config layers, and backups.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Deployment Guide
|
# Deployment Guide
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: Home
|
title: Home
|
||||||
|
description: A self-hosted OpenID Connect provider with a bundled OpenLDAP directory and a web management UI. One login for your modern apps, one LDAP directory for the rest, no phone-home.
|
||||||
---
|
---
|
||||||
|
|
||||||
# SSO Manager
|
# SSO Manager
|
||||||
|
|||||||
+39
-23
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: LDAP
|
title: LDAP
|
||||||
|
description: SSO Manager's bundled OpenLDAP directory — schema, service accounts, TLS, and connecting third-party apps directly.
|
||||||
---
|
---
|
||||||
|
|
||||||
# LDAP Directory
|
# LDAP Directory
|
||||||
@@ -34,6 +35,15 @@ User entries are `cn=<uid>,ou=people,<base>` and carry the objectClasses:
|
|||||||
- `sudoRole` — per-user sudo rules (`sudoCommand`, `sudoHost`, `sudoUser`).
|
- `sudoRole` — per-user sudo rules (`sudoCommand`, `sudoHost`, `sudoUser`).
|
||||||
- `theta42Person` (custom auxiliary; `dateOfBirth`).
|
- `theta42Person` (custom auxiliary; `dateOfBirth`).
|
||||||
|
|
||||||
|
Every user (person or service account) also carries a `manager` attribute
|
||||||
|
(the standard COSINE `manager`, `SUP distinguishedName`) — one or more DNs of
|
||||||
|
the people who created/administer that account. Set automatically to the
|
||||||
|
creator's DN on signup (whoever an admin was logged in as, or whoever sent
|
||||||
|
the invite), and reassignable later from the account's Edit form. Anyone
|
||||||
|
listed as a `manager` can edit that account (same fields an admin can:
|
||||||
|
mobile, description, SSH key, date of birth, home directory, login shell,
|
||||||
|
and the manager list itself) without needing `app_sso_admin`.
|
||||||
|
|
||||||
Passwords are stored as `{SSHA512}` (8-byte salt, sha512(pass+salt), base64),
|
Passwords are stored as `{SSHA512}` (8-byte salt, sha512(pass+salt), base64),
|
||||||
verified by the `pw-sha2` module. The app's `hashPasswordSSHA512` is the
|
verified by the `pw-sha2` module. The app's `hashPasswordSSHA512` is the
|
||||||
canonical hasher; if you provision users out-of-band, hash passwords the same
|
canonical hasher; if you provision users out-of-band, hash passwords the same
|
||||||
@@ -93,33 +103,39 @@ The entrypoint leaves existing certs untouched (idempotent).
|
|||||||
|
|
||||||
## Service accounts
|
## Service accounts
|
||||||
|
|
||||||
There are two different kinds of "not a real person" account, and which one
|
A service account is a normal `posixAccount` for something that isn't a
|
||||||
you want depends on what's consuming it:
|
person: a media manager, a torrent client, a service like Emby, or a
|
||||||
|
read-only bind account an app uses to look users up — anything that needs a
|
||||||
|
real `uidNumber`/`gidNumber` to own files, or that other accounts join via a
|
||||||
|
group for write access (e.g. a `stuff_manager` group granting write rights
|
||||||
|
to a media library). There's only one kind — every account, person or
|
||||||
|
service, is a real `posixAccount` with a UID.
|
||||||
|
|
||||||
**LDAP bind-only** — for an app that just needs to bind LDAP to look users up
|
Create one from the **Users → Service Accounts** tab's "Add new user" form
|
||||||
(its own "LDAP authentication" settings page, or the read-only account
|
with **This is a service account** checked — it skips the birthday/
|
||||||
`theta42/ldap-client` binds as). Not a `posixAccount` — no `uidNumber`, no
|
Terms-of-Service fields a real person's account needs and asks for just an
|
||||||
home directory, can't log into this UI. Create one from the
|
account name. It's flagged (via membership in the `app_sso_service_account`
|
||||||
**Integrations → LDAP** tab's *Service Accounts* section (create, rotate
|
group) so it's listed separately from real people and excluded from "all
|
||||||
password, delete). theta-env's bootstrap creates `cn=ldapclient` this same
|
users" notification broadcasts.
|
||||||
way automatically, and the proxy binds as it — don't reuse the admin DN for
|
|
||||||
this.
|
|
||||||
|
|
||||||
**Unix/POSIX** — for an account something actually *runs as* on a Linux
|
Email and password are both optional for a service account:
|
||||||
host: a media manager, a torrent client, a service like Emby — anything that
|
|
||||||
needs a real `uidNumber`/`gidNumber` to own files or that other accounts join
|
|
||||||
via a group for write access (e.g. a `stuff_manager` group granting write
|
|
||||||
rights to a media library). Create one from the **Users** page's "Add new
|
|
||||||
user" form with **This is a service account** checked — it skips the
|
|
||||||
birthday/Terms-of-Service fields a real person's account needs and asks for
|
|
||||||
just an account name. It's a normal `posixAccount`, just flagged (via
|
|
||||||
membership in the `app_sso_service_account` group) so it's visibly marked in
|
|
||||||
the Users list and excluded from "all users" notification broadcasts.
|
|
||||||
|
|
||||||
Either way: don't reuse the admin DN, and give it only the group memberships
|
- No `mail` is set unless you give it one (it never needs a mailbox).
|
||||||
it actually needs.
|
- Leaving the password blank is fine — no `userPassword` attribute is set at
|
||||||
|
all, and an entry with no `userPassword` simply can't bind with any
|
||||||
|
password (standard LDAP simple-bind behavior). Only set a password if the
|
||||||
|
account actually needs to authenticate as itself (e.g. a bind-only account
|
||||||
|
an app uses to look users up).
|
||||||
|
|
||||||
Example bind test (LDAP bind-only account):
|
theta-env's bootstrap creates its own `cn=ldapclient` bind account directly
|
||||||
|
against LDAP (independent of this app), and the proxy binds as it — that
|
||||||
|
account won't show up in the Service Accounts tab since it isn't managed
|
||||||
|
through this app, but it keeps working unchanged.
|
||||||
|
|
||||||
|
Either way: don't reuse the admin DN, and give a service account only the
|
||||||
|
group memberships and `manager`s it actually needs.
|
||||||
|
|
||||||
|
Example bind test (a service account with a password set):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ldapsearch -x -H ldaps://sso.example.com:636 \
|
ldapsearch -x -H ldaps://sso.example.com:636 \
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: OAuth / OIDC
|
title: OAuth / OIDC
|
||||||
|
description: SSO Manager's OpenID Connect / OAuth 2.0 provider — discovery document, client registration, and token endpoints.
|
||||||
---
|
---
|
||||||
|
|
||||||
# OAuth 2.0 / OpenID Connect
|
# OAuth 2.0 / OpenID Connect
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://theta42.github.io/sso-manager-node/sitemap.xml
|
||||||
@@ -82,7 +82,6 @@ app.use('/api/user', middleware.auth, require('./routes/user'));
|
|||||||
app.use('/api/token', middleware.auth, require('./routes/token'));
|
app.use('/api/token', middleware.auth, require('./routes/token'));
|
||||||
|
|
||||||
app.use('/api/group', middleware.auth, require('./routes/group'));
|
app.use('/api/group', middleware.auth, require('./routes/group'));
|
||||||
app.use('/api/service-account', middleware.auth, require('./routes/service_account'));
|
|
||||||
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
||||||
app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
|
app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
|
||||||
app.use('/api/tos', middleware.auth, require('./routes/tos'));
|
app.use('/api/tos', middleware.auth, require('./routes/tos'));
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
// Non-person "service" accounts under ou=people -- bind-only LDAP identities
|
|
||||||
// for things like theta-env's bootstrap-created cn=ldapclient (the proxy's
|
|
||||||
// direct-LDAP bind account) or any other app/host that needs its own
|
|
||||||
// dedicated read-only credential, as opposed to a real user who logs into
|
|
||||||
// the web UI.
|
|
||||||
//
|
|
||||||
// Deliberately NOT posixAccount/inetOrgPerson (the User model's shape) --
|
|
||||||
// these can't log into the SSO Manager UI or get a home directory/uidNumber.
|
|
||||||
// objectClass matches exactly what theta-env's bootstrap.js already creates
|
|
||||||
// for cn=ldapclient, so this model recognizes and manages that account too,
|
|
||||||
// not just ones created through this UI.
|
|
||||||
|
|
||||||
const { Client, Attribute, Change } = require('ldapts');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const conf = require('@simpleworkjs/conf').ldap;
|
|
||||||
|
|
||||||
function hashPasswordSSHA512(password) {
|
|
||||||
const salt = crypto.randomBytes(8);
|
|
||||||
const hash = crypto.createHash('sha512').update(password).update(salt).digest();
|
|
||||||
return '{SSHA512}' + Buffer.concat([hash, salt]).toString('base64');
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeClient() {
|
|
||||||
return new Client({ url: conf.url });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withClient(fn) {
|
|
||||||
const client = makeClient();
|
|
||||||
try {
|
|
||||||
await client.bind(conf.bindDN, conf.bindPassword);
|
|
||||||
return await fn(client);
|
|
||||||
} finally {
|
|
||||||
await client.unbind().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const FILTER = '(&(objectClass=organizationalRole)(objectClass=simpleSecurityObject))';
|
|
||||||
const CN_RE = /^[A-Za-z][A-Za-z0-9._-]{1,63}$/;
|
|
||||||
|
|
||||||
var ServiceAccount = {};
|
|
||||||
|
|
||||||
ServiceAccount.list = async function(){
|
|
||||||
return withClient(async (client) => {
|
|
||||||
const res = await client.search(conf.userBase, {
|
|
||||||
scope: 'sub',
|
|
||||||
filter: FILTER,
|
|
||||||
attributes: ['cn', 'description', 'createTimestamp', 'modifyTimestamp'],
|
|
||||||
});
|
|
||||||
return res.searchEntries.map((entry) => ({
|
|
||||||
cn: entry.cn,
|
|
||||||
dn: `cn=${entry.cn},${conf.userBase}`,
|
|
||||||
description: entry.description || '',
|
|
||||||
created_on: entry.createTimestamp || null,
|
|
||||||
modified_on: entry.modifyTimestamp || null,
|
|
||||||
})).sort((a, b) => a.cn.localeCompare(b.cn));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
ServiceAccount.create = async function({cn, description}){
|
|
||||||
if(!cn || !CN_RE.test(cn)){
|
|
||||||
throw Object.assign(new Error('InvalidName'), {status: 400, message: 'Name must start with a letter and contain only letters, numbers, dot, dash, underscore.'});
|
|
||||||
}
|
|
||||||
|
|
||||||
const dn = `cn=${cn},${conf.userBase}`;
|
|
||||||
const password = crypto.randomBytes(24).toString('base64url');
|
|
||||||
|
|
||||||
await withClient(async (client) => {
|
|
||||||
let existing = true;
|
|
||||||
try{
|
|
||||||
const res = await client.search(dn, {scope: 'base', filter: '(objectClass=*)', attributes: ['dn']});
|
|
||||||
existing = res.searchEntries.length > 0;
|
|
||||||
}catch(error){ existing = false; }
|
|
||||||
if(existing){
|
|
||||||
throw Object.assign(new Error('NameInUse'), {status: 409, message: `"${cn}" already exists under ${conf.userBase}.`});
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.add(dn, {
|
|
||||||
objectClass: ['organizationalRole', 'simpleSecurityObject', 'top'],
|
|
||||||
cn,
|
|
||||||
description: description || '',
|
|
||||||
userPassword: hashPasswordSSHA512(password),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return {cn, dn, description: description || '', password};
|
|
||||||
};
|
|
||||||
|
|
||||||
ServiceAccount.setPassword = async function(cn, password){
|
|
||||||
const dn = `cn=${cn},${conf.userBase}`;
|
|
||||||
const newPassword = password || crypto.randomBytes(24).toString('base64url');
|
|
||||||
|
|
||||||
await withClient(async (client) => {
|
|
||||||
await client.modify(dn, [
|
|
||||||
new Change({
|
|
||||||
operation: 'replace',
|
|
||||||
modification: new Attribute({type: 'userPassword', values: [hashPasswordSSHA512(newPassword)]}),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {cn, dn, password: newPassword};
|
|
||||||
};
|
|
||||||
|
|
||||||
ServiceAccount.remove = async function(cn){
|
|
||||||
const dn = `cn=${cn},${conf.userBase}`;
|
|
||||||
await withClient(async (client) => {
|
|
||||||
await client.del(dn);
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {ServiceAccount};
|
|
||||||
@@ -103,7 +103,6 @@ async function addPosixAccount(client, data){
|
|||||||
givenName: data.givenName,
|
givenName: data.givenName,
|
||||||
loginShell: data.loginShell,
|
loginShell: data.loginShell,
|
||||||
homeDirectory: data.homeDirectory,
|
homeDirectory: data.homeDirectory,
|
||||||
userPassword: data.userPassword,
|
|
||||||
description: data.description || ' ',
|
description: data.description || ' ',
|
||||||
sudoHost: 'ALL',
|
sudoHost: 'ALL',
|
||||||
sudoCommand: 'ALL',
|
sudoCommand: 'ALL',
|
||||||
@@ -131,6 +130,19 @@ async function addPosixAccount(client, data){
|
|||||||
entry.dateOfBirth = data.dob;
|
entry.dateOfBirth = data.dob;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userPassword is optional -- a service account with no password set
|
||||||
|
// simply can't bind (no special enforcement needed, that's the default
|
||||||
|
// LDAP simple-bind behavior for an entry lacking the attribute).
|
||||||
|
if (data.userPassword) {
|
||||||
|
entry.userPassword = data.userPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
// manager (COSINE, SUP distinguishedName) is naturally multi-valued --
|
||||||
|
// every account gets at least the DN of whoever created it.
|
||||||
|
if (data.manager && [].concat(data.manager).length) {
|
||||||
|
entry.manager = [].concat(data.manager);
|
||||||
|
}
|
||||||
|
|
||||||
await client.add(`cn=${data.cn},${conf.userBase}`, entry);
|
await client.add(`cn=${data.cn},${conf.userBase}`, entry);
|
||||||
|
|
||||||
return data
|
return data
|
||||||
@@ -151,9 +163,13 @@ async function addLdapUser(client, data){
|
|||||||
data.uid = `${data.givenName[0]}${data.sn}`.toLowerCase();
|
data.uid = `${data.givenName[0]}${data.sn}`.toLowerCase();
|
||||||
}
|
}
|
||||||
data.cn = data.uid;
|
data.cn = data.uid;
|
||||||
data.loginShell = '/bin/bash';
|
data.loginShell = data.loginShell || '/bin/bash';
|
||||||
data.homeDirectory= `/home/${data.uid}`;
|
data.homeDirectory = data.homeDirectory || `/home/${data.uid}`;
|
||||||
data.userPassword = hashPasswordSSHA512(data.userPassword);
|
if (data.userPassword) {
|
||||||
|
data.userPassword = hashPasswordSSHA512(data.userPassword);
|
||||||
|
} else {
|
||||||
|
delete data.userPassword;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('addLdapUser', data)
|
console.log('addLdapUser', data)
|
||||||
group = await addPosixGroup(client, data);
|
group = await addPosixGroup(client, data);
|
||||||
@@ -194,6 +210,11 @@ 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
|
||||||
|
// a bare string for a single value and an array for multiple -- normalize
|
||||||
|
// to always be an array of DNs.
|
||||||
|
data.manager = [].concat(data.manager || []).filter(Boolean);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +263,8 @@ User.listDetail = async function(){
|
|||||||
serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase()));
|
serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase()));
|
||||||
}catch(error){ /* group not seeded yet on an old deployment -- treat as none */ }
|
}catch(error){ /* group not seeded yet on an old deployment -- treat as none */ }
|
||||||
|
|
||||||
|
const dnToUid = new Map(searchEntries.map(e => [String(e.dn).toLowerCase(), e.uid]));
|
||||||
|
|
||||||
const users = await Promise.all(searchEntries.map(async (entry) => {
|
const users = await Promise.all(searchEntries.map(async (entry) => {
|
||||||
const rawPassword = entry.userPassword ? entry.userPassword.toString() : '';
|
const rawPassword = entry.userPassword ? entry.userPassword.toString() : '';
|
||||||
const isLegacyMD5 = rawPassword.toUpperCase().startsWith('{MD5}');
|
const isLegacyMD5 = rawPassword.toUpperCase().startsWith('{MD5}');
|
||||||
@@ -269,6 +292,7 @@ User.listDetail = async function(){
|
|||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
|
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
|
||||||
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
|
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
|
||||||
|
obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn);
|
||||||
|
|
||||||
return obj;
|
return obj;
|
||||||
}));
|
}));
|
||||||
@@ -421,7 +445,7 @@ User.update = async function(data){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let editableFeilds = ['mobile', 'description'];
|
let editableFeilds = ['mobile', 'description', 'homeDirectory', 'loginShell'];
|
||||||
|
|
||||||
await withClient(async (client) => {
|
await withClient(async (client) => {
|
||||||
for(let field of editableFeilds){
|
for(let field of editableFeilds){
|
||||||
@@ -469,6 +493,21 @@ User.update = async function(data){
|
|||||||
]);
|
]);
|
||||||
this.dateOfBirth = data.dateOfBirth;
|
this.dateOfBirth = data.dateOfBirth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(data.manager !== undefined){
|
||||||
|
// Client sends uids; resolve each to a DN before writing --
|
||||||
|
// manager (COSINE, SUP distinguishedName) stores DNs, not uids.
|
||||||
|
const uids = [].concat(data.manager || []).filter(Boolean);
|
||||||
|
const managers = await Promise.all(uids.map(uid => User.get(uid)));
|
||||||
|
const dns = managers.map(u => u.dn);
|
||||||
|
await client.modify(this.dn, [
|
||||||
|
new Change({
|
||||||
|
operation: 'replace',
|
||||||
|
modification: new Attribute({ type: 'manager', values: dns }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
this.manager = dns;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
|
||||||
@@ -537,6 +576,12 @@ User.addByInvite = async function(data){
|
|||||||
|
|
||||||
data.mail = token.mail;
|
data.mail = token.mail;
|
||||||
|
|
||||||
|
// Default manager: whoever sent the invite.
|
||||||
|
try {
|
||||||
|
const inviter = await this.get(token.created_by);
|
||||||
|
data.manager = [inviter.dn];
|
||||||
|
} catch(e) { /* inviter no longer exists -- leave manager unset */ }
|
||||||
|
|
||||||
const suggestions = await this.usernameSuggestions(data.givenName, data.sn, data.dob);
|
const suggestions = await this.usernameSuggestions(data.givenName, data.sn, data.dob);
|
||||||
if (!data.uid || !suggestions.includes(data.uid)) {
|
if (!data.uid || !suggestions.includes(data.uid)) {
|
||||||
const err = new Error('Invalid username selection');
|
const err = new Error('Invalid username selection');
|
||||||
|
|||||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.1.4",
|
"version": "1.1.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.1.4",
|
"version": "1.1.7",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
"jq-repeat": "^2.0.1",
|
"jq-repeat": "^2.1.0",
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^3.7.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"ldapts": "^8.1.2",
|
"ldapts": "^8.1.2",
|
||||||
@@ -4357,9 +4357,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jq-repeat": {
|
"node_modules/jq-repeat": {
|
||||||
"version": "2.0.1",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.1.0.tgz",
|
||||||
"integrity": "sha512-ATI25tKQG3uHW8f8XPqBe85JsH4PNGHA/YLy1KgMVeYDoUSf9cqGNBum+4A+Pg1WKh9PA6bYyWfYNsgktwIbSg==",
|
"integrity": "sha512-e1OmSWeBEHEtyOhNVysx0bnT5wd6HlZ37JZgPcGPmACJ0K9bXDPq0xOwrM1slQMSTw7FOSNDX+MD6VwvPeeZyQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.1.4",
|
"version": "1.1.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
"jq-repeat": "^2.0.1",
|
"jq-repeat": "^2.1.0",
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^3.7.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"ldapts": "^8.1.2",
|
"ldapts": "^8.1.2",
|
||||||
|
|||||||
+43
-2
@@ -102,7 +102,15 @@ app.user = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {list, remove, createInvite, setActive};
|
// A user DN's cn is always their uid (see models/user_ldap.js addLdapUser,
|
||||||
|
// `data.cn = data.uid`) -- pulling it straight out of the DN avoids an
|
||||||
|
// extra lookup just to display a manager list.
|
||||||
|
function dnToUid(dn){
|
||||||
|
var m = /^cn=([^,]+)/i.exec(dn || '');
|
||||||
|
return m ? m[1] : dn;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {list, remove, createInvite, setActive, dnToUid};
|
||||||
|
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
@@ -149,6 +157,21 @@ app.ui = (function(app){
|
|||||||
// Drop the cache (e.g. after a group is created) so the next selector refetches.
|
// Drop the cache (e.g. after a group is created) so the next selector refetches.
|
||||||
function refreshGroups(){ _groupsPromise = null; return loadGroups(); }
|
function refreshGroups(){ _groupsPromise = null; return loadGroups(); }
|
||||||
|
|
||||||
|
// All usernames, fetched once and shared across every user selector (e.g. manager pickers).
|
||||||
|
var _usersPromise = null;
|
||||||
|
function loadUsers(){
|
||||||
|
if(!_usersPromise){
|
||||||
|
_usersPromise = new Promise(function(resolve){
|
||||||
|
app.user.list(function(error, data){
|
||||||
|
if(error || !data || !data.results){ resolve([]); return; }
|
||||||
|
resolve(data.results.map(function(u){ return u.uid; }).filter(Boolean).sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _usersPromise;
|
||||||
|
}
|
||||||
|
function refreshUsers(){ _usersPromise = null; return loadUsers(); }
|
||||||
|
|
||||||
// opts: { values, options, freeSolo, placeholder, name, separator }
|
// opts: { values, options, freeSolo, placeholder, name, separator }
|
||||||
// Returns a handle: { get, set, add, clear, setOptions, element }.
|
// Returns a handle: { get, set, add, clear, setOptions, element }.
|
||||||
function tagInput(mount, opts){
|
function tagInput(mount, opts){
|
||||||
@@ -249,7 +272,25 @@ app.ui = (function(app){
|
|||||||
return handle;
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { tagInput: tagInput, groupSelect: groupSelect, loadGroups: loadGroups, refreshGroups: refreshGroups };
|
// Universal user selector (e.g. picking managers). Preloads all usernames.
|
||||||
|
function userSelect(mount, opts){
|
||||||
|
opts = opts || {};
|
||||||
|
var handle = tagInput(mount, {
|
||||||
|
name: opts.name || 'manager',
|
||||||
|
values: opts.values || [],
|
||||||
|
options: [],
|
||||||
|
freeSolo: opts.freeSolo !== false,
|
||||||
|
separator: opts.separator != null ? opts.separator : '\n',
|
||||||
|
placeholder: opts.placeholder || 'Type a username…',
|
||||||
|
});
|
||||||
|
loadUsers().then(function(users){ handle.setOptions(users); });
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tagInput: tagInput, groupSelect: groupSelect, loadGroups: loadGroups, refreshGroups: refreshGroups,
|
||||||
|
userSelect: userSelect, loadUsers: loadUsers, refreshUsers: refreshUsers,
|
||||||
|
};
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
app.oauthClient = (function(app){
|
app.oauthClient = (function(app){
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const router = require('express').Router();
|
|
||||||
const {ServiceAccount} = require('../models/service_account');
|
|
||||||
const permission = require('../utils/permission');
|
|
||||||
|
|
||||||
const ADMIN_GROUP = 'app_sso_admin';
|
|
||||||
|
|
||||||
router.get('/', async function(req, res, next) {
|
|
||||||
try {
|
|
||||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
|
||||||
return res.json({results: await ServiceAccount.list()});
|
|
||||||
} catch(error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/', async function(req, res, next) {
|
|
||||||
try {
|
|
||||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
|
||||||
const result = await ServiceAccount.create({cn: req.body.cn, description: req.body.description});
|
|
||||||
return res.json({
|
|
||||||
results: result,
|
|
||||||
message: `Service account "${result.cn}" created. Save the password now — it will not be shown again.`,
|
|
||||||
});
|
|
||||||
} catch(error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/:cn/password', async function(req, res, next) {
|
|
||||||
try {
|
|
||||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
|
||||||
const result = await ServiceAccount.setPassword(req.params.cn, req.body.password);
|
|
||||||
return res.json({
|
|
||||||
results: result,
|
|
||||||
message: `Password rotated for "${req.params.cn}". Save it now — it will not be shown again.`,
|
|
||||||
});
|
|
||||||
} catch(error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/:cn', async function(req, res, next) {
|
|
||||||
try {
|
|
||||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
|
||||||
await ServiceAccount.remove(req.params.cn);
|
|
||||||
return res.json({message: `Service account "${req.params.cn}" deleted.`});
|
|
||||||
} catch(error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
+10
-1
@@ -23,6 +23,7 @@ router.post('/', async function(req, res, next){
|
|||||||
await permission.byGroup(req.user, ['app_sso_admin'])
|
await permission.byGroup(req.user, ['app_sso_admin'])
|
||||||
|
|
||||||
req.body.created_by = req.user.uid
|
req.body.created_by = req.user.uid
|
||||||
|
req.body.manager = [req.user.dn];
|
||||||
|
|
||||||
const user = await User.add(req.body);
|
const user = await User.add(req.body);
|
||||||
const verif = await UserVerification.getOrCreate(user.uid);
|
const verif = await UserVerification.getOrCreate(user.uid);
|
||||||
@@ -145,7 +146,15 @@ router.put('/:uid', async function(req, res, next){
|
|||||||
user = req.user;
|
user = req.user;
|
||||||
}else{
|
}else{
|
||||||
user = await User.get(req.params.uid);
|
user = await User.get(req.params.uid);
|
||||||
await permission.byGroup(req.user, ['app_sso_admin'])
|
const isManager = (user.manager || []).includes(req.user.dn);
|
||||||
|
if(!isManager) await permission.byGroup(req.user, ['app_sso_admin'])
|
||||||
|
}
|
||||||
|
|
||||||
|
// The manager picker is a tag widget backed by a single newline-separated
|
||||||
|
// hidden input (see public/js/app.js app.ui.userSelect), same convention
|
||||||
|
// as oauth_client.js's allowed_groups.
|
||||||
|
if (typeof req.body.manager === 'string') {
|
||||||
|
req.body.manager = req.body.manager.split('\n').map(s => s.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|||||||
@@ -208,40 +208,8 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Service accounts ──────────────────────────────────────────────────
|
|
||||||
async function svcTableAJAX(){
|
|
||||||
let data = await app.api.get('service-account');
|
|
||||||
$.scope.serviceAccountCard.empty();
|
|
||||||
$.each(data.results, function(_, acct){
|
|
||||||
$.scope.serviceAccountCard.push(acct);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rotateServiceAccountPassword(cn, btn){
|
|
||||||
const $card = $(btn).closest('.card');
|
|
||||||
const confirmed = await app.util.actionConfirm('Rotate the password for "' + cn + '"? Anything still using the old password will stop working immediately.', $card, 'warning');
|
|
||||||
if (!confirmed) return;
|
|
||||||
app.api.put('service-account/' + encodeURIComponent(cn) + '/password', {}, function(error, data){
|
|
||||||
if(error){ app.util.actionMessage('Error: ' + (data && data.message), $card, 'danger'); return; }
|
|
||||||
showSecret(data.results.password, 'Password for ' + cn);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteServiceAccount(cn, btn){
|
|
||||||
const $card = $(btn).closest('.card');
|
|
||||||
$card.addClass('table-warning');
|
|
||||||
const confirmed = await app.util.actionConfirm('Delete service account "' + cn + '"? Anything binding as it will stop working immediately.', $card, 'warning');
|
|
||||||
$card.removeClass('table-warning');
|
|
||||||
if (!confirmed) return;
|
|
||||||
app.api.delete('service-account/' + encodeURIComponent(cn), function(error, data){
|
|
||||||
if(error){ app.util.actionMessage('Error: ' + (data && data.message), $card, 'danger'); return; }
|
|
||||||
$.scope.serviceAccountCard.remove('cn', cn);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
tableAJAX();
|
tableAJAX();
|
||||||
svcTableAJAX();
|
|
||||||
|
|
||||||
// Initialise the create-form tag widgets.
|
// Initialise the create-form tag widgets.
|
||||||
createScopes = app.ui.tagInput('#create-scopes', {
|
createScopes = app.ui.tagInput('#create-scopes', {
|
||||||
@@ -256,9 +224,6 @@
|
|||||||
$('form[action="oauth/client/"]').attr('evalAJAX',
|
$('form[action="oauth/client/"]').attr('evalAJAX',
|
||||||
'showSecret(data.client_secret, "Client Secret"); tableAJAX(); $form.trigger("reset"); createScopes.set(DEFAULT_SCOPES); createGroups.clear();'
|
'showSecret(data.client_secret, "Client Secret"); tableAJAX(); $form.trigger("reset"); createScopes.set(DEFAULT_SCOPES); createGroups.clear();'
|
||||||
);
|
);
|
||||||
$('form[action="service-account/"]').attr('evalAJAX',
|
|
||||||
'showSecret(data.password, "Password for " + data.cn); svcTableAJAX(); $form.trigger("reset");'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -509,8 +474,9 @@
|
|||||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-bindDn', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-bindDn', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<small class="field-help text-muted d-block">
|
<small class="field-help text-muted d-block">
|
||||||
A read-only bind account — create one below under
|
A read-only bind account — create one from
|
||||||
<b>Service Accounts</b> (don't reuse a real person's login or the admin DN).
|
<a href="/users">Users > Service Accounts</a> (don't reuse a real
|
||||||
|
person's login or the admin DN).
|
||||||
</small>
|
</small>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -527,9 +493,10 @@
|
|||||||
<p class="text-muted small">
|
<p class="text-muted small">
|
||||||
For full host login, SSH keys, and sudo via LDAP (not just one app) —
|
For full host login, SSH keys, and sudo via LDAP (not just one app) —
|
||||||
clone <a href="https://github.com/theta42/ldap-client" target="_blank">theta42/ldap-client</a>
|
clone <a href="https://github.com/theta42/ldap-client" target="_blank">theta42/ldap-client</a>
|
||||||
and run this on the host. Fill in a service account's password (create
|
and run this on the host. Fill in a service account's password
|
||||||
one below) and, if you want this host's access/sudo groups
|
(create one from <a href="/users">Users > Service Accounts</a>) and,
|
||||||
auto-registered, an <a href="/">API token</a> from your Profile.
|
if you want this host's access/sudo groups auto-registered, an
|
||||||
|
<a href="/">API token</a> from your Profile.
|
||||||
</p>
|
</p>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<textarea id="f-bashSnippet" class="form-control font-monospace" rows="16" readonly style="font-size:.8rem"></textarea>
|
<textarea id="f-bashSnippet" class="form-control font-monospace" rows="16" readonly style="font-size:.8rem"></textarea>
|
||||||
@@ -541,65 +508,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card shadow-sm border-info">
|
|
||||||
<div class="card-header bg-info bg-opacity-10">
|
|
||||||
<i class="fa-solid fa-user-gear"></i> Service Accounts
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="text-muted small mb-3">
|
|
||||||
Bind-only LDAP identities for apps and hosts — not real people, can't log
|
|
||||||
into this UI, no home directory. theta-env's <code>cn=ldapclient</code>
|
|
||||||
bootstrap account (used by theta42/proxy) shows up here too, since it's
|
|
||||||
the same kind of account.
|
|
||||||
<br>
|
|
||||||
Need an account something actually <i>runs as</i> on a Linux host instead
|
|
||||||
(a media manager, a torrent client, ...) — with a real <code>uidNumber</code>
|
|
||||||
and a group other accounts join for write access? That's a Unix account, not
|
|
||||||
a bind-only one — create it from <a href="/users">Users</a> with
|
|
||||||
<b>This is a service account</b> checked.
|
|
||||||
</p>
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<form action="service-account/" method="post" onsubmit="formAJAX(this)">
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label">Name</label>
|
|
||||||
<input type="text" class="form-control shadow" name="cn" placeholder="ldapclient" validate=":1">
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label">Description <small class="text-muted">(optional)</small></label>
|
|
||||||
<input type="text" class="form-control shadow" name="description" placeholder="Bind account for gitea.example.com">
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-outline-dark btn-sm">
|
|
||||||
<i class="fa-solid fa-plus"></i> Create
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-sm mb-0">
|
|
||||||
<thead><tr><th>Name</th><th>Description</th><th></th></tr></thead>
|
|
||||||
<tbody jq-repeat="serviceAccountCard">
|
|
||||||
<tr>
|
|
||||||
<td><code>cn={{cn}},<%= userBase %></code></td>
|
|
||||||
<td>{{description}}</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-warning" title="Rotate password" onclick="rotateServiceAccountPassword('{{cn}}', this)">
|
|
||||||
<i class="fa-solid fa-key"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" title="Delete" onclick="deleteServiceAccount('{{cn}}', this)">
|
|
||||||
<i class="fa-solid fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -613,8 +521,8 @@
|
|||||||
'export ldap_host="<%= ldapHost %>"',
|
'export ldap_host="<%= ldapHost %>"',
|
||||||
'export ldap_base_dn="<%= baseDn %>"',
|
'export ldap_base_dn="<%= baseDn %>"',
|
||||||
'',
|
'',
|
||||||
'# A read-only service account -- create one under Service Accounts',
|
'# A read-only service account -- create one under Users > Service',
|
||||||
'# above, then fill in its password below.',
|
'# Accounts, then fill in its password below.',
|
||||||
'export ldap_bind_dn="<%= exampleBindDn %>"',
|
'export ldap_bind_dn="<%= exampleBindDn %>"',
|
||||||
'export ldap_bind_password="CHANGE-ME"',
|
'export ldap_bind_password="CHANGE-ME"',
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
// data.photo = unescape(encodeURIComponent(data.jpegPhoto));
|
// data.photo = unescape(encodeURIComponent(data.jpegPhoto));
|
||||||
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
||||||
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
||||||
|
user.managerUids = (user.manager || []).map(app.user.dnToUid);
|
||||||
|
|
||||||
$.scope.user.update(user);
|
$.scope.user.update(user);
|
||||||
$.scope.passwordReset.update(user);
|
$.scope.passwordReset.update(user);
|
||||||
@@ -40,15 +41,31 @@
|
|||||||
var $editCard = $('#editProfile');
|
var $editCard = $('#editProfile');
|
||||||
|
|
||||||
$.scope.editProfile.update(user);
|
$.scope.editProfile.update(user);
|
||||||
$profileCard.slideUp();
|
// jq-repeat's update() is trailing-edge throttled (~50ms) as of 2.1.0 --
|
||||||
$editCard.slideDown();
|
// wait for the throttle tick to land before sliding the updated card
|
||||||
|
// into view, or it can briefly show stale/empty data. The manager
|
||||||
|
// picker is a JS widget, not a mustache-bound input, so it also has to
|
||||||
|
// wait for update() to (re-)render its empty mount div before attaching.
|
||||||
|
setTimeout(function(){
|
||||||
|
app.ui.userSelect('#edit-manager', {
|
||||||
|
name: 'manager',
|
||||||
|
values: user.managerUids || [],
|
||||||
|
placeholder: 'Type a username…',
|
||||||
|
});
|
||||||
|
$profileCard.slideUp();
|
||||||
|
$editCard.slideDown();
|
||||||
|
}, 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
function editUserSeccess(data){
|
function editUserSeccess(data){
|
||||||
currentUser = data.results;
|
currentUser = data.results;
|
||||||
renderProfile(currentUser);
|
renderProfile(currentUser);
|
||||||
$('#editProfile').slideUp();
|
// Same throttle-tick wait as editUser() above -- renderProfile() calls
|
||||||
$('#userProfile').slideDown()
|
// $.scope.user.update()/passwordReset.update() internally.
|
||||||
|
setTimeout(function(){
|
||||||
|
$('#editProfile').slideUp();
|
||||||
|
$('#userProfile').slideDown()
|
||||||
|
}, 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleActive(uid, active){
|
async function toggleActive(uid, active){
|
||||||
@@ -152,6 +169,9 @@
|
|||||||
<i>LDAP DN:</i> <b>{{dn}} </b><br />
|
<i>LDAP DN:</i> <b>{{dn}} </b><br />
|
||||||
<i>Home Directory:</i> <b>{{homeDirectory}} </b><br />
|
<i>Home Directory:</i> <b>{{homeDirectory}} </b><br />
|
||||||
<i>Login Shell:</i> <b>{{loginShell}} </b><br />
|
<i>Login Shell:</i> <b>{{loginShell}} </b><br />
|
||||||
|
<i>Manager(s):</i>
|
||||||
|
{{#managerUids}}<span class="badge bg-secondary me-1">{{.}}</span>{{/managerUids}}
|
||||||
|
<br />
|
||||||
<i>Status:</i>
|
<i>Status:</i>
|
||||||
{{#isActive}}<span class="badge bg-success">Active</span>{{/isActive}}
|
{{#isActive}}<span class="badge bg-success">Active</span>{{/isActive}}
|
||||||
{{#isInactive}}<span class="badge bg-danger">Inactive</span>{{/isInactive}}
|
{{#isInactive}}<span class="badge bg-danger">Inactive</span>{{/isInactive}}
|
||||||
@@ -229,6 +249,18 @@
|
|||||||
<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" validate=":9" value="{{mobile}}" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Home Directory</label>
|
||||||
|
<input type="text" class="form-control" name="homeDirectory" placeholder="/home/jsmith" value="{{homeDirectory}}" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Login Shell</label>
|
||||||
|
<input type="text" class="form-control" name="loginShell" placeholder="/bin/bash" value="{{loginShell}}" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Manager(s)</label>
|
||||||
|
<div id="edit-manager"></div>
|
||||||
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">User Description (Optional)</label>
|
<label class="form-label">User Description (Optional)</label>
|
||||||
<textarea class="form-control" name="description" placeholder="Admin group for gitea app">{{description}}</textarea>
|
<textarea class="form-control" name="description" placeholder="Admin group for gitea app">{{description}}</textarea>
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ async function fetchUsernameSuggestions() {
|
|||||||
$form.find('#personNameFields').toggle(!checked);
|
$form.find('#personNameFields').toggle(!checked);
|
||||||
$form.find('#serviceAccountNameField').toggle(checked);
|
$form.find('#serviceAccountNameField').toggle(checked);
|
||||||
|
|
||||||
|
// Service accounts aren't a person with a mailbox, and a blank
|
||||||
|
// password is fine (no userPassword attribute set -- the account
|
||||||
|
// simply can't bind). Disabling (not just hiding) keeps disabled
|
||||||
|
// fields out of both form serialization and validation.
|
||||||
|
$form.find('[name=mail]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
||||||
|
$form.find('[name=userPassword]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
||||||
|
$form.find('[name=passwordMatch]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
||||||
|
|
||||||
if(checked){
|
if(checked){
|
||||||
// Filler values so the LDAP schema (inetOrgPerson requires sn) is
|
// Filler values so the LDAP schema (inetOrgPerson requires sn) is
|
||||||
// satisfied; not shown anywhere, the account name is what matters.
|
// satisfied; not shown anywhere, the account name is what matters.
|
||||||
|
|||||||
+182
-100
@@ -3,15 +3,17 @@
|
|||||||
|
|
||||||
</script>
|
</script>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function renderUsers(actionMessage, type){
|
function renderUsers(){
|
||||||
|
|
||||||
app.user.list(function(error, data){
|
app.user.list(function(error, data){
|
||||||
if(error){
|
if(error){
|
||||||
app.util.actionMessage(data.message, $target, 'danger');
|
app.util.actionMessage(data.message, $('#tab-people'), 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$.scope.userRow.push(...data.results);
|
$.scope.userRow.empty();
|
||||||
|
$.scope.serviceAccountRow.empty();
|
||||||
|
const results = data.results || [];
|
||||||
|
$.scope.userRow.push(...results.filter(u => !u.isServiceAccount));
|
||||||
|
$.scope.serviceAccountRow.push(...results.filter(u => u.isServiceAccount));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,110 +102,190 @@
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<div class="row" style="display:none">
|
<h4><i class="fa-solid fa-users"></i> Users</h4>
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="shadow-lg card mb-3 card-default group-required group-required-app_sso_admin">
|
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||||
<div class="card-header shadow">
|
<li class="nav-item" role="presentation">
|
||||||
<i class="fas fa-user-plus"></i>
|
<button class="nav-link active" id="tab-people-btn" data-bs-toggle="tab" data-bs-target="#tab-people" type="button" role="tab">
|
||||||
Invite User
|
<i class="fa-solid fa-user"></i> People
|
||||||
<span class="float-end">
|
</button>
|
||||||
<i class="fa-solid fa-arrows-up-down"></i>
|
</li>
|
||||||
</span>
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="tab-service-accounts-btn" data-bs-toggle="tab" data-bs-target="#tab-service-accounts" type="button" role="tab">
|
||||||
|
<i class="fa-solid fa-gears"></i> Service Accounts
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-pane fade show active" id="tab-people" role="tabpanel">
|
||||||
|
<div class="row" style="display:none">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="shadow-lg card mb-3 card-default group-required group-required-app_sso_admin">
|
||||||
|
<div class="card-header shadow">
|
||||||
|
<i class="fas fa-user-plus"></i>
|
||||||
|
Invite User
|
||||||
|
<span class="float-end">
|
||||||
|
<i class="fa-solid fa-arrows-up-down"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-header shadow actionMessage" style="display: none;"></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Email <small class="text-muted">(optional — sends invite immediately)</small></label>
|
||||||
|
<input type="email" id="invite-email" class="form-control form-control-sm shadow" placeholder="user@example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Groups <small class="text-muted">(optional — hold Ctrl/⌘ for multiple)</small></label>
|
||||||
|
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'invite-groups')" />
|
||||||
|
<select id="invite-groups" class="form-select form-select-sm shadow" multiple size="4"></select>
|
||||||
|
</div>
|
||||||
|
<button onclick="sendInvite()" class="btn btn-sm btn-outline-dark shadow">
|
||||||
|
<i class="fa-solid fa-envelope"></i> Send Invite
|
||||||
|
</button>
|
||||||
|
<div id="invite-result" style="display:none" class="mt-2"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header shadow actionMessage" style="display: none;"></div>
|
<div class="card shadow-lg">
|
||||||
<div class="card-body">
|
<div class="card-header">
|
||||||
<div class="mb-2">
|
<i class="fas fa-user-plus"></i>
|
||||||
<label class="form-label small">Email <small class="text-muted">(optional — sends invite immediately)</small></label>
|
Add new user
|
||||||
<input type="email" id="invite-email" class="form-control form-control-sm shadow" placeholder="user@example.com" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
<label class="form-label small">Groups <small class="text-muted">(optional — hold Ctrl/⌘ for multiple)</small></label>
|
<div class="card-body">
|
||||||
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'invite-groups')" />
|
<%- include('user_form', {adminMode: true}) %>
|
||||||
<select id="invite-groups" class="form-select form-select-sm shadow" multiple size="4"></select>
|
|
||||||
</div>
|
</div>
|
||||||
<button onclick="sendInvite()" class="btn btn-sm btn-outline-dark shadow">
|
|
||||||
<i class="fa-solid fa-envelope"></i> Send Invite
|
|
||||||
</button>
|
|
||||||
<div id="invite-result" style="display:none" class="mt-2"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card shadow-lg">
|
<div class="col-md-8">
|
||||||
<div class="card-header">
|
<div class="card shadow">
|
||||||
<i class="fas fa-user-plus"></i>
|
<div class="card-header">
|
||||||
Add new user
|
<i class="fa-solid fa-users"></i>
|
||||||
</div>
|
User List
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
<%- include('user_form', {adminMode: true}) %>
|
<div class="table-responsive">
|
||||||
</div>
|
<table class="card-body table table-striped" style="margin-bottom:0">
|
||||||
</div>
|
<thead>
|
||||||
</div>
|
<th>ID</th>
|
||||||
<div class="col-md-8">
|
<th>Name</th>
|
||||||
<div class="card shadow">
|
<th>eMail</th>
|
||||||
<div class="card-header">
|
<th>Key</th>
|
||||||
<i class="fa-solid fa-users"></i>
|
<th>Active</th>
|
||||||
User List
|
<th>TOS</th>
|
||||||
</div>
|
<th></th>
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
</thead>
|
||||||
<div class="table-responsive">
|
<tbody id="tableAJAX">
|
||||||
<table class="card-body table table-striped" style="margin-bottom:0">
|
<tr jq-repeat="userRow">
|
||||||
<thead>
|
<td>
|
||||||
<th>ID</th>
|
{{ uidNumber }}
|
||||||
<th>Name</th>
|
</td>
|
||||||
<th>eMail</th>
|
<td>
|
||||||
<th>Key</th>
|
<a href='/users/{{uid}}'>{{givenName}} {{sn}}</a>
|
||||||
<th>Active</th>
|
</td>
|
||||||
<th>TOS</th>
|
<td>
|
||||||
<th></th>
|
{{mail}}
|
||||||
</thead>
|
</td>
|
||||||
<tbody id="tableAJAX">
|
<td>
|
||||||
<tr jq-repeat="userRow">
|
{{#sshPublicKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/sshPublicKey}}
|
||||||
<td>
|
</td>
|
||||||
{{ uidNumber }}
|
<td>
|
||||||
</td>
|
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
|
||||||
<td>
|
{{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}}
|
||||||
<a href='/users/{{uid}}'>{{givenName}} {{sn}}</a>
|
</td>
|
||||||
{{#isServiceAccount}}<span class="badge bg-secondary" title="Service account — not a person"><i class="fa-solid fa-gears"></i> service</span>{{/isServiceAccount}}
|
<td>
|
||||||
</td>
|
{{#tosAccepted}}<i class="fa-solid fa-circle-check text-success" title="TOS accepted"></i>{{/tosAccepted}}
|
||||||
<td>
|
{{#tosNotAccepted}}<i class="fa-solid fa-circle-xmark text-danger" title="TOS not accepted"></i>{{/tosNotAccepted}}
|
||||||
{{mail}}
|
</td>
|
||||||
</td>
|
<td class="text-nowrap">
|
||||||
<td>
|
{{#isActive}}
|
||||||
{{#sshPublicKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/sshPublicKey}}
|
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)">
|
||||||
</td>
|
<i class="fa-solid fa-lock"></i>
|
||||||
<td>
|
</button>
|
||||||
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
|
{{/isActive}}
|
||||||
{{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}}
|
{{#isInactive}}
|
||||||
</td>
|
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)">
|
||||||
<td>
|
<i class="fa-solid fa-lock-open"></i>
|
||||||
{{#tosAccepted}}<i class="fa-solid fa-circle-check text-success" title="TOS accepted"></i>{{/tosAccepted}}
|
</button>
|
||||||
{{#tosNotAccepted}}<i class="fa-solid fa-circle-xmark text-danger" title="TOS not accepted"></i>{{/tosNotAccepted}}
|
{{/isInactive}}
|
||||||
</td>
|
<button class="btn btn-sm btn-outline-secondary me-1" title="Impersonate" onclick="startImpersonate('{{uid}}')">
|
||||||
<td class="text-nowrap">
|
<i class="fa-solid fa-user-secret"></i>
|
||||||
{{#isActive}}
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)">
|
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
|
||||||
<i class="fa-solid fa-lock"></i>
|
<i class="fa-solid fa-user-slash"></i>
|
||||||
</button>
|
</button>
|
||||||
{{/isActive}}
|
</td>
|
||||||
{{#isInactive}}
|
</tr>
|
||||||
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)">
|
</tbody>
|
||||||
<i class="fa-solid fa-lock-open"></i>
|
</table>
|
||||||
</button>
|
</div>
|
||||||
{{/isInactive}}
|
|
||||||
<button class="btn btn-sm btn-outline-secondary me-1" title="Impersonate" onclick="startImpersonate('{{uid}}')">
|
|
||||||
<i class="fa-solid fa-user-secret"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
|
|
||||||
<i class="fa-solid fa-user-slash"></i>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="tab-pane fade" id="tab-service-accounts" role="tabpanel">
|
||||||
|
<div class="row" style="display:none">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="card-body table table-striped" style="margin-bottom:0">
|
||||||
|
<thead>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Manager(s)</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Active</th>
|
||||||
|
<th></th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr jq-repeat="serviceAccountRow">
|
||||||
|
<td>
|
||||||
|
<a href='/users/{{uid}}'>{{uid}}</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{description}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{#managerUids}}<span class="badge bg-secondary me-1">{{.}}</span>{{/managerUids}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{createTimestamp}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
|
||||||
|
{{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}}
|
||||||
|
</td>
|
||||||
|
<td class="text-nowrap">
|
||||||
|
{{#isActive}}
|
||||||
|
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)">
|
||||||
|
<i class="fa-solid fa-lock"></i>
|
||||||
|
</button>
|
||||||
|
{{/isActive}}
|
||||||
|
{{#isInactive}}
|
||||||
|
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)">
|
||||||
|
<i class="fa-solid fa-lock-open"></i>
|
||||||
|
</button>
|
||||||
|
{{/isInactive}}
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
|
||||||
|
<i class="fa-solid fa-user-slash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<%- include('impersonate_modal') %>
|
<%- include('impersonate_modal') %>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
Reference in New Issue
Block a user