Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s

This commit is contained in:
Matt
2026-02-14 15:26:42 +01:00
parent e56e143a40
commit b5425e705e
374 changed files with 116737 additions and 111969 deletions

View File

@@ -1,145 +1,145 @@
import { NextRequest, NextResponse } from 'next/server'
import nodemailer from 'nodemailer'
import { checkRateLimit } from '@/lib/rate-limit'
import { auth } from '@/lib/auth'
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> {
// Verify authenticated session
const session = await auth()
if (!session?.user?.email) {
return NextResponse.json(
{ error: 'Authentication required.' },
{ status: 401 }
)
}
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()
// Verify the user can only change their own email password
if (emailLower !== session.user.email.toLowerCase()) {
return NextResponse.json(
{ error: 'You can only change your own email password.' },
{ status: 403 }
)
}
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 }
)
}
}
import { NextRequest, NextResponse } from 'next/server'
import nodemailer from 'nodemailer'
import { checkRateLimit } from '@/lib/rate-limit'
import { auth } from '@/lib/auth'
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> {
// Verify authenticated session
const session = await auth()
if (!session?.user?.email) {
return NextResponse.json(
{ error: 'Authentication required.' },
{ status: 401 }
)
}
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()
// Verify the user can only change their own email password
if (emailLower !== session.user.email.toLowerCase()) {
return NextResponse.json(
{ error: 'You can only change your own email password.' },
{ status: 403 }
)
}
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 }
)
}
}

View File

@@ -1,84 +1,84 @@
import { NextRequest, NextResponse } from 'next/server'
import nodemailer from 'nodemailer'
import { checkRateLimit } from '@/lib/rate-limit'
import { auth } from '@/lib/auth'
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> {
// Verify authenticated session
const session = await auth()
if (!session?.user?.email) {
return NextResponse.json(
{ error: 'Authentication required.' },
{ status: 401 }
)
}
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()
// Verify the user can only check their own email credentials
if (emailLower !== session.user.email.toLowerCase()) {
return NextResponse.json(
{ error: 'You can only verify your own email credentials.' },
{ status: 403 }
)
}
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 }
)
}
}
import { NextRequest, NextResponse } from 'next/server'
import nodemailer from 'nodemailer'
import { checkRateLimit } from '@/lib/rate-limit'
import { auth } from '@/lib/auth'
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> {
// Verify authenticated session
const session = await auth()
if (!session?.user?.email) {
return NextResponse.json(
{ error: 'Authentication required.' },
{ status: 401 }
)
}
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()
// Verify the user can only check their own email credentials
if (emailLower !== session.user.email.toLowerCase()) {
return NextResponse.json(
{ error: 'You can only verify your own email credentials.' },
{ status: 403 }
)
}
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 }
)
}
}