feat(syncServer): add SMTP email verification for user registration

This commit is contained in:
Johannes Millan 2025-11-27 19:16:09 +01:00
parent 93c76d5cdb
commit 6308e33a56
10 changed files with 732 additions and 2 deletions

View file

@ -23,3 +23,11 @@ CORS_ENABLED=true
# Allowed CORS origins (comma-separated, default: *)
# Use "*" to allow all origins, or specify domains like "https://example.com"
CORS_ORIGINS=*
# Email
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM="SuperSync <your-email@gmail.com>"

View file

@ -1,4 +1,5 @@
node_modules
dist
data
./.env
.env

View file

@ -10,11 +10,13 @@
},
"dependencies": {
"@fastify/cors": "^11.1.0",
"@fastify/static": "^8.3.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.4.6",
"dotenv": "^17.2.3",
"fastify": "^5.6.2",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^7.0.11",
"webdav-server": "^2.6.2"
},
"devDependencies": {
@ -22,6 +24,7 @@
"@types/better-sqlite3": "^7.6.13",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^18.0.0",
"@types/nodemailer": "^7.0.4",
"ts-node": "^10.9.1",
"typescript": "^5.0.0"
}

View file

@ -0,0 +1,136 @@
document.addEventListener('DOMContentLoaded', () => {
const tabs = document.querySelectorAll('.tab');
const loginForm = document.getElementById('login-form');
const registerForm = document.getElementById('register-form');
const messageArea = document.getElementById('message-area');
// Tab Switching
tabs.forEach((tab) => {
tab.addEventListener('click', () => {
tabs.forEach((t) => t.classList.remove('active'));
tab.classList.add('active');
const target = tab.dataset.tab;
if (target === 'login') {
loginForm.classList.remove('hidden');
registerForm.classList.add('hidden');
} else {
loginForm.classList.add('hidden');
registerForm.classList.remove('hidden');
}
clearMessage();
});
});
// Login Handler
document.getElementById('login').addEventListener('submit', async (e) => {
e.preventDefault();
setLoading(e.target, true);
clearMessage();
const email = document.getElementById('login-email').value;
const password = document.getElementById('login-password').value;
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (response.ok) {
showMessage(
'Login successful! You can now use these credentials in Super Productivity.',
'success',
);
// Optional: Store token if needed for future admin features
// localStorage.setItem('token', data.token);
} else {
showMessage(data.error || 'Login failed', 'error');
}
} catch (err) {
showMessage('Network error occurred', 'error');
} finally {
setLoading(e.target, false);
}
});
// Register Handler
document.getElementById('register').addEventListener('submit', async (e) => {
e.preventDefault();
setLoading(e.target, true);
clearMessage();
const email = document.getElementById('register-email').value;
const password = document.getElementById('register-password').value;
const confirmPassword = document.getElementById('register-confirm-password').value;
if (password !== confirmPassword) {
showMessage('Passwords do not match', 'error');
setLoading(e.target, false);
return;
}
if (password.length < 6) {
showMessage('Password must be at least 6 characters', 'error');
setLoading(e.target, false);
return;
}
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (response.ok) {
showMessage(
'Registration successful! Please check your server logs for the verification link (dev mode) or verify if email configured.',
'success',
);
// Switch to login tab after brief delay
setTimeout(() => {
tabs[0].click();
}, 2000);
} else {
showMessage(data.error || 'Registration failed', 'error');
}
} catch (err) {
showMessage('Network error occurred', 'error');
} finally {
setLoading(e.target, false);
}
});
function showMessage(text, type) {
messageArea.textContent = text;
messageArea.className = `message ${type}`;
messageArea.classList.remove('hidden');
}
function clearMessage() {
messageArea.classList.add('hidden');
messageArea.textContent = '';
}
function setLoading(form, isLoading) {
const btn = form.querySelector('button');
const text = btn.querySelector('.btn-text');
const loader = btn.querySelector('.loader');
if (isLoading) {
text.classList.add('hidden');
loader.classList.remove('hidden');
btn.disabled = true;
} else {
text.classList.remove('hidden');
loader.classList.add('hidden');
btn.disabled = false;
}
}
});

View file

@ -0,0 +1,176 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>SuperSync Server</title>
<link
rel="stylesheet"
href="style.css"
/>
<link
rel="preconnect"
href="https://fonts.googleapis.com"
/>
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossorigin
/>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div class="background-gradient"></div>
<div class="container">
<header>
<div class="logo">
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 4L12 20M12 4L8 8M12 4L16 8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4 12L20 12M4 12L8 8M4 12L8 16"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span>SuperSync Server</span>
</div>
<div class="status-indicator">
<span class="dot"></span>
<span class="text">Server Online</span>
</div>
</header>
<main>
<section class="hero">
<h1>Your Data.<br />Synchronized.</h1>
<p>Secure, private, and fast synchronization for Super Productivity.</p>
</section>
<section class="auth-card">
<div class="tabs">
<button
class="tab active"
data-tab="login"
>
Login
</button>
<button
class="tab"
data-tab="register"
>
Register
</button>
</div>
<div
class="form-container"
id="login-form"
>
<form id="login">
<div class="input-group">
<label for="login-email">Email</label>
<input
type="email"
id="login-email"
required
placeholder="you@example.com"
/>
</div>
<div class="input-group">
<label for="login-password">Password</label>
<input
type="password"
id="login-password"
required
placeholder="••••••••"
/>
</div>
<button
type="submit"
class="btn-primary"
>
<span class="btn-text">Log In</span>
<div class="loader hidden"></div>
</button>
</form>
</div>
<div
class="form-container hidden"
id="register-form"
>
<form id="register">
<div class="input-group">
<label for="register-email">Email</label>
<input
type="email"
id="register-email"
required
placeholder="you@example.com"
/>
</div>
<div class="input-group">
<label for="register-password">Password</label>
<input
type="password"
id="register-password"
required
placeholder="••••••••"
/>
<small>Minimum 6 characters</small>
</div>
<div class="input-group">
<label for="register-confirm-password">Confirm Password</label>
<input
type="password"
id="register-confirm-password"
required
placeholder="••••••••"
/>
</div>
<button
type="submit"
class="btn-primary"
>
<span class="btn-text">Create Account</span>
<div class="loader hidden"></div>
</button>
</form>
</div>
<div
id="message-area"
class="message hidden"
></div>
</section>
</main>
<footer>
<p>&copy; 2025 Super Productivity. Open Source.</p>
</footer>
</div>
<script src="app.js"></script>
</body>
</html>

View file

