mirror of
https://github.com/Crazyco-xyz/48hr.email.git
synced 2026-01-08 18:59:36 +01:00
[Feat]: V2
Updated: - Update UI, - Update routes - Update filters New: - Add Password change route - Add Account deletion button
This commit is contained in:
parent
3fdf5bf61b
commit
2f58eacfa7
17 changed files with 1209 additions and 135 deletions
|
|
@ -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)')
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@ ${mail.html}
|
|||
<p><code>${verificationLink}</code></p>
|
||||
|
||||
<div class="warning">
|
||||
⚠️ <strong>Important:</strong> This verification link expires in <strong>15 minutes</strong>. Once verified, you'll be able to forward emails to this address for 24 hours.
|
||||
<strong>Important:</strong> This verification link expires in <strong>15 minutes</strong>. Once verified, you'll be able to forward emails to this address for 24 hours.
|
||||
</div>
|
||||
|
||||
<p>If you didn't request this verification, you can safely ignore this email.</p>
|
||||
|
|
|
|||
|
|
@ -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<boolean>} - 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<boolean>} - 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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -20,20 +20,28 @@
|
|||
<p class="account-subtitle">Welcome back, <strong>{{ username }}</strong></p>
|
||||
|
||||
{% if successMessage %}
|
||||
<div class="success-message">
|
||||
{{ successMessage }}
|
||||
<div class="alert alert-success">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
|
||||
<path d="M20 6L9 17l-5-5"></path>
|
||||
</svg>
|
||||
<p>{{ successMessage }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if errorMessage %}
|
||||
<div class="unlock-error">
|
||||
{{ errorMessage }}
|
||||
<div class="alert alert-error">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||
</svg>
|
||||
<p>{{ errorMessage }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="account-grid">
|
||||
<!-- Account Stats -->
|
||||
<div class="account-card account-stats">
|
||||
<div class="account-card frosted-glass">
|
||||
<h2>Account Overview</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
|
|
@ -52,7 +60,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Forwarding Emails Section -->
|
||||
<div class="account-card">
|
||||
<div class="account-card frosted-glass">
|
||||
<h2>Forwarding Emails</h2>
|
||||
<p class="card-description">Add verified emails to forward messages to. Each email must be verified before use.</p>
|
||||
|
||||
|
|
@ -85,7 +93,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Locked Inboxes Section -->
|
||||
<div class="account-card">
|
||||
<div class="account-card frosted-glass">
|
||||
<h2>Locked Inboxes</h2>
|
||||
<p class="card-description">Manage your locked inboxes. These are protected by your account and only accessible when logged in. Locks auto-release after 7 days without login.</p>
|
||||
|
||||
|
|
@ -116,6 +124,105 @@
|
|||
<p class="limit-reached">Maximum {{ stats.maxLockedInboxes }} inboxes locked</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Change Password Section -->
|
||||
<div class="account-card frosted-glass">
|
||||
<h2>Change Password</h2>
|
||||
<p class="card-description">Update your account password. You'll need to enter your current password to confirm.</p>
|
||||
|
||||
<form method="POST" action="/account/change-password" class="password-form">
|
||||
<fieldset>
|
||||
<label for="currentPassword">Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="currentPassword"
|
||||
name="currentPassword"
|
||||
placeholder="Enter current password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
>
|
||||
|
||||
<label for="newPassword">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
placeholder="Min 8 characters"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
<small>Must include uppercase, lowercase, and number</small>
|
||||
|
||||
<label for="confirmNewPassword">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmNewPassword"
|
||||
name="confirmNewPassword"
|
||||
placeholder="Re-enter new password"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
|
||||
<button type="submit" class="button button-primary">Update Password</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Delete Account Section -->
|
||||
<div class="account-card frosted-glass danger-zone">
|
||||
<h2>Danger Zone</h2>
|
||||
<p class="card-description">Permanently delete your account and all associated data. This action cannot be undone.</p>
|
||||
|
||||
<div class="danger-content">
|
||||
<p><strong>Warning:</strong> Deleting your account will:</p>
|
||||
<ul class="danger-list">
|
||||
<li>Remove all forwarding email addresses</li>
|
||||
<li>Release all locked inboxes</li>
|
||||
<li>Permanently delete your account data</li>
|
||||
</ul>
|
||||
|
||||
<button class="button button-danger button-full-width" id="deleteAccountBtn">Delete Account</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Account Modal -->
|
||||
<div id="deleteAccountModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" id="closeDeleteAccount">×</span>
|
||||
<h3>Delete Account</h3>
|
||||
<p class="modal-description" style="color: var(--color-danger);">This action is permanent and cannot be undone!</p>
|
||||
|
||||
<form method="POST" action="/account/delete">
|
||||
<fieldset>
|
||||
<label for="confirmPassword">Enter your password to confirm</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="password"
|
||||
placeholder="Your password"
|
||||
required
|
||||
class="modal-input"
|
||||
autocomplete="current-password"
|
||||
>
|
||||
|
||||
<label for="confirmText">Type "DELETE" to confirm</label>
|
||||
<input
|
||||
type="text"
|
||||
id="confirmText"
|
||||
name="confirmText"
|
||||
placeholder="Type DELETE"
|
||||
required
|
||||
class="modal-input"
|
||||
>
|
||||
|
||||
<button type="submit" class="button button-danger modal-button">Permanently Delete Account</button>
|
||||
<button type="button" class="button button-secondary modal-button" id="cancelDelete">Cancel</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -161,10 +268,37 @@ if (closeAddEmail) {
|
|||
}
|
||||
}
|
||||
|
||||
// Delete Account Modal
|
||||
const deleteAccountBtn = document.getElementById('deleteAccountBtn');
|
||||
const deleteAccountModal = document.getElementById('deleteAccountModal');
|
||||
const closeDeleteAccount = document.getElementById('closeDeleteAccount');
|
||||
const cancelDelete = document.getElementById('cancelDelete');
|
||||
|
||||
if (deleteAccountBtn) {
|
||||
deleteAccountBtn.onclick = function() {
|
||||
deleteAccountModal.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
if (closeDeleteAccount) {
|
||||
closeDeleteAccount.onclick = function() {
|
||||
deleteAccountModal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelDelete) {
|
||||
cancelDelete.onclick = function() {
|
||||
deleteAccountModal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
window.onclick = function(event) {
|
||||
if (event.target == addEmailModal) {
|
||||
addEmailModal.style.display = 'none';
|
||||
}
|
||||
if (event.target == deleteAccountModal) {
|
||||
deleteAccountModal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -20,50 +20,29 @@
|
|||
<h1 class="page-title">Account Access</h1>
|
||||
<p class="auth-subtitle">Login to an existing account or create a new one</p>
|
||||
{% if errorMessage %}
|
||||
<div class="unlock-error">{{ errorMessage }}</div>
|
||||
<div class="alert alert-error">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||
</svg>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if successMessage %}
|
||||
<div class="success-message">{{ successMessage }}</div>
|
||||
<div class="alert alert-success">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
|
||||
<path d="M20 6L9 17l-5-5"></path>
|
||||
</svg>
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="auth-forms-grid">
|
||||
<!-- Login Form -->
|
||||
<div class="auth-card">
|
||||
<h2>Login</h2>
|
||||
<p class="auth-card-subtitle">Access your existing account</p>
|
||||
|
||||
<form method="POST" action="/login">
|
||||
<fieldset>
|
||||
<label for="login-username">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="login-username"
|
||||
name="username"
|
||||
placeholder="Your username"
|
||||
required
|
||||
autocomplete="username"
|
||||
>
|
||||
|
||||
<label for="login-password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="login-password"
|
||||
name="password"
|
||||
placeholder="Your password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
>
|
||||
|
||||
<button class="button button-primary" type="submit">Login</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Register Form -->
|
||||
<div class="auth-card">
|
||||
<div class="auth-card frosted-glass">
|
||||
<h2>Register</h2>
|
||||
<p class="auth-card-subtitle">Create a new account</p>
|
||||
|
||||
<form method="POST" action="/register">
|
||||
<fieldset>
|
||||
|
|
@ -108,10 +87,41 @@
|
|||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<div class="auth-card frosted-glass">
|
||||
<h2>Login</h2>
|
||||
|
||||
<form method="POST" action="/login">
|
||||
<fieldset>
|
||||
<label for="login-username">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="login-username"
|
||||
name="username"
|
||||
placeholder="Your username"
|
||||
required
|
||||
autocomplete="username"
|
||||
>
|
||||
|
||||
<label for="login-password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="login-password"
|
||||
name="password"
|
||||
placeholder="Your password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
>
|
||||
|
||||
<button class="button button-primary" type="submit">Login</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-features-unified">
|
||||
<h3>✓ Account Benefits</h3>
|
||||
<h3>Account Benefits</h3>
|
||||
<div class="features-grid">
|
||||
<div class="feature-item">Forward emails to verified addresses</div>
|
||||
<div class="feature-item">Lock up to 5 inboxes to your account</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
{% if authEnabled %}
|
||||
<div class="action-dropdown">
|
||||
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-menu" data-section-title="Account">
|
||||
<a href="/account" aria-label="Account settings">Settings</a>
|
||||
<a href="/logout?redirect=/" aria-label="Logout">Logout</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<!-- Inbox Dropdown (multiple actions when logged in) -->
|
||||
<div class="action-dropdown">
|
||||
<button class="dropdown-toggle" aria-label="Inbox actions">Inbox ▾</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-menu" data-section-title="Inbox Actions">
|
||||
<a href="#" id="forwardAllBtn" aria-label="Forward all emails">Forward All</a>
|
||||
{% if authEnabled %}
|
||||
{% if isLocked and hasAccess %}
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
{% if authEnabled %}
|
||||
<div class="action-dropdown">
|
||||
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-menu" data-section-title="Account">
|
||||
<a href="/account" aria-label="Account settings">Settings</a>
|
||||
<a href="/logout?redirect={{ ('/inbox/' ~ address) | url_encode }}" aria-label="Logout">Logout</a>
|
||||
</div>
|
||||
|
|
@ -53,8 +53,11 @@
|
|||
<script src="/javascripts/qrcode.js"></script>
|
||||
<script src="/javascripts/inbox-init.js" defer data-address="{{ address }}" data-expiry-time="{{ expiryTime }}" data-expiry-unit="{{ expiryUnit }}" data-refresh-interval="{{ refreshInterval }}"></script>
|
||||
{% if forwardAllSuccess %}
|
||||
<div class="success-message">
|
||||
✓ Successfully forwarded {{ forwardAllSuccess }} email(s)!
|
||||
<div class="alert alert-success">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
|
||||
<path d="M20 6L9 17l-5-5"></path>
|
||||
</svg>
|
||||
Successfully forwarded {{ forwardAllSuccess }} email(s)!
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if verificationSent %}
|
||||
|
|
@ -63,7 +66,12 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
{% if errorMessage %}
|
||||
<div class="unlock-error">
|
||||
<div class="alert alert-error">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||
</svg>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -84,7 +92,7 @@
|
|||
<div class="emails-container">
|
||||
{% for mail in mailSummaries %}
|
||||
<a href="{{ mail.to[0] }}/{{ mail.uid }}" class="email-link">
|
||||
<div class="email-card">
|
||||
<div class="email-card frosted-glass">
|
||||
<div class="email-header">
|
||||
<div class="email-sender">
|
||||
<div class="sender-name">{{ mail.from[0].name }}</div>
|
||||
|
|
@ -111,20 +119,20 @@
|
|||
{% if authEnabled and not isLocked %}
|
||||
<!-- Lock Modal -->
|
||||
<div id="lockModal" class="modal" style="display: none;" data-lock-error="{{ error|default('') }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-content frosted-glass">
|
||||
<span class="close" id="closeLock">×</span>
|
||||
<h3>Lock Inbox</h3>
|
||||
<p class="modal-description">Lock this inbox to your account. Only you will be able to access it while logged in.</p>
|
||||
{% if error and error == 'locking_disabled_for_example' %}
|
||||
<p id="lockServerError" class="unlock-error">Locking is disabled for the example inbox.</p>
|
||||
<p id="lockServerError" class="alert alert-error">Locking is disabled for the example inbox.</p>
|
||||
{% elseif error and error == 'max_locked_inboxes' %}
|
||||
<p id="lockServerError" class="unlock-error">You have reached the maximum of 5 locked inboxes. Please remove a lock before adding a new one.</p>
|
||||
<p id="lockServerError" class="alert alert-error">You have reached the maximum of 5 locked inboxes. Please remove a lock before adding a new one.</p>
|
||||
{% elseif error and error == 'already_locked' %}
|
||||
<p id="lockServerError" class="unlock-error">This inbox is already locked by another user.</p>
|
||||
<p id="lockServerError" class="alert alert-error">This inbox is already locked by another user.</p>
|
||||
{% elseif error and error == 'not_your_lock' %}
|
||||
<p id="lockServerError" class="unlock-error">You don't own the lock on this inbox.</p>
|
||||
<p id="lockServerError" class="alert alert-error">You don't own the lock on this inbox.</p>
|
||||
{% endif %}
|
||||
<p id="lockErrorInline" class="unlock-error" style="display:none"></p>
|
||||
<p id="lockErrorInline" class="alert alert-error" style="display:none"></p>
|
||||
<form method="POST" action="/lock/lock">
|
||||
<input type="hidden" name="address" value="{{ address }}">
|
||||
<fieldset>
|
||||
|
|
@ -142,7 +150,7 @@
|
|||
{% if authEnabled and isLocked and hasAccess %}
|
||||
<!-- Remove Lock Modal -->
|
||||
<div id="removeLockModal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-content frosted-glass">
|
||||
<span class="close" id="closeRemoveLock">×</span>
|
||||
<h3>Remove Lock</h3>
|
||||
<p class="modal-description">Are you sure you want to remove the lock from this inbox? Anyone will be able to access it.</p>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
{% if authEnabled %}
|
||||
<div class="action-dropdown">
|
||||
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-menu" data-section-title="Account">
|
||||
<a href="/account" aria-label="Account settings">Settings</a>
|
||||
<a href="/logout?redirect=/" aria-label="Logout">Logout</a>
|
||||
</div>
|
||||
|
|
@ -32,33 +32,105 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div id="login">
|
||||
<h1 class="page-title">Welcome!</h1>
|
||||
<h4>Here you can either create a new Inbox, or access your old one</h4>
|
||||
<div class="homepage-container">
|
||||
<div class="hero-section">
|
||||
<h1 class="page-title hero-title">Your Temporary Inbox</h1>
|
||||
<p class="hero-subtitle">Create instant disposable email addresses. No registration required. Emails auto-delete after {{ purgeTimeRaw|readablePurgeTime }}.</p>
|
||||
</div>
|
||||
|
||||
{% if userInputError %}
|
||||
<blockquote class="warning">
|
||||
Your input was invalid. Please try other values.
|
||||
</blockquote>
|
||||
{% endif %}
|
||||
<form method="POST" action="/">
|
||||
<fieldset>
|
||||
<label for="nameField">Name</label>
|
||||
<input type="text" id="nameField" name="username" value="{{ username }}">
|
||||
<label for="commentField">Domain ({{ domains|length }})</label>
|
||||
<div class="dropdown">
|
||||
<select id="commentField" name="domain">
|
||||
{% for domain in domains %}
|
||||
<option value="{{ domain }}">{{ domain }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="inbox-creator frosted-glass">
|
||||
<h2 class="creator-title">Get Started</h2>
|
||||
|
||||
{% if userInputError %}
|
||||
<div class="alert alert-warning">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"></path>
|
||||
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||
</svg>
|
||||
Your input was invalid. Please try other values.
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<input class="button" type="submit" value="Access This Inbox">
|
||||
<a class="button" href="/inbox/random">Create Random Inbox</a>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="/" class="inbox-form">
|
||||
<div class="form-group">
|
||||
<label for="nameField">Choose Your Name</label>
|
||||
<input type="text" id="nameField" name="username" value="{{ username }}" placeholder="e.g., john.doe" required>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="commentField">Select Domain</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="commentField" name="domain">
|
||||
{% for domain in domains %}
|
||||
<option value="{{ domain }}">@{{ domain }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<span class="domain-count">{{ domains|length }} domains available</span>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span>Access Inbox</span>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="/inbox/random" class="btn btn-secondary">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</svg>
|
||||
<span>Random Inbox</span>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-card frosted-glass">
|
||||
<h3>Privacy First</h3>
|
||||
<p>No registration, no tracking. Your temporary email is completely anonymous.</p>
|
||||
</div>
|
||||
<div class="feature-card frosted-glass">
|
||||
<h3>Instant Access</h3>
|
||||
<p>Create unlimited temporary email addresses in seconds. No waiting, no verification.</p>
|
||||
</div>
|
||||
<div class="feature-card frosted-glass">
|
||||
<h3>Auto-Delete</h3>
|
||||
<p>All emails automatically purge after {{ purgeTimeRaw|readablePurgeTime }}. Clean and secure by default.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<div class="info-content frosted-glass">
|
||||
<h2>What is a Temporary Email?</h2>
|
||||
<p>A temporary email (also known as disposable email or throwaway email) is a service that allows you to receive emails at a temporary address that self-destructs after a certain time. It's perfect for signing up to websites, testing services, or protecting your real inbox from spam.</p>
|
||||
|
||||
<h3>Common Use Cases</h3>
|
||||
<ul class="use-cases-list">
|
||||
<li><strong>Avoid Spam:</strong> Sign up for services without cluttering your primary inbox</li>
|
||||
<li><strong>Test Services:</strong> Try new apps and websites without commitment</li>
|
||||
<li><strong>Online Privacy:</strong> Keep your real email address private from third parties</li>
|
||||
<li><strong>One-Time Verification:</strong> Receive verification codes without long-term exposure</li>
|
||||
<li><strong>Download Content:</strong> Access gated content that requires email confirmation</li>
|
||||
<li><strong>Form Testing:</strong> Test email workflows during development</li>
|
||||
</ul>
|
||||
|
||||
<h3>Why Choose {{ branding[0] }}?</h3>
|
||||
<ul class="benefits-list">
|
||||
<li><strong>No Sign-Up Required:</strong> Start using immediately without creating an account</li>
|
||||
<li><strong>Multiple Domains:</strong> Choose from several domain options for flexibility</li>
|
||||
<li><strong>Clean Interface:</strong> Simple, modern design focused on usability</li>
|
||||
<li><strong>Real-Time Updates:</strong> See new emails arrive instantly without refreshing</li>
|
||||
<li><strong>Open Source:</strong> Transparent codebase you can review and trust</li>
|
||||
<li><strong>{{ purgeTimeRaw|readablePurgeTime|title }} Retention:</strong> Emails stay accessible for the full duration before auto-deletion</li>
|
||||
</ul>
|
||||
|
||||
<p class="note">For extended features like email forwarding and inbox locking, you can optionally create a free account. But for basic temporary email needs, no registration is ever required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<!-- Email Dropdown (multiple actions when logged in) -->
|
||||
<div class="action-dropdown">
|
||||
<button class="dropdown-toggle" aria-label="Email actions">Email ▾</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-menu" data-section-title="Email Actions">
|
||||
<a href="#" id="forwardBtn" aria-label="Forward this email">Forward</a>
|
||||
<a href="/inbox/{{ address }}/{{ uid }}/delete" aria-label="Delete this email">Delete</a>
|
||||
<a href="/inbox/{{ address }}/{{ uid }}/raw" target="_blank" aria-label="View raw email">View Raw</a>
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
{% if authEnabled %}
|
||||
<div class="action-dropdown">
|
||||
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-menu" data-section-title="Account">
|
||||
<a href="/account" aria-label="Account settings">Settings</a>
|
||||
<a href="/logout?redirect={{ ('/inbox/' ~ address ~ '/' ~ uid) | url_encode }}" aria-label="Logout">Logout</a>
|
||||
</div>
|
||||
|
|
@ -48,8 +48,11 @@
|
|||
|
||||
{% block body %}
|
||||
{% if forwardSuccess %}
|
||||
<div class="success-message">
|
||||
✓ Email forwarded successfully!
|
||||
<div class="alert alert-success">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
|
||||
<path d="M20 6L9 17l-5-5"></path>
|
||||
</svg>
|
||||
Email forwarded successfully!
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if verificationSent %}
|
||||
|
|
@ -125,7 +128,7 @@
|
|||
|
||||
<!-- Forward Email Modal -->
|
||||
<div id="forwardModal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-content frosted-glass">
|
||||
<span class="close" id="closeForward">×</span>
|
||||
<h3>Forward Email</h3>
|
||||
|
||||
|
|
@ -143,9 +146,9 @@
|
|||
{% else %}
|
||||
<p class="modal-description">Select a verified email address to forward this message to.</p>
|
||||
{% if errorMessage %}
|
||||
<p class="unlock-error">{{ errorMessage }}</p>
|
||||
<p class="alert alert-error">{{ errorMessage }}</p>
|
||||
{% endif %}
|
||||
<p id="forwardError" class="unlock-error" style="display:none"></p>
|
||||
<p id="forwardError" class="alert alert-error" style="display:none"></p>
|
||||
<form method="POST" action="/inbox/{{ address }}/{{ uid }}/forward">
|
||||
<fieldset>
|
||||
<label for="forwardEmail" class="floating-label">Forward to</label>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
{% if authEnabled %}
|
||||
<div class="action-dropdown">
|
||||
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-menu" data-section-title="Account">
|
||||
<a href="/account" aria-label="Account settings">Settings</a>
|
||||
<a href="/logout?redirect=/stats" aria-label="Logout">Logout</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const sanitizeHtml = require('sanitize-html')
|
||||
const config = require('../../../application/config')
|
||||
|
||||
/**
|
||||
* Transformes <a> tags to always use "noreferrer noopener" and open in a new window.
|
||||
|
|
@ -6,22 +7,84 @@ const sanitizeHtml = require('sanitize-html')
|
|||
* @returns {*} dom after transformation
|
||||
*/
|
||||
exports.sanitizeHtmlTwigFilter = function(value) {
|
||||
return sanitizeHtml(value, {
|
||||
allowedAttributes: {
|
||||
a: ['href', 'target', 'rel']
|
||||
},
|
||||
return sanitizeHtml(value, {
|
||||
allowedAttributes: {
|
||||
a: ['href', 'target', 'rel']
|
||||
},
|
||||
|
||||
transformTags: {
|
||||
a(tagName, attribs) {
|
||||
return {
|
||||
tagName,
|
||||
attribs: {
|
||||
rel: 'noreferrer noopener',
|
||||
href: attribs.href,
|
||||
target: '_blank'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
transformTags: {
|
||||
a(tagName, attribs) {
|
||||
return {
|
||||
tagName,
|
||||
attribs: {
|
||||
rel: 'noreferrer noopener',
|
||||
href: attribs.href,
|
||||
target: '_blank'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert time to highest possible unit (minutes → hours → days),
|
||||
* rounding if necessary and prefixing "~" when rounded.
|
||||
* Mirrors the logic from Helper.convertAndRound()
|
||||
*
|
||||
* @param {number} time
|
||||
* @param {string} unit "minutes" | "hours" | "days"
|
||||
* @returns {string}
|
||||
*/
|
||||
function convertAndRound(time, unit) {
|
||||
let value = time
|
||||
let u = unit
|
||||
|
||||
// upgrade units
|
||||
const units = [
|
||||
["minutes", 60, "hours"],
|
||||
["hours", 24, "days"]
|
||||
]
|
||||
|
||||
for (const [from, factor, to] of units) {
|
||||
if (u === from && value > factor) {
|
||||
value = value / factor
|
||||
u = to
|
||||
}
|
||||
}
|
||||
|
||||
// determine if rounding is needed
|
||||
const rounded = !Number.isSafeInteger(value)
|
||||
if (rounded) value = Math.round(value)
|
||||
|
||||
// Handle singular/plural
|
||||
const displayValue = value === 1 ? value : value
|
||||
const displayUnit = value === 1 ? u.replace(/s$/, '') : u
|
||||
|
||||
return `${rounded ? "~" : ""}${displayValue} ${displayUnit}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert purgeTime config to readable format, respecting the convert flag
|
||||
* @param {Object} purgeTime - Object with time, unit, and convert properties
|
||||
* @returns {String} Readable time string
|
||||
*/
|
||||
exports.readablePurgeTime = function(purgeTime) {
|
||||
if (!purgeTime || !purgeTime.time || !purgeTime.unit) {
|
||||
// Fallback to config if not provided
|
||||
if (config.email.purgeTime) {
|
||||
purgeTime = config.email.purgeTime
|
||||
} else {
|
||||
return '48 hours'
|
||||
}
|
||||
}
|
||||
|
||||
let result = `${purgeTime.time} ${purgeTime.unit}`
|
||||
|
||||
// Only convert if the convert flag is true
|
||||
if (purgeTime.convert) {
|
||||
result = convertAndRound(purgeTime.time, purgeTime.unit)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const lockRouter = require('./routes/lock')
|
|||
const authRouter = require('./routes/auth')
|
||||
const accountRouter = require('./routes/account')
|
||||
const statsRouter = require('./routes/stats')
|
||||
const { sanitizeHtmlTwigFilter } = require('./views/twig-filters')
|
||||
const { sanitizeHtmlTwigFilter, readablePurgeTime } = require('./views/twig-filters')
|
||||
|
||||
const Helper = require('../../application/helper')
|
||||
const helper = new(Helper)
|
||||
|
|
@ -90,6 +90,7 @@ app.use(
|
|||
})
|
||||
)
|
||||
Twig.extendFilter('sanitizeHtml', sanitizeHtmlTwigFilter)
|
||||
Twig.extendFilter('readablePurgeTime', readablePurgeTime)
|
||||
|
||||
// Middleware to expose user session to all templates
|
||||
app.use((req, res, next) => {
|
||||
|
|
|
|||
24
package.json
24
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "48hr.email",
|
||||
"version": "1.9.0",
|
||||
"version": "2.0.0",
|
||||
"private": false,
|
||||
"description": "48hr.email is your favorite open-source tempmail client.",
|
||||
"keywords": [
|
||||
|
|
@ -68,15 +68,17 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"overrides": [{
|
||||
"files": "public/javascripts/*.js",
|
||||
"esnext": false,
|
||||
"env": [
|
||||
"browser"
|
||||
],
|
||||
"globals": [
|
||||
"io"
|
||||
]
|
||||
}]
|
||||
"overrides": [
|
||||
{
|
||||
"files": "public/javascripts/*.js",
|
||||
"esnext": false,
|
||||
"env": [
|
||||
"browser"
|
||||
],
|
||||
"globals": [
|
||||
"io"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue