504 lines
18 KiB
JavaScript
504 lines
18 KiB
JavaScript
(() => {
|
|
|
|
let ALPHABET = [];
|
|
let data = [];
|
|
let editing = new Set();
|
|
let pendingDelete = null;
|
|
|
|
// QR Code generation function using backend
|
|
async function generateQRCode(secret, imgElement) {
|
|
try {
|
|
const response = await fetch(`/admin/generate_qr/${encodeURIComponent(secret)}`);
|
|
const data = await response.json();
|
|
imgElement.src = `data:image/png;base64,${data.qr_code}`;
|
|
} catch (error) {
|
|
console.error('Error generating QR code:', error);
|
|
}
|
|
}
|
|
|
|
// helper to format DD.MM.YYYY → YYYY-MM-DD
|
|
function formatISO(d) {
|
|
const [dd, mm, yyyy] = d.split('.');
|
|
return (dd && mm && yyyy) ? `${yyyy}-${mm}-${dd}` : '';
|
|
}
|
|
|
|
// load from server
|
|
async function loadData() {
|
|
try {
|
|
const res = await fetch('/admin/folder_secret_config_editor/data');
|
|
data = await res.json();
|
|
render();
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
// send delete/update actions
|
|
async function sendAction(payload) {
|
|
await fetch('/admin/folder_secret_config_editor/action', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
await loadData();
|
|
}
|
|
|
|
// delete record after confirmation
|
|
function deleteRec(secret) {
|
|
sendAction({ action: 'delete', secret });
|
|
}
|
|
|
|
// build all cards
|
|
function render() {
|
|
const cont = document.getElementById('records');
|
|
cont.innerHTML = '';
|
|
|
|
data.forEach(rec => {
|
|
const key = rec.secret;
|
|
const isEdit = editing.has(key);
|
|
const expired = (formatISO(rec.validity) && new Date(formatISO(rec.validity)) < new Date());
|
|
const cls = isEdit ? 'unlocked' : 'locked';
|
|
|
|
// outer card
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'card mb-3';
|
|
wrapper.dataset.secret = key;
|
|
|
|
// Card header for collapse toggle
|
|
const cardHeader = document.createElement('div');
|
|
cardHeader.className = 'card-header';
|
|
cardHeader.style.cursor = 'pointer';
|
|
const collapseId = `collapse-${key}`;
|
|
cardHeader.setAttribute('data-bs-toggle', 'collapse');
|
|
cardHeader.setAttribute('data-bs-target', `#${collapseId}`);
|
|
cardHeader.setAttribute('aria-expanded', 'false');
|
|
cardHeader.setAttribute('aria-controls', collapseId);
|
|
|
|
const headerTitle = document.createElement('div');
|
|
headerTitle.className = 'd-flex justify-content-between align-items-center';
|
|
const folderNames = rec.folders.map(f => f.foldername).join(', ');
|
|
headerTitle.innerHTML = `<strong>Ordner: ${folderNames}${expired ? ' <span class="text-danger fw-bold"> ! abgelaufen !</span>' : ''}</strong><i class="bi bi-chevron-down"></i>`;
|
|
cardHeader.appendChild(headerTitle);
|
|
wrapper.appendChild(cardHeader);
|
|
|
|
// Collapsible body
|
|
const collapseDiv = document.createElement('div');
|
|
collapseDiv.className = 'collapse';
|
|
collapseDiv.id = collapseId;
|
|
|
|
const body = document.createElement('div');
|
|
body.className = `card-body ${cls}`;
|
|
|
|
// Create row for two-column layout (only if not editing)
|
|
if (!isEdit) {
|
|
const topRow = document.createElement('div');
|
|
topRow.className = 'row mb-3';
|
|
|
|
// Left column for secret and validity
|
|
const leftCol = document.createElement('div');
|
|
leftCol.className = 'col-md-8';
|
|
|
|
// secret input
|
|
const secDiv = document.createElement('div');
|
|
secDiv.className = 'mb-2';
|
|
secDiv.innerHTML = `Secret: `;
|
|
const secInput = document.createElement('input');
|
|
secInput.className = 'form-control';
|
|
secInput.type = 'text';
|
|
secInput.value = rec.secret;
|
|
secInput.readOnly = true;
|
|
secInput.dataset.field = 'secret';
|
|
secDiv.appendChild(secInput);
|
|
leftCol.appendChild(secDiv);
|
|
|
|
// validity input
|
|
const valDiv = document.createElement('div');
|
|
valDiv.className = 'mb-2';
|
|
valDiv.innerHTML = `Gültig bis: `;
|
|
const valInput = document.createElement('input');
|
|
valInput.className = 'form-control';
|
|
valInput.type = 'date';
|
|
valInput.value = formatISO(rec.validity);
|
|
valInput.readOnly = true;
|
|
valInput.dataset.field = 'validity';
|
|
valDiv.appendChild(valInput);
|
|
leftCol.appendChild(valDiv);
|
|
|
|
topRow.appendChild(leftCol);
|
|
|
|
// Right column for QR code
|
|
const rightCol = document.createElement('div');
|
|
rightCol.className = 'col-md-4 text-center';
|
|
|
|
const qrImg = document.createElement('img');
|
|
qrImg.className = 'qr-code';
|
|
qrImg.style.maxWidth = '200px';
|
|
qrImg.style.width = '100%';
|
|
qrImg.style.border = '1px solid #ddd';
|
|
qrImg.style.padding = '10px';
|
|
qrImg.style.borderRadius = '5px';
|
|
qrImg.alt = 'QR Code';
|
|
generateQRCode(key, qrImg);
|
|
rightCol.appendChild(qrImg);
|
|
|
|
topRow.appendChild(rightCol);
|
|
body.appendChild(topRow);
|
|
} else {
|
|
// When editing, show fields vertically without QR code
|
|
// secret input
|
|
const secDiv = document.createElement('div');
|
|
secDiv.className = 'mb-2';
|
|
secDiv.innerHTML = `Secret: `;
|
|
const secInput = document.createElement('input');
|
|
secInput.className = 'form-control';
|
|
secInput.type = 'text';
|
|
secInput.value = rec.secret;
|
|
secInput.readOnly = false;
|
|
secInput.dataset.field = 'secret';
|
|
secDiv.appendChild(secInput);
|
|
body.appendChild(secDiv);
|
|
|
|
// validity input
|
|
const valDiv = document.createElement('div');
|
|
valDiv.className = 'mb-2';
|
|
valDiv.innerHTML = `Gültig bis: `;
|
|
const valInput = document.createElement('input');
|
|
valInput.className = 'form-control';
|
|
valInput.type = 'date';
|
|
valInput.value = formatISO(rec.validity);
|
|
valInput.readOnly = false;
|
|
valInput.dataset.field = 'validity';
|
|
valDiv.appendChild(valInput);
|
|
body.appendChild(valDiv);
|
|
}
|
|
|
|
// folders
|
|
const folderHeader = document.createElement('h6');
|
|
folderHeader.textContent = 'Ordner';
|
|
body.appendChild(folderHeader);
|
|
|
|
rec.folders.forEach((f, i) => {
|
|
const fwrap = document.createElement('div');
|
|
fwrap.style = 'border:1px solid #ccc; padding:10px; margin-bottom:10px; border-radius:5px;';
|
|
|
|
// name
|
|
fwrap.appendChild(document.createTextNode("Ordnername: "));
|
|
const nameGroup = document.createElement('div');
|
|
nameGroup.className = 'input-group mb-2';
|
|
const nameInput = document.createElement('input');
|
|
nameInput.className = 'form-control';
|
|
nameInput.type = 'text';
|
|
nameInput.value = f.foldername;
|
|
nameInput.readOnly = !isEdit;
|
|
nameInput.dataset.field = `foldername-${i}`;
|
|
nameGroup.appendChild(nameInput);
|
|
|
|
if (isEdit) {
|
|
const remBtn = document.createElement('button');
|
|
remBtn.className = 'btn btn-outline-danger';
|
|
remBtn.type = 'button';
|
|
remBtn.textContent = 'entfernen';
|
|
remBtn.addEventListener('click', () => removeFolder(key, i));
|
|
nameGroup.appendChild(remBtn);
|
|
}
|
|
fwrap.appendChild(nameGroup);
|
|
|
|
// path
|
|
fwrap.appendChild(document.createTextNode("Ordnerpfad: "));
|
|
const pathInput = document.createElement('input');
|
|
pathInput.className = 'form-control mb-2';
|
|
pathInput.type = 'text';
|
|
pathInput.value = f.folderpath;
|
|
pathInput.readOnly = !isEdit;
|
|
pathInput.dataset.field = `folderpath-${i}`;
|
|
fwrap.appendChild(pathInput);
|
|
|
|
body.appendChild(fwrap);
|
|
});
|
|
|
|
if (isEdit) {
|
|
const addFld = document.createElement('button');
|
|
addFld.className = 'btn btn-sm btn-primary mb-2';
|
|
addFld.type = 'button';
|
|
addFld.textContent = 'Ordner hinzufügen';
|
|
addFld.addEventListener('click', () => addFolder(key));
|
|
body.appendChild(addFld);
|
|
}
|
|
|
|
// actions row
|
|
const actions = document.createElement('div');
|
|
|
|
if (!isEdit) {
|
|
const openButton = document.createElement('button');
|
|
openButton.className = 'btn btn-secondary btn-sm me-2';
|
|
openButton.onclick = () => window.open(`/?secret=${rec.secret}`, '_self');
|
|
openButton.textContent = 'Link öffnen';
|
|
actions.appendChild(openButton);
|
|
}
|
|
|
|
if (!isEdit) {
|
|
const openButton = document.createElement('button');
|
|
openButton.className = 'btn btn-secondary btn-sm me-2';
|
|
openButton.onclick = () => toClipboard(`${window.location.origin}/?secret=${rec.secret}`);
|
|
openButton.textContent = 'Link kopieren';
|
|
actions.appendChild(openButton);
|
|
}
|
|
|
|
const delBtn = document.createElement('button');
|
|
delBtn.className = 'btn btn-danger btn-sm me-2 delete-btn';
|
|
delBtn.type = 'button';
|
|
delBtn.textContent = 'löschen';
|
|
delBtn.dataset.secret = key;
|
|
actions.appendChild(delBtn);
|
|
|
|
const cloneBtn = document.createElement('button');
|
|
cloneBtn.className = 'btn btn-secondary btn-sm me-2';
|
|
cloneBtn.type = 'button';
|
|
cloneBtn.textContent = 'clonen';
|
|
cloneBtn.addEventListener('click', () => cloneRec(key));
|
|
actions.appendChild(cloneBtn);
|
|
|
|
if (isEdit || expired) {
|
|
const renewBtn = document.createElement('button');
|
|
renewBtn.className = 'btn btn-info btn-sm me-2';
|
|
renewBtn.type = 'button';
|
|
renewBtn.textContent = 'erneuern';
|
|
renewBtn.addEventListener('click', () => renewRec(key));
|
|
actions.appendChild(renewBtn);
|
|
}
|
|
|
|
if (isEdit) {
|
|
const saveBtn = document.createElement('button');
|
|
saveBtn.className = 'btn btn-success btn-sm me-2';
|
|
saveBtn.type = 'button';
|
|
saveBtn.textContent = 'speichern';
|
|
saveBtn.addEventListener('click', () => saveRec(key));
|
|
actions.appendChild(saveBtn);
|
|
|
|
const cancelBtn = document.createElement('button');
|
|
cancelBtn.className = 'btn btn-secondary btn-sm';
|
|
cancelBtn.type = 'button';
|
|
cancelBtn.textContent = 'abbrechen';
|
|
cancelBtn.addEventListener('click', () => cancelEdit(key));
|
|
actions.appendChild(cancelBtn);
|
|
}
|
|
|
|
body.appendChild(actions);
|
|
collapseDiv.appendChild(body);
|
|
wrapper.appendChild(collapseDiv);
|
|
cont.appendChild(wrapper);
|
|
});
|
|
}
|
|
|
|
// generate a unique secret
|
|
function generateSecret(existing) {
|
|
let s;
|
|
do {
|
|
s = Array.from({length:32}, () => ALPHABET[Math.floor(Math.random()*ALPHABET.length)]).join('');
|
|
} while (existing.includes(s));
|
|
return s;
|
|
}
|
|
|
|
// CRUD helpers
|
|
function cancelEdit(secret) {
|
|
editing.delete(secret);
|
|
// If this was a new record that was never saved, remove it
|
|
const originalExists = data.some(r => r.secret === secret && !editing.has(secret));
|
|
loadData(); // Reload to discard changes
|
|
}
|
|
function cloneRec(secret) {
|
|
const idx = data.findIndex(r => r.secret === secret);
|
|
const rec = JSON.parse(JSON.stringify(data[idx]));
|
|
const existing = data.map(r => r.secret);
|
|
rec.secret = generateSecret(existing);
|
|
const futureDate = new Date();
|
|
futureDate.setDate(futureDate.getDate() + 35);
|
|
// Format as DD.MM.YYYY for validity input
|
|
const dd = String(futureDate.getDate()).padStart(2, '0');
|
|
const mm = String(futureDate.getMonth() + 1).padStart(2, '0');
|
|
const yyyy = futureDate.getFullYear();
|
|
rec.validity = `${dd}.${mm}.${yyyy}`;
|
|
data.splice(idx+1, 0, rec);
|
|
editing.add(rec.secret);
|
|
render();
|
|
// Auto-expand the cloned item
|
|
setTimeout(() => {
|
|
const collapseEl = document.getElementById(`collapse-${rec.secret}`);
|
|
if (collapseEl) {
|
|
const bsCollapse = new bootstrap.Collapse(collapseEl, { toggle: false });
|
|
bsCollapse.show();
|
|
}
|
|
}, 100);
|
|
}
|
|
async function renewRec(secret) {
|
|
// find current record
|
|
const rec = data.find(r => r.secret === secret);
|
|
if (!rec) return;
|
|
|
|
// generate a fresh unique secret
|
|
const existing = data.map(r => r.secret);
|
|
const newSecret = generateSecret(existing);
|
|
|
|
// validity = today + 35 days, formatted as YYYY-MM-DD
|
|
const future = new Date();
|
|
future.setDate(future.getDate() + 35);
|
|
const yyyy = future.getFullYear();
|
|
const mm = String(future.getMonth() + 1).padStart(2, '0');
|
|
const dd = String(future.getDate()).padStart(2, '0');
|
|
const validity = `${yyyy}-${mm}-${dd}`;
|
|
|
|
// keep folders unchanged
|
|
const folders = rec.folders.map(f => ({
|
|
foldername: f.foldername,
|
|
folderpath: f.folderpath
|
|
}));
|
|
|
|
// persist via existing endpoint
|
|
await sendAction({
|
|
action: 'update',
|
|
oldSecret: secret,
|
|
newSecret,
|
|
validity,
|
|
folders
|
|
});
|
|
}
|
|
function addFolder(secret) {
|
|
data.find(r => r.secret === secret).folders.push({foldername:'', folderpath:''});
|
|
render();
|
|
}
|
|
function removeFolder(secret, i) {
|
|
data.find(r => r.secret === secret).folders.splice(i,1);
|
|
render();
|
|
}
|
|
|
|
async function saveRec(secret) {
|
|
const card = document.querySelector(`[data-secret="${secret}"]`);
|
|
const newSecret = card.querySelector('input[data-field="secret"]').value.trim();
|
|
const validity = card.querySelector('input[data-field="validity"]').value;
|
|
const rec = data.find(r => r.secret === secret);
|
|
const folders = rec.folders.map((_, i) => ({
|
|
foldername: card.querySelector(`input[data-field="foldername-${i}"]`).value.trim(),
|
|
folderpath: card.querySelector(`input[data-field="folderpath-${i}"]`).value.trim()
|
|
}));
|
|
|
|
if (!newSecret || data.some(r => r.secret !== secret && r.secret === newSecret)) {
|
|
return alert('Secret must be unique and non-empty');
|
|
}
|
|
if (!validity) {
|
|
return alert('Validity required');
|
|
}
|
|
if (folders.some(f => !f.foldername || !f.folderpath)) {
|
|
return alert('Folder entries cannot be empty');
|
|
}
|
|
|
|
await sendAction({
|
|
action: 'update',
|
|
oldSecret: secret,
|
|
newSecret,
|
|
validity,
|
|
folders
|
|
});
|
|
editing.delete(secret);
|
|
await loadData();
|
|
}
|
|
|
|
|
|
let addBtnHandler = null;
|
|
let deleteClickHandler = null;
|
|
let confirmDeleteHandler = null;
|
|
|
|
function bindHandlers() {
|
|
const root = document.getElementById('records');
|
|
if (!root || root.dataset.bound) return false;
|
|
root.dataset.bound = '1';
|
|
|
|
const dataScript = document.getElementById('folder-config-page-data');
|
|
if (dataScript) {
|
|
try {
|
|
const parsed = JSON.parse(dataScript.textContent || '{}');
|
|
if (Array.isArray(parsed.alphabet)) {
|
|
ALPHABET = parsed.alphabet;
|
|
} else if (typeof parsed.alphabet === 'string') {
|
|
ALPHABET = parsed.alphabet.split('');
|
|
} else {
|
|
ALPHABET = [];
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to parse folder config data', err);
|
|
}
|
|
}
|
|
|
|
addBtnHandler = () => {
|
|
const existing = data.map(r => r.secret);
|
|
const newSecret = generateSecret(existing);
|
|
data.push({ secret: newSecret, validity: new Date().toISOString().slice(0,10), folders: [] });
|
|
editing.add(newSecret);
|
|
render();
|
|
setTimeout(() => {
|
|
const collapseEl = document.getElementById(`collapse-${newSecret}`);
|
|
if (collapseEl) {
|
|
const bsCollapse = new bootstrap.Collapse(collapseEl, { toggle: false });
|
|
bsCollapse.show();
|
|
}
|
|
}, 100);
|
|
};
|
|
|
|
const addBtn = document.getElementById('add-btn');
|
|
if (addBtn) addBtn.addEventListener('click', addBtnHandler);
|
|
|
|
deleteClickHandler = (e) => {
|
|
if (e.target.matches('.delete-btn')) {
|
|
pendingDelete = e.target.dataset.secret;
|
|
const modal = new bootstrap.Modal(document.getElementById('confirmDeleteModal'));
|
|
modal.show();
|
|
}
|
|
};
|
|
document.addEventListener('click', deleteClickHandler);
|
|
|
|
confirmDeleteHandler = () => {
|
|
if (pendingDelete) deleteRec(pendingDelete);
|
|
pendingDelete = null;
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('confirmDeleteModal'));
|
|
if (modal) modal.hide();
|
|
};
|
|
const confirmBtn = document.querySelector('.confirm-delete');
|
|
if (confirmBtn) confirmBtn.addEventListener('click', confirmDeleteHandler);
|
|
|
|
return true;
|
|
}
|
|
|
|
function unbindHandlers() {
|
|
const addBtn = document.getElementById('add-btn');
|
|
if (addBtn && addBtnHandler) addBtn.removeEventListener('click', addBtnHandler);
|
|
addBtnHandler = null;
|
|
|
|
if (deleteClickHandler) document.removeEventListener('click', deleteClickHandler);
|
|
deleteClickHandler = null;
|
|
|
|
const confirmBtn = document.querySelector('.confirm-delete');
|
|
if (confirmBtn && confirmDeleteHandler) confirmBtn.removeEventListener('click', confirmDeleteHandler);
|
|
confirmDeleteHandler = null;
|
|
}
|
|
|
|
function initFolderConfigPage() {
|
|
data = [];
|
|
editing = new Set();
|
|
pendingDelete = null;
|
|
|
|
if (!bindHandlers()) return;
|
|
loadData();
|
|
}
|
|
|
|
function destroyFolderConfigPage() {
|
|
unbindHandlers();
|
|
}
|
|
|
|
if (window.PageRegistry && typeof window.PageRegistry.register === 'function') {
|
|
window.PageRegistry.register('folder_secret_config', {
|
|
init: initFolderConfigPage,
|
|
destroy: destroyFolderConfigPage
|
|
});
|
|
}
|
|
|
|
})();
|