Merge branch 'User/add-new-features' into 'master'
added bulk creation and e-mail sending See merge request fronk/thetool!1807
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
<?php
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
|
||||
/**
|
||||
* Description of UserController
|
||||
*
|
||||
@@ -44,6 +47,7 @@ class UserController extends mfBaseController
|
||||
"ADD_URL" => 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) ? '<img src="cid:logo_thetool" alt="TheTOOL Logo" style="max-height: 40px; border: 0;">' : '';
|
||||
$logoXinonTag = file_exists($logoXinonPath) ? '<img src="cid:logo_xinon" alt="XINON Logo" style="max-height: 40px; border: 0;">' : '';
|
||||
|
||||
$currentYear = date("Y");
|
||||
$xinonBlue = '#005384';
|
||||
$loginLink = 'https://thetool.xinon.at';
|
||||
$passwordResetLink = 'https://thetool.xinon.at/UserPasswordReset/forgotPassword';
|
||||
|
||||
$html = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{$subject}</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
||||
table, td { border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; -ms-interpolation-mode: bicubic; }
|
||||
p { display: block; margin: 13px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f3f4f6;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f3f4f6;">
|
||||
<tr>
|
||||
<td style="padding: 20px 10px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; max-width: 600px;">
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background-color: #ffffff; width: 100%; border-radius: 8px; box-shadow: 0 4px 10px rgba(0,0,0,0.1); border-top: 5px solid {$xinonBlue};">
|
||||
<tr>
|
||||
<td align="center" style="padding: 25px 20px; border-bottom: 1px solid #eeeeee;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td align="left" width="50%" style="text-align: left; padding-left: 10px;">{$logoToolTag}</td>
|
||||
<td align="right" width="50%" style="text-align: right; padding-right: 10px;">{$logoXinonTag}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 30px 40px 35px 40px;">
|
||||
<h1 style="font-family: Arial, sans-serif; font-size: 28px; font-weight: bold; color: #111827; margin: 0 0 16px 0;">Willkommen an Bord, {$firstName}!</h1>
|
||||
<p style="font-family: Arial, sans-serif; font-size: 16px; color: #4b5563; margin: 0 0 24px;">Wir freuen uns, Sie im Team zu haben. Ihr Zugang zu <strong>einer smarteren Arbeitsweise</strong> ist freigeschaltet.</p>
|
||||
|
||||
<p style="font-family: Arial, sans-serif; font-size: 16px; color: #4b5563;">Ihr persönlicher Benutzername lautet:</p>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td style="background-color: #f3f4f6; border-radius: 5px; padding: 12px 15px; font-family: monospace, sans-serif; font-size: 18px; color: #111827; text-align: center; border: 1px solid #e5e7eb;">
|
||||
{$userUsername}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="font-family: Arial, sans-serif; font-size: 16px; color: #4b5563; margin-top: 30px; text-align: center;">Um zu starten, legen Sie bitte Ihr <strong>persönliches Passwort</strong> fest.</p>
|
||||
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td align="center" style="padding: 15px 0 25px 0;">
|
||||
<a href="{$passwordResetLink}" target="_blank" style="font-family: Arial, sans-serif; font-size: 18px; font-weight: bold; color: #ffffff; text-decoration: none; background-color: {$xinonBlue}; padding: 15px 30px; border-radius: 8px; display: inline-block;">Passwort erstmalig setzen</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td align="center" style="border-top: 1px solid #eeeeee; padding-top: 25px;">
|
||||
<p style="font-family: Arial, sans-serif; font-size: 14px; color: #6b7280; margin: 0;">Oder besitzen Sie bereits ein Passwort?</p>
|
||||
<a href="{$loginLink}" target="_blank" style="font-family: Arial, sans-serif; font-size: 14px; color: {$xinonBlue}; text-decoration: underline;">Direkt zur Login-Seite</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 20px 40px; text-align: center; font-size: 12px; color: #6b7280; border-top: 1px solid #eeeeee; background-color: #fafafa; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px;">
|
||||
<p style="font-family: Arial, sans-serif; margin: 0;">
|
||||
© {$currentYear} XINON GmbH | <a href="https://xinon.at/impressum/" target="_blank" style="color: {$xinonBlue}; text-decoration: none;">Impressum</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +1,109 @@
|
||||
Vue.component("User", {
|
||||
template: `
|
||||
<tt-card>
|
||||
<tt-table :data="window['TT_CONFIG']['USERS']" :config="UserTableConfig">
|
||||
<template v-slot:top-buttons>
|
||||
<tt-button @click="window.location = window['TT_CONFIG']['ADD_URL']"
|
||||
additional-class="btn-primary"
|
||||
text="Benutzer hinzufügen"
|
||||
icon="fas fa-plus"/>
|
||||
</template>
|
||||
<div>
|
||||
<tt-card>
|
||||
<tt-table :data="window['TT_CONFIG']['USERS']" :config="UserTableConfig">
|
||||
<template v-slot:top-buttons>
|
||||
<tt-button @click="window.location = window['TT_CONFIG']['ADD_URL']"
|
||||
additional-class="btn-primary"
|
||||
text="Benutzer hinzufügen"
|
||||
icon="fas fa-plus"/>
|
||||
</template>
|
||||
|
||||
<template v-slot:actions="{ row: user }">
|
||||
<div class="d-flex justify-content-center" style="gap: 4px">
|
||||
<tt-button @click="window.location = window['TT_CONFIG']['EDIT_URL'] + '?id=' + user.id"
|
||||
additional-class="btn-outline-primary"
|
||||
sm
|
||||
icon="far fa-edit"
|
||||
title="Bearbeiten"/>
|
||||
<template v-slot:actions="{ row: user }">
|
||||
<div class="d-flex justify-content-center" style="gap: 4px">
|
||||
<tt-button @click="window.location = window['TT_CONFIG']['EDIT_URL'] + '?id=' + user.id"
|
||||
additional-class="btn-outline-primary"
|
||||
sm
|
||||
icon="far fa-edit"
|
||||
title="Bearbeiten"/>
|
||||
<tt-button @click="openSendMailModal(user)"
|
||||
additional-class="btn-outline-info"
|
||||
sm
|
||||
title="Login E-Mail senden"
|
||||
icon="far fa-envelope"/>
|
||||
<tt-button @click="window.location = window['TT_CONFIG']['IMPERSONATE_URL'] + '?username=' + user.username"
|
||||
additional-class="btn-outline-secondary"
|
||||
sm
|
||||
title="Impersonate"
|
||||
icon="far fa-user-secret"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<tt-button @click="window.location = window['TT_CONFIG']['IMPERSONATE_URL'] + '?username=' + user.username"
|
||||
additional-class="btn-outline-secondary"
|
||||
sm
|
||||
title="Impersonate"
|
||||
icon="far fa-user-secret"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</tt-table>
|
||||
</tt-card>
|
||||
`, data: () => ({
|
||||
window: window, UserTableConfig: {
|
||||
key: "UserTable",
|
||||
tableHeader: "Benutzer",
|
||||
</tt-table>
|
||||
</tt-card>
|
||||
<tt-modal disable-min-height
|
||||
:show.sync="showSendMailModal"
|
||||
v-if="selectedUserForMail"
|
||||
:title="'Login E-Mail an ' + selectedUserForMail.name + ' senden?'"
|
||||
@submit="sendLoginEmail"
|
||||
:save-loading="isSendingMail"
|
||||
save-text="Senden"
|
||||
:delete="false">
|
||||
<p>Sind Sie sicher, dass Sie die Login-Informationen an <strong>{{ selectedUserForMail.email }}</strong> senden möchten?</p>
|
||||
</tt-modal>
|
||||
</div>
|
||||
`,
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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)
|
||||
}
|
||||
@@ -5,9 +5,12 @@ Vue.component("UserEdit", {
|
||||
<tt-loader v-if="isSaving"/>
|
||||
<div class="user-edit-header sticky-header">
|
||||
<h3>Allgemeine Informationen</h3>
|
||||
<tt-tooltip :text="permissionChangesTooltip" position="left">
|
||||
<tt-button text="Speichern" icon="fas fa-save" additional-class="btn-primary" @click="saveUser" :loading="isSaving"/>
|
||||
</tt-tooltip>
|
||||
<div class="header-actions">
|
||||
<tt-button v-if="isNewUser" text="Mehrere User erstellen" icon="fas fa-users" additional-class="btn-info" @click="showBulkCreateModal = true"/>
|
||||
<tt-tooltip :text="permissionChangesTooltip" position="left">
|
||||
<tt-button text="Speichern" icon="fas fa-save" additional-class="btn-primary" @click="saveUser" :loading="isSaving"/>
|
||||
</tt-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-form-grid">
|
||||
<tt-input label="Username" v-model="user.username" sm :disabled="!isNewUser"/>
|
||||
@@ -94,7 +97,7 @@ Vue.component("UserEdit", {
|
||||
<tt-card>
|
||||
<div class="password-generation-grid">
|
||||
<tt-input label="Neues Passwort" v-model="password.new" :type="passwordFieldType" sm/>
|
||||
<tt-button icon="fas fa-sync-alt" @click="generatePassword" additional-class="btn-outline-secondary" sm title="Passwort generieren"/>
|
||||
<tt-button icon="fas fa-sync-alt" @click="generatePassword()" additional-class="btn-outline-secondary" sm title="Passwort generieren"/>
|
||||
<tt-button :icon="passwordFieldType === 'password' ? 'fas fa-eye' : 'fas fa-eye-slash'" @click="togglePasswordVisibility" additional-class="btn-outline-secondary" sm title="Passwort anzeigen"/>
|
||||
</div>
|
||||
<tt-input label="Passwort wiederholen" v-model="password.repeat" type="password" sm/>
|
||||
@@ -116,6 +119,32 @@ Vue.component("UserEdit", {
|
||||
</tt-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tt-modal
|
||||
v-if="showBulkCreateModal"
|
||||
:show.sync="showBulkCreateModal"
|
||||
title="Mehrere Benutzer erstellen"
|
||||
save-text="Erstellen & CSV herunterladen"
|
||||
:delete="false"
|
||||
:save-loading="isBulkSaving"
|
||||
@submit="handleBulkCreate"
|
||||
>
|
||||
<div v-if="isBulkSaving" class="loading-overlay mt-4">
|
||||
<div class="progress" style="height: 30px;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
||||
:style="{ width: bulkProgress + '%' }">
|
||||
{{ Math.round(bulkProgress) }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center mt-2">Benutzer {{ bulkCurrentRow + 1 }} von {{ bulkTotalRows }} wird erstellt...</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="alert alert-info">
|
||||
<strong>Hinweis:</strong> 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.
|
||||
</p>
|
||||
<tt-positions-manager v-model="bulkUsers" :config="bulkCreateConfig" />
|
||||
</div>
|
||||
</tt-modal>
|
||||
</tt-card>
|
||||
`,
|
||||
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('<br>') : 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('<br>') : (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() {
|
||||
|
||||
Reference in New Issue
Block a user