mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Five reviewers went at the two SSO commits. Three of them independently
demonstrated a full account takeover, and it was the same defect each time.
TAKEOVER. An org admin supplies the issuer and client_id, so they control that
identity provider completely and can mint an id_token asserting ANY email with
email_verified:true — including a platform_admin's. Every cryptographic check
passed honestly, because the attacker IS the issuer. upsertFederatedUser then
re-pointed the existing account at whichever provider spoke last, because the
only guard was `password_hash IS NULL` — and every SSO-created account has a
null password. Sessions were issued as the victim, and the victim's own login
then failed forever with subject_mismatch.
The rule came from the old Google handler, where it was safe: only the operator
could add a provider. Making providers customer-configurable turned it into a
takeover primitive and the assumption was not re-examined. Now an org provider
may only assert emails inside the domains it registered, and may never adopt an
account another provider established.
REMOTE CRASH, unauthenticated. The state comparison guarded on UTF-16 character
length while Buffer.from produces UTF-8 bytes, so a state of 43 characters
containing one multi-byte character reached timingSafeEqual with mismatched
buffers and threw — inside an async handler, which Express does not catch, which
server.js turns into process.exit. One request per restart killed any instance
with SSO enabled. Compared as bytes now, and /api/auth/oidc gained a rate limit.
LOGIN CSRF. The callback returned the session token in the URL fragment, so a
crafted link installed an ATTACKER'S token and silently signed the victim into
their account. The token now goes in a one-shot httpOnly cookie exchanged at
POST /sso/claim, which a link cannot forge.
FRONTEND, dead on arrival twice over. login.js used `await` in a non-async
function — a SyntaxError that takes the WHOLE app down, since app.js imports it
statically and there is no bundler. And `esc` was never imported, so the org-SSO
button could never render; the ReferenceError was swallowed by the catch written
for network failures. Both slipped through because `node --check` parses these
files as CommonJS and exits 0 on a broken module. The correct check is
`node --input-type=module --check`, and all four frontend files now pass it.
PUBLIC EMAIL DOMAINS cannot be claimed. A tenant had claimed gmail.com in
review, after which every Gmail user typing their address was offered "sign in
with your organization" pointing at that tenant's infrastructure — phishing from
this product's own login page. server/lib/public-email-domains.js.
MICROSOFT multi-tenant is refused rather than silently broken. `common` metadata
advertises the literal template {tenantid}, so the issuer never matches and
every login already failed; and loosening that check is nOAuth. A tenant GUID is
now required, with a loud warning at boot.
SSRF: https only, loopback/RFC1918/link-local refused, redirects not followed,
and the test endpoint no longer echoes upstream status for a caller-supplied
jwks_uri (it was a readable internal port scanner).
Also: an omitted email_verified was accepted (the comment already said it should
not be); the domain-uniqueness check raced an 8s network call before its insert
and is now inside the transaction; same-org duplicate domains were allowed and
made routing depend on table-scan order; routing is now ordered; a client secret
that cannot be decrypted fails closed instead of silently downgrading to a public
client; SSO audit rows were writing the org id into the deviceId column; and
/sso/start was capped at 10/min per IP, which would 429 the 11th employee behind
a corporate NAT.
Adds per-provider editing in the org admin UI (replace-only secrets — never
returned, blank means keep, explicit clear) and a Test button that checks
discovery, endpoints and signing keys while stating plainly that it cannot
verify the client ID, the secret, or the redirect URI registration.
⚠️ STILL MISSING: domain-ownership verification. A claimed domain means "nobody
else had claimed it", not "they own it". DNS TXT proof is the remaining control.
1582 tests pass. New regression tests cover the takeover confinement, ordering,
fail-closed secrets, the Microsoft refusal and the public-domain blocklist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
1184 lines
66 KiB
JavaScript
1184 lines
66 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>
|
|
|
|
<!-- Per-organization SSO. Hidden unless the signed-in user administers an organization: this
|
|
is the most security-relevant setting a tenant has, so it is not shown to members who
|
|
cannot change it. Instance-wide providers are the operator's business and are configured
|
|
by environment, not here. -->
|
|
<div class="settings-section" id="ssoCard" style="display:none">
|
|
<h3>${t('sso.title')}</h3>
|
|
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${t('sso.blurb')}</p>
|
|
<div id="ssoList"></div>
|
|
<details id="ssoAddDetails" style="margin-top:12px">
|
|
<summary style="cursor:pointer;font-size:13px">${t('sso.add')}</summary>
|
|
<div style="margin-top:12px;display:grid;gap:10px;max-width:560px">
|
|
<div class="form-group"><label>${t('sso.f_name')}</label>
|
|
<input type="text" id="ssoName" class="input" placeholder="Acme SSO"></div>
|
|
<div class="form-group"><label>${t('sso.f_issuer')}</label>
|
|
<input type="url" id="ssoIssuer" class="input" placeholder="https://login.example.com">
|
|
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('sso.f_issuer_hint')}</div></div>
|
|
<div class="form-group"><label>${t('sso.f_client_id')}</label>
|
|
<input type="text" id="ssoClientId" class="input"></div>
|
|
<div class="form-group"><label>${t('sso.f_client_secret')}</label>
|
|
<input type="password" id="ssoClientSecret" class="input" autocomplete="new-password">
|
|
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('sso.f_client_secret_hint')}</div></div>
|
|
<div class="form-group"><label>${t('sso.f_domains')}</label>
|
|
<input type="text" id="ssoDomains" class="input" placeholder="acme.com, acme.co.uk">
|
|
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('sso.f_domains_hint')}</div></div>
|
|
<div><button class="btn btn-primary btn-sm" id="ssoCreateBtn">${t('sso.create')}</button></div>
|
|
</div>
|
|
</details>
|
|
</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(''));
|
|
}
|
|
});
|
|
|
|
/* ── Per-organization SSO ──────────────────────────────────────────────────────────────────
|
|
*
|
|
* Only an org owner/admin sees this. The server enforces the same rule (and answers 404, not
|
|
* 403, so an outsider learns nothing) — this just avoids showing a card the user cannot use.
|
|
*/
|
|
const orgId = user.current_organization?.id;
|
|
const canManageSso = orgId && ['org_owner', 'org_admin'].includes(user.current_org_role);
|
|
|
|
async function loadSso() {
|
|
const card = document.getElementById('ssoCard');
|
|
if (!card || !canManageSso) return;
|
|
card.style.display = '';
|
|
const listEl = document.getElementById('ssoList');
|
|
let providers = [];
|
|
try {
|
|
const res = await fetch(`/api/organizations/${orgId}/sso`, {
|
|
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
|
|
});
|
|
if (!res.ok) throw new Error('load failed');
|
|
providers = (await res.json()).providers || [];
|
|
} catch {
|
|
listEl.innerHTML = `<p style="color:var(--text-muted);font-size:13px">${esc(t('sso.load_failed'))}</p>`;
|
|
return;
|
|
}
|
|
|
|
if (!providers.length) {
|
|
listEl.innerHTML = `<p style="color:var(--text-muted);font-size:13px">${esc(t('sso.none'))}</p>`;
|
|
return;
|
|
}
|
|
|
|
const origin = `${window.location.protocol}//${window.location.host}`;
|
|
listEl.innerHTML = providers.map((p) => `
|
|
<div style="border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-bottom:8px">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
|
|
<div>
|
|
<strong>${esc(p.name)}</strong>
|
|
${p.enabled ? '' : `<span style="font-size:11px;color:var(--text-muted)"> — ${esc(t('sso.disabled'))}</span>`}
|
|
<div style="font-size:12px;color:var(--text-muted);margin-top:2px">${esc(p.issuer)}</div>
|
|
<div style="font-size:12px;color:var(--text-muted)">${esc(t('sso.domains_label'))}: ${esc(p.email_domains || '—')}</div>
|
|
</div>
|
|
<div style="display:flex;gap:6px;flex-shrink:0">
|
|
<button class="btn btn-secondary btn-sm" data-sso-test="${esc(p.id)}">${esc(t('sso.test'))}</button>
|
|
<button class="btn btn-secondary btn-sm" data-sso-edit="${esc(p.id)}">${esc(t('sso.edit'))}</button>
|
|
<button class="btn btn-secondary btn-sm" data-sso-toggle="${esc(p.id)}" data-enabled="${p.enabled ? '1' : '0'}">
|
|
${esc(p.enabled ? t('sso.disable') : t('sso.enable'))}
|
|
</button>
|
|
<button class="btn btn-danger btn-sm" data-sso-delete="${esc(p.id)}">${esc(t('sso.delete'))}</button>
|
|
</div>
|
|
</div>
|
|
<!-- The admin has to paste this into their identity provider, and it must match character
|
|
for character, so it is shown rather than described. -->
|
|
<div style="margin-top:8px;font-size:12px">
|
|
<div style="color:var(--text-muted)">${esc(t('sso.callback_label'))}</div>
|
|
<code style="display:block;word-break:break-all;padding:6px;background:var(--bg-secondary);border-radius:4px">${esc(origin + p.callback_url)}</code>
|
|
</div>
|
|
|
|
<!-- Editing is per provider, because an organization may have several (one per domain, or
|
|
one per identity provider after a merger) and they are configured independently. -->
|
|
<div id="ssoTest-${esc(p.id)}" style="display:none;margin-top:8px;font-size:12px"></div>
|
|
<div id="ssoEdit-${esc(p.id)}" style="display:none;margin-top:12px;padding-top:12px;border-top:1px solid var(--border);display:none">
|
|
<div style="display:grid;gap:10px;max-width:560px">
|
|
<div class="form-group"><label>${esc(t('sso.f_name'))}</label>
|
|
<input type="text" class="input" data-f="name" value="${esc(p.name)}"></div>
|
|
<div class="form-group"><label>${esc(t('sso.f_issuer'))}</label>
|
|
<input type="url" class="input" data-f="issuer" value="${esc(p.issuer)}"></div>
|
|
<div class="form-group"><label>${esc(t('sso.f_client_id'))}</label>
|
|
<input type="text" class="input" data-f="client_id" value="${esc(p.client_id)}"></div>
|
|
<div class="form-group"><label>${esc(t('sso.f_client_secret'))}</label>
|
|
<input type="password" class="input" data-f="client_secret" autocomplete="new-password"
|
|
placeholder="${esc(p.has_client_secret ? t('sso.secret_set') : t('sso.secret_none'))}">
|
|
<!-- A secret can never be shown back: the API does not return it. Blank therefore means
|
|
"leave it alone" rather than "clear it", which is what stops a save from silently
|
|
wiping a working configuration. Clearing is a separate, explicit choice. -->
|
|
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${esc(t('sso.secret_edit_hint'))}</div>
|
|
${p.has_client_secret ? `
|
|
<label style="display:flex;align-items:center;gap:6px;font-size:12px;margin-top:6px">
|
|
<input type="checkbox" data-f="clear_secret"> ${esc(t('sso.secret_clear'))}
|
|
</label>` : ''}
|
|
</div>
|
|
<div class="form-group"><label>${esc(t('sso.f_domains'))}</label>
|
|
<input type="text" class="input" data-f="email_domains" value="${esc(p.email_domains)}"></div>
|
|
<div style="display:flex;gap:6px">
|
|
<button class="btn btn-primary btn-sm" data-sso-save="${esc(p.id)}">${esc(t('sso.save'))}</button>
|
|
<button class="btn btn-secondary btn-sm" data-sso-cancel="${esc(p.id)}">${esc(t('sso.cancel'))}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`).join('');
|
|
|
|
listEl.querySelectorAll('[data-sso-toggle]').forEach((btn) => {
|
|
btn.addEventListener('click', async () => {
|
|
await ssoRequest('PUT', `/${btn.dataset.ssoToggle}`, { enabled: btn.dataset.enabled !== '1' });
|
|
});
|
|
});
|
|
listEl.querySelectorAll('[data-sso-test]').forEach((btn) => {
|
|
btn.addEventListener('click', async () => {
|
|
const id = btn.dataset.ssoTest;
|
|
const out = document.getElementById(`ssoTest-${id}`);
|
|
if (!out) return;
|
|
out.style.display = '';
|
|
out.textContent = t('sso.testing');
|
|
try {
|
|
const res = await fetch(`/api/organizations/${orgId}/sso/${id}/test`, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) { out.textContent = data.error || t('sso.test_failed'); return; }
|
|
/*
|
|
* Literal keys, never a key built by concatenating a check name. Doing that defeats the
|
|
* check in server/test/i18n-keys-exist.js that every key an operator can see is
|
|
* translated — and a check name the UI does not know would render as raw key text. The
|
|
* fallback keeps an unknown one readable instead.
|
|
*/
|
|
const CHECK_LABELS = {
|
|
discovery: t('sso.check_discovery'),
|
|
endpoints: t('sso.check_endpoints'),
|
|
signing_keys: t('sso.check_signing_keys'),
|
|
};
|
|
const rows = (data.checks || []).map((c) => `
|
|
<div>${c.ok ? '✅' : '❌'} ${esc(CHECK_LABELS[c.name] || c.name)} — <span style="color:var(--text-muted)">${esc(c.detail || '')}</span></div>`).join('');
|
|
/*
|
|
* The caveat is shown on SUCCESS, not tucked away. Discovery and keys prove the provider
|
|
* exists and that we could verify a token it signs — they say nothing about whether the
|
|
* client id, the secret, or the redirect URI registration are right. A green tick that
|
|
* implied "SSO works" would send an admin away from the one thing still to check.
|
|
*/
|
|
out.innerHTML = rows + (data.ok
|
|
? `<div style="margin-top:6px;color:var(--text-muted)">${esc(t('sso.test_caveat'))}</div>`
|
|
: '');
|
|
} catch {
|
|
out.textContent = t('sso.test_failed');
|
|
}
|
|
});
|
|
});
|
|
listEl.querySelectorAll('[data-sso-edit]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const panel = document.getElementById(`ssoEdit-${btn.dataset.ssoEdit}`);
|
|
if (panel) panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
|
|
});
|
|
});
|
|
listEl.querySelectorAll('[data-sso-cancel]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const panel = document.getElementById(`ssoEdit-${btn.dataset.ssoCancel}`);
|
|
if (panel) panel.style.display = 'none';
|
|
});
|
|
});
|
|
listEl.querySelectorAll('[data-sso-save]').forEach((btn) => {
|
|
btn.addEventListener('click', async () => {
|
|
const panel = document.getElementById(`ssoEdit-${btn.dataset.ssoSave}`);
|
|
if (!panel) return;
|
|
const val = (f) => panel.querySelector(`[data-f="${f}"]`)?.value?.trim() ?? '';
|
|
const body = {
|
|
name: val('name'),
|
|
issuer: val('issuer'),
|
|
client_id: val('client_id'),
|
|
email_domains: val('email_domains'),
|
|
};
|
|
/*
|
|
* Three states, and only these three:
|
|
* typed a value -> replace the secret
|
|
* ticked "remove" -> send '' so the server clears it
|
|
* left blank, unticked -> send NOTHING, so the stored secret survives
|
|
* Sending '' on every save is the bug this shape exists to avoid.
|
|
*/
|
|
const typed = panel.querySelector('[data-f="client_secret"]')?.value || '';
|
|
const clearing = panel.querySelector('[data-f="clear_secret"]')?.checked;
|
|
if (typed) body.client_secret = typed;
|
|
else if (clearing) body.client_secret = '';
|
|
|
|
if (!body.name || !body.issuer || !body.client_id) {
|
|
showToast(t('sso.missing_fields'), 'error');
|
|
return;
|
|
}
|
|
await ssoRequest('PUT', `/${btn.dataset.ssoSave}`, body);
|
|
});
|
|
});
|
|
listEl.querySelectorAll('[data-sso-delete]').forEach((btn) => {
|
|
btn.addEventListener('click', async () => {
|
|
if (!confirm(t('sso.confirm_delete'))) return;
|
|
await ssoRequest('DELETE', `/${btn.dataset.ssoDelete}`);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function ssoRequest(method, path = '', body) {
|
|
try {
|
|
const res = await fetch(`/api/organizations/${orgId}/sso${path}`, {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
// The server's message is the useful one here — a bad issuer or a domain already claimed by
|
|
// another organization both say exactly what went wrong, and a generic failure would not.
|
|
if (!res.ok) { showToast(data.error || t('sso.save_failed'), 'error'); return false; }
|
|
showToast(t('sso.saved'), 'success');
|
|
await loadSso();
|
|
return true;
|
|
} catch {
|
|
showToast(t('sso.save_failed'), 'error');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
document.getElementById('ssoCreateBtn')?.addEventListener('click', async () => {
|
|
const payload = {
|
|
name: document.getElementById('ssoName').value.trim(),
|
|
issuer: document.getElementById('ssoIssuer').value.trim(),
|
|
client_id: document.getElementById('ssoClientId').value.trim(),
|
|
client_secret: document.getElementById('ssoClientSecret').value,
|
|
email_domains: document.getElementById('ssoDomains').value.trim(),
|
|
};
|
|
if (!payload.name || !payload.issuer || !payload.client_id) {
|
|
showToast(t('sso.missing_fields'), 'error');
|
|
return;
|
|
}
|
|
if (await ssoRequest('POST', '', payload)) {
|
|
['ssoName', 'ssoIssuer', 'ssoClientId', 'ssoClientSecret', 'ssoDomains']
|
|
.forEach((id) => { document.getElementById(id).value = ''; });
|
|
document.getElementById('ssoAddDetails').open = false;
|
|
}
|
|
});
|
|
|
|
loadSso();
|
|
|
|
|
|
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() {}
|