From 6414802ce8d5c9687323e73cf9e1c1cb5bc7498f Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Sun, 7 Dec 2025 02:49:46 +0100 Subject: [PATCH] up --- config/emailtemplate.conf.php | 9 + public/assets/js/app.js | 4 +- public/assets/js/ui-user.js | 357 +++++++++++++++++++ public/index.php | 143 +++++++- schema.sql | 14 +- src/ApiKernel.php | 637 +++++++++++++++++++++++++++++++++- src/AuthService.php | 35 +- 7 files changed, 1190 insertions(+), 9 deletions(-) create mode 100644 public/assets/js/ui-user.js diff --git a/config/emailtemplate.conf.php b/config/emailtemplate.conf.php index 4655572..6b8edb4 100644 --- a/config/emailtemplate.conf.php +++ b/config/emailtemplate.conf.php @@ -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', ], diff --git a/public/assets/js/app.js b/public/assets/js/app.js index f6f2dc5..52c76cb 100644 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -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); - diff --git a/public/assets/js/ui-user.js b/public/assets/js/ui-user.js new file mode 100644 index 0000000..0be17fc --- /dev/null +++ b/public/assets/js/ui-user.js @@ -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 = 'Keine Nutzer vorhanden.'; + return; + } + tbody.innerHTML = state.users.map(user => { + const badge = user.is_active ? 'Aktiv' : 'Inaktiv'; + return ` + ${escapeHtml(user.name)} + ${escapeHtml(user.email)} + ${escapeHtml(user.role)} + ${badge} + + + + + + `; + }).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, '''); +} diff --git a/public/index.php b/public/index.php index 7b7a8b9..96446a5 100644 --- a/public/index.php +++ b/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} @@ -47,8 +57,11 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time(); -
+
+
@@ -152,9 +165,137 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time(); + + +
+
+ Mein Konto + +
+
+
+ + + + +
+ +
+

Profil

+
+ + +
+ +
+
+
+ + + + + + +
+
+
+
+ diff --git a/schema.sql b/schema.sql index f406c26..0f043b2 100644 --- a/schema.sql +++ b/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; - diff --git a/src/ApiKernel.php b/src/ApiKernel.php index b54fda7..abc65e5 100644 --- a/src/ApiKernel.php +++ b/src/ApiKernel.php @@ -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'; + } } diff --git a/src/AuthService.php b/src/AuthService.php index f0112d4..58fae71 100644 --- a/src/AuthService.php +++ b/src/AuthService.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; + } }