up
This commit is contained in:
@@ -68,6 +68,15 @@ $authDefaults = [
|
||||
'col_name' => 'name',
|
||||
'col_id' => 'id',
|
||||
'col_status' => 'is_active',
|
||||
'col_role' => 'role',
|
||||
'customer_fk' => 'customer_id',
|
||||
'customer_table'=> 'customers',
|
||||
'customer_cols' => [
|
||||
'name' => 'name',
|
||||
'slug' => 'slug',
|
||||
'status' => 'status',
|
||||
'plan' => 'plan',
|
||||
],
|
||||
'active_values' => ['active','1',1],
|
||||
'legacy' => 'md5',
|
||||
],
|
||||
|
||||
@@ -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
357
public/assets/js/ui-user.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
143
public/index.php
143
public/index.php
@@ -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>
|
||||
|
||||
14
schema.sql
14
schema.sql
@@ -18,6 +18,7 @@ DROP TABLE IF EXISTS `emailtemplate_snippets`;
|
||||
DROP TABLE IF EXISTS `emailtemplate_blocks`;
|
||||
DROP TABLE IF EXISTS `emailtemplate_sections`;
|
||||
DROP TABLE IF EXISTS `emailtemplate_templates`;
|
||||
DROP TABLE IF EXISTS `emailtemplate_customer_settings`;
|
||||
/*DROP TABLE IF EXISTS `customers`; -- optional (nur falls lokal hier gepflegt) */
|
||||
/*DROP TABLE IF EXISTS `customer_users`; -- optional */
|
||||
|
||||
@@ -164,6 +165,18 @@ CREATE TABLE `emailtemplate_section_items` (
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
/* 8) Kundenbezogene Einstellungen (Bridge/Sender/API Tokens) */
|
||||
CREATE TABLE `emailtemplate_customer_settings` (
|
||||
`customer_id` INT UNSIGNED NOT NULL,
|
||||
`bridge_url` VARCHAR(500) DEFAULT NULL,
|
||||
`bridge_token` VARCHAR(255) DEFAULT NULL,
|
||||
`sender_token` VARCHAR(255) DEFAULT NULL,
|
||||
`external_api_token` VARCHAR(255) DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`customer_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
/* =========================
|
||||
Optionale Seed-Daten
|
||||
========================= */
|
||||
@@ -174,4 +187,3 @@ CREATE TABLE `emailtemplate_section_items` (
|
||||
|
||||
-- FK-Prüfung (falls temporär deaktiviert) wieder aktivieren
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
|
||||
@@ -770,6 +770,39 @@ class ApiKernel
|
||||
$this->authService->logout();
|
||||
$this->respond(['ok' => true]);
|
||||
break;
|
||||
case 'account.profile.get':
|
||||
$this->handleAccountProfileGet();
|
||||
break;
|
||||
case 'account.profile.update':
|
||||
$this->handleAccountProfileUpdate();
|
||||
break;
|
||||
case 'account.password.update':
|
||||
$this->handleAccountPasswordUpdate();
|
||||
break;
|
||||
case 'account.settings.get':
|
||||
$this->handleAccountSettingsGet();
|
||||
break;
|
||||
case 'account.settings.update':
|
||||
$this->handleAccountSettingsUpdate();
|
||||
break;
|
||||
case 'account.users.list':
|
||||
$this->handleAccountUsersList();
|
||||
break;
|
||||
case 'account.users.create':
|
||||
$this->handleAccountUsersCreate();
|
||||
break;
|
||||
case 'account.users.update':
|
||||
$this->handleAccountUsersUpdate();
|
||||
break;
|
||||
case 'account.users.delete':
|
||||
$this->handleAccountUsersDelete();
|
||||
break;
|
||||
case 'downloads.bridge':
|
||||
$this->handleDownloadFile('bridge');
|
||||
break;
|
||||
case 'downloads.sender':
|
||||
$this->handleDownloadFile('sender');
|
||||
break;
|
||||
case 'placeholders.schema':
|
||||
$this->handlePlaceholderSchema();
|
||||
break;
|
||||
@@ -1030,8 +1063,9 @@ class ApiKernel
|
||||
|
||||
private function handlePlaceholderSchema(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$bridge = $this->conf['placeholders']['bridge'] ?? [];
|
||||
$user = $this->authService->requireAuth();
|
||||
$customerId = (int)($user['customer_id'] ?? 0);
|
||||
$bridge = $this->resolveBridgeConfig($customerId);
|
||||
$url = trim((string)($bridge['url'] ?? ''));
|
||||
$token = trim((string)($bridge['token'] ?? ''));
|
||||
if ($url === '' || $token === '') {
|
||||
@@ -1097,4 +1131,603 @@ class ApiKernel
|
||||
$hash = md5($url . '|' . $token);
|
||||
return sys_get_temp_dir() . '/emailtemplate_placeholder_' . $hash . '.json';
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Account & User Management
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private function handleAccountProfileGet(): void
|
||||
{
|
||||
$user = $this->authService->requireAuth();
|
||||
$customerId = (int)($user['customer_id'] ?? 0);
|
||||
$settings = $customerId ? $this->ensureSettingsTokens($customerId, $this->getCustomerSettings($customerId)) : [];
|
||||
$this->respond([
|
||||
'ok' => true,
|
||||
'user' => $user,
|
||||
'customer' => $user['customer'] ?? null,
|
||||
'settings' => $settings,
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleAccountProfileUpdate(): void
|
||||
{
|
||||
$user = $this->authService->requireAuth();
|
||||
$cols = $this->authUserColumns();
|
||||
$table = $cols['table'];
|
||||
$dbCols = $this->tableColumns($table);
|
||||
$name = trim((string)($this->in['name'] ?? ''));
|
||||
$email = trim(strtolower((string)($this->in['email'] ?? '')));
|
||||
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->fail('Valid email required', null, 422);
|
||||
}
|
||||
if ($name === '') $this->fail('Name required', null, 422);
|
||||
$userId = (int)($user['id'] ?? 0);
|
||||
$customerId = (int)($user['customer_id'] ?? 0);
|
||||
if (strtolower($email) !== strtolower((string)$user['email'])) {
|
||||
$this->assertEmailUnique($email, $customerId, $userId);
|
||||
}
|
||||
|
||||
$set = [];
|
||||
$params = [':id' => $userId];
|
||||
if ($this->columnExists($dbCols, $cols['col_name'])) {
|
||||
$set[] = sprintf('`%s` = :name', $cols['col_name']);
|
||||
$params[':name'] = $name;
|
||||
}
|
||||
if ($this->columnExists($dbCols, $cols['col_email'])) {
|
||||
$set[] = sprintf('`%s` = :email', $cols['col_email']);
|
||||
$params[':email'] = $email;
|
||||
}
|
||||
if (!$set) {
|
||||
$this->fail('Profile update not supported', null, 500);
|
||||
}
|
||||
$sql = sprintf(
|
||||
'UPDATE `%s` SET %s WHERE `%s` = :id LIMIT 1',
|
||||
$table,
|
||||
implode(',', $set),
|
||||
$cols['col_id']
|
||||
);
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
|
||||
$stmt->execute();
|
||||
|
||||
$_SESSION['auth']['name'] = $name;
|
||||
$_SESSION['auth']['email'] = $email;
|
||||
|
||||
$this->respond(['ok' => true, 'user' => $_SESSION['auth']]);
|
||||
}
|
||||
|
||||
private function handleAccountPasswordUpdate(): void
|
||||
{
|
||||
$user = $this->authService->requireAuth();
|
||||
$current = (string)($this->in['current_password'] ?? '');
|
||||
$new = (string)($this->in['new_password'] ?? '');
|
||||
if ($current === '' || $new === '') {
|
||||
$this->fail('Current and new password required', null, 422);
|
||||
}
|
||||
if (strlen($new) < 8) {
|
||||
$this->fail('Password must be at least 8 characters', null, 422);
|
||||
}
|
||||
|
||||
$cols = $this->authUserColumns();
|
||||
$table = $cols['table'];
|
||||
$sql = sprintf(
|
||||
'SELECT `%1$s` FROM `%2$s` WHERE `%3$s` = :id LIMIT 1',
|
||||
$cols['col_pass'],
|
||||
$table,
|
||||
$cols['col_id']
|
||||
);
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute([':id' => $user['id']]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) $this->fail('User not found', null, 404);
|
||||
|
||||
$stored = (string)$row[$cols['col_pass']];
|
||||
if (!$this->verifyUserPasswordValue($current, $stored)) {
|
||||
$this->fail('Current password incorrect', null, 403);
|
||||
}
|
||||
|
||||
$hash = $this->hashUserPassword($new);
|
||||
$update = $this->pdo->prepare(
|
||||
sprintf('UPDATE `%s` SET `%s` = :pwd WHERE `%s` = :id LIMIT 1', $table, $cols['col_pass'], $cols['col_id'])
|
||||
);
|
||||
$update->execute([':pwd' => $hash, ':id' => $user['id']]);
|
||||
$this->respond(['ok' => true]);
|
||||
}
|
||||
|
||||
private function handleAccountSettingsGet(): void
|
||||
{
|
||||
$user = $this->authService->requireAuth();
|
||||
$this->ensureOwner($user);
|
||||
$customerId = (int)($user['customer_id'] ?? 0);
|
||||
$settings = $this->ensureSettingsTokens($customerId, $this->getCustomerSettings($customerId));
|
||||
$this->respond(['ok' => true, 'settings' => $settings]);
|
||||
}
|
||||
|
||||
private function handleAccountSettingsUpdate(): void
|
||||
{
|
||||
$user = $this->authService->requireAuth();
|
||||
$this->ensureOwner($user);
|
||||
$customerId = (int)($user['customer_id'] ?? 0);
|
||||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||||
|
||||
$bridgeUrl = trim((string)($this->in['bridge_url'] ?? ''));
|
||||
$bridgeToken = trim((string)($this->in['bridge_token'] ?? ''));
|
||||
$senderToken = trim((string)($this->in['sender_token'] ?? ''));
|
||||
$externalToken = trim((string)($this->in['external_api_token'] ?? ''));
|
||||
$rotateBridge = !empty($this->in['rotate_bridge_token']);
|
||||
$rotateSender = !empty($this->in['rotate_sender_token']);
|
||||
$rotateExternal = !empty($this->in['rotate_external_token']);
|
||||
|
||||
if ($bridgeUrl && !filter_var($bridgeUrl, FILTER_VALIDATE_URL)) {
|
||||
$this->fail('Ungültige Bridge-URL', null, 422);
|
||||
}
|
||||
|
||||
$settings = $this->getCustomerSettings($customerId);
|
||||
if ($rotateBridge || $bridgeToken === '') $bridgeToken = $this->generateToken();
|
||||
if ($rotateSender || $senderToken === '') $senderToken = $this->generateToken();
|
||||
if ($rotateExternal || $externalToken === '') $externalToken = $this->generateToken();
|
||||
|
||||
$settings = $this->saveCustomerSettings($customerId, [
|
||||
'bridge_url' => $bridgeUrl,
|
||||
'bridge_token' => $bridgeToken,
|
||||
'sender_token' => $senderToken,
|
||||
'external_api_token' => $externalToken,
|
||||
]);
|
||||
|
||||
$this->respond(['ok' => true, 'settings' => $settings]);
|
||||
}
|
||||
|
||||
private function handleAccountUsersList(): void
|
||||
{
|
||||
$user = $this->authService->requireAuth();
|
||||
$this->ensureOwner($user);
|
||||
$customerId = (int)($user['customer_id'] ?? 0);
|
||||
$cols = $this->authUserColumns();
|
||||
$table = $cols['table'];
|
||||
$dbCols = $this->tableColumns($table);
|
||||
$select = [
|
||||
sprintf('`%s` AS user_id', $cols['col_id']),
|
||||
sprintf('`%s` AS name', $cols['col_name']),
|
||||
sprintf('`%s` AS email', $cols['col_email']),
|
||||
];
|
||||
if ($this->columnExists($dbCols, $cols['col_role'])) {
|
||||
$select[] = sprintf('`%s` AS role', $cols['col_role']);
|
||||
} else {
|
||||
$select[] = "'user' AS role";
|
||||
}
|
||||
if ($this->columnExists($dbCols, $cols['col_status'])) {
|
||||
$select[] = sprintf('`%s` AS is_active', $cols['col_status']);
|
||||
} else {
|
||||
$select[] = '1 AS is_active';
|
||||
}
|
||||
if ($this->columnExists($dbCols, 'created_at')) $select[] = '`created_at`';
|
||||
if ($this->columnExists($dbCols, 'updated_at')) $select[] = '`updated_at`';
|
||||
|
||||
$sql = sprintf(
|
||||
'SELECT %s FROM `%s` WHERE `%s` = :cid ORDER BY `%s` ASC',
|
||||
implode(',', $select),
|
||||
$table,
|
||||
$cols['col_customer'],
|
||||
$cols['col_name']
|
||||
);
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute([':cid' => $customerId]);
|
||||
$items = [];
|
||||
while ($row = $stmt->fetch()) {
|
||||
$items[] = $this->formatUserOutput($row);
|
||||
}
|
||||
$this->respond(['ok' => true, 'items' => $items]);
|
||||
}
|
||||
|
||||
private function handleAccountUsersCreate(): void
|
||||
{
|
||||
$owner = $this->authService->requireAuth();
|
||||
$this->ensureOwner($owner);
|
||||
$customerId = (int)($owner['customer_id'] ?? 0);
|
||||
|
||||
$name = trim((string)($this->in['name'] ?? ''));
|
||||
$email = trim(strtolower((string)($this->in['email'] ?? '')));
|
||||
$role = $this->sanitizeRole((string)($this->in['role'] ?? 'user'));
|
||||
|
||||
if ($name === '' || $email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->fail('Name und gültige E-Mail sind erforderlich', null, 422);
|
||||
}
|
||||
$this->assertEmailUnique($email, $customerId, null);
|
||||
|
||||
$password = $this->generateReadablePassword();
|
||||
$hash = $this->hashUserPassword($password);
|
||||
|
||||
$cols = $this->authUserColumns();
|
||||
$table = $cols['table'];
|
||||
$dbCols = $this->tableColumns($table);
|
||||
|
||||
$data = [];
|
||||
$data[$cols['col_name']] = $name;
|
||||
$data[$cols['col_email']] = $email;
|
||||
$data[$cols['col_pass']] = $hash;
|
||||
if ($this->columnExists($dbCols, $cols['col_role'])) $data[$cols['col_role']] = $role;
|
||||
if ($this->columnExists($dbCols, $cols['col_status'])) $data[$cols['col_status']] = 1;
|
||||
if ($this->columnExists($dbCols, $cols['col_customer'])) $data[$cols['col_customer']] = $customerId;
|
||||
if ($this->columnExists($dbCols, 'created_at')) $data['created_at'] = date('Y-m-d H:i:s');
|
||||
if ($this->columnExists($dbCols, 'updated_at')) $data['updated_at'] = date('Y-m-d H:i:s');
|
||||
|
||||
$columns = array_keys($data);
|
||||
$insertCols = implode(',', array_map(fn($c) => "`$c`", $columns));
|
||||
$placeholders = implode(',', array_map(fn($c) => ":$c", $columns));
|
||||
$sql = sprintf('INSERT INTO `%s` (%s) VALUES (%s)', $table, $insertCols, $placeholders);
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
foreach ($data as $col => $value) $stmt->bindValue(":$col", $value);
|
||||
$stmt->execute();
|
||||
$newId = (int)$this->pdo->lastInsertId();
|
||||
|
||||
$newUser = $this->fetchUserRow($newId, $customerId);
|
||||
$this->respond(['ok' => true, 'user' => $newUser, 'temp_password' => $password]);
|
||||
}
|
||||
|
||||
private function handleAccountUsersUpdate(): void
|
||||
{
|
||||
$owner = $this->authService->requireAuth();
|
||||
$this->ensureOwner($owner);
|
||||
$customerId = (int)($owner['customer_id'] ?? 0);
|
||||
|
||||
$userId = (int)($this->in['user_id'] ?? 0);
|
||||
if ($userId <= 0) $this->fail('Ungültige Nutzer-ID', null, 422);
|
||||
$target = $this->fetchUserRow($userId, $customerId);
|
||||
if (!$target) $this->fail('Nutzer nicht gefunden', null, 404);
|
||||
|
||||
$cols = $this->authUserColumns();
|
||||
$table = $cols['table'];
|
||||
$dbCols = $this->tableColumns($table);
|
||||
$set = [];
|
||||
$params = [':id' => $userId];
|
||||
|
||||
$name = trim((string)($this->in['name'] ?? $target['name']));
|
||||
$email = trim(strtolower((string)($this->in['email'] ?? $target['email'])));
|
||||
$role = $this->sanitizeRole((string)($this->in['role'] ?? $target['role']));
|
||||
$isActive = isset($this->in['is_active']) ? (int)(bool)$this->in['is_active'] : (int)$target['is_active'];
|
||||
$resetPassword = !empty($this->in['reset_password']);
|
||||
|
||||
if ($name === '' || $email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->fail('Name und gültige E-Mail sind erforderlich', null, 422);
|
||||
}
|
||||
if (strtolower($email) !== strtolower($target['email'])) {
|
||||
$this->assertEmailUnique($email, $customerId, $userId);
|
||||
}
|
||||
|
||||
if ($this->columnExists($dbCols, $cols['col_name'])) {
|
||||
$set[] = sprintf('`%s` = :name', $cols['col_name']);
|
||||
$params[':name'] = $name;
|
||||
}
|
||||
if ($this->columnExists($dbCols, $cols['col_email'])) {
|
||||
$set[] = sprintf('`%s` = :email', $cols['col_email']);
|
||||
$params[':email'] = $email;
|
||||
}
|
||||
if ($this->columnExists($dbCols, $cols['col_role'])) {
|
||||
if ($target['role'] === 'owner' && $role !== 'owner' && $this->countOwners($customerId, $userId) < 1) {
|
||||
$this->fail('Mindestens ein Owner muss bestehen bleiben', null, 422);
|
||||
}
|
||||
$set[] = sprintf('`%s` = :role', $cols['col_role']);
|
||||
$params[':role'] = $role;
|
||||
}
|
||||
if ($this->columnExists($dbCols, $cols['col_status'])) {
|
||||
if ($target['role'] === 'owner' && !$isActive && $this->countOwners($customerId, $userId) < 1) {
|
||||
$this->fail('Mindestens ein Owner muss aktiv bleiben', null, 422);
|
||||
}
|
||||
$set[] = sprintf('`%s` = :status', $cols['col_status']);
|
||||
$params[':status'] = $isActive;
|
||||
}
|
||||
$tempPassword = null;
|
||||
if ($resetPassword) {
|
||||
$tempPassword = $this->generateReadablePassword();
|
||||
$hash = $this->hashUserPassword($tempPassword);
|
||||
$set[] = sprintf('`%s` = :pwd', $cols['col_pass']);
|
||||
$params[':pwd'] = $hash;
|
||||
}
|
||||
if ($this->columnExists($dbCols, 'updated_at')) {
|
||||
$set[] = '`updated_at` = :updated_at';
|
||||
$params[':updated_at'] = date('Y-m-d H:i:s');
|
||||
}
|
||||
if (!$set) $this->fail('Keine Änderungen erkannt', null, 422);
|
||||
|
||||
$sql = sprintf('UPDATE `%s` SET %s WHERE `%s` = :id LIMIT 1', $table, implode(',', $set), $cols['col_id']);
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
|
||||
$stmt->execute();
|
||||
|
||||
$updated = $this->fetchUserRow($userId, $customerId);
|
||||
$resp = ['ok' => true, 'user' => $updated];
|
||||
if ($tempPassword !== null) $resp['temp_password'] = $tempPassword;
|
||||
$this->respond($resp);
|
||||
}
|
||||
|
||||
private function handleAccountUsersDelete(): void
|
||||
{
|
||||
$owner = $this->authService->requireAuth();
|
||||
$this->ensureOwner($owner);
|
||||
$customerId = (int)($owner['customer_id'] ?? 0);
|
||||
|
||||
$userId = (int)($this->in['user_id'] ?? 0);
|
||||
if ($userId <= 0) $this->fail('Ungültige Nutzer-ID', null, 422);
|
||||
if ($userId === (int)($owner['id'] ?? 0)) $this->fail('Du kannst dich nicht selbst löschen', null, 422);
|
||||
|
||||
$cols = $this->authUserColumns();
|
||||
$table = $cols['table'];
|
||||
$dbCols = $this->tableColumns($table);
|
||||
|
||||
$target = $this->fetchUserRow($userId, $customerId);
|
||||
if (!$target) $this->fail('Nutzer nicht gefunden', null, 404);
|
||||
if ($target['role'] === 'owner' && $this->countOwners($customerId, $userId) < 1) {
|
||||
$this->fail('Mindestens ein Owner muss bestehen bleiben', null, 422);
|
||||
}
|
||||
|
||||
if ($this->columnExists($dbCols, $cols['col_status'])) {
|
||||
$sql = sprintf('UPDATE `%s` SET `%s` = 0 WHERE `%s` = :id LIMIT 1', $table, $cols['col_status'], $cols['col_id']);
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute([':id' => $userId]);
|
||||
} else {
|
||||
$sql = sprintf('DELETE FROM `%s` WHERE `%s` = :id LIMIT 1', $table, $cols['col_id']);
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute([':id' => $userId]);
|
||||
}
|
||||
|
||||
$this->respond(['ok' => true, 'deleted' => true]);
|
||||
}
|
||||
|
||||
private function handleDownloadFile(string $type): void
|
||||
{
|
||||
$user = $this->authService->requireAuth();
|
||||
$this->ensureOwner($user);
|
||||
$customerId = (int)($user['customer_id'] ?? 0);
|
||||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||||
|
||||
$settings = $this->ensureSettingsTokens($customerId, $this->getCustomerSettings($customerId));
|
||||
$baseDir = dirname(__DIR__);
|
||||
if ($type === 'bridge') {
|
||||
$path = $baseDir . '/download/emailtemplate_bridge.php';
|
||||
} elseif ($type === 'sender') {
|
||||
$path = $baseDir . '/download/emailtemplate_sender.php';
|
||||
} else {
|
||||
$this->fail('Unknown download type', $type, 404);
|
||||
}
|
||||
if (!is_file($path)) {
|
||||
$this->fail('Datei nicht gefunden', basename($path), 404);
|
||||
}
|
||||
$content = (string)file_get_contents($path);
|
||||
if ($type === 'bridge') {
|
||||
$content = str_replace('REPLACE_WITH_SHARED_TOKEN', $settings['bridge_token'] ?? '', $content);
|
||||
} else {
|
||||
$apiBase = $this->defaultApiBase();
|
||||
$content = str_replace('REPLACE_WITH_SHARED_TOKEN', $settings['sender_token'] ?? '', $content);
|
||||
$content = str_replace('REPLACE_WITH_TEMPLATE_API_TOKEN', $settings['external_api_token'] ?? '', $content);
|
||||
if ($apiBase) {
|
||||
$content = str_replace('https://api.emailtemplate.it/external/render', $apiBase, $content);
|
||||
}
|
||||
}
|
||||
|
||||
$this->respond([
|
||||
'ok' => true,
|
||||
'file_name' => basename($path),
|
||||
'content' => base64_encode($content),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveBridgeConfig(?int $customerId): array
|
||||
{
|
||||
$fileConf = $this->conf['placeholders']['bridge'] ?? [];
|
||||
$settings = $customerId ? $this->getCustomerSettings($customerId) : [];
|
||||
$url = $settings['bridge_url'] ?? ($fileConf['url'] ?? '');
|
||||
$token = $settings['bridge_token'] ?? ($fileConf['token'] ?? '');
|
||||
$ttl = $fileConf['cache_ttl'] ?? 300;
|
||||
return ['url' => $url, 'token' => $token, 'cache_ttl' => $ttl];
|
||||
}
|
||||
|
||||
private function getCustomerSettings(int $customerId): array
|
||||
{
|
||||
if ($customerId <= 0) return [];
|
||||
$table = $this->customerSettingsTable();
|
||||
$stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :id LIMIT 1");
|
||||
$stmt->execute([':id' => $customerId]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ?: [];
|
||||
}
|
||||
|
||||
private function saveCustomerSettings(int $customerId, array $data): array
|
||||
{
|
||||
if ($customerId <= 0) return [];
|
||||
$allowed = ['bridge_url', 'bridge_token', 'sender_token', 'external_api_token'];
|
||||
$fields = array_intersect_key($data, array_flip($allowed));
|
||||
if (!$fields) return $this->getCustomerSettings($customerId);
|
||||
$fields['customer_id'] = $customerId;
|
||||
$columns = array_keys($fields);
|
||||
$insertCols = implode(',', array_map(fn($c) => "`$c`", $columns));
|
||||
$placeholders = implode(',', array_map(fn($c) => ":$c", $columns));
|
||||
$updates = [];
|
||||
foreach ($columns as $col) {
|
||||
if ($col === 'customer_id') continue;
|
||||
$updates[] = "`$col` = VALUES(`$col`)";
|
||||
}
|
||||
$table = $this->customerSettingsTable();
|
||||
$sql = "INSERT INTO `$table` ($insertCols) VALUES ($placeholders) ON DUPLICATE KEY UPDATE " . implode(',', $updates);
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
foreach ($fields as $col => $value) {
|
||||
$stmt->bindValue(":$col", $value);
|
||||
}
|
||||
$stmt->execute();
|
||||
return $this->getCustomerSettings($customerId);
|
||||
}
|
||||
|
||||
private function ensureSettingsTokens(int $customerId, array $settings): array
|
||||
{
|
||||
if ($customerId <= 0) return $settings;
|
||||
$changed = false;
|
||||
foreach (['bridge_token', 'sender_token', 'external_api_token'] as $key) {
|
||||
if (empty($settings[$key])) {
|
||||
$settings[$key] = $this->generateToken();
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
if ($changed) {
|
||||
$settings = $this->saveCustomerSettings($customerId, $settings);
|
||||
}
|
||||
return $settings;
|
||||
}
|
||||
|
||||
private function customerSettingsTable(): string
|
||||
{
|
||||
return 'emailtemplate_customer_settings';
|
||||
}
|
||||
|
||||
private function generateToken(int $length = 48): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode(random_bytes($length)), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private function generateReadablePassword(int $length = 12): string
|
||||
{
|
||||
$bytes = bin2hex(random_bytes($length));
|
||||
return substr($bytes, 0, $length);
|
||||
}
|
||||
|
||||
private function authUserColumns(): array
|
||||
{
|
||||
$db = $this->conf['auth']['db'] ?? [];
|
||||
return [
|
||||
'table' => $db['table'] ?? 'customer_users',
|
||||
'col_id' => $db['col_id'] ?? 'id',
|
||||
'col_email' => $db['col_user'] ?? 'email',
|
||||
'col_pass' => $db['col_pass'] ?? 'password_hash',
|
||||
'col_name' => $db['col_name'] ?? 'name',
|
||||
'col_role' => $db['col_role'] ?? 'role',
|
||||
'col_status' => $db['col_status'] ?? 'is_active',
|
||||
'col_customer' => $db['customer_fk'] ?? 'customer_id',
|
||||
];
|
||||
}
|
||||
|
||||
private function columnExists(array $columns, string $name): bool
|
||||
{
|
||||
if ($name === '') return false;
|
||||
return in_array($name, $columns, true);
|
||||
}
|
||||
|
||||
private function sanitizeRole(string $role): string
|
||||
{
|
||||
$role = strtolower($role);
|
||||
$valid = ['owner', 'admin', 'editor', 'viewer'];
|
||||
return in_array($role, $valid, true) ? $role : 'user';
|
||||
}
|
||||
|
||||
private function assertEmailUnique(string $email, int $customerId, ?int $excludeId): void
|
||||
{
|
||||
$cols = $this->authUserColumns();
|
||||
$table = $cols['table'];
|
||||
$dbCols = $this->tableColumns($table);
|
||||
$conditions = [sprintf('`%s` = :email', $cols['col_email'])];
|
||||
if ($this->columnExists($dbCols, $cols['col_customer'])) {
|
||||
$conditions[] = sprintf('`%s` = :cid', $cols['col_customer']);
|
||||
}
|
||||
if ($excludeId) {
|
||||
$conditions[] = sprintf('`%s` != :exclude', $cols['col_id']);
|
||||
}
|
||||
$sql = sprintf('SELECT COUNT(*) FROM `%s` WHERE %s', $table, implode(' AND ', $conditions));
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->bindValue(':email', $email);
|
||||
if ($this->columnExists($dbCols, $cols['col_customer'])) {
|
||||
$stmt->bindValue(':cid', $customerId);
|
||||
}
|
||||
if ($excludeId) $stmt->bindValue(':exclude', $excludeId, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
if ((int)$stmt->fetchColumn() > 0) {
|
||||
$this->fail('E-Mail-Adresse ist bereits vergeben', null, 422);
|
||||
}
|
||||
}
|
||||
|
||||
private function fetchUserRow(int $userId, int $customerId): array
|
||||
{
|
||||
$cols = $this->authUserColumns();
|
||||
$table = $cols['table'];
|
||||
$sql = sprintf(
|
||||
'SELECT * FROM `%s` WHERE `%s` = :id AND `%s` = :cid LIMIT 1',
|
||||
$table,
|
||||
$cols['col_id'],
|
||||
$cols['col_customer']
|
||||
);
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute([':id' => $userId, ':cid' => $customerId]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) $this->fail('Nutzer nicht gefunden', null, 404);
|
||||
return $this->formatUserOutput([
|
||||
'user_id' => $row[$cols['col_id']],
|
||||
'name' => $row[$cols['col_name']] ?? '',
|
||||
'email' => $row[$cols['col_email']] ?? '',
|
||||
'role' => $row[$cols['col_role']] ?? 'user',
|
||||
'is_active' => $row[$cols['col_status']] ?? 1,
|
||||
'created_at' => $row['created_at'] ?? null,
|
||||
'updated_at' => $row['updated_at'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function formatUserOutput(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int)($row['user_id'] ?? $row['id'] ?? 0),
|
||||
'name' => $row['name'] ?? '',
|
||||
'email' => $row['email'] ?? '',
|
||||
'role' => $row['role'] ?? 'user',
|
||||
'is_active' => (int)($row['is_active'] ?? 1),
|
||||
'created_at' => $row['created_at'] ?? null,
|
||||
'updated_at' => $row['updated_at'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function countOwners(int $customerId, ?int $excludeId = null): int
|
||||
{
|
||||
$cols = $this->authUserColumns();
|
||||
$table = $cols['table'];
|
||||
$dbCols = $this->tableColumns($table);
|
||||
$conditions = [
|
||||
sprintf('`%s` = :cid', $cols['col_customer']),
|
||||
sprintf('`%s` = :role', $cols['col_role']),
|
||||
];
|
||||
if ($this->columnExists($dbCols, $cols['col_status'])) {
|
||||
$conditions[] = sprintf('`%s` = 1', $cols['col_status']);
|
||||
}
|
||||
if ($excludeId) {
|
||||
$conditions[] = sprintf('`%s` != :exclude', $cols['col_id']);
|
||||
}
|
||||
$sql = sprintf('SELECT COUNT(*) FROM `%s` WHERE %s', $table, implode(' AND ', $conditions));
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->bindValue(':cid', $customerId);
|
||||
$stmt->bindValue(':role', 'owner');
|
||||
if ($excludeId) $stmt->bindValue(':exclude', $excludeId, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
return (int)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
private function verifyUserPasswordValue(string $input, string $stored): bool
|
||||
{
|
||||
if (preg_match('~^\$2[aby]\$~', $stored) || strpos($stored, '$argon2') === 0) return password_verify($input, $stored);
|
||||
$legacy = strtolower($this->conf['auth']['db']['legacy'] ?? '');
|
||||
if ($legacy === 'md5') return hash_equals($stored, md5($input));
|
||||
if ($legacy === 'sha1') return hash_equals($stored, sha1($input));
|
||||
if (password_get_info($stored)['algo'] !== 0) return password_verify($input, $stored);
|
||||
return hash_equals($stored, $input);
|
||||
}
|
||||
|
||||
private function hashUserPassword(string $password): string
|
||||
{
|
||||
return password_hash($password, PASSWORD_DEFAULT);
|
||||
}
|
||||
|
||||
private function ensureOwner(array $user): void
|
||||
{
|
||||
if (($user['role'] ?? '') !== 'owner') {
|
||||
$this->fail('Nur Owner dürfen diese Aktion ausführen', null, 403);
|
||||
}
|
||||
}
|
||||
|
||||
private function defaultApiBase(): string
|
||||
{
|
||||
$base = $this->conf['base_url'] ?? '';
|
||||
return $base ? rtrim($base, '/') . '/api.php' : '/api.php';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ class AuthService
|
||||
$colName = $authDb['col_name'] ?? 'name';
|
||||
$colId = $authDb['col_id'] ?? 'id';
|
||||
$colStatus = $authDb['col_status']?? null;
|
||||
$colRole = $authDb['col_role'] ?? 'role';
|
||||
$colCustomer = $authDb['customer_fk'] ?? 'customer_id';
|
||||
$customerTable = $authDb['customer_table'] ?? null;
|
||||
$activeValues = $authDb['active_values'] ?? ['active','1',1];
|
||||
$table = $authDb['table'] ?? 'emailtemplate_users';
|
||||
|
||||
@@ -92,14 +95,38 @@ class AuthService
|
||||
$this->fail('Invalid credentials', null, 401);
|
||||
}
|
||||
|
||||
$customerId = isset($row[$colCustomer]) ? (int)$row[$colCustomer] : null;
|
||||
$customerData = $customerId ? $this->fetchCustomerData($customerId, $customerTable, $authDb) : null;
|
||||
|
||||
$_SESSION['auth'] = [
|
||||
'id' => $row[$colId] ?? null,
|
||||
'name' => $row[$colName] ?? ($row[$colUser] ?? $identifier),
|
||||
'email' => $row[$colUser] ?? $identifier,
|
||||
'at' => time(),
|
||||
'id' => $row[$colId] ?? null,
|
||||
'name' => $row[$colName] ?? ($row[$colUser] ?? $identifier),
|
||||
'email' => $row[$colUser] ?? $identifier,
|
||||
'role' => $row[$colRole] ?? 'user',
|
||||
'customer_id' => $customerId,
|
||||
'customer' => $customerData,
|
||||
'permissions' => [
|
||||
'owner' => ($row[$colRole] ?? '') === 'owner',
|
||||
],
|
||||
'at' => time(),
|
||||
];
|
||||
|
||||
$token = base64_encode(hash('sha256', ($_SESSION['auth']['id'] ?? $identifier).'|'.session_id(), true));
|
||||
return ['user'=>$_SESSION['auth'], 'token'=>$token];
|
||||
}
|
||||
|
||||
private function fetchCustomerData(?int $customerId, ?string $table, array $authDb): ?array
|
||||
{
|
||||
if (!$customerId || !$table) return null;
|
||||
$cols = $authDb['customer_cols'] ?? [];
|
||||
$select = ['`id`'];
|
||||
foreach ($cols as $alias => $column) {
|
||||
$select[] = sprintf('`%s` AS `%s`', $column, $alias);
|
||||
}
|
||||
$sql = sprintf('SELECT %s FROM `%s` WHERE `id` = :id LIMIT 1', implode(',', $select), $table);
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute([':id' => $customerId]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user