375 lines
14 KiB
Plaintext
375 lines
14 KiB
Plaintext
<%- 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">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="fa-solid fa-key"></i> Client 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 secret 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_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;
|
|
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);
|
|
});
|
|
}
|
|
|
|
// 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"); createScopes.set(DEFAULT_SCOPES); createGroups.clear();'
|
|
);
|
|
});
|
|
|
|
</script>
|
|
<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>
|
|
</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>
|
|
<%- include('bottom') %>
|