mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Driving the app in a real browser showed a context menu whose only item read
"schedule.ctx_new". t() returns the KEY when a string is missing — it never
returns undefined — so a missing key renders literally, and the common
`t('x') || 'A readable default'` guard is dead code: the key is truthy, the
default can never fire, and the pattern hides the problem instead of covering
it. Every occurrence of it in the app was doing exactly that.
Nineteen strings were affected, most of them predating this work: fifteen in
the self-hosted update panel and four in video walls, all of which have been
showing raw keys to users. The intended text was recovered from the dead
defaults, so the wording is the authors' own, and the defaults are removed
rather than left to imply a safety net that does not exist.
A test now walks the views for the keys they actually ask for and fails on any
that English does not define, and separately rejects the `|| default` pattern.
Neither problem is visible to a syntax check, a unit test, or review — only to
someone looking at the screen — so the guard is the only thing that keeps them
from coming back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
535 lines
28 KiB
JavaScript
535 lines
28 KiB
JavaScript
import { api } from '../api.js';
|
|
import { showToast } from '../components/toast.js';
|
|
import { esc, isPlatformAdmin } from '../utils.js';
|
|
import { t } from '../i18n.js';
|
|
import { openAddUserModal } from '../components/workspace-members-add-user-modal.js';
|
|
import { openManageWorkspacesModal } from '../components/admin-user-workspaces-modal.js';
|
|
import { openCreateOrgModal } from '../components/admin-create-org-modal.js';
|
|
import { openTypeToConfirmModal } from '../components/type-to-confirm-modal.js';
|
|
// Reuse the members view's server-error -> friendly-string mapper (handles the
|
|
// 409 duplicate-email / weak-password / invalid-email cases) so we don't fork a
|
|
// second mapper.
|
|
import { mapMutationError } from './workspace-members.js';
|
|
|
|
const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' });
|
|
const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(r => r.json());
|
|
|
|
// #14: the platform user-management dropdown manages users.role (the
|
|
// PLATFORM-level role) only - workspace/org roles are managed in the members
|
|
// views. Options are the current model; the legacy 'admin'/'superadmin' strings
|
|
// were normalized away. #13 adds 'platform_operator' (cross-org staff).
|
|
const PLATFORM_ROLE_OPTIONS = ['user', 'platform_operator', 'platform_admin'];
|
|
|
|
// Platform staff have cross-org access (no single workspace), so the Workspace
|
|
// column shows read-only "Platform (all)" for them. Note utils.isPlatformAdmin
|
|
// only covers admin/superadmin; operators are staff here too.
|
|
function isPlatformStaffRole(role) {
|
|
return role === 'platform_admin' || role === 'superadmin' || role === 'platform_operator';
|
|
}
|
|
|
|
// Short summary of a user's workspace membership for the Users-table cell.
|
|
// Platform staff have cross-org access (not per-workspace membership) -> "Platform
|
|
// (all)". Otherwise: Unassigned (0), the workspace name (1), or "N workspaces".
|
|
function workspaceSummary(u) {
|
|
if (isPlatformStaffRole(u.role)) return t('admin.workspace.platform_all');
|
|
const count = u.workspace_count || 0;
|
|
if (count === 0) return t('admin.workspace.unassigned');
|
|
if (count === 1) return esc(u.workspace_name || '');
|
|
return t('admin.workspace.multi', { n: count });
|
|
}
|
|
|
|
// Workspace cell: a summary + a "Manage" button that opens the full membership
|
|
// modal (add/remove workspaces, set per-workspace role). Manage is offered for
|
|
// everyone, including staff (you can grant them explicit memberships too).
|
|
function workspaceCell(u) {
|
|
return `<td style="padding:8px">
|
|
<div style="display:flex;align-items:center;gap:8px">
|
|
<span style="color:var(--text-muted);font-size:12px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${workspaceSummary(u)}</span>
|
|
<button class="btn btn-secondary btn-sm" type="button" data-ws-manage="${esc(u.id)}">${t('admin.workspace.manage')}</button>
|
|
</div>
|
|
</td>`;
|
|
}
|
|
|
|
export async function render(container) {
|
|
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
|
if (!isPlatformAdmin(user)) {
|
|
container.innerHTML = `<div class="empty-state"><h3>${t('admin.access_denied')}</h3><p>${t('admin.access_denied_desc')}</p></div>`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = `
|
|
<div class="page-header">
|
|
<div><h1>${t('admin.title')}</h1><div class="subtitle">${t('admin.subtitle')}</div></div>
|
|
<div style="display:flex;gap:8px">
|
|
<button class="btn btn-secondary" id="adminCreateOrgBtn">${t('admin.create_org.button')}</button>
|
|
<button class="btn btn-primary" id="adminAddUserBtn">${t('admin.add_user')}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<h3>${t('admin.all_users')}</h3>
|
|
<div id="allUsersTable"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<h3>${t('admin.orgs.title')}</h3>
|
|
<p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">${t('admin.orgs.desc')}</p>
|
|
<div id="orgsTable"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<h3>${t('admin.branding.title')}</h3>
|
|
<p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">${t('admin.branding.desc')}</p>
|
|
<div id="brandingForm"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<h3>${t('admin.plans')}</h3>
|
|
<div id="plansTable"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<h3>${t('admin.system')}</h3>
|
|
<div id="systemInfo"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<h3>Status endpoint</h3>
|
|
<div id="statusDebugForm"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
|
|
</div>
|
|
`;
|
|
|
|
// Add User (#10): platform admin provisions a user into ANY workspace. The
|
|
// page is platform_admin-gated; the modal opens in picker mode (no fixed
|
|
// workspace) so the admin chooses the target org/workspace. The endpoint
|
|
// additionally enforces canAdminWorkspace (platform_admin passes everywhere).
|
|
document.getElementById('adminAddUserBtn')?.addEventListener('click', () => {
|
|
openAddUserModal(null, {
|
|
onSuccess: (result) => {
|
|
showToast(t('members.success.user_created', { email: result.email }), 'success');
|
|
loadUsers();
|
|
},
|
|
mapError: mapMutationError,
|
|
});
|
|
});
|
|
|
|
// Create Organization (#35): platform admin provisions a new customer org +
|
|
// its first workspace (owned by the admin). The modal reloads on success so
|
|
// the new org shows up in the switcher.
|
|
document.getElementById('adminCreateOrgBtn')?.addEventListener('click', () => {
|
|
openCreateOrgModal({
|
|
onSuccess: (result) => showToast(t('admin.create_org.success', { name: result.name }), 'success'),
|
|
});
|
|
});
|
|
|
|
loadUsers();
|
|
loadOrgs();
|
|
loadBranding();
|
|
loadPlans();
|
|
loadSystem();
|
|
loadStatusDebug();
|
|
|
|
}
|
|
|
|
// #36: list organizations with owner + resource counts; platform admin can
|
|
// cascade-delete an org or an individual workspace (type-the-name confirm).
|
|
async function loadOrgs() {
|
|
const el = document.getElementById('orgsTable');
|
|
if (!el) return;
|
|
let orgs;
|
|
try {
|
|
orgs = await api.adminListOrgs();
|
|
} catch (err) {
|
|
el.innerHTML = `<p style="color:var(--danger)">${esc(err.message || 'Failed to load organizations')}</p>`;
|
|
return;
|
|
}
|
|
if (!orgs.length) {
|
|
el.innerHTML = `<p style="color:var(--text-muted)">${t('admin.orgs.empty')}</p>`;
|
|
return;
|
|
}
|
|
el.innerHTML = orgs.map(o => {
|
|
const wsRows = (o.workspaces || []).map(w => `
|
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 10px;border-top:1px solid var(--border)">
|
|
<div style="font-size:13px">${esc(w.name)}
|
|
<span style="color:var(--text-muted);font-size:11px">· ${w.device_count} ${t('admin.orgs.devices')} · ${w.member_count} ${t('admin.orgs.members')}</span>
|
|
</div>
|
|
<button class="btn btn-danger btn-sm" data-del-ws="${esc(w.id)}" data-ws-name="${esc(w.name)}">${t('admin.orgs.delete_ws')}</button>
|
|
</div>`).join('');
|
|
return `
|
|
<div style="border:1px solid var(--border);border-radius:var(--radius);margin-bottom:10px">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--bg-secondary)">
|
|
<div>
|
|
<div style="font-weight:600">${esc(o.name)}</div>
|
|
<div style="color:var(--text-muted);font-size:11px">
|
|
${t('admin.orgs.owner')}: ${esc(o.owner_email || '—')} ·
|
|
${o.workspace_count} ${t('admin.orgs.workspaces')} · ${o.device_count} ${t('admin.orgs.devices')} · ${o.member_count} ${t('admin.orgs.members')}
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-danger btn-sm" data-del-org="${esc(o.id)}" data-org-name="${esc(o.name)}">${t('admin.orgs.delete_org')}</button>
|
|
</div>
|
|
${wsRows}
|
|
</div>`;
|
|
}).join('');
|
|
|
|
el.querySelectorAll('[data-del-org]').forEach(btn => btn.addEventListener('click', () => {
|
|
const id = btn.dataset.delOrg, name = btn.dataset.orgName;
|
|
openTypeToConfirmModal({
|
|
title: t('admin.orgs.delete_org_title'),
|
|
body: t('admin.orgs.delete_org_body', { name: esc(name) }),
|
|
expected: name,
|
|
confirmLabel: t('admin.orgs.delete_org'),
|
|
onConfirm: async () => {
|
|
await api.adminDeleteOrg(id);
|
|
showToast(t('admin.orgs.org_deleted', { name }), 'success');
|
|
loadOrgs(); loadUsers();
|
|
},
|
|
});
|
|
}));
|
|
el.querySelectorAll('[data-del-ws]').forEach(btn => btn.addEventListener('click', () => {
|
|
const id = btn.dataset.delWs, name = btn.dataset.wsName;
|
|
openTypeToConfirmModal({
|
|
title: t('admin.orgs.delete_ws_title'),
|
|
body: t('admin.orgs.delete_ws_body', { name: esc(name) }),
|
|
expected: name,
|
|
confirmLabel: t('admin.orgs.delete_ws'),
|
|
onConfirm: async () => {
|
|
await api.adminDeleteWorkspace(id);
|
|
showToast(t('admin.orgs.ws_deleted', { name }), 'success');
|
|
loadOrgs();
|
|
},
|
|
});
|
|
}));
|
|
}
|
|
|
|
// #15: instance-level default branding form (platform default; every workspace
|
|
// without its own white-label inherits this, as does the login page).
|
|
async function loadBranding() {
|
|
const el = document.getElementById('brandingForm');
|
|
if (!el) return;
|
|
let b = {};
|
|
try { b = await api.adminGetBranding(); } catch (e) { el.innerHTML = `<p style="color:var(--danger)">${esc(e.message || 'Failed to load')}</p>`; return; }
|
|
const v = (x) => esc(x == null ? '' : x);
|
|
el.innerHTML = `
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;max-width:640px">
|
|
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.brand_name')}</label><input type="text" id="brBrandName" class="input" placeholder="ScreenTinker" value="${v(b.brand_name)}"></div>
|
|
<div class="form-group"><label>${t('admin.branding.primary_color')}</label><input type="text" id="brPrimary" class="input" placeholder="#3B82F6" value="${v(b.primary_color)}"></div>
|
|
<div class="form-group"><label>${t('admin.branding.bg_color')}</label><input type="text" id="brBg" class="input" placeholder="#111827" value="${v(b.bg_color)}"></div>
|
|
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.logo_url')}</label><input type="text" id="brLogo" class="input" placeholder="https://…/logo.png" value="${v(b.logo_url)}"></div>
|
|
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.favicon_url')}</label><input type="text" id="brFavicon" class="input" placeholder="https://…/favicon.ico" value="${v(b.favicon_url)}"></div>
|
|
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.custom_css')}</label><textarea id="brCss" class="input" rows="3" placeholder="/* optional */">${v(b.custom_css)}</textarea></div>
|
|
<label style="grid-column:1/-1;display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
|
|
<input type="checkbox" id="brHide" ${b.hide_branding ? 'checked' : ''}> ${t('admin.branding.hide_branding')}
|
|
</label>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm" id="brSave" style="margin-top:12px">${t('admin.branding.save')}</button>
|
|
`;
|
|
document.getElementById('brSave').onclick = async () => {
|
|
try {
|
|
await api.adminSetBranding({
|
|
brand_name: document.getElementById('brBrandName').value.trim() || 'ScreenTinker',
|
|
primary_color: document.getElementById('brPrimary').value.trim() || null,
|
|
bg_color: document.getElementById('brBg').value.trim() || null,
|
|
logo_url: document.getElementById('brLogo').value.trim() || null,
|
|
favicon_url: document.getElementById('brFavicon').value.trim() || null,
|
|
custom_css: document.getElementById('brCss').value.trim() || null,
|
|
hide_branding: document.getElementById('brHide').checked,
|
|
});
|
|
showToast(t('admin.branding.saved'), 'success');
|
|
} catch (err) { showToast(err.message, 'error'); }
|
|
};
|
|
}
|
|
|
|
async function loadUsers() {
|
|
const el = document.getElementById('allUsersTable');
|
|
try {
|
|
const [users, plans] = await Promise.all([
|
|
API('/auth/users'),
|
|
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:720px">
|
|
<thead><tr style="border-bottom:1px solid var(--border)">
|
|
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.user')}</th>
|
|
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.auth')}</th>
|
|
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.last_login')}</th>
|
|
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.role')}</th>
|
|
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.plan')}</th>
|
|
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.workspace')}</th>
|
|
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.actions')}</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
${users.map(u => `
|
|
<tr style="border-bottom:1px solid var(--border)">
|
|
<td style="padding:8px"><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:8px"><span style="background:var(--bg-primary);padding:2px 8px;border-radius:10px;font-size:11px">${u.auth_provider}</span></td>
|
|
<td style="padding:8px;font-size:11px;color:var(--text-muted)">${u.last_login ? new Date(u.last_login * 1000).toLocaleString() : t('common.never')}</td>
|
|
<td style="padding:8px">
|
|
<select class="input" style="max-width:120px;width:100%;background:var(--bg-input);font-size:12px;padding:4px" data-role-user="${u.id}">
|
|
${PLATFORM_ROLE_OPTIONS.map(r => `<option value="${r}" ${u.role === r ? 'selected' : ''}>${t('admin.role.' + r)}</option>`).join('')}
|
|
</select>
|
|
</td>
|
|
<td style="padding:8px">
|
|
<select class="input" style="max-width:130px;width:100%;background:var(--bg-input);font-size:12px;padding:4px" data-plan-user="${u.id}">
|
|
${plans.map(p => `<option value="${p.id}" ${u.plan_id === p.id ? 'selected' : ''}>${p.display_name}</option>`).join('')}
|
|
</select>
|
|
</td>
|
|
${workspaceCell(u)}
|
|
<td style="padding:8px;white-space:nowrap">
|
|
${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm" data-reset-pw-user="${u.id}" data-user-email="${u.email}" style="margin-right:4px">${t('admin.reset_password')}</button>` : ''}
|
|
${!isPlatformAdmin(u) ? `<button class="btn btn-danger btn-sm" data-delete-user="${u.id}">${t('admin.remove')}</button>` : `<span style="color:var(--text-muted);font-size:11px">${t('admin.owner')}</span>`}
|
|
</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p style="color:var(--text-muted);font-size:11px;margin-top:8px">${t('admin.total_users', { n: users.length })}</p>
|
|
`;
|
|
|
|
el.querySelectorAll('[data-role-user]').forEach(select => {
|
|
select.onchange = async () => {
|
|
try {
|
|
await API(`/auth/users/${select.dataset.roleUser}/role`, { method: 'PUT', body: JSON.stringify({ role: select.value }) });
|
|
showToast(t('admin.toast.role_updated'), 'success');
|
|
} catch (err) { showToast(err.message, 'error'); loadUsers(); }
|
|
};
|
|
});
|
|
|
|
el.querySelectorAll('[data-plan-user]').forEach(select => {
|
|
select.onchange = async () => {
|
|
try {
|
|
await API('/subscription/assign', { method: 'POST', body: JSON.stringify({ user_id: select.dataset.planUser, plan_id: select.value }) });
|
|
showToast(t('admin.toast.plan_updated'), 'success');
|
|
} catch (err) { showToast(err.message, 'error'); loadUsers(); }
|
|
};
|
|
});
|
|
|
|
// Manage workspaces: open the per-user membership modal (add/remove
|
|
// workspaces, set per-workspace role). Refresh the table on close only if
|
|
// something changed (the modal calls onClose then).
|
|
el.querySelectorAll('[data-ws-manage]').forEach(btn => {
|
|
btn.onclick = () => {
|
|
const u = users.find(x => x.id === btn.dataset.wsManage);
|
|
if (!u) return;
|
|
openManageWorkspacesModal(u, { onClose: () => loadUsers() });
|
|
};
|
|
});
|
|
|
|
// Reset password handlers
|
|
el.querySelectorAll('[data-reset-pw-user]').forEach(btn => {
|
|
btn.onclick = async () => {
|
|
const email = btn.dataset.userEmail;
|
|
const pw = prompt(t('admin.prompt_reset_password', { email }));
|
|
if (pw === null) return;
|
|
if (pw.length < 8) { showToast(t('admin.toast.password_min_8'), 'error'); return; }
|
|
try {
|
|
await api.resetUserPassword(btn.dataset.resetPwUser, pw);
|
|
showToast(t('admin.toast.password_reset'), 'success');
|
|
} catch (err) { showToast(err.message, 'error'); }
|
|
};
|
|
});
|
|
|
|
el.querySelectorAll('[data-delete-user]').forEach(btn => {
|
|
let confirming = false;
|
|
btn.onclick = async () => {
|
|
if (confirming) {
|
|
try { await api.deleteUser(btn.dataset.deleteUser); showToast(t('admin.toast.user_removed'), 'success'); loadUsers(); }
|
|
catch (err) { showToast(err.message, 'error'); }
|
|
return;
|
|
}
|
|
confirming = true; btn.textContent = t('admin.confirm'); btn.style.background = 'var(--danger)'; btn.style.color = 'white';
|
|
setTimeout(() => { confirming = false; btn.textContent = t('admin.remove'); btn.style.background = ''; btn.style.color = ''; }, 3000);
|
|
};
|
|
});
|
|
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
|
|
}
|
|
|
|
// #146: toggle /api/status debug-metrics exposure. Mirrors loadBranding's
|
|
// load-then-save pattern; takes effect on the next status poll (no restart).
|
|
async function loadStatusDebug() {
|
|
const el = document.getElementById('statusDebugForm');
|
|
if (!el) return;
|
|
let enabled = false;
|
|
try { enabled = (await api.adminGetStatusDebug()).enabled; }
|
|
catch (e) { el.innerHTML = `<p style="color:var(--danger)">${esc(e.message || 'Failed to load')}</p>`; return; }
|
|
el.innerHTML = `
|
|
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
|
|
<input type="checkbox" id="statusDebugChk" ${enabled ? 'checked' : ''}> Expose /api/status debug metrics
|
|
</label>
|
|
<p style="color:var(--text-muted);font-size:12px;margin:4px 0 0 24px">Adds internal limiter/prune/OTA counters to the public status endpoint. Off by default.</p>
|
|
`;
|
|
document.getElementById('statusDebugChk').onchange = async (e) => {
|
|
const chk = e.target;
|
|
chk.disabled = true;
|
|
try { await api.adminSetStatusDebug(chk.checked); showToast('Status debug ' + (chk.checked ? 'enabled' : 'disabled'), 'success'); }
|
|
catch (err) { showToast(err.message, 'error'); chk.checked = !chk.checked; }
|
|
finally { chk.disabled = false; }
|
|
};
|
|
}
|
|
|
|
async function loadPlans() {
|
|
const el = document.getElementById('plansTable');
|
|
try {
|
|
const plans = await fetch('/api/subscription/plans').then(r => r.json());
|
|
el.innerHTML = `
|
|
<div class="table-wrap">
|
|
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:500px">
|
|
<thead><tr style="border-bottom:1px solid var(--border)">
|
|
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.plan')}</th>
|
|
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.devices')}</th>
|
|
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.storage')}</th>
|
|
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.monthly')}</th>
|
|
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.yearly')}</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
${plans.map(p => `
|
|
<tr style="border-bottom:1px solid var(--border)">
|
|
<td style="padding:8px;font-weight:500">${p.display_name}</td>
|
|
<td style="padding:8px;text-align:right">${p.max_devices === -1 ? t('admin.unlimited') : p.max_devices}</td>
|
|
<td style="padding:8px;text-align:right">${p.max_storage_mb === -1 ? t('admin.unlimited') : p.max_storage_mb >= 1024 ? (p.max_storage_mb/1024)+'GB' : p.max_storage_mb+'MB'}</td>
|
|
<td style="padding:8px;text-align:right">${p.price_monthly > 0 ? '$'+p.price_monthly : t('admin.free')}</td>
|
|
<td style="padding:8px;text-align:right">${p.price_yearly > 0 ? '$'+p.price_yearly : '-'}</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
|
|
}
|
|
|
|
async function loadSystem() {
|
|
const el = document.getElementById('systemInfo');
|
|
try {
|
|
const version = await fetch('/api/version').then(r => r.json());
|
|
const token = localStorage.getItem('token');
|
|
|
|
const versionComparison = version.latest_version
|
|
? `<div class="info-card">
|
|
<div class="info-card-label">${t('admin.latest_version')}</div>
|
|
<div class="info-card-value small">${esc(version.latest_version)}</div>
|
|
</div>
|
|
<div class="info-card">
|
|
<div class="info-card-label">${t('admin.status')}</div>
|
|
<div class="info-card-value small" style="color:${version.update_available ? 'var(--warning)' : 'var(--success)'}">${version.update_available ? (t('admin.update_available')) : (t('admin.up_to_date'))}</div>
|
|
</div>`
|
|
: `<div class="info-card">
|
|
<div class="info-card-label">${t('admin.latest_version')}</div>
|
|
<div class="info-card-value small" style="color:var(--text-muted)">${t('admin.checking')}</div>
|
|
</div>`;
|
|
|
|
el.innerHTML = `
|
|
<div class="info-grid">
|
|
<div class="info-card"><div class="info-card-label">${t('admin.version')}</div><div class="info-card-value small">${esc(version.version)}</div></div>
|
|
${versionComparison}
|
|
</div>
|
|
<div style="display:flex;gap:8px;margin-top:16px;flex-wrap:wrap">
|
|
<button class="btn btn-secondary btn-sm" id="checkUpdateBtn">${t('admin.check_now')}</button>
|
|
<button class="btn btn-primary btn-sm" id="triggerUpdateBtn"${!version.update_available ? ' style="display:none"' : ''}>${t('admin.update_now')}</button>
|
|
<a href="/api/status/backup?token=${token}" class="btn btn-secondary btn-sm" style="text-decoration:none">${t('admin.download_db_backup')}</a>
|
|
<a href="/api/status" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none">${t('admin.server_status')}</a>
|
|
</div>
|
|
<div id="updateResult" style="margin-top:12px"></div>
|
|
`;
|
|
|
|
// Check Now button
|
|
document.getElementById('checkUpdateBtn')?.addEventListener('click', async () => {
|
|
const btn = document.getElementById('checkUpdateBtn');
|
|
btn.disabled = true;
|
|
btn.textContent = t('admin.checking');
|
|
try {
|
|
const res = await fetch('/api/admin/check-update', { method: 'POST', headers: headers() });
|
|
const data = await res.json();
|
|
const updBtn = document.getElementById('triggerUpdateBtn');
|
|
if (data.update_available && updBtn) {
|
|
updBtn.style.display = '';
|
|
}
|
|
loadSystem(); // refresh the whole card
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
btn.disabled = false;
|
|
btn.textContent = t('admin.check_now');
|
|
}
|
|
});
|
|
|
|
// Update Now button
|
|
document.getElementById('triggerUpdateBtn')?.addEventListener('click', async () => {
|
|
const btn = document.getElementById('triggerUpdateBtn');
|
|
const resultEl = document.getElementById('updateResult');
|
|
btn.disabled = true;
|
|
btn.textContent = t('admin.updating');
|
|
try {
|
|
const res = await fetch('/api/admin/trigger-update', { method: 'POST', headers: headers() });
|
|
const data = await res.json();
|
|
if (data.docker_enabled) {
|
|
// Docker executed — show output with Copy button
|
|
resultEl.innerHTML = `
|
|
<div style="margin-top:12px;border:1px solid var(--border);border-radius:var(--radius);padding:12px;background:var(--bg-card)">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
<strong style="font-size:13px">${data.success ? (t('admin.update_success')) : (t('admin.update_failed'))}</strong>
|
|
<button class="btn btn-secondary btn-sm" id="copyOutputBtn">${t('admin.copy')}</button>
|
|
</div>
|
|
<pre style="max-height:300px;overflow:auto;font-size:11px;margin:0;background:var(--bg-primary);padding:8px;border-radius:4px;white-space:pre-wrap;word-break:break-all">${esc(data.output || '')}</pre>
|
|
</div>`;
|
|
document.getElementById('copyOutputBtn')?.addEventListener('click', () => {
|
|
const pre = resultEl.querySelector('pre');
|
|
const text = pre ? pre.textContent : '';
|
|
if (navigator.clipboard) {
|
|
navigator.clipboard.writeText(text).then(() => showToast(t('admin.copied'), 'success'));
|
|
} else {
|
|
// Fallback for older browsers
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.position = 'fixed';
|
|
ta.style.opacity = '0';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
showToast(t('admin.copied'), 'success');
|
|
}
|
|
});
|
|
} else if (data.instructions) {
|
|
// Docker disabled — show manual instructions with Copy button
|
|
resultEl.innerHTML = `
|
|
<div style="margin-top:12px;border:1px solid var(--border);border-radius:var(--radius);padding:12px;background:var(--bg-secondary)">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
<strong style="font-size:13px">${t('admin.manual_update')}</strong>
|
|
<button class="btn btn-secondary btn-sm" id="copyCmdBtn">${t('admin.copy_command')}</button>
|
|
</div>
|
|
<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">${t('admin.manual_update_desc')}</p>
|
|
<pre style="font-size:11px;margin:0;background:var(--bg-primary);padding:8px;border-radius:4px;white-space:pre-wrap;word-break:break-all">${esc(data.instructions)}</pre>
|
|
</div>`;
|
|
document.getElementById('copyCmdBtn')?.addEventListener('click', () => {
|
|
const pre = resultEl.querySelector('pre');
|
|
const text = pre ? pre.textContent : '';
|
|
if (navigator.clipboard) {
|
|
navigator.clipboard.writeText(text).then(() => showToast(t('admin.copied'), 'success'));
|
|
} else {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.position = 'fixed';
|
|
ta.style.opacity = '0';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
showToast(t('admin.copied'), 'success');
|
|
}
|
|
});
|
|
}
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = t('admin.update_now');
|
|
}
|
|
});
|
|
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
|
|
}
|
|
|
|
export function cleanup() {}
|