Add web server, certificates and Samba/AD management tabs, a web server config generator, toggleable feature menus and an animated start page background
- Web server tab (nginx, Apache, Caddy, PHP-FPM): service control with autostart, config test and live logs, sites list with enable/disable/edit/delete, new site wizard (static, reverse proxy, PHP) and a code editor for all config files; every change is checked (nginx -t, apachectl configtest, caddy validate, php-fpm -t) before reloading and rolled back if the check fails - Config generator: all options grouped and searchable (reverse proxy with load balancing and WebSockets, HTTPS/TLS, HSTS, security headers, IP allow list, basic auth, rate limit, gzip, caching, upload size, PHP, redirects, logs, custom directives), presets, live preview for nginx/Apache/Caddy; save as a site (needed Apache modules are enabled) or open in the editor first - Certificates tab: Let's Encrypt via certbot (request with nginx/Apache/webroot/standalone/DNS challenge, renew, dry run, delete) in a terminal tab, acme.sh certificates, expiry of all found certificate files, auto-renew timer, self-signed certificates and a TLS endpoint check from this computer - Samba/AD tab: shares (smb.conf edited section by section, checked with testparm), Samba users, services and connected clients; create a new AD domain (samba-tool domain provision), join or leave a domain (realmd/sssd) and manage domain users, groups, members and computers; passwords are passed via stdin - Settings: feature menus (Docker, firewall, network, web server, certificates, Samba/AD) can be enabled or disabled for the host menu, terminal toolbar and quick search - Start page: slowly drifting, blurred colour glows in the theme colours behind the vault (can be turned off in the design settings, respects reduced motion) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,875 @@
|
||||
/* global S, T, h, $, $$, esc, ICONS, api, call, toast, modal, confirmBox, promptBox, uid, avatar, hostSub, hostRef, addTab, closeTab, setTabState, openTerminal, settings, fmtDate, relTime, openDocker, openFirewall, openNetwork */
|
||||
'use strict';
|
||||
// Feature-Menüs (Docker, Firewall, Netzwerk, Webserver, Zertifikate, Samba/AD): Liste, Ein-/Ausschalten in den
|
||||
// Einstellungen und die Verwaltungs-Tabs Webserver, Zertifikate und Samba/AD (Backend: src/main/admin.js & Co.).
|
||||
// Wird nach app.js geladen und nutzt dessen Hilfsfunktionen.
|
||||
|
||||
Object.assign(ICONS, {
|
||||
globe: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.7 3.8 5.7 3.8 9s-1.3 6.3-3.8 9c-2.5-2.7-3.8-5.7-3.8-9s1.3-6.3 3.8-9z"/></svg>',
|
||||
cert: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M7 8h10M7 11h6"/><circle cx="16" cy="17" r="3"/><path d="M14.5 19.5L14 22l2-1 2 1-.5-2.5"/></svg>',
|
||||
users: '<svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c.6-3.6 3.2-5.5 6.5-5.5s5.9 1.9 6.5 5.5"/><circle cx="17" cy="9" r="2.8"/><path d="M16.5 14.6c2.6.2 4.5 1.9 5 5"/></svg>',
|
||||
});
|
||||
|
||||
const FEATURES = [
|
||||
{ id: 'docker', label: () => 'Docker', title: () => T('Docker containers'), icon: 'docker', color: 'var(--accent-2)', open: (x) => openDocker(x) },
|
||||
{ id: 'firewall', label: () => T('Firewall'), icon: 'wall', color: 'var(--orange)', open: (x) => openFirewall(x) },
|
||||
{ id: 'network', label: () => T('Network'), icon: 'network', color: 'var(--accent)', open: (x) => openNetwork(x) },
|
||||
{ id: 'webserver', label: () => T('Web server'), icon: 'globe', color: 'var(--green)', open: (x) => new WebServerSession(x) },
|
||||
{ id: 'certs', label: () => T('Certificates'), icon: 'cert', color: 'var(--accent-3)', open: (x) => new CertSession(x) },
|
||||
{ id: 'samba', label: () => 'Samba / AD', icon: 'users', color: 'var(--folder)', open: (x) => new SambaSession(x) },
|
||||
];
|
||||
const featureOn = (id) => settings().features?.[id] !== false;
|
||||
const enabledFeatures = () => FEATURES.filter((f) => featureOn(f.id));
|
||||
const featureOpeners = () => Object.fromEntries(FEATURES.map((f) => [f.id, f.open]));
|
||||
const featureTitle = (f) => (f.title ? f.title() : f.label());
|
||||
|
||||
// Einstellungen: Feature-Menüs ein-/ausschalten
|
||||
function featuresCard(save) {
|
||||
const card = h(`<div class="settings-card"><h3>${T('Feature menus')}</h3><p style="color:var(--muted);margin-top:0">${T('Choose which management tabs are offered for SSH hosts (host menu, terminal toolbar and quick search).')}</p><div class="feature-list"></div></div>`);
|
||||
for (const f of FEATURES) {
|
||||
const el = h(`<label class="check feature-it"><input type="checkbox" ${featureOn(f.id) ? 'checked' : ''}/><span class="fi" style="color:${f.color}">${ICONS[f.icon]}</span>${esc(featureTitle(f))}</label>`);
|
||||
$('input', el).onchange = (e) => save({ features: { ...(settings().features || {}), [f.id]: e.target.checked } });
|
||||
$('.feature-list', card).append(el);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
const pill = (state) => {
|
||||
const on = state === 'active';
|
||||
const label = { active: T('active'), inactive: T('inactive'), failed: T('failed'), activating: T('starting') }[state] || state || T('unknown');
|
||||
return `<span class="status-pill ${on ? 'on' : state === 'failed' ? 'err' : ''}">${esc(label)}</span>`;
|
||||
};
|
||||
const daysLeft = (t) => (t ? Math.floor((t - Date.now()) / 86400000) : null);
|
||||
function expiryHtml(t) {
|
||||
if (!t) return '<span class="muted">–</span>';
|
||||
const d = daysLeft(t);
|
||||
const cls = d < 0 ? 'err' : d < 14 ? 'err' : d < 30 ? 'warn' : 'ok';
|
||||
return `<span class="exp ${cls}">${esc(fmtDate(t))}<small>${d < 0 ? esc(T('expired')) : esc(T('{n} days', { n: d }))}</small></span>`;
|
||||
}
|
||||
const spinnerHtml = (text) => `<div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(text)}</div></div>`;
|
||||
|
||||
// Formular-Dialog mit Logik nach dem Öffnen (z. B. Felder je nach Auswahl ein-/ausblenden)
|
||||
function formModal(opts, wire) {
|
||||
const p = modal(opts);
|
||||
const m = $$('#modalRoot .modal-bg').pop();
|
||||
if (m && wire) wire(m);
|
||||
return p;
|
||||
}
|
||||
|
||||
// ============================================================ Basis für Verwaltungs-Tabs
|
||||
class AdminSession {
|
||||
constructor(host, { kind, ch, title, views }) {
|
||||
this.kind = kind; this.ch = ch; this.id = uid(); this.host = host;
|
||||
this.title = `${title} · ${host.label || host.address}`;
|
||||
this.views = views; this.view = views[0][0]; this.data = null;
|
||||
this.el = h(`<div class="session admin ${kind}">
|
||||
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
|
||||
<div class="seg sm views">${views.map(([v, l]) => `<button data-v="${v}" class="${v === this.view ? 'active' : ''}">${esc(l)}</button>`).join('')}</div>
|
||||
<button class="btn ghost sm" data-ai-toggle title="${T('Support assistant (Ollama)')}">${ICONS.ai}</button>
|
||||
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
|
||||
</div>
|
||||
<div class="docker-body admin-body">${spinnerHtml(T('Connecting to {host} …', { host: host.address }))}</div></div>`);
|
||||
this.body = $('.admin-body', this.el);
|
||||
this.el.addEventListener('click', (e) => {
|
||||
const v = e.target.closest('.views [data-v]')?.dataset.v;
|
||||
if (v && this.data) this.setView(v);
|
||||
if (e.target.closest('[data-a=refresh]') && this.data) this.refresh();
|
||||
});
|
||||
this.unsub = api.on(`${ch}:closed`, (sid) => { if (sid === this.id) { setTabState(this, 'err'); this.error(T('Connection closed.')); } });
|
||||
addTab(this);
|
||||
this.open();
|
||||
}
|
||||
|
||||
async open() {
|
||||
try {
|
||||
this.data = await api.call(`${this.ch}:open`, this.id, hostRef(this.host));
|
||||
setTabState(this, 'on');
|
||||
this.draw();
|
||||
} catch (e) {
|
||||
setTabState(this, 'err');
|
||||
this.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
error(msg) {
|
||||
this.body.innerHTML = `<div class="pane-empty"><div style="color:var(--red);max-width:560px;text-align:center;white-space:pre-wrap">${esc(msg)}</div></div>`;
|
||||
const b = h(`<button class="btn primary">${ICONS.refresh}${T('Try again')}</button>`);
|
||||
b.onclick = () => { closeTab(this.id); featureOpeners()[this.kind](this.host); };
|
||||
$('.pane-empty', this.body).append(b);
|
||||
}
|
||||
|
||||
setView(v) {
|
||||
this.view = v;
|
||||
$$('.views [data-v]', this.el).forEach((b) => b.classList.toggle('active', b.dataset.v === v));
|
||||
this.body.scrollTop = 0;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try { this.data = await api.call(`${this.ch}:read`, this.id); this.draw(); } catch (e) { toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// Operation im Backend; Fehler werden als Toast gezeigt und weitergeworfen
|
||||
op(name, ...args) { return call(`${this.ch}:op`, this.id, name, ...args); }
|
||||
// Operation + Neuladen; true bei Erfolg
|
||||
async act(name, args, okMsg) {
|
||||
try { await this.op(name, ...args); } catch { return false; }
|
||||
if (okMsg) toast(okMsg, 'ok');
|
||||
await this.refresh();
|
||||
return true;
|
||||
}
|
||||
// Befehl vom Backend im Terminal-Tab ausführen (Live-Ausgabe, Rückfragen möglich)
|
||||
async term(name, args, label) {
|
||||
try { openTerminal({ ...this.host, execCommand: await this.op(name, ...args), label: `${label} · ${this.host.label || this.host.address}` }); } catch {}
|
||||
}
|
||||
|
||||
draw() {
|
||||
if (!this.data || this.editing) return;
|
||||
const scroll = this.body.scrollTop;
|
||||
this.body.innerHTML = '';
|
||||
const wrap = h('<div class="admin-wrap"></div>');
|
||||
this.body.append(wrap);
|
||||
this[`draw_${this.view}`](wrap);
|
||||
this.body.scrollTop = scroll;
|
||||
}
|
||||
|
||||
section(title, actions = []) {
|
||||
const sec = h(`<div class="admin-sec"><div class="admin-sec-head"><h4>${esc(title)}</h4><span class="grow"></span></div></div>`);
|
||||
for (const a of actions) {
|
||||
const b = h(`<button class="btn ${a.cls || 'ghost'} sm" ${a.title ? `title="${esc(a.title)}"` : ''}>${a.icon ? ICONS[a.icon] : ''}${esc(a.label)}</button>`);
|
||||
b.onclick = a.run;
|
||||
$('.admin-sec-head', sec).append(b);
|
||||
}
|
||||
return sec;
|
||||
}
|
||||
|
||||
// Tabelle: cols = [[Titel, (zeile) => html, klasse?]], acts = (zeile) => [{ icon, title, run, danger }]
|
||||
table(rows, cols, acts) {
|
||||
const t = h(`<table class="docker-table admin-table"><thead><tr>${cols.map(([c, , cls]) => `<th class="${cls || ''}">${esc(c)}</th>`).join('')}${acts ? '<th></th>' : ''}</tr></thead><tbody></tbody></table>`);
|
||||
for (const r of rows) {
|
||||
const tr = h(`<tr>${cols.map(([, f, cls]) => `<td class="${cls || ''}">${f(r)}</td>`).join('')}${acts ? '<td class="acts"></td>' : ''}</tr>`);
|
||||
if (acts) for (const a of acts(r).filter(Boolean)) {
|
||||
const b = h(`<button class="btn ghost sm ${a.danger ? 'danger' : ''}" title="${esc(a.title)}">${a.icon ? ICONS[a.icon] : ''}${a.label ? esc(a.label) : ''}</button>`);
|
||||
b.onclick = a.run;
|
||||
$('.acts', tr).append(b);
|
||||
}
|
||||
$('tbody', t).append(tr);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
empty(text) { return h(`<div class="admin-empty">${esc(text)}</div>`); }
|
||||
|
||||
// Dienst-Zeile mit Start/Stopp/Neustart/Neu laden und Autostart
|
||||
serviceButtons(u, opName = 'service', withReload = true) {
|
||||
const box = h('<span class="svc-btns"></span>');
|
||||
const run = (action, msg) => this.act(opName, [u.unit, action], msg);
|
||||
const btn = (icon, title, fn, cls = '') => { const b = h(`<button class="btn ghost sm ${cls}" title="${esc(title)}">${ICONS[icon]}</button>`); b.onclick = fn; box.append(b); };
|
||||
if (u.active === 'active') btn('stop', T('Stop'), async () => { if (await confirmBox(T('Stop {name}?', { name: u.unit }), T('The service will stop serving requests.'), T('Stop'))) run('stop', T('{name} stopped', { name: u.unit })); });
|
||||
else btn('play', T('Start'), () => run('start', T('{name} started', { name: u.unit })));
|
||||
btn('refresh', T('Restart'), () => run('restart', T('{name} restarted', { name: u.unit })));
|
||||
if (withReload && u.active === 'active') btn('download', T('Reload configuration'), () => run('reload', T('Configuration reloaded')));
|
||||
const en = h(`<label class="check svc-auto" title="${esc(T('Start automatically at boot'))}"><input type="checkbox" ${u.enabled === 'enabled' ? 'checked' : ''}/>${T('Autostart')}</label>`);
|
||||
$('input', en).onchange = (e) => run(e.target.checked ? 'enable' : 'disable');
|
||||
box.append(en);
|
||||
return box;
|
||||
}
|
||||
|
||||
// Datei im Tab bearbeiten; save(content) → [Operation, ...Argumente]; Ergebnis: Ausgabe der Prüfung oder { path, output }
|
||||
// onSaved(ergebnis) z. B. um nach dem ersten Speichern einer neuen Datei auf "überschreiben" umzustellen
|
||||
async editFile(path, { read, save, note, onSaved }) {
|
||||
let content;
|
||||
try { content = await read(); } catch { return; }
|
||||
this.editing = path;
|
||||
this.body.innerHTML = '';
|
||||
const ed = h(`<div class="stack-editor"><div class="se-head"><b class="mono">${esc(path)}</b><span style="flex:1"></span>
|
||||
<button class="btn ghost sm" data-e="cancel">${T('Back')}</button><button class="btn primary sm" data-e="save">${ICONS.download}${T('Save & apply')}</button></div>
|
||||
${note ? `<div class="se-note">${esc(note)}</div>` : ''}<div class="se-out" style="display:none"></div>
|
||||
<div class="se-grid" style="grid-template-columns:1fr"><div class="se-col"><textarea class="mono" spellcheck="false"></textarea></div></div></div>`);
|
||||
const ta = $('textarea', ed);
|
||||
ta.value = content;
|
||||
ta.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Tab') { e.preventDefault(); ta.setRangeText(' ', ta.selectionStart, ta.selectionEnd, 'end'); }
|
||||
if (e.key === 's' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); $('[data-e=save]', ed).click(); }
|
||||
});
|
||||
const out = $('.se-out', ed);
|
||||
$('[data-e=cancel]', ed).onclick = () => { this.editing = null; this.refresh(); };
|
||||
$('[data-e=save]', ed).onclick = async (e) => {
|
||||
const b = e.currentTarget; b.disabled = true;
|
||||
try {
|
||||
const r = await api.call(`${this.ch}:op`, this.id, ...save(ta.value));
|
||||
onSaved?.(r);
|
||||
if (r?.path) $('.se-head b', ed).textContent = r.path;
|
||||
const msg = typeof r === 'string' ? r : r?.output;
|
||||
out.style.display = ''; out.className = 'se-out ok'; out.textContent = (typeof msg === 'string' && msg.trim()) || T('Saved and applied.');
|
||||
toast(T('Saved'), 'ok');
|
||||
} catch (err) {
|
||||
out.style.display = ''; out.className = 'se-out err'; out.textContent = err.message;
|
||||
}
|
||||
b.disabled = false;
|
||||
};
|
||||
this.body.append(ed);
|
||||
ta.focus();
|
||||
}
|
||||
|
||||
aiContext() { return this.body.innerText; }
|
||||
dispose() { this.unsub(); api.call(`${this.ch}:close`, this.id).catch(() => {}); }
|
||||
}
|
||||
|
||||
// ============================================================ Webserver (nginx, Apache, Caddy, PHP-FPM)
|
||||
const SRV_LABEL = { nginx: 'nginx', apache: 'Apache', caddy: 'Caddy', php: 'PHP-FPM' };
|
||||
|
||||
class WebServerSession extends AdminSession {
|
||||
constructor(host) {
|
||||
super(host, { kind: 'webserver', ch: 'web', title: T('Web server'), views: [['overview', T('Overview')], ['sites', T('Sites')], ['generator', T('Generator')], ['files', T('Configuration')]] });
|
||||
}
|
||||
|
||||
// ---------- Konfigurations-Generator: alle Optionen durchsuchbar, Live-Vorschau, als Site speichern oder im Editor öffnen
|
||||
draw_generator(w) {
|
||||
const W = window.WEBCONF;
|
||||
const inst = this.installed().map((x) => x.id);
|
||||
const g = this.gen ||= { server: inst[0] || 'nginx', o: W.defaults(), q: '' };
|
||||
const nginxVersion = (this.data.servers.find((x) => x.id === 'nginx')?.version.match(/nginx\/([\d.]+)/) || [])[1];
|
||||
const box = h(`<div class="gen">
|
||||
<div class="gen-left">
|
||||
<div class="gen-top"><select class="gen-preset"><option value="">${T('Start from a template …')}</option>${W.PRESETS.map(([id, l]) => `<option value="${id}">${esc(T(l))}</option>`).join('')}</select>
|
||||
<label class="search sm">${ICONS.search}<input class="gen-q" placeholder="${esc(T('Search settings (e.g. upload, websocket, hsts) …'))}" value="${esc(g.q)}"/></label></div>
|
||||
<div class="gen-opts"></div>
|
||||
</div>
|
||||
<div class="gen-right">
|
||||
<div class="gen-bar"><div class="seg sm gen-srv">${['nginx', 'apache', 'caddy'].map((x) => `<button data-s="${x}" class="${g.server === x ? 'active' : ''}">${SRV_LABEL[x]}${inst.includes(x) ? '' : ' <small>·</small>'}</button>`).join('')}</div><span style="flex:1"></span>
|
||||
<button class="btn ghost sm" data-g="copy" title="${esc(T('Copy'))}">${ICONS.copy}</button>
|
||||
<button class="btn sm" data-g="edit">${ICONS.edit}${T('Open in editor')}</button>
|
||||
<button class="btn primary sm" data-g="save">${ICONS.download}${T('Save as site')}</button></div>
|
||||
<div class="gen-note"></div>
|
||||
<pre class="gen-preview mono"></pre>
|
||||
</div></div>`);
|
||||
w.append(box);
|
||||
w.classList.add('gen-wrap');
|
||||
const optsBox = $('.gen-opts', box);
|
||||
const rows = [];
|
||||
for (const [gid, glabel] of W.GROUPS) {
|
||||
const sec = h(`<div class="gen-group" data-group="${gid}"><h4>${esc(T(glabel))}</h4></div>`);
|
||||
for (const opt of W.OPTIONS.filter((x) => x.group === gid)) {
|
||||
const v = g.o[opt.id];
|
||||
let input;
|
||||
if (opt.type === 'bool') input = `<input type="checkbox" data-o="${opt.id}" ${v ? 'checked' : ''}/>`;
|
||||
else if (opt.type === 'select') input = `<select data-o="${opt.id}">${opt.options.map(([k, l]) => `<option value="${esc(k)}" ${String(v) === k ? 'selected' : ''}>${esc(T(l))}</option>`).join('')}</select>`;
|
||||
else if (opt.type === 'textarea') input = `<textarea data-o="${opt.id}" rows="4" class="mono" spellcheck="false">${esc(v)}</textarea>`;
|
||||
else input = `<input type="${opt.type === 'number' ? 'number' : 'text'}" data-o="${opt.id}" value="${esc(v)}" placeholder="${esc(opt.placeholder ? T(opt.placeholder) : '')}" spellcheck="false"/>`;
|
||||
const only = opt.only ? `<span class="badge">${esc(opt.only.map((x) => SRV_LABEL[x]).join(', '))}</span>` : '';
|
||||
const row = h(`<div class="gen-row ${opt.type === 'bool' ? 'bool' : ''} ${opt.type === 'textarea' ? 'wide' : ''}"><label><span class="gl">${esc(T(opt.label))}${only}</span>${opt.desc ? `<small>${esc(T(opt.desc))}</small>` : ''}</label><div class="gc">${input}</div></div>`);
|
||||
row._opt = opt;
|
||||
row._text = [opt.id, opt.label, T(opt.label), opt.desc || '', opt.desc ? T(opt.desc) : '', opt.kw || '', glabel, T(glabel)].join(' ').toLowerCase();
|
||||
rows.push(row);
|
||||
sec.append(row);
|
||||
}
|
||||
optsBox.append(sec);
|
||||
}
|
||||
const gen = () => W.generate(g.server, g.o, { nginxVersion });
|
||||
const update = () => {
|
||||
const q = g.q.trim().toLowerCase();
|
||||
for (const row of rows) {
|
||||
const opt = row._opt;
|
||||
const relevant = !opt.show || opt.show(g.o);
|
||||
const supported = !opt.only || opt.only.includes(g.server);
|
||||
// Bei einer Suche alle passenden Optionen zeigen (auch gerade nicht relevante), sonst nur relevante
|
||||
row.style.display = q ? (q.split(/\s+/).every((t) => row._text.includes(t)) ? '' : 'none') : (relevant ? '' : 'none');
|
||||
row.classList.toggle('off', !supported || !relevant);
|
||||
}
|
||||
$$('.gen-group', optsBox).forEach((sec) => { sec.style.display = $$('.gen-row', sec).some((r) => r.style.display !== 'none') ? '' : 'none'; });
|
||||
$('.gen-preview', box).textContent = gen().text;
|
||||
const notes = [];
|
||||
if (!inst.includes(g.server)) notes.push(T('{server} is not installed on this host – you can still generate and copy the configuration.', { server: SRV_LABEL[g.server] }));
|
||||
if (g.o.tls && g.server !== 'caddy' && g.o.certSource === 'letsencrypt') notes.push(T('The certificate must exist before saving: request it in the “Certificates” tab first (e.g. via webroot), or save without HTTPS first.'));
|
||||
if (g.o.tls && g.server !== 'caddy' && g.o.certSource === 'auto') notes.push(T('Automatic certificates are only available with Caddy.'));
|
||||
if (g.o.basicAuth) notes.push(T('Create the password file on the server with: htpasswd -c {file} <user>', { file: g.o.authFile }));
|
||||
$('.gen-note', box).innerHTML = notes.map((n) => `<div>${esc(n)}</div>`).join('');
|
||||
$('.gen-note', box).style.display = notes.length ? '' : 'none';
|
||||
};
|
||||
optsBox.addEventListener('input', (e) => {
|
||||
const id = e.target.dataset.o; if (!id) return;
|
||||
const opt = W.OPTIONS.find((x) => x.id === id);
|
||||
g.o[id] = opt.type === 'bool' ? e.target.checked : opt.type === 'number' ? Number(e.target.value) : e.target.value;
|
||||
// Dokumentenverzeichnis folgt der ersten Domain, bis es von Hand geändert wurde
|
||||
if (id === 'domains' && !g.rootTouched) { const d = W.splitList(g.o.domains)[0]; g.o.root = d ? `/var/www/${d}` : ''; const r = $('[data-o=root]', optsBox); if (r) r.value = g.o.root; }
|
||||
if (id === 'root') g.rootTouched = true;
|
||||
update();
|
||||
});
|
||||
$('.gen-q', box).oninput = (e) => { g.q = e.target.value; update(); };
|
||||
$('.gen-preset', box).onchange = (e) => {
|
||||
const p = W.PRESETS.find(([id]) => id === e.target.value);
|
||||
if (!p) return;
|
||||
// Vorlage über die aktuellen Werte legen; Domains/Wurzel bleiben erhalten
|
||||
g.o = { ...W.defaults(), domains: g.o.domains, root: g.o.root, ...(p[0] === 'secure' ? g.o : {}), ...p[2] };
|
||||
this.draw();
|
||||
};
|
||||
$('.gen-srv', box).onclick = (e) => { const sv = e.target.closest('[data-s]')?.dataset.s; if (sv) { g.server = sv; $$('.gen-srv button', box).forEach((b) => b.classList.toggle('active', b.dataset.s === sv)); update(); } };
|
||||
const siteArgs = (content) => {
|
||||
const d = W.splitList(g.o.domains)[0];
|
||||
const needsRoot = ['static', 'spa', 'php'].includes(g.o.type) && g.o.createRoot;
|
||||
return { name: g.o.name || d || 'site', content, modules: gen().modules, mkRoot: needsRoot ? g.o.root || (d ? `/var/www/${d}` : '') : '', title: d };
|
||||
};
|
||||
box.addEventListener('click', async (e) => {
|
||||
const a = e.target.closest('[data-g]')?.dataset.g;
|
||||
if (a === 'copy') { await api.call('clipboard:write', gen().text); toast(T('Copied')); }
|
||||
if (a === 'save') {
|
||||
if (!W.splitList(g.o.domains).length && g.server !== 'caddy') return toast(T('Please enter at least one domain.'), 'error');
|
||||
try { const r = await this.op('saveSite', g.server, siteArgs(gen().text)); toast(T('Site saved: {path}', { path: r.path }), 'ok'); await this.refresh(); this.setView('sites'); } catch {}
|
||||
}
|
||||
if (a === 'edit') {
|
||||
const st = { path: null };
|
||||
const text = gen().text;
|
||||
this.editFile(T('New site ({server})', { server: SRV_LABEL[g.server] }), {
|
||||
read: async () => text,
|
||||
save: (c) => (st.path ? ['writeFile', st.path, c] : ['saveSite', g.server, siteArgs(c)]),
|
||||
onSaved: (r) => { if (r?.path) st.path = r.path; },
|
||||
note: T('Edit the generated configuration freely. Saving creates the site, checks it and reloads the web server; if the check fails, nothing is changed.'),
|
||||
});
|
||||
}
|
||||
});
|
||||
update();
|
||||
}
|
||||
|
||||
installed() { return this.data.servers.filter((x) => x.installed); }
|
||||
|
||||
draw_overview(w) {
|
||||
const d = this.data;
|
||||
const grid = h('<div class="ws-grid"></div>');
|
||||
for (const srv of d.servers) {
|
||||
const card = h(`<div class="ws-card ${srv.installed ? '' : 'absent'}"><div class="nh"><span class="ws-ico">${ICONS.globe}</span><b>${esc(srv.label)}</b><span class="grow"></span>${srv.installed ? pill(srv.active) : `<span class="muted">${T('not installed')}</span>`}</div>
|
||||
${srv.installed ? `<div class="muted small">${esc(srv.version)} · ${esc(srv.unit)}</div>` : ''}<div class="ws-acts"></div></div>`);
|
||||
const acts = $('.ws-acts', card);
|
||||
if (srv.installed) {
|
||||
acts.append(this.serviceButtons(srv));
|
||||
const test = h(`<button class="btn sm">${ICONS.shield}${T('Test configuration')}</button>`);
|
||||
test.onclick = async () => { try { const r = await this.op('test', srv.id); modal({ title: `${srv.label}: ${r.ok ? T('Configuration OK') : T('Configuration has errors')}`, body: `<pre class="fw-pre mono" style="max-height:50vh;overflow:auto">${esc(r.output || 'OK')}</pre>`, buttons: [{ label: T('OK'), value: true, cls: 'primary' }] }); } catch {} };
|
||||
const logs = h(`<button class="btn sm">${ICONS.logs}${T('Logs')}</button>`);
|
||||
logs.onclick = () => this.term('logsCommand', [srv.id], `${T('Logs')} ${srv.label}`);
|
||||
acts.append(test, logs);
|
||||
} else {
|
||||
const inst = h(`<button class="btn sm">${ICONS.download}${T('Install')}</button>`);
|
||||
inst.onclick = () => this.term('installCommand', [srv.id], `${T('Install')} ${srv.label}`);
|
||||
acts.append(inst);
|
||||
}
|
||||
grid.append(card);
|
||||
}
|
||||
w.append(grid);
|
||||
const running = d.servers.filter((x) => x.active === 'active');
|
||||
if (running.length > 1) w.append(h(`<div class="net-banner warn">${ICONS.wall}<div>${T('Several web servers are running ({list}). Only one of them can use port 80/443 – make sure they listen on different ports.', { list: running.map((x) => x.label).join(', ') })}</div></div>`));
|
||||
|
||||
const php = this.section('PHP', d.php.pools.length ? [] : [{ label: T('Install PHP-FPM'), icon: 'download', run: () => this.term('installCommand', ['php'], `${T('Install')} PHP-FPM`) }]);
|
||||
if (d.php.version) php.append(h(`<div class="muted small" style="margin:0 0 8px">${esc(d.php.version)}</div>`));
|
||||
if (d.php.pools.length) php.append(this.table(d.php.pools, [[T('Service'), (u) => `<b>${esc(u.unit)}</b>`], [T('Status'), (u) => pill(u.active)]], (u) => [{ icon: 'logs', title: T('Logs'), run: () => this.term('logsCommand', ['php'], `${T('Logs')} ${u.unit}`) }]));
|
||||
else php.append(this.empty(T('No PHP-FPM service found.')));
|
||||
if (d.php.pools.length) {
|
||||
$$('tbody tr', php).forEach((tr, i) => { $('.acts', tr).prepend(this.serviceButtons(d.php.pools[i])); });
|
||||
}
|
||||
if (d.php.sockets.length) php.append(h(`<div class="muted small" style="margin-top:8px">${T('Sockets')}: <span class="mono">${d.php.sockets.map(esc).join(', ')}</span></div>`));
|
||||
w.append(php);
|
||||
}
|
||||
|
||||
draw_sites(w) {
|
||||
const d = this.data;
|
||||
const inst = this.installed();
|
||||
const sec = this.section(T('Sites'), inst.length ? [{ label: T('Generator'), icon: 'code', run: () => this.setView('generator') }, { label: T('New site'), icon: 'plus', cls: 'primary', run: () => this.newSite() }] : []);
|
||||
if (!inst.length) { sec.append(this.empty(T('No web server installed. Install nginx, Apache or Caddy in the overview.'))); w.append(sec); return; }
|
||||
if (!d.sites.length) sec.append(this.empty(T('No sites configured yet.')));
|
||||
else sec.append(this.table(d.sites, [
|
||||
['', (x) => `<span class="dot ${x.enabled ? 'on' : 'none'}"></span>`, 'w-dot'],
|
||||
[T('Server'), (x) => esc(SRV_LABEL[x.server])],
|
||||
[T('Site'), (x) => `<b>${esc(x.name)}</b><div class="muted small mono">${esc(x.path)}${x.line ? `:${x.line}` : ''}</div>`, 'name'],
|
||||
[T('Domains'), (x) => x.domains.map((n) => `<span class="tag">${esc(n)}</span>`).join(' ') || '<span class="muted">–</span>'],
|
||||
], (x) => [
|
||||
x.server !== 'caddy' && { icon: x.enabled ? 'stop' : 'play', title: x.enabled ? T('Disable') : T('Enable'), run: () => this.act('setSiteEnabled', [x.server, x.path, !x.enabled], x.enabled ? T('Site disabled') : T('Site enabled')) },
|
||||
{ icon: 'edit', title: T('Edit'), run: () => this.editConfig(x.path) },
|
||||
{ icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete site?'), x.server === 'caddy' ? T('The block “{name}” will be removed from the Caddyfile.', { name: x.name }) : T('{path} will be deleted (a copy is kept in /var/backups/mrterm).', { path: x.path }))) this.act('deleteSite', [x.server, x.path, x.name], T('Site deleted')); } },
|
||||
]));
|
||||
w.append(sec);
|
||||
w.append(h(`<div class="fw-note" style="border:0;padding:0 2px">${T('Every change is checked with the configuration test of the web server before reloading. If the test fails, the previous state is restored.')}</div>`));
|
||||
}
|
||||
|
||||
draw_files(w) {
|
||||
const d = this.data;
|
||||
const files = [...new Set([...d.files, ...d.sites.map((x) => x.path)])].sort();
|
||||
const sec = this.section(T('Configuration files'));
|
||||
if (!files.length) sec.append(this.empty(T('No configuration files found.')));
|
||||
else {
|
||||
const list = h('<div class="file-list"></div>');
|
||||
for (const f of files) { const b = h(`<button class="file-it">${ICONS.file}<span class="mono">${esc(f)}</span></button>`); b.onclick = () => this.editConfig(f); list.append(b); }
|
||||
sec.append(list);
|
||||
}
|
||||
w.append(sec);
|
||||
}
|
||||
|
||||
editConfig(path) {
|
||||
this.editFile(path, { read: () => this.op('readFile', path), save: (c) => ['writeFile', path, c], note: T('Saving checks the configuration and reloads the service. If the check fails, the previous version is restored.') });
|
||||
}
|
||||
|
||||
async newSite() {
|
||||
const d = this.data;
|
||||
const inst = this.installed();
|
||||
const sock = d.php.sockets[0] || '/run/php/php-fpm.sock';
|
||||
const r = await formModal({ title: T('New site'), body: `
|
||||
<div class="row"><div class="field"><label>${T('Web server')}</label><select name="server">${inst.map((x) => `<option value="${x.id}">${esc(x.label)}</option>`).join('')}</select></div>
|
||||
<div class="field"><label>${T('Type')}</label><select name="type"><option value="static">${T('Static website')}</option><option value="proxy">${T('Reverse proxy')}</option><option value="php">PHP</option></select></div></div>
|
||||
<div class="field"><label>${T('Domains')}</label><input name="domains" placeholder="example.com www.example.com"/></div>
|
||||
<div class="field" data-f="name"><label>${T('Name of the configuration file')}</label><input name="name" placeholder="${esc(T('optional – default: first domain'))}"/></div>
|
||||
<div class="field" data-f="root"><label>${T('Document root')}</label><input name="root" placeholder="/var/www/example.com"/></div>
|
||||
<div class="field" data-f="upstream" style="display:none"><label>${T('Target (upstream)')}</label><input name="upstream" placeholder="http://127.0.0.1:3000"/></div>
|
||||
<div class="field" data-f="sock" style="display:none"><label>${T('PHP-FPM socket')}</label>${d.php.sockets.length > 1 ? `<select name="sock">${d.php.sockets.map((x) => `<option>${esc(x)}</option>`).join('')}</select>` : `<input name="sock" value="${esc(sock)}"/>`}</div>
|
||||
<label class="check" data-f="createRoot"><input type="checkbox" name="createRoot" checked/>${T('Create the folder with a placeholder page if it does not exist')}</label>
|
||||
<div class="hint" data-f="caddy" style="display:none;color:var(--muted);font-size:12px;margin-top:8px">${T('Caddy requests HTTPS certificates for public domains automatically.')}</div>`,
|
||||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Create'), value: 'form', cls: 'primary' }] }, (m) => {
|
||||
const upd = () => {
|
||||
const type = $('[name=type]', m).value, server = $('[name=server]', m).value;
|
||||
const show = (f, on) => { const el = $(`[data-f=${f}]`, m); if (el) el.style.display = on ? '' : 'none'; };
|
||||
show('root', type !== 'proxy'); show('createRoot', type !== 'proxy'); show('upstream', type === 'proxy'); show('sock', type === 'php');
|
||||
show('name', server !== 'caddy'); show('caddy', server === 'caddy');
|
||||
};
|
||||
$('[name=type]', m).onchange = upd; $('[name=server]', m).onchange = upd;
|
||||
$('[name=domains]', m).oninput = (e) => { const r0 = $('[name=root]', m); if (!r0.dataset.touched) r0.value = `/var/www/${e.target.value.trim().split(/[\s,]+/)[0] || ''}`; };
|
||||
$('[name=root]', m).oninput = (e) => { e.target.dataset.touched = '1'; };
|
||||
upd();
|
||||
});
|
||||
if (!r) return;
|
||||
const ok = await this.act('createSite', [r.server, r], T('Site created'));
|
||||
if (ok && r.server !== 'caddy' && featureOn('certs')) toast(T('Tip: request an HTTPS certificate in the “Certificates” tab.'));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================ Zertifikate (Let's Encrypt / certbot, acme.sh)
|
||||
class CertSession extends AdminSession {
|
||||
constructor(host) {
|
||||
super(host, { kind: 'certs', ch: 'certs', title: T('Certificates'), views: [['certs', T('Certificates')], ['check', T('Check endpoint')]] });
|
||||
}
|
||||
|
||||
draw_certs(w) {
|
||||
const d = this.data;
|
||||
const cb = d.certbot;
|
||||
const head = h('<div class="cert-head"></div>');
|
||||
head.append(h(`<div><label>certbot</label><div>${cb.version ? esc(cb.version) : `<span class="muted">${T('not installed')}</span>`}</div></div>`));
|
||||
const timer = d.timers.find((t) => t.unit.endsWith('.timer'));
|
||||
const cron = d.timers.find((t) => t.unit === 'cron');
|
||||
const renew = h(`<div><label>${T('Automatic renewal')}</label><div class="v"></div></div>`);
|
||||
if (timer?.enabled === 'enabled' || cron) $('.v', renew).innerHTML = `${pill(timer?.active || 'active')} <span class="muted small">${esc(timer?.unit || '/etc/cron.d/certbot')}</span>`;
|
||||
else if (cb.version) {
|
||||
$('.v', renew).innerHTML = `<span class="exp warn">${T('not set up')}</span> `;
|
||||
const b = h(`<button class="btn sm">${T('Enable')}</button>`);
|
||||
b.onclick = () => this.act('enableAutoRenew', [timer?.unit || 'certbot.timer'], T('Automatic renewal enabled'));
|
||||
$('.v', renew).append(b);
|
||||
} else $('.v', renew).innerHTML = '<span class="muted">–</span>';
|
||||
head.append(renew);
|
||||
const acts = h('<div class="cert-acts"></div>');
|
||||
const btn = (label, icon, run, cls = '') => { const b = h(`<button class="btn sm ${cls}">${ICONS[icon]}${esc(label)}</button>`); b.onclick = run; acts.append(b); };
|
||||
if (cb.version) {
|
||||
btn(T('Request certificate'), 'plus', () => this.issue(), 'primary');
|
||||
btn(T('Renew all'), 'refresh', () => this.term('renewCommand', ['', false], T('Renew certificates')));
|
||||
btn(T('Test renewal'), 'shield', () => this.term('renewCommand', ['', true], T('Test renewal')));
|
||||
} else btn(T('Install certbot'), 'download', () => this.term('installCommand', [], `${T('Install')} certbot`), 'primary');
|
||||
btn(T('Self-signed …'), 'key', () => this.selfSigned());
|
||||
head.append(acts);
|
||||
w.append(head);
|
||||
|
||||
const sec = this.section("Let's Encrypt (certbot)");
|
||||
if (!cb.version) sec.append(this.empty(T('certbot is not installed. Install it to request free certificates from Let\'s Encrypt.')));
|
||||
else if (!cb.certs.length) sec.append(this.empty(T('No certificates yet.')));
|
||||
else sec.append(this.table(cb.certs, [
|
||||
[T('Name'), (c) => `<b>${esc(c.name)}</b><div class="muted small">${esc(c.keyType || '')}</div>`, 'name'],
|
||||
[T('Domains'), (c) => c.domains.map((x) => `<span class="tag">${esc(x)}</span>`).join(' ')],
|
||||
[T('Expires'), (c) => expiryHtml(c.expires)],
|
||||
], (c) => [
|
||||
{ icon: 'refresh', title: T('Renew'), run: () => this.term('renewCommand', [c.name, false], `${T('Renew')} ${c.name}`) },
|
||||
{ icon: 'eye', title: T('Details'), run: () => this.details(c.certPath) },
|
||||
{ icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete certificate?'), T('“{name}” and its files will be deleted. Web server configurations that still use it will fail.', { name: c.name }))) this.act('deleteCert', [c.name], T('Certificate deleted')); } },
|
||||
]));
|
||||
w.append(sec);
|
||||
|
||||
if (d.acme.dir) {
|
||||
const a = this.section('acme.sh');
|
||||
if (!d.acme.certs.length) a.append(this.empty(T('No certificates yet.')));
|
||||
else a.append(this.table(d.acme.certs, [
|
||||
[T('Domain'), (c) => `<b>${esc(c.domain)}</b>`, 'name'], ['SAN', (c) => c.sans.map((x) => `<span class="tag">${esc(x)}</span>`).join(' ')],
|
||||
['CA', (c) => esc(c.ca)], [T('Next renewal'), (c) => esc(c.renew)],
|
||||
], (c) => [
|
||||
{ icon: 'refresh', title: T('Renew'), run: () => this.term('acmeRenewCommand', [c.domain], `acme.sh ${c.domain}`) },
|
||||
{ icon: 'trash', title: T('Remove'), danger: true, run: async () => { if (await confirmBox(T('Remove certificate?'), T('acme.sh will no longer renew “{name}”.', { name: c.domain }), T('Remove'))) this.act('acmeRemove', [c.domain], T('Removed')); } },
|
||||
]));
|
||||
w.append(a);
|
||||
}
|
||||
|
||||
const f = this.section(T('Certificate files'));
|
||||
const files = d.files.filter((x) => !x.path.startsWith('/etc/letsencrypt/'));
|
||||
if (!files.length) f.append(this.empty(T('No other certificate files found (searched /etc/ssl/mrterm, /etc/nginx/ssl, /etc/apache2/ssl, Caddy).')));
|
||||
else f.append(this.table(files, [
|
||||
[T('File'), (c) => `<span class="mono small">${esc(c.path)}</span><div class="muted small">${esc(c.issuer)}</div>`, 'name'],
|
||||
[T('Domains'), (c) => (c.sans.length ? c.sans : [c.subject]).map((x) => `<span class="tag">${esc(x)}</span>`).join(' ')],
|
||||
[T('Expires'), (c) => expiryHtml(c.expires)],
|
||||
], (c) => [{ icon: 'eye', title: T('Details'), run: () => this.details(c.path) }]));
|
||||
w.append(f);
|
||||
}
|
||||
|
||||
async details(path) {
|
||||
try {
|
||||
const text = await this.op('details', path);
|
||||
modal({ title: path, body: `<pre class="fw-pre mono" style="max-height:60vh;overflow:auto;font-size:11.5px">${esc(text)}</pre>`, buttons: [{ label: T('OK'), value: true, cls: 'primary' }] });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async issue() {
|
||||
const d = this.data;
|
||||
const plug = d.certbot.plugins;
|
||||
const methods = [
|
||||
...(plug.includes('nginx') ? [['nginx', T('nginx (configures HTTPS automatically)')]] : []),
|
||||
...(plug.includes('apache') ? [['apache', T('Apache (configures HTTPS automatically)')]] : []),
|
||||
['webroot', T('Webroot (running web server, files in .well-known)')],
|
||||
['standalone', T('Standalone (port 80 must be free)')],
|
||||
['dns', T('DNS challenge (manual TXT record, also for wildcards)')],
|
||||
];
|
||||
const r = await formModal({ title: T('Request certificate'), body: `
|
||||
<div class="field"><label>${T('Domains')}</label><input name="domains" placeholder="example.com www.example.com"/></div>
|
||||
<div class="field"><label>${T('Method')}</label><select name="method">${methods.map(([v, l]) => `<option value="${v}">${esc(l)}</option>`).join('')}</select></div>
|
||||
<div class="field" data-f="webroot" style="display:none"><label>${T('Webroot')}</label><input name="webroot" placeholder="/var/www/example.com"/></div>
|
||||
<div class="field"><label>${T('E-mail for expiry notices')}</label><input name="email" placeholder="${esc(T('optional'))}"/></div>
|
||||
<div class="row"><div class="field"><label>${T('Key type')}</label><select name="keyType"><option value="ecdsa">ECDSA</option><option value="rsa">RSA 4096</option></select></div>
|
||||
<div class="field"><label>${T('Certificate name')}</label><input name="name" placeholder="${esc(T('optional'))}"/></div></div>
|
||||
<label class="check" data-f="redirect"><input type="checkbox" name="redirect" checked/>${T('Redirect HTTP to HTTPS')}</label>
|
||||
<label class="check"><input type="checkbox" name="staging"/>${T('Test certificate (Let\'s Encrypt staging, no rate limits)')}</label>
|
||||
<div class="hint" style="color:var(--muted);font-size:12px;margin-top:8px">${T('certbot runs in a terminal tab so you can follow the output and answer questions.')}</div>`,
|
||||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Request'), value: 'form', cls: 'primary' }] }, (m) => {
|
||||
const upd = () => { const v = $('[name=method]', m).value; $('[data-f=webroot]', m).style.display = v === 'webroot' ? '' : 'none'; $('[data-f=redirect]', m).style.display = v === 'nginx' || v === 'apache' ? '' : 'none'; };
|
||||
$('[name=method]', m).onchange = upd; upd();
|
||||
});
|
||||
if (r) this.term('issueCommand', [r], 'certbot');
|
||||
}
|
||||
|
||||
async selfSigned() {
|
||||
const r = await modal({ title: T('Self-signed certificate'), body: `
|
||||
<div class="row"><div class="field"><label>${T('Name')}</label><input name="name" placeholder="intranet"/></div><div class="field small"><label>${T('Valid (days)')}</label><input name="days" type="number" value="825"/></div></div>
|
||||
<div class="field"><label>${T('Common name (CN)')}</label><input name="cn" placeholder="intranet.local"/></div>
|
||||
<div class="field"><label>${T('Additional names / IPs')}</label><input name="sans" placeholder="server.local 192.168.1.10"/></div>
|
||||
<div class="field"><label>${T('Key type')}</label><select name="keyType"><option value="ec">ECDSA P-256</option><option value="rsa">RSA 4096</option></select></div>
|
||||
<div class="hint" style="color:var(--muted);font-size:12px">${T('Stored in /etc/ssl/mrterm/<name>.crt and .key. Browsers show a warning for self-signed certificates.')}</div>`,
|
||||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Create'), value: 'form', cls: 'primary' }] });
|
||||
if (!r) return;
|
||||
try {
|
||||
const res = await this.op('selfSigned', r);
|
||||
toast(T('Created: {path}', { path: res.cert }), 'ok');
|
||||
await this.refresh();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
draw_check(w) {
|
||||
const sec = this.section(T('Check TLS endpoint'));
|
||||
const form = h(`<div class="row cert-probe"><div class="field"><label>${T('Host')}</label><input name="host" value="${esc(this.probeHost || this.host.address)}"/></div><div class="field small"><label>Port</label><input name="port" type="number" value="${esc(this.probePort || 443)}"/></div></div>`);
|
||||
const go = h(`<button class="btn primary">${ICONS.search}${T('Check')}</button>`);
|
||||
const out = h('<div class="probe-out"></div>');
|
||||
go.onclick = async () => {
|
||||
this.probeHost = $('[name=host]', form).value.trim(); this.probePort = $('[name=port]', form).value;
|
||||
out.innerHTML = spinnerHtml(T('Checking …'));
|
||||
try {
|
||||
const r = await api.call('certs:probe', this.probeHost, Number(this.probePort) || 443);
|
||||
out.innerHTML = `<div class="net-sys"><div><label>${T('Status')}</label><div class="v">${r.authorized ? `<span class="exp ok">${T('trusted')}</span>` : `<span class="exp err">${esc(r.error || T('not trusted'))}</span>`}</div></div>
|
||||
<div><label>${T('Expires')}</label><div class="v">${expiryHtml(r.expires)}</div></div><div><label>${T('Issuer')}</label><div class="v">${esc(r.issuer)}</div></div>
|
||||
<div><label>${T('Subject')}</label><div class="v">${esc(r.subject)}</div></div><div><label>${T('Protocol')}</label><div class="v">${esc(r.protocol)}</div></div>
|
||||
<div style="grid-column:1/-1"><label>${T('Names')}</label><div class="v">${r.sans.map((x) => `<span class="tag">${esc(x)}</span>`).join(' ')}</div></div>
|
||||
<div style="grid-column:1/-1"><label>SHA-256</label><div class="v mono small" style="word-break:break-all">${esc(r.fingerprint)}</div></div></div>`;
|
||||
} catch (e) { out.innerHTML = `<div class="admin-empty" style="color:var(--red)">${esc(e.message)}</div>`; }
|
||||
};
|
||||
form.append(go);
|
||||
sec.append(form, h(`<div class="muted small" style="margin:4px 0 12px">${T('The connection is made from this computer – useful to check which certificate a web server actually delivers.')}</div>`), out);
|
||||
w.append(sec);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================ Samba / Active Directory
|
||||
const YES = (v) => /^(yes|true|1)$/i.test(String(v));
|
||||
|
||||
class SambaSession extends AdminSession {
|
||||
constructor(host) {
|
||||
super(host, { kind: 'samba', ch: 'samba', title: 'Samba', views: [['shares', T('Shares')], ['users', T('Users')], ['status', T('Status')], ['domain', T('Domain')]] });
|
||||
this.adView = 'users';
|
||||
}
|
||||
|
||||
notInstalled(w) {
|
||||
const box = h(`<div class="pane-empty" style="height:auto;padding:48px 20px"><div class="fw-off">${ICONS.users}<b>${T('Samba is not installed')}</b><span>${T('Install Samba to share folders with Windows clients or to run a domain controller.')}</span></div></div>`);
|
||||
const b = h(`<button class="btn primary">${ICONS.download}${T('Install Samba')}</button>`);
|
||||
b.onclick = () => this.term('installCommand', ['samba'], `${T('Install')} Samba`);
|
||||
$('.fw-off', box).append(b);
|
||||
w.append(box);
|
||||
}
|
||||
|
||||
summary(w) {
|
||||
const d = this.data;
|
||||
const g = d.global;
|
||||
w.append(h(`<div class="net-sys"><div><label>${T('Version')}</label><div class="v">${esc(d.version || '–')}</div></div>
|
||||
<div><label>${T('Server role')}</label><div class="v">${esc(d.role || '–')}</div></div>
|
||||
<div><label>${T('Workgroup')}</label><div class="v">${esc(g.workgroup || '–')}</div></div>
|
||||
${g.realm ? `<div><label>Realm</label><div class="v">${esc(g.realm)}</div></div>` : ''}</div>`));
|
||||
}
|
||||
|
||||
draw_shares(w) {
|
||||
const d = this.data;
|
||||
if (!d.installed) return this.notInstalled(w);
|
||||
this.summary(w);
|
||||
const sec = this.section(T('Shares'), [
|
||||
{ label: T('Edit smb.conf'), icon: 'edit', run: () => this.editConf() },
|
||||
{ label: T('New share'), icon: 'plus', cls: 'primary', run: () => this.editShare(null) },
|
||||
]);
|
||||
if (!d.shares.length) sec.append(this.empty(T('No shares yet.')));
|
||||
else sec.append(this.table(d.shares, [
|
||||
[T('Share'), (s) => `<b>${esc(s.name)}</b>${s.comment ? `<div class="muted small">${esc(s.comment)}</div>` : ''}`, 'name'],
|
||||
[T('Path'), (s) => `<span class="mono small">${esc(s.path || '–')}</span>`],
|
||||
[T('Access'), (s) => `<span class="fw-act ${YES(s.readOnly) ? '' : 'allow'}">${YES(s.readOnly) ? T('read only') : T('read/write')}</span>${YES(s.guestOk) ? ` <span class="fw-act limit">${T('guest')}</span>` : ''}${YES(s.browseable) ? '' : ` <span class="fw-act">${T('hidden')}</span>`}`],
|
||||
[T('Users'), (s) => esc(s.validUsers || T('all'))],
|
||||
], (s) => [
|
||||
{ icon: 'edit', title: T('Edit'), run: () => (s.special ? this.editConf() : this.editShare(s)) },
|
||||
!s.special && { icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete share?'), T('The share “{name}” will be removed from smb.conf. The files in {path} are kept.', { name: s.name, path: s.path || '–' }))) this.act('deleteShare', [s.name], T('Share deleted')); } },
|
||||
]));
|
||||
w.append(sec);
|
||||
}
|
||||
|
||||
editConf() {
|
||||
this.editFile('/etc/samba/smb.conf', { read: () => this.op('readConf'), save: (c) => ['writeConf', c], note: T('Saving checks the file with testparm and reloads Samba. If the check fails, the previous version is restored.') });
|
||||
}
|
||||
|
||||
async editShare(s) {
|
||||
const v = s || { readOnly: 'no', browseable: 'yes', guestOk: 'no' };
|
||||
const r = await modal({ title: s ? T('Edit share') : T('New share'), body: `
|
||||
<div class="row"><div class="field"><label>${T('Name')}</label><input name="name" value="${esc(v.name || '')}" placeholder="daten"/></div>
|
||||
<div class="field"><label>${T('Path')}</label><input name="path" value="${esc(v.path || '')}" placeholder="/srv/samba/daten"/></div></div>
|
||||
<div class="field"><label>${T('Comment')}</label><input name="comment" value="${esc(v.comment || '')}" placeholder="${esc(T('optional'))}"/></div>
|
||||
<div class="row"><label class="check"><input type="checkbox" name="readOnly" ${YES(v.readOnly) ? 'checked' : ''}/>${T('Read only')}</label>
|
||||
<label class="check"><input type="checkbox" name="browseable" ${YES(v.browseable) ? 'checked' : ''}/>${T('Visible in the network')}</label>
|
||||
<label class="check"><input type="checkbox" name="guestOk" ${YES(v.guestOk) ? 'checked' : ''}/>${T('Guest access')}</label></div>
|
||||
<div class="field"><label>${T('Allowed users / groups')}</label><input name="validUsers" value="${esc(v.validUsers || '')}" placeholder="${esc(T('empty = all; e.g. anna, @staff'))}"/></div>
|
||||
<div class="field"><label>${T('Users with write access')}</label><input name="writeList" value="${esc(v.writeList || '')}" placeholder="${esc(T('optional; e.g. @admins'))}"/></div>
|
||||
<div class="row"><div class="field"><label>${T('File permissions (create mask)')}</label><input name="createMask" value="${esc(v.createMask || '')}" placeholder="0664"/></div>
|
||||
<div class="field"><label>${T('Folder permissions (directory mask)')}</label><input name="directoryMask" value="${esc(v.directoryMask || '')}" placeholder="2775"/></div></div>
|
||||
<label class="check"><input type="checkbox" name="createDir" ${s ? '' : 'checked'}/>${T('Create the folder if it does not exist')}</label>`,
|
||||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
|
||||
if (!r) return;
|
||||
const yn = (b) => (b ? 'yes' : 'no');
|
||||
const known = ['path', 'comment', 'read only', 'browseable', 'guest ok', 'valid users', 'write list', 'create mask', 'directory mask'];
|
||||
const extra = s ? Object.fromEntries(Object.entries(s.params || {}).filter(([k]) => !known.includes(k))) : {};
|
||||
this.act('saveShare', [{ ...r, readOnly: yn(r.readOnly), browseable: yn(r.browseable), guestOk: yn(r.guestOk), extra }, s?.name || null, r.createDir], T('Share saved'));
|
||||
}
|
||||
|
||||
draw_users(w) {
|
||||
const d = this.data;
|
||||
if (!d.installed) return this.notInstalled(w);
|
||||
if (d.dc) {
|
||||
w.append(h(`<div class="net-banner">${ICONS.users}<div>${T('This server is an Active Directory domain controller. Users are managed in the “Domain” view.')}</div></div>`));
|
||||
return;
|
||||
}
|
||||
const sec = this.section(T('Samba users'), [{ label: T('Add user'), icon: 'plus', cls: 'primary', run: () => this.addUser() }]);
|
||||
if (!d.users.length) sec.append(this.empty(T('No Samba users yet. Samba users need a Linux account and their own Samba password.')));
|
||||
else sec.append(this.table(d.users, [
|
||||
[T('User'), (u) => `<b>${esc(u.user)}</b>${u.fullName ? `<div class="muted small">${esc(u.fullName)}</div>` : ''}`, 'name'],
|
||||
[T('Status'), (u) => (u.disabled ? `<span class="fw-act deny">${T('disabled')}</span>` : `<span class="fw-act allow">${T('active')}</span>`)],
|
||||
[T('Flags'), (u) => `<span class="mono small">${esc(u.flags)}</span>`],
|
||||
], (u) => [
|
||||
{ icon: 'key', title: T('Set password'), run: () => this.password((pw) => this.act('setPassword', [u.user, pw], T('Password changed')), u.user) },
|
||||
{ icon: u.disabled ? 'play' : 'stop', title: u.disabled ? T('Enable') : T('Disable'), run: () => this.act('setUserEnabled', [u.user, u.disabled], u.disabled ? T('User enabled') : T('User disabled')) },
|
||||
{ icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete Samba user?'), T('“{name}” can no longer access shares. The Linux account is kept.', { name: u.user }))) this.act('deleteUser', [u.user], T('User deleted')); } },
|
||||
]));
|
||||
w.append(sec);
|
||||
}
|
||||
|
||||
async password(run, name) {
|
||||
const r = await modal({ title: T('Set password'), text: name, body: `<div class="field"><label>${T('New password')}</label><input name="pw" type="password" autocomplete="new-password"/></div><div class="field"><label>${T('Repeat password')}</label><input name="pw2" type="password" autocomplete="new-password"/></div>`,
|
||||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
|
||||
if (!r) return;
|
||||
if (!r.pw || r.pw !== r.pw2) return toast(T('The passwords do not match.'), 'error');
|
||||
run(r.pw, r);
|
||||
}
|
||||
|
||||
async addUser() {
|
||||
const r = await modal({ title: T('Add Samba user'), body: `
|
||||
<div class="field"><label>${T('Username')}</label><input name="user" autocomplete="off"/></div>
|
||||
<div class="row"><div class="field"><label>${T('Password')}</label><input name="pw" type="password" autocomplete="new-password"/></div><div class="field"><label>${T('Repeat password')}</label><input name="pw2" type="password" autocomplete="new-password"/></div></div>
|
||||
<label class="check"><input type="checkbox" name="createUnix" checked/>${T('Create a Linux account without login if it does not exist')}</label>`,
|
||||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Add'), value: 'form', cls: 'primary' }] });
|
||||
if (!r) return;
|
||||
if (!r.pw || r.pw !== r.pw2) return toast(T('The passwords do not match.'), 'error');
|
||||
this.act('addUser', [r.user.trim(), r.pw, r.createUnix], T('User added'));
|
||||
}
|
||||
|
||||
draw_status(w) {
|
||||
const d = this.data;
|
||||
if (!d.installed) return this.notInstalled(w);
|
||||
const svc = this.section(T('Services'));
|
||||
if (!d.units.length) svc.append(this.empty(T('No Samba services found.')));
|
||||
else {
|
||||
svc.append(this.table(d.units, [[T('Service'), (u) => `<b>${esc(u.unit)}</b>`], [T('Status'), (u) => pill(u.active)]], () => []));
|
||||
$$('tbody tr', svc).forEach((tr, i) => $('.acts', tr).append(this.serviceButtons(d.units[i], 'service', false)));
|
||||
}
|
||||
w.append(svc);
|
||||
const st = d.status;
|
||||
const con = this.section(T('Connected clients'));
|
||||
if (st.raw !== undefined) con.append(h(`<pre class="fw-pre mono">${esc(st.raw || T('No connections.'))}</pre>`));
|
||||
else if (!st.sessions.length) con.append(this.empty(T('No connections.')));
|
||||
else con.append(this.table(st.sessions, [[T('User'), (x) => `<b>${esc(x.user)}</b>`], [T('Computer'), (x) => esc(x.machine || x.host)], [T('Protocol'), (x) => esc(x.protocol)], [T('Encryption'), (x) => esc(x.encryption || '–')],
|
||||
[T('Shares'), (x) => st.shares.filter((t) => t.session === x.id).map((t) => `<span class="tag">${esc(t.service)}</span>`).join(' ')]]));
|
||||
w.append(con);
|
||||
if (st.files?.length) {
|
||||
const f = this.section(T('Open files'));
|
||||
f.append(this.table(st.files, [[T('File'), (x) => `<span class="mono small">${esc(x.path)}</span>`]]));
|
||||
w.append(f);
|
||||
}
|
||||
}
|
||||
|
||||
async draw_domain(w) {
|
||||
const d = this.data;
|
||||
if (!d.dc) return this.drawMembership(w);
|
||||
const seg = h(`<div class="seg sm ad-seg">${[['users', T('Users')], ['groups', T('Groups')], ['computers', T('Computers')], ['info', T('Domain info')]].map(([v, l]) => `<button data-ad="${v}" class="${this.adView === v ? 'active' : ''}">${esc(l)}</button>`).join('')}</div>`);
|
||||
seg.onclick = (e) => { const v = e.target.closest('[data-ad]')?.dataset.ad; if (v) { this.adView = v; this.draw(); } };
|
||||
w.append(seg);
|
||||
const box = h(`<div>${spinnerHtml(T('Loading …'))}</div>`);
|
||||
w.append(box);
|
||||
try { if (!this.ad || this.adStale) { this.ad = await this.op('adRead'); this.adStale = false; } } catch { box.innerHTML = ''; return; }
|
||||
box.innerHTML = '';
|
||||
const ad = this.ad;
|
||||
if (ad.limited) box.append(h(`<div class="net-banner warn">${ICONS.wall}<div>${T('ldbsearch is missing (package ldb-tools), so only names are shown.')}</div></div>`));
|
||||
const reloadAd = async (name, args, msg) => { this.adStale = true; return this.act(name, args, msg); };
|
||||
if (this.adView === 'users') {
|
||||
const sec = this.section(T('Domain users'), [{ label: T('New user'), icon: 'plus', cls: 'primary', run: () => this.adNewUser(reloadAd) }]);
|
||||
sec.append(this.table(ad.users, [
|
||||
[T('User'), (u) => `<b>${esc(u.name)}</b>${u.display ? `<div class="muted small">${esc(u.display)}</div>` : ''}`, 'name'],
|
||||
[T('E-mail'), (u) => esc(u.mail || '')],
|
||||
[T('Groups'), (u) => (u.groups || []).slice(0, 4).map((g) => `<span class="tag">${esc(g)}</span>`).join(' ') + ((u.groups || []).length > 4 ? ` <span class="muted">+${u.groups.length - 4}</span>` : '')],
|
||||
[T('Last logon'), (u) => (u.lastLogon ? esc(relTime(u.lastLogon)) : '<span class="muted">–</span>')],
|
||||
[T('Status'), (u) => (u.disabled ? `<span class="fw-act deny">${T('disabled')}</span>` : `<span class="fw-act allow">${T('active')}</span>`)],
|
||||
], (u) => [
|
||||
{ icon: 'key', title: T('Set password'), run: () => this.adPassword(u.name, reloadAd) },
|
||||
{ icon: u.disabled ? 'play' : 'stop', title: u.disabled ? T('Enable') : T('Disable'), run: () => reloadAd('adUserEnable', [u.name, !!u.disabled], u.disabled ? T('User enabled') : T('User disabled')) },
|
||||
!/^(Administrator|krbtgt|Guest)$/i.test(u.name) && { icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete domain user?'), T('“{name}” will be permanently deleted from the domain.', { name: u.name }))) reloadAd('adUserDelete', [u.name], T('User deleted')); } },
|
||||
]));
|
||||
box.append(sec);
|
||||
} else if (this.adView === 'groups') {
|
||||
const sec = this.section(T('Groups'), [{ label: T('New group'), icon: 'plus', cls: 'primary', run: async () => {
|
||||
const r = await modal({ title: T('New group'), body: `<div class="field"><label>${T('Name')}</label><input name="name"/></div><div class="field"><label>${T('Description')}</label><input name="description" placeholder="${esc(T('optional'))}"/></div>`, buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Create'), value: 'form', cls: 'primary' }] });
|
||||
if (r) reloadAd('adGroupCreate', [r.name, r.description], T('Group created'));
|
||||
} }]);
|
||||
sec.append(this.table(ad.groups, [
|
||||
[T('Group'), (g) => `<b>${esc(g.name)}</b>${g.description ? `<div class="muted small">${esc(g.description)}</div>` : ''}`, 'name'],
|
||||
[T('Members'), (g) => `${(g.members || []).length}`, 'num'],
|
||||
], (g) => [
|
||||
{ icon: 'users', title: T('Members'), run: () => this.adMembers(g, reloadAd) },
|
||||
!g.builtin && { icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete group?'), T('The group “{name}” will be deleted.', { name: g.name }))) reloadAd('adGroupDelete', [g.name], T('Group deleted')); } },
|
||||
]));
|
||||
box.append(sec);
|
||||
} else if (this.adView === 'computers') {
|
||||
const sec = this.section(T('Computers'));
|
||||
sec.append(this.table(ad.computers, [
|
||||
[T('Computer'), (c) => `<b>${esc(c.name)}</b>${c.dns ? `<div class="muted small">${esc(c.dns)}</div>` : ''}`, 'name'],
|
||||
[T('Operating system'), (c) => esc(c.os || '')],
|
||||
[T('Last logon'), (c) => (c.lastLogon ? esc(relTime(c.lastLogon)) : '<span class="muted">–</span>')],
|
||||
], (c) => [{ icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete computer account?'), T('“{name}” will be removed from the domain and must join again.', { name: c.name }))) reloadAd('adComputerDelete', [c.name], T('Computer deleted')); } }]));
|
||||
box.append(sec);
|
||||
} else {
|
||||
box.append(h(`<pre class="fw-pre mono">${esc([ad.info, ad.level, ad.fsmo].filter(Boolean).join('\n\n'))}</pre>`));
|
||||
}
|
||||
}
|
||||
|
||||
// Kein Domänencontroller: Mitgliedschaft anzeigen, beitreten/verlassen oder eine neue Domäne erstellen
|
||||
drawMembership(w) {
|
||||
const d = this.data;
|
||||
const tools = new Set(d.tools);
|
||||
const joined = (d.realm.match(/^(\S+)\s*$/m) || [])[1];
|
||||
const btn = (label, icon, run, cls = '') => { const b = h(`<button class="btn sm ${cls}">${ICONS[icon]}${esc(label)}</button>`); b.onclick = run; return b; };
|
||||
|
||||
const mem = this.section(T('Domain membership'));
|
||||
if (joined) {
|
||||
mem.append(h(`<div class="net-banner">${ICONS.users}<div>${T('This server is a member of the domain {domain}.', { domain: `<b>${esc(joined)}</b>` })}</div></div>`));
|
||||
mem.append(h(`<pre class="fw-pre mono" style="margin-top:10px">${esc(d.realm)}</pre>`));
|
||||
const acts = h('<div class="cert-acts"></div>');
|
||||
acts.append(btn(T('Leave domain'), 'close', async () => { if (await confirmBox(T('Leave domain?'), T('Domain users can no longer log in on this server. The computer account in the domain is kept.'), T('Leave'))) this.act('leaveDomain', [joined], T('Left the domain')); }, 'danger'));
|
||||
mem.append(acts);
|
||||
} else {
|
||||
mem.append(this.empty(T('This server is not a member of a domain.')));
|
||||
if (d.winbind) mem.append(h(`<div class="muted small">winbind: ${esc(d.winbind)}</div>`));
|
||||
const acts = h('<div class="cert-acts"></div>');
|
||||
if (tools.has('realm')) acts.append(btn(T('Join domain …'), 'plus', () => this.joinDialog(), 'primary'));
|
||||
else acts.append(btn(T('Install domain join tools'), 'download', () => this.term('installCommand', ['join'], `${T('Install')} realmd/sssd`), 'primary'));
|
||||
mem.append(acts);
|
||||
}
|
||||
w.append(mem);
|
||||
|
||||
if (!joined) {
|
||||
const dc = this.section(T('Create a new domain'));
|
||||
dc.append(h(`<div class="muted" style="font-size:13px;margin-bottom:10px">${T('Turns this server into the first domain controller of a new Active Directory domain. Windows and Linux computers can then join it, and you manage users, groups and computers right here.')}</div>`));
|
||||
const acts = h('<div class="cert-acts" style="margin-left:0"></div>');
|
||||
if (tools.has('samba-tool')) acts.append(btn(T('Create domain …'), 'plus', () => this.provisionDialog(), 'primary'));
|
||||
acts.append(btn(T('Install domain controller packages'), 'download', () => this.term('installCommand', ['dc'], `${T('Install')} Samba AD DC`)));
|
||||
dc.append(acts);
|
||||
w.append(dc);
|
||||
}
|
||||
}
|
||||
|
||||
async joinDialog() {
|
||||
const r = await modal({ title: T('Join domain'), body: `
|
||||
<div class="field"><label>${T('Domain')}</label><input name="domain" placeholder="corp.example.com"/></div>
|
||||
<div class="row"><div class="field"><label>${T('Domain administrator')}</label><input name="admin" value="Administrator"/></div><div class="field"><label>${T('Password')}</label><input name="password" type="password" autocomplete="off"/></div></div>
|
||||
<div class="hint" style="color:var(--muted);font-size:12px">${T('The server must resolve the domain via DNS (the domain controller as DNS server). Logins of domain users are then handled by sssd.')}</div>`,
|
||||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Join'), value: 'form', cls: 'primary' }] });
|
||||
if (!r) return;
|
||||
this.body.innerHTML = spinnerHtml(T('Joining {domain} …', { domain: r.domain }));
|
||||
if (!(await this.act('joinDomain', [r.domain, r.admin, r.password], T('Joined the domain')))) this.draw();
|
||||
}
|
||||
|
||||
async provisionDialog() {
|
||||
const host = (this.host.address || '').includes('.') && !/^\d/.test(this.host.address) ? this.host.address.split('.').slice(1).join('.') : '';
|
||||
const r = await formModal({ title: T('Create a new domain'), body: `
|
||||
<div class="row"><div class="field"><label>${T('DNS domain (realm)')}</label><input name="realm" placeholder="corp.example.com" value="${esc(host)}"/></div>
|
||||
<div class="field small" style="flex:0 0 150px"><label>${T('NetBIOS name')}</label><input name="domain" placeholder="CORP" maxlength="15"/></div></div>
|
||||
<div class="row"><div class="field"><label>${T('Password for Administrator')}</label><input name="password" type="password" autocomplete="new-password"/></div><div class="field"><label>${T('Repeat password')}</label><input name="password2" type="password" autocomplete="new-password"/></div></div>
|
||||
<div class="field"><label>${T('DNS forwarder')}</label><input name="forwarder" placeholder="1.1.1.1"/><div class="hint">${T('Queries for other domains are forwarded to this DNS server.')}</div></div>
|
||||
<label class="check"><input type="checkbox" name="useAsDns" checked/>${T('Use this domain controller as DNS server of the host (resolv.conf)')}</label>
|
||||
<label class="check"><input type="checkbox" name="rfc2307"/>${T('Store Unix attributes (UID/GID) in the directory (RFC 2307)')}</label>
|
||||
<div class="net-banner warn" style="margin-top:10px">${ICONS.wall}<div>${T('The file server services (smbd/nmbd) are replaced by samba-ad-dc; the previous smb.conf is kept as smb.conf.mrterm-before-dc. The server needs a fixed IP address and a hostname that is not the domain name. The password must be complex (upper/lower case, digits, 8+ characters).')}</div></div>`,
|
||||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Create domain'), value: 'form', cls: 'primary' }] }, (m) => {
|
||||
$('[name=realm]', m).oninput = (e) => { const n = $('[name=domain]', m); if (!n.dataset.touched) n.value = (e.target.value.split('.')[0] || '').toUpperCase().slice(0, 15); };
|
||||
$('[name=domain]', m).oninput = (e) => { e.target.dataset.touched = '1'; };
|
||||
$('[name=realm]', m).dispatchEvent(new Event('input'));
|
||||
});
|
||||
if (!r) return;
|
||||
if (!r.password || r.password !== r.password2) return toast(T('The passwords do not match.'), 'error');
|
||||
this.body.innerHTML = spinnerHtml(T('Creating the domain {realm} … this takes about a minute.', { realm: r.realm.toUpperCase() }));
|
||||
this.ad = null;
|
||||
if (await this.act('provisionDomain', [r], T('Domain {realm} created', { realm: r.realm.toUpperCase() }))) { this.adView = 'info'; this.draw(); } else this.draw();
|
||||
}
|
||||
|
||||
async adNewUser(reloadAd) {
|
||||
const r = await modal({ title: T('New domain user'), body: `
|
||||
<div class="field"><label>${T('Username')}</label><input name="name" autocomplete="off"/></div>
|
||||
<div class="row"><div class="field"><label>${T('First name')}</label><input name="given"/></div><div class="field"><label>${T('Last name')}</label><input name="surname"/></div></div>
|
||||
<div class="field"><label>${T('E-mail')}</label><input name="mail" placeholder="${esc(T('optional'))}"/></div>
|
||||
<div class="row"><div class="field"><label>${T('Password')}</label><input name="password" type="password" autocomplete="new-password"/></div><div class="field"><label>${T('Repeat password')}</label><input name="password2" type="password" autocomplete="new-password"/></div></div>
|
||||
<label class="check"><input type="checkbox" name="mustChange" checked/>${T('User must change the password at next logon')}</label>`,
|
||||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Create'), value: 'form', cls: 'primary' }] });
|
||||
if (!r) return;
|
||||
if (!r.password || r.password !== r.password2) return toast(T('The passwords do not match.'), 'error');
|
||||
reloadAd('adUserCreate', [r], T('User created'));
|
||||
}
|
||||
|
||||
async adPassword(name, reloadAd) {
|
||||
const r = await modal({ title: T('Set password'), text: name, body: `<div class="field"><label>${T('New password')}</label><input name="pw" type="password" autocomplete="new-password"/></div><div class="field"><label>${T('Repeat password')}</label><input name="pw2" type="password" autocomplete="new-password"/></div><label class="check"><input type="checkbox" name="mustChange"/>${T('User must change the password at next logon')}</label>`,
|
||||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
|
||||
if (!r) return;
|
||||
if (!r.pw || r.pw !== r.pw2) return toast(T('The passwords do not match.'), 'error');
|
||||
reloadAd('adSetPassword', [name, r.pw, r.mustChange], T('Password changed'));
|
||||
}
|
||||
|
||||
async adMembers(g, reloadAd) {
|
||||
const users = (this.ad.users || []).map((u) => u.name);
|
||||
const r = await modal({ title: T('Members of {name}', { name: g.name }), body: `<div class="field"><label>${T('One member per line (users, groups or computers)')}</label><textarea name="members" rows="10" class="mono" spellcheck="false">${esc((g.members || []).join('\n'))}</textarea></div>
|
||||
${users.length ? `<div class="hint" style="color:var(--muted);font-size:12px">${T('Users')}: ${users.slice(0, 40).map(esc).join(', ')}${users.length > 40 ? ' …' : ''}</div>` : ''}`,
|
||||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
|
||||
if (!r) return;
|
||||
const now = r.members.split('\n').map((x) => x.trim()).filter(Boolean);
|
||||
const before = g.members || [];
|
||||
const add = now.filter((x) => !before.includes(x)), remove = before.filter((x) => !now.includes(x));
|
||||
if (add.length || remove.length) reloadAd('adGroupMembers', [g.name, add, remove], T('Members updated'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user