Compare commits

...

43 Commits

Author SHA1 Message Date
0d7c588536 fix: set basePath to /gatekeeper for correct sub-path routing
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 5m30s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-08 10:56:50 +01:00
b6debcbb59 fix: ensure node shebang is preserved in dev binary 2026-02-08 10:39:19 +01:00
5847bc5795 fix: nextjs eslint flat config compatibility 2026-02-07 16:38:14 +01:00
e662415137 feat: add gk_bypass
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 14m32s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 17s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 3m17s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 2m38s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 21s
Monorepo Pipeline / 🚀 Release (push) Successful in 14m53s
2026-02-07 16:02:52 +01:00
580b087e8a chore: improve pipeline 2026-02-07 15:29:45 +01:00
ac3c405cb2 chore: make cookie secure flag conditional for development and add pnpm store to gitignore 2026-02-07 15:11:16 +01:00
a594affdfa feat: add auth to gatekeeper
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 5m0s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m43s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 5m16s
2026-02-07 14:48:39 +01:00
61e78ea672 feat: integrate observability
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 3m50s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m38s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 7m6s
2026-02-07 10:23:51 +01:00
6501eac38a fix: gatekeeper access error 2026-02-07 09:46:31 +01:00
7f9206ae77 feat: Remove hardcoded /gatekeeper base path, update image paths, and introduce configurable base URL and cookie domain for improved routing and session management.
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 3m4s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m3s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 5m18s
2026-02-06 13:54:47 +01:00
6229f8e886 feat: Add Next.js basePath and relocate the login page to /login. 2026-02-06 13:39:38 +01:00
8ac090aff3 feat: Set up new Tailwind CSS configuration, global styling, and initial application pages.
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m36s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m35s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 5m18s
2026-02-06 00:20:55 +01:00
696f9d361d feat: Configure package entry points and exports for better module resolution, update build scripts to include templates, and add React peer dependencies.
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 3m26s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m8s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 5m1s
2026-02-05 13:00:34 +01:00
31840da9e7 feat: Enable global CLI linking and direct mintel command execution, updating installation instructions and project path resolution. 2026-02-05 12:12:56 +01:00
96ec2c7d8d feat: Publish initial version of the branded email system package.
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m58s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m30s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 6m48s
2026-02-05 01:46:36 +01:00
9029375247 test: add rendering tests for MintelLogo and ContactFormNotification mail components.
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m36s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m35s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 4m55s
2026-02-05 01:27:47 +01:00
95d0a1622f feat: Introduce a new mail package for email templates and update the gatekeeper login page with new logo assets.
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 42s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build & Push Images (push) Has been skipped
2026-02-05 01:05:49 +01:00
646d615e76 fix(gatekeeper): ensure public directory exists in Dockerfile to prevent COPY failure
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m29s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m28s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 7m4s
2026-02-04 22:15:44 +01:00
51409099fc feat: Increase registry pruning aggressiveness by reducing tag retention, adding version and buildcache tag deletion, and shortening Docker system prune duration. (includes chore: relax header-max-length)
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m33s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m30s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 6m38s
2026-02-04 18:43:33 +01:00
22cd20e639 fix: build workspace dependencies before gatekeeper in Docker 2026-02-04 18:39:12 +01:00
e7cc1c8ca5 fix: stabilize Docker builds by standardizing on ARM64 and explicit stage naming (klz-2026 pattern)
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m42s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m29s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 4m9s
2026-02-04 18:13:54 +01:00
0ccb15a929 refactor: restructure Dockerfile.gatekeeper into base, builder, and runner stages for improved multi-stage build. 2026-02-04 12:10:51 +01:00
a94ddcfbb2 refactor: rename Dockerfile build stage from base to builder
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m28s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m49s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 23m39s
2026-02-04 01:35:45 +01:00
d3a9af140c feat: add script to prune Docker registry tags, perform garbage collection, and prune host Docker resources. 2026-02-04 01:01:00 +01:00
0dc3ba0da4 chore: Reorder pnpm build arguments in Dockerfile.nextjs.
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m30s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m31s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 25m10s
2026-02-03 22:40:10 +01:00
1a94465dba feat: streamline Docker builds with .dockerignore and pass NPM_TOKEN as a build secret for pnpm install.
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m34s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m40s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 3m6s
2026-02-03 22:13:34 +01:00
7e256025ea refactor: remove pnpm cache configuration from Node.js setup actions in pipeline workflows. 2026-02-03 19:41:31 +01:00
e843de42da ci: Add catthehacker/ubuntu:act-latest container image to Gitea workflow jobs.
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 12m11s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 49s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m32s
2026-02-03 19:28:35 +01:00
4d1b2231e3 refactor: update pipeline to use Docker actions for registry login, build, and push.
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m35s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 2m21s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m35s
2026-02-03 19:10:35 +01:00
71f47f9037 refactor: extract language utilities to lang.ts and adjust CI pipeline triggers.
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 12m31s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 10s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m43s
2026-02-03 18:51:25 +01:00
79d41b6a73 feat: conditionally apply next-intl plugin and fix shared eslint ignore patterns
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 41s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build & Push Images (push) Has been skipped
2026-02-03 16:55:20 +01:00
6b7236ba97 fix: add refined .gitignore and exclude Directus volume data
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m33s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m31s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 9m31s
2026-02-03 12:05:01 +01:00
40a95b5353 fix: implement Lean Docker strategy with mintel/runtime and remove explicit container_name fields
Some checks failed
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🐳 Build & Push Images (push) Has been cancelled
Monorepo Pipeline / 🧪 Quality Assurance (push) Has been cancelled
2026-02-03 11:59:44 +01:00
7329e00125 fix: remove image tags from application compose files to avoid registry ambiguity
Some checks failed
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🐳 Build & Push Images (push) Has been cancelled
Monorepo Pipeline / 🧪 Quality Assurance (push) Has been cancelled
2026-02-03 11:53:01 +01:00
94be60ba4e fix: correct Docker registry strategy, add custom Directus Dockerfile, and revert app tagging 2026-02-03 11:51:45 +01:00
a8bc039c02 feat: implement centralized Docker base-image strategy and automate registry pushes
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m33s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build & Push Images (push) Has been skipped
2026-02-03 11:50:17 +01:00
653deb7995 feat: adapt klz-2026 high-performance Docker setup and environment handling
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m33s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
2026-02-03 11:44:16 +01:00
61f65107f2 feat: standardize NPM scripts across monorepo via @mintel/cli enhancement
Some checks failed
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🧪 Quality Assurance (push) Has been cancelled
2026-02-03 11:31:50 +01:00
664f165234 ci: make release job strictly tag-exclusive
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m31s
Monorepo Pipeline / 🚀 Release (push) Successful in 12m34s
2026-02-03 02:31:51 +01:00
f07e44016a ci: consolidate workflows into pipeline.yml to fix double triggers
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m30s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m28s
2026-02-03 01:35:47 +01:00
cc3ec8f0c2 feat: extract Directus sync/branding and optimize Gitea CI with Next.js lint support
Some checks failed
Code Quality / lint-and-build (push) Has been cancelled
Release Packages / release (push) Has been cancelled
2026-02-03 01:28:36 +01:00
c9db75c945 fix(sample-website): add missing i18n configuration to fix build failure
All checks were successful
Code Quality / lint-and-build (push) Successful in 2m44s
Release Packages / release (push) Successful in 2m33s
2026-02-02 13:17:55 +01:00
bb8665212a fix(gatekeeper): add missing i18n configuration to fix build failure
Some checks failed
Code Quality / lint-and-build (push) Failing after 50s
Release Packages / release (push) Failing after 37s
2026-02-02 11:37:13 +01:00
105 changed files with 4711 additions and 556 deletions

View File

@@ -0,0 +1,5 @@
---
"@mintel/mail": minor
---
Initial release of the branded email system package.

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
.next
.git
.npmrc
dist
build
out
coverage
.vercel
.turbo
*.log
.DS_Store

View File

@@ -1,39 +0,0 @@
name: Code Quality
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-build:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node_version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Lint
run: pnpm lint
- name: Test
run: pnpm test
- name: Build
run: pnpm build

View File

@@ -0,0 +1,135 @@
name: Monorepo Pipeline
on:
push:
tags:
- 'v*'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
qa:
name: 🧪 Quality Assurance
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node_version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Lint
run: pnpm lint
- name: Test
run: pnpm test
- name: Build
run: pnpm build
release:
name: 🚀 Release
needs: qa
if: startsWith(github.ref, 'refs/tags/v')
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node_version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: 🏷️ Release Packages (Tag-Driven)
run: |
echo "🏷️ Tag detected [${{ github.ref_name }}], performing sync release..."
pnpm sync-versions
pnpm release:tag
build-images:
name: 🐳 Build ${{ matrix.name }}
needs: qa
if: startsWith(github.ref, 'refs/tags/v')
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
strategy:
fail-fast: false
matrix:
include:
- image: nextjs
file: packages/infra/docker/Dockerfile.nextjs
name: Build-Base
- image: runtime
file: packages/infra/docker/Dockerfile.runtime
name: Production Runtime
- image: gatekeeper
file: packages/infra/docker/Dockerfile.gatekeeper
name: Gatekeeper (Product)
- image: directus
file: packages/infra/docker/Dockerfile.directus
name: Directus (Base)
steps:
- name: Checkout
uses: actions/checkout@v4
- name: 🐳 Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 🔐 Registry Login
uses: docker/login-action@v3
with:
registry: registry.infra.mintel.me
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }}
- name: 🏗️ Build & Push ${{ matrix.name }}
uses: docker/build-push-action@v5
with:
context: .
file: ${{ matrix.file }}
platforms: linux/arm64
pull: true
push: true
secrets: |
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
tags: |
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,45 +0,0 @@
name: Release Packages
on:
push:
branches:
- main
tags:
- 'v*'
jobs:
release:
runs-on: docker
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node_version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Release Packages
run: |
if [[ "${{ github.ref_type }}" == "tag" ]]; then
echo "🏷️ Tag detected, performing sync release..."
pnpm sync-versions
pnpm release:tag
else
echo "🚀 Push detected, looking for changesets..."
pnpm release
fi

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# dependencies
node_modules
.pnpm-debug.log*
.pnpm-store/
# next.js
.next/

View File

@@ -0,0 +1,31 @@
# Project
PROJECT_NAME=sample-website
PROJECT_COLOR=#82ed20
# Authentication
GATEKEEPER_PASSWORD=mintel
AUTH_COOKIE_NAME=mintel_gatekeeper_session
# Host Config (Local)
TRAEFIK_HOST=sample-website.localhost
DIRECTUS_HOST=cms.sample-website.localhost
# Next.js
NEXT_PUBLIC_BASE_URL=http://sample-website.localhost
# Directus
DIRECTUS_URL=http://localhost:8055
DIRECTUS_KEY=sample-key-123
DIRECTUS_SECRET=sample-secret-123
DIRECTUS_ADMIN_EMAIL=admin@mintel.me
DIRECTUS_ADMIN_PASSWORD=mintel-admin-pass
DIRECTUS_DB_NAME=directus
DIRECTUS_DB_USER=directus
DIRECTUS_DB_PASSWORD=mintel-db-pass
# Sentry / Glitchtip
SENTRY_DSN=
# Analytics (Umami)
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js

34
apps/sample-website/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies
/node_modules
/.pnpm-debug.log*
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# directus
/directus/uploads
/directus/extensions
/.env

View File

@@ -0,0 +1,38 @@
# Start from the pre-built Nextjs Base image
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
WORKDIR /app
# Build-time environment variables for Next.js
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL
# Build the specific application
RUN pnpm --filter sample-website build
# Production runner image
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
WORKDIR /app
COPY --from=builder /app/apps/sample-website/public ./apps/sample-website/public
# Set the correct permission for prerender cache
RUN mkdir -p apps/sample-website/.next && chown nextjs:nodejs apps/sample-website/.next
# Copy standalone output and static files from the monorepo path
COPY --from=builder --chown=nextjs:nodejs /app/apps/sample-website/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/sample-website/.next/static ./apps/sample-website/.next/static
USER nextjs
# server.js in monorepo standalone is created for each app
CMD ["node", "apps/sample-website/server.js"]

View File

@@ -0,0 +1,71 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL:-http://localhost:3000}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL}
NEXT_PUBLIC_TARGET: ${TARGET:-development}
DIRECTUS_URL: ${DIRECTUS_URL:-http://directus:8055}
restart: always
networks:
- infra
env_file:
- .env
ports:
- "3000:3000"
labels:
- "traefik.enable=true"
- "traefik.http.routers.sample-website.rule=Host(`${TRAEFIK_HOST:-sample-website.localhost}`)"
- "traefik.http.services.sample-website.loadbalancer.server.port=3000"
directus:
image: registry.infra.mintel.me/mintel/directus:latest
restart: always
networks:
- infra
env_file:
- .env
environment:
KEY: ${DIRECTUS_KEY:-mintel-key}
SECRET: ${DIRECTUS_SECRET:-mintel-secret}
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL:-admin@mintel.me}
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD:-mintel-admin}
DB_CLIENT: 'pg'
DB_HOST: 'directus-db'
DB_PORT: '5432'
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
DB_USER: ${DIRECTUS_DB_USER:-directus}
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-mintel-db-pass}
WEBSOCKETS_ENABLED: 'true'
PUBLIC_URL: ${DIRECTUS_URL:-http://localhost:8055}
ports:
- "8055:8055"
volumes:
- ./directus/uploads:/directus/uploads
- ./directus/extensions:/directus/extensions
labels:
- "traefik.enable=true"
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
- "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055"
directus-db:
image: postgres:15-alpine
restart: always
networks:
- infra
environment:
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-mintel-db-pass}
volumes:
- directus-db-data:/var/lib/postgresql/data
networks:
infra:
external: true
volumes:
directus-db-data:

View File

@@ -0,0 +1,5 @@
{
"Index": {
"title": "Welcome"
}
}

View File

@@ -4,25 +4,39 @@
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"dev": "mintel dev",
"dev:local": "mintel dev --local",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "vitest run"
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"cms:bootstrap": "mintel directus bootstrap",
"cms:push:testing": "mintel directus sync push testing",
"cms:pull:testing": "mintel directus sync pull testing",
"cms:push:staging": "mintel directus sync push staging",
"cms:pull:staging": "mintel directus sync pull staging",
"cms:push:prod": "mintel directus sync push production",
"cms:pull:prod": "mintel directus sync pull production",
"pagespeed:test": "mintel pagespeed"
},
"dependencies": {
"@mintel/next-utils": "workspace:*",
"@mintel/observability": "workspace:*",
"@mintel/next-observability": "workspace:*",
"@sentry/nextjs": "^8.55.0",
"next": "15.1.6",
"next-intl": "^4.8.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@mintel/next-utils": "workspace:*"
"react-dom": "^19.0.0"
},
"devDependencies": {
"@mintel/eslint-config": "workspace:*",
"@mintel/next-config": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"@types/node": "^20.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.0.0",
"@mintel/tsconfig": "workspace:*",
"@mintel/eslint-config": "workspace:*",
"@mintel/next-config": "workspace:*"
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1,9 @@
import { initSentry } from "@mintel/next-observability";
initSentry({
// Use a placeholder DSN on the client if you want to bypass ad-blockers via tunnel
// Or just use the real DSN if you don't care about ad-blockers for errors.
// The Mintel standard is to use the relay.
dsn: "https://public@errors.infra.mintel.me/1", // Placeholder for relay
tunnel: "/errors/api/relay",
});

View File

@@ -0,0 +1,8 @@
import { initSentry } from "@mintel/next-observability";
import { validateMintelEnv } from "@mintel/next-utils";
const env = validateMintelEnv();
initSentry({
dsn: env.SENTRY_DSN,
});

View File

@@ -0,0 +1,8 @@
import { initSentry } from "@mintel/next-observability";
import { validateMintelEnv } from "@mintel/next-utils";
const env = validateMintelEnv();
initSentry({
dsn: env.SENTRY_DSN,
});

View File

@@ -0,0 +1,6 @@
import { createSentryRelayHandler } from "@mintel/next-observability";
import { validateMintelEnv } from "@mintel/next-utils";
export const POST = createSentryRelayHandler({
dsn: validateMintelEnv().SENTRY_DSN,
});

View File

@@ -1,5 +1,11 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import "./globals.css";
import {
AnalyticsContextProvider,
AnalyticsAutoTracker,
} from "@mintel/next-observability/client";
import { getAnalyticsConfig } from "@/lib/observability";
export const metadata: Metadata = {
title: "Sample Website",
@@ -11,9 +17,18 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const analyticsConfig = getAnalyticsConfig();
return (
<html lang="en">
<body>{children}</body>
<body>
<AnalyticsContextProvider config={analyticsConfig}>
<Suspense fallback={null}>
<AnalyticsAutoTracker />
</Suspense>
{children}
</AnalyticsContextProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,7 @@
import { createUmamiProxyHandler } from "@mintel/next-observability";
import { validateMintelEnv } from "@mintel/next-utils";
export const POST = createUmamiProxyHandler({
websiteId: validateMintelEnv().UMAMI_WEBSITE_ID,
apiEndpoint: validateMintelEnv().UMAMI_API_ENDPOINT,
});

View File

@@ -0,0 +1,7 @@
import { createMintelI18nRequestConfig } from "@mintel/next-utils";
export default createMintelI18nRequestConfig(
["en"],
"en",
(locale) => import(`../../messages/${locale}.json`),
);

View File

@@ -0,0 +1,13 @@
import * as Sentry from "@sentry/nextjs";
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("../sentry.server.config");
}
if (process.env.NEXT_RUNTIME === "edge") {
await import("../sentry.edge.config");
}
}
export const onRequestError = Sentry.captureRequestError;

View File

@@ -0,0 +1,54 @@
import {
UmamiAnalyticsService,
GotifyNotificationService,
NoopNotificationService,
} from "@mintel/observability";
import { validateMintelEnv } from "@mintel/next-utils";
let analyticsService: any = null;
let notificationService: any = null;
export function getAnalyticsConfig() {
const isClient = typeof window !== "undefined";
if (isClient) {
return {
enabled: true,
apiEndpoint: "/stats",
};
}
const env = validateMintelEnv();
return {
enabled: Boolean(env.UMAMI_WEBSITE_ID),
websiteId: env.UMAMI_WEBSITE_ID,
apiEndpoint: env.UMAMI_API_ENDPOINT,
};
}
export function getAnalyticsService() {
if (analyticsService) return analyticsService;
const config = getAnalyticsConfig();
analyticsService = new UmamiAnalyticsService(config);
return analyticsService;
}
export function getNotificationService() {
if (notificationService) return notificationService;
if (typeof window === "undefined") {
const env = validateMintelEnv();
notificationService = new GotifyNotificationService({
enabled: Boolean(env.GOTIFY_URL && env.GOTIFY_TOKEN),
url: env.GOTIFY_URL || "",
token: env.GOTIFY_TOKEN || "",
});
} else {
// Notifications are typically server-side only to protect tokens
notificationService = new NoopNotificationService();
}
return notificationService;
}

View File

@@ -22,6 +22,7 @@
"@mintel/husky-config": "workspace:*",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^20.17.16",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",

View File

@@ -4,10 +4,30 @@ The Mintel CLI is the primary automation tool for managing the monorepo and ensu
## 🚀 Installation
The CLI is intended to be used within the monorepo:
### Using npx (Recommended)
Run the CLI without installing it globally. This always uses the latest version from the registry:
```bash
pnpm install
npx @mintel/cli init apps/my-new-website.com
```
### Global Installation
Install the CLI globally from the Mintel registry:
```bash
npm install -g @mintel/cli
```
### Development (Local Link)
If you are contributing to the CLI, you can link it locally:
```bash
cd packages/cli
pnpm build
npm link
```
## 🛠 Commands
@@ -17,10 +37,11 @@ pnpm install
Scaffolds a new, production-ready client website in the specified path.
```bash
pnpm --filter @mintel/cli start init apps/my-new-website.com
mintel init apps/my-new-website.com
```
#### What it does:
1. **Project Structure**: Creates a modern Next.js directory layout.
2. **Shared Configs**: Generates `package.json`, `tsconfig.json`, and `eslint.config.mjs` that extend the `@mintel` shared packages.
3. **Localization**: Sets up a localized routing structure (`src/app/[locale]`) with `next-intl` pre-configured.

View File

@@ -10,7 +10,7 @@
"mintel": "./dist/index.js"
},
"scripts": {
"build": "tsup src/index.ts --format esm --target es2020",
"build": "tsup",
"start": "node dist/index.js",
"dev": "tsup src/index.ts --format esm --watch --target es2020",
"test": "vitest run"
@@ -28,4 +28,4 @@
"@types/prompts": "^2.4.4",
"@mintel/tsconfig": "workspace:*"
}
}
}

View File

