diff --git a/.env.example b/.env.example index 4b8acef..1f8e476 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,14 @@ SMTP_USER="noreply@monaco-opc.com" SMTP_PASS="your-smtp-password" EMAIL_FROM="MOPC Platform " +# ============================================================================= +# POSTE.IO ADMIN API (for email password management) +# ============================================================================= +POSTE_API_URL="https://mail.monaco-opc.com" +POSTE_ADMIN_EMAIL="admin@monaco-opc.com" +POSTE_ADMIN_PASSWORD="your-poste-admin-password" +POSTE_MAIL_DOMAIN="monaco-opc.com" + # ============================================================================= # AI (OpenAI for Smart Assignment) # ============================================================================= diff --git a/docker/.env.production b/docker/.env.production index d8f131b..0ae5875 100644 --- a/docker/.env.production +++ b/docker/.env.production @@ -41,6 +41,14 @@ SMTP_USER=noreply@monaco-opc.com SMTP_PASS=CHANGE_ME EMAIL_FROM=MOPC Platform +# ============================================================================= +# POSTE.IO ADMIN API (for email password management) +# ============================================================================= +POSTE_API_URL=https://mail.monaco-opc.com +POSTE_ADMIN_EMAIL=admin@monaco-opc.com +POSTE_ADMIN_PASSWORD=CHANGE_ME +POSTE_MAIL_DOMAIN=monaco-opc.com + # ============================================================================= # AI (OpenAI - optional) # ============================================================================= diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 3846df7..4c57353 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -33,6 +33,10 @@ services: - SMTP_USER=${SMTP_USER} - SMTP_PASS=${SMTP_PASS} - EMAIL_FROM=${EMAIL_FROM} + - POSTE_API_URL=${POSTE_API_URL:-https://mail.monaco-opc.com} + - POSTE_ADMIN_EMAIL=${POSTE_ADMIN_EMAIL} + - POSTE_ADMIN_PASSWORD=${POSTE_ADMIN_PASSWORD} + - POSTE_MAIL_DOMAIN=${POSTE_MAIL_DOMAIN:-monaco-opc.com} - OPENAI_API_KEY=${OPENAI_API_KEY:-} - OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o} - MAX_FILE_SIZE=${MAX_FILE_SIZE:-524288000} diff --git a/docker/nginx/mopc-platform.conf b/docker/nginx/mopc-platform.conf index 0e6d3b9..d6dc093 100644 --- a/docker/nginx/mopc-platform.conf +++ b/docker/nginx/mopc-platform.conf @@ -1,31 +1,26 @@ # ============================================================================= # MOPC Platform - Nginx Reverse Proxy Configuration # ============================================================================= -# Install: sudo ln -s /opt/mopc/docker/nginx/mopc-platform.conf /etc/nginx/sites-enabled/ -# Test: sudo nginx -t -# Reload: sudo systemctl reload nginx +# Setup steps: +# 1. sudo ln -s /opt/mopc/docker/nginx/mopc-platform.conf /etc/nginx/sites-enabled/ +# 2. sudo nginx -t && sudo systemctl reload nginx +# 3. sudo certbot --nginx -d portal.monaco-opc.com +# +# Certbot will automatically add SSL configuration to this file. # Rate limiting zone limit_req_zone $binary_remote_addr zone=mopc_limit:10m rate=10r/s; -# MOPC Platform - HTTPS server { - listen 443 ssl http2; - listen [::]:443 ssl http2; + listen 80; + listen [::]:80; server_name portal.monaco-opc.com; - # SSL certificates (managed by Certbot) - ssl_certificate /etc/letsencrypt/live/portal.monaco-opc.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/portal.monaco-opc.com/privkey.pem; - include /etc/letsencrypt/options-ssl-nginx.conf; - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; - # Security headers add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self';" always; # File upload size (500MB for videos) client_max_body_size 500M; @@ -68,11 +63,3 @@ server { access_log off; } } - -# HTTP to HTTPS redirect -server { - listen 80; - listen [::]:80; - server_name portal.monaco-opc.com; - return 301 https://$host$request_uri; -} diff --git a/src/app/(public)/email/change-password/page.tsx b/src/app/(public)/email/change-password/page.tsx new file mode 100644 index 0000000..913d149 --- /dev/null +++ b/src/app/(public)/email/change-password/page.tsx @@ -0,0 +1,386 @@ +'use client' + +import { useState } from 'react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { AlertCircle, CheckCircle2, Eye, EyeOff, Loader2, Mail, Lock, Monitor, Smartphone } from 'lucide-react' + +type Step = 'verify' | 'change' | 'success' + +const MAIL_DOMAIN = 'monaco-opc.com' +const MAIL_SERVER = 'mail.monaco-opc.com' + +export default function ChangeEmailPasswordPage() { + const [step, setStep] = useState('verify') + const [email, setEmail] = useState('') + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [showCurrentPassword, setShowCurrentPassword] = useState(false) + const [showNewPassword, setShowNewPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + async function handleVerify(e: React.FormEvent) { + e.preventDefault() + setError('') + + const emailLower = email.toLowerCase().trim() + if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) { + setError(`Email must be an @${MAIL_DOMAIN} address.`) + return + } + + setLoading(true) + try { + const res = await fetch('/api/email/verify-credentials', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: emailLower, password: currentPassword }), + }) + + const data = await res.json() + + if (res.status === 429) { + setError(data.error || 'Too many attempts. Please try again later.') + return + } + + if (!data.valid) { + setError(data.error || 'Invalid email or password.') + return + } + + setStep('change') + } catch { + setError('Connection error. Please try again.') + } finally { + setLoading(false) + } + } + + async function handleChangePassword(e: React.FormEvent) { + e.preventDefault() + setError('') + + if (newPassword.length < 8) { + setError('Password must be at least 8 characters.') + return + } + + if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(newPassword)) { + setError('Password must contain at least one uppercase letter, one lowercase letter, and one number.') + return + } + + if (newPassword !== confirmPassword) { + setError('Passwords do not match.') + return + } + + setLoading(true) + try { + const res = await fetch('/api/email/change-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: email.toLowerCase().trim(), + currentPassword, + newPassword, + }), + }) + + const data = await res.json() + + if (!res.ok) { + setError(data.error || 'Failed to change password.') + return + } + + setStep('success') + } catch { + setError('Connection error. Please try again.') + } finally { + setLoading(false) + } + } + + return ( +
+
+ +

