mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
An audit of every view turned up two problems with the in-product help. The tips only appeared on :hover. On a tablet or a phone there is no hover, so the entire explanation layer was invisible to touch users — a large share of the people administering signage — and unreachable from a keyboard. Tapping a marker now opens it, Escape or a tap elsewhere closes it, and the marker is focusable so Tab reaches it and a screen reader announces it. Bound once at the document level and applied by observing the DOM, because views render from about twenty call sites and modals appear later still; hooking each one would have left the next new route silently unreachable again. Four views had no tip at all. Playlists is the important one: a playlist is the concept the reported confusion was actually about, and the page said nothing about what one is or how it reaches a screen. Activity and Settings now have one too. Help does not, because it is the help. The schedule tip described a product that no longer exists — it said to click Add Schedule, predating the drag, resize and right-click gestures. Rewritten. All four are translated into every active locale rather than left to fall back to English, since a tip falling back is a non-English user being handed an English paragraph at the moment they are confused. hi.js stays deliberately empty per the note in that file. Tests now check that every tip is translated everywhere, and that a tip marker never names a string that does not exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
925 lines
52 KiB
JavaScript
925 lines
52 KiB
JavaScript
import { api } from '../api.js';
|
|
import { showToast } from '../components/toast.js';
|
|
import { getLanguage, setLanguage, getAvailableLanguages, t, tn } from '../i18n.js';
|
|
import { esc, isPlatformAdmin } 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 = isPlatformAdmin(user);
|
|
// #14: the legacy 'admin' platform role was normalized away; platform-level
|
|
// admin is now just isPlatformAdmin. (Elevated capability otherwise comes from
|
|
// org/workspace membership, gated in the members views, not users.role.)
|
|
const isAdmin = isSuperAdmin;
|
|
|
|
// #83: the "About" version was hardcoded (showed v1.4.1 regardless of the build).
|
|
// Read it from the server (/api/version) the same way the admin view does.
|
|
let appVersion = '';
|
|
try { appVersion = ((await fetch('/api/version').then(r => r.json())).version) || ''; } catch { /* leave blank on failure */ }
|
|
|
|
container.innerHTML = `
|
|
<div class="page-header">
|
|
<div>
|
|
<h1>${t('settings.title')} <span class="help-tip" data-tip="${t('settings.help_tip')}">?</span></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>
|
|
<div class="form-group" style="margin-top:12px">
|
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
|
<input type="checkbox" id="acctEmailAlerts" ${user.email_alerts ? 'checked' : ''}>
|
|
<span>${t('settings.email_alerts')}</span>
|
|
</label>
|
|
</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>
|
|
`}
|
|
|
|
<!-- Two-factor authentication (#100). Populated by load2FA() from /auth/totp/status. -->
|
|
<div id="twoFactorBlock" style="border-top:1px solid var(--border);margin-top:20px;padding-top:16px">
|
|
<h4 style="font-size:14px;margin-bottom:8px">${t('settings.2fa_title')}</h4>
|
|
<p style="color:var(--text-muted);font-size:12px">…</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<h3>${t('apitoken.title')}</h3>
|
|
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${t('apitoken.desc')}</p>
|
|
<p style="font-size:13px;margin-bottom:16px"><a href="/docs" target="_blank" rel="noopener" style="color:var(--accent)">${t('apitoken.docs_link')}</a></p>
|
|
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;margin-bottom:16px">
|
|
<div class="form-group" style="margin-bottom:0;flex:1;min-width:180px">
|
|
<label>${t('apitoken.col_name')}</label>
|
|
<input type="text" id="tokName" class="input" placeholder="${esc(t('apitoken.name_placeholder'))}">
|
|
</div>
|
|
<div class="form-group" style="margin-bottom:0;min-width:200px">
|
|
<label>${t('apitoken.col_scope')}</label>
|
|
<select id="tokScope" class="input" style="background:var(--bg-input)">
|
|
<option value="read">${esc(t('apitoken.scope_read'))}</option>
|
|
<option value="write">${esc(t('apitoken.scope_write'))}</option>
|
|
<option value="full">${esc(t('apitoken.scope_full'))}</option>
|
|
<option value="agency">${esc(t('apitoken.scope_agency'))}</option>
|
|
</select>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm" id="createTokenBtn">${t('apitoken.create')}</button>
|
|
</div>
|
|
<div id="agencyPlaylistPicker" style="display:none;margin-bottom:16px;padding:12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg-secondary)">
|
|
<label style="display:block;font-weight:500;margin-bottom:4px">${t('apitoken.agency_playlists_label')}</label>
|
|
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${t('apitoken.agency_playlists_hint')}</p>
|
|
<div id="agencyPlaylistList" style="display:flex;flex-direction:column;gap:6px;max-height:200px;overflow:auto"></div>
|
|
<label style="display:flex;gap:8px;align-items:center;margin-top:12px;font-weight:500">
|
|
<input type="checkbox" id="tokAutoPublish"> ${t('apitoken.auto_publish_label')}
|
|
</label>
|
|
<p style="color:var(--text-muted);font-size:12px;margin:4px 0 0">${t('apitoken.auto_publish_hint')}</p>
|
|
<label style="display:block;font-weight:500;margin-top:12px;margin-bottom:4px">${t('apitoken.agency_folder_label')}</label>
|
|
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${t('apitoken.agency_folder_hint')}</p>
|
|
<select id="tokUploadFolder" class="input" style="width:100%"><option value="">${t('apitoken.agency_folder_auto')}</option></select>
|
|
</div>
|
|
<div id="tokenSecretBox" style="display:none"></div>
|
|
<div id="tokenList"><p style="color:var(--text-muted);font-size:13px">${t('settings.loading_users')}</p></div>
|
|
<div id="tokenEditPanel" style="display:none"></div>
|
|
</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>${appVersion ? ` v${esc(appVersion)}` : ''}</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>
|
|
·
|
|
<a href="/legal/privacy.html" target="_blank" style="color:var(--accent);font-size:12px">${t('auth.privacy')}</a>
|
|
·
|
|
<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 => '• ' + 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);
|
|
});
|
|
|
|
// API Tokens — available to every user (manages their own, workspace-scoped).
|
|
const fmtTokenDate = (ts) => {
|
|
if (!ts) return '';
|
|
try { return new Date(ts * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); }
|
|
catch { return String(ts); }
|
|
};
|
|
const scopeLabel = (s) => ({
|
|
read: t('apitoken.scope_read'),
|
|
write: t('apitoken.scope_write'),
|
|
full: t('apitoken.scope_full'),
|
|
agency: t('apitoken.scope_agency'),
|
|
}[s] || s);
|
|
|
|
async function loadTokens() {
|
|
const el = document.getElementById('tokenList');
|
|
if (!el) return;
|
|
const tokens = await api.getTokens().catch(() => []);
|
|
if (!tokens.length) {
|
|
el.innerHTML = `<p style="color:var(--text-muted);font-size:13px">${t('apitoken.none')}</p>`;
|
|
return;
|
|
}
|
|
el.innerHTML = `
|
|
<div class="table-wrap">
|
|
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:560px">
|
|
<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('apitoken.col_token')}</th>
|
|
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('apitoken.col_name')}</th>
|
|
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('apitoken.col_scope')}</th>
|
|
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('apitoken.col_created')}</th>
|
|
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('apitoken.col_last_used')}</th>
|
|
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${tokens.map(tok => `
|
|
<tr style="border-bottom:1px solid var(--border)${tok.revoked_at ? ';opacity:0.55' : ''}">
|
|
<td style="padding:10px 12px;font-family:monospace">${esc(tok.prefix)}…</td>
|
|
<td style="padding:10px 12px">${esc(tok.name || '')}</td>
|
|
<td style="padding:10px 12px">${esc(scopeLabel(tok.scope))}${
|
|
tok.scope === 'agency' && Array.isArray(tok.targets)
|
|
? `<div style="font-size:11px;color:var(--text-muted);margin-top:2px">${t('apitoken.targets_label')} ${tok.targets.length ? tok.targets.map(p => esc(p.name)).join(', ') : '—'}${tok.auto_publish ? ' · ' + esc(t('apitoken.auto_publish_on')) : ''}</div><div style="font-size:11px;color:var(--text-muted);margin-top:2px">${t('apitoken.folder_label')} ${tok.upload_folder ? esc(tok.upload_folder) : esc(t('apitoken.folder_root'))}</div>`
|
|
: ''}</td>
|
|
<td style="padding:10px 12px">${esc(fmtTokenDate(tok.created_at))}</td>
|
|
<td style="padding:10px 12px">${tok.last_used_at ? esc(fmtTokenDate(tok.last_used_at)) : t('apitoken.never')}</td>
|
|
<td style="padding:10px 12px;white-space:nowrap;text-align:right">
|
|
${tok.revoked_at
|
|
? `<span style="color:var(--text-muted);font-size:12px">${t('apitoken.revoked')}</span>`
|
|
: `${tok.scope === 'agency' ? `<button class="btn btn-secondary btn-sm edit-targets-btn" data-id="${esc(String(tok.id))}" data-targets="${esc((tok.targets || []).map(p => p.id).join(','))}">${t('apitoken.edit_targets')}</button> <button class="btn btn-secondary btn-sm edit-folder-btn" data-id="${esc(String(tok.id))}" data-folder="${esc(String(tok.upload_folder_id || ''))}">${t('apitoken.edit_folder')}</button> ` : ''}<button class="btn btn-secondary btn-sm revoke-token-btn" data-id="${esc(String(tok.id))}">${t('apitoken.revoke')}</button>`}
|
|
</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
|
|
el.querySelectorAll('.revoke-token-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
if (!confirm(t('apitoken.revoke_confirm'))) return;
|
|
try {
|
|
await api.revokeToken(btn.dataset.id);
|
|
showToast(t('apitoken.revoked_toast'), 'success');
|
|
loadTokens();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
});
|
|
|
|
// #73: edit an agency token's playlist designations -> PUT /:id/targets (atomic re-designate).
|
|
el.querySelectorAll('.edit-targets-btn').forEach(btn => btn.addEventListener('click', async () => {
|
|
const id = btn.dataset.id;
|
|
const current = new Set((btn.dataset.targets || '').split(',').filter(Boolean));
|
|
const panel = document.getElementById('tokenEditPanel');
|
|
const pls = await api.getPlaylists().catch(() => []);
|
|
panel.style.display = 'block';
|
|
panel.innerHTML = `
|
|
<div style="border:1px solid var(--accent);border-radius:var(--radius);padding:16px;margin-top:12px">
|
|
<h4 style="font-size:14px;margin-bottom:8px">${t('apitoken.edit_targets')}</h4>
|
|
<div style="display:flex;flex-direction:column;gap:6px;max-height:200px;overflow:auto;margin-bottom:12px">
|
|
${pls.length
|
|
? pls.map(p => p.zoned
|
|
? `<label style="display:flex;gap:8px;align-items:center;font-size:13px;opacity:.5"><input type="checkbox" disabled> ${esc(p.name)} <span style="font-size:11px;color:var(--text-muted)">— ${esc(t('apitoken.zoned_playlist_reason'))}</span></label>`
|
|
: `<label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" class="edit-pl" value="${esc(String(p.id))}"${current.has(String(p.id)) ? ' checked' : ''}> ${esc(p.name)}</label>`).join('')
|
|
: `<p style="color:var(--text-muted);font-size:12px">${t('apitoken.agency_no_playlists')}</p>`}
|
|
</div>
|
|
<button class="btn btn-primary btn-sm" id="saveTargetsBtn">${t('common.save')}</button>
|
|
<button class="btn btn-secondary btn-sm" id="cancelTargetsBtn">${t('common.cancel')}</button>
|
|
</div>`;
|
|
document.getElementById('saveTargetsBtn').onclick = async () => {
|
|
const ids = [...panel.querySelectorAll('.edit-pl:checked')].map(c => c.value);
|
|
if (!ids.length) return showToast(t('apitoken.agency_needs_playlists'), 'error');
|
|
try {
|
|
await api.setTokenTargets(id, ids);
|
|
showToast(t('apitoken.targets_updated'), 'success');
|
|
panel.style.display = 'none';
|
|
loadTokens();
|
|
} catch (err) { showToast(err.message, 'error'); }
|
|
};
|
|
document.getElementById('cancelTargetsBtn').onclick = () => { panel.style.display = 'none'; };
|
|
}));
|
|
|
|
// #158: rebind an agency token's upload folder -> PUT /:id/upload-folder (null = root).
|
|
el.querySelectorAll('.edit-folder-btn').forEach(btn => btn.addEventListener('click', async () => {
|
|
const id = btn.dataset.id;
|
|
const current = btn.dataset.folder || '';
|
|
const panel = document.getElementById('tokenEditPanel');
|
|
const folders = await api.getFolders().catch(() => []);
|
|
panel.style.display = 'block';
|
|
panel.innerHTML = `
|
|
<div style="border:1px solid var(--accent);border-radius:var(--radius);padding:16px;margin-top:12px">
|
|
<h4 style="font-size:14px;margin-bottom:8px">${t('apitoken.edit_folder')}</h4>
|
|
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${t('apitoken.agency_folder_hint')}</p>
|
|
<select id="rebindFolder" class="input" style="width:100%;margin-bottom:12px">
|
|
<option value="">${t('apitoken.folder_root')}</option>
|
|
${folders.map(f => `<option value="${esc(String(f.id))}"${String(f.id) === current ? ' selected' : ''}>${esc(f.name)}</option>`).join('')}
|
|
</select>
|
|
<button class="btn btn-primary btn-sm" id="saveFolderBtn">${t('common.save')}</button>
|
|
<button class="btn btn-secondary btn-sm" id="cancelFolderBtn">${t('common.cancel')}</button>
|
|
</div>`;
|
|
document.getElementById('saveFolderBtn').onclick = async () => {
|
|
try {
|
|
await api.setTokenUploadFolder(id, document.getElementById('rebindFolder').value || null);
|
|
showToast(t('apitoken.folder_updated'), 'success');
|
|
panel.style.display = 'none';
|
|
loadTokens();
|
|
} catch (err) { showToast(err.message, 'error'); }
|
|
};
|
|
document.getElementById('cancelFolderBtn').onclick = () => { panel.style.display = 'none'; };
|
|
}));
|
|
}
|
|
|
|
// ==================== Two-factor authentication (#100) ====================
|
|
// Drives the merged TOTP backend (/api/auth/totp/*). Re-renders #twoFactorBlock
|
|
// for each state: SSO note / disabled+enroll / recovery-codes / enabled+manage.
|
|
async function load2FA() {
|
|
const block = document.getElementById('twoFactorBlock');
|
|
if (!block) return;
|
|
const head = `<h4 style="font-size:14px;margin-bottom:8px">${t('settings.2fa_title')}</h4>`;
|
|
const muted = 'color:var(--text-muted);font-size:12px';
|
|
const paint = (inner) => { block.innerHTML = head + inner; };
|
|
|
|
let status;
|
|
try { status = await api.totpStatus(); }
|
|
catch (e) { paint(`<p style="${muted}">${esc(e.message)}</p>`); return; }
|
|
|
|
if (!status.eligible) {
|
|
const provider = (JSON.parse(localStorage.getItem('user') || '{}').auth_provider) || 'SSO';
|
|
paint(`<p style="${muted}">${t('settings.2fa_sso_note', { provider: esc(provider) })}</p>`);
|
|
return;
|
|
}
|
|
if (status.enabled) return showEnabled(status.recovery_codes_remaining);
|
|
return showDisabled();
|
|
|
|
function showDisabled() {
|
|
paint(`
|
|
<p style="${muted};margin-bottom:12px">${t('settings.2fa_desc')}
|
|
<span style="color:var(--text-secondary);margin-left:6px">${t('settings.2fa_status_off')}</span></p>
|
|
<button class="btn btn-primary btn-sm" id="enable2faBtn">${t('settings.2fa_enable')}</button>`);
|
|
document.getElementById('enable2faBtn').addEventListener('click', startEnroll);
|
|
}
|
|
|
|
async function startEnroll() {
|
|
let data;
|
|
try { data = await api.totpSetup(); } catch (e) { showToast(e.message, 'error'); return; }
|
|
const qr = data.qr_data_url
|
|
? `<img src="${data.qr_data_url}" alt="TOTP QR" width="200" height="200" style="border-radius:8px;background:#fff;padding:6px">`
|
|
: `<p style="${muted}">${t('settings.2fa_setup_manual')}</p>`;
|
|
paint(`
|
|
<p style="${muted};margin-bottom:10px">${t('settings.2fa_setup_scan')}</p>
|
|
<div style="display:flex;gap:20px;flex-wrap:wrap;align-items:flex-start">
|
|
<div>${qr}</div>
|
|
<div style="flex:1;min-width:220px">
|
|
<p style="${muted}">${t('settings.2fa_setup_manual')}</p>
|
|
<code style="display:block;word-break:break-all;background:var(--bg-primary);border:1px solid var(--border);border-radius:6px;padding:8px;margin:6px 0 14px;font-family:monospace;font-size:13px">${esc(data.secret)}</code>
|
|
<label style="${muted}">${t('settings.2fa_setup_confirm')}</label>
|
|
<input type="text" id="enroll2faCode" class="input" inputmode="numeric" autocomplete="one-time-code" placeholder="${t('settings.2fa_code_placeholder')}" maxlength="6" style="letter-spacing:4px;text-align:center;font-family:monospace;margin:6px 0">
|
|
<div style="display:flex;gap:8px;margin-top:6px">
|
|
<button class="btn btn-primary btn-sm" id="enroll2faVerify">${t('settings.2fa_verify_enable')}</button>
|
|
<button class="btn btn-secondary btn-sm" id="enroll2faCancel">${t('settings.2fa_cancel')}</button>
|
|
</div>
|
|
</div>
|
|
</div>`);
|
|
const codeEl = document.getElementById('enroll2faCode');
|
|
codeEl.focus();
|
|
const doEnable = async () => {
|
|
const code = codeEl.value.trim();
|
|
if (!code) { showToast(t('settings.2fa_code_required'), 'error'); return; }
|
|
try {
|
|
const r = await api.totpEnable(code);
|
|
showToast(t('settings.2fa_enabled_toast'), 'success');
|
|
showRecoveryCodes(r.recovery_codes);
|
|
} catch (e) { showToast(e.message, 'error'); codeEl.select(); }
|
|
};
|
|
document.getElementById('enroll2faVerify').addEventListener('click', doEnable);
|
|
codeEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') doEnable(); });
|
|
document.getElementById('enroll2faCancel').addEventListener('click', load2FA);
|
|
}
|
|
|
|
function showRecoveryCodes(codes) {
|
|
const list = codes.map((c) => `<div>${esc(c)}</div>`).join('');
|
|
const text = codes.join('\n');
|
|
paint(`
|
|
<p style="font-weight:600;margin-bottom:4px">${t('settings.2fa_recovery_title')}</p>
|
|
<p style="${muted};margin-bottom:12px">${t('settings.2fa_recovery_warning')}</p>
|
|
<div style="font-family:monospace;font-size:14px;letter-spacing:1px;background:var(--bg-primary);border:1px solid var(--border);border-radius:8px;padding:14px;display:grid;grid-template-columns:repeat(2,1fr);gap:6px 24px">${list}</div>
|
|
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">
|
|
<button class="btn btn-secondary btn-sm" id="copyRecovery">${t('settings.2fa_recovery_copy')}</button>
|
|
<button class="btn btn-secondary btn-sm" id="dlRecovery">${t('settings.2fa_recovery_download')}</button>
|
|
<button class="btn btn-primary btn-sm" id="doneRecovery">${t('settings.2fa_recovery_done')}</button>
|
|
</div>`);
|
|
document.getElementById('copyRecovery').addEventListener('click', async () => {
|
|
try { await navigator.clipboard.writeText(text); showToast(t('settings.2fa_recovery_copy'), 'success'); } catch { /* field is selectable */ }
|
|
});
|
|
document.getElementById('dlRecovery').addEventListener('click', () => {
|
|
const blob = new Blob([text + '\n'], { type: 'text/plain' });
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = 'screentinker-recovery-codes.txt';
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
});
|
|
document.getElementById('doneRecovery').addEventListener('click', load2FA);
|
|
}
|
|
|
|
function showEnabled(remaining) {
|
|
paint(`
|
|
<p style="margin-bottom:12px">
|
|
<span style="color:var(--success);font-weight:600">✓ ${t('settings.2fa_status_on')}</span>
|
|
<span style="${muted};margin-left:10px">${t('settings.2fa_recovery_remaining', { n: remaining })}</span></p>
|
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
|
<button class="btn btn-secondary btn-sm" id="regen2faBtn">${t('settings.2fa_recovery_regenerate')}</button>
|
|
<button class="btn btn-danger btn-sm" id="disable2faBtn">${t('settings.2fa_disable')}</button>
|
|
</div>
|
|
<div id="twoFactorAction" style="margin-top:12px"></div>`);
|
|
document.getElementById('disable2faBtn').addEventListener('click', () => promptCode({
|
|
prompt: t('settings.2fa_disable_prompt'), confirm: t('settings.2fa_disable_confirm'), danger: true,
|
|
run: async (code) => { await api.totpDisable(code); showToast(t('settings.2fa_disabled_toast'), 'success'); load2FA(); },
|
|
}));
|
|
document.getElementById('regen2faBtn').addEventListener('click', () => promptCode({
|
|
prompt: t('settings.2fa_regen_prompt'), confirm: t('settings.2fa_regen_confirm'),
|
|
run: async (code) => { const r = await api.totpRegenRecovery(code); showRecoveryCodes(r.recovery_codes); },
|
|
}));
|
|
}
|
|
|
|
function promptCode({ prompt, confirm, danger, run }) {
|
|
const box = document.getElementById('twoFactorAction');
|
|
box.innerHTML = `
|
|
<p style="${muted};margin-bottom:6px">${prompt}</p>
|
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
|
<input type="text" id="twoFactorActionCode" class="input" autocomplete="one-time-code" autocapitalize="characters" spellcheck="false" placeholder="${t('settings.2fa_code_placeholder')}" maxlength="12" style="max-width:170px;letter-spacing:3px;text-align:center;font-family:monospace">
|
|
<button class="btn btn-sm ${danger ? 'btn-danger' : 'btn-primary'}" id="twoFactorActionConfirm">${confirm}</button>
|
|
<button class="btn btn-secondary btn-sm" id="twoFactorActionCancel">${t('settings.2fa_cancel')}</button>
|
|
</div>`;
|
|
const codeEl = document.getElementById('twoFactorActionCode');
|
|
codeEl.focus();
|
|
const go = async () => {
|
|
const code = codeEl.value.trim();
|
|
if (!code) { showToast(t('settings.2fa_code_required'), 'error'); return; }
|
|
try { await run(code); } catch (e) { showToast(e.message, 'error'); codeEl.select(); }
|
|
};
|
|
document.getElementById('twoFactorActionConfirm').addEventListener('click', go);
|
|
codeEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') go(); });
|
|
document.getElementById('twoFactorActionCancel').addEventListener('click', () => { box.innerHTML = ''; });
|
|
}
|
|
}
|
|
|
|
loadTokens();
|
|
load2FA();
|
|
|
|
// #73: agency scope reveals a playlist picker (the token's allowlist). Loaded lazily once.
|
|
const tokScopeSel = document.getElementById('tokScope');
|
|
let agencyPlaylistsLoaded = false;
|
|
tokScopeSel?.addEventListener('change', async () => {
|
|
const picker = document.getElementById('agencyPlaylistPicker');
|
|
const isAgency = tokScopeSel.value === 'agency';
|
|
picker.style.display = isAgency ? 'block' : 'none';
|
|
if (isAgency && !agencyPlaylistsLoaded) {
|
|
agencyPlaylistsLoaded = true;
|
|
const list = document.getElementById('agencyPlaylistList');
|
|
const pls = await api.getPlaylists().catch(() => []);
|
|
list.innerHTML = pls.length
|
|
? pls.map(p => p.zoned
|
|
? `<label style="display:flex;gap:8px;align-items:center;font-size:13px;opacity:.5"><input type="checkbox" disabled> ${esc(p.name)} <span style="font-size:11px;color:var(--text-muted)">— ${esc(t('apitoken.zoned_playlist_reason'))}</span></label>`
|
|
: `<label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" class="agency-pl" value="${esc(String(p.id))}"> ${esc(p.name)}</label>`).join('')
|
|
: `<p style="color:var(--text-muted);font-size:12px">${t('apitoken.agency_no_playlists')}</p>`;
|
|
// #158: offer existing folders to bind, or leave on the auto-create default.
|
|
const folders = await api.getFolders().catch(() => []);
|
|
const fsel = document.getElementById('tokUploadFolder');
|
|
if (fsel && folders.length) fsel.insertAdjacentHTML('beforeend', folders.map(f => `<option value="${esc(String(f.id))}">${esc(f.name)}</option>`).join(''));
|
|
}
|
|
});
|
|
|
|
document.getElementById('createTokenBtn')?.addEventListener('click', async () => {
|
|
const name = document.getElementById('tokName').value.trim();
|
|
const scope = document.getElementById('tokScope').value;
|
|
const payload = { name, scope };
|
|
if (scope === 'agency') {
|
|
const ids = [...document.querySelectorAll('#agencyPlaylistList .agency-pl:checked')].map(c => c.value);
|
|
if (!ids.length) return showToast(t('apitoken.agency_needs_playlists'), 'error');
|
|
payload.target_playlist_ids = ids;
|
|
payload.auto_publish = !!document.getElementById('tokAutoPublish')?.checked;
|
|
// #158: blank = auto-create "Agency — <name>"; a value binds that existing folder.
|
|
const fv = document.getElementById('tokUploadFolder')?.value;
|
|
if (fv) payload.upload_folder_id = fv;
|
|
}
|
|
const btn = document.getElementById('createTokenBtn');
|
|
btn.disabled = true;
|
|
try {
|
|
const r = await api.createToken(payload);
|
|
const box = document.getElementById('tokenSecretBox');
|
|
box.style.display = 'block';
|
|
// #73: for agency tokens, surface the handoff (portal URL + a copyable invite). The key
|
|
// is in the invite TEXT, never in a URL (Cloudflare logs query strings + chat apps unfurl
|
|
// links). window.location.origin is the real public host the admin is on (correct behind CF).
|
|
const portalUrl = window.location.origin + '/agency';
|
|
const inviteText = t('apitoken.invite_text', { url: portalUrl, key: r.token });
|
|
box.innerHTML = `
|
|
<div style="background:var(--bg-secondary);border:1px solid var(--accent);border-radius:var(--radius);padding:16px;margin-bottom:16px">
|
|
<h4 style="font-size:14px;margin-bottom:8px">${t('apitoken.secret_title')}</h4>
|
|
<p style="color:var(--danger);font-size:12px;margin-bottom:12px"><strong>${t('apitoken.secret_warning')}</strong></p>
|
|
<div style="display:flex;gap:8px;align-items:center">
|
|
<input type="text" class="input" readonly value="${esc(r.token)}" style="font-family:monospace;flex:1" onclick="this.select()">
|
|
<button class="btn btn-secondary btn-sm" id="copyTokenBtn">${t('apitoken.copy')}</button>
|
|
</div>
|
|
${scope === 'agency' ? `
|
|
<div style="margin-top:12px;border-top:1px solid var(--border);padding-top:12px">
|
|
<label style="font-size:12px;color:var(--text-muted)">${t('apitoken.portal_url_label')}</label>
|
|
<input type="text" class="input" readonly value="${esc(portalUrl)}" style="width:100%;margin-top:4px" onclick="this.select()">
|
|
<label style="font-size:12px;color:var(--text-muted);display:block;margin-top:10px">${t('apitoken.invite_label')}</label>
|
|
<textarea class="input" readonly rows="2" style="width:100%;margin-top:4px;font-size:13px;font-family:inherit" onclick="this.select()">${esc(inviteText)}</textarea>
|
|
<button class="btn btn-secondary btn-sm" id="copyInviteBtn" style="margin-top:8px">${t('apitoken.copy_invite')}</button>
|
|
</div>` : ''}
|
|
</div>
|
|
`;
|
|
document.getElementById('copyTokenBtn')?.addEventListener('click', async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(r.token);
|
|
showToast(t('apitoken.copied'), 'success');
|
|
} catch { /* clipboard may be unavailable; the field is selectable */ }
|
|
});
|
|
document.getElementById('copyInviteBtn')?.addEventListener('click', async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(inviteText); // full "go here + paste key" text
|
|
showToast(t('apitoken.copied'), 'success');
|
|
} catch { /* field is selectable as a fallback */ }
|
|
});
|
|
document.getElementById('tokName').value = '';
|
|
showToast(t('apitoken.created_toast'), 'success');
|
|
loadTokens();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
|
|
document.getElementById('saveAcctBtn')?.addEventListener('click', async () => {
|
|
const name = document.getElementById('acctName').value.trim();
|
|
if (!name) return showToast(t('settings.toast.name_required'), 'error');
|
|
const email_alerts = !!document.getElementById('acctEmailAlerts')?.checked;
|
|
const btn = document.getElementById('saveAcctBtn');
|
|
btn.disabled = true;
|
|
try {
|
|
const updated = await api.updateMe({ name, email_alerts });
|
|
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 plans or platform admins.
|
|
// 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' && !isPlatformAdmin(user)) {
|
|
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:${isPlatformAdmin(u) ? '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() {}
|