diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7872be1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +Dockerfile +.dockerignore +.git +.gitignore +README.md +ROADMAP.md +CONTRIBUTING.md +.env*.local +.next +.vercel +*.md +.github +.vscode +screenshots +node_modules \ No newline at end of file diff --git a/.env.example b/.env.example index 5b695be..41b4281 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,122 @@ -# JMAP Webmail Configuration -# Copy this file to .env.local and fill in your values +# ============================================================ +# JMAP WEBMAIL - ENVIRONMENT CONFIGURATION +# ============================================================ +# This file documents all environment variables that can be +# configured for Docker/Kubernetes deployments. +# Copy this file to .env.local for local development. +# ============================================================ -# App name displayed in the UI -NEXT_PUBLIC_APP_NAME=JMAP Webmail +# ============================================================ +# REQUIRED CONFIGURATION +# ============================================================ -# JMAP server URL (required) -# This is the URL of your JMAP-compatible mail server -NEXT_PUBLIC_JMAP_SERVER_URL=https://your-jmap-server.com +# JMAP server URL (REQUIRED for email functionality) +# This should point to your Stalwart Mail Server or other JMAP-compatible server +JMAP_SERVER_URL=https://mail.example.com + +# ============================================================ +# APPLICATION SETTINGS +# ============================================================ + +# Application name displayed in the UI +# Default: Webmail +APP_NAME=JMAP Webmail + +# ============================================================ +# INTERNATIONALIZATION & LOCALIZATION +# ============================================================ + +# Server timezone for date/time rendering +# Default: UTC +# Examples: UTC, America/New_York, Europe/London, Europe/Paris, Asia/Tokyo +TZ=UTC + +# Alternative timezone environment variable (same as TZ) +# TIMEZONE=UTC + +# ============================================================ +# HEALTH CHECK CONFIGURATION +# ============================================================ + +# Memory warning threshold (0.0 to 1.0) +# Triggers "degraded" status when heap usage exceeds this percentage +# Default: 0.85 (85%) +HEALTH_MEMORY_WARNING_THRESHOLD=0.85 + +# Memory critical threshold (0.0 to 1.0) +# Triggers "unhealthy" status and returns HTTP 503 +# Default: 0.95 (95%) +HEALTH_MEMORY_CRITICAL_THRESHOLD=0.95 + +# ============================================================ +# NODE.JS & NEXT.JS CONFIGURATION +# ============================================================ + +# Node environment +# Values: production, development +# Default: production +NODE_ENV=production + +# Node.js memory allocation (in MB) +# Controls the maximum heap size for Node.js +# Default: Node.js default (~1.4GB on 64-bit, or auto-detected) +# Recommended for containers: 512MB minimum for Next.js +# Adjust based on your container resource limits +NODE_OPTIONS=--max-old-space-size=512 + +# Port the application listens on +# Default: 3000 +# Note: Must match the container's exposed port +PORT=3000 + +# Hostname to bind to +# Default: 0.0.0.0 (all interfaces - required for containers) +# For local development, you might use: localhost or 127.0.0.1 +HOSTNAME=0.0.0.0 + +# ============================================================ +# LOGGING CONFIGURATION +# ============================================================ + +# Log level controls verbosity of logs +# Values: error, warn, info, debug +# Default: info +# - error: Only log errors +# - warn: Log warnings and errors +# - info: Log general information, warnings, and errors (recommended) +# - debug: Log everything including debug information +LOG_LEVEL=info + +# Log format for output +# Values: text, json +# Default: text +# - text: Human-readable colored logs (good for development) +# - json: Structured JSON logs (good for production/log aggregation) +# Use 'json' with: Kubernetes, Fluentd, Loki, CloudWatch, Datadog, etc. +LOG_FORMAT=text + +# ============================================================ +# LEGACY SUPPORT (Build-time variables) +# ============================================================ +# These are only used as fallbacks if runtime variables are not set +# Prefer using the runtime variables above for Docker/Kubernetes + +# NEXT_PUBLIC_APP_NAME=Webmail +# NEXT_PUBLIC_JMAP_SERVER_URL=https://mail.example.com + +# ============================================================ +# KUBERNETES SECRETS EXAMPLE +# ============================================================ +# In Kubernetes, you can reference secrets instead of hardcoding values: +# +# env: +# - name: JMAP_SERVER_URL +# valueFrom: +# secretKeyRef: +# name: stalwart-config +# key: jmap-url +# - name: APP_NAME +# value: "My Corporate Webmail" +# - name: TZ +# value: "America/New_York" +# ============================================================ diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..66a2ff4 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,123 @@ +name: Docker Build & Push + +on: + pull_request: + branches: + - main + paths: + - 'app/**' + - 'components/**' + - 'lib/**' + - 'public/**' + - 'Dockerfile' + - 'next.config.ts' + - 'tsconfig.json' + - 'package.json' + - 'package-lock.json' + - '.dockerignore' + release: + types: [published] + +permissions: + contents: read + packages: write + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Extract metadata + id: meta + run: | + # Get version from package.json + VERSION=$(grep '"version"' package.json | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/') + echo "version=${VERSION}" >> $GITHUB_OUTPUT + + # Determine if this is a release + if [[ "${{ github.event_name }}" == "release" ]]; then + echo "is_release=true" >> $GITHUB_OUTPUT + echo "release_tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT + else + echo "is_release=false" >> $GITHUB_OUTPUT + fi + + echo "Metadata: version=${VERSION}, is_release=${{ github.event_name == 'release' }}" + + - name: Docker meta + id: docker_meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/${{ github.repository }} + tags: | + type=ref,event=pr,prefix=pr-,suffix= + type=semver,pattern={{version}},value=${{ steps.meta.outputs.version }},enable=${{ steps.meta.outputs.is_release }} + type=semver,pattern={{major}}.{{minor}},value=${{ steps.meta.outputs.version }},enable=${{ steps.meta.outputs.is_release }} + type=semver,pattern={{major}},value=${{ steps.meta.outputs.version }},enable=${{ steps.meta.outputs.is_release }} + type=raw,value=latest,enable=${{ steps.meta.outputs.is_release }} + type=sha + labels: | + org.opencontainers.image.description=A modern, privacy-focused webmail client built with Next.js and the JMAP protocol. + org.opencontainers.image.source=https://github.com/root-fr/jmap-webmail + org.opencontainers.image.licenses=MIT + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push (pull request) + if: github.event_name == 'pull_request' + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64 + push: true + tags: ${{ steps.docker_meta.outputs.tags }} + labels: ${{ steps.docker_meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + sbom: true + provenance: true + + - name: Build and push (release) + if: github.event_name == 'release' + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.docker_meta.outputs.tags }} + labels: ${{ steps.docker_meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + sbom: true + provenance: true + + notify: + needs: build + runs-on: ubuntu-latest + if: github.event_name == 'release' + steps: + - name: Release notification + run: | + echo "✅ Docker image built and pushed to GHCR" + echo "📦 Repository: ghcr.io/${{ github.repository }}" + echo "🏷️ Tags:" + echo " - ${{ github.event.release.tag_name }}" + echo " - latest" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b71f747 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,70 @@ +# Build stage +FROM node:22-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Update npm to pinned version +RUN npm install -g npm@11.7.0 + +# Install dependencies +RUN npm install --frozen-lockfile + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM node:22-alpine AS runner + +WORKDIR /app + +# Install curl for healthcheck +RUN apk add --no-cache curl + +# Create a non-root user +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Copy package files +COPY package*.json ./ + +# Update npm to pinned version +RUN npm install -g npm@11.7.0 + +# Install production dependencies +RUN npm install --omit=dev --ignore-scripts && npm cache clean --force + +# Copy public assets +COPY --from=builder /app/public ./public + +# Copy built application +COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next + +# Switch to non-root user +USER nextjs + +# Expose port +EXPOSE 3000 + +# Set environment variables +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" +ENV APP_NAME=Webmail + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:3000/api/health || exit 1 + +# Labels for Kubernetes +LABEL org.opencontainers.image.title="JMAP Webmail" +LABEL org.opencontainers.image.description="A modern, privacy-focused webmail client built with Next.js and the JMAP protocol" +LABEL org.opencontainers.image.source="https://github.com/root-fr/jmap-webmail" +LABEL org.opencontainers.image.licenses="MIT" + +# Start the application +CMD ["npm", "start"] \ No newline at end of file diff --git a/README.md b/README.md index 04b8c74..cb66980 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server* ### Prerequisites -- Node.js 18+ +- Node.js 22+ - A JMAP-compatible mail server (we recommend [Stalwart](https://stalw.art/)) ### Installation diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 7ff9fa6..68384c9 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -19,6 +19,7 @@ export default function LoginPage() { const [formData, setFormData] = useState({ username: "", password: "", + totpCode: "", }); const [savedUsernames, setSavedUsernames] = useState([]); diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..97ff82c --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { JMAPClient } from '@/lib/jmap/client'; +import { cookies } from 'next/headers'; + +const JMAP_SERVER_URL = + process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL; + +const sessions = new Map(); + +export { sessions }; + +const SESSION_TIMEOUT = parseInt(process.env.SESSION_TIMEOUT_MS || '86400000'); // 24 hours default + +function generateSessionId(): string { + return crypto.randomUUID(); +} + +function cleanupExpiredSessions() { + const now = Date.now(); + for (const [id, session] of sessions) { + if (session.expires < now) { + sessions.delete(id); + } + } +} + +export async function POST(request: NextRequest) { + if (!JMAP_SERVER_URL) { + logger.error('JMAP_SERVER_URL not configured'); + return NextResponse.json({ error: 'Server misconfigured' }, { status: 500 }); + } + + try { + const body = await request.json(); + const { serverUrl, username, password } = body; + + if (!serverUrl || !username || !password) { + return NextResponse.json({ error: 'Missing credentials' }, { status: 400 }); + } + + // Create JMAP client + const client = new JMAPClient(serverUrl, username, password); + + // Try to connect + await client.connect(); + + // Create session + const sessionId = generateSessionId(); + const expires = Date.now() + SESSION_TIMEOUT; + + sessions.set(sessionId, { client, expires }); + + // Cleanup old sessions + cleanupExpiredSessions(); + + logger.info('User logged in', { username, sessionId }); + + // Set httpOnly cookie + const response = NextResponse.json({ success: true }); + response.cookies.set('jmap-session', sessionId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: SESSION_TIMEOUT / 1000, + }); + + return response; + } catch (error) { + logger.error('Login failed', { error: error instanceof Error ? error.message : error }); + return NextResponse.json({ error: 'Authentication failed' }, { status: 401 }); + } +} + +export async function DELETE() { + const cookieStore = await cookies(); + const sessionId = cookieStore.get('jmap-session')?.value; + + if (sessionId) { + const session = sessions.get(sessionId); + if (session) { + session.client.disconnect(); + sessions.delete(sessionId); + } + } + + const response = NextResponse.json({ success: true }); + response.cookies.set('jmap-session', '', { maxAge: 0 }); + return response; +} \ No newline at end of file diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 553024b..5be5523 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; /** * Runtime configuration endpoint @@ -13,8 +14,20 @@ import { NextResponse } from 'next/server'; * 3. Default values */ export async function GET() { + const appName = process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail'; + const jmapServerUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || ''; + + logger.debug('Config requested', { + appName, + jmapServerUrl: jmapServerUrl ? '[CONFIGURED]' : '[NOT SET]', + }); + + if (!jmapServerUrl) { + logger.warn('JMAP_SERVER_URL not configured'); + } + return NextResponse.json({ - appName: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail', - jmapServerUrl: process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '', + appName, + jmapServerUrl, }); } diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 28ca306..1cb296e 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,9 +1,10 @@ import { NextResponse } from 'next/server'; import { NextRequest } from 'next/server'; +import { logger } from '@/lib/logger'; -// Health check thresholds -const MEMORY_WARNING_THRESHOLD = 0.85; // 85% heap usage -const MEMORY_CRITICAL_THRESHOLD = 0.95; // 95% heap usage +// Health check thresholds (configurable via environment variables) +const MEMORY_WARNING_THRESHOLD = parseFloat(process.env.HEALTH_MEMORY_WARNING_THRESHOLD || '0.85'); +const MEMORY_CRITICAL_THRESHOLD = parseFloat(process.env.HEALTH_MEMORY_CRITICAL_THRESHOLD || '0.95'); interface HealthStatus { status: 'healthy' | 'degraded' | 'unhealthy'; @@ -52,9 +53,17 @@ export async function GET(request: NextRequest) { if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) { status = 'unhealthy'; httpStatus = 503; + logger.error('Health check failed: Memory critical', { + heapUsagePercent: heapUsagePercent.toFixed(1), + threshold: MEMORY_CRITICAL_THRESHOLD, + }); } else if (heapUsagePercent >= MEMORY_WARNING_THRESHOLD * 100) { status = 'degraded'; warnings.push(`Memory usage high: ${heapUsagePercent.toFixed(1)}%`); + logger.warn('Health check degraded: Memory high', { + heapUsagePercent: heapUsagePercent.toFixed(1), + threshold: MEMORY_WARNING_THRESHOLD, + }); } // Build response diff --git a/app/api/jmap/session/route.ts b/app/api/jmap/session/route.ts new file mode 100644 index 0000000..5214279 --- /dev/null +++ b/app/api/jmap/session/route.ts @@ -0,0 +1,193 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { cookies } from 'next/headers'; + +import { sessions } from '../../auth/login/route'; + +const JMAP_SERVER_URL = + process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL; + +const FETCH_TIMEOUT_MS = 5000; + +function forbidden() { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); +} + +export async function GET(request: NextRequest) { + if (!JMAP_SERVER_URL) { + logger.error('JMAP_SERVER_URL not configured'); + return NextResponse.json({ error: 'Server misconfigured' }, { status: 500 }); + } + + const origin = request.headers.get('origin'); + const host = request.headers.get('host'); + + if (origin && host && !origin.includes(host)) { + logger.warn('Blocked cross-origin JMAP session request', { origin, host }); + return forbidden(); + } + + const cookieStore = await cookies(); + const sessionId = cookieStore.get('jmap-session')?.value; + let authHeader: string | undefined; + + if (sessionId) { + const session = sessions.get(sessionId); + if (session && session.expires > Date.now()) { + authHeader = session.client.getAuthHeader(); + } else { + sessions.delete(sessionId); + return NextResponse.json({ error: 'Session expired' }, { status: 401 }); + } + } else { + authHeader = request.headers.get('authorization') || undefined; + if (!authHeader || (!authHeader.startsWith('Basic ') && !authHeader.startsWith('Bearer '))) { + return NextResponse.json({ error: 'Authorization required' }, { status: 401 }); + } + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + logger.info('Fetching JMAP session from upstream', { + url: `${JMAP_SERVER_URL}/.well-known/jmap`, + hasAuth: !!authHeader, + authType: authHeader?.split(' ')[0], + }); + + const response = await fetch( + `${JMAP_SERVER_URL}/.well-known/jmap`, + { + method: 'GET', + headers: { + Authorization: authHeader, + Accept: 'application/json', + }, + signal: controller.signal, + redirect: 'follow', // Follow 307 redirects from .well-known/jmap to /jmap/session + } + ); + + clearTimeout(timeout); + + if (!response.ok) { + const responseText = await response.text(); + logger.warn('Upstream JMAP auth failed', { + status: response.status, + statusText: response.statusText, + responseBody: responseText.substring(0, 500), + url: `${JMAP_SERVER_URL}/.well-known/jmap`, + }); + + return NextResponse.json( + { error: `Authentication failed: ${response.status} ${response.statusText}` }, + { status: response.status } + ); + } + + const json = await response.json(); + + // Fix mixed content issues - rewrite HTTP URLs to HTTPS + // This is necessary when the JMAP server returns HTTP URLs but the webmail uses HTTPS + // Security: Only rewrite URLs from the configured JMAP server domain to prevent URL injection + const serverUrl = new URL(JMAP_SERVER_URL); + const serverHostname = serverUrl.hostname; + // Extract base domain (e.g., peekoff.com from mailadmin.peekoff.com) + const domainParts = serverHostname.split('.'); + const baseDomain = domainParts.length >= 2 + ? domainParts.slice(-2).join('.') + : serverHostname; + + const rewriteUrl = (url: string): string => { + if (!url || !url.startsWith('http://')) return url; + + try { + const urlObj = new URL(url); + const urlHostname = urlObj.hostname; + + // Check if the URL is from the same domain or subdomain + const isAllowed = urlHostname === serverHostname || + urlHostname.endsWith(`.${baseDomain}`) || + urlHostname === baseDomain; + + if (isAllowed) { + urlObj.protocol = 'https:'; + // Remove non-standard ports when switching to HTTPS + if (urlObj.port === '8080' || urlObj.port === '80') { + urlObj.port = ''; + } + return urlObj.toString(); + } + + logger.warn('Refusing to rewrite URL from different domain', { + url, + serverHostname, + baseDomain, + urlHostname, + isAllowed + }); + return url; + } catch (err) { + logger.error('Failed to parse URL for rewriting', { url, error: err }); + return url; + } + }; + + if (json.apiUrl) { + const rewritten = rewriteUrl(json.apiUrl); + if (rewritten !== json.apiUrl) { + logger.info('Rewrote apiUrl to HTTPS', { original: json.apiUrl, rewritten }); + json.apiUrl = rewritten; + } + } + if (json.downloadUrl) { + const rewritten = rewriteUrl(json.downloadUrl); + if (rewritten !== json.downloadUrl) { + logger.info('Rewrote downloadUrl to HTTPS', { original: json.downloadUrl, rewritten }); + json.downloadUrl = rewritten; + } + } + if (json.uploadUrl) { + const rewritten = rewriteUrl(json.uploadUrl); + if (rewritten !== json.uploadUrl) { + logger.info('Rewrote uploadUrl to HTTPS', { original: json.uploadUrl, rewritten }); + json.uploadUrl = rewritten; + } + } + if (json.eventSourceUrl) { + const rewritten = rewriteUrl(json.eventSourceUrl); + if (rewritten !== json.eventSourceUrl) { + logger.info('Rewrote eventSourceUrl to HTTPS', { original: json.eventSourceUrl, rewritten }); + json.eventSourceUrl = rewritten; + } + } + + logger.info('JMAP session fetched successfully'); + return NextResponse.json(json); + + } catch (err) { + clearTimeout(timeout); + + logger.error('JMAP session proxy error', { + error: err instanceof Error ? err.message : err, + stack: err instanceof Error ? err.stack : undefined, + url: `${JMAP_SERVER_URL}/.well-known/jmap`, + }); + + return NextResponse.json( + { error: 'Upstream unavailable' }, + { status: 502 } + ); + } +} + +export function POST() { + return NextResponse.json({ error: 'Method not allowed' }, { status: 405 }); +} +export function PUT() { + return NextResponse.json({ error: 'Method not allowed' }, { status: 405 }); +} +export function DELETE() { + return NextResponse.json({ error: 'Method not allowed' }, { status: 405 }); +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index ec7324f..f6b388d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,5 @@ import { ReactNode } from 'react'; +import '@/lib/server-init'; // Initialize logging on server startup type Props = { children: ReactNode; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..abba6ef --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,89 @@ +services: + webmail: + build: . + image: jmap-webmail:latest + container_name: jmap-webmail + ports: + - "3002:3000" + environment: + # ============================================================ + # REQUIRED CONFIGURATION + # ============================================================ + + # JMAP server URL (required for email functionality) + - JMAP_SERVER_URL=https://mailadmin.peekoff.com + + # Session timeout in milliseconds (default: 86400000 = 24 hours) + - SESSION_TIMEOUT_MS=86400000 + + # ============================================================ + # APPLICATION SETTINGS + # ============================================================ + + # Application name displayed in UI (default: Webmail) + - APP_NAME=JMAP Webmail + + # ============================================================ + # INTERNATIONALIZATION + # ============================================================ + + # Timezone for server-side rendering (default: UTC) + # Examples: UTC, America/New_York, Europe/Paris, Asia/Tokyo + - TZ=UTC + # Alternative env var name + # - TIMEZONE=UTC + + # ============================================================ + # HEALTH CHECK CONFIGURATION + # ============================================================ + + # Memory warning threshold (0.0-1.0, default: 0.85 = 85%) + - HEALTH_MEMORY_WARNING_THRESHOLD=0.85 + + # Memory critical threshold (0.0-1.0, default: 0.95 = 95%) + - HEALTH_MEMORY_CRITICAL_THRESHOLD=0.95 + + # ============================================================ + # NODE.JS & NEXT.JS CONFIGURATION + # ============================================================ + + # Node environment (production/development) + - NODE_ENV=production + + # Node.js memory allocation (in MB) + # Allocate sufficient heap space for Next.js production + # Default heap is too small, causing "degraded" health status + - NODE_OPTIONS=--max-old-space-size=512 + + # Next.js port (default: 3000, should match container port) + - PORT=3000 + + # Next.js hostname binding (default: 0.0.0.0 for containers) + - HOSTNAME=0.0.0.0 + + # ============================================================ + # LOGGING CONFIGURATION + # ============================================================ + + # Log level (error, warn, info, debug) + # Default: info + - LOG_LEVEL=debug + + # Log level (error, warn, info, debug) + # Default: info + - LOG_LEVEL=debug + + # Log format (text or json) + # Use 'json' for log aggregation tools (Fluentd, Loki, CloudWatch) + # Use 'text' for human-readable logs + # Default: text + - LOG_FORMAT=text + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"] + interval: 30s + timeout: 3s + start_period: 5s + retries: 3 + # Security: Runs as non-root user (UID 1001) + user: "1001:1001" diff --git a/i18n/request.ts b/i18n/request.ts index deb9a27..a27dbd2 100644 --- a/i18n/request.ts +++ b/i18n/request.ts @@ -16,7 +16,7 @@ export default getRequestConfig(async ({ requestLocale }) => { return { locale, messages, - timeZone: 'Europe/Paris', + timeZone: process.env.TZ || process.env.TIMEZONE || 'UTC', now: new Date() }; }); \ No newline at end of file diff --git a/kubernetes-deployment.yaml b/kubernetes-deployment.yaml new file mode 100644 index 0000000..8f19b0a --- /dev/null +++ b/kubernetes-deployment.yaml @@ -0,0 +1,205 @@ +--- +# Kubernetes Secret for sensitive configuration +apiVersion: v1 +kind: Secret +metadata: + name: jmap-webmail-secrets + namespace: default +type: Opaque +stringData: + jmap-server-url: "https://mail.example.com" + +--- +# Kubernetes ConfigMap for non-sensitive configuration +apiVersion: v1 +kind: ConfigMap +metadata: + name: jmap-webmail-config + namespace: default +data: + APP_NAME: "JMAP Webmail" + TZ: "UTC" + HEALTH_MEMORY_WARNING_THRESHOLD: "0.85" + HEALTH_MEMORY_CRITICAL_THRESHOLD: "0.95" + NODE_ENV: "production" + PORT: "3000" + HOSTNAME: "0.0.0.0" + +--- +# Kubernetes Deployment +apiVersion: apps/v1 +kind: Deployment +metadata: + name: jmap-webmail + namespace: default + labels: + app: jmap-webmail +spec: + replicas: 2 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + app: jmap-webmail + template: + metadata: + labels: + app: jmap-webmail + spec: + # Security context for non-root execution + securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 + + containers: + - name: webmail + image: jmap-webmail:latest + imagePullPolicy: IfNotPresent + + ports: + - name: http + containerPort: 3000 + protocol: TCP + + # Environment variables from Secret + env: + - name: JMAP_SERVER_URL + valueFrom: + secretKeyRef: + name: jmap-webmail-secrets + key: jmap-server-url + + # Environment variables from ConfigMap + envFrom: + - configMapRef: + name: jmap-webmail-config + + # Resource limits + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + + # Liveness probe - checks if container is alive + livenessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + + # Readiness probe - checks if container is ready to serve traffic + readinessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 2 + + # Startup probe - gives app time to start + startupProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 0 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 12 + + # Security context + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL + +--- +# Kubernetes Service +apiVersion: v1 +kind: Service +metadata: + name: jmap-webmail + namespace: default + labels: + app: jmap-webmail +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: http + protocol: TCP + name: http + selector: + app: jmap-webmail + +--- +# Kubernetes Ingress (optional, for external access) +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: jmap-webmail + namespace: default + annotations: + # Configure according to your ingress controller + # Example for nginx-ingress: + # nginx.ingress.kubernetes.io/ssl-redirect: "true" + # cert-manager.io/cluster-issuer: "letsencrypt-prod" +spec: + ingressClassName: nginx + rules: + - host: webmail.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: jmap-webmail + port: + name: http + # TLS configuration (optional, requires cert-manager or manual certificate) + # tls: + # - hosts: + # - webmail.example.com + # secretName: jmap-webmail-tls + +--- +# Horizontal Pod Autoscaler (optional, for auto-scaling) +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: jmap-webmail + namespace: default +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: jmap-webmail + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 578d701..07dc053 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -80,9 +80,13 @@ export class JMAPClient { this.authHeader = `Basic ${btoa(`${username}:${password}`)}`; } + getAuthHeader(): string { + return this.authHeader; + } + async connect(): Promise { // Get the session first - const sessionUrl = `${this.serverUrl}/.well-known/jmap`; + const sessionUrl = '/api/jmap/session'; try { const sessionResponse = await fetch(sessionUrl, { diff --git a/lib/logger.ts b/lib/logger.ts new file mode 100644 index 0000000..0dd3ec5 --- /dev/null +++ b/lib/logger.ts @@ -0,0 +1,75 @@ +/** + * Structured logging utility for container/Kubernetes environments + * + * Supports both human-readable and JSON formatted logs based on LOG_FORMAT env var. + * Log levels: error, warn, info, debug + */ + +type LogLevel = 'error' | 'warn' | 'info' | 'debug'; + +interface LogContext { + [key: string]: unknown; +} + +const LOG_FORMAT = process.env.LOG_FORMAT || 'text'; // 'text' or 'json' +const LOG_LEVEL = process.env.LOG_LEVEL || 'info'; // error, warn, info, debug + +const LOG_LEVELS: Record = { + error: 0, + warn: 1, + info: 2, + debug: 3, +}; + +const COLORS = { + error: '\x1b[31m', // Red + warn: '\x1b[33m', // Yellow + info: '\x1b[36m', // Cyan + debug: '\x1b[90m', // Gray + reset: '\x1b[0m', +}; + +function shouldLog(level: LogLevel): boolean { + return LOG_LEVELS[level] <= LOG_LEVELS[LOG_LEVEL as LogLevel]; +} + +function formatTimestamp(): string { + return new Date().toISOString(); +} + +function log(level: LogLevel, message: string, context?: LogContext): void { + if (!shouldLog(level)) return; + + const timestamp = formatTimestamp(); + + if (LOG_FORMAT === 'json') { + // JSON format for log aggregation tools (Fluentd, Loki, etc.) + const logEntry = { + timestamp, + level: level.toUpperCase(), + message, + ...context, + }; + console.log(JSON.stringify(logEntry)); + } else { + // Human-readable format + const color = COLORS[level]; + const reset = COLORS.reset; + const levelStr = level.toUpperCase().padEnd(5); + const contextStr = context ? ` ${JSON.stringify(context)}` : ''; + + console.log(`${color}[${timestamp}] ${levelStr}${reset} ${message}${contextStr}`); + } +} + +export const logger = { + error: (message: string, context?: LogContext) => log('error', message, context), + warn: (message: string, context?: LogContext) => log('warn', message, context), + info: (message: string, context?: LogContext) => log('info', message, context), + debug: (message: string, context?: LogContext) => log('debug', message, context), + + // Request logging helper + request: (method: string, path: string, status: number, duration: number) => { + log('info', `${method} ${path} ${status}`, { duration: `${duration}ms` }); + }, +}; diff --git a/lib/server-init.ts b/lib/server-init.ts new file mode 100644 index 0000000..32b74aa --- /dev/null +++ b/lib/server-init.ts @@ -0,0 +1,52 @@ +import { logger } from '@/lib/logger'; + +/** + * Server initialization logging + * Logs configuration on application startup for debugging and monitoring + */ + +if (typeof window === 'undefined') { + // Server-side only + const config = { + appName: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail', + jmapServerUrl: process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '[NOT SET]', + nodeEnv: process.env.NODE_ENV || 'development', + port: process.env.PORT || '3000', + hostname: process.env.HOSTNAME || '0.0.0.0', + timezone: process.env.TZ || process.env.TIMEZONE || 'UTC', + logLevel: process.env.LOG_LEVEL || 'info', + logFormat: process.env.LOG_FORMAT || 'text', + healthWarningThreshold: process.env.HEALTH_MEMORY_WARNING_THRESHOLD || '0.85', + healthCriticalThreshold: process.env.HEALTH_MEMORY_CRITICAL_THRESHOLD || '0.95', + nodeVersion: process.version, + }; + + logger.info('🚀 JMAP Webmail starting...', { + appName: config.appName, + environment: config.nodeEnv, + nodeVersion: config.nodeVersion, + }); + + logger.info('📡 Server configuration', { + port: config.port, + hostname: config.hostname, + timezone: config.timezone, + }); + + logger.info('🔧 Application configuration', { + jmapServerUrl: config.jmapServerUrl !== '[NOT SET]' ? '✓ Configured' : '✗ Not configured', + logLevel: config.logLevel, + logFormat: config.logFormat, + }); + + logger.info('💚 Health check configuration', { + warningThreshold: `${(parseFloat(config.healthWarningThreshold) * 100).toFixed(0)}%`, + criticalThreshold: `${(parseFloat(config.healthCriticalThreshold) * 100).toFixed(0)}%`, + }); + + if (config.jmapServerUrl === '[NOT SET]') { + logger.warn('⚠️ JMAP_SERVER_URL not configured - email functionality will not work'); + } + + logger.info('✓ Initialization complete'); +} diff --git a/next.config.ts b/next.config.ts index e9ffa30..ce0388a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,76 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ + async headers() { + return [ + { + source: '/(.*)', + headers: [ + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'Referrer-Policy', value: 'no-referrer' }, + { key: 'Permissions-Policy', value: 'geolocation=(), microphone=(), camera=()' }, + { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }, + { + key: 'Content-Security-Policy', + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", // unsafe-eval needed for Next.js dev mode + "style-src 'self' 'unsafe-inline'", // unsafe-inline needed for Tailwind and email styles + "img-src 'self' data: https: blob:", // Allow images from emails + "font-src 'self' data:", + "connect-src 'self' https: wss: ws:", // Allow HTTPS/WSS connections to any JMAP server + "frame-src 'none'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "upgrade-insecure-requests" + ].join('; ') + }, + ], + }, + ]; + }, + async rewrites() { + const jmapServerUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL; + if (!jmapServerUrl) return []; + + return [ + { + source: '/jmap', + destination: `${jmapServerUrl}/jmap`, + }, + { + source: '/jmap/:path*', + destination: `${jmapServerUrl}/jmap/:path*`, + }, + { + source: '/download', + destination: `${jmapServerUrl}/download`, + }, + { + source: '/download/:path*', + destination: `${jmapServerUrl}/download/:path*`, + }, + { + source: '/upload', + destination: `${jmapServerUrl}/upload`, + }, + { + source: '/upload/:path*', + destination: `${jmapServerUrl}/upload/:path*`, + }, + { + source: '/eventsource', + destination: `${jmapServerUrl}/eventsource`, + }, + { + source: '/eventsource/:path*', + destination: `${jmapServerUrl}/eventsource/:path*`, + }, + ]; + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index bfab536..8df66a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jmap-webmail", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jmap-webmail", - "version": "0.1.0", + "version": "1.0.0", "license": "MIT", "dependencies": { "@types/dompurify": "^3.0.5", @@ -15,10 +15,10 @@ "dompurify": "^3.3.1", "jmap-jam": "^0.13.1", "lucide-react": "^0.562.0", - "next": "^16.0.8", - "next-intl": "^4.5.8", - "react": "^19.2.1", - "react-dom": "^19.2.1", + "next": "^16.1.2", + "next-intl": "^4.7.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zustand": "^5.0.9" @@ -36,13 +36,13 @@ "@vitejs/plugin-react": "^5.1.2", "@vitest/ui": "^4.0.16", "eslint": "^9.39.1", - "eslint-config-next": "^16.0.8", + "eslint-config-next": "^16.1.2", "eslint-plugin-react": "^7.37.5", "globals": "^17.0.0", "husky": "^9.1.7", "jsdom": "^27.4.0", "lint-staged": "^16.2.7", - "tailwindcss": "^4.1.17", + "tailwindcss": "^4.1.18", "typescript": "^5.9.3", "vitest": "^4.0.16" } @@ -160,7 +160,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -530,7 +529,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -574,7 +572,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1916,15 +1913,15 @@ } }, "node_modules/@next/env": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz", - "integrity": "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.2.tgz", + "integrity": "sha512-r6TpLovDTvWtzw11UubUQxEK6IduT8rSAHbGX68yeFpA/1Oq9R4ovi5nqMUMgPN0jr2SpfeyFRbTZg3Inuuv3g==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.1.tgz", - "integrity": "sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.2.tgz", + "integrity": "sha512-jjO5BKDxZEXt2VCAnAG/ldULnpxeXspjCo9AZErV3Lm5HmNj8r2rS+eUMIAAj6mXPAOiPqAMgVPGnkyhPyDx4g==", "dev": true, "license": "MIT", "dependencies": { @@ -1932,9 +1929,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.1.tgz", - "integrity": "sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.2.tgz", + "integrity": "sha512-0N2baysDpTXASTVxTV+DkBnD97bo9PatUj8sHlKA+oR9CyvReaPQchQyhCbH0Jm0mC/Oka5F52intN+lNOhSlA==", "cpu": [ "arm64" ], @@ -1948,9 +1945,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.1.tgz", - "integrity": "sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.2.tgz", + "integrity": "sha512-Q0wnSK0lmeC9ps+/w/bDsMSF3iWS45WEwF1bg8dvMH3CmKB2BV4346tVrjWxAkrZq20Ro6Of3R19IgrEJkXKyw==", "cpu": [ "x64" ], @@ -1964,9 +1961,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.1.tgz", - "integrity": "sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.2.tgz", + "integrity": "sha512-4twW+h7ZatGKWq+2pUQ9SDiin6kfZE/mY+D8jOhSZ0NDzKhQfAPReXqwTDWVrNjvLzHzOcDL5kYjADHfXL/b/Q==", "cpu": [ "arm64" ], @@ -1980,9 +1977,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.1.tgz", - "integrity": "sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.2.tgz", + "integrity": "sha512-Sn6LxPIZcADe5AnqqMCfwBv6vRtDikhtrjwhu+19WM6jHZe31JDRcGuPZAlJrDk6aEbNBPUUAKmySJELkBOesg==", "cpu": [ "arm64" ], @@ -1996,9 +1993,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.1.tgz", - "integrity": "sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.2.tgz", + "integrity": "sha512-nwzesEQBfQIOOnQ7JArzB08w9qwvBQ7nC1i8gb0tiEFH94apzQM3IRpY19MlE8RBHxc9ArG26t1DEg2aaLaqVQ==", "cpu": [ "x64" ], @@ -2012,9 +2009,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.1.tgz", - "integrity": "sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.2.tgz", + "integrity": "sha512-s60bLf16BDoICQHeKEm0lDgUNMsL1UpQCkRNZk08ZNnRpK0QUV+6TvVHuBcIA7oItzU0m7kVmXe8QjXngYxJVA==", "cpu": [ "x64" ], @@ -2028,9 +2025,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.1.tgz", - "integrity": "sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.2.tgz", + "integrity": "sha512-Sq8k4SZd8Y8EokKdz304TvMO9HoiwGzo0CTacaiN1bBtbJSQ1BIwKzNFeFdxOe93SHn1YGnKXG6Mq3N+tVooyQ==", "cpu": [ "arm64" ], @@ -2044,9 +2041,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.1.tgz", - "integrity": "sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.2.tgz", + "integrity": "sha512-KQDBwspSaNX5/wwt6p7ed5oINJWIxcgpuqJdDNubAyq7dD+ZM76NuEjg8yUxNOl5R4NNgbMfqE/RyNrsbYmOKg==", "cpu": [ "x64" ], @@ -3500,7 +3497,6 @@ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3511,7 +3507,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3522,7 +3517,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3568,7 +3562,6 @@ "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -4160,7 +4153,6 @@ "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", @@ -4197,7 +4189,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4595,7 +4586,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5410,7 +5400,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5466,13 +5455,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.1.tgz", - "integrity": "sha512-55nTpVWm3qeuxoQKLOjQVciKZJUphKrNM0fCcQHAIOGl6VFXgaqeMfv0aKJhs7QtcnlAPhNVqsqRfRjeKBPIUA==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.2.tgz", + "integrity": "sha512-y97rpFfUsaXdXlQc2FMl/yqRc5yfVVKtKRcv+7LeyBrKh83INFegJuZBE28dc9Chp4iKXwmjaW4sHHx/mgyDyA==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.1.1", + "@next/eslint-plugin-next": "16.1.2", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -5596,7 +5585,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7129,7 +7117,6 @@ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -7840,12 +7827,12 @@ } }, "node_modules/next": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/next/-/next-16.1.1.tgz", - "integrity": "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.2.tgz", + "integrity": "sha512-SVSWX7wjUUDrIDVqhl4xm/jiOrvYGMG7NzVE/dGzzgs7r3dFGm4V19ia0xn3GDNtHCKM7C9h+5BoimnJBhmt9A==", "license": "MIT", "dependencies": { - "@next/env": "16.1.1", + "@next/env": "16.1.2", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", @@ -7859,14 +7846,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.1.1", - "@next/swc-darwin-x64": "16.1.1", - "@next/swc-linux-arm64-gnu": "16.1.1", - "@next/swc-linux-arm64-musl": "16.1.1", - "@next/swc-linux-x64-gnu": "16.1.1", - "@next/swc-linux-x64-musl": "16.1.1", - "@next/swc-win32-arm64-msvc": "16.1.1", - "@next/swc-win32-x64-msvc": "16.1.1", + "@next/swc-darwin-arm64": "16.1.2", + "@next/swc-darwin-x64": "16.1.2", + "@next/swc-linux-arm64-gnu": "16.1.2", + "@next/swc-linux-arm64-musl": "16.1.2", + "@next/swc-linux-x64-gnu": "16.1.2", + "@next/swc-linux-x64-musl": "16.1.2", + "@next/swc-win32-arm64-msvc": "16.1.2", + "@next/swc-win32-x64-msvc": "16.1.2", "sharp": "^0.34.4" }, "peerDependencies": { @@ -8420,7 +8407,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8430,7 +8416,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9416,7 +9401,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9659,7 +9643,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9814,7 +9797,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -9908,7 +9890,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9922,7 +9903,6 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -10324,7 +10304,6 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index cad9703..bb8cff0 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ ], "scripts": { "dev": "next dev --turbopack", - "build": "next build --turbopack", + "build": "next build", "start": "next start", "lint": "next lint", "lint:fix": "next lint --fix", @@ -37,10 +37,10 @@ "dompurify": "^3.3.1", "jmap-jam": "^0.13.1", "lucide-react": "^0.562.0", - "next": "^16.0.8", - "next-intl": "^4.5.8", - "react": "^19.2.1", - "react-dom": "^19.2.1", + "next": "^16.1.2", + "next-intl": "^4.7.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zustand": "^5.0.9" @@ -58,13 +58,13 @@ "@vitejs/plugin-react": "^5.1.2", "@vitest/ui": "^4.0.16", "eslint": "^9.39.1", - "eslint-config-next": "^16.0.8", + "eslint-config-next": "^16.1.2", "eslint-plugin-react": "^7.37.5", "globals": "^17.0.0", "husky": "^9.1.7", "jsdom": "^27.4.0", "lint-staged": "^16.2.7", - "tailwindcss": "^4.1.17", + "tailwindcss": "^4.1.18", "typescript": "^5.9.3", "vitest": "^4.0.16" } diff --git a/stores/auth-store.ts b/stores/auth-store.ts index aa8e124..0bc59e8 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -151,7 +151,6 @@ export const useAuthStore = create()( { name: 'auth-storage', partialize: (state) => ({ - // Only persist non-sensitive data serverUrl: state.serverUrl, username: state.username, // Don't persist isAuthenticated since we can't restore the session without a password