@@ -1,3 +1,4 @@
#!/usr/bin/env node
import { Command } from "commander";
import fs from "fs-extra";
import path from "path";
@@ -10,7 +11,76 @@ const program = new Command();
program
.name("mintel")
.description("CLI for Mintel monorepo management")
.version("1.0.0");
.version("1.0.1");
program
.command("dev")
.description("Start the development environment (Docker stack)")
.option("-l, --local", "Run Next.js locally instead of in Docker")
.action(async (options) => {
const { execSync } = await import("child_process");
console.log(chalk.blue("🚀 Starting Development Environment..."));
if (options.local) {
console.log(chalk.cyan("Running Next.js locally..."));
execSync("next dev", { stdio: "inherit" });
} else {
console.log(chalk.cyan("Starting Docker stack (App, Directus, DB)..."));
// Ensure network exists
try {
execSync("docker network create infra", { stdio: "ignore" });
} catch (e) {}
console.log(
chalk.yellow(`
📱 App: http://localhost:3000
🗄️ CMS: http://localhost:8055/admin
🚦 Traefik: http://localhost:8080
`),
);
execSync(
"docker-compose down --remove-orphans && docker-compose up app directus directus-db",
{ stdio: "inherit" },
);
}
});
const directus = program
.command("directus")
.description("Directus management commands");
directus
.command("bootstrap")
.description("Setup Directus branding and settings")
.action(async () => {
const { execSync } = await import("child_process");
console.log(chalk.blue("🎨 Bootstrapping Directus..."));
execSync("npx tsx --env-file=.env scripts/setup-directus.ts", {
stdio: "inherit",
});
});
directus
.command("sync <action> <env>")
.description("Sync Directus data (push/pull) for a specific environment")
.action(async (action, env) => {
const { execSync } = await import("child_process");
console.log(
chalk.blue(`📥 Executing Directus sync: ${action} -> ${env}...`),
);
execSync(`./scripts/sync-directus.sh ${action} ${env}`, {
stdio: "inherit",
});
});
program
.command("pagespeed")
.description("Run PageSpeed (Lighthouse) tests")
.action(async () => {
const { execSync } = await import("child_process");
console.log(chalk.blue("⚡ Running PageSpeed tests..."));
execSync("npx tsx ./scripts/pagespeed-sitemap.ts", { stdio: "inherit" });
});
program
.command("init <path>")
@@ -18,7 +88,7 @@ program
.action(async (projectPath) => {
const fullPath = path.isAbsolute(projectPath)
? projectPath
: path.resolve(process.cwd(), "../../", projectPath);
: path.resolve(process.cwd(), projectPath);
const projectName = path.basename(fullPath);
console.log(chalk.blue(`Initializing new project: ${projectName}...`));
@@ -34,16 +104,28 @@ program
private: true,
type: "module",
scripts: {
dev: "next dev",
dev: "mintel dev",
"dev:local": "mintel dev --local",
build: "next build",
start: "next start",
lint: "next lint",
typecheck: "tsc --noEmit",
test: "vitest run --passWithNoTests",
"directus:bootstrap": "mintel directus bootstrap",
"directus:push:testing": "mintel directus sync push testing",
"directus:pull:testing": "mintel directus sync pull testing",
"directus:push:staging": "mintel directus sync push staging",
"directus:pull:staging": "mintel directus sync pull staging",
"directus:push:prod": "mintel directus sync push production",
"directus:pull:prod": "mintel directus sync pull production",
"pagespeed:test": "mintel pagespeed",
},
dependencies: {
next: "15.1.6",
react: "^19.0.0",
"react-dom": "^19.0.0",
"@mintel/next-utils": "workspace:*",
"@mintel/next-observability": "workspace:*",
"@directus/sdk": "^21.0.0",
},
devDependencies: {
@@ -182,11 +264,15 @@ export default createMintelI18nRequestConfig(
// Create instrumentation.ts
await fs.writeFile(
path.join(fullPath, "src/instrumentation.ts"),
`import * as Sentry from '@sentry/nextjs';
`import { Sentry } from '@mintel/next-observability';
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
// Server-side initialization
await import('./sentry.server.config');
}
if (process.env.NEXT_RUNTIME === 'edge') {
await import('./sentry.edge.config');
}
}
@@ -238,34 +324,56 @@ export default function Home() {
// Copy infra templates
const infraPath = path.resolve(__dirname, "../../infra");
if (await fs.pathExists(infraPath)) {
await fs.copy(
path.join(infraPath, "docker/Dockerfile.nextjs"),
path.join(fullPath, "Dockerfile"),
// Setup Dockerfile from template
const templatePath = path.join(
infraPath,
"docker/Dockerfile.app-template",
);
await fs.copy(
path.join(infraPath, "docker/docker-compose.template.yml"),
path.join(fullPath, "docker-compose.yml"),
if (await fs.pathExists(templatePath)) {
let dockerfile = await fs.readFile(templatePath, "utf8");
dockerfile = dockerfile.replace(/\$\{APP_NAME:-app\}/g, projectName);
await fs.writeFile(path.join(fullPath, "Dockerfile"), dockerfile);
}
// Setup docker-compose from template
const composeTemplatePath = path.join(
infraPath,
"docker/docker-compose.template.yml",
);
if (await fs.pathExists(composeTemplatePath)) {
let compose = await fs.readFile(composeTemplatePath, "utf8");
compose = compose.replace(/\$\{APP_NAME:-app\}/g, projectName);
compose = compose.replace(/\$\{PROJECT_NAME:-app\}/g, projectName);
await fs.writeFile(
path.join(fullPath, "docker-compose.yml"),
compose,
);
}
await fs.ensureDir(path.join(fullPath, ".gitea/workflows"));
await fs.copy(
path.join(infraPath, "gitea/deploy-action.yml"),
path.join(fullPath, ".gitea/workflows/deploy.yml"),
const deployActionPath = path.join(
infraPath,
"gitea/deploy-action.yml",
);
if (await fs.pathExists(deployActionPath)) {
await fs.copy(
deployActionPath,
path.join(fullPath, ".gitea/workflows/deploy.yml"),
);
}
}
// Create Directus structure
await fs.ensureDir(path.join(fullPath, "directus/uploads"));
await fs.ensureDir(path.join(fullPath, "directus/extensions"));
await fs.writeFile(
path.join(fullPath, "directus/uploads/.gitkeep"),
"",
);
await fs.writeFile(
path.join(fullPath, "directus/extensions/.gitkeep"),
"",
);
// Create Directus structure
await fs.ensureDir(path.join(fullPath, "directus/uploads"));
await fs.ensureDir(path.join(fullPath, "directus/extensions"));
await fs.writeFile(path.join(fullPath, "directus/uploads/.gitkeep"), "");
await fs.writeFile(
path.join(fullPath, "directus/extensions/.gitkeep"),
"",
);
// Create .env.example
const envExample = `# Project
// Create .env.example
const envExample = `# Project
PROJECT_NAME=${projectName}
PROJECT_COLOR=#82ed20
@@ -294,17 +402,20 @@ DIRECTUS_DB_PASSWORD=mintel-db-pass
SENTRY_DSN=
# Analytics (Umami)
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
`;
await fs.writeFile(path.join(fullPath, ".env.example"), envExample);
UMAMI_WEBSITE_ID=
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
// Copy premium templates (globals.css, lib/directus.ts, scripts/setup-directus.ts)
const templatePath = path.join(infraPath, "templates/website");
if (await fs.pathExists(templatePath)) {
console.log(chalk.blue("Applying premium templates..."));
await fs.copy(templatePath, fullPath, { overwrite: true });
}
# Notifications (Gotify)
GOTIFY_URL=
GOTIFY_TOKEN=
`;
await fs.writeFile(path.join(fullPath, ".env.example"), envExample);
// Copy premium templates (globals.css, lib/directus.ts, scripts/setup-directus.ts)
const templatePath = path.join(infraPath, "templates/website");
if (await fs.pathExists(templatePath)) {
console.log(chalk.blue("Applying premium templates..."));
await fs.copy(templatePath, fullPath, { overwrite: true });
}
console.log(

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
target: 'es2020',
clean: true,
banner: {
js: '#!/usr/bin/env node',
},
});

View File

@@ -2,6 +2,9 @@ import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: ["**/dist/**", "**/node_modules/**", "**/.next/**"],
},
js.configs.recommended,
...tseslint.configs.recommended,
{

View File

@@ -1,24 +1,41 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
import nextPlugin from "@next/eslint-plugin-next";
import reactPlugin from "eslint-plugin-react";
import hooksPlugin from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";
import js from "@eslint/js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
export const nextConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
/**
* Mintel Next.js ESLint Configuration (Flat Config)
*
* This configuration replaces the legacy 'eslint-config-next' which
* relies on @rushstack/eslint-patch and causes issues in ESLint 9.
*/
export const nextConfig = tseslint.config(
{
plugins: {
"react": reactPlugin,
"react-hooks": hooksPlugin,
"@next/next": nextPlugin,
},
languageOptions: {
globals: {
// Add common browser/node globals if needed,
// though usually handled by base configs
},
},
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/no-require-imports": "off",
"prefer-const": "warn",
...reactPlugin.configs.recommended.rules,
...hooksPlugin.configs.recommended.rules,
...nextPlugin.configs.recommended.rules,
...nextPlugin.configs["core-web-vitals"].rules,
"react/react-in-jsx-scope": "off",
"react/no-unescaped-entities": "off",
"@next/next/no-img-element": "warn"
}
"@next/next/no-img-element": "warn",
},
settings: {
react: {
version: "detect",
},
},
}
];
);

View File

@@ -20,7 +20,10 @@
"dependencies": {
"@eslint/eslintrc": "^3.0.0",
"@eslint/js": "^9.39.2",
"@next/eslint-plugin-next": "15.1.6",
"eslint-config-next": "15.1.6",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"typescript-eslint": "^8.54.0"
}
}

View File

@@ -0,0 +1,15 @@
> @mintel/gatekeeper@1.0.0 dev
> next dev
⚠ Port 3000 is in use, trying 3001 instead.
▲ Next.js 15.1.6
- Local: http://localhost:3001
- Network: http://192.168.1.126:3001
- Experiments (use with caution):
· clientTraceMetadata
✓ Starting...
warn - It seems like you don't have a global error handler set up. It is recommended that you add a global-error.js file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)
✓ Ready in 2.7s
[?25h

View File

@@ -0,0 +1,5 @@
{
"LoginPage": {
"title": "Gatekeeper"
}
}

View File

@@ -2,7 +2,7 @@ import mintelNextConfig from "@mintel/next-config";
import { NextConfig } from "next";
const nextConfig: NextConfig = {
// Gatekeeper specific overrides
basePath: '/gatekeeper',
};
export default mintelNextConfig(nextConfig);

View File

@@ -11,9 +11,11 @@
"test": "vitest run"
},
"dependencies": {
"@mintel/next-utils": "workspace:*",
"clsx": "^2.1.1",
"lucide-react": "^0.474.0",
"next": "15.1.6",
"next-intl": "^4.8.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.6.0"
@@ -22,6 +24,7 @@
"@mintel/eslint-config": "workspace:*",
"@mintel/next-config": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^20.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -9,17 +9,79 @@ export async function GET(req: NextRequest) {
const session = cookieStore.get(authCookieName);
if (session?.value === password) {
return new NextResponse("OK", { status: 200 });
}
// Traefik ForwardAuth headers
// 1. URL Parameter Bypass (for automated tests/staging)
const originalUrl = req.headers.get("x-forwarded-uri") || "/";
const host =
req.headers.get("x-forwarded-host") || req.headers.get("host") || "";
const proto = req.headers.get("x-forwarded-proto") || "https";
const loginUrl = `${proto}://${host}/gatekeeper/login?redirect=${encodeURIComponent(originalUrl)}`;
try {
const url = new URL(originalUrl, `${proto}://${host}`);
if (url.searchParams.get("gk_bypass") === password) {
// Remove the bypass parameter from the redirect URL
url.searchParams.delete("gk_bypass");
const cleanUrl = url.pathname + url.search;
const absoluteCleanUrl = `${proto}://${host}${cleanUrl}`;
const response = NextResponse.redirect(absoluteCleanUrl);
// Set the session cookie so the bypass is persistent
const isDev = process.env.NODE_ENV === "development";
const cookieDomain = process.env.COOKIE_DOMAIN;
const sessionValue = JSON.stringify({
identity: "Bypass",
timestamp: Date.now(),
});
response.cookies.set(authCookieName, sessionValue, {
httpOnly: true,
secure: !isDev,
path: "/",
maxAge: 30 * 24 * 60 * 60, // 30 days
sameSite: "lax",
...(cookieDomain ? { domain: cookieDomain } : {}),
});
return response;
}
} catch (e) {
// URL parsing failed, proceed with normal logic
}
let isAuthenticated = false;
let identity = "Guest";
if (session?.value) {
if (session.value === password) {
isAuthenticated = true;
} else {
try {
const payload = JSON.parse(session.value);
if (payload.identity) {
isAuthenticated = true;
identity = payload.identity;
}
} catch (e) {
// Fallback or old format
}
}
}
if (isAuthenticated) {
return new NextResponse("OK", {
status: 200,
headers: {
"X-Auth-User": identity,
},
});
}
// Traefik ForwardAuth headers
const gatekeeperUrl =
process.env.NEXT_PUBLIC_BASE_URL || `${proto}://gatekeeper.${host}`;
const absoluteOriginalUrl = `${proto}://${host}${originalUrl}`;
const loginUrl = `${gatekeeperUrl}/login?redirect=${encodeURIComponent(absoluteOriginalUrl)}`;
return NextResponse.redirect(loginUrl);
}

View File

@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET(req: NextRequest) {
const cookieStore = await cookies();
const authCookieName =
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
const session = cookieStore.get(authCookieName);
if (!session?.value) {
return NextResponse.json({ authenticated: false }, { status: 401 });
}
let identity = "Guest";
try {
const payload = JSON.parse(session.value);
identity = payload.identity || "Guest";
} catch (e) {
// Old format probably just the password
}
return NextResponse.json({
authenticated: true,
identity: identity,
});
}

View File

@@ -1,131 +0,0 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { Lock, ShieldCheck, ArrowRight } from "lucide-react";
interface LoginPageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function LoginPage({ searchParams }: LoginPageProps) {
const params = await searchParams;
const redirectUrl = (params.redirect as string) || "/";
const error = params.error === "1";
const projectName = process.env.PROJECT_NAME || "Mintel";
const projectColor = process.env.PROJECT_COLOR || "#82ed20";
async function login(formData: FormData) {
"use server";
const password = formData.get("password");
const expectedPassword = process.env.GATEKEEPER_PASSWORD || "mintel";
const authCookieName =
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
const targetRedirect = formData.get("redirect") as string;
if (password === expectedPassword) {
const cookieStore = await cookies();
cookieStore.set(authCookieName, expectedPassword, {
httpOnly: true,
secure: true,
path: "/",
maxAge: 30 * 24 * 60 * 60, // 30 days
sameSite: "lax",
});
redirect(targetRedirect);
} else {
redirect(
`/gatekeeper/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`,
);
}
}
return (
<div className="min-h-screen flex items-center justify-center relative overflow-hidden bg-mintel-dark">
{/* Background Decor */}
<div className="absolute inset-0 bg-grid pointer-events-none opacity-20" />
<div
className="absolute inset-0 pointer-events-none opacity-30"
style={{
background: `radial-gradient(circle at center, ${projectColor}11 0%, transparent 70%)`,
}}
/>
<div className="relative z-10 w-full max-w-md px-6 animate-in fade-in zoom-in duration-700">
{/* Logo / Icon */}
<div className="flex justify-center mb-12">
<div
className="w-20 h-20 rounded-3xl flex items-center justify-center border border-white/10 bg-white/5 backdrop-blur-xl shadow-2xl"
style={{ borderBottom: `2px solid ${projectColor}44` }}
>
<ShieldCheck
className="w-10 h-10"
style={{ color: projectColor }}
/>
</div>
</div>
<div className="bg-white/[0.03] backdrop-blur-3xl border border-white/10 p-10 rounded-[48px] shadow-2xl relative overflow-hidden group">
{/* Subtle accent line */}
<div
className="absolute top-0 left-0 w-full h-1 opacity-50"
style={{
background: `linear-gradient(to right, transparent, ${projectColor}, transparent)`,
}}
/>
<div className="mb-10 text-center">
<h1 className="text-3xl font-black mb-3 tracking-tighter uppercase italic flex items-center justify-center gap-2">
{projectName.split(" ")[0]}
<span style={{ color: projectColor }}>GATEKEEPER</span>
</h1>
<p className="text-white/40 text-sm font-medium">
Restricted Infrastructure Access
</p>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-200 p-4 rounded-2xl mb-8 text-sm flex items-center gap-3 animate-pulse">
<Lock className="w-5 h-5 flex-shrink-0" />
<span>Invalid access password. Please try again.</span>
</div>
)}
<form action={login} className="space-y-8">
<input type="hidden" name="redirect" value={redirectUrl} />
<div className="space-y-3">
<label className="block text-[10px] font-black uppercase tracking-[0.3em] text-white/20 ml-5">
Authentication Code
</label>
<input
type="password"
name="password"
required
autoFocus
className="w-full bg-white/5 border border-white/10 rounded-3xl px-8 py-6 focus:outline-none focus:ring-2 transition-all text-xl tracking-[0.5em] text-center placeholder:tracking-normal placeholder:text-white/10"
style={{ "--tw-ring-color": `${projectColor}33` } as any}
placeholder="••••••••"
/>
</div>
<button
type="submit"
className="w-full font-black uppercase tracking-[0.2em] py-6 rounded-3xl transition-all active:scale-[0.98] flex items-center justify-center gap-3 shadow-lg hover:shadow-mintel-green/10"
style={{ backgroundColor: projectColor, color: "#000" }}
>
Verify Identity
<ArrowRight className="w-5 h-5" />
</button>
</form>
</div>
<div className="mt-12 text-center">
<p className="text-[10px] font-bold text-white/10 uppercase tracking-[0.5em]">
&copy; 2026 {projectName} Infrastructure
</p>
</div>
</div>
</div>
);
}

View File

@@ -2,20 +2,83 @@
@tailwind components;
@tailwind utilities;
:root {
--background: #000c1f;
--foreground: #ffffff;
@layer base {
html {
scroll-behavior: smooth;
}
body {
@apply bg-white text-slate-800 font-serif antialiased selection:bg-slate-900 selection:text-white;
line-height: 1.6;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-sans font-bold text-slate-900 tracking-tighter;
}
p {
@apply mb-4 text-base leading-relaxed text-slate-700;
}
a {
@apply text-slate-900 hover:text-slate-700 transition-colors no-underline;
}
}
body {
color: var(--foreground);
background: var(--background);
min-height: 100vh;
@layer components {
.narrow-container {
@apply max-w-4xl mx-auto px-6 py-10;
}
.btn {
@apply inline-flex items-center justify-center px-6 py-3 border border-slate-200 bg-white text-slate-600 font-sans font-bold text-sm uppercase tracking-widest rounded-full transition-all duration-500 ease-industrial hover:border-slate-400 hover:text-slate-900 hover:bg-slate-50 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-slate-100 active:translate-y-0 active:shadow-sm;
}
.btn-primary {
@apply border-slate-900 text-slate-900 hover:bg-slate-900 hover:text-white;
}
}
.bg-grid {
background-image:
linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Animations */
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-4px);
}
75% {
transform: translateX(4px);
}
}
.animate-shake {
animation: shake 0.2s ease-in-out 0s 2;
}

View File

@@ -1,6 +1,15 @@
import type { Metadata } from "next";
import { Inter, Newsreader } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
const newsreader = Newsreader({
subsets: ["latin"],
variable: "--font-newsreader",
style: "italic",
display: "swap",
});
export const metadata: Metadata = {
title: "Gatekeeper | Access Control",
description: "Mintel Infrastructure Protection",
@@ -12,7 +21,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="en">
<html lang="en" className={`${inter.variable} ${newsreader.variable}`}>
<body className="antialiased">{children}</body>
</html>
);

View File

@@ -0,0 +1,199 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { ArrowRight, ShieldCheck } from "lucide-react";
import Image from "next/image";
interface LoginPageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function LoginPage({ searchParams }: LoginPageProps) {
const params = await searchParams;
const redirectUrl = (params.redirect as string) || "/";
const error = params.error === "1";
const projectName = process.env.PROJECT_NAME || "Mintel";
async function login(formData: FormData) {
"use server";
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const expectedCode = process.env.GATEKEEPER_PASSWORD || "mintel";
const adminEmail = process.env.DIRECTUS_ADMIN_EMAIL;
const adminPassword = process.env.DIRECTUS_ADMIN_PASSWORD;
const authCookieName =
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
const targetRedirect = formData.get("redirect") as string;
const cookieDomain = process.env.COOKIE_DOMAIN;
let userIdentity = "";
// 1. Check Global Admin (from ENV)
if (
adminEmail &&
adminPassword &&
email === adminEmail &&
password === adminPassword
) {
userIdentity = "Admin";
}
// 2. Check Generic Code (Guest)
else if (!email && password === expectedCode) {
userIdentity = "Guest";
}
// 3. Check Directus if email is provided
if (email && password && process.env.DIRECTUS_URL) {
try {
const loginRes = await fetch(`${process.env.DIRECTUS_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (loginRes.ok) {
const { data } = await loginRes.json();
const accessToken = data.access_token;
// Fetch user info to get a nice display name
const userRes = await fetch(`${process.env.DIRECTUS_URL}/users/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (userRes.ok) {
const { data: user } = await userRes.json();
userIdentity = user.first_name || user.email;
}
}
} catch (e) {
console.error("Directus Auth Error:", e);
}
}
if (userIdentity) {
const cookieStore = await cookies();
// Store identity in the cookie (simplified for now, ideally signed)
const sessionValue = JSON.stringify({
identity: userIdentity,
timestamp: Date.now(),
});
const isDev = process.env.NODE_ENV === "development";
cookieStore.set(authCookieName, sessionValue, {
httpOnly: true,
secure: !isDev,
path: "/",
maxAge: 30 * 24 * 60 * 60, // 30 days
sameSite: "lax",
...(cookieDomain ? { domain: cookieDomain } : {}),
});
redirect(targetRedirect);
} else {
redirect(`/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`);
}
}
return (
<div className="min-h-screen flex items-center justify-center relative bg-white font-serif antialiased overflow-hidden">
{/* Background Decor - Signature mintel.me style */}
<div
className="absolute inset-0 pointer-events-none opacity-[0.03] scale-[1.01]"
style={{
backgroundImage: `linear-gradient(to right, #000 1px, transparent 1px), linear-gradient(to bottom, #000 1px, transparent 1px)`,
backgroundSize: "clamp(30px, 8vw, 40px) clamp(30px, 8vw, 40px)",
}}
/>
<main className="relative z-10 w-full max-w-sm px-8 sm:px-6">
<div className="space-y-12 sm:space-y-16 animate-fade-in">
{/* Top Icon Box - Signature mintel.me Black Square */}
<div className="flex justify-center">
<div className="w-16 h-16 bg-black rounded-xl flex items-center justify-center shadow-xl shadow-slate-100 hover:scale-105 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] rotate-2 hover:rotate-0">
<Image
src="/icon-white.svg"
alt="Mintel"
width={32}
height={32}
className="w-8 h-8"
/>
</div>
</div>
<div className="space-y-12 animate-slide-up">
<div className="text-center space-y-4">
<h1 className="text-xs font-sans font-bold uppercase tracking-[0.4em] text-slate-900 border-b border-slate-50 pb-4 inline-block mx-auto min-w-[200px]">
{projectName} <span className="text-slate-300">Gatekeeper</span>
</h1>
<p className="text-[10px] text-slate-400 font-sans uppercase tracking-widest italic flex items-center justify-center gap-2">
<span className="w-1 h-1 bg-slate-200 rounded-full" />
Infrastructure Protection
<span className="w-1 h-1 bg-slate-200 rounded-full" />
</p>
</div>
{error && (
<div className="bg-red-50 text-red-600 px-5 py-3 rounded-2xl text-[9px] font-sans font-bold uppercase tracking-widest flex items-center gap-3 border border-red-100 animate-shake">
<ShieldCheck className="w-4 h-4" />
<span>Access Denied. Try Again.</span>
</div>
)}
<form action={login} className="space-y-4">
<input type="hidden" name="redirect" value={redirectUrl} />
<div className="space-y-2">
<div className="relative group">
<input
type="email"
name="email"
placeholder="EMAIL (OPTIONAL)"
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-[10px] font-sans font-bold tracking-[0.2em] uppercase placeholder:text-slate-300 shadow-sm shadow-slate-50"
/>
</div>
<div className="relative group">
<input
type="password"
name="password"
required
autoFocus
autoComplete="current-password"
placeholder="ACCESS CODE"
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-sm font-sans font-bold tracking-[0.3em] uppercase placeholder:text-slate-300 placeholder:tracking-widest shadow-sm shadow-slate-50"
/>
</div>
</div>
<button
type="submit"
className="btn btn-primary w-full py-5 rounded-2xl text-[10px] shadow-lg shadow-slate-100 flex items-center justify-center"
>
Unlock Access
<ArrowRight className="ml-3 w-3 h-3 group-hover:translate-x-1 transition-transform" />
</button>
</form>
{/* Bottom Section - Full Branding Parity */}
<div className="pt-12 sm:pt-20 flex flex-col items-center gap-6 sm:gap-8">
<div className="h-px w-8 bg-slate-100" />
<div className="opacity-80 transition-opacity hover:opacity-100">
<Image
src="/logo-black.svg"
alt={projectName}
width={140}
height={40}
className="h-7 sm:h-auto grayscale contrast-125 w-auto"
/>
</div>
<p className="text-[8px] font-sans font-bold text-slate-300 uppercase tracking-[0.4em] sm:tracking-[0.5em] text-center">
&copy; 2026 MINTEL
</p>
</div>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/login");
}

View File

@@ -0,0 +1,7 @@
import { createMintelI18nRequestConfig } from "@mintel/next-utils";
export default createMintelI18nRequestConfig(
["en"],
"en",
(locale) => import(`../../messages/${locale}.json`),
);

View File

@@ -0,0 +1,59 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
borderRadius: {
xl: "1rem",
"2xl": "1.5rem",
"3xl": "2rem",
full: "9999px",
},
colors: {
slate: {
850: "#1e293b",
900: "#0f172a",
950: "#020617",
},
},
fontFamily: {
sans: ["var(--font-inter)", "Inter", "system-ui", "sans-serif"],
serif: ["var(--font-newsreader)", "Georgia", "serif"],
mono: ["JetBrains Mono", "monospace"],
},
animation: {
"fade-in": "fadeIn 0.5s ease-in-out",
"slide-up": "slideUp 0.6s ease-out",
"slide-down": "slideDown 0.6s ease-out",
shake: "shake 0.2s ease-in-out 0s 2",
},
keyframes: {
fadeIn: {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
slideUp: {
"0%": { transform: "translateY(20px)", opacity: "0" },
"100%": { transform: "translateY(0)", opacity: "1" },
},
slideDown: {
"0%": { transform: "translateY(-20px)", opacity: "0" },
"100%": { transform: "translateY(0)", opacity: "1" },
},
shake: {
"0%, 100%": { transform: "translateX(0)" },
"25%": { transform: "translateX(-4px)" },
"75%": { transform: "translateX(4px)" },
},
},
transitionTimingFunction: {
industrial: "cubic-bezier(0.23, 1, 0.32, 1)",
},
},
},
plugins: [require("@tailwindcss/typography")],
};

