initial files

This commit is contained in:
2026-04-22 11:55:23 +02:00
commit 92444ff38c
85 changed files with 16324 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
plugins/
secure/
configuration.json
stylesheet.json
.npmrc
radix_os_*.png
radix_os_icon.ico

4
license_internal.txt Normal file
View File

@@ -0,0 +1,4 @@
© 2025 Grünflächenamt | Manuel Sowada
Diese Software ist ausschließlich für den internen dienstlichen Gebrauch durch Mitarbeiter des Grünflächenamtes vorgesehen.
Weitergabe, Veröffentlichung oder private Nutzung ist ohne ausdrückliche Genehmigung untersagt.

4937
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "radixos",
"version": "0.9",
"description": "Webbasiertes Betriebssystem für das Grünflächenamt Frankfurt am Main",
"main": "server.js",
"_moduleAliases": {
"@root": "./",
"@plugins": "./plugins",
"@public": "./public",
"@source": "./src",
"@services": "./src/services",
"@models": "./src/models",
"@routes": "./src/routes",
"@sockets": "./src/sockets"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"author": "Grünflächenamt - Manuel Sowada",
"license": "UNLICENSED",
"licensefile": "license_internal.txt",
"dependencies": {
"activedirectory2": "^2.2.0",
"bcryptjs": "^3.0.2",
"body-parser": "^2.2.0",
"child_process": "^1.0.2",
"chokidar": "^4.0.3",
"cookie-parser": "^1.4.7",
"express": "^5.1.0",
"express-handlebars": "^8.0.3",
"fs": "^0.0.1-security",
"fs-extra": "^11.3.2",
"https": "^1.0.0",
"jsonwebtoken": "^9.0.2",
"ldapjs": "^3.0.7",
"module-alias": "^2.2.3",
"multer": "^2.0.2",
"net": "^1.0.2",
"npm": "^11.6.4",
"oracledb": "^6.10.0",
"os": "^0.1.2",
"p-limit": "^7.2.0",
"path": "^0.12.7",
"sequelize": "^6.37.7",
"serve-favicon": "^2.5.1",
"socket.io": "^4.8.1",
"tedious": "^18.6.1"
}
}

View File

@@ -0,0 +1,72 @@
const Handlebars = require('handlebars');
module.exports = {
toJSON: function(object) {
if(typeof object === 'object') {
return JSON.stringify(object);
} else {
throw 'no object type';
}
},
isObject: function(value, options) {
return typeof value === 'object' && value !== null && !Array.isArray(value) ? options.fn(this) : options.inverse(this);
},
isArray: function(value, options) {
return Array.isArray(value) ? options.fn(this) : options.inverse(this);
},
isRGB: function(value) {
return Array.isArray(value) && (value.length === 3 || value.length === 4);
},
rgbString: function(value) {
if (Array.isArray(value)) {
return value.length === 3
? `rgb(${value.join(',')})`
: value.length === 4
? `rgba(${value.join(',')})`
: '';
}
return '';
},
toArray: function(value) {
if (Array.isArray(value)) return value;
if (typeof value === "string") return [value];
return [];
},
replaceAll: function(string, pattern, replacement) {
return new Handlebars.SafeString(string.replaceAll(pattern, replacement) || '');
},
equaler: function(v1, operator, v2, options) {
switch (operator) {
case '==':
return (v1 == v2) ? options.fn(this) : options.inverse(this);
case '===':
return (v1 === v2) ? options.fn(this) : options.inverse(this);
case '!=':
return (v1 != v2) ? options.fn(this) : options.inverse(this);
case '!==':
return (v1 !== v2) ? options.fn(this) : options.inverse(this);
case '<':
return (v1 < v2) ? options.fn(this) : options.inverse(this);
case '<=':
return (v1 <= v2) ? options.fn(this) : options.inverse(this);
case '>':
return (v1 > v2) ? options.fn(this) : options.inverse(this);
case '>=':
return (v1 >= v2) ? options.fn(this) : options.inverse(this);
case '&&':
return (v1 && v2) ? options.fn(this) : options.inverse(this);
case '||':
return (v1 || v2) ? options.fn(this) : options.inverse(this);
case 'typeof':
return (typeof v1 === v2) ? options.fn(this) : options.inverse(this);
case 'like':
return (v1.indexOf(v2)) > -1 ? options.fn(this) : options.inverse(this);
case 'includes':
return (v1.includes(v2)) <= 0 ? options.inverse(this) : options.fn(this);
case '%':
return (v1 % v2) == 0 ? options.fn(this) : options.inverse(this);
default:
return options.inverse(this);
}
},
};

View File

@@ -0,0 +1,6 @@
// const moment = require('moment'); // npm install moment
// moment.locale('de');
module.exports = {
dateFormat: (date, format) => dateFormat(date, format)
};

View File

@@ -0,0 +1,24 @@
module.exports = {
// objectTree: function(context, options) {
// let ret = '';
// if (context === null || context === undefined) return ret;
// if (Array.isArray(context)) {
// context.forEach((item, index) => {
// ret += options.fn({ key: index, value: item });
// });
// } else if (typeof context === 'object') {
// for (const key in context) {
// if (context.hasOwnProperty(key)) {
// ret += options.fn({ key, value: context[key] });
// }
// }
// } else {
// // primitive Werte
// ret += options.fn({ key: null, value: context });
// }
// return ret;
// }
}

View File

@@ -0,0 +1,145 @@
module.exports = {
objectTree: function(obj) {
function traverse(o) {
if (o === null || typeof o !== 'object') return o;
if (Array.isArray(o)) {
// RGB oder RGBA-Erkennung: Array aus 3 oder 4 Zahlen
if ((o.length === 3 || o.length === 4) && o.every(v => typeof v === 'number')) {
// const rgbString = o.length === 3
// ? `rgb(${o.join(',')})`
// : `rgba(${o.join(',')})`;
const rgbString = o.join(',');
return { key: null, value: rgbString, children: null, isRGB: true };
}
// normales Array
return o.map((v, i) => ({
key: i,
value: typeof v !== 'object' ? v : null,
children: typeof v === 'object' ? traverse(v) : null
}));
}
const result = [];
for (const key in o) {
if (o.hasOwnProperty(key)) {
const val = o[key];
const children = (val !== null && typeof val === 'object') ? traverse(val) : null;
result.push({
key,
value: children ? null : val,
children
});
}
}
return result;
}
return traverse(obj);
},
jsonEntriesRecursive: function(obj, options) {
const entries = [];
function traverse(prefix, data) {
if (Array.isArray(data)) {
data.forEach((item, index) => {
const arrayKey = `${prefix}[${index}]`;
if (typeof item === "object" && item !== null) {
traverse(arrayKey, item); // Rekursion für Array-Objekte
} else {
entries.push({ key: arrayKey, value: item });
}
});
} else if (typeof data === "object" && data !== null) {
for (const key in data) {
if (!Object.prototype.hasOwnProperty.call(data, key)) continue;
const value = data[key];
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === "object" && value !== null) {
traverse(fullKey, value); // Rekursion für verschachtelte Objekte
} else {
entries.push({ key: fullKey, value });
}
}
} else {
// Primitive Werte am obersten Level
entries.push({ key: prefix, value: data });
}
console.log({ key: prefix, value: data })
}
traverse("", obj);
// Handlebars-Block Helper
if (options.fn) {
return options.fn(entries);
}
return entries;
},
groupBy: function(items, keys, options) {
// keys kann String oder Array sein
if (!Array.isArray(keys)) keys = [keys];
if (!keys.length) return '';
const key = keys[0]; // aktueller Gruppierungsschlüssel
const remainingKeys = keys.slice(1); // restliche Schlüssel
// Gruppieren nach aktuellem Schlüssel
const groups = {};
items.forEach(item => {
let groupKeys = item[key];
if (!Array.isArray(groupKeys)) groupKeys = [groupKeys];
groupKeys.forEach(groupKey => {
if (!groups[groupKey]) groups[groupKey] = [];
groups[groupKey].push(item);
});
});
// Ergebnis zusammensetzen
let result = '';
for (const group in groups) {
const groupItems = groups[group];
if (remainingKeys.length > 0) {
// Rekursive Verschachtelung
// Wir rufen die gleiche Helper-Funktion intern auf
const nestedResult = Handlebars.helpers.groupBy(groupItems, remainingKeys, options);
result += options.fn({ key: group, items: nestedResult });
} else {
// Basisfall: keine weiteren Keys, items direkt weitergeben
result += options.fn({ key: group, items: groupItems });
}
}
return result;
},
parseArray: function(str){
try {
return JSON.parse(str);
} catch (e) {
return [];
}
},
ifSingle: function(array, options) {
if (Array.isArray(array) && array.length === 1) {
return options.fn(array[0]); // Item direkt übergeben
}
return options.inverse(this); // else-Block
},
findValue: function(array, searchKey, searchValue, returnKey) {
if (!Array.isArray(array)) return "";
for (let i = 0; i < array.length; i++) {
const item = array[i];
if (item && item[searchKey] === searchValue) {
return item[returnKey] ?? "";
}
}
return "";
}
};

BIN
public/images/app.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/images/brush.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
public/images/eventlog.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
public/images/folder.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
public/images/help.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
public/images/plugins.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 914 B

BIN
public/images/tutorial.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

361
public/javascript/JSON.js Normal file
View File

@@ -0,0 +1,361 @@
/*
createJsonTree({
container: domElement,
data: jsonData,
onChange: (data) => { }, // optional: callback on change
onSave: (data) => { } // optional: if set, a save button will be added
});
*/function createJsonTree({
container,
data,
onChange = () => {},
onSave = null
}) {
container.innerHTML = "";
container.classList.add("json-tree-root");
const history = [];
const redoStack = [];
let lastSnapshot = clone(data);
function clone(obj) {
return JSON.parse(JSON.stringify(obj));
}
function diff(oldObj, newObj) {
const changes = {};
function walk(o, n, path = "") {
const keys = new Set([
...Object.keys(o || {}),
...Object.keys(n || {})
]);
for (const key of keys) {
const oldVal = o?.[key];
const newVal = n?.[key];
const currentPath = path ? `${path}.${key}` : key;
if (
typeof oldVal === "object" &&
oldVal !== null &&
typeof newVal === "object" &&
newVal !== null &&
!Array.isArray(oldVal)
) {
walk(oldVal, newVal, currentPath);
continue;
}
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
changes[currentPath] = newVal;
}
}
}
walk(oldObj, newObj);
return changes;
}
function pushHistory() {
history.push(clone(data));
redoStack.length = 0;
}
function undo() {
if (!history.length) return;
redoStack.push(clone(data));
data = history.pop();
render();
}
function redo() {
if (!redoStack.length) return;
history.push(clone(data));
data = redoStack.pop();
render();
}
function save() {
if (onSave) onSave(data);
}
function autoResize(input) {
input.style.width = "10px";
input.style.width = input.scrollWidth + "px";
}
function remove(key, parent) {
if (key === null || parent == null) return;
if (!confirm("Wirklich löschen?")) return;
pushHistory();
if (Array.isArray(parent)) {
parent.splice(key, 1);
} else {
delete parent[key];
}
onChange(data);
render();
}
function render() {
container.innerHTML = "";
const controls = document.createElement("div");
controls.className = "json-controls";
const undoBtn = document.createElement("button");
undoBtn.className = "monolyth";
undoBtn.textContent = "Undo";
undoBtn.onclick = undo;
const redoBtn = document.createElement("button");
redoBtn.className = "monolyth";
redoBtn.textContent = "Redo";
redoBtn.onclick = redo;
controls.appendChild(undoBtn);
controls.appendChild(redoBtn);
if (onSave) {
const saveBtn = document.createElement("button");
saveBtn.className = "monolyth";
saveBtn.textContent = "Save";
saveBtn.onclick = save;
controls.appendChild(saveBtn);
}
container.appendChild(controls);
container.appendChild(renderNode(data, null, null, "root", 0));
}
function renderNode(value, key, parent, path, level) {
const wrapper = document.createElement("div");
wrapper.className = "json-line";
wrapper.style.marginLeft = `${level * 18}px`;
const keySpan = document.createElement("span");
keySpan.className = "json-key";
if (key !== null) {
keySpan.textContent = `"${key}": `;
}
const removeBtn = document.createElement("span");
removeBtn.className = "json-remove";
removeBtn.textContent = " [x]";
removeBtn.onclick = (evt) => {
evt.stopPropagation();
remove(key, parent);
};
// OBJECT / ARRAY
if (typeof value === "object" && value !== null) {
const isArray = Array.isArray(value);
const header = document.createElement("div");
header.className = "json-header";
const toggle = document.createElement("span");
toggle.className = "json-toggle";
toggle.textContent = isArray ? "[ ]" : "{ }";
const keyLabel = document.createElement("span");
keyLabel.className = "json-key";
if (key !== null) keyLabel.textContent = `"${key}": `;
const addBtn = document.createElement("span");
addBtn.className = "json-add";
addBtn.textContent = " [+]";
const children = document.createElement("div");
children.className = "json-children";
keyLabel.onclick = toggle.onclick = () => {
children.classList.toggle("collapsed");
};
addBtn.onclick = () => {
let newKey = "";
let newValue;
feedbox({
title: `<span style="color:#f44336">Key</span> hinzufügen`,
message: `
<div style="display:flex;flex-direction:column;gap:8px;">
${!isArray ? `
<input id="newJsonKeyName" placeholder="Key Name" style="padding:6px;" />
` : ``}
<select id="newJsonValueType">
<option value="text">Text</option>
<option value="number">Zahl</option>
<option value="boolean">Boolean</option>
<option value="object">Objekt</option>
<option value="array">Array</option>
</select>
</div>
`,
buttons: {
yes: {
text: '<b>Anlegen</b>',
onClick: () => {
pushHistory();
if (!isArray) {
const input = document.querySelector('#newJsonKeyName');
newKey = input?.value?.trim();
if (!newKey) return;
}
const selectedType = document.querySelector('#newJsonValueType').value;
switch (selectedType) {
case "number": newValue = 0; break;
case "boolean": newValue = false; break;
case "object": newValue = {}; break;
case "array": newValue = []; break;
default: newValue = "";
}
if (isArray) {
value.push(newValue);
} else {
value[newKey] = newValue;
}
onChange(data);
render();
}
},
no: { text: 'Abbrechen' }
}
});
};
const inner = document.createElement("div");
const entries = isArray
? value.map((v, i) => [i, v])
: Object.entries(value);
entries.forEach(([k, v]) => {
inner.appendChild(
renderNode(v, k, value, `${path}.${k}`, level + 1)
);
});
children.appendChild(inner);
header.appendChild(keyLabel);
header.appendChild(toggle);
header.appendChild(addBtn);
if (key !== null) header.appendChild(removeBtn);
wrapper.appendChild(header);
wrapper.appendChild(children);
return wrapper;
}
// STRING
if (typeof value === "string") {
const input = document.createElement("input");
input.className = "json-input string";
input.value = value;
input.oninput = () => {
pushHistory();
parent[key] = input.value;
autoResize(input);
onChange(data);
};
setTimeout(() => autoResize(input), 0);
wrapper.appendChild(keySpan);
wrapper.appendChild(input);
if (key !== null) wrapper.appendChild(removeBtn);
return wrapper;
}
// NUMBER
if (typeof value === "number") {
const input = document.createElement("input");
input.type = "number";
input.className = "json-input number";
input.value = value;
input.oninput = () => {
pushHistory();
parent[key] = Number(input.value);
autoResize(input);
onChange(data);
};
setTimeout(() => autoResize(input), 0);
wrapper.appendChild(keySpan);
wrapper.appendChild(input);
if (key !== null) wrapper.appendChild(removeBtn);
return wrapper;
}
// BOOLEAN
if (typeof value === "boolean") {
const input = document.createElement("input");
input.type = "checkbox";
input.checked = value;
input.onchange = () => {
pushHistory();
parent[key] = input.checked;
onChange(data);
};
wrapper.appendChild(keySpan);
wrapper.appendChild(input);
if (key !== null) wrapper.appendChild(removeBtn);
return wrapper;
}
const span = document.createElement("span");
span.textContent = String(value);
wrapper.appendChild(keySpan);
wrapper.appendChild(span);
if (key !== null) wrapper.appendChild(removeBtn);
return wrapper;
}
pushHistory();
render();
return {
undo,
redo,
save,
refresh: render,
getData: () => data,
getChanges() {
return diff(lastSnapshot, data);
},
commit() {
lastSnapshot = clone(data);
},
update(newData) {
data = newData;
lastSnapshot = clone(newData);
pushHistory();
render();
}
};
}

View File

@@ -0,0 +1,244 @@
/*
const ctx = new ContextMenu();
ctx.setItems([
{ label: "Öffnen", disable: true, onClick: () => alert("Öffnen!") },
{ label: "Öffnen", onClick: () => alert("Öffnen!") },
{ label: "Bearbeiten", onClick: () => alert("Bearbeiten…") },
{ type: "divider" },
{
label: "Mehr Optionen",
color: 'rgb()|color-name|color-value',
children: [
{ label: "Duplizieren", onClick: () => alert("Dupliziert!") },
{ label: "Umbenennen", onClick: () => alert("Rename…") },
{
label: "Exportieren",
children: [
{ label: "PDF", onClick: () => alert("PDF export") },
{ label: "CSV", onClick: () => alert("CSV export") },
]
}
]
},
{ label: "Löschen", onClick: () => alert("Gelöscht!") },
]);
ctx.show(element, or location, { position: "right" });
*/
class ContextMenu {
constructor() {
this.menu = document.createElement("div");
this.menu.className = "ctx-menu hidden";
document.body.appendChild(this.menu);
// close on click outside → sofort
document.addEventListener("click", (e) => {
if (!this.menu.contains(e.target)) {
this.closeAllSubmenus();
this.hide();
}
});
window.addEventListener("resize", () => this.hide());
window.addEventListener("scroll", () => this.hide());
}
setItems(items) {
this.menu.innerHTML = "";
this.menu.appendChild(this.buildMenu(items));
}
buildMenu(items) {
const ul = document.createElement("ul");
ul.className = "ctx-list";
items.forEach(item => {
if (item.type === "divider") {
const divider = document.createElement("li");
divider.className = "ctx-divider";
ul.appendChild(divider);
return;
}
const li = document.createElement("li");
li.className = "ctx-item";
if (item.color) li.style.boxShadow = `inset 5px 0px 0px 0px ${item.color}`;
if (item.disabled) li.classList.add("ctx-disabled");
li.innerHTML = `
<span class="ctx-label">${item.label}</span>
${item.children ? "<span class='ctx-arrow'>▶</span>" : ""}
`;
if (item.onClick && !item.children && !item.disabled) {
li.addEventListener("click", e => {
e.stopPropagation();
item.onClick(e);
this.hide(); // Hauptmenü sofort schließen
});
}
if (item.children) {
const submenu = this.buildMenu(item.children);
submenu.classList.add("ctx-submenu");
li.appendChild(submenu);
let hideTimeout;
const openSubmenu = () => {
clearTimeout(hideTimeout);
submenu.classList.add("open");
submenu.style.left = "";
submenu.style.top = "";
const liRect = li.getBoundingClientRect();
const submenuRect = submenu.getBoundingClientRect();
let left = li.offsetWidth;
if (liRect.right + submenuRect.width > window.innerWidth) left = -submenuRect.width;
submenu.style.left = left + "px";
let top = 0;
if (liRect.top + submenuRect.height > window.innerHeight) {
top = window.innerHeight - (liRect.top + submenuRect.height) - 4;
}
submenu.style.top = top + "px";
};
const closeSubmenu = (delay = 500) => {
clearTimeout(hideTimeout);
hideTimeout = setTimeout(() => {
submenu.classList.remove("open");
submenu.style.left = "";
submenu.style.top = "";
}, delay);
};
li.addEventListener("mouseenter", openSubmenu);
li.addEventListener("mouseleave", () => closeSubmenu(500));
submenu.addEventListener("mouseenter", () => clearTimeout(hideTimeout));
submenu.addEventListener("mouseleave", () => closeSubmenu(500));
}
ul.appendChild(li);
});
return ul;
}
closeAllSubmenus() {
this.menu.querySelectorAll(".ctx-submenu.open").forEach(sm => {
sm.classList.remove("open");
sm.style.left = "";
sm.style.top = "";
});
}
show(target, options = {}) {
this.closeAllSubmenus();
let x, y;
const position = options.position || "right"; // default
const offset = options.offset || 4;
// 🖱️ Mausposition
if (typeof target === "number") {
x = target;
y = options.y;
}
// 📦 Element als Anchor
else if (target instanceof HTMLElement) {
const rect = target.getBoundingClientRect();
switch (position) {
case "right":
x = rect.right + offset;
y = rect.top;
break;
case "left":
x = rect.left - this.menu.offsetWidth - offset;
y = rect.top;
break;
case "top":
x = rect.left;
y = rect.top - this.menu.offsetHeight - offset;
break;
case "bottom":
default:
x = rect.left;
y = rect.bottom + offset;
break;
}
}
// ❌ invalid fallback
else {
return;
}
// 👉 erstmal anzeigen (wichtig für width/height!)
this.menu.style.left = "0px";
this.menu.style.top = "0px";
this.menu.classList.add("show");
this.menu.classList.remove("hidden");
const rect = this.menu.getBoundingClientRect();
// 🧠 Reposition nach echten Maßen
if (target instanceof HTMLElement) {
const anchor = target.getBoundingClientRect();
switch (position) {
case "right":
x = anchor.right + offset;
y = anchor.top;
break;
case "left":
x = anchor.left - rect.width - offset;
y = anchor.top;
break;
case "top":
x = anchor.left;
y = anchor.top - rect.height - offset;
break;
case "bottom":
x = anchor.left;
y = anchor.bottom + offset;
break;
}
}
// 🧱 Screen Bounds
if (x + rect.width > window.innerWidth) {
x = window.innerWidth - rect.width - 4;
}
if (y + rect.height > window.innerHeight) {
y = window.innerHeight - rect.height - 4;
}
if (x < 0) x = 4;
if (y < 0) y = 4;
this.menu.style.left = x + "px";
this.menu.style.top = y + "px";
}
hide() {
this.menu.classList.remove("show");
setTimeout(() => this.menu.classList.add("hidden"), 200);
}
}

View File

@@ -0,0 +1,259 @@
let activeFeedbox = null;
//#region Messagebox
function getMessageColorByLevelId(levelId) {
return levelId == -1 ? 'test' :
levelId == 0 ? 'success' :
levelId == 1 ? 'info' :
levelId == 2 ? 'warn' :
levelId == 4 ? 'error' : 'throw_exception';
}
/**
* Shows messages in a containerbox on the right
* @param {string} title - Title
* @param {string} text - Message content
* @param {string} levelId - 1=info | 2=warn | 4=error | 8=throw_exception | 16=success
* @param {string} [targetId] - Which element is getting focused
* @param {number} duration - Time in ms until auto-close
*/
// function showMessage(title, text, levelId = 1, targetId = null, duration = 4000) {
function showMessage(title, text, levelId = 1, onclick = null, duration = 4000) {
// Falls kein Container existiert → automatisch anlegen
let container = document.getElementById('message-container');
if (!container) {
container = document.createElement('div');
container.id = 'message-container';
container.style.position = 'fixed';
container.style.top = '20px';
container.style.right = '20px';
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.alignItems = 'flex-end';
container.style.zIndex = '9999';
document.body.appendChild(container);
}
const message = document.createElement('div');
message.classList.add('message', getMessageColorByLevelId(levelId));
// --- HEADER ---
const header = document.createElement('div');
header.className = 'message-header';
const titleContainer = document.createElement('div');
titleContainer.className = 'message-title';
titleContainer.innerHTML = title;
const pinDiv = document.createElement('div');
pinDiv.className = 'pin-div';
pinDiv.innerHTML = '📌';
pinDiv.title = 'Anpinnen';
const countdown = document.createElement('span');
countdown.className = 'countdown';
countdown.textContent = `${(duration / 1000).toFixed(1)}s`;
// Titel + Countdown + Pin in eine Zeile
header.appendChild(titleContainer);
header.appendChild(countdown);
header.appendChild(pinDiv);
// --- BODY ---
const body = document.createElement('div');
body.className = 'message-text';
body.innerHTML = text;
message.appendChild(header);
message.appendChild(body);
container.appendChild(message);
// --- LOGIK ---
let pinned = false;
let remainingTime = duration;
let interval = null;
let lastTick = Date.now();
function startCountdown() {
lastTick = Date.now();
clearInterval(interval);
interval = setInterval(() => {
const now = Date.now();
const delta = now - lastTick;
lastTick = now;
remainingTime -= delta;
if (remainingTime <= 0 && !pinned) {
clearInterval(interval);
slideOutMessage(message);
} else if (!pinned) {
countdown.textContent = `${(remainingTime / 1000).toFixed(1)}s`;
}
}, 100);
}
function stopCountdown() {
clearInterval(interval);
}
// Start Countdown
startCountdown();
// Hover pausiert Countdown
message.addEventListener('mouseenter', stopCountdown);
message.addEventListener('mouseleave', () => {
if (!pinned) startCountdown();
});
// Klick auf Nachricht → schließen (nur wenn nicht gepinnt)
message.addEventListener('click', (e) => {
if(onclick) {
onclick();
}
// if (e.target === pinDiv) return;
// if (targetId) {
// const target = document.getElementById(targetId);
// if (target) target.scrollIntoView({ behavior: 'smooth' });
// }
if (!pinned) slideOutMessage(message);
});
// Klick auf Pin → anheften / lösen
pinDiv.addEventListener('click', (e) => {
e.stopPropagation();
pinned = !pinned;
if (pinned) {
pinDiv.classList.add('pinned');
pinDiv.title = 'Loslösen';
stopCountdown();
countdown.textContent = '📍 Angeheftet';
} else {
pinDiv.classList.remove('pinned');
pinDiv.title = 'Anpinnen';
startCountdown();
}
});
}
function slideOutMessage(message) {
if (!message) return;
message.style.animation = 'slideOut 0.5s forwards';
setTimeout(() => {
if (message.parentNode) message.parentNode.removeChild(message);
}, 300);
}
//#region Feedbox
/**
* feedbox({
* title: `<span style="color:#f44336">⚠ Upload abbrechen?</span>`,
* message: `
* <p>Es laufen noch <b>aktive Uploads</b>.</p>
* <p>Möchtest du wirklich <u>alle abbrechen</u>?</p>
* `,
* buttons: {
* yes: {
* text: '<b>Ja</b>, abbrechen',
* onClick: () => stopUploadQueue()
* },
* no: {
* text: 'Weiter hochladen'
* },
* cancel: {
* text: 'Zurück'
* }
* }
*});
*/
function feedbox({
title = '',
message = '',
buttons = {}, // buttons: { yes: { text: 'Yes', onClick: () => { } } }
primary = null, // name of the primary button, to accept with enter-key
lock = false, // locks desktop
replace = false // replaces an actually shown feedbox
}) {
// 🚫 Nested verhindern
if (activeFeedbox) {
if (!replace) {
return Promise.resolve('blocked');
}
activeFeedbox.close('replaced');
}
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'feedbox-overlay';
const box = document.createElement('div');
box.className = 'feedbox';
const h = document.createElement('h3');
h.innerHTML = title;
const msg = document.createElement('div');
msg.className = 'feedbox-message';
msg.innerHTML = message;
const actions = document.createElement('div');
actions.className = 'feedbox-actions';
const btnMap = {};
Object.entries(buttons).forEach(([key, cfg]) => {
if (!cfg) return;
const btn = document.createElement('button');
btn.className = `feedbox-btn ${key}`;
btn.innerHTML = cfg.text ?? key;
btn.onclick = () => {
cfg.onClick?.();
close(key);
};
btnMap[key] = btn;
actions.appendChild(btn);
});
function close(result) {
document.removeEventListener('keydown', keyHandler);
overlay.remove();
activeFeedbox = null;
resolve(result);
}
function keyHandler(e) {
if (e.key === 'Escape') close('cancel');
if (e.key === 'Enter' && primary && btnMap[primary]) {
btnMap[primary].click();
}
}
if (!lock) {
overlay.onclick = e => {
if (e.target === overlay) close('cancel');
};
}
document.addEventListener('keydown', keyHandler);
box.append(h, msg, actions);
overlay.appendChild(box);
document.body.appendChild(overlay);
// Fokus
if (primary && btnMap[primary]) {
setTimeout(() => btnMap[primary].focus(), 0);
}
// 🔐 Singleton setzen
activeFeedbox = { close };
});
}
//#endregion

View File

@@ -0,0 +1,97 @@
const auth = { objectGuid: getCookie('ObjectGUID'), sAMAccountName: getCookie('sAMAccountName') };
const mainSocket = io.connect('/', { reconnect: true, auth: auth });
const adminSocket = io.connect('/admin', { reconnect: true, auth: auth });
const container = document.getElementById('message-container');
adminSocket.on('eventlog', (data) => {
showMessage('EventLog',
`${data.datetime}<br>${[-1,0].includes(data.levelId) ? '' : data.trace}<br><br>${(Array.isArray(data.message) ? data.message.split('\r\n').join('<br>').split('\t').join('&ensp;') : data.message.split('\r\n').join('<br>').split('\t').join('&ensp;'))}`,
data.levelId,
() => {
} ,
10000);
});
mainSocket.on('event', (data) => {
showMessage(data.pluginName,
`[${data.datetime}]<br><br>${(Array.isArray(data.message) ? data.message.split('\r\n').join('<br>').split('\t').join('&ensp;') : data.message.split('\r\n').join('<br>').split('\t').join('&ensp;'))}`,
data.levelId,
() => {
} ,
10000);
});
//-1=test, 0=success, 1=log, 2=warn, 4=error, 8=throw_exception
function writeEventLog(levelId, pluginName, message) {
adminSocket.emit('eventlog', {
objectGuid: getCookie('ObjectGUID'),
levelId: levelId,
pluginName: pluginName,
message: message.stack === undefined ? message : { message: message.message, stack: message.stack }
});
}
// levelId: if -1, then write no log entry
// sendToParams: where clause to find objectGUIDs to send
function sendUserEvent(pluginName, message, sendToParams, levelId = -1) {
mainSocket.emit('event', {
objectGuid: getCookie('ObjectGUID'),
levelId: levelId,
pluginName: pluginName,
message: message.stack === undefined ? message : { message: message.message },
sendToParams: sendToParams
});
}
/*
{
"status":"unload",
"pluginName":"user_management",
"metadata":{
"name":"user_management",
"menuName":"User Management",
"description":"Beschreibung hier einfügen",
"version":"0.9.25.11.14",
"icon":"",
"permissions":[],
"config":{
},
"active":false
},
"levelId":0,
"message":"Plugin user_management entladen",
"authorized": true
}
*/
mainSocket.on('plugin_status', payload => {
const startMenuItem = document.querySelector(`[data-appname=${payload.metadata.name}]`);
if(['load', 'unload', 'update'].includes(payload.status)) {
if(payload.status == 'load') {
if(payload.authorized) {
startMenuItem.classList.remove('unload');
startMenuItem.setAttribute('data-active', true);
} else {
startMenuItem.classList.add('unload');
startMenuItem.setAttribute('data-active', false);
}
} else if(payload.status == 'unload') {
startMenuItem.classList.add('unload');
startMenuItem.setAttribute('data-active', false);
} else if(payload.status == 'update') {
if(payload.authorized && payload.metadata.active) {
startMenuItem.classList.remove('unload');
startMenuItem.setAttribute('data-active', true);
} else if(!payload.authorized){
startMenuItem.classList.add('unload');
startMenuItem.setAttribute('data-active', false);
}
}
}
});

