diff --git a/application/helper.js b/application/helper.js index 98f7f87..dae48cf 100644 --- a/application/helper.js +++ b/application/helper.js @@ -249,7 +249,7 @@ class Helper { // Warn about old locked-inboxes.db if (fs.existsSync(legacyLockedInboxesDb)) { - console.log(`⚠️ Found legacy ${legacyLockedInboxesDb}`) + console.log(`WARNING: Found legacy ${legacyLockedInboxesDb}`) console.log(` This database is no longer used. Locks are now stored in ${path.basename(dbPath)}.`) console.log(` You can safely delete ${legacyLockedInboxesDb} after verifying your locks are working.`) debug('Legacy locked-inboxes.db detected but not migrated (data already in user_locked_inboxes table)') diff --git a/application/smtp-service.js b/application/smtp-service.js index 87da49e..f84aed0 100644 --- a/application/smtp-service.js +++ b/application/smtp-service.js @@ -273,7 +273,7 @@ ${mail.html}

${verificationLink}

- ⚠️ Important: This verification link expires in 15 minutes. Once verified, you'll be able to forward emails to this address for 24 hours. + Important: This verification link expires in 15 minutes. Once verified, you'll be able to forward emails to this address for 24 hours.

If you didn't request this verification, you can safely ignore this email.

diff --git a/domain/user-repository.js b/domain/user-repository.js index 83aafe9..8662bf6 100644 --- a/domain/user-repository.js +++ b/domain/user-repository.js @@ -354,6 +354,100 @@ class UserRepository { return `${Math.floor(days / 365)} years` } + /** + * Verify user password + * @param {number} userId - User ID + * @param {string} password - Plain text password to verify + * @returns {Promise} - True if password matches + */ + async verifyPassword(userId, password) { + try { + const bcrypt = require('bcrypt') + const stmt = this.db.prepare('SELECT password_hash FROM users WHERE id = ?') + const user = stmt.get(userId) + + if (!user) { + debug(`User not found for password verification: ${userId}`) + return false + } + + const isValid = await bcrypt.compare(password, user.password_hash) + debug(`Password verification for user ${userId}: ${isValid ? 'success' : 'failed'}`) + return isValid + } catch (error) { + debug(`Error verifying password: ${error.message}`) + return false + } + } + + /** + * Update user password + * @param {number} userId - User ID + * @param {string} newPassword - New plain text password + * @returns {Promise} - True if successful + */ + async updatePassword(userId, newPassword) { + try { + const bcrypt = require('bcrypt') + const saltRounds = 10 + const passwordHash = await bcrypt.hash(newPassword, saltRounds) + + const stmt = this.db.prepare(` + UPDATE users + SET password_hash = ? + WHERE id = ? + `) + const result = stmt.run(passwordHash, userId) + + if (result.changes > 0) { + debug(`Password updated for user ${userId}`) + return true + } else { + debug(`User not found for password update: ${userId}`) + return false + } + } catch (error) { + debug(`Error updating password: ${error.message}`) + throw error + } + } + + /** + * Delete user account and all associated data + * @param {number} userId - User ID + * @returns {boolean} - True if successful + */ + deleteUser(userId) { + try { + // Delete in order due to foreign key constraints: + // 1. forward_emails (references users.id) + // 2. users + + const deleteForwardEmails = this.db.prepare('DELETE FROM forward_emails WHERE user_id = ?') + const deleteUser = this.db.prepare('DELETE FROM users WHERE id = ?') + + // Use transaction for atomicity + const deleteTransaction = this.db.transaction((uid) => { + deleteForwardEmails.run(uid) + const result = deleteUser.run(uid) + return result.changes > 0 + }) + + const success = deleteTransaction(userId) + + if (success) { + debug(`User ${userId} and all associated data deleted`) + } else { + debug(`User ${userId} not found for deletion`) + } + + return success + } catch (error) { + debug(`Error deleting user: ${error.message}`) + throw error + } + } + /** * Close database connection */ diff --git a/infrastructure/web/public/stylesheets/custom.css b/infrastructure/web/public/stylesheets/custom.css index bf05e3f..023584d 100644 --- a/infrastructure/web/public/stylesheets/custom.css +++ b/infrastructure/web/public/stylesheets/custom.css @@ -428,7 +428,8 @@ text-muted { /* Auth pages */ .auth-container { - max-width: 900px; + min-width: 75%; + max-width: 1500px; margin: 2rem auto; padding: 0 1rem; display: grid; @@ -468,9 +469,10 @@ text-muted { } .auth-card small { + margin-top: -10px !important; + font-size: 1.2rem; display: block; color: var(--color-text-gray); - font-size: 0.85rem; margin-top: 0.25rem; margin-bottom: 0.5rem; } @@ -561,7 +563,8 @@ text-muted { /* Unified auth page (side-by-side login/register) */ .auth-unified-container { - max-width: 1100px; + min-width: 75%; + max-width: 1500px; margin: 2rem auto; padding: 0 1rem; } @@ -581,6 +584,7 @@ text-muted { grid-template-columns: 1fr 1fr; gap: 3rem; margin-bottom: 3rem; + align-items: stretch; } @media (max-width: 768px) { @@ -590,6 +594,27 @@ text-muted { } } +.auth-card { + display: flex; + flex-direction: column; +} + +.auth-card form { + display: flex; + flex-direction: column; + flex: 1; +} + +.auth-card fieldset { + display: flex; + flex-direction: column; + flex: 1; +} + +.auth-card .button { + margin-top: auto; +} + .auth-card h2 { font-size: 2rem; margin-bottom: 0.5rem; @@ -741,7 +766,8 @@ text-muted { /* Account dashboard page */ .account-container { - max-width: 1200px; + min-width: 75%; + max-width: 1500px; margin: 0 auto; padding: 2rem 1rem; } @@ -763,12 +789,14 @@ text-muted { border: 1px solid var(--color-border-dark); border-radius: 8px; padding: 2rem; + display: flex; + flex-direction: column; } .account-card h2 { color: var(--color-accent-purple); margin-bottom: 0.5rem; - font-size: 1.5rem; + font-size: 2.5rem; } .card-description { @@ -777,7 +805,7 @@ text-muted { margin-bottom: 1.5rem; } -.account-stats { +.account-card:nth-child(1) { grid-column: 1 / -1; } @@ -910,6 +938,87 @@ form { margin-top: 1rem; } +.password-form { + display: flex; + flex-direction: column; + flex: 1; +} + +.password-form fieldset { + display: flex; + flex-direction: column; + gap: 1rem; + flex: 1; +} + +.password-form label { + font-weight: 600; + margin-bottom: 0.25rem; + color: var(--color-text-light); +} + +.password-form input { + padding: 0.75rem; + border: 1px solid var(--color-border-dark); + border-radius: 5px; + background: var(--color-bg-dark); + color: var(--color-text-primary); +} + +.password-form small { + color: var(--color-text-gray); + font-size: 0.85rem; + margin-top: -0.5rem; +} + +.password-form .button { + margin-top: auto; +} + +.danger-zone { + border: 2px solid var(--color-danger); +} + +.danger-zone h2 { + color: var(--color-danger); +} + +.danger-content { + margin-top: 1.5rem; + display: flex; + flex-direction: column; + flex: 1; +} + +.danger-list { + list-style: none; + padding: 0; + margin: 1rem 0 1.5rem 0; +} + +.danger-list li { + padding: 0.5rem 0; + padding-left: 1.5rem; + position: relative; + color: var(--color-text-primary); +} + +.danger-list li::before { + content: '•'; + position: absolute; + left: 0; + color: var(--color-danger); + font-weight: bold; +} + +.button-full-width { + width: 100%; +} + +.danger-content .button { + margin-top: auto; +} + @media (max-width: 768px) { .account-grid { grid-template-columns: 1fr; @@ -990,6 +1099,390 @@ select:hover { background-image: none; } + +/* Frosted Glass Utility Class */ + +.frosted-glass { + background: linear-gradient(135deg, var(--overlay-white-08), var(--overlay-white-04)); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--overlay-white-15); + border-radius: 24px; + box-shadow: 0 8px 32px var(--overlay-black-40), inset 0 1px 0 var(--overlay-white-10); +} + +body.light-mode .frosted-glass { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.5)); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.3); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.5); +} + + +/* Homepage Styles */ + +.homepage-container { + min-width: 75%; + max-width: 1500px; + margin: 0 auto; + padding: 2rem; +} + +.hero-section { + text-align: center; + margin-bottom: 4rem; + padding: 4rem 2rem 2rem; +} + +.hero-title { + font-size: 4.5rem; + margin-bottom: 1.5rem; + line-height: 1.2; +} + +.hero-subtitle { + font-size: 1.8rem; + color: var(--color-text-dim); + max-width: 700px; + margin: 0 auto; + line-height: 1.6; +} + +.inbox-creator { + max-width: 600px; + margin: 0 auto 6rem; + padding: 3rem; +} + +.creator-title { + text-align: center; + font-size: 2.4rem; + margin-bottom: 2.5rem; + color: var(--color-text-light); +} + +.inbox-form { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.form-group label { + font-size: 1.4rem; + font-weight: 600; + color: var(--color-text-light); + padding-left: 0.4rem; +} + +.form-group input[type="text"] { + padding: 1.4rem 1.6rem; + font-size: 1.6rem; + border: 1px solid var(--overlay-purple-30); + border-radius: 12px; + background: var(--overlay-white-04); + color: var(--color-text-primary); + transition: all 0.3s ease; +} + +.form-group input[type="text"]:focus { + outline: none; + border-color: var(--color-accent-purple-light); + background: var(--overlay-white-06); + box-shadow: 0 0 0 3px var(--overlay-purple-15); +} + +.select-wrapper { + position: relative; +} + +.select-wrapper::after { + content: '▾'; + position: absolute; + right: 1.6rem; + top: 35%; + transform: translateY(-50%); + pointer-events: none; + color: var(--color-text-dim); + font-size: 1.6rem; +} + +.form-group select { + padding-left: 1.6rem; + font-size: 1.6rem; + border: 1px solid var(--overlay-purple-30); + border-radius: 12px; + background: var(--overlay-white-04); + color: var(--color-text-primary); + appearance: none; + cursor: pointer; + transition: all 0.3s ease; + width: 100%; +} + +.form-group select:hover { + background: var(--overlay-white-06); + border-color: var(--overlay-purple-40); +} + +.form-group select:focus { + outline: none; + border-color: var(--color-accent-purple-light); + box-shadow: 0 0 0 3px var(--overlay-purple-15); +} + +.form-group select option { + background: var(--color-bg-dark); + color: var(--color-text-primary); +} + +.domain-count { + margin-top: -10px; + font-size: 1.2rem; + color: var(--color-text-dimmer); + padding-left: 0.4rem; +} + +.form-actions { + display: flex; + gap: 1.2rem; + margin-top: 1rem; + align-items: stretch; +} + +.btn { + display: flex; + align-items: center; + justify-content: center; + gap: 0.8rem; + padding: 1.4rem 2.4rem; + font-size: 1.6rem; + font-weight: 600; + border-radius: 12px; + border: none; + cursor: pointer; + transition: all 0.3s ease; + text-decoration: none; + flex: 1; + height: 5.2rem; + box-sizing: border-box; +} + +.btn svg { + transition: transform 0.3s ease; +} + +.btn-primary { + background: linear-gradient(135deg, var(--color-accent-purple), var(--color-accent-purple-bright)); + color: white; + box-shadow: 0 4px 16px var(--overlay-purple-40); +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px var(--overlay-purple-50); +} + +.btn-primary:hover svg { + transform: translateX(4px); +} + +.btn-secondary { + background: var(--overlay-white-08); + color: var(--color-text-light); + border: 1px solid var(--overlay-purple-25); +} + +.btn-secondary:hover { + background: var(--overlay-white-12); + border-color: var(--overlay-purple-35); + transform: translateY(-2px); +} + +.btn-secondary:hover svg { + transform: rotate(15deg); +} + +.alert { + display: flex; + align-items: center; + gap: 1.2rem; + padding: 1.4rem 1.8rem; + border-radius: 12px; + margin-bottom: 2rem; +} + +.alert-success { + background: rgba(46, 204, 113, 0.1); + border: 1px solid #2ecc71; + color: #2ecc71; +} + +.alert-error { + background: rgba(176, 0, 0, 0.1); + border: 1px solid var(--color-error); + color: var(--color-error); +} + +.alert-warning { + background: var(--overlay-warning-10); + border: 1px solid var(--color-warning); + color: var(--color-warning); +} + +.alert span { + font-size: 2rem; +} + +.alert p { + margin: 0; + font-size: 1.4rem; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 2rem; + margin-bottom: 4rem; +} + +.feature-card { + padding: 3rem 2.5rem; + text-align: center; + transition: all 0.3s ease; +} + +.feature-card:hover { + transform: translateY(-8px); + box-shadow: 0 12px 40px var(--overlay-black-45), inset 0 1px 0 var(--overlay-white-15); +} + +.feature-card h3 { + font-size: 2rem; + margin-bottom: 1rem; + color: var(--color-text-light); +} + +.feature-card p { + font-size: 1.4rem; + color: var(--color-text-dim); + line-height: 1.6; + margin: 0; +} + + +/* Info Section */ + +.info-section { + margin-top: 5rem; + margin-bottom: 4rem; +} + +.info-content { + padding: 4rem 3rem; + min-width: 75%; + max-width: 1500px; + margin: 0 auto; +} + +.info-content h2 { + font-size: 3rem; + margin-bottom: 2rem; + color: var(--color-text-light); + text-align: center; +} + +.info-content h3 { + font-size: 2.2rem; + margin-top: 3rem; + margin-bottom: 1.5rem; + color: var(--color-accent-purple-light); +} + +.info-content p { + font-size: 1.6rem; + line-height: 1.8; + color: var(--color-text-primary); + margin-bottom: 2rem; +} + +.info-content ul { + list-style: none; + padding: 0; + margin-bottom: 2rem; +} + +.info-content ul li { + font-size: 1.5rem; + line-height: 1.8; + color: var(--color-text-primary); + margin-bottom: 1.5rem; + padding-left: 2rem; + position: relative; +} + +.info-content ul li::before { + content: '•'; + position: absolute; + left: 0.5rem; + color: var(--color-accent-purple); + font-size: 2rem; + line-height: 1.5; +} + +.info-content ul li strong { + color: var(--color-text-light); +} + +.info-content .note { + background: var(--overlay-purple-10); + border-left: 4px solid var(--color-accent-purple); + padding: 1.5rem 2rem; + margin-top: 3rem; + font-size: 1.5rem; + line-height: 1.7; + border-radius: 4px; +} + + +/* Responsive Design */ + +@media (max-width: 768px) { + .hero-title { + font-size: 3.2rem; + } + .hero-subtitle { + font-size: 1.6rem; + } + .inbox-creator { + padding: 2rem; + } + .form-actions { + flex-direction: column; + } + .features-grid { + grid-template-columns: 1fr; + } + .info-content { + padding: 3rem 2rem; + } + .info-content h2 { + font-size: 2.4rem; + } + .info-content h3 { + font-size: 1.8rem; + } +} + + +/* Legacy Login Styles (kept for compatibility) */ + #login { padding-top: 15vh; display: flex; @@ -1596,6 +2089,8 @@ label { width: 90%; max-width: 400px; box-shadow: 0 4px 6px var(--overlay-black-30); + display: flex; + flex-direction: column; } .modal-content h3 { @@ -1646,6 +2141,23 @@ label { color: var(--color-text-primary); } +.modal-content form { + display: flex; + flex-direction: column; + flex: 1; +} + +.modal-content fieldset { + flex: 1; + display: flex; + flex-direction: column; +} + +.modal-content .button, +.modal-content .modal-button { + margin-top: auto; +} + .modal-input { border-radius: 0.4rem; color: var(--color-text-primary); @@ -1969,7 +2481,8 @@ body.light-mode .theme-icon-light { /* Statistics Page */ .stats-container { - max-width: 1200px; + min-width: 75%; + max-width: 1500px; margin: 0 auto; padding: 2rem; } @@ -2047,12 +2560,57 @@ body.light-mode .theme-icon-light { .hamburger-menu { display: flex; } + /* Hide dropdowns on mobile, show links directly */ + .action-dropdown { + display: contents; + } + .action-dropdown .dropdown-toggle { + display: none; + } + .action-dropdown .dropdown-menu { + display: block; + position: static; + border: none; + border-radius: 0; + box-shadow: none; + padding: 0; + min-width: auto; + background: transparent; + } + /* Add section headers before dropdown menus on mobile */ + .action-dropdown .dropdown-menu::before { + content: attr(data-section-title); + display: block; + font-size: 1.1rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-dim); + padding: 12px 0 8px 0; + margin-top: 8px; + border-top: 1px solid var(--overlay-white-10); + } + .action-dropdown:first-of-type .dropdown-menu::before { + margin-top: 0; + border-top: none; + } + .action-dropdown .dropdown-menu a { + padding: 10px 0; + text-align: left; + border: none !important; + } + .action-dropdown .dropdown-menu a:hover { + background: transparent; + color: var(--color-accent-purple-light); + padding-left: 8px; + } .action-links.mobile-open .theme-toggle { justify-content: center; width: 100%; } .action-links.mobile-hidden>a, - .action-links.mobile-hidden>button:not(.hamburger-menu) { + .action-links.mobile-hidden>button:not(.hamburger-menu), + .action-links.mobile-hidden>.action-dropdown { display: none; } .action-links.mobile-open { @@ -2066,7 +2624,7 @@ body.light-mode .theme-icon-light { padding: 15px; box-shadow: 0 10px 40px var(--overlay-black-40); z-index: 1000; - min-width: 200px; + min-width: 220px; } .action-links.mobile-open>.hamburger-menu { display: none; @@ -2075,7 +2633,24 @@ body.light-mode .theme-icon-light { .action-links.mobile-open>button:not(.hamburger-menu) { display: block; width: 100%; - text-align: center; + text-align: left; + padding: 10px 0; + border: none; + border-radius: 0; + height: auto; + background: transparent; + font-size: 1.4rem; + font-weight: 500; + text-transform: none; + letter-spacing: normal; + } + .action-links.mobile-open>a:hover, + .action-links.mobile-open>button:not(.hamburger-menu):hover { + background: transparent; + color: var(--color-accent-purple-light); + transform: none; + box-shadow: none; + padding-left: 8px; } .qr-icon-btn { display: none; diff --git a/infrastructure/web/routes/account.js b/infrastructure/web/routes/account.js index 58c16a3..49adc06 100644 --- a/infrastructure/web/routes/account.js +++ b/infrastructure/web/routes/account.js @@ -93,8 +93,8 @@ router.post('/account/forward-email/add', }) // Send verification email - const baseUrl = config.http.baseUrl || 'http://localhost:3000' - const branding = config.http.branding[0] || '48hr.email' + const baseUrl = config.http.baseUrl + const branding = config.http.branding[0] await smtpService.sendVerificationEmail( email, @@ -220,4 +220,108 @@ router.post('/account/locked-inbox/release', } ) +// POST /account/change-password - Change user password +router.post('/account/change-password', + requireAuth, + body('currentPassword').notEmpty().withMessage('Current password is required'), + body('newPassword').isLength({ min: 8 }).withMessage('New password must be at least 8 characters'), + body('confirmNewPassword').notEmpty().withMessage('Password confirmation is required'), + async(req, res) => { + try { + const errors = validationResult(req) + if (!errors.isEmpty()) { + req.session.accountError = errors.array()[0].msg + return res.redirect('/account') + } + + const { currentPassword, newPassword, confirmNewPassword } = req.body + + // Check if new passwords match + if (newPassword !== confirmNewPassword) { + req.session.accountError = 'New passwords do not match' + return res.redirect('/account') + } + + // Validate new password strength + const hasUpperCase = /[A-Z]/.test(newPassword) + const hasLowerCase = /[a-z]/.test(newPassword) + const hasNumber = /[0-9]/.test(newPassword) + + if (!hasUpperCase || !hasLowerCase || !hasNumber) { + req.session.accountError = 'Password must include uppercase, lowercase, and number' + return res.redirect('/account') + } + + const userRepository = req.app.get('userRepository') + + // Verify current password + const isValidPassword = await userRepository.verifyPassword(req.session.userId, currentPassword) + if (!isValidPassword) { + req.session.accountError = 'Current password is incorrect' + return res.redirect('/account') + } + + // Update password + await userRepository.updatePassword(req.session.userId, newPassword) + + req.session.accountSuccess = 'Password updated successfully' + res.redirect('/account') + } catch (error) { + console.error('Change password error:', error) + req.session.accountError = 'Failed to change password. Please try again.' + res.redirect('/account') + } + } +) + +// POST /account/delete - Permanently delete user account +router.post('/account/delete', + requireAuth, + body('password').notEmpty().withMessage('Password is required'), + body('confirmText').equals('DELETE').withMessage('You must type DELETE to confirm'), + async(req, res) => { + try { + const errors = validationResult(req) + if (!errors.isEmpty()) { + req.session.accountError = errors.array()[0].msg + return res.redirect('/account') + } + + const { password } = req.body + const userRepository = req.app.get('userRepository') + + // Verify password + const isValidPassword = await userRepository.verifyPassword(req.session.userId, password) + if (!isValidPassword) { + req.session.accountError = 'Incorrect password' + return res.redirect('/account') + } + + // Get user's locked inboxes to release them + const inboxLock = req.app.get('inboxLock') + if (inboxLock) { + const lockedInboxes = inboxLock.getUserLockedInboxes(req.session.userId) + for (const inbox of lockedInboxes) { + inboxLock.release(req.session.userId, inbox.address) + } + } + + // Delete user account + await userRepository.deleteUser(req.session.userId) + + // Destroy session + req.session.destroy((err) => { + if (err) { + console.error('Session destroy error:', err) + } + res.redirect('/?deleted=true') + }) + } catch (error) { + console.error('Delete account error:', error) + req.session.accountError = 'Failed to delete account. Please try again.' + res.redirect('/account') + } + } +) + module.exports = router diff --git a/infrastructure/web/routes/auth.js b/infrastructure/web/routes/auth.js index a232bff..f42ec62 100644 --- a/infrastructure/web/routes/auth.js +++ b/infrastructure/web/routes/auth.js @@ -3,6 +3,11 @@ const router = new express.Router() const { body, validationResult } = require('express-validator') const debug = require('debug')('48hr-email:auth-routes') const { redirectIfAuthenticated } = require('../middleware/auth') +const config = require('../../../application/config') +const Helper = require('../../../application/helper') +const helper = new Helper() + +const purgeTime = helper.purgeTimeElemetBuilder() // Simple in-memory rate limiters for registration and login const registrationRateLimitStore = new Map() @@ -87,6 +92,7 @@ router.get('/auth', redirectIfAuthenticated, (req, res) => { res.render('auth', { title: `Login or Register | ${config.http.branding[0]}`, branding: config.http.branding, + purgeTime: purgeTime, errorMessage, successMessage }) diff --git a/infrastructure/web/routes/login.js b/infrastructure/web/routes/login.js index 363e05a..f96179b 100644 --- a/infrastructure/web/routes/login.js +++ b/infrastructure/web/routes/login.js @@ -21,6 +21,7 @@ router.get('/', async(req, res, next) => { title: `${config.http.branding[0]} | Your temporary Inbox`, username: randomWord(), purgeTime: purgeTime, + purgeTimeRaw: config.email.purgeTime, domains: helper.getDomains(), branding: config.http.branding, example: config.email.examples.account, @@ -59,6 +60,7 @@ router.post( userInputError: true, title: `${config.http.branding[0]} | Your temporary Inbox`, purgeTime: purgeTime, + purgeTimeRaw: config.email.purgeTime, username: randomWord(), domains: helper.getDomains(), branding: config.http.branding, diff --git a/infrastructure/web/views/account.twig b/infrastructure/web/views/account.twig index 314fc38..51b7a27 100644 --- a/infrastructure/web/views/account.twig +++ b/infrastructure/web/views/account.twig @@ -20,20 +20,28 @@ {% if successMessage %} -
- {{ successMessage }} +
+ + + +

{{ successMessage }}

{% endif %} {% if errorMessage %} -
- {{ errorMessage }} +
+ + + + + +

{{ errorMessage }}

{% endif %}