diff --git a/backend/.env.example b/backend/.env.example index 58b7c46..b448618 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,160 +1,25 @@ -# ============================================================================== -# Tududi Environment Configuration -# ============================================================================== -# Copy this file to .env and update the values for your environment -# ============================================================================== - -# ============================================================================== -# Application Configuration -# ============================================================================== - -# Environment: production, development, or test NODE_ENV=development -# Server host and port HOST=0.0.0.0 PORT=3002 -# Frontend URL (for redirects and CORS) FRONTEND_URL=http://localhost:8080 +BACKEND_URL=http://localhost:3002 -# ============================================================================== -# User Configuration -# ============================================================================== - -# Default user credentials (used during initial setup) TUDUDI_USER_EMAIL=admin@example.com TUDUDI_USER_PASSWORD=change-me-to-secure-password - -# Session secret (generate with: openssl rand -hex 64) TUDUDI_SESSION_SECRET=your-random-64-character-hex-string-here -# ============================================================================== -# Database Configuration -# ============================================================================== - -# Custom database file location (optional) -# If not set, defaults to backend/db/{environment}.sqlite3 -# DB_FILE=/path/to/custom/database.sqlite3 - -# ============================================================================== -# CORS Configuration -# ============================================================================== - -# Comma-separated list of allowed origins for CORS -# If not set, defaults to localhost development URLs -# TUDUDI_ALLOWED_ORIGINS=https://yourdomain.com,http://localhost:8080,http://localhost:9292 - -# ============================================================================== -# File Upload Configuration -# ============================================================================== - -# Custom upload directory path (optional) -# If not set, defaults to backend/uploads -# TUDUDI_UPLOAD_PATH=/path/to/custom/uploads - -# ============================================================================== -# Email/SMTP Configuration -# ============================================================================== - -# Enable/disable email functionality ENABLE_EMAIL=false - -# SMTP server configuration EMAIL_SMTP_HOST=smtp.gmail.com EMAIL_SMTP_PORT=587 EMAIL_SMTP_SECURE=false - -# SMTP authentication EMAIL_SMTP_USERNAME=your-email@example.com EMAIL_SMTP_PASSWORD=your-app-password - -# Email sender information EMAIL_FROM_ADDRESS=noreply@example.com EMAIL_FROM_NAME=Tududi -# ============================================================================== -# Task Scheduler Configuration -# ============================================================================== +REGISTRATION_TOKEN_EXPIRY_HOURS=24 -# Disable the task scheduler (useful for development/testing) -# Set to 'true' to disable recurring task processing DISABLE_SCHEDULER=false - -# ============================================================================== -# Telegram Bot Configuration -# ============================================================================== - -# Disable Telegram integration (useful for development/testing) -# Set to 'true' to disable Telegram bot functionality DISABLE_TELEGRAM=false - -# Telegram bot token (get from @BotFather on Telegram) -# TELEGRAM_BOT_TOKEN=your-telegram-bot-token - -# ============================================================================== -# API Documentation (Swagger) -# ============================================================================== - -# Enable/disable Swagger API documentation -# Default: enabled in all environments, protected by user authentication -# Set to 'false' to disable API documentation -# SWAGGER_ENABLED=true - -# ============================================================================== -# API Versioning -# ============================================================================== - -# API version (e.g., v1, v2) -# If not set, defaults to 'v1' -# API_VERSION=v1 - -# ============================================================================== -# Rate Limiting Configuration -# ============================================================================== -# Rate limiting helps prevent abuse and brute force attacks -# All time windows are in milliseconds -# Set RATE_LIMITING_ENABLED=false to completely disable rate limiting - -# Enable/disable rate limiting globally -# Automatically disabled in test environment -# RATE_LIMITING_ENABLED=true - -# Authentication endpoints (login, register) -# Default: 5 requests per 15 minutes -# RATE_LIMIT_AUTH_WINDOW_MS=900000 -# RATE_LIMIT_AUTH_MAX=5 - -# General API for unauthenticated requests -# Default: 100 requests per 15 minutes -# RATE_LIMIT_API_WINDOW_MS=900000 -# RATE_LIMIT_API_MAX=100 - -# Authenticated API requests -# Default: 1000 requests per 15 minutes -# RATE_LIMIT_AUTH_API_WINDOW_MS=900000 -# RATE_LIMIT_AUTH_API_MAX=1000 - -# Resource creation endpoints (POST requests) -# Default: 50 requests per 15 minutes -# RATE_LIMIT_CREATE_WINDOW_MS=900000 -# RATE_LIMIT_CREATE_MAX=50 - -# API key management endpoints -# Default: 10 requests per hour -# RATE_LIMIT_API_KEY_WINDOW_MS=3600000 -# RATE_LIMIT_API_KEY_MAX=10 - -# ============================================================================== -# Production Security Notes -# ============================================================================== -# When deploying to production, make sure to: -# 1. Change NODE_ENV to 'production' -# 2. Use strong, randomly generated TUDUDI_SESSION_SECRET -# 3. Use strong TUDUDI_USER_PASSWORD -# 4. Set proper TUDUDI_ALLOWED_ORIGINS for your domain -# 5. Enable HTTPS and set EMAIL_SMTP_SECURE=true if using TLS -# 6. Keep email passwords and API tokens secure -# 7. Consider adjusting rate limits based on your traffic patterns -# 8. Regularly update dependencies and review security advisories -# ============================================================================== diff --git a/backend/config/config.js b/backend/config/config.js index 5e42c15..aa563d0 100644 --- a/backend/config/config.js +++ b/backend/config/config.js @@ -27,6 +27,31 @@ const credentials = { const defaultHost = environment === 'test' ? '127.0.0.1' : '0.0.0.0'; +const emailConfig = { + enabled: process.env.ENABLE_EMAIL === 'true', + smtp: { + host: process.env.EMAIL_SMTP_HOST, + port: process.env.EMAIL_SMTP_PORT + ? parseInt(process.env.EMAIL_SMTP_PORT, 10) + : 587, + secure: process.env.EMAIL_SMTP_SECURE === 'true', + auth: { + user: process.env.EMAIL_SMTP_USERNAME, + pass: process.env.EMAIL_SMTP_PASSWORD, + }, + }, + from: { + address: process.env.EMAIL_FROM_ADDRESS, + name: process.env.EMAIL_FROM_NAME || 'Tududi', + }, +}; + +const registrationConfig = { + tokenExpiryHours: process.env.REGISTRATION_TOKEN_EXPIRY_HOURS + ? parseInt(process.env.REGISTRATION_TOKEN_EXPIRY_HOURS, 10) + : 24, +}; + const config = { allowedOrigins: process.env.TUDUDI_ALLOWED_ORIGINS ? process.env.TUDUDI_ALLOWED_ORIGINS.split(',').map((origin) => @@ -53,6 +78,8 @@ const config = { frontendUrl: process.env.FRONTEND_URL || 'http://localhost:8080', + backendUrl: process.env.BACKEND_URL || 'http://localhost:3002', + // Some CI/sandbox environments disallow binding to 0.0.0.0, so force // loopback for tests unless HOST is explicitly provided. host: process.env.HOST || defaultHost, @@ -69,6 +96,10 @@ const config = { credentials, + emailConfig, + + registrationConfig, + uploadPath: process.env.TUDUDI_UPLOAD_PATH || path.join(projectRootPath, 'uploads'), diff --git a/backend/migrations/20251017000000-add-email-verification-to-users.js b/backend/migrations/20251017000000-add-email-verification-to-users.js new file mode 100644 index 0000000..6e33f7c --- /dev/null +++ b/backend/migrations/20251017000000-add-email-verification-to-users.js @@ -0,0 +1,51 @@ +'use strict'; + +const { safeAddColumns } = require('../utils/migration-utils'); + +module.exports = { + async up(queryInterface, Sequelize) { + await safeAddColumns(queryInterface, 'users', [ + { + name: 'email_verified', + definition: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, // Existing users are considered verified + }, + }, + { + name: 'email_verification_token', + definition: { + type: Sequelize.STRING, + allowNull: true, + }, + }, + { + name: 'email_verification_token_expires_at', + definition: { + type: Sequelize.DATE, + allowNull: true, + }, + }, + ]); + + // Add index on verification token for faster lookups + await queryInterface.addIndex('users', ['email_verification_token'], { + name: 'users_email_verification_token_idx', + unique: false, + }); + }, + + async down(queryInterface) { + await queryInterface.removeIndex( + 'users', + 'users_email_verification_token_idx' + ); + await queryInterface.removeColumn('users', 'email_verified'); + await queryInterface.removeColumn('users', 'email_verification_token'); + await queryInterface.removeColumn( + 'users', + 'email_verification_token_expires_at' + ); + }, +}; diff --git a/backend/migrations/20251019000000-create-settings.js b/backend/migrations/20251019000000-create-settings.js new file mode 100644 index 0000000..8f28ec6 --- /dev/null +++ b/backend/migrations/20251019000000-create-settings.js @@ -0,0 +1,51 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('settings', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + key: { + type: Sequelize.STRING, + allowNull: false, + unique: true, + }, + value: { + type: Sequelize.TEXT, + allowNull: false, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + }, + }); + + // Add unique index on key column + await queryInterface.addIndex('settings', ['key'], { + name: 'settings_key_idx', + unique: true, + }); + + // Seed initial registration_enabled setting with default value of false + await queryInterface.bulkInsert('settings', [ + { + key: 'registration_enabled', + value: 'false', + created_at: new Date(), + updated_at: new Date(), + }, + ]); + }, + + async down(queryInterface) { + await queryInterface.removeIndex('settings', 'settings_key_idx'); + await queryInterface.dropTable('settings'); + }, +}; diff --git a/backend/models/index.js b/backend/models/index.js index 1239bee..87ebdd1 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -34,6 +34,7 @@ const Action = require('./action')(sequelize); const Permission = require('./permission')(sequelize); const View = require('./view')(sequelize); const ApiToken = require('./api_token')(sequelize); +const Setting = require('./setting')(sequelize); // Define associations User.hasMany(Area, { foreignKey: 'user_id' }); @@ -158,4 +159,5 @@ module.exports = { Permission, View, ApiToken, + Setting, }; diff --git a/backend/models/setting.js b/backend/models/setting.js new file mode 100644 index 0000000..f263eb8 --- /dev/null +++ b/backend/models/setting.js @@ -0,0 +1,30 @@ +const { DataTypes } = require('sequelize'); + +module.exports = (sequelize) => { + const Setting = sequelize.define( + 'Setting', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + key: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + value: { + type: DataTypes.TEXT, + allowNull: false, + }, + }, + { + tableName: 'settings', + timestamps: true, + underscored: true, + } + ); + + return Setting; +}; diff --git a/backend/models/user.js b/backend/models/user.js index e20857c..ae3751d 100644 --- a/backend/models/user.js +++ b/backend/models/user.js @@ -176,6 +176,19 @@ module.exports = (sequelize) => { }, }, }, + email_verified: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + email_verification_token: { + type: DataTypes.STRING, + allowNull: true, + }, + email_verification_token_expires_at: { + type: DataTypes.DATE, + allowNull: true, + }, }, { tableName: 'users', diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 93386e3..27e8316 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -267,3 +267,25 @@ router.delete('/admin/users/:id', requireAdmin, async (req, res) => { }); } }); + +// POST /api/admin/toggle-registration - toggle registration setting +router.post('/admin/toggle-registration', requireAdmin, async (req, res) => { + try { + const { enabled } = req.body; + if (typeof enabled !== 'boolean') { + return res + .status(400) + .json({ error: 'enabled must be a boolean value' }); + } + + const { + setRegistrationEnabled, + } = require('../services/registrationService'); + await setRegistrationEnabled(enabled); + + res.json({ enabled }); + } catch (err) { + logError('Error toggling registration:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 9d550c8..c39b133 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -2,6 +2,13 @@ const express = require('express'); const { User } = require('../models'); const { isAdmin } = require('../services/rolesService'); const { logError } = require('../services/logService'); +const { getConfig } = require('../config/config'); +const { + isRegistrationEnabled, + createUnverifiedUser, + sendVerificationEmail, + verifyUserEmail, +} = require('../services/registrationService'); const packageJson = require('../../package.json'); const { authLimiter } = require('../middleware/rateLimiter'); const router = express.Router(); @@ -10,6 +17,112 @@ router.get('/version', (req, res) => { res.json({ version: packageJson.version }); }); +// Get registration status +router.get('/registration-status', async (req, res) => { + res.json({ enabled: await isRegistrationEnabled() }); +}); + +// Register new user +router.post('/register', async (req, res) => { + const { sequelize } = require('../models'); + const transaction = await sequelize.transaction(); + + try { + if (!(await isRegistrationEnabled())) { + await transaction.rollback(); + return res + .status(404) + .json({ error: 'Registration is not enabled' }); + } + + const { email, password } = req.body; + + if (!email || !password) { + await transaction.rollback(); + return res + .status(400) + .json({ error: 'Email and password are required' }); + } + + const { user, verificationToken } = await createUnverifiedUser( + email, + password, + transaction + ); + + const emailResult = await sendVerificationEmail( + user, + verificationToken + ); + + if (!emailResult.success) { + await transaction.rollback(); + logError( + new Error(emailResult.reason), + 'Email sending failed during registration, rolling back user creation' + ); + return res.status(500).json({ + error: 'Failed to send verification email. Please try again later.', + }); + } + + await transaction.commit(); + + res.status(201).json({ + message: + 'Registration successful. Please check your email to verify your account.', + }); + } catch (error) { + await transaction.rollback(); + + if (error.message === 'Email already registered') { + return res.status(400).json({ error: error.message }); + } + if ( + error.message === 'Invalid email format' || + error.message === 'Password must be at least 6 characters long' + ) { + return res.status(400).json({ error: error.message }); + } + logError('Registration error:', error); + res.status(500).json({ + error: 'Registration failed. Please try again.', + }); + } +}); + +// Verify email +router.get('/verify-email', async (req, res) => { + try { + const { token } = req.query; + + if (!token) { + return res + .status(400) + .json({ error: 'Verification token is required' }); + } + + await verifyUserEmail(token); + + const config = getConfig(); + res.redirect(`${config.frontendUrl}/login?verified=true`); + } catch (error) { + const config = getConfig(); + let errorParam = 'invalid'; + + if (error.message === 'Email already verified') { + errorParam = 'already_verified'; + } else if (error.message === 'Verification token has expired') { + errorParam = 'expired'; + } + + logError('Email verification error:', error); + res.redirect( + `${config.frontendUrl}/login?verified=false&error=${errorParam}` + ); + } +}); + router.get('/current_user', async (req, res) => { try { if (req.session && req.session.userId) { @@ -71,6 +184,13 @@ router.post('/login', authLimiter, async (req, res) => { return res.status(401).json({ errors: ['Invalid credentials'] }); } + if (!user.email_verified) { + return res.status(403).json({ + error: 'Please verify your email address before logging in.', + email_not_verified: true, + }); + } + req.session.userId = user.id; await new Promise((resolve, reject) => { diff --git a/backend/services/emailService.js b/backend/services/emailService.js new file mode 100644 index 0000000..a5a9b70 --- /dev/null +++ b/backend/services/emailService.js @@ -0,0 +1,143 @@ +const nodemailer = require('nodemailer'); +const { getConfig } = require('../config/config'); +const { logError, logInfo } = require('./logService'); + +let transporter = null; + +const isEmailEnabled = () => { + const config = getConfig(); + return config.emailConfig.enabled; +}; + +const hasValidEmailConfig = () => { + const config = getConfig(); + const { smtp, from } = config.emailConfig; + return !!(smtp.host && smtp.auth.user && smtp.auth.pass && from.address); +}; + +const createTransporter = () => { + if (!isEmailEnabled()) { + return null; + } + + if (!hasValidEmailConfig()) { + logError( + new Error( + 'Email is enabled but configuration is incomplete. Email service will not function.' + ) + ); + return null; + } + + const config = getConfig(); + const { smtp } = config.emailConfig; + + try { + return nodemailer.createTransport({ + host: smtp.host, + port: smtp.port, + secure: smtp.secure, + auth: { + user: smtp.auth.user, + pass: smtp.auth.pass, + }, + }); + } catch (error) { + logError(error, 'Failed to create email transporter'); + return null; + } +}; + +const initializeEmailService = () => { + if (!isEmailEnabled()) { + logInfo('Email service is disabled'); + return; + } + + transporter = createTransporter(); + + if (transporter) { + logInfo('Email service initialized successfully'); + } +}; + +const sendEmail = async ({ to, subject, text, html }) => { + if (!isEmailEnabled()) { + logInfo( + `Email would be sent to ${to} with subject: "${subject}" (email service is disabled)` + ); + return { success: false, reason: 'Email service is disabled' }; + } + + if (!transporter) { + return { success: false, reason: 'Email transporter not initialized' }; + } + + if (!to || !subject) { + return { + success: false, + reason: 'Missing required fields: to, subject', + }; + } + + if (!text && !html) { + return { + success: false, + reason: 'Either text or html content is required', + }; + } + + const config = getConfig(); + const { from } = config.emailConfig; + + const mailOptions = { + from: from.name ? `"${from.name}" <${from.address}>` : from.address, + to, + subject, + text, + html, + }; + + try { + const info = await transporter.sendMail(mailOptions); + return { + success: true, + messageId: info.messageId, + }; + } catch (error) { + logError(error, `Failed to send email to ${to}`); + return { + success: false, + reason: error.message, + }; + } +}; + +const verifyEmailConnection = async () => { + if (!isEmailEnabled()) { + return { success: false, reason: 'Email service is disabled' }; + } + + if (!transporter) { + return { success: false, reason: 'Email transporter not initialized' }; + } + + try { + await transporter.verify(); + return { success: true }; + } catch (error) { + logError(error, 'Email connection verification failed'); + return { + success: false, + reason: error.message, + }; + } +}; + +module.exports = { + initializeEmailService, + sendEmail, + verifyEmailConnection, + isEmailEnabled, + hasValidEmailConfig, +}; diff --git a/backend/services/registrationService.js b/backend/services/registrationService.js new file mode 100644 index 0000000..bd75739 --- /dev/null +++ b/backend/services/registrationService.js @@ -0,0 +1,238 @@ +const crypto = require('crypto'); +const { User, Setting } = require('../models'); +const { getConfig } = require('../config/config'); +const { logError, logInfo } = require('./logService'); +const { sendEmail } = require('./emailService'); +const { validateEmail, validatePassword } = require('./userService'); + +const isRegistrationEnabled = async () => { + const setting = await Setting.findOne({ + where: { key: 'registration_enabled' }, + }); + // Default to false if setting doesn't exist + return setting ? setting.value === 'true' : false; +}; + +const setRegistrationEnabled = async (enabled) => { + await Setting.upsert({ + key: 'registration_enabled', + value: String(enabled), + }); + logInfo(`Registration ${enabled ? 'enabled' : 'disabled'} by admin`); +}; + +const generateVerificationToken = () => { + return crypto.randomBytes(32).toString('hex'); +}; + +const getTokenExpirationDate = () => { + const config = getConfig(); + const hours = config.registrationConfig.tokenExpiryHours; + const expirationDate = new Date(); + expirationDate.setHours(expirationDate.getHours() + hours); + return expirationDate; +}; + +const createUnverifiedUser = async (email, password, transaction = null) => { + if (!validateEmail(email)) { + throw new Error('Invalid email format'); + } + + if (!validatePassword(password)) { + throw new Error('Password must be at least 6 characters long'); + } + + const existingUser = await User.findOne({ + where: { email }, + transaction, + }); + if (existingUser) { + throw new Error('Email already registered'); + } + + const verificationToken = generateVerificationToken(); + const tokenExpiresAt = getTokenExpirationDate(); + + const user = await User.create( + { + email, + password, + email_verified: false, + email_verification_token: verificationToken, + email_verification_token_expires_at: tokenExpiresAt, + }, + { transaction } + ); + + return { + user, + verificationToken, + tokenExpiresAt, + }; +}; + +const isVerificationTokenValid = (token, expiresAt) => { + if (!token || !expiresAt) { + return false; + } + + const now = new Date(); + const expiration = new Date(expiresAt); + return now <= expiration; +}; + +const verifyUserEmail = async (token) => { + if (!token) { + throw new Error('Verification token is required'); + } + + const user = await User.findOne({ + where: { email_verification_token: token }, + }); + + if (!user) { + throw new Error('Invalid verification token'); + } + + if (user.email_verified) { + throw new Error('Email already verified'); + } + + if ( + !isVerificationTokenValid( + token, + user.email_verification_token_expires_at + ) + ) { + throw new Error('Verification token has expired'); + } + + user.email_verified = true; + user.email_verification_token = null; + user.email_verification_token_expires_at = null; + await user.save(); + + return user; +}; + +const sendVerificationEmail = async (user, verificationToken) => { + const config = getConfig(); + const { isEmailEnabled } = require('./emailService'); + + if (!isEmailEnabled()) { + logInfo( + `Email service is disabled. Verification email for ${user.email} not sent. User must be verified manually.` + ); + return { success: false, reason: 'Email service is disabled' }; + } + + const verificationUrl = `${config.backendUrl}/api/verify-email?token=${verificationToken}`; + const tokenExpiryHours = config.registrationConfig.tokenExpiryHours; + + const subject = 'Welcome to Tududi - Verify your email'; + + const text = `Welcome to Tududi! + +Thank you for registering. To complete your registration and start using Tududi, please verify your email address by clicking the link below: + +${verificationUrl} + +This verification link will expire in ${tokenExpiryHours} hours. + +After verification, you'll be able to: +- Create and organize tasks with ease +- Manage projects and areas +- Set up recurring tasks +- Track your productivity + +If you didn't create an account with Tududi, you can safely ignore this email. + +Best regards, +The Tududi Team`; + + const html = ` +

