Skip to content

Commit df392a4

Browse files
committed
feat: real-time email/username availability check on account creation
1 parent bc2b654 commit df392a4

3 files changed

Lines changed: 193 additions & 2 deletions

File tree

public/css/style.css

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,45 @@ a:hover { color: var(--accent-1); }
340340
color: var(--text-muted);
341341
}
342342

343+
/* ===== REGISTER FIELD AVAILABILITY ===== */
344+
.input-wrap {
345+
position: relative;
346+
}
347+
348+
.input-wrap input {
349+
padding-right: 44px;
350+
}
351+
352+
.input-status {
353+
position: absolute;
354+
right: 14px;
355+
top: 50%;
356+
transform: translateY(-50%);
357+
display: flex;
358+
align-items: center;
359+
justify-content: center;
360+
pointer-events: none;
361+
}
362+
363+
.input-status.checking { color: var(--text-muted); }
364+
.input-status.ok { color: var(--accent-green); }
365+
.input-status.taken { color: var(--accent-red); }
366+
367+
.field-hint {
368+
font-size: 0.78rem;
369+
margin-top: 6px;
370+
min-height: 0;
371+
line-height: 1.3;
372+
color: var(--text-secondary);
373+
}
374+
375+
.field-hint:empty {
376+
margin-top: 0;
377+
}
378+
379+
.field-hint.ok { color: var(--accent-green); }
380+
.field-hint.taken { color: var(--accent-red); }
381+
343382
.form-group select {
344383
cursor: pointer;
345384
appearance: none;

public/js/app.js

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -705,11 +705,19 @@ function renderRegisterPage() {
705705
<div class="auth-error"></div>
706706
<div class="form-group">
707707
<label for="reg-email">Email</label>
708-
<input type="email" id="reg-email" placeholder="your@email.com" required autocomplete="email" />
708+
<div class="input-wrap">
709+
<input type="email" id="reg-email" placeholder="your@email.com" required autocomplete="email" />
710+
<span class="input-status" id="reg-email-status"></span>
711+
</div>
712+
<div class="field-hint" id="reg-email-hint"></div>
709713
</div>
710714
<div class="form-group">
711715
<label for="reg-username">Username</label>
712-
<input type="text" id="reg-username" placeholder="myusername" required autocomplete="username" />
716+
<div class="input-wrap">
717+
<input type="text" id="reg-username" placeholder="myusername" required autocomplete="username" />
718+
<span class="input-status" id="reg-username-status"></span>
719+
</div>
720+
<div class="field-hint" id="reg-username-hint"></div>
713721
</div>
714722
<div class="form-group">
715723
<label for="reg-password">Password</label>
@@ -742,6 +750,101 @@ function renderRegisterPage() {
742750
e.preventDefault();
743751
navigateTo('login');
744752
});
753+
754+
setupRegisterAvailabilityChecks();
755+
}
756+
757+
const registerAvailability = { email: null, username: null };
758+
let registerAvailabilityTimer = null;
759+
760+
function setFieldStatus(field, state, message) {
761+
const statusEl = $(`#reg-${field}-status`);
762+
const hintEl = $(`#reg-${field}-hint`);
763+
if (!statusEl) return;
764+
statusEl.className = `input-status ${state || ''}`;
765+
if (state === 'checking') {
766+
statusEl.innerHTML = '<span class="spinner" style="width:16px;height:16px;"></span>';
767+
} else if (state === 'ok') {
768+
statusEl.innerHTML = '<i data-lucide="check" style="width:18px;height:18px"></i>';
769+
} else if (state === 'taken') {
770+
statusEl.innerHTML = '<i data-lucide="x" style="width:18px;height:18px"></i>';
771+
} else {
772+
statusEl.innerHTML = '';
773+
}
774+
if (hintEl) {
775+
hintEl.textContent = message || '';
776+
hintEl.className = `field-hint ${state === 'ok' || state === 'taken' ? state : ''}`;
777+
}
778+
if (state === 'taken' || state === 'ok') initIcons();
779+
}
780+
781+
function setupRegisterAvailabilityChecks() {
782+
const emailInput = $('#reg-email');
783+
const usernameInput = $('#reg-username');
784+
if (!emailInput || !usernameInput) return;
785+
786+
const runCheck = async () => {
787+
const email = emailInput.value.trim();
788+
const username = usernameInput.value.trim();
789+
790+
if (!email && !username) {
791+
registerAvailability.email = null;
792+
registerAvailability.username = null;
793+
setFieldStatus('email', '');
794+
setFieldStatus('username', '');
795+
return;
796+
}
797+
798+
if (email && !validateRegEmail(email)) {
799+
setFieldStatus('email', '');
800+
registerAvailability.email = null;
801+
}
802+
if (username && !validateRegUsername(username)) {
803+
setFieldStatus('username', '');
804+
registerAvailability.username = null;
805+
}
806+
807+
setFieldStatus('email', email && validateRegEmail(email) ? 'checking' : '');
808+
setFieldStatus('username', username && validateRegUsername(username) ? 'checking' : '');
809+
810+
try {
811+
const params = new URLSearchParams();
812+
if (email && validateRegEmail(email)) params.set('email', email);
813+
if (username && validateRegUsername(username)) params.set('username', username);
814+
const data = await api(`/auth/check-availability?${params.toString()}`);
815+
if (email && validateRegEmail(email)) {
816+
const available = data.email?.available;
817+
registerAvailability.email = available;
818+
setFieldStatus('email', available ? 'ok' : 'taken', available ? 'Available' : 'This email is already taken');
819+
}
820+
if (username && validateRegUsername(username)) {
821+
const available = data.username?.available;
822+
registerAvailability.username = available;
823+
setFieldStatus('username', available ? 'ok' : 'taken', available ? 'Available' : 'This username is already taken');
824+
}
825+
} catch {
826+
setFieldStatus('email', '');
827+
setFieldStatus('username', '');
828+
}
829+
};
830+
831+
const onInput = () => {
832+
clearTimeout(registerAvailabilityTimer);
833+
registerAvailabilityTimer = setTimeout(runCheck, 400);
834+
};
835+
836+
emailInput.addEventListener('input', onInput);
837+
usernameInput.addEventListener('input', onInput);
838+
emailInput.addEventListener('blur', runCheck);
839+
usernameInput.addEventListener('blur', runCheck);
840+
}
841+
842+
function validateRegEmail(email) {
843+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
844+
}
845+
846+
function validateRegUsername(username) {
847+
return /^[a-zA-Z0-9_-]{3,32}$/.test(username);
745848
}
746849

