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:
parent
530390089c
commit
2df3f11cf4
2 changed files with 119 additions and 9 deletions
|
|
@ -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'),
|
||||||
|
|
|
||||||
117
public/js/app.js
117
public/js/app.js
|
|
@ -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,12 +104,14 @@ 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) {
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
|
|
@ -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 ===================== */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue