Compare commits

...

6 Commits

Author SHA1 Message Date
wmantly 96adf60cf7 Merge pull request #78 from theta42/personal-group-members
Add personal Unix group member management
2026-07-17 11:31:11 -04:00
wmantly 82f703f560 Add personal Unix group member management
Every account gets a personal posixGroup at creation (its primary GID
holder) but there was no way to manage its memberUid list -- add
add/remove endpoints and a profile-page UI (admin-only), reusing the
userSelect widget already built for the manager field.

Bumps to v1.1.9.

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

Bumps to v1.1.8.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 11:00:50 -04:00
wmantly 3ad817767a Merge pull request #76 from theta42/accounts-manager-rework
Unify service accounts, add manager field, editable homeDirectory/loginShell
2026-07-17 00:35:52 -04:00
wmantly cdc5d1528c Unify service accounts to one kind, add manager field, make homeDirectory/loginShell editable
Removes the LDAP bind-only service account type in favor of a single
Unix/POSIX account model, surfaced in a new Users > Service Accounts tab.
Adds a multi-valued `manager` field to every account (defaults to the
creator, editable, and grants edit rights on the accounts a person manages
without needing app_sso_admin). homeDirectory and loginShell are now
editable from the profile edit form.

Bumps to v1.1.7.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 00:32:19 -04:00
14 changed files with 641 additions and 410 deletions
+27 -1
View File
@@ -6,6 +6,29 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased]
## [1.1.9] - 2026-07-17
### Added
- Every account's personal Unix group (its primary GID holder) can now have supplementary members managed from the account's profile page ("Members of `<uid>`'s group", admin-only) — e.g. to share write access to files owned by that group. Uses the standard `memberUid` attribute (RFC 2307 `posixGroup`).
## [1.1.8] - 2026-07-17
### Added
- Group membership is now editable directly from a user's profile page ("My groups" -- add via a group-name picker, remove with a button per row), instead of only from each group's own card on the Groups page. Admin-only, using the existing per-group member add/remove endpoints.
### Fixed
- The Edit Profile form's Mobile Phone field had a stray `validate=":9"` making it effectively required (submission was blocked with "Please fix the form errors" if left blank) -- it was always meant to be optional, matching the "Add user" form. Removed.
- A service account's profile always showed `Name: Service Account` -- every service account has the same literal filler given/last name (a schema-satisfying placeholder, not meant to be shown), making them indistinguishable by name. The Name line is now hidden for service accounts.
- The Users page's Service Accounts tab, and a freshly-created service account's own profile, could appear empty/not-a-service-account for up to 5 minutes right after creation. Creating a user caches it via `User.get()` *before* the route handler marks it as a service account (group membership), so the cached copy had `isServiceAccount` stuck wrong until the cache TTL expired. Now cleared and re-fetched immediately after marking.
- A user belonging to exactly one LDAP group had their `memberOf` attribute returned as a bare string instead of a one-element array (ldapts's normal behavior for single-valued attributes) -- client-side permission checks (`for(let group of user.memberOf)`) would then iterate the DN character-by-character instead of once, causing pages gated on that group (e.g. Groups) to incorrectly show "You do not have permission to be here." Normalized `memberOf` to always be an array, same fix already applied to `manager`.
## [1.1.7] - 2026-07-17
### Changed
- **Service accounts unified to one kind.** Removed the LDAP bind-only service account type (the Integrations → LDAP "Service Accounts" card, and its `/api/service-account` routes) -- every service account is now a real Unix/POSIX account with a UID, created from the new **Users → Service Accounts** tab. Email and password are both optional for service accounts; a blank password means no `userPassword` is set at all (the account simply can't bind).
- **Added a `manager` field to every account.** Multi-valued (a list of usernames), defaults to whoever created the account (the admin who added it, or whoever sent the invite), and reassignable from the account's Edit form. Anyone listed as a manager can edit that account -- same fields an admin can (mobile, description, SSH key, date of birth, home directory, login shell, manager list) -- without needing `app_sso_admin`.
- `homeDirectory` and `loginShell` are now editable from the Edit Profile form (previously view-only).
## [1.1.6] - 2026-07-16
### Changed
@@ -54,7 +77,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.
- Merged OAuth Apps + LDAP Info into a single Integrations page.
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.6...HEAD
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.9...HEAD
[1.1.9]: https://github.com/theta42/sso-manager-node/compare/v1.1.8...v1.1.9
[1.1.8]: https://github.com/theta42/sso-manager-node/compare/v1.1.7...v1.1.8
[1.1.7]: https://github.com/theta42/sso-manager-node/compare/v1.1.6...v1.1.7
[1.1.6]: https://github.com/theta42/sso-manager-node/compare/v1.1.5...v1.1.6
[1.1.5]: https://github.com/theta42/sso-manager-node/compare/v1.1.4...v1.1.5
[1.1.4]: https://github.com/theta42/sso-manager-node/compare/v1.1.3...v1.1.4
+51 -23
View File
@@ -35,6 +35,15 @@ User entries are `cn=<uid>,ou=people,<base>` and carry the objectClasses:
- `sudoRole` — per-user sudo rules (`sudoCommand`, `sudoHost`, `sudoUser`).
- `theta42Person` (custom auxiliary; `dateOfBirth`).
Every user (person or service account) also carries a `manager` attribute
(the standard COSINE `manager`, `SUP distinguishedName`) — one or more DNs of
the people who created/administer that account. Set automatically to the
creator's DN on signup (whoever an admin was logged in as, or whoever sent
the invite), and reassignable later from the account's Edit form. Anyone
listed as a `manager` can edit that account (same fields an admin can:
mobile, description, SSH key, date of birth, home directory, login shell,
and the manager list itself) without needing `app_sso_admin`.
Passwords are stored as `{SSHA512}` (8-byte salt, sha512(pass+salt), base64),
verified by the `pw-sha2` module. The app's `hashPasswordSSHA512` is the
canonical hasher; if you provision users out-of-band, hash passwords the same
@@ -48,6 +57,19 @@ membership (`memberOf` on the user); `refint` keeps it consistent on
add/remove. **Admin permission checks read the group's `member` list**, not
`memberOf` on the user.
### Personal groups
Every user (person or service account) also gets a **personal Unix group**
at creation — `cn=<uid>,ou=groups,<base>`, `objectClass: posixGroup` (RFC
2307), holding just `cn` and `gidNumber` (the user's primary GID). This is a
different schema than the `groupOfNames` groups above — its membership
attribute is `memberUid` (a bare username, not a DN), and unlike
`groupOfNames` it's valid with zero members. It's excluded from the
`/groups` page (which filters on `objectClass=groupOfNames`) and managed
instead from the owning user's own profile page ("Members of `<uid>`'s
group", admin-only) — add other accounts as supplementary members, e.g. to
share write access to files owned by this group.
The SSO requires three groups (seeded automatically by the entrypoint /
`install.sh`):
@@ -94,33 +116,39 @@ The entrypoint leaves existing certs untouched (idempotent).
## Service accounts
There are two different kinds of "not a real person" account, and which one
you want depends on what's consuming it:
A service account is a normal `posixAccount` for something that isn't a
person: a media manager, a torrent client, a service like Emby, or a
read-only bind account an app uses to look users up — anything that needs a
real `uidNumber`/`gidNumber` to own files, or that other accounts join via a
group for write access (e.g. a `stuff_manager` group granting write rights
to a media library). There's only one kind — every account, person or
service, is a real `posixAccount` with a UID.
**LDAP bind-only** — for an app that just needs to bind LDAP to look users up
(its own "LDAP authentication" settings page, or the read-only account
`theta42/ldap-client` binds as). Not a `posixAccount` — no `uidNumber`, no
home directory, can't log into this UI. Create one from the
**Integrations → LDAP** tab's *Service Accounts* section (create, rotate
password, delete). theta-env's bootstrap creates `cn=ldapclient` this same
way automatically, and the proxy binds as it — don't reuse the admin DN for
this.
Create one from the **Users → Service Accounts** tab's "Add new user" form
with **This is a service account** checked — it skips the birthday/
Terms-of-Service fields a real person's account needs and asks for just an
account name. It's flagged (via membership in the `app_sso_service_account`
group) so it's listed separately from real people and excluded from "all
users" notification broadcasts.
**Unix/POSIX** — for an account something actually *runs as* on a Linux
host: a media manager, a torrent client, a service like Emby — anything that
needs a real `uidNumber`/`gidNumber` to own files or that other accounts join
via a group for write access (e.g. a `stuff_manager` group granting write
rights to a media library). Create one from the **Users** page's "Add new
user" form with **This is a service account** checked — it skips the
birthday/Terms-of-Service fields a real person's account needs and asks for
just an account name. It's a normal `posixAccount`, just flagged (via
membership in the `app_sso_service_account` group) so it's visibly marked in
the Users list and excluded from "all users" notification broadcasts.
Email and password are both optional for a service account:
Either way: don't reuse the admin DN, and give it only the group memberships
it actually needs.
- No `mail` is set unless you give it one (it never needs a mailbox).
- Leaving the password blank is fine — no `userPassword` attribute is set at
all, and an entry with no `userPassword` simply can't bind with any
password (standard LDAP simple-bind behavior). Only set a password if the
account actually needs to authenticate as itself (e.g. a bind-only account
an app uses to look users up).
Example bind test (LDAP bind-only account):
theta-env's bootstrap creates its own `cn=ldapclient` bind account directly
against LDAP (independent of this app), and the proxy binds as it — that
account won't show up in the Service Accounts tab since it isn't managed
through this app, but it keeps working unchanged.
Either way: don't reuse the admin DN, and give a service account only the
group memberships and `manager`s it actually needs.
Example bind test (a service account with a password set):
```bash
ldapsearch -x -H ldaps://sso.example.com:636 \
-1
View File
@@ -82,7 +82,6 @@ app.use('/api/user', middleware.auth, require('./routes/user'));
app.use('/api/token', middleware.auth, require('./routes/token'));
app.use('/api/group', middleware.auth, require('./routes/group'));
app.use('/api/service-account', middleware.auth, require('./routes/service_account'));
app.use('/api/notification', middleware.auth, require('./routes/notification'));
app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
app.use('/api/tos', middleware.auth, require('./routes/tos'));
-114
View File
@@ -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};
+107 -5
View File
@@ -103,7 +103,6 @@ async function addPosixAccount(client, data){
givenName: data.givenName,
loginShell: data.loginShell,
homeDirectory: data.homeDirectory,
userPassword: data.userPassword,
description: data.description || ' ',
sudoHost: 'ALL',
sudoCommand: 'ALL',
@@ -131,6 +130,19 @@ async function addPosixAccount(client, data){
entry.dateOfBirth = data.dob;
}
// userPassword is optional -- a service account with no password set
// simply can't bind (no special enforcement needed, that's the default
// LDAP simple-bind behavior for an entry lacking the attribute).
if (data.userPassword) {
entry.userPassword = data.userPassword;
}
// manager (COSINE, SUP distinguishedName) is naturally multi-valued --
// every account gets at least the DN of whoever created it.
if (data.manager && [].concat(data.manager).length) {
entry.manager = [].concat(data.manager);
}
await client.add(`cn=${data.cn},${conf.userBase}`, entry);
return data
@@ -151,9 +163,13 @@ async function addLdapUser(client, data){
data.uid = `${data.givenName[0]}${data.sn}`.toLowerCase();
}
data.cn = data.uid;
data.loginShell = '/bin/bash';
data.homeDirectory= `/home/${data.uid}`;
data.userPassword = hashPasswordSSHA512(data.userPassword);
data.loginShell = data.loginShell || '/bin/bash';
data.homeDirectory = data.homeDirectory || `/home/${data.uid}`;
if (data.userPassword) {
data.userPassword = hashPasswordSSHA512(data.userPassword);
} else {
delete data.userPassword;
}
console.log('addLdapUser', data)
group = await addPosixGroup(client, data);
@@ -194,6 +210,14 @@ const user_parse = function(data){
data.isActive = data.pwdAccountLockedTime ? '' : 'active';
data.isInactive = data.pwdAccountLockedTime ? 'inactive' : '';
// manager (COSINE, SUP distinguishedName) and memberOf (from the memberof
// overlay) are both multi-valued; ldapts returns a bare string for a
// single value and an array for multiple -- normalize both to always be
// an array, or app-base.js's `for(let group of user.memberOf)` silently
// iterates a single DN string character-by-character instead of once.
data.manager = [].concat(data.manager || []).filter(Boolean);
data.memberOf = [].concat(data.memberOf || []).filter(Boolean);
return data;
}
@@ -242,6 +266,8 @@ User.listDetail = async function(){
serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase()));
}catch(error){ /* group not seeded yet on an old deployment -- treat as none */ }
const dnToUid = new Map(searchEntries.map(e => [String(e.dn).toLowerCase(), e.uid]));
const users = await Promise.all(searchEntries.map(async (entry) => {
const rawPassword = entry.userPassword ? entry.userPassword.toString() : '';
const isLegacyMD5 = rawPassword.toUpperCase().startsWith('{MD5}');
@@ -269,6 +295,7 @@ User.listDetail = async function(){
].filter(Boolean);
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn);
return obj;
}));
@@ -324,6 +351,13 @@ User.get = async function(data, key) {
const verif = await UserVerification.getOrCreate(obj.uid);
// Same membership check as User.listDetail() -- see the comment there.
try{
const svcGroup = await Group.get('app_sso_service_account');
const serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase()));
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
}catch(error){ obj.isServiceAccount = ''; }
// Auto-flag legacy MD5 password users — persist so subsequent cache hits see it
if (isLegacyMD5 && !verif.password_must_change) {
await verif.update({ password_must_change: true });
@@ -421,7 +455,7 @@ User.update = async function(data){
}
}
let editableFeilds = ['mobile', 'description'];
let editableFeilds = ['mobile', 'description', 'homeDirectory', 'loginShell'];
await withClient(async (client) => {
for(let field of editableFeilds){
@@ -469,6 +503,21 @@ User.update = async function(data){
]);
this.dateOfBirth = data.dateOfBirth;
}
if(data.manager !== undefined){
// Client sends uids; resolve each to a DN before writing --
// manager (COSINE, SUP distinguishedName) stores DNs, not uids.
const uids = [].concat(data.manager || []).filter(Boolean);
const managers = await Promise.all(uids.map(uid => User.get(uid)));
const dns = managers.map(u => u.dn);
await client.modify(this.dn, [
new Change({
operation: 'replace',
modification: new Attribute({ type: 'manager', values: dns }),
}),
]);
this.manager = dns;
}
});
cache.clear();
@@ -537,6 +586,12 @@ User.addByInvite = async function(data){
data.mail = token.mail;
// Default manager: whoever sent the invite.
try {
const inviter = await this.get(token.created_by);
data.manager = [inviter.dn];
} catch(e) { /* inviter no longer exists -- leave manager unset */ }
const suggestions = await this.usernameSuggestions(data.givenName, data.sn, data.dob);
if (!data.uid || !suggestions.includes(data.uid)) {
const err = new Error('Invalid username selection');
@@ -739,6 +794,53 @@ User.addSSHkey = async function(data) {
return result;
};
// Every user gets a personal Unix group of the same name at creation (see
// addPosixGroup) -- just a GID holder, cn always equal to the user's uid.
// memberUid (RFC 2307, posixGroup) is a bare username, not a DN, unlike
// groupOfNames' `member` used by app_sso_* groups in group_ldap.js.
function personalGroupDN(uid){
return `cn=${uid},${conf.groupBase}`;
}
User.getPersonalGroupMembers = async function(uid) {
try {
return await withClient(async (client) => {
const res = await client.search(personalGroupDN(uid), {
scope: 'base',
filter: '(objectClass=posixGroup)',
attributes: ['memberUid'],
});
const entry = res.searchEntries[0];
return [].concat((entry && entry.memberUid) || []).filter(Boolean);
});
} catch(error) {
throw error;
}
};
User.addPersonalGroupMember = async function(uid, memberUid) {
await this.get(memberUid); // throws UserNotFound if the target uid doesn't exist
await withClient(async (client) => {
await client.modify(personalGroupDN(uid), [
new Change({
operation: 'add',
modification: new Attribute({ type: 'memberUid', values: [memberUid] }),
}),
]);
});
};
User.removePersonalGroupMember = async function(uid, memberUid) {
await withClient(async (client) => {
await client.modify(personalGroupDN(uid), [
new Change({
operation: 'delete',
modification: new Attribute({ type: 'memberUid', values: [memberUid] }),
}),
]);
});
};
User.invite = async function(data = {}){
try{
let token = await InviteToken.create({
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "t42-sso-manager",
"version": "1.1.6",
"version": "1.1.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-sso-manager",
"version": "1.1.6",
"version": "1.1.9",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.1.6",
"version": "1.1.9",
"private": true,
"author": [
{
+43 -2
View File
@@ -102,7 +102,15 @@ app.user = (function(app){
});
}
return {list, remove, createInvite, setActive};
// A user DN's cn is always their uid (see models/user_ldap.js addLdapUser,
// `data.cn = data.uid`) -- pulling it straight out of the DN avoids an
// extra lookup just to display a manager list.
function dnToUid(dn){
var m = /^cn=([^,]+)/i.exec(dn || '');
return m ? m[1] : dn;
}
return {list, remove, createInvite, setActive, dnToUid};
})(app);
@@ -149,6 +157,21 @@ app.ui = (function(app){
// Drop the cache (e.g. after a group is created) so the next selector refetches.
function refreshGroups(){ _groupsPromise = null; return loadGroups(); }
// All usernames, fetched once and shared across every user selector (e.g. manager pickers).
var _usersPromise = null;
function loadUsers(){
if(!_usersPromise){
_usersPromise = new Promise(function(resolve){
app.user.list(function(error, data){
if(error || !data || !data.results){ resolve([]); return; }
resolve(data.results.map(function(u){ return u.uid; }).filter(Boolean).sort());
});
});
}
return _usersPromise;
}
function refreshUsers(){ _usersPromise = null; return loadUsers(); }
// opts: { values, options, freeSolo, placeholder, name, separator }
// Returns a handle: { get, set, add, clear, setOptions, element }.
function tagInput(mount, opts){
@@ -249,7 +272,25 @@ app.ui = (function(app){
return handle;
}
return { tagInput: tagInput, groupSelect: groupSelect, loadGroups: loadGroups, refreshGroups: refreshGroups };
// Universal user selector (e.g. picking managers). Preloads all usernames.
function userSelect(mount, opts){
opts = opts || {};
var handle = tagInput(mount, {
name: opts.name || 'manager',
values: opts.values || [],
options: [],
freeSolo: opts.freeSolo !== false,
separator: opts.separator != null ? opts.separator : '\n',
placeholder: opts.placeholder || 'Type a username…',
});
loadUsers().then(function(users){ handle.setOptions(users); });
return handle;
}
return {
tagInput: tagInput, groupSelect: groupSelect, loadGroups: loadGroups, refreshGroups: refreshGroups,
userSelect: userSelect, loadUsers: loadUsers, refreshUsers: refreshUsers,
};
})(app);
app.oauthClient = (function(app){
-54
View File
@@ -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;
+52 -2
View File
@@ -23,8 +23,9 @@ router.post('/', async function(req, res, next){
await permission.byGroup(req.user, ['app_sso_admin'])
req.body.created_by = req.user.uid
req.body.manager = [req.user.dn];
const user = await User.add(req.body);
let user = await User.add(req.body);
const verif = await UserVerification.getOrCreate(user.uid);
const updates = { password_must_change: true };
if (req.body.tosAgree) updates.tos_accepted = true, updates.tos_accepted_at = Date.now();
@@ -37,6 +38,12 @@ router.post('/', async function(req, res, next){
try {
const group = await Group.get('app_sso_service_account');
await group.addMember(user);
// User.add() already cached `user` (via its own internal
// User.get()) before this group membership existed, so the
// cached isServiceAccount would be stuck wrong for 5 minutes
// (the cache TTL) without this -- re-fetch after clearing.
User.clearCache();
user = await User.get(user.uid);
} catch (error) {
console.error(`user.add: failed to mark ${user.uid} as a service account:`, error.message);
}
@@ -137,6 +144,41 @@ router.put('/:uid/active', async function(req, res, next){
}
});
router.get('/:uid/group-members', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
return res.json({results: await User.getPersonalGroupMembers(req.params.uid)});
}catch(error){
next(error);
}
});
router.put('/:uid/group-member/:memberUid', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
await User.addPersonalGroupMember(req.params.uid, req.params.memberUid);
return res.json({
results: true,
message: `Added ${req.params.memberUid} to ${req.params.uid}'s group`
});
}catch(error){
next(error);
}
});
router.delete('/:uid/group-member/:memberUid', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
await User.removePersonalGroupMember(req.params.uid, req.params.memberUid);
return res.json({
results: true,
message: `Removed ${req.params.memberUid} from ${req.params.uid}'s group`
});
}catch(error){
next(error);
}
});
router.put('/:uid', async function(req, res, next){
try{
let user;
@@ -145,7 +187,15 @@ router.put('/:uid', async function(req, res, next){
user = req.user;
}else{
user = await User.get(req.params.uid);
await permission.byGroup(req.user, ['app_sso_admin'])
const isManager = (user.manager || []).includes(req.user.dn);
if(!isManager) await permission.byGroup(req.user, ['app_sso_admin'])
}
// The manager picker is a tag widget backed by a single newline-separated
// hidden input (see public/js/app.js app.ui.userSelect), same convention
// as oauth_client.js's allowed_groups.
if (typeof req.body.manager === 'string') {
req.body.manager = req.body.manager.split('\n').map(s => s.trim()).filter(Boolean);
}
return res.json({
+9 -101
View File
@@ -208,40 +208,8 @@
});
}
// ── Service accounts ──────────────────────────────────────────────────
async function svcTableAJAX(){
let data = await app.api.get('service-account');
$.scope.serviceAccountCard.empty();
$.each(data.results, function(_, acct){
$.scope.serviceAccountCard.push(acct);
});
}
async function rotateServiceAccountPassword(cn, btn){
const $card = $(btn).closest('.card');
const confirmed = await app.util.actionConfirm('Rotate the password for "' + cn + '"? Anything still using the old password will stop working immediately.', $card, 'warning');
if (!confirmed) return;
app.api.put('service-account/' + encodeURIComponent(cn) + '/password', {}, function(error, data){
if(error){ app.util.actionMessage('Error: ' + (data && data.message), $card, 'danger'); return; }
showSecret(data.results.password, 'Password for ' + cn);
});
}
async function deleteServiceAccount(cn, btn){
const $card = $(btn).closest('.card');
$card.addClass('table-warning');
const confirmed = await app.util.actionConfirm('Delete service account "' + cn + '"? Anything binding as it will stop working immediately.', $card, 'warning');
$card.removeClass('table-warning');
if (!confirmed) return;
app.api.delete('service-account/' + encodeURIComponent(cn), function(error, data){
if(error){ app.util.actionMessage('Error: ' + (data && data.message), $card, 'danger'); return; }
$.scope.serviceAccountCard.remove('cn', cn);
});
}
$(document).ready(function(){
tableAJAX();
svcTableAJAX();
// Initialise the create-form tag widgets.
createScopes = app.ui.tagInput('#create-scopes', {
@@ -256,9 +224,6 @@
$('form[action="oauth/client/"]').attr('evalAJAX',
'showSecret(data.client_secret, "Client Secret"); tableAJAX(); $form.trigger("reset"); createScopes.set(DEFAULT_SCOPES); createGroups.clear();'
);
$('form[action="service-account/"]').attr('evalAJAX',
'showSecret(data.password, "Password for " + data.cn); svcTableAJAX(); $form.trigger("reset");'
);
});
</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>
</div>
<small class="field-help text-muted d-block">
A read-only bind account — create one below under
<b>Service Accounts</b> (don't reuse a real person's login or the admin DN).
A read-only bind account — create one from
<a href="/users">Users &gt; Service Accounts</a> (don't reuse a real
person's login or the admin DN).
</small>
</dd>
</dl>
@@ -527,9 +493,10 @@
<p class="text-muted small">
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>
and run this on the host. Fill in a service account's password (create
one below) and, if you want this host's access/sudo groups
auto-registered, an <a href="/">API token</a> from your Profile.
and run this on the host. Fill in a service account's password
(create one from <a href="/users">Users &gt; Service Accounts</a>) and,
if you want this host's access/sudo groups auto-registered, an
<a href="/">API token</a> from your Profile.
</p>
<div class="input-group">
<textarea id="f-bashSnippet" class="form-control font-monospace" rows="16" readonly style="font-size:.8rem"></textarea>
@@ -541,65 +508,6 @@
</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>
@@ -613,8 +521,8 @@
'export ldap_host="<%= ldapHost %>"',
'export ldap_base_dn="<%= baseDn %>"',
'',
'# A read-only service account -- create one under Service Accounts',
'# above, then fill in its password below.',
'# A read-only service account -- create one under Users > Service',
'# Accounts, then fill in its password below.',
'export ldap_bind_dn="<%= exampleBindDn %>"',
'export ldap_bind_password="CHANGE-ME"',
'',
+159 -4
View File
@@ -9,6 +9,7 @@
// data.photo = unescape(encodeURIComponent(data.jpegPhoto));
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
user.managerUids = (user.manager || []).map(app.user.dnToUid);
$.scope.user.update(user);
$.scope.passwordReset.update(user);
@@ -17,12 +18,77 @@
async function renderUserGroups(user){
try{
let res = await app.api.get('group/?detail=true&member='+user.uid);
$.scope.mygroups.empty();
$.scope.mygroups.push(...res.results);
}catch(error){
console.error('renderUserGroups error:', error)
}
}
async function removeFromGroup(cn, btn){
const $row = $(btn).closest('tr');
const confirmed = await app.util.actionConfirm(`Remove ${currentUser.uid} from "${cn}"?`, $row, 'warning');
if (!confirmed) return;
app.api.delete('group/' + encodeURIComponent(cn) + '/' + encodeURIComponent(currentUser.uid), function(error, data){
if(error){ app.util.actionMessage((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; }
$.scope.mygroups.remove('cn', cn);
});
}
var addGroupSelect;
async function addToGroups(btn){
const cns = addGroupSelect.get();
if(!cns.length) return;
const $card = $(btn).closest('.card-body');
for(const cn of cns){
await new Promise(function(resolve){
app.api.put('group/' + encodeURIComponent(cn) + '/' + encodeURIComponent(currentUser.uid), {}, function(error, data){
if(error) app.util.actionMessage((data && data.message) || `Failed to add to "${cn}"`, $card, 'danger');
resolve();
});
});
}
addGroupSelect.clear();
renderUserGroups(currentUser);
}
async function renderPersonalGroupMembers(user){
try{
let res = await app.api.get('user/' + user.uid + '/group-members');
$.scope.personalGroupMembers.empty();
$.scope.personalGroupMembers.push(...(res.results || []).map(uid => ({uid})));
}catch(error){
console.error('renderPersonalGroupMembers error:', error)
}
}
async function removePersonalGroupMember(memberUid, btn){
const $row = $(btn).closest('tr');
const confirmed = await app.util.actionConfirm(`Remove ${memberUid} from ${currentUser.uid}'s group?`, $row, 'warning');
if (!confirmed) return;
app.api.delete('user/' + encodeURIComponent(currentUser.uid) + '/group-member/' + encodeURIComponent(memberUid), function(error, data){
if(error){ app.util.actionMessage((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; }
$.scope.personalGroupMembers.remove('uid', memberUid);
});
}
var addPersonalGroupMemberSelect;
async function addPersonalGroupMembers(btn){
const uids = addPersonalGroupMemberSelect.get();
if(!uids.length) return;
const $card = $(btn).closest('.card-body');
for(const uid of uids){
await new Promise(function(resolve){
app.api.put('user/' + encodeURIComponent(currentUser.uid) + '/group-member/' + encodeURIComponent(uid), {}, function(error, data){
if(error) app.util.actionMessage((data && data.message) || `Failed to add "${uid}"`, $card, 'danger');
resolve();
});
});
}
addPersonalGroupMemberSelect.clear();
renderPersonalGroupMembers(currentUser);
}
async function determinUser(){
if(location.pathname.includes('/users/')){
let uid = location.pathname.replace('/users/', '');
@@ -42,8 +108,15 @@
$.scope.editProfile.update(user);
// jq-repeat's update() is trailing-edge throttled (~50ms) as of 2.1.0 --
// wait for the throttle tick to land before sliding the updated card
// into view, or it can briefly show stale/empty data.
// into view, or it can briefly show stale/empty data. The manager
// picker is a JS widget, not a mustache-bound input, so it also has to
// wait for update() to (re-)render its empty mount div before attaching.
setTimeout(function(){
app.ui.userSelect('#edit-manager', {
name: 'manager',
values: user.managerUids || [],
placeholder: 'Type a username…',
});
$profileCard.slideUp();
$editCard.slideDown();
}, 60);
@@ -86,6 +159,15 @@
renderProfile(currentUser);
renderUserGroups(currentUser);
renderPersonalGroupMembers(currentUser);
$('#personal-group-uid-label').text(currentUser.uid);
addGroupSelect = app.ui.groupSelect('#add-group-select', {
name: 'groups', values: [], placeholder: 'Type a group name…',
});
addPersonalGroupMemberSelect = app.ui.userSelect('#add-personal-group-member-select', {
name: 'members', values: [], placeholder: 'Type a username…',
});
// API Tokens are self-service only — never shown when an admin is
// viewing someone else's profile via /users/:uid.
@@ -151,7 +233,7 @@
<div class="profile-body" jq-repeat="user">
<div class="card-body profile-body-{{uid}}">
<h2><i>User Name:</i> <b>{{uid}}</b></h2>
<i>Name:</i> <b>{{givenName}} {{sn}}</b><br />
{{^isServiceAccount}}<i>Name:</i> <b>{{givenName}} {{sn}}</b><br />{{/isServiceAccount}}
<i>Email:</i> <b>{{mail}}</b>
{{#emailVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/emailVerified}}
<br />
@@ -161,6 +243,9 @@
<i>LDAP DN:</i> <b>{{dn}} </b><br />
<i>Home Directory:</i> <b>{{homeDirectory}} </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>
{{#isActive}}<span class="badge bg-success">Active</span>{{/isActive}}
{{#isInactive}}<span class="badge bg-danger">Inactive</span>{{/isInactive}}
@@ -236,7 +321,19 @@
<div class="mb-3">
<label class="form-label">Mobile Phone</label>
<input type="text" class="form-control" name="mobile" placeholder="9175551234" validate=":9" value="{{mobile}}" />
<input type="text" class="form-control" name="mobile" placeholder="9175551234" value="{{mobile}}" />
</div>
<div class="mb-3">
<label class="form-label">Home Directory</label>
<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">
<label class="form-label">User Description (Optional)</label>
@@ -259,7 +356,7 @@
</div>
<div class="card-header shadow actionMessage" style="display:none">
</div>
<div class="card-body" style="padding-bottom:0">
<div class="card-body">
<div class="table-responsive">
<table class="table">
<thead>
@@ -269,19 +366,77 @@
<th>
Description
</th>
<th class="group-required group-required-app_sso_admin"></th>
</thead>
<tbody jq-repeat="mygroups">
<tr>
<td>{{cn}}</td>
<td>{{description}}</td>
<td class="text-end group-required group-required-app_sso_admin">
<button type="button" class="btn btn-sm btn-outline-danger" title="Remove from group" onclick="removeFromGroup('{{cn}}', this)">
<i class="fa-solid fa-xmark"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="group-required group-required-app_sso_admin">
<label class="form-label small">Add to group</label>
<div class="d-flex gap-2 align-items-start">
<div id="add-group-select" class="flex-grow-1"></div>
<button type="button" class="btn btn-outline-dark" onclick="addToGroups(this)">Add</button>
</div>
</div>
</div>
</div>
<div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin">
<div class="card-header shadow">
<i class="fa-solid fa-people-group"></i>
Members of <span id="personal-group-uid-label"></span>'s group
<div class="float-end">
<i class="fa-solid fa-arrows-up-down"></i>
</div>
</div>
<div class="card-header shadow actionMessage" style="display:none">
</div>
<div class="card-body">
<p class="text-muted small">
Every account gets a personal Unix group (its primary GID) — add
other accounts here as supplementary members (e.g. to share write
access to files owned by this group).
</p>
<div class="table-responsive">
<table class="table">
<thead>
<th>
Username
</th>
<th class="text-end"></th>
</thead>
<tbody jq-repeat="personalGroupMembers">
<tr>
<td>{{uid}}</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger" title="Remove from group" onclick="removePersonalGroupMember('{{uid}}', this)">
<i class="fa-solid fa-xmark"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<label class="form-label small">Add member</label>
<div class="d-flex gap-2 align-items-start">
<div id="add-personal-group-member-select" class="flex-grow-1"></div>
<button type="button" class="btn btn-outline-dark" onclick="addPersonalGroupMembers(this)">Add</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Token modal (shown once on create/rotate) -->
<div class="modal fade" id="secretModal" tabindex="-1">
+8
View File
@@ -59,6 +59,14 @@ async function fetchUsernameSuggestions() {
$form.find('#personNameFields').toggle(!checked);
$form.find('#serviceAccountNameField').toggle(checked);
// Service accounts aren't a person with a mailbox, and a blank
// password is fine (no userPassword attribute set -- the account
// simply can't bind). Disabling (not just hiding) keeps disabled
// fields out of both form serialization and validation.
$form.find('[name=mail]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
$form.find('[name=userPassword]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
$form.find('[name=passwordMatch]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
if(checked){
// Filler values so the LDAP schema (inetOrgPerson requires sn) is
// satisfied; not shown anywhere, the account name is what matters.
+182 -100
View File
@@ -3,15 +3,17 @@
</script>
<script type="text/javascript">
function renderUsers(actionMessage, type){
function renderUsers(){
app.user.list(function(error, data){
if(error){
app.util.actionMessage(data.message, $target, 'danger');
app.util.actionMessage(data.message, $('#tab-people'), 'danger');
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>
<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>
<h4><i class="fa-solid fa-users"></i> Users</h4>
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-people-btn" data-bs-toggle="tab" data-bs-target="#tab-people" type="button" role="tab">
<i class="fa-solid fa-user"></i> People
</button>
</li>
<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 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 class="card shadow-lg">
<div class="card-header">
<i class="fas fa-user-plus"></i>
Add new user
<small class="text-muted">(check <b>This is a service account</b> below to create one — it'll show up under the Service Accounts tab)</small>
</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 class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<%- include('user_form', {adminMode: true}) %>
</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 class="card shadow-lg">
<div class="card-header">
<i class="fas fa-user-plus"></i>
Add new user
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<%- include('user_form', {adminMode: true}) %>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card shadow">
<div class="card-header">
<i class="fa-solid fa-users"></i>
User List
</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>ID</th>
<th>Name</th>
<th>eMail</th>
<th>Key</th>
<th>Active</th>
<th>TOS</th>
<th></th>
</thead>
<tbody id="tableAJAX">
<tr jq-repeat="userRow">
<td>
{{ uidNumber }}
</td>
<td>
<a href='/users/{{uid}}'>{{givenName}} {{sn}}</a>
{{#isServiceAccount}}<span class="badge bg-secondary" title="Service account — not a person"><i class="fa-solid fa-gears"></i> service</span>{{/isServiceAccount}}
</td>
<td>
{{mail}}
</td>
<td>
{{#sshPublicKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/sshPublicKey}}
</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>
{{#tosAccepted}}<i class="fa-solid fa-circle-check text-success" title="TOS accepted"></i>{{/tosAccepted}}
{{#tosNotAccepted}}<i class="fa-solid fa-circle-xmark text-danger" title="TOS not accepted"></i>{{/tosNotAccepted}}
</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-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 class="col-md-8">
<div class="card shadow">
<div class="card-header">
<i class="fa-solid fa-users"></i>
User List
</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>ID</th>
<th>Name</th>
<th>eMail</th>
<th>Key</th>
<th>Active</th>
<th>TOS</th>
<th></th>
</thead>
<tbody id="tableAJAX">
<tr jq-repeat="userRow">
<td>
{{ uidNumber }}
</td>
<td>
<a href='/users/{{uid}}'>{{givenName}} {{sn}}</a>
</td>
<td>
{{mail}}
</td>
<td>
{{#sshPublicKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/sshPublicKey}}
</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>
{{#tosAccepted}}<i class="fa-solid fa-circle-check text-success" title="TOS accepted"></i>{{/tosAccepted}}
{{#tosNotAccepted}}<i class="fa-solid fa-circle-xmark text-danger" title="TOS not accepted"></i>{{/tosNotAccepted}}
</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-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 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('bottom') %>