747850
async function handleLogin(e) {
@@ -886,6 +989,15 @@ async function handleRegister(e) {
886989
return;
887990
}
888991

992+
if (registerAvailability.email === false) {
993+
showError(e.target, 'This email is already taken. Please choose another one.');
994+
return;
995+
}
996+
if (registerAvailability.username === false) {
997+
showError(e.target, 'This username is already taken. Please choose another one.');
998+
return;
999+
}
1000+
8891001
btn.disabled = true;
8901002
btn.innerHTML = '<span class="spinner"></span> Creating...';
8911003

routes/auth.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,46 @@ router.get('/check-vpn', async (req, res) => {
2424
}
2525
});
2626

27+
const availabilityLimiter = rateLimit({
28+
windowMs: 60 * 1000,
29+
max: 120,
30+
message: { error: 'Too many requests' },
31+
standardHeaders: true,
32+
legacyHeaders: false,
33+
});
34+
35+
router.get('/check-availability', availabilityLimiter, async (req, res) => {
36+
try {
37+
const email = typeof req.query.email === 'string' && req.query.email ? req.query.email : null;
38+
const username = typeof req.query.username === 'string' && req.query.username ? req.query.username : null;
39+
40+
if (!email && !username) {
41+
return res.json({});
42+
}
43+
44+
const clauses = [];
45+
const params = [];
46+
if (email) { clauses.push('email = ?'); params.push(email); }
47+
if (username) { clauses.push('username = ?'); params.push(username); }
48+
49+
const rows = await query(
50+
`SELECT email, username FROM users WHERE ${clauses.join(' OR ')}`,
51+
params
52+
);
53+
54+
const takenEmails = new Set(rows.map(r => r.email));
55+
const takenUsernames = new Set(rows.map(r => r.username));
56+
57+
const result = {};
58+
if (email) result.email = { available: !takenEmails.has(email) };
59+
if (username) result.username = { available: !takenUsernames.has(username) };
60+
61+
res.json(result);
62+
} catch {
63+
res.status(500).json({ error: 'Availability check failed' });
64+
}
65+
});
66+
2767
function gravatarHash(email) {
2868
return createHash('md5').update(email.trim().toLowerCase()).digest('hex');
2969
}

0 commit comments

Comments
 (0)