Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e662415137 | |||
| 580b087e8a | |||
| ac3c405cb2 | |||
| a594affdfa | |||
| 61e78ea672 | |||
| 6501eac38a | |||
| 7f9206ae77 | |||
| 6229f8e886 | |||
| 8ac090aff3 | |||
| 696f9d361d | |||
| 31840da9e7 | |||
| 96ec2c7d8d | |||
| 9029375247 |
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.
|
||||||
@@ -28,6 +28,7 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node_version: 20
|
node_version: 20
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -68,6 +69,7 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node_version: 20
|
node_version: 20
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -79,12 +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:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
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
|
||||||
@@ -99,58 +117,19 @@ jobs:
|
|||||||
username: ${{ secrets.REGISTRY_USER }}
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
password: ${{ secrets.REGISTRY_PASS }}
|
password: ${{ secrets.REGISTRY_PASS }}
|
||||||
|
|
||||||
- name: 🏗️ Build & Push Nextjs Build-Base
|
- name: 🏗️ Build & Push ${{ matrix.name }}
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: packages/infra/docker/Dockerfile.nextjs
|
file: ${{ matrix.file }}
|
||||||
platforms: linux/arm64
|
platforms: linux/arm64
|
||||||
pull: true
|
pull: true
|
||||||
push: true
|
push: true
|
||||||
secrets: |
|
secrets: |
|
||||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||||
tags: |
|
tags: |
|
||||||
registry.infra.mintel.me/mintel/nextjs:${{ github.ref_name }}
|
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
|
||||||
registry.infra.mintel.me/mintel/nextjs:latest
|
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: 🏗️ Build & Push Production Runtime
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: packages/infra/docker/Dockerfile.runtime
|
|
||||||
platforms: linux/arm64
|
|
||||||
pull: true
|
|
||||||
push: true
|
|
||||||
secrets: |
|
|
||||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
|
||||||
tags: |
|
|
||||||
registry.infra.mintel.me/mintel/runtime:${{ github.ref_name }}
|
|
||||||
registry.infra.mintel.me/mintel/runtime:latest
|
|
||||||
|
|
||||||
- name: 🏗️ Build & Push Gatekeeper (Product)
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: packages/infra/docker/Dockerfile.gatekeeper
|
|
||||||
platforms: linux/arm64
|
|
||||||
pull: true
|
|
||||||
push: true
|
|
||||||
secrets: |
|
|
||||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
|
||||||
tags: |
|
|
||||||
registry.infra.mintel.me/mintel/gatekeeper:${{ github.ref_name }}
|
|
||||||
registry.infra.mintel.me/mintel/gatekeeper:latest
|
|
||||||
|
|
||||||
- name: 🏗️ Build & Push Directus (Base)
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: packages/infra/docker/Dockerfile.directus
|
|
||||||
platforms: linux/arm64
|
|
||||||
pull: true
|
|
||||||
push: true
|
|
||||||
secrets: |
|
|
||||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
|
||||||
tags: |
|
|
||||||
registry.infra.mintel.me/mintel/directus:${{ github.ref_name }}
|
|
||||||
registry.infra.mintel.me/mintel/directus:latest
|
|
||||||
|
|||||||
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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"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",
|
||||||
"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
|
||||||
@@ -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 |
@@ -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,132 +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-48 h-24 rounded-3xl flex items-center justify-center border border-white/10 bg-white/5 backdrop-blur-xl shadow-2xl p-6"
|
|
||||||
style={{ borderBottom: `2px solid ${projectColor}44` }}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src="/logo-white.svg"
|
|
||||||
alt={projectName}
|
|
||||||
className="w-full h-auto opacity-90"
|
|
||||||
/>
|
|
||||||
</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: [],
|
|
||||||
};
|
|
||||||
@@ -25,9 +25,12 @@ 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,20 +1,35 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/mail",
|
"name": "@mintel/mail",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"private": true,
|
"private": false,
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": {
|
||||||
"./templates/*": "./src/templates/*"
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./templates/*": {
|
||||||
|
"types": "./dist/templates/*.d.ts",
|
||||||
|
"import": "./dist/templates/*.js"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup src/index.ts --format esm --dts",
|
"build": "tsup src/index.ts src/templates/*.tsx --format esm --dts --clean",
|
||||||
"dev": "tsup src/index.ts --format esm --watch",
|
"dev": "tsup src/index.ts src/templates/*.tsx --format esm --watch --dts",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-email/components": "^0.0.33",
|
"@react-email/components": "^0.0.33"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
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;
|
||||||
|
|||||||
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"]
|
||||||
|
}
|
||||||
632
pnpm-lock.yaml
generated
632
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user