प्रकाशित सोर्स
सोर्स ZIP डाउनलोड करें
CH-J Server Manager
किसी विशेष ऐप्लिकेशन रिलीज़ की डायरेक्टरी और फ़ाइलें देखें।
CH-J / Server Manager
368 फ़ाइलें
core-20261017T201717Z-057348c4a70f
जोड़ा गया: तारीख दर्ज नहीं · बिल्ड किया गया:
1
"use strict";3
const api = window.chjPlugin;4
const el = new Proxy({}, { get: (_target, id) => document.getElementById(id) });6
// Every asynchronous result is checked against these tokens, so data of a previous session or7
// account is never rendered after the user switched.8
const state = {9
sessions: [],10
sessionId: "",11
generation: 0,12
detailToken: 0,13
privilege: null,14
inventory: null,15
selected: null,16
detail: null,17
detailLoading: false,18
tab: "account",19
loading: false,20
mutating: false21
};23
const PRIVILEGE_TEXT = {24
root: ["root", "ok"],25
"sudo-nopasswd": ["sudo bez hesla", "ok"],26
"sudo-password": ["sudo s heslem", "warn"],27
none: ["bez sudo – jen čtení", "danger"],28
unknown: ["neověřeno", "muted"],29
"windows-elevated": ["správce Windows (zvýšený token)", "ok"],30
"windows-admin-unelevated": ["člen Administrators bez zvýšeného tokenu – jen čtení", "warn"],31
"windows-standard": ["standardní účet Windows – jen čtení", "danger"]32
};33
const SUDO_TEXT = {34
full: "plné sudo",35
"full-nopasswd": "plné sudo bez hesla",36
limited: "omezené sudo",37
none: "bez sudo",38
unavailable: "sudo není nainstalováno",39
unknown: "neznámé"40
};41
const PASSWORD_TEXT = {42
set: ["heslo nastaveno", "ok"],43
locked: ["heslo zamčeno", "warn"],44
none: ["bez hesla", "muted"],45
empty: ["prázdné heslo!", "danger"],46
unknown: ["stav hesla neznámý", "muted"]47
};48
const PROTECTION_TEXT = {49
root: "Chráněný systémový účet root: nelze ho smazat ani měnit UID/GID, skupiny, shell či domovský adresář. SSH přístup (včetně PermitRootLogin) a SSH klíče lze spravovat s bezpečnostními kontrolami.",50
system: "Systémový účet je chráněný. CH-J ho zobrazuje jen pro čtení.",51
builtin: "Vestavěný účet Windows (Administrator, Guest, DefaultAccount, WDAGUtilityAccount) je chráněný a zobrazuje se jen pro čtení.",52
session: "Tímto účtem je CH-J právě připojeno. Změny, které by mohly přerušit přístup, jsou zablokované; SSH klíče a SSH přístup lze měnit s ochranou proti odříznutí."53
};54
const ERROR_TEXT = {55
SUDO_PASSWORD_REQUIRED: "Operace vyžaduje heslo pro sudo.",56
SUDO_PASSWORD_INCORRECT: "Heslo pro sudo bylo odmítnuto.",57
SUDO_NOT_PERMITTED: "Účet SSH relace nemá pro tuto operaci oprávnění sudo. Zadání hesla to nezmění.",58
SUDO_TTY_REQUIRED: "Politika sudo na serveru vyžaduje terminál (requiretty). CH-J ji neobchází.",59
SUDO_AUTH_FAILED: "Ověření sudo selhalo nebo je účet dočasně zablokován.",60
SUDO_UNAVAILABLE: "Na serveru není sudo a relace neběží jako root.",61
SUDO_FAILED: "sudo operaci nespustilo.",62
SUDO_PASSWORD_INVALID: "Heslo pro sudo má neplatný formát.",63
SESSION_NOT_CONNECTED: "SSH relace byla odpojena. Připojte se znovu v hlavním okně.",64
REMOTE_COMMAND_TIMEOUT: "Server neodpověděl včas.",65
REMOTE_COMMAND_FAILED: "Vzdálený příkaz se nepodařilo spustit.",66
REMOTE_OUTPUT_TOO_LARGE: "Odpověď serveru je příliš velká.",67
PLUGIN_PERMISSION_DENIED: "Plugin nemá potřebné oprávnění.",68
USERS_NAME_INVALID: "Neplatný název. Povolené jsou malé znaky a-z, číslice, _ a - (max. 32, nezačíná číslicí ani pomlčkou).",69
USERS_GROUP_INVALID: "Neplatný název skupiny.",70
USERS_SHELL_INVALID: "Neplatný shell (musí existovat a být v /etc/shells, případně nologin/false).",71
USERS_HOME_INVALID: "Neplatný domovský adresář. Použijte absolutní cestu mimo systémové adresáře.",72
USERS_HOME_EXISTS: "Cílový domovský adresář už existuje.",73
USERS_COMMENT_INVALID: "Celé jméno může mít nejvýše 100 znaků bez dvojtečky a řídicích znaků.",74
USERS_PASSWORD_INVALID: "Heslo musí mít 1–256 znaků a nesmí obsahovat zalomení řádku.",75
USERS_USER_EXISTS: "Účet s tímto jménem už existuje.",76
USERS_GROUP_EXISTS: "Skupina se stejným názvem už existuje; zvolte primární skupinu.",77
USERS_USER_NOT_FOUND: "Účet na serveru neexistuje.",78
USERS_GROUP_NOT_FOUND: "Skupina na serveru neexistuje.",79
USERS_GROUP_NOT_MEMBER: "Účet není doplňkovým členem skupiny.",80
USERS_GROUP_PRIMARY: "Primární skupinu nelze odebrat; nejprve změňte primární skupinu.",81
USERS_PROTECTED_USER: "Účet je chráněný.",82
USERS_LAST_ADMIN: "Jde o posledního funkčního administrátora; změna byla odmítnuta.",83
USERS_USER_HAS_PROCESSES: "Účet má spuštěné procesy. Ukončete je a zkuste to znovu.",84
USERS_TOOL_MISSING: "Na serveru chybí potřebný systémový nástroj.",85
USERS_SUDO_GROUP_UNAVAILABLE: "Na serveru není skupina sudo/wheel/admin, na kterou by odkazoval sudoers. CH-J sudoers nemění.",86
USERS_USERADD_FAILED: "useradd selhal:",87
USERS_USERMOD_FAILED: "usermod selhal:",88
USERS_USERDEL_FAILED: "userdel selhal:",89
USERS_PASSWORD_FAILED: "Nastavení hesla selhalo:",90
USERS_GROUP_CHANGE_FAILED: "Změna skupiny selhala:",91
USERS_KEY_INVALID: "Neplatný veřejný klíč. Vložte přesně jeden klíč OpenSSH bez voleb.",92
USERS_KEY_PRIVATE: "Vložili jste soukromý klíč! Na server se smí nahrát jen veřejný klíč (.pub).",93
USERS_KEY_TYPE_UNSUPPORTED: "Přidat lze jen klíče Ed25519.",94
USERS_KEY_DUPLICATE: "Tento klíč už je pro účet povolený.",95
USERS_KEY_NOT_FOUND: "Klíč se mezitím změnil nebo neexistuje. Obnovte seznam.",96
USERS_KEY_IN_USE: "Tento klíč používá aktivní relace CH-J, proto nebyl odebrán.",97
USERS_KEY_LAST: "Poslední klíč účtu aktivní relace nelze odebrat.",98
USERS_KEYS_FILE_UNMANAGEABLE: "Soubor authorized_keys nelze bezpečně upravit:",99
USERS_KEYS_FILE_TOO_LARGE: "Soubor authorized_keys by přesáhl 256 KiB.",100
USERS_KEYS_SYMLINK: "authorized_keys nebo .ssh je symbolický odkaz; soubor nebyl změněn.",101
USERS_KEYS_CHANGED: "Soubor s klíči se mezitím změnil. Obnovte seznam a zkuste to znovu.",102
USERS_KEYS_PATH_UNSUPPORTED: "Cesta k authorized_keys mimo domovský adresář není podporovaná.",103
USERS_SSH_POLICY_INVALID: "Neplatné nastavení SSH.",104
USERS_SSH_LAYOUT_UNSUPPORTED: "sshd_config nenačítá sshd_config.d před prvním blokem Match. CH-J hlavní konfiguraci neupravuje.",105
USERS_SSH_MANAGED_FILE_MODIFIED: "Soubor spravovaný CH-J byl ručně upraven; CH-J ho nepřepíše.",106
USERS_SSH_MANAGED_FILE_UNSAFE: "Soubor spravovaný CH-J není běžný čitelný soubor.",107
USERS_SSH_CONFIG_ALREADY_INVALID: "Současná konfigurace SSH je neplatná, změna nebyla provedena:",108
USERS_SSH_CONFIG_INVALID: "Nová konfigurace neprošla sshd -t a nebyla použita:",109
USERS_SSH_POLICY_NOT_EFFECTIVE: "Nastavení by se neprojevilo kvůli existujícím pravidlům s vyšší prioritou; vráceno zpět.",110
USERS_SSH_LOCKOUT_PREVENTED: "Změna byla zablokována, aby nepřerušila přístup aktivní relace:",111
USERS_SSH_PROBE_FAILED: "Nové testovací přihlášení selhalo; konfigurace byla vrácena:",112
USERS_SSH_PROBE_CREDENTIALS_REQUIRED: "Pro ověření nového přihlášení je potřeba heslo nebo passphrase profilu.",113
USERS_SSH_RELOAD_FAILED: "Reload SSH služby selhal; konfigurace byla vrácena:",114
USERS_SSH_POLICY_CHANGED: "Spravovaná SSH konfigurace se mezitím změnila. Obnovte detail a zkuste to znovu.",115
USERS_SSH_CLIENT_UNKNOWN: "Adresu klienta SSH relace nelze zjistit, ochranu proti odříznutí proto nelze ověřit.",116
USERS_SSHD_MISSING: "Na serveru nebyl nalezen OpenSSH server (sshd).",117
USERS_PRIVILEGE_REQUIRED: "Operace vyžaduje oprávnění root.",118
USERS_ROOT_PASSWORD_CONFIRMATION_REQUIRED: "Povolení hesla pro root vyžaduje dodatečné potvrzení.",119
USERS_NAME_INVALID_WINDOWS: "Neplatný název účtu Windows: písmena, číslice, mezera, tečka, _ nebo - (max. 20), nesmí končit tečkou ani mezerou.",120
USERS_SID_INVALID: "Neplatný identifikátor účtu (SID).",121
USERS_WINDOWS_ELEVATION_REQUIRED: "Operace vyžaduje SSH relaci správce se zvýšeným tokenem. Windows nemá sudo a CH-J neobchází UAC:",122
USERS_ACCESS_DENIED: "Windows operaci zamítl (přístup odepřen bezpečnostní politikou):",123
USERS_POWERSHELL_UNAVAILABLE: "Na serveru nelze spustit Windows PowerShell (powershell.exe).",124
USERS_POWERSHELL_RESTRICTED: "PowerShell je omezen politikou (Constrained Language Mode / blokování skriptů); CH-J ji neobchází.",125
USERS_LOCALACCOUNTS_UNAVAILABLE: "Modul Microsoft.PowerShell.LocalAccounts není v PowerShellu na serveru dostupný.",126
USERS_DOMAIN_READ_ONLY: "Doménové a cloudové identity jsou jen pro čtení.",127
USERS_NOT_SUPPORTED_ON_PLATFORM: "Tato operace na Windows neexistuje (shell, HOME, primární skupina, zamknutí hesla).",128
USERS_PASSWORD_POLICY: "Heslo nesplňuje politiku hesel Windows.",129
USERS_PROFILE_MISSING: "Profil účtu zatím neexistuje (účet se ještě nepřihlásil), soubor klíčů nelze vytvořit.",130
USERS_SSH_NAME_UNSUPPORTED: "Název účtu nelze bezpečně použít v sshd_config.",131
USERS_SSH_APPLY_IN_PROGRESS: "Jiná změna SSH konfigurace ještě probíhá nebo čeká na potvrzení.",132
USERS_SSH_APPLY_NOT_STARTED: "Úloha s automatickým rollbackem se nespustila; nic nebylo změněno.",133
USERS_SSH_ROLLED_BACK: "Změna SSH nebyla ponechána, původní konfigurace byla obnovena:",134
USERS_SSHD_QUERY_FAILED: "Vyhodnocení sshd -T pod účtem SYSTEM se nepodařilo.",135
USERS_PLATFORM_UNSUPPORTED: "Operační systém serveru nelze rozpoznat nebo není podporován:",136
USERS_GROUP_MEMBER_EXISTS: "Účet už je členem skupiny.",137
USERS_PROFILE_IN_USE: "Profil účtu je stále načten (uživatel je přihlášen nebo ho Windows po SSH přihlášení ještě neuvolnil). Zkuste to později, nebo odstraňte účet bez profilu.",138
USERS_WINDOWS_OPERATION_FAILED: "Operace Windows selhala:"139
};140
const DETAIL_CODES = new Set(["USERS_USERADD_FAILED", "USERS_USERMOD_FAILED", "USERS_USERDEL_FAILED", "USERS_PASSWORD_FAILED", "USERS_GROUP_CHANGE_FAILED",141
"USERS_KEYS_FILE_UNMANAGEABLE", "USERS_SSH_CONFIG_ALREADY_INVALID", "USERS_SSH_CONFIG_INVALID", "USERS_SSH_POLICY_NOT_EFFECTIVE", "USERS_SSH_LOCKOUT_PREVENTED",142
"USERS_SSH_PROBE_FAILED", "USERS_SSH_RELOAD_FAILED", "USERS_PROTECTED_USER", "USERS_TOOL_MISSING", "USERS_WINDOWS_ELEVATION_REQUIRED", "USERS_ACCESS_DENIED",143
"USERS_SSH_ROLLED_BACK", "USERS_PLATFORM_UNSUPPORTED", "USERS_WINDOWS_OPERATION_FAILED", "USERS_PASSWORD_POLICY"]);144
const POLICY_ROWS = [145
{ key: "access", label: "SSH přístup", hint: "Úplné povolení nebo zákaz SSH bez zrušení systémového účtu.", options: [["allow", "Povolit"], ["deny", "Zakázat"]] },146
{ key: "password", label: "Přihlášení heslem", hint: "Dědit = platí globální nastavení serveru.", options: [["inherit", "Dědit"], ["yes", "Povolit"], ["no", "Zakázat"]] },147
{ key: "pubkey", label: "Přihlášení veřejným klíčem", hint: "Zákaz zablokuje i Ed25519 klíče.", options: [["inherit", "Dědit"], ["yes", "Povolit"], ["no", "Zakázat"]] },148
{ key: "ipv4", label: "Přístup z IPv4", hint: "Podle IP rodiny zdrojové adresy klienta.", options: [["allow", "Povolit"], ["deny", "Zakázat"]] },149
{ key: "ipv6", label: "Přístup z IPv6", hint: "Podle IP rodiny zdrojové adresy klienta.", options: [["allow", "Povolit"], ["deny", "Zakázat"]] }150
];151
const ROOT_LOGIN_ROW = { key: "permitRootLogin", label: "PermitRootLogin (root)", hint: "Doporučeno: Jen klíč (prohibit-password). „Povolit“ dovolí i heslo podle ostatních pravidel.",152
options: [["inherit", "Dědit"], ["no", "Zakázat"], ["prohibit-password", "Jen klíč (doporučeno)"], ["forced-commands-only", "Klíč s vynuceným příkazem"], ["yes", "Povolit"]] };153
const CANCELLED = "CHJ_UI_CANCELLED";155
// ---------- small DOM helpers (text only, never innerHTML with server data) ----------156
function h(tag, props = {}, children = []) {157
const node = document.createElement(tag);158
for (const [key, value] of Object.entries(props)) {159
if (value === undefined || value === null || value === false) continue;160
if (key === "className") node.className = value;161
else if (key === "text") node.textContent = String(value);162
else if (key === "dataset") Object.assign(node.dataset, value);163
else if (key.startsWith("on") && typeof value === "function") node.addEventListener(key.slice(2), value);164
else if (typeof value === "boolean") node[key] = value;165
else node.setAttribute(key, String(value));166
}167
for (const child of [].concat(children)) {168
if (child === null || child === undefined || child === false) continue;169
node.append(child instanceof Node ? child : String(child));170
}171
return node;172
}173
function badge(text, tone = "muted") { return h("span", { className: `badge ${tone}`, text }); }174
function setStatus(message, error = false) { el.status.textContent = message; el.status.classList.toggle("error", error); }175
function connected() { return Boolean(state.sessionId); }176
function isWindows() { return state.inventory?.platform === "windows" || state.privilege?.platform === "windows"; }177
// Windows accounts are addressed by SID; Linux accounts by name.178
function identFor(username) {179
const user = state.inventory?.users.find((candidate) => candidate.username === username) || (state.detail?.username === username ? state.detail : null);180
return user?.sid && isWindows() ? { sid: user.sid, username } : { username };181
}183
function parseError(error) {184
const raw = String(error?.message || error || "");185
const match = raw.match(/\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+): ([\s\S]*)$/);186
if (match) return { code: match[1], detail: match[2].trim() };187
return { code: error?.code || "UNKNOWN", detail: raw.replace(/^Error invoking remote method '[^']+': (?:\w*Error: )?/, "") };188
}189
function describeError(error) {190
const { code, detail } = parseError(error);191
if (code === CANCELLED) return "Operace byla zrušena.";192
const base = ERROR_TEXT[code];193
if (!base) return detail || code;194
return DETAIL_CODES.has(code) && detail ? `${base} ${detail}` : base;195
}196
function cancelled() { const error = new Error(`${CANCELLED}: cancelled`); error.code = CANCELLED; return error; }198
// ---------- dialogs ----------199
function runDialog(dialog, { init, submit }) {200
return new Promise((resolve) => {201
const form = dialog.querySelector("form");202
let finished = false;203
const finish = (value) => {204
if (finished) return;205
finished = true;206
form.removeEventListener("submit", onSubmit);207
dialog.removeEventListener("close", onClose);208
for (const button of dialog.querySelectorAll("[data-close]")) button.removeEventListener("click", onClose);209
if (dialog.open) dialog.close();210
resolve(value);211
};212
const onSubmit = async (event) => {213
event.preventDefault();214
const value = await submit();215
if (value !== undefined) finish(value);216
};217
const onClose = () => finish(null);218
form.addEventListener("submit", onSubmit);219
dialog.addEventListener("close", onClose);220
for (const button of dialog.querySelectorAll("[data-close]")) button.addEventListener("click", onClose);221
init?.();222
dialog.showModal();223
});224
}225
function showError(node, message) { node.textContent = message || ""; node.hidden = !message; }227
function askSudo(label, error) {228
return runDialog(el.sudoDialog, {229
init: () => {230
el.sudoMessage.textContent = `Operace „${label}“ vyžaduje oprávnění správce.`;231
el.sudoUser.textContent = state.privilege?.username || "";232
el.sudoPassword.value = "";233
showError(el.sudoError, error);234
setTimeout(() => el.sudoPassword.focus(), 0);235
},236
submit: () => { const value = el.sudoPassword.value; el.sudoPassword.value = ""; return value || undefined; }237
}).finally(() => { el.sudoPassword.value = ""; });238
}240
function confirmAction({ title, message, details = [], confirmLabel = "Potvrdit", danger = false, typed = "", option = "" }) {241
return runDialog(el.confirmDialog, {242
init: () => {243
el.confirmTitle.textContent = title;244
el.confirmMessage.textContent = message;245
el.confirmDetails.replaceChildren(...details.map((item) => h("li", { text: item })));246
el.confirmDetails.hidden = !details.length;247
el.confirmTypedLabel.hidden = !typed;248
el.confirmTypedValue.textContent = typed;249
el.confirmTyped.value = "";250
el.confirmOptionLabel.hidden = !option;251
el.confirmOptionText.textContent = option;252
el.confirmOption.checked = false;253
el.confirmSubmit.textContent = confirmLabel;254
el.confirmSubmit.className = `button ${danger ? "danger" : "primary"}`;255
},256
submit: () => {257
if (typed && el.confirmTyped.value.trim() !== typed) { el.confirmTyped.focus(); return undefined; }258
return { option: el.confirmOption.checked };259
}260
});261
}263
function askValue({ title, message = "", label, value = "", type = "text", label2 = "", option = "", validate }) {264
return runDialog(el.valueDialog, {265
init: () => {266
el.valueTitle.textContent = title;267
el.valueMessage.textContent = message;268
el.valueLabelText.textContent = label;269
el.valueInput.type = type; el.valueInput.value = value;270
el.valueLabel2.hidden = !label2;271
el.valueLabelText2.textContent = label2;272
el.valueInput2.type = type; el.valueInput2.value = "";273
el.valueOptionLabel.hidden = !option;274
el.valueOptionText.textContent = option;275
el.valueOption.checked = false;276
showError(el.valueError, "");277
setTimeout(() => el.valueInput.focus(), 0);278
},279
submit: () => {280
const result = { value: el.valueInput.value, value2: el.valueInput2.value, option: el.valueOption.checked };281
const problem = validate?.(result);282
if (problem) { showError(el.valueError, problem); return undefined; }283
return result;284
}285
}).finally(() => { el.valueInput.value = ""; el.valueInput2.value = ""; });286
}288
function askProbeSecret(missing) {289
const passphrase = missing === "passphrase";290
return runDialog(el.probeDialog, {291
init: () => {292
el.probeLabel.textContent = passphrase ? "Passphrase soukromého klíče profilu" : "Heslo SSH profilu";293
el.probeSecret.value = "";294
setTimeout(() => el.probeSecret.focus(), 0);295
},296
submit: () => { const value = el.probeSecret.value; el.probeSecret.value = ""; return value ? (passphrase ? { passphrase: value } : { password: value }) : undefined; }297
});298
}300
// Calls Core without a password first. Only if Core reports that sudo needs one, the dialog is shown.301
async function withSudo(label, invoke) {302
let password = "";303
let attempts = 0;304
try {305
for (;;) {306
try { return await invoke(password ? { sudoPassword: password } : {}); }307
catch (error) {308
const { code } = parseError(error);309
if ((code === "SUDO_PASSWORD_REQUIRED" || code === "SUDO_PASSWORD_INCORRECT") && attempts < 3) {310
attempts += 1;311
password = await askSudo(label, code === "SUDO_PASSWORD_INCORRECT" && password ? "Heslo bylo odmítnuto. Zkuste to znovu." : "");312
if (!password) throw cancelled();313
continue;314
}315
if (code === "SUDO_NOT_PERMITTED" || code === "SUDO_UNAVAILABLE") void refreshPrivilege();316
if (code === "SESSION_NOT_CONNECTED") void loadSessions();317
throw error;318
}319
}320
} finally { password = ""; }321
}323
// ---------- sessions and privileges ----------324
async function loadSessions() {325
const previous = state.sessionId;326
try {327
state.sessions = (await api.sessions.list()).filter((session) => session.state === "connected");328
} catch (error) {329
state.sessions = [];330
setStatus(`Relace nelze načíst: ${describeError(error)}`, true);331
}332
el.sessionSelect.replaceChildren(new Option(state.sessions.length ? "Vyberte relaci…" : "Žádná připojená relace", ""));333
for (const session of state.sessions) el.sessionSelect.add(new Option(`${session.label} — ${session.username}@${session.host}`, session.sessionId));334
const next = state.sessions.some((session) => session.sessionId === previous) ? previous : (state.sessions[0]?.sessionId || "");335
el.sessionSelect.value = next;336
if (next !== previous) await selectSession(next);337
else updateControls();338
if (!state.sessions.length) setStatus("Nejprve se v hlavním okně připojte k serveru přes SSH.");339
}341
async function selectSession(sessionId) {342
state.sessionId = sessionId;343
state.generation += 1;344
state.detailToken += 1;345
Object.assign(state, { privilege: null, inventory: null, selected: null, detail: null, detailLoading: false });346
renderPrivilege(); renderUsers(); renderDetail(); updateControls();347
if (!sessionId) return;348
await refreshPrivilege();349
await loadInventory();350
}352
async function refreshPrivilege() {353
const token = state.generation;354
if (!connected()) return;355
try {356
const privilege = await api.users.privileges(state.sessionId, true);357
if (token !== state.generation) return;358
state.privilege = privilege;359
} catch (error) {360
if (token === state.generation) setStatus(`Oprávnění nelze zjistit: ${describeError(error)}`, true);361
}362
renderPrivilege(); updateControls();363
}365
function renderPrivilege() {366
const mode = state.privilege?.mode || "unknown";367
const [text, tone] = PRIVILEGE_TEXT[mode] || PRIVILEGE_TEXT.unknown;368
const suffix = mode === "sudo-password" && !state.privilege?.verified ? " (neověřeno)" : "";369
el.privilegeBadge.textContent = state.privilege ? `${state.privilege.username}: ${text}${suffix}` : "neověřeno";370
el.privilegeBadge.className = `badge ${tone}`;371
if (mode.startsWith("windows-")) {372
el.privilegeBadge.title = mode === "windows-elevated" ? "Relace má zvýšený token; administrativní operace jsou dostupné." : "Windows nemá sudo. Administrativní operace vyžadují zvýšený token správce; CH-J neobchází UAC.";373
return;374
}375
el.privilegeBadge.title = mode === "none" ? "Účet nemá sudo. Plugin funguje v režimu čtení." : mode === "sudo-password" ? "Administrativní operace si vyžádají heslo pro sudo." : "";376
}378
function updateControls() {379
const busy = state.loading || state.mutating;380
el.sessionSelect.disabled = state.mutating;381
el.refreshSessionsButton.disabled = state.mutating;382
el.reloadButton.disabled = !connected() || busy;383
const readOnlyWindows = isWindows() && state.privilege?.mode !== "windows-elevated";384
el.createButton.disabled = !connected() || busy || !state.inventory || state.privilege?.mode === "none" || readOnlyWindows;385
const canElevate = !isWindows() && connected() && state.inventory && !state.inventory.privileged && ["sudo-password", "unknown"].includes(state.privilege?.mode || "unknown");386
el.elevatedButton.hidden = !canElevate;387
el.elevatedButton.disabled = busy;388
for (const button of document.querySelectorAll("[data-mutation]")) button.disabled = busy || !connected();389
}391
// ---------- inventory ----------392
async function loadInventory({ elevated = false } = {}) {393
if (!connected()) return;394
const token = state.generation;395
const sessionId = state.sessionId;396
state.loading = true; updateControls();397
setStatus(elevated ? "Načítám rozšířený stav účtů…" : "Načítám účty…");398
try {399
const run = (auth) => api.users.inventory(sessionId, { ...auth, privileged: elevated });400
const inventory = elevated ? await withSudo("Načtení rozšířeného stavu účtů", run) : await run({});401
if (token !== state.generation) return;402
applyInventory(inventory);403
const regular = inventory.users.filter((user) => !user.system).length;404
setStatus(`Načteno ${inventory.users.length} účtů (${regular} běžných)${inventory.privileged ? "" : " – stav hesel a sudo vyžaduje sudo"}.`);405
} catch (error) {406
if (token === state.generation) setStatus(describeError(error), parseError(error).code !== CANCELLED);407
} finally {408
if (token === state.generation) { state.loading = false; updateControls(); }409
}410
}412
function applyInventory(inventory) {413
state.inventory = inventory;414
if (inventory.privilege) state.privilege = inventory.privilege;415
if (state.selected && !inventory.users.some((user) => user.username === state.selected)) {416
state.selected = null; state.detail = null; state.detailToken += 1;417
} else if (state.detail) {418
const user = inventory.users.find((candidate) => candidate.username === state.detail.username);419
if (user) mergeAccount(state.detail, user, inventory.privileged);420
}421
renderPrivilege(); renderUsers(); renderDetail(); updateControls();422
}424
function mergeAccount(detail, user, privileged) {425
const account = detail.account;426
Object.assign(account, { shell: user.shell, home: user.home, comment: user.comment, primaryGroup: user.primaryGroup, groups: user.groups, loginShell: user.loginShell, uid: user.uid, gid: user.gid });427
if (privileged) Object.assign(account, { password: user.password, expired: user.expired, expireDays: user.expireDays, sudo: user.sudo });428
}430
function filteredUsers() {431
const users = state.inventory?.users || [];432
const filter = el.filterSelect.value;433
const query = el.searchInput.value.trim().toLowerCase();434
return users.filter((user) => (filter === "all" || (filter === "system" ? user.system : !user.system))435
&& (!query || user.username.includes(query) || user.comment.toLowerCase().includes(query) || String(user.uid) === query));436
}438
function policySummary(policy) {439
if (!policy) return badge("dědí server", "muted");440
if (policy.access === "deny") return badge("SSH zakázáno", "danger");441
const parts = [];442
if (policy.password !== "inherit") parts.push(`heslo ${policy.password === "no" ? "ne" : "ano"}`);443
if (policy.pubkey !== "inherit") parts.push(`klíč ${policy.pubkey === "no" ? "ne" : "ano"}`);444
if (policy.ipv4 === "deny") parts.push("bez IPv4");445
if (policy.ipv6 === "deny") parts.push("bez IPv6");446
return badge(parts.join(" · ") || "dědí server", parts.length ? "warn" : "muted");447
}449
function accountState(user) {450
if (user.platform === "windows") {451
const items = [badge(user.enabled ? "povolen" : "zakázán", user.enabled ? "ok" : "danger")];452
if (user.source && user.source !== "Local") items.push(badge(user.source === "MicrosoftAccount" ? "účet Microsoft" : user.source, "muted"));453
if (user.passwordRequired === false && !user.passwordLastSet) items.push(badge("bez hesla", "muted"));454
return items;455
}456
const items = [];457
if (user.expired) items.push(badge("deaktivován", "danger"));458
const [text, tone] = PASSWORD_TEXT[user.password] || PASSWORD_TEXT.unknown;459
items.push(badge(text, tone));460
if (!user.loginShell) items.push(badge("bez shellu", "muted"));461
return items;462
}464
function renderTableHead() {465
const labels = isWindows() ? ["Účet", "SID (RID)", "Stav", "Administrators", "SSH (CH-J)"] : ["Účet", "UID", "Stav", "Sudo", "SSH (CH-J)"];466
el.usersBody.closest("table").querySelector("thead").replaceChildren(h("tr", {}, labels.map((text) => h("th", { scope: "col", text }))));467
}469
function renderUsers() {470
renderTableHead();471
const users = filteredUsers();472
if (!state.inventory) {473
el.usersBody.replaceChildren(h("tr", {}, h("td", { colspan: 5, className: "empty", text: connected() ? "Načítám…" : "Nejsou načtena žádná data." })));474
el.inventoryNote.textContent = "";475
return;476
}477
if (!users.length) {478
el.usersBody.replaceChildren(h("tr", {}, h("td", { colspan: 5, className: "empty", text: "Žádný účet neodpovídá filtru." })));479
} else {480
el.usersBody.replaceChildren(...users.map((user) => h("tr", { className: user.username === state.selected ? "selected" : "" }, [481
h("td", {}, h("button", { type: "button", className: "row-button", dataset: { username: user.username }, "aria-pressed": String(user.username === state.selected) }, [482
h("strong", { text: user.username }),483
user.protection ? h("small", { text: user.protection === "session" ? " (tato relace)" : user.protection === "root" ? " (root)" : user.protection === "builtin" ? " (vestavěný)" : "" }) : null,484
user.comment ? h("span", { className: "sub", text: user.comment }) : null485
])),486
h("td", { title: user.sid || "", text: user.platform === "windows" ? `…-${user.rid}` : String(user.uid) }),487
h("td", {}, h("div", { className: "badges" }, accountState(user))),488
user.platform === "windows" ? h("td", { text: user.admin ? "člen" : "—" }) : h("td", { text: user.sudo === "unknown" ? (user.adminGroups.length ? `skupina ${user.adminGroups.join(", ")}` : "—") : SUDO_TEXT[user.sudo] || user.sudo }),489
h("td", {}, policySummary(user.sshPolicy))490
])));491
}492
const managed = state.inventory.managedPolicy;493
const notes = [];494
if (isWindows()) {495
if (!state.inventory.privileged) notes.push("Režim čtení: správa účtů, SSH a cizích klíčů vyžaduje SSH relaci správce se zvýšeným tokenem (Windows nemá sudo).");496
if (state.inventory.server.serverCore) notes.push("Windows Server Core (bez GUI).");497
if (state.inventory.server.partOfDomain) notes.push(`Počítač je v doméně ${state.inventory.server.domain}; doménové identity jsou jen pro čtení (${state.inventory.externalIdentities.length} v lokálních skupinách).`);498
} else if (!state.inventory.privileged) notes.push("Režim čtení: stav hesel, expirace a skutečná sudo práva vyžadují sudo.");499
if (state.inventory.server.sshLayout !== "ok" && isWindows()) notes.push("SSH politiku CH-J nelze v tomto sshd_config bezpečně spravovat (viz Diagnostika).");500
else if (state.inventory.server.sshLayout !== "ok") notes.push("SSH politiku CH-J nelze na tomto serveru spravovat (sshd_config nenačítá sshd_config.d před Match).");501
if (managed.state === "present" && !managed.consistent) notes.push("Soubor SSH politiky CH-J byl ručně upraven – CH-J ho nepřepíše.");502
if (state.inventory.server.os) notes.push(`Server: ${state.inventory.server.os}.`);503
el.inventoryNote.textContent = notes.join(" ");504
}506
// ---------- detail ----------507
async function loadDetail(username, { elevated = false } = {}) {508
if (!connected()) return;509
const token = state.generation;510
const detailToken = ++state.detailToken;511
const sessionId = state.sessionId;512
if (state.selected !== username) { state.detail = null; state.tab = state.tab || "account"; }513
state.selected = username;514
state.detailLoading = true;515
renderUsers(); renderDetail();516
try {517
const run = (auth) => api.users.detail(sessionId, username, { ...auth, ...identFor(username), privileged: elevated });518
const detail = elevated ? await withSudo(`Detail účtu ${username}`, run) : await run({});519
if (token !== state.generation || detailToken !== state.detailToken) return;520
state.detail = detail;521
setStatus(`Detail účtu ${username} načten${detail.privileged ? " včetně údajů vyžadujících sudo" : " (režim čtení)"}.`);522
} catch (error) {523
if (token !== state.generation || detailToken !== state.detailToken) return;524
setStatus(describeError(error), parseError(error).code !== CANCELLED);525
} finally {526
if (token === state.generation && detailToken === state.detailToken) { state.detailLoading = false; renderDetail(); updateControls(); }527
}528
}530
function renderDetail() {531
const detail = state.detail;532
el.detailEmpty.hidden = Boolean(state.selected);533
el.detailContent.hidden = !state.selected;534
el.detailEmpty.textContent = connected() ? "Vyberte účet v seznamu." : "Nejprve vyberte připojenou relaci.";535
if (!state.selected) return;536
el.detailTitle.textContent = state.selected;537
if (!detail) {538
el.detailSubtitle.textContent = state.detailLoading ? "Načítám detail…" : "Detail se nepodařilo načíst.";539
el.detailBadges.replaceChildren();540
el.detailNotice.hidden = true;541
for (const panel of ["account", "groups", "ssh", "keys", "diagnostics"]) el[`panel-${panel}`].replaceChildren();542
return;543
}544
const account = detail.account;545
el.detailSubtitle.textContent = (account.platform === "windows" ? [account.fullName, account.sid, account.profilePath] : [account.comment, `UID ${account.uid}`, account.home]).filter(Boolean).join(" · ") + (state.detailLoading ? " · obnovuji…" : "");546
el["tab-groups"].textContent = account.platform === "windows" ? "Skupiny a správci" : "Skupiny a sudo";547
el.detailBadges.replaceChildren(...[548
account.protection ? badge(account.protection === "session" ? "aktivní relace" : account.protection === "root" ? "root" : account.protection === "builtin" ? "vestavěný" : "systémový", "warn") : null,549
badge(detail.privileged ? (detail.platform === "windows" ? "ověřeno se zvýšeným tokenem" : "ověřeno se sudo") : "režim čtení", detail.privileged ? "ok" : "muted"),550
...accountState(account)551
].filter(Boolean));552
const notices = [];553
if (account.protection) notices.push(PROTECTION_TEXT[account.protection]);554
if (!detail.privileged) notices.push(detail.platform === "windows"555
? "Skutečná SSH konfigurace (sshd -T), klíče jiných účtů a změny vyžadují SSH relaci správce se zvýšeným tokenem. Windows nemá sudo a CH-J neobchází UAC."556
: "Část údajů (stav hesla, sudo, skutečná SSH konfigurace, klíče jiných účtů) vyžaduje sudo.");557
el.detailNotice.replaceChildren(...notices.map((text) => h("p", { text })));558
if (!detail.privileged && detail.platform !== "windows") el.detailNotice.append(h("button", { type: "button", className: "button", text: "Načíst údaje se sudo", onclick: () => loadDetail(detail.username, { elevated: true }) }));559
el.detailNotice.hidden = !notices.length;560
renderAccountTab(detail); renderGroupsTab(detail); renderSshTab(detail); renderKeysTab(detail); renderDiagnosticsTab(detail);561
selectTab(state.tab, false);562
updateControls();563
}565
function infoGrid(rows) {566
return h("dl", { className: "info-grid" }, rows.flatMap(([label, value]) => [h("dt", { text: label }), h("dd", {}, value instanceof Node ? value : String(value ?? "—"))]));567
}568
function actionButton(label, onClick, { danger = false, sudo = true, title = "" } = {}) {569
return h("button", { type: "button", className: `button${danger ? " danger" : ""}`, dataset: { mutation: "1" }, title: title || (sudo ? "Vyžaduje oprávnění správce (sudo)" : ""), onclick: onClick }, [label, sudo ? h("span", { className: "sudo-mark", text: " sudo" }) : null]);570
}571
function section(title, children, hint = "") {572
return h("section", { className: "sub-panel" }, [h("h4", { text: title }), hint ? h("p", { className: "hint", text: hint }) : null, ...[].concat(children)]);573
}575
function renderAccountTab(detail) {576
if (detail.platform === "windows") { renderWindowsAccountTab(detail); return; }577
const account = detail.account;578
const [passwordText] = PASSWORD_TEXT[account.password] || PASSWORD_TEXT.unknown;579
const expiry = account.expired ? "deaktivován (expirovaný účet)" : account.expireDays ? `platný do ${new Date(account.expireDays * 86400000).toISOString().slice(0, 10)}` : account.password === "unknown" ? "neznámý (vyžaduje sudo)" : "aktivní";580
const children = [infoGrid([581
["Uživatelské jméno", account.username],582
["Celé jméno", account.comment || "—"],583
["UID / GID", `${account.uid} / ${account.gid}`],584
["Primární skupina", account.primaryGroup],585
["Domovský adresář", `${account.home}${account.homeMeta && account.homeMeta !== "absent" ? ` (${account.homeMeta})` : account.homeMeta === "absent" ? " (neexistuje)" : ""}`],586
["Shell", `${account.shell}${account.loginShell ? "" : " – interaktivní přihlášení vypnuto"}`],587
["Heslo", passwordText],588
["Stav účtu", expiry],589
["Typ", account.uid === 0 ? "root" : account.system ? "systémový účet" : "běžný účet"],590
["Spuštěné procesy", account.processes === null ? "—" : String(account.processes)]591
]), h("p", { className: "hint", text: "Zamknutí hesla (passwd -l) nebrání přihlášení SSH klíčem. Úplné zablokování: Deaktivovat účet, nebo SSH přístup → Zakázat." })];592
if (!account.protection) {593
const actions = [actionButton("Změnit heslo…", () => changePassword(account.username))];594
if (account.password === "locked") actions.push(actionButton("Odemknout heslo", () => simpleAction("unlock", account.username, "Odemknutí hesla")));595
else if (account.password === "set") actions.push(actionButton("Zamknout heslo", () => simpleAction("lock", account.username, "Zamknutí hesla")));596
actions.push(account.expired597
? actionButton("Aktivovat účet", () => simpleAction("unexpire", account.username, "Aktivace účtu"))598
: actionButton("Deaktivovat účet", () => simpleAction("expire", account.username, "Deaktivace účtu", "Účet se nebude moci přihlásit žádnou metodou (ani SSH klíčem). Data zůstanou zachována.")));599
actions.push(actionButton("Upravit celé jméno…", () => changeComment(account)));600
actions.push(actionButton("Změnit domovský adresář…", () => changeHome(account)));601
actions.push(actionButton("Odstranit účet…", () => deleteAccount(account), { danger: true }));602
children.push(h("div", { className: "inline-actions" }, actions));603
const shells = [...new Set([...(state.inventory?.shells || []), "/usr/sbin/nologin", "/bin/false", account.shell])].filter(Boolean);604
const shellSelect = h("select", { "aria-label": "Shell" }, shells.map((shell) => h("option", { value: shell, text: shell, selected: shell === account.shell })));605
children.push(section("Shell", h("div", { className: "inline-actions" }, [shellSelect, actionButton("Uložit shell", () => {606
if (shellSelect.value === account.shell) return;607
void runMutation("Změna shellu", (sessionId, auth) => api.users.manage(sessionId, { action: "setShell", ...identFor(account.username), shell: shellSelect.value, ...auth }));608
})]), "nologin/false zablokuje interaktivní přihlášení, ale ne nutně všechny SSH funkce (např. přesměrování portů)."));609
}610
el["panel-account"].replaceChildren(...children);611
}613
function renderGroupsTab(detail) {614
if (detail.platform === "windows") { renderWindowsGroupsTab(detail); return; }615
const account = detail.account;616
const groups = state.inventory?.groups || [];617
const editable = !account.protection;618
const children = [];619
const memberRows = account.groups.length620
? account.groups.map((group) => h("li", {}, [h("span", { text: group }), editable && group !== account.primaryGroup ? h("button", { type: "button", className: "mini", dataset: { mutation: "1" }, text: "Odebrat", onclick: () => removeGroup(account.username, group) }) : null]))621
: [h("li", { className: "muted", text: "Žádné doplňkové skupiny." })];622
children.push(section("Skupiny", [623
infoGrid([["Primární skupina", account.primaryGroup]]),624
h("ul", { className: "chip-list" }, memberRows)625
]));626
if (editable && groups.length) {627
const candidates = groups.filter((group) => !account.groups.includes(group.name) && group.name !== account.primaryGroup).sort((left, right) => left.name.localeCompare(right.name));628
const addSelect = h("select", { "aria-label": "Přidat do skupiny" }, candidates.map((group) => h("option", { value: group.name, text: `${group.name} (${group.gid})` })));629
const primarySelect = h("select", { "aria-label": "Primární skupina" }, groups.slice().sort((left, right) => left.name.localeCompare(right.name)).map((group) => h("option", { value: group.name, text: `${group.name} (${group.gid})`, selected: group.name === account.primaryGroup })));630
children.push(section("Změnit členství", [631
h("div", { className: "inline-actions" }, [addSelect, actionButton("Přidat do skupiny", () => addGroup(account.username, addSelect.value))]),632
h("div", { className: "inline-actions" }, [primarySelect, actionButton("Nastavit primární skupinu", () => setPrimary(account.username, primarySelect.value, account.primaryGroup))])633
], "Ostatní skupiny a nesouvisející oprávnění zůstávají zachované."));634
}635
const sudoState = account.sudo || "unknown";636
const adminGroups = state.inventory?.adminGroups || [];637
const sudoChildren = [infoGrid([638
["Skutečný stav (sudo -l)", sudoState === "unknown" ? "neznámý – vyžaduje sudo" : SUDO_TEXT[sudoState] || sudoState],639
["Členství ve skupinách správců", ["sudo", "wheel", "admin"].filter((name) => account.groups.includes(name) || account.primaryGroup === name).join(", ") || "žádné"],640
["Skupiny s pravidlem v sudoers", adminGroups.length ? adminGroups.join(", ") : state.inventory?.privileged ? "žádná" : "neznámé (vyžaduje sudo)"]641
]), h("p", { className: "hint", text: "Členství ve skupině samo nezaručuje funkční sudo. CH-J nikdy nemění sudoers a nenastavuje NOPASSWD." })];642
if (editable) {643
sudoChildren.push(h("div", { className: "inline-actions" }, [644
actionButton("Přidat sudo", () => simpleAction("grantAdmin", account.username, "Přidání sudo", "Účet bude přidán do skupiny správců, na kterou odkazuje sudoers.")),645
actionButton("Odebrat sudo", () => simpleAction("revokeAdmin", account.username, "Odebrání sudo", "Účet bude odebrán ze skupin sudo/wheel/admin. Pravidla sudoers pro konkrétní účet zůstanou beze změny."), { danger: true })646
]));647
}648
children.push(section("Sudo", sudoChildren));649
el["panel-groups"].replaceChildren(...children);650
}652
function effectiveCell(detail, family, key) {653
const effective = detail.ssh.effective[family];654
if (!effective) return h("span", { className: "muted", text: detail.privileged ? "nedostupné" : "vyžaduje sudo" });655
const yesNo = (value) => value === "yes" ? badge("povoleno", "ok") : value === "no" ? badge("zakázáno", "danger") : badge(value, "muted");656
if (key === "password") return yesNo(effective.methods.password ? "yes" : "no");657
if (key === "pubkey") return yesNo(effective.methods.publickey ? "yes" : "no");658
const listener = detail.ssh.effective.listener;659
if (listener && listener[family] === false) return badge("sshd nenaslouchá", "danger");660
if (effective.allowed === true) return badge("povoleno", "ok");661
if (effective.allowed === null) return badge("nejisté", "warn");662
const managedOnly = effective.reasons.every((reason) => reason.managed);663
return h("span", { title: effective.reasons.map((reason) => `${reason.rule} ${reason.entry}`).join(", ") }, badge(managedOnly ? "zakázáno (CH-J)" : "zakázáno (pravidla serveru)", "danger"));664
}666
function renderSshTab(detail) {667
const ssh = detail.ssh;668
const account = detail.account;669
const children = [];670
if (!ssh.available && detail.privileged) {671
el["panel-ssh"].replaceChildren(h("p", { className: "notice", text: "Na serveru nebyl nalezen OpenSSH server (sshd)." }));672
return;673
}674
const windows = detail.platform === "windows";675
const blocked = windows ? account.protection === "builtin" : account.system;676
const rows = !windows && account.uid === 0 ? [...POLICY_ROWS, ROOT_LOGIN_ROW] : POLICY_ROWS;677
const reason = blocked ? (windows ? "SSH přístup vestavěných účtů Windows CH-J nespravuje." : "SSH přístup systémových účtů CH-J nespravuje.")678
: !detail.privileged ? (windows ? "Skutečný stav a změny SSH vyžadují SSH relaci správce se zvýšeným tokenem." : "Skutečný stav a změny SSH vyžadují sudo.")679
: windows && !ssh.sshName ? "Název účtu obsahuje znaky, které nelze bezpečně použít v sshd_config."680
: !ssh.layoutSupported ? (windows ? `sshd_config nelze bezpečně upravit: ${(ssh.layoutProblems || []).join("; ")}` : "sshd_config nenačítá sshd_config.d před prvním blokem Match – CH-J hlavní konfiguraci neupravuje.")681
: ssh.managed.state === "present" && !ssh.managed.consistent ? `${ssh.managed.path} byl ručně upraven – CH-J ho nepřepíše.`682
: ssh.syntaxOk === false ? `Současná konfigurace SSH je neplatná: ${ssh.syntaxMessage}` : "";683
const form = h("form", { className: "policy-form" });684
const table = h("table", { className: "policy-table" }, [685
h("thead", {}, h("tr", {}, [h("th", { text: "Nastavení" }), h("th", { text: "Požadováno (CH-J)" }), h("th", { text: `Skutečně IPv4${ssh.effective.ipv4?.sample ? " (vzorová adresa)" : ""}` }), h("th", { text: `Skutečně IPv6${ssh.effective.ipv6?.sample ? " (vzorová adresa)" : ""}` })])),686
h("tbody", {}, rows.map((row) => {687
const current = ssh.policy[row.key];688
const options = h("div", { className: "segmented", role: "radiogroup", "aria-label": row.label }, row.options.map(([value, label]) => h("label", {}, [689
h("input", { type: "radio", name: `policy-${row.key}`, value, checked: current === value, disabled: Boolean(reason) }), h("span", { text: label })690
])));691
const family = row.key === "ipv4" ? ["ipv4"] : row.key === "ipv6" ? ["ipv6"] : ["ipv4", "ipv6"];692
const effectiveKey = row.key === "ipv4" || row.key === "ipv6" ? "access" : row.key;693
if (row.key === "permitRootLogin") {694
const shown = (family) => {695
const value = detail.ssh.effective[family]?.permitRootLogin;696
const text = value === "without-password" ? "prohibit-password" : value;697
return text ? badge({ no: "zakázáno", "prohibit-password": "jen klíč", "forced-commands-only": "klíč s vynuceným příkazem", yes: "heslo i klíč" }[text] || text, text === "no" ? "danger" : text === "yes" ? "warn" : "ok") : h("span", { className: "muted", text: detail.privileged ? "nedostupné" : "vyžaduje sudo" });698
};699
return h("tr", {}, [h("th", { scope: "row" }, [h("span", { text: row.label }), h("small", { className: "sub", text: row.hint })]), h("td", {}, options), h("td", {}, shown("ipv4")), h("td", {}, shown("ipv6"))]);700
}701
return h("tr", {}, [702
h("th", { scope: "row" }, [h("span", { text: row.label }), h("small", { className: "sub", text: row.hint })]),703
h("td", {}, options),704
h("td", {}, family.includes("ipv4") ? effectiveCell(detail, "ipv4", effectiveKey) : h("span", { className: "muted", text: "—" })),705
h("td", {}, family.includes("ipv6") ? effectiveCell(detail, "ipv6", effectiveKey) : h("span", { className: "muted", text: "—" }))706
]);707
}))708
]);709
form.append(table);710
if (reason) form.append(h("p", { className: "notice", text: reason }));711
else {712
if (windows) form.append(h("p", { className: "notice warn", text: "Windows OpenSSH vyžaduje po změně restart služby sshd. Před restartem se na serveru aktivuje nezávislá úloha (SYSTEM), která do 120 s vrátí původní sshd_config, pokud změnu nepotvrdí nové SSH přihlášení. Původní relace se může přerušit." }));713
if (detail.self) form.append(h("p", { className: "notice warn", text: "Měníte účet aktivní relace. Core ověří přes sshd -T, že vaše současné přihlášení zůstane povolené, a po reloadu zkusí nové SSH přihlášení. Při selhání se konfigurace automaticky vrátí." }));714
form.append(h("div", { className: "inline-actions" }, [h("button", { type: "submit", className: "button primary", dataset: { mutation: "1" }, text: windows ? "Uložit, restartovat sshd a ověřit" : "Uložit a ověřit (sudo)" })]));715
form.addEventListener("submit", (event) => { event.preventDefault(); void saveSshPolicy(detail, form); });716
}717
children.push(form);718
if (windows) children.push(h("p", { className: "hint", text: "Na Windows zapisuje CH-J dvě označené sekce přímo do C:\\ProgramData\\ssh\\sshd_config: DenyUsers před první existující blok Match a vlastní Match User na konec. KbdInteractiveAuthentication se nepoužívá (Windows OpenSSH ji nepodporuje). Před restartem proběhne sshd -t a sshd -T -C nad kandidátní konfigurací." }));719
else children.push(h("p", { className: "hint", text: "Pravidla zapisuje CH-J do vlastního souboru sshd_config.d (DenyUsers user@0.0.0.0/0, user@::/0 a Match User). Před reloadem proběhne sshd -t a kontrola účinného nastavení sshd -T -C; neplatná nebo neúčinná změna se vrátí. Používá se reload, ne restart." }));720
for (const warning of ssh.warnings) children.push(h("p", { className: "notice warn", text: translateWarning(warning) }));721
const reasons = ["ipv4", "ipv6"].flatMap((family) => (ssh.effective[family]?.reasons || []).map((item) => `${family.toUpperCase()}: ${item.rule} ${item.entry}${item.managed ? " (CH-J)" : ""}${item.uncertain ? " – nelze vyhodnotit bez DNS" : ""}`));722
if (reasons.length) children.push(section("Pravidla omezující přístup", h("ul", { className: "details-list" }, [...new Set(reasons)].map((text) => h("li", { text })))));723
el["panel-ssh"].replaceChildren(...children);724
}726
function translateWarning(text) {727
if (/does not listen on IPv6/.test(text)) return "sshd nenaslouchá na IPv6 – přihlášení přes IPv6 není možné bez ohledu na nastavení účtu.";728
if (/does not listen on IPv4/.test(text)) return "sshd nenaslouchá na IPv4 – přihlášení přes IPv4 není možné bez ohledu na nastavení účtu.";729
if (/AuthorizedKeysCommand/.test(text)) return "Je nastaven AuthorizedKeysCommand – klíče mohou pocházet i z externího zdroje, který CH-J nespravuje.";730
if (/edited manually/.test(text)) return "Soubor SSH politiky CH-J byl ručně upraven; CH-J ho nepřepíše.";731
return text;732
}734
function renderKeysTab(detail) {735
const files = detail.keys.files;736
const account = detail.account;737
const children = [];738
if (files.some((file) => file.shared)) children.push(h("p", { className: "notice warn", text: "Účet je správce: Windows OpenSSH používá sdílený soubor administrators_authorized_keys. Každý klíč v něm umožní přihlášení ke všem účtům ze skupiny Administrators; klíče proto nelze přiřadit jednomu účtu." }));739
if (!files.length || (!detail.privileged && !detail.self)) {740
children.push(h("p", { className: "notice", text: detail.privileged || detail.self ? "Soubor authorized_keys nebyl nalezen." : detail.platform === "windows" ? "Klíče jiného účtu lze číst jen ze SSH relace správce se zvýšeným tokenem." : "Klíče jiného účtu lze číst jen se sudo (soubor má práva 0600)." }));741
}742
const reasons = { "needs-elevation": "vyžaduje zvýšený token správce", "profile-missing": "profil účtu zatím neexistuje (účet se ještě nepřihlásil)", "outside-home": "mimo domovský adresář", symlink: "symbolický odkaz", "not-regular": "není běžný soubor", unreadable: "nelze přečíst", "unsupported-token": "nepodporovaný token v AuthorizedKeysFile", "too-large": "příliš velký", unknown: "neznámý stav" };743
for (const [index, file] of files.entries()) {744
if (!detail.privileged && !detail.self) break;745
const status = file.type === "file" ? `počet klíčů: ${file.entries.filter((entry) => entry.valid).length}` : file.type === "absent" || file.type === "absent-dir" ? "zatím neexistuje" : file.type;746
const rows = file.entries.map((entry) => entry.valid747
? h("tr", {}, [748
h("td", { text: entry.type }), h("td", { className: "mono", text: entry.fingerprint }), h("td", { text: entry.comment || "—" }),749
h("td", { className: "mono", text: entry.options || "—" }), h("td", { text: String(entry.line) }),750
h("td", {}, account.protection === "system" || account.protection === "builtin" || !file.manageable ? null751
: h("button", { type: "button", className: "mini danger", dataset: { mutation: "1" }, text: "Odebrat", onclick: () => removeKey(detail, file, entry) }))752
])753
: h("tr", { className: "invalid" }, [h("td", { colspan: 6, text: `Řádek ${entry.line}: nerozpoznaný záznam (${entry.type || entry.reason}) – zůstane beze změny.` })]));754
children.push(section(`${index === 0 ? "Primární" : "Další"} soubor: ${file.path}`, [755
h("p", { className: "hint", text: `${status} · ${file.meta || ""}${file.dirMeta ? ` · adresář ${file.dirMeta}` : ""}${file.manageable ? "" : ` · nelze spravovat: ${reasons[file.reason] || file.reason}`}` }),756
rows.length ? h("div", { className: "table-wrap" }, h("table", {}, [h("thead", {}, h("tr", {}, ["Typ", "Otisk SHA256", "Komentář", "Volby", "Řádek", ""].map((text) => h("th", { text })))), h("tbody", {}, rows)])) : null757
]));758
}759
const primary = files[0];760
if (!account.protection || account.protection === "session" || account.protection === "root") {761
if ((detail.platform === "windows" ? account.protection !== "builtin" : !account.system) && (detail.privileged || detail.self)) {762
children.push(h("div", { className: "inline-actions" }, [763
h("button", { type: "button", className: "button primary", dataset: { mutation: "1" }, disabled: !primary?.manageable, text: "Přidat Ed25519 klíč…", onclick: () => addKey(detail) })764
]));765
}766
}767
if (detail.platform === "windows") children.push(h("p", { className: "hint", text: "Soukromé klíče se nikdy neodesílají na server. Úprava zachová ostatní řádky beze změny a zachová existující ACL souboru (ReplaceFile). Nový soubor dostane ACL jen pro SYSTEM, Administrators a u běžného účtu jeho vlastníka." }));768
else children.push(h("p", { className: "hint", text: "Soukromé klíče se nikdy neodesílají na server. Úprava zachová všechny ostatní řádky, komentáře a volby klíčů beze změny; zápis probíhá atomicky pod identitou cílového účtu (0700 .ssh, 0600 authorized_keys)." }));769
el["panel-keys"].replaceChildren(...children);770
}772
function renderDiagnosticsTab(detail) {773
if (detail.platform === "windows") { renderWindowsDiagnosticsTab(detail); return; }774
const ssh = detail.ssh;775
const privilege = state.privilege;776
const listener = ssh.effective.listener;777
const files = detail.keys.files;778
const rows = [779
["Oprávnění relace", privilege ? `${privilege.username}: ${(PRIVILEGE_TEXT[privilege.mode] || PRIVILEGE_TEXT.unknown)[0]}${privilege.verified ? "" : " (neověřeno)"}` : "neověřeno"],780
["Adresa klienta (SSH_CLIENT)", privilege?.clientAddress || "neznámá"],781
["OpenSSH", ssh.version || (ssh.available ? "neznámá verze" : "nenalezen")],782
["Služba SSH", ssh.service || "neznámý stav"],783
["Kontrola sshd -t", ssh.syntaxOk === null ? "vyžaduje sudo" : ssh.syntaxOk ? "v pořádku" : `CHYBA: ${ssh.syntaxMessage}`],784
["Struktura konfigurace", { ok: "Include sshd_config.d před Match – podporováno", "include-missing": "sshd_config.d se nenačítá – nepodporováno", "include-after-match": "Include až za blokem Match – nepodporováno" }[ssh.layout] || ssh.layout],785
["Soubor spravovaný CH-J", `${ssh.managed.path} – ${{ absent: "neexistuje", present: ssh.managed.consistent ? "v pořádku" : "ručně upraven", unsafe: "není běžný soubor", unreadable: "nečitelný" }[ssh.managed.state] || ssh.managed.state}`],786
["Naslouchání sshd", listener ? `IPv4: ${listener.ipv4 ? "ano" : "ne"}, IPv6: ${listener.ipv6 ? "ano" : "ne"}` : "vyžaduje sudo"],787
["AuthorizedKeysCommand", ssh.effective.authorizedKeysCommand || (detail.privileged ? "není nastaven" : "vyžaduje sudo")],788
["Domovský adresář", detail.account.homeMeta || "—"],789
...files.map((file) => [`Klíče ${file.spec || file.path}`, `${file.path} – ${file.type}${file.meta ? `, ${file.meta}` : ""}${file.dirMeta ? `, adresář ${file.dirMeta}` : ""}`]),790
["Načteno", new Date(detail.collectedAt).toLocaleString("cs-CZ")]791
];792
const strict = [];793
for (const file of files) {794
const [owner, , mode] = String(file.meta || "").split(":");795
if (file.type === "file" && owner && owner !== detail.username && owner !== "root") strict.push(`${file.path} nevlastní ${detail.username} (StrictModes může klíče ignorovat).`);796
if (file.type === "file" && mode && /[2367]$|[2367].$/.test(mode)) strict.push(`${file.path} je zapisovatelný pro skupinu/ostatní (${mode}); sshd s StrictModes klíče odmítne.`);797
}798
el["panel-diagnostics"].replaceChildren(infoGrid(rows), ...strict.map((text) => h("p", { className: "notice warn", text })), ...ssh.warnings.map((text) => h("p", { className: "notice warn", text: translateWarning(text) })));799
}801
function selectTab(name, focus = true) {802
state.tab = name;803
for (const tab of document.querySelectorAll("[role=tab]")) {804
const selected = tab.id === `tab-${name}`;805
tab.setAttribute("aria-selected", String(selected));806
tab.tabIndex = selected ? 0 : -1;807
el[tab.getAttribute("aria-controls")].hidden = !selected;808
if (selected && focus) tab.focus();809
}810
}812
// ---------- Windows variants (SID, Administrators, account state; no UID/shell/sudo) ----------813
function formatDate(value) { return value ? new Date(value).toLocaleString("cs-CZ") : "—"; }815
function renderWindowsAccountTab(detail) {816
const account = detail.account;817
const children = [infoGrid([818
["Uživatelské jméno", account.username],819
["Celé jméno", account.fullName || "—"],820
["Popis", account.description || "—"],821
["SID", account.sid],822
["Zdroj", account.source === "MicrosoftAccount" ? "lokální účet propojený s účtem Microsoft" : account.source || "—"],823
["Stav účtu", account.enabled ? "povolen" : "zakázán"],824
["Heslo naposledy nastaveno", formatDate(account.passwordLastSet)],825
["Platnost hesla", account.passwordExpires ? formatDate(account.passwordExpires) : "neomezená"],826
["Platnost účtu", account.accountExpires ? formatDate(account.accountExpires) : "neomezená"],827
["Poslední přihlášení", formatDate(account.lastLogon)],828
["Profil", account.profilePath || "zatím nevytvořen"],829
["Typ", account.protection === "builtin" ? "vestavěný účet" : "běžný lokální účet"],830
["Administrators (S-1-5-32-544)", account.admin ? "člen" : "není člen"]831
]), h("p", { className: "hint", text: "Windows účty se identifikují podle SID. UID/GID, shell ani sudo se na Windows nepoužívají. Zakázaný účet se nepřihlásí žádnou metodou včetně SSH klíče." })];832
if (!account.protection && detail.privileged) {833
children.push(h("div", { className: "inline-actions" }, [834
actionButton("Změnit heslo…", () => changePassword(account.username), { sudo: false }),835
account.enabled836
? actionButton("Zakázat účet", () => simpleAction("disable", account.username, "Zakázání účtu", "Účet se nebude moci přihlásit žádnou metodou. Data a profil zůstanou zachovány."), { sudo: false, danger: true })837
: actionButton("Povolit účet", () => simpleAction("enable", account.username, "Povolení účtu"), { sudo: false }),838
actionButton("Upravit celé jméno…", () => changeWindowsText(account, "setFullName", "Celé jméno", account.fullName, 256), { sudo: false }),839
actionButton("Upravit popis…", () => changeWindowsText(account, "setDescription", "Popis", account.description, 48), { sudo: false }),840
actionButton("Odstranit účet…", () => deleteAccount(account), { danger: true, sudo: false })841
]));842
} else if (!account.protection) {843
children.push(h("p", { className: "notice", text: "Změny účtu vyžadují SSH relaci správce se zvýšeným tokenem." }));844
}845
el["panel-account"].replaceChildren(...children);846
}848
function renderWindowsGroupsTab(detail) {849
const account = detail.account;850
const editable = !account.protection && detail.privileged;851
const groups = state.inventory?.groups || [];852
const memberSids = new Set(account.groups.map((group) => group.sid));853
const children = [section("Lokální skupiny", [854
h("ul", { className: "chip-list" }, account.groups.length ? account.groups.map((group) => h("li", { title: group.sid }, [855
h("span", { text: group.name }),856
editable ? h("button", { type: "button", className: "mini", dataset: { mutation: "1" }, text: "Odebrat", onclick: () => removeWindowsGroup(account, group) }) : null857
])) : [h("li", { className: "muted", text: "Účet není členem žádné lokální skupiny." })])858
], "Názvy skupin jsou lokalizované; CH-J pracuje se skupinami podle SID.")];859
if (editable) {860
const candidates = groups.filter((group) => !memberSids.has(group.sid) && group.sid !== "S-1-5-32-544").sort((left, right) => left.name.localeCompare(right.name));861
const select = h("select", { "aria-label": "Přidat do skupiny" }, candidates.map((group) => h("option", { value: group.sid, text: group.name })));862
children.push(section("Přidat do skupiny", h("div", { className: "inline-actions" }, [select, actionButton("Přidat", () => {863
const group = candidates.find((candidate) => candidate.sid === select.value);864
if (group) void runMutation(`Přidání do skupiny ${group.name}`, (sessionId, auth) => api.users.manage(sessionId, { action: "addGroup", ...identFor(account.username), group: group.sid, ...auth }));865
}, { sudo: false })])));866
}867
const tokenMode = (PRIVILEGE_TEXT[detail.privilege?.mode] || PRIVILEGE_TEXT.unknown)[0];868
const adminChildren = [infoGrid([869
["Členství v Administrators", account.admin ? "člen (S-1-5-32-544)" : "není člen"],870
["Token této SSH relace", tokenMode]871
]), h("p", { className: "hint", text: "Členství ve skupině Administrators neznamená automaticky zvýšený token procesu (UAC). Na Windows se nepoužívá sudo a CH-J neobchází UAC ani bezpečnostní politiku." })];872
if (editable) {873
adminChildren.push(h("div", { className: "inline-actions" }, [874
account.admin875
? actionButton("Odebrat z Administrators", () => simpleAction("revokeAdmin", account.username, "Odebrání administrátorských oprávnění", "Účet bude odebrán ze skupiny Administrators (S-1-5-32-544). Posledního povoleného správce CH-J neodebere."), { danger: true, sudo: false })876
: actionButton("Přidat do Administrators", () => simpleAction("grantAdmin", account.username, "Přidání administrátorských oprávnění", "Účet bude přidán do skupiny Administrators (S-1-5-32-544). Jeho SSH klíče se pak čtou ze sdíleného administrators_authorized_keys."), { sudo: false })877
]));878
}879
children.push(section("Správci", adminChildren));880
const external = state.inventory?.externalIdentities || [];881
if (external.length) {882
children.push(section("Doménové a cloudové identity v lokálních skupinách (jen pro čtení)", h("ul", { className: "details-list" }, external.map((item) => h("li", { text: `${item.name} (${item.sid}, ${item.source}) – ${item.groups.join(", ")}` })))));883
}884
el["panel-groups"].replaceChildren(...children);885
}887
function renderWindowsDiagnosticsTab(detail) {888
const ssh = detail.ssh;889
const system = detail.system || {};890
const privilege = detail.privilege || state.privilege || {};891
const listener = ssh.effective.listener;892
const rows = [893
["Systém", `${system.caption || "Windows"} ${system.version || ""} (build ${system.build || "?"})`],894
["Typ instalace", system.installationType ? `${system.installationType}${system.serverCore ? " – bez GUI" : ""}` : "—"],895
["Detekce platformy", `${system.detectedBy || "?"}${system.unameOutput ? `, uname: ${system.unameOutput}` : ""}`],896
["Výchozí SSH shell", `${system.defaultShell || "?"}${system.defaultShellPath ? ` (${system.defaultShellPath})` : " (výchozí cmd.exe)"}`],897
["PowerShell pro CH-J", `${system.powershell || "?"}${system.is64BitProcess ? ", 64bit" : ", 32bit!"}, ${system.languageMode || "?"}${system.pwsh ? `; PowerShell 7: ${system.pwsh}` : "; PowerShell 7 nenalezen"}`],898
["LocalAccounts", system.localAccounts ? "dostupný" : "nedostupný"],899
["Token relace", `${privilege.username || "?"} – ${(PRIVILEGE_TEXT[privilege.mode] || PRIVILEGE_TEXT.unknown)[0]}, integrita ${privilege.integrity || "?"}`],900
["Adresa klienta (SSH_CLIENT)", privilege.clientAddress || "neznámá"],901
["OpenSSH Server", ssh.version || (ssh.available ? "neznámá verze" : "nenainstalován")],902
["Služba sshd", ssh.service || "—"],903
["sshd_config", ssh.configPath || "—"],904
["Kontrola sshd -t", ssh.syntaxOk === null ? "vyžaduje zvýšený token" : ssh.syntaxOk ? "v pořádku" : `CHYBA: ${ssh.syntaxMessage}`],905
["Spravované sekce CH-J", ssh.layoutSupported ? (ssh.managed.state === "present" ? (ssh.managed.consistent ? "přítomny, v pořádku" : "ručně upraveny") : "zatím nejsou") : `nepodporováno: ${(ssh.layoutProblems || []).join("; ")}`],906
["Naslouchání sshd", listener ? `IPv4: ${listener.ipv4 ? "ano" : "ne"}, IPv6: ${listener.ipv6 ? "ano" : "ne"}` : "vyžaduje zvýšený token"],907
["Aplikace změn", "restart služby sshd s nezávislým rollbackem (naplánovaná úloha SYSTEM, 120 s)"],908
...detail.keys.files.map((file) => [`Klíče ${file.spec || ""}`, `${file.path || "nevyřešeno"} – ${file.type}${file.shared ? " (sdílený)" : ""}${file.acl?.length ? `; ACL: ${file.acl.map((entry) => `${entry.sid}:${entry.rights}`).join(", ")}` : ""}`]),909
["Načteno", new Date(detail.collectedAt).toLocaleString("cs-CZ")]910
];911
el["panel-diagnostics"].replaceChildren(infoGrid(rows), ...ssh.warnings.map((text) => h("p", { className: "notice warn", text: translateWarning(text) })));912
}914
async function changeWindowsText(account, action, label, value, limit) {915
const answer = await askValue({ title: label, label, value: value || "", validate: ({ value: text }) => text.length > limit || /[\x00-\x1f]/.test(text) ? `Max. ${limit} znaků bez řídicích znaků.` : "" });916
if (!answer) return;917
const field = action === "setDescription" ? "description" : "fullName";918
await runMutation(`Úprava: ${label}`, (sessionId, auth) => api.users.manage(sessionId, { action, ...identFor(account.username), [field]: answer.value.trim(), ...auth }));919
}921
async function removeWindowsGroup(account, group) {922
const answer = await confirmAction({ title: `Odebrat ze skupiny ${group.name}`, message: `Účet ${account.username} přestane být členem skupiny ${group.name} (${group.sid}). Ostatní skupiny zůstanou.`, confirmLabel: "Odebrat", danger: true });923
if (!answer) return;924
await runMutation(`Odebrání ze skupiny ${group.name}`, (sessionId, auth) => api.users.manage(sessionId, { action: "removeGroup", ...identFor(account.username), group: group.sid, ...auth }));925
}927
// ---------- mutations ----------928
async function runMutation(label, invoke, { successMessage } = {}) {929
if (state.mutating || !connected()) return null;930
const token = state.generation;931
const sessionId = state.sessionId;932
state.mutating = true; updateControls();933
setStatus(`${label}…`);934
try {935
const result = await withSudo(label, (auth) => invoke(sessionId, auth));936
if (token !== state.generation) return null;937
if (result?.inventory) applyInventory(result.inventory);938
if (result?.sessionLost) { setStatus("Změna byla potvrzena, ale původní SSH relace se restartem sshd ukončila. Připojte se znovu v hlavním okně.", true); void loadSessions(); return result; }939
if (result?.detail) {940
// Keep the list column in sync with the verified policy returned by Core.941
const listed = state.inventory?.users.find((user) => user.username === result.detail.username);942
if (listed) {943
const current = result.detail.ssh.policy;944
listed.sshPolicy = POLICY_ROWS.every((row) => current[row.key] === row.options[0][0]) ? null : { ...current };945
renderUsers();946
}947
if (state.selected === result.detail.username) { state.detailToken += 1; state.detail = result.detail; state.detailLoading = false; renderDetail(); }948
}949
const failedSteps = (result?.steps || []).filter((step) => step.status === "failed");950
const warnings = [...(result?.warnings || []), ...failedSteps.map((step) => `Krok ${step.step} selhal: ${step.message}`)];951
const partial = result?.result === "partial" || failedSteps.length > 0;952
setStatus(`${successMessage || `${label}: hotovo, stav byl znovu načten ze serveru.`}${partial ? " Pouze částečně!" : ""}${warnings.length ? ` ${warnings.join(" ")}` : ""}`, partial);953
return result;954
} catch (error) {955
if (token === state.generation) setStatus(describeError(error), parseError(error).code !== CANCELLED);956
return null;957
} finally {958
state.mutating = false; updateControls();959
}960
}962
async function simpleAction(action, username, label, message = "") {963
const answer = await confirmAction({ title: label, message: message || `Provést „${label}“ pro účet ${username}?`, confirmLabel: label, danger: ["expire", "revokeAdmin", "lock"].includes(action) });964
if (!answer) return;965
await runMutation(label, (sessionId, auth) => api.users.manage(sessionId, { action, ...identFor(username), ...auth }));966
}968
async function changePassword(username) {969
const answer = await askValue({970
title: `Nové heslo pro ${username}`, label: "Nové heslo", label2: "Heslo znovu", type: "password",971
message: "Heslo se přenese přes stdin SSH kanálu do chpasswd, nikdy v argumentech příkazu.",972
validate: ({ value, value2 }) => !value ? "Zadejte heslo." : value !== value2 ? "Hesla se neshodují." : value.length > 256 || /[\r\n]/.test(value) ? "Heslo má neplatný formát." : ""973
});974
if (!answer) return;975
let password = answer.value;976
answer.value = ""; answer.value2 = "";977
await runMutation("Změna hesla", (sessionId, auth) => api.users.manage(sessionId, { action: "setPassword", ...identFor(username), password, ...auth }));978
password = "";979
}981
async function changeComment(account) {982
const answer = await askValue({ title: "Celé jméno", label: "Celé jméno (GECOS)", value: account.comment, validate: ({ value }) => value.length > 100 || /[:\\]/.test(value) ? "Max. 100 znaků bez dvojtečky." : "" });983
if (!answer) return;984
await runMutation("Úprava celého jména", (sessionId, auth) => api.users.manage(sessionId, { action: "setComment", ...identFor(account.username), comment: answer.value.trim(), ...auth }));985
}987
async function changeHome(account) {988
const answer = await askValue({989
title: "Domovský adresář", label: "Nová cesta", value: account.home, option: "přesunout obsah (usermod -m, cíl nesmí existovat)",990
validate: ({ value }) => /^\/[A-Za-z0-9._/@+-]+$/.test(value.trim()) ? "" : "Zadejte absolutní cestu bez mezer a speciálních znaků."991
});992
if (!answer || answer.value.trim() === account.home) return;993
await runMutation("Změna domovského adresáře", (sessionId, auth) => api.users.manage(sessionId, { action: "setHome", ...identFor(account.username), home: answer.value.trim(), moveHome: answer.option, ...auth }));994
}996
async function deleteAccount(account) {997
const answer = await confirmAction({998
title: `Odstranit účet ${account.username}`,999
message: "Účet bude odstraněn ze serveru. Operaci nelze vrátit.",1000
details: account.platform === "windows"1001
? [`SID ${account.sid}`, `Profil ${account.profilePath || "neexistuje"}`, "Pravidla SSH spravovaná CH-J pro tento účet budou také odstraněna (vyžaduje restart sshd a testovací přihlášení)."]1002
: [`UID ${account.uid}, domovský adresář ${account.home}`, account.processes ? `Účet má ${account.processes} spuštěných procesů – odstranění bude odmítnuto.` : "Pravidla SSH spravovaná CH-J pro tento účet budou také odstraněna."],1003
option: account.platform === "windows" ? (account.profilePath ? `odstranit také profil ${account.profilePath} (samostatné potvrzení)` : "") : `odstranit také domovský adresář ${account.home} (samostatné potvrzení)`,1004
typed: account.username, confirmLabel: "Odstranit účet", danger: true1005
});1006
if (!answer) return;1007
let removeHome = false;1008
if (answer.option) {1009
const second = await confirmAction({ title: account.platform === "windows" ? "Odstranit profil?" : "Odstranit domovský adresář?", message: `Obsah ${account.platform === "windows" ? account.profilePath : account.home} bude nevratně smazán.`, confirmLabel: "Ano, smazat i data", danger: true });1010
if (!second) { setStatus("Odstranění bylo zrušeno."); return; }1011
removeHome = true;1012
}1013
const result = await runMutation("Odstranění účtu", (sessionId, auth) => api.users.manage(sessionId, { action: "delete", ...identFor(account.username), removeHome, removeProfile: removeHome, ...auth }));1014
if (result && state.selected === account.username) { state.selected = null; state.detail = null; renderUsers(); renderDetail(); }1015
}1017
async function addGroup(username, group) {1018
if (!group) return;1019
await runMutation(`Přidání do skupiny ${group}`, (sessionId, auth) => api.users.manage(sessionId, { action: "addGroup", ...identFor(username), group, ...auth }));1020
}1022
async function removeGroup(username, group) {1023
const answer = await confirmAction({ title: `Odebrat ze skupiny ${group}`, message: `Účet ${username} přestane být členem skupiny ${group}. Ostatní skupiny zůstanou.`, confirmLabel: "Odebrat", danger: true });1024
if (!answer) return;1025
await runMutation(`Odebrání ze skupiny ${group}`, (sessionId, auth) => api.users.manage(sessionId, { action: "removeGroup", ...identFor(username), group, ...auth }));1026
}1028
async function setPrimary(username, group, current) {1029
if (!group || group === current) return;1030
const answer = await confirmAction({ title: "Změna primární skupiny", message: `Primární skupina účtu ${username} bude ${group}. Vlastnictví existujících souborů se nemění.`, confirmLabel: "Změnit" });1031
if (!answer) return;1032
await runMutation("Změna primární skupiny", (sessionId, auth) => api.users.manage(sessionId, { action: "setPrimaryGroup", username, group, ...auth }));1033
}1035
async function saveSshPolicy(detail, form) {1036
const rows = detail.platform !== "windows" && detail.account.uid === 0 ? [...POLICY_ROWS, ROOT_LOGIN_ROW] : POLICY_ROWS;1037
const policy = Object.fromEntries(rows.map((row) => [row.key, form.querySelector(`input[name="policy-${row.key}"]:checked`)?.value || detail.ssh.policy[row.key] || "inherit"]));1038
const changes = rows.filter((row) => policy[row.key] !== detail.ssh.policy[row.key]).map((row) => `${row.label}: ${row.options.find(([value]) => value === detail.ssh.policy[row.key])?.[1]} → ${row.options.find(([value]) => value === policy[row.key])?.[1]}`);1039
if (!changes.length) { setStatus("Nastavení SSH se nezměnilo."); return; }1040
let confirmRootPassword = false;1041
if (detail.account.uid === 0 && detail.platform !== "windows" && (policy.password === "yes" || policy.permitRootLogin === "yes")) {1042
const warning = await confirmAction({1043
title: "Povolit přihlášení root heslem?",1044
message: "Přihlášení root heslem výrazně zvyšuje riziko útoků hádáním hesel. Doporučená volba je „Jen klíč“ (prohibit-password). Pro přihlášení Ed25519 klíčem není heslo root potřeba.",1045
typed: "root", confirmLabel: "Rozumím, povolit heslo", danger: true1046
});1047
if (!warning) { setStatus("Povolení hesla pro root nebylo potvrzeno; nic nebylo změněno."); return; }1048
confirmRootPassword = true;1049
}1050
const risky = detail.self || policy.access === "deny" || policy.pubkey === "no" || policy.password === "no";1051
const answer = await confirmAction({1052
title: `SSH přístup účtu ${detail.username}`,1053
message: detail.platform === "windows" ? "sshd bude restartován. Změna se ponechá jen tehdy, když ji do 120 s potvrdí nové SSH přihlášení; jinak ji úloha na serveru sama vrátí." : detail.self ? "Měníte SSH přístup účtu této relace. Nechte relaci otevřenou; při neúspěšném testu nového přihlášení se změna vrátí." : "Změna se ověří (sshd -t, sshd -T -C) a aplikuje reloadem SSH služby.",1054
details: changes, confirmLabel: "Aplikovat a ověřit", danger: risky1055
});1056
if (!answer) return;1057
let probeCredentials = null;1058
await runMutation("Změna SSH přístupu", async (sessionId, auth) => {1059
for (;;) {1060
try { return await api.users.sshPolicy(sessionId, { ...identFor(detail.username), policy, confirmRootPassword, ...auth, ...(probeCredentials ? { probeCredentials } : {}) }); }1061
catch (error) {1062
const { code, detail: message } = parseError(error);1063
if (code !== "USERS_SSH_PROBE_CREDENTIALS_REQUIRED" || probeCredentials) throw error;1064
probeCredentials = await askProbeSecret(/passphrase/.test(message) ? "passphrase" : "password");1065
if (!probeCredentials) throw cancelled();1066
}1067
}1068
}, { successMessage: detail.platform === "windows" ? "SSH přístup byl změněn, ověřen přes sshd -T, sshd byl restartován a změnu potvrdilo nové SSH přihlášení." : "SSH přístup byl změněn, ověřen přes sshd -T a SSH služba byla znovu načtena." });1069
probeCredentials = null;1070
}1072
async function removeKey(detail, file, entry) {1073
const answer = await confirmAction({1074
title: "Odebrat SSH klíč", message: `Z ${file.path} bude odebrán pouze tento klíč. Ostatní řádky zůstanou beze změny.`,1075
details: [`${entry.type} ${entry.fingerprint}`, entry.comment ? `Komentář: ${entry.comment}` : "", `Řádek ${entry.line}`].filter(Boolean), confirmLabel: "Odebrat klíč", danger: true1076
});1077
if (!answer) return;1078
await runMutation("Odebrání SSH klíče", (sessionId, auth) => api.users.keys(sessionId, { action: "remove", ...identFor(detail.username), path: file.path, line: entry.line, fingerprint: entry.fingerprint, ...auth }));1079
}1081
async function addKey(detail) {1082
const publicKey = await runDialog(el.keyDialog, {1083
init: () => {1084
el.keyText.value = ""; el.keyGenComment.value = `${detail.username}@${state.sessions.find((session) => session.sessionId === state.sessionId)?.host || "server"}`;1085
el.keyGenPassphrase.value = ""; el.keyGenerateFields.hidden = true;1086
showError(el.keyError, ""); el.keyInfo.hidden = true;1087
},1088
submit: () => {1089
const text = el.keyText.value.trim();1090
if (!/^(ssh-ed25519|sk-ssh-ed25519@openssh\.com) [A-Za-z0-9+/=]+( .*)?$/.test(text)) {1091
showError(el.keyError, /PRIVATE KEY/.test(text) ? ERROR_TEXT.USERS_KEY_PRIVATE : "Vložte jeden veřejný klíč Ed25519 ve formátu OpenSSH.");1092
return undefined;1093
}1094
return text;1095
}1096
}).finally(() => { el.keyGenPassphrase.value = ""; });1097
if (!publicKey) return;1098
await runMutation("Přidání SSH klíče", (sessionId, auth) => api.users.keys(sessionId, { action: "add", ...identFor(detail.username), publicKey, ...auth }));1099
}1101
async function generateKeyPair() {1102
showError(el.keyError, ""); el.keyInfo.hidden = true;1103
el.keyGenRun.disabled = true;1104
let passphrase = el.keyGenPassphrase.value;1105
el.keyGenPassphrase.value = "";1106
try {1107
const generated = await api.keys.generate({ type: "ed25519", comment: el.keyGenComment.value, passphrase, privateKeyFormat: "openssh" });1108
passphrase = "";1109
for (const field of ["privateKey", "privateKeyPem", "privateKeyOpenSSH", "privateKeyPpk"]) generated[field] = "";1110
const saved = await api.keys.save(generated.generationId);1111
if (!saved || saved.canceled) { showError(el.keyError, "Soukromý klíč nebyl uložen, proto se veřejný klíč nepřidá."); return; }1112
el.keyText.value = generated.publicKey;1113
el.keyInfo.textContent = `Soukromý klíč byl uložen lokálně: ${saved.privatePath} (${saved.privateKeyFormat}). ${generated.notice || ""} Potvrďte přidání veřejného klíče na server.`;1114
el.keyInfo.hidden = false;1115
} catch (error) {1116
showError(el.keyError, `Generování selhalo: ${describeError(error)}`);1117
} finally {1118
passphrase = "";1119
el.keyGenRun.disabled = false;1120
}1121
}1123
async function openCreateDialog() {1124
const inventory = state.inventory;1125
if (!inventory) return;1126
const windows = inventory.platform === "windows";1127
const groups = windows1128
? inventory.groups.filter((group) => !["S-1-5-32-544", "S-1-5-32-545"].includes(group.sid)).sort((left, right) => left.name.localeCompare(right.name))1129
: inventory.groups.filter((group) => group.gid >= (inventory.server.uidMin || 1000) || ["sudo", "wheel", "adm", "users", "docker", "www-data"].includes(group.name)).sort((left, right) => left.name.localeCompare(right.name));1130
const payload = await runDialog(el.createDialog, {1131
init: () => {1132
for (const id of ["createUsername", "createComment", "createHome", "createPassword", "createPassword2"]) el[id].value = "";1133
el.createMakeHome.checked = true; el.createAdmin.checked = false; el.createDescription.value = "";1134
for (const id of ["createShell", "createHome", "createPrimary"]) el[id].closest("label").hidden = windows;1135
el.createMakeHomeLabel.hidden = windows;1136
el.createDescriptionLabel.hidden = !windows;1137
el.createUsername.pattern = windows ? "[A-Za-z0-9_][A-Za-z0-9._ \\-]{0,19}" : "[a-z_][a-z0-9_\\-]{0,31}";1138
el.createUsername.maxLength = windows ? 20 : 32;1139
el.createAdminText.textContent = windows ? "přidat do skupiny Administrators (S-1-5-32-544)" : "přidat do administrátorské skupiny (sudo/wheel podle serveru)";1140
el.createHint.textContent = windows ? "Účet se přidá do skupiny Users. Bez hesla se lze přihlásit jen SSH klíčem (je-li to povoleno politikou). Profil vznikne při prvním přihlášení." : "Bez hesla se účet může přihlásit jen SSH klíčem. Sudo práva se po vytvoření ověří pomocí sudo -l.";1141
const shells = inventory.shells.length ? inventory.shells : ["/bin/sh"];1142
el.createShell.replaceChildren(...shells.map((shell) => h("option", { value: shell, text: shell, selected: shell === "/bin/bash" })));1143
el.createPrimary.replaceChildren(h("option", { value: "", text: "Vlastní skupina se jménem účtu" }), ...groups.map((group) => h("option", { value: group.name, text: group.name })));1144
el.createGroups.replaceChildren(...groups.map((group) => h("option", { value: windows ? group.sid : group.name, text: group.name })));1145
showError(el.createError, "");1146
setTimeout(() => el.createUsername.focus(), 0);1147
},1148
submit: () => {1149
const username = el.createUsername.value.trim();1150
const password = el.createPassword.value;1151
const home = el.createHome.value.trim();1152
let problem = "";1153
if (windows ? !/^[A-Za-z0-9_][A-Za-z0-9._ -]{0,19}$/.test(username) || /[. ]$/.test(username) : !/^[a-z_][a-z0-9_-]{0,31}$/.test(username)) problem = windows ? ERROR_TEXT.USERS_NAME_INVALID_WINDOWS : ERROR_TEXT.USERS_NAME_INVALID;1154
else if (inventory.users.some((user) => user.username === username)) problem = ERROR_TEXT.USERS_USER_EXISTS;1155
else if (home && !/^\/[A-Za-z0-9._/@+-]+$/.test(home)) problem = ERROR_TEXT.USERS_HOME_INVALID;1156
else if (password !== el.createPassword2.value) problem = "Hesla se neshodují.";1157
else if (/[\r\n]/.test(password)) problem = ERROR_TEXT.USERS_PASSWORD_INVALID;1158
if (problem) { showError(el.createError, problem); return undefined; }1159
if (windows) {1160
return { action: "create", username, fullName: el.createComment.value.trim(), description: el.createDescription.value.trim(),1161
groups: [...el.createGroups.selectedOptions].map((option) => option.value), admin: el.createAdmin.checked, ...(password ? { password } : {}) };1162
}1163
return {1164
action: "create", username, comment: el.createComment.value.trim(), shell: el.createShell.value, home, createHome: el.createMakeHome.checked,1165
primaryGroup: el.createPrimary.value, groups: [...el.createGroups.selectedOptions].map((option) => option.value), admin: el.createAdmin.checked,1166
...(password ? { password } : {})1167
};1168
}1169
}).finally(() => { el.createPassword.value = ""; el.createPassword2.value = ""; });1170
if (!payload) return;1171
const result = await runMutation(`Vytvoření účtu ${payload.username}`, (sessionId, auth) => api.users.manage(sessionId, { ...payload, ...auth }));1172
payload.password = "";1173
if (result) {1174
el.filterSelect.value = "regular"; renderUsers();1175
void loadDetail(result.username);1176
}1177
}1179
// ---------- events ----------1180
el.closeButton.addEventListener("click", () => api.close());1181
el.refreshSessionsButton.addEventListener("click", () => { void loadSessions(); });1182
el.sessionSelect.addEventListener("change", () => { void selectSession(el.sessionSelect.value); });1183
el.reloadButton.addEventListener("click", async () => {1184
await refreshPrivilege();1185
await loadInventory();1186
if (state.selected) void loadDetail(state.selected);1187
});1188
el.elevatedButton.addEventListener("click", () => { void loadInventory({ elevated: true }); });1189
el.searchInput.addEventListener("input", renderUsers);1190
el.filterSelect.addEventListener("change", renderUsers);1191
el.createButton.addEventListener("click", () => { void openCreateDialog(); });1192
el.usersBody.addEventListener("click", (event) => {1193
const button = event.target.closest("button[data-username]");1194
if (button) void loadDetail(button.dataset.username);1195
});1196
document.querySelector("[role=tablist]").addEventListener("click", (event) => {1197
const tab = event.target.closest("[role=tab]");1198
if (tab) selectTab(tab.id.replace("tab-", ""));1199
});1200
document.querySelector("[role=tablist]").addEventListener("keydown", (event) => {1201
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;1202
const tabs = [...document.querySelectorAll("[role=tab]")];1203
const index = tabs.findIndex((tab) => tab.id === `tab-${state.tab}`);1204
const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : (index + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;1205
event.preventDefault();1206
selectTab(tabs[next].id.replace("tab-", ""));1207
});1208
el.keyImportButton.addEventListener("click", () => el.keyFile.click());1209
el.keyFile.addEventListener("change", async () => {1210
const file = el.keyFile.files?.[0];1211
el.keyFile.value = "";1212
if (!file) return;1213
if (file.size > 16 * 1024) { showError(el.keyError, "Soubor je příliš velký pro veřejný klíč."); return; }1214
const text = await file.text();1215
if (/PRIVATE KEY/.test(text)) { showError(el.keyError, ERROR_TEXT.USERS_KEY_PRIVATE); return; }1216
el.keyText.value = text.trim();1217
showError(el.keyError, "");1218
});1219
el.keyGenerateButton.addEventListener("click", () => { el.keyGenerateFields.hidden = !el.keyGenerateFields.hidden; });1220
el.keyGenRun.addEventListener("click", () => { void generateKeyPair(); });1222
(async () => {1223
try {1224
const info = await api.getInfo();1225
el.pluginVersion.textContent = `${info.name} v${info.version} · ${info.publisher}`;1226
} catch {}1227
await loadSessions();1228
})().catch((error) => setStatus(describeError(error), true));SHA-256
5bce947716cbcd3e6315bcdef90bf3207271e9b782b1ff80e254646e79f77df0CH-J Proprietary Software License 1.14 · सोर्स CH-J Proprietary Software License 1.14 के तहत उपलब्ध कराया जाता है। इसकी उपलब्धता लाइसेंस की शर्तें नहीं बदलती और न ही अतिरिक्त अधिकार देती है।
आर्काइव SHA-256
cf4455b698f0f28e456a37b1834976a0194d8486acb7101052d59a838745cca3