diff --git a/public/js/api.js b/public/js/api.js index 5e3cc59..1b00a67 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -19,6 +19,13 @@ export const api = { authStatus: () => jget('/api/auth/status'), login: (username, password) => jpost('/api/login', { username, password }), 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'), meta: () => jget('/api/meta'), diff --git a/public/js/app.js b/public/js/app.js index a82d633..59556ff 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -29,6 +29,7 @@ const state = { columns: ['name', 'size', 'progress', 'state', 'seeds', 'peers', 'dlspeed', 'upspeed', 'eta', 'ratio', 'category', 'tags', 'addedOn'], searchResults: null, searchQuery: '', + auth: { user: '', role: '' }, }; let viewRenderSeq = 0; @@ -103,11 +104,13 @@ async function loadMeta() { } async function ensureLogin() { - const auth = await api.authStatus(); - if (auth.authenticated) return; - document.getElementById('app').hidden = true; - await new Promise((resolve) => showLogin(auth, resolve)); - document.getElementById('app').hidden = false; + let auth = await api.authStatus(); + if (!auth.authenticated) { + document.getElementById('app').hidden = true; + auth = await new Promise((resolve) => showLogin(auth, resolve)); + document.getElementById('app').hidden = false; + } + state.auth = { user: auth.user || '', role: auth.role || '' }; } function showLogin(auth, done) { @@ -134,9 +137,9 @@ function showLogin(auth, done) { err.hidden = true; form.querySelector('.login-submit').disabled = true; 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(); - done(); + done(res || {}); } catch { err.textContent = 'Invalid username or password'; err.hidden = false; @@ -1438,11 +1441,38 @@ function bindSearchRows() { } /* ===================== 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 card = (title, rows) => `

${title}

${rows.map(([k, v]) => `
${k}${v}
`).join('')}
`; - host.innerHTML = `
+ + const accountCard = `

Account

+
+
Signed in as${f.esc(state.auth.user)}
+
Role${f.esc(state.auth.role)}
`; + + const usersCard = isAdmin ? `

Users

+
+ + ${users.map((u) => ` + + + + `).join('')} +
UsernameRoleCreated
${f.esc(u.username)}${f.esc(u.role)}${u.createdAt ? f.ago(u.createdAt) : '—'} + + + +
` : ''; + + host.innerHTML = `
+
+ ${accountCard} + ${usersCard} ${card('Bandwidth', [ ['Global download limit', p.dl_limit ? f.rate(p.dl_limit) : '∞'], ['Global upload limit', p.up_limit ? f.rate(p.up_limit) : '∞'], @@ -1477,6 +1507,79 @@ function renderSettingsView(host) {

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.

`; + + 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', ` +
+
+
`, + [{ 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', ` +
+
+
+
`, + [{ 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}`, ` +
+

${f.esc(username)} will be signed out and must use the new password.

`, + [{ 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 ===================== */