oauth edit

This commit is contained in:
2026-07-11 00:54:49 -04:00
parent bb79247054
commit 4f432ec448
4 changed files with 533 additions and 6 deletions
+67
View File
@@ -44,3 +44,70 @@ a:hover, button:hover, div.form-group:hover, header nav a{
transform: scale(1.05); /* Standard syntax */
z-index: 999999;
}
/* ── Reusable tag / token input (app.ui.tagInput / groupSelect) ────────────── */
.tag-input{
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .25rem;
min-height: calc(1.5em + .75rem + 2px);
height: auto;
cursor: text;
}
.tag-input .tag-chips{
display: contents;
}
.tag-input .tag-chip{
display: inline-flex;
align-items: center;
gap: .35rem;
background-color: #0d6efd;
color: #fff;
font-size: .85em;
font-weight: 500;
padding: .25rem .5rem;
border-radius: .5rem;
}
.tag-input .tag-chip .tag-remove{
color: #fff;
opacity: .8;
text-decoration: none;
font-weight: 700;
line-height: 1;
cursor: pointer;
}
.tag-input .tag-chip .tag-remove:hover{ opacity: 1; transform: none; }
.tag-input .tag-typeahead{
flex: 1 1 8ch;
min-width: 8ch;
border: none;
outline: none;
background: transparent;
padding: .15rem 0;
}
.tag-input .tag-menu{
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1080;
background: #fff;
border: 1px solid rgba(0,0,0,.15);
border-radius: .375rem;
box-shadow: 0 .5rem 1rem rgba(0,0,0,.15);
max-height: 12rem;
overflow-y: auto;
margin-top: .15rem;
}
.tag-input .tag-option{
padding: .35rem .6rem;
cursor: pointer;
}
.tag-input .tag-option:hover,
.tag-input .tag-option.active{
background-color: #0d6efd;
color: #fff;
transform: none;
}
+124
View File
@@ -128,6 +128,130 @@ app.group = (function(app){
return {list, get, remove}
})(app)
// Reusable UI widgets. app.ui.tagInput is a generic tag/token field with
// autocomplete; app.ui.groupSelect is a tagInput preloaded with every LDAP
// group, meant to be dropped in anywhere group selection is needed.
app.ui = (function(app){
// All LDAP group CNs, fetched once and shared across every group selector.
var _groupsPromise = null;
function loadGroups(){
if(!_groupsPromise){
_groupsPromise = new Promise(function(resolve){
app.group.list(function(error, data){
if(error || !data || !data.results){ resolve([]); return; }
resolve(data.results.map(function(g){ return g.cn || g; }).filter(Boolean).sort());
});
});
}
return _groupsPromise;
}
// Drop the cache (e.g. after a group is created) so the next selector refetches.
function refreshGroups(){ _groupsPromise = null; return loadGroups(); }
// opts: { values, options, freeSolo, placeholder, name, separator }
// Returns a handle: { get, set, add, clear, setOptions, element }.
function tagInput(mount, opts){
opts = opts || {};
var separator = opts.separator != null ? opts.separator : '\n';
var values = (opts.values || []).slice();
var options = (opts.options || []).slice();
var freeSolo = opts.freeSolo !== false; // default: allow custom entries
var $mount = $(mount).addClass('tag-input form-control shadow').empty();
var $chips = $('<span class="tag-chips"></span>');
var $input = $('<input type="text" class="tag-typeahead" autocomplete="off">')
.attr('placeholder', opts.placeholder || 'Type to add…');
var $menu = $('<div class="tag-menu"></div>').hide();
var $hidden = $('<input type="hidden">').attr('name', opts.name || '');
$mount.append($chips, $input, $menu, $hidden);
function syncHidden(){
var joined = values.join(separator);
$hidden.val(joined).attr('value', joined);
$mount.trigger('change');
}
function renderChips(){
$chips.empty();
values.forEach(function(v){
var $remove = $('<a class="tag-remove" href="#">×</a>').on('click', function(e){
e.preventDefault(); e.stopPropagation();
values = values.filter(function(x){ return x !== v; });
renderChips(); syncHidden();
});
$chips.append($('<span class="tag-chip badge"></span>').text(v).append(' ', $remove));
});
}
function suggestions(){
var q = ($input.val() || '').toLowerCase();
return options.filter(function(o){
return values.indexOf(o) === -1 && o.toLowerCase().indexOf(q) !== -1;
}).slice(0, 10);
}
function showMenu(){
var items = suggestions();
if(!items.length){ return hideMenu(); }
$menu.empty();
items.forEach(function(o){
$('<div class="tag-option"></div>').text(o).on('mousedown', function(e){
e.preventDefault(); add(o);
}).appendTo($menu);
});
$menu.show();
}
function hideMenu(){ $menu.hide(); }
function add(val){
val = (val || '').trim();
if(!val){ return; }
if(!freeSolo && options.indexOf(val) === -1){ return; } // reject invalid
if(values.indexOf(val) === -1){ values.push(val); renderChips(); syncHidden(); }
$input.val(''); hideMenu();
}
$input.on('input focus', showMenu);
$input.on('keydown', function(e){
if(e.key === 'Enter' || e.key === ','){
e.preventDefault();
add(freeSolo ? $input.val() : (suggestions()[0] || ''));
}else if(e.key === 'Backspace' && !$input.val() && values.length){
values.pop(); renderChips(); syncHidden();
}
});
$input.on('blur', function(){ setTimeout(hideMenu, 150); });
$mount.on('click', function(e){
if(e.target === $mount[0] || e.target === $chips[0]){ $input.focus(); }
});
renderChips(); syncHidden();
return {
get: function(){ return values.slice(); },
set: function(v){ values = (v || []).slice(); renderChips(); syncHidden(); },
add: add,
clear: function(){ values = []; renderChips(); syncHidden(); },
setOptions: function(o){ options = (o || []).slice(); },
element: $mount,
};
}
// Universal group selector. Preloads all LDAP groups for autocomplete.
function groupSelect(mount, opts){
opts = opts || {};
var handle = tagInput(mount, {
name: opts.name || 'groups',
values: opts.values || [],
options: [],
freeSolo: opts.freeSolo !== false,
separator: opts.separator != null ? opts.separator : '\n',
placeholder: opts.placeholder || 'Type a group name…',
});
loadGroups().then(function(groups){ handle.setOptions(groups); });
return handle;
}
return { tagInput: tagInput, groupSelect: groupSelect, loadGroups: loadGroups, refreshGroups: refreshGroups };
})(app);
app.oauthClient = (function(app){
function list(callack){
return app.api.get('oauth/client/', function(error, data){
+126 -6
View File
@@ -1,5 +1,55 @@
<%- include('top') %>
<!-- Edit 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>
</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 -->
<div class="modal fade" id="secretModal" tabindex="-1">
<div class="modal-dialog">
@@ -27,7 +77,16 @@
<script type="text/javascript">
app.auth.forceLogin('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){
document.getElementById('secretValue').value = secret;
@@ -60,6 +119,7 @@
}
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;
@@ -98,11 +158,67 @@
});
}
// 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();
});
}
$(document).ready(function(){
tableAJAX();
// 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); tableAJAX(); $form.trigger("reset");'
'showSecret(data.client_secret); tableAJAX(); $form.trigger("reset"); createScopes.set(DEFAULT_SCOPES); createGroups.clear();'
);
});
@@ -157,13 +273,12 @@
placeholder="https://ha.example.com/auth/external/callback" validate=":1"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Scopes <small class="text-muted">(space-separated)</small></label>
<input type="text" class="form-control shadow" name="scopes" value="openid profile email groups">
<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">(one per line, optional)</small></label>
<textarea class="form-control shadow font-monospace" name="allowed_groups" rows="2"
placeholder="app_homeassistant&#10;app_sso_admin"></textarea>
<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">
@@ -236,6 +351,11 @@
</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">