Files
sso-manager-node/nodejs/views/user_form.ejs
T
wmantly 3790e8001a Add Unix/POSIX service accounts, distinct from LDAP bind-only ones
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>
2026-07-15 20:52:50 -04:00

156 lines
6.6 KiB
Plaintext

<script>
function renderUsernameSuggestions(suggestions) {
const container = document.getElementById('usernameOptions');
const hidden = document.getElementById('selectedUid');
const selector = document.getElementById('usernameSelector');
container.innerHTML = '';
if (!suggestions || !suggestions.length) return;
selector.style.display = '';
suggestions.forEach(function(uid, i) {
const btn = document.createElement('button');
btn.type = 'button';
btn.dataset.uid = uid;
btn.textContent = uid;
btn.className = 'btn ' + (i === 0 ? 'btn-secondary' : 'btn-outline-secondary');
btn.onclick = function() {
container.querySelectorAll('.btn').forEach(function(b) {
b.className = 'btn btn-outline-secondary';
});
btn.className = 'btn btn-secondary';
hidden.value = uid;
const override = document.getElementById('adminUidOverride');
if (override) override.value = '';
};
container.appendChild(btn);
});
hidden.value = suggestions[0];
}
async function fetchUsernameSuggestions() {
const gnEl = document.querySelector('[name="givenName"]');
const snEl = document.querySelector('[name="sn"]');
const dobEl = document.querySelector('[name="dob"]');
if (!gnEl || !snEl) return;
const gn = gnEl.value.trim();
const sn = snEl.value.trim();
const dob = dobEl ? dobEl.value.trim() : '';
if (gn.length < 2 || sn.length < 2) return;
try {
const params = new URLSearchParams({ givenName: gn, sn, dob });
const resp = await fetch('/api/auth/username-suggestions?' + params);
const data = await resp.json();
renderUsernameSuggestions(data.suggestions);
} catch(e) {}
}
</script>
<% if (locals.adminMode) { %>
<script>
// A service account (a Unix-style account something runs as -- a media
// manager, a torrent client, ...) isn't a person: no birthday, nothing to
// agree to, and it gets one name (a username), not a first/last name.
// Toggling this swaps the person-shaped fields for a single account-name
// field instead of asking for throwaway values.
function toggleServiceAccountFields(el){
var checked = el.checked;
var $form = $(el).closest('form');
$form.find('[name=dob]').prop('required', !checked).closest('.mb-3').toggle(!checked);
$form.find('#tosAgree').prop('required', !checked).closest('.mb-3').toggle(!checked);
$form.find('#personNameFields').toggle(!checked);
$form.find('#serviceAccountNameField').toggle(checked);
if(checked){
// Filler values so the LDAP schema (inetOrgPerson requires sn) is
// satisfied; not shown anywhere, the account name is what matters.
$form.find('[name=givenName]').val('Service');
$form.find('[name=sn]').val('Account');
document.getElementById('selectedUid').value = '';
}else{
$form.find('[name=givenName]').val('');
$form.find('[name=sn]').val('');
document.getElementById('usernameSelector').style.display = 'none';
document.getElementById('serviceAccountName').value = '';
}
}
</script>
<% } %>
<form action="user/" method="post" onsubmit="formAJAX(this)">
<input type="hidden" class="form-control" name="delete" value="false" />
<% if (locals.adminMode) { %>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="isServiceAccount" name="isServiceAccount" onchange="toggleServiceAccountFields(this)">
<label class="form-check-label" for="isServiceAccount">
This is a service account <small class="text-muted">(not a person — e.g. a Unix account an app/service runs as)</small>
</label>
</div>
<div class="mb-3" id="serviceAccountNameField" style="display:none">
<label class="form-label">Account name</label>
<input type="text" class="form-control shadow" id="serviceAccountName" placeholder="stuff_manager"
oninput="document.getElementById('selectedUid').value = this.value.trim()" />
</div>
<% } %>
<div id="personNameFields">
<div class="mb-3">
<label class="form-label">First name</label>
<input type="text" class="form-control shadow" name="givenName" placeholder="John" validate=":3" onblur="fetchUsernameSuggestions()" />
<div class="invalid-feedback"></div>
</div>
<div class="mb-3">
<label class="form-label">Last name</label>
<input type="text" class="form-control shadow" name="sn" placeholder="smith" validate=":3" onblur="fetchUsernameSuggestions()" />
</div>
</div>
<div class="mb-3">
<label class="form-label">Date of Birth</label>
<input type="date" class="form-control shadow" name="dob" required onchange="fetchUsernameSuggestions()" />
</div>
<div class="mb-3" id="usernameSelector" style="display:none">
<label class="form-label">Username</label>
<div id="usernameOptions" class="d-flex flex-wrap gap-2 mb-2"></div>
<input type="hidden" name="uid" id="selectedUid" />
<% if (locals.adminMode) { %>
<input type="text" class="form-control shadow mt-2" id="adminUidOverride"
placeholder="Custom username (admin only)"
oninput="document.getElementById('selectedUid').value = this.value.trim() || (document.querySelector('#usernameOptions .btn-secondary') && document.querySelector('#usernameOptions .btn-secondary').dataset.uid) || ''" />
<% } %>
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="text" class="form-control shadow" name="mail" placeholder="jsmith@gmail.com" validate="email:3" />
</div>
<div class="mb-3">
<label class="form-label">SSH Public Key</label>
<input type="text" class="form-control shadow" name="sshPublicKey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ..." />
</div>
<div class="mb-3">
<label class="form-label">Mobile Phone <small class="text-muted">(optional, include country code e.g. +14155551234)</small></label>
<input type="text" class="form-control shadow" name="mobile" placeholder="+14155551234" />
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control shadow" name="userPassword" placeholder="Atleast 5 char. long" validate="password:5"/>
</div>
<div class="mb-3">
<label class="form-label">Again</label>
<input type="password" class="form-control shadow" name="passwordMatch" placeholder="Retype password" validate="eq:userPassword"/>
</div>
<div class="mb-3">
<label class="form-label">User Description (Optional)</label>
<textarea class="form-control shadow" name="description" placeholder="Admin group for gitea app"></textarea>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="tosAgree" name="tosAgree" required>
<label class="form-check-label" for="tosAgree">
I have read and agree to the <a href="/tos" target="_blank">Terms of Service</a>
</label>
</div>
<button type="submit" class="btn btn-outline-dark">Add</button>
</form>