View File

@@ -1,23 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
mintel: {
green: "#82ed20",
blue: "#001a4d",
dark: "#000c1f",
},
},
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
},
},
},
plugins: [],
};

View File

@@ -1,8 +1,10 @@
export default {
const config = {
extends: ["@commitlint/config-conventional"],
rules: {
"header-max-length": [2, "always", 150],
"header-max-length": [2, "always", 250],
"subject-case": [0],
"subject-full-stop": [0],
},
};
export default config;

View File

@@ -1,4 +1,22 @@
export default {
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
import path from "path";
const buildLintCommand = (filenames) => {
const isNext =
process.env.npm_package_devDependencies_next ||
process.env.npm_package_dependencies_next;
if (isNext) {
return `next lint --fix --file ${filenames
.map((f) => path.relative(process.cwd(), f))
.join(" --file ")}`;
}
return "eslint --fix";
};
const config = {
"*.{js,jsx,ts,tsx}": [buildLintCommand, "prettier --write"],
"*.{json,md,css,scss}": ["prettier --write"],
};
export default config;

View File

@@ -0,0 +1,33 @@
# Start from the pre-built Nextjs Base image
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
WORKDIR /app
# Build-time environment variables for Next.js
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL
# Build the specific application
RUN pnpm --filter ${APP_NAME:-app} build
# Production runner image
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
WORKDIR /app
# Copy standalone output and static files
COPY --from=builder --chown=nextjs:nodejs /app/apps/${APP_NAME:-app}/public ./apps/${APP_NAME:-app}/public
COPY --from=builder --chown=nextjs:nodejs /app/apps/${APP_NAME:-app}/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/${APP_NAME:-app}/.next/static ./apps/${APP_NAME:-app}/.next/static
USER nextjs
CMD ["node", "apps/${APP_NAME:-app}/server.js"]

View File

@@ -0,0 +1,12 @@
FROM directus/directus:11
# Add any custom extensions or configurations here if needed
# COPY ./extensions /directus/extensions
# Default environment for optimized production use
ENV LOGGER_LEVEL="info"
ENV WEBSOCKETS_ENABLED="true"
# Health check
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8055/health || exit 1

View File

@@ -1,47 +1,40 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
# Step 1: Builder stage
FROM node:20-alpine AS builder
RUN apk add --no-cache libc6-compat curl
WORKDIR /app
RUN corepack enable pnpm
# Install dependencies
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable pnpm && pnpm i --frozen-lockfile
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
# Copy source (honoring .dockerignore)
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# Use a secret for NPM_TOKEN to authenticate with private registry
RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
--mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
pnpm i --frozen-lockfile
# Build the application
RUN corepack enable pnpm && pnpm run build
# Build Gatekeeper and its dependencies
RUN pnpm --filter @mintel/gatekeeper... build
RUN mkdir -p packages/gatekeeper/public
# Production image, copy all the files and run next
FROM base AS runner
# Step 2: Runner stage
FROM node:20-alpine AS runner
RUN apk add --no-cache libc6-compat curl
WORKDIR /app
RUN apk add --no-cache curl
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Set the correct permission for prerender cache
RUN mkdir -p packages/gatekeeper/.next && chown nextjs:nodejs packages/gatekeeper/.next
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/public ./packages/gatekeeper/public
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/.next/static ./packages/gatekeeper/.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
CMD ["node", "packages/gatekeeper/server.js"]

View File

@@ -1,66 +1,19 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Step 1: Builder image
FROM node:20-alpine AS builder
RUN apk add --no-cache libc6-compat curl
WORKDIR /app
RUN corepack enable pnpm
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json* pnpm-lock.yaml* ./
RUN if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
else npm i; fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
# Step 2: Install dependencies
# We copy everything first because we have a .dockerignore
# and we need the workspace structure for pnpm to work correctly
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
ENV NEXT_TELEMETRY_DISABLED=1
# Use a secret for NPM_TOKEN to authenticate with private registry
RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
--mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
pnpm i --frozen-lockfile
# Build-time environment variables for Next.js
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
# Build the application
RUN if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else npm run build; fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
# Install curl for health checks
RUN apk add --no-cache curl
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
# Step 3: Build shared packages
RUN pnpm --filter "./packages/*" -r build

View File

@@ -0,0 +1,19 @@
FROM node:20-alpine
# Install essential production utilities
RUN apk add --no-cache curl libc6-compat
# Set standard production environment
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
WORKDIR /app
# Create non-root user for security
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Expose the default Next.js port
EXPOSE 3000

View File

@@ -1,6 +1,5 @@
services:
app:
image: registry.infra.mintel.me/mintel/${APP_NAME:-app}:${IMAGE_TAG:-latest}
restart: always
networks:
- infra
@@ -40,7 +39,6 @@ services:
gatekeeper:
image: registry.infra.mintel.me/mintel/gatekeeper:${IMAGE_TAG:-latest}
container_name: ${PROJECT_NAME}-gatekeeper
restart: always
networks:
- infra
@@ -55,7 +53,7 @@ services:
- "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000"
directus:
image: directus/directus:11
image: registry.infra.mintel.me/mintel/directus:latest
restart: always
networks:
- infra

View File

@@ -24,6 +24,8 @@ jobs:
prepare:
name: 🔍 Prepare Environment
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
outputs:
target: ${{ steps.determine.outputs.target }}
image_tag: ${{ steps.determine.outputs.image_tag }}
@@ -39,17 +41,23 @@ jobs:
short_sha: ${{ steps.determine.outputs.short_sha }}
commit_msg: ${{ steps.determine.outputs.commit_msg }}
steps:
- name: 🧹 Maintenance (High Density Cleanup)
shell: bash
run: |
echo "Purging old build layers and dangling images..."
docker image prune -f
docker builder prune -f --filter "until=6h"
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 2
- name: 🔍 Environment & Version ermitteln
id: determine
run: |
TAG="${{ github.ref_name }}"
SHORT_SHA="${{ github.sha }}"
SHORT_SHA="${SHORT_SHA:0:9}"
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-9)
COMMIT_MSG=$(git log -1 --pretty=%s || echo "No commit message available")
# Base Domain (e.g. example.com)
@@ -79,7 +87,6 @@ jobs:
TARGET="production"
IMAGE_TAG="$TAG"
ENV_FILE=".env.prod"
TRAEFIK_HOST="\${DOMAIN_BASE}, www.\${DOMAIN_BASE}" # Note: Host() backticks usually needed in compose
TRAEFIK_HOST="\`\${DOMAIN_BASE}\`, \`www.\${DOMAIN_BASE}\`"
NEXT_PUBLIC_BASE_URL="https://\${DOMAIN_BASE}"
DIRECTUS_URL="https://cms.\${DOMAIN_BASE}"
@@ -88,7 +95,7 @@ jobs:
IS_PROD="true"
GOTIFY_TITLE="🚀 Production-Release"
GOTIFY_PRIORITY=6
elif [[ "$TAG" =~ -rc\. || "$TAG" =~ -beta\. || "$TAG" =~ -alpha\. ]]; then
elif [[ "$TAG" =~ -rc || "$TAG" =~ -beta || "$TAG" =~ -alpha ]]; then
TARGET="staging"
IMAGE_TAG="$TAG"
ENV_FILE=".env.staging"
@@ -107,19 +114,21 @@ jobs:
TARGET="skip"
fi
echo "target=$TARGET" >> $GITHUB_OUTPUT
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
echo "env_file=$ENV_FILE" >> $GITHUB_OUTPUT
echo "traefik_host=$TRAEFIK_HOST" >> $GITHUB_OUTPUT
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL" >> $GITHUB_OUTPUT
echo "directus_url=$DIRECTUS_URL" >> $GITHUB_OUTPUT
echo "directus_host=$DIRECTUS_HOST" >> $GITHUB_OUTPUT
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
echo "is_prod=$IS_PROD" >> $GITHUB_OUTPUT
echo "gotify_title=$GOTIFY_TITLE" >> $GITHUB_OUTPUT
echo "gotify_priority=$GOTIFY_PRIORITY" >> $GITHUB_OUTPUT
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "commit_msg=$COMMIT_MSG" >> $GITHUB_OUTPUT
{
echo "target=$TARGET"
echo "image_tag=$IMAGE_TAG"
echo "env_file=$ENV_FILE"
echo "traefik_host=$TRAEFIK_HOST"
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL"
echo "directus_url=$DIRECTUS_URL"
echo "directus_host=$DIRECTUS_HOST"
echo "project_name=$PROJECT_NAME"
echo "is_prod=$IS_PROD"
echo "gotify_title=$GOTIFY_TITLE"
echo "gotify_priority=$GOTIFY_PRIORITY"
echo "short_sha=$SHORT_SHA"
echo "commit_msg=$COMMIT_MSG"
} >> "$GITHUB_OUTPUT"
# ──────────────────────────────────────────────────────────────────────────────
# JOB 2: Quality Assurance (Lint & Test)
@@ -129,6 +138,8 @@ jobs:
needs: prepare
if: needs.prepare.outputs.target != 'skip'
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -137,17 +148,23 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: 🧪 Run Checks
- name: 🧪 Run Checks in Parallel
if: github.event.inputs.skip_long_checks != 'true'
run: |
npm run lint
npm run typecheck
npm run test
npm run lint &
LINT_PID=$!
npm run typecheck &
TYPE_PID=$!
npm run test &
TEST_PID=$!
wait $LINT_PID || exit 1
wait $TYPE_PID || exit 1
wait $TEST_PID || exit 1
# ──────────────────────────────────────────────────────────────────────────────
# JOB 3: Build & Push
@@ -157,25 +174,38 @@ jobs:
needs: prepare
if: needs.prepare.outputs.target != 'skip'
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: 🐳 Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 🔐 Registry Login
run: |
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
uses: docker/login-action@v3
with:
registry: registry.infra.mintel.me
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }}
- name: 🏗️ Docker Build & Push
env:
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
run: |
docker buildx build \
--pull \
--platform linux/arm64 \
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
-t registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:$IMAGE_TAG \
--push .
uses: docker/build-push-action@v5
with:
context: .
file: packages/infra/docker/Dockerfile.nextjs
platforms: linux/arm64
pull: true
build-args: |
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }}
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
push: true
secrets: |
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
tags: registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:${{ needs.prepare.outputs.image_tag }}
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:buildcache
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:buildcache,mode=max
# ──────────────────────────────────────────────────────────────────────────────
# JOB 4: Deploy
@@ -185,18 +215,18 @@ jobs:
needs: [prepare, build, qa]
if: needs.prepare.outputs.target != 'skip'
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
env:
TARGET: ${{ needs.prepare.outputs.target }}
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: 🚀 Deploy via SSH
env:
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519
@@ -208,12 +238,13 @@ jobs:
# Generated by CI - $TARGET - $(date -u)
NODE_ENV=production
IMAGE_TAG=$IMAGE_TAG
TRAEFIK_HOST=$TRAEFIK_HOST
TRAEFIK_HOST=${{ needs.prepare.outputs.traefik_host }}
PROJECT_NAME=$PROJECT_NAME
ENV_FILE=$ENV_FILE
# App Config
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }}
NEXT_PUBLIC_TARGET=$TARGET
# Directus Config
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
@@ -237,10 +268,11 @@ jobs:
ssh root@${{ secrets.SSH_HOST }} IMAGE_TAG="$IMAGE_TAG" ENV_FILE="$ENV_FILE" PROJECT_NAME="$PROJECT_NAME" bash << 'EOF'
set -e
cd "/home/deploy/sites/${{ github.event.repository.name }}"
chmod 600 "$ENV_FILE"
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" pull
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --remove-orphans
docker system prune -f --filter "until=168h"
docker system prune -f --filter "until=24h"
EOF
# ──────────────────────────────────────────────────────────────────────────────
@@ -251,11 +283,25 @@ jobs:
needs: [prepare, deploy]
if: always()
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: 🔔 Gotify
- name: 🔔 Gotify - Success
if: needs.deploy.result == 'success'
run: |
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=${{ needs.prepare.outputs.gotify_title }}" \
-F "message=Erfolgreich deployt auf **${{ needs.prepare.outputs.target }}**" \
-F "message=Erfolgreich deployt auf **${{ needs.prepare.outputs.target }}**\n\nVersion: **${{ needs.prepare.outputs.image_tag }}**\nCommit: ${{ needs.prepare.outputs.short_sha }}\nRun: ${{ github.run_id }}" \
-F "priority=4" || true
- name: 🔔 Gotify - Failure
if: |
needs.prepare.result == 'failure' ||
needs.qa.result == 'failure' ||
needs.build.result == 'failure' ||
needs.deploy.result == 'failure'
run: |
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=❌ Deployment FEHLGESCHLAGEN ${{ github.event.repository.name }}" \
-F "message=**Fehler beim Deploy auf ${{ needs.prepare.outputs.target || 'unknown' }}**\n\nRun: ${{ github.run_id }}\nBitte Logs prüfen!" \
-F "priority=8" || true

