screentinker/frontend/js/views/settings.js
ScreenTinker 388e9e6ab8 Admin password reset + widget visibility fix
Password reset for other users:
- New PUT /api/auth/users/:id/password endpoint
- Superadmin can reset any local user; admin can reset role=user
  members of teams they own only (cannot reset other admins or
  superadmins, cannot self-reset — that goes through PUT /me with
  current_password)
- OAuth users are excluded (no password to reset)
- Rate-limited 20 req/min/IP to cap blast radius if an admin session
  is compromised
- Explicit audit log entry "password_reset_for_user / target: <email>"
  on every reset; activity logger's summarizeAction never reads the
  password field, so the password value is not stored anywhere

Frontend: Reset Password button in the Admin user table and Settings
> User Management table. Shown only for local-auth users that aren't
the current user; prompts for an 8+ char password.

Widgets visibility fix:
- routes/widgets.js had `const isAdmin = req.user.role === 'superadmin'`
  which mislabeled superadmin as admin and silently restricted real
  admins (role=admin) to seeing only their own widgets. Now matches
  /auth/users behavior: superadmin sees all, admin sees own + public
  + widgets owned by members of teams they own, user sees own + public.

7 new i18n keys (admin.reset_password, admin.prompt_reset_password,
admin.toast.password_min_8, admin.toast.password_reset, and the
matching settings.user.* / settings.toast.* trio). 1024 keys total,
parity 100% across en/es/fr/de/pt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:45:25 -05:00

509 lines
26 KiB
JavaScript

