wireguard-ui/templates/users_settings.html

741 lines
35 KiB
HTML

{{define "title"}}{{ tr .UILang "users.title" }}{{end}}
{{define "top_css"}}{{end}}
{{define "username"}}{{ .username }}{{end}}
{{define "page_title"}}{{ tr .UILang "users.page" }}{{end}}
{{define "page_content"}}
<section class="content wg-users-content">
<div class="container-fluid p-0">
<div class="wg-users-page" id="wg_users_root">
<div class="usr-stats">
<div class="sc cb">
<div class="sl">{{ tr .UILang "users.stat_total_upper" }}</div>
<div class="sv" id="wu-stat-total">0</div>
<div class="sf2"><span class="pill pb">{{ tr .UILang "users.stat_total_badge" }}</span></div>
</div>
<div class="sc ca">
<div class="sl">{{ tr .UILang "users.stat_admins_upper" }}</div>
<div class="sv" id="wu-stat-admins">0</div>
<div class="sf2"><span class="pill pa">{{ tr .UILang "users.stat_admins_badge" }}</span></div>
</div>
<div class="sc cg">
<div class="sl">{{ tr .UILang "users.stat_with_pk_upper" }}</div>
<div class="sv" id="wu-stat-passkeys">0</div>
<div class="sf2"><span class="pill pg">{{ tr .UILang "users.stat_with_pk_badge" }}</span></div>
</div>
<div class="sc cr">
<div class="sl">{{ tr .UILang "users.stat_no_pk_upper" }}</div>
<div class="sv" id="wu-stat-nokeys">0</div>
<div class="sf2"><span class="pill pr">{{ tr .UILang "users.stat_no_pk_badge" }}</span></div>
</div>
</div>
<div class="wu-toolbar">
<div class="sr-wrap">
<svg class="sr-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" class="si" id="wu_search_input" autocomplete="off" placeholder="{{ tr .UILang "users.search_ph" }}"/>
</div>
<button type="button" class="wg-btn bp" id="wu_btn_new_open">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="width:12px;height:12px"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
{{ tr .UILang "users.btn_new" }}
</button>
</div>
<div class="usr-table">
<div class="usr-th">
<div class="usr-thc">{{ tr .UILang "users.th_user" }}</div>
<div class="usr-thc">{{ tr .UILang "users.th_email" }}</div>
<div class="usr-thc">{{ tr .UILang "users.th_role" }}</div>
<div class="usr-thc">{{ tr .UILang "users.th_state" }}</div>
<div class="usr-thc">{{ tr .UILang "users.th_passkeys" }}</div>
<div class="usr-thc" style="text-align:right">{{ tr .UILang "users.th_actions" }}</div>
</div>
<div id="wu_tbody"></div>
</div>
</div>
</div>
</section>
{{/* New user modal */}}
<div class="modal fade" id="modal_wu_new" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title font-weight-bold">{{ tr .UILang "users.modal_new_title" }}</h4>
<button type="button" class="close" data-dismiss="modal">&times;</button>
</div>
<div class="modal-body">
<div class="form-row">
<div class="form-group col-md-6">
<label>{{ tr .UILang "users.lbl_display_name" }}</label>
<input type="text" class="form-control" id="wu_new_display" placeholder="{{ tr .UILang "users.ph_display_ex" }}"/>
</div>
<div class="form-group col-md-6">
<label>{{ tr .UILang "users.lbl_username" }}</label>
<input type="text" class="form-control" id="wu_new_username" placeholder="{{ tr .UILang "users.ph_username_ex" }}"/>
</div>
</div>
<div class="form-group">
<label>{{ tr .UILang "users.lbl_email" }}</label>
<input type="email" class="form-control" id="wu_new_email" placeholder="{{ tr .UILang "profile.ph_email" }}"/>
</div>
<div class="form-group">
<label>{{ tr .UILang "users.lbl_initial_pw" }}</label>
<input type="password" class="form-control" id="wu_new_password" autocomplete="new-password" placeholder="{{ tr .UILang "users.ph_min_pw" }}"/>
</div>
<div class="form-group mb-0">
<label>{{ tr .UILang "users.lbl_role" }}</label>
<select class="form-control" id="wu_new_admin">
<option value="0">{{ tr .UILang "users.role_standard" }}</option>
<option value="1">{{ tr .UILang "users.role_admin_option" }}</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="wg-btn bg" data-dismiss="modal">{{ tr .UILang "users.btn_cancel" }}</button>
<button type="button" class="wg-btn bp" id="wu_new_submit">{{ tr .UILang "users.btn_create" }}</button>
</div>
</div>
</div>
</div>
{{/* Edit user modal */}}
<div class="modal fade" id="modal_wu_edit" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title font-weight-bold" id="wu_edit_title">{{ tr .UILang "users.modal_edit_title" }}</h4>
<button type="button" class="close" data-dismiss="modal">&times;</button>
</div>
<div class="modal-body">
<input type="hidden" id="wu_edit_previous"/>
<div class="form-row">
<div class="form-group col-md-6">
<label>{{ tr .UILang "users.lbl_display_name" }}</label>
<input type="text" class="form-control" id="wu_edit_display"/>
</div>
<div class="form-group col-md-6">
<label>{{ tr .UILang "users.lbl_username" }}</label>
<input type="text" class="form-control" id="wu_edit_username"/>
</div>
</div>
<div class="form-group">
<label>{{ tr .UILang "users.lbl_email" }}</label>
<input type="email" class="form-control" id="wu_edit_email"/>
</div>
<div class="form-group">
<label>{{ tr .UILang "users.lbl_password" }}</label>
<input type="password" class="form-control" id="wu_edit_password" autocomplete="new-password" placeholder="{{ tr .UILang "users.ph_pw_unchanged" }}"/>
</div>
<div class="form-check" id="wu_edit_admin_wrap">
<input type="checkbox" class="form-check-input" id="wu_edit_admin"/>
<label class="form-check-label" for="wu_edit_admin">{{ tr .UILang "users.edit_admin_checkbox" }}</label>
<small class="form-text text-muted">{{ tr .UILang "users.edit_admin_note" }}</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="wg-btn bg" data-dismiss="modal">{{ tr .UILang "users.btn_cancel" }}</button>
<button type="button" class="wg-btn bp" id="wu_edit_submit">{{ tr .UILang "users.btn_save" }}</button>
</div>
</div>
</div>
</div>
{{/* Delete user modal */}}
<div class="modal fade" id="modal_wu_del" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<button type="button" class="close ml-auto" data-dismiss="modal">&times;</button>
</div>
<div class="modal-body text-center pt-0 pb-4">
<div style="width:52px;height:52px;margin:0 auto 12px;border-radius:12px;background:rgba(239,83,80,.14);border:1px solid rgba(239,83,80,.28);display:flex;align-items:center;justify-content:center;">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#EF5350" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
</div>
<h5 class="font-weight-bold mb-2">{{ tr .UILang "users.del_title" }}</h5>
<p class="text-muted small mb-0" id="wu_del_msg"></p>
</div>
<div class="modal-footer border-0 justify-content-center pb-4">
<button type="button" class="wg-btn bg" data-dismiss="modal">{{ tr .UILang "users.btn_cancel" }}</button>
<button type="button" class="wg-btn brd" id="wu_del_confirm">{{ tr .UILang "users.del_confirm_btn" }}</button>
</div>
</div>
</div>
</div>
{{end}}
{{define "bottom_js"}}
<script>
(function () {
var BASE = '{{.basePath}}';
var WG_ME = '{{.baseData.CurrentUser}}';
var PASSKEYS_OK = {{if and .globalSettings .globalSettings.TOTPEnabled}}true{{else}}false{{end}};
if (typeof window.wgReauthenticateRedirectIfNeeded !== 'function') {
window.wgReauthenticateRedirectIfNeeded = function (resp) {
if (!resp || !resp.reauthenticate) return false;
window.location.assign(BASE + '/login');
return true;
};
}
var usersCache = [];
var avatarColors = ['#C62828','#388E3C','#F57C00','#7B1FA2','#C62828','#00838F','#558B2F','#AD1457'];
$('body').addClass('wusers-hide-new-client');
function avColor(seed) {
var s = String(seed || 'x');
var n = 0;
for (var i = 0; i < s.length; i++) n += s.charCodeAt(i);
return avatarColors[n % avatarColors.length];
}
function initials(name) {
var p = String(name || '').trim().split(/\s+/).filter(Boolean);
if (!p.length) return '??';
if (p.length === 1) return p[0].substring(0, 2).toUpperCase();
return (p[0][0] + p[p.length - 1][0]).toUpperCase();
}
function esc(s) { return typeof wgEscapeHtml === 'function' ? wgEscapeHtml(String(s ?? '')) : String(s ?? ''); }
function escAttr(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/\r|\n/g, '');
}
function toggleDetail(un) {
var det = document.getElementById('wu-det-' + un);
if (!det) return;
var open = det.classList.contains('open');
document.querySelectorAll('.wg-users-page .usr-detail.open').forEach(function (el) {
el.classList.remove('open');
});
if (!open) det.classList.add('open');
}
function updateStats(users) {
var t = users.length;
var a = users.filter(function (u) { return u.admin; }).length;
var w = users.filter(function (u) { return (u.passkeys && u.passkeys.length); }).length;
$('#wu-stat-total').text(t);
$('#wu-stat-admins').text(a);
$('#wu-stat-passkeys').text(w);
$('#wu-stat-nokeys').text(t - w);
}
function renderPkList(u) {
if (u.disabled) {
return '<div class="pk-empty">' + esc(wgT('users.pk_suspended')) + '</div>';
}
if (!PASSKEYS_OK) {
return '<div class="pk-empty">' + esc(wgT('users.pk_disabled_global')) + '</div>';
}
if (!u.passkeys || !u.passkeys.length) {
return '<div class="pk-empty"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="8" cy="15" r="4"/><line x1="10.85" y1="12.15" x2="19" y2="4"/></svg>' + esc(wgT('users.pk_none_row')) + '</div>';
}
var lines = '<div class="pk-list">';
u.passkeys.forEach(function (pk) {
var nid = escAttr(pk.credential_id);
lines += '<div class="pk-item">' +
'<div class="pk-info">' +
'<div class="pk-name" tabindex="0" role="button" title="' + escAttr(wgT('users.rename_tooltip')) + '" data-un="' + escAttr(u.username) + '" data-crid="' + nid + '">' + esc(pk.name) + '</div>' +
'<div class="pk-fp">' + esc(pk.fingerprint) + '</div></div>' +
'<button type="button" class="pk-del wu-del-pk" data-un="' + escAttr(u.username) + '" data-crid="' + nid + '" title="' + escAttr(wgT('users.delete_pk_tooltip')) + '">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg></button></div>';
});
lines += '</div>';
return lines;
}
function rowHtml(u) {
var email = esc(u.email || '—');
var handle = esc(u.username);
var dn = esc(u.display_name || u.username);
var nk = u.passkeys ? u.passkeys.length : 0;
var nkLabel = nk + ' ' + (nk !== 1 ? wgT('users.key_plural') : wgT('users.key_single'));
var dis = !!u.disabled;
var adm = !!u.admin;
var adminsActive = usersCache.filter(function (x) { return x.admin && !x.disabled; }).length;
var onlyOneAdmin = adminsActive <= 1 && adm && !dis;
var adminBtnTxt = adm ? wgT('users.btn_demote_admin') : wgT('users.btn_promote_admin');
var adminSvg = adm
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:11px;height:11px"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>'
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:11px;height:11px"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>';
var disableDemoteAdmin = adm && onlyOneAdmin && String(u.username) !== String(WG_ME);
var adminBtnStyle = disableDemoteAdmin ? 'opacity:0.45;pointer-events:none;' : '';
var canDeleteRow = !(adm || String(u.username) === String(WG_ME));
var delStyle = !canDeleteRow ? 'opacity:0.35;pointer-events:none' : '';
var forbidSuspendLastAdmin = adm && !dis && adminsActive <= 1;
var suspendBtnStyle = forbidSuspendLastAdmin ? 'opacity:0.45;pointer-events:none;' : '';
var revokeStyle = dis ? 'opacity:0.38;pointer-events:none;' : '';
var suspendLabel = dis ? wgT('users.btn_activate') : wgT('users.btn_suspend');
var roleBadge = adm
? '<span class="role-admin">' + adminSvg + ' ' + esc(wgT('users.role_admin_badge')) + '</span>'
: '<span class="role-user"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>' + esc(wgT('users.role_user_badge')) + '</span>';
var stateBadge = dis
? '<span class="usr-state usr-state-off">' + esc(wgT('users.state_suspended')) + '</span>'
: '<span class="usr-state usr-state-on">' + esc(wgT('users.state_active')) + '</span>';
return '<div class="usr-row' + (dis ? ' usr-disabled' : '') + '">' +
'<div class="usr-main" data-un="' + escAttr(u.username) + '">' +
'<div class="usr-ident">' +
'<div class="usr-av" style="background:' + avColor(u.username) + '">' + initials(u.display_name || u.username) + '</div>' +
'<div><div class="usr-name">' + dn + '</div><div class="usr-handle">@' + handle + '</div></div></div>' +
'<div class="usr-email">' + email + '</div>' +
'<div>' + roleBadge + '</div>' +
'<div class="usr-st-cell">' + stateBadge + '</div>' +
'<div class="usr-keys-count">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="8" cy="15" r="4"/><line x1="10.85" y1="12.15" x2="19" y2="4"/><line x1="18" y1="5" x2="20" y2="7"/><line x1="15" y1="8" x2="17" y2="6"/></svg>'
+ esc(nkLabel) + '</div>' +
'<div class="usr-actions-cell">' +
(String(u.username) === String(WG_ME)
? '<span class="pill pb" style="font-size:10px">' + esc(wgT('users.account_yours')) + '</span>'
: '<button type="button" class="wg-btn bg wu-toggle-admin" data-un="' + escAttr(u.username) + '" style="' + escAttr(adminBtnStyle) + '">' + adminSvg + esc(adminBtnTxt) + '</button>'
) +
'<button type="button" class="wg-btn bg wu-revoke-sessions" title="' + escAttr(wgT('users.btn_revoke_title')) + '" data-un="' + escAttr(u.username) + '" style="' + escAttr(revokeStyle) + '">' + esc(wgT('users.btn_revoke')) + '</button>' +
'<button type="button" class="wg-btn bg wu-toggle-disabled" title="' + escAttr(dis ? wgT('users.btn_suspend_title_allow') : wgT('users.btn_suspend_title_block')) + '" data-un="' + escAttr(u.username) + '" data-dis="' + (dis ? '1' : '0') + '" style="' + escAttr(suspendBtnStyle) + '">' + esc(suspendLabel) + '</button>' +
'<button type="button" class="wg-btn bg" title="' + escAttr(wgT('users.btn_edit_title')) + '" data-edit="' + escAttr(u.username) + '">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>' + esc(wgT('users.btn_edit')) + '</button>' +
'<button type="button" class="wg-btn brd wu-del-btn" style="' + delStyle + '" data-un="' + escAttr(u.username) + '" data-dn="' + escAttr(u.display_name || u.username) + '">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg></button>' +
'</div></div>' +
'<div class="usr-detail" id="wu-det-' + escAttr(u.username) + '">' +
'<div class="udet-inner">' +
'<div class="udet-title">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:12px;height:12px"><circle cx="8" cy="15" r="4"/><line x1="10.85" y1="12.15" x2="19" y2="4"/><line x1="18" y1="5" x2="20" y2="7"/><line x1="15" y1="8" x2="17" y2="6"/></svg>'
+ esc(wgT('users.passkeys_for')) + dn + '</div>' +
'<div id="wu-pkl-' + escAttr(u.username) + '">' + renderPkList(u) + '</div>' +
'<div class="pk-add-form" id="wu-pkf-' + escAttr(u.username) + '"' + (dis ? ' style="display:none"' : '') + '>' +
'<input type="text" id="wu-pkin-' + escAttr(u.username) + '" autocomplete="off" placeholder="' + escAttr(wgT('users.pk_placeholder_short')) + '"/>' +
'<button type="button" class="wg-btn bp wu-add-pk" data-un="' + escAttr(u.username) + '">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="width:11px;height:11px"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' + esc(wgT('users.pk_add_inline')) + '</button>' +
'</div></div></div></div>';
}
window.WU = {
redraw: renderAll
};
function filteredUsers(q) {
q = String(q || '').trim().toLowerCase();
if (!q) return usersCache.slice();
return usersCache.filter(function (u) {
var dn = String(u.display_name || '').toLowerCase();
var un = String(u.username || '').toLowerCase();
var em = String(u.email || '').toLowerCase();
return dn.indexOf(q) !== -1 || un.indexOf(q) !== -1 || em.indexOf(q) !== -1;
});
}
function renderAll(q) {
var list = filteredUsers(q);
var tbody = $('#wu_tbody');
tbody.empty();
if (!list.length) {
tbody.html('<div class="wu-empty">' + esc(wgT('users.empty_search')) + '</div>');
return;
}
list.forEach(function (u) {
tbody.append(rowHtml(u));
});
updateStats(usersCache);
}
function loadUsers() {
$.getJSON(BASE + '/get-users').done(function (data) {
usersCache = Array.isArray(data) ? data : [];
renderAll($('#wu_search_input').val());
}).fail(function (xhr) {
var m = wgT('users.err_load_users');
try { m = xhr.responseJSON.message || m; } catch (eE) {}
toastr.error(m);
});
}
$('#wu_search_input').on('input', function () {
renderAll($(this).val());
});
$('#wu_btn_new_open').click(function () {
$('#wu_new_display').val('');
$('#wu_new_username').val('');
$('#wu_new_email').val('');
$('#wu_new_password').val('');
$('#wu_new_admin').val('0');
$('#modal_wu_new').modal('show');
});
$('#wu_new_submit').click(function () {
var dn = $('#wu_new_display').val().trim();
var un = $('#wu_new_username').val().trim();
var em = $('#wu_new_email').val().trim();
var pw = $('#wu_new_password').val();
var ad = $('#wu_new_admin').val() === '1';
if (!un || !pw) {
toastr.error(wgT('users.err_new_user_pass'));
return;
}
$.ajax({
cache: false,
method: 'POST',
url: BASE + '/create-user',
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify({ username: un, password: pw, admin: ad, display_name: dn, email: em }),
success: function () {
toastr.success(wgT('users.ok_user_created'));
$('#modal_wu_new').modal('hide');
loadUsers();
},
error: function (xhr) {
var j = {}; try { j = xhr.responseJSON; } catch (e0) {}
toastr.error(j.message || xhr.responseText);
}
});
});
$(document).on('click', '[data-edit]', function (e) {
e.preventDefault();
e.stopPropagation();
var un = $(this).data('edit');
$.getJSON(BASE + '/api/user/' + encodeURIComponent(un)).done(function (u) {
$('#wu_edit_title').text(wgT('users.modal_edit_title') + ' — ' + un);
$('#wu_edit_previous').val(u.username);
$('#wu_edit_username').val(u.username);
$('#wu_edit_display').val(u.display_name || '');
$('#wu_edit_email').val(u.email || '');
$('#wu_edit_password').val('');
$('#wu_edit_admin').prop('checked', !!u.admin);
var editingOther = WG_ME !== u.username;
$('#wu_edit_admin').prop('disabled', !editingOther);
$('#wu_edit_admin_wrap').toggle(editingOther);
$('#modal_wu_edit').modal('show');
}).fail(function (xhr) {
var j = {}; try { j = xhr.responseJSON; } catch (e1) {}
toastr.error(j.message || wgT('users.err_generic_short'));
});
});
$('#wu_edit_submit').click(function () {
var prev = $('#wu_edit_previous').val();
var un = $('#wu_edit_username').val().trim();
var dn = $('#wu_edit_display').val().trim();
var em = $('#wu_edit_email').val().trim();
var pw = $('#wu_edit_password').val();
var ad = $('#wu_edit_admin').is(':checked');
if (!prev || !un) {
toastr.error(wgT('users.err_username_req'));
return;
}
$.ajax({
cache: false,
method: 'POST',
url: BASE + '/update-user',
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify({
previous_username: prev,
username: un,
password: pw,
admin: ad,
display_name: dn,
email: em
}),
success: function (resp) {
toastr.success((resp && resp.message) || wgT('users.ok_user_updated'));
if (window.wgReauthenticateRedirectIfNeeded && window.wgReauthenticateRedirectIfNeeded(resp)) return;
$('#modal_wu_edit').modal('hide');
loadUsers();
if (un === WG_ME || prev === WG_ME) {
window.location.reload();
}
},
error: function (xhr) {
var j = {}; try { j = xhr.responseJSON; } catch (e2) {}
toastr.error(j.message || xhr.responseText);
}
});
});
var pendingDel = null;
$(document).on('click', '.wu-del-btn', function (e) {
e.stopPropagation();
var un = $(this).data('un');
pendingDel = un;
var dn = $(this).data('dn');
$('#wu_del_msg').text(wgT('users.del_body_fmt').replace(/\{display\}/g, String(dn)).replace(/\{username\}/g, String(un)));
$('#modal_wu_del').modal('show');
});
$('#wu_del_confirm').click(function () {
if (!pendingDel) return;
var un = pendingDel;
$.ajax({
cache: false,
method: 'POST',
url: BASE + '/remove-user',
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify({ username: un }),
success: function () {
toastr.success(wgT('users.ok_user_deleted'));
$('#modal_wu_del').modal('hide');
pendingDel = null;
loadUsers();
},
error: function (xhr) {
var j = {}; try { j = xhr.responseJSON; } catch (e3) {}
toastr.error(j.message || xhr.responseText);
}
});
});
$(document).on('click', '.wu-toggle-admin', function (e) {
e.stopPropagation();
var un = $(this).data('un');
if (String(un) === String(WG_ME)) {
toastr.info(wgT('users.self_promote_hint'));
return;
}
var u = usersCache.find(function (x) { return x.username === un; });
if (!u) return;
var next = !u.admin;
$.ajax({
cache: false,
method: 'POST',
url: BASE + '/api/user/set-admin',
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify({ username: un, admin: next }),
success: function () {
toastr.success(next ? wgT('users.ok_promoted_admin') : wgT('users.ok_demoted_admin'));
loadUsers();
},
error: function (xhr) {
var j = {}; try { j = xhr.responseJSON; } catch (e4) {}
toastr.error(j.message || xhr.responseText);
}
});
});
$(document).on('click', '.wu-revoke-sessions', function (e) {
e.stopPropagation();
var un = $(this).attr('data-un');
var u = usersCache.find(function (x) { return x.username === un; });
if (!u || u.disabled) return;
if (!confirm(wgT('users.confirm_revoke_fmt').replace(/\{username\}/g, String(un)))) return;
$.ajax({
cache: false,
method: 'POST',
url: BASE + '/api/user/revoke-sessions',
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify({ username: un }),
success: function (resp) {
toastr.success((resp && resp.message) || wgT('users.ok_sessions_revoked'));
if (window.wgReauthenticateRedirectIfNeeded && window.wgReauthenticateRedirectIfNeeded(resp)) return;
loadUsers();
},
error: function (xhr) {
var j = {}; try { j = xhr.responseJSON; } catch (er) {}
toastr.error(j.message || xhr.responseText);
}
});
});
$(document).on('click', '.wu-toggle-disabled', function (e) {
e.stopPropagation();
var $b = $(this);
var un = $b.attr('data-un');
var curDis = $b.attr('data-dis') === '1';
var nextDis = !curDis;
if (nextDis) {
if (!confirm(wgT('users.confirm_suspend_fmt').replace(/\{username\}/g, String(un)))) return;
}
$.ajax({
cache: false,
method: 'POST',
url: BASE + '/api/user/set-disabled',
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify({ username: un, disabled: nextDis }),
success: function (resp) {
toastr.success((resp && resp.message) || wgT('users.ok_state_updated'));
if (window.wgReauthenticateRedirectIfNeeded && window.wgReauthenticateRedirectIfNeeded(resp)) return;
loadUsers();
},
error: function (xhr) {
var j = {}; try { j = xhr.responseJSON; } catch (ed) {}
toastr.error(j.message || xhr.responseText);
}
});
});
function b64urlToBytes(s) {
var pad = '='.repeat((4 - (s.length % 4)) % 4);
var b64 = (s + pad).replace(/-/g, '+').replace(/_/g, '/');
var raw = atob(b64);
var out = new Uint8Array(raw.length);
for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
function bytesToB64url(buf) {
var bytes = new Uint8Array(buf);
var str = '';
for (var i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i]);
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function publicKeyCredentialToJSON(cred) {
if (cred instanceof Array) return cred.map(publicKeyCredentialToJSON);
if (cred instanceof ArrayBuffer) return bytesToB64url(cred);
if (cred && typeof cred === 'object') {
var obj = {};
for (var k in cred) obj[k] = publicKeyCredentialToJSON(cred[k]);
return obj;
}
return cred;
}
$(document).on('click', '.wu-add-pk', async function (e) {
e.stopPropagation();
var un = $(this).data('un');
var input = document.getElementById('wu-pkin-' + un);
var label = (input && input.value || '').trim();
if (!label) {
toastr.error(wgT('users.err_pk_need_name_inline'));
if (input) input.focus();
return;
}
if (!PASSKEYS_OK) {
toastr.error(wgT('users.err_pk_disabled'));
return;
}
if (!window.PublicKeyCredential || !navigator.credentials) {
toastr.error(wgT('users.err_pk_browser_inline'));
return;
}
try {
var beginResp = await fetch(BASE + '/api/passkeys/register/' + encodeURIComponent(un) + '/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
});
var options = await beginResp.json();
if (!beginResp.ok) throw new Error(options.message || wgT('profile.err_pk_begin'));
var pk = options.publicKey || options;
pk.challenge = b64urlToBytes(pk.challenge);
if (pk.user && pk.user.id) pk.user.id = b64urlToBytes(pk.user.id);
if (pk.excludeCredentials) {
pk.excludeCredentials = pk.excludeCredentials.map(function (c) {
c.id = b64urlToBytes(c.id);
return c;
});
}
var cred = await navigator.credentials.create({ publicKey: pk });
var cjson = publicKeyCredentialToJSON(cred);
var finishBody = {
credential_name: label,
id: cjson.id,
rawId: cjson.rawId,
type: cjson.type,
response: cjson.response,
clientExtensionResults: cjson.clientExtensionResults || {}
};
var finishResp = await fetch(BASE + '/api/passkeys/register/' + encodeURIComponent(un) + '/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(finishBody)
});
var finishData = await finishResp.json();
if (!finishResp.ok || !finishData.status) throw new Error(finishData.message || wgT('profile.err_pk_finish'));
toastr.success(wgT('users.ok_pk_registered'));
if (input) input.value = '';
loadUsers();
setTimeout(function () {
toggleDetail(un);
var d = document.getElementById('wu-det-' + un);
if (d) d.classList.add('open');
}, 50);
} catch (err) {
toastr.error(err.message || String(err));
}
});
$(document).on('click', '.wu-del-pk', function (e) {
e.stopPropagation();
var un = $(this).attr('data-un');
var cr = $(this).attr('data-crid');
var self = String(un) === String(WG_ME);
var cmsg = self ? wgT('profile.confirm_remove_pk') : wgT('users.confirm_del_pk_other');
if (!confirm(cmsg)) return;
$.ajax({
cache: false,
method: 'POST',
url: BASE + '/api/passkeys/remove',
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify({ username: un, credential_id: cr }),
success: function (resp) {
var self = String(un) === String(WG_ME);
var msg = (resp && resp.message) || wgT('profile.warn_pk_removed');
if (self) {
toastr.warning(msg);
} else {
toastr.success(msg);
}
if (window.wgReauthenticateRedirectIfNeeded && window.wgReauthenticateRedirectIfNeeded(resp)) return;
loadUsers();
},
error: function (xhr) {
var j = {}; try { j = xhr.responseJSON; } catch (e5) {}
toastr.error(j.message || xhr.responseText);
}
});
});
$(document).on('click', '.pk-name[role=\"button\"]', function (e) {
e.stopPropagation();
var un = $(this).data('un');
var cr = $(this).data('crid');
var cur = ($(this).text() || '').trim();
var n = prompt(wgT('profile.prompt_pk_name'), cur || '');
if (n === null) return;
n = String(n).trim();
$.ajax({
cache: false,
method: 'POST',
url: BASE + '/api/passkeys/rename',
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify({ username: un, credential_id: cr, name: n }),
success: function () {
toastr.success(wgT('users.ok_rename_pk'));
loadUsers();
},
error: function (xhr) {
var j = {}; try { j = xhr.responseJSON; } catch (e6) {}
toastr.error(j.message || xhr.responseText);
}
});
});
$(document).ready(function () {
$('#wg_users_root').on('click', '.usr-main', function (e) {
if ($(e.target).closest('.usr-actions-cell').length) return;
toggleDetail($(this).data('un'));
});
loadUsers();
});
})();
</script>
{{end}}