711 lines
32 KiB
JavaScript
711 lines
32 KiB
JavaScript
import { apiGet, apiPost, apiPut, apiDownloadFetch, redirectToLogin, apiUrl } from '../api.js';
|
|
import { getRole, pageHeaderHTML, showToast } from '../app.js';
|
|
import { confirmAction } from '../confirm-modal.js';
|
|
import { navigate } from '../router.js';
|
|
|
|
const REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS';
|
|
const DELETE_ALL_TRANSLATIONS_CONFIRM = 'DELETE ALL TRANSLATIONS';
|
|
|
|
const ACTIVITY_LABELS = {
|
|
app_sync: 'App sync',
|
|
app_change: 'App change',
|
|
web_change: 'Website change',
|
|
web_export: 'Website export',
|
|
api_error: 'API error',
|
|
};
|
|
|
|
export function devPage() {
|
|
if (getRole() !== 'admin') {
|
|
navigate('#/');
|
|
return;
|
|
}
|
|
|
|
const app = document.getElementById('app');
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
app.innerHTML = `
|
|
${pageHeaderHTML('Admin Settings')}
|
|
<div class="card security-settings-card" style="max-width:720px;margin-bottom:16px">
|
|
<h2 style="margin:0 0 8px;font-size:1.1rem">Security & sessions</h2>
|
|
<p class="field-hint" style="margin:0 0 14px">
|
|
Login rate limits apply per username and IP. Session length applies to new logins
|
|
(website and mobile). Password-change tokens use the temporary session length.
|
|
</p>
|
|
<div id="securitySettingsForm" class="security-settings-form">
|
|
<div class="spinner" style="margin:12px 0"></div>
|
|
</div>
|
|
<p id="securitySettingsMeta" class="field-hint" style="margin:12px 0 0"></p>
|
|
<div class="security-revoke-all" style="margin-top:20px;padding-top:16px;border-top:1px solid var(--border)">
|
|
<h3 style="margin:0 0 8px;font-size:1rem">Revoke all sessions</h3>
|
|
<p class="field-hint callout-danger" style="margin:0 0 12px">
|
|
<strong>Signs out everyone</strong> — all admins, supervisors, and counselors on every device.
|
|
You will be logged out too and must sign in again. Use after a suspected compromise or policy change.
|
|
</p>
|
|
<label for="revokeAllConfirm" style="font-size:.85rem;font-weight:500;display:block;margin-bottom:6px">
|
|
Type <code>${REVOKE_ALL_CONFIRM}</code> to confirm
|
|
</label>
|
|
<input type="text" id="revokeAllConfirm" class="revoke-confirm-input" autocomplete="off" spellcheck="false"
|
|
placeholder="${REVOKE_ALL_CONFIRM}">
|
|
<button type="button" class="btn btn-danger" id="revokeAllSessionsBtn" style="margin-top:10px" disabled>
|
|
Revoke all sessions
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card" style="max-width:720px;margin-bottom:16px">
|
|
<h2 style="margin:0 0 8px;font-size:1.1rem">Translations</h2>
|
|
<p class="field-hint callout-danger" style="margin:0 0 12px">
|
|
<strong>Removes all non-German translations</strong> from question, answer-option, and string tables,
|
|
and deletes all languages except German (<code>de</code>). Questionnaire structure and German source
|
|
text are kept. Re-import a bundle from the Translations page to restore other languages.
|
|
</p>
|
|
<label for="deleteAllTranslationsConfirm" style="font-size:.85rem;font-weight:500;display:block;margin-bottom:6px">
|
|
Type <code>${DELETE_ALL_TRANSLATIONS_CONFIRM}</code> to confirm
|
|
</label>
|
|
<input type="text" id="deleteAllTranslationsConfirm" class="revoke-confirm-input" autocomplete="off" spellcheck="false"
|
|
placeholder="${DELETE_ALL_TRANSLATIONS_CONFIRM}">
|
|
<button type="button" class="btn btn-danger" id="deleteAllTranslationsBtn" style="margin-top:10px" disabled>
|
|
Delete all translations
|
|
</button>
|
|
<pre id="deleteAllTranslationsResult" style="margin-top:12px;font-size:.8rem;max-height:160px;overflow:auto;display:none"></pre>
|
|
</div>
|
|
<div class="card" style="max-width:720px;margin-bottom:16px">
|
|
<h2 style="margin:0 0 8px;font-size:1.1rem">Export all response data (ZIP)</h2>
|
|
<p class="field-hint" style="margin:0 0 14px">
|
|
Download one CSV per questionnaire in a single ZIP (full server data).
|
|
<strong>Current data</strong> uses live answers;
|
|
<strong>All versions</strong> includes every upload (one row per submission).
|
|
</p>
|
|
<div style="display:flex;flex-wrap:wrap;gap:10px">
|
|
<button type="button" class="btn btn-primary" id="exportAllCurrentZipBtn">Export all — current data (ZIP)</button>
|
|
<button type="button" class="btn" id="exportAllVersionsZipBtn">Export all — all versions (ZIP)</button>
|
|
</div>
|
|
</div>
|
|
<div class="card" style="max-width:720px">
|
|
<h2 style="margin:0 0 8px;font-size:1.1rem">Dev import</h2>
|
|
<p class="field-hint callout-warn">
|
|
<strong>Local / test only.</strong> Imports prefixed dev users (<code>dev_*</code>),
|
|
clients (<code>DEV-CL-*</code>), and completed questionnaire runs.
|
|
Existing real users are not modified. Conflicts are skipped (usernames, client codes, completions).
|
|
Default password: <code>socialvrlab</code>. Questionnaires must already exist in the database.
|
|
</p>
|
|
<div class="form-group">
|
|
<label for="devFixtureFile">Fixture JSON</label>
|
|
<input type="file" id="devFixtureFile" accept=".json,application/json">
|
|
<span class="field-hint">Use <code>dev-test-fixture.json</code> from the repo root (regenerate with <code>barometer-server/scripts/generate_dev_fixture.py</code> after importing the latest <code>questionnaires_bundle_*.json</code>; uneven counselor load, multi-version uploads, natural timestamps).</span>
|
|
</div>
|
|
<div id="devFixtureSummary" class="field-hint" style="margin:12px 0;display:none"></div>
|
|
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">
|
|
<button type="button" class="btn btn-primary" id="devImportBtn" disabled>Import fixture</button>
|
|
<button type="button" class="btn btn-danger" id="devRemoveBtn">Remove test data</button>
|
|
</div>
|
|
<pre id="devImportResult" style="margin-top:16px;font-size:.8rem;max-height:320px;overflow:auto;display:none"></pre>
|
|
</div>
|
|
<div class="card" style="max-width:720px;margin-top:16px">
|
|
<h2 style="margin:0 0 8px;font-size:1.1rem">Reset database</h2>
|
|
<p class="field-hint callout-danger">
|
|
<strong>Destructive.</strong> Deletes all clients, completions, counselors, and supervisors
|
|
(including non-dev accounts). Admin logins and questionnaire definitions are kept.
|
|
</p>
|
|
<button type="button" class="btn btn-danger" id="devWipeBtn">Remove all data (keep admins)</button>
|
|
</div>
|
|
<div class="card activity-log-card" style="margin-top:16px">
|
|
<h2 style="margin:0 0 8px;font-size:1.1rem">API activity log</h2>
|
|
<p class="field-hint" style="margin:0 0 14px">
|
|
App syncs, website edits (translations, users, questionnaires, …),
|
|
and exports/downloads. Routine page loads (lists, dashboards) are not logged.
|
|
</p>
|
|
<div class="filter-bar" style="margin-bottom:12px">
|
|
<label style="font-size:.85rem;font-weight:500">Date</label>
|
|
<input type="date" id="activityLogDate" value="${today}">
|
|
<label style="font-size:.85rem;font-weight:500">Activity</label>
|
|
<select id="activityLogKind">
|
|
<option value="">All kinds</option>
|
|
<option value="app_sync">App sync</option>
|
|
<option value="app_change">App change</option>
|
|
<option value="web_change">Website change</option>
|
|
<option value="web_export">Website export</option>
|
|
<option value="errors">Errors only (4xx/5xx)</option>
|
|
</select>
|
|
<button type="button" class="btn btn-sm" id="activityLogRefresh">Refresh</button>
|
|
</div>
|
|
<div id="activityLogStatus" class="field-hint" style="margin:0 0 10px;display:none"></div>
|
|
<p id="activityLogMeta" class="field-hint" style="margin:0 0 10px"></p>
|
|
<div class="table-wrapper">
|
|
<table class="data-table activity-log-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Time (UTC)</th>
|
|
<th>Kind</th>
|
|
<th>Subject</th>
|
|
<th>Method</th>
|
|
<th>Route</th>
|
|
<th>Status</th>
|
|
<th>Duration</th>
|
|
<th>Values</th>
|
|
<th>Performed by</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="activityLogBody">
|
|
<tr><td colspan="9" style="text-align:center;color:var(--text-secondary);padding:20px">Loading…</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
let parsedFixture = null;
|
|
|
|
document.getElementById('devFixtureFile').addEventListener('change', async (e) => {
|
|
const file = e.target.files?.[0];
|
|
const summary = document.getElementById('devFixtureSummary');
|
|
const btn = document.getElementById('devImportBtn');
|
|
parsedFixture = null;
|
|
btn.disabled = true;
|
|
summary.style.display = 'none';
|
|
|
|
if (!file) return;
|
|
|
|
try {
|
|
const text = await file.text();
|
|
parsedFixture = JSON.parse(text);
|
|
const st = parsedFixture.stats || {};
|
|
summary.innerHTML = `
|
|
<strong>Preview:</strong>
|
|
${parsedFixture.admins?.length ?? st.admins ?? 0} admins,
|
|
${parsedFixture.supervisors?.length ?? st.supervisors ?? 0} supervisors,
|
|
${parsedFixture.coaches?.length ?? st.coaches ?? 0} counselors,
|
|
${parsedFixture.clients?.length ?? st.clients ?? 0} clients,
|
|
${parsedFixture.completions?.length ?? st.completionRecords ?? st.completions ?? 0} completions,
|
|
${st.totalUploads ?? '—'} uploads (${st.multiVersionPairs ?? '—'} re-uploaded)
|
|
`;
|
|
summary.style.display = '';
|
|
btn.disabled = false;
|
|
} catch (err) {
|
|
showToast('Invalid JSON: ' + err.message, 'error');
|
|
}
|
|
});
|
|
|
|
document.getElementById('devRemoveBtn').addEventListener('click', async () => {
|
|
if (!(await confirmAction({
|
|
title: 'Remove dev test data',
|
|
message: 'Remove all dev test data?\n\nDeletes users matching dev_*, clients DEV-CL-*, and their answers/completions.\nReal accounts are not affected.',
|
|
confirmLabel: 'Remove',
|
|
variant: 'danger',
|
|
}))) {
|
|
return;
|
|
}
|
|
const btn = document.getElementById('devRemoveBtn');
|
|
const pre = document.getElementById('devImportResult');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Removing…';
|
|
pre.style.display = 'none';
|
|
|
|
try {
|
|
const res = await apiPost('dev', {
|
|
action: 'remove',
|
|
usernamePrefix: 'dev_',
|
|
clientCodePrefix: 'DEV-CL-',
|
|
});
|
|
if (!res.success) {
|
|
showToast(res.error || res.message || 'Remove failed', 'error');
|
|
return;
|
|
}
|
|
pre.textContent = JSON.stringify(res, null, 2);
|
|
pre.style.display = '';
|
|
const d = res.deleted || {};
|
|
showToast(
|
|
`Removed ${d.clients ?? 0} clients, ${d.users ?? 0} users, `
|
|
+ `${d.completed_questionnaires ?? 0} completions`,
|
|
'success'
|
|
);
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Remove test data';
|
|
}
|
|
});
|
|
|
|
document.getElementById('devWipeBtn').addEventListener('click', async () => {
|
|
if (!(await confirmAction({
|
|
title: 'Wipe operational data',
|
|
message: 'Remove ALL clients, completions, counselors, and supervisors?\n\nAdmin accounts and questionnaires will NOT be deleted.\nThis cannot be undone.',
|
|
confirmLabel: 'Remove all',
|
|
variant: 'danger',
|
|
}))) {
|
|
return;
|
|
}
|
|
const btn = document.getElementById('devWipeBtn');
|
|
const pre = document.getElementById('devImportResult');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Removing…';
|
|
pre.style.display = 'none';
|
|
|
|
try {
|
|
const res = await apiPost('dev', { action: 'wipeExceptAdmins' });
|
|
if (!res.success) {
|
|
showToast(res.error || res.message || 'Wipe failed', 'error');
|
|
return;
|
|
}
|
|
pre.textContent = JSON.stringify(res, null, 2);
|
|
pre.style.display = '';
|
|
const d = res.deleted || {};
|
|
const kept = res.kept?.admins ?? '?';
|
|
showToast(
|
|
`Wiped ${d.clients ?? 0} clients, ${d.users ?? 0} users, `
|
|
+ `${d.completed_questionnaires ?? 0} completions (${kept} admins kept)`,
|
|
'success'
|
|
);
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Remove all data (keep admins)';
|
|
}
|
|
});
|
|
|
|
wireAdminZipExport();
|
|
wireActivityLog();
|
|
wireSecuritySettings();
|
|
wireDeleteAllTranslations();
|
|
|
|
document.getElementById('devImportBtn').addEventListener('click', async () => {
|
|
if (!parsedFixture) return;
|
|
const btn = document.getElementById('devImportBtn');
|
|
const pre = document.getElementById('devImportResult');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Importing…';
|
|
pre.style.display = 'none';
|
|
|
|
try {
|
|
const res = await apiPost('dev', { fixture: parsedFixture });
|
|
if (!res.success) {
|
|
showToast(res.error || 'Import failed', 'error');
|
|
return;
|
|
}
|
|
pre.textContent = JSON.stringify(res, null, 2);
|
|
pre.style.display = '';
|
|
const imp = res.imported || {};
|
|
const sk = res.skipped || {};
|
|
showToast(
|
|
`Imported: ${imp.completions ?? 0} completions, ${imp.submissions ?? 0} uploads, `
|
|
+ `${imp.clients ?? 0} clients, ${imp.coaches ?? 0} counselors `
|
|
+ `(skipped ${sk.completions ?? 0} existing)`,
|
|
'success'
|
|
);
|
|
if (res.errors?.length) {
|
|
showToast(`${res.errors.length} warning(s) — see log below`, 'error');
|
|
}
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Import fixture';
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderActivityLogStatus(status) {
|
|
const el = document.getElementById('activityLogStatus');
|
|
if (!el || !status) {
|
|
if (el) el.style.display = 'none';
|
|
return;
|
|
}
|
|
if (!status.enabled) {
|
|
el.className = 'field-hint callout-warn';
|
|
el.innerHTML = 'Activity logging is disabled (<code>QDB_API_LOG=0</code> in .env).';
|
|
el.style.display = '';
|
|
return;
|
|
}
|
|
if (status.writable) {
|
|
el.className = 'field-hint';
|
|
el.innerHTML = `Logging to <code>${escHtml(status.dir)}</code>`
|
|
+ (status.php_user ? ` (PHP user: <code>${escHtml(status.php_user)}</code>)` : '');
|
|
el.style.display = '';
|
|
return;
|
|
}
|
|
el.className = 'field-hint callout-danger';
|
|
el.innerHTML = `<strong>Cannot write activity log.</strong> ${escHtml(status.error || 'Permission problem.')}
|
|
<br>Path: <code>${escHtml(status.dir)}</code>`
|
|
+ (status.php_user ? `<br>PHP runs as: <code>${escHtml(status.php_user)}</code>` : '')
|
|
+ (status.fix_hint ? `<br>${escHtml(status.fix_hint)}` : '');
|
|
el.style.display = '';
|
|
}
|
|
|
|
function wireActivityLog() {
|
|
const dateEl = document.getElementById('activityLogDate');
|
|
const kindEl = document.getElementById('activityLogKind');
|
|
document.getElementById('activityLogRefresh')?.addEventListener('click', loadActivityLog);
|
|
dateEl?.addEventListener('change', loadActivityLog);
|
|
kindEl?.addEventListener('change', loadActivityLog);
|
|
loadActivityLog();
|
|
}
|
|
|
|
async function loadActivityLog() {
|
|
const tbody = document.getElementById('activityLogBody');
|
|
const meta = document.getElementById('activityLogMeta');
|
|
if (!tbody) return;
|
|
|
|
const date = document.getElementById('activityLogDate')?.value || new Date().toISOString().slice(0, 10);
|
|
const activity = document.getElementById('activityLogKind')?.value || '';
|
|
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:20px">Loading…</td></tr>';
|
|
|
|
try {
|
|
const q = new URLSearchParams({ date, limit: '300' });
|
|
if (activity) q.set('activity', activity);
|
|
const data = await apiGet(`activity-log?${q}`);
|
|
if (!data.success) throw new Error(data.error || 'Failed to load activity log');
|
|
|
|
renderActivityLogStatus(data.logStatus);
|
|
|
|
const entries = data.entries || [];
|
|
const files = (data.files || []).join(', ') || 'none';
|
|
const trunc = data.truncated ? ' (newest 300 shown)' : '';
|
|
if (meta) {
|
|
meta.textContent = entries.length
|
|
? `${entries.length} entries from ${files}${trunc}`
|
|
: `No entries for this date/filter (files: ${files})`;
|
|
}
|
|
|
|
if (!entries.length) {
|
|
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;color:var(--text-secondary);padding:24px">No activity recorded</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = entries.map((e) => {
|
|
const kind = e.activity || '';
|
|
const label = ACTIVITY_LABELS[kind] || kind || '—';
|
|
const badgeClass = kind ? `badge-activity-${kind}` : '';
|
|
const time = formatActivityTime(e.ts);
|
|
const statusNum = e.status != null ? Number(e.status) : null;
|
|
const status = statusNum != null ? String(statusNum) : '—';
|
|
const statusClass = statusNum != null && statusNum >= 400 ? 'error-text' : '';
|
|
const dur = e.duration_ms != null ? `${e.duration_ms} ms` : '—';
|
|
const subject = formatActivitySubject(e);
|
|
const performedBy = formatActivityPerformer(e);
|
|
const errPart = e.error
|
|
? `<div class="activity-log-error">${escHtml(e.error)}</div>`
|
|
: '';
|
|
return `<tr>
|
|
<td class="activity-log-col-time">${escHtml(time)}</td>
|
|
<td class="activity-log-col-kind"><span class="badge ${badgeClass}">${escHtml(label)}</span></td>
|
|
<td class="activity-log-subject">${subject}</td>
|
|
<td class="activity-log-col-meta"><code>${escHtml(e.method || '')}</code></td>
|
|
<td class="activity-log-col-route"><code>${escHtml(e.route || '')}</code></td>
|
|
<td class="activity-log-col-meta ${statusClass}">${escHtml(status)}</td>
|
|
<td class="activity-log-col-meta">${escHtml(dur)}</td>
|
|
<td class="activity-log-changes-cell">${renderActivityChanges(e.changes)}</td>
|
|
<td class="activity-log-actor">${performedBy}${errPart}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
} catch (err) {
|
|
if (meta) meta.textContent = '';
|
|
tbody.innerHTML = `<tr><td colspan="9" class="error-text" style="padding:16px">${escHtml(err.message)}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
function formatActivitySubject(entry) {
|
|
if (entry.target_username) {
|
|
return `<strong>${escHtml(entry.target_username)}</strong>`;
|
|
}
|
|
const changes = entry.changes || [];
|
|
const userChange = changes.find((c) => (c.field || '').toLowerCase() === 'username');
|
|
if (userChange) {
|
|
const name = userChange.display || formatActivityChangeValue(userChange.value);
|
|
return `<strong>${escHtml(name)}</strong>`;
|
|
}
|
|
const client = changes.find((c) => (c.field || '').toLowerCase().includes('clientcode'));
|
|
if (client) {
|
|
const code = client.display || formatActivityChangeValue(client.value);
|
|
return `client <strong>${escHtml(code)}</strong>`;
|
|
}
|
|
const qClient = changes.find((c) => (c.field || '') === 'query.clientCode');
|
|
if (qClient) {
|
|
return `client <strong>${escHtml(formatActivityChangeValue(qClient.value))}</strong>`;
|
|
}
|
|
return '<span style="color:var(--text-secondary)">—</span>';
|
|
}
|
|
|
|
function formatActivityPerformer(entry) {
|
|
if (entry.actor_username) {
|
|
const role = entry.role ? ` <span class="activity-log-role">(${escHtml(entry.role)})</span>` : '';
|
|
return `<strong>${escHtml(entry.actor_username)}</strong>${role}`;
|
|
}
|
|
if (entry.role) {
|
|
return escHtml(entry.role);
|
|
}
|
|
return '—';
|
|
}
|
|
|
|
function renderActivityChanges(changes) {
|
|
const list = Array.isArray(changes) ? changes : [];
|
|
if (!list.length) {
|
|
return '<span style="color:var(--text-secondary)">—</span>';
|
|
}
|
|
const rows = list.map((c) => {
|
|
const field = c.field ?? '';
|
|
const raw = formatActivityChangeValue(c.value);
|
|
const display = (c.display != null && String(c.display) !== '')
|
|
? String(c.display)
|
|
: raw;
|
|
const showRaw = display !== raw
|
|
&& !raw.startsWith('[REDACTED]')
|
|
&& !raw.startsWith('[ENCRYPTED');
|
|
return `<tr>
|
|
<th scope="row">${escHtml(field)}</th>
|
|
<td>
|
|
<strong>${escHtml(display)}</strong>
|
|
${showRaw ? `<div class="activity-change-raw">${escHtml(raw)}</div>` : ''}
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
return `<table class="activity-changes-table"><tbody>${rows}</tbody></table>`;
|
|
}
|
|
|
|
function formatActivityChangeValue(value) {
|
|
if (value === null || value === undefined) return 'null';
|
|
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
return String(value);
|
|
}
|
|
|
|
function formatActivityTime(iso) {
|
|
if (!iso) return '—';
|
|
try {
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return iso;
|
|
return d.toISOString().replace('T', ' ').slice(0, 19);
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function escHtml(s) {
|
|
const d = document.createElement('div');
|
|
d.textContent = s ?? '';
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function filenameFromContentDisposition(res, fallback) {
|
|
const disp = res.headers.get('Content-Disposition');
|
|
if (!disp) return fallback;
|
|
const m = /filename="([^"]+)"/i.exec(disp);
|
|
return m ? m[1] : fallback;
|
|
}
|
|
|
|
async function downloadExportZip(url, defaultFilename) {
|
|
const res = await apiDownloadFetch(url);
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: 'Download failed' }));
|
|
throw new Error(err.error?.message || err.error || 'Download failed');
|
|
}
|
|
const blob = await res.blob();
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = blobUrl;
|
|
a.download = filenameFromContentDisposition(res, defaultFilename);
|
|
a.click();
|
|
URL.revokeObjectURL(blobUrl);
|
|
}
|
|
|
|
function wireAdminZipExport() {
|
|
document.getElementById('exportAllCurrentZipBtn')?.addEventListener('click', async () => {
|
|
const btn = document.getElementById('exportAllCurrentZipBtn');
|
|
btn.disabled = true;
|
|
const prev = btn.textContent;
|
|
btn.textContent = 'Preparing ZIP…';
|
|
try {
|
|
await downloadExportZip(apiUrl('export?exportAll=1'), 'responses_export_current.zip');
|
|
showToast('ZIP download started (current data)', 'success');
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = prev;
|
|
}
|
|
});
|
|
|
|
document.getElementById('exportAllVersionsZipBtn')?.addEventListener('click', async () => {
|
|
const btn = document.getElementById('exportAllVersionsZipBtn');
|
|
btn.disabled = true;
|
|
const prev = btn.textContent;
|
|
btn.textContent = 'Preparing ZIP…';
|
|
try {
|
|
await downloadExportZip(apiUrl('export?exportAll=1&allVersions=1'), 'responses_export_all_versions.zip');
|
|
showToast('ZIP download started (all versions)', 'success');
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = prev;
|
|
}
|
|
});
|
|
}
|
|
|
|
function wireSecuritySettings() {
|
|
const formEl = document.getElementById('securitySettingsForm');
|
|
const metaEl = document.getElementById('securitySettingsMeta');
|
|
const revokeInput = document.getElementById('revokeAllConfirm');
|
|
const revokeBtn = document.getElementById('revokeAllSessionsBtn');
|
|
if (!formEl) return;
|
|
|
|
revokeInput?.addEventListener('input', () => {
|
|
if (revokeBtn) {
|
|
revokeBtn.disabled = revokeInput.value.trim() !== REVOKE_ALL_CONFIRM;
|
|
}
|
|
});
|
|
|
|
revokeBtn?.addEventListener('click', async () => {
|
|
if (revokeInput.value.trim() !== REVOKE_ALL_CONFIRM) return;
|
|
if (!(await confirmAction({
|
|
title: 'Revoke all sessions',
|
|
message: 'Revoke every active session?\n\nAll users (including you) will be signed out on website and mobile.',
|
|
confirmLabel: 'Revoke all',
|
|
variant: 'danger',
|
|
}))) {
|
|
return;
|
|
}
|
|
revokeBtn.disabled = true;
|
|
try {
|
|
const data = await apiPost('settings', {
|
|
action: 'revokeAllSessions',
|
|
confirmPhrase: REVOKE_ALL_CONFIRM,
|
|
});
|
|
if (!data.success) throw new Error(data.error || 'Failed');
|
|
showToast(`Revoked ${data.revokedSessions ?? 0} session(s). Signing you out…`, 'success');
|
|
revokeInput.value = '';
|
|
setTimeout(() => redirectToLogin(), 800);
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
revokeBtn.disabled = revokeInput.value.trim() !== REVOKE_ALL_CONFIRM;
|
|
}
|
|
});
|
|
|
|
loadSecuritySettings(formEl, metaEl);
|
|
}
|
|
|
|
async function loadSecuritySettings(formEl, metaEl) {
|
|
try {
|
|
const data = await apiGet('settings');
|
|
if (!data.success) throw new Error(data.error || 'Failed to load settings');
|
|
const s = data.settings || {};
|
|
const active = data.activeSessions ?? 0;
|
|
if (metaEl) {
|
|
metaEl.textContent = `${active} active session(s) in the database.`;
|
|
}
|
|
formEl.innerHTML = `
|
|
<div class="security-settings-grid">
|
|
<div class="form-group">
|
|
<label for="setLoginMax">Max failed logins</label>
|
|
<input type="number" id="setLoginMax" min="1" max="100" step="1"
|
|
value="${escAttr(s.login_max_attempts)}">
|
|
<span class="field-hint">Before lockout (per username + IP)</span>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="setLoginWindow">Login attempt window (minutes)</label>
|
|
<input type="number" id="setLoginWindow" min="1" max="1440" step="1"
|
|
value="${escAttr(Math.round((s.login_window_seconds || 900) / 60))}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="setLoginLockout">Lockout duration (minutes)</label>
|
|
<input type="number" id="setLoginLockout" min="1" max="1440" step="1"
|
|
value="${escAttr(Math.round((s.login_lockout_seconds || 900) / 60))}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="setSessionDays">Session length (days)</label>
|
|
<input type="number" id="setSessionDays" min="1" max="365" step="1"
|
|
value="${escAttr(Math.max(1, Math.round((s.session_ttl_seconds || 2592000) / 86400)))}">
|
|
<span class="field-hint">Website & app after normal login</span>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="setTempSession">Temporary session (minutes)</label>
|
|
<input type="number" id="setTempSession" min="5" max="1440" step="1"
|
|
value="${escAttr(Math.round((s.temp_session_ttl_seconds || 600) / 60))}">
|
|
<span class="field-hint">Forced password change flow</span>
|
|
</div>
|
|
</div>
|
|
<button type="button" class="btn btn-primary" id="saveSecuritySettingsBtn">Save security settings</button>
|
|
`;
|
|
document.getElementById('saveSecuritySettingsBtn')?.addEventListener('click', () => {
|
|
saveSecuritySettings(formEl, metaEl);
|
|
});
|
|
} catch (err) {
|
|
formEl.innerHTML = `<p class="error-text">${escHtml(err.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
async function saveSecuritySettings(formEl, metaEl) {
|
|
const btn = document.getElementById('saveSecuritySettingsBtn');
|
|
const payload = {
|
|
login_max_attempts: parseInt(document.getElementById('setLoginMax')?.value || '10', 10),
|
|
login_window_seconds: parseInt(document.getElementById('setLoginWindow')?.value || '15', 10) * 60,
|
|
login_lockout_seconds: parseInt(document.getElementById('setLoginLockout')?.value || '15', 10) * 60,
|
|
session_ttl_seconds: parseInt(document.getElementById('setSessionDays')?.value || '30', 10) * 86400,
|
|
temp_session_ttl_seconds: parseInt(document.getElementById('setTempSession')?.value || '10', 10) * 60,
|
|
};
|
|
if (btn) btn.disabled = true;
|
|
try {
|
|
const data = await apiPut('settings', payload);
|
|
if (!data.success) throw new Error(data.error || 'Save failed');
|
|
showToast('Security settings saved', 'success');
|
|
await loadSecuritySettings(formEl, metaEl);
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
} finally {
|
|
if (btn) btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function wireDeleteAllTranslations() {
|
|
const input = document.getElementById('deleteAllTranslationsConfirm');
|
|
const btn = document.getElementById('deleteAllTranslationsBtn');
|
|
const result = document.getElementById('deleteAllTranslationsResult');
|
|
if (!input || !btn) return;
|
|
|
|
input.addEventListener('input', () => {
|
|
btn.disabled = input.value.trim() !== DELETE_ALL_TRANSLATIONS_CONFIRM;
|
|
});
|
|
|
|
btn.addEventListener('click', async () => {
|
|
if (input.value.trim() !== DELETE_ALL_TRANSLATIONS_CONFIRM) return;
|
|
if (!(await confirmAction({
|
|
title: 'Delete all translations',
|
|
message: 'Remove every non-German translation and language from the database?\n\nGerman source text is kept.',
|
|
confirmLabel: 'Delete all',
|
|
variant: 'danger',
|
|
}))) {
|
|
return;
|
|
}
|
|
btn.disabled = true;
|
|
if (result) result.style.display = 'none';
|
|
try {
|
|
const data = await apiPost('translations', {
|
|
action: 'deleteAll',
|
|
confirmPhrase: DELETE_ALL_TRANSLATIONS_CONFIRM,
|
|
});
|
|
if (!data.success) throw new Error(data.error || 'Failed');
|
|
const d = data.data || {};
|
|
const summary = [
|
|
`Question translations removed: ${d.question ?? 0}`,
|
|
`Answer-option translations removed: ${d.answer_option ?? 0}`,
|
|
`String translations removed: ${d.string ?? 0}`,
|
|
`Languages removed: ${d.languages ?? 0}`,
|
|
].join('\n');
|
|
if (result) {
|
|
result.textContent = summary;
|
|
result.style.display = 'block';
|
|
}
|
|
showToast('All non-German translations deleted', 'success');
|
|
input.value = '';
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
btn.disabled = input.value.trim() !== DELETE_ALL_TRANSLATIONS_CONFIRM;
|
|
}
|
|
});
|
|
}
|
|
|
|
function escAttr(v) {
|
|
return String(v ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/"/g, '"')
|
|
.replace(/</g, '<');
|
|
}
|