sdasd
This commit is contained in:
@@ -27,6 +27,7 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time();
|
|||||||
.team-table{width:100%;border-collapse:collapse;font-size:.9rem}
|
.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}
|
.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}
|
.badge{display:inline-flex;align-items:center;padding:.1rem .5rem;border-radius:999px;font-size:.75rem;background:#e2e8f0;color:#0f172a}
|
||||||
|
.chip{display:inline-flex;align-items:center;padding:.15rem .55rem;border-radius:999px;background:#f1f5f9;color:#0f172a;border:1px solid #e2e8f0;font-size:.8rem}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-slate-50 text-slate-800" data-page="account">
|
<body class="bg-slate-50 text-slate-800" data-page="account">
|
||||||
@@ -45,8 +46,9 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time();
|
|||||||
<div class="user-tabs">
|
<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="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="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="senders" class="btn flex-1 hidden" data-role="admin">Absender</button>
|
||||||
<button type="button" data-user-tab="integration" class="btn flex-1 owner-only hidden">Integrationen</button>
|
<button type="button" data-user-tab="team" class="btn flex-1 hidden" data-role="owner">Team</button>
|
||||||
|
<button type="button" data-user-tab="integration" class="btn flex-1 hidden" data-role="admin">Integrationen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section data-user-panel="profile" class="section-card">
|
<section data-user-panel="profile" class="section-card">
|
||||||
@@ -79,7 +81,7 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time();
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section data-user-panel="team" class="section-card hidden owner-only">
|
<section data-user-panel="team" class="section-card hidden" data-role="owner">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<h4>Team</h4>
|
<h4>Team</h4>
|
||||||
<button type="button" id="btn-user-add" class="btn">+ Nutzer</button>
|
<button type="button" id="btn-user-add" class="btn">+ Nutzer</button>
|
||||||
@@ -121,7 +123,41 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time();
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section data-user-panel="integration" class="section-card hidden owner-only">
|
<section data-user-panel="senders" class="section-card hidden" data-role="admin">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h4>Absender für Testmails</h4>
|
||||||
|
<button type="button" id="btn-sender-add" class="btn">+ Absender</button>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-auto">
|
||||||
|
<table class="team-table" id="senderTable">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Bezeichnung</th><th>From-Name</th><th>E-Mail</th><th>Reply-To</th><th class="text-right">Aktionen</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<form id="senderForm" class="space-y-3 mt-4 hidden">
|
||||||
|
<input type="hidden" name="sender_id">
|
||||||
|
<label class="block text-sm text-slate-600">Bezeichnung
|
||||||
|
<input type="text" name="label" class="input mt-1" placeholder="Interner Name (optional)">
|
||||||
|
</label>
|
||||||
|
<label class="block text-sm text-slate-600">Absender-Name
|
||||||
|
<input type="text" name="from_name" class="input mt-1" placeholder="z.B. Newsletter Team">
|
||||||
|
</label>
|
||||||
|
<label class="block text-sm text-slate-600">Absender-E-Mail
|
||||||
|
<input type="email" name="from_email" class="input mt-1" required placeholder="news@example.com">
|
||||||
|
</label>
|
||||||
|
<label class="block text-sm text-slate-600">Reply-To (optional)
|
||||||
|
<input type="email" name="reply_to" class="input mt-1" placeholder="support@example.com">
|
||||||
|
</label>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button type="button" id="senderFormCancel" class="btn">Abbrechen</button>
|
||||||
|
<button type="submit" class="btn">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section data-user-panel="integration" class="section-card hidden" data-role="admin">
|
||||||
<h4>Integrationen & Tokens</h4>
|
<h4>Integrationen & Tokens</h4>
|
||||||
<form id="settingsForm" class="space-y-3">
|
<form id="settingsForm" class="space-y-3">
|
||||||
<label class="block text-sm text-slate-600">Bridge-URL
|
<label class="block text-sm text-slate-600">Bridge-URL
|
||||||
@@ -148,8 +184,17 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time();
|
|||||||
<button type="button" class="btn" data-rotate="external">Neu erstellen</button>
|
<button type="button" class="btn" data-rotate="external">Neu erstellen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-slate-600">Verfügbare Tabellen (kommagetrennt oder Zeilen)</label>
|
||||||
|
<textarea name="bridge_tables" class="input mt-1" rows="3" placeholder="z.B. customers, orders"></textarea>
|
||||||
|
<p class="text-xs text-slate-500 mt-1">Nur diese Tabellen werden als Platzhalter angeboten. Leer = alle.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<button type="button" id="btn-validate-bridge" class="btn w-max" data-role="admin">Verbindung prüfen & Tabellen laden</button>
|
||||||
|
<div id="bridgeTablesPreview" class="flex flex-wrap gap-2 text-sm text-slate-600">Noch nicht geprüft.</div>
|
||||||
|
</div>
|
||||||
<div class="flex justify-between gap-2 flex-wrap pt-2">
|
<div class="flex justify-between gap-2 flex-wrap pt-2">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2" data-role="admin">
|
||||||
<button type="button" class="btn" data-download="bridge">Bridge-Datei</button>
|
<button type="button" class="btn" data-download="bridge">Bridge-Datei</button>
|
||||||
<button type="button" class="btn" data-download="sender">Sender-Datei</button>
|
<button type="button" class="btn" data-download="sender">Sender-Datei</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,12 +21,16 @@ export function initEditor() {
|
|||||||
const sendInfo = document.getElementById('send_template_info');
|
const sendInfo = document.getElementById('send_template_info');
|
||||||
const btnCancelSend= document.getElementById('btn-cancel-send');
|
const btnCancelSend= document.getElementById('btn-cancel-send');
|
||||||
const btnSendNow = document.getElementById('btn-send-now');
|
const btnSendNow = document.getElementById('btn-send-now');
|
||||||
|
const sendSender = document.getElementById('send_sender');
|
||||||
|
const sendSenderHint = document.getElementById('send_sender_hint');
|
||||||
const prevFrame = document.getElementById('previewFrame');
|
const prevFrame = document.getElementById('previewFrame');
|
||||||
const btnPrevClose = document.getElementById('btn-close-preview');
|
const btnPrevClose = document.getElementById('btn-close-preview');
|
||||||
|
|
||||||
let current = null; // { resource, id, name }
|
let current = null; // { resource, id, name }
|
||||||
let bridgeListener = null;
|
let bridgeListener = null;
|
||||||
let reqToken = 0; // steigender Token pro Öffnen -> ignoriert verspätete Events
|
let reqToken = 0; // steigender Token pro Öffnen -> ignoriert verspätete Events
|
||||||
|
let senderOptions = [];
|
||||||
|
let senderLoadPromise = null;
|
||||||
|
|
||||||
const ok = (m) => toast(m, true);
|
const ok = (m) => toast(m, true);
|
||||||
const err = (m) => toast(m, false);
|
const err = (m) => toast(m, false);
|
||||||
@@ -228,6 +232,43 @@ export function initEditor() {
|
|||||||
return (rows || []).map(r => ({ id: r.id, name: r.name, html: r.content || r.html || '' }));
|
return (rows || []).map(r => ({ id: r.id, name: r.name, html: r.content || r.html || '' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadSenderOptions(force = false) {
|
||||||
|
if (!sendSender) return;
|
||||||
|
if (senderLoadPromise && !force) return senderLoadPromise;
|
||||||
|
senderLoadPromise = apiAction('account.senders.list', { method: 'GET' })
|
||||||
|
.then(res => {
|
||||||
|
senderOptions = res?.items || [];
|
||||||
|
renderSenderOptions();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
senderOptions = [];
|
||||||
|
renderSenderOptions();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
senderLoadPromise = null;
|
||||||
|
});
|
||||||
|
return senderLoadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSenderOptions() {
|
||||||
|
if (!sendSender) return;
|
||||||
|
const previous = sendSender.value;
|
||||||
|
let html = '<option value="">Standard (System)</option>';
|
||||||
|
senderOptions.forEach(opt => {
|
||||||
|
const label = opt.label || opt.from_name || opt.from_email;
|
||||||
|
html += `<option value="${opt.id}">${escapeHtml(label)} <${escapeHtml(opt.from_email)}></option>`;
|
||||||
|
});
|
||||||
|
sendSender.innerHTML = html;
|
||||||
|
if (previous && senderOptions.some(opt => String(opt.id) === previous)) {
|
||||||
|
sendSender.value = previous;
|
||||||
|
} else {
|
||||||
|
sendSender.value = '';
|
||||||
|
}
|
||||||
|
if (sendSenderHint) {
|
||||||
|
sendSenderHint.classList.toggle('hidden', senderOptions.length > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Initialen HTML-Inhalt in Editor pushen (mit Token/Race-Schutz) ----------
|
// ---------- Initialen HTML-Inhalt in Editor pushen (mit Token/Race-Schutz) ----------
|
||||||
async function pushInitialHtmlToEditor({ mode, html, snippets, ref, token, hasJson, json }) {
|
async function pushInitialHtmlToEditor({ mode, html, snippets, ref, token, hasJson, json }) {
|
||||||
if (token !== reqToken) return; // veraltete Anfrage ignorieren
|
if (token !== reqToken) return; // veraltete Anfrage ignorieren
|
||||||
@@ -423,9 +464,13 @@ export function initEditor() {
|
|||||||
setSendContext(ctxId, ctxName);
|
setSendContext(ctxId, ctxName);
|
||||||
if (sendSubject) sendSubject.value = ctx?.subject || 'Testversand';
|
if (sendSubject) sendSubject.value = ctx?.subject || 'Testversand';
|
||||||
if (sendTo) sendTo.value = ctx?.to || '';
|
if (sendTo) sendTo.value = ctx?.to || '';
|
||||||
|
await loadSenderOptions(true);
|
||||||
sendDlg?.showModal?.();
|
sendDlg?.showModal?.();
|
||||||
}
|
}
|
||||||
function closeSend(){ sendDlg?.close?.(); }
|
function closeSend(){
|
||||||
|
sendDlg?.close?.();
|
||||||
|
if (sendSender) sendSender.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
async function doSend(ev){
|
async function doSend(ev){
|
||||||
ev?.preventDefault?.();
|
ev?.preventDefault?.();
|
||||||
@@ -437,7 +482,15 @@ export function initEditor() {
|
|||||||
if(!id){ toast("Kein Template geladen", false); return; }
|
if(!id){ toast("Kein Template geladen", false); return; }
|
||||||
// Hier wird der gespeicherte HTML-Code verwendet, nicht der Live-HTML, da apiAction
|
// Hier wird der gespeicherte HTML-Code verwendet, nicht der Live-HTML, da apiAction
|
||||||
// keine Live-Daten erwartet. Es geht um template_id.
|
// keine Live-Daten erwartet. Es geht um template_id.
|
||||||
const r = await apiAction('templates.test_send', { method:'POST', data:{ template_id: id, to, subject: (sendSubject?.value || 'Testversand') } });
|
const payload = {
|
||||||
|
template_id: id,
|
||||||
|
to,
|
||||||
|
subject: (sendSubject?.value || 'Testversand'),
|
||||||
|
};
|
||||||
|
if (sendSender && sendSender.value) {
|
||||||
|
payload.sender_id = Number(sendSender.value);
|
||||||
|
}
|
||||||
|
const r = await apiAction('templates.test_send', { method:'POST', data: payload });
|
||||||
if(r?.ok){ toast("Testversand ausgelöst"); closeSend(); } else { toast("Senden fehlgeschlagen", false); }
|
if(r?.ok){ toast("Testversand ausgelöst"); closeSend(); } else { toast("Senden fehlgeschlagen", false); }
|
||||||
}
|
}
|
||||||
function closePreview(){ prevDlg?.close?.(); }
|
function closePreview(){ prevDlg?.close?.(); }
|
||||||
@@ -488,6 +541,15 @@ export function initEditor() {
|
|||||||
window.EditorUI = { open, save, close, clear: clearEditor, preview: openPreview };
|
window.EditorUI = { open, save, close, clear: clearEditor, preview: openPreview };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
// Default-Export + globaler Fallback
|
// Default-Export + globaler Fallback
|
||||||
export default initEditor;
|
export default initEditor;
|
||||||
window.initEditor = initEditor;
|
window.initEditor = initEditor;
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ const state = {
|
|||||||
rotate: { bridge: false, sender: false, external: false },
|
rotate: { bridge: false, sender: false, external: false },
|
||||||
users: [],
|
users: [],
|
||||||
userMap: new Map(),
|
userMap: new Map(),
|
||||||
|
senders: [],
|
||||||
|
senderMap: new Map(),
|
||||||
|
bridgeTables: [],
|
||||||
currentTab: 'profile',
|
currentTab: 'profile',
|
||||||
loading: false,
|
loading: false,
|
||||||
};
|
};
|
||||||
@@ -15,6 +18,10 @@ let passwordForm;
|
|||||||
let settingsForm;
|
let settingsForm;
|
||||||
let teamTable;
|
let teamTable;
|
||||||
let userForm;
|
let userForm;
|
||||||
|
let senderTable;
|
||||||
|
let senderForm;
|
||||||
|
let bridgePreview;
|
||||||
|
let validateBridgeBtn;
|
||||||
|
|
||||||
export function initUserPanel() {
|
export function initUserPanel() {
|
||||||
avatarBtn = document.getElementById('btn-user');
|
avatarBtn = document.getElementById('btn-user');
|
||||||
@@ -27,15 +34,25 @@ export function initAccountPage() {
|
|||||||
settingsForm = document.getElementById('settingsForm');
|
settingsForm = document.getElementById('settingsForm');
|
||||||
teamTable = document.getElementById('teamTable');
|
teamTable = document.getElementById('teamTable');
|
||||||
userForm = document.getElementById('userForm');
|
userForm = document.getElementById('userForm');
|
||||||
|
senderTable = document.getElementById('senderTable');
|
||||||
|
senderForm = document.getElementById('senderForm');
|
||||||
|
bridgePreview = document.getElementById('bridgeTablesPreview');
|
||||||
|
validateBridgeBtn = document.getElementById('btn-validate-bridge');
|
||||||
|
|
||||||
document.getElementById('btn-user-add')?.addEventListener('click', () => openUserForm());
|
document.getElementById('btn-user-add')?.addEventListener('click', () => openUserForm());
|
||||||
document.getElementById('userFormCancel')?.addEventListener('click', () => closeUserForm());
|
document.getElementById('userFormCancel')?.addEventListener('click', () => closeUserForm());
|
||||||
userForm?.addEventListener('submit', submitUserForm);
|
userForm?.addEventListener('submit', submitUserForm);
|
||||||
|
|
||||||
|
document.getElementById('btn-sender-add')?.addEventListener('click', () => openSenderForm());
|
||||||
|
document.getElementById('senderFormCancel')?.addEventListener('click', () => closeSenderForm());
|
||||||
|
senderForm?.addEventListener('submit', submitSenderForm);
|
||||||
|
|
||||||
profileForm?.addEventListener('submit', submitProfileForm);
|
profileForm?.addEventListener('submit', submitProfileForm);
|
||||||
passwordForm?.addEventListener('submit', submitPasswordForm);
|
passwordForm?.addEventListener('submit', submitPasswordForm);
|
||||||
settingsForm?.addEventListener('submit', submitSettingsForm);
|
settingsForm?.addEventListener('submit', submitSettingsForm);
|
||||||
teamTable?.addEventListener('click', handleTeamTableClick);
|
teamTable?.addEventListener('click', handleTeamTableClick);
|
||||||
|
senderTable?.addEventListener('click', handleSenderTableClick);
|
||||||
|
validateBridgeBtn?.addEventListener('click', validateBridgeSettings);
|
||||||
|
|
||||||
document.querySelectorAll('[data-user-tab]').forEach(btn => {
|
document.querySelectorAll('[data-user-tab]').forEach(btn => {
|
||||||
btn.addEventListener('click', () => switchTab(btn.getAttribute('data-user-tab')));
|
btn.addEventListener('click', () => switchTab(btn.getAttribute('data-user-tab')));
|
||||||
@@ -60,12 +77,18 @@ export function initAccountPage() {
|
|||||||
|
|
||||||
switchTab(state.currentTab);
|
switchTab(state.currentTab);
|
||||||
loadAccountData();
|
loadAccountData();
|
||||||
|
updateRoleVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isOwner() {
|
function isOwner() {
|
||||||
return (window.__currentUser?.role || '').toLowerCase() === 'owner';
|
return (window.__currentUser?.role || '').toLowerCase() === 'owner';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAdmin() {
|
||||||
|
const role = (window.__currentUser?.role || '').toLowerCase();
|
||||||
|
return role === 'owner' || role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
function updateAvatar() {
|
function updateAvatar() {
|
||||||
const target = document.getElementById('userAvatar');
|
const target = document.getElementById('userAvatar');
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
@@ -73,7 +96,19 @@ function updateAvatar() {
|
|||||||
target.textContent = name ? name.trim().charAt(0).toUpperCase() : 'U';
|
target.textContent = name ? name.trim().charAt(0).toUpperCase() : 'U';
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateOwnerVisibility() {
|
function updateRoleVisibility() {
|
||||||
|
const role = (window.__currentUser?.role || '').toLowerCase();
|
||||||
|
document.querySelectorAll('[data-role]').forEach(el => {
|
||||||
|
const allowed = (el.getAttribute('data-role') || '').split(/[\s,]+/).filter(Boolean).map(r => r.toLowerCase());
|
||||||
|
if (!allowed.length) return;
|
||||||
|
const visible = allowed.some(targetRole => {
|
||||||
|
if (targetRole === 'owner') return role === 'owner';
|
||||||
|
if (targetRole === 'admin') return role === 'owner' || role === 'admin';
|
||||||
|
if (targetRole === 'editor') return role === 'owner' || role === 'admin' || role === 'editor';
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
el.classList.toggle('hidden', !visible);
|
||||||
|
});
|
||||||
document.querySelectorAll('.owner-only').forEach(el => {
|
document.querySelectorAll('.owner-only').forEach(el => {
|
||||||
el.classList.toggle('hidden', !isOwner());
|
el.classList.toggle('hidden', !isOwner());
|
||||||
});
|
});
|
||||||
@@ -100,13 +135,20 @@ async function loadAccountData() {
|
|||||||
if (res.user) {
|
if (res.user) {
|
||||||
window.__currentUser = res.user;
|
window.__currentUser = res.user;
|
||||||
updateAvatar();
|
updateAvatar();
|
||||||
updateOwnerVisibility();
|
updateRoleVisibility();
|
||||||
}
|
}
|
||||||
fillProfileForm(res.user);
|
fillProfileForm(res.user);
|
||||||
fillSettingsForm(res.settings || {});
|
fillSettingsForm(res.settings || {});
|
||||||
if (isOwner()) {
|
if (isOwner()) {
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
}
|
}
|
||||||
|
if (isAdmin()) {
|
||||||
|
await loadSenders();
|
||||||
|
} else {
|
||||||
|
state.senders = [];
|
||||||
|
state.senderMap = new Map();
|
||||||
|
renderSenderList();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
toast(err.message || 'Fehler beim Laden', false);
|
toast(err.message || 'Fehler beim Laden', false);
|
||||||
@@ -128,6 +170,9 @@ function fillSettingsForm(settings) {
|
|||||||
settingsForm.bridge_token.value = settings.bridge_token || '';
|
settingsForm.bridge_token.value = settings.bridge_token || '';
|
||||||
settingsForm.sender_token.value = settings.sender_token || '';
|
settingsForm.sender_token.value = settings.sender_token || '';
|
||||||
settingsForm.external_api_token.value = settings.external_api_token || '';
|
settingsForm.external_api_token.value = settings.external_api_token || '';
|
||||||
|
const tables = Array.isArray(settings.bridge_tables) ? settings.bridge_tables : [];
|
||||||
|
settingsForm.bridge_tables ? settingsForm.bridge_tables.value = tables.join(', ') : null;
|
||||||
|
applyBridgePreview(tables);
|
||||||
state.rotate = { bridge: false, sender: false, external: false };
|
state.rotate = { bridge: false, sender: false, external: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +219,7 @@ async function submitSettingsForm(ev) {
|
|||||||
rotate_bridge_token: state.rotate.bridge ? 1 : 0,
|
rotate_bridge_token: state.rotate.bridge ? 1 : 0,
|
||||||
rotate_sender_token: state.rotate.sender ? 1 : 0,
|
rotate_sender_token: state.rotate.sender ? 1 : 0,
|
||||||
rotate_external_token: state.rotate.external ? 1 : 0,
|
rotate_external_token: state.rotate.external ? 1 : 0,
|
||||||
|
bridge_tables: parseBridgeTablesInput(),
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const res = await apiAction('account.settings.update', { method: 'POST', data });
|
const res = await apiAction('account.settings.update', { method: 'POST', data });
|
||||||
@@ -207,6 +253,50 @@ async function downloadFile(type) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseBridgeTablesInput() {
|
||||||
|
if (!settingsForm) return [];
|
||||||
|
const raw = settingsForm.bridge_tables?.value || '';
|
||||||
|
return raw
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.map(part => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBridgePreview(tables) {
|
||||||
|
state.bridgeTables = Array.isArray(tables) ? tables : [];
|
||||||
|
if (!bridgePreview) return;
|
||||||
|
if (!state.bridgeTables.length) {
|
||||||
|
bridgePreview.innerHTML = '<span class="text-xs text-slate-500">Keine Einschränkung – alle Tabellen erlaubt.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bridgePreview.innerHTML = state.bridgeTables.map(name => `<span class="chip">${escapeHtml(name)}</span>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateBridgeSettings(ev) {
|
||||||
|
ev?.preventDefault();
|
||||||
|
if (!settingsForm) return;
|
||||||
|
const data = {
|
||||||
|
bridge_url: settingsForm.bridge_url.value.trim(),
|
||||||
|
bridge_token: settingsForm.bridge_token.value.trim(),
|
||||||
|
};
|
||||||
|
if (!data.bridge_url || !data.bridge_token) {
|
||||||
|
toast('Bitte Bridge-URL und Token angeben', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await apiAction('account.bridge.test', { method: 'POST', data });
|
||||||
|
if (!res?.ok) throw new Error(res?.error || 'Prüfung fehlgeschlagen');
|
||||||
|
const tables = Array.isArray(res.tables) ? res.tables : [];
|
||||||
|
applyBridgePreview(tables);
|
||||||
|
if (settingsForm.bridge_tables) {
|
||||||
|
settingsForm.bridge_tables.value = tables.join(', ');
|
||||||
|
}
|
||||||
|
toast('Bridge erfolgreich geprüft', true);
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message || 'Prüfung fehlgeschlagen', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
try {
|
try {
|
||||||
const res = await apiAction('account.users.list', { method: 'GET' });
|
const res = await apiAction('account.users.list', { method: 'GET' });
|
||||||
@@ -329,6 +419,110 @@ function copyToClipboard(value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadSenders() {
|
||||||
|
if (!senderTable) return;
|
||||||
|
try {
|
||||||
|
const res = await apiAction('account.senders.list', { method: 'GET' });
|
||||||
|
if (!res?.ok) throw new Error(res?.error || 'Absender konnten nicht geladen werden');
|
||||||
|
state.senders = res.items || [];
|
||||||
|
state.senderMap = new Map(state.senders.map(item => [item.id, item]));
|
||||||
|
renderSenderList();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message || 'Fehler beim Laden der Absender', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSenderList() {
|
||||||
|
if (!senderTable) return;
|
||||||
|
const tbody = senderTable.querySelector('tbody');
|
||||||
|
if (!tbody) return;
|
||||||
|
if (!state.senders.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="text-sm text-slate-500">Keine Absender vorhanden.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = state.senders.map(sender => `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(sender.label || sender.from_name || sender.from_email)}</td>
|
||||||
|
<td>${escapeHtml(sender.from_name || '—')}</td>
|
||||||
|
<td>${escapeHtml(sender.from_email)}</td>
|
||||||
|
<td>${escapeHtml(sender.reply_to || '')}</td>
|
||||||
|
<td class="text-right flex gap-2 justify-end">
|
||||||
|
<button class="btn" data-sender-action="edit" data-sender-id="${sender.id}">Bearbeiten</button>
|
||||||
|
<button class="btn btn-danger" data-sender-action="delete" data-sender-id="${sender.id}">Löschen</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSenderTableClick(ev) {
|
||||||
|
const btn = ev.target.closest('button[data-sender-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
const id = Number(btn.getAttribute('data-sender-id'));
|
||||||
|
const action = btn.getAttribute('data-sender-action');
|
||||||
|
const sender = state.senderMap.get(id);
|
||||||
|
if (!sender) return;
|
||||||
|
if (action === 'edit') {
|
||||||
|
openSenderForm(sender);
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
if (confirm(`Absender "${sender.label || sender.from_email}" wirklich löschen?`)) {
|
||||||
|
deleteSender(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSenderForm(sender = null) {
|
||||||
|
if (!senderForm) return;
|
||||||
|
senderForm.classList.remove('hidden');
|
||||||
|
senderForm.sender_id.value = sender?.id || '';
|
||||||
|
senderForm.label.value = sender?.label || '';
|
||||||
|
senderForm.from_name.value = sender?.from_name || '';
|
||||||
|
senderForm.from_email.value = sender?.from_email || '';
|
||||||
|
senderForm.reply_to.value = sender?.reply_to || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSenderForm() {
|
||||||
|
if (!senderForm) return;
|
||||||
|
senderForm.classList.add('hidden');
|
||||||
|
senderForm.reset();
|
||||||
|
senderForm.sender_id.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSenderForm(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!senderForm) return;
|
||||||
|
const payload = {
|
||||||
|
sender_id: senderForm.sender_id.value ? Number(senderForm.sender_id.value) : undefined,
|
||||||
|
label: senderForm.label.value.trim(),
|
||||||
|
from_name: senderForm.from_name.value.trim(),
|
||||||
|
from_email: senderForm.from_email.value.trim(),
|
||||||
|
reply_to: senderForm.reply_to.value.trim(),
|
||||||
|
};
|
||||||
|
if (!payload.from_email) {
|
||||||
|
toast('Bitte eine Absenderadresse angeben', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await apiAction('account.senders.save', { method: 'POST', data: payload });
|
||||||
|
if (!res?.ok) throw new Error(res?.error || 'Speichern fehlgeschlagen');
|
||||||
|
closeSenderForm();
|
||||||
|
await loadSenders();
|
||||||
|
toast('Absender gespeichert', true);
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message || 'Fehler beim Speichern', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSender(senderId) {
|
||||||
|
try {
|
||||||
|
const res = await apiAction('account.senders.delete', { method: 'POST', data: { sender_id: senderId } });
|
||||||
|
if (!res?.ok) throw new Error(res?.error || 'Löschen fehlgeschlagen');
|
||||||
|
await loadSenders();
|
||||||
|
toast('Absender gelöscht', true);
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message || 'Fehler beim Löschen', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
return String(str || '')
|
return String(str || '')
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
|
|||||||
@@ -132,6 +132,13 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time();
|
|||||||
<span class="text-sm text-slate-600">Betreff</span>
|
<span class="text-sm text-slate-600">Betreff</span>
|
||||||
<input id="send_subject" type="text" class="mt-1 w-full border rounded-lg px-3 py-2" value="Testversand" />
|
<input id="send_subject" type="text" class="mt-1 w-full border rounded-lg px-3 py-2" value="Testversand" />
|
||||||
</label>
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm text-slate-600">Absender</span>
|
||||||
|
<select id="send_sender" class="mt-1 w-full border rounded-lg px-3 py-2">
|
||||||
|
<option value="">Standard (System)</option>
|
||||||
|
</select>
|
||||||
|
<p id="send_sender_hint" class="text-xs text-slate-500 mt-1 hidden">Keine individuellen Absender gefunden. Lege sie unter „Mein Konto“ an.</p>
|
||||||
|
</label>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button type="button" id="btn-cancel-send" class="btn">Abbrechen</button>
|
<button type="button" id="btn-cancel-send" class="btn">Abbrechen</button>
|
||||||
<button type="submit" id="btn-send-now" class="btn">Senden</button>
|
<button type="submit" id="btn-send-now" class="btn">Senden</button>
|
||||||
|
|||||||
19
schema.sql
19
schema.sql
@@ -101,6 +101,7 @@ CREATE TABLE IF NOT EXISTS `emailtemplate_customer_settings` (
|
|||||||
`bridge_token` varchar(255) DEFAULT NULL,
|
`bridge_token` varchar(255) DEFAULT NULL,
|
||||||
`sender_token` varchar(255) DEFAULT NULL,
|
`sender_token` varchar(255) DEFAULT NULL,
|
||||||
`external_api_token` varchar(255) DEFAULT NULL,
|
`external_api_token` varchar(255) DEFAULT NULL,
|
||||||
|
`bridge_tables` text DEFAULT NULL,
|
||||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||||
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||||
PRIMARY KEY (`customer_id`)
|
PRIMARY KEY (`customer_id`)
|
||||||
@@ -122,6 +123,22 @@ CREATE TABLE IF NOT EXISTS `emailtemplate_customer_tokens` (
|
|||||||
CONSTRAINT `emailtemplate_customer_tokens_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `emailtemplate_customers` (`id`) ON DELETE CASCADE
|
CONSTRAINT `emailtemplate_customer_tokens_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `emailtemplate_customers` (`id`) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- Tabelle: emailtemplate_sender_identities
|
||||||
|
CREATE TABLE IF NOT EXISTS `emailtemplate_sender_identities` (
|
||||||
|
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`customer_id` int(10) unsigned NOT NULL,
|
||||||
|
`label` varchar(255) NOT NULL,
|
||||||
|
`from_name` varchar(255) DEFAULT NULL,
|
||||||
|
`from_email` varchar(255) NOT NULL,
|
||||||
|
`reply_to` 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 (`id`),
|
||||||
|
KEY `idx_sender_customer` (`customer_id`),
|
||||||
|
KEY `idx_sender_email` (`from_email`),
|
||||||
|
CONSTRAINT `fk_sender_customer` FOREIGN KEY (`customer_id`) REFERENCES `emailtemplate_customers` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
-- Tabelle: emailtemplate_customer_users
|
-- Tabelle: emailtemplate_customer_users
|
||||||
CREATE TABLE IF NOT EXISTS `emailtemplate_customer_users` (
|
CREATE TABLE IF NOT EXISTS `emailtemplate_customer_users` (
|
||||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
@@ -228,4 +245,4 @@ CREATE TABLE IF NOT EXISTS `emailtemplate_template_items` (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
SET FOREIGN_KEY_CHECKS = 1;
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
-- Ende des Schema-Dumps
|
-- Ende des Schema-Dumps
|
||||||
|
|||||||
@@ -642,6 +642,7 @@ class ApiKernel
|
|||||||
if ($subject === '') {
|
if ($subject === '') {
|
||||||
$subject = 'Testversand';
|
$subject = 'Testversand';
|
||||||
}
|
}
|
||||||
|
$senderId = (int)$this->val($this->in, ['sender_id'], 0);
|
||||||
|
|
||||||
$t = $this->tableMap['templates'];
|
$t = $this->tableMap['templates'];
|
||||||
[$idCol, $allCols] = $this->resolveIdCol('templates');
|
[$idCol, $allCols] = $this->resolveIdCol('templates');
|
||||||
@@ -668,7 +669,15 @@ class ApiKernel
|
|||||||
$html = $this->renderHtmlWithReferences($html, $auth, $renderCache, $renderStack);
|
$html = $this->renderHtmlWithReferences($html, $auth, $renderCache, $renderStack);
|
||||||
$html = $this->prepareEmailHtml($html);
|
$html = $this->prepareEmailHtml($html);
|
||||||
|
|
||||||
if (!$this->dispatchTestMail($recipient, $subject, $html)) {
|
$sender = null;
|
||||||
|
if ($senderId > 0) {
|
||||||
|
$customerId = (int)($auth['customer_id'] ?? 0);
|
||||||
|
if ($customerId > 0) {
|
||||||
|
$sender = $this->fetchSenderRow($customerId, $senderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->dispatchTestMail($recipient, $subject, $html, $sender)) {
|
||||||
$this->fail('Send failed', null, 500);
|
$this->fail('Send failed', null, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,6 +686,7 @@ class ApiKernel
|
|||||||
'template_id' => $templateId,
|
'template_id' => $templateId,
|
||||||
'to' => $recipient,
|
'to' => $recipient,
|
||||||
'subject' => $subject,
|
'subject' => $subject,
|
||||||
|
'sender_id' => $senderId > 0 ? $senderId : null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,20 +709,24 @@ class ApiKernel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function dispatchTestMail(string $to, string $subject, string $html): bool
|
private function dispatchTestMail(string $to, string $subject, string $html, ?array $sender = null): bool
|
||||||
{
|
{
|
||||||
if (!function_exists('mail')) {
|
if (!function_exists('mail')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$smtp = $this->conf['smtp'] ?? [];
|
$smtp = $this->conf['smtp'] ?? [];
|
||||||
$fromEmail = $smtp['from_email'] ?? 'no-reply@example.com';
|
$fromEmail = $sender['from_email'] ?? ($smtp['from_email'] ?? 'no-reply@example.com');
|
||||||
$fromName = $smtp['from_name'] ?? 'EmailTemplate';
|
$fromName = $sender['from_name'] ?? ($sender['label'] ?? ($smtp['from_name'] ?? 'EmailTemplate'));
|
||||||
|
$replyTo = $sender['reply_to'] ?? '';
|
||||||
$headers = [
|
$headers = [
|
||||||
'MIME-Version: 1.0',
|
'MIME-Version: 1.0',
|
||||||
'Content-Type: text/html; charset=UTF-8',
|
'Content-Type: text/html; charset=UTF-8',
|
||||||
'From: ' . $this->formatEmailAddress($fromEmail, $fromName),
|
'From: ' . $this->formatEmailAddress($fromEmail, $fromName),
|
||||||
];
|
];
|
||||||
|
if ($replyTo !== '') {
|
||||||
|
$headers[] = 'Reply-To: ' . $this->formatEmailAddress($replyTo, $fromName ?: $fromEmail);
|
||||||
|
}
|
||||||
|
|
||||||
$encodedSubject = function_exists('mb_encode_mimeheader')
|
$encodedSubject = function_exists('mb_encode_mimeheader')
|
||||||
? mb_encode_mimeheader($subject, 'UTF-8')
|
? mb_encode_mimeheader($subject, 'UTF-8')
|
||||||
@@ -797,12 +811,24 @@ class ApiKernel
|
|||||||
case 'account.users.delete':
|
case 'account.users.delete':
|
||||||
$this->handleAccountUsersDelete();
|
$this->handleAccountUsersDelete();
|
||||||
break;
|
break;
|
||||||
|
case 'account.senders.list':
|
||||||
|
$this->handleAccountSendersList();
|
||||||
|
break;
|
||||||
|
case 'account.senders.save':
|
||||||
|
$this->handleAccountSenderSave();
|
||||||
|
break;
|
||||||
|
case 'account.senders.delete':
|
||||||
|
$this->handleAccountSenderDelete();
|
||||||
|
break;
|
||||||
case 'downloads.bridge':
|
case 'downloads.bridge':
|
||||||
$this->handleDownloadFile('bridge');
|
$this->handleDownloadFile('bridge');
|
||||||
break;
|
break;
|
||||||
case 'downloads.sender':
|
case 'downloads.sender':
|
||||||
$this->handleDownloadFile('sender');
|
$this->handleDownloadFile('sender');
|
||||||
break;
|
break;
|
||||||
|
case 'account.bridge.test':
|
||||||
|
$this->handleAccountBridgeTest();
|
||||||
|
break;
|
||||||
case 'placeholders.schema':
|
case 'placeholders.schema':
|
||||||
$this->handlePlaceholderSchema();
|
$this->handlePlaceholderSchema();
|
||||||
break;
|
break;
|
||||||
@@ -1080,9 +1106,15 @@ class ApiKernel
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$settings = $this->getCustomerSettings($customerId);
|
||||||
|
$tables = $schema['tables'] ?? [];
|
||||||
|
if (!empty($settings['bridge_tables'])) {
|
||||||
|
$tables = $this->filterSchemaTables($tables, $settings['bridge_tables']);
|
||||||
|
}
|
||||||
|
|
||||||
$this->respond([
|
$this->respond([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'tables' => $schema['tables'] ?? [],
|
'tables' => $tables,
|
||||||
'fetched' => $schema['fetched'] ?? date(DATE_ATOM),
|
'fetched' => $schema['fetched'] ?? date(DATE_ATOM),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -1237,7 +1269,7 @@ class ApiKernel
|
|||||||
private function handleAccountSettingsGet(): void
|
private function handleAccountSettingsGet(): void
|
||||||
{
|
{
|
||||||
$user = $this->authService->requireAuth();
|
$user = $this->authService->requireAuth();
|
||||||
$this->ensureOwner($user);
|
$this->ensureRole($user, ['owner', 'admin']);
|
||||||
$customerId = (int)($user['customer_id'] ?? 0);
|
$customerId = (int)($user['customer_id'] ?? 0);
|
||||||
$settings = $this->ensureSettingsTokens($customerId, $this->getCustomerSettings($customerId));
|
$settings = $this->ensureSettingsTokens($customerId, $this->getCustomerSettings($customerId));
|
||||||
$this->respond(['ok' => true, 'settings' => $settings]);
|
$this->respond(['ok' => true, 'settings' => $settings]);
|
||||||
@@ -1246,7 +1278,7 @@ class ApiKernel
|
|||||||
private function handleAccountSettingsUpdate(): void
|
private function handleAccountSettingsUpdate(): void
|
||||||
{
|
{
|
||||||
$user = $this->authService->requireAuth();
|
$user = $this->authService->requireAuth();
|
||||||
$this->ensureOwner($user);
|
$this->ensureRole($user, ['owner', 'admin']);
|
||||||
$customerId = (int)($user['customer_id'] ?? 0);
|
$customerId = (int)($user['customer_id'] ?? 0);
|
||||||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||||||
|
|
||||||
@@ -1254,6 +1286,8 @@ class ApiKernel
|
|||||||
$bridgeToken = trim((string)($this->in['bridge_token'] ?? ''));
|
$bridgeToken = trim((string)($this->in['bridge_token'] ?? ''));
|
||||||
$senderToken = trim((string)($this->in['sender_token'] ?? ''));
|
$senderToken = trim((string)($this->in['sender_token'] ?? ''));
|
||||||
$externalToken = trim((string)($this->in['external_api_token'] ?? ''));
|
$externalToken = trim((string)($this->in['external_api_token'] ?? ''));
|
||||||
|
$bridgeTablesInput = $this->in['bridge_tables'] ?? null;
|
||||||
|
$bridgeTables = $this->normalizeBridgeTables($bridgeTablesInput);
|
||||||
$rotateBridge = !empty($this->in['rotate_bridge_token']);
|
$rotateBridge = !empty($this->in['rotate_bridge_token']);
|
||||||
$rotateSender = !empty($this->in['rotate_sender_token']);
|
$rotateSender = !empty($this->in['rotate_sender_token']);
|
||||||
$rotateExternal = !empty($this->in['rotate_external_token']);
|
$rotateExternal = !empty($this->in['rotate_external_token']);
|
||||||
@@ -1272,6 +1306,7 @@ class ApiKernel
|
|||||||
'bridge_token' => $bridgeToken,
|
'bridge_token' => $bridgeToken,
|
||||||
'sender_token' => $senderToken,
|
'sender_token' => $senderToken,
|
||||||
'external_api_token' => $externalToken,
|
'external_api_token' => $externalToken,
|
||||||
|
'bridge_tables' => $bridgeTables,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->respond(['ok' => true, 'settings' => $settings]);
|
$this->respond(['ok' => true, 'settings' => $settings]);
|
||||||
@@ -1473,10 +1508,86 @@ class ApiKernel
|
|||||||
$this->respond(['ok' => true, 'deleted' => true]);
|
$this->respond(['ok' => true, 'deleted' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function handleAccountSendersList(): void
|
||||||
|
{
|
||||||
|
$user = $this->authService->requireAuth();
|
||||||
|
$customerId = (int)($user['customer_id'] ?? 0);
|
||||||
|
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||||||
|
$table = $this->senderTable();
|
||||||
|
$stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :cid ORDER BY `label` ASC");
|
||||||
|
$stmt->execute([':cid' => $customerId]);
|
||||||
|
$items = [];
|
||||||
|
while ($row = $stmt->fetch()) {
|
||||||
|
$items[] = $this->formatSenderRow($row);
|
||||||
|
}
|
||||||
|
$this->respond(['ok' => true, 'items' => $items]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleAccountSenderSave(): void
|
||||||
|
{
|
||||||
|
$user = $this->authService->requireAuth();
|
||||||
|
$this->ensureRole($user, ['owner', 'admin']);
|
||||||
|
$customerId = (int)($user['customer_id'] ?? 0);
|
||||||
|
|
||||||
|
$senderId = (int)($this->in['sender_id'] ?? 0);
|
||||||
|
$label = trim((string)($this->in['label'] ?? ''));
|
||||||
|
$fromName = trim((string)($this->in['from_name'] ?? ''));
|
||||||
|
$fromEmail = trim((string)($this->in['from_email'] ?? ''));
|
||||||
|
$replyTo = trim((string)($this->in['reply_to'] ?? ''));
|
||||||
|
if ($label === '') $label = $fromName ?: $fromEmail;
|
||||||
|
if ($fromEmail === '' || !filter_var($fromEmail, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$this->fail('Gültige Absender-Adresse erforderlich', null, 422);
|
||||||
|
}
|
||||||
|
if ($replyTo !== '' && !filter_var($replyTo, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$this->fail('Ungültige Reply-To-Adresse', null, 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$table = $this->senderTable();
|
||||||
|
if ($senderId > 0) {
|
||||||
|
$stmt = $this->pdo->prepare("UPDATE `$table` SET `label`=:label,`from_name`=:fname,`from_email`=:fmail,`reply_to`=:reply,`updated_at`=NOW() WHERE `id`=:id AND `customer_id`=:cid LIMIT 1");
|
||||||
|
$stmt->execute([
|
||||||
|
':label' => $label,
|
||||||
|
':fname' => $fromName ?: null,
|
||||||
|
':fmail' => $fromEmail,
|
||||||
|
':reply' => $replyTo ?: null,
|
||||||
|
':id' => $senderId,
|
||||||
|
':cid' => $customerId,
|
||||||
|
]);
|
||||||
|
if ($stmt->rowCount() === 0) $this->fail('Absender nicht gefunden', null, 404);
|
||||||
|
} else {
|
||||||
|
$stmt = $this->pdo->prepare("INSERT INTO `$table` (`customer_id`,`label`,`from_name`,`from_email`,`reply_to`,`created_at`,`updated_at`) VALUES (:cid,:label,:fname,:fmail,:reply,NOW(),NOW())");
|
||||||
|
$stmt->execute([
|
||||||
|
':cid' => $customerId,
|
||||||
|
':label' => $label,
|
||||||
|
':fname' => $fromName ?: null,
|
||||||
|
':fmail' => $fromEmail,
|
||||||
|
':reply' => $replyTo ?: null,
|
||||||
|
]);
|
||||||
|
$senderId = (int)$this->pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
$sender = $this->fetchSenderRow($customerId, $senderId);
|
||||||
|
$this->respond(['ok' => true, 'sender' => $sender]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleAccountSenderDelete(): void
|
||||||
|
{
|
||||||
|
$user = $this->authService->requireAuth();
|
||||||
|
$this->ensureRole($user, ['owner', 'admin']);
|
||||||
|
$customerId = (int)($user['customer_id'] ?? 0);
|
||||||
|
$senderId = (int)($this->in['sender_id'] ?? 0);
|
||||||
|
if ($senderId <= 0) $this->fail('Ungültige Sender-ID', null, 422);
|
||||||
|
$table = $this->senderTable();
|
||||||
|
$stmt = $this->pdo->prepare("DELETE FROM `$table` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1");
|
||||||
|
$stmt->execute([':id' => $senderId, ':cid' => $customerId]);
|
||||||
|
if ($stmt->rowCount() === 0) $this->fail('Absender nicht gefunden', null, 404);
|
||||||
|
$this->respond(['ok' => true, 'deleted' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
private function handleDownloadFile(string $type): void
|
private function handleDownloadFile(string $type): void
|
||||||
{
|
{
|
||||||
$user = $this->authService->requireAuth();
|
$user = $this->authService->requireAuth();
|
||||||
$this->ensureOwner($user);
|
$this->ensureRole($user, ['owner', 'admin']);
|
||||||
$customerId = (int)($user['customer_id'] ?? 0);
|
$customerId = (int)($user['customer_id'] ?? 0);
|
||||||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||||||
|
|
||||||
@@ -1511,6 +1622,34 @@ class ApiKernel
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function handleAccountBridgeTest(): void
|
||||||
|
{
|
||||||
|
$user = $this->authService->requireAuth();
|
||||||
|
$this->ensureRole($user, ['owner', 'admin']);
|
||||||
|
$customerId = (int)($user['customer_id'] ?? 0);
|
||||||
|
$bridgeUrl = trim((string)($this->in['bridge_url'] ?? ''));
|
||||||
|
$bridgeToken = trim((string)($this->in['bridge_token'] ?? ''));
|
||||||
|
if ($bridgeUrl === '' || $bridgeToken === '') {
|
||||||
|
$settings = $this->getCustomerSettings($customerId);
|
||||||
|
if ($bridgeUrl === '') $bridgeUrl = (string)($settings['bridge_url'] ?? '');
|
||||||
|
if ($bridgeToken === '') $bridgeToken = (string)($settings['bridge_token'] ?? '');
|
||||||
|
}
|
||||||
|
if ($bridgeUrl === '' || $bridgeToken === '') {
|
||||||
|
$this->fail('Bridge nicht konfiguriert', null, 422);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$schema = $this->fetchPlaceholderSchema($bridgeUrl, $bridgeToken, 0);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->fail('Bridge request failed', $e->getMessage(), 502);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->respond([
|
||||||
|
'ok' => true,
|
||||||
|
'tables' => $schema['tables'] ?? [],
|
||||||
|
'fetched' => $schema['fetched'] ?? date(DATE_ATOM),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveBridgeConfig(?int $customerId): array
|
private function resolveBridgeConfig(?int $customerId): array
|
||||||
{
|
{
|
||||||
$fileConf = $this->conf['placeholders']['bridge'] ?? [];
|
$fileConf = $this->conf['placeholders']['bridge'] ?? [];
|
||||||
@@ -1528,15 +1667,19 @@ class ApiKernel
|
|||||||
$stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :id LIMIT 1");
|
$stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :id LIMIT 1");
|
||||||
$stmt->execute([':id' => $customerId]);
|
$stmt->execute([':id' => $customerId]);
|
||||||
$row = $stmt->fetch();
|
$row = $stmt->fetch();
|
||||||
return $row ?: [];
|
return $row ? $this->formatCustomerSettingsRow($row) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function saveCustomerSettings(int $customerId, array $data): array
|
private function saveCustomerSettings(int $customerId, array $data): array
|
||||||
{
|
{
|
||||||
if ($customerId <= 0) return [];
|
if ($customerId <= 0) return [];
|
||||||
$allowed = ['bridge_url', 'bridge_token', 'sender_token', 'external_api_token'];
|
$allowed = ['bridge_url', 'bridge_token', 'sender_token', 'external_api_token', 'bridge_tables'];
|
||||||
$fields = array_intersect_key($data, array_flip($allowed));
|
$fields = array_intersect_key($data, array_flip($allowed));
|
||||||
if (!$fields) return $this->getCustomerSettings($customerId);
|
if (!$fields) return $this->getCustomerSettings($customerId);
|
||||||
|
if (array_key_exists('bridge_tables', $fields)) {
|
||||||
|
$normalized = $this->normalizeBridgeTables($fields['bridge_tables']);
|
||||||
|
$fields['bridge_tables'] = $normalized ? $this->encodeBridgeTables($normalized) : null;
|
||||||
|
}
|
||||||
$fields['customer_id'] = $customerId;
|
$fields['customer_id'] = $customerId;
|
||||||
$columns = array_keys($fields);
|
$columns = array_keys($fields);
|
||||||
$insertCols = implode(',', array_map(fn($c) => "`$c`", $columns));
|
$insertCols = implode(',', array_map(fn($c) => "`$c`", $columns));
|
||||||
@@ -1572,6 +1715,76 @@ class ApiKernel
|
|||||||
return $settings;
|
return $settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function formatCustomerSettingsRow(array $row): array
|
||||||
|
{
|
||||||
|
if (array_key_exists('bridge_tables', $row)) {
|
||||||
|
$row['bridge_tables'] = $this->decodeBridgeTables($row['bridge_tables']);
|
||||||
|
} else {
|
||||||
|
$row['bridge_tables'] = [];
|
||||||
|
}
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeBridgeTables($input): array
|
||||||
|
{
|
||||||
|
$items = [];
|
||||||
|
if (is_string($input)) {
|
||||||
|
$items = preg_split('/[\s,]+/', $input) ?: [];
|
||||||
|
} elseif (is_array($input)) {
|
||||||
|
$items = $input;
|
||||||
|
} elseif ($input === null) {
|
||||||
|
return [];
|
||||||
|
} else {
|
||||||
|
$items = [$input];
|
||||||
|
}
|
||||||
|
$items = array_map(static function ($value) {
|
||||||
|
return trim((string)$value);
|
||||||
|
}, $items);
|
||||||
|
$items = array_filter($items, fn($value) => $value !== '');
|
||||||
|
return array_values(array_unique($items));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function encodeBridgeTables(?array $tables): ?string
|
||||||
|
{
|
||||||
|
if (empty($tables)) return null;
|
||||||
|
return json_encode(array_values($tables), JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeBridgeTables($stored): array
|
||||||
|
{
|
||||||
|
if (is_array($stored)) {
|
||||||
|
return $this->normalizeBridgeTables($stored);
|
||||||
|
}
|
||||||
|
$str = (string)$stored;
|
||||||
|
if ($str === '') return [];
|
||||||
|
$decoded = json_decode($str, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
return $this->normalizeBridgeTables($decoded);
|
||||||
|
}
|
||||||
|
return $this->normalizeBridgeTables($str);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function filterSchemaTables(array $tables, array $allowed): array
|
||||||
|
{
|
||||||
|
if (empty($allowed)) return $tables;
|
||||||
|
$allowedLower = array_map('strtolower', $allowed);
|
||||||
|
$filtered = [];
|
||||||
|
foreach ($tables as $entry) {
|
||||||
|
if (is_array($entry)) {
|
||||||
|
$name = strtolower((string)($entry['name'] ?? $entry['table'] ?? $entry['label'] ?? ''));
|
||||||
|
if ($name !== '' && in_array($name, $allowedLower, true)) {
|
||||||
|
$filtered[] = $entry;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$name = strtolower((string)$entry);
|
||||||
|
if ($name !== '' && in_array($name, $allowedLower, true)) {
|
||||||
|
$filtered[] = $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $filtered;
|
||||||
|
}
|
||||||
|
|
||||||
private function customerSettingsTable(): string
|
private function customerSettingsTable(): string
|
||||||
{
|
{
|
||||||
return 'emailtemplate_customer_settings';
|
return 'emailtemplate_customer_settings';
|
||||||
@@ -1666,6 +1879,37 @@ class ApiKernel
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function senderTable(): string
|
||||||
|
{
|
||||||
|
return 'emailtemplate_sender_identities';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchSenderRow(int $customerId, int $senderId): array
|
||||||
|
{
|
||||||
|
if ($customerId <= 0 || $senderId <= 0) {
|
||||||
|
$this->fail('Absender nicht gefunden', null, 404);
|
||||||
|
}
|
||||||
|
$table = $this->senderTable();
|
||||||
|
$stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1");
|
||||||
|
$stmt->execute([':id' => $senderId, ':cid' => $customerId]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
if (!$row) $this->fail('Absender nicht gefunden', null, 404);
|
||||||
|
return $this->formatSenderRow($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatSenderRow(array $row): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int)($row['id'] ?? 0),
|
||||||
|
'label' => $row['label'] ?? '',
|
||||||
|
'from_name' => $row['from_name'] ?? '',
|
||||||
|
'from_email' => $row['from_email'] ?? '',
|
||||||
|
'reply_to' => $row['reply_to'] ?? '',
|
||||||
|
'created_at' => $row['created_at'] ?? null,
|
||||||
|
'updated_at' => $row['updated_at'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function formatUserOutput(array $row): array
|
private function formatUserOutput(array $row): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -1720,8 +1964,15 @@ class ApiKernel
|
|||||||
|
|
||||||
private function ensureOwner(array $user): void
|
private function ensureOwner(array $user): void
|
||||||
{
|
{
|
||||||
if (($user['role'] ?? '') !== 'owner') {
|
$this->ensureRole($user, ['owner']);
|
||||||
$this->fail('Nur Owner dürfen diese Aktion ausführen', null, 403);
|
}
|
||||||
|
|
||||||
|
private function ensureRole(array $user, array $roles): void
|
||||||
|
{
|
||||||
|
$role = strtolower((string)($user['role'] ?? ''));
|
||||||
|
$allowed = array_values(array_unique(array_map('strtolower', $roles)));
|
||||||
|
if (!in_array($role, $allowed, true)) {
|
||||||
|
$this->fail('Unzureichende Berechtigungen', null, 403);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user