From 6308e33a5673e5bc79c9a6ef64fc64658296cf83 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Thu, 27 Nov 2025 19:16:09 +0100 Subject: [PATCH] feat(syncServer): add SMTP email verification for user registration --- packages/super-sync-server/.env.example | 8 + packages/super-sync-server/.gitignore | 1 + packages/super-sync-server/package.json | 3 + packages/super-sync-server/public/app.js | 136 +++++++++ packages/super-sync-server/public/index.html | 176 ++++++++++++ packages/super-sync-server/public/style.css | 281 +++++++++++++++++++ packages/super-sync-server/src/auth.ts | 9 +- packages/super-sync-server/src/config.ts | 20 ++ packages/super-sync-server/src/email.ts | 84 ++++++ packages/super-sync-server/src/server.ts | 16 ++ 10 files changed, 732 insertions(+), 2 deletions(-) create mode 100644 packages/super-sync-server/public/app.js create mode 100644 packages/super-sync-server/public/index.html create mode 100644 packages/super-sync-server/public/style.css create mode 100644 packages/super-sync-server/src/email.ts diff --git a/packages/super-sync-server/.env.example b/packages/super-sync-server/.env.example index b231be8660..26227f7e34 100644 --- a/packages/super-sync-server/.env.example +++ b/packages/super-sync-server/.env.example @@ -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 " diff --git a/packages/super-sync-server/.gitignore b/packages/super-sync-server/.gitignore index 2dd2f3ee83..011fe6b0e9 100644 --- a/packages/super-sync-server/.gitignore +++ b/packages/super-sync-server/.gitignore @@ -1,4 +1,5 @@ node_modules dist data +./.env .env diff --git a/packages/super-sync-server/package.json b/packages/super-sync-server/package.json index 50735e009d..50d951ccaa 100644 --- a/packages/super-sync-server/package.json +++ b/packages/super-sync-server/package.json @@ -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" } diff --git a/packages/super-sync-server/public/app.js b/packages/super-sync-server/public/app.js new file mode 100644 index 0000000000..aa430ff981 --- /dev/null +++ b/packages/super-sync-server/public/app.js @@ -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; + } + } +}); diff --git a/packages/super-sync-server/public/index.html b/packages/super-sync-server/public/index.html new file mode 100644 index 0000000000..5c5deeaad9 --- /dev/null +++ b/packages/super-sync-server/public/index.html @@ -0,0 +1,176 @@ + + + + + + SuperSync Server + + + + + + +
+ +
+
+ +
+ + Server Online +
+
+ +
+
+

Your Data.
Synchronized.

+

Secure, private, and fast synchronization for Super Productivity.

+
+ +
+
+ + +
+ +
+
+
+ + +
+
+ + +
+ +
+
+ + + + +
+
+ +
+

© 2025 Super Productivity. Open Source.

+
+
+ + + + diff --git a/packages/super-sync-server/public/style.css b/packages/super-sync-server/public/style.css new file mode 100644 index 0000000000..473f280472 --- /dev/null +++ b/packages/super-sync-server/public/style.css @@ -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; + } +} diff --git a/packages/super-sync-server/src/auth.ts b/packages/super-sync-server/src/auth.ts index c830aa0c06..b7478cea3c 100644 --- a/packages/super-sync-server/src/auth.ts +++ b/packages/super-sync-server/src/auth.ts @@ -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 }; }; diff --git a/packages/super-sync-server/src/config.ts b/packages/super-sync-server/src/config.ts index bb1577a8e0..46b782131b 100644 --- a/packages/super-sync-server/src/config.ts +++ b/packages/super-sync-server/src/config.ts @@ -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" ', + }; + } + // Validation if (!Number.isInteger(config.port) || config.port <= 0) { throw new Error(`Invalid port configuration: ${config.port}`); diff --git a/packages/super-sync-server/src/email.ts b/packages/super-sync-server/src/email.ts new file mode 100644 index 0000000000..1eba9d67c6 --- /dev/null +++ b/packages/super-sync-server/src/email.ts @@ -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" '; + + // 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: ` +
+

Welcome to SuperSync!

+

Please verify your account by clicking the button below:

+ Verify Email +

If the button doesn't work, copy and paste this link into your browser:

+

${verificationLink}

+

Token: ${token}

+
+ `, + }); + + 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; + } +}; diff --git a/packages/super-sync-server/src/server.ts b/packages/super-sync-server/src/server.ts index 0e87fb5aad..9d5ba6d9c7 100644 --- a/packages/super-sync-server/src/server.ts +++ b/packages/super-sync-server/src/server.ts @@ -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();