View File

@@ -0,0 +1,60 @@
#!/bin/bash
set -e
# Configuration
REGISTRY_DATA="/opt/infra/registry/data/docker/registry/v2"
KEEP_TAGS=3
echo "🏥 Starting Aggressive Registry & Docker Maintenance..."
# 1. Prune Registry Tags (Filesystem level)
for repo_dir in "$REGISTRY_DATA/repositories/mintel/"*; do
repo_name=$(basename "$repo_dir")
tags_dir="$repo_dir/_manifests/tags"
if [ -d "$tags_dir" ]; then
echo "🔍 Processing repository: mintel/$repo_name"
# Prune main-* tags
echo " 📦 Pruning main tags..."
main_tags=$(ls -dt "$tags_dir"/main-* 2>/dev/null || true)
count=0
for tag_path in $main_tags; do
((++count))
if [ $count -gt $KEEP_TAGS ]; then
echo " 🗑️ Deleting old main tag: $(basename "$tag_path")"
rm -rf "$tag_path"
fi
done
# Prune version tags (v* and rc*)
echo " 🏷️ Pruning version tags..."
version_tags=$(ls -dt "$tags_dir"/v1* 2>/dev/null || true)
count=0
for tag_path in $version_tags; do
((++count))
if [ $count -gt $KEEP_TAGS ]; then
echo " 🗑️ Deleting old version tag: $(basename "$tag_path")"
rm -rf "$tag_path"
fi
done
# Always prune buildcache (as it rebuilds quickly)
if [ -d "$tags_dir/buildcache" ]; then
echo " 🧹 Deleting buildcache tag"
rm -rf "$tags_dir/buildcache"
fi
fi
done
# 2. Run Garbage Collection
echo "♻️ Running Registry Garbage Collection..."
docker exec registry-registry-1 bin/registry garbage-collect /etc/docker/registry/config.yml
# 3. Prune Host Docker resources (Shorter window: 24h)
echo "🧹 Pruning Host Docker resources..."
docker system prune -af --filter "until=24h"
docker volume prune -f
echo "✅ Maintenance complete!"
df -h /

View File

