Publish Container / publish (push) Successful in 2m51s
- Install banner (phones), Settings guide and dialog with iPhone/iPad and Android steps, native Install button where supported; steps also on the dashboard Account page - apple-touch-icon now a PNG (iOS ignores SVG icons) - Shared pwa-safe.css: dark cap behind the status bar/Dynamic Island in the installed app, inset-aware offline banner, pull-to-refresh, bottom bars, full-screen map, dialogs - Landscape-aware content gutters; measured top-bar height; viewport-fit=cover on every page Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
217 lines
13 KiB
HTML
217 lines
13 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
<meta name="theme-color" content="#070c14">
|
|
<link rel="manifest" href="/dashboard.webmanifest">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
<link rel="stylesheet" href="/pwa-safe.css">
|
|
<title>Account</title>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
:root {
|
|
--ink: #0d1b2a; --ink-light: #5a7089; --paper: #edf2f7; --paper-dark: #ffffff; --paper-darker: #e3eaf2;
|
|
--border: #ccd6e2; --accent: #2563eb; --cool: #0e7490; --ok: #2f8a4a;
|
|
--mono: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', sans-serif;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root { --ink: #dce6f0; --ink-light: #8aa2b8; --paper: #070c14; --paper-dark: #0d1720; --paper-darker: #101c29; --border: #22405c; --accent: #60a5fa; --cool: #38bdf8; --ok: #5cc27a; }
|
|
}
|
|
html { -webkit-text-size-adjust: 100%; }
|
|
html, body { min-height: 100vh; min-height: 100dvh; background: var(--paper); color: var(--ink); font-family: var(--mono); font-size: 15px; }
|
|
.header { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; flex-wrap: wrap; padding: calc(1rem + env(safe-area-inset-top)) calc(1.5rem + env(safe-area-inset-right)) 1rem calc(1.5rem + env(safe-area-inset-left)); border-bottom: 1px solid var(--border); background: var(--paper-dark); }
|
|
.header h1 { font-size: 1.15rem; }
|
|
.header .sub { font-size: 0.8rem; color: var(--ink-light); }
|
|
.header-right { display: flex; gap: 0.6rem; }
|
|
.nav-link { color: var(--ink-light); text-decoration: none; font-size: 0.8rem; font-weight: 600; padding: 0.4rem 0.8rem; min-height: 38px; display: inline-flex; align-items: center; border: 1px solid var(--border); border-radius: 8px; }
|
|
.nav-link:hover { border-color: var(--accent); color: var(--accent); }
|
|
.page { max-width: 860px; margin: 0 auto; padding: 1.5rem calc(1rem + env(safe-area-inset-right)) calc(4rem + env(safe-area-inset-bottom)) calc(1rem + env(safe-area-inset-left)); }
|
|
.section { background: var(--paper-dark); border: 1px solid var(--border); border-radius: 14px; padding: 1.4rem; margin-bottom: 1.25rem; }
|
|
.section-hdr { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.09em; color: var(--ink-light); margin-bottom: 1rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
|
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.9rem; margin-bottom: 0.9rem; }
|
|
label { display: block; font-size: 0.8rem; color: var(--ink-light); margin-bottom: 0.3rem; }
|
|
input, select { width: 100%; padding: 0.6rem 0.75rem; min-height: 44px; font: inherit; font-size: 16px; color: var(--ink); background: var(--paper); border: 1px solid var(--border); border-radius: 8px; }
|
|
input:focus, select:focus { outline: 2px solid var(--accent); border-color: transparent; }
|
|
button { font: inherit; font-size: 0.85rem; font-weight: 600; min-height: 44px; padding: 0 1.1rem; border-radius: 8px; border: 1px solid var(--border); background: var(--paper); color: var(--ink); cursor: pointer; touch-action: manipulation; }
|
|
button:hover { border-color: var(--accent); }
|
|
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
button.danger { color: var(--accent); }
|
|
button.small { min-height: 36px; padding: 0 0.7rem; font-size: 0.78rem; }
|
|
.msg { font-size: 0.85rem; margin-left: 0.7rem; color: var(--ok); }
|
|
.msg.err { color: var(--accent); }
|
|
.hint { font-size: 0.78rem; color: var(--ink-light); line-height: 1.5; margin-top: 0.6rem; }
|
|
.badge { display: inline-block; padding: 0.12rem 0.55rem; border-radius: 99px; font-size: 0.7rem; font-weight: 700; border: 1px solid var(--border); background: var(--paper); text-transform: uppercase; letter-spacing: 0.04em; }
|
|
.badge.admin { color: var(--accent); border-color: var(--accent); }
|
|
.badge.viewer { color: var(--cool); border-color: var(--cool); }
|
|
.badge.off { color: var(--ink-light); }
|
|
.users { display: grid; gap: 0.7rem; }
|
|
.user { background: var(--paper); border: 1px solid var(--border); border-radius: 12px; padding: 0.8rem 0.95rem; display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 1rem; }
|
|
.user.disabled { opacity: 0.6; }
|
|
.user .who { flex: 1 1 180px; min-width: 0; }
|
|
.user .who b { display: block; overflow-wrap: anywhere; }
|
|
.user .who span { font-size: 0.74rem; color: var(--ink-light); }
|
|
.user .actions { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
|
dialog { border: 1px solid var(--border); border-radius: 14px; background: var(--paper-dark); color: var(--ink); padding: 1.3rem; width: min(380px, 92vw); margin: auto; }
|
|
dialog::backdrop { background: rgba(0,0,0,0.55); }
|
|
dialog h3 { font-size: 1rem; margin-bottom: 0.8rem; }
|
|
dialog .row { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
|
|
.hidden { display: none !important; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<div><h1>Account</h1><div class="sub" id="whoami">Loading…</div></div>
|
|
<div class="header-right">
|
|
<a href="/dashboard" class="nav-link">← Dashboard</a>
|
|
<a href="/dashboard/logout" class="nav-link">Sign out</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="page">
|
|
<div class="section">
|
|
<div class="section-hdr">Change my password</div>
|
|
<div class="grid">
|
|
<div><label for="pwCurrent">Current password</label><input id="pwCurrent" type="password" autocomplete="current-password"></div>
|
|
<div><label for="pwNew">New password (min 8 characters)</label><input id="pwNew" type="password" autocomplete="new-password"></div>
|
|
</div>
|
|
<button class="primary" id="pwBtn">Change password</button><span class="msg" id="pwMsg"></span>
|
|
<p class="hint">Changing your password signs you out everywhere else.</p>
|
|
</div>
|
|
|
|
<div class="section" id="installSection">
|
|
<div class="section-hdr">Install this dashboard as an app</div>
|
|
<p class="hint" style="margin:0 0 0.7rem" id="installDone" hidden>✓ You're using the installed app.</p>
|
|
<div id="installSteps">
|
|
<p class="hint" style="margin:0 0 0.4rem"><b>iPhone / iPad (Safari):</b> tap the <b>Share</b> button, then <b>Add to Home Screen</b>, then <b>Add</b>.</p>
|
|
<p class="hint" style="margin:0"><b>Android (Chrome):</b> tap the <b>⋮ menu</b>, then <b>Install app</b> (or <b>Add to Home screen</b>), then <b>Install</b>.</p>
|
|
<p class="hint">Open the app from its Home Screen icon for full screen. The weather app at <b>/</b> installs separately the same way.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="adminArea" class="hidden">
|
|
<div class="section">
|
|
<div class="section-hdr"><span>Users</span><span id="userCount" class="hint" style="margin:0"></span></div>
|
|
<div class="users" id="userList"></div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<div class="section-hdr">Add a user</div>
|
|
<div class="grid">
|
|
<div><label for="nuName">Username</label><input id="nuName" type="text" autocomplete="off" autocapitalize="none" spellcheck="false"></div>
|
|
<div><label for="nuPass">Temporary password (min 8)</label><input id="nuPass" type="password" autocomplete="new-password"></div>
|
|
<div><label for="nuRole">Role</label>
|
|
<select id="nuRole"><option value="viewer">Viewer — read-only dashboard</option><option value="admin">Admin — full access + users</option></select></div>
|
|
</div>
|
|
<button class="primary" id="nuBtn">Create user</button><span class="msg" id="nuMsg"></span>
|
|
<p class="hint"><b>Viewers</b> can see all station data, records and charts, and change their own password. <b>Admins</b> can also change station settings, import data, and manage users. The last active admin can't be removed, demoted or disabled.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<dialog id="dlg"><h3 id="dlgTitle"></h3><div id="dlgBody"></div><div class="row"><button id="dlgCancel">Cancel</button><button class="primary" id="dlgOk">OK</button></div></dialog>
|
|
|
|
<script>
|
|
const $ = (id) => document.getElementById(id);
|
|
let me = null;
|
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
const when = (ts) => (ts ? new Date(ts).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) : 'never');
|
|
|
|
async function api(url, method = 'GET', body) {
|
|
const r = await fetch(url, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined });
|
|
if (r.status === 401) { location.href = '/dashboard/login'; throw new Error('Signed out'); }
|
|
const data = await r.json().catch(() => ({}));
|
|
if (!r.ok) throw new Error(data.error || 'Request failed');
|
|
return data;
|
|
}
|
|
function flash(el, text, isErr) { el.textContent = text; el.className = 'msg' + (isErr ? ' err' : ''); setTimeout(() => { el.textContent = ''; }, 5000); }
|
|
|
|
// Small promise-based dialog (works on iOS where prompt()/confirm() are awkward)
|
|
function ask(title, bodyHtml, okLabel = 'OK') {
|
|
return new Promise((resolve) => {
|
|
const dlg = $('dlg');
|
|
$('dlgTitle').textContent = title; $('dlgBody').innerHTML = bodyHtml; $('dlgOk').textContent = okLabel;
|
|
const done = (v) => { dlg.close(); resolve(v); };
|
|
$('dlgOk').onclick = () => done(dlg.querySelector('input') ? dlg.querySelector('input').value : true);
|
|
$('dlgCancel').onclick = () => done(null);
|
|
dlg.oncancel = () => resolve(null);
|
|
dlg.showModal();
|
|
dlg.querySelector('input')?.focus();
|
|
});
|
|
}
|
|
|
|
$('pwBtn').addEventListener('click', async () => {
|
|
const cur = $('pwCurrent').value, nw = $('pwNew').value;
|
|
if (!cur || nw.length < 8) return flash($('pwMsg'), 'Enter your current password and a new one (min 8 characters)', true);
|
|
try {
|
|
await api('/api/dashboard/change-password', 'POST', { currentPassword: cur, newPassword: nw });
|
|
$('pwCurrent').value = ''; $('pwNew').value = '';
|
|
flash($('pwMsg'), 'Password changed');
|
|
} catch (e) { flash($('pwMsg'), e.message, true); }
|
|
});
|
|
|
|
async function loadUsers() {
|
|
const users = await api('/api/users');
|
|
$('userCount').textContent = `${users.length} user${users.length === 1 ? '' : 's'}`;
|
|
$('userList').innerHTML = users.map((u) => {
|
|
const self = u.username === me.username;
|
|
return `<div class="user${u.disabled ? ' disabled' : ''}" data-u="${esc(u.username)}">
|
|
<div class="who"><b>${esc(u.username)}${self ? ' (you)' : ''}</b><span>Added ${when(u.createdAt)} · last sign-in ${when(u.lastLogin)}</span></div>
|
|
<div><span class="badge ${u.role}">${u.role}</span> ${u.disabled ? '<span class="badge off">disabled</span>' : ''}</div>
|
|
<div class="actions">
|
|
<button class="small" data-act="role">${u.role === 'admin' ? 'Make viewer' : 'Make admin'}</button>
|
|
<button class="small" data-act="reset">Reset password</button>
|
|
<button class="small" data-act="toggle">${u.disabled ? 'Enable' : 'Disable'}</button>
|
|
<button class="small danger" data-act="delete">Delete</button>
|
|
</div></div>`;
|
|
}).join('');
|
|
$('userList').querySelectorAll('.user').forEach((row) => {
|
|
const name = row.dataset.u, u = users.find((x) => x.username === name);
|
|
row.querySelectorAll('[data-act]').forEach((b) => b.addEventListener('click', () => act(b.dataset.act, u)));
|
|
});
|
|
}
|
|
|
|
async function act(kind, u) {
|
|
try {
|
|
if (kind === 'role') {
|
|
const next = u.role === 'admin' ? 'viewer' : 'admin';
|
|
if (!(await ask(`Make ${u.username} a ${next}?`, `<p class="hint">${next === 'admin' ? 'Admins can change settings and manage users.' : 'Viewers have read-only access to the dashboard.'}</p>`, 'Confirm'))) return;
|
|
await api('/api/users/' + encodeURIComponent(u.username), 'PATCH', { role: next });
|
|
} else if (kind === 'reset') {
|
|
const pw = await ask(`Reset password for ${u.username}`, '<label for="rp">New password (min 8)</label><input id="rp" type="password" autocomplete="new-password">', 'Reset');
|
|
if (pw == null) return;
|
|
await api('/api/users/' + encodeURIComponent(u.username), 'PATCH', { password: pw });
|
|
} else if (kind === 'toggle') {
|
|
await api('/api/users/' + encodeURIComponent(u.username), 'PATCH', { disabled: !u.disabled });
|
|
} else if (kind === 'delete') {
|
|
if (!(await ask(`Delete ${u.username}?`, '<p class="hint">They will lose access immediately. This can\'t be undone.</p>', 'Delete'))) return;
|
|
await api('/api/users/' + encodeURIComponent(u.username), 'DELETE');
|
|
}
|
|
await loadUsers();
|
|
} catch (e) { await ask('Could not do that', `<p class="hint">${esc(e.message)}</p>`, 'OK'); }
|
|
}
|
|
|
|
$('nuBtn').addEventListener('click', async () => {
|
|
try {
|
|
await api('/api/users', 'POST', { username: $('nuName').value.trim(), password: $('nuPass').value, role: $('nuRole').value });
|
|
$('nuName').value = ''; $('nuPass').value = '';
|
|
flash($('nuMsg'), 'User created');
|
|
await loadUsers();
|
|
} catch (e) { flash($('nuMsg'), e.message, true); }
|
|
});
|
|
|
|
if (window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true) {
|
|
$('installDone').hidden = false; $('installSteps').hidden = true;
|
|
}
|
|
(async () => {
|
|
try {
|
|
me = await api('/api/dashboard/me');
|
|
$('whoami').innerHTML = `Signed in as <b>${esc(me.username)}</b> · ${me.role}`;
|
|
if (me.role === 'admin') { $('adminArea').classList.remove('hidden'); await loadUsers(); }
|
|
} catch {}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|