import { api } from '../api.js';
import { showToast } from '../components/toast.js';
import { getLanguage, setLanguage, getAvailableLanguages, t, tn } from '../i18n.js';
import { esc } from '../utils.js';
import { resetBranding } from '../branding.js';
export async function render(container) {
const serverUrl = `${window.location.protocol}//${window.location.host}`;
// Fetch fresh user from the server — plan_id and role may have been changed
// by an admin since login. Fall back to localStorage if the request fails.
let user;
try { user = await api.getMe(); localStorage.setItem('user', JSON.stringify(user)); }
catch { user = JSON.parse(localStorage.getItem('user') || '{}'); }
const isSuperAdmin = user.role === 'superadmin';
const isAdmin = user.role === 'admin' || isSuperAdmin;
container.innerHTML = `
<div class="page-header">
<div>
<h1>${t('settings.title')}</h1>
<div class="subtitle">${t('settings.subtitle')}</div>
</div>
</div>
<div class="settings-section">
<h3>${t('settings.account')}</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px">
<div class="form-group"><label>${t('auth.email')}</label><input type="email" class="input" value="${esc(user.email || '')}" disabled></div>
<div class="form-group"><label>${t('auth.name')}</label><input type="text" id="acctName" class="input" value="${esc(user.name || '')}"></div>
</div>
<button class="btn btn-secondary btn-sm" id="saveAcctBtn">${t('settings.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">${t('settings.change_password')}</h4>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">${t('settings.password_min_8')}</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px">
<div class="form-group"><label>${t('settings.current_password')}</label><input type="password" id="acctCurrentPw" class="input" autocomplete="current-password"></div>
<div class="form-group"><label>${t('settings.new_password')}</label><input type="password" id="acctNewPw" class="input" autocomplete="new-password"></div>
<div class="form-group"><label>${t('settings.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">${t('settings.change_password')}</button>
</div>
` : `
<p style="color:var(--text-muted);font-size:12px;margin-top:16px">${t('settings.sso_note', { provider: esc(user.auth_provider || 'SSO') })}</p>
`}
</div>
${isAdmin ? `
<div class="settings-section">
<h3>${t('settings.license')}</h3>
<div id="licenseSection"><p style="color:var(--text-muted);font-size:13px">${t('settings.license_mit')}</p></div>
</div>
${isSuperAdmin ? `<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">${t('settings.platform_admin_link')} <a href="#/admin" style="color:var(--accent)">${t('nav.admin')}</a> ${t('settings.platform_admin_page_suffix')}</p>` : ''}
<div class="settings-section">
<h3>${t('settings.user_management')}</h3>
<div id="userManagement"><p style="color:var(--text-muted)">${t('settings.loading_users')}</p></div>
</div>
<div class="settings-section" id="whiteLabelSection">
<h3>${t('settings.white_label')}</h3>
<div id="whiteLabelForm">
<p style="color:var(--text-muted);font-size:12px;margin-bottom:16px">${t('settings.white_label_desc')}</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="form-group"><label>${t('settings.brand_name')}</label><input type="text" id="wlBrandName" class="input" placeholder="ScreenTinker"></div>
<div class="form-group"><label>${t('settings.logo_url')}</label><input type="text" id="wlLogoUrl" class="input" placeholder="https://..."></div>
<div class="form-group"><label>${t('settings.primary_color')}</label><input type="color" id="wlPrimaryColor" value="#3B82F6" style="width:100%;height:36px;border:none;cursor:pointer;border-radius:var(--radius)"></div>
<div class="form-group"><label>${t('settings.bg_color')}</label><input type="color" id="wlBgColor" value="#111827" style="width:100%;height:36px;border:none;cursor:pointer;border-radius:var(--radius)"></div>
<div class="form-group"><label>${t('settings.custom_domain')}</label><input type="text" id="wlDomain" class="input" placeholder="signage.yourcompany.com"></div>
<div class="form-group"><label>${t('settings.favicon_url')}</label><input type="text" id="wlFavicon" class="input" placeholder="https://..."></div>
</div>
<div class="form-group"><label>${t('settings.custom_css')}</label><textarea id="wlCustomCss" class="input" rows="3" style="font-family:monospace;font-size:12px" placeholder=":root { --accent: #ff6600; }"></textarea></div>
<div class="form-group"><label style="display:flex;align-items:center;gap:8px"><input type="checkbox" id="wlHideBranding"> ${t('settings.hide_branding')}</label></div>
<button class="btn btn-primary btn-sm" id="saveWhiteLabelBtn">${t('settings.save_branding')}</button>
<button class="btn btn-secondary btn-sm" id="previewWhiteLabelBtn" style="margin-left:8px">${t('settings.preview')}</button>
</div>
</div>
` : ''}
<div class="settings-section">
<h3>${t('settings.server_info')}</h3>
<div class="info-grid">
<div class="info-card">
<div class="info-card-label">${t('settings.server_url')}</div>
<div class="info-card-value small">${serverUrl}</div>
<p style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('settings.server_url_hint')}</p>
</div>
<div class="info-card">
<div class="info-card-label">${t('settings.api_endpoint')}</div>
<div class="info-card-value small">${serverUrl}/api</div>
</div>
</div>
</div>
<div class="settings-section">
<h3>${t('settings.setup_guide')}</h3>
<div style="color:var(--text-secondary);font-size:13px;line-height:1.8">
<ol style="padding-left:20px;list-style:decimal">
<li>${t('settings.setup_step_1')}</li>
<li>${t('settings.setup_step_2_prefix')} <code style="background:var(--bg-input);padding:2px 6px;border-radius:4px">${serverUrl}</code></li>
<li>${t('settings.setup_step_3')}</li>
<li>${t('settings.setup_step_4')}</li>
<li>${t('settings.setup_step_5')}</li>
<li>${t('settings.setup_step_6')}</li>
</ol>
</div>
</div>
<div class="settings-section">
<h3>${t('settings.your_data')}</h3>
<p style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">${t('settings.your_data_desc')}</p>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<button class="btn btn-secondary btn-sm" id="exportDataBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
${t('settings.export_my_data')}
</button>
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-secondary);cursor:pointer">
<input type="checkbox" id="exportIncludeFiles"> ${t('settings.include_media_zip')}
</label>
<button class="btn btn-secondary btn-sm" id="importDataBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg>
${t('settings.import_data')}
</button>
<input type="file" id="importFileInput" accept=".json,.zip" style="display:none">
</div>
<div id="importStatus" style="display:none;margin-top:12px;padding:12px;border-radius:var(--radius);font-size:13px"></div>
</div>
<div class="settings-section">
<h3>${t('settings.language')}</h3>
<select id="langSelect" class="input" style="width:200px;background:var(--bg-input)">
${getAvailableLanguages().map(l => `<option value="${l.code}" ${l.code === getLanguage() ? 'selected' : ''}>${l.name}</option>`).join('')}
</select>
</div>
<div class="settings-section">
<h3>${t('settings.about')}</h3>
<div style="color:var(--text-secondary);font-size:13px">
<p><strong>ScreenTinker</strong> v1.4.1</p>
<p style="margin-top:4px">${t('settings.about_tagline')}</p>
<p style="margin-top:12px">
<a href="/legal/terms.html" target="_blank" style="color:var(--accent);font-size:12px">${t('auth.terms')}</a>
&nbsp;&middot;&nbsp;
<a href="/legal/privacy.html" target="_blank" style="color:var(--accent);font-size:12px">${t('auth.privacy')}</a>
&nbsp;&middot;&nbsp;
<a href="/legal/third-party.html" target="_blank" style="color:var(--accent);font-size:12px">${t('settings.third_party_licenses')}</a>
</p>
</div>
</div>
`;
if (isAdmin) {
loadUsers();
loadWhiteLabel();
// Support token generator
document.getElementById('generateSupportBtn')?.addEventListener('click', async () => {
const org = document.getElementById('supportOrg').value.trim() || 'Customer';
const hours = parseInt(document.getElementById('supportHours').value) || 4;
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/auth/support/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ org, hours, reason: 'Support session' })
});
const data = await res.json();
if (res.ok) {
document.getElementById('supportTokenOutput').value = data.token;
document.getElementById('supportTokenResult').style.display = 'block';
showToast(t('settings.toast.support_token_generated', { hours }), 'success');
} else showToast(data.error, 'error');
} catch (err) { showToast(err.message, 'error'); }
});
}
// Export data handler
document.getElementById('exportDataBtn')?.addEventListener('click', () => {
const includeFiles = document.getElementById('exportIncludeFiles')?.checked;
const token = localStorage.getItem('token');
const url = `/api/status/export?token=${token}${includeFiles ? '&include_files=true' : ''}`;
window.location.href = url;
});
// Import data handler
document.getElementById('importDataBtn')?.addEventListener('click', () => {
document.getElementById('importFileInput').click();
});
document.getElementById('importFileInput')?.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const isZip = file.name.endsWith('.zip') || file.type === 'application/zip';
const statusEl = document.getElementById('importStatus');
statusEl.style.display = 'block';
statusEl.style.background = 'var(--bg-secondary)';
statusEl.style.border = '1px solid var(--border)';
statusEl.style.color = 'var(--text-secondary)';
statusEl.textContent = t('settings.import.reading_file');
try {
let data;
if (isZip) {
// For ZIP, show basic info and skip preview parsing
data = { format: 'screentinker-export-v1', _isZip: true };
statusEl.innerHTML = `${t('settings.import.zip_detected', { name: esc(file.name), size: (file.size / 1048576).toFixed(1) })}<br><br><button class="btn btn-primary btn-sm" id="confirmImportBtn">${t('settings.import.confirm')}</button> <button class="btn btn-secondary btn-sm" id="cancelImportBtn">${t('common.cancel')}</button>`;
} else {
const text = await file.text();
data = JSON.parse(text);
if (!data.format || !data.format.startsWith('screentinker-export')) {
statusEl.style.color = 'var(--danger)';
statusEl.textContent = t('settings.import.invalid_file');
return;
}
const summary = [
data.devices?.length ? t('settings.import.summary_devices', { n: data.devices.length }) : null,
data.content?.length ? t('settings.import.summary_content', { n: data.content.length }) : null,
data.widgets?.length ? t('settings.import.summary_widgets', { n: data.widgets.length }) : null,
data.layouts?.length ? t('settings.import.summary_layouts', { n: data.layouts.length }) : null,
data.schedules?.length ? t('settings.import.summary_schedules', { n: data.schedules.length }) : null,
data.video_walls?.length ? t('settings.import.summary_walls', { n: data.video_walls.length }) : null,
data.kiosk_pages?.length ? t('settings.import.summary_kiosk', { n: data.kiosk_pages.length }) : null,
].filter(Boolean).join(', ');
statusEl.innerHTML = `${t('settings.import.found_summary', { summary: esc(summary) || t('settings.import.empty_export'), email: esc(data.user?.email) || t('common.unknown'), date: esc(data.exported_at?.split('T')[0]) || t('common.unknown') })}<br><br><button class="btn btn-primary btn-sm" id="confirmImportBtn">${t('settings.import.confirm')}</button> <button class="btn btn-secondary btn-sm" id="cancelImportBtn">${t('common.cancel')}</button>`;
}
document.getElementById('cancelImportBtn').onclick = () => { statusEl.style.display = 'none'; e.target.value = ''; };
document.getElementById('confirmImportBtn').onclick = async () => {
statusEl.innerHTML = isZip ? t('settings.import.uploading_zip') : t('settings.import.importing');
try {
const token = localStorage.getItem('token');
let res;
if (isZip) {
const formData = new FormData();
formData.append('file', file);
res = await fetch('/api/status/import', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
} else {
res = await fetch('/api/status/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(data),
});
}
const result = await res.json();
if (res.ok) {
const imported = Object.entries(result.stats).filter(([k,v]) => v > 0 && k !== 'files_restored').map(([k,v]) => `${v} ${k}`).join(', ');
statusEl.style.color = 'var(--success)';
let html = t('settings.import.complete', { imported });
if (result.device_pairings?.length) {
html += `<br><br><strong>${t('settings.import.pairing_codes_title')}</strong><br><table style="margin-top:8px;font-size:12px;border-collapse:collapse">` +
result.device_pairings.map(d => `<tr><td style="padding:4px 12px 4px 0">${d.name}</td><td style="font-family:monospace;font-weight:700;font-size:14px;letter-spacing:2px">${d.pairing_code}</td></tr>`).join('') +
`</table><br>${t('settings.import.pairing_codes_hint')}`;
}
html += `<br><br>${(result.notes || []).map(n => '&bull; ' + n).join('<br>')}`;
statusEl.innerHTML = html;
showToast(t('settings.toast.import_success'), 'success');
} else {
statusEl.style.color = 'var(--danger)';
statusEl.textContent = result.error || t('settings.import.failed');
}
} catch (err) {
statusEl.style.color = 'var(--danger)';
statusEl.textContent = t('settings.import.failed_with_error', { error: err.message });
}
e.target.value = '';
};
} catch (err) {
statusEl.style.color = 'var(--danger)';
statusEl.textContent = t('settings.import.read_failed', { error: err.message });
}
});
document.getElementById('langSelect')?.addEventListener('change', (e) => {
// setLanguage dispatches hashchange so the router re-renders the current
// view (including this settings page) with new strings — no refresh needed.
setLanguage(e.target.value);
});
document.getElementById('saveAcctBtn')?.addEventListener('click', async () => {
const name = document.getElementById('acctName').value.trim();
if (!name) return showToast(t('settings.toast.name_required'), '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(t('settings.toast.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(t('settings.toast.current_password_required'), 'error');
if (next.length < 8) return showToast(t('settings.toast.new_password_min_8'), 'error');
if (next !== confirm) return showToast(t('settings.toast.passwords_dont_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(t('settings.toast.password_changed'), 'success');
} catch (err) {
showToast(err.message, 'error');
} finally {
btn.disabled = false;
}
});
}
async function loadWhiteLabel() {
const token = localStorage.getItem('token');
const headers = { Authorization: `Bearer ${token}` };
// Only show white-label for enterprise/superadmin.
// Use the fresh user cached by render() above, which called api.getMe().
const user = JSON.parse(localStorage.getItem('user') || '{}');
const section = document.getElementById('whiteLabelSection');
if (section && user.plan_id !== 'enterprise' && user.role !== 'superadmin') {
section.innerHTML = `
<h3>${t('settings.white_label')}</h3>
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:16px;text-align:center">
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:8px">${t('settings.white_label_enterprise_only')}</p>
<a href="#/billing" class="btn btn-secondary btn-sm" style="text-decoration:none">${t('settings.view_plans')}</a>
</div>
`;
return;
}
try {
const res = await fetch('/api/white-label', { headers });
const wl = await res.json();
if (wl.brand_name) document.getElementById('wlBrandName').value = wl.brand_name;
if (wl.logo_url) document.getElementById('wlLogoUrl').value = wl.logo_url;
if (wl.primary_color) document.getElementById('wlPrimaryColor').value = wl.primary_color;
if (wl.bg_color) document.getElementById('wlBgColor').value = wl.bg_color;
if (wl.custom_domain) document.getElementById('wlDomain').value = wl.custom_domain;
if (wl.favicon_url) document.getElementById('wlFavicon').value = wl.favicon_url;
if (wl.custom_css) document.getElementById('wlCustomCss').value = wl.custom_css;
if (wl.hide_branding) document.getElementById('wlHideBranding').checked = true;
} catch {}
document.getElementById('saveWhiteLabelBtn')?.addEventListener('click', async () => {
try {
await fetch('/api/white-label', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
brand_name: document.getElementById('wlBrandName').value,
logo_url: document.getElementById('wlLogoUrl').value,
primary_color: document.getElementById('wlPrimaryColor').value,
bg_color: document.getElementById('wlBgColor').value,
custom_domain: document.getElementById('wlDomain').value,
favicon_url: document.getElementById('wlFavicon').value,
custom_css: document.getElementById('wlCustomCss').value,
hide_branding: document.getElementById('wlHideBranding').checked ? 1 : 0,
})
});
await resetBranding();
showToast(t('settings.toast.branding_saved'), 'success');
} catch (err) {
showToast(err.message, 'error');
}
});
document.getElementById('previewWhiteLabelBtn')?.addEventListener('click', () => {
const primary = document.getElementById('wlPrimaryColor').value;
const bg = document.getElementById('wlBgColor').value;
document.documentElement.style.setProperty('--accent', primary);
document.documentElement.style.setProperty('--bg-primary', bg);
showToast(t('settings.toast.preview_applied'), 'info');
});
}
async function loadUsers() {
const el = document.getElementById('userManagement');
if (!el) return;
try {
const [users, plans] = await Promise.all([
api.getUsers(),
fetch('/api/subscription/plans').then(r => r.json())
]);
const currentUser = JSON.parse(localStorage.getItem('user') || '{}');
el.innerHTML = `
<div class="table-wrap">
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:520px">
<thead>
<tr style="border-bottom:1px solid var(--border);text-align:left">
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('settings.user.col_user')}</th>
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('settings.user.col_auth')}</th>
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('settings.user.col_role')}</th>
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('settings.user.col_plan')}</th>
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('settings.user.col_actions')}</th>
</tr>
</thead>
<tbody>
${users.map(u => `
<tr style="border-bottom:1px solid var(--border)" data-user-id="${u.id}">
<td style="padding:10px 12px">
<div style="font-weight:500">${u.name || u.email}</div>
<div style="font-size:11px;color:var(--text-muted)">${u.email}</div>
</td>
<td style="padding:10px 12px">
<span style="background:var(--bg-primary);padding:2px 8px;border-radius:10px;font-size:11px">${u.auth_provider}</span>
</td>
<td style="padding:10px 12px">
<span style="color:${u.role === 'admin' ? 'var(--accent)' : 'var(--text-secondary)'}">${u.role}</span>
</td>
<td style="padding:10px 12px">
<select class="input plan-select" data-user-id="${u.id}" style="padding:4px 8px;font-size:12px;width:auto">
${plans.map(p => `<option value="${p.id}" ${u.plan_id === p.id ? 'selected' : ''}>${p.display_name}</option>`).join('')}
</select>
</td>
<td style="padding:10px 12px;white-space:nowrap">
${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm reset-user-pw-btn" data-user-id="${u.id}" data-user-email="${u.email}" style="margin-right:4px">${t('settings.user.reset_password')}</button>` : ''}
${u.id !== currentUser.id ? `<button class="btn btn-danger btn-sm delete-user-btn" data-user-id="${u.id}">${t('settings.user.remove')}</button>` : `<span style="color:var(--text-muted);font-size:11px">${t('settings.user.you')}</span>`}
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
<p style="color:var(--text-muted);font-size:11px;margin-top:12px">${tn('settings.user.count', users.length)}</p>
`;
// Plan change handlers
el.querySelectorAll('.plan-select').forEach(select => {
select.addEventListener('change', async () => {
const userId = select.dataset.userId;
const planId = select.value;
try {
await api.assignPlan(userId, planId);
showToast(t('settings.toast.plan_updated'), 'success');
} catch (err) {
showToast(err.message, 'error');
loadUsers(); // Revert
}
});
});
// Reset password handlers
el.querySelectorAll('.reset-user-pw-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const email = btn.dataset.userEmail;
const pw = prompt(t('settings.user.prompt_reset_password', { email }));
if (pw === null) return;
if (pw.length < 8) { showToast(t('settings.toast.new_password_min_8'), 'error'); return; }
try {
await api.resetUserPassword(btn.dataset.userId, pw);
showToast(t('settings.toast.password_reset_for_user'), 'success');
} catch (err) {
showToast(err.message, 'error');
}
});
});
// Delete user handlers
el.querySelectorAll('.delete-user-btn').forEach(btn => {
let confirming = false;
btn.addEventListener('click', async () => {
if (confirming) {
try {
await api.deleteUser(btn.dataset.userId);
showToast(t('settings.toast.user_removed'), 'success');
loadUsers();
} catch (err) {
showToast(err.message, 'error');
}
return;
}
confirming = true;
btn.textContent = t('settings.user.confirm');
btn.style.background = 'var(--danger)';
btn.style.color = 'white';
setTimeout(() => {
confirming = false;
btn.textContent = t('settings.user.remove');
btn.style.background = '';
btn.style.color = '';
}, 3000);
});
});
} catch (err) {
el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`;
}
}
export function cleanup() {}