@@ -0,0 +1,68 @@
#!/bin/bash
# Mintel Directus Sync Engine
# Synchronizes Directus Data (Postgres + Uploads) between Local and Remote
REMOTE_HOST="${SSH_HOST:-root@alpha.mintel.me}"
ACTION=$1
ENV=$2
# Help
if [ -z "$ACTION" ] || [ -z "$ENV" ]; then
echo "Usage: mintel-sync [push|pull] [testing|staging|production]"
echo ""
echo "Commands:"
echo " push Sync LOCAL data -> REMOTE"
echo " pull Sync REMOTE data -> LOCAL"
echo ""
echo "Environments:"
echo " testing, staging, production"
exit 1
fi
PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///')
case $ENV in
testing) PROJECT_NAME="${PRJ_ID}-testing"; ENV_FILE=".env.testing" ;;
staging) PROJECT_NAME="${PRJ_ID}-staging"; ENV_FILE=".env.staging" ;;
production) PROJECT_NAME="${PRJ_ID}-prod"; ENV_FILE=".env.prod" ;;
*) echo "❌ Invalid environment: $ENV"; exit 1 ;;
esac
REMOTE_DIR="/home/deploy/sites/${PRJ_ID}.com"
# DB Details
DB_USER="directus"
DB_NAME="directus"
echo "🔍 Detecting local database..."
LOCAL_DB_CONTAINER=$(docker compose ps -q directus-db)
if [ -z "$LOCAL_DB_CONTAINER" ]; then
echo "❌ Local directus-db container not found. Running?"
exit 1
fi
if [ "$ACTION" == "push" ]; then
echo "🚀 Pushing LOCAL -> $ENV ($PROJECT_NAME)..."
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" --clean --if-exists --no-owner --no-privileges "$DB_NAME" > dump.sql
scp dump.sql "$REMOTE_HOST:$REMOTE_DIR/dump.sql"
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
ssh "$REMOTE_HOST" "docker exec -i $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME < $REMOTE_DIR/dump.sql"
rsync -avz --progress ./directus/uploads/ "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/"
rm dump.sql
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
echo "✨ Push complete!"
elif [ "$ACTION" == "pull" ]; then
echo "📥 Pulling $ENV -> LOCAL..."
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $DB_USER --clean --if-exists --no-owner --no-privileges $DB_NAME > $REMOTE_DIR/dump.sql"
scp "$REMOTE_HOST:$REMOTE_DIR/dump.sql" dump.sql
docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" < dump.sql
rsync -avz --progress "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/" ./directus/uploads/
rm dump.sql
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
echo "✨ Pull complete!"
fi

View File

@@ -27,3 +27,8 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# directus
/directus/uploads
/directus/extensions
/.env

View File

@@ -1,18 +1,46 @@
import client, { ensureAuthenticated } from "../src/lib/directus";
import {
createMintelDirectusClient,
ensureDirectusAuthenticated,
} from "@mintel/next-utils";
import { updateSettings } from "@directus/sdk";
const client = createMintelDirectusClient();
async function setupBranding() {
console.log("🎨 Setup Directus Branding...");
await ensureAuthenticated();
const prjName = process.env.PROJECT_NAME || "Mintel Project";
const prjColor = process.env.PROJECT_COLOR || "#82ed20";
console.log(`🎨 Setup Directus Branding for ${prjName}...`);
await ensureDirectusAuthenticated(client);
const cssInjection = `
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
body, .v-app { font-family: 'Inter', sans-serif !important; }
.public-view .v-card {
backdrop-filter: blur(20px);
border-radius: 32px !important;
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.4) !important;
}
.v-navigation-drawer { background: #000c24 !important; }
</style>
<div style="font-family: 'Inter', sans-serif; text-align: center; margin-top: 24px;">
<p style="color: rgba(255,255,255,0.7); font-size: 14px; margin-bottom: 4px; font-weight: 500;">MINTEL INFRASTRUCTURE ENGINE</p>
<h1 style="color: #ffffff; font-size: 18px; font-weight: 700; margin: 0;">${prjName.toUpperCase()} <span style="color: ${prjColor};">RELIABILITY.</span></h1>
</div>
`;
try {
await client.request(
updateSettings({
project_name: process.env.PROJECT_NAME || "Mintel Project",
project_color: process.env.PROJECT_COLOR || "#82ed20",
project_name: prjName,
project_color: prjColor,
public_note: cssInjection,
theme_light_overrides: {
primary: process.env.PROJECT_COLOR || "#82ed20",
primary: prjColor,
borderRadius: "16px",
navigationBackground: "#000c24",
navigationForeground: "#ffffff",
},
} as any),
);

View File

@@ -0,0 +1,45 @@
{
"name": "@mintel/mail",
"version": "1.2.0",
"private": false,
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"
},
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./templates/*": {
"types": "./dist/templates/*.d.ts",
"import": "./dist/templates/*.js"
}
},
"scripts": {
"build": "tsup src/index.ts src/templates/*.tsx --format esm --dts --clean",
"dev": "tsup src/index.ts src/templates/*.tsx --format esm --watch --dts",
"lint": "eslint src",
"test": "vitest run"
},
"dependencies": {
"@react-email/components": "^0.0.33"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@mintel/eslint-config": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tsup": "^8.3.5",
"typescript": "^5.0.0",
"vitest": "^3.0.4"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -0,0 +1,29 @@
import * as React from "react";
import { Link, Img } from "@react-email/components";
export interface MintelLogoProps {
size?: number;
}
export const MintelLogo = ({ size = 200 }: MintelLogoProps) => {
// Original Logo is 545x260, we scale it
const width = size;
const height = (size * 260) / 545;
return (
<Link
href="https://mintel.me"
style={{
textDecoration: "none",
display: "inline-block",
}}
>
<Img
src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+Cjxzdmcgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgdmlld0JveD0iMCAwIDU0NSAyNjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIgeG1sbnM6c2VyaWY9Imh0dHA6Ly93d3cuc2VyaWYuY29tLyIgc3R5bGU9ImZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2UtbWl0ZXJsaW1pdDoyOyI+CiAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCgxLDAsMCwxLC0xMjg2LC0xMTUwKSI+CiAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoMSwtMCwtMCwxLDEyODYsMTE1MCkiPgogICAgICAgICAgICA8dXNlIHhsaW5rOmhyZWY9IiNfSW1hZ2UxIiB4PSI0MS41NjkiIHk9IjMxLjM4NSIgd2lkdGg9IjQ2MnB4IiBoZWlnaHQ9IjE5N3B4Ii8+CiAgICAgICAgPC9nPgogICAgPC9nPgogICAgPGRlZnM+CiAgICAgICAgPGltYWdlIGlkPSJfSW1hZ2UxIiB3aWR0aD0iNDYycHgiIGhlaWdodD0iMTk3cHgiIHhsaW5rOmhyZWY9ImRhdGE6aW1hZ2UvcG5nO2Jhc2U2NCxpVkJPUncwS0dnb0FBQU5TVWhFVWdBQUFjNFNBQUFERkNBWUFBQUNYQlJXMEFBQWdBRWxFUVZSNFhMU0JTQlNScGRmZWU0eE9lTXVlemV6NnoxY3VaeXpNeXpNeDU3cnI5ZXY5ZTFlNTVxNU56WXpNeFpPYmRkNWRNdG93MHcwMHcwMHcwMHcwMHV0eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4cDVxNHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4O0VYSVRDT0RFOiAwIgogICAgPC9kZWZzPgo8L3N2Zz4K"
alt="Mintel Logo"
width={width}
height={height}
/>
</Link>
);
};

View File

@@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest";
import * as React from "react";
import { render } from "./index";
import { MintelLogo } from "./components/MintelLogo";
import { ContactFormNotification } from "./templates/ContactFormNotification";
describe("@mintel/mail rendering", () => {
it("should render the MintelLogo to HTML", async () => {
const html = await render(React.createElement(MintelLogo));
expect(html).toContain("Mintel Logo");
});
it("should render a ContactFormNotification to HTML", async () => {
const html = await render(
React.createElement(ContactFormNotification, {
name: "Test User",
email: "test@example.com",
message: "Hello World",
}),
);
expect(html).toContain("New Submission");
expect(html).toContain("Test User");
expect(html).toContain("test@example.com");
});
});

View File

@@ -0,0 +1,24 @@
import { render as reactEmailRender } from "@react-email/components";
import { ReactElement } from "react";
/**
* Renders a React email template to HTML.
*/
export async function render(
template: ReactElement,
options?: any,
): Promise<string> {
return reactEmailRender(template, options);
}
// Export Components
export * from "./components/MintelLogo";
// Export Layouts
export * from "./layouts/BaseLayout";
export * from "./layouts/MintelLayout";
export * from "./layouts/ClientLayout";
// Export Templates
export * from "./templates/ContactFormNotification";
export * from "./templates/ConfirmationMessage";

View File

@@ -0,0 +1,53 @@
import {
Body,
Container,
Head,
Html,
Preview,
Section,
} from "@react-email/components";
import * as React from "react";
export interface BaseLayoutProps {
preview: string;
children: React.ReactNode;
brandColor?: string;
}
export const BaseLayout = ({
preview,
children,
brandColor = "#82ed20",
}: BaseLayoutProps) => {
return (
<Html>
<Head />
<Preview>{preview}</Preview>
<Body style={main}>
<Container style={container}>
<Section style={content}>{children}</Section>
</Container>
</Body>
</Html>
);
};
const main = {
backgroundColor: "#0a0a0a",
color: "#ffffff",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
};
const container = {
backgroundColor: "#0f0f0f",
margin: "0 auto",
padding: "40px 0",
maxWidth: "600px",
border: "1px solid #1a1a1a",
borderRadius: "12px",
};
const content = {
padding: "0 40px",
};

View File

@@ -0,0 +1,80 @@
import * as React from "react";
import { Hr, Section, Text, Img } from "@react-email/components";
import { BaseLayout } from "./BaseLayout";
export interface ClientLayoutProps {
preview: string;
children: React.ReactNode;
clientLogo?: string;
clientName: string;
brandColor?: string;
}
export const ClientLayout = ({
preview,
children,
clientLogo,
clientName,
brandColor = "#82ed20",
}: ClientLayoutProps) => {
return (
<BaseLayout preview={preview} brandColor={brandColor}>
<Section style={header}>
{clientLogo ? (
<Img src={clientLogo} alt={clientName} height="40" style={logo} />
) : (
<Text style={logoText(brandColor)}>{clientName}</Text>
)}
</Section>
<Hr style={hr} />
<Section style={mainContent}>{children}</Section>
<Hr style={hr} />
<Section style={footer}>
<Text style={footerText}>
&copy; 2026 {clientName}. All rights reserved.
</Text>
</Section>
</BaseLayout>
);
};
const header = {
marginBottom: "32px",
};
const logo = {
margin: "0 auto",
display: "block",
};
const logoText = (color: string) => ({
margin: "0 auto",
textAlign: "center" as const,
fontSize: "24px",
fontWeight: 900,
color: "#ffffff",
letterSpacing: "-0.02em",
borderLeft: `4px solid ${color}`,
paddingLeft: "12px",
});
const mainContent = {
marginBottom: "32px",
};
const hr = {
borderColor: "#222222",
margin: "20px 0",
};
const footer = {
marginTop: "32px",
textAlign: "center" as const,
};
const footerText = {
fontSize: "10px",
color: "#333333",
textTransform: "uppercase" as const,
letterSpacing: "0.1em",
};

View File

@@ -0,0 +1,53 @@
import * as React from "react";
import { Hr, Section, Text } from "@react-email/components";
import { BaseLayout } from "./BaseLayout";
import { MintelLogo } from "../components/MintelLogo";
export interface MintelLayoutProps {
preview: string;
children: React.ReactNode;
}
export const MintelLayout = ({ preview, children }: MintelLayoutProps) => {
return (
<BaseLayout preview={preview} brandColor="#82ed20">
<Section style={header}>
<MintelLogo />
</Section>
<Hr style={hr} />
<Section style={mainContent}>{children}</Section>
<Hr style={hr} />
<Section style={footer}>
<Text style={footerText}>
&copy; 2026 Mintel Infrastructure. Secure Communication Channel.
</Text>
</Section>
</BaseLayout>
);
};
const header = {
marginBottom: "32px",
};
const mainContent = {
marginBottom: "32px",
};
const hr = {
borderColor: "#222222",
margin: "20px 0",
};
const footer = {
marginTop: "32px",
textAlign: "center" as const,
};
const footerText = {
fontSize: "12px",
color: "#444444",
fontWeight: 700,
textTransform: "uppercase" as const,
letterSpacing: "0.1em",
};

View File

@@ -0,0 +1,57 @@
import * as React from "react";
import { Heading, Text } from "@react-email/components";
import { ClientLayout } from "../layouts/ClientLayout";
export interface ConfirmationMessageProps {
name: string;
clientName: string;
clientLogo?: string;
brandColor?: string;
}
export const ConfirmationMessage = ({
name,
clientName,
clientLogo,
brandColor,
}: ConfirmationMessageProps) => {
const preview = `Thank you for your message, ${name}`;
return (
<ClientLayout
preview={preview}
clientName={clientName}
clientLogo={clientLogo}
brandColor={brandColor}
>
<Heading style={h1}>Thank You</Heading>
<Text style={text}>Hello {name},</Text>
<Text style={text}>
Thank you for contacting us. We have received your message and will get
back to you as soon as possible.
</Text>
<Text style={text}>
Best regards,
<br />
The {clientName} Team
</Text>
</ClientLayout>
);
};
export default ConfirmationMessage;
const h1 = {
fontSize: "28px",
fontWeight: "900",
margin: "0 0 16px",
color: "#ffffff",
letterSpacing: "-0.04em",
};
const text = {
fontSize: "16px",
lineHeight: "24px",
color: "#cccccc",
margin: "16px 0",
};

View File

@@ -0,0 +1,118 @@
import * as React from "react";
import { Heading, Section, Text, Row, Column } from "@react-email/components";
import { MintelLayout } from "../layouts/MintelLayout";
export interface ContactFormNotificationProps {
name: string;
email: string;
message: string;
productName?: string;
}
export const ContactFormNotification = ({
name,
email,
message,
productName,
}: ContactFormNotificationProps) => {
const preview = `New message from ${name}`;
return (
<MintelLayout preview={preview}>
<Heading style={h1}>New Submission</Heading>
<Text style={intro}>
A new message has been received via the contact form.
</Text>
<Section style={detailsContainer}>
<Row>
<Column style={labelCol}>
<Text style={label}>Name</Text>
</Column>
<Column>
<Text style={value}>{name}</Text>
</Column>
</Row>
<Row>
<Column style={labelCol}>
<Text style={label}>Email</Text>
</Column>
<Column>
<Text style={value}>{email}</Text>
</Column>
</Row>
{productName && (
<Row>
<Column style={labelCol}>
<Text style={label}>Product</Text>
</Column>
<Column>
<Text style={value}>{productName}</Text>
</Column>
</Row>
)}
</Section>
<Section style={messageSection}>
<Text style={label}>Message</Text>
<Text style={messageText}>{message}</Text>
</Section>
</MintelLayout>
);
};
export default ContactFormNotification;
const h1 = {
fontSize: "28px",
fontWeight: "900",
margin: "0 0 16px",
color: "#ffffff",
letterSpacing: "-0.04em",
};
const intro = {
fontSize: "16px",
color: "#888888",
margin: "0 0 32px",
};
const detailsContainer = {
backgroundColor: "#151515",
padding: "24px",
borderRadius: "8px",
marginBottom: "24px",
};
const labelCol = {
width: "100px",
};
const label = {
fontSize: "10px",
fontWeight: "900",
textTransform: "uppercase" as const,
color: "#444444",
margin: "0 0 4px",
letterSpacing: "0.1em",
};
const value = {
fontSize: "16px",
color: "#ffffff",
margin: "0 0 12px",
};
const messageSection = {
padding: "0 24px",
};
const messageText = {
fontSize: "16px",
lineHeight: "24px",
color: "#cccccc",
fontStyle: "italic",
borderLeft: "2px solid #222222",
paddingLeft: "16px",
margin: "12px 0 0",
};

View File

@@ -0,0 +1,14 @@
{
"extends": "@mintel/tsconfig/base.json",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src"]
}

View File

@@ -1,46 +1,71 @@
import createNextIntlPlugin from 'next-intl/plugin';
import { withSentryConfig } from '@sentry/nextjs';
const withNextIntl = createNextIntlPlugin();
import createNextIntlPlugin from "next-intl/plugin";
import { withSentryConfig } from "@sentry/nextjs";
import fs from "node:fs";
import path from "node:path";
/** @type {import('next').NextConfig} */
export const baseNextConfig = {
output: 'standalone',
output: "standalone",
images: {
dangerouslyAllowSVG: true,
contentDispositionType: 'attachment',
contentDispositionType: "attachment",
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
},
async rewrites() {
const umamiUrl = (process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL || 'https://analytics.infra.mintel.me').replace('/script.js', '');
const umamiUrl = (
process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL ||
"https://analytics.infra.mintel.me"
).replace("/script.js", "");
const glitchtipUrl = process.env.SENTRY_DSN
? new URL(process.env.SENTRY_DSN).origin
: 'https://errors.infra.mintel.me';
: "https://errors.infra.mintel.me";
return [
{
source: '/stats/:path*',
source: "/stats/:path*",
destination: `${umamiUrl}/:path*`,
},
{
source: '/errors/:path*',
source: "/errors/:path*",
destination: `${glitchtipUrl}/:path*`,
},
];
},
};
export default (config) => {
const nextIntlConfig = withNextIntl({ ...baseNextConfig, ...config });
const withMintelConfig = (config) => {
const i18nPaths = [
"src/i18n/request.ts",
"src/i18n/request.tsx",
"i18n/request.ts",
"i18n/request.tsx",
"src/i18n.ts",
"src/i18n.tsx",
"i18n.ts",
"i18n.tsx",
];
const hasI18nConfig = i18nPaths.some((p) =>
fs.existsSync(path.resolve(process.cwd(), p)),
);
let nextConfig = { ...baseNextConfig, ...config };
if (hasI18nConfig) {
const withNextIntl = createNextIntlPlugin();
nextConfig = withNextIntl(nextConfig);
}
return withSentryConfig(
nextIntlConfig,
nextConfig,
{
silent: !process.env.CI,
treeshake: { removeDebugLogging: true },
},
{
authToken: undefined,
}
},
);
};
export default withMintelConfig;

View File

@@ -15,7 +15,7 @@
}
},
"dependencies": {
"next-intl": "^3.0.0",
"next-intl": "^4.8.2",
"@sentry/nextjs": "^8.0.0"
}
}