@ -0,0 +1,281 @@
:root {
--bg-color: #0f172a;
--text-color: #f8fafc;
--text-muted: #94a3b8;
--primary-color: #3b82f6;
--primary-hover: #2563eb;
--card-bg: rgba(30, 41, 59, 0.7);
--border-color: rgba(255, 255, 255, 0.1);
--success-color: #10b981;
--error-color: #ef4444;
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-family);
background-color: var(--bg-color);
color: var(--text-color);
min-height: 100vh;
display: flex;
justify-content: center;
overflow-x: hidden;
}
.background-gradient {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
radial-gradient(circle at 15% 50%, rgba(59, 130, 246, 0.15), transparent 25%),
radial-gradient(circle at 85% 30%, rgba(16, 185, 129, 0.15), transparent 25%);
z-index: -1;
}
.container {
width: 100%;
max-width: 1200px;
padding: 2rem;
display: flex;
flex-direction: column;
min-height: 100vh;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 4rem;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 700;
font-size: 1.25rem;
color: var(--text-color);
}
.logo svg {
color: var(--primary-color);
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--success-color);
background: rgba(16, 185, 129, 0.1);
padding: 0.5rem 1rem;
border-radius: 9999px;
border: 1px solid rgba(16, 185, 129, 0.2);
}
.dot {
width: 8px;
height: 8px;
background-color: var(--success-color);
border-radius: 50%;
box-shadow: 0 0 8px var(--success-color);
}
main {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
align-items: center;
}
.hero h1 {
font-size: 4rem;
line-height: 1.1;
font-weight: 800;
margin-bottom: 1.5rem;
background: linear-gradient(to right, #fff, #94a3b8);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.hero p {
font-size: 1.25rem;
color: var(--text-muted);
max-width: 400px;
}
.auth-card {
background: var(--card-bg);
backdrop-filter: blur(12px);
border: 1px solid var(--border-color);
border-radius: 1.5rem;
padding: 2rem;
width: 100%;
max-width: 400px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
margin-left: auto;
}
.tabs {
display: flex;
margin-bottom: 2rem;
background: rgba(0, 0, 0, 0.2);
padding: 0.25rem;
border-radius: 0.75rem;
}
.tab {
flex: 1;
background: transparent;
border: none;
color: var(--text-muted);
padding: 0.75rem;
font-family: inherit;
font-weight: 600;
cursor: pointer;
border-radius: 0.5rem;
transition: all 0.2s;
}
.tab.active {
background: rgba(255, 255, 255, 0.1);
color: var(--text-color);
}
.input-group {
margin-bottom: 1.5rem;
}
.input-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: var(--text-muted);
}
.input-group input {
width: 100%;
padding: 0.75rem 1rem;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
color: var(--text-color);
font-family: inherit;
transition: border-color 0.2s;
}
.input-group input:focus {
outline: none;
border-color: var(--primary-color);
}
.input-group small {
display: block;
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--text-muted);
}
.btn-primary {
width: 100%;
padding: 0.75rem;
background: var(--primary-color);
border: none;
border-radius: 0.5rem;
color: white;
font-family: inherit;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
display: flex;
justify-content: center;
align-items: center;
}
.btn-primary:hover {
background: var(--primary-hover);
}
.hidden {
display: none;
}
.message {
margin-top: 1rem;
padding: 0.75rem;
border-radius: 0.5rem;
font-size: 0.875rem;
text-align: center;
}
.message.success {
background: rgba(16, 185, 129, 0.2);
color: #34d399;
border: 1px solid rgba(16, 185, 129, 0.3);
}
.message.error {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.3);
}
footer {
text-align: center;
color: var(--text-muted);
font-size: 0.875rem;
padding-top: 4rem;
}
/* Loader */
.loader {
width: 20px;
height: 20px;
border: 2px solid #ffffff;
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
main {
grid-template-columns: 1fr;
gap: 2rem;
}
.hero {
text-align: center;
}
.hero h1 {
font-size: 3rem;
}
.hero p {
margin: 0 auto;
}
.auth-card {
margin: 0 auto;
}
}

View file

