'use strict';
(() => {
// ------------------------------------------------------------------ basics
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
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 = ``;
for (let g = 0; g <= 4; g++) {
const v = (max * g) / 4, yy = y(v);
svg += `${esc(fmt(Math.round(v * 100) / 100))} `;
}
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 += `${esc(labels[i])} `;
}
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 += ` `;
});
});
} else {
series.forEach((s) => {
const pts = s.values.map((v, i) => `${x(i)},${y(v)}`).join(' ');
if (area && n > 1) svg += ` `;
svg += ` `;
});
}
svg += ` `;
el.innerHTML = `
${series.map((s) => ` ${esc(s.name)} `).join('')}
`;
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 = `${esc(labels[i])} ${series.map((s) => `${esc(s.name)}: ${esc(fmt(s.values[i]))}`).join(' ')}`;
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 ` `;
}
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 += `${esc(it.label)}: ${it.value} `;
off += len;
});
el.innerHTML = `${arcs}${esc(center ?? total)}
${items.map((it, i) => `
${esc(it.label)} - ${fmtNum(it.value)}
`).join('') || `
${T('Brak danych', 'No data')}
`}
`;
}
function hbars(items, cls = 'k1') {
const max = Math.max(1, ...items.map((i) => i.value));
return items.map((i) => `${esc(i.label)} ${fmtNum(i.value)} `).join('')
|| `${T('Brak danych', 'No data')}
`;
}
// ------------------------------------------------------------------ modal
function modal(html, cls = '') {
const root = $('#modal-root');
root.innerHTML = ``;
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(`
${esc(title)} ✕
${esc(text)}
${typed ? `${T('Wpisz', 'Type')} ${esc(typed)} ${T('aby potwierdzić', 'to confirm')} ` : ''}
${T('Anuluj', 'Cancel')} ${T('Potwierdź', 'Confirm')}
`, '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) => `${String(i + 1).padStart(2, '0')} ${esc(n[1])} ${n[0] === 'devices' ? ' ' : ''} `).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 = `${esc(e.message)}
`; });
}
function head(eyebrow, title, desc, actions = '') {
return `${esc(eyebrow)}
${esc(title)} ${desc ? `
${esc(desc)}
` : ''}
${actions}
`;
}
// ------------------------------------------------------------------ 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.'), `60 min 6 h 24 h `) +
`
${T('ŻĄDANIA / MIN', 'REQUESTS / MIN')}
${T('Ruch', 'Traffic')}
${T('ELEMENTY / MIN', 'ITEMS / MIN')}
${T('Synchronizacja danych', 'Data synchronisation')}
${T('TRANSFER / MIN', 'TRANSFER / MIN')}
${T('Dane', 'Data')}
${T('OSTATNIE 7 DNI', 'LAST 7 DAYS')}
${T('Żądania na godzinę', 'Requests per hour')}
${T('OD STARTU', 'SINCE START')}
${T('Komendy', 'Commands')}
${T('URZĄDZENIA', 'DEVICES')}
${T('Typy urządzeń', 'Device types')}
RUNTIME
${T('Serwer', 'Server')}
`;
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 = '') => `${esc(label)} ${value}
${sub}
${sp}
`;
$('#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)}/ ${fmtNum(o.devices_total)} `, 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)}/ ${fmtNum(o.users_total)} `, 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', `${o.devices_pending ? `${o.devices_pending} ${T('urządzeń czeka na zatwierdzenie', 'devices await approval')} ` : ''}${o.wipes_pending ? `${o.wipes_pending} ${T('zdalnych wymazań w toku', 'remote wipes pending')} ` : ''}
`);
}
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]) => `${esc(k)} ${esc(val)} `).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.'), `${T('Pauza', 'Pause')} `) +
`
PUSH
${T('Aktywne połączenia push', 'Active push connections')} 0
IN-FLIGHT
${T('Trwające żądania', 'Requests in progress')} 0
`;
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) => `
${esc(fmtTime(e.ts))} ${esc(e.user)}
${esc(e.command)} ${esc(e.device_type)} ${esc(e.http)} ${esc(e.status)} ${e.items_out || e.items_in ? ` ↓${e.items_out} ↑${e.items_in} ` : ''}
${e.command === 'Ping' ? fmtDur(e.ms / 1000) : `${e.ms} ms`} ${fmtBytes(e.bytes_out)}
`;
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('') || `${T('Brak żądań', 'No requests yet')}
`;
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 ? `${T('Użytkownik', 'User')} ${T('Urządzenie', 'Device')} ${T('Typ', 'Kind')} ${T('Foldery', 'Folders')} ${T('Heartbeat', 'Heartbeat')} ${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 `${esc(p.user)} ${esc(p.device_type)}${esc(p.device_id)} ${esc(p.kind)} ${p.folders} ${fmtDur(el)} / ${fmtDur(p.lifetime)} `;
}).join('')}
` : `${T('Brak połączeń push', 'No push connections')}
`;
$('#active').innerHTML = d.active.length ? `${T('Komenda', 'Command')} ${T('Użytkownik', 'User')} ${T('Urządzenie', 'Device')} IP ${T('Czas', 'Time')} ${d.active.map((a) => `${esc(a.command)} ${esc(a.user)} ${esc(a.device_type)}AS ${esc(a.version)} ${esc(a.ip)} ${fmtDur(d.now - a.started)} `).join('')}
` : `${T('Brak', 'None')}
`;
applyWidths(v);
});
};
function deviceState(d) {
const p = [];
if (d.pinging) p.push(`PUSH `);
if (d.blocked) p.push(`${T('ZABLOKOWANE', 'BLOCKED')} `);
if (!d.approved) p.push(`${T('DO ZATWIERDZENIA', 'PENDING')} `);
if (d.wipe_status === 2 || d.wipe_status === 4) p.push(`WIPE ${d.wipe_status === 4 ? T('WYSŁANY', 'SENT') : T('CZEKA', 'PENDING')} `);
if (d.wipe_status === 8) p.push(`${T('WYMAZANE', 'WIPED')} `);
if (!p.length) p.push(`OK `);
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).')) +
``;
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 ? `${T('Urządzenie', 'Device')} ${T('Skrzynki', 'Mailboxes')} OS AS ${T('Ostatnio', 'Last seen')} IP ${T('Żądania', 'Requests')} ${T('Stan', 'State')} ${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 `
${esc(d.friendly_name || d.model || d.device_type)}${esc(d.device_type)} · ${esc(d.device_id)}
${g.map((x) => `${x.pinging ? ' ' : ''}${esc(x.username)} `).join('')}
${esc(d.os || '-')} ${esc(d.as_version || '-')} ${esc(ago(d.last_seen))}${esc(d.last_command)}
${esc(d.last_ip)} ${fmtNum(g.reduce((a, x) => a + x.requests, 0))} ${deviceState(merged)} `;
}).join('')}
` : `${T('Brak urządzeń', 'No devices')}
`;
$$('.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 ? `${T('Folder', 'Folder')} ${T('Klasa', 'Class')} ${T('Elementy', 'Items')} ↓ ↑ ${T('Ostatnia sync.', 'Last sync')} ${T('Okno', 'Window')} ${T('Pętle', 'Loops')} ${d.folders.map((f) => `${esc(f.folder_name)}id ${esc(f.collection)} ${esc(f.folder_class)} ${fmtNum(f.item_count)} ${fmtNum(f.items_out)} ${fmtNum(f.items_in)} ${esc(ago(f.last_sync))} ${f.window_size} ${f.loop_hits ? `${f.loop_hits} ` : 0} `).join('')}
` : `${T('Brak zsynchronizowanych folderów', 'No synchronised folders')}
`;
const recent = d.recent.length ? `${d.recent.map((e) => `
${esc(fmtTime(e.ts))} ${esc(e.ip)} ${esc(e.command)} ${e.http} ${esc(e.status)} ${e.ms} ms ${fmtBytes(e.bytes_out)}
`).join('')}
` : `-
`;
const btn = (act, label, cls = 'outline') => `${esc(label)} `;
const dlg = modal(`${T('URZĄDZENIE', 'DEVICE')} #${d.id} ${esc(d.friendly_name || d.model || d.device_type)} - ${esc(d.username)} ${deviceState(d)}
✕
${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')}
${T('Szczegóły', 'Details')} ${kv.map(([k, val]) => `${esc(k)} ${esc(val)} `).join('')}
${T('Notatki', 'Notes')} ${T('Zapisz notatkę', 'Save note')}
${T('Ostatnie żądania', 'Recent requests')} ${recent}
${T('Foldery', 'Folders')} ${folders}
`);
$$('[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.')) +
`
${T('OCHRONA', 'PROTECTION')}
${T('Aktywne blokady logowania', 'Active login lockouts')}
`;
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 ? `${T('Skrzynka', 'Mailbox')} ${T('Urządzenia', 'Devices')} ${T('Na tym samym urządzeniu', 'Same device as')} PUSH ${T('Żądania', 'Requests')} ${T('Ostatnio', 'Last seen')} ${T('Polityka', 'Policy')} ${T('Stan', 'State')} ${list.map((u) => `${esc(u.username)}
${esc((u.device_names || '-').split(',').join(', '))}${u.devices} ${T('partnerstw', 'partnership(s)')}
${u.same_device ? u.same_device.split(',').map((x) => `${esc(x)} `).join('') : '- '}
${u.pings} ${fmtNum(u.requests)} ${esc(ago(u.last_seen))} ${esc(u.policy)} ${u.blocked ? `${T('ZABLOKOWANA', 'BLOCKED')} ` : 'OK '}
${u.blocked ? T('Odblokuj', 'Unblock') : T('Zablokuj', 'Block')} ${T('Urządzenia', 'Devices')} `).join('')}
` : `${T('Brak skrzynek', 'No mailboxes')}
`;
$$('[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 ? `${T('Klucz', 'Key')} ${T('Do', 'Until')} ${locks.map((l) => `${esc(l.key)} ${esc(fmtTs(l.until))} ${T('Odblokuj', 'Unlock')} `).join('')}
` : `${T('Brak blokad', 'No lockouts')}
`;
$$('[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 ? `${esc(opt.help)}
` : '';
const wide = opt.wide || type === 'list' || type === 'textarea' ? ' wide' : '';
if (type === 'bool') return ` ${esc(label)} ${help}
`;
let input;
if (type === 'select') input = `${opt.options.map(([k, l]) => `${esc(l)} `).join('')} `;
else if (type === 'tz') input = `${S.timezones.map((t) => ``).join('')} `;
else if (type === 'list') input = ``;
else if (type === 'secret') input = ` `;
else if (type === 'number') input = ` `;
else input = ` `;
return `${esc(label)} ${input}${help}
`;
}
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 `${esc(title)} ${desc ? `
${esc(desc)}
` : ''}
${fields.map((f) => fieldHtml(s, f)).join('')}
${extra}
`;
}
async function settingsPage(v, eyebrow, title, desc, build, after) {
const s = await loadSettings();
v.innerHTML = head(eyebrow, title, desc, `● ${T('niezapisane zmiany', 'unsaved changes')} ${T('Cofnij', 'Revert')} ${T('Zapisz i zastosuj', 'Save & apply')} `) + `${build(s)}
`;
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 `
`;
}
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]) }],
]) + `${T('Polityka', 'Policy')} ${pols.map((p, i) => `${esc(p.name)} `).join('')} ${T('Duplikuj', 'Duplicate')} ${T('Usuń', 'Delete')}
${T('Przypisania użytkowników', 'User assignments')} ${T('Jedna linia: login=nazwa_polityki', 'One per line: login=policy_name')}
`;
}, {
bind(form, s) {
const pf = $('#pol-form', form);
const drawPol = () => {
const p = s.provisioning.policies[sel];
pf.innerHTML = `${fieldHtml({ p }, ['p.name', T('Nazwa', 'Name'), 'text'])}${POLICY_FIELDS().map((f) => fieldHtml({ p }, [`p.${f[0]}`, f[1], f[2]])).join('')}
`;
$$('[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', `${esc(c.name)} `);
$('#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) => `${esc(p.name)} `).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 ', 'Empty = endpoint disabled. Use: Authorization: Bearer ') }],
]) +
`
${T('Konto administratora', 'Administrator account')}
${T('Wyloguj inne sesje', 'Sign out other sessions')} ${T('Zmień hasło', 'Change password')}
`,
{
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.')) +
`${T('Log serwera', 'Server log')} ${T('Audyt', 'Audit')}
`;
$$('.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) => `${esc(fmtTime(x.ts))} ${esc(x.level)} ${esc(x.message)}
`).join('') || `-
`;
if (atBottom) box.scrollTop = box.scrollHeight;
}
async function audit() {
const a = await api('GET', '/api/audit');
$('#auditbox').innerHTML = a.length ? `${T('Czas', 'Time')} ${T('Kto', 'Actor')} ${T('Akcja', 'Action')} ${T('Cel', 'Target')} IP ${T('Szczegóły', 'Details')} ${a.map((e) => `${esc(fmtTs(e.ts))} ${esc(e.actor)} ${esc(e.action)} ${esc(e.target)} ${esc(e.ip)} ${esc(e.details)} `).join('')}
` : `-
`;
}
$('#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.')) +
`${T('Wersja', 'Version')} ${esc(o.version)}
ActiveSync 2.5, 12.0, 12.1, 14.0, 14.1
${T('Komendy', 'Commands')} Sync, FolderSync, FolderCreate/Update/Delete, Ping, Provision, SendMail, SmartReply, SmartForward, GetAttachment, ItemOperations, GetItemEstimate, MoveItems, MeetingResponse, Search (GAL, Mailbox), ResolveRecipients, Settings, ValidateCert
Endpoints /Microsoft-Server-ActiveSync /autodiscover/autodiscover.xml (+ AutoDiscover/AutoDiscover.xml, Autodiscover/Autodiscover.xml)
CLI active-push admin | config | device | probe
nginx ${T('Zastępuje lokalizacje fastcgi Z-Push:', 'Replaces the Z-Push fastcgi locations:')}
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;
} `;
};
// ------------------------------------------------------------------ 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();
})();