Settings: add account profile + password change UI

Adds a per-user Account section in Settings with name edit and password
change. Password change requires current password; local auth only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-21 19:13:20 -05:00
parent 772ead28a2
commit 52297ec618
3 changed files with 79 additions and 2 deletions

View file

@ -121,6 +121,9 @@ export const api = {
// Device Groups - Playlist // Device Groups - Playlist
groupAssignPlaylist: (groupId, playlist_id) => request(`/groups/${groupId}/assign-playlist`, { method: 'POST', body: JSON.stringify({ playlist_id }) }), groupAssignPlaylist: (groupId, playlist_id) => request(`/groups/${groupId}/assign-playlist`, { method: 'POST', body: JSON.stringify({ playlist_id }) }),
// Current user
updateMe: (data) => request('/auth/me', { method: 'PUT', body: JSON.stringify(data) }),
// Admin - Users // Admin - Users
getUsers: () => request('/auth/users'), getUsers: () => request('/auth/users'),
deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }), deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }),

View file

@ -17,6 +17,30 @@ export async function render(container) {
</div> </div>
</div> </div>
<div class="settings-section">
<h3>Account</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px">
<div class="form-group"><label>Email</label><input type="email" class="input" value="${esc(user.email || '')}" disabled></div>
<div class="form-group"><label>Name</label><input type="text" id="acctName" class="input" value="${esc(user.name || '')}"></div>
</div>
<button class="btn btn-secondary btn-sm" id="saveAcctBtn">Save Profile</button>
${user.auth_provider === 'local' ? `
<div style="border-top:1px solid var(--border);margin-top:20px;padding-top:16px">
<h4 style="font-size:14px;margin-bottom:8px">Change Password</h4>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">Must be at least 8 characters.</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px">
<div class="form-group"><label>Current Password</label><input type="password" id="acctCurrentPw" class="input" autocomplete="current-password"></div>
<div class="form-group"><label>New Password</label><input type="password" id="acctNewPw" class="input" autocomplete="new-password"></div>
<div class="form-group"><label>Confirm New Password</label><input type="password" id="acctConfirmPw" class="input" autocomplete="new-password"></div>
</div>
<button class="btn btn-primary btn-sm" id="changePwBtn">Change Password</button>
</div>
` : `
<p style="color:var(--text-muted);font-size:12px;margin-top:16px">You sign in via <strong>${esc(user.auth_provider || 'SSO')}</strong>. Manage your password there.</p>
`}
</div>
${isAdmin ? ` ${isAdmin ? `
<div class="settings-section"> <div class="settings-section">
<h3>License</h3> <h3>License</h3>
@ -255,6 +279,45 @@ export async function render(container) {
setLanguage(e.target.value); setLanguage(e.target.value);
showToast('Language changed. Refresh for full effect.', 'info'); showToast('Language changed. Refresh for full effect.', 'info');
}); });
document.getElementById('saveAcctBtn')?.addEventListener('click', async () => {
const name = document.getElementById('acctName').value.trim();
if (!name) return showToast('Name cannot be empty', 'error');
const btn = document.getElementById('saveAcctBtn');
btn.disabled = true;
try {
const updated = await api.updateMe({ name });
const stored = JSON.parse(localStorage.getItem('user') || '{}');
localStorage.setItem('user', JSON.stringify({ ...stored, ...updated }));
showToast('Profile saved', 'success');
} catch (err) {
showToast(err.message, 'error');
} finally {
btn.disabled = false;
}
});
document.getElementById('changePwBtn')?.addEventListener('click', async () => {
const current = document.getElementById('acctCurrentPw').value;
const next = document.getElementById('acctNewPw').value;
const confirm = document.getElementById('acctConfirmPw').value;
if (!current) return showToast('Enter your current password', 'error');
if (next.length < 8) return showToast('New password must be at least 8 characters', 'error');
if (next !== confirm) return showToast('New passwords do not match', 'error');
const btn = document.getElementById('changePwBtn');
btn.disabled = true;
try {
await api.updateMe({ current_password: current, password: next });
document.getElementById('acctCurrentPw').value = '';
document.getElementById('acctNewPw').value = '';
document.getElementById('acctConfirmPw').value = '';
showToast('Password changed', 'success');
} catch (err) {
showToast(err.message, 'error');
} finally {
btn.disabled = false;
}
});
} }
async function loadWhiteLabel() { async function loadWhiteLabel() {

View file

@ -227,12 +227,23 @@ router.get('/me', requireAuth, (req, res) => {
// Update current user // Update current user
router.put('/me', requireAuth, (req, res) => { router.put('/me', requireAuth, (req, res) => {
const { name, password } = req.body; const { name, password, current_password } = req.body;
if (name) { if (name) {
db.prepare('UPDATE users SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?') db.prepare('UPDATE users SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
.run(name, req.user.id); .run(name, req.user.id);
} }
if (password && password.length >= 8) { if (password) {
if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
const row = db.prepare('SELECT password_hash, auth_provider FROM users WHERE id = ?').get(req.user.id);
if (!row) return res.status(404).json({ error: 'User not found' });
if (row.auth_provider !== 'local') {
return res.status(400).json({ error: `Your account signs in via ${row.auth_provider}. Manage your password there.` });
}
if (row.password_hash) {
if (!current_password || !bcrypt.compareSync(current_password, row.password_hash)) {
return res.status(401).json({ error: 'Current password is incorrect' });
}
}
const hash = bcrypt.hashSync(password, 10); const hash = bcrypt.hashSync(password, 10);
db.prepare('UPDATE users SET password_hash = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?') db.prepare('UPDATE users SET password_hash = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
.run(hash, req.user.id); .run(hash, req.user.id);