Welcome to Tududi!

+ +

Thank you for registering. To complete your registration and start using Tududi, please verify your email address by clicking the button below:

+ +

+ Verify Email Address +

+ +

Or copy and paste this link into your browser:

+

${verificationUrl}

+ +

This verification link will expire in ${tokenExpiryHours} hours.

+ +

After verification, you'll be able to:

+ + +

If you didn't create an account with Tududi, you can safely ignore this email.

+ +

Best regards,
The Tududi Team

+`; + + const result = await sendEmail({ + to: user.email, + subject, + text, + html, + }); + + if (result.success) { + logInfo(`Verification email sent to ${user.email}`); + return result; + } else { + logError( + new Error(result.reason), + `Failed to send verification email to ${user.email}` + ); + return result; + } +}; + +const cleanupExpiredTokens = async () => { + const now = new Date(); + + try { + const result = await User.update( + { + email_verification_token: null, + email_verification_token_expires_at: null, + }, + { + where: { + email_verified: false, + email_verification_token_expires_at: { + [require('sequelize').Op.lt]: now, + }, + }, + } + ); + + if (result[0] > 0) { + logInfo(`Cleaned up ${result[0]} expired verification tokens`); + } + + return result[0]; + } catch (error) { + logError(error, 'Failed to cleanup expired tokens'); + return 0; + } +}; + +module.exports = { + isRegistrationEnabled, + setRegistrationEnabled, + generateVerificationToken, + createUnverifiedUser, + verifyUserEmail, + sendVerificationEmail, + isVerificationTokenValid, + cleanupExpiredTokens, +}; diff --git a/backend/services/taskScheduler.js b/backend/services/taskScheduler.js index 5498094..6967d03 100644 --- a/backend/services/taskScheduler.js +++ b/backend/services/taskScheduler.js @@ -35,6 +35,7 @@ const getCronExpression = (frequency) => { '8h': '0 */8 * * *', '12h': '0 */12 * * *', recurring_tasks: '0 6 * * *', // Daily at 6 AM for recurring task generation + cleanup_tokens: '0 2 * * *', // Daily at 2 AM for cleaning up expired tokens }; return expressions[frequency]; }; @@ -43,6 +44,8 @@ const getCronExpression = (frequency) => { const createJobHandler = (frequency) => async () => { if (frequency === 'recurring_tasks') { await processRecurringTasks(); + } else if (frequency === 'cleanup_tokens') { + await cleanupExpiredTokens(); } else { await processSummariesForFrequency(frequency); } @@ -60,6 +63,7 @@ const createJobEntries = () => { '8h', '12h', 'recurring_tasks', + 'cleanup_tokens', ]; return frequencies.map((frequency) => { @@ -134,6 +138,19 @@ const processRecurringTasks = async () => { } }; +// Function to cleanup expired verification tokens (contains side effects) +const cleanupExpiredTokens = async () => { + try { + const { + cleanupExpiredTokens: cleanup, + } = require('./registrationService'); + const count = await cleanup(); + return count; + } catch (error) { + throw error; + } +}; + // Function to initialize scheduler (contains side effects) const initialize = async () => { if (schedulerState.isInitialized) { @@ -196,6 +213,7 @@ module.exports = { getStatus, processSummariesForFrequency, processRecurringTasks, + cleanupExpiredTokens, // For testing _createSchedulerState: createSchedulerState, _shouldDisableScheduler: shouldDisableScheduler, diff --git a/frontend/App.tsx b/frontend/App.tsx index 3389e92..256093d 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -2,8 +2,9 @@ import React, { useEffect, useState, Suspense, lazy } from 'react'; import { Routes, Route, Navigate, Outlet } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import Login from './components/Login'; +import Register from './components/Register'; import NotFound from './components/Shared/NotFound'; -import ProjectDetails from './components/Project/ProjectDetails.tsx'; +import ProjectDetails from './components/Project/ProjectDetails'; import Projects from './components/Projects'; import AreaDetails from './components/Area/AreaDetails'; import Areas from './components/Areas'; @@ -290,6 +291,7 @@ const App: React.FC = () => { ) : ( <> } /> + } /> } diff --git a/frontend/components/Admin/AdminUsersPage.tsx b/frontend/components/Admin/AdminUsersPage.tsx index 87909fc..e0e81d3 100644 --- a/frontend/components/Admin/AdminUsersPage.tsx +++ b/frontend/components/Admin/AdminUsersPage.tsx @@ -437,8 +437,62 @@ const AdminUsersPage: React.FC = () => { const [userToDelete, setUserToDelete] = useState( null ); + const [registrationEnabled, setRegistrationEnabled] = useState(false); + const [registrationLoading, setRegistrationLoading] = useState(true); const navigate = useNavigate(); + // Fetch registration status + useEffect(() => { + const fetchRegistrationStatus = async () => { + try { + const res = await fetch(getApiPath('registration-status'), { + credentials: 'include', + }); + if (res.ok) { + const data = await res.json(); + setRegistrationEnabled(data.enabled); + } + } catch (err) { + console.error('Error fetching registration status:', err); + } finally { + setRegistrationLoading(false); + } + }; + fetchRegistrationStatus(); + }, []); + + // Toggle registration + const toggleRegistration = async () => { + try { + const res = await fetch(getApiPath('admin/toggle-registration'), { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ enabled: !registrationEnabled }), + }); + if (res.ok) { + const data = await res.json(); + setRegistrationEnabled(data.enabled); + } else { + setError( + t( + 'admin.failedToToggleRegistration', + 'Failed to toggle registration' + ) + ); + } + } catch { + setError( + t( + 'admin.failedToToggleRegistration', + 'Failed to toggle registration' + ) + ); + } + }; + const load = async () => { setLoading(true); setError(null); @@ -497,6 +551,43 @@ const AdminUsersPage: React.FC = () => { + {/* Registration Toggle */} +
+
+
+

+ {t( + 'admin.userRegistration', + 'User Registration' + )} +

+

+ {t( + 'admin.registrationDescription', + 'Allow new users to register via email verification' + )} +

+
+ +
+
+ {error && (
{error} diff --git a/frontend/components/Login.tsx b/frontend/components/Login.tsx index 4d404dc..63e1537 100644 --- a/frontend/components/Login.tsx +++ b/frontend/components/Login.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams, Link } from 'react-router-dom'; import i18n from 'i18next'; import { useTranslation } from 'react-i18next'; import { getApiPath, getAssetPath } from '../config/paths'; @@ -8,8 +8,11 @@ const Login: React.FC = () => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [registrationEnabled, setRegistrationEnabled] = useState(false); const navigate = useNavigate(); const { t } = useTranslation(); + const [searchParams] = useSearchParams(); const [isDarkMode] = useState(() => { const storedPreference = localStorage.getItem('isDarkMode'); return storedPreference !== null @@ -21,6 +24,65 @@ const Login: React.FC = () => { document.documentElement.classList.toggle('dark', isDarkMode); }, [isDarkMode]); + // Check for verification status in URL params + useEffect(() => { + const verified = searchParams.get('verified'); + const verifyError = searchParams.get('error'); + + if (verified === 'true') { + setSuccessMessage( + t( + 'auth.email_verified', + 'Your email has been verified! You can now log in.' + ) + ); + } else if (verified === 'false') { + if (verifyError === 'expired') { + setError( + t( + 'auth.verification_expired', + 'Verification link has expired. Please register again.' + ) + ); + } else if (verifyError === 'already_verified') { + setSuccessMessage( + t( + 'auth.already_verified', + 'Your email is already verified. You can log in.' + ) + ); + } else { + setError( + t( + 'auth.verification_failed', + 'Email verification failed. Please try again.' + ) + ); + } + } + }, [searchParams, t]); + + // Check if registration is enabled + useEffect(() => { + const checkRegistration = async () => { + try { + const response = await fetch( + getApiPath('registration-status'), + { + credentials: 'include', + } + ); + if (response.ok) { + const data = await response.json(); + setRegistrationEnabled(data.enabled); + } + } catch (err) { + console.error('Error checking registration status:', err); + } + }; + checkRegistration(); + }, []); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -47,7 +109,20 @@ const Login: React.FC = () => { navigate('/today'); } else { - setError(data.errors[0] || 'Login failed. Please try again.'); + if (data.email_not_verified) { + setError( + t( + 'auth.email_not_verified', + 'Please verify your email address before logging in.' + ) + ); + } else { + setError( + data.error || + data.errors?.[0] || + 'Login failed. Please try again.' + ); + } } } catch (err) { setError('An error occurred. Please try again.'); @@ -81,6 +156,11 @@ const Login: React.FC = () => {

{t('auth.login', 'Login')}

+ {successMessage && ( +
+ {successMessage} +
+ )} {error && (
{error} @@ -132,6 +212,20 @@ const Login: React.FC = () => { {t('auth.login', 'Login')} + {registrationEnabled && ( +
+ {t( + 'auth.no_account', + "Don't have an account?" + )}{' '} + + {t('auth.sign_up', 'Sign Up')} + +
+ )}
diff --git a/frontend/components/Note/NoteModal.tsx b/frontend/components/Note/NoteModal.tsx index 74d46af..4139288 100644 --- a/frontend/components/Note/NoteModal.tsx +++ b/frontend/components/Note/NoteModal.tsx @@ -326,9 +326,8 @@ const NoteModal: React.FC = ({ [] ); - const handleProjectSearch = (e: React.ChangeEvent) => { - const value = e.target.value; - setNewProjectName(value); + const handleProjectSearch = (query: string) => { + setNewProjectName(query); setDropdownOpen(true); if (!projects || projects.length === 0) { @@ -336,14 +335,14 @@ const NoteModal: React.FC = ({ return; } - const query = value.toLowerCase(); + const searchQuery = query.toLowerCase(); const filtered = projects.filter((project) => - project.name.toLowerCase().includes(query) + project.name.toLowerCase().includes(searchQuery) ); setFilteredProjects(filtered); // If the user clears the project name, also clear the project_id in form data - if (value.trim() === '') { + if (query.trim() === '') { setFormData((prev) => ({ ...prev, project_id: null })); } }; @@ -358,11 +357,11 @@ const NoteModal: React.FC = ({ setDropdownOpen(false); }; - const handleCreateProject = async () => { - if (newProjectName.trim() !== '' && onCreateProject) { + const handleCreateProject = async (name: string) => { + if (name.trim() !== '' && onCreateProject) { setIsCreatingProject(true); try { - const newProject = await onCreateProject(newProjectName.trim()); + const newProject = await onCreateProject(name.trim()); setFormData((prev) => ({ ...prev, project: { diff --git a/frontend/components/Project/useProjectMetrics.ts b/frontend/components/Project/useProjectMetrics.ts index 298af33..41e7757 100644 --- a/frontend/components/Project/useProjectMetrics.ts +++ b/frontend/components/Project/useProjectMetrics.ts @@ -417,8 +417,9 @@ export const useProjectMetrics = ( }, [tasks]); const getDueDescriptor = useCallback( - (task: Task) => { - if (!task.due_date) return t('tasks.noDue', 'No due date'); + (task: Task): string => { + if (!task.due_date) + return t('tasks.noDue', 'No due date') as string; const now = new Date(); const startOfToday = new Date( @@ -428,7 +429,7 @@ export const useProjectMetrics = ( ); const due = new Date(task.due_date); if (Number.isNaN(due.getTime())) - return t('tasks.noDue', 'No due date'); + return t('tasks.noDue', 'No due date') as string; const diffDays = Math.floor( (due.getTime() - startOfToday.getTime()) / (1000 * 60 * 60 * 24) @@ -438,20 +439,22 @@ export const useProjectMetrics = ( return t('tasks.overdueBy', { defaultValue: 'Overdue by {{days}}d', days: Math.abs(diffDays), - } as any); + }) as string; } - if (diffDays === 0) return t('dateIndicators.today', 'Today'); - if (diffDays === 1) return t('dateIndicators.tomorrow', 'Tomorrow'); + if (diffDays === 0) + return t('dateIndicators.today', 'Today') as string; + if (diffDays === 1) + return t('dateIndicators.tomorrow', 'Tomorrow') as string; if (diffDays <= 7) return t('tasks.dueInDays', { defaultValue: 'Due in {{days}}d', days: diffDays, - } as any); + }) as string; return t('tasks.dueInDays', { defaultValue: 'Due in {{days}}d', days: diffDays, - } as any); + }) as string; }, [t] ); diff --git a/frontend/components/Register.tsx b/frontend/components/Register.tsx new file mode 100644 index 0000000..f7b1f43 --- /dev/null +++ b/frontend/components/Register.tsx @@ -0,0 +1,271 @@ +import React, { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { getAssetPath } from '../config/paths'; + +const Register: React.FC = () => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const { t } = useTranslation(); + const [isDarkMode] = useState(() => { + const storedPreference = localStorage.getItem('isDarkMode'); + return storedPreference !== null + ? storedPreference === 'true' + : window.matchMedia('(prefers-color-scheme: dark)').matches; + }); + + useEffect(() => { + document.documentElement.classList.toggle('dark', isDarkMode); + }, [isDarkMode]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (password !== confirmPassword) { + setError(t('auth.passwords_not_match', 'Passwords do not match')); + return; + } + + if (password.length < 6) { + setError( + t( + 'auth.password_too_short', + 'Password must be at least 6 characters long' + ) + ); + return; + } + + try { + const response = await fetch('/api/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password }), + credentials: 'include', + }); + + const data = await response.json(); + + if (response.ok) { + setSuccess(true); + } else { + setError( + data.error || + t( + 'auth.registration_failed', + 'Registration failed. Please try again.' + ) + ); + } + } catch (err) { + setError( + t('auth.error_occurred', 'An error occurred. Please try again.') + ); + console.error('Error during registration:', err); + } + }; + + if (success) { + return ( + <> + {/* Navbar */} + + + {/* Main Content */} +
+
+ {/* Left side - Success Message */} +
+
+
+
+ ✓ +
+

+ {t( + 'auth.registration_successful', + 'Check Your Email' + )} +

+

+ {t( + 'auth.registration_email_sent', + 'We have sent a verification link to your email address. Please check your inbox and click the link to verify your account.' + )} +

+ + {t( + 'auth.back_to_login', + 'Back to Login' + )} + +
+
+
+ + {/* Right side - Graphic */} +
+ Registration illustration +
+
+
+ + ); + } + + return ( + <> + {/* Navbar */} + + + {/* Main Content */} +
+
+ {/* Left side - Register Form */} +
+
+

+ {t('auth.sign_up', 'Sign Up')} +

+ {error && ( +
+ {error} +
+ )} +
+
+ + + setEmail(e.target.value) + } + className="w-full px-4 py-2 border dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" + required + /> +
+
+ + + setPassword(e.target.value) + } + className="w-full px-4 py-2 border dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" + required + minLength={6} + /> +
+
+ + + setConfirmPassword(e.target.value) + } + className="w-full px-4 py-2 border dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" + required + minLength={6} + /> +
+ +
+
+ {t( + 'auth.already_have_account', + 'Already have an account?' + )}{' '} + + {t('auth.login', 'Login')} + +
+
+
+ + {/* Right side - Graphic */} +
+ Registration illustration +
+
+
+ + ); +}; + +export default Register; diff --git a/frontend/components/Shared/ProjectDropdown.tsx b/frontend/components/Shared/ProjectDropdown.tsx index 00fb51c..1e17bcb 100644 --- a/frontend/components/Shared/ProjectDropdown.tsx +++ b/frontend/components/Shared/ProjectDropdown.tsx @@ -5,11 +5,11 @@ import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline'; interface ProjectDropdownProps { projectName: string; - onProjectSearch: (e: React.ChangeEvent) => void; + onProjectSearch: (query: string) => void; dropdownOpen: boolean; filteredProjects: Project[]; onProjectSelection: (project: Project) => void; - onCreateProject: () => void; + onCreateProject: (name: string) => void | Promise; isCreatingProject: boolean; onShowAllProjects: () => void; allProjects: Project[]; @@ -167,7 +167,7 @@ const ProjectDropdown: React.FC = ({ onProjectSelection(projectsToShow[highlightedIndex]); } else if (projectName.trim() && projectsToShow.length === 0) { // No matches - create new project - onCreateProject(); + onCreateProject(projectName.trim()); } // Note: Enter does nothing if no exact match and user hasn't navigated with arrows } else if (event.key === 'Escape') { @@ -207,7 +207,7 @@ const ProjectDropdown: React.FC = ({ ) } value={projectName} - onChange={onProjectSearch} + onChange={(e) => onProjectSearch(e.target.value)} onKeyDown={handleKeyDown} disabled={disabled} className="w-full bg-transparent border-none outline-none text-sm text-gray-900 dark:text-gray-100 disabled:cursor-not-allowed pr-8" @@ -273,7 +273,7 @@ const ProjectDropdown: React.FC = ({ {projectName && (