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
This commit is contained in:
@@ -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'));
|
||||
|
||||
@@ -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,
|
||||
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,11 @@ const user_parse = function(data){
|
||||
data.isActive = data.pwdAccountLockedTime ? '' : 'active';
|
||||
data.isInactive = data.pwdAccountLockedTime ? 'inactive' : '';
|
||||
|
||||
// manager (COSINE, SUP distinguishedName) is multi-valued; ldapts returns
|
||||
// a bare string for a single value and an array for multiple -- normalize
|
||||
// to always be an array of DNs.
|
||||
data.manager = [].concat(data.manager || []).filter(Boolean);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -242,6 +263,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 +292,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;
|
||||
}));
|
||||
@@ -421,7 +445,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 +493,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 +576,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');
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.7",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.7",
|
||||
"private": true,
|
||||
"author": [
|
||||
{
|
||||
|
||||
+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);
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -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'])
|
||||
|
||||
req.body.created_by = req.user.uid
|
||||
req.body.manager = [req.user.dn];
|
||||
|
||||
const user = await User.add(req.body);
|
||||
const verif = await UserVerification.getOrCreate(user.uid);
|
||||
@@ -145,7 +146,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({
|
||||
|
||||
@@ -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 > 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 > 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"',
|
||||
'',
|
||||
|
||||
@@ -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);
|
||||
@@ -42,8 +43,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);
|
||||
@@ -161,6 +169,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}}
|
||||
@@ -238,6 +249,18 @@
|
||||
<label class="form-label">Mobile Phone</label>
|
||||
<input type="text" class="form-control" name="mobile" placeholder="9175551234" validate=":9" 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>
|
||||
<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('#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
@@ -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') %>
|
||||
|
||||
Reference in New Issue
Block a user