Add email password change feature and fix nginx config
Build and Push Docker Image / build (push) Successful in 4m22s
Details
Build and Push Docker Image / build (push) Successful in 4m22s
Details
- Add public page at /email/change-password for Poste.io mailbox password management - Add API routes for SMTP credential verification and Poste.io password change - Rewrite nginx config as HTTP-only (certbot --nginx will add SSL) - Add Poste.io admin API env vars to docker-compose and env templates Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d5398e93a0
commit
8c598ba3ee
|
|
@ -47,6 +47,14 @@ SMTP_USER="noreply@monaco-opc.com"
|
||||||
SMTP_PASS="your-smtp-password"
|
SMTP_PASS="your-smtp-password"
|
||||||
EMAIL_FROM="MOPC Platform <noreply@monaco-opc.com>"
|
EMAIL_FROM="MOPC Platform <noreply@monaco-opc.com>"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 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)
|
# AI (OpenAI for Smart Assignment)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,14 @@ SMTP_USER=noreply@monaco-opc.com
|
||||||
SMTP_PASS=CHANGE_ME
|
SMTP_PASS=CHANGE_ME
|
||||||
EMAIL_FROM=MOPC Platform <noreply@monaco-opc.com>
|
EMAIL_FROM=MOPC Platform <noreply@monaco-opc.com>
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 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)
|
# AI (OpenAI - optional)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@ services:
|
||||||
- SMTP_USER=${SMTP_USER}
|
- SMTP_USER=${SMTP_USER}
|
||||||
- SMTP_PASS=${SMTP_PASS}
|
- SMTP_PASS=${SMTP_PASS}
|
||||||
- EMAIL_FROM=${EMAIL_FROM}
|
- 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_API_KEY=${OPENAI_API_KEY:-}
|
||||||
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o}
|
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o}
|
||||||
- MAX_FILE_SIZE=${MAX_FILE_SIZE:-524288000}
|
- MAX_FILE_SIZE=${MAX_FILE_SIZE:-524288000}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,26 @@
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# MOPC Platform - Nginx Reverse Proxy Configuration
|
# MOPC Platform - Nginx Reverse Proxy Configuration
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Install: sudo ln -s /opt/mopc/docker/nginx/mopc-platform.conf /etc/nginx/sites-enabled/
|
# Setup steps:
|
||||||
# Test: sudo nginx -t
|
# 1. sudo ln -s /opt/mopc/docker/nginx/mopc-platform.conf /etc/nginx/sites-enabled/
|
||||||
# Reload: sudo systemctl reload nginx
|
# 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
|
# Rate limiting zone
|
||||||
limit_req_zone $binary_remote_addr zone=mopc_limit:10m rate=10r/s;
|
limit_req_zone $binary_remote_addr zone=mopc_limit:10m rate=10r/s;
|
||||||
|
|
||||||
# MOPC Platform - HTTPS
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 80;
|
||||||
listen [::]:443 ssl http2;
|
listen [::]:80;
|
||||||
server_name portal.monaco-opc.com;
|
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
|
# Security headers
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" 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)
|
# File upload size (500MB for videos)
|
||||||
client_max_body_size 500M;
|
client_max_body_size 500M;
|
||||||
|
|
@ -68,11 +63,3 @@ server {
|
||||||
access_log off;
|
access_log off;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# HTTP to HTTPS redirect
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
listen [::]:80;
|
|
||||||
server_name portal.monaco-opc.com;
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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<Step>('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 (
|
||||||
|
<div className="mx-auto max-w-lg">
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<Mail className="mx-auto h-12 w-12 text-[#053d57] mb-4" />
|
||||||
|
<h1 className="text-heading font-semibold text-[#053d57]">Email Account</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Change your @{MAIL_DOMAIN} email password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 'verify' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Verify Your Identity</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your email address and current password to continue.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleVerify} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email Address</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder={`yourname@${MAIL_DOMAIN}`}
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="current-password">Current Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="current-password"
|
||||||
|
type={showCurrentPassword ? 'text' : 'password'}
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showCurrentPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Verifying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Continue'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'change' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Set New Password</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Choose a new password for <strong>{email.toLowerCase().trim()}</strong>.
|
||||||
|
Must be at least 8 characters with uppercase, lowercase, and a number.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="new-password">New Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="new-password"
|
||||||
|
type={showNewPassword ? 'text' : 'password'}
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showNewPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirm-password">Confirm New Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setStep('verify')
|
||||||
|
setNewPassword('')
|
||||||
|
setConfirmPassword('')
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" className="flex-1" disabled={loading}>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Changing Password...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Change Password'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'success' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex flex-col items-center text-center space-y-3">
|
||||||
|
<CheckCircle2 className="h-12 w-12 text-green-600" />
|
||||||
|
<h2 className="text-xl font-semibold">Password Changed Successfully</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Your password for <strong>{email.toLowerCase().trim()}</strong> has been updated.
|
||||||
|
Use your new password to sign in to your email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Lock className="h-5 w-5" />
|
||||||
|
Mail Client Setup
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Use these settings to add your email account to any mail app.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="rounded-lg border p-4 space-y-3">
|
||||||
|
<h3 className="font-semibold text-sm uppercase tracking-wide text-muted-foreground">
|
||||||
|
Incoming Mail (IMAP)
|
||||||
|
</h3>
|
||||||
|
<dl className="space-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt className="text-muted-foreground">Server</dt>
|
||||||
|
<dd className="font-mono font-medium">{MAIL_SERVER}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-muted-foreground">Port</dt>
|
||||||
|
<dd className="font-mono font-medium">993</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-muted-foreground">Security</dt>
|
||||||
|
<dd className="font-mono font-medium">SSL/TLS</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-muted-foreground">Username</dt>
|
||||||
|
<dd className="font-mono font-medium text-xs break-all">{email.toLowerCase().trim()}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-4 space-y-3">
|
||||||
|
<h3 className="font-semibold text-sm uppercase tracking-wide text-muted-foreground">
|
||||||
|
Outgoing Mail (SMTP)
|
||||||
|
</h3>
|
||||||
|
<dl className="space-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt className="text-muted-foreground">Server</dt>
|
||||||
|
<dd className="font-mono font-medium">{MAIL_SERVER}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-muted-foreground">Port</dt>
|
||||||
|
<dd className="font-mono font-medium">587</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-muted-foreground">Security</dt>
|
||||||
|
<dd className="font-mono font-medium">STARTTLS</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-muted-foreground">Username</dt>
|
||||||
|
<dd className="font-mono font-medium text-xs break-all">{email.toLowerCase().trim()}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-muted/50 p-4 space-y-3">
|
||||||
|
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||||
|
<Smartphone className="h-4 w-4" />
|
||||||
|
Mobile Apps
|
||||||
|
</h3>
|
||||||
|
<ul className="text-sm space-y-1 text-muted-foreground">
|
||||||
|
<li><strong>iPhone/iPad:</strong> Settings > Mail > Accounts > Add Account > Other</li>
|
||||||
|
<li><strong>Gmail App:</strong> Settings > Add Account > Other</li>
|
||||||
|
<li><strong>Outlook App:</strong> Settings > Add Email Account</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-muted/50 p-4 space-y-3">
|
||||||
|
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||||
|
<Monitor className="h-4 w-4" />
|
||||||
|
Desktop Apps
|
||||||
|
</h3>
|
||||||
|
<ul className="text-sm space-y-1 text-muted-foreground">
|
||||||
|
<li><strong>Apple Mail:</strong> Mail > Add Account > Other Mail Account</li>
|
||||||
|
<li><strong>Outlook:</strong> File > Add Account</li>
|
||||||
|
<li><strong>Thunderbird:</strong> Account Settings > Account Actions > Add Mail Account</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<NextResponse> {
|
||||||
|
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<NextResponse> {
|
||||||
|
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue