Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a55649c5f2 | |||
| 0d7c588536 | |||
| b6debcbb59 | |||
| 5847bc5795 | |||
| e662415137 | |||
| 580b087e8a | |||
| ac3c405cb2 | |||
| a594affdfa | |||
| 61e78ea672 | |||
| 6501eac38a | |||
| 7f9206ae77 | |||
| 6229f8e886 | |||
| 8ac090aff3 | |||
| 696f9d361d | |||
| 31840da9e7 | |||
| 96ec2c7d8d | |||
| 9029375247 | |||
| 95d0a1622f | |||
| 646d615e76 | |||
| 51409099fc | |||
| 22cd20e639 | |||
| e7cc1c8ca5 | |||
| 0ccb15a929 | |||
| a94ddcfbb2 | |||
| d3a9af140c | |||
| 0dc3ba0da4 | |||
| 1a94465dba | |||
| 7e256025ea | |||
| e843de42da | |||
| 4d1b2231e3 | |||
| 71f47f9037 |
5
.changeset/init-mail-package.md
Normal file
5
.changeset/init-mail-package.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@mintel/mail": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Initial release of the branded email system package.
|
||||||
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.npmrc
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
out
|
||||||
|
coverage
|
||||||
|
.vercel
|
||||||
|
.turbo
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -2,13 +2,8 @@ name: Monorepo Pipeline
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -18,6 +13,8 @@ jobs:
|
|||||||
qa:
|
qa:
|
||||||
name: 🧪 Quality Assurance
|
name: 🧪 Quality Assurance
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -52,6 +49,8 @@ jobs:
|
|||||||
needs: qa
|
needs: qa
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
env:
|
env:
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -82,10 +81,28 @@ jobs:
|
|||||||
pnpm release:tag
|
pnpm release:tag
|
||||||
|
|
||||||
build-images:
|
build-images:
|
||||||
name: 🐳 Build & Push Images
|
name: 🐳 Build ${{ matrix.name }}
|
||||||
needs: qa
|
needs: qa
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
runs-on: docker
|
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -94,49 +111,25 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: 🔐 Registry Login
|
- name: 🔐 Registry Login
|
||||||
run: |
|
uses: docker/login-action@v3
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
with:
|
||||||
|
registry: registry.infra.mintel.me
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASS }}
|
||||||
|
|
||||||
- name: 🏗️ Build & Push Nextjs Build-Base
|
- name: 🏗️ Build & Push ${{ matrix.name }}
|
||||||
env:
|
uses: docker/build-push-action@v5
|
||||||
TAG: ${{ github.ref_name }}
|
with:
|
||||||
run: |
|
context: .
|
||||||
docker buildx build \
|
file: ${{ matrix.file }}
|
||||||
--platform linux/amd64,linux/arm64 \
|
platforms: linux/arm64
|
||||||
-t registry.infra.mintel.me/mintel/nextjs:$TAG \
|
pull: true
|
||||||
-t registry.infra.mintel.me/mintel/nextjs:latest \
|
push: true
|
||||||
-f packages/infra/docker/Dockerfile.nextjs \
|
secrets: |
|
||||||
--push .
|
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
|
||||||
|
|
||||||
- name: 🏗️ Build & Push Production Runtime
|
|
||||||
env:
|
|
||||||
TAG: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
docker buildx build \
|
|
||||||
--platform linux/amd64,linux/arm64 \
|
|
||||||
-t registry.infra.mintel.me/mintel/runtime:$TAG \
|
|
||||||
-t registry.infra.mintel.me/mintel/runtime:latest \
|
|
||||||
-f packages/infra/docker/Dockerfile.runtime \
|
|
||||||
--push .
|
|
||||||
|
|
||||||
- name: 🏗️ Build & Push Gatekeeper (Product)
|
|
||||||
env:
|
|
||||||
TAG: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
docker buildx build \
|
|
||||||
--platform linux/amd64,linux/arm64 \
|
|
||||||
-t registry.infra.mintel.me/mintel/gatekeeper:$TAG \
|
|
||||||
-t registry.infra.mintel.me/mintel/gatekeeper:latest \
|
|
||||||
-f packages/infra/docker/Dockerfile.gatekeeper \
|
|
||||||
--push .
|
|
||||||
|
|
||||||
- name: 🏗️ Build & Push Directus (Base)
|
|
||||||
env:
|
|
||||||
TAG: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
docker buildx build \
|
|
||||||
--platform linux/amd64,linux/arm64 \
|
|
||||||
-t registry.infra.mintel.me/mintel/directus:$TAG \
|
|
||||||
-t registry.infra.mintel.me/mintel/directus:latest \
|
|
||||||
-f packages/infra/docker/Dockerfile.directus \
|
|
||||||
--push .
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# dependencies
|
# dependencies
|
||||||
node_modules
|
node_modules
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
.next/
|
.next/
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mintel/next-utils": "workspace:*",
|
"@mintel/next-utils": "workspace:*",
|
||||||
|
"@mintel/observability": "workspace:*",
|
||||||
|
"@mintel/next-observability": "workspace:*",
|
||||||
|
"@sentry/nextjs": "^8.55.0",
|
||||||
"next": "15.1.6",
|
"next": "15.1.6",
|
||||||
"next-intl": "^4.8.2",
|
"next-intl": "^4.8.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
9
apps/sample-website/sentry.client.config.ts
Normal file
9
apps/sample-website/sentry.client.config.ts
Normal 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",
|
||||||
|
});
|
||||||
8
apps/sample-website/sentry.edge.config.ts
Normal file
8
apps/sample-website/sentry.edge.config.ts
Normal 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,
|
||||||
|
});
|
||||||
8
apps/sample-website/sentry.server.config.ts
Normal file
8
apps/sample-website/sentry.server.config.ts
Normal 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,
|
||||||
|
});
|
||||||
6
apps/sample-website/src/app/errors/api/relay/route.ts
Normal file
6
apps/sample-website/src/app/errors/api/relay/route.ts
Normal 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,
|
||||||
|
});
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import {
|
||||||
|
AnalyticsContextProvider,
|
||||||
|
AnalyticsAutoTracker,
|
||||||
|
} from "@mintel/next-observability/client";
|
||||||
|
import { getAnalyticsConfig } from "@/lib/observability";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Sample Website",
|
title: "Sample Website",
|
||||||
@@ -11,9 +17,18 @@ export default function RootLayout({
|
|||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
const analyticsConfig = getAnalyticsConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body>{children}</body>
|
<body>
|
||||||
|
<AnalyticsContextProvider config={analyticsConfig}>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<AnalyticsAutoTracker />
|
||||||
|
</Suspense>
|
||||||
|
{children}
|
||||||
|
</AnalyticsContextProvider>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
7
apps/sample-website/src/app/stats/api/send/route.ts
Normal file
7
apps/sample-website/src/app/stats/api/send/route.ts
Normal 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,
|
||||||
|
});
|
||||||
13
apps/sample-website/src/instrumentation.ts
Normal file
13
apps/sample-website/src/instrumentation.ts
Normal 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;
|
||||||
54
apps/sample-website/src/lib/observability.ts
Normal file
54
apps/sample-website/src/lib/observability.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"@mintel/husky-config": "workspace:*",
|
"@mintel/husky-config": "workspace:*",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/node": "^20.17.16",
|
||||||
"@types/react": "^19.2.10",
|
"@types/react": "^19.2.10",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
|
|||||||
@@ -4,10 +4,30 @@ The Mintel CLI is the primary automation tool for managing the monorepo and ensu
|
|||||||
|
|
||||||
## 🚀 Installation
|
## 🚀 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
|
```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
|
## 🛠 Commands
|
||||||
@@ -17,10 +37,11 @@ pnpm install
|
|||||||
Scaffolds a new, production-ready client website in the specified path.
|
Scaffolds a new, production-ready client website in the specified path.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm --filter @mintel/cli start init apps/my-new-website.com
|
mintel init apps/my-new-website.com
|
||||||
```
|
```
|
||||||
|
|
||||||
#### What it does:
|
#### What it does:
|
||||||
|
|
||||||
1. **Project Structure**: Creates a modern Next.js directory layout.
|
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.
|
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.
|
3. **Localization**: Sets up a localized routing structure (`src/app/[locale]`) with `next-intl` pre-configured.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"mintel": "./dist/index.js"
|
"mintel": "./dist/index.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup src/index.ts --format esm --target es2020",
|
"build": "tsup",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsup src/index.ts --format esm --watch --target es2020",
|
"dev": "tsup src/index.ts --format esm --watch --target es2020",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
@@ -28,4 +28,4 @@
|
|||||||
"@types/prompts": "^2.4.4",
|
"@types/prompts": "^2.4.4",
|
||||||
"@mintel/tsconfig": "workspace:*"
|
"@mintel/tsconfig": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import fs from "fs-extra";
|
import fs from "fs-extra";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
@@ -87,7 +88,7 @@ program
|
|||||||
.action(async (projectPath) => {
|
.action(async (projectPath) => {
|
||||||
const fullPath = path.isAbsolute(projectPath)
|
const fullPath = path.isAbsolute(projectPath)
|
||||||
? projectPath
|
? projectPath
|
||||||
: path.resolve(process.cwd(), "../../", projectPath);
|
: path.resolve(process.cwd(), projectPath);
|
||||||
const projectName = path.basename(fullPath);
|
const projectName = path.basename(fullPath);
|
||||||
|
|
||||||
console.log(chalk.blue(`Initializing new project: ${projectName}...`));
|
console.log(chalk.blue(`Initializing new project: ${projectName}...`));
|
||||||
@@ -124,6 +125,7 @@ program
|
|||||||
react: "^19.0.0",
|
react: "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"@mintel/next-utils": "workspace:*",
|
"@mintel/next-utils": "workspace:*",
|
||||||
|
"@mintel/next-observability": "workspace:*",
|
||||||
"@directus/sdk": "^21.0.0",
|
"@directus/sdk": "^21.0.0",
|
||||||
},
|
},
|
||||||
devDependencies: {
|
devDependencies: {
|
||||||
@@ -262,11 +264,15 @@ export default createMintelI18nRequestConfig(
|
|||||||
// Create instrumentation.ts
|
// Create instrumentation.ts
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(fullPath, "src/instrumentation.ts"),
|
path.join(fullPath, "src/instrumentation.ts"),
|
||||||
`import * as Sentry from '@sentry/nextjs';
|
`import { Sentry } from '@mintel/next-observability';
|
||||||
|
|
||||||
export async function register() {
|
export async function register() {
|
||||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,8 +402,12 @@ DIRECTUS_DB_PASSWORD=mintel-db-pass
|
|||||||
SENTRY_DSN=
|
SENTRY_DSN=
|
||||||
|
|
||||||
# Analytics (Umami)
|
# Analytics (Umami)
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
UMAMI_WEBSITE_ID=
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
|
||||||
|
# Notifications (Gotify)
|
||||||
|
GOTIFY_URL=
|
||||||
|
GOTIFY_TOKEN=
|
||||||
`;
|
`;
|
||||||
await fs.writeFile(path.join(fullPath, ".env.example"), envExample);
|
await fs.writeFile(path.join(fullPath, ".env.example"), envExample);
|
||||||
|
|
||||||
|
|||||||
11
packages/cli/tsup.config.ts
Normal file
11
packages/cli/tsup.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,40 +1,41 @@
|
|||||||
import { dirname } from "path";
|
import nextPlugin from "@next/eslint-plugin-next";
|
||||||
import { fileURLToPath } from "url";
|
import reactPlugin from "eslint-plugin-react";
|
||||||
import { FlatCompat } from "@eslint/eslintrc";
|
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);
|
* Mintel Next.js ESLint Configuration (Flat Config)
|
||||||
|
*
|
||||||
const compat = new FlatCompat({
|
* This configuration replaces the legacy 'eslint-config-next' which
|
||||||
baseDirectory: __dirname,
|
* relies on @rushstack/eslint-patch and causes issues in ESLint 9.
|
||||||
});
|
*/
|
||||||
|
export const nextConfig = tseslint.config(
|
||||||
export const nextConfig = [
|
|
||||||
{
|
|
||||||
ignores: [
|
|
||||||
"**/dist/**",
|
|
||||||
"**/build/**",
|
|
||||||
"**/out/**",
|
|
||||||
"**/coverage/**",
|
|
||||||
"**/.next/**",
|
|
||||||
"**/node_modules/**",
|
|
||||||
"**/.gitea/**",
|
|
||||||
"**/.changeset/**",
|
|
||||||
"**/.vercel/**",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
|
||||||
{
|
{
|
||||||
|
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: {
|
rules: {
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
...reactPlugin.configs.recommended.rules,
|
||||||
"@typescript-eslint/no-unused-vars": [
|
...hooksPlugin.configs.recommended.rules,
|
||||||
"warn",
|
...nextPlugin.configs.recommended.rules,
|
||||||
{ argsIgnorePattern: "^_" },
|
...nextPlugin.configs["core-web-vitals"].rules,
|
||||||
],
|
"react/react-in-jsx-scope": "off",
|
||||||
"@typescript-eslint/no-require-imports": "off",
|
|
||||||
"prefer-const": "warn",
|
|
||||||
"react/no-unescaped-entities": "off",
|
"react/no-unescaped-entities": "off",
|
||||||
"@next/next/no-img-element": "warn",
|
"@next/next/no-img-element": "warn",
|
||||||
},
|
},
|
||||||
},
|
settings: {
|
||||||
];
|
react: {
|
||||||
|
version: "detect",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -20,7 +20,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint/eslintrc": "^3.0.0",
|
"@eslint/eslintrc": "^3.0.0",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
|
"@next/eslint-plugin-next": "15.1.6",
|
||||||
"eslint-config-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"
|
"typescript-eslint": "^8.54.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
packages/gatekeeper/dev-output.log
Normal file
15
packages/gatekeeper/dev-output.log
Normal 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
|
||||||
@@ -2,7 +2,7 @@ import mintelNextConfig from "@mintel/next-config";
|
|||||||
import { NextConfig } from "next";
|
import { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// Gatekeeper specific overrides
|
basePath: '/gatekeeper',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default mintelNextConfig(nextConfig);
|
export default mintelNextConfig(nextConfig);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"@mintel/eslint-config": "workspace:*",
|
"@mintel/eslint-config": "workspace:*",
|
||||||
"@mintel/next-config": "workspace:*",
|
"@mintel/next-config": "workspace:*",
|
||||||
"@mintel/tsconfig": "workspace:*",
|
"@mintel/tsconfig": "workspace:*",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
|||||||
6
packages/gatekeeper/postcss.config.cjs
Normal file
6
packages/gatekeeper/postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
12
packages/gatekeeper/public/icon-white.svg
Normal file
12
packages/gatekeeper/public/icon-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 29 KiB |
12
packages/gatekeeper/public/logo-black.svg
Normal file
12
packages/gatekeeper/public/logo-black.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 17 KiB |
12
packages/gatekeeper/public/logo-white.svg
Normal file
12
packages/gatekeeper/public/logo-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
@@ -9,17 +9,79 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
const session = cookieStore.get(authCookieName);
|
const session = cookieStore.get(authCookieName);
|
||||||
|
|
||||||
if (session?.value === password) {
|
// 1. URL Parameter Bypass (for automated tests/staging)
|
||||||
return new NextResponse("OK", { status: 200 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Traefik ForwardAuth headers
|
|
||||||
const originalUrl = req.headers.get("x-forwarded-uri") || "/";
|
const originalUrl = req.headers.get("x-forwarded-uri") || "/";
|
||||||
const host =
|
const host =
|
||||||
req.headers.get("x-forwarded-host") || req.headers.get("host") || "";
|
req.headers.get("x-forwarded-host") || req.headers.get("host") || "";
|
||||||
const proto = req.headers.get("x-forwarded-proto") || "https";
|
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);
|
return NextResponse.redirect(loginUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
26
packages/gatekeeper/src/app/api/whoami/route.ts
Normal file
26
packages/gatekeeper/src/app/api/whoami/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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]">
|
|
||||||
© 2026 {projectName} Infrastructure
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,20 +2,83 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
@layer base {
|
||||||
--background: #000c1f;
|
html {
|
||||||
--foreground: #ffffff;
|
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 {
|
@layer components {
|
||||||
color: var(--foreground);
|
.narrow-container {
|
||||||
background: var(--background);
|
@apply max-w-4xl mx-auto px-6 py-10;
|
||||||
min-height: 100vh;
|
}
|
||||||
|
|
||||||
|
.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 {
|
/* Custom scrollbar */
|
||||||
background-image:
|
::-webkit-scrollbar {
|
||||||
linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
width: 8px;
|
||||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
height: 8px;
|
||||||
background-size: 40px 40px;
|
}
|
||||||
|
|
||||||
|
::-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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Inter, Newsreader } from "next/font/google";
|
||||||
import "./globals.css";
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: "Gatekeeper | Access Control",
|
title: "Gatekeeper | Access Control",
|
||||||
description: "Mintel Infrastructure Protection",
|
description: "Mintel Infrastructure Protection",
|
||||||
@@ -12,7 +21,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className={`${inter.variable} ${newsreader.variable}`}>
|
||||||
<body className="antialiased">{children}</body>
|
<body className="antialiased">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
199
packages/gatekeeper/src/app/login/page.tsx
Normal file
199
packages/gatekeeper/src/app/login/page.tsx
Normal 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">
|
||||||
|
© 2026 MINTEL
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
packages/gatekeeper/src/app/page.tsx
Normal file
5
packages/gatekeeper/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function RootPage() {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
59
packages/gatekeeper/tailwind.config.cjs
Normal file
59
packages/gatekeeper/tailwind.config.cjs
Normal 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")],
|
||||||
|
};
|
||||||
@@ -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: [],
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
const config = {
|
const config = {
|
||||||
extends: ["@commitlint/config-conventional"],
|
extends: ["@commitlint/config-conventional"],
|
||||||
rules: {
|
rules: {
|
||||||
"header-max-length": [2, "always", 150],
|
"header-max-length": [2, "always", 250],
|
||||||
"subject-case": [0],
|
"subject-case": [0],
|
||||||
"subject-full-stop": [0],
|
"subject-full-stop": [0],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,38 +1,45 @@
|
|||||||
FROM node:20-alpine AS base
|
# Step 1: Builder stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
RUN apk add --no-cache libc6-compat curl
|
RUN apk add --no-cache libc6-compat curl
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Enable pnpm
|
|
||||||
RUN corepack enable pnpm
|
RUN corepack enable pnpm
|
||||||
|
|
||||||
# Install dependencies (using monorepo root context)
|
# Copy manifest files specifically for better layer caching
|
||||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc* ./
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./
|
||||||
COPY packages/gatekeeper/package.json ./packages/gatekeeper/
|
COPY packages/gatekeeper/package.json ./packages/gatekeeper/package.json
|
||||||
COPY packages/next-utils/package.json ./packages/next-utils/
|
COPY packages/next-utils/package.json ./packages/next-utils/package.json
|
||||||
COPY packages/tsconfig/package.json ./packages/tsconfig/
|
COPY packages/eslint-config/package.json ./packages/eslint-config/package.json
|
||||||
COPY packages/eslint-config/package.json ./packages/eslint-config/
|
COPY packages/next-config/package.json ./packages/next-config/package.json
|
||||||
COPY packages/next-config/package.json ./packages/next-config/
|
COPY packages/tsconfig/package.json ./packages/tsconfig/package.json
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
|
# Use a secret for NPM_TOKEN and a cache mount for the pnpm store
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
|
--mount=type=secret,id=NPM_TOKEN \
|
||||||
|
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
||||||
|
pnpm config set store-dir /pnpm/store && \
|
||||||
pnpm i --frozen-lockfile
|
pnpm i --frozen-lockfile
|
||||||
|
|
||||||
# Copy source
|
# Copy the rest of the source
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build Gatekeeper
|
# Build Gatekeeper and its dependencies
|
||||||
RUN pnpm --filter @mintel/gatekeeper build
|
RUN pnpm --filter @mintel/gatekeeper... build
|
||||||
|
RUN mkdir -p packages/gatekeeper/public
|
||||||
|
|
||||||
# Runner
|
# Step 2: Runner stage
|
||||||
FROM base AS runner
|
FROM node:20-alpine AS runner
|
||||||
|
RUN apk add --no-cache libc6-compat curl
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
COPY --from=builder /app/packages/gatekeeper/public ./packages/gatekeeper/public
|
# Set the correct permission for prerender cache
|
||||||
COPY --from=builder /app/packages/gatekeeper/.next/standalone ./
|
RUN mkdir -p packages/gatekeeper/.next && chown nextjs:nodejs packages/gatekeeper/.next
|
||||||
COPY --from=builder /app/packages/gatekeeper/.next/static ./packages/gatekeeper/.next/static
|
|
||||||
|
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
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
FROM node:20-alpine AS base
|
# Step 1: Builder image
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
RUN apk add --no-cache libc6-compat curl
|
RUN apk add --no-cache libc6-compat curl
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Enable pnpm
|
|
||||||
RUN corepack enable pnpm
|
RUN corepack enable pnpm
|
||||||
|
|
||||||
# Copy root configurations
|
# Step 2: Install dependencies
|
||||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc* ./
|
# We copy everything first because we have a .dockerignore
|
||||||
|
# and we need the workspace structure for pnpm to work correctly
|
||||||
# Copy all package.json files to allow pnpm install to be cached
|
|
||||||
COPY packages/*/package.json ./packages/
|
|
||||||
COPY apps/*/package.json ./apps/
|
|
||||||
|
|
||||||
# Install dependencies for the entire monorepo
|
|
||||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
|
|
||||||
pnpm i --frozen-lockfile
|
|
||||||
|
|
||||||
# Copy the rest of the source code
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Post-install/Build shared packages if needed
|
# Use a secret for NPM_TOKEN to authenticate with private registry
|
||||||
RUN pnpm -r build --filter="./packages/*"
|
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
|
||||||
|
|
||||||
|
# Step 3: Build shared packages
|
||||||
|
RUN pnpm --filter "./packages/*" -r build
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ jobs:
|
|||||||
prepare:
|
prepare:
|
||||||
name: 🔍 Prepare Environment
|
name: 🔍 Prepare Environment
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
outputs:
|
outputs:
|
||||||
target: ${{ steps.determine.outputs.target }}
|
target: ${{ steps.determine.outputs.target }}
|
||||||
image_tag: ${{ steps.determine.outputs.image_tag }}
|
image_tag: ${{ steps.determine.outputs.image_tag }}
|
||||||
@@ -136,6 +138,8 @@ jobs:
|
|||||||
needs: prepare
|
needs: prepare
|
||||||
if: needs.prepare.outputs.target != 'skip'
|
if: needs.prepare.outputs.target != 'skip'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -144,7 +148,6 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -171,6 +174,8 @@ jobs:
|
|||||||
needs: prepare
|
needs: prepare
|
||||||
if: needs.prepare.outputs.target != 'skip'
|
if: needs.prepare.outputs.target != 'skip'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -179,23 +184,28 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: 🔐 Registry Login
|
- name: 🔐 Registry Login
|
||||||
run: |
|
uses: docker/login-action@v3
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
with:
|
||||||
|
registry: registry.infra.mintel.me
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASS }}
|
||||||
|
|
||||||
- name: 🏗️ Docker Build & Push
|
- name: 🏗️ Docker Build & Push
|
||||||
env:
|
uses: docker/build-push-action@v5
|
||||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
with:
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
context: .
|
||||||
run: |
|
file: packages/infra/docker/Dockerfile.nextjs
|
||||||
docker buildx build \
|
platforms: linux/arm64
|
||||||
--pull \
|
pull: true
|
||||||
--platform linux/arm64 \
|
build-args: |
|
||||||
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }}
|
||||||
--build-arg NEXT_PUBLIC_TARGET="${{ needs.prepare.outputs.target }}" \
|
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||||
-t registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:$IMAGE_TAG \
|
push: true
|
||||||
--cache-from type=registry,ref=registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:buildcache \
|
secrets: |
|
||||||
--cache-to type=registry,ref=registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:buildcache,mode=max \
|
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||||
--push .
|
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
|
# JOB 4: Deploy
|
||||||
@@ -205,6 +215,8 @@ jobs:
|
|||||||
needs: [prepare, build, qa]
|
needs: [prepare, build, qa]
|
||||||
if: needs.prepare.outputs.target != 'skip'
|
if: needs.prepare.outputs.target != 'skip'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
env:
|
env:
|
||||||
TARGET: ${{ needs.prepare.outputs.target }}
|
TARGET: ${{ needs.prepare.outputs.target }}
|
||||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||||
@@ -271,6 +283,8 @@ jobs:
|
|||||||
needs: [prepare, deploy]
|
needs: [prepare, deploy]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- name: 🔔 Gotify - Success
|
- name: 🔔 Gotify - Success
|
||||||
if: needs.deploy.result == 'success'
|
if: needs.deploy.result == 'success'
|
||||||
|
|||||||
60
packages/infra/scripts/prune-registry.sh
Normal file
60
packages/infra/scripts/prune-registry.sh
Normal 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 /
|
||||||
45
packages/mail/package.json
Normal file
45
packages/mail/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/mail/src/assets/logo-black.svg
Normal file
12
packages/mail/src/assets/logo-black.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 17 KiB |
12
packages/mail/src/assets/logo-white.svg
Normal file
12
packages/mail/src/assets/logo-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
29
packages/mail/src/components/MintelLogo.tsx
Normal file
29
packages/mail/src/components/MintelLogo.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
25
packages/mail/src/index.test.tsx
Normal file
25
packages/mail/src/index.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
24
packages/mail/src/index.ts
Normal file
24
packages/mail/src/index.ts
Normal 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";
|
||||||
53
packages/mail/src/layouts/BaseLayout.tsx
Normal file
53
packages/mail/src/layouts/BaseLayout.tsx
Normal 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",
|
||||||
|
};
|
||||||
80
packages/mail/src/layouts/ClientLayout.tsx
Normal file
80
packages/mail/src/layouts/ClientLayout.tsx
Normal 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}>
|
||||||
|
© 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",
|
||||||
|
};
|
||||||
53
packages/mail/src/layouts/MintelLayout.tsx
Normal file
53
packages/mail/src/layouts/MintelLayout.tsx
Normal 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}>
|
||||||
|
© 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",
|
||||||
|
};
|
||||||
57
packages/mail/src/templates/ConfirmationMessage.tsx
Normal file
57
packages/mail/src/templates/ConfirmationMessage.tsx
Normal 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",
|
||||||
|
};
|
||||||
118
packages/mail/src/templates/ContactFormNotification.tsx
Normal file
118
packages/mail/src/templates/ContactFormNotification.tsx
Normal 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",
|
||||||
|
};
|
||||||
14
packages/mail/tsconfig.json
Normal file
14
packages/mail/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
49
packages/next-observability/package.json
Normal file
49
packages/next-observability/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
packages/next-observability/src/analytics/auto-tracker.tsx
Normal file
23
packages/next-observability/src/analytics/auto-tracker.tsx
Normal 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;
|
||||||
|
}
|
||||||
50
packages/next-observability/src/analytics/context.tsx
Normal file
50
packages/next-observability/src/analytics/context.tsx
Normal 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;
|
||||||
|
}
|
||||||
4
packages/next-observability/src/client.ts
Normal file
4
packages/next-observability/src/client.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
export * from "./analytics/context";
|
||||||
|
export * from "./analytics/auto-tracker";
|
||||||
26
packages/next-observability/src/errors/sentry.ts
Normal file
26
packages/next-observability/src/errors/sentry.ts
Normal 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 };
|
||||||
90
packages/next-observability/src/handlers/index.ts
Normal file
90
packages/next-observability/src/handlers/index.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
2
packages/next-observability/src/index.ts
Normal file
2
packages/next-observability/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./handlers/index";
|
||||||
|
export * from "./errors/sentry";
|
||||||
10
packages/next-observability/tsconfig.json
Normal file
10
packages/next-observability/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"esModuleInterop": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -1,20 +1,36 @@
|
|||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
|
|
||||||
export const mintelEnvSchema = {
|
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_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(),
|
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_HOST: z.string().optional(),
|
||||||
MAIL_PORT: z.coerce.number().default(587),
|
MAIL_PORT: z.coerce.number().default(587),
|
||||||
MAIL_USERNAME: z.string().optional(),
|
MAIL_USERNAME: z.string().optional(),
|
||||||
MAIL_PASSWORD: z.string().optional(),
|
MAIL_PASSWORD: z.string().optional(),
|
||||||
MAIL_FROM: z.string().optional(),
|
MAIL_FROM: z.string().optional(),
|
||||||
MAIL_RECIPIENTS: z.preprocess(
|
MAIL_RECIPIENTS: z.preprocess(
|
||||||
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
|
(val) => (typeof val === "string" ? val.split(",").filter(Boolean) : val),
|
||||||
z.array(z.string()).default([])
|
z.array(z.string()).default([]),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,11 +40,26 @@ export function validateMintelEnv(schemaExtension = {}) {
|
|||||||
...schemaExtension,
|
...schemaExtension,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isBuildTime =
|
||||||
|
process.env.NEXT_PHASE === "phase-production-build" ||
|
||||||
|
process.env.SKIP_ENV_VALIDATION === "true";
|
||||||
|
|
||||||
const result = fullSchema.safeParse(process.env);
|
const result = fullSchema.safeParse(process.env);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
|
if (isBuildTime) {
|
||||||
throw new Error('Invalid environment variables');
|
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;
|
return result.data;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { isValidLang } from "../src/index";
|
import { isValidLang } from "./lang";
|
||||||
|
|
||||||
describe("next-utils", () => {
|
describe("next-utils", () => {
|
||||||
it("should validate languages correctly", () => {
|
it("should validate languages correctly", () => {
|
||||||
|
|||||||
@@ -30,12 +30,7 @@ export async function rateLimit(
|
|||||||
submissions[identifier] = now;
|
submissions[identifier] = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const languages = ["en", "de"] as const;
|
export * from "./lang";
|
||||||
export type Lang = (typeof languages)[number];
|
|
||||||
|
|
||||||
export function isValidLang(lang: string): lang is Lang {
|
|
||||||
return (languages as readonly string[]).includes(lang);
|
|
||||||
}
|
|
||||||
|
|
||||||
export * from "./i18n";
|
export * from "./i18n";
|
||||||
export * from "./env";
|
export * from "./env";
|
||||||
|
|||||||
6
packages/next-utils/src/lang.ts
Normal file
6
packages/next-utils/src/lang.ts
Normal 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);
|
||||||
|
}
|
||||||
123
packages/observability/README.md
Normal file
123
packages/observability/README.md
Normal 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.
|
||||||
34
packages/observability/package.json
Normal file
34
packages/observability/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
packages/observability/src/analytics/noop.test.ts
Normal file
14
packages/observability/src/analytics/noop.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
15
packages/observability/src/analytics/noop.ts
Normal file
15
packages/observability/src/analytics/noop.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
31
packages/observability/src/analytics/service.ts
Normal file
31
packages/observability/src/analytics/service.ts
Normal 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;
|
||||||
|
}
|
||||||
74
packages/observability/src/analytics/umami.test.ts
Normal file
74
packages/observability/src/analytics/umami.test.ts
Normal 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 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
115
packages/observability/src/analytics/umami.ts
Normal file
115
packages/observability/src/analytics/umami.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
9
packages/observability/src/index.ts
Normal file
9
packages/observability/src/index.ts
Normal 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";
|
||||||
82
packages/observability/src/notifications/gotify.test.ts
Normal file
82
packages/observability/src/notifications/gotify.test.ts
Normal 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 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
56
packages/observability/src/notifications/gotify.ts
Normal file
56
packages/observability/src/notifications/gotify.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
packages/observability/src/notifications/noop.test.ts
Normal file
11
packages/observability/src/notifications/noop.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
10
packages/observability/src/notifications/noop.ts
Normal file
10
packages/observability/src/notifications/noop.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { NotificationService } from "./service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-operation notification service.
|
||||||
|
*/
|
||||||
|
export class NoopNotificationService implements NotificationService {
|
||||||
|
async notify(): Promise<void> {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
16
packages/observability/src/notifications/service.ts
Normal file
16
packages/observability/src/notifications/service.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export interface NotificationOptions {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for notification service implementations.
|
||||||
|
* Allows for different implementations (Gotify, Slack, Email, etc.)
|
||||||
|
*/
|
||||||
|
export interface NotificationService {
|
||||||
|
/**
|
||||||
|
* Send a notification.
|
||||||
|
*/
|
||||||
|
notify(options: NotificationOptions): Promise<void>;
|
||||||
|
}
|
||||||
11
packages/observability/tsconfig.json
Normal file
11
packages/observability/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"esModuleInterop": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
1276
pnpm-lock.yaml
generated
1276
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user