1494
public/javascript/main.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,157 @@
class NotifyBubble {
constructor(trayButton, bubbleSelector) {
this.button = trayButton;
this.bubble = document.querySelector(bubbleSelector);
this.hideTimeout = null;
this.initEvents();
this.initExistingItems();
this.updateCounter(); // 🔥 initialer Counter
}
/* =========================
SHOW / HIDE
========================== */
show() {
clearTimeout(this.hideTimeout);
this.button.classList.add("active");
// Tooltip entfernen beim Öffnen
this.button.removeAttribute('data-tooltip');
}
hide(delay = 1000) {
clearTimeout(this.hideTimeout);
this.hideTimeout = setTimeout(() => {
this.button.classList.remove("active");
// 🔥 Counter nach Schließen anzeigen
this.updateCounter();
}, delay);
}
/* =========================
COUNTER / TOOLTIP
========================== */
updateCounter() {
const items = this.bubble.querySelectorAll(".bubble-item");
const count = items.length;
if (!this.button.classList.contains("active") && count > 0) {
this.button.setAttribute('data-tooltip', `${count} neue Benachrichtigung${count > 1 ? 'en' : ''}`);
} else {
this.button.setAttribute('data-tooltip', "Keine Benachrichtigungen");
}
}
/* =========================
EVENTS
========================== */
initEvents() {
// Toggle bei Klick auf Button
this.button.addEventListener("click", (e) => {
e.stopPropagation();
if (this.button.classList.contains("active")) {
this.hide(0);
} else {
this.show();
}
});
// Klick innerhalb der Bubble soll NICHT schließen
this.bubble.addEventListener("click", (e) => {
e.stopPropagation();
});
// Klick irgendwo anders → schließen
document.addEventListener("click", () => {
if (this.button.classList.contains("active")) {
this.hide(0);
}
});
}
/* =========================
INIT EXISTING ITEMS
========================== */
initExistingItems() {
this.bubble.querySelectorAll(".bubble-item").forEach(item => {
this.attachItemEvents(item);
});
}
/* =========================
ITEM EVENTS
========================== */
attachItemEvents(item) {
item.addEventListener("click", () => {
const checkbox = item.querySelector("input[type='checkbox']");
if (checkbox) {
checkbox.checked = !checkbox.checked;
fetch('/api/NotifyTray/markAsSeen', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: item.dataset.id,
value: checkbox.checked
})
});
// 🔥 Counter ggf. aktualisieren
this.updateCounter();
}
});
}
/* =========================
ADD SINGLE ITEM
========================== */
addItem(notification) {
const item = document.createElement("label");
item.className = "bubble-item";
item.dataset.id = notification.ID;
item.innerHTML = `
<div>${notification.Message}</div>
<div style="display:flex;flex-direction:row;flex-wrap:no-wrap;justify-content:flex-end;gap:10px;align-items:center;">
<div>
<div>${dateFormat(notification.CreatedAt, 'dd.mm.yyyy HH:MM:SS')}</div>
<div>${notification.PluginName}</div>
</div>
<div>
<label class="cb cb-modern">
<input id="notification_${notification.ID}" type="checkbox">
<span class="cb-box"></span>
</label>
</div>
</div>
`;
this.attachItemEvents(item);
this.bubble.appendChild(item);
// 🔥 Counter aktualisieren
this.updateCounter();
}
/* =========================
ADD MULTIPLE ITEMS
========================== */
addItems(notifications) {
notifications.forEach(n => this.addItem(n));
}
/* =========================
CLEAR
========================== */
clear() {
this.bubble.innerHTML = "";
this.updateCounter();
}
}

1081
public/javascript/os.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
const pluginAPI = {
async update(name, updates) {
if(Object.keys(updates)[0] && Object.values(updates)[0] == "") {
updates[Object.keys(updates)[0]] = [ ];
}
return await this._request(
`/api/plugins/${name}/update`,
'POST',
{ updates }
);
},
async activation(name, state) {
return await this._request(
`/api/plugins/activation`,
'POST',
{ name, state }
);
},
async create(name) {
return await this._request(
`/api/plugins/${name}/create`,
'POST'
);
},
async rename(name, newName) {
return await this._request(
`/api/plugins/${name}/rename`,
'POST',
{ newName }
);
},
async delete(name) {
return await this._request(
`/api/plugins/${name}/delete`,
'POST'
);
},
// --- zentrale Request-Funktion ---
async _request(url, method, body) {
try {
const options = { method };
if (body) {
options.headers = { 'Content-Type': 'application/json' };
options.body = JSON.stringify(body);
}
const res = await fetch(url, options);
if (!res.ok) {
const text = await res.text();
writeEventLog(4, 'CLIENT', `Request fehlgeschlagen: ${text}`);
throw new Error(`HTTP ${res.status}: ${text}`);
}
return res.json();
} catch (err) {
writeEventLog(4, 'CLIENT', err);
throw err;
}
}
};
class AttachOnBlurChange {
constructor(el, callback) {
this.el = el;
this.callback = callback;
this.initial = this.el.value;
this.el.addEventListener('blur', () => {
this.handleBlur();
});
}
handleBlur() {
if (this.el.value === this.initial) return;
const oldValue = this.initial;
const newValue = this.el.value;
this.initial = newValue;
this.callback(newValue, oldValue, this.el);
}
}

View File

@@ -0,0 +1,89 @@
function createRequiredProgress({ container, progress, onChange }) {
let total = 0;
let filled = 0;
const body = typeof container === 'string'
? document.querySelector(container)
: container;
const progressEl = typeof progress === 'string'
? document.querySelector(progress)
: progress;
if (!body || !progressEl) return;
function isElementVisible(el) {
const style = window.getComputedStyle(el);
return el.offsetParent !== null && style.opacity !== '0';
}
function updateRequiredProgress() {
const requiredElements = Array.from(
body.querySelectorAll('input[required], select[required], textarea[required]')
).filter(isElementVisible);
total = requiredElements.length;
filled = requiredElements.filter(el =>
el.classList.contains('is-required-filled')
).length;
progressEl.textContent = `${filled} / ${total} Pflichtfelder ausgefüllt`;
progressEl.classList.toggle('complete', filled === total);
progressEl.classList.toggle('incomplete', filled !== total);
}
function updateRequiredState(el) {
const isEmpty =
(el instanceof HTMLSelectElement && !el.value) ||
(el instanceof HTMLInputElement && el.type !== 'checkbox' && !el.value.trim()) ||
(el instanceof HTMLTextAreaElement && !el.value.trim());
el.classList.toggle('is-required-empty', isEmpty);
el.classList.toggle('is-required-filled', !isEmpty);
updateRequiredProgress();
if (typeof onChange === 'function') {
onChange({
element: el,
isEmpty,
isFilled: !isEmpty,
isFinished: total === filled
});
}
}
function initialize() {
const elements = Array.from(
body.querySelectorAll('input[required], select[required], textarea[required]')
).filter(isElementVisible);
elements.forEach(el => {
updateRequiredState(el);
el.addEventListener('input', () => updateRequiredState(el));
el.addEventListener('change', () => updateRequiredState(el));
});
updateRequiredProgress();
}
// 🔥 Reagiere auf Sichtbarkeitsänderungen
const observer = new MutationObserver(updateRequiredProgress);
observer.observe(body, {
attributes: true,
subtree: true,
attributeFilter: ['style', 'class', 'hidden']
});
initialize();
// Optional: API zurückgeben
return {
initialize: initialize,
refresh: updateRequiredProgress,
destroy() {
observer.disconnect();
}
};
}

View File

@@ -0,0 +1,450 @@
/*
//Generic table filter module
// Usage:
TableFilter({
table: document.querySelector('#myTable'),
exceptedColumns: [ 'Status_ID' ],
filterConfig: {
columnModes: {
ID: 'text', // Textsuche in dieser Spalte
Status: 'dropdown', // Dropdown-Werte aus Tabelle sammeln
Objekt: 'text', // Textsuche
Priorität: 'dropdown',
Gewerk: 'dropdown',
Typ: 'dropdown',
Bedarfsmelder: 'text'
Bearbeiter: 'text'
Genehmiger: 'text'
},
checkboxFilter: {
column: 'Status_ID',
rules: [
{ label: 'Bearbeitung', test: v => [1,4,6,7,12,13,14].includes(parseInt(v)) },
{ label: 'Genehmigt', test: v => [2,5,9,10,11].includes(parseInt(v)) },
{ label: 'Abgelehnt', test: v => [3].includes(parseInt(v)) },
{ label: 'Abgeschlossen', test: v => [8].includes(parseInt(v)) }
]
}
}
});
// Generic table filter module (one textbox OR column-based dropdown)
// New features:
// - Selecting a column in the column-selector decides: textbox OR dropdown (configured in options)
// - If column-selector = empty value ⇒ full-row search
// - No extra checkbox needed
// - UI stays compact (only ONE input element + ONE dropdown)
*/
function TableFilter(options) {
try {
const table = options.table;
const cfg = options.filterConfig;
const exceptedColumns = options.exceptedColumns || [];
// Gruppenerkennung + Verwaltung
const groupMap = new Map(); // groupRow → childRows[]
let groupsInitialized = false;
const state = {
selectedColumn: '',
searchText: '',
dropdownValue: '',
dropdownCache: {},
checkbox: new Set(),
};
//------------------------------------
// Sticky Container für Filter + Header
//------------------------------------
if(document.querySelectorAll('.table-filter-container').length > 0) {
// Array.from(document.querySelectorAll('.table-filter-container')).forEach(filterContainer => {
// filterContainer.remove();
// })
const existing = table.previousElementSibling;
if (existing && existing.classList.contains('table-filter-container')) {
existing.remove();
}
}
const container = document.createElement('div');
container.className = 'table-filter-container';
container.style.position = 'sticky';
container.style.top = '0px';
container.style.left = '0px';
container.style.zIndex = 20;
// Filter UI wird hier reingesetzt
table.before(container);
// Tabelle selbst: header sticky
const thead = table.querySelector('thead');
const headerCells = Array.from(table.querySelectorAll('thead th'));
const getColumnIndex = name => headerCells.findIndex(c => c.textContent.trim() === name);
//------------------------------------
// Dynamisches Filter-UI
//------------------------------------
function createDynamicFilterUI() {
const wrap = document.createElement('div');
wrap.style="display:flex;flex-direction:row;align-items:center;gap:10px"
const colSelectLabel = document.createElement('label');
const colSelect = document.createElement('select');
const emptyOpt = document.createElement('option');
emptyOpt.value = '';
emptyOpt.textContent = '-- Alles --';
colSelect.appendChild(emptyOpt);
colSelectLabel.innerText = 'Spalte';
headerCells.forEach(cell => {
if(!exceptedColumns.includes(cell.textContent.trim())) {
const name = cell.textContent.trim();
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
colSelect.appendChild(opt);
}
});
const input = document.createElement('input');
input.type = 'search';
input.style.display = 'none';
const select = document.createElement('select');
select.style.display = 'none';
select.innerHTML = '<option value="">-- Alle --</option>';
function populateDropdownForColumn(colName) {
const colIndex = getColumnIndex(colName);
const rows = Array.from(table.querySelectorAll('tbody tr')).filter(r => !r.classList.contains('grouprow'));
const values = new Set();
rows.forEach(r => {
const cell = r.children[colIndex];
if (cell) values.add(cell.textContent.trim());
});
const sorted = new Set([...values].sort((a, b) => a.localeCompare(b)));
select.innerHTML = '<option value="">-- Alle --</option>' + [...sorted].map(v => `<option value="${v}">${v}</option>`).join('');
}
colSelect.addEventListener('change', () => {
state.selectedColumn = colSelect.value;
state.searchText = '';
state.dropdownValue = '';
input.value = '';
select.value = '';
if (!state.selectedColumn) {
input.style.display = '';
select.style.display = 'none';
return applyFilters();
}
const mode = cfg.columnModes[state.selectedColumn];
if (mode === 'text') {
input.style.display = '';
select.style.display = 'none';
} else if (mode === 'dropdown') {
input.style.display = 'none';
select.style.display = '';
populateDropdownForColumn(state.selectedColumn);
} else {
input.style.display = '';
select.style.display = 'none';
}
applyFilters();
});
input.style.display = '';
input.addEventListener('input', () => { state.searchText = input.value.toLowerCase(); applyFilters(); });
select.addEventListener('change', () => { state.dropdownValue = select.value; applyFilters(); });
wrap.append(colSelectLabel, colSelect, document.createElement('br'));
wrap.append(input, select);
container.appendChild(wrap);
}
//--------------------------------------------
// Filterbreite an Parent minus Scrollbar
//--------------------------------------------
function updateFilterWidth() {
if (!table || !container) return;
const wrapper = table.parentElement; // z.B. .table-wrapper
if (!wrapper) return;
const style = getComputedStyle(table.querySelector('thead th'));
const rect = wrapper.getBoundingClientRect();
const scrollbarWidth = wrapper.offsetWidth - wrapper.clientWidth;
container.style.width = (rect.width - scrollbarWidth - parseFloat(style.paddingRight)) + "px";
container.style.maxWidth = (rect.width - scrollbarWidth - parseFloat(style.paddingRight)) + "px";
}
// Initial setzen
updateFilterWidth();
// Fenstergröße beobachten
window.addEventListener('resize', updateFilterWidth);
// Dynamische Anpassung bei Wrapper Resize
const wrapper = table.parentElement;
if (wrapper) {
new ResizeObserver(updateFilterWidth).observe(wrapper);
}
//------------------------------------
// Checkbox Filter
//------------------------------------
function createCheckboxFilter() {
const cfgCheck = cfg.checkboxFilter;
const idx = getColumnIndex(cfgCheck.column);
if (idx < 0) return;
const wrapper = document.createElement('div');
cfgCheck.rules.forEach(rule => {
const checkWrapper = document.createElement('label');
const checkInput = document.createElement('input');
const checkTrack = document.createElement('span');
const checkThumb = document.createElement('span');
checkWrapper.classList.add("cb", "cb-switch");
checkWrapper.style.marginRight = '15px'
checkInput.type = 'checkbox';
checkInput.addEventListener('change', () => {
if (checkInput.checked) state.checkbox.add(rule);
else state.checkbox.delete(rule);
applyFilters();
});
checkTrack.classList.add('switch-track');
checkTrack.setAttribute("aria-hidden", 'true');
checkThumb.classList.add('switch-thumb');
checkThumb.setAttribute("aria-hidden", 'true');
checkTrack.append(checkThumb)
checkWrapper.append(rule.label, checkInput, checkTrack);
wrapper.appendChild(checkWrapper);
});
container.appendChild(wrapper);
}
//------------------------------------
// Live Counter
//------------------------------------
const counter = document.createElement('div');
counter.className = 'live-counter';
container.appendChild(counter);
//------------------------------------
// Filter Logik
//------------------------------------
function detectGroups() {
if (groupsInitialized) return;
groupsInitialized = true;
const rows = Array.from(table.querySelectorAll('tbody tr'));
let currentGroupRow = null;
let childBuffer = [];
rows.forEach(row => {
if (row.classList.contains('grouprow')) {
// vorige Gruppe speichern
if (currentGroupRow && childBuffer.length > 0) {
groupMap.set(currentGroupRow, childBuffer);
}
// neue Gruppe starten
currentGroupRow = row;
childBuffer = [];
}
else if (currentGroupRow) {
childBuffer.push(row);
}
});
// letzte Gruppe speichern
if (currentGroupRow && childBuffer.length > 0) {
groupMap.set(currentGroupRow, childBuffer);
}
// jedem groupRow ein Toggle verpassen (wenn nicht vorhanden)
groupMap.forEach((childRows, groupRow) => {
const toggle = groupRow.querySelector("span");
if (!toggle || toggle._groupToggleBound) return;
toggle._groupToggleBound = true;
toggle.addEventListener("click", () => toggleGroup(groupRow));
groupRow.addEventListener("dblclick", () => toggleGroup(groupRow));
});
}
function toggleGroup(groupRow) {
const childRows = groupMap.get(groupRow);
if (!childRows) return;
const toggle = groupRow.querySelector("span");
const collapsed = toggle.textContent === '+';
if (collapsed) {
// ausklappen → nur gefilterte Rows zeigen
childRows.forEach(r => {
if (!r._filteredOut) r.style.display = '';
});
toggle.textContent = '-';
} else {
// einklappen → alle verstecken
childRows.forEach(r => r.style.display = 'none');
toggle.textContent = '+';
}
}
function applyFilters() {
detectGroups();
const rows = Array.from(table.querySelectorAll('tbody tr'));
let visible = 0;
rows.forEach(row => {
if (row.classList.contains('grouprow')) { row.style.display = ''; return; }
let show = true;
if (state.selectedColumn === '') {
if (state.searchText) show = [...row.cells].some(c => c.textContent.toLowerCase().includes(state.searchText));
} else {
const idx = getColumnIndex(state.selectedColumn);
const mode = cfg.columnModes[state.selectedColumn];
if (mode === 'text' && state.searchText) {
show = row.cells[idx]?.textContent.toLowerCase().includes(state.searchText);
}
if (mode === 'dropdown' && state.dropdownValue) {
show = row.cells[idx]?.textContent === state.dropdownValue;
}
}
if (state.checkbox.size > 0) {
const idx = getColumnIndex(cfg.checkboxFilter.column);
const cellVal = row.cells[idx]?.textContent ?? '';
const pass = [...state.checkbox].some(rule => rule.test(cellVal));
if (!pass) show = false;
}
row._filteredOut = !show; // fürs Gruppensystem speichern
// Wenn Gruppen existieren:
if (groupMap.size > 0) {
// Prüfen ob row zu einer Gruppe gehört
let parentGroup = null;
for (const [g, list] of groupMap.entries()) {
if (list.includes(row)) {
parentGroup = g;
break;
}
}
if (parentGroup) {
const toggle = parentGroup.querySelector("span");
const collapsed = toggle.textContent === '+';
if (collapsed) {
// eingeklappt → immer ausblenden
row.style.display = 'none';
if (show) visible++;
} else {
// ausgeklappt → nur gefilterte anzeigen
row.style.display = show ? '' : 'none';
if (show) visible++;
}
return;
}
} else {
if (show) visible++;
}
// normale Zeile ohne Gruppe:
row.style.display = show ? '' : 'none';
});
counter.textContent = `${visible} Treffer`;
}
//------------------------------------
// Init
//------------------------------------
createDynamicFilterUI();
if(options.filterConfig.checkboxFilter) createCheckboxFilter();
applyFilters();
if (thead) {
thead.style.position = 'sticky';
thead.style.top = (container.offsetHeight - 1) + 'px';
thead.style.zIndex = 10;
}
//------------------------------------
// Public API
//------------------------------------
return {
refreshDropdown() { if (state.selectedColumn && cfg.columnModes[state.selectedColumn] === 'dropdown') {} },
reapply() { applyFilters(); }
};
} catch(err) {
alert(err.stack)
}
}
//#region Sort table
// let sortDirection = {};
function sortTable(tableId,colIndex) {
const sortDirection = {};
const table = document.getElementById(tableId);
const tbody = table.tBodies[0];
const rows = Array.from(tbody.querySelectorAll("tr"));
// Toggle sort direction
sortDirection[colIndex] = !sortDirection[colIndex];
rows.sort((a, b) => {
const cellA = a.cells[colIndex].textContent.trim();
const cellB = b.cells[colIndex].textContent.trim();
// Numeric sort if possible
const numA = parseFloat(cellA);
const numB = parseFloat(cellB);
if (!isNaN(numA) && !isNaN(numB)) {
return sortDirection[colIndex] ? numA - numB : numB - numA;
} else {
return sortDirection[colIndex]
? cellA.localeCompare(cellB)
: cellB.localeCompare(cellA);
}
});
// Remove old rows
tbody.innerHTML = "";
rows.forEach(row => tbody.appendChild(row));
// Update sort arrows
const th = table.querySelectorAll("th");
th.forEach((header, i) => {
header.classList.remove("sort-asc", "sort-desc");
if (i === colIndex) {
header.classList.add(sortDirection[colIndex] ? "sort-asc" : "sort-desc");
}
});
}
//#endregion

View File

