3790e8001a
The Integrations page's Service Accounts (bind-only, organizationalRole)
don't cover the other real use case: an account something actually runs
as on a Linux host -- a media manager, a torrent client, Emby -- with a
real uidNumber/gidNumber that owns files, and a group other accounts
join for write access (e.g. a `stuff_manager` group granting write
rights to a media library). That needs a real posixAccount, which the
bind-only model can't be.
- New well-known group `app_sso_service_account`, seeded the same way as
app_sso_admin/app_sso_invite/app_sso_oauth_admin (docker-entrypoint.sh,
ops/ldap-setup.sh). Not a permission gate -- a marker.
- "Add new user" form gets a "This is a service account" checkbox: swaps
the person-shaped fields (first/last name, birthday, ToS agreement)
for a single account-name field, since none of those make sense for a
non-person account. On create, the route adds the user to
app_sso_service_account.
- User.listDetail() annotates each user with isServiceAccount (checked
against the marker group's member list once per call, not the memberof
overlay's reverse attribute -- not reliably returned by every LDAP
server this app might point at, confirmed against a real external
directory during testing). Users page shows a "service" badge.
- Notification broadcasts (filter_type=all/all_active) exclude service
accounts by default -- nobody reads mail as `stuff_manager`.
- Fixed a real, previously-unrelated bug this surfaced: addPosixAccount
unconditionally set `mail: data.mail` in the LDAP entry even when
undefined, and ldapts/slapd reject an attribute given an explicit
undefined value ("no values for attribute type") rather than treating
it as absent. This meant creating ANY user without an email already
failed outright -- not something a service account (which commonly has
no real mailbox) could route around. Made mail conditional, matching
how mobile/sshPublicKey/dob already work.
- docs/ldap.md now explains both kinds of service account side by side
and when to use which.
Verified end-to-end against a real external LDAP server (not a local
sandbox): created a service account with no email, confirmed it's
correctly flagged and excluded from broadcast recipient resolution,
confirmed a normal user is unaffected, confirmed the code degrades
gracefully if the marker group doesn't exist yet (pre-upgrade
deployments).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
210 lines
6.9 KiB
Plaintext
Executable File
210 lines
6.9 KiB
Plaintext
Executable File
<%- include('top') %>
|
|
<script id="rowTemplate" type="text/html">
|
|
|
|
</script>
|
|
<script type="text/javascript">
|
|
function renderUsers(actionMessage, type){
|
|
|
|
app.user.list(function(error, data){
|
|
if(error){
|
|
app.util.actionMessage(data.message, $target, 'danger');
|
|
return;
|
|
}
|
|
$.scope.userRow.push(...data.results);
|
|
|
|
});
|
|
}
|
|
|
|
function toggleActive(uid, active){
|
|
app.user.setActive(uid, active, function(error, data){
|
|
if(error) return alert('Failed to update user status');
|
|
renderUsers();
|
|
});
|
|
}
|
|
|
|
async function deleteUser(uid, btn){
|
|
const $row = $(btn).closest('tr');
|
|
$row.addClass('table-warning');
|
|
const confirmed = await app.util.actionConfirm(`Delete user "${uid}"?`, $row, 'warning');
|
|
$row.removeClass('table-warning');
|
|
if (!confirmed) return;
|
|
app.api.delete('user/' + uid, function(error, data){
|
|
if (error) {
|
|
app.util.actionMessage(data.message || 'Failed to delete user', $row, 'danger');
|
|
return;
|
|
}
|
|
renderUsers();
|
|
});
|
|
}
|
|
|
|
function fuzzyMatch(query, text) {
|
|
query = query.toLowerCase();
|
|
text = text.toLowerCase();
|
|
let qi = 0;
|
|
for (let i = 0; i < text.length && qi < query.length; i++) {
|
|
if (text[i] === query[qi]) qi++;
|
|
}
|
|
return qi === query.length;
|
|
}
|
|
|
|
function filterGroups(inputEl, selectId) {
|
|
const query = inputEl.value;
|
|
[...document.getElementById(selectId).options].forEach(function(opt) {
|
|
opt.hidden = query ? !fuzzyMatch(query, opt.value) : false;
|
|
});
|
|
}
|
|
|
|
async function loadInviteGroups(){
|
|
const data = await app.api.get('group/');
|
|
const sel = document.getElementById('invite-groups');
|
|
(data.results || []).forEach(function(cn){
|
|
const opt = document.createElement('option');
|
|
opt.value = cn;
|
|
opt.textContent = cn;
|
|
sel.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
async function sendInvite(){
|
|
const mail = document.getElementById('invite-email').value.trim();
|
|
const groups = [...document.getElementById('invite-groups').selectedOptions].map(o => o.value);
|
|
const result = document.getElementById('invite-result');
|
|
result.style.display = 'none';
|
|
try{
|
|
const data = await app.api.post('user/invite', { mail, groups });
|
|
result.style.display = '';
|
|
if(data.mail_sent){
|
|
result.className = 'alert alert-success mt-2';
|
|
result.textContent = `Invite sent to ${mail}`;
|
|
} else {
|
|
result.className = 'alert alert-info mt-2';
|
|
result.innerHTML = `Link: <a href="${data.link}" target="_blank">${data.link}</a>`;
|
|
}
|
|
document.getElementById('invite-email').value = '';
|
|
[...document.getElementById('invite-groups').options].forEach(o => o.selected = false);
|
|
}catch(e){
|
|
result.style.display = '';
|
|
result.className = 'alert alert-danger mt-2';
|
|
result.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || 'Unknown error');
|
|
}
|
|
}
|
|
|
|
(async function(){
|
|
await app.auth.forceLogin('app_sso_admin');
|
|
|
|
$(document).ready(function(){
|
|
renderUsers();
|
|
loadInviteGroups();
|
|
$('form[action="user/"]').attr('evalAJAX', 'renderUsers("User added", "success")')
|
|
});
|
|
})();
|
|
|
|
</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>
|
|
</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 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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%- include('impersonate_modal') %>
|
|
<%- include('bottom') %>
|