webui: account management UI (users + change password)

Settings now has an Account card (current user/role + change password)
and, for admins, a Users card to add/delete users, reset passwords, and
toggle admin/user roles. Login captures the role; the Users section is
admin-only. api.js gains the account/user endpoints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ookami125 2026-06-23 21:48:05 -04:00
parent 530390089c
commit 2df3f11cf4
2 changed files with 119 additions and 9 deletions

View file

@ -19,6 +19,13 @@ export const api = {
authStatus: () => jget('/api/auth/status'), authStatus: () => jget('/api/auth/status'),
login: (username, password) => jpost('/api/login', { username, password }), login: (username, password) => jpost('/api/login', { username, password }),
logout: () => jpost('/api/logout', {}), logout: () => jpost('/api/logout', {}),
// account management
changePassword: (oldPassword, newPassword) => jpost('/api/account/password', { oldPassword, newPassword }),
listUsers: () => jget('/api/users'),
createUser: (username, password, role) => jpost('/api/users', { username, password, role }),
deleteUser: (username) => jpost('/api/users/delete', { username }),
setUserPassword: (username, password) => jpost('/api/users/password', { username, password }),
setUserRole: (username, role) => jpost('/api/users/role', { username, role }),
plugins: () => jget('/api/plugins'), plugins: () => jget('/api/plugins'),
meta: () => jget('/api/meta'), meta: () => jget('/api/meta'),

View file

@ -29,6 +29,7 @@ const state = {
columns: ['name', 'size', 'progress', 'state', 'seeds', 'peers', 'dlspeed', 'upspeed', 'eta', 'ratio', 'category', 'tags', 'addedOn'], columns: ['name', 'size', 'progress', 'state', 'seeds', 'peers', 'dlspeed', 'upspeed', 'eta', 'ratio', 'category', 'tags', 'addedOn'],
searchResults: null, searchResults: null,
searchQuery: '', searchQuery: '',
auth: { user: '', role: '' },
}; };
let viewRenderSeq = 0; let viewRenderSeq = 0;
@ -103,11 +104,13 @@ async function loadMeta() {
} }
async function ensureLogin() { async function ensureLogin() {
const auth = await api.authStatus(); let auth = await api.authStatus();
if (auth.authenticated) return; if (!auth.authenticated) {
document.getElementById('app').hidden = true; document.getElementById('app').hidden = true;
await new Promise((resolve) => showLogin(auth, resolve)); auth = await new Promise((resolve) => showLogin(auth, resolve));
document.getElementById('app').hidden = false; document.getElementById('app').hidden = false;
}
state.auth = { user: auth.user || '', role: auth.role || '' };
} }
function showLogin(auth, done) { function showLogin(auth, done) {
@ -134,9 +137,9 @@ function showLogin(auth, done) {
err.hidden = true; err.hidden = true;
form.querySelector('.login-submit').disabled = true; form.querySelector('.login-submit').disabled = true;
try { try {
await api.login(host.querySelector('#loginUser').value.trim(), host.querySelector('#loginPass').value); const res = await api.login(host.querySelector('#loginUser').value.trim(), host.querySelector('#loginPass').value);
host.remove(); host.remove();
done(); done(res || {});
} catch { } catch {
err.textContent = 'Invalid username or password'; err.textContent = 'Invalid username or password';
err.hidden = false; err.hidden = false;
@ -1438,11 +1441,38 @@ function bindSearchRows() {
} }
/* ===================== Engine/settings view ===================== */ /* ===================== Engine/settings view ===================== */
function renderSettingsView(host) { async function renderSettingsView(host) {
const isAdmin = state.auth.role === 'admin';
let users = [];
if (isAdmin) { try { users = await api.listUsers(); } catch (e) { users = []; } }
if (state.view !== 'settings') return;
const p = state.meta.preferences; const p = state.meta.preferences;
const card = (title, rows) => `<div class="card"><h3>${title}</h3>${rows.map(([k, v]) => const card = (title, rows) => `<div class="card"><h3>${title}</h3>${rows.map(([k, v]) =>
`<div class="prop"><span class="k">${k}</span><span class="v mono">${v}</span></div>`).join('')}</div>`; `<div class="prop"><span class="k">${k}</span><span class="v mono">${v}</span></div>`).join('')}</div>`;
host.innerHTML = `<div class="pane"><div class="settings-grid">
const accountCard = `<div class="card"><div class="card-head"><h3>Account</h3>
<button class="btn" id="changePwBtn">Change password</button></div>
<div class="prop"><span class="k">Signed in as</span><span class="v mono">${f.esc(state.auth.user)}</span></div>
<div class="prop"><span class="k">Role</span><span class="v mono">${f.esc(state.auth.role)}</span></div></div>`;
const usersCard = isAdmin ? `<div class="card"><div class="card-head"><h3>Users</h3>
<button class="btn" id="addUserBtn"> Add user</button></div>
<table class="dtbl"><thead><tr><th>Username</th><th>Role</th><th>Created</th><th></th></tr></thead><tbody>
${users.map((u) => `<tr data-user="${f.esc(u.username)}">
<td>${f.esc(u.username)}</td>
<td><span class="pill ${u.role === 'admin' ? 'on' : ''}">${f.esc(u.role)}</span></td>
<td class="dim">${u.createdAt ? f.ago(u.createdAt) : '—'}</td>
<td style="white-space:nowrap">
<button class="iconbtn u-role" title="Toggle admin/user">${u.role === 'admin' ? '▼ user' : '▲ admin'}</button>
<button class="iconbtn u-pw" title="Reset password"></button>
<button class="iconbtn u-del" title="Delete user"></button>
</td></tr>`).join('')}
</tbody></table></div>` : '';
host.innerHTML = `<div class="pane">
<div class="settings-grid">
${accountCard}
${usersCard}
${card('Bandwidth', [ ${card('Bandwidth', [
['Global download limit', p.dl_limit ? f.rate(p.dl_limit) : '∞'], ['Global download limit', p.dl_limit ? f.rate(p.dl_limit) : '∞'],
['Global upload limit', p.up_limit ? f.rate(p.up_limit) : '∞'], ['Global upload limit', p.up_limit ? f.rate(p.up_limit) : '∞'],
@ -1477,6 +1507,79 @@ function renderSettingsView(host) {
</div> </div>
<p class="dim" style="margin-top:14px">This is a stubbed engine view values are read from the mock server. Wire these to a real client's API (qBittorrent WebAPI, Transmission RPC, Deluge JSON-RPC) to make them editable.</p> <p class="dim" style="margin-top:14px">This is a stubbed engine view values are read from the mock server. Wire these to a real client's API (qBittorrent WebAPI, Transmission RPC, Deluge JSON-RPC) to make them editable.</p>
</div>`; </div>`;
document.getElementById('changePwBtn')?.addEventListener('click', openChangePassword);
document.getElementById('addUserBtn')?.addEventListener('click', openAddUser);
host.querySelectorAll('.u-del').forEach((b) => b.addEventListener('click', () => guard(async () => {
const u = b.closest('[data-user]').dataset.user;
if (u === state.auth.user && !confirm('Delete your own account? You will be signed out.')) return;
await api.deleteUser(u);
if (u === state.auth.user) return location.reload();
renderView();
}, 'Failed to delete user')));
host.querySelectorAll('.u-pw').forEach((b) => b.addEventListener('click', () =>
openResetPassword(b.closest('[data-user]').dataset.user)));
host.querySelectorAll('.u-role').forEach((b) => b.addEventListener('click', () => guard(async () => {
const row = b.closest('[data-user]');
const u = row.dataset.user;
const cur = users.find((x) => x.username === u);
await api.setUserRole(u, cur && cur.role === 'admin' ? 'user' : 'admin');
renderView();
}, 'Failed to change role')));
}
function openChangePassword() {
openModal('Change password', `
<div class="field"><label>Current password</label><input type="password" id="cpOld" autocomplete="current-password" /></div>
<div class="field"><label>New password</label><input type="password" id="cpNew" autocomplete="new-password" /></div>
<div class="field"><label>Confirm new password</label><input type="password" id="cpConf" autocomplete="new-password" /></div>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Update', primary: true, act: () => guard(async () => {
const oldp = document.getElementById('cpOld').value;
const newp = document.getElementById('cpNew').value;
if (newp !== document.getElementById('cpConf').value) { toast('Passwords do not match', 'err'); return; }
if (!newp) { toast('New password is empty', 'err'); return; }
await api.changePassword(oldp, newp);
closeModal();
toast('Password changed', 'ok');
}, 'Failed to change password'),
}]);
setTimeout(() => document.getElementById('cpOld')?.focus(), 0);
}
function openAddUser() {
openModal('Add user', `
<div class="field"><label>Username</label><input type="text" id="auName" placeholder="letters, digits, . _ -" /></div>
<div class="field"><label>Password</label><input type="password" id="auPass" autocomplete="new-password" /></div>
<div class="field"><label>Role</label>
<select id="auRole"><option value="user" selected>user</option><option value="admin">admin</option></select></div>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Create', primary: true, act: () => guard(async () => {
const name = document.getElementById('auName').value.trim();
const pass = document.getElementById('auPass').value;
if (!name || !pass) { toast('Username and password required', 'err'); return; }
await api.createUser(name, pass, document.getElementById('auRole').value);
closeModal();
renderView();
}, 'Failed to create user'),
}]);
setTimeout(() => document.getElementById('auName')?.focus(), 0);
}
function openResetPassword(username) {
openModal(`Reset password — ${username}`, `
<div class="field"><label>New password</label><input type="password" id="rpNew" autocomplete="new-password" /></div>
<p class="dim" style="font-size:12px">${f.esc(username)} will be signed out and must use the new password.</p>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Reset', primary: true, act: () => guard(async () => {
const newp = document.getElementById('rpNew').value;
if (!newp) { toast('Password is empty', 'err'); return; }
await api.setUserPassword(username, newp);
closeModal();
toast('Password reset', 'ok');
}, 'Failed to reset password'),
}]);
setTimeout(() => document.getElementById('rpNew')?.focus(), 0);
} }
/* ===================== status bar ===================== */ /* ===================== status bar ===================== */