feat: Implement password reset functionality

- Added PasswordResetController to handle password reset requests and confirmations.
- Created views for password reset request, confirmation, and invalid link scenarios.
- Implemented email sending for password reset requests with security measures.
- Added form validation for password reset confirmation, including password strength requirements.
- Enhanced user experience with success and error messages during the password reset process.
This commit is contained in:
olli 2025-11-11 14:31:40 +01:00
parent b3e42b5eb5
commit 3e30d958b3
9 changed files with 1483 additions and 213 deletions

View File

@ -9,6 +9,19 @@
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<p class="text-500 mb-2">Wählen Sie die anzuzeigenden Spalten aus und ziehen Sie sie, um die Reihenfolge zu ändern:</p> <p class="text-500 mb-2">Wählen Sie die anzuzeigenden Spalten aus und ziehen Sie sie, um die Reihenfolge zu ändern:</p>
<!-- Freeze first column option -->
<div class="flex items-center gap-2 p-3 bg-surface-50 dark:bg-surface-800 border rounded-md">
<i class="pi pi-lock text-500"></i>
<Checkbox
inputId="freeze-first"
v-model="localFreezeFirst"
:binary="true"
/>
<label for="freeze-first" class="cursor-pointer flex-1">
Erste Spalte beim horizontalen Scrollen fixieren
</label>
</div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div <div
v-for="(column, index) in localColumns" v-for="(column, index) in localColumns"
@ -55,14 +68,19 @@ const props = defineProps({
visibleColumns: { visibleColumns: {
type: Object, type: Object,
required: true required: true
},
freezeFirstColumn: {
type: Boolean,
default: false
} }
}) })
const emit = defineEmits(['update:visible', 'update:columns', 'update:visibleColumns', 'save']) const emit = defineEmits(['update:visible', 'update:columns', 'update:visibleColumns', 'update:freezeFirstColumn', 'save'])
// Local copies for editing // Local copies for editing
const localColumns = ref([...props.columns]) const localColumns = ref([...props.columns])
const localVisibility = ref({ ...props.visibleColumns }) const localVisibility = ref({ ...props.visibleColumns })
const localFreezeFirst = ref(props.freezeFirstColumn)
// Watch for prop changes // Watch for prop changes
watch(() => props.columns, (newVal) => { watch(() => props.columns, (newVal) => {
@ -73,6 +91,10 @@ watch(() => props.visibleColumns, (newVal) => {
localVisibility.value = { ...newVal } localVisibility.value = { ...newVal }
}, { deep: true }) }, { deep: true })
watch(() => props.freezeFirstColumn, (newVal) => {
localFreezeFirst.value = newVal
})
// Drag and drop for column reordering // Drag and drop for column reordering
let draggedIndex = null let draggedIndex = null
@ -96,6 +118,7 @@ const onDrop = (dropIndex) => {
const save = () => { const save = () => {
emit('update:columns', localColumns.value) emit('update:columns', localColumns.value)
emit('update:visibleColumns', localVisibility.value) emit('update:visibleColumns', localVisibility.value)
emit('update:freezeFirstColumn', localFreezeFirst.value)
emit('save') emit('save')
} }
@ -103,6 +126,7 @@ const cancel = () => {
// Reset local changes // Reset local changes
localColumns.value = [...props.columns] localColumns.value = [...props.columns]
localVisibility.value = { ...props.visibleColumns } localVisibility.value = { ...props.visibleColumns }
localFreezeFirst.value = props.freezeFirstColumn
emit('update:visible', false) emit('update:visible', false)
} }
</script> </script>

View File

@ -35,6 +35,27 @@
<!-- Optional: Custom Filter Buttons --> <!-- Optional: Custom Filter Buttons -->
<slot name="filter-buttons" :load-data="loadData"></slot> <slot name="filter-buttons" :load-data="loadData"></slot>
<!-- Filter Active Indicator -->
<div v-if="hasActiveFilters" class="mb-3 p-3 bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg flex items-center justify-between">
<div class="flex items-center gap-2">
<i class="pi pi-filter text-primary-600 dark:text-primary-400"></i>
<span class="text-sm text-primary-900 dark:text-primary-100">
<strong>{{ filterCount }}</strong> {{ filterCount === 1 ? 'Filter aktiv' : 'Filter aktiv' }}
<span v-if="filteredData.length > 0" class="text-primary-700 dark:text-primary-300">
- {{ filteredData.length }} von {{ data.length }} {{ data.length === 1 ? 'Datensatz' : 'Datensätze' }} angezeigt
</span>
</span>
</div>
<Button
label="Filter zurücksetzen"
icon="pi pi-filter-slash"
text
size="small"
severity="secondary"
@click="clearAllFilters"
/>
</div>
<!-- DataTable --> <!-- DataTable -->
<DataTable <DataTable
ref="dt" ref="dt"
@ -60,7 +81,7 @@
</template> </template>
<!-- Dynamische Spalten basierend auf Config --> <!-- Dynamische Spalten basierend auf Config -->
<template v-for="column in visibleColumnsOrdered" :key="column.key"> <template v-for="(column, index) in visibleColumnsOrdered" :key="column.key">
<Column <Column
:field="column.field || column.key" :field="column.field || column.key"
:header="column.label" :header="column.label"
@ -69,6 +90,8 @@
:dataType="column.dataType" :dataType="column.dataType"
:showFilterMatchModes="column.showFilterMatchModes !== false" :showFilterMatchModes="column.showFilterMatchModes !== false"
:showFilterOperator="column.showFilterOperator !== false" :showFilterOperator="column.showFilterOperator !== false"
:frozen="freezeFirstColumn && index === 0"
:alignFrozen="freezeFirstColumn && index === 0 ? 'left' : undefined"
> >
<!-- Custom Body Template (via Slot) --> <!-- Custom Body Template (via Slot) -->
<template v-if="$slots[`body-${column.key}`]" #body="slotProps"> <template v-if="$slots[`body-${column.key}`]" #body="slotProps">
@ -140,13 +163,14 @@
v-model:visible="columnConfigDialog" v-model:visible="columnConfigDialog"
v-model:columns="availableColumns" v-model:columns="availableColumns"
v-model:visible-columns="visibleColumns" v-model:visible-columns="visibleColumns"
v-model:freeze-first-column="freezeFirstColumn"
@save="saveColumnConfig" @save="saveColumnConfig"
/> />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import { FilterMatchMode, FilterOperator } from '@primevue/core/api' import { FilterMatchMode, FilterOperator } from '@primevue/core/api'
import DataTable from 'primevue/datatable' import DataTable from 'primevue/datatable'
@ -221,6 +245,46 @@ const loading = ref(false)
const columnConfigDialog = ref(false) const columnConfigDialog = ref(false)
const exportMenu = ref() const exportMenu = ref()
const exportButton = ref() const exportButton = ref()
const freezeFirstColumn = ref(false)
// Check if any filters are active
const hasActiveFilters = computed(() => {
// Check global filter
if (internalFilters.value.global?.value) {
return true
}
// Check column filters
return Object.keys(internalFilters.value).some(key => {
if (key === 'global') return false
const filter = internalFilters.value[key]
if (filter?.constraints) {
return filter.constraints.some(c => c.value !== null && c.value !== undefined && c.value !== '')
}
return false
})
})
const filterCount = computed(() => {
let count = 0
// Count global filter
if (internalFilters.value.global?.value) {
count++
}
// Count column filters
Object.keys(internalFilters.value).forEach(key => {
if (key === 'global') return
const filter = internalFilters.value[key]
if (filter?.constraints) {
const hasValue = filter.constraints.some(c => c.value !== null && c.value !== undefined && c.value !== '')
if (hasValue) count++
}
})
return count
})
// Export Menu Items // Export Menu Items
const exportItems = computed(() => [ const exportItems = computed(() => [
@ -291,6 +355,32 @@ function loadColumnConfig() {
if (saved) { if (saved) {
const config = JSON.parse(saved) const config = JSON.parse(saved)
// Load filters if available - will be applied in onMounted
if (config.filters) {
// Deep merge filters into internalFilters
Object.keys(config.filters).forEach(key => {
const savedFilter = config.filters[key]
if (savedFilter) {
// Handle both constraint-based (menu) and value-based (global) filters
if (savedFilter.constraints) {
// Menu filter with constraints
const hasValue = savedFilter.constraints.some(c => c.value !== null && c.value !== undefined && c.value !== '')
if (hasValue) {
internalFilters.value[key] = JSON.parse(JSON.stringify(savedFilter))
}
} else if (key === 'global' && (savedFilter.value !== null && savedFilter.value !== undefined && savedFilter.value !== '')) {
// Global filter
internalFilters.value[key] = { ...savedFilter }
}
}
})
}
// Load freeze first column setting
if (config.freezeFirstColumn !== undefined) {
freezeFirstColumn.value = config.freezeFirstColumn
}
// Handle both old and new format // Handle both old and new format
if (config.visibility) { if (config.visibility) {
// Apply saved order // Apply saved order
@ -329,7 +419,9 @@ function loadColumnConfig() {
function saveColumnConfig() { function saveColumnConfig() {
const config = { const config = {
visibility: visibleColumns.value, visibility: visibleColumns.value,
order: availableColumns.value.map(col => col.key) order: availableColumns.value.map(col => col.key),
filters: internalFilters.value,
freezeFirstColumn: freezeFirstColumn.value
} }
localStorage.setItem(props.storageKey, JSON.stringify(config)) localStorage.setItem(props.storageKey, JSON.stringify(config))
columnConfigDialog.value = false columnConfigDialog.value = false
@ -342,6 +434,61 @@ function saveColumnConfig() {
}) })
} }
// Save filters to localStorage whenever they change
function saveFilters() {
const saved = localStorage.getItem(props.storageKey)
const config = saved ? JSON.parse(saved) : {
visibility: visibleColumns.value,
order: availableColumns.value.map(col => col.key)
}
// Clean up filters - only save non-empty values
const cleanedFilters = {}
Object.keys(internalFilters.value).forEach(key => {
const filter = internalFilters.value[key]
if (filter) {
if (filter.constraints) {
// Menu filter - only save if has actual values
const hasValue = filter.constraints.some(c => c.value !== null && c.value !== undefined && c.value !== '')
if (hasValue) {
cleanedFilters[key] = filter
}
} else if (key === 'global' && filter.value !== null && filter.value !== undefined && filter.value !== '') {
// Global filter
cleanedFilters[key] = filter
}
}
})
config.filters = cleanedFilters
localStorage.setItem(props.storageKey, JSON.stringify(config))
}
function clearAllFilters() {
// Reset global filter
internalFilters.value.global = { value: null, matchMode: FilterMatchMode.CONTAINS }
// Reset all column filters
Object.keys(internalFilters.value).forEach(key => {
if (key !== 'global' && internalFilters.value[key]?.constraints) {
internalFilters.value[key].constraints = [{ value: null, matchMode: FilterMatchMode.STARTS_WITH }]
}
})
// Clear filtered data
filteredData.value = []
// Save cleared filters
saveFilters()
toast.add({
severity: 'info',
summary: 'Filter zurückgesetzt',
detail: 'Alle Filter wurden entfernt',
life: 3000
})
}
async function loadData(params = {}) { async function loadData(params = {}) {
loading.value = true loading.value = true
try { try {
@ -387,6 +534,9 @@ async function loadData(params = {}) {
function onFilter(event) { function onFilter(event) {
// PrimeVue's @filter event provides filteredValue // PrimeVue's @filter event provides filteredValue
filteredData.value = event.filteredValue || [] filteredData.value = event.filteredValue || []
// Save filters to localStorage
saveFilters()
} }
// Export Functions // Export Functions
@ -593,13 +743,18 @@ function exportToExcel() {
} }
} }
// Watch for changes in global filter to save automatically
watch(() => internalFilters.value.global?.value, () => {
saveFilters()
}, { deep: true })
// Initialize // Initialize
onMounted(() => { onMounted(() => {
visibleColumns.value = loadColumnConfig() visibleColumns.value = loadColumnConfig()
// Initialize column-specific filters with operator structure for menu mode // Initialize column-specific filters with operator structure for menu mode
props.columns.forEach(col => { props.columns.forEach(col => {
if (col.filterable !== false) { if (col.filterable !== false && !internalFilters.value[col.key]) {
internalFilters.value[col.key] = { internalFilters.value[col.key] = {
operator: FilterOperator.AND, operator: FilterOperator.AND,
constraints: [{ value: null, matchMode: FilterMatchMode.STARTS_WITH }] constraints: [{ value: null, matchMode: FilterMatchMode.STARTS_WITH }]

View File

@ -39,6 +39,8 @@ security:
# Note: Only the *first* access control that matches will be used # Note: Only the *first* access control that matches will be used
access_control: access_control:
- { path: ^/login, roles: PUBLIC_ACCESS } - { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/password-reset, roles: PUBLIC_ACCESS }
- { path: ^/password-setup, roles: PUBLIC_ACCESS }
- { path: ^/connect/pocketid, roles: PUBLIC_ACCESS } - { path: ^/connect/pocketid, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
- { path: ^/, roles: ROLE_USER } - { path: ^/, roles: ROLE_USER }

View File

@ -0,0 +1,109 @@
<?php
namespace App\Controller;
use App\Repository\UserRepository;
use App\Service\PasswordSetupService;
use App\Service\SettingsService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
class PasswordResetController extends AbstractController
{
public function __construct(
private PasswordSetupService $passwordSetupService,
private SettingsService $settingsService,
private UserPasswordHasherInterface $passwordHasher,
private EntityManagerInterface $entityManager,
private UserRepository $userRepository
) {}
#[Route('/password-reset', name: 'app_password_reset_request', methods: ['GET', 'POST'])]
public function request(Request $request): Response
{
// Redirect to login if already authenticated
if ($this->getUser()) {
return $this->redirectToRoute('app_home');
}
$emailSent = false;
$error = null;
if ($request->isMethod('POST')) {
$email = $request->request->get('email');
if (empty($email)) {
$error = 'Bitte geben Sie Ihre E-Mail-Adresse ein';
} else {
$user = $this->userRepository->findOneBy(['email' => $email]);
// Always show success message for security (don't reveal if email exists)
if ($user) {
try {
$this->passwordSetupService->sendPasswordResetEmail($user);
} catch (\Exception $e) {
// Log error but don't show to user
error_log('Password reset email failed: ' . $e->getMessage());
}
}
$emailSent = true;
}
}
return $this->render('security/password_reset_request.html.twig', [
'email_sent' => $emailSent,
'error' => $error,
]);
}
#[Route('/password-reset/{token}', name: 'app_password_reset_confirm', methods: ['GET', 'POST'])]
public function confirm(string $token, Request $request): Response
{
$user = $this->passwordSetupService->getUserByToken($token);
if (!$user) {
return $this->render('security/password_reset_invalid.html.twig');
}
$error = null;
$minLength = $this->settingsService->getPasswordMinLength();
if ($request->isMethod('POST')) {
$password = $request->request->get('password');
$passwordConfirm = $request->request->get('password_confirm');
// Validate password
if (empty($password)) {
$error = 'Das Passwort darf nicht leer sein';
} elseif (mb_strlen($password) < $minLength) {
$error = "Das Passwort muss mindestens {$minLength} Zeichen lang sein";
} elseif ($password !== $passwordConfirm) {
$error = 'Die Passwörter stimmen nicht überein';
} else {
// Hash and save password
$hashedPassword = $this->passwordHasher->hashPassword($user, $password);
$user->setPassword($hashedPassword);
$user->setPasswordSetupToken(null);
$user->setPasswordSetupTokenExpiresAt(null);
$this->entityManager->flush();
return $this->redirectToRoute('app_login', [
'password_reset' => 1
]);
}
}
return $this->render('security/password_reset_confirm.html.twig', [
'user' => $user,
'token' => $token,
'error' => $error,
'min_length' => $minLength
]);
}
}

View File

@ -22,6 +22,22 @@ class PasswordSetupService
* Generate a secure token and send setup email * Generate a secure token and send setup email
*/ */
public function sendPasswordSetupEmail(User $user): void public function sendPasswordSetupEmail(User $user): void
{
$this->sendEmail($user, 'setup');
}
/**
* Generate a secure token and send password reset email
*/
public function sendPasswordResetEmail(User $user): void
{
$this->sendEmail($user, 'reset');
}
/**
* Internal method to send email (setup or reset)
*/
private function sendEmail(User $user, string $type): void
{ {
$logFile = $this->projectDir . '/var/log/mail.log'; $logFile = $this->projectDir . '/var/log/mail.log';
@ -38,25 +54,26 @@ class PasswordSetupService
$this->entityManager->flush(); $this->entityManager->flush();
// Generate setup URL // Generate setup URL
$setupUrl = $this->appUrl . '/password-setup/' . $token; $setupUrl = $this->appUrl . ($type === 'reset' ? '/password-reset/' : '/password-setup/') . $token;
// Send email // Send email
$email = (new Email()) $email = (new Email())
->from('noreply@mycrm.local') ->from('noreply@mycrm.local')
->to($user->getEmail()) ->to($user->getEmail())
->subject('Willkommen bei myCRM - Passwort einrichten') ->subject($type === 'reset' ? 'Passwort zurücksetzen - myCRM' : 'Willkommen bei myCRM - Passwort einrichten')
->html($this->getEmailTemplate($user, $setupUrl)); ->html($this->getEmailTemplate($user, $setupUrl, $type));
$this->mailer->send($email); $this->mailer->send($email);
// Log success // Log success
$logEntry = sprintf( $logEntry = sprintf(
"[%s] Password setup email queued successfully\n" . "[%s] Password %s email queued successfully\n" .
" User: %s %s (%s)\n" . " User: %s %s (%s)\n" .
" Token expires: %s\n" . " Token expires: %s\n" .
" Setup URL: %s\n" . " Setup URL: %s\n" .
" ------------------------\n\n", " ------------------------\n\n",
date('Y-m-d H:i:s'), date('Y-m-d H:i:s'),
$type,
$user->getFirstName(), $user->getFirstName(),
$user->getLastName(), $user->getLastName(),
$user->getEmail(), $user->getEmail(),
@ -68,11 +85,12 @@ class PasswordSetupService
} catch (\Exception $e) { } catch (\Exception $e) {
// Log error // Log error
$logEntry = sprintf( $logEntry = sprintf(
"[%s] ✗ Failed to send password setup email\n" . "[%s] ✗ Failed to send password %s email\n" .
" User: %s %s (%s)\n" . " User: %s %s (%s)\n" .
" Error: %s\n" . " Error: %s\n" .
" ------------------------\n\n", " ------------------------\n\n",
date('Y-m-d H:i:s'), date('Y-m-d H:i:s'),
$type,
$user->getFirstName(), $user->getFirstName(),
$user->getLastName(), $user->getLastName(),
$user->getEmail(), $user->getEmail(),
@ -120,8 +138,17 @@ class PasswordSetupService
return true; return true;
} }
private function getEmailTemplate(User $user, string $setupUrl): string private function getEmailTemplate(User $user, string $setupUrl, string $type = 'setup'): string
{ {
$isReset = $type === 'reset';
$title = $isReset ? '🔒 Passwort zurücksetzen' : '📊 Willkommen bei myCRM';
$greeting = $isReset
? "Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt."
: "Ihr Administrator hat einen Account für Sie in myCRM erstellt.";
$buttonText = $isReset ? 'Passwort zurücksetzen' : 'Passwort einrichten';
$action = $isReset ? 'zurückzusetzen' : 'einzurichten und Ihren Account zu aktivieren';
$currentYear = date('Y');
return <<<HTML return <<<HTML
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@ -168,19 +195,19 @@ class PasswordSetupService
</head> </head>
<body> <body>
<div class="header"> <div class="header">
<h1>📊 Willkommen bei myCRM</h1> <h1>{$title}</h1>
</div> </div>
<div class="content"> <div class="content">
<p>Hallo {$user->getFirstName()},</p> <p>Hallo {$user->getFirstName()},</p>
<p>Ihr Administrator hat einen Account für Sie in myCRM erstellt.</p> <p>{$greeting}</p>
<p><strong>E-Mail:</strong> {$user->getEmail()}</p> <p><strong>E-Mail:</strong> {$user->getEmail()}</p>
<p>Bitte klicken Sie auf den folgenden Link, um Ihr Passwort einzurichten und Ihren Account zu aktivieren:</p> <p>Bitte klicken Sie auf den folgenden Link, um Ihr Passwort {$action}:</p>
<div style="text-align: center;"> <div style="text-align: center;">
<a href="{$setupUrl}" class="button">Passwort einrichten</a> <a href="{$setupUrl}" class="button">{$buttonText}</a>
</div> </div>
<p>Oder kopieren Sie diesen Link in Ihren Browser:</p> <p>Oder kopieren Sie diesen Link in Ihren Browser:</p>
@ -194,7 +221,7 @@ class PasswordSetupService
</div> </div>
<div class="footer"> <div class="footer">
<p>Dies ist eine automatisch generierte E-Mail. Bitte antworten Sie nicht auf diese Nachricht.</p> <p>Dies ist eine automatisch generierte E-Mail. Bitte antworten Sie nicht auf diese Nachricht.</p>
<p>&copy; " . date('Y') . " myCRM. Alle Rechte vorbehalten.</p> <p>&copy; {$currentYear} myCRM. Alle Rechte vorbehalten.</p>
</div> </div>
</body> </body>
</html> </html>

View File

@ -4,245 +4,459 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - myCRM</title> <title>Login - myCRM</title>
{{ encore_entry_link_tags('app') }}
<style> <style>
* {
box-sizing: border-box;
}
body { body {
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family: var(--font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: var(--p-surface-50, #f8fafc);
min-height: 100vh; min-height: 100vh;
}
.login-container {
background: var(--p-surface-50, #f8fafc);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh;
min-width: 100vw;
overflow: hidden;
} }
.login-container {
background: white; .login-wrapper {
border-radius: 12px; display: flex;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); flex-direction: column;
padding: 3rem; align-items: center;
justify-content: center;
}
.login-card-wrapper {
border-radius: 56px;
padding: 0.3rem;
background: linear-gradient(180deg, var(--p-primary-color, #3B82F6) 10%, rgba(33, 150, 243, 0) 30%);
}
.login-card {
width: 100%; width: 100%;
max-width: 450px; background: var(--p-surface-0, #ffffff);
padding: 5rem 2rem;
border-radius: 53px;
} }
@media (min-width: 576px) {
.login-card {
padding: 5rem 5rem;
}
}
.login-header { .login-header {
text-align: center; text-align: center;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.login-header h1 {
margin: 0; .login-logo {
color: #2563eb; margin-bottom: 2rem;
font-size: 2rem; width: 4rem;
font-weight: 700; flex-shrink: 0;
margin-left: auto;
margin-right: auto;
} }
.login-header p {
margin: 0.5rem 0 0; .login-title {
color: #6b7280; color: var(--p-surface-900, #0f172a);
font-size: 0.95rem; font-size: 1.875rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #374151;
font-weight: 500; font-weight: 500;
font-size: 0.9rem; margin-bottom: 1rem;
margin-top: 0;
} }
.form-control {
.login-subtitle {
color: var(--p-text-muted-color, #64748b);
font-weight: 500;
margin: 0;
}
.login-form {
width: 100%; width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
} }
.form-control:focus {
outline: none; @media (min-width: 768px) {
border-color: #2563eb; .login-form {
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); width: 30rem;
}
} }
.btn-login {
.form-field {
margin-bottom: 2rem;
}
.form-field label {
display: block;
color: var(--p-surface-900, #0f172a);
font-weight: 500;
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.p-inputtext {
width: 100%; width: 100%;
padding: 0.875rem; padding: var(--p-inputtext-padding-y, 0.75rem) var(--p-inputtext-padding-x, 0.75rem);
background: #2563eb;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem; font-size: 1rem;
font-weight: 600; color: var(--p-inputtext-color, #0f172a);
background: var(--p-inputtext-background, #ffffff);
border: 1px solid var(--p-inputtext-border-color, #cbd5e1);
border-radius: var(--p-inputtext-border-radius, 0.375rem);
transition: background var(--p-inputtext-transition-duration, 0.2s),
border-color var(--p-inputtext-transition-duration, 0.2s),
box-shadow var(--p-inputtext-transition-duration, 0.2s);
outline: 0 none;
font-family: var(--font-family, inherit);
}
.p-inputtext:enabled:hover {
border-color: var(--p-inputtext-hover-border-color, #94a3b8);
}
.p-inputtext:enabled:focus {
border-color: var(--p-inputtext-focus-border-color, #3B82F6);
box-shadow: var(--p-inputtext-focus-ring-shadow, 0 0 0 3px rgba(59, 130, 246, 0.2));
outline: var(--p-inputtext-focus-ring-width, 0) var(--p-inputtext-focus-ring-style, solid) var(--p-inputtext-focus-ring-color, rgba(59, 130, 246, 0.2));
outline-offset: var(--p-inputtext-focus-ring-offset, 0);
}
.p-inputtext::placeholder {
color: var(--p-inputtext-placeholder-color, #94a3b8);
}
.password-wrapper {
position: relative;
width: 100%;
}
.password-toggle {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
cursor: pointer; cursor: pointer;
transition: background 0.2s; color: var(--p-text-muted-color, #64748b);
} transition: color 0.2s;
.btn-login:hover {
background: #1d4ed8;
}
.btn-login:active {
background: #1e40af;
}
.btn-oidc {
background: #10b981;
}
.btn-oidc:hover {
background: #059669;
}
.btn-oidc:active {
background: #047857;
}
.alert {
padding: 1rem;
border-radius: 6px;
margin-bottom: 1.5rem;
font-size: 0.9rem;
}
.alert-danger {
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
.alert-info {
background: #eff6ff;
color: #1e40af;
border: 1px solid #bfdbfe;
}
.remember-me {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; justify-content: center;
margin-bottom: 1.5rem; width: 1.5rem;
height: 1.5rem;
} }
.remember-me input[type="checkbox"] {
width: 18px; .password-toggle:hover {
height: 18px; color: var(--p-text-color, #0f172a);
}
.password-toggle svg {
width: 1.25rem;
height: 1.25rem;
}
.checkbox-wrapper {
display: flex;
align-items: center;
margin-top: 0.5rem;
margin-bottom: 2rem;
gap: 2rem;
}
.checkbox-field {
display: flex;
align-items: center;
}
.p-checkbox {
margin-right: 0.5rem;
}
.p-checkbox input[type="checkbox"] {
width: 1.25rem;
height: 1.25rem;
cursor: pointer; cursor: pointer;
accent-color: var(--p-primary-color, #3B82F6);
background: transparent !important;
background-color: transparent !important;
border: 1px solid #cbd5e1 !important;
border-radius: 0.25rem;
} }
.remember-me label {
.checkbox-field label {
margin: 0; margin: 0;
cursor: pointer; cursor: pointer;
font-weight: normal; font-weight: normal;
color: #6b7280; color: #1e293b !important;
font-size: 1rem;
} }
.test-credentials {
margin-top: 2rem; .forgot-password {
padding: 1rem; font-weight: 500;
background: #f9fafb; text-decoration: none;
border-radius: 6px; margin-left: auto;
font-size: 0.85rem; text-align: right;
cursor: pointer;
color: var(--p-primary-color, #3B82F6);
} }
.test-credentials h4 {
margin: 0 0 0.75rem; .p-button {
color: #374151; width: 100%;
font-size: 0.9rem; padding: var(--p-button-padding-y, 0.75rem) var(--p-button-padding-x, 1.25rem);
font-size: 1rem;
font-weight: var(--p-button-label-font-weight, 500);
color: var(--p-button-primary-color, #ffffff);
background: var(--p-button-primary-background, #3B82F6);
border: 1px solid var(--p-button-primary-border-color, #3B82F6);
border-radius: var(--p-button-border-radius, 0.375rem);
cursor: pointer;
transition: background var(--p-button-transition-duration, 0.2s),
color var(--p-button-transition-duration, 0.2s),
border-color var(--p-button-transition-duration, 0.2s),
box-shadow var(--p-button-transition-duration, 0.2s);
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--p-button-gap, 0.5rem);
text-decoration: none;
font-family: var(--font-family, inherit);
outline: 0 none;
} }
.test-credentials div {
margin: 0.5rem 0; .p-button:enabled:hover {
color: #6b7280; background: var(--p-button-primary-hover-background, #2563eb);
border-color: var(--p-button-primary-hover-border-color, #2563eb);
color: var(--p-button-primary-hover-color, #ffffff);
} }
.test-credentials code {
background: white; .p-button:enabled:active {
padding: 0.25rem 0.5rem; background: var(--p-button-primary-active-background, #1d4ed8);
border-radius: 3px; border-color: var(--p-button-primary-active-border-color, #1d4ed8);
color: #2563eb; color: var(--p-button-primary-active-color, #ffffff);
font-family: 'Courier New', monospace; }
.p-button:focus-visible {
box-shadow: var(--p-button-primary-focus-ring-shadow, 0 0 0 3px rgba(59, 130, 246, 0.5));
outline: var(--p-button-focus-ring-width, 0) var(--p-button-focus-ring-style, solid) var(--p-button-primary-focus-ring-color, rgba(59, 130, 246, 0.5));
outline-offset: var(--p-button-focus-ring-offset, 0);
}
.p-button-outlined {
background: transparent;
color: var(--p-button-outlined-primary-color, #3B82F6);
border-color: var(--p-button-outlined-primary-border-color, #3B82F6);
}
.p-button-outlined:enabled:hover {
background: var(--p-button-outlined-primary-hover-background, rgba(59, 130, 246, 0.04));
color: var(--p-button-outlined-primary-hover-color, #2563eb);
border-color: var(--p-button-outlined-primary-hover-border-color, #2563eb);
}
.p-button-outlined:enabled:active {
background: var(--p-button-outlined-primary-active-background, rgba(59, 130, 246, 0.16));
color: var(--p-button-outlined-primary-active-color, #1d4ed8);
border-color: var(--p-button-outlined-primary-active-border-color, #1d4ed8);
}
.p-message {
padding: 1rem 1.25rem;
border-radius: var(--p-content-border-radius, 0.375rem);
margin-bottom: 1.5rem;
font-size: 0.875rem;
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.p-message-error {
background: color-mix(in srgb, var(--p-red-50, #fef2f2), transparent 5%);
border: 1px solid var(--p-red-200, #fecaca);
color: var(--p-red-600, #dc2626);
}
.p-message-info {
background: color-mix(in srgb, var(--p-blue-50, #eff6ff), transparent 5%);
border: 1px solid var(--p-blue-200, #bfdbfe);
color: var(--p-blue-600, #2563eb);
}
.p-message-icon {
font-size: 1.125rem;
flex-shrink: 0;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="login-container"> <div class="login-container">
<div class="login-header"> <div class="login-wrapper">
<h1>📊 myCRM</h1> <div class="login-card-wrapper">
<p>Moderne CRM-Lösung</p> <div class="login-card">
<div class="login-header">
<svg viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="login-logo">
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.1637 19.2467C17.1566 19.4033 17.1529 19.561 17.1529 19.7194C17.1529 25.3503 21.7203 29.915 27.3546 29.915C32.9887 29.915 37.5561 25.3503 37.5561 19.7194C37.5561 19.5572 37.5524 19.3959 37.5449 19.2355C38.5617 19.0801 39.5759 18.9013 40.5867 18.6994L40.6926 18.6782C40.7191 19.0218 40.7326 19.369 40.7326 19.7194C40.7326 27.1036 34.743 33.0896 27.3546 33.0896C19.966 33.0896 13.9765 27.1036 13.9765 19.7194C13.9765 19.374 13.9896 19.0316 14.0154 18.6927L14.0486 18.6994C15.0837 18.9062 16.1223 19.0886 17.1637 19.2467ZM33.3284 11.4538C31.6493 10.2396 29.5855 9.52381 27.3546 9.52381C25.1195 9.52381 23.0524 10.2421 21.3717 11.4603C20.0078 11.3232 18.6475 11.1387 17.2933 10.907C19.7453 8.11308 23.3438 6.34921 27.3546 6.34921C31.36 6.34921 34.9543 8.10844 37.4061 10.896C36.0521 11.1292 34.692 11.3152 33.3284 11.4538ZM43.826 18.0518C43.881 18.6003 43.9091 19.1566 43.9091 19.7194C43.9091 28.8568 36.4973 36.2642 27.3546 36.2642C18.2117 36.2642 10.8 28.8568 10.8 19.7194C10.8 19.1615 10.8276 18.61 10.8816 18.0663L7.75383 17.4411C7.66775 18.1886 7.62354 18.9488 7.62354 19.7194C7.62354 30.6102 16.4574 39.4388 27.3546 39.4388C38.2517 39.4388 47.0855 30.6102 47.0855 19.7194C47.0855 18.9439 47.0407 18.1789 46.9536 17.4267L43.826 18.0518ZM44.2613 9.54743L40.9084 10.2176C37.9134 5.95821 32.9593 3.1746 27.3546 3.1746C21.7442 3.1746 16.7856 5.96385 13.7915 10.2305L10.4399 9.56057C13.892 3.83178 20.1756 0 27.3546 0C34.5281 0 40.8075 3.82591 44.2613 9.54743Z" fill="var(--p-primary-color)"/>
<mask id="mask0_1413_1551" style="mask-type:alpha;" maskUnits="userSpaceOnUse" x="0" y="8" width="54" height="11">
<path d="M27 18.3652C10.5114 19.1944 0 8.88892 0 8.88892C0 8.88892 16.5176 14.5866 27 14.5866C37.4824 14.5866 54 8.88892 54 8.88892C54 8.88892 43.4886 17.5361 27 18.3652Z" fill="var(--p-primary-color)"/>
</mask>
<g mask="url(#mask0_1413_1551)">
<path d="M-4.673e-05 8.88887L3.73084 -1.91434L-8.00806 17.0473L-4.673e-05 8.88887ZM27 18.3652L26.4253 6.95109L27 18.3652ZM54 8.88887L61.2673 17.7127L50.2691 -1.91434L54 8.88887ZM-4.673e-05 8.88887C-8.00806 17.0473 -8.00469 17.0505 -8.00132 17.0538C-8.00018 17.055 -7.99675 17.0583 -7.9944 17.0607C-7.98963 17.0653 -7.98474 17.0701 -7.97966 17.075C-7.96949 17.0849 -7.95863 17.0955 -7.94707 17.1066C-7.92401 17.129 -7.89809 17.1539 -7.86944 17.1812C-7.8122 17.236 -7.74377 17.3005 -7.66436 17.3743C-7.50567 17.5218 -7.30269 17.7063 -7.05645 17.9221C-6.56467 18.3532 -5.89662 18.9125 -5.06089 19.5534C-3.39603 20.83 -1.02575 22.4605 1.98012 24.0457C7.97874 27.2091 16.7723 30.3226 27.5746 29.7793L26.4253 6.95109C20.7391 7.23699 16.0326 5.61231 12.6534 3.83024C10.9703 2.94267 9.68222 2.04866 8.86091 1.41888C8.45356 1.10653 8.17155 0.867278 8.0241 0.738027C7.95072 0.673671 7.91178 0.637576 7.90841 0.634492C7.90682 0.63298 7.91419 0.639805 7.93071 0.65557C7.93897 0.663455 7.94952 0.673589 7.96235 0.686039C7.96883 0.692262 7.97582 0.699075 7.98338 0.706471C7.98719 0.710167 7.99113 0.714014 7.99526 0.718014C7.99729 0.720008 8.00047 0.723119 8.00148 0.724116C8.00466 0.727265 8.00796 0.730446 -4.673e-05 8.88887ZM27.5746 29.7793C37.6904 29.2706 45.9416 26.3684 51.6602 23.6054C54.5296 22.2191 56.8064 20.8465 58.4186 19.7784C59.2265 19.2431 59.873 18.7805 60.3494 18.4257C60.5878 18.2482 60.7841 18.0971 60.9374 17.977C61.014 17.9169 61.0799 17.8645 61.1349 17.8203C61.1624 17.7981 61.1872 17.7781 61.2093 17.7602C61.2203 17.7512 61.2307 17.7427 61.2403 17.7348C61.2452 17.7308 61.2499 17.727 61.2544 17.7233C61.2566 17.7215 61.2598 17.7188 61.261 17.7179C61.2642 17.7153 61.2673 17.7127 54 8.88887C46.7326 0.0650536 46.7357 0.0625219 46.7387 0.0600241C46.7397 0.0592345 46.7427 0.0567658 46.7446 0.0551857C46.7485 0.0520238 46.7521 0.0489887 46.7557 0.0460799C46.7628 0.0402623 46.7694 0.0349487 46.7753 0.0301318C46.7871 0.0204986 46.7966 0.0128495 46.8037 0.00712562C46.818 -0.00431848 46.8228 -0.00808311 46.8184 -0.00463784C46.8096 0.00228345 46.764 0.0378652 46.6828 0.0983779C46.5199 0.219675 46.2165 0.439161 45.7812 0.727519C44.9072 1.30663 43.5257 2.14765 41.7061 3.02677C38.0469 4.79468 32.7981 6.63058 26.4253 6.95109L27.5746 29.7793ZM54 8.88887C50.2691 -1.91433 50.27 -1.91467 50.271 -1.91498C50.2712 -1.91506 50.272 -1.91535 50.2724 -1.9155C50.2733 -1.91581 50.274 -1.91602 50.2743 -1.91616C50.2752 -1.91643 50.275 -1.91636 50.2738 -1.91595C50.2714 -1.91515 50.2652 -1.91302 50.2552 -1.9096C50.2351 -1.90276 50.1999 -1.89078 50.1503 -1.874C50.0509 -1.84043 49.8938 -1.78773 49.6844 -1.71863C49.2652 -1.58031 48.6387 -1.377 47.8481 -1.13035C46.2609 -0.635237 44.0427 0.0249875 41.5325 0.6823C36.215 2.07471 30.6736 3.15796 27 3.15796V26.0151C33.8087 26.0151 41.7672 24.2495 47.3292 22.7931C50.2586 22.026 52.825 21.2618 54.6625 20.6886C55.5842 20.4011 56.33 20.1593 56.8551 19.986C57.1178 19.8993 57.3258 19.8296 57.4735 19.7797C57.5474 19.7548 57.6062 19.7348 57.6493 19.72C57.6709 19.7127 57.6885 19.7066 57.7021 19.7019C57.7089 19.6996 57.7147 19.6976 57.7195 19.696C57.7219 19.6952 57.7241 19.6944 57.726 19.6938C57.7269 19.6934 57.7281 19.693 57.7286 19.6929C57.7298 19.6924 57.7309 19.692 54 8.88887ZM27 3.15796C23.3263 3.15796 17.7849 2.07471 12.4674 0.6823C9.95717 0.0249875 7.73904 -0.635237 6.15184 -1.13035C5.36118 -1.377 4.73467 -1.58031 4.3155 -1.71863C4.10609 -1.78773 3.94899 -1.84043 3.84961 -1.874C3.79994 -1.89078 3.76474 -1.90276 3.74471 -1.9096C3.73469 -1.91302 3.72848 -1.91515 3.72613 -1.91595C3.72496 -1.91636 3.72476 -1.91643 3.72554 -1.91616C3.72593 -1.91602 3.72657 -1.91581 3.72745 -1.9155C3.72789 -1.91535 3.72874 -1.91506 3.72896 -1.91498C3.72987 -1.91467 3.73084 -1.91433 -4.673e-05 8.88887C-3.73093 19.692 -3.72983 19.6924 -3.72868 19.6929C-3.72821 19.693 -3.72698 19.6934 -3.72603 19.6938C-3.72415 19.6944 -3.72201 19.6952 -3.71961 19.696C-3.71482 19.6976 -3.70901 19.6996 -3.7022 19.7019C-3.68858 19.7066 -3.67095 19.7127 -3.6494 19.72C-3.60629 19.7348 -3.54745 19.7548 -3.47359 19.7797C-3.32589 19.8296 -3.11788 19.8993 -2.85516 19.986C-2.33008 20.1593 -1.58425 20.4011 -0.662589 20.6886C1.17485 21.2618 3.74125 22.026 6.67073 22.7931C12.2327 24.2495 20.1913 26.0151 27 26.0151V3.15796Z" fill="var(--p-primary-color)"/>
</g>
</svg>
<h1 class="login-title">Willkommen bei myCRM!</h1>
<p class="login-subtitle">Melden Sie sich an, um fortzufahren</p>
</div>
{% if error %}
<div class="p-message p-message-error">
<i class="pi pi-times-circle p-message-icon"></i>
<div>{{ error.messageKey|trans(error.messageData, 'security') }}</div>
</div>
{% endif %}
{% if app.request.query.get('password_set') %}
<div class="p-message p-message-info">
<i class="pi pi-check-circle p-message-icon"></i>
<div>
<strong>Passwort erfolgreich eingerichtet!</strong><br>
Sie können sich jetzt mit Ihrer E-Mail-Adresse und dem neu gesetzten Passwort anmelden.
</div>
</div>
{% endif %}
{% if app.request.query.get('password_reset') %}
<div class="p-message p-message-info">
<i class="pi pi-check-circle p-message-icon"></i>
<div>
<strong>Passwort erfolgreich zurückgesetzt!</strong><br>
Sie können sich jetzt mit Ihrem neuen Passwort anmelden.
</div>
</div>
{% endif %}
<div class="login-form">
{% if app.user %}
<div class="p-message p-message-info">
<i class="pi pi-info-circle p-message-icon"></i>
<div>
Sie sind bereits angemeldet als <strong>{{ app.user.userIdentifier }}</strong>.
<a href="{{ path('app_logout') }}" style="color: var(--p-primary-color); font-weight: 600;">Abmelden</a> oder
<a href="/" style="color: var(--p-primary-color); font-weight: 600;">zum Dashboard</a>
</div>
</div>
{% else %}
{% if allow_password_login %}
<form method="post">
<div class="form-field">
<label for="username">E-Mail</label>
<input
type="email"
value="{{ last_username }}"
name="email"
id="username"
class="p-inputtext"
autocomplete="email"
placeholder="ihre@email.de"
required
autofocus
>
</div>
<div class="form-field">
<label for="password">Passwort</label>
<div class="password-wrapper">
<input
type="password"
name="password"
id="password"
class="p-inputtext"
autocomplete="current-password"
placeholder="Geben Sie Ihr Passwort ein"
required
style="padding-right: 3rem;"
>
<span class="password-toggle" onclick="togglePasswordVisibility()">
<svg id="password-hide-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 5C8.24261 5 5.43602 7.4404 3.76737 9.43934C2.51521 10.9394 2.51521 13.0606 3.76737 14.5607C5.43602 16.5596 8.24261 19 12 19C15.7574 19 18.564 16.5596 20.2326 14.5607C21.4848 13.0606 21.4848 10.9394 20.2326 9.43934C18.564 7.4404 15.7574 5 12 5Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg id="password-show-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="display: none;">
<path d="M3 10C3 10 5.5 15 12 15C18.5 15 21 10 21 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 14L6 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20 14L18 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 17V15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
</div>
</div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
<div class="checkbox-wrapper">
<div class="checkbox-field">
<div class="p-checkbox">
<input type="checkbox" name="_remember_me" id="_remember_me">
</div>
<label for="_remember_me">Angemeldet bleiben</label>
</div>
<a href="{{ path('app_password_reset_request') }}" class="forgot-password">Passwort vergessen?</a>
</div>
<button class="p-button" type="submit">
<span>Anmelden</span>
</button>
</form>
{% else %}
<div class="p-message p-message-info">
<i class="pi pi-info-circle p-message-icon"></i>
<div>
Der Login mit E-Mail und Passwort ist derzeit deaktiviert. Bitte verwenden Sie eine alternative Anmeldemethode.
</div>
</div>
{% endif %}
{# Pocket-ID Login Option #}
<div style="margin-top: 2rem;">
<div style="text-align: center; margin-bottom: 1.5rem; position: relative;">
<div style="height: 1px; background: var(--p-surface-border); position: absolute; top: 50%; left: 0; right: 0;"></div>
<span style="background: var(--p-surface-0); padding: 0 1rem; position: relative; color: var(--p-text-muted-color); font-size: 0.875rem;">oder</span>
</div>
<a href="{{ path('connect_pocketid_start') }}" class="p-button p-button-outlined" style="text-decoration: none;">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: 1.25rem; height: 1.25rem; margin-right: 0.5rem;">
<path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM12 5C13.66 5 15 6.34 15 8C15 9.66 13.66 11 12 11C10.34 11 9 9.66 9 8C9 6.34 10.34 5 12 5ZM12 19.2C9.5 19.2 7.29 17.92 6 16C6.03 13.99 10 12.9 12 12.9C13.99 12.9 17.97 13.99 18 16C16.71 17.92 14.5 19.2 12 19.2Z" fill="currentColor"/>
</svg>
<span>Mit Pocket-ID anmelden</span>
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div> </div>
{% if error %}
<div class="alert alert-danger">
{{ error.messageKey|trans(error.messageData, 'security') }}
</div>
{% endif %}
{% if app.request.query.get('password_set') %}
<div class="alert alert-info">
<strong>✓ Passwort erfolgreich eingerichtet!</strong><br>
Sie können sich jetzt mit Ihrer E-Mail-Adresse und dem neu gesetzten Passwort anmelden.
</div>
{% endif %}
{% if app.user %}
<div class="alert alert-info">
Sie sind bereits angemeldet als <strong>{{ app.user.userIdentifier }}</strong>.
<a href="{{ path('app_logout') }}">Abmelden</a> oder
<a href="/">zum Dashboard</a>
</div>
{% else %}
{% if allow_password_login %}
<form method="post">
<div class="form-group">
<label for="username">E-Mail-Adresse</label>
<input
type="email"
value="{{ last_username }}"
name="email"
id="username"
class="form-control"
autocomplete="email"
placeholder="ihre@email.de"
required
autofocus
>
</div>
<div class="form-group">
<label for="password">Passwort</label>
<input
type="password"
name="password"
id="password"
class="form-control"
autocomplete="current-password"
placeholder="••••••••"
required
>
</div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
<div class="remember-me">
<input type="checkbox" name="_remember_me" id="_remember_me">
<label for="_remember_me">Angemeldet bleiben</label>
</div>
<button class="btn-login" type="submit">
Anmelden
</button>
</form>
<div style="text-align: center; margin: 1.5rem 0; color: #9ca3af; font-size: 0.9rem;">
oder
</div>
{% else %}
<div class="alert alert-info">
Der Login mit E-Mail und Passwort ist derzeit deaktiviert. Bitte verwenden Sie eine alternative Anmeldemethode.
</div>
{% endif %}
<a href="{{ path('connect_pocketid_start') }}" style="text-decoration: none;">
<button type="button" class="btn-login" style="background: #10b981; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M12 8v8M8 12h8"/>
</svg>
Mit Pocket-ID anmelden
</button>
</a>
{% if allow_password_login %}
<div class="test-credentials">
<h4>🔐 Test-Zugangsdaten (Development):</h4>
<div><strong>Administrator:</strong> <code>admin@mycrm.local</code> / <code>admin123</code></div>
<div><strong>Vertrieb:</strong> <code>sales@mycrm.local</code> / <code>sales123</code></div>
</div>
{% endif %}
{% endif %}
</div> </div>
<script>
function togglePasswordVisibility() {
const passwordInput = document.getElementById('password');
const hideIcon = document.getElementById('password-hide-icon');
const showIcon = document.getElementById('password-show-icon');
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
hideIcon.style.display = 'none';
showIcon.style.display = 'block';
} else {
passwordInput.type = 'password';
hideIcon.style.display = 'block';
showIcon.style.display = 'none';
}
}
</script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,309 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Neues Passwort setzen - myCRM</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f8fafc;
min-height: 100vh;
}
.login-container {
background: #f8fafc;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
min-width: 100vw;
overflow: hidden;
}
.login-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.login-card-wrapper {
border-radius: 56px;
padding: 0.3rem;
background: linear-gradient(180deg, #3B82F6 10%, rgba(33, 150, 243, 0) 30%);
}
.login-card {
width: 100%;
background: #ffffff;
padding: 5rem 2rem;
border-radius: 53px;
}
@media (min-width: 576px) {
.login-card {
padding: 5rem 5rem;
}
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-logo {
margin-bottom: 2rem;
width: 4rem;
flex-shrink: 0;
margin-left: auto;
margin-right: auto;
}
.login-title {
color: #0f172a;
font-size: 1.875rem;
font-weight: 500;
margin-bottom: 1rem;
margin-top: 0;
}
.login-subtitle {
color: #64748b;
font-weight: 500;
margin: 0;
}
.login-form {
width: 100%;
}
@media (min-width: 768px) {
.login-form {
width: 30rem;
}
}
.user-info {
background: #f1f5f9;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 2rem;
}
.user-info p {
margin: 0.25rem 0;
color: #334155;
font-size: 0.875rem;
}
.user-info strong {
color: #0f172a;
}
.form-field {
margin-bottom: 2rem;
}
.form-field label {
display: block;
color: #0f172a;
font-weight: 500;
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.p-inputtext {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
color: #0f172a;
background: #ffffff;
border: 1px solid #cbd5e1;
border-radius: 0.375rem;
transition: border-color 0.2s, box-shadow 0.2s;
outline: 0 none;
}
.p-inputtext:hover {
border-color: #94a3b8;
}
.p-inputtext:focus {
border-color: #3B82F6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
.p-button {
width: 100%;
padding: 0.75rem 1.25rem;
font-size: 1rem;
font-weight: 500;
color: #ffffff;
background: #3B82F6;
border: 1px solid #3B82F6;
border-radius: 0.375rem;
cursor: pointer;
transition: background 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
text-decoration: none;
outline: 0 none;
}
.p-button:hover {
background: #2563eb;
border-color: #2563eb;
}
.p-message {
padding: 1rem 1.25rem;
border-radius: 0.375rem;
margin-bottom: 1.5rem;
font-size: 0.875rem;
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.p-message-error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
}
.p-message-icon {
font-size: 1.125rem;
flex-shrink: 0;
}
.requirements {
background: #f9fafb;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
font-size: 0.875rem;
color: #64748b;
}
.requirements strong {
color: #0f172a;
display: block;
margin-bottom: 0.5rem;
}
.requirements ul {
margin: 0;
padding-left: 1.5rem;
}
.requirements li {
margin: 0.25rem 0;
}
.back-link {
text-align: center;
margin-top: 1.5rem;
}
.back-link a {
color: #3B82F6;
text-decoration: none;
font-weight: 500;
}
.back-link a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-wrapper">
<div class="login-card-wrapper">
<div class="login-card">
<div class="login-header">
<svg viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="login-logo">
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.1637 19.2467C17.1566 19.4033 17.1529 19.561 17.1529 19.7194C17.1529 25.3503 21.7203 29.915 27.3546 29.915C32.9887 29.915 37.5561 25.3503 37.5561 19.7194C37.5561 19.5572 37.5524 19.3959 37.5449 19.2355C38.5617 19.0801 39.5759 18.9013 40.5867 18.6994L40.6926 18.6782C40.7191 19.0218 40.7326 19.369 40.7326 19.7194C40.7326 27.1036 34.743 33.0896 27.3546 33.0896C19.966 33.0896 13.9765 27.1036 13.9765 19.7194C13.9765 19.374 13.9896 19.0316 14.0154 18.6927L14.0486 18.6994C15.0837 18.9062 16.1223 19.0886 17.1637 19.2467ZM33.3284 11.4538C31.6493 10.2396 29.5855 9.52381 27.3546 9.52381C25.1195 9.52381 23.0524 10.2421 21.3717 11.4603C20.0078 11.3232 18.6475 11.1387 17.2933 10.907C19.7453 8.11308 23.3438 6.34921 27.3546 6.34921C31.36 6.34921 34.9543 8.10844 37.4061 10.896C36.0521 11.1292 34.692 11.3152 33.3284 11.4538ZM43.826 18.0518C43.881 18.6003 43.9091 19.1566 43.9091 19.7194C43.9091 28.8568 36.4973 36.2642 27.3546 36.2642C18.2117 36.2642 10.8 28.8568 10.8 19.7194C10.8 19.1615 10.8276 18.61 10.8816 18.0663L7.75383 17.4411C7.66775 18.1886 7.62354 18.9488 7.62354 19.7194C7.62354 30.6102 16.4574 39.4388 27.3546 39.4388C38.2517 39.4388 47.0855 30.6102 47.0855 19.7194C47.0855 18.9439 47.0407 18.1789 46.9536 17.4267L43.826 18.0518ZM44.2613 9.54743L40.9084 10.2176C37.9134 5.95821 32.9593 3.1746 27.3546 3.1746C21.7442 3.1746 16.7856 5.96385 13.7915 10.2305L10.4399 9.56057C13.892 3.83178 20.1756 0 27.3546 0C34.5281 0 40.8075 3.82591 44.2613 9.54743Z" fill="#3B82F6"/>
<mask id="mask0_1413_1551" style="mask-type:alpha;" maskUnits="userSpaceOnUse" x="0" y="8" width="54" height="11">
<path d="M27 18.3652C10.5114 19.1944 0 8.88892 0 8.88892C0 8.88892 16.5176 14.5866 27 14.5866C37.4824 14.5866 54 8.88892 54 8.88892C54 8.88892 43.4886 17.5361 27 18.3652Z" fill="#3B82F6"/>
</mask>
<g mask="url(#mask0_1413_1551)">
<path d="M-4.673e-05 8.88887L3.73084 -1.91434L-8.00806 17.0473L-4.673e-05 8.88887ZM27 18.3652L26.4253 6.95109L27 18.3652ZM54 8.88887L61.2673 17.7127L50.2691 -1.91434L54 8.88887ZM-4.673e-05 8.88887C-8.00806 17.0473 -8.00469 17.0505 -8.00132 17.0538C-8.00018 17.055 -7.99675 17.0583 -7.9944 17.0607C-7.98963 17.0653 -7.98474 17.0701 -7.97966 17.075C-7.96949 17.0849 -7.95863 17.0955 -7.94707 17.1066C-7.92401 17.129 -7.89809 17.1539 -7.86944 17.1812C-7.8122 17.236 -7.74377 17.3005 -7.66436 17.3743C-7.50567 17.5218 -7.30269 17.7063 -7.05645 17.9221C-6.56467 18.3532 -5.89662 18.9125 -5.06089 19.5534C-3.39603 20.83 -1.02575 22.4605 1.98012 24.0457C7.97874 27.2091 16.7723 30.3226 27.5746 29.7793L26.4253 6.95109C20.7391 7.23699 16.0326 5.61231 12.6534 3.83024C10.9703 2.94267 9.68222 2.04866 8.86091 1.41888C8.45356 1.10653 8.17155 0.867278 8.0241 0.738027C7.95072 0.673671 7.91178 0.637576 7.90841 0.634492C7.90682 0.63298 7.91419 0.639805 7.93071 0.65557C7.93897 0.663455 7.94952 0.673589 7.96235 0.686039C7.96883 0.692262 7.97582 0.699075 7.98338 0.706471C7.98719 0.710167 7.99113 0.714014 7.99526 0.718014C7.99729 0.720008 8.00047 0.723119 8.00148 0.724116C8.00466 0.727265 8.00796 0.730446 -4.673e-05 8.88887ZM27.5746 29.7793C37.6904 29.2706 45.9416 26.3684 51.6602 23.6054C54.5296 22.2191 56.8064 20.8465 58.4186 19.7784C59.2265 19.2431 59.873 18.7805 60.3494 18.4257C60.5878 18.2482 60.7841 18.0971 60.9374 17.977C61.014 17.9169 61.0799 17.8645 61.1349 17.8203C61.1624 17.7981 61.1872 17.7781 61.2093 17.7602C61.2203 17.7512 61.2307 17.7427 61.2403 17.7348C61.2452 17.7308 61.2499 17.727 61.2544 17.7233C61.2566 17.7215 61.2598 17.7188 61.261 17.7179C61.2642 17.7153 61.2673 17.7127 54 8.88887C46.7326 0.0650536 46.7357 0.0625219 46.7387 0.0600241C46.7397 0.0592345 46.7427 0.0567658 46.7446 0.0551857C46.7485 0.0520238 46.7521 0.0489887 46.7557 0.0460799C46.7628 0.0402623 46.7694 0.0349487 46.7753 0.0301318C46.7871 0.0204986 46.7966 0.0128495 46.8037 0.00712562C46.818 -0.00431848 46.8228 -0.00808311 46.8184 -0.00463784C46.8096 0.00228345 46.764 0.0378652 46.6828 0.0983779C46.5199 0.219675 46.2165 0.439161 45.7812 0.727519C44.9072 1.30663 43.5257 2.14765 41.7061 3.02677C38.0469 4.79468 32.7981 6.63058 26.4253 6.95109L27.5746 29.7793ZM54 8.88887C50.2691 -1.91433 50.27 -1.91467 50.271 -1.91498C50.2712 -1.91506 50.272 -1.91535 50.2724 -1.9155C50.2733 -1.91581 50.274 -1.91602 50.2743 -1.91616C50.2752 -1.91643 50.275 -1.91636 50.2738 -1.91595C50.2714 -1.91515 50.2652 -1.91302 50.2552 -1.9096C50.2351 -1.90276 50.1999 -1.89078 50.1503 -1.874C50.0509 -1.84043 49.8938 -1.78773 49.6844 -1.71863C49.2652 -1.58031 48.6387 -1.377 47.8481 -1.13035C46.2609 -0.635237 44.0427 0.0249875 41.5325 0.6823C36.215 2.07471 30.6736 3.15796 27 3.15796V26.0151C33.8087 26.0151 41.7672 24.2495 47.3292 22.7931C50.2586 22.026 52.825 21.2618 54.6625 20.6886C55.5842 20.4011 56.33 20.1593 56.8551 19.986C57.1178 19.8993 57.3258 19.8296 57.4735 19.7797C57.5474 19.7548 57.6062 19.7348 57.6493 19.72C57.6709 19.7127 57.6885 19.7066 57.7021 19.7019C57.7089 19.6996 57.7147 19.6976 57.7195 19.696C57.7219 19.6952 57.7241 19.6944 57.726 19.6938C57.7269 19.6934 57.7281 19.693 57.7286 19.6929C57.7298 19.6924 57.7309 19.692 54 8.88887ZM27 3.15796C23.3263 3.15796 17.7849 2.07471 12.4674 0.6823C9.95717 0.0249875 7.73904 -0.635237 6.15184 -1.13035C5.36118 -1.377 4.73467 -1.58031 4.3155 -1.71863C4.10609 -1.78773 3.94899 -1.84043 3.84961 -1.874C3.79994 -1.89078 3.76474 -1.90276 3.74471 -1.9096C3.73469 -1.91302 3.72848 -1.91515 3.72613 -1.91595C3.72496 -1.91636 3.72476 -1.91643 3.72554 -1.91616C3.72593 -1.91602 3.72657 -1.91581 3.72745 -1.9155C3.72789 -1.91535 3.72874 -1.91506 3.72896 -1.91498C3.72987 -1.91467 3.73084 -1.91433 -4.673e-05 8.88887C-3.73093 19.692 -3.72983 19.6924 -3.72868 19.6929C-3.72821 19.693 -3.72698 19.6934 -3.72603 19.6938C-3.72415 19.6944 -3.72201 19.6952 -3.71961 19.696C-3.71482 19.6976 -3.70901 19.6996 -3.7022 19.7019C-3.68858 19.7066 -3.67095 19.7127 -3.6494 19.72C-3.60629 19.7348 -3.54745 19.7548 -3.47359 19.7797C-3.32589 19.8296 -3.11788 19.8993 -2.85516 19.986C-2.33008 20.1593 -1.58425 20.4011 -0.662589 20.6886C1.17485 21.2618 3.74125 22.026 6.67073 22.7931C12.2327 24.2495 20.1913 26.0151 27 26.0151V3.15796Z" fill="#3B82F6"/>
</g>
</svg>
<h1 class="login-title">Neues Passwort setzen</h1>
<p class="login-subtitle">Wählen Sie ein sicheres Passwort</p>
</div>
<div class="login-form">
<div class="user-info">
<p><strong>Benutzer:</strong> {{ user.firstName }} {{ user.lastName }}</p>
<p><strong>E-Mail:</strong> {{ user.email }}</p>
</div>
{% if error %}
<div class="p-message p-message-error">
<span class="p-message-icon">✕</span>
<div>{{ error }}</div>
</div>
{% endif %}
<div class="requirements">
<strong>Passwort-Anforderungen:</strong>
<ul>
<li>Mindestens {{ min_length }} Zeichen</li>
<li>Beide Passwörter müssen übereinstimmen</li>
</ul>
</div>
<form method="post">
<div class="form-field">
<label for="password">Neues Passwort</label>
<input
type="password"
id="password"
name="password"
class="p-inputtext"
placeholder="Neues Passwort eingeben"
required
minlength="{{ min_length }}"
autofocus
>
</div>
<div class="form-field">
<label for="password_confirm">Passwort bestätigen</label>
<input
type="password"
id="password_confirm"
name="password_confirm"
class="p-inputtext"
placeholder="Passwort wiederholen"
required
minlength="{{ min_length }}"
>
</div>
<button type="submit" class="p-button">
<span>Passwort speichern</span>
</button>
</form>
<div class="back-link">
<a href="{{ path('app_login') }}">← Zurück zum Login</a>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ungültiger Link - myCRM</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f8fafc;
min-height: 100vh;
}
.login-container {
background: #f8fafc;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
min-width: 100vw;
overflow: hidden;
}
.login-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.login-card-wrapper {
border-radius: 56px;
padding: 0.3rem;
background: linear-gradient(180deg, #dc2626 10%, rgba(220, 38, 38, 0) 30%);
}
.login-card {
width: 100%;
background: #ffffff;
padding: 5rem 2rem;
border-radius: 53px;
text-align: center;
}
@media (min-width: 576px) {
.login-card {
padding: 5rem 5rem;
}
}
@media (min-width: 768px) {
.login-card {
width: 35rem;
}
}
.error-icon {
font-size: 4rem;
margin-bottom: 1.5rem;
}
.error-title {
color: #dc2626;
font-size: 1.875rem;
font-weight: 600;
margin: 0 0 1rem;
}
.error-text {
color: #64748b;
font-size: 1rem;
line-height: 1.6;
margin: 0 0 2rem;
}
.error-reasons {
background: #f9fafb;
padding: 1.5rem;
border-radius: 0.5rem;
margin: 2rem 0;
text-align: left;
}
.error-reasons h2 {
margin: 0 0 1rem;
color: #0f172a;
font-size: 1rem;
font-weight: 600;
}
.error-reasons ul {
margin: 0;
padding-left: 1.5rem;
color: #64748b;
font-size: 0.875rem;
}
.error-reasons li {
margin: 0.5rem 0;
}
.p-button {
display: inline-block;
padding: 0.875rem 2rem;
font-size: 1rem;
font-weight: 500;
color: #ffffff;
background: #3B82F6;
border: 1px solid #3B82F6;
border-radius: 0.375rem;
cursor: pointer;
text-decoration: none;
transition: background 0.2s;
margin: 0.5rem;
}
.p-button:hover {
background: #2563eb;
border-color: #2563eb;
}
.p-button-secondary {
background: #f1f5f9;
color: #334155;
border-color: #e2e8f0;
}
.p-button-secondary:hover {
background: #e2e8f0;
border-color: #cbd5e1;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-wrapper">
<div class="login-card-wrapper">
<div class="login-card">
<div class="error-icon">❌</div>
<h1 class="error-title">Ungültiger oder abgelaufener Link</h1>
<p class="error-text">
Der Link zum Zurücksetzen des Passworts ist nicht gültig oder bereits abgelaufen.
</p>
<div class="error-reasons">
<h2>Mögliche Gründe:</h2>
<ul>
<li>Der Link ist bereits verwendet worden</li>
<li>Der Link ist älter als 24 Stunden</li>
<li>Der Link wurde nicht korrekt kopiert</li>
</ul>
</div>
<div>
<a href="{{ path('app_password_reset_request') }}" class="p-button">
Neuen Link anfordern
</a>
</div>
<div>
<a href="{{ path('app_login') }}" class="p-button p-button-secondary">
Zurück zum Login
</a>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,256 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Passwort zurücksetzen - myCRM</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f8fafc;
min-height: 100vh;
}
.login-container {
background: #f8fafc;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
min-width: 100vw;
overflow: hidden;
}
.login-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.login-card-wrapper {
border-radius: 56px;
padding: 0.3rem;
background: linear-gradient(180deg, #3B82F6 10%, rgba(33, 150, 243, 0) 30%);
}
.login-card {
width: 100%;
background: #ffffff;
padding: 5rem 2rem;
border-radius: 53px;
}
@media (min-width: 576px) {
.login-card {
padding: 5rem 5rem;
}
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-logo {
margin-bottom: 2rem;
width: 4rem;
flex-shrink: 0;
margin-left: auto;
margin-right: auto;
}
.login-title {
color: #0f172a;
font-size: 1.875rem;
font-weight: 500;
margin-bottom: 1rem;
margin-top: 0;
}
.login-subtitle {
color: #64748b;
font-weight: 500;
margin: 0;
}
.login-form {
width: 100%;
}
@media (min-width: 768px) {
.login-form {
width: 30rem;
}
}
.form-field {
margin-bottom: 2rem;
}
.form-field label {
display: block;
color: #0f172a;
font-weight: 500;
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.p-inputtext {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
color: #0f172a;
background: #ffffff;
border: 1px solid #cbd5e1;
border-radius: 0.375rem;
transition: border-color 0.2s, box-shadow 0.2s;
outline: 0 none;
}
.p-inputtext:hover {
border-color: #94a3b8;
}
.p-inputtext:focus {
border-color: #3B82F6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
.p-button {
width: 100%;
padding: 0.75rem 1.25rem;
font-size: 1rem;
font-weight: 500;
color: #ffffff;
background: #3B82F6;
border: 1px solid #3B82F6;
border-radius: 0.375rem;
cursor: pointer;
transition: background 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
text-decoration: none;
outline: 0 none;
}
.p-button:hover {
background: #2563eb;
border-color: #2563eb;
}
.p-message {
padding: 1rem 1.25rem;
border-radius: 0.375rem;
margin-bottom: 1.5rem;
font-size: 0.875rem;
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.p-message-error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
}
.p-message-success {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #16a34a;
}
.p-message-icon {
font-size: 1.125rem;
flex-shrink: 0;
}
.back-link {
text-align: center;
margin-top: 1.5rem;
}
.back-link a {
color: #3B82F6;
text-decoration: none;
font-weight: 500;
}
.back-link a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-wrapper">
<div class="login-card-wrapper">
<div class="login-card">
<div class="login-header">
<svg viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="login-logo">
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.1637 19.2467C17.1566 19.4033 17.1529 19.561 17.1529 19.7194C17.1529 25.3503 21.7203 29.915 27.3546 29.915C32.9887 29.915 37.5561 25.3503 37.5561 19.7194C37.5561 19.5572 37.5524 19.3959 37.5449 19.2355C38.5617 19.0801 39.5759 18.9013 40.5867 18.6994L40.6926 18.6782C40.7191 19.0218 40.7326 19.369 40.7326 19.7194C40.7326 27.1036 34.743 33.0896 27.3546 33.0896C19.966 33.0896 13.9765 27.1036 13.9765 19.7194C13.9765 19.374 13.9896 19.0316 14.0154 18.6927L14.0486 18.6994C15.0837 18.9062 16.1223 19.0886 17.1637 19.2467ZM33.3284 11.4538C31.6493 10.2396 29.5855 9.52381 27.3546 9.52381C25.1195 9.52381 23.0524 10.2421 21.3717 11.4603C20.0078 11.3232 18.6475 11.1387 17.2933 10.907C19.7453 8.11308 23.3438 6.34921 27.3546 6.34921C31.36 6.34921 34.9543 8.10844 37.4061 10.896C36.0521 11.1292 34.692 11.3152 33.3284 11.4538ZM43.826 18.0518C43.881 18.6003 43.9091 19.1566 43.9091 19.7194C43.9091 28.8568 36.4973 36.2642 27.3546 36.2642C18.2117 36.2642 10.8 28.8568 10.8 19.7194C10.8 19.1615 10.8276 18.61 10.8816 18.0663L7.75383 17.4411C7.66775 18.1886 7.62354 18.9488 7.62354 19.7194C7.62354 30.6102 16.4574 39.4388 27.3546 39.4388C38.2517 39.4388 47.0855 30.6102 47.0855 19.7194C47.0855 18.9439 47.0407 18.1789 46.9536 17.4267L43.826 18.0518ZM44.2613 9.54743L40.9084 10.2176C37.9134 5.95821 32.9593 3.1746 27.3546 3.1746C21.7442 3.1746 16.7856 5.96385 13.7915 10.2305L10.4399 9.56057C13.892 3.83178 20.1756 0 27.3546 0C34.5281 0 40.8075 3.82591 44.2613 9.54743Z" fill="#3B82F6"/>
</svg>
<h1 class="login-title">Passwort zurücksetzen</h1>
<p class="login-subtitle">Geben Sie Ihre E-Mail-Adresse ein</p>
</div>
<div class="login-form">
{% if email_sent %}
<div class="p-message p-message-success">
<span class="p-message-icon">✓</span>
<div>
<strong>E-Mail versendet!</strong><br>
Falls ein Account mit dieser E-Mail-Adresse existiert, haben Sie eine E-Mail mit weiteren Anweisungen erhalten.
Bitte überprüfen Sie auch Ihren Spam-Ordner.
</div>
</div>
<div class="back-link">
<a href="{{ path('app_login') }}">← Zurück zum Login</a>
</div>
{% else %}
{% if error %}
<div class="p-message p-message-error">
<span class="p-message-icon">✕</span>
<div>{{ error }}</div>
</div>
{% endif %}
<form method="post">
<div class="form-field">
<label for="email">E-Mail-Adresse</label>
<input
type="email"
name="email"
id="email"
class="p-inputtext"
placeholder="ihre@email.de"
required
autofocus
>
</div>
<button class="p-button" type="submit">
<span>Link zum Zurücksetzen senden</span>
</button>
</form>
<div class="back-link">
<a href="{{ path('app_login') }}">← Zurück zum Login</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</body>
</html>