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>
640 lines
26 KiB
Plaintext
640 lines
26 KiB
Plaintext
<%- include('top') %>
|
|
|
|
<!-- Edit OAuth client modal -->
|
|
<div class="modal fade" id="editModal" tabindex="-1">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="fa-solid fa-pen-to-square"></i> Edit OAuth Client</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="card-header actionMessage mb-3" style="display:none"></div>
|
|
<input type="hidden" id="edit-client-id">
|
|
<div class="mb-3">
|
|
<label class="form-label">Name</label>
|
|
<input type="text" id="edit-name" class="form-control shadow">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Description</label>
|
|
<input type="text" id="edit-description" class="form-control shadow">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
|
|
<textarea id="edit-redirect_uris" class="form-control shadow font-monospace" rows="3"></textarea>
|
|
<small class="field-help text-muted d-block">
|
|
<code>*</code> matches one hostname label, <code>**</code> matches any number of labels.
|
|
</small>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Scopes</label>
|
|
<div id="edit-scopes"></div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Restrict to Groups <small class="text-muted">(optional)</small></label>
|
|
<div id="edit-allowed_groups"></div>
|
|
</div>
|
|
<div class="row mb-3">
|
|
<div class="col">
|
|
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
|
|
<input type="number" id="edit-access_ttl" class="form-control shadow" min="60">
|
|
</div>
|
|
<div class="col">
|
|
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
|
|
<input type="number" id="edit-refresh_ttl" class="form-control shadow" min="3600">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" onclick="saveEdit(this)"><i class="fa-solid fa-floppy-disk"></i> Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Secret modal — shared by OAuth client secrets and service account passwords -->
|
|
<div class="modal fade" id="secretModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="secretModalTitle"><i class="fa-solid fa-key"></i> Secret</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this now — it will <strong>not</strong> be shown again.</p>
|
|
<div class="input-group">
|
|
<input type="text" id="secretValue" class="form-control font-monospace" readonly>
|
|
<button class="btn btn-outline-secondary" onclick="copySecret()" title="Copy">
|
|
<i class="fa-solid fa-copy"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Done</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script type="text/javascript">
|
|
app.auth.forceLogin(['app_sso_admin', 'app_sso_oauth_admin']);
|
|
|
|
// The scopes this provider actually understands (see routes/oauth.js discovery).
|
|
var VALID_SCOPES = ['openid', 'profile', 'email', 'groups'];
|
|
var DEFAULT_SCOPES = ['openid', 'profile', 'email', 'groups'];
|
|
|
|
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
|
|
var editModal = new bootstrap.Modal(document.getElementById('editModal'));
|
|
|
|
// Widget handles + a lookup of the latest client data (for the edit modal).
|
|
var createScopes, createGroups, editScopes, editGroups;
|
|
var clientsById = {};
|
|
|
|
function showSecret(secret, title){
|
|
document.getElementById('secretModalTitle').innerHTML = '<i class="fa-solid fa-key"></i> ' + (title || 'Secret');
|
|
document.getElementById('secretValue').value = secret;
|
|
secretModal.show();
|
|
}
|
|
|
|
function copySecret(){
|
|
copyField('secretValue');
|
|
}
|
|
|
|
// Copy the value of an input by id; briefly flips the button icon to a check.
|
|
function copyField(id, btn){
|
|
var el = document.getElementById(id);
|
|
if(!el) return;
|
|
el.select();
|
|
el.setSelectionRange(0, 99999);
|
|
document.execCommand('copy');
|
|
if(btn){
|
|
var $i = $(btn).find('i');
|
|
var prev = $i.attr('class');
|
|
$i.attr('class', 'fa-solid fa-check');
|
|
setTimeout(function(){ $i.attr('class', prev); }, 1200);
|
|
}
|
|
}
|
|
|
|
function fmtTTL(seconds){
|
|
if(seconds < 3600) return seconds + 's';
|
|
if(seconds < 86400) return (seconds / 3600).toFixed(1) + 'h';
|
|
return (seconds / 86400).toFixed(1) + 'd';
|
|
}
|
|
|
|
function processClient(client){
|
|
clientsById[client.client_id] = client; // keep raw data for the edit modal
|
|
client.scopes_display = (client.scopes || []).join(' ');
|
|
client.allowed_groups_display = (client.allowed_groups || []).join(', ');
|
|
client.has_group_restriction = (client.allowed_groups || []).length > 0;
|
|
client.access_token_ttl = fmtTTL((client.token_lifetime || {}).access_token || 3600);
|
|
client.refresh_token_ttl = fmtTTL((client.token_lifetime || {}).refresh_token || 2592000);
|
|
return client;
|
|
}
|
|
|
|
async function tableAJAX(){
|
|
let data = await app.oauthClient.list();
|
|
$.scope.oauthClientCard.empty();
|
|
$.each(data.results, function(_, client){
|
|
$.scope.oauthClientCard.push(processClient(client));
|
|
});
|
|
}
|
|
|
|
async function deleteClient(client_id, name, btn){
|
|
const $card = $(btn).closest('.card');
|
|
$card.addClass('table-warning');
|
|
const confirmed = await app.util.actionConfirm('Delete OAuth client "' + name + '"?', $card, 'warning');
|
|
$card.removeClass('table-warning');
|
|
if (!confirmed) return;
|
|
app.api.delete('oauth/client/' + client_id, function(error, data){
|
|
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
|
|
$.scope.oauthClientCard.remove('client_id', client_id);
|
|
});
|
|
}
|
|
|
|
async function rotateSecret(client_id, name, btn){
|
|
const $card = $(btn).closest('.card');
|
|
const confirmed = await app.util.actionConfirm('Rotate secret for "' + name + '"? The old secret will stop working immediately.', $card, 'warning');
|
|
if (!confirmed) return;
|
|
app.oauthClient.rotateSecret({client_id: client_id}, function(error, data){
|
|
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
|
|
showSecret(data.client_secret, 'Client Secret');
|
|
});
|
|
}
|
|
|
|
// Open the edit modal pre-filled from the client's current values.
|
|
function editClient(client_id){
|
|
var c = clientsById[client_id];
|
|
if(!c) return;
|
|
$('#edit-client-id').val(client_id);
|
|
$('#edit-name').val(c.name || '');
|
|
$('#edit-description').val(c.description || '');
|
|
$('#edit-redirect_uris').val((c.redirect_uris || []).join('\n'));
|
|
$('#edit-access_ttl').val((c.token_lifetime || {}).access_token || 3600);
|
|
$('#edit-refresh_ttl').val((c.token_lifetime || {}).refresh_token || 2592000);
|
|
|
|
// (Re)build the tag widgets fresh each open so they reflect this client.
|
|
editScopes = app.ui.tagInput('#edit-scopes', {
|
|
values: c.scopes || [], options: VALID_SCOPES, freeSolo: false,
|
|
separator: ' ', placeholder: 'Add a scope…',
|
|
});
|
|
editGroups = app.ui.groupSelect('#edit-allowed_groups', {
|
|
values: c.allowed_groups || [], placeholder: 'Type a group name…',
|
|
});
|
|
editModal.show();
|
|
}
|
|
|
|
function saveEdit(btn){
|
|
var $msg = $('#editModal .actionMessage');
|
|
var payload = {
|
|
client_id: $('#edit-client-id').val(),
|
|
name: $('#edit-name').val(),
|
|
description: $('#edit-description').val(),
|
|
redirect_uris: $('#edit-redirect_uris').val().split('\n').map(function(s){ return s.trim(); }).filter(Boolean),
|
|
scopes: editScopes.get(),
|
|
allowed_groups: editGroups.get(),
|
|
token_lifetime: {
|
|
access_token: Number($('#edit-access_ttl').val()) || 3600,
|
|
refresh_token: Number($('#edit-refresh_ttl').val()) || 2592000,
|
|
},
|
|
};
|
|
app.oauthClient.update(payload, function(error, data){
|
|
if(error){
|
|
app.util.actionMessage((data && data.message) || 'Update failed.', $msg.parent(), 'danger');
|
|
return;
|
|
}
|
|
editModal.hide();
|
|
tableAJAX();
|
|
});
|
|
}
|
|
|
|
// ── 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', {
|
|
name: 'scopes', values: DEFAULT_SCOPES, options: VALID_SCOPES,
|
|
freeSolo: false, separator: ' ', placeholder: 'Add a scope…',
|
|
});
|
|
createGroups = app.ui.groupSelect('#create-allowed_groups', {
|
|
name: 'allowed_groups', values: [], placeholder: 'Type a group name…',
|
|
});
|
|
|
|
// After a successful create, reset the widgets too (form reset ignores them).
|
|
$('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>
|
|
|
|
<h4><i class="fa-solid fa-plug"></i> Integrations</h4>
|
|
|
|
<ul class="nav nav-tabs mb-3" role="tablist">
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link active" id="tab-oauth-btn" data-bs-toggle="tab" data-bs-target="#tab-oauth" type="button" role="tab">
|
|
<i class="fa-solid fa-key"></i> OAuth Apps
|
|
</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" id="tab-ldap-btn" data-bs-toggle="tab" data-bs-target="#tab-ldap" type="button" role="tab">
|
|
<i class="fa-solid fa-network-wired"></i> LDAP
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
|
|
<div class="tab-content">
|
|
<div class="tab-pane fade show active" id="tab-oauth" role="tabpanel">
|
|
<div class="row" style="display:none">
|
|
<div class="col-12 mb-3">
|
|
<div class="card shadow-sm border-info">
|
|
<div class="card-header bg-info bg-opacity-10">
|
|
<i class="fa-solid fa-circle-info"></i>
|
|
OpenID Connect Endpoints
|
|
</div>
|
|
<div class="card-body">
|
|
<p class="mb-2 text-muted small">
|
|
Point OIDC/OAuth clients (e.g. Home Assistant) at the discovery URL below.
|
|
It advertises the authorization, token, and userinfo endpoints automatically.
|
|
</p>
|
|
<dl class="row mb-0">
|
|
<dt class="col-sm-2">Issuer</dt>
|
|
<dd class="col-sm-10"><code><%= issuer %></code></dd>
|
|
<dt class="col-sm-2">Discovery URL</dt>
|
|
<dd class="col-sm-10">
|
|
<div class="input-group input-group-sm">
|
|
<input type="text" id="discoveryUrl" class="form-control font-monospace" readonly value="<%= discoveryUrl %>">
|
|
<a class="btn btn-outline-secondary" href="<%= discoveryUrl %>" target="_blank" title="Open"><i class="fa-solid fa-arrow-up-right-from-square"></i></a>
|
|
<button class="btn btn-outline-secondary" type="button" onclick="copyField('discoveryUrl', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
</div>
|
|
</dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<div class="card shadow-lg">
|
|
<div class="card-header">
|
|
<i class="fa-solid fa-plus"></i>
|
|
Register OAuth Client
|
|
</div>
|
|
<div class="card-header actionMessage" style="display:none"></div>
|
|
<div class="card-body">
|
|
<form action="oauth/client/" method="post" onsubmit="formAJAX(this)">
|
|
<div class="mb-3">
|
|
<label class="form-label">Name</label>
|
|
<input type="text" class="form-control shadow" name="name" placeholder="Home Assistant" validate=":1">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Description</label>
|
|
<input type="text" class="form-control shadow" name="description" placeholder="Home automation dashboard">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
|
|
<textarea class="form-control shadow font-monospace" name="redirect_uris" rows="3"
|
|
placeholder="https://ha.example.com/auth/external/callback" validate=":1"></textarea>
|
|
<small class="field-help text-muted d-block">
|
|
<code>*</code> matches one hostname label and <code>**</code> matches any
|
|
number of labels, e.g. <code>https://*.example.com/__proxy_auth/callback</code>
|
|
covers every host theta42/proxy fronts under example.com without registering
|
|
each one individually.
|
|
</small>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Scopes</label>
|
|
<div id="create-scopes"></div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Restrict to Groups <small class="text-muted">(optional)</small></label>
|
|
<div id="create-allowed_groups"></div>
|
|
<small class="text-muted">Leave empty to allow any user. If set, only members of a listed LDAP group can log in.</small>
|
|
</div>
|
|
<div class="row mb-3">
|
|
<div class="col">
|
|
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
|
|
<input type="number" class="form-control shadow" name="token_lifetime[access_token]" value="3600" min="60">
|
|
</div>
|
|
<div class="col">
|
|
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
|
|
<input type="number" class="form-control shadow" name="token_lifetime[refresh_token]" value="2592000" min="3600">
|
|
</div>
|
|
</div>
|
|
<button type="submit" class="btn btn-outline-dark">
|
|
<i class="fa-solid fa-plus"></i> Register
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-8" style="background-color: initial; border: none">
|
|
<div class="card-header actionMessage" style="display:none"></div>
|
|
|
|
<div jq-repeat="oauthClientCard" jq-index-key="client_id" id="oauth-card-{{client_id}}" class="card shadow mb-3">
|
|
<div class="card-header">
|
|
<h5>
|
|
<i class="fa-solid fa-server"></i>
|
|
{{ name }}
|
|
</h5>
|
|
<small class="text-muted font-monospace">{{ client_id }}</small>
|
|
</div>
|
|
<div class="card-header actionMessage" style="display:none"></div>
|
|
<div class="card-body">
|
|
{{ #description }}
|
|
<p>{{ description }}</p>
|
|
{{ /description }}
|
|
<dl class="row mb-0">
|
|
<dt class="col-sm-3">Client ID</dt>
|
|
<dd class="col-sm-9">
|
|
<div class="input-group input-group-sm">
|
|
<input type="text" id="clientid-{{client_id}}" class="form-control font-monospace" readonly value="{{client_id}}">
|
|
<button class="btn btn-outline-secondary" type="button" onclick="copyField('clientid-{{client_id}}', this)" title="Copy Client ID"><i class="fa-solid fa-copy"></i></button>
|
|
</div>
|
|
</dd>
|
|
<dt class="col-sm-3">Redirect URIs</dt>
|
|
<dd class="col-sm-9">
|
|
<ul class="list-unstyled mb-0">
|
|
{{ #redirect_uris }}
|
|
<li><code>{{ . }}</code></li>
|
|
{{ /redirect_uris }}
|
|
</ul>
|
|
</dd>
|
|
<dt class="col-sm-3">Scopes</dt>
|
|
<dd class="col-sm-9"><code>{{ scopes_display }}</code></dd>
|
|
<dt class="col-sm-3">Access</dt>
|
|
<dd class="col-sm-9">
|
|
{{ #has_group_restriction }}
|
|
<span class="badge bg-warning text-dark"><i class="fa-solid fa-user-lock"></i> Restricted</span>
|
|
<code>{{ allowed_groups_display }}</code>
|
|
{{ /has_group_restriction }}
|
|
{{ ^has_group_restriction }}
|
|
<span class="badge bg-secondary"><i class="fa-solid fa-users"></i> Any user</span>
|
|
{{ /has_group_restriction }}
|
|
</dd>
|
|
<dt class="col-sm-3">Access Token</dt>
|
|
<dd class="col-sm-9">{{ access_token_ttl }}</dd>
|
|
<dt class="col-sm-3">Refresh Token</dt>
|
|
<dd class="col-sm-9">{{ refresh_token_ttl }}</dd>
|
|
<dt class="col-sm-3">Created by</dt>
|
|
<dd class="col-sm-9">{{ created_by }}</dd>
|
|
</dl>
|
|
</div>
|
|
<div class="card-footer">
|
|
<button type="button"
|
|
onclick="editClient('{{client_id}}')"
|
|
class="btn btn-primary btn-sm">
|
|
<i class="fa-solid fa-pen-to-square"></i> Edit
|
|
</button>
|
|
<button type="button"
|
|
onclick="rotateSecret('{{client_id}}', '{{name}}', this)"
|
|
class="btn btn-warning btn-sm">
|
|
<i class="fa-solid fa-arrows-rotate"></i> Rotate Secret
|
|
</button>
|
|
<button type="button"
|
|
onclick="deleteClient('{{client_id}}', '{{name}}', this)"
|
|
class="btn btn-danger btn-sm float-end">
|
|
<i class="fa-solid fa-trash"></i> Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tab-pane fade" id="tab-ldap" role="tabpanel">
|
|
<p class="text-muted">
|
|
Everything a 3rd-party app or host needs to bind this directory, filled in
|
|
for <b><%= ssoUrl %></b>.
|
|
</p>
|
|
|
|
<div class="row g-3">
|
|
<div class="col-lg-6">
|
|
<div class="card shadow-lg">
|
|
<div class="card-header shadow">
|
|
<i class="fa-solid fa-circle-info"></i> Connection details
|
|
</div>
|
|
<div class="card-body">
|
|
<p class="text-muted small">
|
|
For a single app's own "LDAP authentication" settings — see
|
|
<a href="https://theta42.github.io/sso-manager-node/ldap.html#connecting-a-3rd-party-app-or-container" target="_blank">Connecting a 3rd-party app or container</a>
|
|
for a field-by-field walkthrough (Gitea, generic Docker <code>LDAP_*</code> env vars, …).
|
|
</p>
|
|
<dl class="row mb-0">
|
|
<dt class="col-sm-4">LDAPS URL</dt>
|
|
<dd class="col-sm-8">
|
|
<div class="input-group input-group-sm">
|
|
<input type="text" id="f-ldapsUrl" class="form-control font-monospace" readonly value="<%= ldapsUrl %>">
|
|
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-ldapsUrl', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
</div>
|
|
</dd>
|
|
|
|
<dt class="col-sm-4">Base DN</dt>
|
|
<dd class="col-sm-8">
|
|
<div class="input-group input-group-sm">
|
|
<input type="text" id="f-baseDn" class="form-control font-monospace" readonly value="<%= baseDn %>">
|
|
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-baseDn', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
</div>
|
|
</dd>
|
|
|
|
<dt class="col-sm-4">User search base</dt>
|
|
<dd class="col-sm-8">
|
|
<div class="input-group input-group-sm">
|
|
<input type="text" id="f-userBase" class="form-control font-monospace" readonly value="<%= userBase %>">
|
|
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userBase', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
</div>
|
|
</dd>
|
|
|
|
<dt class="col-sm-4">Group search base</dt>
|
|
<dd class="col-sm-8">
|
|
<div class="input-group input-group-sm">
|
|
<input type="text" id="f-groupBase" class="form-control font-monospace" readonly value="<%= groupBase %>">
|
|
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-groupBase', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
</div>
|
|
</dd>
|
|
|
|
<dt class="col-sm-4">User filter</dt>
|
|
<dd class="col-sm-8">
|
|
<div class="input-group input-group-sm">
|
|
<input type="text" id="f-userFilter" class="form-control font-monospace" readonly value="<%= userFilter %>">
|
|
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userFilter', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
</div>
|
|
</dd>
|
|
|
|
<dt class="col-sm-4">Username attribute</dt>
|
|
<dd class="col-sm-8">
|
|
<div class="input-group input-group-sm">
|
|
<input type="text" id="f-userNameAttribute" class="form-control font-monospace" readonly value="<%= userNameAttribute %>">
|
|
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userNameAttribute', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
</div>
|
|
</dd>
|
|
|
|
<dt class="col-sm-4">Example bind DN</dt>
|
|
<dd class="col-sm-8">
|
|
<div class="input-group input-group-sm">
|
|
<input type="text" id="f-bindDn" class="form-control font-monospace" readonly value="<%= exampleBindDn %>">
|
|
<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).
|
|
</small>
|
|
</dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-6">
|
|
<div class="card shadow-lg">
|
|
<div class="card-header shadow">
|
|
<i class="fa-solid fa-terminal"></i> Set up a Linux host (ldap-client)
|
|
</div>
|
|
<div class="card-body">
|
|
<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.
|
|
</p>
|
|
<div class="input-group">
|
|
<textarea id="f-bashSnippet" class="form-control font-monospace" rows="16" readonly style="font-size:.8rem"></textarea>
|
|
</div>
|
|
<button class="btn btn-outline-secondary btn-sm mt-2" type="button" onclick="copyField('f-bashSnippet', this)">
|
|
<i class="fa-solid fa-copy"></i> Copy
|
|
</button>
|
|
</div>
|
|
</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>
|
|
|
|
<script type="text/javascript">
|
|
(function(){
|
|
var lines = [
|
|
'git clone https://github.com/theta42/ldap-client.git',
|
|
'cd ldap-client',
|
|
'cat > ldap.vars << \'EOF\'',
|
|
'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.',
|
|
'export ldap_bind_dn="<%= exampleBindDn %>"',
|
|
'export ldap_bind_password="CHANGE-ME"',
|
|
'',
|
|
'# Optional: auto-register this host\'s access/sudo groups in the SSO',
|
|
'# Manager. Create a personal access token under Profile > API Tokens',
|
|
'# and paste it here; leave blank to skip.',
|
|
'export sso_url="<%= ssoUrl %>"',
|
|
'export sso_token=""',
|
|
'',
|
|
'# Optional: set this if you run ldap-client against more than one site.',
|
|
'export ldap_location=""',
|
|
'',
|
|
'ldap_access_groups=( "${ldap_location}_access" "${ldap_location}_host_$(hostname)_access" )',
|
|
'EOF',
|
|
'',
|
|
'sudo ./index.sh',
|
|
];
|
|
document.getElementById('f-bashSnippet').value = lines.join('\n');
|
|
})();
|
|
</script>
|
|
|
|
<%- include('bottom') %>
|