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:
2026-07-17 00:32:19 -04:00
parent 5fc65d6fb3
commit cdc5d1528c
14 changed files with 376 additions and 406 deletions
+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"',
'',
+24 -1
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);
@@ -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>
+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') %>