View File

@@ -0,0 +1,49 @@
{
"name": "@mintel/next-observability",
"version": "1.0.0",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"
},
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./client": {
"types": "./dist/client.d.ts",
"import": "./dist/client.js",
"require": "./dist/client.cjs"
}
},
"type": "module",
"scripts": {
"build": "tsup src/index.ts src/client.ts --format cjs,esm --dts --splitting",
"dev": "tsup src/index.ts src/client.ts --format cjs,esm --watch --dts --splitting",
"lint": "eslint src/"
},
"dependencies": {
"@mintel/observability": "workspace:*",
"@sentry/nextjs": "^8.55.0",
"next": "15.1.6"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@mintel/eslint-config": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^9.39.2",
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}

View File

@@ -0,0 +1,23 @@
"use client";
import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { useAnalytics } from "./context";
/**
* Automatically tracks pageviews on client-side route changes in Next.js.
*/
export function AnalyticsAutoTracker() {
const pathname = usePathname();
const searchParams = useSearchParams();
const analytics = useAnalytics();
useEffect(() => {
if (!pathname) return;
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ""}`;
analytics.trackPageview(url);
}, [pathname, searchParams, analytics]);
return null;
}

View File

@@ -0,0 +1,50 @@
"use client";
import { createContext, useContext, ReactNode, useMemo } from "react";
import type { AnalyticsService } from "@mintel/observability";
import {
NoopAnalyticsService,
UmamiAnalyticsService,
} from "@mintel/observability";
const AnalyticsContext = createContext<AnalyticsService>(
new NoopAnalyticsService(),
);
export interface AnalyticsContextProviderProps {
service?: AnalyticsService;
config?: {
enabled: boolean;
websiteId?: string;
apiEndpoint: string;
};
children: ReactNode;
}
export function AnalyticsContextProvider({
service,
config,
children,
}: AnalyticsContextProviderProps) {
const activeService = useMemo(() => {
if (service) return service;
if (config) return new UmamiAnalyticsService(config);
return new NoopAnalyticsService();
}, [service, config]);
return (
<AnalyticsContext.Provider value={activeService}>
{children}
</AnalyticsContext.Provider>
);
}
export function useAnalytics() {
const context = useContext(AnalyticsContext);
if (!context) {
throw new Error(
"useAnalytics must be used within an AnalyticsContextProvider",
);
}
return context;
}

View File

@@ -0,0 +1,4 @@
"use client";
export * from "./analytics/context";
export * from "./analytics/auto-tracker";

View File

@@ -0,0 +1,26 @@
import * as Sentry from "@sentry/nextjs";
export interface SentryConfig {
dsn?: string;
enabled?: boolean;
tracesSampleRate?: number;
tunnel?: string;
replaysOnErrorSampleRate?: number;
replaysSessionSampleRate?: number;
}
/**
* Standardized Sentry initialization for Mintel projects.
*/
export function initSentry(config: SentryConfig) {
Sentry.init({
dsn: config.dsn,
enabled: config.enabled ?? Boolean(config.dsn || config.tunnel),
tracesSampleRate: config.tracesSampleRate ?? 0,
tunnel: config.tunnel,
replaysOnErrorSampleRate: config.replaysOnErrorSampleRate ?? 1.0,
replaysSessionSampleRate: config.replaysSessionSampleRate ?? 0.1,
});
}
export { Sentry };

View File

@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from "next/server";
/**
* Logic for Umami Smart Proxy Route Handler.
*/
export function createUmamiProxyHandler(config: {
websiteId?: string;
apiEndpoint: string;
}) {
return async function POST(request: NextRequest) {
try {
const body = await request.json();
const { type, payload } = body;
if (!config.websiteId) {
return NextResponse.json({ status: "ignored" }, { status: 200 });
}
const enhancedPayload = {
...payload,
website: config.websiteId,
};
const response = await fetch(`${config.apiEndpoint}/api/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": request.headers.get("user-agent") || "Mintel-Proxy",
"X-Forwarded-For": request.headers.get("x-forwarded-for") || "",
},
body: JSON.stringify({ type, payload: enhancedPayload }),
});
if (!response.ok) {
const errorText = await response.text();
return new NextResponse(errorText, { status: response.status });
}
return NextResponse.json({ status: "ok" });
} catch (error) {
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
};
}
/**
* Logic for Sentry/GlitchTip Relay Route Handler.
*/
export function createSentryRelayHandler(config: { dsn?: string }) {
return async function POST(request: NextRequest) {
try {
const envelope = await request.text();
const lines = envelope.split("\n");
if (lines.length < 1) {
return NextResponse.json({ error: "Empty envelope" }, { status: 400 });
}
if (!config.dsn) {
return NextResponse.json({ status: "ignored" }, { status: 200 });
}
const dsnUrl = new URL(config.dsn);
const projectId = dsnUrl.pathname.replace("/", "");
const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/`;
const response = await fetch(relayUrl, {
method: "POST",
body: envelope,
headers: {
"Content-Type": "application/x-sentry-envelope",
},
});
if (!response.ok) {
const errorText = await response.text();
return new NextResponse(errorText, { status: response.status });
}
return NextResponse.json({ status: "ok" });
} catch (error) {
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
};
}

View File

@@ -0,0 +1,2 @@
export * from "./handlers/index";
export * from "./errors/sentry";

View File

@@ -0,0 +1,10 @@
{
"extends": "../tsconfig/base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"jsx": "react-jsx",
"esModuleInterop": true
},
"include": ["src"]
}

View File

@@ -17,7 +17,7 @@
"dependencies": {
"@directus/sdk": "^21.0.0",
"next": "15.1.6",
"next-intl": "^3.0.0",
"next-intl": "^4.8.2",
"zod": "^3.0.0"
},
"devDependencies": {

View File

@@ -1,20 +1,36 @@
import { z } from 'zod';
import { z } from "zod";
export const mintelEnvSchema = {
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
NEXT_PUBLIC_BASE_URL: z.string().url(),
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(),
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.string().url().default('https://analytics.infra.mintel.me/script.js'),
// Analytics (Proxy Pattern)
UMAMI_WEBSITE_ID: z.string().optional(),
UMAMI_API_ENDPOINT: z
.string()
.url()
.default("https://analytics.infra.mintel.me"),
// Error Tracking
SENTRY_DSN: z.string().optional(),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
// Notifications
GOTIFY_URL: z.string().url().optional(),
GOTIFY_TOKEN: z.string().optional(),
LOG_LEVEL: z
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
.default("info"),
MAIL_HOST: z.string().optional(),
MAIL_PORT: z.coerce.number().default(587),
MAIL_USERNAME: z.string().optional(),
MAIL_PASSWORD: z.string().optional(),
MAIL_FROM: z.string().optional(),
MAIL_RECIPIENTS: z.preprocess(
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
z.array(z.string()).default([])
(val) => (typeof val === "string" ? val.split(",").filter(Boolean) : val),
z.array(z.string()).default([]),
),
};
@@ -24,11 +40,26 @@ export function validateMintelEnv(schemaExtension = {}) {
...schemaExtension,
});
const isBuildTime =
process.env.NEXT_PHASE === "phase-production-build" ||
process.env.SKIP_ENV_VALIDATION === "true";
const result = fullSchema.safeParse(process.env);
if (!result.success) {
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
throw new Error('Invalid environment variables');
if (isBuildTime) {
console.warn(
"⚠️ Some environment variables are missing during build, but skipping strict validation.",
);
// Return partial data to allow build to continue
return process.env as unknown as z.infer<typeof fullSchema>;
}
console.error(
"❌ Invalid environment variables:",
result.error.flatten().fieldErrors,
);
throw new Error("Invalid environment variables");
}
return result.data;

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { isValidLang } from "../src/index";
import { isValidLang } from "./lang";
describe("next-utils", () => {
it("should validate languages correctly", () => {

View File

@@ -30,12 +30,7 @@ export async function rateLimit(
submissions[identifier] = now;
}
export const languages = ["en", "de"] as const;
export type Lang = (typeof languages)[number];
export function isValidLang(lang: string): lang is Lang {
return (languages as readonly string[]).includes(lang);
}
export * from "./lang";
export * from "./i18n";
export * from "./env";

View File

@@ -0,0 +1,6 @@
export const languages = ["en", "de"] as const;
export type Lang = (typeof languages)[number];
export function isValidLang(lang: string): lang is Lang {
return (languages as readonly string[]).includes(lang);
}

View File

@@ -0,0 +1,123 @@
# @mintel/observability
Standardized observability package for the Mintel ecosystem, providing Umami analytics and Sentry/GlitchTip error tracking with a focus on privacy and ad-blocker resilience.
## Features
- **Umami Smart Proxy**: Track analytics without external scripts and hide your Website ID.
- **Sentry Relay**: Bypass ad-blockers for error tracking by relaying envelopes through your own server.
- **Unified API**: consistent interface for tracking across multiple projects.
## Installation
```bash
pnpm add @mintel/observability @sentry/nextjs
```
## Usage
### 1. Unified Environment (via @mintel/next-utils)
Define the following environment variables:
```bash
# Analytics
UMAMI_WEBSITE_ID=your-website-id
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
# Error Tracking
SENTRY_DSN=your-sentry-dsn
```
Note: No `NEXT_PUBLIC_` prefix is required for these anymore, as they are handled by server-side proxies.
### 2. Analytics Setup
In your root layout:
```tsx
import {
AnalyticsContextProvider,
AnalyticsAutoTracker,
UmamiAnalyticsService,
} from "@mintel/observability";
const analytics = new UmamiAnalyticsService({
enabled: true,
websiteId: process.env.UMAMI_WEBSITE_ID, // Server-side
apiEndpoint:
typeof window === "undefined" ? process.env.UMAMI_API_ENDPOINT : "/stats",
});
export default function Layout({ children }) {
return (
<AnalyticsContextProvider service={analytics}>
<AnalyticsAutoTracker />
{children}
</AnalyticsContextProvider>
);
}
```
### 3. Route Handlers
Create a proxy for Umami:
`app/stats/api/send/route.ts`
```ts
import { createUmamiProxyHandler } from "@mintel/observability";
export const POST = await createUmamiProxyHandler({
websiteId: process.env.UMAMI_WEBSITE_ID,
apiEndpoint: process.env.UMAMI_API_ENDPOINT,
});
```
Create a relay for Sentry:
`app/errors/api/relay/route.ts`
```ts
import { createSentryRelayHandler } from "@mintel/observability";
export const POST = await createSentryRelayHandler({
dsn: process.env.SENTRY_DSN,
});
```
### 4. Notification Setup (Server-side)
```ts
import { GotifyNotificationService } from "@mintel/observability";
const notifications = new GotifyNotificationService({
enabled: true,
url: process.env.GOTIFY_URL,
token: process.env.GOTIFY_TOKEN,
});
await notifications.notify({
title: "Lead Capture",
message: "New contact form submission",
priority: 5,
});
```
### 5. Sentry Configuration
Use `initSentry` in your `sentry.server.config.ts` and `sentry.client.config.ts`.
On the client, use the tunnel:
```ts
initSentry({
dsn: "https://public@errors.infra.mintel.me/1", // Placeholder
tunnel: "/errors/api/relay",
});
```
## Architecture
This package implements the **Smart Proxy** pattern:
- The client NEVER knows the real `UMAMI_WEBSITE_ID`.
- Tracking events are sent to your own domain (`/stats/api/send`).
- Your server injects the secret ID and forwards to Umami.
- This bypasses ad-blockers and keeps your configuration secure.

View File

@@ -0,0 +1,34 @@
{
"name": "@mintel/observability",
"version": "1.0.0",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"
},
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"type": "module",
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"dev": "tsup src/index.ts --format cjs,esm --watch --dts",
"lint": "eslint src/",
"test": "vitest run"
},
"dependencies": {},
"devDependencies": {
"@mintel/eslint-config": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"eslint": "^9.39.2",
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"vitest": "^2.0.0"
}
}

View File

@@ -0,0 +1,14 @@
import { describe, it, expect } from "vitest";
import { NoopAnalyticsService } from "./noop";
describe("NoopAnalyticsService", () => {
it("should not throw on track", () => {
const service = new NoopAnalyticsService();
expect(() => service.track("test")).not.toThrow();
});
it("should not throw on trackPageview", () => {
const service = new NoopAnalyticsService();
expect(() => service.trackPageview()).not.toThrow();
});
});

View File

@@ -0,0 +1,15 @@
import type { AnalyticsService, AnalyticsEventProperties } from "./service";
/**
* No-operation analytics service.
* Used when analytics are disabled or for local development.
*/
export class NoopAnalyticsService implements AnalyticsService {
track(eventName: string, props?: AnalyticsEventProperties): void {
// Do nothing
}
trackPageview(url?: string): void {
// Do nothing
}
}

View File

@@ -0,0 +1,31 @@
/**
* Type definition for analytics event properties.
*/
export type AnalyticsEventProperties = Record<
string,
string | number | boolean | null | undefined
>;
/**
* Interface for analytics service implementations.
*
* This interface defines the contract for all analytics services,
* allowing for different implementations (Umami, Google Analytics, etc.)
* while maintaining a consistent API.
*/
export interface AnalyticsService {
/**
* Track a custom event with optional properties.
*
* @param eventName - The name of the event to track
* @param props - Optional event properties (metadata)
*/
track(eventName: string, props?: AnalyticsEventProperties): void;
/**
* Track a pageview.
*
* @param url - The URL to track (defaults to current location)
*/
trackPageview(url?: string): void;
}

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { UmamiAnalyticsService } from "./umami";
describe("UmamiAnalyticsService", () => {
const mockConfig = {
websiteId: "test-website-id",
apiEndpoint: "https://analytics.test",
enabled: true,
};
const mockLogger = {
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
trace: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn();
});
it("should not send payload if disabled", async () => {
const service = new UmamiAnalyticsService({
...mockConfig,
enabled: false,
});
service.track("test-event");
expect(global.fetch).not.toHaveBeenCalled();
});
it("should send payload with correct data for track", async () => {
const service = new UmamiAnalyticsService(mockConfig, mockLogger);
(global.fetch as any).mockResolvedValue({ ok: true });
service.track("test-event", { foo: "bar" });
// Wait for async sendPayload
await new Promise((resolve) => setTimeout(resolve, 0));
expect(global.fetch).toHaveBeenCalledWith(
"https://analytics.test/api/send",
expect.objectContaining({
method: "POST",
body: expect.stringContaining('"type":"event"'),
}),
);
const callBody = JSON.parse((global.fetch as any).mock.calls[0][1].body);
expect(callBody.payload.name).toBe("test-event");
expect(callBody.payload.data.foo).toBe("bar");
expect(callBody.payload.website).toBe("test-website-id");
});
it("should log warning if send fails", async () => {
const service = new UmamiAnalyticsService(mockConfig, mockLogger);
(global.fetch as any).mockResolvedValue({
ok: false,
status: 500,
text: () => Promise.resolve("Internal error"),
});
service.track("test-event");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(mockLogger.warn).toHaveBeenCalledWith(
"Umami API responded with error",
expect.objectContaining({ status: 500 }),
);
});
});

View File

@@ -0,0 +1,115 @@
import type { AnalyticsService, AnalyticsEventProperties } from "./service";
export interface UmamiConfig {
websiteId?: string;
apiEndpoint: string; // The endpoint to send to (proxied or direct)
enabled: boolean;
}
export interface Logger {
debug(msg: string, data?: any): void;
warn(msg: string, data?: any): void;
error(msg: string, data?: any): void;
trace(msg: string, data?: any): void;
}
/**
* Umami Analytics Service Implementation (Script-less/Proxy edition).
*/
export class UmamiAnalyticsService implements AnalyticsService {
private logger?: Logger;
constructor(
private config: UmamiConfig,
logger?: Logger,
) {
this.logger = logger;
}
private async sendPayload(type: "event", data: Record<string, any>) {
if (!this.config.enabled) return;
const isClient = typeof window !== "undefined";
const websiteId = this.config.websiteId;
if (!isClient && !websiteId) {
this.logger?.warn(
"Umami tracking called on server but no Website ID configured",
);
return;
}
try {
const payload = {
website: websiteId,
hostname: isClient ? window.location.hostname : "server",
screen: isClient
? `${window.screen.width}x${window.screen.height}`
: undefined,
language: isClient ? navigator.language : undefined,
referrer: isClient ? document.referrer : undefined,
...data,
};
this.logger?.trace("Sending analytics payload", { type, url: data.url });
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(`${this.config.apiEndpoint}/api/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": isClient ? navigator.userAgent : "Mintel-Server",
},
body: JSON.stringify({ type, payload }),
keepalive: true,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
this.logger?.warn("Umami API responded with error", {
status: response.status,
error: errorText.slice(0, 100),
});
}
} catch (fetchError) {
clearTimeout(timeoutId);
if ((fetchError as Error).name === "AbortError") {
this.logger?.error("Umami request timed out");
} else {
throw fetchError;
}
}
} catch (error) {
this.logger?.error("Failed to send analytics", {
error: (error as Error).message,
});
}
}
track(eventName: string, props?: AnalyticsEventProperties) {
this.sendPayload("event", {
name: eventName,
data: props,
url:
typeof window !== "undefined"
? window.location.pathname + window.location.search
: undefined,
});
}
trackPageview(url?: string) {
this.sendPayload("event", {
url:
url ||
(typeof window !== "undefined"
? window.location.pathname + window.location.search
: undefined),
});
}
}

