From 2133364e3dbd80889097eb11eceb480fb5de8ae4 Mon Sep 17 00:00:00 2001 From: mARTin Date: Fri, 27 Feb 2026 08:57:15 +0100 Subject: [PATCH] feat: email verification, auto-save settings, calendar UX improvements - Email verification: 6-digit code + one-click link on signup - SMTP integration with nodemailer (lazy-init, auto-detect TLS) - Verify-email page with 6 input boxes, paste support, auto-submit - Resend verification with 60s rate limiting - Block login for unverified email accounts - Auto-save settings: debounced 800ms save on all profile changes - Redirect to calendar settings after OAuth connection - Fix Google Calendar connection display after connecting - Task actions hover toolbar: fix z-index/overflow clipping on first row - Make day header sticky with proper stacking context --- package-lock.json | 22 +- package.json | 4 +- prisma/schema.prisma | 6 +- scripts/lxc-setup.sh | 35 ++ scripts/supabase-setup.sh | 36 ++ src/app/api/auth/resend-verification/route.ts | 76 ++++ src/app/api/auth/signup/route.ts | 56 ++- src/app/api/auth/verify-email/route.ts | 122 ++++++- src/app/api/calendar/google/oauth/route.ts | 4 +- src/app/auth/oauth-complete/page.tsx | 2 +- src/app/auth/signup/page.tsx | 8 +- src/app/auth/verify-email/page.tsx | 335 ++++++++++++------ src/app/globals.css | 15 +- src/components/WeeklyView.tsx | 44 ++- src/lib/auth.ts | 5 + src/lib/email.ts | 128 +++++++ 16 files changed, 764 insertions(+), 134 deletions(-) create mode 100644 scripts/lxc-setup.sh create mode 100644 scripts/supabase-setup.sh create mode 100644 src/app/api/auth/resend-verification/route.ts create mode 100644 src/lib/email.ts diff --git a/package-lock.json b/package-lock.json index 7419218..25ca24f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@auth/prisma-adapter": "^2.11.1", "@prisma/client": "^5.22.0", "@types/bcryptjs": "^2.4.6", + "@types/nodemailer": "^7.0.11", "bcryptjs": "^3.0.3", "date-fns": "^2.30.0", "googleapis": "^170.1.0", @@ -19,6 +20,7 @@ "lucide-react": "^0.563.0", "next": "^14.0.0", "next-auth": "^4.24.13", + "nodemailer": "^7.0.13", "postcss-cli": "^11.0.1", "prisma": "^5.0.0", "react": "^18.2.0", @@ -2463,12 +2465,20 @@ "version": "20.19.34", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.34.tgz", "integrity": "sha512-by3/Z0Qp+L9cAySEsSNNwZ6WWw8ywgGLPQGgbQDhNRSitqYgkgp4pErd23ZSCavbtUA2CN4jQtoB3T8nk4j3Rg==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -8805,6 +8815,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -11306,7 +11325,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, "license": "MIT" }, "node_modules/universalify": { diff --git a/package.json b/package.json index 4790211..501f0cc 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@auth/prisma-adapter": "^2.11.1", "@prisma/client": "^5.22.0", "@types/bcryptjs": "^2.4.6", + "@types/nodemailer": "^7.0.11", "bcryptjs": "^3.0.3", "date-fns": "^2.30.0", "googleapis": "^170.1.0", @@ -31,6 +32,7 @@ "lucide-react": "^0.563.0", "next": "^14.0.0", "next-auth": "^4.24.13", + "nodemailer": "^7.0.13", "postcss-cli": "^11.0.1", "prisma": "^5.0.0", "react": "^18.2.0", @@ -56,4 +58,4 @@ "ts-jest": "^29.0.0", "typescript": "^5.0.0" } -} \ No newline at end of file +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f6df43c..39243a1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3,8 +3,9 @@ generator client { } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") + provider = "postgresql" + url = env("DATABASE_URL") + directUrl = env("DIRECT_URL") } model User { @@ -16,6 +17,7 @@ model User { emailVerified DateTime? verifiedAt DateTime? emailVerificationToken String? + emailVerificationCode String? emailVerificationExpires DateTime? passwordResetToken String? passwordResetExpires DateTime? diff --git a/scripts/lxc-setup.sh b/scripts/lxc-setup.sh new file mode 100644 index 0000000..be1d7c8 --- /dev/null +++ b/scripts/lxc-setup.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# My-Weekly-ToDo-List LXC Setup Script +# Run this inside your Ubuntu/Debian LXC container + +set -e + +echo "Updating system..." +apt update && apt upgrade -y + +echo "Installing dependencies..." +apt install -y curl openssl git build-essential ufw + +# Install Node.js 18 +echo "Installing Node.js 18..." +curl -fsSL https://deb.nodesource.com/setup_18.x | bash - +apt install -y nodejs + +# Configure Firewall +echo "Configuring firewall..." +ufw allow 22/tcp +ufw allow 3000/tcp +ufw --force enable + +echo "Installing PM2..." +npm install -g pm2 + +echo "Setup complete!" +echo "Next steps:" +echo "1. git clone " +echo "2. cd My-Weekly-ToDo-List" +echo "3. npm install" +echo "4. Create .env.production with your Supabase credentials" +echo "5. npm run build" +echo "6. pm2 start npm --name 'my-weekly-todo' -- start" diff --git a/scripts/supabase-setup.sh b/scripts/supabase-setup.sh new file mode 100644 index 0000000..2b7006e --- /dev/null +++ b/scripts/supabase-setup.sh @@ -0,0 +1,36 @@ +#!/bash +# Setup script for Supabase via Docker on LXC/VM +# Run this on a NEW LXC/VM (Ubuntu/Debian) + +# 1. Update and install dependencies +sudo apt update && sudo apt upgrade -y +sudo apt install -y git curl docker.io docker-compose-v2 + +# 2. Clone Supabase Docker +git clone --depth 1 https://github.com/supabase/supabase +cd supabase/docker + +# 3. Setup environment variables +cp .env.example .env + +# Generate secure random strings for secrets +JWT_SECRET=$(openssl rand -base64 32) +ANON_KEY=$(openssl rand -base64 32) +SERVICE_ROLE_KEY=$(openssl rand -base64 32) +POSTGRES_PASSWORD=$(openssl rand -base64 16) + +# Update .env (Basic replacement) +sed -i "s/JWT_SECRET=.*/JWT_SECRET=$JWT_SECRET/" .env +sed -i "s/ANON_KEY=.*/ANON_KEY=$ANON_KEY/" .env +sed -i "s/SERVICE_ROLE_KEY=.*/SERVICE_ROLE_KEY=$SERVICE_ROLE_KEY/" .env +sed -i "s/POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$POSTGRES_PASSWORD/" .env + +# 4. Start Supabase +sudo docker compose up -d + +echo "--------------------------------------------------" +echo "Supabase is starting up!" +echo "Postgres Password: $POSTGRES_PASSWORD" +echo "API URL: http://$(hostname -I | awk '{print $1}'):8000" +echo "Studio (Dashboard): http://$(hostname -I | awk '{print $1}'):8001" +echo "--------------------------------------------------" diff --git a/src/app/api/auth/resend-verification/route.ts b/src/app/api/auth/resend-verification/route.ts new file mode 100644 index 0000000..9b57d6a --- /dev/null +++ b/src/app/api/auth/resend-verification/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email'; + +export async function POST(request: NextRequest) { + try { + const { email } = await request.json(); + + if (!email) { + return NextResponse.json( + { error: 'Email is required' }, + { status: 400 } + ); + } + + const user = await prisma.user.findUnique({ + where: { email }, + select: { + id: true, + emailVerified: true, + emailVerificationExpires: true, + }, + }); + + if (!user) { + // Don't reveal whether user exists + return NextResponse.json({ message: 'If an account exists, a new code has been sent.' }); + } + + if (user.emailVerified) { + return NextResponse.json({ message: 'Email is already verified.' }); + } + + // Rate limit: don't resend if last code was sent less than 60 seconds ago + if (user.emailVerificationExpires) { + const lastSent = new Date(user.emailVerificationExpires.getTime() - 15 * 60 * 1000); + if (Date.now() - lastSent.getTime() < 60 * 1000) { + return NextResponse.json( + { error: 'Please wait before requesting a new code.' }, + { status: 429 } + ); + } + } + + const code = generateVerificationCode(); + const token = generateVerificationToken(); + const expires = new Date(Date.now() + 15 * 60 * 1000); + + await prisma.user.update({ + where: { id: user.id }, + data: { + emailVerificationCode: code, + emailVerificationToken: token, + emailVerificationExpires: expires, + }, + }); + + try { + await sendVerificationEmail(email, code, token); + } catch (emailError) { + console.error('Failed to resend verification email:', emailError); + return NextResponse.json( + { error: 'Failed to send email. Please try again later.' }, + { status: 500 } + ); + } + + return NextResponse.json({ message: 'A new verification code has been sent.' }); + } catch (error) { + console.error('Resend verification error:', error); + return NextResponse.json( + { error: 'Something went wrong' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts index 3f2dd7b..729eecf 100644 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -1,12 +1,13 @@ import { NextRequest, NextResponse } from 'next/server'; import { PrismaClient } from '@prisma/client'; import { hash } from 'bcryptjs'; +import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email'; const prisma = new PrismaClient(); export async function POST(request: NextRequest) { try { - const { email, password } = await request.json(); + const { name, email, password } = await request.json(); if (!email || !password) { return NextResponse.json( @@ -28,6 +29,33 @@ export async function POST(request: NextRequest) { }); if (existingUser) { + // If user exists but isn't verified, resend verification + if (!existingUser.emailVerified) { + const code = generateVerificationCode(); + const token = generateVerificationToken(); + const expires = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes + + await prisma.user.update({ + where: { id: existingUser.id }, + data: { + emailVerificationCode: code, + emailVerificationToken: token, + emailVerificationExpires: expires, + }, + }); + + try { + await sendVerificationEmail(email, code, token); + } catch (emailError) { + console.error('Failed to send verification email:', emailError); + } + + return NextResponse.json({ + message: 'Verification email resent', + requiresVerification: true, + }); + } + return NextResponse.json( { error: 'User already exists' }, { status: 400 } @@ -37,12 +65,21 @@ export async function POST(request: NextRequest) { // Hash password const passwordHash = await hash(password, 12); - // Create user + // Generate verification code and token + const code = generateVerificationCode(); + const token = generateVerificationToken(); + const expires = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes + + // Create user (NOT verified) const user = await prisma.user.create({ data: { + name: name || undefined, email, passwordHash, - emailVerified: new Date(), // Auto-verify for now + emailVerificationCode: code, + emailVerificationToken: token, + emailVerificationExpires: expires, + // emailVerified is NOT set — user must verify }, select: { id: true, @@ -51,9 +88,18 @@ export async function POST(request: NextRequest) { } }); + // Send verification email + try { + await sendVerificationEmail(email, code, token); + } catch (emailError) { + console.error('Failed to send verification email:', emailError); + // User is created but email failed — they can use "resend" later + } + return NextResponse.json({ - message: 'User created successfully', - user + message: 'Account created. Please check your email for a verification code.', + requiresVerification: true, + user, }); } catch (error) { console.error('Signup error:', error); diff --git a/src/app/api/auth/verify-email/route.ts b/src/app/api/auth/verify-email/route.ts index 86c933c..8c6c55f 100644 --- a/src/app/api/auth/verify-email/route.ts +++ b/src/app/api/auth/verify-email/route.ts @@ -1,31 +1,125 @@ import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +// POST: Verify via 6-digit code export async function POST(request: NextRequest) { try { - const { token } = await request.json(); + const { email, code } = await request.json(); - // Basic validation - if (!token) { + if (!email || !code) { return NextResponse.json( - { error: 'Token is required' }, + { error: 'Email and code are required' }, { status: 400 } ); } - // In a real app, you would: - // 1. Look up user by emailVerificationToken - // 2. Verify the token against stored token and expiration - // 3. Set user.verified to true - // 4. Clear the verification token - console.log('Verifying email with token:', token); - - // Mock successful verification + const user = await prisma.user.findUnique({ + where: { email }, + select: { + id: true, + emailVerified: true, + emailVerificationCode: true, + emailVerificationExpires: true, + }, + }); + + if (!user) { + return NextResponse.json( + { error: 'User not found' }, + { status: 404 } + ); + } + + if (user.emailVerified) { + return NextResponse.json({ message: 'Email already verified' }); + } + + if (!user.emailVerificationCode || !user.emailVerificationExpires) { + return NextResponse.json( + { error: 'No verification code found. Please request a new one.' }, + { status: 400 } + ); + } + + if (new Date() > user.emailVerificationExpires) { + return NextResponse.json( + { error: 'Verification code expired. Please request a new one.' }, + { status: 400 } + ); + } + + if (user.emailVerificationCode !== code) { + return NextResponse.json( + { error: 'Invalid verification code' }, + { status: 400 } + ); + } + + // Verify the user + await prisma.user.update({ + where: { id: user.id }, + data: { + emailVerified: new Date(), + verifiedAt: new Date(), + emailVerificationCode: null, + emailVerificationToken: null, + emailVerificationExpires: null, + }, + }); + return NextResponse.json({ message: 'Email verified successfully' }); } catch (error) { - console.error('Email verification error:', error); + console.error('Verification error:', error); return NextResponse.json( - { error: 'Internal server error' }, + { error: 'Failed to verify email' }, { status: 500 } ); } +} + +// GET: Verify via one-click link +export async function GET(request: NextRequest) { + try { + const token = request.nextUrl.searchParams.get('token'); + + if (!token) { + return NextResponse.redirect( + new URL('/auth/verify-email?error=missing_token', request.url) + ); + } + + const user = await prisma.user.findFirst({ + where: { + emailVerificationToken: token, + emailVerificationExpires: { gt: new Date() }, + }, + }); + + if (!user) { + return NextResponse.redirect( + new URL('/auth/verify-email?error=invalid_token', request.url) + ); + } + + // Verify the user + await prisma.user.update({ + where: { id: user.id }, + data: { + emailVerified: new Date(), + verifiedAt: new Date(), + emailVerificationCode: null, + emailVerificationToken: null, + emailVerificationExpires: null, + }, + }); + + return NextResponse.redirect( + new URL('/auth/login?verified=true', request.url) + ); + } catch (error) { + console.error('Token verification error:', error); + return NextResponse.redirect( + new URL('/auth/verify-email?error=server_error', request.url) + ); + } } \ No newline at end of file diff --git a/src/app/api/calendar/google/oauth/route.ts b/src/app/api/calendar/google/oauth/route.ts index f5143d0..c05f0bb 100644 --- a/src/app/api/calendar/google/oauth/route.ts +++ b/src/app/api/calendar/google/oauth/route.ts @@ -155,8 +155,8 @@ export async function GET(request: NextRequest) { }); } - // Redirect to tasks page with success message - return NextResponse.redirect(new URL('/tasks?calendar=connected', appBaseUrl)); + // Redirect to tasks page with settings open to calendar section + return NextResponse.redirect(new URL('/tasks?calendar=connected&openSettings=calendars', appBaseUrl)); } catch (error) { console.error('Google OAuth error:', error); const appBaseUrl = process.env.NEXTAUTH_URL || request.url; diff --git a/src/app/auth/oauth-complete/page.tsx b/src/app/auth/oauth-complete/page.tsx index 02df654..8bba109 100644 --- a/src/app/auth/oauth-complete/page.tsx +++ b/src/app/auth/oauth-complete/page.tsx @@ -18,7 +18,7 @@ function OAuthCompleteContent() { await update(); if (status === "connected") { - router.replace(`/tasks?calendar=${provider}_connected`); + router.replace(`/tasks?calendar=${provider}_connected&openSettings=calendars`); } else { const message = searchParams.get("message") || "connection_failed"; router.replace(`/tasks?error=${message}`); diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index d90b84e..c83b4da 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -48,7 +48,13 @@ export default function SignupPage() { return; } - // Auto sign in after successful signup + // Redirect to verification page + if (data.requiresVerification) { + router.push(`/auth/verify-email?email=${encodeURIComponent(email)}`); + return; + } + + // Fallback: Auto sign in after successful signup await signIn('credentials', { email, password, diff --git a/src/app/auth/verify-email/page.tsx b/src/app/auth/verify-email/page.tsx index d3b45ae..dc26efb 100644 --- a/src/app/auth/verify-email/page.tsx +++ b/src/app/auth/verify-email/page.tsx @@ -1,130 +1,265 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; -const VerifyEmailPage = () => { - const [token, setToken] = useState(''); +export default function VerifyEmailPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const email = searchParams.get('email') || ''; + const error = searchParams.get('error'); + const [code, setCode] = useState(['', '', '', '', '', '']); const [isLoading, setIsLoading] = useState(false); const [message, setMessage] = useState(null); - const [error, setError] = useState(null); - const [showSuccess, setShowSuccess] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + const [isResending, setIsResending] = useState(false); + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); - // Get token from URL query parameter useEffect(() => { - const urlParams = new URLSearchParams(window.location.search); - const tokenParam = urlParams.get('token'); - if (tokenParam) { - setToken(tokenParam); + if (error === 'invalid_token') { + setErrorMsg('Verification link is invalid or expired. Please enter your code manually or request a new one.'); + } else if (error === 'missing_token') { + setErrorMsg('Invalid verification link.'); + } else if (error === 'server_error') { + setErrorMsg('Something went wrong. Please try again.'); } + }, [error]); + + // Auto-focus first input + useEffect(() => { + inputRefs.current[0]?.focus(); }, []); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const handleInput = (index: number, value: string) => { + // Handle paste of full code + if (value.length > 1) { + const digits = value.replace(/\D/g, '').slice(0, 6).split(''); + const newCode = [...code]; + digits.forEach((digit, i) => { + if (index + i < 6) newCode[index + i] = digit; + }); + setCode(newCode); + const nextIndex = Math.min(index + digits.length, 5); + inputRefs.current[nextIndex]?.focus(); + // Auto-submit if all 6 digits entered + if (newCode.every(d => d !== '')) { + submitCode(newCode.join('')); + } + return; + } + + const digit = value.replace(/\D/g, ''); + const newCode = [...code]; + newCode[index] = digit; + setCode(newCode); + + if (digit && index < 5) { + inputRefs.current[index + 1]?.focus(); + } + + // Auto-submit if all 6 digits entered + if (newCode.every(d => d !== '')) { + submitCode(newCode.join('')); + } + }; + + const handleKeyDown = (index: number, e: React.KeyboardEvent) => { + if (e.key === 'Backspace' && !code[index] && index > 0) { + inputRefs.current[index - 1]?.focus(); + } + }; + + const submitCode = async (fullCode: string) => { setIsLoading(true); + setErrorMsg(null); setMessage(null); - setError(null); - + try { - // Simulate API call const response = await fetch('/api/auth/verify-email', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ token }), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, code: fullCode }), }); - - const result = await response.json(); - + + const data = await response.json(); + if (!response.ok) { - throw new Error(result.error || 'Failed to verify email'); + setErrorMsg(data.error || 'Verification failed'); + setCode(['', '', '', '', '', '']); + inputRefs.current[0]?.focus(); + setIsLoading(false); + return; } - - setMessage('Email verified successfully!'); - setShowSuccess(true); - } catch (err) { - setError(err instanceof Error ? err.message : 'An unknown error occurred'); - console.error('Email verification error:', err); - } finally { + + setMessage('Email verified! Redirecting to login...'); + setTimeout(() => { + router.push('/auth/login?verified=true'); + }, 1500); + } catch { + setErrorMsg('Something went wrong. Please try again.'); setIsLoading(false); } }; - if (showSuccess) { - return ( -
-
-
-