@@ -0,0 +1,354 @@
class Tutorial {
constructor(steps, options = {}) {
this.steps = steps;
this.currentStep = 0;
this.isRunning = false;
this.isWaitingForEvent = false;
this.options = {
overlayOpacity: 0.7,
...options
};
this.zIndexBase = 1000;
this.zIndexStep = 0;
this.modifiedElements = new Map();
this.baseElement = null;
this.currentElement = null;
this.tooltipObserver = null;
this.tooltipRAF = null;
this.currentStepElement = null;
}
// ---------------- UI ----------------
createUI() {
document.addEventListener("keydown", e => {
if (this.isWaitingForEvent) return;
if (e.key === "ArrowRight") this.next();
if (e.key === "ArrowLeft") this.prev();
});
this.tooltip = document.createElement("div");
this.tooltip.id = "tutorial-tooltip";
Object.assign(this.tooltip.style, {
position: "absolute",
display: "flex",
padding: "12px",
borderRadius: "8px",
zIndex: 9999,
maxWidth: "350px",
boxShadow: "0 10px 30px rgba(0,0,0,0.2)",
backdropFilter: "blur(5px)",
justifyContent: "center"
});
this.text = document.createElement("p");
this.nextBtn = document.createElement("button");
this.nextBtn.innerHTML = ">";
this.nextBtn.classList.add("bluebutton");
this.prevBtn = document.createElement("button");
this.prevBtn.innerHTML = "<";
this.prevBtn.classList.add("yellowbutton");
this.nextBtn.onclick = () => this.next();
this.prevBtn.onclick = () => this.prev();
this.tooltip.appendChild(this.text);
this.tooltip.appendChild(this.prevBtn);
this.tooltip.appendChild(this.nextBtn);
document.body.appendChild(this.tooltip);
}
// ---------------- Element wait ----------------
waitForElement(selector, timeout = 5000) {
return new Promise(resolve => {
const start = Date.now();
const check = () => document.querySelector(selector);
let el = check();
if (el) return resolve(el);
const observer = new MutationObserver(() => {
el = check();
if (el) {
observer.disconnect();
resolve(el);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
const timer = setInterval(() => {
if (Date.now() - start > timeout) {
clearInterval(timer);
observer.disconnect();
resolve(null);
}
el = check();
if (el) {
clearInterval(timer);
observer.disconnect();
resolve(el);
}
}, 100);
});
}
// ---------------- Highlight ----------------
highlightElement(el) {
this.removeHighlight();
if (!this.modifiedElements.has(el)) {
this.modifiedElements.set(el, {
zIndex: el.style.zIndex,
position: el.style.position,
boxShadow: el.style.boxShadow
});
}
if (this.currentStep === 0 && !this.baseElement) {
this.baseElement = el;
}
let zIndex;
if (el === this.baseElement) {
zIndex = this.zIndexBase;
} else {
this.zIndexStep++;
zIndex = this.zIndexBase + this.zIndexStep;
}
el.style.position = "relative";
el.style.zIndex = zIndex;
el.style.boxShadow =
"0 0 0 4px rgba(255, 0, 0, 1), 0 0 20px rgba(255,0,0,0.7)";
this.currentElement = el;
this.startTooltipTracking(el);
}
removeHighlight() {
if (!this.currentElement) return;
this.currentElement.style.boxShadow = "";
this.stopTooltipTracking();
}
// ---------------- Event system (NEW) ----------------
getEventConfig(type) {
const map = {
click: () => new MouseEvent("click", { bubbles: true, cancelable: true, view: window }),
contextmenu: () => new MouseEvent("contextmenu", { bubbles: true, cancelable: true, view: window, button: 2 }),
dblclick: () => new MouseEvent("dblclick", { bubbles: true, cancelable: true, view: window }),
mouseenter: () => new MouseEvent("mouseenter", { bubbles: true, cancelable: true, view: window }),
mouseover: () => new MouseEvent("mouseover", { bubbles: true, cancelable: true, view: window })
};
return map[type] ? map[type]() : null;
}
async executeStepOnly(step, el) {
if (!step.action) return;
const event = this.getEventConfig(step.action);
if (event) {
el.dispatchEvent(event);
}
}
waitForUserEvent(el, type) {
return new Promise(resolve => {
const handler = (e) => {
el.removeEventListener(type, handler);
resolve(e);
};
el.addEventListener(type, handler);
});
}
// ---------------- Tooltip tracking ----------------
startTooltipTracking(el) {
this.stopTooltipTracking();
this.currentStepElement = el;
const update = () => {
if (!this.currentStepElement || !this.tooltip) return;
const rect = this.currentStepElement.getBoundingClientRect();
this.tooltip.style.top = rect.bottom + 10 + "px";
this.tooltip.style.left = rect.left + "px";
this.tooltipRAF = requestAnimationFrame(update);
};
this.tooltipObserver = new ResizeObserver(() => {
if (!this.currentStepElement) return;
const rect = this.currentStepElement.getBoundingClientRect();
this.tooltip.style.top = rect.bottom + 10 + "px";
this.tooltip.style.left = rect.left + "px";
});
this.tooltipObserver.observe(el);
this.tooltipRAF = requestAnimationFrame(update);
}
stopTooltipTracking() {
if (this.tooltipObserver) {
this.tooltipObserver.disconnect();
this.tooltipObserver = null;
}
if (this.tooltipRAF) {
cancelAnimationFrame(this.tooltipRAF);
this.tooltipRAF = null;
}
this.currentStepElement = null;
}
// ---------------- Steps ----------------
async showStep() {
let step = this.steps[this.currentStep];
this.updateButtons(step);
this.isWaitingForEvent = false;
while (step && (!step.text || step.text.trim() === "")) {
const elSilent = await this.waitForElement(step.element);
if (elSilent) {
await this.executeStepOnly(step, elSilent);
}
this.currentStep++;
if (this.currentStep >= this.steps.length) {
this.destroy();
return;
}
step = this.steps[this.currentStep];
}
const el = await this.waitForElement(step.element);
if (!el) {
this.next();
return;
}
this.highlightElement(el);
el.scrollIntoView({ behavior: "smooth", block: "center" });
const rect = el.getBoundingClientRect();
this.tooltip.style.display = "block";
this.text.innerHTML = step.text;
this.tooltip.style.top = rect.bottom + 10 + "px";
this.tooltip.style.left = rect.left + "px";
this.isWaitingForEvent = !!step.waitFor;
this.nextBtn.style.display = step.waitFor ? "none" : "inline-block";
this.prevBtn.style.display =
this.currentStep === 0 || step.waitFor ? "none" : "inline-block";
if (step.waitFor) {
await this.waitForUserEvent(el, step.waitFor);
this.isWaitingForEvent = false;
this.next();
}
}
updateButtons(step) {
const isLastStep = this.currentStep === this.steps.length - 1;
this.nextBtn.innerHTML = isLastStep ? "Fertig" : ">";
}
// ---------------- Navigation ----------------
start() {
if (this.isRunning) return;
this.isRunning = true;
this.createUI();
this.currentStep = 0;
this.showStep();
}
next() {
if (this.isWaitingForEvent) return;
this.removeHighlight();
if (this.currentStep < this.steps.length - 1) {
this.currentStep++;
this.showStep();
} else {
this.destroy();
}
}
prev() {
if (this.isWaitingForEvent) return;
this.removeHighlight();
if (this.currentStep > 0) {
this.currentStep--;
this.showStep();
}
}
// ---------------- Cleanup ----------------
destroy() {
this.isRunning = false;
this.stopTooltipTracking();
this.modifiedElements.forEach((styles, el) => {
el.style.zIndex = styles.zIndex || "";
el.style.position = styles.position || "";
el.style.boxShadow = styles.boxShadow || "";
});
this.modifiedElements.clear();
this.baseElement = null;
this.zIndexStep = 0;
this.tooltip?.remove();
}
}

131
public/styles/colors.css Normal file
View File

@@ -0,0 +1,131 @@
/* #region OS */
#desktop { background:var(--theme-desktop-backcolor); background-image:var(--theme-desktop-image); }
#taskbar { background:var(--theme-taskbar-backcolor); color:var(--theme-taskbar-color); }
#tutorial-tooltip {background:var(--theme-window-backcolor); color:var(--theme-window-color);}
#tutorial-tooltip img {filter: invert(1);}
#start-btn { background:var(--theme-accent-default-backcolor); color:var(--theme-accent-default-color); }
#start-btn:hover { background:var(--theme-accent-hover-backcolor); color:var(--theme-accent-hover-color); }
#start-btn:active { background:var(--theme-accent-active-backcolor); color:var(--theme-accent-active-color); }
/* #start-menu, .submenu { background:var(--theme-taskbar-backcolor); color:var(--theme-taskbar-color); } */
#start-menu { padding-bottom:10px; background:var(--theme-startmenu-backcolor); color:var(--theme-startmenu-color); border:3px solid rgb(128,128,128); }
.start-submenu-head { background:var(--theme-startmenu-submenu-header-backcolor); color:var(--theme-startmenu-submenu-header-backcolor) }
.start-icon { background:var(--theme-accent-default-backcolor); }
/* .start-item:hover, .start-sys-item:hover { color:var(--theme-accent-hover-color); background:var(--theme-accent-hover-backcolor); } */
.start-item.has-submenu.open > .menu-label , .start-item:not(.has-submenu.open), .start-sys-item { background-color:var(--theme-startmenu-item-default-backcolor); color:var(--theme-startmenu-item-default-color); }
.start-item:not(.has-submenu.open):not(.unload):hover, .start-sys-item:hover { background-color:var(--theme-startmenu-item-hover-backcolor); color:var(--theme-startmenu-item-hover-color); }
.start-item:not(.has-submenu):not(.has-submenu.open):not(.unload):active, .start-sys-item:active { color:var(--theme-accent-active-color); background:var(--theme-accent-active-backcolor); }
.start-item-sys-container { background:var(--theme-startmenu-syscontainer-backcolor);}
.start-item .unload { background:var(--theme-startmenu-item-disabled-backcolor); color:var(--theme-startmenu-item-disabled-color); }
/* #taskbar .taskbar-item { background:var(--theme-taskbar-item-backcolor); } */
.taskbar-item { position: relative;}
.taskbar-item::before { background: var(--theme-accent-active-color); }
.taskbar-item.focus::before { background: var(--theme-accent-active-backcolor); }
/* .taskbar-item.minimized { background:var(--theme-taskbar-item-minimized-backcolor); color:var(--theme-taskbar-item-minimized-color); border-color:var(--theme-taskbar-item-minimized-border-color);} */
.taskbar-item.default { background:var(--theme-taskbar-item-default-backcolor); color:var(--theme-taskbar-item-default-color); border-color:var(--theme-taskbar-item-default-border-color);}
.taskbar-item:hover { background-color:var(--theme-startmenu-item-hover-backcolor); color:var(--theme-startmenu-item-hover-color); }
.window { border:2px solid var(--theme-window-titlebar-backcolor); background:var(--theme-window-backcolor); }
.window-content { color:var(--theme-window-color); background:var(--theme-window-backcolor); }
.window-titlebar { color:var(--theme-window-titlebar-color); background:var(--theme-window-titlebar-backcolor); }
.window .controls button { color:var(--theme-window-titlebar-color); background:transparent; }
.window .controls button:hover { color:var(--theme-accent-hover-color); background:var(--theme-accent-hover-backcolor); }
/* #endregion */
/* #region Table */
table, tr, td, th { border-color:var(--theme-table-border-color); }
table thead th { background-color:var(--theme-table-header-backcolor); color:var(--theme-table-header-color); border-color:var(--theme-table-border-color); }
table tbody tr:nth-child(even) { background-color:var(--theme-table-rows-even-backcolor); color:var(--theme-table-rows-even-color); }
table tbody tr:nth-child(odd) { background-color:var(--theme-table-rows-odd-backcolor); color:var(--theme-table-rows-odd-color); }
table tbody tr:not(.grouprow):not(.no-hover):hover { color:var(--theme-accent-hover-color); background:var(--theme-accent-hover-backcolor); }
table tbody tr.grouprow { background-color:var(--theme-table-rows-grouprow-backcolor); color:var(--theme-table-rows-grouprow-color); }
table tbody tr.active, table tbody td.active { color:var(--theme-accent-active-color); background:var(--theme-accent-active-backcolor); }
.table-filter-container { border-color:var(--theme-table-border-color); background-color:var(--theme-table-header-backcolor); color:var(--theme-table-header-color); }
/* #endregion */
/* #region Scrollbar */
::-webkit-scrollbar-track, ::-webkit-scrollbar-corner { background:var(--theme-scrollbar-backcolor); }
::-webkit-scrollbar-thumb { background-color:var(--theme-scrollbar-thumb-default-backcolor); border-color:var(--theme-scrollbar-thumb-default-border-color); }
::-webkit-scrollbar-thumb:hover { background-color:var(--theme-scrollbar-thumb-hover-backcolor); border-color:var(--theme-scrollbar-thumb-hover-border-color); }
/* #endregion */
/* #region Tooltip */
.global-tooltip { background:var(--theme-tooltip-backcolor); color:var(--theme-tooltip-color); border-color:var(--theme-tooltip-border-color) }
/* #endregion */
/* #region feedbox */
.feedbox { background:var(--theme-feedbox-backcolor); color:var(--theme-feedbox-color);box-shadow:0 5px 25px rgba(0,0,0,0.3); }
.feedbox-overlay { background:rgba(0,0,0,0.5); }
/* #endregion */
/* #region Messagbox */
.test, .test .countdown { color:var(--theme-message-test-color); background-color:var(--theme-message-test-backcolor); border-color:var(--theme-message-test-border-color); }
.success, .success .countdown { color:var(--theme-message-success-color); background-color:var(--theme-message-success-backcolor); border-color:var(--theme-message-success-border-color); }
.error, .error .countdown { color:var(--theme-message-error-color); background:var(--theme-message-error-backcolor); border-color:var(--theme-message-error-border-color); }
.info, .info .countdown { color:var(--theme-message-info-color); background-color:var(--theme-message-info-backcolor); border-color:var(--theme-message-info-border-color); }
.warn, .warn .countdown { color:var(--theme-message-warn-color); background-color:var(--theme-message-warn-backcolor); border-color:var(--theme-message-warn-border-color); }
.throw_exception, .throw_exception .countdown { color:var(--theme-message-throw-color); background-color:var(--theme-message-throw-backcolor); border-color:var(--theme-message-throw-border-color); }
.message { box-shadow:0 4px 10px rgba(0,0,0,0.3); } /* SHADOW? */
/* #endregion */
.error-field { background:var(--theme-message-error-backcolor); border-color:var(--theme-message-error-border-color); color:var(--theme-message-error-color); }
.success-field { background:var(--theme-message-success-backcolor); border-color: var(--theme-message-success-border-color); color:var(--theme-message-success-color); }
/* region Container*/
.card { background:var(--theme-container-card-backcolor); border-color:var(--theme-container-card-border); }
/* #endregion */
/* #region Checkbox */
.cb-box { border-color:var(--theme-checkbox-default-border-color); box-shadow:0 1px 2px var(--theme-checkbox-shadow-color) inset; }
.cb input:checked + .cb-box { background:var(--theme-checkbox-default-thumb); border-color:var(--theme-checkbox-default-thumb); }
.cb input:disabled + .cb-box { background:var(--theme-checkbox-disabled-backcolor); border-color:var(--theme-checkbox-disabled-border-color); }
.cb-label { color:var(--theme-checkbox-color); }
/* #endregion */
/* #region Switch */
.cb-switch .cb-label { color:var(--theme-switch-color); }
.switch-track { background:var(--theme-switch-backcolor); border-color:var(--theme-switch-border-color); }
/* #endregion */
/* #region Required */
.is-required-empty { border-width:2px; border-color:var(--theme-required-empty); border-style:solid; }
.is-required-filled { border-color:var(--theme-required-accept); }
/* #endregion */
/* #region Tabs */
.tabs { border-bottom-color: #ccc; }
.tab-content { border-color: #ccc; }
.tab:hover { background-color: var(--theme-accent-hover-backcolor); color: var(--theme-accent-hover-color); }
.tab.active { background-color: var(--theme-accent-active-backcolor); color: var(--theme-accent-active-color); border-color: var(--theme-accent-active-border-color); border-bottom-color: var(--theme-accent-active-backcolor); }
/* #endregion */
/* #region DOM */
button:not(:disabled).monolyth { background-color:var(--theme-button-monolyth-default-backcolor); color:var(--theme-button-monolyth-default-color); }
button:not(:disabled).monolyth:hover { background-color:var(--theme-button-monolyth-hover-backcolor); color:var(--theme-button-monolyth-hover-color); }
button:not(:disabled).monolyth:hover, button:not(:disabled).bluebutton:hover, button:not(:disabled).redbutton:hover, button:not(:disabled).yellowbutton:hover, button:not(:disabled).greenbutton:hover { box-shadow:0 6px 8px rgba(0,0,0,0.15); }
select { background:var(--theme-select-default-backcolor); color:var(--theme-select-default-color); box-shadow:0 0 5px var(--theme-select-default-border-color); }
input:hover, input[type="text"]:hover, input[type="email"]:hover, input[type="password"]:hover, input[type="number"]:hover, input[type="date"]:hover,textarea:hover, select:hover { background:var(--theme-input-hover-backcolor); color:var(--theme-input-hover-color); box-shadow:0 0 5px var(--theme-input-hover-border-color); }
input:focus, input[type="text"]:focus, input[type="email"]:focus, input[type="password"]:focus, input[type="number"]:focus, input[type="date"]:focus, textarea:focus, select:focus { background:var(--theme-input-focus-backcolor); color:var(--theme-input-focus-color); box-shadow:0 0 5px var(--theme-input-focus-border-color); }
input.valid { border: 2px solid var(--theme-input-accept-border-color); }
input.invalid { border: 2px solid var(--theme-input-decline-border-color); }
*::placeholder, input[id="sAMAccountName"] { color:var(--theme-input-placeholder-color); }
textarea, input { border-color:var(--theme-input-default-border-color); background:var(--theme-input-default-backcolor); color:var(--theme-input-default-color); }
textarea:not([required]):hover, input:not([required]):hover { border-color:var(--theme-input-hover-border-color); background-color:var(--theme-input-hover-backcolor); color:var(--theme-input-hover-color); }
textarea:focus, input:focus { border-color:var(--theme-input-focus-border-color); background-color:var(--theme-input-focus-backcolor); color:var(--theme-input-focus-color); }
select, select option { border-color:var(--theme-select-border-color); background-color:var(--theme-select-backcolor); color:var(--theme-select-color); }
select:focus, select:not([required]):hover, select:focus option,select:not([required]):hover option { border-color:var(--theme-select-hover-border-color); background-color:var(--theme-select-hover-backcolor); color:var(--theme-select-hover-color); }
/* #endregion */

View File

@@ -0,0 +1,79 @@
.ctx-menu {
position: fixed;
min-width: 180px;
background: var(--theme-contextmenu-backcolor);
border: 1px solid var(--theme-contextmenu-border-color);
color: var(--theme-contextmenu-color);
border-radius: 6px;
padding: 4px 0;
box-shadow: 0 8px 20px rgba(0,0,0,0.15);
z-index: 2;
}
.ctx-menu.hidden {
opacity: 0;
display: none;
}
.ctx-list {
list-style: none;
margin: 0;
padding: 0;
}
.ctx-item {
padding: 5px 7px 5px 20px;
margin-left: 1px;
white-space: nowrap;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
}
.ctx-item:hover {
background: var(--theme-contextmenu-hover-backcolor);
color: var(--theme-contextmenu-hover-color);
}
.ctx-item .icon {
display: inline-flex;
position: relative;
justify-content: flex-start;
width: 20px;
}
.ctx-disabled {
opacity: 0.5;
/* cursor: default; */
}
.ctx-divider {
border-top: 1px solid var(--theme-contextmenu-divider);
margin: 4px 0;
}
.ctx-submenu {
position: absolute;
left: 100%;
top: 0;
min-width: 160px;
background: var(--theme-contextmenu-backcolor);
border: 1px solid var(--theme-contextmenu-border-color);
color: var(--theme-contextmenu-color);
padding: 4px 0;
border-radius: 6px;
display: none;
z-index: 99999;
}
.ctx-submenu.open {
display: block;
}
.ctx-arrow {
font-size: 11px;
opacity: 0.6;
}

434
public/styles/default.css Normal file
View File

@@ -0,0 +1,434 @@
/* #region DOM */
*::placeholder { text-align:center; }
* {
user-select:none;
cursor: var(--theme-cursor-default) 1 1, auto;
}
.selectable { user-select: text !important; cursor: var(--theme-cursor-pointer) 0 16, pointer; }
input, input[type="text"], input[type="email"], input[type="password"], input[type="search"], input[type="date"], textarea, select { border-width:2px; border-style:solid; font-size: var(--fontSize); font-family: var(--fontFamily); border-radius:10px; padding:10px 12px; outline:none; transition:all .5s ease; width:auto; }
input.width\:100px { width:100px; }
input.width\:90px { width:90px; }
input.width\:75px { width:75px; }
input.width\:50px { width:50px; }
input.width\:25px { width:25px; }
*::placeholder, input[id="sAMAccountName"] { font-style:italic; font-weight:100; letter-spacing:3px; }
html, button { font-size: var(--fontSize); font-family: var(--fontFamily); }
button.monolyth, button.bluebutton, button.greenbutton, button.yellowbutton, button.redbutton { display:inline-block; padding:8px 10px; margin:0.2rem 1.6rem; font-weight:600; text-align:center; text-decoration:none; color:rgb(255, 255, 255); border:none; border-radius:8px; box-shadow:0 4px 6px rgba(0,0,0,0.1); transition:all 0.2s ease; }
button.monolyth { background-color:transparent; }
button:not(:disabled).monolyth:hover { opacity:0.9; }
button.bluebutton { color:var(--theme-button-blue-default-color); background:var(--theme-button-blue-default-backcolor); }
button.greenbutton { color:var(--theme-button-green-default-color); background:var(--theme-button-green-default-backcolor); }
button.yellowbutton { color:var(--theme-button-yellow-default-color); background:var(--theme-button-yellow-default-backcolor); }
button.redbutton { color:var(--theme-button-red-default-color); background:var(--theme-button-red-default-backcolor); }
button:disabled { color:var(--theme-button-disabled-color); background:var(--theme-button-disabled-backcolor); }
button:not(:disabled).redbutton:hover { background:var(--theme-button-red-hover-color); background:var(--theme-button-red-hover-backcolor); }
button:not(:disabled).greenbutton:hover { background:var(--theme-button-green-hover-color); background:var(--theme-button-green-hover-backcolor); }
button:not(:disabled).bluebutton:hover { background:var(--theme-button-blue-hover-color); background:var(--theme-button-blue-hover-backcolor); }
button:not(:disabled).yellowbutton:hover { background:var(--theme-button-yellow-hover-color); background:var(--theme-button-yellow-hover-backcolor); }
/* #endregion */
/* #region Container */
.container.static { width:calc(100% - 20px); margin:10px auto; display:flex; gap:12px; min-height:0; overflow:auto; max-height:100%; flex-direction: column;}
/* .card.static { display:flex; flex-direction:column;flex: 0 0 auto; } */
.card.static.row { overflow:hidden; display:flex; flex-direction:row; flex-wrap: wrap;}
.card.static { overflow:hidden; display:flex; flex-direction:column; }
.container { width:calc(100% - 20px); margin:10px auto; display:grid; grid-template-columns:100%; gap:12px; min-height:0; overflow:auto; max-height:100%; }
.container:not(.static) * { box-sizing:border-box; }
.card { border-width:1px; border-style:solid; border-radius:8px; padding:20px; }
.grid { display:grid; gap:16px; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); }
/* #endregion */
/* #region Copy icon */
.copy-icon { transition:opacity .25s; cursor:var(--theme-cursor-pointer) -16 16, pointer; opacity:0.7; }
.copy-icon:hover { opacity:1; }
/* #endregion */
/* #region Tooltip */
.global-tooltip { position:fixed; padding:6px 8px; border-width:1px; border-style:solid; border-radius:10px; max-width:50vw; white-space:normal; z-index:999; pointer-events:none; opacity:0; transform:translateY(-30px); transition:opacity .12s ease; }
.global-tooltip.visible { opacity:1; }
/* #endregion*/
/* #region Scrollbar */
::-webkit-scrollbar { width:12px; height:12px; }
::-webkit-scrollbar-track { border-radius:0px; }
::-webkit-scrollbar-thumb { border-radius:6px; border-width:2px; border-style:solid; }
/* #endregion */
/* #region Messagebox */
#message-container { position: fixed; top: 1rem; right: 1px; display: flex; flex-direction: column; gap: 0.5rem; z-index: 1000; max-height: 80vh; padding-left: 15px; overflow-y: auto; overflow-x: visible; scrollbar-width: none; -ms-overflow-style: none; }
#message-container::-webkit-scrollbar { display: none; }
.message { border-radius: 8px; margin: 8px 8px 8px 0; padding: 10px 14px; width: auto; max-width: 45vw; animation: slideIn 0.4s ease-out; transition: transform 0.2s ease; word-break: break-word; overflow-wrap: anywhere; }
.message:hover { transform:scale(1.02); }
.message-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:6px; }
.message-title { flex:1; font-weight:600; }
.countdown { margin-right:8px; white-space:nowrap; }
.pin-div { margin-left:8px; cursor:var(--theme-cursor-pointer) -16 16, pointer; user-select:none; transition:transform 0.2s ease, color 0.2s ease; }
.pin-div:hover { transform:scale(1.2); }
.pin-div.pinned { transform:scale(1.1); }
@keyframes slideIn { 0% { opacity:0; transform:translateX(100%); } 60% { opacity:1; transform:translateX(-10px); } 80% { opacity:1; transform:translateX(5px); } 100% { opacity:1; transform:translateX(-10px); } }
@keyframes slideOut { 0% { opacity:1; transform:translateX(0); } 100% { opacity:1; transform:translateX(100%); } }
/* #endregion */
/* #region Toggle switch */
/*
<label class="cb cb-switch">
<input data-status="{{name}}" type="checkbox" {{#if active}}checked{{/if}} >
<span class="switch-track" aria-hidden="true">
<span class="switch-thumb" aria-hidden="true"></span>
</span>
<span class="cb-label">Ein / Aus</span>
</label>
*/
.cb-switch { --w:45px; --h:27px; display:inline-flex; align-items:center; }
.cb-switch input { position:absolute; opacity:0; width:0; height:0; pointer-events:none; }
.switch-track { width:var(--w); height:var(--h); border-radius:999px; padding:3px; border-width:1px; border-style:solid; box-sizing:border-box; display:inline-flex; align-items:center; transition:background .18s ease, transform .12s ease, border-color .25s ease; }
.switch-thumb { min-width:calc(var(--h) - 2 * 3px); width:calc(var(--h) - 2 * 3px); height:calc(var(--h) - 2 * 3px); background:var(--theme-switch-thumb); border-radius:50%; box-shadow:0 2px 6px rgba(0,0,0,.15); transform:translateX(-1px); transition:transform .25s cubic-bezier(.2,.9,.2,1), background .18s ease }
.cb-switch input:disabled + .switch-track { background-color: dimgray;}
.cb-switch input:disabled + .switch-track .switch-thumb { background-color: rgb(54, 50, 50); }
.cb-switch input:not(:disabled):checked + .switch-track { background:var(--theme-switch-active); }
.cb-switch input:not(:disabled):hover + .switch-track { border-color:var(--theme-switch-hover); }
.cb-switch input:focus-visible + .switch-track { box-shadow:0 0 0 6px rgba(6,193,103,0.12); }
.cb-switch input:checked + .switch-track .switch-thumb { transform:translateX(calc(var(--w) - var(--h))); }
.cb-switch label { width: calc(100% - var(--w)); }
/* #endregion */
/* #region CheckBox */
/*
<label class="cb cb-modern">
<input data-status="{{name}}" type="checkbox" {{#if active}}checked{{/if}} disabled>
<span class="cb-box" aria-hidden="true"></span>
</label>
*/
.cb { display:inline-flex; align-items:center; gap:10px; user-select:none; transform:translateY(2px); }
.cb input { position:absolute; opacity:0; width:0; height:0; pointer-events:none; }
.cb-box { width:20px; height:20px; border-radius:6px; background:var(--theme-checkbox-backcolor); border-width:2px; border-style:solid; display:inline-grid; place-items:center; transition:transform .12s ease, border-color .12s ease, background .12s ease; }
.cb-box::after { content:""; width:12px; height:8px; transform:scale(0) translateY(-2px); background-repeat:no-repeat; background-position:center; background-size:contain; background-image:url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 4L4 7l7-7' stroke='%23fff' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); transition:transform .18s cubic-bezier(.2,.9,.2,1); }
.cb input:checked + .cb-box { transform:translateY(-1px); }
.cb input:checked + .cb-box::after { transform:scale(1) translate(-1px, 1px); }
table .cb input:checked + .cb-box::after { transform:scale(1) translate(1px, 1px); }
.cb input:focus-visible + .cb-box { outline:none; }
/* #endregion */
/* #region Required */
.is-required-empty { border-width:2px; border-style:solid; }
/* #endregion */
/* #region Tabs */
.tabs { display: flex; margin-bottom: 10px; border-bottom-width: 2px; border-bottom-style: solid; }
.tab { padding: 10px 20px; border: 1px solid transparent; border-top-left-radius: 5px; border-top-right-radius: 5px; margin-right: 5px; transition: background .25s, color .25s, border-color .25s; }
.tab-content { border-width: 1px; border-style: solid; border-radius: 5px; padding: 15px; }
.item { margin-bottom: 5px; }
/* #endregion */
/* #region Feebox */
.feedbox-overlay { position:fixed; top:0; left:0; width:100vw; height:100vh; display:flex; align-items:center; justify-content:center; z-index:999; }
.feedbox { border-radius:8px; max-width:50vw; width:100%; padding:20px; animation:feedboxFadeIn 0.2s ease-out; max-height:80vh; display:flex; flex-direction:column; overflow:hidden; }
.feedbox h3 { margin:0 0 10px 0; }
.feedbox-message { margin-bottom:20px; line-height:1.4; flex:1; overflow-y: auto; /* font-size:1rem; */ }
.feedbox-actions { display:flex; justify-content:flex-end; gap:10px; flex-wrap:wrap; flex-shrink:0; }
@keyframes feedboxFadeIn { from { transform:translateY(-20px); opacity:0; } to { transform:translateY(0); opacity:1; } }
.feedbox-btn {
padding:6px 14px;
/* font-size:0.95rem; */
border-radius:4px;
border:1px solid #ccc;
background:#f5f5f5;
transition:all 0.15s;
}
.feedbox-btn:hover {
background:#e5e5e5;
}
.feedbox-btn.primary {
background:#4a90e2;
color:#fff;
border-color:#4a90e2;
}
.feedbox-btn.primary:hover {
background:#357ABD;
}
.feedbox-btn.danger {
background:#e94e3c;
color:#fff;
border-color:#e94e3c;
}
.feedbox-btn.danger:hover {
background:#c0392b;
}
.feedbox-input {
width:100%;
padding:6px 8px;
/* font-size:0.95rem; */
margin-top:8px;
margin-bottom:12px;
border:1px solid #ccc;
border-radius:4px;
box-sizing:border-box;
}
.feedbox-btn:focus, .feedbox-input:focus {
outline:2px solid #4a90e2;
outline-offset:2px;
}
/* Responsive Small Screens */
@media (max-width:400px) {
.feedbox {
padding:15px;
}
.feedbox-actions {
flex-direction:column-reverse;
gap:8px;
}
.feedbox-btn {
width:100%;
}
}
/* #endregion */
.select-wrapper {
position: relative;
width: 250px;
}
.select-wrapper select {
width: 100%;
padding: 12px 40px 12px 14px;
font-size: 16px;
border: 2px solid #ddd;
border-radius: 10px;
background-color: #fff;
color: #333;
appearance: none; /* entfernt Standard-Styling */
-webkit-appearance: none;
-moz-appearance: none;
cursor: pointer;
transition: all 0.2s ease;
}
/* Hover / Focus */
.select-wrapper select:hover {
border-color: #999;
}
.select-wrapper select:focus {
outline: none;
border-color: #4a90e2;
box-shadow: 0 0 0 3px rgba(74,144,226,0.2);
}
/* Custom Pfeil */
.select-wrapper::after {
content: "▾";
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
font-size: 14px;
color: #666;
}
.error-field { padding:12px 16px; border-radius:10px; border-width:1px; border-style:solid; }
.success-field { border-width: 1px; border-style: solid; padding:12px 16px; border-radius:10px; }
label { color:var(--muted); display:block; margin-bottom:6px; }
/* #region Dropzone */
.dropzone {
max-width:360px;
padding:12px;
box-shadow:0 6px 18px rgba(0,0,0,.08);
background:var(--theme-dropzone-default-backcolor);
border:1px dashed #aaa;
border-radius:8px;
padding:10px;
/* font-size:13px; */
text-align:center;
margin-bottom:8px;
color:var(--theme-dropzone-default-color);
}
.dropzone.active {
border-color:var(--theme-dropzone-active-border-color);
color:var(--theme-dropzone-active-color);
background:var(--theme-dropzone-active-backcolor);
}
.dropzone-area ul {
list-style:none;
padding:0;
margin:0;
max-height:120px;
overflow-y:auto;
}
.dropzone-area li {
display:flex;
justify-content:space-between;
align-items:center;
/* font-size:13px; */
padding:4px 0;
border-bottom:1px solid #eee;
}
button.removeButton {
border:none;
background:none;
color:#d11a2a;
cursor:var(--theme-cursor-pointer) -16 16, pointer;
/* font-size:14px; */
}
input[type="file"] {
display:none;
}
tr.drop-hover {
outline:3px dashed var(--theme-dropzone-active-border-color);
/* outline:2px dashed var(--primary-color, #4a90e2);
background:rgba(74, 144, 226, 0.08); */
}
tr.no-drop-hover {
outline:3px dashed red;
cursor:no-drop !important;
/* outline:2px dashed var(--primary-color, #4a90e2);
background:rgba(74, 144, 226, 0.08); */
}
/* #endregion */
/* #region Multiselect textbox */
.mst-wrapper {
position:relative;
display:block;
border-radius:4px;
padding:4px 8px;
background:var(--theme-window-backcolor);
cursor:text;
width:300px; /* optional, je nach gewünschter Breite */
}
/* Eingabefeld oben */
.mst-input {
width:100%;
border:none;
outline:none;
/* font-size:14px; */
padding:4px 0;
}
/* Container für Chips unterhalb des Inputs */
.mst-chips-container {
display:flex;
flex-wrap:wrap; /* Chips umbrechen */
gap:4px;
margin-top:4px;
}
/* Einzelner Chip */
.mst-chip {
display:inline-flex;
align-items:center;
background-color:#007bff;
color:#fff;
border-radius:8px;
padding:8px 8px;
/* font-size:13px; */
}
/* Entfernen-Button im Chip */
.mst-chip-remove {
margin-left:4px;
cursor:var(--theme-cursor-pointer) -16 16, pointer;
font-weight:bold;
}
/* Dropdown-Liste */
.mst-dropdown {
position:absolute;
top:100%;
left:0;
right:0;
max-height:200px;
overflow-y:auto;
border:1px solid #ccc;
background:inherit;
z-index:1000;
}
.mst-item {
padding:6px 8px;
cursor:var(--theme-cursor-pointer) -16 16, pointer;
}
.mst-item:hover {
background-color:var(--theme-accent-hover-backcolor);
}
.mst-item.new {
font-style:italic;
color:#555;
}
/* #endregion */
.tutorial-highlight {
transition: all 0.3s ease;
}

View File

@@ -0,0 +1,79 @@
.json-controls {
position: sticky;
top: -8px;
backdrop-filter: blur(5px);
padding: 8px;
border-bottom: 2px solid var(--theme-window-titlebar-backcolor);
z-index: 1;
}
.json-controls button {
background: var(--theme-window-backcolor) !important;
color: var(--theme-window-color) !important;
}
.json-line {
padding: 3px 0;
white-space: pre;
}
.json-key {
color: #d73a49;
}
.json-input {
padding: 3px 8px;
border: none;
background: transparent;
outline: none;
}
.json-input.string {
color: #032f62;
}
.json-input.number {
color: #005cc5;
}
.json-toggle {
cursor:var(--theme-cursor-pointer) -16 16, pointer;
color: #6f42c1;
}
.json-children {
padding: 3px 0;
}
.json-children.collapsed {
display: none;
}
.json-header {
display: flex;
align-items: center;
gap: 6px;
}
.json-add {
cursor:var(--theme-cursor-pointer) -16 16, pointer;
color: #28a745;
opacity: 0.7;
font-size: 13px;
}
.json-add:hover {
opacity: 1;
}
.json-remove {
cursor:var(--theme-cursor-pointer) -16 16, pointer;
color: #dc3545;
opacity: 0.6;
margin-left: 6px;
font-size: 12px;
}
.json-remove:hover {
opacity: 1;
}

217
public/styles/os.css Normal file
View File

@@ -0,0 +1,217 @@
/* public/css/desktop.css */
body, html { margin:0; padding:0; height:100%; overflow: hidden; font-family: var(--fontFamily); font-size: var(--fontSize); }
#desktop { position:relative; height:100vh; overflow:hidden; background-size:var(--theme-desktop-background-size); background-repeat: no-repeat; background-position: center; touch-action: none; }
#windows { z-index: 1; position:absolute; inset:0; padding:8px; box-sizing:border-box; }
.window { position:absolute; width: 800px; height: 600px; border-radius:6px; box-shadow: 0 6px 20px rgba(0,0,0,0.4); overflow:hidden; top:50px; left:50px; display:flex; flex-direction:column; resize:both; }
.window-titlebar { padding: 0 0 1px 0; height: 28px; display:flex; justify-content:space-between; align-items:center; }
.window-icon { height: 20px; background-size:contain; transform: translate(1px, -1px); background-repeat: no-repeat; background-position: left; background-color: rgb(144, 179, 144); padding:4px;border-radius: 8px;}
.window-content { display: flex; flex-direction: column; flex:1; padding:8px; overflow: auto; }
.window .controls button { transition: background-color .3s, color .3s; padding: 6px 10px; border:none; }
.window[class="max"] .window-resize-handle { display: none; }
.window-resize-handle { position:absolute; right:0; bottom:0; width:12px; height:12px; cursor:se-resize; z-index: 10; }
.window-resize-n { top: -4px; left: 0; right: 0; height: 8px; cursor: n-resize; }
.window-resize-s { bottom: -4px; left: 0; right: 0; height: 8px; cursor: s-resize; }
.window-resize-e { right: -4px; top: 0; bottom: 0; width: 8px; cursor: e-resize; }
.window-resize-w { left: -4px; top: 0; bottom: 0; width: 8px; cursor: w-resize; }
.window-resize-ne { top: -4px; right: -4px; width: 12px; height: 12px; cursor: ne-resize; }
.window-resize-nw { top: -4px; left: -4px; width: 12px; height: 12px; cursor: nw-resize; }
.window-resize-se { bottom: -4px; right: -4px; width: 12px; height: 12px; cursor: se-resize; }
.window-resize-sw { bottom: -4px; left: -4px; width: 12px; height: 12px; cursor: sw-resize; }
#taskbar { z-index: 2; position: absolute; width:100%; bottom:0; left:0; height:42px; overflow:visible; display:flex; flex: 0 0 auto; min-width:0; align-items:center; padding:0 8px; box-sizing:border-box; }
#start-btn { transition: background-color 0.3s ease; padding: 8px 15px; border-radius: 5px; border: none; margin-right:8px; }
#taskbar-windows { display:flex; gap:6px; align-items:center; flex:1; overflow-y:hidden;overflow-x: auto; min-width: 0; }
.taskbar-item { display: flex; position: relative; padding:7px 10px; border-radius:4px; }
.taskbar-item::before { content: ''; position: absolute; bottom:1px; left:50%; width:40%; height: 4px; border-radius:4px; transform:translateX(-50%) scaleX(0); transform-origin:center; transition:transform 0.3s ease; }
.taskbar-item.focus::before { transform: translateX(-50%) scaleX(1); }
.notify-button { margin-left:auto; flex: 0 0 auto; display: flex; align-items: center; justify-content: center; }
.notify-button.resume, .notify-button.pulse { animation: pulse 1.5s infinite; animation-play-state: running; }
.notify-button.pause, .notify-button.pause *, .notify-button.active, .notify-button.active * { animation-play-state: paused; }
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
.hidden { opacity: 0; pointer-events: none; }
/* Open submenu vertical */
#start-menu { z-index: 2; transition: opacity 0.3s; display:flex; flex-direction:column; position: absolute; bottom: 50px; left: 8px; width: auto; min-width: 300px; border-radius: 8px; overflow: hidden; }
.start-header { display: flex; align-items: center; padding: 8px; font-weight: 600; }
.start-header > img { height: 24px; width: 24px; margin-right: 5px; }
.start-list { list-style: none; margin: 0; padding: 8px 0; height: 60vh; overflow-y: auto; }
.start-item { padding: 8px 12px 16px 20px; display: flex; gap: 8px; transition: background 0.2s; border-radius: 4px; }
.start-item:not(:has(.submenu)) { padding: 12px;align-items:center; }
.start-icon, #start-menu-icon { height: 20px; width: 20px; border-radius: 8px; padding:4px; }
.menu-label { display: block; margin-top: 4px; padding-bottom: 0; margin-left: 26px }
.start-item.has-submenu { position: relative; display: flex; flex-direction: column; padding-bottom: 8px; }
.start-item.has-submenu > .submenu { width: 100%; list-style: none; padding-left: 2px; max-height: 0; overflow: hidden; transition: max-height 0.3s ease; margin: 2px 0 0 8px; }
.start-item-sys-container { position: relative; left:0; bottom: -8px; padding: 5px 0px; width:100%; display:flex; height:30px; flex-direction:row; justify-content:flex-end; }
.start-sys-item { margin: 0 10px 0 8px !important; }
.start-submenu-head { position: relative; margin-left: 16px; height:32px; display:flex; flex-direction: row; align-items: center; gap:8px; }
.start-item.has-submenu > .menu-label::after { content: ""; position: absolute; right: 14px; transform: translateY(50%) rotate(0deg); border: 5px solid transparent; border-top-color: #aaa; transition: transform 0.3s; }
.start-item.has-submenu.open > .menu-label::after { content: ""; position: absolute; right: 14px; transform: translateY(50%) rotate(180deg); border: 5px solid transparent; border-top-color: #aaa; transition: transform 0.3s; }
.start-item.has-submenu.open > .submenu { max-height: 500px; }
.start-item.has-submenu > .submenu li { opacity: 0; transform: translateY(-5px); transition: opacity 0.2s, transform 0.2s; }
.start-item.has-submenu.open > .submenu li { opacity: 1; transform: translateY(0); }
img.icon { width: auto; height:20px; object-fit: contain; filter: var(--theme-notifybubble-filter); transform: translate(2px, 2px) }
@media (max-width: 768px) {
body, html {
overflow: auto;
}
#desktop {
height: 100dvh;
}
/* Fenster = Fullscreen */
.window {
width: 100% !important;
height: calc(100% - 54px) !important;
top: 0 !important;
left: 0 !important;
border-radius: 0;
resize: none;
}
.window-resize-handle {
display: none !important;
}
.window-titlebar {
height: 29px;
padding: 0 0px;
}
.window-content {
padding: 12px;
}
/* Taskbar größer für Touch */
#taskbar {
height: 50px;
padding: 0 6px;
}
#start-btn {
padding: 10px 14px;
font-size: 16px;
}
.taskbar-item {
padding: 10px;
}
.start-list { list-style: none; margin: 0; padding: 8px 0; height: 100%; overflow-y: auto; }
#start-menu {
bottom: 50px !important;
height: calc(100dvh - 65px) !important;
width: calc(100dvw - 5px);
left: 0 !important;
border-radius: 0 !important;
} */
.start-item {
padding: 14px;
font-size: 16px;
}
.start-icon {
height: 24px;
width: 24px;
}
.start-item.has-submenu > .menu-label { margin: 8px 0 0 40px; }
.start-item-sys-container { margin:auto; bottom: -10px; }
/* Buttons besser klickbar */
.window .controls button {
padding: 10px 12px;
}
/* Notify Button */
.notify-button {
padding: 8px;
}
}
@media (max-width: 1000px) AND (orientation: landscape) {
body, html {
overflow: auto;
}
#desktop {
height: 100dvh;
}
/* Fenster IMMER fullscreen */
.window {
width: 100% !important;
height: calc(100dvh - 46px) !important;
top: 0 !important;
left: 0 !important;
border-radius: 0 !important;
}
/* Resize komplett aus */
.window-resize-handle {
display: none !important;
}
.window-titlebar {
height: 30px;
padding: 0 0px;
}
/* Startmenü bis ganz oben */
#start-menu {
bottom: 44px !important;
height: calc(100dvh - 60px) !important;
width: 300px;
max-width: 35dvw;
left: 0 !important;
border-radius: 0 !important;
}
/* Taskbar bleibt sichtbar, aber oben drüber sauber */
#taskbar {
z-index: 9999;
}
/* Startliste nutzt volle Höhe */
.start-list {
height: 100%;
}
.start-item-sys-container { bottom: -10px;}
.start-item.has-submenu > .menu-label { margin: 8px 0 0 28px; }
}

