From f27583031192e15ccb9c62d74247d64f4a00788a Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Mon, 6 Oct 2025 08:59:48 +0200 Subject: [PATCH] added bulk creation and e-mail sending --- application/User/UserController.php | 159 +++++++++++++++++++++++ public/js/pages/User/User.js | 161 ++++++++++++++--------- public/js/pages/UserEdit/UserEdit.css | 116 ++++++++++------- public/js/pages/UserEdit/UserEdit.js | 179 ++++++++++++++++++++++++-- 4 files changed, 500 insertions(+), 115 deletions(-) diff --git a/application/User/UserController.php b/application/User/UserController.php index 474a12468..0b4a6f934 100644 --- a/application/User/UserController.php +++ b/application/User/UserController.php @@ -1,5 +1,8 @@ self::getUrl("User", "Form"), "EDIT_URL" => self::getUrl("User", "Form"), "IMPERSONATE_URL" => self::getUrl("User", "impersonate"), + "SEND_LOGIN_EMAIL_URL" => self::getUrl("User", "sendLoginEmail"), ]); } @@ -583,4 +587,159 @@ class UserController extends mfBaseController $_SESSION[MFAPPNAME.'_impersonate'] = $this->request->username; $this->redirect("Dashboard"); } + + protected function sendLoginEmailAction() + { + $id = $this->request->id; + if (!$id || !is_numeric($id)) { + self::sendError("Benutzer-ID fehlt oder ist ungültig."); + } + + $user = new User($id); + if (!$user->id || !$user->email) { + self::sendError("Benutzer nicht gefunden oder keine E-Mail-Adresse hinterlegt."); + } + + $userFullName = htmlspecialchars($user->name); + $userUsername = htmlspecialchars($user->username); + $firstName = htmlspecialchars(explode(' ', $user->name)[0]); + + $subject = "Ihr Zugang zu TheTOOL by XINON, {$firstName}!"; + + $logoToolPath = LIBDIR . '/../public/assets/images/the-tool-logo.png'; + $logoXinonPath = LIBDIR . '/../public/assets/images/xinon-full.png'; + + $logoToolTag = file_exists($logoToolPath) ? 'TheTOOL Logo' : ''; + $logoXinonTag = file_exists($logoXinonPath) ? 'XINON Logo' : ''; + + $currentYear = date("Y"); + $xinonBlue = '#005384'; + $loginLink = 'https://thetool.xinon.at'; + $passwordResetLink = 'https://thetool.xinon.at/UserPasswordReset/forgotPassword'; + + $html = << + + + + + {$subject} + + + + + + + +
+ + + + +
+ + + + + + + + + + +
+ + + + + +
{$logoToolTag}{$logoXinonTag}
+
+

Willkommen an Bord, {$firstName}!

+

Wir freuen uns, Sie im Team zu haben. Ihr Zugang zu einer smarteren Arbeitsweise ist freigeschaltet.

+ +

Ihr persönlicher Benutzername lautet:

+ + + + +
+ {$userUsername} +
+ +

Um zu starten, legen Sie bitte Ihr persönliches Passwort fest.

+ + + + + +
+ Passwort erstmalig setzen +
+ + + + + +
+

Oder besitzen Sie bereits ein Passwort?

+ Direkt zur Login-Seite +
+
+

+ © {$currentYear} XINON GmbH | Impressum +

+
+
+
+ + +HTML; + + $altBody = "Willkommen an Bord, {$firstName}!\n\n" . + "Wir freuen uns, Sie im Team zu haben. Ihr Zugang zu einer smarteren Arbeitsweise ist freigeschaltet.\n\n" . + "Ihr persönlicher Benutzername lautet: {$userUsername}\n\n" . + "Um zu starten, legen Sie bitte Ihr persönliches Passwort fest. Besuchen Sie dazu folgende Seite:\n" . + "{$passwordResetLink}\n\n" . + "Oder besitzen Sie bereits ein Passwort? Hier geht es direkt zum Login:\n" . + "{$loginLink}\n\n" . + "© {$currentYear} XINON GmbH | Impressum: https://xinon.at/impressum/"; + + $mail = new PHPMailer(true); + try { + $mail->isSMTP(); + $mail->Host = TT_PIPEWORK_SMTP_HOST; + $mail->SMTPAuth = true; + $mail->Username = TT_PIPEWORK_SMTP_USER; + $mail->Password = TT_PIPEWORK_SMTP_PASS; + $mail->CharSet = PHPMailer::CHARSET_UTF8; + $mail->Encoding = 'base64'; + $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + $mail->Port = 587; + + if (file_exists($logoToolPath)) $mail->addEmbeddedImage($logoToolPath, 'logo_thetool'); + if (file_exists($logoXinonPath)) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon'); + + $mail->setFrom('thetool@xinon.at', 'TheTOOL by XINON'); + $mail->addReplyTo('office@xinon.at', 'XINON Office'); + $mail->addAddress($user->email, $user->name); + + $mail->isHTML(true); + $mail->Subject = $subject; + $mail->Body = $html; + $mail->AltBody = $altBody; + + $mail->send(); + self::returnJson(['success' => true, 'message' => 'Willkommens-E-Mail wurde erfolgreich an ' . htmlspecialchars($user->email) . ' gesendet.']); + + } catch (Exception $e) { + error_log("Mailer Error in sendLoginEmailAction for user ID {$id}: " . $mail->ErrorInfo); + self::sendError("E-Mail konnte nicht gesendet werden. Bitte kontaktieren Sie den Support."); + } + } } diff --git a/public/js/pages/User/User.js b/public/js/pages/User/User.js index 343267058..b39a9b265 100644 --- a/public/js/pages/User/User.js +++ b/public/js/pages/User/User.js @@ -1,64 +1,109 @@ Vue.component("User", { template: ` - - - +
+ + + - - - - - `, data: () => ({ - window: window, UserTableConfig: { - key: "UserTable", - tableHeader: "Benutzer", + + + +

Sind Sie sicher, dass Sie die Login-Informationen an {{ selectedUserForMail.email }} senden möchten?

+
+
+ `, + data: () => ({ + window: window, + showSendMailModal: false, + selectedUserForMail: null, + isSendingMail: false, + UserTableConfig: { + key: "UserTable", + tableHeader: "Benutzer", defaultPageSize: 25, - headers: [{text: "Username", key: "username", class: "text-center", sortable: false, priority: 20}, - {text: "Name", key: "name", class: "text-center", sortable: false, priority: 18}, - {text: "Firma", key: "address", class: "text-center", priority: 19}, - {text: "E-Mail", key: "email", priority: 14}, - {text: "Tel. Nr.", key: "mobile", priority: 17, filter: false, sortable: false}, - { - text: "2FA", key: "twofactor", class: "text-center", priority: 16, filter: 'iconSelect', sortable: false, - filterOptions: [{value: "N/A", text: "N/A", icon: "fa fa-exclamation-triangle text-danger"}, - {value: "Mail", text: "Mail", icon: "fa-light fa-envelope text-primary"}, - {value: "SMS", text: "SMS", icon: "fa-light fa-mobile-retro text-info"}] - }, - { - text: "Admin", key: "isAdmin", class: "text-center", priority: 13, filter: 'iconSelect', sortable: false, - filterOptions: [{value: true, text: "Ist Admin", icon: "fa-regular fa-circle-check text-success"}, - {value: false, text: "Ist kein Admin", icon: "fa-regular fa-circle-xmark text-danger"}] - }, - { - text: "Techniker", key: "isTechnician", class: "text-center", priority: 12, filter: 'iconSelect', sortable: false, - filterOptions: [{value: true, text: "Ist Techniker", icon: "fa-regular fa-circle-check text-success"}, - {value: false, text: "Ist kein Techniker", icon: "fa-regular fa-circle-xmark text-danger"}], - }, - { - text: "Aktiv", key: "isActive", class: "text-center", priority: 12, filter: 'iconSelect', sortable: false, - filterOptions: [{value: "1", text: "Ist Aktiv", icon: "fa-regular fa-circle-check text-success"}, - {value: "0", text: "Ist nicht aktiv", icon: "fa-regular fa-circle-xmark text-danger"}], - }, - {text: "Aktionen", key: "actions", class: "text-center", sortable: false, priority: 21, filter: false}] + headers: [{text: "Username", key: "username", class: "text-center", sortable: false, priority: 20}, + {text: "Name", key: "name", class: "text-center", sortable: false, priority: 18}, + {text: "Firma", key: "address", class: "text-center", priority: 19}, + {text: "E-Mail", key: "email", priority: 14}, + {text: "Tel. Nr.", key: "mobile", priority: 17, filter: false, sortable: false}, + { + text: "2FA", key: "twofactor", class: "text-center", priority: 16, filter: 'iconSelect', sortable: false, + filterOptions: [{value: "N/A", text: "N/A", icon: "fa fa-exclamation-triangle text-danger"}, + {value: "Mail", text: "Mail", icon: "fa-light fa-envelope text-primary"}, + {value: "SMS", text: "SMS", icon: "fa-light fa-mobile-retro text-info"}] + }, + { + text: "Admin", key: "isAdmin", class: "text-center", priority: 13, filter: 'iconSelect', sortable: false, + filterOptions: [{value: true, text: "Ist Admin", icon: "fa-regular fa-circle-check text-success"}, + {value: false, text: "Ist kein Admin", icon: "fa-regular fa-circle-xmark text-danger"}] + }, + { + text: "Techniker", key: "isTechnician", class: "text-center", priority: 12, filter: 'iconSelect', sortable: false, + filterOptions: [{value: true, text: "Ist Techniker", icon: "fa-regular fa-circle-check text-success"}, + {value: false, text: "Ist kein Techniker", icon: "fa-regular fa-circle-xmark text-danger"}], + }, + { + text: "Aktiv", key: "isActive", class: "text-center", priority: 12, filter: 'iconSelect', sortable: false, + filterOptions: [{value: "1", text: "Ist Aktiv", icon: "fa-regular fa-circle-check text-success"}, + {value: "0", text: "Ist nicht aktiv", icon: "fa-regular fa-circle-xmark text-danger"}], + }, + {text: "Aktionen", key: "actions", class: "text-center", sortable: false, priority: 21, filter: false}] } - }) -}); + }), + methods: { + openSendMailModal(user) { + this.selectedUserForMail = user; + this.showSendMailModal = true; + }, + async sendLoginEmail() { + if (!this.selectedUserForMail) return; + this.isSendingMail = true; + try { + const response = await axios.get(window.TT_CONFIG.SEND_LOGIN_EMAIL_URL + '?id=' + this.selectedUserForMail.id); + if (response.data.success) { + window.notify('success', response.data.message); + } else { + window.notify('error', response.data.message || 'E-Mail konnte nicht gesendet werden.'); + } + } catch (error) { + window.notify('error', 'Ein Fehler ist aufgetreten.'); + console.error(error); + } + this.isSendingMail = false; + this.showSendMailModal = false; + this.selectedUserForMail = null; + } + } +}); \ No newline at end of file diff --git a/public/js/pages/UserEdit/UserEdit.css b/public/js/pages/UserEdit/UserEdit.css index ffbe45d53..bdfa333f4 100644 --- a/public/js/pages/UserEdit/UserEdit.css +++ b/public/js/pages/UserEdit/UserEdit.css @@ -1,5 +1,5 @@ .user-edit-container { - padding-bottom: 60px; /* Space for the bottom save button */ + padding-bottom: 60px } .user-edit-header { @@ -8,136 +8,146 @@ align-items: center; margin-bottom: 1rem; margin-top: 1.5rem; - padding: 0.5rem; + padding: .5rem +} + +.user-edit-header .header-actions { + display: flex; + gap: .5rem } .user-edit-header.sticky-header { position: sticky; - top: 60px; /* Adjust based on your main header height */ - background-color: #f8f9fa; /* Match card background */ + top: 60px; + background-color: #f8f9fa; z-index: 10; - padding: 0.75rem 1rem; + padding: .75rem 1rem; border-bottom: 1px solid #dee2e6; - margin: -1.25rem -1.25rem 1rem -1.25rem; /* Make it span the card width */ + margin: -1.25rem -1.25rem 1rem -1.25rem } .user-edit-header.collapsible { cursor: pointer; border-bottom: 1px solid #dee2e6; - padding-bottom: 0.75rem; - transition: background-color 0.2s; + padding-bottom: .75rem; + transition: background-color .2s } + .user-edit-header.collapsible:hover { - background-color: #f1f3f5; + background-color: #f1f3f5 } .user-edit-header h3 { - margin: 0; + margin: 0 } .user-form-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 0.5rem 1.5rem; + grid-template-columns:repeat(auto-fit, minmax(300px, 1fr)); + gap: .5rem 1.5rem } .user-form-grid-half { display: grid; - grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); - gap: 1rem; + grid-template-columns:repeat(auto-fit, minmax(400px, 1fr)); + gap: 1rem } .user-form-grid-toggles { display: flex; gap: 2rem; - margin-top: 1rem; + margin-top: 1rem } .user-form-grid-toggles .form-group { display: flex; align-items: center; - gap: 0.75rem; - margin-bottom: 0; + gap: .75rem; + margin-bottom: 0 } .permission-template-section { display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + grid-template-columns:repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; - align-items: end; + align-items: end } .permissions-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 1.5rem 2rem; + grid-template-columns:repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.5rem 2rem } .permission-group { border: 1px solid #dee2e6; - border-radius: 0.3rem; + border-radius: .3rem; padding: 1rem; - background-color: #fff; + background-color: #fff } .permission-group h5 { font-size: 1.1rem; margin-bottom: 1rem; - color: #0056b3; + color: #0056b3 } .form-check-label { - user-select: none; + user-select: none } .password-generation-grid { display: grid; - grid-template-columns: 1fr auto auto; - gap: 0.5rem; - align-items: end; + grid-template-columns:1fr auto auto; + gap: .5rem; + align-items: end } .password-generation-grid .form-group { - margin-bottom: 0; + margin-bottom: 0 } .selected-items-viewer { display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + grid-template-columns:repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; - margin-top: 1.5rem; + margin-top: 1.5rem } .selection-list-container { border: 1px solid #e9ecef; - padding: 0.75rem; - border-radius: 0.25rem; + padding: .75rem; + border-radius: .25rem } .selection-list { list-style-type: none; padding-left: 0; - margin-top: 0.5rem; + margin-top: .5rem; max-height: 200px; - overflow-y: auto; + overflow-y: auto } + .selection-list li { display: flex; justify-content: space-between; align-items: center; - padding: 0.25rem 0.5rem; - border-radius: 0.2rem; + padding: .25rem .5rem; + border-radius: .2rem } + .selection-list li:hover { - background-color: #f8f9fa; + background-color: #f8f9fa } + .selection-list li .fa-times-circle { color: #dc3545; cursor: pointer; - opacity: 0.7; + opacity: .7 } + .selection-list li .fa-times-circle:hover { - opacity: 1; + opacity: 1 } .user-edit-footer { @@ -145,26 +155,36 @@ bottom: 0; left: 0; right: 0; - padding: 0.75rem; - background-color: #ffffff; + padding: .75rem; + background-color: #fff; border-top: 1px solid #dee2e6; display: flex; justify-content: flex-end; - z-index: 1000; + z-index: 1000 } -/* Slide-fade transition */ .slide-fade-enter-active, .slide-fade-leave-active { - transition: all .3s ease; + transition: all .3s ease } + .slide-fade-enter, .slide-fade-leave-to { transform: translateY(-10px); - opacity: 0; + opacity: 0 } + .slide-fade-fast-enter-active, .slide-fade-fast-leave-active { - transition: all .2s ease; + transition: all .2s ease } + .slide-fade-fast-enter, .slide-fade-fast-leave-to { transform: translateY(-5px); - opacity: 0; + opacity: 0 +} + +.loading-overlay { + position: relative; + padding: 20px; + background: white; + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) } \ No newline at end of file diff --git a/public/js/pages/UserEdit/UserEdit.js b/public/js/pages/UserEdit/UserEdit.js index 0d8622d34..296594535 100644 --- a/public/js/pages/UserEdit/UserEdit.js +++ b/public/js/pages/UserEdit/UserEdit.js @@ -5,9 +5,12 @@ Vue.component("UserEdit", {
@@ -94,7 +97,7 @@ Vue.component("UserEdit", {
- +
@@ -116,6 +119,32 @@ Vue.component("UserEdit", {
+ + +
+
+
+ {{ Math.round(bulkProgress) }}% +
+
+
Benutzer {{ bulkCurrentRow + 1 }} von {{ bulkTotalRows }} wird erstellt...
+
+
+

+ Hinweis: Die Berechtigungen, Zugriffe und allgemeinen Einstellungen (Firma, 2FA, etc.) werden aus dem Hauptformular übernommen. Tragen Sie hier nur die individuellen Benutzerdaten wie Name und E-Mail ein. +

+ +
+
`, components: { @@ -160,6 +189,27 @@ Vue.component("UserEdit", { employeeSpecific: true, projects: true, security: true, + }, + showBulkCreateModal: false, + isBulkSaving: false, + bulkUsers: [], + bulkProgress: 0, + bulkCurrentRow: 0, + bulkTotalRows: 0, + bulkCreateConfig: { + fields: { + username: { type: 'input', label: 'Username' }, + name: { type: 'input', label: 'Name' }, + email: { type: 'input', label: 'Email', inputType: 'email' }, + mobile: { type: 'input', label: 'Handy Nr.' } + }, + validateForm(formData) { + if (!formData.username || !formData.name || !formData.email) { + window.notify('error', 'Username, Name und Email sind Pflichtfelder.'); + return false; + } + return true; + } } } }, @@ -320,12 +370,23 @@ Vue.component("UserEdit", { (this.user.constructionconsent_projects || []).forEach(val => formData.append('constructionconsent_projects[]', val)); axios.post(window.TT_CONFIG.SAVE_URL, formData) - .then(() => { - window.notify('success', 'Benutzer erfolgreich gespeichert.'); - setTimeout(() => window.location.href = '/User', 150); + .then(response => { + // A successful save redirects. The final URL will be different from the POST URL. + const wasRedirected = response.request.responseURL && !response.request.responseURL.endsWith(window.TT_CONFIG.SAVE_URL); + + if (response.status === 200 && wasRedirected) { + window.notify('success', 'Benutzer erfolgreich gespeichert.'); + setTimeout(() => window.location.href = '/User', 150); + } else if (response.data && response.data.success === false) { // Explicit JSON error from backend + const errorMsg = response.data.errors ? Object.values(response.data.errors).join('
') : response.data.message; + window.notify('error', errorMsg || 'Fehler beim Speichern des Benutzers.'); + } else { // Fallback + window.notify('error', 'Eine unerwartete Antwort vom Server wurde empfangen.'); + } }) .catch(error => { - window.notify('error', 'Fehler beim Speichern des Benutzers.'); + const errorMsg = error.response?.data?.errors ? Object.values(error.response.data.errors).join('
') : (error.response?.data?.message || 'Fehler beim Speichern des Benutzers.'); + window.notify('error', errorMsg); console.error(error); }) .finally(() => { @@ -341,13 +402,16 @@ Vue.component("UserEdit", { window.notify('error', 'API Key konnte nicht generiert werden.'); } }, - generatePassword() { + generatePassword(returnOnly = false) { const length = 14; const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; let retVal = ""; for (let i = 0, n = charset.length; i < length; ++i) { retVal += charset.charAt(Math.floor(Math.random() * n)); } + if(returnOnly) { + return retVal; + } this.password.new = retVal; this.password.repeat = retVal; window.notify('info', 'Neues Passwort generiert.'); @@ -360,6 +424,103 @@ Vue.component("UserEdit", { if (index > -1) { this.user[arrayName].splice(index, 1); } + }, + async handleBulkCreate() { + if (this.bulkUsers.length === 0) { + window.notify('error', 'Bitte fügen Sie mindestens einen Benutzer hinzu.'); + return; + } + this.isBulkSaving = true; + this.bulkTotalRows = this.bulkUsers.length; + this.bulkCurrentRow = 0; + this.bulkProgress = 0; + + const createdUsersForCSV = []; + const failedUsers = []; + + for (const [index, user] of this.bulkUsers.entries()) { + this.bulkCurrentRow = index; + this.bulkProgress = ((index + 1) / this.bulkTotalRows) * 100; + + const password = this.generatePassword(true); + const formData = new FormData(); + + // Append base data from main form + formData.append('active', this.user.active ? 'true' : 'false'); + formData.append('twofactorrequired', this.user.twofactorrequired ? 'true' : 'false'); + for (const key in this.user.permissions) { + if (this.user.permissions[key]) { + if (key.startsWith('can')) { + formData.append(`can[${key.replace('can', '')}]`, 'true'); + } else { + formData.append(key, 'true'); + } + } + } + const baseFields = ['address_id', 'employee_number', 'project_api_key', 'vodia_identity_domain', 'vodia_identity_username', 'vodia_identity_default']; + baseFields.forEach(field => formData.append(field, this.user[field] || '')); + (this.user.preorder_networks || []).forEach(val => formData.append('preorder_networks[]', val)); + (this.user.constructionconsent_projects || []).forEach(val => formData.append('constructionconsent_projects[]', val)); + + // Append user-specific data from loop + formData.append('username', user.username); + formData.append('name', user.name); + formData.append('email', user.email); + formData.append('mobile', user.mobile || ''); + formData.append('password', password); + formData.append('password2', password); + + try { + const response = await axios.post(window.TT_CONFIG.SAVE_URL, formData); + const wasRedirected = response.request.responseURL && !response.request.responseURL.endsWith(window.TT_CONFIG.SAVE_URL); + + if (response.status === 200 && wasRedirected) { + createdUsersForCSV.push({ ...user, password: password }); + } else { + const errorMsg = response.data.errors ? Object.values(response.data.errors).join(', ') : (response.data.message || 'Unbekannter Serverfehler'); + failedUsers.push({ ...user, error: errorMsg }); + } + } catch (error) { + const errorMsg = error.response?.data?.errors ? Object.values(error.response.data.errors).join(', ') : (error.response?.data?.message || error.message); + failedUsers.push({ ...user, error: errorMsg }); + } + } + + this.isBulkSaving = false; + + if (createdUsersForCSV.length > 0) { + this.downloadCSV(createdUsersForCSV); + window.notify('success', `${createdUsersForCSV.length} von ${this.bulkTotalRows} Benutzern erfolgreich erstellt.`); + } + + if (failedUsers.length > 0) { + let errorMessage = `${failedUsers.length} Benutzer konnten nicht erstellt werden:\n` + failedUsers.map(u => `- ${u.username}: ${u.error}`).join('\n'); + alert(errorMessage); + } + + if (createdUsersForCSV.length > 0 && failedUsers.length === 0) { + this.showBulkCreateModal = false; + setTimeout(() => window.location.href = '/User', 500); + } + }, + downloadCSV(data) { + if (!data || data.length === 0) return; + + const headers = ['Username', 'Password', 'Name', 'Email']; + const rows = data.map(user => [ + `"${user.username}"`, `"${user.password}"`, `"${user.name}"`, `"${user.email}"` + ]); + + let csvContent = headers.join(";") + "\n" + rows.map(e => e.join(";")).join("\n"); + + const blob = new Blob([`\uFEFF${csvContent}`], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement("a"); + const url = URL.createObjectURL(blob); + link.setAttribute("href", url); + link.setAttribute("download", "new_users_credentials.csv"); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); } }, created() {