Email Verified Successfully!

-

- Your email has been verified. You can now sign in to your account. -

-
-
-
- ); - } + const handleResend = async () => { + setIsResending(true); + setErrorMsg(null); + setMessage(null); + + try { + const response = await fetch('/api/auth/resend-verification', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + + const data = await response.json(); + + if (!response.ok) { + setErrorMsg(data.error || 'Failed to resend code'); + } else { + setMessage('A new verification code has been sent to your email.'); + } + } catch { + setErrorMsg('Failed to resend code. Please try again.'); + } finally { + setIsResending(false); + } + }; return ( -
-
-
-
-

Weekly To Do List

-

Verify your email address

-
- -
- {error && ( -
-
{error}
-
- )} - -
-
- - setToken(e.target.value)} - className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm" - placeholder="Verification Token" - /> -
-
+
+
+ {/* Logo */} +
+ My Weekly ToDo's +
-
- -
- - -
-

- - Back to login - -

+ {/* Title */} +

+ Verify your email +

+

+ We sent a 6-digit code to {email} +

+ + {/* Messages */} + {errorMsg && ( +
+ {errorMsg}
+ )} + {message && ( +
+ {message} +
+ )} + + {/* Code Input */} +
+ {code.map((digit, index) => ( + { inputRefs.current[index] = el; }} + type="text" + inputMode="numeric" + maxLength={1} + value={digit} + onChange={e => handleInput(index, e.target.value)} + onKeyDown={e => handleKeyDown(index, e)} + onPaste={e => { + e.preventDefault(); + const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6); + if (pasted) handleInput(index, pasted); + }} + disabled={isLoading} + style={{ + width: '48px', + height: '56px', + textAlign: 'center', + fontSize: '1.5rem', + fontWeight: 700, + fontFamily: "'Courier New', monospace", + background: '#f8f9fa', + border: digit ? '2px solid #667eea' : '2px solid #d0d5dd', + borderRadius: '10px', + color: '#667eea', + outline: 'none', + transition: 'border-color 0.2s, box-shadow 0.2s', + caretColor: '#667eea', + boxSizing: 'border-box', + }} + onFocus={e => { + e.target.style.borderColor = '#667eea'; + e.target.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.15)'; + }} + onBlur={e => { + e.target.style.borderColor = digit ? '#667eea' : '#d0d5dd'; + e.target.style.boxShadow = 'none'; + }} + /> + ))} +
+ + {/* Verify Button */} + + + {/* Resend */} +
+ + Didn't receive the code?{' '} + + +
+ + {/* Back to login */} +
+ + ← Back to login +
+ +
+

Simple. Beautiful. Yours.

+
); -}; - -export default VerifyEmailPage; \ No newline at end of file +} \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index a777a35..61d374f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -668,6 +668,10 @@ h3 { text-align: left; min-height: 50px; box-sizing: border-box; + position: sticky; + top: 0; + z-index: 5; + background: var(--weekly-bg, white); } .weekly-day-date { @@ -721,6 +725,7 @@ h3 { .weekly-task-item:hover { background-color: rgba(0,0,0,0.02); + z-index: 10; } .weekly-task-item.completed { @@ -965,19 +970,19 @@ h3 { color: var(--weekly-text); } -/* Action Buttons Styling - Float above task on hover to avoid overlap */ +/* Action Buttons Styling - Float above task on hover */ .task-actions { display: flex; align-items: center; gap: 2px; position: absolute; right: 0; - top: -26px; /* Position above the task item */ + top: -26px; background: var(--weekly-bg, white); border: 1px solid var(--weekly-border, #e0e0e0); border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.12); - z-index: 100; + z-index: 20; padding: 2px 4px; flex-wrap: nowrap; pointer-events: auto; @@ -2024,8 +2029,8 @@ h3 { } .time-grid-wrapper .time-slots-container { - overflow-y: auto; - overflow-x: hidden; + overflow-y: visible; + overflow-x: visible; } /* All-Day Events Section */ diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index b05c6c9..5a4e8cf 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -1074,6 +1074,22 @@ export default function WeeklyView() { } }, [session, fetchMotivationalQuote]); + // Auto-open settings to calendar tab after OAuth redirect + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (params.get('openSettings') === 'calendars') { + setShowSettings(true); + setActiveTab('calendar'); + // Clean up URL + const url = new URL(window.location.href); + url.searchParams.delete('openSettings'); + url.searchParams.delete('calendar'); + window.history.replaceState({}, '', url.pathname); + // Refresh connections to pick up the new one + fetchConnections(); + } + }, []); + // Periodic pull-sync from Google Tasks (every 2 minutes) useEffect(() => { if (!session) return; @@ -5465,6 +5481,7 @@ export default function WeeklyView() { {/* Settings Sidebar */} {showSettings && ( setShowSettings(false)} onSettingsChanged={handleSettingsChanged} @@ -6425,6 +6442,7 @@ interface SettingsSidebarProps { fetchAvailableTaskLists: ( provider: "google" | "apple" | "outlook", ) => Promise; + initialTab?: "calendar" | "general" | "account" | "styling" | "motivation" | "about"; } // Notes Sidebar Component interface NotesSidebarProps { @@ -6593,10 +6611,11 @@ function SettingsSidebar({ somedayLists, handleToggleTaskList, fetchAvailableTaskLists, + initialTab, }: SettingsSidebarProps) { const [activeTab, setActiveTab] = useState< "calendar" | "general" | "account" | "styling" | "motivation" | "about" - >("general"); + >(initialTab || "general"); const [isLoading, setIsLoading] = useState(true); const [isSyncing, setIsSyncing] = useState(false); const [exportStartDate, setExportStartDate] = useState(""); @@ -6781,7 +6800,30 @@ function SettingsSidebar({ }; // Live preview: propagate styling changes immediately without Save + // Auto-save: debounce profile changes to the database const profileLoadedRef = useRef(false); + const autoSaveTimerRef = useRef(null); + + useEffect(() => { + if (!profileLoadedRef.current) return; // Skip initial load from API + // Debounce: save after 800ms of no changes + if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = setTimeout(async () => { + try { + await fetch("/api/user/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(profile), + }); + console.log("[SETTINGS] Auto-saved profile"); + } catch (err) { + console.error("[SETTINGS] Auto-save failed:", err); + } + }, 800); + return () => { + if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); + }; + }, [profile]); useEffect(() => { if (!profileLoadedRef.current) return; if (!onSettingsChanged) return; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 7881fdb..08f2526 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -40,6 +40,11 @@ export const authOptions: NextAuthOptions = { throw new Error("Invalid credentials"); } + // Block login if email is not verified + if (!user.emailVerified) { + throw new Error("email_not_verified"); + } + const isPasswordValid = await compare( credentials.password, user.passwordHash diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..da037e5 --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,128 @@ +import nodemailer from 'nodemailer'; + +let _transporter: nodemailer.Transporter | null = null; + +function getTransporter() { + if (!_transporter) { + const host = process.env.SMTP_HOST || 'smtp.gmail.com'; + const port = parseInt(process.env.SMTP_PORT || '587'); + const secure = port === 465; // Port 465 = TLS, Port 587 = STARTTLS + const user = process.env.SMTP_USERNAME || process.env.SMTP_USER || ''; + const pass = process.env.SMTP_PASSWORD || process.env.SMTP_PASS || ''; + + console.log(`[EMAIL] Creating SMTP transport: ${host}:${port} (secure=${secure}, user=${user})`); + + _transporter = nodemailer.createTransport({ + host, + port, + secure, + auth: { user, pass }, + tls: { rejectUnauthorized: false }, + }); + } + return _transporter; +} + +export async function sendVerificationEmail( + email: string, + code: string, + token: string +) { + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3001'; + const verifyLink = `${baseUrl}/auth/verify-email?token=${token}`; + + const html = ` + + + + + + + + + + + +
+ + + + + + + + + + + + + + + +
+

+ My Weekly ToDo's +

+
+

+ Welcome! Please verify your email address to complete your registration. +

+ + +
+

+ Your verification code +

+

+ ${code} +

+
+ +

+ Or click the button below to verify instantly: +

+ + + + + + +
+ + ✓ Verify Email + +
+ +

+ This code expires in 15 minutes. If you didn't create an account, you can safely ignore this email. +

+
+

+ Simple. Beautiful. Yours. +

+
+
+ + + `; + + const from = process.env.SMTP_FROM || '"My Weekly ToDo\'s" '; + console.log(`[EMAIL] Sending verification email to ${email} from ${from}`); + + const result = await getTransporter().sendMail({ + from, + to: email, + subject: `${code} – Verify your email for My Weekly ToDo's`, + html, + }); + + console.log(`[EMAIL] Email sent successfully: ${result.messageId}`); +} + +export function generateVerificationCode(): string { + return Math.floor(100000 + Math.random() * 900000).toString(); +} + +export function generateVerificationToken(): string { + return crypto.randomUUID(); +}