Email Account

+

+ Change your @{MAIL_DOMAIN} email password +

+
+ + {step === 'verify' && ( + + + Verify Your Identity + + Enter your email address and current password to continue. + + + +
+
+ + setEmail(e.target.value)} + required + autoComplete="email" + /> +
+ +
+ +
+ setCurrentPassword(e.target.value)} + required + autoComplete="current-password" + /> + +
+
+ + {error && ( +
+ + {error} +
+ )} + + +
+
+
+ )} + + {step === 'change' && ( + + + Set New Password + + Choose a new password for {email.toLowerCase().trim()}. + Must be at least 8 characters with uppercase, lowercase, and a number. + + + +
+
+ +
+ setNewPassword(e.target.value)} + required + autoComplete="new-password" + minLength={8} + /> + +
+
+ +
+ +
+ setConfirmPassword(e.target.value)} + required + autoComplete="new-password" + minLength={8} + /> + +
+
+ + {error && ( +
+ + {error} +
+ )} + +
+ + +
+
+
+
+ )} + + {step === 'success' && ( +
+ + +
+ +

Password Changed Successfully

+

+ Your password for {email.toLowerCase().trim()} has been updated. + Use your new password to sign in to your email. +

+
+
+
+ + + + + + Mail Client Setup + + + Use these settings to add your email account to any mail app. + + + +
+
+

+ Incoming Mail (IMAP) +

+
+
+
Server
+
{MAIL_SERVER}
+
+
+
Port
+
993
+
+
+
Security
+
SSL/TLS
+
+
+
Username
+
{email.toLowerCase().trim()}
+
+
+
+ +
+

+ Outgoing Mail (SMTP) +

+
+
+
Server
+
{MAIL_SERVER}
+
+
+
Port
+
587
+
+
+
Security
+
STARTTLS
+
+
+
Username
+
{email.toLowerCase().trim()}
+
+
+
+
+ +
+

+ + Mobile Apps +

+
    +
  • iPhone/iPad: Settings > Mail > Accounts > Add Account > Other
  • +
  • Gmail App: Settings > Add Account > Other
  • +
  • Outlook App: Settings > Add Email Account
  • +
+
+ +
+

+ + Desktop Apps +

+
    +
  • Apple Mail: Mail > Add Account > Other Mail Account
  • +
  • Outlook: File > Add Account
  • +
  • Thunderbird: Account Settings > Account Actions > Add Mail Account
  • +
+
+
+
+
+ )} +
+ ) +} diff --git a/src/app/api/email/change-password/route.ts b/src/app/api/email/change-password/route.ts new file mode 100644 index 0000000..9e22df2 --- /dev/null +++ b/src/app/api/email/change-password/route.ts @@ -0,0 +1,127 @@ +import { NextRequest, NextResponse } from 'next/server' +import nodemailer from 'nodemailer' +import { checkRateLimit } from '@/lib/rate-limit' + +const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com' +const SMTP_HOST = process.env.SMTP_HOST || 'localhost' +const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587') +const POSTE_API_URL = process.env.POSTE_API_URL || 'https://mail.monaco-opc.com' +const POSTE_ADMIN_EMAIL = process.env.POSTE_ADMIN_EMAIL || '' +const POSTE_ADMIN_PASSWORD = process.env.POSTE_ADMIN_PASSWORD || '' + +const PASSWORD_MIN_LENGTH = 8 +const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/ + +function validateNewPassword(password: string): string | null { + if (password.length < PASSWORD_MIN_LENGTH) { + return `Password must be at least ${PASSWORD_MIN_LENGTH} characters.` + } + if (!PASSWORD_REGEX.test(password)) { + return 'Password must contain at least one uppercase letter, one lowercase letter, and one number.' + } + return null +} + +export async function POST(request: NextRequest): Promise { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown' + const rateLimit = checkRateLimit(`email-change:${ip}`, 3, 15 * 60 * 1000) + + if (!rateLimit.success) { + return NextResponse.json( + { error: 'Too many attempts. Please try again later.' }, + { status: 429 } + ) + } + + try { + const body = await request.json() + const { email, currentPassword, newPassword } = body as { + email: string + currentPassword: string + newPassword: string + } + + if (!email || !currentPassword || !newPassword) { + return NextResponse.json( + { error: 'All fields are required.' }, + { status: 400 } + ) + } + + const emailLower = email.toLowerCase().trim() + + if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) { + return NextResponse.json( + { error: `Email must be an @${MAIL_DOMAIN} address.` }, + { status: 400 } + ) + } + + const passwordError = validateNewPassword(newPassword) + if (passwordError) { + return NextResponse.json({ error: passwordError }, { status: 400 }) + } + + if (!POSTE_ADMIN_EMAIL || !POSTE_ADMIN_PASSWORD) { + console.error('Poste.io admin credentials not configured') + return NextResponse.json( + { error: 'Email service is not configured. Contact an administrator.' }, + { status: 503 } + ) + } + + // Re-verify current credentials via SMTP + const transporter = nodemailer.createTransport({ + host: SMTP_HOST, + port: SMTP_PORT, + secure: SMTP_PORT === 465, + auth: { + user: emailLower, + pass: currentPassword, + }, + connectionTimeout: 10000, + greetingTimeout: 10000, + }) + + try { + await transporter.verify() + } catch { + return NextResponse.json( + { error: 'Current password is incorrect.' }, + { status: 401 } + ) + } finally { + transporter.close() + } + + // Change password via Poste.io Admin API + const apiUrl = `${POSTE_API_URL}/admin/api/v1/boxes/${encodeURIComponent(emailLower)}` + const authHeader = 'Basic ' + Buffer.from(`${POSTE_ADMIN_EMAIL}:${POSTE_ADMIN_PASSWORD}`).toString('base64') + + const response = await fetch(apiUrl, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': authHeader, + }, + body: JSON.stringify({ passwordPlaintext: newPassword }), + }) + + if (!response.ok) { + console.error('Poste.io API error:', response.status, await response.text()) + return NextResponse.json( + { error: 'Failed to change password. Please try again or contact an administrator.' }, + { status: 502 } + ) + } + + return NextResponse.json({ success: true }) + } catch (err) { + console.error('Password change error:', err) + return NextResponse.json( + { error: 'An unexpected error occurred.' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/email/verify-credentials/route.ts b/src/app/api/email/verify-credentials/route.ts new file mode 100644 index 0000000..35cbfe0 --- /dev/null +++ b/src/app/api/email/verify-credentials/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server' +import nodemailer from 'nodemailer' +import { checkRateLimit } from '@/lib/rate-limit' + +const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com' +const SMTP_HOST = process.env.SMTP_HOST || 'localhost' +const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587') + +export async function POST(request: NextRequest): Promise { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown' + const rateLimit = checkRateLimit(`email-verify:${ip}`, 5, 15 * 60 * 1000) + + if (!rateLimit.success) { + return NextResponse.json( + { error: 'Too many attempts. Please try again later.' }, + { status: 429 } + ) + } + + try { + const body = await request.json() + const { email, password } = body as { email: string; password: string } + + if (!email || !password) { + return NextResponse.json( + { error: 'Email and password are required.' }, + { status: 400 } + ) + } + + const emailLower = email.toLowerCase().trim() + + if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) { + return NextResponse.json( + { error: `Email must be an @${MAIL_DOMAIN} address.` }, + { status: 400 } + ) + } + + const transporter = nodemailer.createTransport({ + host: SMTP_HOST, + port: SMTP_PORT, + secure: SMTP_PORT === 465, + auth: { + user: emailLower, + pass: password, + }, + connectionTimeout: 10000, + greetingTimeout: 10000, + }) + + try { + await transporter.verify() + return NextResponse.json({ valid: true }) + } catch { + return NextResponse.json({ valid: false, error: 'Invalid email or password.' }) + } finally { + transporter.close() + } + } catch { + return NextResponse.json( + { error: 'Invalid request.' }, + { status: 400 } + ) + } +}