Files
proxy/nodejs/views/permissions.ejs
T
wmantly 255835af7a
Pull Request Tests / Run Tests (18.x) (push) Successful in 49s
Pull Request Tests / Run Tests (20.x) (push) Successful in 40s
Pull Request Tests / Run Tests (22.x) (push) Successful in 41s
Pull Request Tests / Test Summary (push) Successful in 4s
feat: edit permission entries (v1.35.0)
The Permissions page only offered Delete, so changing a role or scope
meant removing the grant and re-adding it from memory.

A permission's id is derived from (subjectType, subject, scope, domain),
so changing any of those is a different record rather than an update. The
new PUT creates the new grant and removes the superseded one in that
order, so an edit can never leave the old grant behind still conferring
access.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:34:11 -04:00

246 lines
9.8 KiB
Plaintext

<%- include('top') %>
<script type="text/javascript">
// Require login to see this page. The API is admin-only; non-admins get 403s.
app.auth.forceLogin();
</script>
<script type="text/javascript">
// Kept alongside the rendered scope so the Edit modal can pre-fill from the
// record without a second round-trip ($.scope exposes push/empty/remove,
// not a lookup by key).
var permissionsById = {};
function loadPermissions() {
app.permission.list(function(error, data){
if(error) return app.messages.action(error, $('#permissions-list'), 'danger');
permissionsById = {};
$.scope.Permission.empty();
for(let p of (data.results || [])){
permissionsById[p.id] = p;
$.scope.Permission.push(p);
}
});
}
// Fill the username/group datalists that back the Subject autocomplete.
function loadSubjectSuggestions(){
app.permission.subjects(function(error, data){
if(error || !data) return;
let $users = $('#subjectUsers').empty();
for(let u of (data.users || [])) $users.append($('<option>').val(u));
let $groups = $('#subjectGroups').empty();
for(let g of (data.groups || [])) $groups.append($('<option>').val(g));
});
}
// Point the Subject input at the right suggestion list for the chosen type.
function subjectTypeChanged(sel){
let $input = $(sel).closest('form').find('input[name="subject"]');
if(sel.value === 'group'){
$input.attr('list', 'subjectGroups').attr('placeholder', 'dns-team');
}else{
$input.attr('list', 'subjectUsers').attr('placeholder', 'alice');
}
}
function permissionAddOpen(){
app.modal.open({title: 'Add Permission', bodyHtml:
'<form action="permission/" onsubmit="formAJAX(this)" evalAJAX="app.modal.close(); loadPermissions();">'
+ '<div class="mb-3">'
+ '<label class="form-label fw-bold">Subject type</label>'
+ '<select class="form-select" name="subjectType" onchange="subjectTypeChanged(this)">'
+ '<option value="user">User</option>'
+ '<option value="group">Group</option>'
+ '</select>'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label fw-bold">Subject (username or group)</label>'
+ '<input type="text" class="form-control" name="subject" list="subjectUsers" placeholder="alice" autocomplete="off" />'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label fw-bold">Scope</label>'
+ '<select class="form-select" name="scope">'
+ '<option value="domain">Domain</option>'
+ '<option value="global">Global</option>'
+ '</select>'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label fw-bold">Domain (for domain scope)</label>'
+ '<input type="text" class="form-control" name="domain" placeholder="example.com" autocomplete="off" />'
+ '<div class="form-text text-muted">'
+ 'Wildcards: <code>*.example.com</code> matches one label, '
+ '<code>**.example.com</code> matches any depth (incl. the apex), '
+ '<code>**</code> matches every domain.'
+ '</div>'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label fw-bold">Role</label>'
+ '<select class="form-select" name="role">'
+ '<option value="viewer">Viewer (read)</option>'
+ '<option value="manager">Manager (full over domain)</option>'
+ '<option value="admin">Admin (global only)</option>'
+ '</select>'
+ '</div>'
+ '<hr />'
+ '<div class="d-flex justify-content-end gap-2">'
+ '<button type="button" class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>'
+ '<button type="submit" class="btn btn-primary">Add Permission</button>'
+ '</div>'
+ '</form>',
});
}
// Edit an existing grant. A permission's id is derived from
// (subjectType, subject, scope, domain), so changing any of those replaces
// the record rather than updating it -- the API handles that and removes the
// superseded grant, which is why this is a single Edit rather than making
// the operator delete and re-add (and risk leaving the old grant in place).
function permissionEditOpen(id){
var p = permissionsById[id] || {};
function sel(v, want){ return v === want ? ' selected' : ''; }
app.modal.open({title: 'Edit Permission', bodyHtml:
'<div class="mb-3">'
+ '<label class="form-label fw-bold">Subject type</label>'
+ '<select class="form-select" id="perm-edit-subjectType">'
+ '<option value="user"' + sel(p.subjectType, 'user') + '>User</option>'
+ '<option value="group"' + sel(p.subjectType, 'group') + '>Group</option>'
+ '</select>'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label fw-bold">Subject (username or group)</label>'
+ '<input type="text" class="form-control" id="perm-edit-subject" list="subjectUsers" autocomplete="off" value="' + app.util.escapeHtml(p.subject || '') + '" />'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label fw-bold">Scope</label>'
+ '<select class="form-select" id="perm-edit-scope">'
+ '<option value="domain"' + sel(p.scope, 'domain') + '>Domain</option>'
+ '<option value="global"' + sel(p.scope, 'global') + '>Global</option>'
+ '</select>'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label fw-bold">Domain (for domain scope)</label>'
+ '<input type="text" class="form-control" id="perm-edit-domain" autocomplete="off" value="' + app.util.escapeHtml(p.domain || '') + '" />'
+ '<div class="form-text text-muted">'
+ 'Wildcards: <code>*.example.com</code> matches one label, '
+ '<code>**.example.com</code> matches any depth (incl. the apex), '
+ '<code>**</code> matches every domain.'
+ '</div>'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label fw-bold">Role</label>'
+ '<select class="form-select" id="perm-edit-role">'
+ '<option value="viewer"' + sel(p.role, 'viewer') + '>Viewer (read)</option>'
+ '<option value="manager"' + sel(p.role, 'manager') + '>Manager (full over domain)</option>'
+ '<option value="admin"' + sel(p.role, 'admin') + '>Admin (global only)</option>'
+ '</select>'
+ '</div>'
+ '<hr />'
+ '<div class="d-flex justify-content-end gap-2">'
+ '<button type="button" class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>'
+ '<button type="button" class="btn btn-primary" onclick="permissionEditSave(\'' + id + '\')">Save changes</button>'
+ '</div>',
});
}
function permissionEditSave(id){
var payload = {
subjectType: $('#perm-edit-subjectType').val(),
subject: ($('#perm-edit-subject').val() || '').trim(),
scope: $('#perm-edit-scope').val(),
domain: ($('#perm-edit-domain').val() || '').trim(),
role: $('#perm-edit-role').val(),
};
if(!payload.subject){
return app.messages.toast('Subject is required', 'warning');
}
app.permission.update(id, payload, function(error, data){
if(error) return app.messages.action((data && data.message) || error, $('#permissions-list'), 'danger');
app.modal.close();
loadPermissions();
});
}
function removePermission(id){
app.permission.remove(id, function(error, data){
if(error) return app.messages.action(error, $('#permissions-list'), 'danger');
$.scope.Permission.remove(id);
loadPermissions();
});
}
$(document).ready(function(){
loadPermissions();
loadSubjectSuggestions();
$.scope.Permission.__take = function($el, item, list){
$el.addClass('bg-danger');
$el.fadeOut(600, function(){ $el.remove(); });
};
// Live updates (model:Permission:*), so adds/removes reflect for everyone.
app.subscribe(/^model:Permission:create/, function(data){
loadPermissions();
});
app.subscribe(/^model:Permission:remove/, function(data, topic){
loadPermissions();
});
});
</script>
<div class="container mt-4">
<datalist id="subjectUsers"></datalist>
<datalist id="subjectGroups"></datalist>
<div class="row">
<div class="col-12">
<div class="card shadow">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<i class="fa-solid fa-user-shield me-2"></i><strong>Permissions List</strong>
</div>
<div>
<a href="/docs/access" class="text-reset me-3" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<button type="button" class="btn btn-sm btn-primary" onclick="permissionAddOpen()">
<i class="fa-solid fa-user-shield me-1"></i> Add Permission
</button>
</div>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body p-0">
<ul class="list-group list-group-flush" id="permissions-list">
<li class="list-group-item d-flex align-items-center justify-content-between py-3" jq-repeat="Permission" jq-repeat-index="id" id="permission-row-{{id}}" style="display:none">
<div class="d-flex align-items-center">
<div class="me-3 fs-4 text-primary">
<i class="fa-solid fa-user-check"></i>
</div>
<div>
<h6 class="mb-1 fw-bold">
<span class="badge bg-secondary me-2">{{ subjectType }}</span> {{ subject }}
</h6>
<div class="small text-muted">
<span class="me-3"><strong>Scope:</strong> {{ scope }}</span>
<span class="me-3"><strong>Domain:</strong> <code>{{ domain }}</code></span>
<span><strong>Role:</strong> <span class="badge bg-info text-dark">{{ role }}</span></span>
</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="permissionEditOpen('{{id}}')">
<i class="fa-solid fa-pen me-1"></i> Edit
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removePermission('{{id}}')">
<i class="fa-solid fa-trash me-1"></i> Delete
</button>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<%- include('bottom') %>