Files
2026-11-05 12:52:32 +01:00

932 lines
75 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
(() => {
// ------------------------------------------------------------------ basics
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
const store = {
get(k, d) { try { return localStorage.getItem(k) ?? d; } catch { return d; } },
set(k, v) { try { localStorage.setItem(k, v); } catch { /* private mode */ } },
};
let LANG = store.get('ap.lang', 'en');
const T = (pl, en) => (LANG === 'pl' ? pl : en);
const S = { session: null, csrf: '', settings: null, timezones: [], timers: [], sse: null, page: 'overview', devices: [] };
function clearTimers() {
S.timers.forEach((t) => clearInterval(t));
S.timers = [];
if (S.sse) { S.sse.close(); S.sse = null; }
}
function every(ms, fn) { fn(); S.timers.push(setInterval(fn, ms)); }
// ------------------------------------------------------------------ formatting
const fmtNum = (n) => (n == null ? '-' : Number(n).toLocaleString(LANG === 'pl' ? 'pl-PL' : 'en-US'));
function fmtBytes(b) {
b = Number(b || 0);
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
return `${b.toFixed(b < 10 && i ? 1 : 0)} ${u[i]}`;
}
function fmtDur(s) {
s = Math.max(0, Math.floor(s));
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
if (d) return `${d}d ${h}h`;
if (h) return `${h}h ${m}m`;
if (m) return `${m}m ${s % 60}s`;
return `${s}s`;
}
const fmtTs = (t) => (t ? new Date(t * 1000).toLocaleString(LANG === 'pl' ? 'pl-PL' : 'en-GB') : '-');
const fmtTime = (t) => (t ? new Date(t * 1000).toLocaleTimeString(LANG === 'pl' ? 'pl-PL' : 'en-GB') : '-');
function ago(t) {
if (!t) return T('nigdy', 'never');
const s = Math.floor(Date.now() / 1000) - t;
if (s < 60) return T(`${s}s temu`, `${s}s ago`);
return T(`${fmtDur(s)} temu`, `${fmtDur(s)} ago`);
}
// ------------------------------------------------------------------ api
async function api(method, path, body) {
const opt = { method, credentials: 'same-origin', headers: {} };
if (method !== 'GET') opt.headers['X-CSRF-Token'] = S.csrf;
if (body !== undefined) {
opt.headers['Content-Type'] = 'application/json';
opt.body = JSON.stringify(body);
}
const r = await fetch(path, opt);
if (r.status === 401 && !path.startsWith('/api/login') && !path.startsWith('/api/setup')) {
showAuth();
throw new Error(T('Sesja wygasła', 'Session expired'));
}
const ct = r.headers.get('content-type') || '';
const data = ct.includes('json') ? await r.json() : await r.text();
if (!r.ok) throw new Error((data && data.error) || `HTTP ${r.status}`);
return data;
}
let toastT;
function toast(msg, error) {
const t = $('#toast');
t.textContent = msg;
t.classList.toggle('error', !!error);
t.hidden = false;
clearTimeout(toastT);
toastT = setTimeout(() => { t.hidden = true; }, error ? 6000 : 3000);
}
function applyWidths(root) {
$$('[data-w]', root).forEach((el) => { el.style.width = `${Math.max(0, Math.min(100, Number(el.dataset.w)))}%`; });
}
// ------------------------------------------------------------------ charts (SVG)
const NS = 'http://www.w3.org/2000/svg';
function niceMax(v) {
if (v <= 0) return 1;
const p = 10 ** Math.floor(Math.log10(v));
for (const m of [1, 2, 2.5, 5, 10]) if (m * p >= v) return m * p;
return 10 * p;
}
/** Line/area chart. series: [{name, cls:'k1', values:[...] }], labels: [string] */
function lineChart(el, { labels, series, height = 170, area = true, fmt = fmtNum, bars = false, min = 1 }) {
const W = Math.max(el.clientWidth || 600, 280), H = height, L = 46, R = 22, Tp = 8, B = 20;
const n = labels.length;
const max = niceMax(Math.max(min, ...series.flatMap((s) => s.values)));
const slot = (W - L - R) / Math.max(1, n);
const x = bars ? (i) => L + slot * (i + 0.5) : (i) => L + (n <= 1 ? 0 : (i * (W - L - R)) / (n - 1));
const y = (v) => Tp + (H - Tp - B) * (1 - v / max);
let svg = `<svg class="chart" viewBox="0 0 ${W} ${H}" width="100%" height="${H}" preserveAspectRatio="none">`;
for (let g = 0; g <= 4; g++) {
const v = (max * g) / 4, yy = y(v);
svg += `<line class="grid-line" x1="${L}" x2="${W - R}" y1="${yy}" y2="${yy}"/><text x="${L - 6}" y="${yy + 3}" text-anchor="end">${esc(fmt(Math.round(v * 100) / 100))}</text>`;
}
const step = Math.max(1, Math.ceil(n / 8));
for (let i = 0; i < n; i += step) {
const anchor = !bars && n > 1 && i === 0 ? 'start' : (x(i) > W - R - 30 ? 'end' : 'middle');
svg += `<text x="${x(i)}" y="${H - 5}" text-anchor="${anchor}">${esc(labels[i])}</text>`;
}
if (bars) {
const bw = Math.min(26, Math.max(1, (slot * 0.7) / series.length));
series.forEach((s, si) => {
s.values.forEach((v, i) => {
const xx = x(i) - (bw * series.length) / 2 + si * bw;
svg += `<rect class="br ${s.cls}" x="${xx}" y="${y(v)}" width="${bw}" height="${Math.max(0, y(0) - y(v))}" rx="1"/>`;
});
});
} else {
series.forEach((s) => {
const pts = s.values.map((v, i) => `${x(i)},${y(v)}`).join(' ');
if (area && n > 1) svg += `<polygon class="ar ${s.cls}" points="${x(0)},${y(0)} ${pts} ${x(n - 1)},${y(0)}"/>`;
svg += `<polyline class="ln ${s.cls}" points="${pts}"/>`;
});
}
svg += `<line class="cursor" id="cur" x1="0" x2="0" y1="${Tp}" y2="${H - B}" visibility="hidden"/></svg>`;
el.innerHTML = `<div class="chart-wrap">${svg}<div class="chart-tip" hidden></div></div>
<div class="legend">${series.map((s) => `<span><i class="sw ${s.cls}"></i>${esc(s.name)}</span>`).join('')}</div>`;
const wrap = $('.chart-wrap', el), tip = $('.chart-tip', el), cur = $('#cur', el), svgEl = $('svg', el);
wrap.addEventListener('mousemove', (e) => {
const rect = svgEl.getBoundingClientRect();
const px = ((e.clientX - rect.left) / rect.width) * W;
const i = Math.max(0, Math.min(n - 1, bars ? Math.floor((px - L) / slot) : Math.round(((px - L) / (W - L - R)) * (n - 1))));
cur.setAttribute('x1', x(i)); cur.setAttribute('x2', x(i)); cur.setAttribute('visibility', 'visible');
tip.innerHTML = `<b>${esc(labels[i])}</b><br>${series.map((s) => `${esc(s.name)}: ${esc(fmt(s.values[i]))}`).join('<br>')}`;
tip.hidden = false;
const left = Math.min(e.clientX - rect.left + 12, rect.width - tip.offsetWidth - 4);
tip.style.left = `${Math.max(0, left)}px`;
tip.style.top = '6px';
});
wrap.addEventListener('mouseleave', () => { tip.hidden = true; cur.setAttribute('visibility', 'hidden'); });
}
function spark(values, cls = 'k1') {
const n = values.length;
if (n < 2) return '';
const max = Math.max(1, ...values);
const pts = values.map((v, i) => `${(i * 100) / (n - 1)},${30 - (v / max) * 28}`).join(' ');
return `<svg class="spark" viewBox="0 0 100 32" preserveAspectRatio="none"><polygon class="ar ${cls}" points="0,32 ${pts} 100,32"/><polyline class="ln ${cls}" points="${pts}"/></svg>`;
}
function donut(el, items, center) {
const total = items.reduce((a, b) => a + b.value, 0) || 1;
const r = 48, C = 2 * Math.PI * r;
let off = 0, arcs = '';
items.forEach((it, i) => {
const len = (it.value / total) * C;
arcs += `<circle class="dn k${(i % 7) + 1}" r="${r}" cx="60" cy="60" stroke-dasharray="${len} ${C - len}" stroke-dashoffset="${-off}" transform="rotate(-90 60 60)"><title>${esc(it.label)}: ${it.value}</title></circle>`;
off += len;
});
el.innerHTML = `<div class="donut-layout"><svg viewBox="0 0 120 120" width="130" height="130">${arcs}<text class="donut-center" x="60" y="65" text-anchor="middle">${esc(center ?? total)}</text></svg>
<div>${items.map((it, i) => `<div class="legend"><span><i class="sw k${(i % 7) + 1}"></i>${esc(it.label)} - <b>${fmtNum(it.value)}</b></span></div>`).join('') || `<div class="empty">${T('Brak danych', 'No data')}</div>`}</div></div>`;
}
function hbars(items, cls = 'k1') {
const max = Math.max(1, ...items.map((i) => i.value));
return items.map((i) => `<div class="hbar"><span title="${esc(i.label)}">${esc(i.label)}</span><div class="track"><div class="fill ${cls}" data-w="${(i.value / max) * 100}"></div></div><span class="n">${fmtNum(i.value)}</span></div>`).join('')
|| `<div class="empty">${T('Brak danych', 'No data')}</div>`;
}
// ------------------------------------------------------------------ modal
function modal(html, cls = '') {
const root = $('#modal-root');
root.innerHTML = `<div class="backdrop"><div class="dialog ${cls}" role="dialog" aria-modal="true">${html}</div></div>`;
const bd = $('.backdrop', root);
bd.addEventListener('mousedown', (e) => { if (e.target === bd) closeModal(); });
$$('[data-close]', root).forEach((b) => b.addEventListener('click', closeModal));
document.addEventListener('keydown', escClose);
applyWidths(root);
return $('.dialog', root);
}
function escClose(e) { if (e.key === 'Escape') closeModal(); }
function closeModal() { $('#modal-root').innerHTML = ''; document.removeEventListener('keydown', escClose); }
function confirmBox(title, text, danger, typed) {
return new Promise((resolve) => {
const d = modal(`<div class="dialog-head"><h2>${esc(title)}</h2><button class="quiet" data-close>✕</button></div>
<p class="fine">${esc(text)}</p>
${typed ? `<label>${T('Wpisz', 'Type')} <b class="mono">${esc(typed)}</b> ${T('aby potwierdzić', 'to confirm')}</label><input id="cf-typed" autocomplete="off">` : ''}
<div class="form-actions"><button class="quiet" data-close>${T('Anuluj', 'Cancel')}</button><button class="${danger ? 'danger' : 'primary'}" id="cf-ok">${T('Potwierdź', 'Confirm')}</button></div>`, 'small');
$('#cf-ok', d).addEventListener('click', () => {
if (typed && $('#cf-typed', d).value.trim() !== typed) { toast(T('Wpisany tekst się nie zgadza', 'Text does not match'), true); return; }
closeModal(); resolve(true);
});
$$('[data-close]', d).forEach((b) => b.addEventListener('click', () => resolve(false)));
});
}
// ------------------------------------------------------------------ navigation
const NAV = [
['overview', T('Przegląd', 'Overview')],
['live', T('Na żywo', 'Live')],
['devices', T('Urządzenia', 'Devices')],
['users', T('Skrzynki', 'Mailboxes')],
['backends', T('Backendy', 'Backends')],
['activesync', 'ActiveSync'],
['policies', T('Polityki', 'Policies')],
['security', T('Bezpieczeństwo', 'Security')],
['logs', T('Logi i audyt', 'Logs & audit')],
['about', T('O programie', 'About')],
];
const navLabel = (id) => (NAV.find((n) => n[0] === id) || NAV[0])[1];
function renderNav() {
$('#nav').innerHTML = NAV.map((n, i) => `<button class="nav${S.page === n[0] ? ' active' : ''}" data-page="${n[0]}"><span>${String(i + 1).padStart(2, '0')}</span><b>${esc(n[1])}</b>${n[0] === 'devices' ? '<i class="count" id="nav-dev-count"></i>' : ''}</button>`).join('');
$$('#nav .nav').forEach((b) => b.addEventListener('click', () => { location.hash = b.dataset.page; $('#nav').classList.remove('open'); }));
}
function go() {
const id = (location.hash || '#overview').slice(1).split('/')[0];
S.page = PAGES[id] ? id : 'overview';
clearTimers();
closeModal();
renderNav();
$('#page-label').textContent = navLabel(S.page);
const v = $('#view');
v.innerHTML = '';
PAGES[S.page](v).catch((e) => { v.innerHTML = `<div class="notice">${esc(e.message)}</div>`; });
}
function head(eyebrow, title, desc, actions = '') {
return `<div class="page-head"><div><div class="small-label eyebrow">${esc(eyebrow)}</div><h1>${esc(title)}</h1>${desc ? `<p class="fine">${esc(desc)}</p>` : ''}</div><div class="top-actions">${actions}</div></div>`;
}
// ------------------------------------------------------------------ pages
const PAGES = {};
PAGES.overview = async (v) => {
v.innerHTML = head(T('PANEL STEROWANIA', 'CONTROL ROOM'), T('Przegląd', 'Overview'), T('Ruch ActiveSync, aktywne połączenia push, urządzenia i stan backendów.', 'ActiveSync traffic, push connections, devices and backend state.'), `<select id="range" class="quiet"><option value="60">60 min</option><option value="360">6 h</option><option value="1440">24 h</option></select>`) +
`<div class="grid stats" id="tiles"></div>
<div class="grid two">
<div class="card"><div class="card-heading"><div><span class="small-label">${T('ŻĄDANIA / MIN', 'REQUESTS / MIN')}</span><h2>${T('Ruch', 'Traffic')}</h2></div></div><div id="ch-req"></div></div>
<div class="card"><div class="card-heading"><div><span class="small-label">${T('ELEMENTY / MIN', 'ITEMS / MIN')}</span><h2>${T('Synchronizacja danych', 'Data synchronisation')}</h2></div></div><div id="ch-items"></div></div>
<div class="card"><div class="card-heading"><div><span class="small-label">${T('TRANSFER / MIN', 'TRANSFER / MIN')}</span><h2>${T('Dane', 'Data')}</h2></div></div><div id="ch-bytes"></div></div>
<div class="card"><div class="card-heading"><div><span class="small-label">${T('OSTATNIE 7 DNI', 'LAST 7 DAYS')}</span><h2>${T('Żądania na godzinę', 'Requests per hour')}</h2></div></div><div id="ch-hourly"></div></div>
</div>
<div class="grid three">
<div class="card"><div class="card-heading"><div><span class="small-label">${T('OD STARTU', 'SINCE START')}</span><h2>${T('Komendy', 'Commands')}</h2></div></div><div id="cmds"></div></div>
<div class="card"><div class="card-heading"><div><span class="small-label">${T('URZĄDZENIA', 'DEVICES')}</span><h2>${T('Typy urządzeń', 'Device types')}</h2></div></div><div id="types"></div></div>
<div class="card"><div class="card-heading"><div><span class="small-label">RUNTIME</span><h2>${T('Serwer', 'Server')}</h2></div></div><dl class="kv" id="runtime"></dl></div>
</div>`;
const rangeSel = $('#range', v);
rangeSel.value = store.get('ap.range', '60');
rangeSel.addEventListener('change', () => { store.set('ap.range', rangeSel.value); refresh(); });
async function refresh() {
const [o, ts] = await Promise.all([api('GET', '/api/overview'), api('GET', `/api/timeseries?minutes=${rangeSel.value}`)]);
const lab = ts.map((b) => new Date(b.t * 1000).toLocaleTimeString(LANG === 'pl' ? 'pl-PL' : 'en-GB', { hour: '2-digit', minute: '2-digit' }));
const last = ts.slice(-15);
const rpm = last.length ? Math.round(last.reduce((a, b) => a + b.requests, 0) / last.length * 10) / 10 : 0;
const errRate = o.requests ? ((o.errors / o.requests) * 100).toFixed(2) : '0.00';
const tile = (label, value, sub, cls = '', sp = '') => `<div class="card stat ${cls}"><span class="small-label">${esc(label)}</span><div class="value">${value}</div><div class="sub">${sub}</div>${sp}</div>`;
$('#tiles').innerHTML = [
tile(T('Żądania / min', 'Requests / min'), esc(rpm), T(`łącznie ${fmtNum(o.requests)}`, `total ${fmtNum(o.requests)}`), 'accent', spark(ts.map((b) => b.requests))),
tile(T('Połączenia push', 'Push connections'), fmtNum(o.pings), T(`${o.active_requests} aktywnych żądań`, `${o.active_requests} active requests`), 'good', spark(ts.map((b) => b.pings), 'k2')),
tile(T('Urządzenia', 'Devices'), `${fmtNum(o.devices_24h)}<small>/ ${fmtNum(o.devices_total)}</small>`, T(`aktywne 24h / wszystkie · ${fmtNum(o.partnerships_total)} partnerstw`, `active 24h / total · ${fmtNum(o.partnerships_total)} partnerships`)),
tile(T('Skrzynki', 'Mailboxes'), `${fmtNum(o.users_active_15m)}<small>/ ${fmtNum(o.users_total)}</small>`, T('aktywne 15 min / wszystkie', 'active 15 min / total')),
tile(T('Wysłane maile', 'Mails sent'), fmtNum(o.mails_sent), T(`elementy ↓${fmtNum(o.items_out)} ↑${fmtNum(o.items_in)}`, `items ↓${fmtNum(o.items_out)} ↑${fmtNum(o.items_in)}`), '', spark(ts.map((b) => b.mails_sent), 'k3')),
tile(T('Błędy', 'Errors'), fmtNum(o.errors), `${errRate}% ${T('żądań', 'of requests')}`, o.errors ? 'bad' : '', spark(ts.map((b) => b.errors), 'k5')),
tile(T('Nieudane logowania', 'Failed logins'), fmtNum(o.auth_failures), T(`${o.lockouts} blokad aktywnych`, `${o.lockouts} active lockouts`), o.lockouts ? 'bad' : '', spark(ts.map((b) => b.auth_failures), 'k5')),
tile(T('Transfer', 'Transfer'), fmtBytes(o.bytes_out), `↑ ${fmtBytes(o.bytes_in)}`, '', spark(ts.map((b) => b.bytes_out), 'k4')),
].join('');
if (o.devices_pending || o.wipes_pending) {
$('#tiles').insertAdjacentHTML('afterbegin', `<div class="notice">${o.devices_pending ? `<span><b>${o.devices_pending}</b> ${T('urządzeń czeka na zatwierdzenie', 'devices await approval')}</span>` : ''}${o.wipes_pending ? `<span><b>${o.wipes_pending}</b> ${T('zdalnych wymazań w toku', 'remote wipes pending')}</span>` : ''}</div>`);
}
lineChart($('#ch-req'), { labels: lab, series: [
{ name: T('żądania', 'requests'), cls: 'k1', values: ts.map((b) => b.requests) },
{ name: 'ping', cls: 'k2', values: ts.map((b) => b.pings) },
{ name: T('błędy', 'errors'), cls: 'k5', values: ts.map((b) => b.errors) },
] });
lineChart($('#ch-items'), { labels: lab, bars: true, series: [
{ name: T('do urządzeń', 'to devices'), cls: 'k3', values: ts.map((b) => b.items_out) },
{ name: T('z urządzeń', 'from devices'), cls: 'k4', values: ts.map((b) => b.items_in) },
] });
lineChart($('#ch-bytes'), { labels: lab, fmt: fmtBytes, min: 1024, series: [
{ name: T('wysłane', 'sent'), cls: 'k4', values: ts.map((b) => b.bytes_out) },
{ name: T('odebrane', 'received'), cls: 'k6', values: ts.map((b) => b.bytes_in) },
] });
$('#cmds').innerHTML = hbars(o.commands.slice(0, 12).map(([k, s]) => ({ label: k, value: s.count })));
applyWidths($('#cmds'));
donut($('#types'), o.device_types.map(([t, n]) => ({ label: t, value: n })), o.devices_total);
$('#runtime').innerHTML = [
[T('Wersja', 'Version'), o.version],
['Uptime', fmtDur(o.uptime)],
[T('Pamięć RSS', 'Memory RSS'), fmtBytes(o.rss)],
[T('Pula IMAP', 'IMAP pool'), o.imap_pool],
[T('Baza danych', 'Database'), `${fmtBytes(o.db_bytes)} + WAL ${fmtBytes(o.wal_bytes)}`],
['IMAP', o.backends.imap],
['SMTP', o.backends.smtp],
['CalDAV', o.backends.caldav || T('wyłączony', 'disabled')],
['CardDAV', o.backends.carddav || T('wyłączony', 'disabled')],
['Provisioning', o.provisioning ? T('włączony', 'enabled') : T('wyłączony', 'disabled')],
['Autodiscover', fmtNum(o.autodiscover)],
].map(([k, val]) => `<dt>${esc(k)}</dt><dd>${esc(val)}</dd>`).join('');
$('#push-count').textContent = `PUSH ${o.pings}`;
}
async function hourly() {
const h = await api('GET', '/api/stats/hourly?hours=168');
const lab = h.map((b) => { const d = new Date(b.t * 1000); return `${d.toLocaleDateString(LANG === 'pl' ? 'pl-PL' : 'en-GB', { weekday: 'short' })} ${String(d.getHours()).padStart(2, '0')}:00`; });
lineChart($('#ch-hourly'), { labels: lab, bars: true, series: [
{ name: T('żądania', 'requests'), cls: 'k1', values: h.map((b) => b.requests) },
{ name: T('błędy', 'errors'), cls: 'k5', values: h.map((b) => b.errors) },
] });
}
every(5000, () => refresh().catch(() => {}));
every(300000, () => hourly().catch(() => {}));
};
PAGES.live = async (v) => {
v.innerHTML = head(T('NA ŻYWO', 'LIVE'), T('Ruch na żywo', 'Live traffic'), T('Trwające żądania, połączenia push (Ping / hanging Sync) i strumień zakończonych żądań.', 'In-flight requests, push connections (Ping / hanging Sync) and a stream of finished requests.'), `<button class="quiet" id="pause">${T('Pauza', 'Pause')}</button>`) +
`<div class="grid two">
<div class="card"><div class="card-heading"><div><span class="small-label">PUSH</span><h2>${T('Aktywne połączenia push', 'Active push connections')}</h2></div><span class="pill live" id="ping-n">0</span></div><div class="table-scroll" id="pings"></div></div>
<div class="card"><div class="card-heading"><div><span class="small-label">IN-FLIGHT</span><h2>${T('Trwające żądania', 'Requests in progress')}</h2></div><span class="pill" id="act-n">0</span></div><div class="table-scroll" id="active"></div></div>
</div>
<div class="card"><div class="card-heading"><div><span class="small-label">STREAM</span><h2>${T('Zakończone żądania', 'Finished requests')}</h2></div>
<input id="feed-filter" placeholder="${T('filtr: użytkownik, urządzenie, komenda', 'filter: user, device, command')}"></div>
<div class="feed" id="feed"></div></div>`;
let paused = false;
$('#pause', v).addEventListener('click', (e) => { paused = !paused; e.target.textContent = paused ? T('Wznów', 'Resume') : T('Pauza', 'Pause'); });
const filter = $('#feed-filter', v);
const feed = $('#feed', v);
const row = (e, fresh) => `<div class="feed-row${fresh ? ' fresh' : ''}" data-s="${esc(`${e.user} ${e.device_id} ${e.device_type} ${e.command} ${e.ip}`.toLowerCase())}">
<span class="t">${esc(fmtTime(e.ts))}</span><span class="u" title="${esc(e.device_id)}">${esc(e.user)}</span>
<span><span class="cmd">${esc(e.command)}</span> ${esc(e.device_type)} <span class="${e.http >= 400 || e.status.startsWith('error') ? 'err' : 'fine'}">${esc(e.http)} ${esc(e.status)}</span>${e.items_out || e.items_in ? ` <span class="fine">↓${e.items_out} ↑${e.items_in}</span>` : ''}</span>
<span class="ms">${e.command === 'Ping' ? fmtDur(e.ms / 1000) : `${e.ms} ms`}</span><span class="fine">${fmtBytes(e.bytes_out)}</span></div>`;
const applyFilter = () => {
const q = filter.value.trim().toLowerCase();
$$('.feed-row', feed).forEach((r) => { r.hidden = q && !r.dataset.s.includes(q); });
};
filter.addEventListener('input', applyFilter);
const data = await api('GET', '/api/live');
feed.innerHTML = data.recent.map((e) => row(e, false)).join('') || `<div class="empty">${T('Brak żądań', 'No requests yet')}</div>`;
S.sse = new EventSource('/api/events');
S.sse.addEventListener('request', (m) => {
if (paused) return;
const e = JSON.parse(m.data);
$('.empty', feed)?.remove();
feed.insertAdjacentHTML('afterbegin', row(e, true));
while (feed.children.length > 400) feed.lastElementChild.remove();
applyFilter();
});
every(2000, async () => {
const d = await api('GET', '/api/live').catch(() => null);
if (!d) return;
$('#ping-n').textContent = d.pings.length;
$('#act-n').textContent = d.active.length;
$('#pings').innerHTML = d.pings.length ? `<table><thead><tr><th>${T('Użytkownik', 'User')}</th><th>${T('Urządzenie', 'Device')}</th><th>${T('Typ', 'Kind')}</th><th class="num">${T('Foldery', 'Folders')}</th><th>${T('Heartbeat', 'Heartbeat')}</th></tr></thead><tbody>${d.pings.sort((a, b) => a.started - b.started).map((p) => {
const el = d.now - p.started, pct = Math.min(100, (el / Math.max(1, p.lifetime)) * 100);
return `<tr><td>${esc(p.user)}</td><td title="${esc(p.device_id)}">${esc(p.device_type)}<span class="sub">${esc(p.device_id)}</span></td><td>${esc(p.kind)}</td><td class="num">${p.folders}</td><td>${fmtDur(el)} / ${fmtDur(p.lifetime)}<div class="countdown"><div data-w="${pct}"></div></div></td></tr>`;
}).join('')}</tbody></table>` : `<div class="empty">${T('Brak połączeń push', 'No push connections')}</div>`;
$('#active').innerHTML = d.active.length ? `<table><thead><tr><th>${T('Komenda', 'Command')}</th><th>${T('Użytkownik', 'User')}</th><th>${T('Urządzenie', 'Device')}</th><th>IP</th><th class="num">${T('Czas', 'Time')}</th></tr></thead><tbody>${d.active.map((a) => `<tr><td>${esc(a.command)}</td><td>${esc(a.user)}</td><td>${esc(a.device_type)}<span class="sub">AS ${esc(a.version)}</span></td><td>${esc(a.ip)}</td><td class="num">${fmtDur(d.now - a.started)}</td></tr>`).join('')}</tbody></table>` : `<div class="empty">${T('Brak', 'None')}</div>`;
applyWidths(v);
});
};
function deviceState(d) {
const p = [];
if (d.pinging) p.push(`<span class="pill live">PUSH</span>`);
if (d.blocked) p.push(`<span class="pill bad">${T('ZABLOKOWANE', 'BLOCKED')}</span>`);
if (!d.approved) p.push(`<span class="pill">${T('DO ZATWIERDZENIA', 'PENDING')}</span>`);
if (d.wipe_status === 2 || d.wipe_status === 4) p.push(`<span class="pill bad">WIPE ${d.wipe_status === 4 ? T('WYSŁANY', 'SENT') : T('CZEKA', 'PENDING')}</span>`);
if (d.wipe_status === 8) p.push(`<span class="pill bad">${T('WYMAZANE', 'WIPED')}</span>`);
if (!p.length) p.push(`<span class="pill ok">OK</span>`);
return p.join(' ');
}
PAGES.devices = async (v) => {
v.innerHTML = head(T('PARTNERSTWA', 'PARTNERSHIPS'), T('Urządzenia', 'Devices'), T('Jeden wiersz to jedno fizyczne urządzenie; skrzynki skonfigurowane na nim są widoczne jako osobne partnerstwa. Kliknij skrzynkę, aby zobaczyć foldery, historię i akcje (wymazanie, resynchronizacja, blokada).', 'One row per physical device; each mailbox configured on it is a separate partnership. Click a mailbox for folders, history and actions (wipe, resync, block).')) +
`<div class="card"><div class="toolbar"><input id="dev-q" placeholder="${T('Szukaj użytkownika, modelu, ID…', 'Search user, model, ID…')}">
<select id="dev-f"><option value="">${T('Wszystkie', 'All')}</option><option value="push">PUSH</option><option value="pending">${T('Do zatwierdzenia', 'Pending')}</option><option value="blocked">${T('Zablokowane', 'Blocked')}</option><option value="stale">${T('Nieaktywne >30 dni', 'Inactive >30 days')}</option></select>
<span class="fine" id="dev-n"></span></div><div class="table-scroll" id="dev-table"></div></div>`;
const q = $('#dev-q', v), f = $('#dev-f', v);
q.value = store.get('ap.devq', '');
const draw = () => {
store.set('ap.devq', q.value);
const s = q.value.trim().toLowerCase(), now = Date.now() / 1000;
const list = S.devices.filter((d) => {
const hay = `${d.username} ${d.device_id} ${d.device_type} ${d.model} ${d.friendly_name} ${d.os} ${d.user_agent} ${d.last_ip}`.toLowerCase();
if (s && !hay.includes(s)) return false;
if (f.value === 'push') return d.pinging;
if (f.value === 'pending') return !d.approved;
if (f.value === 'blocked') return d.blocked;
if (f.value === 'stale') return now - d.last_seen > 30 * 86400;
return true;
});
// one row per physical device (DeviceId); every mailbox on it is a partnership chip
const groups = new Map();
list.forEach((d) => {
if (!groups.has(d.device_id)) groups.set(d.device_id, []);
groups.get(d.device_id).push(d);
});
const rows = Array.from(groups.values()).map((g) => g.sort((a, b2) => b2.last_seen - a.last_seen))
.sort((a, b2) => b2[0].last_seen - a[0].last_seen);
const physical = new Set(S.devices.map((d) => d.device_id)).size;
$('#dev-n').textContent = T(`${rows.length} / ${physical} urządzeń · ${list.length} partnerstw`, `${rows.length} / ${physical} devices · ${list.length} partnerships`);
$('#dev-table').innerHTML = rows.length ? `<table><thead><tr><th>${T('Urządzenie', 'Device')}</th><th>${T('Skrzynki', 'Mailboxes')}</th><th>OS</th><th>AS</th><th>${T('Ostatnio', 'Last seen')}</th><th>IP</th><th class="num">${T('Żądania', 'Requests')}</th><th>${T('Stan', 'State')}</th></tr></thead><tbody>${rows.map((g) => {
const d = g[0];
const merged = { ...d, pinging: g.some((x) => x.pinging), blocked: g.some((x) => x.blocked), approved: g.every((x) => x.approved), wipe_status: Math.max(...g.map((x) => x.wipe_status)) };
return `<tr class="clickable" data-id="${d.id}">
<td>${esc(d.friendly_name || d.model || d.device_type)}<span class="sub">${esc(d.device_type)} · ${esc(d.device_id)}</span></td>
<td class="wrap">${g.map((x) => `<button class="chip" data-pid="${x.id}" title="${esc(ago(x.last_seen))}">${x.pinging ? '<span class="signal"></span>' : ''}${esc(x.username)}</button>`).join('')}</td>
<td>${esc(d.os || '-')}</td><td>${esc(d.as_version || '-')}</td><td title="${esc(fmtTs(d.last_seen))}">${esc(ago(d.last_seen))}<span class="sub">${esc(d.last_command)}</span></td>
<td>${esc(d.last_ip)}</td><td class="num">${fmtNum(g.reduce((a, x) => a + x.requests, 0))}</td><td>${deviceState(merged)}</td></tr>`;
}).join('')}</tbody></table>` : `<div class="empty">${T('Brak urządzeń', 'No devices')}</div>`;
$$('.chip[data-pid]', v).forEach((c) => c.addEventListener('click', (e) => { e.stopPropagation(); deviceDialog(Number(c.dataset.pid), load); }));
$$('tr.clickable', v).forEach((tr) => tr.addEventListener('click', () => deviceDialog(Number(tr.dataset.id), load)));
};
async function load() {
S.devices = await api('GET', '/api/devices');
const nc = $('#nav-dev-count');
if (nc) nc.textContent = new Set(S.devices.map((d) => d.device_id)).size;
draw();
}
q.addEventListener('input', draw);
f.addEventListener('change', draw);
every(10000, () => load().catch(() => {}));
};
async function deviceDialog(id, reload) {
const d = await api('GET', `/api/devices/${id}`);
const kv = [
[T('Użytkownik', 'User'), d.username], ['Device ID', d.device_id], [T('Typ', 'Type'), d.device_type], [T('Model', 'Model'), d.model],
[T('Nazwa', 'Friendly name'), d.friendly_name], ['OS', `${d.os} ${d.os_language}`], ['IMEI', d.imei], [T('Telefon', 'Phone'), d.phone_number],
[T('Operator', 'Operator'), d.mobile_operator], ['User-Agent', d.user_agent], ['ActiveSync', d.as_version],
[T('Polityka', 'Policy'), `${d.policy_name || '-'} (key ${d.policy_key || 0})`], [T('Pierwszy raz', 'First seen'), fmtTs(d.first_seen)],
[T('Ostatnio', 'Last seen'), `${fmtTs(d.last_seen)} · ${d.last_command} · ${d.last_ip}`], [T('Żądania', 'Requests'), fmtNum(d.requests)],
[T('Ping', 'Ping'), d.ping_lifetime ? `${d.ping_lifetime}s` : '-'],
['Wipe', d.wipe_requested_at ? `${T('zlecony', 'requested')} ${fmtTs(d.wipe_requested_at)} ${T('przez', 'by')} ${d.wipe_requested_by || ''}${d.wipe_completed_at ? ` · ${T('potwierdzony', 'acknowledged')} ${fmtTs(d.wipe_completed_at)}` : ''}` : '-'],
].filter(([, val]) => val && String(val).trim());
const folders = d.folders.length ? `<table><thead><tr><th>${T('Folder', 'Folder')}</th><th>${T('Klasa', 'Class')}</th><th class="num">${T('Elementy', 'Items')}</th><th class="num">↓</th><th class="num">↑</th><th>${T('Ostatnia sync.', 'Last sync')}</th><th class="num">${T('Okno', 'Window')}</th><th class="num">${T('Pętle', 'Loops')}</th></tr></thead><tbody>${d.folders.map((f) => `<tr><td>${esc(f.folder_name)}<span class="sub">id ${esc(f.collection)}</span></td><td>${esc(f.folder_class)}</td><td class="num">${fmtNum(f.item_count)}</td><td class="num">${fmtNum(f.items_out)}</td><td class="num">${fmtNum(f.items_in)}</td><td>${esc(ago(f.last_sync))}</td><td class="num">${f.window_size}</td><td class="num">${f.loop_hits ? `<span class="pill bad">${f.loop_hits}</span>` : 0}</td></tr>`).join('')}</tbody></table>` : `<div class="empty">${T('Brak zsynchronizowanych folderów', 'No synchronised folders')}</div>`;
const recent = d.recent.length ? `<div class="feed">${d.recent.map((e) => `<div class="feed-row"><span class="t">${esc(fmtTime(e.ts))}</span><span class="u">${esc(e.ip)}</span><span><span class="cmd">${esc(e.command)}</span> <span class="${e.http >= 400 ? 'err' : 'fine'}">${e.http} ${esc(e.status)}</span></span><span class="ms">${e.ms} ms</span><span class="fine">${fmtBytes(e.bytes_out)}</span></div>`).join('')}</div>` : `<div class="empty">-</div>`;
const btn = (act, label, cls = 'outline') => `<button class="${cls}" data-act="${act}">${esc(label)}</button>`;
const dlg = modal(`<div class="dialog-head"><div><span class="small-label">${T('URZĄDZENIE', 'DEVICE')} #${d.id}</span><h2>${esc(d.friendly_name || d.model || d.device_type)} - ${esc(d.username)}</h2><div>${deviceState(d)}</div></div><button class="quiet" data-close>✕</button></div>
<div class="actions-row">
${d.approved ? '' : btn('approve', T('Zatwierdź', 'Approve'), 'primary')}
${btn('resync', T('Resynchronizuj wszystko', 'Full resync'))}
${btn('reprovision', T('Wymuś provisioning', 'Force re-provision'))}
${d.blocked ? btn('unblock', T('Odblokuj', 'Unblock')) : btn('block', T('Zablokuj', 'Block'), 'danger')}
${d.wipe_status === 2 || d.wipe_status === 4 ? btn('cancel-wipe', T('Anuluj wymazanie', 'Cancel wipe')) : btn('wipe', T('Zdalnie wymaż urządzenie', 'Remote wipe device'), 'danger')}
${d.has_recovery_password ? btn('recovery-password', T('Hasło odzyskiwania', 'Recovery password'), 'quiet') : ''}
${btn('remove', T('Usuń partnerstwo', 'Remove partnership'), 'danger')}
</div>
<div class="grid two"><div><h3>${T('Szczegóły', 'Details')}</h3><dl class="kv">${kv.map(([k, val]) => `<dt>${esc(k)}</dt><dd>${esc(val)}</dd>`).join('')}</dl>
<h3 class="mt">${T('Notatki', 'Notes')}</h3><textarea id="dev-notes" maxlength="2000">${esc(d.notes)}</textarea><div class="form-actions"><button class="quiet" data-act="notes">${T('Zapisz notatkę', 'Save note')}</button></div></div>
<div><h3>${T('Ostatnie żądania', 'Recent requests')}</h3>${recent}</div></div>
<h3>${T('Foldery', 'Folders')}</h3><div class="table-scroll">${folders}</div>`);
$$('[data-act]', dlg).forEach((b) => b.addEventListener('click', async () => {
const act = b.dataset.act;
if (act === 'wipe' && !(await confirmBox(T('Zdalne wymazanie', 'Remote wipe'), T('Urządzenie przy następnym połączeniu wykona reset do ustawień fabrycznych i usunie WSZYSTKIE dane. Tej operacji nie można cofnąć po jej wykonaniu przez urządzenie.', 'On its next connection the device performs a factory reset and erases ALL data. This cannot be undone once the device executes it.'), true, d.device_id))) return;
if (act === 'remove' && !(await confirmBox(T('Usunąć partnerstwo?', 'Remove partnership?'), T('Stany synchronizacji zostaną usunięte; urządzenie zsynchronizuje się od nowa przy następnym połączeniu.', 'Sync states are removed; the device will resync from scratch on its next connection.'), true))) return;
if (act === 'block' && !(await confirmBox(T('Zablokować urządzenie?', 'Block device?'), T('Urządzenie otrzyma HTTP 403 przy każdej próbie synchronizacji.', 'The device receives HTTP 403 on every sync attempt.'), true))) return;
try {
if (act === 'recovery-password') {
const r = await api('POST', `/api/devices/${id}/recovery-password`);
toast(`${T('Hasło odzyskiwania', 'Recovery password')}: ${r.password}`);
return;
}
const body = act === 'notes' ? { notes: $('#dev-notes', dlg).value } : undefined;
await api('POST', `/api/devices/${id}/${act}`, body);
toast(T('Zapisano', 'Done'));
if (act === 'remove') { closeModal(); } else if (act !== 'notes') { deviceDialog(id, reload); }
reload && reload();
} catch (e) { toast(e.message, true); }
}));
}
PAGES.users = async (v) => {
v.innerHTML = head(T('KONTA', 'ACCOUNTS'), T('Skrzynki', 'Mailboxes'), T('Skrzynki (loginy) synchronizowane przez ActiveSync. Jedna osoba może mieć kilka skrzynek na tym samym telefonie - kolumna "Na tym samym urządzeniu" je łączy. Blokada działa natychmiast na wszystkich urządzeniach.', 'Mailboxes (logins) syncing through ActiveSync. One person may have several mailboxes on the same phone - the "Same device as" column links them. Blocking applies immediately on every device.')) +
`<div class="card"><div class="toolbar"><input id="u-q" placeholder="${T('Szukaj', 'Search')}"></div><div class="table-scroll" id="u-table"></div></div>
<div class="card" id="lock-card"><div class="card-heading"><div><span class="small-label">${T('OCHRONA', 'PROTECTION')}</span><h2>${T('Aktywne blokady logowania', 'Active login lockouts')}</h2></div></div><div id="locks"></div></div>`;
let users = [];
const draw = () => {
const s = $('#u-q', v).value.trim().toLowerCase();
const list = users.filter((u) => !s || u.username.includes(s));
$('#u-table').innerHTML = list.length ? `<table><thead><tr><th>${T('Skrzynka', 'Mailbox')}</th><th>${T('Urządzenia', 'Devices')}</th><th>${T('Na tym samym urządzeniu', 'Same device as')}</th><th class="num">PUSH</th><th class="num">${T('Żądania', 'Requests')}</th><th>${T('Ostatnio', 'Last seen')}</th><th>${T('Polityka', 'Policy')}</th><th>${T('Stan', 'State')}</th><th></th></tr></thead><tbody>${list.map((u) => `<tr><td>${esc(u.username)}</td>
<td class="wrap">${esc((u.device_names || '-').split(',').join(', '))}<span class="sub">${u.devices} ${T('partnerstw', 'partnership(s)')}</span></td>
<td class="wrap">${u.same_device ? u.same_device.split(',').map((x) => `<span class="chip">${esc(x)}</span>`).join('') : '<span class="fine">-</span>'}</td>
<td class="num">${u.pings}</td><td class="num">${fmtNum(u.requests)}</td><td>${esc(ago(u.last_seen))}</td><td>${esc(u.policy)}</td><td>${u.blocked ? `<span class="pill bad">${T('ZABLOKOWANA', 'BLOCKED')}</span>` : '<span class="pill ok">OK</span>'}</td>
<td><button class="${u.blocked ? 'outline' : 'danger'}" data-u="${esc(u.username)}" data-b="${u.blocked ? 0 : 1}">${u.blocked ? T('Odblokuj', 'Unblock') : T('Zablokuj', 'Block')}</button> <button class="quiet" data-dev="${esc(u.username)}">${T('Urządzenia', 'Devices')}</button></td></tr>`).join('')}</tbody></table>` : `<div class="empty">${T('Brak skrzynek', 'No mailboxes')}</div>`;
$$('[data-u]', v).forEach((b) => b.addEventListener('click', async () => {
const name = b.dataset.u, block = b.dataset.b === '1';
if (block && !(await confirmBox(T('Zablokować skrzynkę?', 'Block mailbox?'), name, true))) return;
const s2 = await loadSettings();
const set = new Set(s2.security.blocked_users.map((x) => x.toLowerCase()));
if (block) set.add(name.toLowerCase()); else set.delete(name.toLowerCase());
s2.security.blocked_users = Array.from(set);
await saveSettings(s2);
load();
}));
$$('[data-dev]', v).forEach((b) => b.addEventListener('click', () => { store.set('ap.devq', b.dataset.dev); location.hash = 'devices'; }));
};
async function load() {
users = await api('GET', '/api/users');
draw();
const locks = await api('GET', '/api/lockouts');
$('#locks').innerHTML = locks.length ? `<table><thead><tr><th>${T('Klucz', 'Key')}</th><th>${T('Do', 'Until')}</th><th></th></tr></thead><tbody>${locks.map((l) => `<tr><td>${esc(l.key)}</td><td>${esc(fmtTs(l.until))}</td><td><button class="outline" data-unlock="${esc(l.key)}">${T('Odblokuj', 'Unlock')}</button></td></tr>`).join('')}</tbody></table>` : `<div class="empty">${T('Brak blokad', 'No lockouts')}</div>`;
$$('[data-unlock]', v).forEach((b) => b.addEventListener('click', async () => { await api('POST', '/api/lockouts/unlock', { key: b.dataset.unlock }); load(); }));
}
$('#u-q', v).addEventListener('input', draw);
every(15000, () => load().catch(() => {}));
};
// ------------------------------------------------------------------ settings forms
async function loadSettings() {
const r = await api('GET', '/api/settings');
S.settings = r.settings;
S.timezones = r.timezones;
return JSON.parse(JSON.stringify(r.settings));
}
async function saveSettings(s) {
const r = await api('PUT', '/api/settings', s);
toast(T(`Zapisano i zastosowano (rewizja ${r.revision})`, `Saved and applied (revision ${r.revision})`));
return loadSettings();
}
const getP = (o, p) => p.split('.').reduce((a, k) => (a == null ? a : a[k]), o);
function setP(o, p, val) {
const ks = p.split('.');
let c = o;
ks.slice(0, -1).forEach((k) => { c = c[k]; });
c[ks[ks.length - 1]] = val;
}
/** field: [path, label, type, opts] type: text|number|bool|select|list|secret|tz|textarea */
function fieldHtml(s, f) {
const [path, label, type, opt = {}] = f;
const val = getP(s, path);
const id = `f-${path.replace(/\./g, '-')}`;
const help = opt.help ? `<div class="help">${esc(opt.help)}</div>` : '';
const wide = opt.wide || type === 'list' || type === 'textarea' ? ' wide' : '';
if (type === 'bool') return `<div class="field${wide}"><label class="check"><input type="checkbox" id="${id}" data-path="${path}" data-type="bool" ${val ? 'checked' : ''}> ${esc(label)}</label>${help}</div>`;
let input;
if (type === 'select') input = `<select id="${id}" data-path="${path}" data-type="${opt.num ? 'number' : 'text'}">${opt.options.map(([k, l]) => `<option value="${esc(k)}" ${String(val) === String(k) ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select>`;
else if (type === 'tz') input = `<input id="${id}" data-path="${path}" data-type="text" list="tz-list" value="${esc(val)}"><datalist id="tz-list">${S.timezones.map((t) => `<option value="${esc(t)}">`).join('')}</datalist>`;
else if (type === 'list') input = `<textarea id="${id}" data-path="${path}" data-type="list" spellcheck="false" placeholder="${esc(opt.ph || T('jedna wartość w linii', 'one value per line'))}">${esc((val || []).join('\n'))}</textarea>`;
else if (type === 'secret') input = `<input id="${id}" type="password" data-path="${path}" data-type="secret" autocomplete="new-password" placeholder="${val === '__SET__' ? T('•••••• (ustawione - zostaw puste, by nie zmieniać)', '•••••• (set - leave empty to keep)') : ''}">`;
else if (type === 'number') input = `<input id="${id}" type="number" data-path="${path}" data-type="number" value="${esc(val)}" ${opt.min != null ? `min="${opt.min}"` : ''} ${opt.max != null ? `max="${opt.max}"` : ''}>`;
else input = `<input id="${id}" data-path="${path}" data-type="text" value="${esc(val)}" spellcheck="false" ${opt.ph ? `placeholder="${esc(opt.ph)}"` : ''}>`;
return `<div class="field${wide}"><label for="${id}">${esc(label)}</label>${input}${help}</div>`;
}
function collect(root, s) {
$$('[data-path]', root).forEach((el) => {
const p = el.dataset.path, t = el.dataset.type;
let val;
if (t === 'bool') val = el.checked;
else if (t === 'number') val = el.value === '' ? 0 : Number(el.value);
else if (t === 'list') val = el.value.split('\n').map((x) => x.trim()).filter(Boolean);
else if (t === 'secret') val = el.value === '' ? (getP(S.settings, p) === '__SET__' ? '__SET__' : '') : el.value;
else val = el.value.trim();
setP(s, p, val);
});
return s;
}
function section(s, title, desc, fields, extra = '') {
return `<div class="card"><div class="section-title"><div><h2>${esc(title)}</h2>${desc ? `<p class="fine">${esc(desc)}</p>` : ''}</div></div><div class="form-grid">${fields.map((f) => fieldHtml(s, f)).join('')}</div>${extra}</div>`;
}
async function settingsPage(v, eyebrow, title, desc, build, after) {
const s = await loadSettings();
v.innerHTML = head(eyebrow, title, desc, `<span class="dirty-flag" id="dirty" hidden>● ${T('niezapisane zmiany', 'unsaved changes')}</span><button class="quiet" id="reset">${T('Cofnij', 'Revert')}</button><button class="primary" id="save">${T('Zapisz i zastosuj', 'Save & apply')}</button>`) + `<div class="stack" id="form">${build(s)}</div>`;
const form = $('#form', v);
form.addEventListener('input', () => { $('#dirty').hidden = false; });
form.addEventListener('change', () => { $('#dirty').hidden = false; });
$('#reset', v).addEventListener('click', () => go());
$('#save', v).addEventListener('click', async (e) => {
e.target.disabled = true;
try {
const cur = JSON.parse(JSON.stringify(S.settings));
collect(form, cur);
if (after?.collect) after.collect(form, cur);
await saveSettings(cur);
go();
} catch (err) { toast(err.message, true); } finally { e.target.disabled = false; }
});
after?.bind?.(form, s);
}
function testBlock(kind) {
return `<div class="test-row"><div><label>${T('Login testowy', 'Test login')}</label><input data-test-user="${kind}" autocomplete="off" placeholder="user@example.com"></div>
<div><label>${T('Hasło testowe (nie jest zapisywane)', 'Test password (not stored)')}</label><input type="password" data-test-pass="${kind}" autocomplete="new-password"></div>
<button class="outline" data-test="${kind}">${T('Testuj połączenie', 'Test connection')}</button></div><div class="result" data-result="${kind}" hidden></div>`;
}
function bindTests(form) {
$$('[data-test]', form).forEach((b) => b.addEventListener('click', async () => {
const k = b.dataset.test;
const out = $(`[data-result="${k}"]`, form);
out.hidden = false; out.className = 'result'; out.textContent = T('Testowanie…', 'Testing…');
b.disabled = true;
try {
const cur = collect(form, JSON.parse(JSON.stringify(S.settings)));
const r = await api('POST', `/api/test/${k}`, { login: $(`[data-test-user="${k}"]`, form).value, password: $(`[data-test-pass="${k}"]`, form).value, settings: cur });
out.classList.add(r.ok ? 'ok' : 'bad');
out.textContent = r.message;
} catch (e) { out.classList.add('bad'); out.textContent = e.message; } finally { b.disabled = false; }
}));
}
const TLS = () => [['none', T('brak (tylko localhost)', 'none (localhost only)')], ['start_tls', 'STARTTLS'], ['tls', T('TLS (implicit)', 'TLS (implicit)')]];
PAGES.backends = (v) => settingsPage(v, T('ŹRÓDŁA DANYCH', 'DATA SOURCES'), T('Backendy', 'Backends'), T('Poczta przez IMAP/SMTP, kalendarze i zadania przez CalDAV, kontakty przez CardDAV - odpowiednik backendu "combined" z Z-Push.', 'Mail via IMAP/SMTP, calendars and tasks via CalDAV, contacts via CardDAV - the Z-Push "combined" backend.'), (s) =>
section(s, 'IMAP', T('Serwer poczty przychodzącej (np. Dovecot). Uwierzytelnianie urządzeń odbywa się loginem IMAP.', 'Incoming mail server (e.g. Dovecot). Devices authenticate with their IMAP login.'), [
['imap.host', T('Host', 'Host'), 'text'], ['imap.port', T('Port', 'Port'), 'number', { min: 1, max: 65535 }],
['imap.security', T('Szyfrowanie', 'Encryption'), 'select', { options: TLS() }], ['imap.verify_tls', T('Weryfikuj certyfikat', 'Verify certificate'), 'bool'],
['imap.folder_prefix', T('Prefiks folderów', 'Folder prefix'), 'text', { ph: 'INBOX.' }],
['imap.use_special_use', T('Rozpoznawaj foldery po SPECIAL-USE', 'Detect folders via SPECIAL-USE'), 'bool'],
['imap.folder_inbox', 'Inbox', 'text'], ['imap.folder_sent', T('Wysłane', 'Sent'), 'text'], ['imap.folder_drafts', T('Szkice', 'Drafts'), 'text'],
['imap.folder_trash', T('Kosz', 'Trash'), 'text'], ['imap.folder_spam', 'Spam', 'text'], ['imap.folder_archive', T('Archiwum', 'Archive'), 'text'],
['imap.excluded_folders', T('Ukryte foldery', 'Hidden folders'), 'list', { ph: 'Shared/*\nPublic' }],
['imap.autoseen_on_delete', T('Oznacz jako przeczytane przed usunięciem', 'Mark read before delete'), 'bool'],
['imap.inline_forward', T('Przekazywanie inline (zamiast załącznika .eml)', 'Inline forward (instead of .eml)'), 'bool'],
['imap.timeout_secs', T('Timeout (s)', 'Timeout (s)'), 'number', { min: 5 }], ['imap.pool_idle_secs', T('Czas życia połączeń w puli (s)', 'Pool idle time (s)'), 'number'],
['imap.pool_max_per_user', T('Maks. połączeń w puli / użytkownika', 'Max pooled per user'), 'number', { min: 0, max: 20 }],
], testBlock('imap')) +
section(s, 'SMTP', T('Wysyłka poczty z urządzeń (SendMail / SmartReply / SmartForward).', 'Outgoing mail from devices (SendMail / SmartReply / SmartForward).'), [
['smtp.method', T('Metoda', 'Method'), 'select', { options: [['smtp', 'SMTP'], ['sendmail', 'sendmail']] }],
['smtp.host', 'Host', 'text'], ['smtp.port', 'Port', 'number'], ['smtp.security', T('Szyfrowanie', 'Encryption'), 'select', { options: TLS() }],
['smtp.verify_tls', T('Weryfikuj certyfikat', 'Verify certificate'), 'bool'],
['smtp.auth', T('Uwierzytelnianie', 'Authentication'), 'select', { options: [['user_credentials', T('dane użytkownika urządzenia', 'device user credentials')], ['static', T('stałe konto relay', 'static relay account')], ['none', T('brak', 'none')]] }],
['smtp.username', T('Login relay', 'Relay username'), 'text'], ['smtp.password', T('Hasło relay', 'Relay password'), 'secret'],
['smtp.sendmail_path', T('Ścieżka sendmail', 'sendmail path'), 'text'], ['smtp.timeout_secs', 'Timeout (s)', 'number'],
['smtp.max_message_mb', T('Maks. rozmiar wiadomości (MB)', 'Max message size (MB)'), 'number'],
['smtp.save_in_sent', T('Zapisuj kopię w Wysłanych', 'Save copy to Sent'), 'bool'],
['smtp.enforce_from', T('Wymuszaj adres nadawcy = adres konta', 'Force sender = account address'), 'bool', { help: T('Zapobiega podszywaniu się pod inne adresy.', 'Prevents sender spoofing.') }],
], testBlock('smtp')) +
section(s, 'CalDAV', T('Kalendarze i zadania (VEVENT/VTODO). Placeholders: %u login, %l część lokalna, %d domena.', 'Calendars and tasks (VEVENT/VTODO). Placeholders: %u login, %l local part, %d domain.'), [
['caldav.enabled', T('Włączony', 'Enabled'), 'bool'], ['caldav.base_url', 'URL', 'text', { ph: 'https://dav.example.com' }],
['caldav.path', T('Ścieżka kalendarzy', 'Calendar home path'), 'text', { ph: '/dav/calendars/%u/' }], ['caldav.verify_tls', T('Weryfikuj certyfikat', 'Verify certificate'), 'bool'],
['caldav.default_collection', T('Domyślny kalendarz', 'Default calendar'), 'text'], ['caldav.include_regex', T('Tylko kolekcje pasujące do regex', 'Include regex'), 'text'],
['caldav.exclude_regex', T('Pomiń kolekcje pasujące do regex', 'Exclude regex'), 'text'], ['caldav.timeout_secs', 'Timeout (s)', 'number'],
], testBlock('caldav')) +
section(s, 'CardDAV', T('Kontakty oraz globalna książka adresowa (GAL) do wyszukiwania i rozwiązywania odbiorców.', 'Contacts plus a global address list (GAL) for search and recipient resolution.'), [
['carddav.enabled', T('Włączony', 'Enabled'), 'bool'], ['carddav.base_url', 'URL', 'text'],
['carddav.path', T('Ścieżka książek adresowych', 'Address book home path'), 'text', { ph: '/dav/addressbooks/%u/' }], ['carddav.verify_tls', T('Weryfikuj certyfikat', 'Verify certificate'), 'bool'],
['carddav.default_collection', T('Domyślna książka', 'Default book'), 'text'], ['carddav.gal_path', T('Ścieżka GAL', 'GAL path'), 'text', { ph: '/dav/addressbooks/%d/gal/' }],
['carddav.gal_min_length', T('Min. długość zapytania GAL', 'GAL min query length'), 'number'], ['carddav.max_picture_kb', T('Maks. zdjęcie kontaktu (KB)', 'Max contact picture (KB)'), 'number'],
['carddav.include_regex', 'Include regex', 'text'], ['carddav.exclude_regex', 'Exclude regex', 'text'], ['carddav.timeout_secs', 'Timeout (s)', 'number'],
], testBlock('carddav')), { bind: bindTests });
PAGES.activesync = (v) => settingsPage(v, 'PROTOCOL', 'ActiveSync', T('Logowanie, protokół, synchronizacja, push i Autodiscover.', 'Login, protocol, synchronisation, push and Autodiscover.'), (s) =>
section(s, T('Ogólne', 'General'), '', [
['general.server_name', T('Nazwa serwera', 'Server name'), 'text'], ['general.default_timezone', T('Domyślna strefa czasowa', 'Default time zone'), 'tz'],
['general.login_format', T('Format loginu', 'Login format'), 'select', { options: [['as_given', T('jak podany przez urządzenie', 'as given by device')], ['local_part', T('tylko część lokalna (bez @domena)', 'local part only')], ['full_email', T('pełny e-mail (dopisz domenę)', 'full e-mail (append domain)')]], help: T('Odpowiednik USE_FULLEMAIL_FOR_LOGIN. Prefiks DOMENA\\ jest zawsze usuwany.', 'Equivalent of USE_FULLEMAIL_FOR_LOGIN. DOMAIN\\ prefixes are always stripped.') }],
['general.default_domain', T('Domyślna domena', 'Default domain'), 'text'],
['general.email_template', T('Szablon adresu e-mail', 'E-mail address template'), 'text', { help: '{login} {local} {domain}' }],
['general.max_protocol', T('Najwyższa wersja protokołu', 'Highest protocol version'), 'select', { options: ['14.1', '14.0', '12.1', '12.0', '2.5'].map((x) => [x, x]) }],
['general.allowed_domains', T('Dozwolone domeny (puste = wszystkie)', 'Allowed domains (empty = all)'), 'list'],
]) +
section(s, T('Synchronizacja', 'Synchronisation'), '', [
['sync.max_items', T('Maks. elementów w paczce (SYNC_MAX_ITEMS)', 'Max items per batch'), 'number', { min: 1, max: 2000 }],
['sync.filter_time_max', T('Maks. zakres czasu', 'Max time window'), 'select', { num: true, options: [[0, T('bez limitu', 'unlimited')], [1, T('1 dzień', '1 day')], [2, T('3 dni', '3 days')], [3, T('1 tydzień', '1 week')], [4, T('2 tygodnie', '2 weeks')], [5, T('1 miesiąc', '1 month')], [6, T('3 miesiące', '3 months')], [7, T('6 miesięcy', '6 months')]] }],
['sync.server_wins', T('Konflikty: wygrywa serwer', 'Conflicts: server wins'), 'bool'], ['sync.loop_detection', T('Wykrywanie pętli synchronizacji', 'Sync loop detection'), 'bool'],
['sync.max_attachment_mb', T('Maks. załącznik (MB)', 'Max attachment (MB)'), 'number'], ['sync.max_request_mb', T('Maks. żądanie (MB)', 'Max request (MB)'), 'number'],
['sync.heartbeat_min', T('Hanging Sync min (s)', 'Hanging Sync min (s)'), 'number'], ['sync.heartbeat_max', T('Hanging Sync max (s)', 'Hanging Sync max (s)'), 'number'],
['sync.search_wait_secs', T('Limit czasu wyszukiwania (s)', 'Search time limit (s)'), 'number'], ['sync.search_max_results', T('Maks. wyników wyszukiwania', 'Max search results'), 'number'],
['sync.retry_after_secs', 'Retry-After (s)', 'number'],
]) +
section(s, 'Push (Ping)', T('Urządzenia trzymają otwarte połączenie; serwer sprawdza zmiany w backendzie co zadany interwał.', 'Devices keep a connection open; the server checks the backend for changes every interval.'), [
['ping.check_interval_secs', T('Interwał sprawdzania (s)', 'Check interval (s)'), 'number', { min: 5 }],
['ping.lifetime_min', T('Min. heartbeat (s)', 'Min heartbeat (s)'), 'number'], ['ping.lifetime_max', T('Maks. heartbeat (s)', 'Max heartbeat (s)'), 'number'],
['ping.max_folders', T('Maks. folderów', 'Max folders'), 'number'],
]) +
section(s, 'Autodiscover', T('Automatyczna konfiguracja kont na telefonach (/autodiscover/autodiscover.xml).', 'Automatic account configuration (/autodiscover/autodiscover.xml).'), [
['autodiscover.enabled', T('Włączony', 'Enabled'), 'bool'], ['autodiscover.require_auth', T('Wymagaj poprawnego hasła', 'Require valid password'), 'bool'],
['autodiscover.server_url', T('Publiczny URL ActiveSync', 'Public ActiveSync URL'), 'text', { wide: true, ph: 'https://mail.example.com/Microsoft-Server-ActiveSync', help: T('Puste = wyznaczony z nagłówka Host.', 'Empty = derived from the Host header.') }],
]) +
section(s, T('Logowanie i retencja', 'Logging & retention'), '', [
['logging.log_wbxml', T('Loguj zdekodowany WBXML (debug, zawiera treść poczty!)', 'Log decoded WBXML (debug, contains mail data!)'), 'bool'],
['logging.debug_users', T('Użytkownicy z logowaniem debug', 'Debug users'), 'list'],
['logging.ring_size', T('Linii logu w panelu', 'Log lines in panel'), 'number'], ['logging.stats_retention_days', T('Retencja statystyk (dni)', 'Stats retention (days)'), 'number'],
['logging.audit_retention_days', T('Retencja audytu (dni)', 'Audit retention (days)'), 'number'],
['panel.device_inactive_days', T('Usuwaj urządzenia nieaktywne (dni, 0 = nigdy)', 'Remove inactive devices (days, 0 = never)'), 'number'],
]));
const POLICY_FIELDS = () => [
['devpwenabled', T('Wymagaj hasła urządzenia', 'Require device password'), 'bool'], ['alphanumpwreq', T('Hasło alfanumeryczne', 'Alphanumeric password'), 'bool'],
['allowsimpledevpw', T('Pozwól na proste hasła', 'Allow simple passwords'), 'bool'], ['mindevpwlenngth', T('Min. długość hasła', 'Min password length'), 'number'],
['mindevcomplexchars', T('Min. znaków złożonych', 'Min complex characters'), 'number'], ['maxinacttimedevlock', T('Blokada po bezczynności (s)', 'Lock after inactivity (s)'), 'number'],
['maxdevpwfailedattempts', T('Wymaż po N błędnych hasłach', 'Wipe after N failed passwords'), 'number'], ['devpwexpiration', T('Wygaśnięcie hasła (dni)', 'Password expiration (days)'), 'number'],
['devpwhistory', T('Historia haseł', 'Password history'), 'number'], ['pwrecoveryenabled', T('Odzyskiwanie hasła', 'Password recovery'), 'bool'],
['devencenabled', T('Szyfrowanie urządzenia', 'Device encryption'), 'bool'], ['reqdevenc', T('Wymagaj szyfrowania', 'Require encryption'), 'bool'],
['attenabled', T('Załączniki dozwolone', 'Attachments allowed'), 'bool'], ['maxattsize', T('Maks. załącznik (bajty, -1 = bez limitu)', 'Max attachment (bytes, -1 unlimited)'), 'number'],
['allowhtmlemail', T('Poczta HTML', 'HTML email'), 'bool'], ['maxemailagefilter', T('Maks. wiek poczty (0-5)', 'Max email age filter'), 'number'],
['maxcalagefilter', T('Maks. wiek kalendarza (0,4-7)', 'Max calendar age filter'), 'number'], ['maxemailbodytruncsize', T('Obcięcie treści (bajty, -1)', 'Body truncation (bytes, -1)'), 'number'],
['maxemailhtmlbodytruncsize', T('Obcięcie HTML (bajty, -1)', 'HTML truncation (bytes, -1)'), 'number'], ['allowstoragecard', T('Karta pamięci', 'Storage card'), 'bool'],
['allowcam', T('Aparat', 'Camera'), 'bool'], ['allowwifi', 'Wi-Fi', 'bool'], ['allowbluetooth', 'Bluetooth (0/1/2)', 'number'], ['allowirda', 'IrDA', 'bool'],
['allowtextmessaging', 'SMS', 'bool'], ['allowpopimapemail', 'POP/IMAP', 'bool'], ['allowbrowser', T('Przeglądarka', 'Browser'), 'bool'],
['allowconsumeremail', T('Prywatna poczta', 'Consumer email'), 'bool'], ['allowinternetsharing', T('Udostępnianie internetu', 'Internet sharing'), 'bool'],
['allowremotedesktop', T('Pulpit zdalny', 'Remote desktop'), 'bool'], ['allowdesktopsync', T('Synchronizacja z PC', 'Desktop sync'), 'bool'],
['allowunsignedapps', T('Niepodpisane aplikacje', 'Unsigned apps'), 'bool'], ['allowunsignedinstallationpackages', T('Niepodpisane pakiety', 'Unsigned packages'), 'bool'],
['reqmansyncroam', T('Ręczna synchronizacja w roamingu', 'Manual sync when roaming'), 'bool'], ['reqsignedsmimemessages', T('Wymagaj podpisu S/MIME', 'Require signed S/MIME'), 'bool'],
['reqencsmimemessages', T('Wymagaj szyfrowania S/MIME', 'Require encrypted S/MIME'), 'bool'], ['allowsmimesoftcerts', T('Certyfikaty programowe S/MIME', 'S/MIME soft certs'), 'bool'],
];
PAGES.policies = async (v) => {
let sel = 0;
await settingsPage(v, 'PROVISIONING', T('Polityki urządzeń', 'Device policies'), T('Polityki bezpieczeństwa przekazywane urządzeniom (odpowiednik policies.ini). Zmiana polityki wymusza ponowny provisioning urządzeń.', 'Security policies pushed to devices (policies.ini equivalent). Changing a policy forces devices to re-provision.'), (s) => {
const pols = s.provisioning.policies;
return section(s, 'Provisioning', '', [
['provisioning.enabled', T('Wymuszaj provisioning', 'Enforce provisioning'), 'bool'],
['provisioning.loose', T('Luźny provisioning (urządzenia bez obsługi polityk)', 'Loose provisioning (devices without policy support)'), 'bool'],
['provisioning.default_policy', T('Domyślna polityka', 'Default policy'), 'select', { options: pols.map((p) => [p.name, p.name]) }],
]) + `<div class="card"><div class="section-title"><h2>${T('Polityka', 'Policy')}</h2><div class="top-actions"><select id="pol-sel">${pols.map((p, i) => `<option value="${i}">${esc(p.name)}</option>`).join('')}</select><button class="quiet" id="pol-add">${T('Duplikuj', 'Duplicate')}</button><button class="danger" id="pol-del">${T('Usuń', 'Delete')}</button></div></div><div id="pol-form"></div></div>
<div class="card"><div class="section-title"><div><h2>${T('Przypisania użytkowników', 'User assignments')}</h2><p class="fine">${T('Jedna linia: login=nazwa_polityki', 'One per line: login=policy_name')}</p></div></div><textarea id="pol-users" spellcheck="false">${esc(s.provisioning.user_policies.map((u) => `${u.username}=${u.policy}`).join('\n'))}</textarea></div>`;
}, {
bind(form, s) {
const pf = $('#pol-form', form);
const drawPol = () => {
const p = s.provisioning.policies[sel];
pf.innerHTML = `<div class="form-grid">${fieldHtml({ p }, ['p.name', T('Nazwa', 'Name'), 'text'])}${POLICY_FIELDS().map((f) => fieldHtml({ p }, [`p.${f[0]}`, f[1], f[2]])).join('')}</div>`;
$$('[data-path]', pf).forEach((el) => { el.dataset.pol = el.dataset.path.slice(2); el.removeAttribute('data-path'); });
};
const keep = () => {
const p = s.provisioning.policies[sel];
$$('[data-pol]', pf).forEach((el) => {
const k = el.dataset.pol;
p[k] = el.type === 'checkbox' ? el.checked : el.type === 'number' ? Number(el.value) : el.value.trim();
});
};
$('#pol-sel', form).addEventListener('change', (e) => { keep(); sel = Number(e.target.value); drawPol(); });
$('#pol-add', form).addEventListener('click', () => {
keep();
const c = JSON.parse(JSON.stringify(s.provisioning.policies[sel]));
c.name = `${c.name}-copy`;
s.provisioning.policies.push(c);
sel = s.provisioning.policies.length - 1;
$('#pol-sel', form).insertAdjacentHTML('beforeend', `<option value="${sel}">${esc(c.name)}</option>`);
$('#pol-sel', form).value = String(sel);
drawPol();
$('#dirty').hidden = false;
});
$('#pol-del', form).addEventListener('click', () => {
if (s.provisioning.policies.length < 2) { toast(T('Musi zostać przynajmniej jedna polityka', 'At least one policy is required'), true); return; }
s.provisioning.policies.splice(sel, 1);
sel = 0;
$('#pol-sel', form).innerHTML = s.provisioning.policies.map((p, i) => `<option value="${i}">${esc(p.name)}</option>`).join('');
drawPol();
$('#dirty').hidden = false;
});
drawPol();
this.keep = keep;
this.s = s;
},
collect(form, cur) {
this.keep();
cur.provisioning.policies = this.s.provisioning.policies;
cur.provisioning.user_policies = $('#pol-users', form).value.split('\n').map((l) => l.trim()).filter((l) => l.includes('=')).map((l) => { const [u, p] = l.split('='); return { username: u.trim(), policy: p.trim() }; });
},
});
};
PAGES.security = async (v) => {
await settingsPage(v, 'HARDENING', T('Bezpieczeństwo', 'Security'), T('Ochrona przed zgadywaniem haseł, kontrola dostępu sieciowego, urządzeń i panelu.', 'Brute-force protection, network/device access control and panel security.'), (s) =>
section(s, T('Ochrona logowania', 'Login protection'), T('Po przekroczeniu limitu konto (i IP przy 3× limicie) jest blokowane. Linia logu jest zgodna z fail2ban.', 'Exceeding the limit locks the account (and the IP at 3× the limit). The log line is fail2ban friendly.'), [
['security.auth_fail_max', T('Maks. błędnych logowań', 'Max failed logins'), 'number'], ['security.auth_fail_window_secs', T('Okno (s)', 'Window (s)'), 'number'],
['security.lockout_secs', T('Czas blokady (s)', 'Lockout (s)'), 'number'], ['security.auth_cache_secs', T('Cache poprawnego logowania (s)', 'Successful login cache (s)'), 'number', { help: T('Przechowywany jest tylko HMAC, nigdy hasło.', 'Only an HMAC is kept, never the password.') }],
['security.log_auth_failures', T('Loguj nieudane logowania', 'Log failed logins'), 'bool'],
]) +
section(s, T('Sieć', 'Network'), '', [
['security.trusted_proxies', T('Zaufane proxy (CIDR)', 'Trusted proxies (CIDR)'), 'list', { help: T('Tylko od nich akceptowany jest nagłówek z prawdziwym IP klienta.', 'Only these may set the real client IP header.') }],
['security.real_ip_header', T('Nagłówek z IP klienta', 'Client IP header'), 'text'],
['security.eas_allowed_networks', T('Sieci z dostępem do ActiveSync (puste = wszystkie)', 'Networks allowed to use ActiveSync (empty = all)'), 'list'],
['panel.allowed_networks', T('Sieci z dostępem do panelu (puste = wszystkie)', 'Networks allowed to use the panel (empty = all)'), 'list'],
]) +
section(s, T('Użytkownicy i urządzenia', 'Users & devices'), '', [
['security.require_device_approval', T('Nowe urządzenia wymagają zatwierdzenia', 'New devices require approval'), 'bool'],
['security.allowed_users', T('Tylko ci użytkownicy (puste = wszyscy)', 'Only these users (empty = all)'), 'list'],
['security.blocked_users', T('Zablokowani użytkownicy', 'Blocked users'), 'list'],
['security.blocked_device_types', T('Zablokowane typy urządzeń / User-Agent', 'Blocked device types / User-Agent'), 'list', { ph: 'WindowsMail\nOutlook' }],
]) +
section(s, T('Panel', 'Panel'), '', [
['panel.session_ttl_minutes', T('Wygasanie sesji (min)', 'Session lifetime (min)'), 'number'],
['panel.prometheus_token', T('Token Prometheus (/metrics)', 'Prometheus token (/metrics)'), 'secret', { help: T('Puste = endpoint wyłączony. Użycie: Authorization: Bearer <token>', 'Empty = endpoint disabled. Use: Authorization: Bearer <token>') }],
]) +
`<div class="card"><div class="section-title"><h2>${T('Konto administratora', 'Administrator account')}</h2></div>
<div class="form-grid"><div class="field"><label>${T('Obecne hasło', 'Current password')}</label><input type="password" id="pw-cur" autocomplete="current-password"></div>
<div class="field"><label>${T('Nowe hasło (min. 12 znaków)', 'New password (min. 12 chars)')}</label><input type="password" id="pw-new" autocomplete="new-password"></div>
<div class="field"><label>${T('Powtórz', 'Repeat')}</label><input type="password" id="pw-new2" autocomplete="new-password"></div></div>
<div class="form-actions"><button class="quiet" id="revoke">${T('Wyloguj inne sesje', 'Sign out other sessions')}</button><button class="outline" id="pw-save">${T('Zmień hasło', 'Change password')}</button></div></div>`,
{
bind(form) {
$('#pw-save', form).addEventListener('click', async () => {
const n = $('#pw-new', form).value;
if (n !== $('#pw-new2', form).value) { toast(T('Hasła się różnią', 'Passwords differ'), true); return; }
try { await api('POST', '/api/password', { current: $('#pw-cur', form).value, new: n }); toast(T('Hasło zmienione', 'Password changed')); $$('#pw-cur,#pw-new,#pw-new2', form).forEach((i) => { i.value = ''; }); } catch (e) { toast(e.message, true); }
});
$('#revoke', form).addEventListener('click', async () => { await api('POST', '/api/sessions/revoke-others'); toast(T('Inne sesje wylogowane', 'Other sessions revoked')); });
// password fields are not settings
$$('#pw-cur,#pw-new,#pw-new2', form).forEach((i) => i.addEventListener('input', (e) => e.stopPropagation()));
},
});
};
PAGES.logs = async (v) => {
v.innerHTML = head(T('DIAGNOSTYKA', 'DIAGNOSTICS'), T('Logi i audyt', 'Logs & audit'), T('Ostatnie wpisy logu serwera oraz dziennik działań administracyjnych.', 'Recent server log lines and the administrative audit trail.')) +
`<div class="tabs"><button class="tab active" data-tab="log">${T('Log serwera', 'Server log')}</button><button class="tab" data-tab="audit">${T('Audyt', 'Audit')}</button></div>
<div class="card" id="tab-log"><div class="toolbar"><select id="lv"><option value="">${T('wszystkie', 'all')}</option><option value="INFO">INFO+</option><option value="WARN">WARN+</option><option value="ERROR">ERROR</option></select><input id="lq" placeholder="${T('szukaj', 'search')}"><label class="check"><input type="checkbox" id="lf" checked> ${T('auto-odświeżanie', 'auto refresh')}</label></div><div class="feed" id="logbox"></div></div>
<div class="card" id="tab-audit" hidden><div class="table-scroll" id="auditbox"></div></div>`;
$$('.tab', v).forEach((t) => t.addEventListener('click', () => {
$$('.tab', v).forEach((x) => x.classList.toggle('active', x === t));
$('#tab-log').hidden = t.dataset.tab !== 'log';
$('#tab-audit').hidden = t.dataset.tab !== 'audit';
}));
async function logs() {
if (!$('#lf').checked) return;
const q = new URLSearchParams({ n: '800' });
if ($('#lv').value) q.set('level', $('#lv').value);
if ($('#lq').value.trim()) q.set('q', $('#lq').value.trim());
const l = await api('GET', `/api/logs?${q}`);
const box = $('#logbox');
const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 30;
box.innerHTML = l.map((x) => `<div class="feed-row"><span class="t">${esc(fmtTime(x.ts))}</span><span class="${x.level === 'ERROR' || x.level === 'WARN' ? 'err' : 'cmd'}">${esc(x.level)}</span><span>${esc(x.message)}</span><span></span><span></span></div>`).join('') || `<div class="empty">-</div>`;
if (atBottom) box.scrollTop = box.scrollHeight;
}
async function audit() {
const a = await api('GET', '/api/audit');
$('#auditbox').innerHTML = a.length ? `<table><thead><tr><th>${T('Czas', 'Time')}</th><th>${T('Kto', 'Actor')}</th><th>${T('Akcja', 'Action')}</th><th>${T('Cel', 'Target')}</th><th>IP</th><th>${T('Szczegóły', 'Details')}</th></tr></thead><tbody>${a.map((e) => `<tr><td>${esc(fmtTs(e.ts))}</td><td>${esc(e.actor)}</td><td>${esc(e.action)}</td><td>${esc(e.target)}</td><td>${esc(e.ip)}</td><td>${esc(e.details)}</td></tr>`).join('')}</tbody></table>` : `<div class="empty">-</div>`;
}
$('#lv').addEventListener('change', logs);
$('#lq').addEventListener('input', () => { clearTimeout(PAGES._lq); PAGES._lq = setTimeout(logs, 300); });
every(3000, () => logs().catch(() => {}));
every(30000, () => audit().catch(() => {}));
};
PAGES.about = async (v) => {
const o = await api('GET', '/api/overview');
v.innerHTML = head('ABOUT', 'ActivePush', T('Serwer Exchange ActiveSync dla IMAP/SMTP + CalDAV/CardDAV - przepisany z Z-Push na Rust.', 'Exchange ActiveSync server for IMAP/SMTP + CalDAV/CardDAV - Z-Push rewritten in Rust.')) +
`<div class="grid two"><div class="card"><h2>${T('Wersja', 'Version')} ${esc(o.version)}</h2><dl class="kv">
<dt>ActiveSync</dt><dd>2.5, 12.0, 12.1, 14.0, 14.1</dd>
<dt>${T('Komendy', 'Commands')}</dt><dd>Sync, FolderSync, FolderCreate/Update/Delete, Ping, Provision, SendMail, SmartReply, SmartForward, GetAttachment, ItemOperations, GetItemEstimate, MoveItems, MeetingResponse, Search (GAL, Mailbox), ResolveRecipients, Settings, ValidateCert</dd>
<dt>Endpoints</dt><dd>/Microsoft-Server-ActiveSync<br>/autodiscover/autodiscover.xml (+ AutoDiscover/AutoDiscover.xml, Autodiscover/Autodiscover.xml)</dd>
<dt>CLI</dt><dd>active-push admin | config | device | probe</dd></dl></div>
<div class="card"><h2>nginx</h2><p class="fine">${T('Zastępuje lokalizacje fastcgi Z-Push:', 'Replaces the Z-Push fastcgi locations:')}</p><pre class="command">location = /Microsoft-Server-ActiveSync {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3660s;
proxy_buffering off;
client_max_body_size 64m;
}
location ~* ^/autodiscover/autodiscover\\.xml$ {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
}</pre></div></div>`;
};
// ------------------------------------------------------------------ auth / boot
function setAuthTexts(setup) {
$('#auth-eyebrow').textContent = setup ? T('PIERWSZE URUCHOMIENIE', 'FIRST RUN') : T('ADMINISTRACJA', 'ADMINISTRATION');
$('#auth-title').textContent = setup ? T('Utwórz administratora', 'Create administrator') : T('Zaloguj się', 'Sign in');
$('#setup-help').textContent = T('Wygeneruj jednorazowy token na serwerze i wklej go poniżej:', 'Generate a one-time token on the server and paste it below:');
$('#lbl-token').textContent = T('Token instalacyjny', 'Setup token');
$('#lbl-user').textContent = T('Login', 'Username');
$('#lbl-pass').textContent = setup ? T('Hasło (min. 12 znaków)', 'Password (min. 12 characters)') : T('Hasło', 'Password');
$('#lbl-pass2').textContent = T('Powtórz hasło', 'Repeat password');
$('#auth-submit').textContent = setup ? T('Utwórz i zaloguj', 'Create & sign in') : T('Zaloguj', 'Sign in');
$('#setup-fields').hidden = !setup;
$('#confirm-wrap').hidden = !setup;
$('#password').autocomplete = setup ? 'new-password' : 'current-password';
}
function showAuth(setup) {
clearTimers();
$('#loading').hidden = true;
$('#shell').hidden = true;
$('#auth').hidden = false;
S.setup = !!setup;
setAuthTexts(S.setup);
$('#username').focus();
}
async function boot() {
let sess;
try { sess = await api('GET', '/api/session'); } catch (e) { $('#loading').textContent = e.message; return; }
if (!sess.authenticated) { showAuth(sess.setup_required); return; }
S.session = sess;
S.csrf = sess.csrf;
$('#loading').hidden = true;
$('#auth').hidden = true;
$('#shell').hidden = false;
$('#whoami').textContent = sess.username;
$('#logout').textContent = T('Zablokuj', 'Lock');
$('#theme').title = T('Motyw jasny/ciemny', 'Light/dark theme');
document.title = `${sess.server_name || 'ActivePush'} / Control room`;
go();
}
$('#auth-form').addEventListener('submit', async (e) => {
e.preventDefault();
const msg = $('#auth-msg');
msg.textContent = '';
const body = { username: $('#username').value.trim(), password: $('#password').value, token: $('#setup-token').value.trim() };
if (S.setup && body.password !== $('#password2').value) { msg.textContent = T('Hasła się różnią', 'Passwords differ'); return; }
$('#auth-submit').disabled = true;
try {
await api('POST', S.setup ? '/api/setup' : '/api/login', body);
$('#password').value = '';
$('#password2').value = '';
await boot();
} catch (err) { msg.textContent = err.message; } finally { $('#auth-submit').disabled = false; }
});
$('#logout').addEventListener('click', async () => { try { await api('POST', '/api/logout'); } catch { /* ignore */ } showAuth(false); });
$('#menu-toggle').addEventListener('click', () => $('#nav').classList.toggle('open'));
const theme = store.get('ap.theme', 'dark');
document.documentElement.dataset.theme = theme;
$('#theme').addEventListener('click', () => {
const t = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = t;
store.set('ap.theme', t);
if (S.session) go();
});
const langSel = $('#lang');
langSel.value = LANG;
document.documentElement.lang = LANG;
langSel.addEventListener('change', () => { LANG = langSel.value; store.set('ap.lang', LANG); location.reload(); });
window.addEventListener('hashchange', () => { if (S.session) go(); });
let resizeT;
window.addEventListener('resize', () => { clearTimeout(resizeT); resizeT = setTimeout(() => { if (S.session && S.page === 'overview') go(); }, 400); });
boot();
})();