Add per-user TOTP 2FA, client-level user assignment, self-service portal

- TOTP (RFC 6238, stdlib-only) enrollment in profile, login step-up,
  admin emergency reset.
- Admins can grant a user visibility into individual clients
  (User.ClientIDs) in addition to whole-server access (User.ServerIDs).
- New "My Access" page: non-admin users see only their assigned clients
  (view/QR/download only, no management), reachable from the main nav.
- GetUser/GetUsers now redact TOTPSecret before returning JSON.

No Go toolchain was available while writing this - not yet build-verified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PvrfUytqd74H6WcQkRzFM4
This commit is contained in:
sysops
2026-07-25 00:42:05 +02:00
co-authored by Claude Sonnet 5
parent c29edfdcc3
commit 34bc8f76f9
12 changed files with 1213 additions and 36 deletions
+45 -1
View File
@@ -59,6 +59,12 @@ Users Settings
</select>
<small class="form-text text-muted">Servers this user (if non-admin) may access. Admins always have access to all servers.</small>
</div>
<div class="form-group">
<label for="_client_ids" class="control-label">Individual Client Access</label>
<select multiple class="form-control" id="_client_ids" name="_client_ids">
</select>
<small class="form-text text-muted">Grants a non-admin user visibility into these specific clients, even without full server access.</small>
</div>
</div>
<div class="modal-footer justify-content-between">
@@ -179,7 +185,9 @@ Users Settings
success: function (servers) {
const select = modal.find("#_server_ids");
select.empty();
const serverNameById = {};
$.each(servers, function (index, srv) {
serverNameById[srv.id] = srv.name;
select.append($('<option>').val(srv.id).text(srv.name + " (" + srv.id + ")"));
});
@@ -188,6 +196,35 @@ Users Settings
if (user_name !== "") {
select.val(select.data('preselect') || []);
}
// populate the individual client access select, labeling each option
// with both the client's name/email and the server it belongs to so
// admins aren't picking blind between same-named clients on different servers
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/api/clients',
dataType: 'json',
contentType: "application/json",
success: function (clientDataList) {
const clientSelect = modal.find("#_client_ids");
clientSelect.empty();
$.each(clientDataList, function (index, clientData) {
const client = clientData.Client;
const serverName = serverNameById[client.server_id] || client.server_id || "unknown server";
const label = (client.name || client.email || client.id) + " — " + serverName;
clientSelect.append($('<option>').val(client.id).text(label));
});
if (user_name !== "") {
clientSelect.val(clientSelect.data('preselect') || []);
}
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
@@ -216,6 +253,10 @@ Users Settings
// once its options have been populated (see the servers ajax above)
modal.find("#_server_ids").data('preselect', user.server_ids || []);
modal.find("#_server_ids").val(user.server_ids || []);
// remember the granted client ids so the select can pre-select them
// once its options have been populated (see the clients ajax above)
modal.find("#_client_ids").data('preselect', user.client_ids || []);
modal.find("#_client_ids").val(user.client_ids || []);
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
@@ -230,6 +271,7 @@ Users Settings
modal.find("#_user_password").prop("placeholder", "")
modal.find("#_admin").prop("checked", false);
modal.find("#_server_ids").data('preselect', []);
modal.find("#_client_ids").data('preselect', []);
}
});
});
@@ -243,12 +285,14 @@ Users Settings
admin = true;
}
const server_ids = $("#_server_ids").val() || [];
const client_ids = $("#_client_ids").val() || [];
const data = {
"username": username,
"password": password,
"previous_username": previous_username,
"admin": admin,
"server_ids": server_ids
"server_ids": server_ids,
"client_ids": client_ids
};
if (previous_username !== "") {