This commit is contained in:
2025-12-07 02:49:46 +01:00
parent 37318e69fb
commit 6414802ce8
7 changed files with 1190 additions and 9 deletions

View File

@@ -3,6 +3,7 @@ import { initTabs } from './ui-tabs.js';
import { initLists } from './ui-list.js';
import { initCreate } from './ui-create.js';
import { initEditor } from './ui-editor.js';
import { initUserPanel } from './ui-user.js';
import { mountLogoutButton, ensureFloatingLogout } from './ui-auth.js';
import { apiAction } from './api.js';
@@ -18,6 +19,7 @@ async function ensureAuthenticated() {
window.location.href = '/login.php';
return false;
}
window.__currentUser = me.user;
// ✅ nur für eingeloggte Nutzer: UI freigebensss
document.documentElement.classList.remove('auth-pending');
const appRoot = document.getElementById('app');
@@ -34,6 +36,7 @@ function initAppFeatures() {
initLists();
initCreate();
initEditor();
initUserPanel();
// Logout-Buttons
mountLogoutButton('#btn-logout', { redirect: '/login.php' });
@@ -81,4 +84,3 @@ document.addEventListener('DOMContentLoaded', async () => {
});
window.addEventListener('message', handleEditorMessages);

357
public/assets/js/ui-user.js Normal file
View File

@@ -0,0 +1,357 @@
import { apiAction, toast } from './api.js';
const state = {
settings: {},
rotate: { bridge: false, sender: false, external: false },
users: [],
userMap: new Map(),
currentTab: 'profile',
loading: false,
};
let dialog;
let avatarBtn;
let profileForm;
let passwordForm;
let settingsForm;
let teamTable;
let userForm;
export function initUserPanel() {
dialog = document.getElementById('userDialog');
avatarBtn = document.getElementById('btn-user');
if (!dialog || !avatarBtn) return;
profileForm = document.getElementById('profileForm');
passwordForm = document.getElementById('passwordForm');
settingsForm = document.getElementById('settingsForm');
teamTable = document.getElementById('teamTable');
userForm = document.getElementById('userForm');
avatarBtn.addEventListener('click', () => openUserDialog());
document.getElementById('userClose')?.addEventListener('click', () => dialog.close());
profileForm?.addEventListener('submit', submitProfileForm);
passwordForm?.addEventListener('submit', submitPasswordForm);
settingsForm?.addEventListener('submit', submitSettingsForm);
document.getElementById('btn-user-add')?.addEventListener('click', () => openUserForm());
document.getElementById('userFormCancel')?.addEventListener('click', () => closeUserForm());
userForm?.addEventListener('submit', submitUserForm);
teamTable?.addEventListener('click', handleTeamTableClick);
dialog.addEventListener('close', () => closeUserForm());
document.querySelectorAll('[data-user-tab]').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.getAttribute('data-user-tab')));
});
settingsForm?.querySelectorAll('button[data-rotate]').forEach(btn => {
btn.addEventListener('click', () => {
const type = btn.getAttribute('data-rotate');
if (type && state.rotate[type] !== undefined) {
state.rotate[type] = true;
toast('Token wird nach dem Speichern erneuert.', true, { duration: 2000 });
}
});
});
settingsForm?.querySelectorAll('button[data-download]').forEach(btn => {
btn.addEventListener('click', () => {
const type = btn.getAttribute('data-download');
if (type) downloadFile(type);
});
});
updateAvatar();
updateOwnerVisibility();
}
function isOwner() {
return (window.__currentUser?.role || '').toLowerCase() === 'owner';
}
function updateAvatar() {
const target = document.getElementById('userAvatar');
if (!target) return;
const name = window.__currentUser?.name || window.__currentUser?.email || '';
target.textContent = name ? name.trim().charAt(0).toUpperCase() : 'U';
}
function updateOwnerVisibility() {
document.querySelectorAll('.owner-only').forEach(el => {
el.classList.toggle('hidden', !isOwner());
});
}
function switchTab(tab) {
if (!tab) return;
state.currentTab = tab;
document.querySelectorAll('[data-user-panel]').forEach(panel => {
panel.classList.toggle('hidden', panel.getAttribute('data-user-panel') !== tab);
});
document.querySelectorAll('[data-user-tab]').forEach(btn => {
const isActive = btn.getAttribute('data-user-tab') === tab;
btn.classList.toggle('bg-sky-50', isActive);
btn.classList.toggle('text-sky-700', isActive);
});
}
async function openUserDialog() {
if (dialog.open || state.loading) return;
dialog.showModal();
switchTab(state.currentTab);
await loadAccountData();
}
async function loadAccountData() {
try {
state.loading = true;
const res = await apiAction('account.profile.get', { method: 'GET' });
if (!res?.ok) throw new Error(res?.error || 'Profil konnte nicht geladen werden');
if (res.user) {
window.__currentUser = res.user;
updateAvatar();
updateOwnerVisibility();
}
fillProfileForm(res.user);
fillSettingsForm(res.settings || {});
if (isOwner()) {
await loadUsers();
}
} catch (err) {
console.error(err);
toast(err.message || 'Fehler beim Laden', false);
dialog.close();
} finally {
state.loading = false;
}
}
function fillProfileForm(user) {
if (!profileForm) return;
profileForm.name.value = user?.name || '';
profileForm.email.value = user?.email || '';
}
function fillSettingsForm(settings) {
state.settings = settings;
if (!settingsForm) return;
settingsForm.bridge_url.value = settings.bridge_url || '';
settingsForm.bridge_token.value = settings.bridge_token || '';
settingsForm.sender_token.value = settings.sender_token || '';
settingsForm.external_api_token.value = settings.external_api_token || '';
state.rotate = { bridge: false, sender: false, external: false };
}
async function submitProfileForm(ev) {
ev.preventDefault();
const data = {
name: profileForm.name.value.trim(),
email: profileForm.email.value.trim(),
};
try {
const res = await apiAction('account.profile.update', { method: 'POST', data });
if (!res?.ok) throw new Error(res?.error || 'Profil konnte nicht gespeichert werden');
window.__currentUser = res.user;
updateAvatar();
toast('Profil aktualisiert', true);
} catch (err) {
toast(err.message || 'Fehler beim Speichern', false);
}
}
async function submitPasswordForm(ev) {
ev.preventDefault();
const data = {
current_password: passwordForm.current_password.value,
new_password: passwordForm.new_password.value,
};
try {
const res = await apiAction('account.password.update', { method: 'POST', data });
if (!res?.ok) throw new Error(res?.error || 'Passwort konnte nicht geändert werden');
passwordForm.reset();
toast('Passwort aktualisiert', true);
} catch (err) {
toast(err.message || 'Fehler beim Speichern', false);
}
}
async function submitSettingsForm(ev) {
ev.preventDefault();
const data = {
bridge_url: settingsForm.bridge_url.value.trim(),
bridge_token: settingsForm.bridge_token.value.trim(),
sender_token: settingsForm.sender_token.value.trim(),
external_api_token: settingsForm.external_api_token.value.trim(),
rotate_bridge_token: state.rotate.bridge ? 1 : 0,
rotate_sender_token: state.rotate.sender ? 1 : 0,
rotate_external_token: state.rotate.external ? 1 : 0,
};
try {
const res = await apiAction('account.settings.update', { method: 'POST', data });
if (!res?.ok) throw new Error(res?.error || 'Einstellungen konnten nicht gespeichert werden');
fillSettingsForm(res.settings || {});
toast('Integrationen gespeichert', true);
} catch (err) {
toast(err.message || 'Fehler beim Speichern', false);
}
}
async function downloadFile(type) {
try {
const action = type === 'sender' ? 'downloads.sender' : 'downloads.bridge';
const res = await apiAction(action, { method: 'POST', data: {} });
if (!res?.ok || !res.content) throw new Error(res?.error || 'Download fehlgeschlagen');
const bytes = atob(res.content);
const buffer = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; i++) buffer[i] = bytes.charCodeAt(i);
const blob = new Blob([buffer], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = res.file_name || `${type}.php`;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (err) {
toast(err.message || 'Download fehlgeschlagen', false);
}
}
async function loadUsers() {
try {
const res = await apiAction('account.users.list', { method: 'GET' });
if (!res?.ok) throw new Error(res?.error || 'Team konnte nicht geladen werden');
state.users = res.items || [];
state.userMap = new Map(state.users.map(u => [u.id, u]));
renderUserList();
} catch (err) {
toast(err.message || 'Fehler beim Laden der Nutzer', false);
}
}
function renderUserList() {
if (!teamTable) return;
const tbody = teamTable.querySelector('tbody');
if (!tbody) return;
if (!state.users.length) {
tbody.innerHTML = '<tr><td colspan="5" class="text-sm text-slate-500">Keine Nutzer vorhanden.</td></tr>';
return;
}
tbody.innerHTML = state.users.map(user => {
const badge = user.is_active ? '<span class="badge bg-green-100 text-green-800">Aktiv</span>' : '<span class="badge bg-slate-200 text-slate-700">Inaktiv</span>';
return `<tr>
<td>${escapeHtml(user.name)}</td>
<td>${escapeHtml(user.email)}</td>
<td>${escapeHtml(user.role)}</td>
<td>${badge}</td>
<td class="text-right flex gap-2 justify-end">
<button class="btn" data-user-action="edit" data-user-id="${user.id}">Bearbeiten</button>
<button class="btn" data-user-action="reset" data-user-id="${user.id}">Passwort</button>
<button class="btn btn-danger" data-user-action="delete" data-user-id="${user.id}">Löschen</button>
</td>
</tr>`;
}).join('');
}
function handleTeamTableClick(ev) {
const btn = ev.target.closest('button[data-user-action]');
if (!btn) return;
const id = Number(btn.getAttribute('data-user-id'));
const action = btn.getAttribute('data-user-action');
const user = state.userMap.get(id);
if (!user) return;
if (action === 'edit') {
openUserForm(user);
} else if (action === 'delete') {
if (confirm(`Soll ${user.name} wirklich entfernt werden?`)) deleteUser(id);
} else if (action === 'reset') {
openUserForm(user, true);
}
}
function openUserForm(user = null, resetOnly = false) {
if (!userForm) return;
userForm.classList.remove('hidden');
userForm.user_id.value = user?.id || '';
userForm.name.value = user?.name || '';
userForm.email.value = user?.email || '';
userForm.role.value = user?.role || 'user';
userForm.is_active.checked = user ? !!user.is_active : true;
const resetRow = userForm.querySelector('.reset-only');
if (resetRow) resetRow.classList.toggle('hidden', !user);
if (resetRow) resetRow.querySelector('input').checked = resetOnly;
}
function closeUserForm() {
if (!userForm) return;
userForm.classList.add('hidden');
userForm.reset();
userForm.user_id.value = '';
const resetInput = userForm.querySelector('.reset-only input');
if (resetInput) resetInput.checked = false;
}
async function submitUserForm(ev) {
ev.preventDefault();
const formData = new FormData(userForm);
const payload = {
name: formData.get('name')?.toString().trim() || '',
email: formData.get('email')?.toString().trim() || '',
role: formData.get('role')?.toString() || 'user',
is_active: userForm.is_active.checked ? 1 : 0,
};
const userId = formData.get('user_id')?.toString();
let action = 'account.users.create';
if (userId) {
action = 'account.users.update';
payload.user_id = Number(userId);
payload.reset_password = userForm.querySelector('input[name="reset_password"]').checked ? 1 : 0;
}
try {
const res = await apiAction(action, { method: 'POST', data: payload });
if (!res?.ok) throw new Error(res?.error || 'Speichern fehlgeschlagen');
closeUserForm();
await loadUsers();
toast('Nutzer gespeichert', true);
if (res.temp_password) {
copyToClipboard(res.temp_password);
alert(`Neues Passwort: ${res.temp_password}`);
}
} catch (err) {
toast(err.message || 'Fehler beim Speichern', false);
}
}
async function deleteUser(userId) {
try {
const res = await apiAction('account.users.delete', { method: 'POST', data: { user_id: userId } });
if (!res?.ok) throw new Error(res?.error || 'Löschen fehlgeschlagen');
await loadUsers();
toast('Nutzer gelöscht', true);
} catch (err) {
toast(err.message || 'Fehler beim Löschen', false);
}
}
function copyToClipboard(value) {
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(value).catch(() => {});
}
}
function closeUserDialog() {
dialog?.close();
}
function escapeHtml(str) {
return String(str || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

View File

@@ -35,6 +35,16 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time();
#toast-root{z-index:2147483647}
.truncate{max-width:22rem;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
.hidden{display:none}
.btn-avatar{padding:.35rem;border-radius:999px;width:38px;height:38px;justify-content:center;font-weight:600;background:#0ea5e9;color:#fff;border:none}
.btn-avatar:hover{background:#0284c7}
.section-card{background:#fff;border:1px solid #e2e8f0;border-radius:1rem;padding:1rem;margin-bottom:1.25rem}
.section-card h4{margin:0 0 .75rem;font-size:1rem;font-weight:600;color:#0f172a}
.input{width:100%;border:1px solid #cbd5f5;border-radius:.5rem;padding:.5rem .75rem}
.user-tabs{display:flex;gap:.5rem;margin-bottom:1rem}
.user-panel{width:90vw;max-width:960px}
.team-table{width:100%;border-collapse:collapse;font-size:.9rem}
.team-table th,.team-table td{padding:.35rem .5rem;border-bottom:1px solid #e2e8f0;text-align:left}
.badge{display:inline-flex;align-items:center;padding:.1rem .5rem;border-radius:999px;font-size:.75rem;background:#e2e8f0;color:#0f172a}
</style>
</head>
<body class="page-admin bg-slate-50 text-slate-800">
@@ -47,8 +57,11 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time();
<button type="button" data-tab="blocks" class="px-4 py-2 text-sm border-e">Blocks</button>
<button type="button" data-tab="snippets" class="px-4 py-2 text-sm">Snippets</button>
</nav>
<div class="ms-auto flex gap-2">
<div class="ms-auto flex gap-2 items-center">
<button id="btn-new" type="button" class="btn">Neu …</button>
<button id="btn-user" type="button" class="btn-avatar" title="Mein Konto">
<span id="userAvatar">U</span>
</button>
</div>
</div>
</header>
@@ -152,9 +165,137 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time();
</form>
</dialog>
<!-- User Account Dialog -->
<dialog id="userDialog" class="rounded-2xl p-0 user-panel">
<div class="flex flex-col bg-white rounded-2xl max-h-[90vh]">
<div class="px-4 py-3 border-b flex items-center gap-3 bg-white/80 backdrop-blur rounded-t-2xl">
<strong class="text-lg me-auto">Mein Konto</strong>
<button type="button" id="userClose" class="btn">Schließen</button>
</div>
<div class="p-4 overflow-y-auto flex-1">
<div class="user-tabs">
<button type="button" data-user-tab="profile" class="btn bg-sky-50 text-sky-700 flex-1">Profil</button>
<button type="button" data-user-tab="security" class="btn flex-1">Passwort</button>
<button type="button" data-user-tab="team" class="btn flex-1 owner-only hidden">Team</button>
<button type="button" data-user-tab="integration" class="btn flex-1 owner-only hidden">Integrationen</button>
</div>
<section data-user-panel="profile" class="section-card">
<h4>Profil</h4>
<form id="profileForm" class="space-y-3">
<label class="block text-sm text-slate-600">Name
<input type="text" id="profile_name" name="name" class="input mt-1" required>
</label>
<label class="block text-sm text-slate-600">E-Mail
<input type="email" id="profile_email" name="email" class="input mt-1" required>
</label>
<div class="flex justify-end">
<button type="submit" class="btn">Speichern</button>
</div>
</form>
</section>
<section data-user-panel="security" class="section-card hidden">
<h4>Passwort ändern</h4>
<form id="passwordForm" class="space-y-3">
<label class="block text-sm text-slate-600">Aktuelles Passwort
<input type="password" name="current_password" class="input mt-1" required>
</label>
<label class="block text-sm text-slate-600">Neues Passwort (min. 8 Zeichen)
<input type="password" name="new_password" class="input mt-1" required minlength="8">
</label>
<div class="flex justify-end">
<button type="submit" class="btn">Aktualisieren</button>
</div>
</form>
</section>
<section data-user-panel="team" class="section-card hidden owner-only">
<div class="flex items-center justify-between mb-2">
<h4>Team</h4>
<button type="button" id="btn-user-add" class="btn">+ Nutzer</button>
</div>
<div class="overflow-auto">
<table class="team-table" id="teamTable">
<thead>
<tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Status</th><th class="text-right">Aktionen</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
<form id="userForm" class="space-y-3 mt-4 hidden">
<input type="hidden" name="user_id">
<label class="block text-sm text-slate-600">Name
<input type="text" name="name" class="input mt-1" required>
</label>
<label class="block text-sm text-slate-600">E-Mail
<input type="email" name="email" class="input mt-1" required>
</label>
<label class="block text-sm text-slate-600">Rolle
<select name="role" class="input mt-1">
<option value="owner">Owner</option>
<option value="admin">Admin</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
</label>
<label class="inline-flex items-center gap-2 text-sm text-slate-600">
<input type="checkbox" name="is_active" checked> Aktiv
</label>
<label class="inline-flex items-center gap-2 text-sm text-slate-600 reset-only hidden">
<input type="checkbox" name="reset_password"> Passwort zurücksetzen
</label>
<div class="flex justify-end gap-2">
<button type="button" id="userFormCancel" class="btn">Abbrechen</button>
<button type="submit" class="btn">Speichern</button>
</div>
</form>
</section>
<section data-user-panel="integration" class="section-card hidden owner-only">
<h4>Integrationen & Tokens</h4>
<form id="settingsForm" class="space-y-3">
<label class="block text-sm text-slate-600">Bridge-URL
<input type="url" name="bridge_url" class="input mt-1" placeholder="https://domain.tld/emailtemplate_bridge.php">
</label>
<div>
<label class="block text-sm text-slate-600">Bridge Token</label>
<div class="flex gap-2 mt-1">
<input type="text" name="bridge_token" class="input" readonly>
<button type="button" class="btn" data-rotate="bridge">Neu erstellen</button>
</div>
</div>
<div>
<label class="block text-sm text-slate-600">Sender Token</label>
<div class="flex gap-2 mt-1">
<input type="text" name="sender_token" class="input" readonly>
<button type="button" class="btn" data-rotate="sender">Neu erstellen</button>
</div>
</div>
<div>
<label class="block text-sm text-slate-600">Externer API-Token</label>
<div class="flex gap-2 mt-1">
<input type="text" name="external_api_token" class="input" readonly>
<button type="button" class="btn" data-rotate="external">Neu erstellen</button>
</div>
</div>
<div class="flex justify-between gap-2 flex-wrap pt-2">
<div class="flex gap-2">
<button type="button" class="btn" data-download="bridge">Bridge-Datei</button>
<button type="button" class="btn" data-download="sender">Sender-Datei</button>
</div>
<button type="submit" class="btn ms-auto">Einstellungen speichern</button>
</div>
</form>
</section>
</div>
</div>
</dialog>
<div id="toast-root"></div>
<script src="assets/js/toast.js?v=<?= htmlspecialchars($assetVersion, ENT_QUOTES) ?>"></script>
<script type="module" src="assets/js/app.js?v=<?= htmlspecialchars($assetVersion, ENT_QUOTES) ?>"></script>
<script type="module" src="assets/js/ui-user.js?v=<?= htmlspecialchars($assetVersion, ENT_QUOTES) ?>"></script>
</body>
</html>