@ -3,6 +3,7 @@ import * as bcrypt from 'bcryptjs';
import * as jwt from 'jsonwebtoken';
import { Logger } from './logger';
import { randomBytes } from 'crypto';
import { sendVerificationEmail } from './email';
const getJwtSecret = (): string => {
const secret = process.env.JWT_SECRET;
@ -55,12 +56,16 @@ export const registerUser = (email: string, password: string) => {
.run(email, passwordHash, verificationToken);
Logger.info(`User registered: ${email} (ID: ${info.lastInsertRowid})`);
Logger.info(`Verification token for ${email}: ${verificationToken}`); // In real app, send email
// Send verification email asynchronously
sendVerificationEmail(email, verificationToken).catch((err) => {
Logger.error(`Failed to send verification email to ${email}:`, err);
});
return {
id: info.lastInsertRowid,
email,
verificationToken, // Return it for now since we don't have email sending
// verificationToken, // Don't return token in response for security, user must check email
};
};

View file

@ -7,6 +7,14 @@ export interface ServerConfig {
enabled: boolean;
allowedOrigins?: string[];
};
smtp?: {
host: string;
port: number;
secure: boolean;
user?: string;
pass?: string;
from: string;
};
}
const DEFAULT_CONFIG: ServerConfig = {
@ -67,6 +75,18 @@ export const loadConfigFromEnv = (
}
}
// SMTP Configuration
if (process.env.SMTP_HOST) {
config.smtp = {
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587', 10),
secure: process.env.SMTP_SECURE === 'true',
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
from: process.env.SMTP_FROM || '"SuperSync" <noreply@example.com>',
};
}
// Validation
if (!Number.isInteger(config.port) || config.port <= 0) {
throw new Error(`Invalid port configuration: ${config.port}`);

View file

@ -0,0 +1,84 @@
import * as nodemailer from 'nodemailer';
import { Logger } from './logger';
import { loadConfigFromEnv } from './config';
let transporter: nodemailer.Transporter | null = null;
const getTransporter = async () => {
if (transporter) return transporter;
const config = loadConfigFromEnv();
if (config.smtp) {
transporter = nodemailer.createTransport({
host: config.smtp.host,
port: config.smtp.port,
secure: config.smtp.secure,
auth: config.smtp.user
? {
user: config.smtp.user,
pass: config.smtp.pass,
}
: undefined,
});
Logger.info(`SMTP configured: ${config.smtp.host}:${config.smtp.port}`);
} else {
// Fallback to Ethereal for development if no SMTP config
Logger.warn('No SMTP configuration found. Using Ethereal Email for testing.');
const testAccount = await nodemailer.createTestAccount();
transporter = nodemailer.createTransport({
host: 'smtp.ethereal.email',
port: 587,
secure: false,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
});
Logger.info(`Ethereal Email configured: ${testAccount.user}`);
}
return transporter;
};
export const sendVerificationEmail = async (to: string, token: string) => {
try {
const transporter = await getTransporter();
const config = loadConfigFromEnv();
const from = config.smtp?.from || '"SuperSync" <noreply@example.com>';
// In a real app, this URL should be configurable
// For now, we assume the server is running on the same host
// or we can use a configured PUBLIC_URL
const verificationLink = `http://localhost:${config.port}/verify-email?token=${token}`;
const info = await transporter.sendMail({
from,
to,
subject: 'Verify your SuperSync account',
text: `Please verify your account by clicking the following link: ${verificationLink}\n\nToken: ${token}`,
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Welcome to SuperSync!</h2>
<p>Please verify your account by clicking the button below:</p>
<a href="${verificationLink}" style="display: inline-block; padding: 10px 20px; background-color: #3b82f6; color: white; text-decoration: none; border-radius: 5px;">Verify Email</a>
<p style="margin-top: 20px; font-size: 12px; color: #666;">If the button doesn't work, copy and paste this link into your browser:</p>
<p style="font-size: 12px; color: #666;">${verificationLink}</p>
<p style="font-size: 12px; color: #666;">Token: ${token}</p>
</div>
`,
});
Logger.info(`Verification email sent to ${to}: ${info.messageId}`);
// If using Ethereal, log the preview URL
if (nodemailer.getTestMessageUrl(info)) {
Logger.info(`Preview URL: ${nodemailer.getTestMessageUrl(info)}`);
}
return true;
} catch (err) {
Logger.error('Failed to send verification email:', err);
return false;
}
};

View file

@ -2,6 +2,8 @@ import { v2 as webdav } from 'webdav-server';
import * as fs from 'fs';
import Fastify, { FastifyInstance } from 'fastify';
import cors from '@fastify/cors';
import fastifyStatic from '@fastify/static';
import * as path from 'path';
import { loadConfigFromEnv, ServerConfig } from './config';
import { Logger } from './logger';
import { initDb } from './db';
@ -151,6 +153,12 @@ export const createServer = (
});
}
// Serve static files
await fastifyServer.register(fastifyStatic, {
root: path.join(__dirname, '../public'),
prefix: '/',
});
// API Routes
await fastifyServer.register(apiRoutes, { prefix: '/api' });
@ -162,6 +170,14 @@ export const createServer = (
return;
}
// Allow static files to be handled by Fastify
const staticFiles = ['/', '/index.html', '/style.css', '/app.js', '/favicon.ico'];
const urlPath = req.url.split('?')[0];
if (req.method === 'GET' && staticFiles.includes(urlPath)) {
done();
return;
}
// Bypass Fastify and let WebDAV server handle the raw request
reply.hijack();