86
public/styles/table.css Normal file
View File

@@ -0,0 +1,86 @@
/* .table-wrapper { display:grid; grid-column:auto; overflow:auto; min-height:0; min-width:0;} */
.table-wrapper { overflow:auto; min-width:0; }
.table-wrapper.fit-table { grid-template-columns:calc(1fr - 150px); }
/* .table-wrapper { flex:1 1 auto; overflow-y:auto; } */
/* .table-wrapper.fit-table { width:100%; max-width:100%; } */
/* .table-wrapper.fit-table th.word-wrap, .table-wrapper.fit-table td.word-wrap { white-space:normal; word-wrap:break-word; word-break:break-word; overflow-wrap:anywhere; } */
/* #region Dont's */
th.no-wrap, td.no-wrap { white-space:nowrap !important; }
th.wrap, td.wrap { white-space:wrap; word-wrap: break-word; word-break: keep-all; }
table.no-background tr, table.no-background td { background:none !important; }
table.border * { border:1px solid white; }
/* #endregion */
/* #region performance optimizing */
table thead { position:sticky; top:0; z-index:20; will-change:transform; transform:translateZ(0);}
/* #endregion */
/* echte Tabelle */
table { width:calc(100%); border-spacing:0 5px; }
table th, table td { min-width:100px; max-width:250px; overflow:hidden; white-space:nowrap; }
table tr.grouprow:hover { background: rgba(0,0,0,0.05);}
/* Header sticky — aber innerhalb echter Tabelle */
thead th { position:sticky; top:var(--filter-height); }
/* KEIN display:block mehr! */
thead, tbody { display:table-row-group; }
table thead th { padding:5px; }
/* table tbody td { padding:5px 0px 5px 20px; } */
table tbody td:not(:first-child):not(:last-child), table thead th:not(:first-child):not(:last-child) { border-width:0; border-style:solid; }
table tbody tr.grouprow { font-weight:700; }
table .text-align\:center { text-align:center; }
table .text-align\:right { text-align:right; }
table .text-align\:left { text-align:left; }
td { overflow:hidden; text-overflow:ellipsis; /* verhindert, dass Inhalt die Zelle sprengt */ }
.table-filter-container {
border-bottom-width:8px;
border-bottom-style:solid;
display:flex;
justify-content:flex-start;
flex-direction:column;
flex-wrap:wrap;
gap:10px;
position:sticky;
left:0px;
top:0px;
/* z-index:20; */
padding:5px 10px;
border-radius:var(--border-raduis) var(--border-raduis) 0 0;
}
.table-filter-container .live-counter { position:absolute; right:18px; margin-left:auto; font-weight:bold; }
.table-filter-container input, .table-filter-container select { padding:5px !important; }
th.sort-asc::after {
content:" ▲";
}
th.sort-desc::after {
content:" ▼";
}
td.vote { width:28px; height:28px; position:relative; padding:0; }
td.vote:hover { background:rgba(0,0,0,0.05); /* cursor:pointer;*/ }
td.vote::before,
td.vote::after { position:absolute; top:50%; left:50%; transform:translate(-50%, -50%); }
td.yes::before { content:""; width:8px; height:16px; border:solid #2ecc71; border-width:0 3px 3px 0; transform:translate(-50%, -60%) rotate(45deg); }
td.no::before, td.no::after { content:""; width:3px; height:18px; background:#e74c3c; }
td.no::before { transform:translate(-50%, -50%) rotate(45deg); }
td.no::after { transform:translate(-50%, -50%) rotate(-45deg); }
td.maybe::before { content:""; width:0px; height:0px; border-radius:50%; border:10px solid #f1c40f; transform:translate(-50%, -50%); }

View File

@@ -0,0 +1,67 @@
/* Bubble-Container */
#notify-bubble {
position: absolute;
bottom: 125%;
right: 3px;
min-width: 200px;
max-height: 50vh;
width: max-content; /* 🔑 wächst mit Inhalt */
max-width: 600px; /* aber capped */
background: var(--theme-taskbar-tray-backcolor);
color: var(--theme-taskbar-tray-color);
border-color: var(--theme-taskbar-tray-border-color);
border-radius: 10px;
overflow-y: auto; /* vertikal scrollen */
overflow-x: hidden; /* horizontal verhindern */
box-shadow: 0 8px 20px rgba(0,0,0,0.4);
padding: 10px 10px 6px 10px;
opacity: 0;
pointer-events: none;
transform: translateY(10px);
transition: all 0.25s ease;
z-index: 99999999;
text-align: end;
font-weight: normal;
}
/* Bubble-Items als Grid */
.bubble-item {
display: grid;
grid-template-columns: minmax(0, auto) 250px;
gap: 10px;
padding: 6px 8px;
border-radius: 6px;
/* cursor: pointer; */
align-items: center;
transition: transform 0.25s;
}
/* linke Spalte */
.bubble-item > :nth-child(1) {
min-width: 0; /* wichtig für Grid Shrink */
white-space: normal; /* Text darf umbrechen */
overflow-wrap: normal; /* Wörter nur bei Bedarf umbrechen */
word-break: normal; /* nicht mitten in Wörtern */
text-align: left;
}
/* rechte Spalte fix */
.bubble-item > :nth-child(2) {
width: 250px;
height: 50px;
overflow: hidden;
}
/* hover Effekt */
.bubble-item:hover {
background: rgba(64,64,64,0.4);
transform: scale(1.01);
}
/* Bubble sichtbar machen */
.notify-button.active #notify-bubble {
pointer-events: auto;
opacity:1;
transform: translateY(0);
}

153
public/views/desktop.hbs Normal file
View File

@@ -0,0 +1,153 @@
<head>
<script src="/socket.io/socket.io.js"></script>
<script src="javascript/main.js"></script>
<script src="javascript/customModal.js"></script>
<script src="javascript/pluginAPI.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="styles/default.css">
<link rel="stylesheet" href="styles/contextMenu.css">
<link rel="stylesheet" href="styles/userNotification.css">
<link rel="stylesheet" href="styles/table.css">
<link rel="stylesheet" href="styles/jsonTree.css">
<link rel="stylesheet" href="styles/colors.css" />
<link rel="stylesheet" href="styles/os.css" />
<div id="message-container"></div>
<div id="copy-toast"></div>
<title>Radix OS</title>
</head>
<div id="desktop">
<div id="windows"></div>
<!-- Startmenu -->
<div id="start-menu" class="hidden">
<div class="start-header"><img id="start-menu-icon"><span>Radix OS</span></div>
<ul class="start-list">
{{#groupBy startMenuItems "section"}}
<li class="start-submenu-head">
<span><i>{{this.key}}</i></span>
</li>
{{#each items}}
{{#ifSingle this.menu.items}}
{{#if this.authorized}}
<li class="start-item {{#unless ../this.active}}unload{{/unless}}" data-active="{{#equaler ../this.active "&&" this.authorized}}true{{else}}false{{/equaler}}" data-appname="{{../this.name}}" data-appview="{{this.view}}" data-viewlabel="{{this.label}}">
{{#if this.icon}}
<img src="{{#if ../this.pluginPath}}/{{../this.name}}{{/if}}/images/{{this.icon}}" class="start-icon" />
{{else}}
{{/if}}
<span>{{this.label}}</span>
</li>
{{/if}}
{{else}}
<li class="start-item has-submenu">
<img src="{{#if ../this.pluginPath}}/{{../this.name}}{{/if}}/images/folder.png" class="start-icon" style="position:absolute;left:12px;"/>
<span class="menu-label">{{this.menu.label}}</span>
{{!-- {{#if this.version}}<small>v{{this.version}}</small>{{/if}} --}}
<ul class="submenu">
{{#each this.menu.items}}
{{#equaler this.label "==" "hr"}}
<li><hr></li>
{{else}}
{{#if this.authorized}}
<li class="start-item {{#unless ../this.active}}unload{{/unless}}" data-active="{{#equaler ../this.active "&&" this.authorized}}true{{else}}false{{/equaler}}" data-appname="{{../this.name}}" data-appview="{{this.view}}" data-viewlabel="{{this.label}}">
{{#if this.icon}}
<img src="{{#if ../this.pluginPath}}/{{../this.name}}{{/if}}/images/{{this.icon}}" class="start-icon" />
{{else}}
{{/if}}
<span>{{this.label}}</span>
{{#if this.version}}<small>v{{this.version}}</small>{{/if}}
</li>
{{/if}}
{{/equaler}}
{{/each}}
</ul>
</li>
{{/ifSingle}}
{{/each}}
{{/groupBy}}
{{!-- [Function restart] in javascript/main.js --}}
</ul>
<div class="start-item-sys-container">
<button class="monolyth start-sys-item" data-tooltip="Neustart" onclick="restart();" data-tooltip-mode="always">⟳</button>
<button class="monolyth start-sys-item" data-tooltip="Abmelden" onclick="logout()" data-tooltip-mode="always">🔓</button>
</div>
</div>
<!-- Taskbar -->
<div id="taskbar">
<button id="start-btn">☰</button>
<div id="taskbar-windows"></div>
<button style="margin-right:0;" class="monolyth notify-button pulse">
<img class="icon" src="/images/notifybubble.png">
<div id="notify-bubble"></div>
</button>
</div>
</div>
<script src="javascript/tutorial.js"></script>
<script src="javascript/notifyBubble.js"></script>
<script src="javascript/contextMenu.js"></script>
<script src="javascript/tableFilter.js"></script>
<script src="javascript/requiredFields.js"></script>
<script src="javascript/loadOnce.js"></script>
<script src="javascript/JSON.js"></script>
<script src="javascript/os.js"></script>
<script>
addRestartHook(() => localStorage.setItem('openWindows', JSON.stringify({ })));
fetch('/api/Plugins/loadScripts', { method: 'POST', headers: {'Content-Type': 'application/json'} })
.then(res => res.json())
.then(scripts => {
(scripts).forEach(script => {
reloadPluginScript(script);
});
});
loadServerStyles(getCookie('theme'));
const notifyButtons = document.querySelectorAll('.notify-button');
const trayNotifyButton = document.querySelector('#taskbar > .notify-button')
const notify = new NotifyBubble(trayNotifyButton, "#notify-bubble");
document.addEventListener("contextmenu", evt => evt.preventDefault());
document.querySelectorAll('#start-menu .start-item.has-submenu').forEach(item => {
item.addEventListener('click', evt => {
// Toggle nur dieses Submenu
item.classList.toggle('open');
});
});
async function logout() {
const response = await fetch('/logout', { method: 'POST', headers: {'Content-Type': 'application/json'} });
if (response.ok) {
window.history.pushState({ }, '', '/login');
location.reload();
}
}
fetch('/api/NotifyTray/getTrays')
.then(res => res.json())
.then(trays => {
notify.clear();
if (trays.length > 0) {
trays.forEach(tray => {
notify.addItem(tray, () => {
if(tray.JSON) {
//execution of tray action, e.g. open window
}
});
})
}
});
</script>

127
public/views/eventlog.hbs Normal file
View File

@@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Event Log</title>
</head>
<body>
<div class="container">
<div class="card static">
<div class="table-wrapper">
<table id="eventTable" >
<thead class="no-wrap">
<tr>
<th class="text-align:left" onclick="">ID</th>
<th class="text-align:left" onclick="">Datum</th>
<th class="text-align:left" onclick="">Level</th>
<th class="text-align:left" onclick="">Plugin</th>
<th class="text-align:left" onclick="">Message</th>
<th class="text-align:left" onclick="">Trace</th>
<th class="text-align:left" onclick="">User</th>
</tr>
</thead>
<tbody>
{{#each logs}}
<tr>
<td>{{this.ID}}</td>
<td class="no-wrap">{{dateFormat this.Date "yyyy-mm-dd HH:MM:SS"}}</td>
<td class="{{this.Level_ID}}">
{{LevelDisplayName}}
</td>
<td>{{this.PluginName}}</td>
<td><span>{{replaceAll this.Message "||" "<br>"}}</span> <span class="copy-icon" onclick="copyToClipboard(`{{replaceAll this.Message "||" "<br>"}}`)">⧉</span></td>
<td>{{this.Trace}}</td>
<td class="no-wrap">{{this.ClearTextUser}}</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
</div>
</div>
<div class="grid" style="pointer-events:auto;position:absolute; bottom:18px;right:18px;grid-template-columns: 1fr;">
<button class="redbutton" id="confirmClearLog" data-tooltip="Löscht alle Einträge aus der Datenbank">Log leeren</button>
</div>
<script type="text/javascript">
const eventTable = document.getElementById("eventTable");
let mappedLogs = [];
confirmClearLog.onclick = evt => {
feedbox(
{ title: `Hast Du Sie nicht mehr Alle?`,
message: `<p>Damit werden alle Einträge aus dem EventLog entfernt!</p>`,
buttons: {
yes: {
text: 'Ich weiß, <b>Geht auf meinen Nacken!</b>',
onClick: async () => await clearLog()
},
no: {
text: 'Dazu habe ich nicht die Eier'
}
}
}
);
}
async function clearLog() {
const res = await fetch(`/api/eventlog/clearlog`, { method: 'POST' });
const data = await res.json();
vt.source([]);
}
const vt = virtualTable({
tableEl: eventTable,
rowHeight: 40,
buffer: 5,
groupKey: null, // optional zum Gruppieren
filterConfig:{
exceptedColumns: [''],
columnModes:{
Plugin: 'dropdown',
Level: 'dropdown',
User: 'dropdown'
},
checkboxFilter:{
column:'Level_ID',
rules:[
{ label:'Erfolgreich', test:v => parseInt(v) === 0 },
{ label:'Information', test:v => parseInt(v) === 1 },
{ label:'Warnung', test:v => parseInt(v) === 2 },
{ label:'Fehler', test:v => parseInt(v) === 4 },
{ label:'Absturz', test:v => parseInt(v) === 8 },
]
}
},
customRender: (row, tr) => {
tr.style.height = '40px';
createTd(tr, row['ID'], { classes: [ 'text-align:left'] });
createTd(tr, row['Datum']);
createTd(tr, row['Level']);
createTd(tr, row['Plugin']);
createTd(tr, row['Message'], { classes: [ 'no-wrap' ], attributes: { 'data-tooltip': formatHtml(row['Message']) } });
createTd(tr, row['Trace'], { attributes: { 'data-tooltip': formatHtml(row['Trace']) } });
createTd(tr, row['User']);
}
});
fetch('/api/eventlog/getLogs', { method: 'POST' })
.then(logs => logs.json())
.then(logs => {
mappedLogs = logs.map(row => ({ ...row, Plugin: row['PluginName'], Level: row['LevelDisplayName'], Datum: dateFormat(row['Date'], 'yyyy-mm-dd HH:MM:SS'), User: row['ClearTextUser'] }))
vt.addData(mappedLogs.length != 0 ? mappedLogs : {});
});
adminSocket.on('eventlog_table', (data) => {
data = {...data, Plugin: data['PluginName'], Level: data['LevelDisplayName'], Datum: dateFormat(data['Date'], 'yyyy-mm-dd HH:MM:SS'), User: data['ClearTextUser'] };
mappedLogs.unshift(data)
vt.source(mappedLogs);
});
</script>
</body>
</html>

View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<p>
Bilder, Symbole und Zeichnungen wurden durch KI generiert und teilweise aus bereits existierenden, lizenzierten Quellen im Auftrag der Stadt Frankfurt am Main verwendet.
</p>
<p>
Der Programmcode wurde ebenfalls durch KI unterstützend erstellt. Dies betrifft ausschließlich die Darstellungs- und Designlogik. Alle Verarbeitungen personenbezogener Daten liegen in der Obhut des nutzenden Betriebs.
</p>
<p>
Alle Inhalte der in RadixOS enthaltenen Plugins dienen ausschließlich dem dienstlichen Betrieb sowie der Weiterentwicklung interner Machine-Learning-Prozesse
<i>(automatisiertes Lernen computergestützter Verfahren)</i>.
</p>
<p>
Datenbanken, Inhalte sowie personenbezogene Daten werden nicht an Dritte weitergegeben oder öffentlich zugänglich gemacht. RadixOS ist als geschlossenes System konzipiert.
</p>
<p>
<b style="color:var(--theme-accent-default-backcolor)">
Das Urheberrecht an RADIX OS liegt beim Entwickler
</b>
</p>
</body>
<script>
</script>
</html>

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
test
</body>
</html>

View File

@@ -0,0 +1,106 @@
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hilfe</title>
<style>
ul#help li a,
ul#help li a:visited {
text-decoration: none;
color: var(--theme-container-card-color);
}
ul#help {
display:flex;
width: 100%;
padding: 0;
margin: 0;
left: 0;
position: relative;
overflow: auto;
flex-wrap: nowrap;
scrollbar-width: thin;
}
ul#help li {
display: inline-block;
width: auto;
padding: 10px;
box-sizing: border-box;
text-align: center;
}
</style>
</head>
<body>
<div class="container static" style="height: 100vh;">
<div id="helpTabs" class="tabs" style="overflow-y: auto;scrollbar-width: thin; padding:0;flex: 0 0 auto;"></div>
<div class="card static" style="overflow-y:auto;flex: 1 1 auto;" >
<div id="tabWrapper" class="tab-contents" ></div>
</div>
<div id="helpFooter" class="card static" style="height:auto;bottom:0;text-align:left;flex:0 0 auto;flex-direction:row;justify-content:space-between;gap:4px">
<div class="selectable">
<div style="color:var(--theme-accent-default-backcolor);font-weight:bold;">
<span>&copy; Radix OS</span> <span id="year"></span>
<span>Manuel Sowada</span>
</div>
</div>
<div id="plugin"></div>
</div>
</div>
</div>
<script type="text/javascript">
function showTabs() {
fetch(`/api/help/getTabs`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
.then(data => data.json())
.then(async data => {
data.forEach(async t => {
const tabElement = document.createElement('div');
tabElement.className = 'tab';
tabElement.dataset.tab = t.name;
tabElement.textContent = t.name;
tabElement.addEventListener('click', async () => {
Array.from(document.querySelectorAll('.tab')).forEach(t => t.classList.remove('active'));
tabElement.classList.add('active');
tabWrapper.innerHTML = '';
const response = await fetch('/api/help/getHelp', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: t.name })
});
const item = await response.json();
const html = `
<div>
<p>${response.status === 500 ? 'Hilfe nicht verfügbar' : item.html}</p>
${item.description ? `<hr /><p>${item.description}</p>` : ''}
</div>
`;
const container = document.querySelector('#tabWrapper');
container.innerHTML = html;
});
document.getElementById('helpTabs').appendChild(tabElement);
})
})
}
showTabs();
year.textContent = new Date().getFullYear();
</script>
</body>
</html>

View File

@@ -0,0 +1,35 @@
<header>
<style>
</style>
</header>
<body>
<div class="container static">
<div class="card" style="overflow: auto;height:100vh">
<div id="jsonConfigTree" class="json-tree"></div>
</div>
</div>
</div>
</body>
<script>
fetch('/api/getConfig', { method: 'POST' })
.then(res => res.json())
.then(json => {
const tree = createJsonTree({
container: document.getElementById("jsonConfigTree"),
data: json,
expandInitially: true,
onSave: json => {
fetch('/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(json, null, 2)
}).then(() => writeEventLog(0, 'Serverconfig', tree.getChanges()) );
}
});
});
</script>

View File

@@ -0,0 +1,101 @@
<div class="container">
<div class="card">
<span style="font-weight: bold;">Dienst</span>
<button class="redbutton" id="shutdownButton">Abschalten</button>
<button class="yellowbutton" id="restartButton">Neustart</button>
<div>
<span>PID:</span> <span class="selectable" id="pid"></span>
<span class="copy-icon" onclick="copyToClipboard(`${document.getElementById('pid').textContent}`)">⧉</span>
</div>
</div>
<div class="card">
<div class="table-wrapper" style="max-height: 350px;">
<table id="releaseNotes">
<thead>
<tr>
<th class="text-align:left">Erledigt</th>
<th class="text-align:left">Timestamp</th>
<th class="text-align:left">User</th>
<th class="text-align:left">Note</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
<div class="card">
<div id="package" style="overflow:auto"></div>
</div>
</div>
<script>
shutdownButton.onclick = evt => {
fetch('/api/shutdown', { method: 'POST' })
.then(res => res.json())
.then(json => {
console.log(json);
});
};
restartButton.onclick = evt => {
fetch('/api/restart', { method: 'POST' })
.then(res => res.json())
.then(json => {
console.log(json);
});
};
fetch('/api/getServerInfo', { method: 'POST' })
.then(res => res.json())
.then(json => {
document.querySelector('#pid').innerHTML = json.pid;
const vt = virtualTable({
tableEl: document.getElementById('releaseNotes'),
data: [],
buffer: 5,
rowHeight: 30,
filterConfig:{
exceptedColumns: [ 'Erledigt' ],
columnModes: {
'datetime': 'text',
'sAMAccountName': 'text',
'Note': 'text'
},
checkboxFilter: {
column: 'finish',
rules: [
{ label: 'Nur erledigte', test: v => v === true },
{ label: 'Nur unerledigte', test: v => v === false },
]
}
},
customRender: (row, tr) => {
createTd(tr,
`
<label class="cb cb-modern">
<input id="id1" data-status="{{name}}" type="checkbox" ${row.finish ? 'checked' : ''} >
<span class="cb-box" aria-hidden="true"></span>
</label>
`, { });
createTd(tr, row.datetime, { });
createTd(tr, row.sAMAccountName, { });
createTd(tr, row.value, { attributes: { "data-tooltip": row.value } });
}
});
vt.addData(json.releaseNotes)
document.querySelector('#package').innerHTML = JSON.stringify(json.package).split(',').join('<br>');
/*
createJsonTree({
container: document.getElementById("package-json"),
data: json,
expandInitially: true,
onSave: () => { }
});
*/
});
</script>

View File

@@ -0,0 +1,30 @@
<header>
<style>
</style>
</header>
<div style="width:100%;height:calc(100% - 64px);">
<div id="jsonStyleTree" class="json-tree"></div>
</div>
<script>
fetch('/api/getStyles', { method: 'POST' })
.then(res => res.json())
.then(json => {
const tree = createJsonTree({
container: document.getElementById("jsonStyleTree"),
data: json,
expandInitially: true,
onSave: json => {
fetch('/style', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(json, null, 2)
}).then(() => writeEventLog(0, 'Stylesheet', JSON.stringify(tree.getChanges())) );
}
});
});
</script>

View File

@@ -0,0 +1,113 @@
<body>
<div class="container">
<div class="card">
<label><b>Farbgebung</b></label>
<select id="themeSwitch" style="width: 100%;">
<option value="dark">Dunkel</option>
<option value="light">Hell</option>
<option value="modern">Modern</option>
</select>
</div>
<div class="card">
<label><b>Schrift</b></label>
<select id="fontSelector" style="width: 100%;">
<optgroup label="Sans-Serif (Windows Standard)">
<option value="Arial">Arial</option>
<option value="Calibri">Calibri</option>
<option value="Candara">Candara</option>
<option value="Segoe UI">Segoe UI</option>
<option value="Tahoma">Tahoma</option>
<option value="Trebuchet MS">Trebuchet MS</option>
<option value="Verdana">Verdana</option>
</optgroup>
<optgroup label="Serif (Windows Standard)">
<option value="Cambria">Cambria</option>
<option value="Constantia">Constantia</option>
<option value="Georgia">Georgia</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Palatino Linotype">Palatino Linotype</option>
</optgroup>
<optgroup label="Monospace / Fixed-Width">
<option value="Consolas">Consolas</option>
<option value="Courier New">Courier New</option>
<option value="Lucida Console">Lucida Console</option>
</optgroup>
<optgroup label="Fun / Decorative (Windows enthält einige)">
<option value="Comic Sans MS">Comic Sans MS</option>
<option value="Impact">Impact</option>
<option value="Segoe Script">Segoe Script</option>
<option value="Segoe Print">Segoe Print</option>
</optgroup>
</select>
<div style="display:flex;flex-direction:row;align-items:center;">
<input type="range" id="sizeSlider" min="10" max="32" value="18" style="width:100%;">
<span id="sizeValue">18px</span>
</div>
</div>
<div class="card" style="display:flex;flex:1;flex-direction:column;">
<label><b>Vollbildmodus</b></label>
<button class="bluebutton" id="fullscreenBtn">Einschalten</button>
<label>
Der Vollbildmodus hat den Vorteil, dass der komplette Bildschirm mit dieser Webseite ausgefüllt wird.<br><i style="color:var(--theme-accent-default-backcolor)">Sehr nützlich für mehr als einen Bildschirme</i>
</label>
</div>
</body>
<script>
const themeSwitch = document.getElementById("themeSwitch");
const selector = document.getElementById("fontSelector");
const slider = document.getElementById("sizeSlider");
const label = document.getElementById("sizeValue");
const fullScreenBtn = document.getElementById("fullscreenBtn");
fullScreenBtn.addEventListener("click", goFullscreen);
function goFullscreen() {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
document.documentElement.requestFullscreen();
}
fullScreenBtn.textContent = !document.fullscreenElement ? "Ausschalten" : "Einschalten";
}
if(savedTheme) { themeSwitch.value = savedTheme; }
if(savedFontFamily) { selector.value = savedFontFamily; }
if(savedFontSize) { slider.value = savedFontSize; label.textContent = savedFontSize + 'px'; }
themeSwitch.addEventListener('change', (evt) => {
const theme = evt.target.value;
switchTheme(theme);
})
function applySettings() {
switchTheme(themeSwitch.value);
setFontFamily(selector.value);
setFontSize(slider.value)
}
applySettings();
selector.addEventListener("change", () => {
setFontFamily(selector.value);
});
slider.addEventListener("input", () => {
setFontSize(parseInt(slider.value))
label.textContent = slider.value + 'px';
});
// jede option bekommt ihre eigene Schrift
[...selector.options].forEach(option => {
option.style.fontFamily = option.value;
});
</script>

View File

@@ -0,0 +1,9 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
{{{body}}}
</body>
</html>

187
public/views/login.hbs Normal file
View File

@@ -0,0 +1,187 @@
<html>
<head>
<script src="/socket.io/socket.io.js"></script>
<script src="javascript/main.js"></script>
<script src="javascript/customModal.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="styles/default.css">
<link rel="stylesheet" href="styles/contextMenu.css">
<link rel="stylesheet" href="styles/userNotification.css">
<link rel="stylesheet" href="styles/table.css">
<link rel="stylesheet" href="styles/colors.css" />
<link rel="stylesheet" href="styles/os.css" />
<div id="message-container"></div>
<div id="copy-toast"></div>
<title>Radix OS</title>
<style>
</style>
</head>
<body id="desktop">
{{!-- <form id="login-form" action="/login" method="POST"> --}}
<form id="login-form" class="container" style="width:50vw;height:100vh;align-content:center;">
<div class="static card" style="flex-direction:column">
<label for="sAMAccountName">Benutzername:</label>
<input type="text" {{!-- autocomplete="username" --}} id="sAMAccountName" name="sAMAccountName" placeholder="< Vorname.Nachname >" data-tooltip="Melde dich mit deinem Windows-Benutzernamen an.<br>Ist dies deine erste Anmeldung, dann vergib ein neues Kennwort!<br><i style='color:green;'>Hat keine Auswirkungen auf deine Windows-Anmeldung</i>" required>
<label for="password">Passwort:</label>
<input type="password" autocomplete="current-password" id="password" name="password" required>
<br>
<button class="bluebutton" type="submit">Einloggen</button>
</div>
</form>
<button class="yellowbutton" id="openSettings" type="button" style="position:absolute;top:10px;right:10px;">Einstellungen</button>
</body>
<script src="javascript/notifyBubble.js"></script>
<script src="javascript/tableFilter.js"></script>
<script src="javascript/requiredFields.js"></script>
<script src="javascript/loadOnce.js"></script>
<script src="javascript/JSON.js"></script>
<script type="text/javascript">
const sAMAccountName = document.querySelector('#sAMAccountName');
const form = document.getElementById('login-form');
loadServerStyles(getCookie('theme'));
setCSSVariable('fontFamily', getCookie('fontFamily') ?? 'Verdana');
setCSSVariable('fontSize', getCookie('fontSize') ?? '14');
setCSSVariable('theme', getCookie('theme') ?? 'light');
form.addEventListener('submit', async e => {
try {
e.preventDefault(); // verhindert Reload
let error = null;
const data = new FormData(form);
const obj = Object.fromEntries(data.entries());
// Sende Daten via Fetch an Backend
const resSendLoginData = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(obj)
});
if(resSendLoginData.ok) {
const loginData = await resSendLoginData.json();
if(resSendLoginData.status === 200) {
setTimeout(() => {
window.history.pushState({ }, '', '/');
location.reload();
}, 5000);
}
if(resSendLoginData.status === 401) {
}
showMessage('Login', `${dateFormat(new Date(), 'yyyy-mm-dd HH:MM:SS')}<br><br>${loginData.message}`, data.levelId, () => { window.history.pushState({ }, '', '/'); location.reload(); } , 5000);
} else {
const errorData = await resSendLoginData.json();
showMessage('Login', `${dateFormat(new Date(), 'yyyy-mm-dd HH:MM:SS')}<br><br>${errorData.message}`, errorData.levelId, () => { window.history.pushState({ }, '', '/'); location.reload(); } , 5000);
}
} catch(err) {
alert(err)
}
});
sAMAccountName.addEventListener('blur', async () => {
if(sAMAccountName.value.trim() === '') {
sAMAccountName.classList.remove('valid', 'invalid');
sAMAccountName.removeAttribute('data-tooltip');
sAMAccountName.setAttribute('data-tooltip', "Melde dich mit deinem Windows-Benutzernamen an.<br>Ist dies deine erste Anmeldung, dann vergib ein neues Kennwort!<br><i style='color:green;'>Hat keine Auswirkungen auf deine Windows-Anmeldung</i>")
return;
}
const responseVerifyAccount = await fetch('/checkLoginName', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sAMAccountName: sAMAccountName.value })
});
if(responseVerifyAccount.ok || responseVerifyAccount.status === 200) {
sAMAccountName.classList.add('valid');
sAMAccountName.setAttribute('data-tooltip', `Du kannst dich mit dem Benutzername <b style="color:green;">${sAMAccountName.value}</b> anmelden`)
} else {
sAMAccountName.classList.add('invalid');
sAMAccountName.setAttribute('data-tooltip', `Der Benutzername <b style="color:red;">${sAMAccountName.value}</b> wurde nicht gefunden`)
}
});
document.querySelector('#openSettings').onclick = async evt => {
feedbox(
{ title: `Einstellungen`,
message: /*html*/`
<div class="container">
<div class="card static">
<label>Farbgebung</label>
<select id="themeSwitch" onchange="switchTheme(this.value)">
<option value="dark" ${getCookie('theme') == 'dark' ? 'selected' : ''}>Dunkel</option>
<option value="light" ${getCookie('theme') == 'light' ? 'selected' : ''}>Hell</option>
<option value="modern" ${getCookie('theme') == 'modern' ? 'selected' : ''}>Modern</option>
</select>
</div>
<div class="card static">
<label>Schrift</label>
<select id="fontSelector" size="8" onchange="setFontFamily(this.value)">
<optgroup label="Sans-Serif (Windows Standard)">
<option style="font-family:Arial" value="Arial">Arial</option>
<option style="font-family:Calibri" value="Calibri">Calibri</option>
<option style="font-family:Candara" value="Candara">Candara</option>
<option style="font-family:Segoe UI" value="Segoe UI">Segoe UI</option>
<option style="font-family:Tahoma" value="Tahoma">Tahoma</option>
<option style="font-family:Trebuchet MS" value="Trebuchet MS">Trebuchet MS</option>
<option style="font-family:Verdana" value="Verdana">Verdana</option>
</optgroup>
<optgroup label="Serif (Windows Standard)">
<option style="font-family:Cambria" value="Cambria">Cambria</option>
<option style="font-family:Constantia" value="Constantia">Constantia</option>
<option style="font-family:Georgia" value="Georgia">Georgia</option>
<option style="font-family:Times New Roman" value="Times New Roman">Times New Roman</option>
<option style="font-family:Palatino Linotype" value="Palatino Linotype">Palatino Linotype</option>
</optgroup>
<optgroup label="Monospace / Fixed-Width">
<option style="font-family:Consolas" value="Consolas">Consolas</option>
<option style="font-family:Courier New" value="Courier New">Courier New</option>
<option style="font-family:Lucida Console" value="Lucida Console">Lucida Console</option>
</optgroup>
<optgroup label="Fun / Decorative (Windows enthält einige)">
<option style="font-family:Comic Sans MS" value="Comic Sans MS">Comic Sans MS</option>
<option style="font-family:Impact" value="Impact">Impact</option>
<option style="font-family:Segoe Script" value="Segoe Script">Segoe Script</option>
<option style="font-family:Segoe Print" value="Segoe Print">Segoe Print</option>
</optgroup>
</select>
<div style="display:flex;flex-direction:row;align-items:center">
<input type="range" id="sizeSlider" min="10" max="32" value="${savedFontSize ?? '14'}" onchange="setFontSize(parseInt(this.value)); sizeValue.textContent = this.value + 'px';">
<span id="sizeValue">${savedFontSize ?? '14'}px</span>
</div>
</div>
</div>
`,
buttons: {
yes: {
text: '<b>Fertig</b>',
onClick: async () => {}
}
}
}
);
}
</script>
</html>

View File

@@ -0,0 +1,16 @@
<div class="window" data-winid="{{name}}-{{@index}}" data-plugin="{{name}}">
<div class="window-titlebar">
<div class="title">{{{viewLabel}}}</div>
<div class="controls">
{{#if this.printable}}
<button data-tooltip="Drucken" id="printbutton">🖨</button>
{{/if}}
<button class="close">✕</button>
</div>
</div>
<div class="window-content">
{{{contentHtml}}}
</div>
<div class="window-resize-handle"></div>
</div>

View File

@@ -0,0 +1,19 @@
<div class="window" data-winid="{{appname}}-{{@index}}" data-plugin="{{appname}}">
<div class="window-titlebar">
<img src="{{#equaler section "==" "Plugin"}}{{appname}}{{/equaler}}/images/{{icon}}" class="window-icon" />
<div class="title">{{appname}} [{{label}}]</div>
<div class="controls">
{{#if tutorial}}
<button class="tutorial" id="tutorial-{{appname}}-{{label}}" onclick="tutorial.start()" data-tooltip="Startet eine interaktive Einführung">&#128161;</button>
{{/if}}
<button class="minimize" style="transform:translateY(0px)">🗕</button>
<button class="maximize">▢</button>
<button class="close">✕</button>
</div>
</div>
<div class="window-content">
{{{contentHtml}}}
</div>
<div class="window-resize-handle"></div>
</div>

View File

@@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Plugin Dashboard</title>
<style>
</style>
</head>
<body>
<div class="container">
<div id="pluginTabs" class="tabs"></div>
<div class="card static">
<div class="tab-contents container static" id="pluginsTabWrapper" style="height:100vh;grid-template-columns: repeat(3, 1fr);"></div>
</div>
<div class="grid" style="pointer-events:auto;position:absolute; bottom:18px;right:18px;grid-template-columns: 1fr;">
<button class="bluebutton" id="createNewPlugin">Neues Plugin</button>
</div>
</div>
</body>
<script type="text/javascript">
createNewPlugin.onclick = () => {
feedbox({
title: 'Neues Plugin erstellen',
message: `
<input id="newPluginName" placeholder="Name des Plugins" />
`,
buttons: {
yes: {
text: '<b>Erstellen</b>',
onClick: () => {
const name = document.getElementById('newPluginName').value;
pluginAPI.create(name);
}
},
no: {
text: 'Abbrechen',
onClick: () => { /* automatisch geschlossen */ }
}
}
});
}
/*
{"name":"Demo",
"description":"Beschreibung hier einfügen",
"version":"1.0.0.0",
"menu":{
"label":"Demo",
"items":[
{"label":"Demo",
"view":"index",
"defaultSize":{"width":"600px","height":"150px"},
"icon":"app.png",
"permissions":["*"]}
]},
"config":{},
"active":true
}
}*/
fetch('/api/plugins/getAll', { method: 'POST' })
.then(res => res.json())
.then(data => {
data.forEach(async metaData => {
createTab(pluginTabs, metaData.name, async (tabElement) => {
pluginsTabWrapper.innerHTML = '';
const objectsContainer = document.createElement('div');
objectsContainer.className = 'card grid';
objectsContainer.style = ``;
const card = document.createElement('div');
card.className = 'card static row';
card.style = `gap:0 10px;min-height:fit-content;justify-content: center;`;
pluginsTabWrapper.appendChild(card);
const sortedKeys = Object.keys(metaData).sort((a, b) => {
if (typeof metaData[a] === 'object' && typeof metaData[b] !== 'object') {
return 1;
} else if (typeof metaData[a] !== 'object' && typeof metaData[b] === 'object') {
return -1;
} else {
return a.localeCompare(b);
}
});
for (const key of sortedKeys) { // each plugin-metaData
if (metaData.hasOwnProperty(key) && metaData[key] !== null && !key.includes('Path')) { // only first level
const value = metaData[key];
const container = document.createElement('div');
if(typeof value === 'string') {
container.style = `flex:${key === 'description' ? '1 1' : '0 0'} auto;`;
const label = document.createElement('label');
label.style = `font-weight:bold`;
label.textContent = key.charAt(0).toUpperCase() + key.slice(1);
container.appendChild(label);
const input = document.createElement('input');
input.type = 'text';
input.style="width:100%"
input.value = value;
input.setAttribute('data-name', metaData.name);
input.setAttribute('data-field', key);
new AttachOnBlurChange(input, async (newValue, oldValue) => {
if(key === 'name') {
await pluginAPI.rename(oldValue, newValue);
return;
}
await pluginAPI.update(`${metaData.name}`, { [key]: newValue});
});
container.appendChild(input);
card.appendChild(container);
}
if(typeof value === 'boolean') {
container.style = `flex:0;flex-direction:column; display:flex;align-items:center;`;
const label = document.createElement('label');
label.style = `font-weight:bold`;
label.textContent = key.charAt(0).toUpperCase() + key.slice(1);
container.appendChild(label);
const checkbox = document.createElement('label');
checkbox.className = 'cb cb-modern';
checkbox.innerHTML = `
<input onchange="pluginAPI.activation('${metaData.name}', this.checked);" id="plugin-${metaData.name}" data-status="${metaData.name}" type="checkbox" ${metaData.active ? 'checked' : ''}>
<span class="cb-box" aria-hidden="true"></span>
`;
container.appendChild(checkbox);
card.appendChild(container);
}
if(typeof value === 'object') {
const objectCard = document.createElement('div');
objectCard.className = 'card';
objectCard.style = `height:100%`;
objectCard.innerHTML = `
<label style="font-weight:bold">${key}</label>
<div style="overflow:auto" id="${metaData.name}-${key}"></div>
`;
objectsContainer.appendChild(objectCard);
pluginsTabWrapper.appendChild(objectsContainer);
const menu = createJsonTree({
container: document.getElementById(`${metaData.name}-${key}`),
data: metaData[key],
schema: {
"active": { type: "boolean" }
},
onChange: (data) => { },
onSave: async (data) => {
await pluginAPI.update(metaData.name, { [key]: data });
}
});
}
}
}
pluginsTabWrapper.appendChild(objectsContainer);
/*pluginsTabWrapper.innerHTML = `
<div class="card static row" style="grid-column: span 3;height:50px;">
<label class="cb cb-modern">
<label for="plugin-${plugin.name}" style="font-weight:bold">Aktiv</label>
<input onchange="pluginAPI.activation('${plugin.name}', this.checked);" id="plugin-${plugin.name}" data-status="${plugin.name}" type="checkbox" ${plugin.active ? 'checked' : ''}>
<span class="cb-box" aria-hidden="true"></span>
</label>
</div>
<div class="card static row" style="gap:0 10px;min-height:fit-content;">
<div style="flex:0 0 auto">
<label style="font-weight:bold">Name</label>
<input type="text" data-name="${plugin.name}" data-field="name" value="${plugin.name}" />
</div>
<div style="flex:0 0 auto">
<label style="font-weight:bold">Version</label>
<input type="text" data-name="${plugin.name}" data-field="version" value="${plugin.version}" />
</div>
<div style="flex:1 1 auto">
<label style="font-weight:bold">Beschreibung</label>
<input type="text" data-name="${plugin.name}" data-field="description" style="width:100%" value="${plugin.description}" />
</div>
</div>
<div class="card static" style="grid-column: span 3;">
<label style="font-weight:bold">Startmenüeinträge</label>
<div style="overflow:auto" id="${plugin.name}-menu"></div>
</div>
`;
createJsonTree({
container: document.getElementById(`${plugin.name}-menu`),
data: plugin,
schema: {
"active": { type: "boolean" }
}
});
*/
});
/*
{
"name": "TEST",
"description": "Remote Desktop Verbindungen über MSRA realisieren",
"version": "0.2025.11.07",
"config": {},
"menu": {
"label": "TEST",
"items": [
{
"label": "Remoteunterstützung",
"view": "index",
"icon": "remoteServer.png",
"permissions": [
"Administration"
]
}
]
},
"active": true
}
*/
/*
new AttachOnBlurChange(document.querySelector(`[data-name="${plugin.name}"][data-field="name"]`), async (newValue, oldValue) => { await pluginAPI.rename(`${plugin.name}`, newValue); });
new AttachOnBlurChange(document.querySelector(`[data-name="${plugin.name}"][data-field="version"]`), async (newValue, oldValue) => await pluginAPI.update(`${plugin.name}`, { version: newValue }))
new AttachOnBlurChange(document.querySelector(`[data-name="${plugin.name}"][data-field="description"]`), async (newValue, oldValue) => await pluginAPI.update(`${plugin.name}`, { description: newValue }))
*/
});
})
</script>
</html>

253
server.js Normal file
View File

@@ -0,0 +1,253 @@
//#region Modules
const { dirname } = require('path');
const path = require('path');
const https = require('https');
var express = require('express');
var app = express();
var { create } = require('express-handlebars');
const cookieParser = require('cookie-parser');
var fs = require('fs');
var os = require('os');
var favicon = require('serve-favicon');
const Sequelize = require('sequelize');
const { Server } = require('socket.io');
const { on } = require('cluster');
// const { start } = require('repl');
// const WebSocket = require('ws');
//#endregion
require('module-alias/register'); // define paths in package.json
process.env.TZ = 'Europe/Berlin';
//#region Paths
app.locals.path = {
root: dirname(require.main.filename),
plugins: `${dirname(require.main.filename)}/plugins`,
public: `${dirname(require.main.filename)}/public`,
source: `${dirname(require.main.filename)}/src`,
}
//#endregion
//#region Server configuration
app.locals.stylesheet = JSON.parse(fs.readFileSync(`${app.locals.path.source}/models/stylesheet.json`, 'utf-8'));
app.locals.configuration = JSON.parse(fs.readFileSync(`${app.locals.path.source}/models/configuration.json`, 'utf-8'));
app.locals.package = JSON.parse(fs.readFileSync(`${app.locals.path.root}/package.json`, 'utf-8'));
app.locals.startMenuItems = [ ];
(async () => {
// const server = https.createServer({
// key: fs.readFileSync(`${app.locals.path.source}/secure/${app.locals.configuration.certificate.key}`),
// cert: fs.readFileSync(`${app.locals.path.source}/secure/${app.locals.configuration.certificate.chain}`),
// pfx: fs.readFileSync(`${app.locals.path.source}/secure/${app.locals.configuration.certificate.pfx}`),
// passphrase: "password",
// //cert: fs.readFileSync(`${app.locals.path.source}/secure/${app.locals.configuration.certificate.chain}`),
// }, app);
const securePath = `${app.locals.path.source}/secure`;
const certConfig = app.locals.configuration.certificate;
let httpsOptions = {};
if (certConfig.pfx) {
httpsOptions = {
pfx: fs.readFileSync(`${securePath}/${certConfig.pfx}`),
passphrase: certConfig.passphrase
};
} else {
httpsOptions = {
key: fs.readFileSync(`${securePath}/${certConfig.key}`),
cert: fs.readFileSync(`${securePath}/${certConfig.chain}`)
};
}
const server = https.createServer(httpsOptions, app);
// const wss = new WebSocket.Server({ server });
// wss.on('connection', socket => {
// socket.send('HELLO')
// });
const io = new Server(server, {
pingTimeout: 60000,
maxHttpBufferSize: 1e8, // 100 MB
});
//#endregion
//#region Services/DatabaseModel
let service = new Map();
let databaseModel = new Map();
let SocketManager = require(`@services/socketManager.js`);
let SqlManager = require(`@services/sqlManager.js`);
let EventManager = require(`@services/eventManager.js`);
let NotifyTrayManager = require(`@services/notifyTrayManager.js`);
let PluginManager = require(`@services/pluginManager.js`);
let FileSystemManager = require(`@services/fileSystemManager.js`);
let AuthenticationManager = require(`@services/authenticationManager.js`);
let ActiveDirectory = require(`@services/activeDirectoryManager.js`);
service.set('socketManager', new SocketManager(io));
await service.get('socketManager').addAsync('/');
await service.get('socketManager').addAsync('admin');
service.set('sqlManager', new SqlManager());
service.get('sqlManager').addInstance('main', app.locals.configuration.integration.sql.connect);
databaseModel.set('eventlog', require(`${app.locals.path.source}/models/eventlogModel`)(service.get('sqlManager').getInstance('main')));
databaseModel.set('eventlogView', require(`@models/eventlogView`)(service.get('sqlManager').getInstance('main')));
service.set('eventManager', new EventManager(app, databaseModel.get('eventlog'), databaseModel.get('eventlogView'), service.get('socketManager')));
databaseModel.set('notifyTrayModel', require(`@models/notifyTrayModel`)(service.get('sqlManager').getInstance('main')));
databaseModel.set('notifyTrayObjectModel', require(`@models/notifyTrayObjectsModel`)(service.get('sqlManager').getInstance('main')));
databaseModel.set('notifyTrayView', require(`@models/notifyTrayView`)(service.get('sqlManager').getInstance('main')));
service.set('notifyTray', new NotifyTrayManager(databaseModel.get('notifyTrayModel'), databaseModel.get('notifyTrayView'), databaseModel.get('notifyTrayObjectModel')) );
databaseModel.set('plugin', require(`@models/pluginModel`)(service.get('sqlManager').getInstance('main')));
databaseModel.set('authentication', require(`@models/authenticationModel`)(service.get('sqlManager').getInstance('main')));
service.set('fileSystemManager', new FileSystemManager());
service.set('authenticationManager', new AuthenticationManager(databaseModel.get('authentication'), app.locals.configuration.integration.token.secret, service.get('eventManager')));
service.set('activeDirectoryManager', new ActiveDirectory(app.locals.configuration.integration.activedirectory))
// everytime last created service!
service.set('pluginManager', new PluginManager(app, databaseModel.get('plugin'), app.locals.path.plugins, app.locals.configuration.plugin.chown, service));
exports.databaseModel = databaseModel;
exports.service = service;
exports.path = app.locals.path;
//#endregion
//#region Service-Registration/Middleware/Utils/Helpers
require(`${app.locals.path.root}/utils.js`);
let helpers = service.get('fileSystemManager').loadAllFiles(`${app.locals.path.public}/helpers`, '.js');
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(cookieParser());
app.use(favicon(`${app.locals.path.public}/images/radix_os_icon.ico`));
app.use(express.static(app.locals.path.root));
app.use(express.static(app.locals.path.public));
app.use(express.static(app.locals.path.source));
app.use(function(request, response, next) {
if (!request.secure) {
return response.redirect("https://" + request.headers.host + request.url + app.locals.configuration.server.port);
}
next(); // Http redirection to secure protocol
})
//#endregion
//#region App config values
app.set('view engine', '.hbs');
app.set('views', [
`${app.locals.path.public}/views`,
`${app.locals.path.public}/views/integrated`
]);
app.set('trust proxy', true)
//#endregion
//#region Error exception handling
app.on('uncaughtException', (err) => service.get('eventManager').write(null, 8, null, err ));
process.on('uncaughtException', (err) => service.get('eventManager').write(null, 8, null, err ));
process.on('unhandledRejection', (reason, promise) => service.get('eventManager').write(null, 8, null, reason ));
//#endregion
app.engine('hbs', create({
extname: 'hbs',
helpers: helpers,
partialsDir: `${app.locals.path.public}/views/partials`,
layoutsDir: `${app.locals.path.public}/views/layouts`,
defaultLayout: `${app.locals.path.public}/views/layouts/default.hbs`
}).engine)
server.listen(app.locals.configuration.server.port, () => {
(async () => {
const databaseTest = await service.get('sqlManager').test("main"); // Check if database connection is established
service.get('eventManager').write(null, databaseTest.levelId, null, databaseTest.message);
// Loading plugins
const plugins = await service.get('pluginManager').loadAll()
// const pluginsLoaded = {
// levelId: plugins.some(plugin => plugin.levelId > 0) ? 2 : 0,
// message: plugins.map(plugin => `${plugin.pluginName} v${plugin.metadata.version} ${plugin.message}`).join('<br>')
// }
// service.get('eventManager').write(null, pluginsLoaded.levelId, null, pluginsLoaded.message);
plugins.forEach(plugin => {
service.get('eventManager').write(null, plugin.levelId, null, `${plugin.pluginName} v${plugin.metadata.version} ${plugin.message}`);
});
//#region Menu-Generator
app.use(async (req, res, next) => {
next();
});
//#endregion
//#region Implement routes
require(`${app.locals.path.source}/routes/indexRoutes.js`).route(app, service);
require(`${app.locals.path.source}/routes/loginRoutes.js`).route(
app,
service.get('authenticationManager'),
service.get('socketManager'),
service.get('eventManager')
);
require(`${app.locals.path.source}/routes/adminRoutes.js`).route(
app,
service.get('authenticationManager'),
service.get('pluginManager'),
service.get('eventManager'),
service.get('socketManager'),
service.get('activeDirectoryManager'),
`${app.locals.path.source}/models/stylesheet.json`
);
//#endregion
//#region Implements sockets
require(`${app.locals.path.source}/sockets/mainSocket.js`)(
app,
service.get('socketManager'),
'/',
service.get('pluginManager'),
databaseModel.get('authentication'),
service.get('fileSystemManager'),
service.get('eventManager'),
service.get('activeDirectoryManager')
);
require(`${app.locals.path.source}/sockets/adminSocket.js`)(
app,
service.get('socketManager'),
'admin',
service.get('eventManager')
);
//#endregion
})();
setTimeout(() => {
service.get('eventManager').write(null, 1, null,
`${app.locals.configuration.server.name} is running`,
`fqdn: https://${os.hostname()}:${app.locals.configuration.server.port}/`,
`process id: ${process.pid}`,
`url: ${os.hostname()}`,
`port: ${app.locals.configuration.server.port}`
)
}, 1000);
});
})();

138
skeleton.txt Normal file
View File

@@ -0,0 +1,138 @@
howto:
--> Watch out for names. Stylesheet and javascript names, will be used global!
-----------------------------------------------------------------------------------------------------------------------
├─ create new plugin:
│ ├─ add line to index.js: 'service.get('socketManager').add("%namespace%");'
│ ├─ add context in plugins route.js:
│ │ └─ app.post('/window/:name/index', async (req, res) => { await renderWindow(app, 'index', metadata, { user: await vUser.findOne({ where: { Benutzer: req.cookies.sAMAccountName }, raw: true } ) }, res) });
│ └─
├─ create new socket namespaces:
│ └─ service.get('socketManager').add("%namespace%");
└─ add new startmenu entries:
├─ add context object to file '/src/models/integratedStartmenuItems.js', where you can define pass through context paramters
└─ add view-template to '/public/views/integrated/'
-----------------------------------------------------------------------------------------------------------------------
useful global features:
-----------------------------------------------------------------------------------------------------------------------
├─ %plugin%/public/javascripts.main.js ** for each plugin, main.js is loading for global use
├─ eventlog
│ ├─ service.get('eventManager).write(...) ** writes log entry and notifys admins on webui
│ └─ service.get('eventManager).writeLog(...) ** writes log entry only without firing webui message
├─ notifyTray
│ └─ service.get('notifyTray').createAndNotify ...
└─
-----------------------------------------------------------------------------------------------------------------------
rules:
-----------------------------------------------------------------------------------------------------------------------
├─ filesystem:
│ ├─ app descriptions, file/folder names in English; except proper names
│ ├─ files and folders always lowercase; continue in uppercase instead of spaces
│ ├─ files in routes folder, have to be named %name%Routes.js to generate dynamic menu
│ ├─ folder names in plural, if files are included
│ └─ folder names in singular, if files are excluded
├─ code:
| ├─ javascript:
| │ ├─ ids, classes, data-attributes (names and values) with hyphen (Bindestrich) instead of spaces; always lowercase
| │ └─ functions (and parameters in it) always lowercase; continue in uppercase instead of spaces
| ├─ socket.io:
| │ └─ socket names with underscores instead of spaces; always lowercase
| ├─ json:
| │ └─ variables in strings: "${}" (e.g ${ROOTPATH})
└─ others:
├─ admin eventlog in English
├─ regions in English; first letter and proper names in capital
└─ users eventlog/messaging in German
-----------------------------------------------------------------------------------------------------------------------
folder structur:
-----------------------------------------------------------------------------------------------------------------------
root/ ** server relevant files
├─ node_modules/ ** npm third party modules for nodejs server
├─ plugins/ ** server webapp based extensions
│ └─ %plugin_name%/ ** dynamic name reservation
│ ├─ views/ ** contains handlebar views
│ ├─ public/ ** contains plugin specified static files
│ │ ├─ helpers/ ** contains plugin specified handlebars helpers function
│ │ ├─ javascript/ ** contains plugin specified javascript code
│ │ ├─ others/ ** contains plugin specified files like options or templates
│ │ └─ styles/ ** contains plugin specified stylesheets
│ ├─ docs/
│ │ ├─ help.hbs *** helper file for webapp description
│ │ └─ tutorial.hbs *** tutorial file, for webapp tutorial
│ ├─ index.js *** plugin entry point
│ ├─ plugin.json *** metadata basefile
│ └─ sockets.js *** plugin specified sockets
├─ public/ ** contains all visible static files
│ ├─ helpers/ ** javascript express handlebars helpers for client views
│ ├─ images/ ** global images
│ ├─ styles/ ** global stylesheets file
│ │ ├─ responsive/
│ │ │ ├─ desktop.css *** responsive layout for desktops
│ │ │ ├─ mobile.css *** responsive layout for smartphones
│ │ │ └─ tablet.css *** responsive layout for tablets
│ ├─ dark.css *** dark theme
│ ├─ light.css *** light theme
│ └─ default.css *** global layout for everything everywhere all at once
│ └─ views/ ** contains the handlebars views
│ ├─ layouts/ ** templates for views
│ │ ├─ default.hbs *** main layout for everything everywhere all at once
│ │ └─ stricted_area.hbs *** layout without menus and links
│ └─ partials/ ** view snippets for multiple use
├─ src/ ** files for handle server inquiries
│ ├─ controllers/ ** contains the business logic receiving and validating client inquiries
│ ├─ models/ ** data/-base models
│ │ ├─ configuration.json *** server configuration file
│ │ └─ stylesheets.json *** global client stylesheet variabels
│ ├─ routes/ ** handles endpoint routing
│ ├─ secure/ ** contains encrypted passfiles and certificates
│ ├─ services/ ** contains business logic classes
│ │ ├─ authentication.js *** authenticates client connection attempts
│ │ ├─ encryption.js *** de-/ encryption serialization class
│ │ ├─ eventlog.js *** messaging class; client and server based
│ │ ├─ pluginsystem.js *** plugin engine
│ │ └─ sql.js *** microsoft sql connection and processing queries
│ └─ sockets/ ** global sockets
│ development.json *** development to do's
│ license_internal.txt *** license text file
│ package-lock.json *** npm link to node_modules
│ package.json *** file for server initialization
│ release_notes.json *** shows server version release notes
└─ server.js *** file for handle server start
-----------------------------------------------------------------------------------------------------------------------
stucture of style.json:
-----------------------------------------------------------------------------------------------------------------------
root
├─ themes:
│ ├─ dark:
│ └─ light:
└─ responsive:
├─ desktop:
├─ tablet:
└─ mobile:
-----------------------------------------------------------------------------------------------------------------------
structure of plugin:
-----------------------------------------------------------------------------------------------------------------------
root/
├─ plugins/
│ ├─ ${plugin}/
│ │ ├─ views/
│ │ │ └─ index.hbs (main file)
│ │ ├─ styles/
│ │ │ └─ *.css (Set stylesheet as usual in header-section)
│ │ ├─ scripts/
│ │ │ └─ *.js (Load file with function reloadPluginScript("/${pluginname}/javascript/${name}.js") in script-section)
│ │ └─ files/
│ │ └─ *.png (Set the name exactly as it appears in the plugin.json file)
│ └─ plugin.json
-----------------------------------------------------------------------------------------------------------------------

View File

@@ -0,0 +1,84 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const Authentication = sequelize.define('Authentication', {
ObjectGUID: {
type: DataTypes.UUID,
primaryKey: true,
allowNull: false,
},
sAMAccountName: {
type: DataTypes.STRING,
allowNull: true,
},
mail: {
type: DataTypes.STRING,
allowNull: true,
},
givenName: {
type: DataTypes.STRING,
allowNull: true,
},
sn: {
type: DataTypes.STRING,
allowNull: true,
},
employeeID: {
type: DataTypes.STRING,
allowNull: true,
},
title: {
type: DataTypes.STRING,
allowNull: true,
},
department: {
type: DataTypes.STRING,
allowNull: true,
},
streetAddress: {
type: DataTypes.STRING,
allowNull: true,
},
userAccountControl_ID: {
type: DataTypes.STRING,
allowNull: true,
},
authenticationType_ID: {
type: DataTypes.INTEGER,
allowNull: true,
},
telephoneNumber: {
type: DataTypes.STRING,
allowNull: true,
},
physicalDeliveryOfficeName: {
type: DataTypes.STRING,
allowNull: true,
},
distinguishedName: {
type: DataTypes.STRING,
allowNull: true,
},
password: {
type: DataTypes.STRING,
allowNull: true, // Passwort wird erst bei Erstanmeldung gesetzt
},
refreshtoken: {
type: DataTypes.STRING,
allowNull: true,
},
active: {
type: DataTypes.BOOLEAN,
allowNull: true,
},
online: {
type: DataTypes.BOOLEAN,
allowNull: true,
},
}, {
tableName: 'Authentication', // Tabellenname in der Datenbank
timestamps: false, // Falls du keine createdAt/updatedAt Spalten hast
})
return Authentication;
};

View File

@@ -0,0 +1,51 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
async () => {
try {
await sequelize.authenticate();
next(`database connection has been established successfully`);
} catch(error) {
next([`unable to connect to the database`, error]);
}
}
const EventLog = sequelize.define('EventLog', {
ID: {
type: DataTypes.INTEGER,
allowNull: false,
primaryKey: true,
autoIncrement: true, // sorgt für Auto-Inkrement
},
Message: {
type: DataTypes.STRING,
allowNull: false,
},
Trace: {
type: DataTypes.STRING,
allowNull: true,
},
Level_ID: {
type: DataTypes.INTEGER,
allowNull: false,
},
PluginName: {
type: DataTypes.STRING,
allowNull: false,
},
Date: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,
},
ObjectGUID: {
type: DataTypes.UUID,
allowNull: false,
},
}, {
tableName: 'EventLog', // Tabellenname in der Datenbank
timestamps: false, // Falls du keine createdAt/updatedAt Spalten hast
})
return EventLog;
};

View File

@@ -0,0 +1,34 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const EventLogView = sequelize.define('EventLogView', {
ID: { type: DataTypes.INTEGER, primaryKey: true },
Message: { type: DataTypes.STRING },
Trace: { type: DataTypes.STRING },
Surname: { type: DataTypes.STRING },
givenName: { type: DataTypes.STRING },
Date: { type: DataTypes.DATE },
department: { type: DataTypes.STRING },
ClearTextUser: { type: DataTypes.STRING },
ObjectTypDisplayName: { type: DataTypes.STRING },
Level_ID: { type: DataTypes.INTEGER },
LevelName: { type: DataTypes.STRING },
LevelPriority: { type: DataTypes.INTEGER },
LevelDisplayName: { type: DataTypes.STRING },
PluginName: { type: DataTypes.STRING },
ObjectGUID: { type: DataTypes.UUID },
sAMAccountName: { type: DataTypes.STRING },
mail: { type: DataTypes.STRING },
Phone: { type: DataTypes.STRING },
Office: { type: DataTypes.STRING },
Adress: { type: DataTypes.STRING },
authenticationType_ID: { type: DataTypes.INTEGER },
TypeName: { type: DataTypes.STRING }
}, {
tableName: 'vEventLog', // dein SQL-View
timestamps: false,
freezeTableName: true
});
return EventLogView;
};

View File

@@ -0,0 +1,108 @@
module.exports = ([
{
section: "System",
name: 'Server',
active: true,
menu: {
label: 'Server',
items: [
{
label: 'Styles',
view: 'styleconfig',
icon: 'brush.png',
permissions: [ 'Administration' ]
},
{
label: 'Configs',
view: 'serverconfig',
icon: "app.png",
permissions: [ 'Administration' ]
}
]
},
},
{
section: 'System',
name: 'EventLog',
active: true,
menu: {
label: 'EventLog',
items: [
{
label: 'EventLog',
view: 'eventlog',
defaultSize: { width: "1200px", height: "1200px" },
icon: "eventlog.ico",
permissions: [ 'Administration' ]
}
]
}
},
{
section: 'System',
name: 'Plugins',
active: true,
menu: {
label: 'Plugins',
items: [
{
label: 'Plugins',
view: 'plugindashboard',
defaultSize: { width: "1000px", height: "400px" },
icon: "plugins.png",
permissions: [ 'Administration' ]
}
]
}
},
{
section: 'System',
name: 'Info',
active: true,
menu: {
label: 'Info',
items: [
{
label: 'Info',
view: 'serverinfo',
defaultSize: { width: "900px", height: "500px" },
icon: "serverinfo.png",
permissions: [ 'Administration' ]
}
]
}
},
{
section: "Benutzer",
name: 'Einstellungen',
active: true,
menu: {
label: 'Einstellungen',
items: [
{
label: 'Einstellungen',
view: 'usersettings',
defaultSize: { width: "460px", height: "515px" },
icon: "app.png",
permissions: [ '*' ]
}
]
}
},
{
section: "Benutzer",
name: 'Hilfe',
active: true,
menu: {
label: 'Hilfe',
items: [
{
label: 'Hilfe',
view: 'help',
icon: "help.png",
permissions: [ '*' ]
}
]
}
}
]);

View File

@@ -0,0 +1,40 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
// // DB-Verbindung testen (optional)
// (async () => {
// try {
// await sequelize.authenticate();
// console.log(`Database connection established for NotifyTray`);
// } catch (error) {
// console.error(`Unable to connect to the database`, error);
// }
// })();
const NotifyTray = sequelize.define('NotifyTray', {
ID: {
type: DataTypes.INTEGER,
allowNull: false,
primaryKey: true,
autoIncrement: true, // Identity
},
ObjectGUID: {
type: DataTypes.UUID,
allowNull: false,
},
NotifyTrayObject_ID: {
type: DataTypes.INTEGER,
allowNull: true
},
SeenAt: {
type: DataTypes.DATE,
allowNull: true,
}
}, {
tableName: 'NotifyTray',
timestamps: false,
});
return NotifyTray;
};

View File

@@ -0,0 +1,39 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const NotifyTrayObjects = sequelize.define('NotifyTrayObjects', {
ID: {
type: DataTypes.INTEGER,
allowNull: false,
primaryKey: true,
autoIncrement: true, // manuell zu vergeben
},
PluginName: {
type: DataTypes.STRING(50),
allowNull: true,
},
Message: {
type: DataTypes.TEXT,
allowNull: false,
},
JSON: {
type: DataTypes.TEXT, // große JSON-Strings
allowNull: true,
},
ActionRequired: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
CreatedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
}
}, {
tableName: 'NotifyTrayObjects',
timestamps: false,
});
return NotifyTrayObjects;
};

View File

@@ -0,0 +1,70 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const NotifyTrayView = sequelize.define('vNotifyTray', {
ID: {
type: DataTypes.INTEGER,
allowNull: false,
primaryKey: true,
},
active: {
type: DataTypes.BOOLEAN,
allowNull: true,
},
online: {
type: DataTypes.BOOLEAN,
allowNull: true,
},
ObjectGUID: {
type: DataTypes.STRING,
allowNull: true,
},
sAMAccountName: {
type: DataTypes.STRING,
allowNull: true,
},
givenName: {
type: DataTypes.STRING,
allowNull: true,
},
sn: {
type: DataTypes.STRING,
allowNull: true,
},
mail: {
type: DataTypes.STRING,
allowNull: true,
},
PluginName: {
type: DataTypes.STRING(50),
allowNull: true,
},
JSON: {
type: DataTypes.TEXT,
allowNull: true,
},
ActionRequired: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
CreatedAt: {
type: DataTypes.DATE,
allowNull: true,
},
Message: {
type: DataTypes.TEXT,
allowNull: true,
},
SeenAt: {
type: DataTypes.DATE,
allowNull: true,
}
}, {
tableName: 'vNotifyTray', // 👈 WICHTIG: View statt Tabelle
timestamps: false,
});
return NotifyTrayView;
};

24
src/models/pluginModel.js Normal file
View File

@@ -0,0 +1,24 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const Plugin = sequelize.define('Plugin', {
Name: {
type: DataTypes.STRING(50),
allowNull: false,
primaryKey: true
},
Active: {
type: DataTypes.BOOLEAN,
defaultValue: true,
},
Version: {
type: DataTypes.STRING(25),
}
}, {
tableName: 'Plugins', // Tabellenname in der Datenbank
timestamps: false, // Falls du keine createdAt/updatedAt Spalten hast
});
return Plugin;
};

View File

@@ -0,0 +1,58 @@
[
{
"sAMAccountName": "manuel.sowada",
"plugin": "System",
"datetime": "2026-02-11",
"value": "HTML-Tabellen werden durch virtuelles Dataset abgebildet",
"finish": true
},
{
"sAMAccountName": "manuel.sowada",
"plugin": "System",
"datetime": "2026-04-14",
"value": "Bubble-Notify eingefügt",
"finish": true
},
{
"sAMAccountName": "manuel.sowada",
"plugin": "System",
"datetime": "2026-04-16",
"value": "Window drag 'n drop bugfixes",
"finish": true
},
{
"sAMAccountName": "manuel.sowada",
"plugin": "System",
"datetime": "2026-04-17",
"value": "OS-Logik und -Design Bugfixes + mobile-responsive-design",
"finish": true
},
{
"sAMAccountName": "manuel.sowada",
"plugin": "System",
"datetime": "2026-04-18",
"value": "Speichern der Window-Payloads pro Benutzer, divers Bugfixes und Verbesserungen in der OS-Logik",
"finish": true
},
{
"sAMAccountName": "manuel.sowada",
"plugin": "System",
"datetime": "2026-04-19",
"value": "PluginManager erweitert, Hilfe in Tabs umbgebaut, diverse Design-Bugfixes",
"finish": true
},
{
"sAMAccountName": "manuel.sowada",
"plugin": "System",
"datetime": "2026-04-20",
"value": "OS-Logik update: fetch ersetzt socket.io bei open_window und open_view",
"finish": true
},
{
"sAMAccountName": "manuel.sowada",
"plugin": "System",
"datetime": "2026-04-21",
"value": "Plugin-System + JSONtree bugfixes",
"finish": true
}
]

175
src/routes/adminRoutes.js Normal file
View File

@@ -0,0 +1,175 @@
const { exec } = require('child_process');
const fs = require('fs');
const path = require('path');
const { service } = require('@root/server.js');
const configurationFile = path.join(require('@root/server.js').path.source, 'models', 'configuration.json');
const stylesheetFile = path.join(require('@root/server.js').path.source, 'models', 'stylesheet.json');
const serverInfoFile = path.join(require('@root/server.js').path.root, 'package.json');
module.exports = {
route(app, authenticationManager, pluginManager, eventManager, socketManager, activeDirectoryManager, stylesheetJson) {
// JSON configuration abrufen
app.post('/api/getConfig', (req, res) => {
fs.readFile(configurationFile, 'utf8', (err, data) => {
if (err) return res.status(500).send(err);
res.status(200).send(JSON.parse(data));
});
});
// JSON configuration speichern
app.post('/config', (req, res) => {
fs.writeFile(configurationFile, JSON.stringify(req.body, null, 2), (err) => {
if (err) return res.status(500).send(err);
res.status(200).send({ status: 'ok' });
});
});
// JSON stylesheet abrufen
app.post('/api/getStyles', (req, res) => {
fs.readFile(stylesheetFile, 'utf8', (err, data) => {
if (err) return res.status(500).send(err);
res.status(200).send(JSON.parse(data));
});
});
// JSON stylesheet speichern
app.post('/style', (req, res) => {
fs.writeFile(stylesheetFile, JSON.stringify(req.body, null, 2), (err) => {
if (err) return res.status(500).send(err);
res.status(200).send({ status: 'ok' });
});
});
// JSON package.json abrufen
app.post('/api/getServerInfo', (req, res) => {
fs.readFile(serverInfoFile, 'utf8', (err, data) => {
if (err) { return res.status(500).send(err); }
res.status(200).send({ package: JSON.parse(data), pid: process.pid, releaseNotes: global.json.releaseNotes.live });
});
});
// JSON package.json speichern
app.post('/serverinfo', (req, res) => {
fs.writeFile(serverInfoFile, JSON.stringify(req.body, null, 2), (err) => {
if (err) return res.status(500).send(err);
res.status(200).send({ status: 'ok' });
});
});
app.post('/api/eventlog/clearlog', (req, res) => {
eventManager.clear();
res.status(200).send({ status: 'ok' })
})
app.post('/api/eventlog/getLogs', async (req, res) => {
res.status(200).json(await service.get('eventManager').getAllEventLogs());
})
app.post('/api/shutdown', (req, res) => {
exec(`kill -9 ${process.pid}`, (error, stdout, stderr) => {
if (error) {
service.get('eventManager').write(req.cookies.ObjectGUID, 4, null, error.message);
return res.status(500).send({ status: 'error', message: error.message });
}
service.get('eventManager').write(req.cookies.ObjectGUID, 0, null, `Server heruntergefahren`);
res.status(200).send({ status: 'ok' });
});
});
app.post('/api/restart', (req, res) => {
service.get('eventManager').write(req.cookies.ObjectGUID, 2, null, `Der Neustart ist noch nicht implementiert`);
res.status(200).send({ status: 'ok' });
// exec(`kill -9 ${process.pid}`, (error, stdout, stderr) => {
// if (error) {
// service.get('eventManager').write(req.cookies.ObjectGUID, 4, null, error.message);
// return res.status(500).send({ status: 'error', message: error.message });
// }
// service.get('eventManager').write(req.cookies.ObjectGUID, 0, null, `Server neu gestartet`);
// res.status(200).send({ status: 'ok' });
// });
});
app.post('/api/plugins/activation', async (req, res) => {
const { name, state } = req.body;
let result = null;
if(state) {
result = await pluginManager.load(name, true);
} else {
result = await pluginManager.unload(name);
}
console.log(result)
// result = { ...result, authorized: result.metadata.permissions.some(async permission => { await activeDirectoryManager.getGroup(permission) != null && await activeDirectoryManager.isUserMemberOfRecursive(req.cookies.sAMAccountName, permission)}) }
eventManager.write(null, result.levelId, name, result.message);
// socketManager.broadcast('admin', 'plugin_status', result);
socketManager.broadcast('/', 'plugin_status', result);
res.status(200).json(result);
});
app.post('/api/plugins/getAll', async (req, res) => {
try {
const plugins = await pluginManager.getStatus();
res.status(200).json(plugins);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/plugins/:name/update', async (req, res) => {
const { name } = req.params;
try {
const { updates } = req.body;
const result = await pluginManager.update(name, updates);
// result = { ...result, authorized: result.metadata.permissions.some(async permission => { await activeDirectoryManager.getGroup(permission) != null && await activeDirectoryManager.isUserMemberOfRecursive(req.cookies.sAMAccountName, permission)}) }
service.get('eventManager').writeLog(req.cookies.ObjectGUID, result.levelId, name, result.message);
// socketManager.broadcast('admin', 'plugin_status', result);
// socketManager.broadcast('/', 'plugin_status', result);
res.status(200).json(result);
} catch (error) {
service.get('eventManager').write(req.cookies.ObjectGUID, 4, name, `Fehler beim Aktualisieren des Plugins: ${error}`);
res.status(500).json({ error: error.message });
}
});
app.post('/api/plugins/:name/rename', async (req, res) => {
const { name } = req.params;
const { newName } = req.body;
const result = await service.get('pluginManager').rename(name, newName);
// const result = { levelId: 0, pluginName: name, message: `Plugin erstellt` };
// await pluginManager.create(name);
// res.status(200).json(result);
eventManager.writeLog(null, result.levelId, name, result.message);
// socketManager.broadcast('admin', 'plugin_status', result);
});
app.post('/api/plugins/:name/create', async (req, res) => {
const { name } = req.params;
const result = { levelId: 0, pluginName: name, message: `Plugin erstellt` };
await pluginManager.create(name);
eventManager.writeLog(null, result.levelId, name, result.message);
res.status(200).json(result);
// socketManager.broadcast('admin', 'plugin_status', result);
});
app.post('/admin/plugins/:name/delete', async (req, res) => {
const { name } = req.params;
const result = { status: 'delete', pluginName: name, levelId: 0, message: `Plugin ${name} gelöscht` }; //await pluginManager.delete(name);
res.status(200).json(result);
eventManager.write(null, result.levelId, name, result.message);
socketManager.broadcast('admin', 'plugin_status', result);
});
}
};

125
src/routes/indexRoutes.js Normal file
View File

@@ -0,0 +1,125 @@
const fs = require('fs');
const path = require('path');
const { renderWindow } = require('@services/renderWindow.js');
const { service } = require('@root/server.js');
const { File: HotReload } = require(`@services/hotReload.js`);
const { doesNotReject } = require('assert');
// let startMenuItemContext = require('@models/integratedStartmenuItems.js')
module.exports = {
route: function(app, service) {
app.get('/', service.get('authenticationManager').authenticate(), async (req, res) => {
const startMenuItems = await global.startMenuItems(
app,
req.cookies.sAMAccountName,
service
);
res.render('desktop', { layout: 'default', startMenuItems: startMenuItems });
});
app.post('/api/open_app', (req, res) => {
const { name, view, viewLabel, location, size, state, zIndex } = req.body;
const pluginPath = path.join(global.path.plugins, name, 'plugin.json');
let context = fs.existsSync(pluginPath)
? service.get('fileSystemManager').loadJSON(pluginPath)
: (global.json.startMenuItems.live).find(item => item.name == name);
context.defaultSize =
context.menu.items.find(item => item.label == viewLabel)?.defaultSize ||
{ width: 800, height: 600 };
delete context.config;
res.json({ name, view, viewLabel, context, location, size, state, zIndex });
});
app.get('/api/NotifyTray/getTrays', async (req, res) => {
const objectGuid = req.cookies.ObjectGUID;
// console.log(await service.get('notifyTray').getOpenNotifications(objectGuid))
res.status(200).json(await service.get('notifyTray').getOpenNotifications(objectGuid));
})
app.post('/api/NotifyTray/markAsSeen', async (req, res) => {
const objectGuid = req.cookies.ObjectGUID;
const notificationId = req.body.id;
const notificationValue = req.body.value;
await service.get('notifyTray').markAsSeen(objectGuid, notificationId, notificationValue);
res.status(204).send();
})
app.post('/api/Plugins/loadScripts', service.get('authenticationManager').authenticate(), async(req, res) => {
const scripts = service.get('pluginManager').getStatus().map(plugin => {
const exists = service.get('fileSystemManager').exists(path.join(plugin.pluginPath, 'public', 'javascript', 'main.js'))
if (exists && exists.status) {
return path.join('/', plugin.name, 'javascript', 'main.js');
}
return null;
})
.filter(script => script !== null);
res.status(200).send(scripts);
});
app.post('/window/EventLog/eventlog', async (req, res) => {
await renderWindow(app, 'eventlog.hbs', { }, { }, res)
});
app.post('/window/Server/styleconfig', async (req, res) => {
await renderWindow(app, 'styleconfig.hbs', { }, { }, res)
});
app.post('/window/Plugins/plugindashboard', async (req, res) => {
await renderWindow(app, 'plugindashboard.hbs', { }, { }, res)
});
app.post('/window/Info/serverinfo', async (req, res) => {
await renderWindow(app, 'serverinfo.hbs', { }, { }, res)
});
app.post('/window/Server/serverconfig', async (req, res) => {
await renderWindow(app, 'serverconfig.hbs', { }, { }, res)
});
app.post('/window/Einstellungen/usersettings', async (req, res) => {
await renderWindow(app, 'usersettings.hbs', { }, { }, res)
});
app.post('/window/Hilfe/help', async (req, res) => {
await renderWindow(app, 'help.hbs', { }, { }, res)
});
app.get('/api/help/getTabs', async (req, res) => {
const tabNames = (await startMenuItems(app, req.cookies.sAMAccountName))
.filter(plugin => plugin.active && plugin.menu.items.some(i => i.authorized))
.map(plugin => ({ name: plugin.menu.label }));
res.status(200).json(tabNames);
});
app.post('/api/help/getHelp', async (req, res) => {
const { name } = req.body;
const props = (await startMenuItems(app, req.cookies.sAMAccountName))
.filter(plugin => plugin.name === name && plugin.active && plugin.menu.items.some(i => i.authorized))
.map(async plugin => (
{
name: plugin.menu.label,
version: plugin.version,
description: plugin.description,
html: await fs.promises.readFile(plugin.section === 'Plugin' ? path.join(plugin.pluginPath, 'docs', 'help.html') : path.join(app.locals.path.public, 'views', 'help', plugin.name + '.html'), 'utf-8')
}
));
res.status(200).send(await props[0]);
});
}
};

79
src/routes/loginRoutes.js Normal file
View File

@@ -0,0 +1,79 @@
const { verify } = require("jsonwebtoken");
module.exports = {
route(app, authenticationManager, socketManager, eventManager) {
app.get(`/login`, (req, res) => {
res.render(`login`, { layout: 'default' });
})
app.post('/login', async (req, res) => {
const { sAMAccountName, password } = req.body;
const userModel = await authenticationManager.Authentication.findOne({
where: { sAMAccountName: sAMAccountName }, attributes: ['ObjectGUID'],
raw: true
});
const objectGuid = userModel !== null ? userModel.ObjectGUID : sAMAccountName;
try {
// set safety cookies
res.cookie('sAMAccountName', sAMAccountName, {
httpOnly: false,
secure: true,
sameSite: 'Strict',
maxAge: 1000 * 60 * 60 * 24 * 365
})
res.cookie('ObjectGUID', objectGuid, {
httpOnly: false,
secure: true,
sameSite: 'Strict',
maxAge: 1000 * 60 * 60 * 24 * 365
})
const login = await authenticationManager.login(sAMAccountName, password);
eventManager.writeLog(objectGuid, login.levelId, null, login.message);
res.status(login.levelId == 0 ? 200 : 401).json(login);
} catch (err) {
res.status(500).json(login);
}
});
// Geschützte Route
app.get('/me', authenticationManager.authenticate(), (req, res) => {
res.json(JSON.stringify({
user: {
name: req.user
}
}, null, 4));
});
app.post('/checkLoginName', async (req, res) => {
const { sAMAccountName } = req.body;
const userExists = await authenticationManager.Authentication.findOne({ where: { sAMAccountName: sAMAccountName } });
const auth = { objectGuid: userExists != null ? userExists.ObjectGUID : sAMAccountName, sAMAccountName: sAMAccountName };
res.status(userExists ? 200 : 404).json({ exists: userExists != null });
});
app.get('/verifying', async (req, res, next) => {
const verify = await authenticationManager.verifyUserToken();
eventManager.write(req.user.objectGuid, verify.levelId, null, verify.message);
next();
});
// Logout
app.post('/logout', authenticationManager.authenticate(), async (req, res) => {
const logout = await authenticationManager.logout(req.user.sAMAccountName);
socketManager.sendTo('/', req.user.objectGuid, 'login_status', { levelId: logout.levelId, message: logout.message } )
eventManager.write(req.user.objectGuid, logout.levelId, null, logout.message);
res.clearCookie('sAMAccountName');
res.clearCookie('ObjectGUID');
setTimeout(() => res.render('login', { layout: false, title: app.locals.configuration.server.name }), 3000);
// res.json({ message: 'Logout erfolgreich' });
});
}
};

View File

@@ -0,0 +1,258 @@
const ActiveDirectory = require('activedirectory2');
class ActiveDirectoryManager {
constructor({
url,
baseDN,
username,
password,
userAttributes,
groupAttributes,
computerAttributes
}) {
this.ad = new ActiveDirectory({
url,
baseDN,
username,
password,
attributes: {
user: userAttributes,
group: groupAttributes,
computer: computerAttributes
}
});
this.userAttributes = userAttributes;
this.groupAttributes = groupAttributes;
this.computerAttributes = computerAttributes;
}
/**
* -----------------------------------------------------
* INTERNAL GENERIC LDAP SEARCH
* -----------------------------------------------------
*/
async ldapSearch(options) {
return new Promise((resolve, reject) => {
this.ad.find(options, (err, result) => {
if (err) return reject(err);
resolve(result || {});
});
});
}
/**
* -----------------------------------------------------
* USER FUNCTIONS
* -----------------------------------------------------
*/
async getUser(username, attributes = this.userAttributes) {
return new Promise((resolve, reject) => {
this.ad.findUser({ attributes }, username, (err, user) => {
if (err) return reject(err);
resolve(user || null);
});
});
}
async getUserDN(username) {
const user = await this.getUser(username);
return user?.dn || null;
}
async findUsers(query, attributes = this.userAttributes) {
return new Promise((resolve, reject) => {
const filter = `(&(objectClass=user)(|(cn=${query})(sAMAccountName=${query})(mail=${query})(displayName=${query})))`;
this.ad.findUsers({ filter, attributes }, (err, users) => {
if (err) return reject(err);
resolve(users || []);
});
});
}
/**
* -----------------------------------------------------
* GROUP FUNCTIONS
* -----------------------------------------------------
*/
async getGroup(groupName, attributes = this.groupAttributes) {
return new Promise((resolve, reject) => {
this.ad.findGroup({ attributes }, groupName, (err, group) => {
if (err) return reject(err);
resolve(group || null);
});
});
}
async findGroups(query, attributes = this.groupAttributes) {
return new Promise((resolve, reject) => {
const filter = `(&(objectClass=group)(cn=${query}))`;
this.ad.findGroups({ filter, attributes }, (err, groups) => {
if (err) return reject(err);
resolve(groups || []);
});
});
}
/**
* -----------------------------------------------------
* COMPUTER / OU FUNCTIONS 🖥️
* -----------------------------------------------------
*/
/**
* Einzelnen Computer holen
*/
async getComputer(name, attributes = this.computerAttributes) {
return new Promise((resolve, reject) => {
const filter = `(&(objectClass=computer)(|(cn=${name})(dNSHostName=${name})))`;
this.ad.find({ filter, attributes }, (err, result) => {
if (err) return reject(err);
resolve(result?.other?.[0] || null);
});
});
}
/**
* Alle Computer
*/
async getComputers(attributes = this.computerAttributes) {
const options = {
baseDN: this.ad.baseDN,
filter: '(objectClass=computer)',
attributes
};
const result = await this.ldapSearch(options);
return result.other || [];
}
/**
* Alle Computer aus einer OU holen
*/
async getComputersFromOU(ouDn, attributes = this.computerAttributes) {
const options = {
baseDN: ouDn,
filter: '(objectClass=computer)',
attributes
};
const result = await this.ldapSearch(options);
return result.other || [];
}
/**
* Computer suchen (Wildcard möglich)
* Beispiele: "PC-*", "*LAPTOP*", "SRV01"
*/
async findComputers(query, attributes = this.computerAttributes) {
const filter = `(&(objectClass=computer)(|(cn=${query})(dNSHostName=${query})))`;
const result = await this.ldapSearch({
filter,
attributes
});
return result.other || [];
}
/**
* -----------------------------------------------------
* GROUP MEMBERSHIP (DIRECT & RECURSIVE)
* -----------------------------------------------------
*/
async isUserMemberOfDirect(username, groupName) {
return new Promise((resolve, reject) => {
this.ad.isUserMemberOf(username, groupName, (err, isMember) => {
if (err) return reject(err);
resolve(isMember);
});
});
}
async isUserMemberOfRecursive(username, groupName, visited = new Set()) {
const key = groupName.toLowerCase();
if (visited.has(key)) return false;
visited.add(key);
const direct = await this.isUserMemberOfDirect(username, groupName);
if (direct) return true;
const group = await this.getGroup(groupName);
if (!group || !Array.isArray(group.member)) return false;
for (const dn of group.member) {
const match = dn.match(/CN=([^,]+)/i);
if (!match) continue;
const subGroupName = match[1];
const found = await this.isUserMemberOfRecursive(username, subGroupName, visited);
if (found) return true;
}
return false;
}
async getGroupSubgroups(groupName, visited = new Set()) {
const key = groupName.toLowerCase();
if (visited.has(key)) return [];
visited.add(key);
const group = await this.getGroup(groupName);
if (!group || !Array.isArray(group.member)) return [];
const results = [];
for (const memberDN of group.member) {
const match = memberDN.match(/CN=([^,]+)/i);
if (!match) continue;
const subGroupName = match[1];
const sub = await this.getGroup(subGroupName).catch(() => null);
if (!sub) continue;
results.push(sub);
results.push(...await this.getGroupSubgroups(subGroupName, visited));
}
return results;
}
async getGroupRecursive(groupName, visited = new Set()) {
const key = groupName.toLowerCase();
if (visited.has(key)) return null;
visited.add(key);
const group = await this.getGroup(groupName);
if (!group) return null;
const result = {
...group,
subgroups: []
};
if (!Array.isArray(group.member)) return result;
for (const memberDN of group.member) {
const match = memberDN.match(/CN=([^,]+)/i);
if (!match) continue;
const subGroupName = match[1];
const subTree = await this.getGroupRecursive(subGroupName, visited);
if (subTree) result.subgroups.push(subTree);
}
return result;
}
}
module.exports = ActiveDirectoryManager;

View File

@@ -0,0 +1,172 @@
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
let { levelId, message } = '';
/**
* Authentication class for login method, token validation and password setting
*/
class AuthenticationManager {
/**
*
* @param {object} model - Use the authentication database model for interact with the database
* @param {string} secretKey - Defines the server secret for token validation
*/
constructor(model, secretKey, eventManager) {
this.eventManager = eventManager;
// if (!model) throw new Error('Sequelize Model wird benötigt');
// if (!secretKey) throw new Error('Secret Key wird benötigt');
this.Authentication = model;
this.SECRET_KEY = secretKey;
}
/**
* Set or reset password of user
* @param {string} sAMAccountName - Windows account name
* @param {string} password - Set the new password
*/
async setPassword(sAMAccountName, password) {
const user = await this.Authentication.findOne({ where: { sAMAccountName } });
if (!user) {
// this.eventManager.write(null, 2, 0, { aboveLevel: 1 }, `User nicht gefunden`);
levelId = 2;
message = `Unbekannter User`
return {token: null, levelId: levelId };
// throw new Error(`User ${sAMAccountName} nicht gefunden`);
}
// if (user.password) throw new Error('Passwort bereits gesetzt');
const hashedPassword = await bcrypt.hash(password, 10);
user.password = hashedPassword;
await user.save();
}
/**
* Login mit Speicherung des Tokens in der Datenbank
*/
async login(sAMAccountName, password) {
const user = await this.Authentication.findOne({ where: { sAMAccountName } });
if (!user) {
//this.eventManager.write(null, 2, null, null, `User ${sAMAccountName} nicht geufunden`)
levelId = 2;
message = `Unbekannter Benutzer`;
return { token: null, levelId: levelId, message: message };
// throw new Error('Unkown user');
}
if (!user.password) {
this.setPassword(sAMAccountName, password);
// this.eventManager.write(user.ObjectGUID, 2, null, null, 'User registration initialized')
levelId = 1;
message = `Benutzer nicht registiert`;
return { token: null, levelId: levelId, message: message };
// throw new Error('User not registered');
}
const passwordMatch = await bcrypt.compare(password, user.password);
if (!passwordMatch) {
// this.eventManager.write(user.ObjectGUID, 2, null, null, 'Password doesn\'t match');
levelId = 2;
message = `Falsches Passwort`;
return { token: null, levelId: levelId, message: message };
// throw new Error('Wrong password');
}
// Token erzeugen
const payload = {
sAMAccountName: user.sAMAccountName,
mail: user.mail,
givenName: user.givenName,
sn: user.sn
};
const token = jwt.sign(payload, this.SECRET_KEY, { expiresIn: '100y' });
// Token in DB speichern
user.refreshtoken = token;
user.online = true;
await user.save();
// this.eventManager.write(user.ObjectGUID, 1, null, null, 'Erfolgreich angemeldet');
levelId = 0;
message = `Erfolgreich angemeldet`;
return { token: token, levelId: levelId, message: message };
}
/**
* Logout löscht Token aus der DB
*/
async logout(sAMAccountName) {
const user = await this.Authentication.findOne({ where: { sAMAccountName } });
if (user) {
user.refreshtoken = null;
user.online = false;
await user.save();
levelId = 0;
message = `Erfolgreich abgemeldet`;
return { token: null, levelId: levelId, message: message };
}
}
/**
* Token-Prüfung (über DB)
*/
async verifyUserToken(sAMAccountName) {
const user = await this.Authentication.findOne({ where: { sAMAccountName } });
if (!user || !user.refreshtoken) {
levelId = 1,
message = `Kein gültiger Token`;
// throw new Error('Kein gespeicherter Token gefunden');
}
try {
const payload = jwt.verify(user.refreshtoken, this.SECRET_KEY);
levelId = 0;
message = `User verifiziert`;
return { valid: true, payload, user, levelId: levelId, message: message }
} catch {
levelId = 4;
message = `Ungültiger Token`;
return { valid: false, payload, user, levelId: levelId, message: message }
}
}
/**
* Express Middleware prüft Token direkt aus DB anhand sAMAccountNamec
*/
authenticate() {
return async (req, res, next) => {
try {
const sAMAccountName = req.cookies?.sAMAccountName;
const objectGUID = req.cookies?.ObjectGUID;
if (!sAMAccountName || !objectGUID) {
return res.redirect('/login');
// return res.status(401).json({ message: 'Kein Benutzer-Cookie gefunden' });
}
const user = await this.Authentication.findOne({ where: { sAMAccountName } });
if (!user || !user.refreshtoken) {
return res.redirect('/login');
// return res.status(401).json({ message: 'Benutzer oder Token nicht gefunden' });
}
if (user.active === false) {
return res.redirect('/login');
// return res.status(401).json({ message: 'Benutzer ist nicht aktiv' });
}
// Token aus DB prüfen
const payload = jwt.verify(user.refreshtoken, this.SECRET_KEY);
req.user = user;
next();
} catch (err) {
console.error(err);
return res.redirect('/login');
// res.status(401).json({ message: 'Authentifizierung fehlgeschlagen' });
}
};
}
}
module.exports = AuthenticationManager;

View File

@@ -0,0 +1,159 @@
const { Op } = require('sequelize');
/**
* Custom event logging class
*
*/
class EventManager {
/**
* @param {*} eventlogModel - Use the eventlog database model for interact with the database
* @param {*} socketManager - Get the administration socket for sending events to frontend
*/
constructor(app, eventlogModel, eventLogView, socketManager) {
this.app = app;
this.EventLog = eventlogModel;
this.socketManager = socketManager;
this.EventLogView = eventLogView;
}
/**
* Creates a new log entry in EventLog database table
* @param {string} [objectGuid] - MSSQL uniqueidentifier (UUID) or null = '00000000-0000-0000-0000-000000000000' AS 'SYSTEM'
* @param {number} levelId - -1=test, 0=success, 1=log, 2=warn, 4=error, 8=throw_exception
* @param {number} pluginId - ID of plugin
* @param {Object} socketSending - Use socket to send error message to admin web frontends. Object: { active: true, levelId: 1 }
* @param {Array} args - Message: string or comma seperated array of string
* args[0].stack !== undefined - => Client side (error) stack and message
*/
async write(objectGuid, levelId, pluginName = null, ...args) {
const err = new Error();
const stackLine = args[0].stack !== undefined ? args[0].stack : err.stack.split('\n')[2]; // calls trace-line
//const trace = stackLine.split("\n") !== undefined ? stackLine.split("\n")[1].trim() : stackLine.match(/\/.*\d+/)[0].replace(this.app.locals.path.root, '') || ''; // path:line:column
const trace = stackLine?.split("\n")?.[1]?.trim() ?? stackLine?.match(/\/.*\d+/)?.[0]?.replace(this.app.locals.path.root, '') ?? '';
// const message = !Array.isArray(...args) ? [...args][0] : [...args][0][0]
const message = args[0].stack !== undefined ? args[0].message : args.join('\r\n\t');
const convertLevel = levelId == -1 ? 'test' : levelId == 0 ? 'success' : levelId == 1 ? 'log' : levelId == 2 ? 'warn' : levelId == 4 ? 'error' : 'throw exception';
const convertPluginName = pluginName == null ? 'SYSTEM' : pluginName;
const entry = await this.EventLog.create({
Message: message,
Trace: trace,
Level_ID: levelId,
PluginName: convertPluginName,
ObjectGUID: (objectGuid == null || objectGuid === undefined ? '00000000-0000-0000-0000-000000000000' : objectGuid)
});
const newLogEntry = await this.EventLogView.findOne( { where: { ID: entry.ID }, plain: true } );
if(levelId > -1) { // if not levelId = -1 | test-message
this.socketManager.broadcast('admin', 'eventlog_table', newLogEntry);
}
if(this.app.locals.configuration.debug.active && levelId >= this.app.locals.configuration.debug.levelId) {
this.socketManager.broadcast('admin', 'eventlog', { levelId: levelId, pluginName: pluginName, datetime: `[${dateFormat(new Date(), 'yyyy-mm-dd HH:MM:SS')}]`, trace: `[${trace}]`, message: message })
}
console.log(`[${dateFormat(new Date(), 'yyyy-mm-dd HH:MM:SS')}][${convertPluginName}][${convertLevel}][${trace}]:`, message.replaceAll('<br>', '\r\n'));
}
/**
* Only creates a new log entry in EventLog database table, without sending socketEvent
* @param {string} [objectGuid] - MSSQL uniqueidentifier (UUID) or null = '00000000-0000-0000-0000-000000000000' AS 'SYSTEM'
* @param {number} levelId - -1=test, 0=success, 1=log, 2=warn, 4=error, 8=throw_exception
* @param {number} pluginId - ID of plugin
* @param {Object} socketSending - Use socket to send error message to admin web frontends. Object: { active: true, levelId: 1 }
* @param {Array} args - Message: string or comma seperated array of string
* args[0].stack !== undefined - => Client side (error) stack and message
*/
async writeLog(objectGuid, levelId, pluginName = null, ...args) {
const err = new Error();
const stackLine = args[0].stack !== undefined ? args[0].stack : err.stack.split('\n')[2]; // calls trace-line
const trace = args[0].stack !== undefined ? stackLine.split("\n")[1].trim() : stackLine.match(/\/.*\d+/)[0].replace(this.app.locals.path.root, ''); // path:line:column
// const message = !Array.isArray(...args) ? [...args][0] : [...args][0][0]
const message = args[0].stack !== undefined ? args[0].message : args.join('\r\n\t');
const convertLevel = levelId == -1 ? 'test' : levelId == 0 ? 'success' : levelId == 1 ? 'log' : levelId == 2 ? 'warn' : levelId == 4 ? 'error' : 'throw exception';
const convertPluginName = pluginName == null ? 'SYSTEM' : pluginName;
const entry = await this.EventLog.create({
Message: message,
Trace: trace,
Level_ID: levelId,
PluginName: convertPluginName,
ObjectGUID: (objectGuid == null || objectGuid === undefined ? '00000000-0000-0000-0000-000000000000' : objectGuid)
});
const newLogEntry = await this.EventLogView.findOne( { where: { ID: entry.ID }, plain: true } );
if(levelId > -1) { // if not levelId = -1 | test-message
this.socketManager.broadcast('admin', 'eventlog_table', newLogEntry);
}
console.log(`[${dateFormat(new Date(), 'yyyy-mm-dd HH:MM:SS')}][${convertPluginName}][${convertLevel}][${trace}]:`, message.replaceAll('<br>', '\r\n'));
}
/**
* Clears the eventlog database table
*/
async clear() {
const err = new Error();
const stackLine = err.stack.split('\n')[2]; // calls trace-line
const trace = stackLine.match(/\/.*\d+/)[0].replace(this.app.locals.path.root, ''); // path:line:column
const message = `${this.EventLog.tableName} cleared successfully`;
await this.EventLog.destroy({
where: {},
truncate: true,
});
this.socketManager.broadcast('admin', 'eventlog', { levelId: 0, pluginName: 'SYSTEM', datetime: `[${dateFormat(new Date(), 'yyyy-mm-dd HH:MM:SS')}]`, trace: `[${trace}]`, message: message })
console.log(`[${dateFormat(new Date(), 'yyyy-mm-dd HH:MM:SS')}][${trace}]`, message.replaceAll('<br>', '\r\n'));
}
/**
* Get all eventlogs from database table
*/
async getAllEventLogs() {
try {
const logs = await this.EventLogView.findAll({ order: [['ID', 'DESC']] }); // Alle Zeilen abrufen
const logsArray = logs.map(log => log.get({ plain: true })); // Sequelize-Objekte in reine JS-Objekte
return logsArray; // Rückgabe als Array
} catch (error) {
console.error('Fehler beim Abrufen der EventLogs:', error);
return [];
}
}
/**
* Get a specified range of eventlogs from database table
*/
async getEventLogs(fromID = null, toID) {
try {
const whereStatement = fromID != null ? {
where: {
ID: {
[Op.gte]: fromID, // größer gleich vonID
[Op.lte]: toID // kleiner gleich bisID
}
},
order: [['ID', 'DESC']]
} :
{
limit: toID,
order: [['ID', 'DESC']]
}
const logs = await this.EventLogView.findAll(whereStatement);
const logsArray = logs.map(log => log.get({ plain: true }));
return logsArray;
} catch (error) {
console.error('Fehler beim Abrufen der EventLogs:', error);
return [];
}
}
}
module.exports = EventManager;

View File

@@ -0,0 +1,187 @@
const fs = require('fs');
const path = require('path');
class FileSystemManager {
/**
* @param {string} jsonPath - Pfad zur JSON-Datei
* @param {boolean} watch - ob die JSON-Datei überwacht werden soll (Live-Update)
*/
constructor() { }
loadAllFiles(path, fileextension = null) {
let files = {};
fs.readdirSync(path).forEach(file => {
if (fileextension == null || file.endsWith(fileextension)) {
const filefound = require(`${path}/${file}`);
files = { ...files, ...filefound }; // zusammenführen
}
});
return files;
}
getAllFiles(path, fileextension = null) {
let files = {};
fs.readdirSync(path).forEach(file => {
if (fileextension == null || file.endsWith(fileextension)) {
const filefound = `${path}/${file}`;
files = { ...files, ...filefound }; // zusammenführen
}
});
return files;
}
/**
* Liest rekursiv Dateien und gibt nur die gewünschten Attribute zurück.
*
* @param {string} dirPath - Startverzeichnis
* @param {string[]} attributes - gewünschte Attribute:
* ['name','fullPath','size','lastModified','isDirectory','extension', ...]
* @param {string|null} sortBy - Attribut zum Sortieren (z.B. 'lastModified' oder 'name')
* @param {string} order - 'asc' oder 'desc'
*/
readFiles(dirPath, attributes = ['name', 'fullPath'], sortBy = null, order = 'asc') {
let results = [];
const items = fs.readdirSync(dirPath);
for (const item of items) {
const fullPath = path.join(dirPath, item);
const stats = fs.statSync(fullPath);
const isDir = stats.isDirectory();
// Objekt mit ALLEN möglichen Infos
const allInfo = {
name: item,
nameWithoutExt: item.substring(0, item.indexOf('.')),
fullPath: fullPath,
size: stats.size,
lastModified: stats.mtime,
created: stats.birthtime,
isDirectory: isDir,
extension: isDir ? null : path.extname(item)
};
if (isDir) {
// rekursiv weitermachen
results = results.concat(this.readFiles(fullPath, attributes, null, order));
} else {
// nur gewünschte Attribute ausgeben
const filtered = {};
for (const attr of attributes) {
if (allInfo[attr] !== undefined) {
filtered[attr] = allInfo[attr];
}
}
results.push(filtered);
}
}
// Sortieren, falls gewünscht
if (sortBy) {
results.sort((a, b) => {
if (a[sortBy] < b[sortBy]) return order === 'asc' ? -1 : 1;
if (a[sortBy] > b[sortBy]) return order === 'asc' ? 1 : -1;
return 0;
});
}
return results;
}
/**
* Sammelt verschiedene Pattern-Ergebnisse aus Dateien mehrerer Ordner.
*
* @param {Object} options
* @param {string|string[]} options.folderPaths - Pfad oder Array von Pfaden zu Ordnern
* @param {string} [options.extension='.js'] - Dateiendung
* @param {Array<{ name: string, pattern: RegExp, mapFn?: Function }>} options.patterns - Liste von Pattern-Definitionen
* @param {boolean} [options.recursive=true] - Unterordner durchsuchen?
* @returns {Array} - kombinierte Ergebnisse aller Pattern
*/
collectFromFiles({
folderPaths,
extension = '.js',
patterns,
recursive = true
}) {
const results = [];
// if only one path selected
const paths = Array.isArray(folderPaths) ? folderPaths : [folderPaths];
function readDir(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory() && recursive) {
readDir(fullPath);
} else if (entry.isFile() && entry.name.endsWith(extension)) {
const content = fs.readFileSync(fullPath, 'utf8');
// 👉 NEU: fallback wenn keine patterns
if (!patterns || patterns.length === 0) {
results.push({
file: entry.name,
fullPath
});
continue;
}
for (const { name, pattern, mapFn } of patterns) {
let match;
while ((match = pattern.exec(content)) !== null) {
const mapped = mapFn
? mapFn(match, entry.name, fullPath, name)
: { file: entry.name, type: name, match: match[0] };
results.push(mapped);
}
}
}
}
}
// Run through multiple paths
for (const dir of paths) {
if (fs.existsSync(dir)) {
readDir(path.resolve(dir));
} else {
console.warn(`Ordner nicht gefunden: ${dir}`);
}
}
return results;
}
// JSON-Datei laden
loadJSON(path) {
try {
const rawData = fs.readFileSync(path, 'utf8');
return JSON.parse(rawData);
} catch (err) {
console.log(err)
return err
}
}
// Check file-/path
exists(path) {
try {
const info = fs.statSync(path);
if (!info.isFile() && !info.isDirectory()) return { status: false, levelId: 4, message: `${info.isFile() ? 'Datei' : 'Pfad'} ${path} existiert nicht` };
return { status: true, levelId: 0, message: `${info.isFile() ? 'Datei' : 'Pfad'} existiert: ${path}` };
} catch (err) {
return ;
}
}
}
module.exports = FileSystemManager;

377
src/services/hotReload.js Normal file
View File

@@ -0,0 +1,377 @@
// hotReloadJson.js
const fs = require('fs');
const path = require('path');
const { watch } = require('chokidar');
module.exports = {
File: class {
constructor(filePath, options = {}) {
if (!filePath || typeof filePath !== 'string') {
return { levelId: 4, pluginName: null, message: 'filePath muss ein gültiger String sein' };
}
this.filePath = path.resolve(filePath);
this.fileType = options.fileType || "json"; // "json" oder "js"
this.historyLimit = options.historyLimit || 50;
this.autoCreate = options.autoCreate || false;
this.autoSaveJs = options.autoSaveJs || false; // neu: JS-Dateien optional speichern
this.callbacks = [];
this.history = [];
this.redoStack = [];
if (!fs.existsSync(this.filePath)) {
if(this.autoCreate) {
fs.writeFileSync(this.filePath, this.fileType === 'json' ? '{ }' : 'module.exports = {}');
} else {
return { levelId: 4, pluginName: null, message: `Datei existiert nicht: ${this.filePath}` };
}
}
const initialData = this.loadFile();
if (!initialData) {
this.data = {};
return { levelId: 4, pluginName: null, message: `Fehler beim Laden der Datei: ${this.filePath}` };
} else {
this.data = initialData;
}
this.proxy = this.createProxy(this.data);
this.pushHistory(null, this.deepCopy(this.data));
this.watcher = watch(this.filePath, { ignoreInitial: true });
this.watcher.on('change', () => this.reload());
}
loadJS() {
try {
delete require.cache[require.resolve(this.filePath)];
const module = require(this.filePath);
if (typeof module !== "object") {
console.warn("[LiveJSON] JS-Datei exportiert kein Objekt:", this.filePath);
return {};
}
return this.deepCopy(module);
} catch (err) {
console.error(`[LiveJSON] Fehler beim Laden der JS-Datei ${this.filePath}:`, err.message);
return null;
}
}
loadJSON() {
try {
const raw = fs.readFileSync(this.filePath, 'utf-8');
return JSON.parse(raw);
} catch (err) {
console.error(`[LiveJSON] Fehler beim Laden von ${this.filePath}:`, err.message);
return null;
}
}
loadFile() {
if (this.fileType === "js") return this.loadJS();
return this.loadJSON();
}
saveToFile() {
try {
if (this.fileType === 'json') {
fs.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2), 'utf-8');
} else if (this.fileType === 'js' && this.autoSaveJs) {
const content = 'module.exports = ' + JSON.stringify(this.data, null, 2);
fs.writeFileSync(this.filePath, content, 'utf-8');
}
return { levelId: 0, pluginName: null, message: 'Änderungen erfolgreich gespeichert' };
} catch (err) {
return { levelId: 4, pluginName: null, message: `Fehler beim Schreiben in Datei: ${err.message}` };
}
}
createProxy(obj) {
const self = this;
return new Proxy(obj, {
get(target, prop) {
const value = target[prop];
if (value && typeof value === 'object') {
return self.createProxy(value);
}
return value;
},
set(target, prop, value) {
const oldValue = self.deepCopy(target);
const result = Reflect.set(target, prop, value);
const newValue = self.deepCopy(target);
self.pushHistory(oldValue, newValue);
self.saveToFile();
self.triggerCallbacks(oldValue, newValue);
return result;
},
deleteProperty(target, prop) {
const oldValue = self.deepCopy(target);
const result = Reflect.deleteProperty(target, prop);
const newValue = self.deepCopy(target);
self.pushHistory(oldValue, newValue);
self.saveToFile();
self.triggerCallbacks(oldValue, newValue);
return result;
}
});
}
reload() {
setTimeout(() => {
const oldData = this.deepCopy(this.data);
const newData = this.loadFile();
if (newData) {
this.mergeObjects(this.data, newData);
this.pushHistory(oldData, this.deepCopy(this.data));
// Trigger Callback jetzt mit absPath
this.triggerCallbacks(oldData, this.data, this.filePath);
}
}, 100);
return { levelId: 0, message: 'Reload initiiert' };
}
deepCopy(obj) {
return JSON.parse(JSON.stringify(obj));
}
pushHistory(oldState, newState) {
const entry = {
timestamp: new Date().toISOString(),
oldState,
newState
};
this.history.push(entry);
if (this.history.length > this.historyLimit) this.history.shift();
this.redoStack = [];
}
undo() {
if (this.history.length < 2) return { levelId: 4, message: 'Nichts zum Rückgängigmachen' };
const last = this.history.pop();
this.redoStack.push(last);
const prev = this.history[this.history.length - 1];
this.mergeObjects(this.data, prev.newState, true);
this.saveToFile();
this.triggerCallbacks(last.newState, prev.newState);
return { levelId: 0, message: 'Undo erfolgreich', state: prev.newState };
}
redo() {
if (this.redoStack.length === 0) return { levelId: 4, message: 'Nichts zum Wiederherstellen' };
const next = this.redoStack.pop();
this.history.push(next);
this.mergeObjects(this.data, next.newState, true);
this.saveToFile();
this.triggerCallbacks(next.oldState, next.newState);
return { levelId: 0, message: 'Redo erfolgreich', state: next.newState };
}
// Rekursive Diff inkl. Arrays
diff(oldObj, newObj) {
if (Array.isArray(oldObj) && Array.isArray(newObj)) return this.diffArray(oldObj, newObj);
if (this.isObject(oldObj) && this.isObject(newObj)) {
const changes = {};
const allKeys = new Set([...Object.keys(oldObj || {}), ...Object.keys(newObj || {})]);
allKeys.forEach(key => {
const oldVal = oldObj[key];
const newVal = newObj[key];
if (this.isObject(oldVal) && this.isObject(newVal)) {
const childDiff = this.diff(oldVal, newVal);
if (Object.keys(childDiff).length > 0) changes[key] = childDiff;
} else if (Array.isArray(oldVal) && Array.isArray(newVal)) {
const arrayDiff = this.diffArray(oldVal, newVal);
if (arrayDiff.length > 0) changes[key] = arrayDiff;
} else if (oldVal !== newVal) {
changes[key] = { old: oldVal, new: newVal };
}
});
return changes;
}
return oldObj !== newObj ? { old: oldObj, new: newObj } : {};
}
// Diff für Arrays auf Elementebene
diffArray(oldArr, newArr) {
const maxLen = Math.max(oldArr.length, newArr.length);
const changes = [];
for (let i = 0; i < maxLen; i++) {
if (i >= oldArr.length) {
changes.push({ index: i, old: undefined, new: newArr[i] });
} else if (i >= newArr.length) {
changes.push({ index: i, old: oldArr[i], new: undefined });
} else if (JSON.stringify(oldArr[i]) !== JSON.stringify(newArr[i])) {
changes.push({ index: i, old: oldArr[i], new: newArr[i] });
}
}
return changes;
}
isObject(value) {
return value && typeof value === 'object' && !Array.isArray(value);
}
// Merge für Objekte und Arrays
mergeObjects(target, source, removeExtra = false) {
if (Array.isArray(target) && Array.isArray(source)) {
target.length = 0;
source.forEach((el) => target.push(this.deepCopy(el)));
return;
}
Object.keys(target).forEach(key => {
if (source[key] === undefined && removeExtra) delete target[key];
});
Object.keys(source).forEach(key => {
if (this.isObject(source[key])) {
if (!target[key] || !this.isObject(target[key])) target[key] = {};
this.mergeObjects(target[key], source[key], removeExtra);
} else if (Array.isArray(source[key])) {
target[key] = [];
this.mergeObjects(target[key], source[key], removeExtra);
} else {
target[key] = source[key];
}
});
}
onChange(cb) {
if (typeof cb === 'function') this.callbacks.push(cb);
}
triggerCallbacks(oldState, newState, absPath = null) {
const delta = this.diff(oldState, newState);
this.callbacks.forEach(cb => cb({ oldState, newState, delta, path: absPath }));
}
get live() {
return this.proxy;
}
close() {
this.watcher.close();
return { levelId: 0, pluginName: null, message: 'Watcher gestoppt' };
}
},
Folder: class {
constructor(folderPath, options = {}) {
if (!folderPath || typeof folderPath !== 'string') {
return {
levelId: 4,
pluginName: null,
message: "folderPath muss ein gültiger String sein"
};
}
this.folderPath = path.resolve(folderPath);
this.files = {}; // relPath -> File-Instanz
this.liveProxies = {}; // relPath -> Proxy für live Zugriff
this.options = {
ignoreInitial: false,
persistent: true,
ignored: options.ignored ?? null,
};
// Callbacks
this.handlers = {
onAdd: options.onAdd || (() => {}),
onChange: options.onChange || (() => {}),
onUnlink: options.onUnlink || (() => {}),
onAddDir: options.onAddDir || (() => {}),
onUnlinkDir: options.onUnlinkDir || (() => {}),
onReady: options.onReady || (() => {}),
onError: options.onError || (() => {}),
};
try {
this.watcher = watch(this.folderPath, this.options);
this.bindEvents();
return { levelId: 0, pluginName: null, message: "SmartFolderWatcher mit History erfolgreich initialisiert" };
} catch (err) {
return { levelId: 4, pluginName: null, message: `Watcher konnte nicht gestartet werden: ${err.message}` };
}
}
// Datei initial laden / als File-Instanz tracken
loadTrackedFile(absPath) {
const rel = path.relative(this.folderPath, absPath);
const ext = path.extname(absPath).toLowerCase();
const fileType = ext === '.js' ? 'js' : 'json';
// File-Instanz erstellen, falls nicht existiert
if (!this.files[rel]) {
const { File } = module.exports;
const fileInstance = new File(absPath, { fileType, autoCreate: true, autoSaveJs: true });
this.files[rel] = fileInstance;
this.liveProxies[rel] = fileInstance.live;
} else {
// reload falls schon bekannt
this.files[rel].reload();
}
return this.files[rel];
}
// Chokidar Events
bindEvents() {
this.watcher
.on("add", (file) => {
const rel = path.relative(this.folderPath, file);
const fileInstance = this.loadTrackedFile(file);
this.handlers.onAdd(rel, fileInstance.data, file); // rel, Inhalt, absPath
})
.on("change", (file) => {
const rel = path.relative(this.folderPath, file);
const fileInstance = this.loadTrackedFile(file);
this.handlers.onChange(rel, fileInstance.data, file);
})
.on("unlink", (file) => {
const rel = path.relative(this.folderPath, file);
delete this.liveProxies[rel];
delete this.files[rel];
this.handlers.onUnlink(rel, file);
})
.on("addDir", (dir) => {
const rel = path.relative(this.folderPath, dir);
this.handlers.onAddDir(rel, dir);
})
.on("unlinkDir", (dir) => {
const rel = path.relative(this.folderPath, dir);
this.handlers.onUnlinkDir(rel, dir);
})
.on("ready", () => {
this.handlers.onReady(this.liveProxies);
})
.on("error", (err) => {
this.handlers.onError(err);
});
}
// Zugriff auf alle live Proxies
get live() {
return this.liveProxies;
}
// Undo / Redo auf Dateiebene
undoFile(relPath) {
if (!this.files[relPath]) return { levelId: 4, message: 'Datei nicht gefunden' };
return this.files[relPath].undo();
}
redoFile(relPath) {
if (!this.files[relPath]) return { levelId: 4, message: 'Datei nicht gefunden' };
return this.files[relPath].redo();
}
close() {
this.watcher.close();
// close alle File-Watcher
Object.values(this.files).forEach(f => f.close());
return { levelId: 0, pluginName: null, message: "FolderWatcher gestoppt" };
}
}
};

View File

@@ -0,0 +1,302 @@
const { Op } = require('sequelize');
class IdentityManager {
constructor(adManager, AuthenticationModel) {
this.ad = adManager || null;
this.Authentication = AuthenticationModel;
}
/**
* -----------------------------------------------------
* REQUIRED FIELDS (MANUAL USER)
* -----------------------------------------------------
*/
REQUIRED_FIELDS = [
'sAMAccountName',
'mail',
'givenName',
'sn',
'password'
];
/**
* -----------------------------------------------------
* VALIDATE MANUAL USER
* -----------------------------------------------------
*/
validateManualUser(user) {
const missing = [];
for (const field of this.REQUIRED_FIELDS) {
if (
user[field] === undefined ||
user[field] === null ||
user[field] === ''
) {
missing.push(field);
}
}
if (missing.length) {
throw new Error(
`Fehlende Pflichtfelder: ${missing.join(', ')}`
);
}
// 🔍 Optional: einfache Zusatzvalidierungen
if (user.mail && !user.mail.includes('@')) {
throw new Error('Ungültige E-Mail-Adresse');
}
if (user.password && user.password.length < 6) {
throw new Error('Passwort muss mindestens 6 Zeichen lang sein');
}
}
/**
* -----------------------------------------------------
* FIXED MAPPING (AD → Authentication)
* -----------------------------------------------------
*/
mapAdObject(obj) {
if (!obj || !obj.objectGUID) return null;
return {
ObjectGUID: obj.objectGUID,
sAMAccountName: obj.sAMAccountName || obj.cn || null,
mail: obj.mail || null,
givenName: obj.givenName || null,
sn: obj.sn || null,
employeeID: obj.employeeID || null,
title: obj.title || null,
department: obj.department || null,
streetAddress: obj.streetAddress || null,
userAccountControl_ID: obj.userAccountControl || null,
authenticationType_ID: 1,
telephoneNumber: obj.telephoneNumber || null,
physicalDeliveryOfficeName: obj.physicalDeliveryOfficeName || null,
distinguishedName: obj.dn || null,
password: null,
refreshtoken: null,
active: true,
online: false
};
}
/**
* -----------------------------------------------------
* DEDUP (wie SQL UNION)
* -----------------------------------------------------
*/
deduplicateByGUID(items) {
const map = new Map();
for (const item of items) {
if (!item?.ObjectGUID) continue;
map.set(item.ObjectGUID, item);
}
return Array.from(map.values());
}
/**
* -----------------------------------------------------
* TABLE CHECK / CREATE
* -----------------------------------------------------
*/
async ensureTable() {
const qi = this.Authentication.sequelize.getQueryInterface();
const tables = await qi.showAllTables();
const exists = tables.includes('Authentication');
if (!exists) {
await this.Authentication.sync();
return false;
}
return true;
}
/**
* -----------------------------------------------------
* CORE SYNC (INTELLIGENT)
* -----------------------------------------------------
*/
async syncFromAD() {
if (!this.ad) {
throw new Error('AD nicht konfiguriert');
}
const [users, groups, computers] = await Promise.all([
this.ad.findUsers('*'),
this.ad.findGroups('*'),
this.ad.getComputers()
]);
const mapped = this.deduplicateByGUID([
...users.map(u => this.mapAdObject(u)),
...groups.map(g => this.mapAdObject(g)),
...computers.map(c => this.mapAdObject(c))
].filter(Boolean));
if (!mapped.length) {
return { total: 0, deactivated: 0 };
}
await this.Authentication.bulkCreate(mapped, {
updateOnDuplicate: [
'mail',
'givenName',
'sn',
'employeeID',
'title',
'department',
'streetAddress',
'userAccountControl_ID',
'telephoneNumber',
'physicalDeliveryOfficeName',
'distinguishedName',
'active'
]
});
const existing = await this.Authentication.findAll({
where: { authenticationType_ID: 1 },
attributes: ['ObjectGUID']
});
const adGuids = new Set(mapped.map(u => u.ObjectGUID));
const toDeactivate = existing
.filter(e => !adGuids.has(e.ObjectGUID))
.map(e => e.ObjectGUID);
if (toDeactivate.length) {
await this.Authentication.update(
{ active: false },
{
where: {
ObjectGUID: toDeactivate
}
}
);
}
return {
total: mapped.length,
deactivated: toDeactivate.length,
adGuids: Array.from(adGuids)
};
}
/**
* -----------------------------------------------------
* OPTIONAL: HARD DELETE
* -----------------------------------------------------
*/
async removeDeletedADObjects(adGuids) {
return this.Authentication.destroy({
where: {
authenticationType_ID: 1,
ObjectGUID: {
[Op.notIn]: adGuids
}
}
});
}
/**
* -----------------------------------------------------
* RECREATE
* -----------------------------------------------------
*/
async recreateAuthentications(hardReset = false) {
let message = '';
const exists = await this.ensureTable();
if (!exists) {
message = 'Tabelle wurde neu erstellt ';
}
try {
const result = await this.syncFromAD();
message += `Sync abgeschlossen (${result.total} Objekte)`;
if (result.deactivated) {
message += `, ${result.deactivated} deaktiviert`;
}
if (hardReset) {
const deleted = await this.removeDeletedADObjects(result.adGuids);
message += `, ${deleted} gelöscht`;
}
} catch (err) {
message += 'Fehler: ' + err.message;
}
return message;
}
/**
* -----------------------------------------------------
* MANUAL USER (MIT VALIDATION)
* -----------------------------------------------------
*/
async createManualUser(user) {
this.validateManualUser(user);
return this.Authentication.create({
...user,
authenticationType_ID: 2,
active: true,
online: false
});
}
/**
* -----------------------------------------------------
* MANUAL BULK (MIT VALIDATION)
* -----------------------------------------------------
*/
async createManualUsers(users) {
const errors = [];
users.forEach((user, index) => {
try {
this.validateManualUser(user);
} catch (err) {
errors.push(`User ${index}: ${err.message}`);
}
});
if (errors.length) {
throw new Error(errors.join(' | '));
}
return this.Authentication.bulkCreate(
users.map(user => ({
...user,
authenticationType_ID: 2,
active: true,
online: false
}))
);
}
/**
* -----------------------------------------------------
* GET USER
* -----------------------------------------------------
*/
async getUser(username) {
return this.Authentication.findOne({
where: { sAMAccountName: username }
});
}
}
module.exports = IdentityManager;

View File

@@ -0,0 +1,137 @@
class notifyTrayManager {
/**
* @param {object} models - { UserNotificationObjects, UserNotifications }
*/
constructor(notifyTrayModel, notifyTrayView, notifyTrayObject) {
this.objects = notifyTrayObject;
this.view = notifyTrayView;
this.notifications = notifyTrayModel;
}
//--------------------------------------------
// 1. Notification Object erstellen oder updaten
//--------------------------------------------
async upsertObject({ id = null, pluginName, message, json, actionRequired = false, expiresAt = null }) {
if (id) {
// ID existiert → upsert
return this.objects.upsert({
ID: id,
PluginName: pluginName,
Message: message,
JSON: json ? JSON.stringify(json) : null,
ActionRequired: actionRequired,
CreatedAt: new Date(),
ExpiresAt: expiresAt
});
} else {
// ID null → neue Zeile einfügen, Auto-Increment nutzen
const obj = await this.objects.create({
PluginName: pluginName,
Message: message,
JSON: json ? JSON.stringify(json) : null,
ActionRequired: actionRequired,
CreatedAt: new Date(),
ExpiresAt: expiresAt
});
// zurückgeben inklusive der generierten ID
return obj;
}
}
//--------------------------------------------
// 2. User benachrichtigen (insert)
//--------------------------------------------
async notifyUsers({ objectId, objectGuids = [] }) {
if (!objectGuids.length) return;
const notificationsToCreate = objectGuids.map(guid => ({
ObjectGUID: guid,
NotifyTrayObject_ID: objectId,
}));
return this.notifications.bulkCreate(notificationsToCreate);
}
//--------------------------------------------
// 3. Offene Notifications abfragen
//--------------------------------------------
async getOpenNotifications(objectGuid) {
return await this.view.findAll({
where: {
ObjectGUID: objectGuid,
SeenAt: null
},
// include: [{
// model: this.objects,
// as: 'NotificationObject',
// required: true, // join zwingend
// where: {
// [this.objects.sequelize.Op.Or]: [
// { ExpiresAt: null },
// { ExpiresAt: { [this.objects.sequelize.Op.gt]: new Date() } }
// ]
// }
// }],
order: [[ 'SeenAt', 'ASC']]
});
}
//--------------------------------------------
// 4. Einzelne Notification als gesehen markieren
//--------------------------------------------
async markAsSeen(objectGuid, notificationId, value) {
return this.notifications.update(
{ SeenAt: value ? new Date() : null },
{ where: { ID: notificationId, ObjectGUID: objectGuid } }
);
}
//--------------------------------------------
// 5. Alle Notifications eines Users als gesehen markieren
//--------------------------------------------
async markAllAsSeen(objectGuid, value) {
return this.notifications.update(
{ SeenAt: value ? new Date() : null },
{ where: { ObjectGUID: objectGuid, SeenAt: null } }
);
}
//--------------------------------------------
// 6. Komplett-Flow: Object upserten + User benachrichtigen
//--------------------------------------------
async createAndNotify({ objectId = null, pluginName, json, actionRequired, message, objectGuids, expiresAt }) {
if(objectGuids === null || !objectGuids.length) {
throw 'There is no user to notify for tray';
}
let obj;
if (objectId) {
obj = await this.upsertObject({
id: objectId,
pluginName,
message,
json,
actionRequired,
expiresAt: Date.now()
});
} else {
obj = await this.upsertObject({
id: null,
pluginName,
message,
json,
actionRequired,
expiresAt: Date.now()
});
}
// Auto-Increment ID korrekt für Notifications nutzen
const finalObjectId = obj.ID || obj.id; // Sequelize gibt je nach DB dialect mal 'ID' oder 'id'
return await this.notifyUsers({
objectId: finalObjectId,
objectGuids
});
}
}
module.exports = notifyTrayManager;

View File

@@ -0,0 +1,497 @@
const fs = require('fs');
const path = require('path');
const fse = require('fs-extra');
const expressHandlebars = require('express-handlebars');
const { execSync } = require('child_process');
const { File: HotReload } = require(`@services/hotReload.js`);
class PluginManager {
constructor(app, pluginModel, pluginBasePath, filePermissions, service) {
this.app = app;
this.Plugin = pluginModel;
this.pluginBasePath = pluginBasePath;
this.filePermissions = filePermissions;
this.service = service;
this.plugins = new Map();
// FIX: metadata jetzt Map (aber Struktur bleibt gleich)
this.metadata = new Map();
this.hbsInstance = expressHandlebars.create({
extname: '.hbs',
defaultLayout: 'main',
});
if(!fs.existsSync(this.pluginBasePath)) {
fs.mkdirSync(this.pluginBasePath)
}
}
async loadAll() {
if (!fs.existsSync(this.pluginBasePath)) {
return [{ levelId: 4, message: `Plugin-Pfad existiert nicht: ${this.pluginBasePath}` }];
}
const pluginDirs = fs.readdirSync(this.pluginBasePath)
.filter(f => fs.lstatSync(path.join(this.pluginBasePath, f)).isDirectory());
const loadPromises = pluginDirs.map(dir => this.load(dir));
return await Promise.all(loadPromises);
}
async load(name, withActivate = false) {
const pluginPath = path.join(this.pluginBasePath, name);
const metadataPath = path.join(pluginPath, 'plugin.json');
const Plugin = require(pluginPath);
const instance = new Plugin(pluginPath, this.app, this.service);
let result;
try {
// FIX: HotReload pro Plugin korrekt speichern
const meta = new HotReload(metadataPath, {
historyLimit: 50,
autoCreate: true
});
this.metadata.set(name, meta);
if (Object.keys(meta.live).length === 0) {
meta.live = this.__pluginTemplate(name);
meta.saveToFile();
this.__setPermissions(pluginPath);
}
// Routes
const routesPath = path.join(pluginPath, 'routes.js');
if (fs.existsSync(routesPath)) {
const routes = require(routesPath);
routes(this.app, pluginPath, meta.live, this.service);
}
// Sockets
const socketsPath = path.join(pluginPath, 'sockets.js');
if (fs.existsSync(socketsPath)) {
const sockets = require(socketsPath);
sockets(this.app, pluginPath, meta.live, this.service);
}
this.app.use(`/${name}`, require('express').static(path.join(pluginPath, 'public')));
const viewsDir = path.join(pluginPath, 'views');
this.app.set("views", [
...(Array.isArray(this.app.get("views")) ? this.app.get("views") : [this.app.get("views")]),
path.join(this.pluginBasePath)
]);
this.registerPartialsRecursive(viewsDir, `plugins/${name}`);
await this.Plugin.upsert({
Name: name,
Active: withActivate ? true : (await this.Plugin.findOne({ where: { Name: meta.live.name }})).Active,
Version: meta.live.version
});
const static_meta = this.service.get('fileSystemManager').loadJSON(metadataPath);
this.plugins.set(name, {
...static_meta,
pluginPath,
viewPath: viewsDir,
metadataPath
});
meta.live.active = withActivate ? true : (await this.Plugin.findOne({ where: { Name: meta.live.name }})).Active;
result = {
status: 'load',
pluginName: name,
metadata: meta.live,
levelId: 0,
message: `Plugin ${name} geladen`
};
return result;
} catch (err) {
console.log(err)
return {
status: 'load',
pluginName: name,
levelId: 4,
message: [err.message, err]
};
}
}
async unload(name) {
let result;
const pluginPath = path.join(this.pluginBasePath, name);
const metadataPath = path.join(pluginPath, 'plugin.json');
try {
if (!this.plugins.has(name)) {
return {
status: 'unload',
pluginName: name,
levelId: 4,
message: 'Plugin nicht vorhanden'
};
}
// FIX: korrektes metadata handling
const meta = this.metadata.get(name);
if (meta) {
meta.live.active = false;
meta.saveToFile();
}
await this.Plugin.update({ Active: false }, { where: { Name: name } });
result = {
status: 'unload',
pluginName: name,
levelId: 0,
message: `Plugin ${name} entladen`
};
return result;
} catch (err) {
return {
status: 'unload',
pluginName: name,
levelId: 4,
message: [err.message, err]
};
}
}
async reload(name) {
const unloadResult = await this.unload(name);
if (unloadResult.levelId === 4) return unloadResult;
delete require.cache[require.resolve(path.join(this.pluginBasePath, name))];
return await this.load(name);
}
async delete(name) {
const pluginPath = path.join(this.pluginBasePath, name);
try {
await this.unload(name);
this.plugins.delete(name);
await this.Plugin.destroy({ where: { Name: name } });
if (fs.existsSync(pluginPath)) {
await fse.remove(pluginPath);
}
return {
status: 'delete',
pluginName: name,
levelId: 0,
message: `Plugin ${name} gelöscht`
};
} catch (err) {
return {
status: 'delete',
pluginName: name,
levelId: 4,
message: [err.message, err]
};
}
}
async create(name, options = {}) {
const pluginPath = path.join(this.pluginBasePath, name);
if (fs.existsSync(pluginPath)) {
return { levelId: 4, pluginName: name, message: `Plugin existiert bereits` };
}
try {
const folders = [
'views',
'views/children',
'public/javascript',
'public/styles',
'public/helpers',
'public/images',
'public/others',
'docs'
];
for (const folder of folders) {
await fse.ensureDir(path.join(pluginPath, folder));
}
const files = {
'plugin.json': this.__pluginTemplate(name),
'index.js':
`module.exports = class Plugin {
constructor(pluginPath, app, service) {
this.pluginPath = pluginPath;
this.app = app;
this.service = service;
}
}`,
'routes.js':
`module.exports = async (app, pluginPath, metadata, service) => {};`,
'sockets.js':
`module.exports = (app, socketManager, pluginPath, metadata, eventManager) => {};`,
'docs/help.html':
`<h1>Hilfedatei für ${name}</h1><p>${options.description || 'Beschreibung hier einfügen'}</p>`,
'views/index.hbs':
`<div>{{plugin.name}}</div>`
};
for (const [file, content] of Object.entries(files)) {
await fse.outputFile(path.join(pluginPath, file), content);
}
this.__setPermissions(pluginPath);
await this.Plugin.upsert({
Name: name,
Active: false,
Version: options.version || '1.0.0'
});
return {
status: 'create',
levelId: 0,
pluginName: name,
message: `Plugin ${name} erstellt`
};
} catch (err) {
return {
status: 'create',
levelId: 4,
pluginName: name,
message: err.message
};
}
}
__pluginTemplate(name, options = { }) {
return {
name,
description: options.description || 'Beschreibung hier einfügen',
version: options.version || `1.${new Date().getFullYear().toString().slice(-2)}.${new Date().getMonth() + 1}.${new Date().getDate()}`,
menu: {
label: name,
items:[
{
label: name,
view: "index",
defaultSize: { width: '800px', height: '600px' },
icon: "../../images/app.png",
permissions: ["*"]
}
]
},
config: options.config || {},
active: true
}
}
__setPermissions(pluginPath) {
if (!this.filePermissions.user && !this.filePermissions.group) return;
try {
let uid, gid;
if (this.filePermissions.user) {
uid = parseInt(execSync(`id -u ${this.filePermissions.user}`).toString().trim());
}
if (this.filePermissions.group) {
gid = parseInt(execSync(`getent group ${this.filePermissions.group} | cut -d: -f3`).toString().trim());
}
const apply = dir => {
const entries = fs.readdirSync(dir);
for (const entry of entries) {
const full = path.join(dir, entry);
const stat = fs.lstatSync(full);
fs.chownSync(full, uid, gid);
if (stat.isDirectory()) apply(full);
}
};
apply(pluginPath);
} catch (err) {
throw new Error(err.message);
}
}
update(name, updates = {}) {
const meta = this.metadata.get(name);
if (!meta) {
return { status: 'update', levelId: 4, message: 'Plugin nicht geladen' };
}
Object.assign(meta.live, updates);
meta.saveToFile?.();
return {
status: 'update',
pluginName: name,
levelId: 0,
metadata: meta.live,
message: `Plugin aktualisiert ${Object.keys(updates)[0]}: ${Object.values(updates)[0]}`
};
}
async rename(oldName, newName) {
const oldPath = path.join(this.pluginBasePath, oldName);
const newPath = path.join(this.pluginBasePath, newName);
const newMetadataPath = path.join(newPath, 'plugin.json');
const routesPath = path.join(newPath, 'routes.js');
const updateRoutesFile = async (filePath, oldName, newName) => {
let content = await fse.readFile(filePath, 'utf-8');
// ersetzt /window/OLDNAME/... → /window/NEWNAME/...
const regex = new RegExp(`(app\\.(get|post)\\(['"\`]\\/window\\/)${oldName}(\\/[^'"\`]*['"\`])`, 'g');
content = content.replace(regex, `$1${newName}$3`);
await fse.writeFile(filePath, content, 'utf-8');
}
try {
// 🔹 Checks
if (!this.plugins.has(oldName)) {
return {
status: 'rename',
levelId: 4,
message: 'Plugin nicht gefunden'
};
}
if (fs.existsSync(newPath)) {
return {
status: 'rename',
levelId: 4,
message: 'Neuer Name existiert bereits'
};
}
// 🔹 1. copy
await fse.copy(oldPath, newPath);
// 🔹 2. plugin.json fix
const metaRaw = this.service.get('fileSystemManager').loadJSON(newMetadataPath);
metaRaw.name = newName;
metaRaw.menu.label = newName;
await fse.writeJSON(newMetadataPath, metaRaw, { spaces: 2 });
// 🔹 3. routes fix (falls vorhanden)
if (fs.existsSync(routesPath)) {
await updateRoutesFile(routesPath, oldName, newName);
}
// 🔥 4. ALTES Plugin vorher unloaden
await this.unload(oldName);
// 🔹 5. metadata cleanup
this.metadata.delete(oldName);
// 🔹 6. DB update
await this.Plugin.update(
{ Name: newName },
{ where: { Name: oldName } }
);
// 🔥 7. kurzer delay → verhindert ENOENT durch watcher
await new Promise(res => setTimeout(res, 100));
// 🔹 8. neues Plugin laden
await this.load(newName, metaRaw.active);
// 🔹 9. alten Ordner löschen
await fse.remove(oldPath);
// 🔹 10. altes Plugin löschen
this.plugins.delete(oldName);
return {
status: 'rename',
pluginName: newName,
levelId: 0,
message: `Plugin ${oldName}${newName} umbenannt`
};
} catch (err) {
// 🔹 Rollback: neuen Ordner entfernen
if (fs.existsSync(newPath)) {
await fse.remove(newPath);
}
return {
status: 'rename',
pluginName: oldName,
levelId: 4,
message: [err.message, err]
};
}
}
getStatus() {
return Array.from(this.plugins.entries()).map(([name, plugin]) => {
const meta = this.metadata.get(name);
return {
name,
...plugin,
...(meta ? meta.live : {})
};
});
}
compileHbsTemplate(template) {
return this.hbsInstance.handlebars.compile(template);
}
registerPartialsRecursive(dir, prefix = '') {
if (!fs.existsSync(dir)) return;
const files = fs.readdirSync(dir);
for (const file of files) {
const fullPath = path.join(dir, file);
const stat = fs.lstatSync(fullPath);
if (stat.isDirectory()) {
this.registerPartialsRecursive(fullPath, path.join(prefix, file));
} else if (file.endsWith('.hbs')) {
const name = path.join(prefix, path.basename(file, '.hbs')).replace(/\\/g, '/');
const template = fs.readFileSync(fullPath, 'utf8');
this.hbsInstance.handlebars.registerPartial(name, template);
}
}
}
}
module.exports = PluginManager;

View File

@@ -0,0 +1,85 @@
const { service } = require('@root/server.js');
module.exports = {
renderWindow: async function(app, view, data = {}, extraData = {}, res) {
const name = res.req.body.name;
const label = res.req.body.viewLabel;
try {
const plugin = app.locals.startMenuItems.find(plugin => plugin.name == name);
const windowData = plugin.menu.items.find(item => item.label == label);
// Alle Daten zusammenführen
const templateData = {
...{ appname: plugin.menu.label, label: label, section: plugin.section },
...windowData,
// ...data,
...extraData
};
// console.log(templateData)
// Zuerst Plugin-View rendern
app.render(view, templateData, (err, contentHtml) => {
if (err) {
service.get('eventManager').write(res.req.cookies.ObjectGUID, 4, name, err);
return res.status(500).send(err.message);
}
// Dann Window-Partial rendern
app.render('partials/window', {
layout: false,
contentHtml,
...templateData
}, (err, html) => {
if (err) {
service.get('eventManager').write(res.req.cookies.ObjectGUID, 4, name, err);
return res.status(500).send(err.message);
}
res.status(200).send(html);
});
});
} catch (err) {
service.get('eventManager').write(res.req.cookies.ObjectGUID, 4, name, err);
res.status(500).send(err.message);
}
},
renderView: async function(app, view, data = {}, res) {
const payload = res.req.body;
try {
// console.log(app.locals.startMenuItems.find(name == ).menu.items.find(item => ))
// Alle Daten zusammenführen
const templateData = {
...payload,
...{ appname: payload.name, section: 'view' },
...{ name: payload.name, view: payload.view, viewLabel: payload.viewLabel},
...data,
};
console.log(templateData)
// Zuerst Plugin-View rendern
app.render(view, templateData, (err, contentHtml) => {
if (err) {
service.get('eventManager').write(res.req.cookies.ObjectGUID, 4, payload.name, err);
return res.status(500).send(err.message);
}
// Dann Window-Partial rendern
app.render('partials/child', {
layout: false,
contentHtml,
...templateData
}, (err, html) => {
if (err) {
service.get('eventManager').write(res.req.cookies.ObjectGUID, 4, payload.name, err);
return res.status(500).send(err.message);
}
res.status(200).send(html);
});
});
} catch (err) {
service.get('eventManager').write(res.req.cookies.ObjectGUID, 4, payload.name, err);
res.status(500).send(err.message);
}
}
};

View File

@@ -0,0 +1,336 @@
class SocketManager {
constructor(io) {
this.io = io;
this.namespaces = new Map();
this.clients = new Map();
this.io.on('connection', socket => {});
}
add(namespace, exists) {
if (this.namespaces.has(namespace)) return this.namespaces.get(namespace);
const nsp = this.io.of(namespace);
const clients = new Map();
this.clients.set(namespace, clients);
nsp.on('connection', socket => {
const { objectGuid, sAMAccountName } = socket.handshake.auth || {};
if (!objectGuid) return;
clients.set(objectGuid, {
socket,
userName: sAMAccountName
});
// console.log(`${sAMAccountName} [${objectGuid}] connected to ${namespace}`);
socket.on('disconnect', () => {
clients.delete(objectGuid);
// console.log(`${sAMAccountName} [${objectGuid}] disconnected from ${namespace}`);
});
});
this.namespaces.set(namespace, nsp);
return nsp;
}
async addAsync(namespace, exists) {
return new Promise(resolve => {
this.add(namespace, exists)
// simulierter async Init (z. B. DB)
setTimeout(resolve, 0);
});
}
/**
* Broadcast in namespace
* @param {string} namespace - Namespace-Name
* @param {string} event - Event-Name
* @param {any} data - Event-Data
*/
broadcast(namespace, event, data) {
const nsp = this.namespaces.get(namespace);
if (nsp) nsp.emit(event, data);
}
/**
* Event-Handler für einen Namespace registrieren
* @param {string} namespace - Namespace-Name
* @param {string} event - Event-Name
* @param {Function} callback - Callback mit (socket, data)
*/
// on(namespace, event, callback) {
// const nsp = this.namespaces.get(namespace);
// if (!nsp) {
// console.warn(`Namespace ${namespace} doesn't exist`);
// return;
// }
// nsp.on('connection', (socket) => {
// socket.on(event, (data) => {
// // übergibt socket als Kontext, damit sendTo automatisch weiß, wer anfragte
// callback(socket, data, (responseEvent, responseData, targetGuid = null) => {
// this.sendTo(namespace, targetGuid, responseEvent, responseData, socket);
// });
// });
// });
// }
on(namespace, event, callback) {
const nsp = this.namespaces.get(namespace);
if (!nsp) {
console.warn(`Namespace ${namespace} doesn't exist`);
return;
}
nsp.on('connection', (socket) => {
socket.on(event, (data) => callback(socket, data));
});
}
registerClient(namespace, socket) {
const nsp = this.namespaces.get(namespace);
if (!nsp) {
console.warn(`Namespace ${namespace} does not exist`);
return;
}
const auth = socket.handshake.auth;
const objectGuid = auth.objectGuid;
const sAMAccountName = auth.sAMAccountName;
if (!objectGuid) {
console.warn('Socket has no objectGuid, cannot register');
return;
}
socket.customId = objectGuid;
// In Map speichern
this.clients.set(objectGuid, {
userName: sAMAccountName,
socketId: socket.id,
socket: socket
});
console.log(`${sAMAccountName} [${objectGuid}] registered to namespace ${namespace}`);
// Disconnect-Handler
socket.on('disconnect', () => {
this.clients.delete(objectGuid);
console.log(`${sAMAccountName} [${objectGuid}] disconnected from namespace ${namespace}`);
});
};
/**
* Entfernt einen Client aus dem Namespace
* @param {string} objectGuid - GUID des Clients
* @param {string} [namespace] - optional, um Namespace-spezifisch zu loggen
*/
unregisterClient(objectGuid) {
try {
const client = this.clients.get(objectGuid);
if (!client) return;
// Optional: Socket trennen
client.socket.disconnect();
this.clients.delete(objectGuid);
const name = client.userName;
console.log(`${name} [${objectGuid}] manuell entfernt`);
} catch (err) {
console.log(err);
}
}
/**
* Send a personal message to ObjectGUID
* @param {string} namespace - Namespace-Name
* @param {string} objectGuid - Socket-ID
* @param {string} event - Event-Name
* @param {any} data - Event-Data
* @param {object} [senderSocket] - optionaler Absender-Socket
*/
sendTo(namespace, objectGuid, event, data, senderSocket = null) {
const clients = this.clients.get(namespace);
if (!clients) return;
// Antwort an Absender
if (!objectGuid && senderSocket) {
senderSocket.emit(event, data);
return;
}
const client = clients.get(objectGuid);
if (!client?.socket) return;
client.socket.emit(event, data);
}
}
module.exports = SocketManager;
// const WebSocket = require("ws");
// const url = require("url");
// class SocketManager {
// constructor() {
// this.namespaces = new Map(); // "/admin" → { clients:Set, handlers:Map }
// this.clients = new Map(); // objectGuid → { userName, socket }
// // Default-Namespaces wie bei Socket.IO
// this.add("/");
// this.add("/admin");
// }
// /**
// * Namespace erstellen
// */
// add(namespace) {
// if (this.namespaces.has(namespace)) return this.namespaces.get(namespace);
// const ns = {
// clients: new Set(),
// handlers: new Map() // event → callback
// };
// this.namespaces.set(namespace, ns);
// return ns;
// }
// /**
// * Upgrade (HTTP → WebSocket) pro Namespace auswerten
// */
// handleUpgrade(namespace, req, socket, head) {
// if (!this.namespaces.has(namespace)) {
// socket.destroy();
// return;
// }
// const ns = this.namespaces.get(namespace);
// const wss = new WebSocket.Server({ noServer: true });
// wss.handleUpgrade(req, socket, head, ws => {
// this._onConnection(namespace, ws, req);
// });
// }
// /**
// * Verbindung herstellen + authentifizieren
// */
// _onConnection(namespace, socket, req) {
// const params = new URLSearchParams(url.parse(req.url).query);
// const objectGuid = params.get("objectGuid");
// const sAMAccountName = params.get("sAMAccountName") || "Unknown";
// socket.namespace = namespace;
// socket.objectGuid = objectGuid;
// socket.userName = sAMAccountName;
// // Helper zum JSON-Senden
// socket.sendJSON = (obj) => socket.send(JSON.stringify(obj));
// const ns = this.namespaces.get(namespace);
// ns.clients.add(socket);
// if (objectGuid) {
// this.clients.set(objectGuid, { userName: sAMAccountName, socket: socket });
// console.log(`${sAMAccountName} [${objectGuid}] connected to [${namespace}]`);
// }
// // Nachrichten-Handling
// socket.on("message", raw => {
// let msg;
// try { msg = JSON.parse(raw); }
// catch { return; }
// const event = msg.event;
// const data = msg.data;
// const cb = ns.handlers.get(event);
// if (cb) cb(socket, data);
// });
// // Disconnect
// socket.on("close", () => {
// ns.clients.delete(socket);
// if (objectGuid) {
// this.clients.delete(objectGuid);
// console.log(`${sAMAccountName} [${objectGuid}] disconnected from namespace ${namespace}`);
// }
// });
// }
// /**
// * Events registrieren wie socket.on("event")
// */
// on(namespace, event, callback) {
// const ns = this.namespaces.get(namespace);
// if (!ns) return console.warn(`Namespace ${namespace} doesn't exist`);
// ns.handlers.set(event, callback);
// }
// /**
// * Broadcast in Namespace
// */
// broadcast(namespace, event, data) {
// const ns = this.namespaces.get(namespace);
// if (!ns) return;
// for (const client of ns.clients) {
// if (client.readyState === WebSocket.OPEN) {
// client.sendJSON({ event, data });
// }
// }
// }
// /**
// * Persönliche Nachricht an GUID
// */
// sendTo(namespace, objectGuid, event, data, sender = null) {
// if (!objectGuid && sender) {
// sender.sendJSON({ event, data });
// return;
// }
// const client = this.clients.get(objectGuid);
// if (!client) return;
// if (client.socket.readyState === WebSocket.OPEN) {
// client.socket.sendJSON({ event, data });
// }
// }
// /**
// * Client manuell entfernen
// */
// unregisterClient(objectGuid) {
// const client = this.clients.get(objectGuid);
// if (!client) return;
// client.socket.close(4000, "manual kick");
// this.clients.delete(objectGuid);
// console.log(`${client.userName} [${objectGuid}] manually removed`);
// }
// }
// module.exports = SocketManager;

139
src/services/sqlManager.js Normal file
View File

@@ -0,0 +1,139 @@
const Sequelize = require('sequelize');
class SqlManager {
constructor() {
if (SqlManager._instance) return SqlManager._instance;
this.instances = {};
SqlManager._instance = this;
}
/**
* Neue MSSQL Instanz registrieren
*/
addInstance(name, config) {
if (this.instances[name]) {
console.log(`[INFO] Instance "${name}" already exists.`);
return this.instances[name];
}
const sequelize = new Sequelize(config.database, config.user, config.password, {
host: config.host,
dialect: 'mssql',
port: config.port || 1433,
logging: config.logging || false,
pool: {
max: 10,
min: 0,
acquire: 30000,
idle: 10000
},
dialectOptions: {
options: {
encrypt: false,
trustServerCertificate: true
}
}
});
this.instances[name] = { sequelize, config };
return sequelize;
}
/**
* Instanz abrufen
*/
getInstance(name) {
const inst = this.instances[name];
if (!inst) {
throw new Error(`Instance "${name}" not found.`);
}
return inst.sequelize;
}
/**
* Reconnect-Logik (falls Verbindung verloren geht)
*/
async reconnect(name) {
console.log(`[WARN] Reconnecting to ${name}...`);
const old = this.instances[name];
if (!old) throw new Error(`Instance "${name}" not found`);
const { config } = old;
// alte Verbindung killen
try { await old.sequelize.close(); } catch {}
// neu verbinden
const newSequelize = new Sequelize(
config.database,
config.username,
config.password,
{
...old.sequelize.options
}
);
this.instances[name].sequelize = newSequelize;
try {
await newSequelize.authenticate();
console.log(`[OK] Reconnected to ${name}`);
return true;
} catch (err) {
console.log(`[ERROR] Reconnect to ${name} failed:`, err);
return false;
}
}
/**
* Query ausführen (async)
*/
async query(instanceName, sql, options) {
const sequelize = this.getInstance(instanceName);
try {
const [result] = await sequelize.query(sql, options);
return result;
} catch (err) {
console.error(`[ERROR] Query failed on "${instanceName}":`, err.message);
if (err.original && err.original.code === "ECONNCLOSED") {
await this.reconnect(instanceName);
return this.query(instanceName, sql, options);
}
throw err;
}
}
/**
* Pseudo-synchrones Query (Wirkung wie .querySync, intern aber async)
* Du wartest darauf, also fühlt es sich synchron an.
*/
querySync(instanceName, sql, options) {
return this.query(instanceName, sql, options);
}
/**
* Verbindung testen
*/
async test(instanceName) {
const sequelize = this.getInstance(instanceName);
try {
await sequelize.authenticate();
return {
levelId: 0,
message: `${sequelize.config.database} database connection hergestellt`
};
} catch (error) {
return {
levelId: 0,
message: [`Unable to connect to ${sequelize.config.database}`, error]
};
}
}
}
module.exports = SqlManager;

View File

@@ -0,0 +1,14 @@
module.exports = (app, socketManager, namespace, eventManager) => {
const adminSocket = socketManager.namespaces.get(namespace);
// socketManager.on(namespace, 'plugin_status', (socket, data) => {
// eventManger.write(data.objectGuid, data.levelId, data.pluginName, app.locals.configuration.debug, data.message)
// })
socketManager.on(namespace, 'eventlog', (socket, data) => {
eventManager.write(data.objectGuid, data.levelId, data.pluginName, data.message)
});
socketManager.on(namespace, 'heartbeat', () => {
})
}

25
src/sockets/mainSocket.js Normal file
View File

@@ -0,0 +1,25 @@
const path = require('path');
const fs = require('fs');
const startMenuItemContext = require('@models/integratedStartmenuItems.js')
module.exports = (app, socketManager, namespace, pluginManager, authenticationModel, fileSystemManager, eventManager, activeDirectory) => {
const mainSocket = socketManager.namespaces.get(namespace);
socketManager.on(namespace, 'heartbeat', () => setInterval(() => console.log('test'), 1000));
socketManager.on(namespace, 'event', (socket, data) => {
socket.emit('event', { pluginName: data.pluginName, datetime: global.dateFormat(new Date(), 'dd.mm.yyyy HH:MM:SS'), levelId: data.levelId, message: data.message });
// eventManager.write(data.objectGuid, data.levelId, data.pluginName, data.message)
});
// global.json.configuration.onChange(info => {
// console.log(info.delta)
// });
mainSocket.on('connection', socket => {
socket.on('changePartial', ({ partial, data }) => {
socket.emit('updatePartial', { partial, data });
});
})
}

156
utils.js Normal file
View File

@@ -0,0 +1,156 @@
const { exec } = require('child_process');
const path = require('path');
const { permission } = require('process');
const { dirname } = require('path');
const { File: HotReload } = require(`@services/hotReload.js`);
const { service } = require(`@root/server.js`);
let integratedStartmenuItems = require('@models/integratedStartmenuItems');
global.path = {
root: dirname(require.main.filename),
source:`${dirname(require.main.filename)}/src`,
public: `${dirname(require.main.filename)}/public`,
plugins: `${dirname(require.main.filename)}/plugins`
};
global.json = {
releaseNotes: new HotReload(path.join(global.path.source, 'models', 'releasenotes.json')),
configuration: new HotReload(path.join(global.path.source, 'models', 'configuration.json')),
stylesheet: new HotReload(path.join(global.path.source, 'models', 'stylesheet.json')),
indexRoutes: new HotReload(path.join(global.path.source, 'routes', 'indexRoutes.js'), { historyLimit: 50, fileType: 'js' }),
startMenuItems: new HotReload(path.join(global.path.source, 'models', 'integratedStartmenuItems.js'), { historyLimit: 50, fileType: 'js' })
}
module.exports = startMenuItems = async function(app, sAMAccountName) {
function safeClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
delete integratedStartmenuItems;
integratedStartmenuItems = safeClone(json.startMenuItems.live);
const plugins = service
.get('pluginManager')
.getStatus()
.map(({ config, ...plugin }) => ({
...safeClone(plugin),
section: 'Plugin'
}));
let getAllPlugins = [...plugins, ...integratedStartmenuItems];
for (const plugin of getAllPlugins) {
plugin.menu.items = await Promise.all(
(plugin.menu.items || []).map(async item => {
const authorized =
item.label === 'hr' ||
item.permissions.includes('Administration')
? global.json.configuration.live.administration.includes(sAMAccountName)
: item.permissions.includes('*') ||
(
await Promise.all(
item.permissions.map(async permission =>
(await service.get('activeDirectoryManager').getGroup(permission)) &&
(await service.get('activeDirectoryManager').isUserMemberOfRecursive(sAMAccountName, permission))
)
)
).some(Boolean);
return {
...safeClone(item),
authorized
};
})
);
plugin.onlyAdministration =
plugin.menu.items.every(item => !item.authorized) &&
!global.json.configuration.live.administration.includes(sAMAccountName);
}
getAllPlugins = getAllPlugins
.filter(plugin => !plugin.onlyAdministration)
.filter(plugin => plugin.active);
app.locals.startMenuItems = getAllPlugins;
return [...getAllPlugins];
};
/**
* Convert date into custom dateformat
* @param {any} date - Valid date as datetype or string
* @param {string} [format] - (optional) date characters are lowercase, time characters are uppercase; If is null, format will be "dd.mm.yyyy HH:MM:SS"
*/
module.exports = dateFormat = function(date, format = null) {
format = (format == null ? "dd.mm.yyyy HH:MM:SS" : format);
const finish_date = new Date(date);
return format
.replace('yyyy', finish_date.getFullYear())
.replace('yy', finish_date.getFullYear().toString().slice(-2))
.replace('mm', ("0" + (finish_date.getMonth() + 1)).slice(-2))
.replace('dd', ("0" + finish_date.getDate()).slice(-2))
.replace('HH', ("0" + finish_date.getHours()).slice(-2))
.replace('MM', ("0" + finish_date.getMinutes()).slice(-2))
.replace('SS', ("0" + finish_date.getSeconds()).slice(-2));
}
/**
* Limits the number of functions that can be executed in parallel.
*/
// module.exports = createLimiter = function(max) {
// let active = 0;
// const queue = [];
// const runNext = () => {
// if (active >= max || queue.length === 0) return;
// active++;
// const { fn, resolve, reject } = queue.shift();
// fn()
// .then(resolve)
// .catch(reject)
// .finally(() => {
// active--;
// runNext();
// });
// };
// return fn =>
// new Promise((resolve, reject) => {
// queue.push({ fn, resolve, reject });
// runNext();
// });
// }
/**
* try to call an existing host by name or ip adress
* @param {string} hostname - Valid hostname or ip adress
* @param {string} [format] - (optional) date characters are lowercase, time characters are uppercase; If is null, format will be "dd.mm.yyyy HH:MM:SS"
*/
module.exports = ping = function(hostname, cbErr, cb, ttl = 1) {
exec(`ping ${hostname} -c 1 -W ${ttl}`, function (err, stdout, stderr) {
if(err) {
cbErr(err);
} else {
cb(stdout);
}
});
}
module.exports = isObject = function(param) {
return param !== null && typeof param === 'object' && !Array.isArray(param);
}