Skip to content

Commit 49cf8af

Browse files
committed
feat: two-step email change with verification link and code
1 parent b02f20c commit 49cf8af

4 files changed

Lines changed: 460 additions & 21 deletions

File tree

config/migrate.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ const tables = {
2121
{ name: 'email_verified', def: 'TINYINT(1) NOT NULL DEFAULT 0' },
2222
{ name: 'verification_token', def: 'VARCHAR(64) DEFAULT NULL' },
2323
{ name: 'verification_token_expires', def: 'DATETIME DEFAULT NULL' },
24+
{ name: 'pending_email', def: 'VARCHAR(255) DEFAULT NULL' },
25+
{ name: 'email_change_token', def: 'VARCHAR(64) DEFAULT NULL' },
26+
{ name: 'email_change_code', def: 'VARCHAR(10) DEFAULT NULL' },
27+
{ name: 'email_change_expires', def: 'DATETIME DEFAULT NULL' },
2428
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
2529
],
2630
},

public/js/app.js

Lines changed: 175 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -910,6 +910,165 @@ async function renderVerifyEmail(token) {
910910
}
911911
}
912912

913+
async function renderChangeEmailVerify(token) {
914+
const app = $('#app');
915+
916+
if (!token) {
917+
app.innerHTML = html`
918+
<div class="auth-page">
919+
<div class="auth-card" style="text-align:center;">
920+
<div style="margin:24px 0 16px;">
921+
<i data-lucide="x-circle" style="width:48px;height:48px;color:var(--accent-red);"></i>
922+
</div>
923+
<h1 class="auth-title">Invalid Link</h1>
924+
<p class="auth-subtitle">This email change link is invalid or has expired.</p>
925+
<div style="margin-top:24px;">
926+
<a href="/login" class="btn btn-primary">Sign In</a>
927+
</div>
928+
</div>
929+
</div>
930+
`;
931+
initIcons();
932+
return;
933+
}
934+
935+
app.innerHTML = html`
936+
<div class="login-page">
937+
<div class="login-left">
938+
<div class="login-left-top">
939+
<img src="https://img.zero-host.org/assets/picto.png" alt="ZeroHost" />
940+
<span>| Dashboard</span>
941+
</div>
942+
</div>
943+
<div class="login-right">
944+
<div class="login-card">
945+
<div style="margin:0 auto 24px;width:fit-content;">
946+
<span class="spinner" style="width:36px;height:36px;"></span>
947+
</div>
948+
<h1 class="auth-title" style="text-align:center">Verifying link...</h1>
949+
</div>
950+
</div>
951+
</div>
952+
`;
953+
initIcons();
954+
955+
try {
956+
const data = await api(`/auth/change-email/verify?token=${encodeURIComponent(token)}`);
957+
958+
app.innerHTML = html`
959+
<div class="login-page">
960+
<div class="login-left">
961+
<div class="login-left-top">
962+
<img src="https://img.zero-host.org/assets/picto.png" alt="ZeroHost" />
963+
<span>| Dashboard</span>
964+
</div>
965+
</div>
966+
<div class="login-right">
967+
<div class="login-card">
968+
<h1 class="auth-title">Enter verification code</h1>
969+
<p class="auth-subtitle">A 6-digit code was sent to <strong style="color:var(--text-primary)">${escapeHtml(data.pendingEmail)}</strong></p>
970+
<form id="change-email-code-form">
971+
<div class="auth-error"></div>
972+
<div class="form-group">
973+
<label for="change-email-code">Verification Code</label>
974+
<input type="text" id="change-email-code" placeholder="000000" maxlength="6" pattern="[0-9]{6}" inputmode="numeric" autocomplete="one-time-code" required style="text-align:center;font-size:1.4rem;letter-spacing:8px;font-family:'JetBrains Mono',monospace;" />
975+
</div>
976+
<button type="submit" class="btn btn-primary btn-full" id="change-email-code-btn">
977+
Confirm
978+
</button>
979+
</form>
980+
<button type="button" class="btn btn-ghost btn-full" id="change-email-resend-btn" style="margin-top:12px;border:1px solid var(--border)">
981+
Resend code
982+
</button>
983+
<div class="auth-footer">
984+
<a href="/account/info" id="change-email-cancel">Cancel</a>
985+
</div>
986+
</div>
987+
</div>
988+
</div>
989+
`;
990+
991+
$('#change-email-code-form').addEventListener('submit', async (e) => {
992+
e.preventDefault();
993+
const btn = $('#change-email-code-btn');
994+
const errorEl = $('#change-email-code-form .auth-error');
995+
const code = $('#change-email-code').value.trim();
996+
997+
if (code.length !== 6) {
998+
errorEl.textContent = 'Please enter the 6-digit code';
999+
errorEl.classList.add('show');
1000+
return;
1001+
}
1002+
1003+
btn.disabled = true;
1004+
btn.innerHTML = '<span class="spinner"></span>';
1005+
1006+
try {
1007+
const result = await api('/auth/change-email/confirm', {
1008+
method: 'POST',
1009+
body: JSON.stringify({ code }),
1010+
});
1011+
state.token = result.token;
1012+
state.user = result.user;
1013+
localStorage.setItem('zh_token', result.token);
1014+
localStorage.setItem('zh_user', JSON.stringify(result.user));
1015+
showToast('Email updated successfully', 'success');
1016+
navigateTo('account/info');
1017+
} catch (err) {
1018+
errorEl.textContent = err.message;
1019+
errorEl.classList.add('show');
1020+
} finally {
1021+
btn.disabled = false;
1022+
btn.innerHTML = 'Confirm';
1023+
}
1024+
});
1025+
1026+
$('#change-email-resend-btn').addEventListener('click', async () => {
1027+
const btn = $('#change-email-resend-btn');
1028+
btn.disabled = true;
1029+
try {
1030+
await api(`/auth/change-email/verify?token=${encodeURIComponent(token)}`);
1031+
showToast('New code sent', 'success');
1032+
} catch (err) {
1033+
showToast(err.message, 'error');
1034+
} finally {
1035+
btn.disabled = false;
1036+
}
1037+
});
1038+
1039+
$('#change-email-cancel').addEventListener('click', (e) => {
1040+
e.preventDefault();
1041+
navigateTo('account/info');
1042+
});
1043+
1044+
initIcons();
1045+
} catch (err) {
1046+
app.innerHTML = html`
1047+
<div class="login-page">
1048+
<div class="login-left">
1049+
<div class="login-left-top">
1050+
<img src="https://img.zero-host.org/assets/picto.png" alt="ZeroHost" />
1051+
<span>| Dashboard</span>
1052+
</div>
1053+
</div>
1054+
<div class="login-right">
1055+
<div class="login-card" style="text-align:center;">
1056+
<div style="margin:0 auto 24px;width:fit-content;">
1057+
<i data-lucide="x-circle" style="width:48px;height:48px;color:var(--accent-red);"></i>
1058+
</div>
1059+
<h1 class="auth-title">Link expired</h1>
1060+
<p class="auth-subtitle">${escapeHtml(err.message)}</p>
1061+
<div style="margin-top:24px;">
1062+
<a href="/account/info" class="btn btn-primary">Back to Account</a>
1063+
</div>
1064+
</div>
1065+
</div>
1066+
</div>
1067+
`;
1068+
initIcons();
1069+
}
1070+
}
1071+
9131072
function renderVerificationSent(email) {
9141073
const app = $('#app');
9151074
app.innerHTML = html`
@@ -1472,6 +1631,13 @@ function navigateTo(page) {
14721631
history.replaceState({ page: 'verify-email' }, '', window.location.pathname + window.location.search);
14731632
return;
14741633
}
1634+
if (basePage === 'change-email') {
1635+
const params = new URLSearchParams(window.location.search);
1636+
const token = params.get('token');
1637+
renderChangeEmailVerify(token);
1638+
history.replaceState({ page: 'change-email' }, '', window.location.pathname + window.location.search);
1639+
return;
1640+
}
14751641

14761642
// Auth guard: require valid token for all other pages
14771643
if (!state.token) {
@@ -3013,21 +3179,13 @@ async function handleChangeEmail(e) {
30133179
showToast('Please fill in all fields', 'error');
30143180
return;
30153181
}
3016-
const data = await api('/auth/change-email', {
3182+
await api('/auth/change-email', {
30173183
method: 'POST',
30183184
body: JSON.stringify({ newEmail, password }),
30193185
});
3020-
state.token = data.token;
3021-
state.user = data.user;
3022-
localStorage.setItem('zh_token', data.token);
3023-
localStorage.setItem('zh_user', JSON.stringify(data.user));
3024-
$('#acc-new-email').placeholder = newEmail;
30253186
$('#acc-new-email').value = '';
30263187
$('#acc-email-pw').value = '';
3027-
showToast('Email updated successfully', 'success');
3028-
3029-
const sidebarImg = document.querySelector('#avatar-container img');
3030-
if (sidebarImg) sidebarImg.src = gravatarUrl(state.user.email, 32);
3188+
showToast('Confirmation link sent to your current email', 'success');
30313189
} catch (err) {
30323190
showToast(err.message, 'error');
30333191
} finally {
@@ -3588,6 +3746,13 @@ function init() {
35883746
return;
35893747
}
35903748

3749+
if (basePage === 'change-email') {
3750+
const params = new URLSearchParams(window.location.search);
3751+
const token = params.get('token');
3752+
renderChangeEmailVerify(token);
3753+
return;
3754+
}
3755+
35913756
if (basePage === 'login' || basePage === 'signup') {
35923757
if (state.token) {
35933758
history.replaceState({ page: 'overview' }, '', '/');

routes/auth.js

Lines changed: 85 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { generateToken, authenticateToken } from '../middleware/auth.js';
99
import { createPteroUser, updatePteroPassword, updatePteroEmail, deletePteroUser, getServersByUser, deletePteroServer } from '../services/pyrodactyl.js';
1010
import { verifyCap } from '../config/cap.js';
1111
import { logActivity } from '../services/activity.js';
12-
import { sendVerificationEmail } from '../services/email.js';
12+
import { sendVerificationEmail, sendEmailChangeLink, sendEmailChangeCode } from '../services/email.js';
1313

1414
const router = Router();
1515

@@ -491,7 +491,6 @@ router.post('/change-password', authenticateToken, sensitiveLimiter, async (req,
491491
router.post('/change-email', authenticateToken, sensitiveLimiter, async (req, res) => {
492492
try {
493493
const { newEmail, password } = req.body;
494-
const pteroId = req.user?.pteroId;
495494
const userId = req.user?.userId;
496495

497496
if (!newEmail || !password) {
@@ -513,34 +512,109 @@ router.post('/change-email', authenticateToken, sensitiveLimiter, async (req, re
513512

514513
const user = users[0];
515514

515+
if (newEmail === user.email) {
516+
return res.status(400).json({ error: 'New email is the same as current email' });
517+
}
518+
516519
const valid = await argon2.verify(user.password_hash, password, { type: argon2.argon2id });
517520
if (!valid) {
518521
return res.status(401).json({ error: 'Password is incorrect' });
519522
}
520523

521-
// Check if new email is already taken
522524
const existing = await query('SELECT id FROM users WHERE email = ? AND id != ?', [newEmail, userId]);
523525
if (existing.length > 0) {
524526
return res.status(409).json({ error: 'Email is already in use' });
525527
}
526528

527-
// Update Pyrodactyl
529+
const token = randomBytes(32).toString('hex');
530+
const expires = new Date(Date.now() + 60 * 60 * 1000);
531+
532+
await query(
533+
'UPDATE users SET pending_email = ?, email_change_token = ?, email_change_expires = ? WHERE id = ?',
534+
[newEmail, token, expires, userId]
535+
);
536+
537+
await sendEmailChangeLink(user.email, user.username, token, newEmail);
538+
539+
res.json({ message: 'Confirmation link sent to your current email' });
540+
} catch (err) {
541+
console.error('Change email initiate error:', err.message);
542+
res.status(500).json({ error: 'Failed to initiate email change' });
543+
}
544+
});
545+
546+
router.get('/change-email/verify', async (req, res) => {
547+
try {
548+
const { token } = req.query;
549+
550+
if (!token) {
551+
return res.status(400).json({ error: 'Token is required' });
552+
}
553+
554+
const users = await query(
555+
'SELECT * FROM users WHERE email_change_token = ? AND email_change_expires > NOW()',
556+
[token]
557+
);
558+
559+
if (users.length === 0) {
560+
return res.status(400).json({ error: 'Invalid or expired token' });
561+
}
562+
563+
const user = users[0];
564+
const code = String(Math.floor(100000 + Math.random() * 900000));
565+
const codeExpires = new Date(Date.now() + 10 * 60 * 1000);
566+
567+
await query(
568+
'UPDATE users SET email_change_code = ?, email_change_expires = ? WHERE id = ?',
569+
[code, codeExpires, user.id]
570+
);
571+
572+
await sendEmailChangeCode(user.pending_email, user.username, code);
573+
574+
res.json({ message: 'Verification code sent to your new email', pendingEmail: user.pending_email });
575+
} catch (err) {
576+
console.error('Change email verify error:', err.message);
577+
res.status(500).json({ error: 'Failed to verify email change' });
578+
}
579+
});
580+
581+
router.post('/change-email/confirm', authenticateToken, sensitiveLimiter, async (req, res) => {
582+
try {
583+
const { code } = req.body;
584+
const userId = req.user?.userId;
585+
586+
if (!code) {
587+
return res.status(400).json({ error: 'Verification code is required' });
588+
}
589+
590+
const users = await query(
591+
'SELECT * FROM users WHERE id = ? AND email_change_code = ? AND email_change_expires > NOW()',
592+
[userId, code]
593+
);
594+
595+
if (users.length === 0) {
596+
return res.status(400).json({ error: 'Invalid or expired code' });
597+
}
598+
599+
const user = users[0];
600+
const newEmail = user.pending_email;
601+
528602
try {
529-
await updatePteroEmail(pteroId, newEmail);
603+
await updatePteroEmail(user.ptero_user_id, newEmail);
530604
} catch (err) {
531605
console.error('Failed to update Pyrodactyl email:', err.message);
532606
return res.status(500).json({ error: 'Failed to update email on panel' });
533607
}
534608

535-
// Update local DB and invalidate existing sessions
536-
await query('UPDATE users SET email = ?, token_version = token_version + 1 WHERE id = ?', [newEmail, userId]);
609+
await query(
610+
'UPDATE users SET email = ?, pending_email = NULL, email_change_token = NULL, email_change_code = NULL, email_change_expires = NULL, token_version = token_version + 1 WHERE id = ?',
611+
[newEmail, userId]
612+
);
537613

538614
await logActivity(userId, 'email_changed', `Changed email to ${newEmail}`);
539615

540-
// Fetch updated token_version
541616
const [updatedUser] = await query('SELECT token_version FROM users WHERE id = ?', [userId]);
542617

543-
// Generate new token with updated email
544618
const token = generateToken({
545619
userId: user.id,
546620
email: newEmail,
@@ -566,8 +640,8 @@ router.post('/change-email', authenticateToken, sensitiveLimiter, async (req, re
566640
message: 'Email updated successfully',
567641
});
568642
} catch (err) {
569-
console.error('Change email error:', err.message);
570-
res.status(500).json({ error: 'Failed to change email' });
643+
console.error('Change email confirm error:', err.message);
644+
res.status(500).json({ error: 'Failed to confirm email change' });
571645
}
572646
});
573647

0 commit comments

Comments
 (0)