View File

@@ -0,0 +1,9 @@
// Analytics
export * from "./analytics/service";
export * from "./analytics/umami";
export * from "./analytics/noop";
// Notifications
export * from "./notifications/service";
export * from "./notifications/gotify";
export * from "./notifications/noop";

View File

@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GotifyNotificationService } from "./gotify";
describe("GotifyNotificationService", () => {
const mockConfig = {
url: "https://gotify.test",
token: "test-token",
enabled: true,
};
const mockLogger = {
error: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn();
});
it("should not notify if disabled", async () => {
const service = new GotifyNotificationService({
...mockConfig,
enabled: false,
});
await service.notify({ title: "test", message: "test" });
expect(global.fetch).not.toHaveBeenCalled();
});
it("should send correct payload to Gotify", async () => {
const service = new GotifyNotificationService(mockConfig, mockLogger);
(global.fetch as any).mockResolvedValue({ ok: true });
await service.notify({
title: "Alert",
message: "Critical issue",
priority: 8,
});
expect(global.fetch).toHaveBeenCalledWith(
"https://gotify.test/message?token=test-token",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: "Alert",
message: "Critical issue",
priority: 8,
}),
}),
);
});
it("should handle missing trailing slash in URL", async () => {
const service = new GotifyNotificationService({
...mockConfig,
url: "https://gotify.test",
});
(global.fetch as any).mockResolvedValue({ ok: true });
await service.notify({ title: "test", message: "test" });
expect((global.fetch as any).mock.calls[0][0]).toBe(
"https://gotify.test/message?token=test-token",
);
});
it("should log error if notify fails", async () => {
const service = new GotifyNotificationService(mockConfig, mockLogger);
(global.fetch as any).mockResolvedValue({
ok: false,
status: 401,
text: () => Promise.resolve("Unauthorized"),
});
await service.notify({ title: "test", message: "test" });
expect(mockLogger.error).toHaveBeenCalledWith(
"Gotify notification failed",
expect.objectContaining({ status: 401 }),
);
});
});

View File

@@ -0,0 +1,56 @@
import { NotificationOptions, NotificationService } from "./service";
export interface GotifyConfig {
url: string;
token: string;
enabled: boolean;
}
/**
* Gotify Notification Service implementation.
*/
export class GotifyNotificationService implements NotificationService {
constructor(
private config: GotifyConfig,
private logger?: { error(msg: string, data?: any): void },
) {}
async notify(options: NotificationOptions): Promise<void> {
if (!this.config.enabled) return;
try {
const { title, message, priority = 4 } = options;
// Ensure we have a trailing slash for base URL, then append 'message'
const baseUrl = this.config.url.endsWith("/")
? this.config.url
: `${this.config.url}/`;
const url = new URL("message", baseUrl);
url.searchParams.set("token", this.config.token);
const response = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
message,
priority,
}),
});
if (!response.ok) {
const errorText = await response.text();
this.logger?.error("Gotify notification failed", {
status: response.status,
error: errorText.slice(0, 100),
});
}
} catch (error) {
this.logger?.error("Gotify notification error", {
error: (error as Error).message,
});
}
}
}

View File

@@ -0,0 +1,11 @@
import { describe, it, expect } from "vitest";
import { NoopNotificationService } from "./noop";
describe("NoopNotificationService", () => {
it("should not throw on notify", async () => {
const service = new NoopNotificationService();
await expect(
service.notify({ title: "test", message: "test" }),
).resolves.not.toThrow();
});
});

Some files were not shown because too many files have changed in this diff Show More