feat: introduce Gatekeeper application, Directus utilities, and monorepo configuration for linting, testing, and husky hooks.
This commit is contained in:
@@ -32,5 +32,8 @@ jobs:
|
|||||||
- name: Lint
|
- name: Lint
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: pnpm test
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|||||||
1
.husky/commit-msg
Normal file
1
.husky/commit-msg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
npx --no -- commitlint --edit "$1"
|
||||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
1
.lintstagedrc.js
Normal file
1
.lintstagedrc.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "@mintel/husky-config/lint-staged";
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "15.1.6",
|
"next": "15.1.6",
|
||||||
|
|||||||
7
apps/sample-website/src/health.test.ts
Normal file
7
apps/sample-website/src/health.test.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
describe("sample-website", () => {
|
||||||
|
it("should pass basic health check", () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
1
commitlint.config.js
Normal file
1
commitlint.config.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "@mintel/husky-config/commitlint";
|
||||||
3
eslint.config.js
Normal file
3
eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { nextConfig } from "@mintel/eslint-config/next";
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
35
package.json
35
package.json
@@ -1,18 +1,39 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/monorepo",
|
"name": "@mintel/monorepo",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm --filter \"./packages/*\" build",
|
"build": "pnpm -r build",
|
||||||
"dev": "pnpm --filter \"./packages/*\" dev",
|
"dev": "pnpm -r dev",
|
||||||
"lint": "pnpm --filter \"./packages/*\" lint",
|
"lint": "pnpm -r lint",
|
||||||
"test": "pnpm --filter \"./packages/*\" test",
|
"test": "pnpm -r test",
|
||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
"version-packages": "changeset version",
|
"version-packages": "changeset version",
|
||||||
"release": "pnpm build && changeset publish"
|
"release": "pnpm build && changeset publish",
|
||||||
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@changesets/cli": "^2.29.8",
|
"@changesets/cli": "^2.29.8",
|
||||||
"prettier": "^3.0.0",
|
"@commitlint/cli": "^20.4.0",
|
||||||
"typescript": "^5.0.0"
|
"@commitlint/config-conventional": "^20.4.0",
|
||||||
|
"@mintel/eslint-config": "workspace:*",
|
||||||
|
"@mintel/husky-config": "workspace:*",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/react": "^19.2.10",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
|
"eslint": "^9.39.2",
|
||||||
|
"eslint-plugin-next": "^0.0.0",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"happy-dom": "^20.4.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"jsdom": "^27.4.0",
|
||||||
|
"lint-staged": "^16.2.7",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"typescript-eslint": "^8.54.0",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup src/index.ts --format esm --target es2020",
|
"build": "tsup src/index.ts --format esm --target es2020",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^11.0.0",
|
"commander": "^11.0.0",
|
||||||
|
|||||||
7
packages/cli/src/index.test.ts
Normal file
7
packages/cli/src/index.test.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
describe("cli", () => {
|
||||||
|
it("should have a working environment", () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,6 +44,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:*",
|
||||||
|
"@directus/sdk": "^21.0.0",
|
||||||
},
|
},
|
||||||
devDependencies: {
|
devDependencies: {
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
@@ -53,6 +54,7 @@ program
|
|||||||
"@mintel/tsconfig": "workspace:*",
|
"@mintel/tsconfig": "workspace:*",
|
||||||
"@mintel/eslint-config": "workspace:*",
|
"@mintel/eslint-config": "workspace:*",
|
||||||
"@mintel/next-config": "workspace:*",
|
"@mintel/next-config": "workspace:*",
|
||||||
|
"@mintel/husky-config": "workspace:*",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await fs.writeJson(path.join(fullPath, "package.json"), pkgJson, {
|
await fs.writeJson(path.join(fullPath, "package.json"), pkgJson, {
|
||||||
@@ -96,7 +98,17 @@ export default nextConfig;
|
|||||||
`;
|
`;
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(fullPath, "eslint.config.mjs"),
|
path.join(fullPath, "eslint.config.mjs"),
|
||||||
eslintConfig
|
eslintConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create Husky/Linting configs
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(fullPath, "commitlint.config.js"),
|
||||||
|
`export { default } from "@mintel/husky-config/commitlint";\n`,
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(fullPath, ".lintstagedrc.js"),
|
||||||
|
`export { default } from "@mintel/husky-config/lint-staged";\n`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create env validation script
|
// Create env validation script
|
||||||
@@ -111,7 +123,7 @@ try {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
`
|
`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create basic src structure
|
// Create basic src structure
|
||||||
@@ -129,7 +141,7 @@ export default createMintelMiddleware({
|
|||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!api|_next|_vercel|health|.*\\\\..*).*)", "/", "/(de|en)/:path*"]
|
matcher: ["/((?!api|_next|_vercel|health|.*\\\\..*).*)", "/", "/(de|en)/:path*"]
|
||||||
};
|
};
|
||||||
`
|
`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create i18n/request.ts
|
// Create i18n/request.ts
|
||||||
@@ -143,21 +155,29 @@ export default createMintelI18nRequestConfig(
|
|||||||
"en",
|
"en",
|
||||||
(locale) => import(\`../../messages/\${locale}.json\`)
|
(locale) => import(\`../../messages/\${locale}.json\`)
|
||||||
);
|
);
|
||||||
`
|
`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create messages directory
|
// Create messages directory
|
||||||
await fs.ensureDir(path.join(fullPath, "messages"));
|
await fs.ensureDir(path.join(fullPath, "messages"));
|
||||||
await fs.writeJson(path.join(fullPath, "messages/en.json"), {
|
await fs.writeJson(
|
||||||
Index: {
|
path.join(fullPath, "messages/en.json"),
|
||||||
title: "Welcome"
|
{
|
||||||
}
|
Index: {
|
||||||
}, { spaces: 2 });
|
title: "Welcome",
|
||||||
await fs.writeJson(path.join(fullPath, "messages/de.json"), {
|
},
|
||||||
Index: {
|
},
|
||||||
title: "Willkommen"
|
{ spaces: 2 },
|
||||||
}
|
);
|
||||||
}, { spaces: 2 });
|
await fs.writeJson(
|
||||||
|
path.join(fullPath, "messages/de.json"),
|
||||||
|
{
|
||||||
|
Index: {
|
||||||
|
title: "Willkommen",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ spaces: 2 },
|
||||||
|
);
|
||||||
|
|
||||||
// Create instrumentation.ts
|
// Create instrumentation.ts
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
@@ -171,7 +191,7 @@ export async function register() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const onRequestError = Sentry.captureRequestError;
|
export const onRequestError = Sentry.captureRequestError;
|
||||||
`
|
`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
@@ -192,11 +212,11 @@ export default function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang={locale}>
|
<html lang={locale}>
|
||||||
<body>{children}</body>
|
<body className="antialiased">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
`
|
`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
@@ -212,7 +232,7 @@ export default function Home() {
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
`
|
`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Copy infra templates
|
// Copy infra templates
|
||||||
@@ -220,21 +240,75 @@ export default function Home() {
|
|||||||
if (await fs.pathExists(infraPath)) {
|
if (await fs.pathExists(infraPath)) {
|
||||||
await fs.copy(
|
await fs.copy(
|
||||||
path.join(infraPath, "docker/Dockerfile.nextjs"),
|
path.join(infraPath, "docker/Dockerfile.nextjs"),
|
||||||
path.join(fullPath, "Dockerfile")
|
path.join(fullPath, "Dockerfile"),
|
||||||
);
|
);
|
||||||
await fs.copy(
|
await fs.copy(
|
||||||
path.join(infraPath, "docker/docker-compose.template.yml"),
|
path.join(infraPath, "docker/docker-compose.template.yml"),
|
||||||
path.join(fullPath, "docker-compose.yml")
|
path.join(fullPath, "docker-compose.yml"),
|
||||||
);
|
);
|
||||||
await fs.ensureDir(path.join(fullPath, ".gitea/workflows"));
|
await fs.ensureDir(path.join(fullPath, ".gitea/workflows"));
|
||||||
await fs.copy(
|
await fs.copy(
|
||||||
path.join(infraPath, "gitea/deploy-action.yml"),
|
path.join(infraPath, "gitea/deploy-action.yml"),
|
||||||
path.join(fullPath, ".gitea/workflows/deploy.yml")
|
path.join(fullPath, ".gitea/workflows/deploy.yml"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Create Directus structure
|
||||||
|
await fs.ensureDir(path.join(fullPath, "directus/uploads"));
|
||||||
|
await fs.ensureDir(path.join(fullPath, "directus/extensions"));
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(fullPath, "directus/uploads/.gitkeep"),
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(fullPath, "directus/extensions/.gitkeep"),
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create .env.example
|
||||||
|
const envExample = `# Project
|
||||||
|
PROJECT_NAME=${projectName}
|
||||||
|
PROJECT_COLOR=#82ed20
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
GATEKEEPER_PASSWORD=mintel
|
||||||
|
AUTH_COOKIE_NAME=mintel_gatekeeper_session
|
||||||
|
|
||||||
|
# Host Config (Local)
|
||||||
|
TRAEFIK_HOST=\`${projectName}.localhost\`
|
||||||
|
DIRECTUS_HOST=\`cms.${projectName}.localhost\`
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
NEXT_PUBLIC_BASE_URL=http://${projectName}.localhost
|
||||||
|
|
||||||
|
# Directus
|
||||||
|
DIRECTUS_URL=http://cms.${projectName}.localhost
|
||||||
|
DIRECTUS_KEY=$(openssl rand -hex 32 2>/dev/null || echo "mintel-key")
|
||||||
|
DIRECTUS_SECRET=$(openssl rand -hex 32 2>/dev/null || echo "mintel-secret")
|
||||||
|
DIRECTUS_ADMIN_EMAIL=admin@mintel.me
|
||||||
|
DIRECTUS_ADMIN_PASSWORD=mintel-admin-pass
|
||||||
|
DIRECTUS_DB_NAME=directus
|
||||||
|
DIRECTUS_DB_USER=directus
|
||||||
|
DIRECTUS_DB_PASSWORD=mintel-db-pass
|
||||||
|
|
||||||
|
# Sentry / Glitchtip
|
||||||
|
SENTRY_DSN=
|
||||||
|
|
||||||
|
# Analytics (Umami)
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||||
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||||
|
`;
|
||||||
|
await fs.writeFile(path.join(fullPath, ".env.example"), envExample);
|
||||||
|
|
||||||
|
// Copy premium templates (globals.css, lib/directus.ts, scripts/setup-directus.ts)
|
||||||
|
const templatePath = path.join(infraPath, "templates/website");
|
||||||
|
if (await fs.pathExists(templatePath)) {
|
||||||
|
console.log(chalk.blue("Applying premium templates..."));
|
||||||
|
await fs.copy(templatePath, fullPath, { overwrite: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
chalk.green(`Successfully initialized ${projectName} at ${fullPath}`)
|
chalk.green(`Successfully initialized ${projectName} at ${fullPath}`),
|
||||||
);
|
);
|
||||||
console.log(chalk.yellow("\nNext steps:"));
|
console.log(chalk.yellow("\nNext steps:"));
|
||||||
console.log(chalk.cyan("1. pnpm install"));
|
console.log(chalk.cyan("1. pnpm install"));
|
||||||
|
|||||||
3
packages/eslint-config/index.d.ts
vendored
Normal file
3
packages/eslint-config/index.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const baseConfig: any;
|
||||||
|
declare const config: any;
|
||||||
|
export default config;
|
||||||
14
packages/eslint-config/index.js
Normal file
14
packages/eslint-config/index.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
"no-unused-vars": "warn",
|
||||||
|
"no-console": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
3
packages/eslint-config/next.d.ts
vendored
Normal file
3
packages/eslint-config/next.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const nextConfig: any;
|
||||||
|
declare const config: any;
|
||||||
|
export default config;
|
||||||
@@ -8,11 +8,19 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.js",
|
".": {
|
||||||
"./next": "./next.js"
|
"types": "./index.d.ts",
|
||||||
|
"default": "./index.js"
|
||||||
|
},
|
||||||
|
"./next": {
|
||||||
|
"types": "./next.d.ts",
|
||||||
|
"default": "./next.js"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint/eslintrc": "^3.0.0",
|
"@eslint/eslintrc": "^3.0.0",
|
||||||
"eslint-config-next": "15.1.6"
|
"@eslint/js": "^9.39.2",
|
||||||
|
"eslint-config-next": "15.1.6",
|
||||||
|
"typescript-eslint": "^8.54.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
packages/gatekeeper/next.config.ts
Normal file
8
packages/gatekeeper/next.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import mintelNextConfig from "@mintel/next-config";
|
||||||
|
import { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
// Gatekeeper specific overrides
|
||||||
|
};
|
||||||
|
|
||||||
|
export default mintelNextConfig(nextConfig);
|
||||||
33
packages/gatekeeper/package.json
Normal file
33
packages/gatekeeper/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/gatekeeper",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.474.0",
|
||||||
|
"next": "15.1.6",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^2.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@mintel/eslint-config": "workspace:*",
|
||||||
|
"@mintel/next-config": "workspace:*",
|
||||||
|
"@mintel/tsconfig": "workspace:*",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
packages/gatekeeper/src/app/api/verify/route.ts
Normal file
25
packages/gatekeeper/src/app/api/verify/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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 password = process.env.GATEKEEPER_PASSWORD || "mintel";
|
||||||
|
|
||||||
|
const session = cookieStore.get(authCookieName);
|
||||||
|
|
||||||
|
if (session?.value === password) {
|
||||||
|
return new NextResponse("OK", { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traefik ForwardAuth headers
|
||||||
|
const originalUrl = req.headers.get("x-forwarded-uri") || "/";
|
||||||
|
const host =
|
||||||
|
req.headers.get("x-forwarded-host") || req.headers.get("host") || "";
|
||||||
|
const proto = req.headers.get("x-forwarded-proto") || "https";
|
||||||
|
|
||||||
|
const loginUrl = `${proto}://${host}/gatekeeper/login?redirect=${encodeURIComponent(originalUrl)}`;
|
||||||
|
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
131
packages/gatekeeper/src/app/gatekeeper/login/page.tsx
Normal file
131
packages/gatekeeper/src/app/gatekeeper/login/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
packages/gatekeeper/src/app/globals.css
Normal file
21
packages/gatekeeper/src/app/globals.css
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #000c1f;
|
||||||
|
--foreground: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: var(--foreground);
|
||||||
|
background: var(--background);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-grid {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
||||||
|
background-size: 40px 40px;
|
||||||
|
}
|
||||||
19
packages/gatekeeper/src/app/layout.tsx
Normal file
19
packages/gatekeeper/src/app/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Gatekeeper | Access Control",
|
||||||
|
description: "Mintel Infrastructure Protection",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className="antialiased">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
packages/gatekeeper/src/components/Button.test.tsx
Normal file
18
packages/gatekeeper/src/components/Button.test.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
describe("Button", () => {
|
||||||
|
it("renders children correctly", () => {
|
||||||
|
render(<Button>Click me</Button>);
|
||||||
|
expect(screen.getByText("Click me")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClick when clicked", () => {
|
||||||
|
const handleClick = vi.fn();
|
||||||
|
render(<Button onClick={handleClick}>Click me</Button>);
|
||||||
|
fireEvent.click(screen.getByText("Click me"));
|
||||||
|
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
18
packages/gatekeeper/src/components/Button.tsx
Normal file
18
packages/gatekeeper/src/components/Button.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="px-4 py-2 bg-mintel-green text-mintel-blue rounded-xl font-bold"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
packages/gatekeeper/src/test/setup.ts
Normal file
1
packages/gatekeeper/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
23
packages/gatekeeper/tailwind.config.js
Normal file
23
packages/gatekeeper/tailwind.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/** @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: [],
|
||||||
|
};
|
||||||
11
packages/gatekeeper/tsconfig.json
Normal file
11
packages/gatekeeper/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "@mintel/tsconfig/nextjs.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
17
packages/gatekeeper/vitest.config.ts
Normal file
17
packages/gatekeeper/vitest.config.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: "happy-dom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./src/test/setup.ts"],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
8
packages/husky-config/commitlint.js
Normal file
8
packages/husky-config/commitlint.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export default {
|
||||||
|
extends: ["@commitlint/config-conventional"],
|
||||||
|
rules: {
|
||||||
|
"header-max-length": [2, "always", 150],
|
||||||
|
"subject-case": [0],
|
||||||
|
"subject-full-stop": [0],
|
||||||
|
},
|
||||||
|
};
|
||||||
2
packages/husky-config/index.js
Normal file
2
packages/husky-config/index.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as commitlint } from "./commitlint.js";
|
||||||
|
export { default as lintStaged } from "./lint-staged.js";
|
||||||
4
packages/husky-config/lint-staged.js
Normal file
4
packages/husky-config/lint-staged.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export default {
|
||||||
|
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
|
||||||
|
"*.{json,md,css,scss}": ["prettier --write"],
|
||||||
|
};
|
||||||
18
packages/husky-config/package.json
Normal file
18
packages/husky-config/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/husky-config",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"main": "index.js",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.js",
|
||||||
|
"./commitlint": "./commitlint.js",
|
||||||
|
"./lint-staged": "./lint-staged.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@commitlint/config-conventional": "^20.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
47
packages/infra/docker/Dockerfile.gatekeeper
Normal file
47
packages/infra/docker/Dockerfile.gatekeeper
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
# Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
|
RUN corepack enable pnpm && pnpm i --frozen-lockfile
|
||||||
|
|
||||||
|
# Rebuild the source code only when needed
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN corepack enable pnpm && pnpm run build
|
||||||
|
|
||||||
|
# Production image, copy all the files and run next
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Automatically leverage output traces to reduce image size
|
||||||
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -1,33 +1,109 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: registry.infra.mintel.me/${PROJECT_NAME:-mintel/app}:${IMAGE_TAG:-latest}
|
image: registry.infra.mintel.me/mintel/${APP_NAME:-app}:${IMAGE_TAG:-latest}
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- infra
|
- infra
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
env_file:
|
env_file:
|
||||||
- ${ENV_FILE:-.env}
|
- ${ENV_FILE:-.env}
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
# HTTP ⇒ HTTPS redirect
|
# HTTP ⇒ HTTPS redirect
|
||||||
- "traefik.http.routers.${APP_NAME:-app}-web.rule=Host(${TRAEFIK_HOST}) && !PathPrefix(`/.well-known/acme-challenge/`)"
|
- "traefik.http.routers.${PROJECT_NAME:-app}-web.rule=Host(${TRAEFIK_HOST}) && !PathPrefix(`/.well-known/acme-challenge/`)"
|
||||||
- "traefik.http.routers.${APP_NAME:-app}-web.entrypoints=web"
|
- "traefik.http.routers.${PROJECT_NAME:-app}-web.entrypoints=web"
|
||||||
- "traefik.http.routers.${APP_NAME:-app}-web.middlewares=redirect-https"
|
- "traefik.http.routers.${PROJECT_NAME:-app}-web.middlewares=redirect-https"
|
||||||
# HTTPS router
|
# HTTPS router
|
||||||
- "traefik.http.routers.${APP_NAME:-app}.rule=Host(${TRAEFIK_HOST})"
|
- "traefik.http.routers.${PROJECT_NAME:-app}.rule=Host(${TRAEFIK_HOST})"
|
||||||
- "traefik.http.routers.${APP_NAME:-app}.entrypoints=websecure"
|
- "traefik.http.routers.${PROJECT_NAME:-app}.entrypoints=websecure"
|
||||||
- "traefik.http.routers.${APP_NAME:-app}.tls.certresolver=le"
|
- "traefik.http.routers.${PROJECT_NAME:-app}.tls.certresolver=le"
|
||||||
- "traefik.http.routers.${APP_NAME:-app}.tls=true"
|
- "traefik.http.routers.${PROJECT_NAME:-app}.tls=true"
|
||||||
- "traefik.http.routers.${APP_NAME:-app}.service=${APP_NAME:-app}"
|
- "traefik.http.routers.${PROJECT_NAME:-app}.service=${PROJECT_NAME:-app}"
|
||||||
- "traefik.http.services.${APP_NAME:-app}.loadbalancer.server.port=3000"
|
- "traefik.http.services.${PROJECT_NAME:-app}.loadbalancer.server.port=3000"
|
||||||
- "traefik.http.services.${APP_NAME:-app}.loadbalancer.server.scheme=http"
|
- "traefik.http.services.${PROJECT_NAME:-app}.loadbalancer.server.scheme=http"
|
||||||
# Forwarded Headers
|
# Forwarded Headers
|
||||||
- "traefik.http.middlewares.${APP_NAME:-app}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
- "traefik.http.middlewares.${PROJECT_NAME:-app}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||||
- "traefik.http.middlewares.${APP_NAME:-app}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
- "traefik.http.middlewares.${PROJECT_NAME:-app}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||||
# Middlewares
|
# Middlewares (Auth + Compress)
|
||||||
- "traefik.http.routers.${APP_NAME:-app}.middlewares=${APP_NAME:-app}-forward,compress"
|
- "traefik.http.routers.${PROJECT_NAME:-app}.middlewares=${PROJECT_NAME:-app}-forward,${AUTH_MIDDLEWARE:-compress}"
|
||||||
|
|
||||||
|
# Gatekeeper Router (to show the login page)
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-app}-gatekeeper.rule=Host(${TRAEFIK_HOST}) && PathPrefix(`/gatekeeper`)"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-app}-gatekeeper.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-app}-gatekeeper.tls.certresolver=le"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-app}-gatekeeper.tls=true"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-app}-gatekeeper.service=${PROJECT_NAME:-app}-gatekeeper"
|
||||||
|
|
||||||
|
# Auth Middleware Definition
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-app}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/api/verify"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-app}-auth.forwardauth.trustForwardHeader=true"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-app}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||||
|
|
||||||
|
gatekeeper:
|
||||||
|
image: registry.infra.mintel.me/mintel/gatekeeper:${IMAGE_TAG:-latest}
|
||||||
|
container_name: ${PROJECT_NAME}-gatekeeper
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- infra
|
||||||
|
env_file:
|
||||||
|
- ${ENV_FILE:-.env}
|
||||||
|
environment:
|
||||||
|
PORT: 3000
|
||||||
|
PROJECT_NAME: ${PROJECT_NAME:-Mintel App}
|
||||||
|
PROJECT_COLOR: ${PROJECT_COLOR:-#82ed20}
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000"
|
||||||
|
|
||||||
|
directus:
|
||||||
|
image: directus/directus:11
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- infra
|
||||||
|
env_file:
|
||||||
|
- ${ENV_FILE:-.env}
|
||||||
|
environment:
|
||||||
|
KEY: ${DIRECTUS_KEY}
|
||||||
|
SECRET: ${DIRECTUS_SECRET}
|
||||||
|
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
|
||||||
|
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
|
||||||
|
DB_CLIENT: 'pg'
|
||||||
|
DB_HOST: 'directus-db'
|
||||||
|
DB_PORT: '5432'
|
||||||
|
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
|
||||||
|
DB_USER: ${DIRECTUS_DB_USER:-directus}
|
||||||
|
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-mintel}
|
||||||
|
WEBSOCKETS_ENABLED: 'true'
|
||||||
|
PUBLIC_URL: ${DIRECTUS_URL}
|
||||||
|
SENTRY_DSN: ${SENTRY_DSN}
|
||||||
|
volumes:
|
||||||
|
- ./directus/uploads:/directus/uploads
|
||||||
|
- ./directus/extensions:/directus/extensions
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME}-directus.rule=Host(${DIRECTUS_HOST})"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME}-directus.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME}-directus.tls.certresolver=le"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME}-directus.tls=true"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME}-directus.middlewares=${PROJECT_NAME}-forward,${AUTH_MIDDLEWARE:-compress}"
|
||||||
|
- "traefik.http.services.${PROJECT_NAME}-directus.loadbalancer.server.port=8055"
|
||||||
|
|
||||||
|
directus-db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- infra
|
||||||
|
env_file:
|
||||||
|
- ${ENV_FILE:-.env}
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
|
||||||
|
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
|
||||||
|
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-mintel}
|
||||||
|
volumes:
|
||||||
|
- directus-db-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
infra:
|
infra:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
directus-db-data:
|
||||||
|
|||||||
@@ -6,64 +6,85 @@ on:
|
|||||||
- main
|
- main
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
skip_long_checks:
|
||||||
|
description: 'Skip tests? (true/false)'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 1: Prepare & Determine Environment
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
prepare:
|
||||||
|
name: 🔍 Prepare Environment
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
outputs:
|
||||||
|
target: ${{ steps.determine.outputs.target }}
|
||||||
|
image_tag: ${{ steps.determine.outputs.image_tag }}
|
||||||
|
env_file: ${{ steps.determine.outputs.env_file }}
|
||||||
|
traefik_host: ${{ steps.determine.outputs.traefik_host }}
|
||||||
|
next_public_base_url: ${{ steps.determine.outputs.next_public_base_url }}
|
||||||
|
directus_url: ${{ steps.determine.outputs.directus_url }}
|
||||||
|
directus_host: ${{ steps.determine.outputs.directus_host }}
|
||||||
|
project_name: ${{ steps.determine.outputs.project_name }}
|
||||||
|
is_prod: ${{ steps.determine.outputs.is_prod }}
|
||||||
|
gotify_title: ${{ steps.determine.outputs.gotify_title }}
|
||||||
|
gotify_priority: ${{ steps.determine.outputs.gotify_priority }}
|
||||||
|
short_sha: ${{ steps.determine.outputs.short_sha }}
|
||||||
|
commit_msg: ${{ steps.determine.outputs.commit_msg }}
|
||||||
steps:
|
steps:
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Workflow Start & Basic Info
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
- name: 📢 Workflow Start
|
|
||||||
run: |
|
|
||||||
echo "┌──────────────────────────────────────────────────────────────┐"
|
|
||||||
echo "│ 🚀 Deployment Workflow gestartet │"
|
|
||||||
echo "├──────────────────────────────────────────────────────────────┤"
|
|
||||||
echo "│ Repository: ${{ github.repository }} │"
|
|
||||||
echo "│ Ref: ${{ github.ref }} │"
|
|
||||||
echo "│ Ref-Name: ${{ github.ref_name }} │"
|
|
||||||
echo "│ Commit: ${{ github.sha }} │"
|
|
||||||
echo "│ Actor: ${{ github.actor }} │"
|
|
||||||
echo "│ Datum: $(date -u +'%Y-%m-%d %H:%M:%S UTC') │"
|
|
||||||
echo "└──────────────────────────────────────────────────────────────┘"
|
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Environment bestimmen + Commit-Message holen
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
- name: 🔍 Environment & Version ermitteln
|
- name: 🔍 Environment & Version ermitteln
|
||||||
id: determine
|
id: determine
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ github.ref_name }}"
|
TAG="${{ github.ref_name }}"
|
||||||
SHORT_SHA="${{ github.sha }}"
|
SHORT_SHA="${{ github.sha }}"
|
||||||
SHORT_SHA="${SHORT_SHA:0:9}"
|
SHORT_SHA="${SHORT_SHA:0:9}"
|
||||||
|
|
||||||
# Get base domain from secret or env if possible, otherwise placeholder
|
|
||||||
# In a real project, you'd likely have a primary domain secret
|
|
||||||
DOMAIN_BASE=$(echo "${{ secrets.NEXT_PUBLIC_BASE_URL }}" | sed -E 's|https?://||' | sed -E 's|/.*||')
|
|
||||||
|
|
||||||
# Commit-Message holen (erste Zeile)
|
|
||||||
COMMIT_MSG=$(git log -1 --pretty=%s || echo "No commit message available")
|
COMMIT_MSG=$(git log -1 --pretty=%s || echo "No commit message available")
|
||||||
|
|
||||||
|
# Base Domain (e.g. example.com)
|
||||||
|
DOMAIN_BASE=$(echo "${{ secrets.NEXT_PUBLIC_BASE_URL }}" | sed -E 's|https?://||' | sed -E 's|/.*||')
|
||||||
|
PRJ_ID="${{ github.event.repository.name }}"
|
||||||
|
|
||||||
if [[ "${{ github.ref_type }}" == "branch" && "$TAG" == "main" ]]; then
|
if [[ "${{ github.ref_type }}" == "branch" && "$TAG" == "main" ]]; then
|
||||||
TARGET="testing"
|
if [[ "$COMMIT_MSG" =~ ^chore: ]]; then
|
||||||
IMAGE_TAG="main-${SHORT_SHA}"
|
TARGET="skip"
|
||||||
ENV_FILE=".env.testing"
|
GOTIFY_TITLE="ℹ️ Skip Deploy (Chore)"
|
||||||
TRAEFIK_HOST="\`testing.${DOMAIN_BASE}\`"
|
GOTIFY_PRIORITY=2
|
||||||
IS_PROD="false"
|
else
|
||||||
GOTIFY_TITLE="🧪 Testing-Deploy"
|
TARGET="testing"
|
||||||
GOTIFY_PRIORITY=4
|
IMAGE_TAG="main-${SHORT_SHA}"
|
||||||
|
ENV_FILE=".env.testing"
|
||||||
|
TRAEFIK_HOST="\`testing.\${DOMAIN_BASE}\`"
|
||||||
|
NEXT_PUBLIC_BASE_URL="https://testing.\${DOMAIN_BASE}"
|
||||||
|
DIRECTUS_URL="https://cms.testing.\${DOMAIN_BASE}"
|
||||||
|
DIRECTUS_HOST="\`cms.testing.\${DOMAIN_BASE}\`"
|
||||||
|
PROJECT_NAME="\${PRJ_ID}-testing"
|
||||||
|
IS_PROD="false"
|
||||||
|
GOTIFY_TITLE="🧪 Testing-Deploy"
|
||||||
|
GOTIFY_PRIORITY=4
|
||||||
|
fi
|
||||||
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
|
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
TARGET="production"
|
TARGET="production"
|
||||||
IMAGE_TAG="$TAG"
|
IMAGE_TAG="$TAG"
|
||||||
ENV_FILE=".env.prod"
|
ENV_FILE=".env.prod"
|
||||||
TRAEFIK_HOST="\`${DOMAIN_BASE}\`, \`www.${DOMAIN_BASE}\`"
|
TRAEFIK_HOST="\${DOMAIN_BASE}, www.\${DOMAIN_BASE}" # Note: Host() backticks usually needed in compose
|
||||||
|
TRAEFIK_HOST="\`\${DOMAIN_BASE}\`, \`www.\${DOMAIN_BASE}\`"
|
||||||
|
NEXT_PUBLIC_BASE_URL="https://\${DOMAIN_BASE}"
|
||||||
|
DIRECTUS_URL="https://cms.\${DOMAIN_BASE}"
|
||||||
|
DIRECTUS_HOST="\`cms.\${DOMAIN_BASE}\`"
|
||||||
|
PROJECT_NAME="\${PRJ_ID}-prod"
|
||||||
IS_PROD="true"
|
IS_PROD="true"
|
||||||
GOTIFY_TITLE="🚀 Production-Release"
|
GOTIFY_TITLE="🚀 Production-Release"
|
||||||
GOTIFY_PRIORITY=6
|
GOTIFY_PRIORITY=6
|
||||||
@@ -71,14 +92,16 @@ jobs:
|
|||||||
TARGET="staging"
|
TARGET="staging"
|
||||||
IMAGE_TAG="$TAG"
|
IMAGE_TAG="$TAG"
|
||||||
ENV_FILE=".env.staging"
|
ENV_FILE=".env.staging"
|
||||||
TRAEFIK_HOST="\`staging.${DOMAIN_BASE}\`"
|
TRAEFIK_HOST="\`staging.\${DOMAIN_BASE}\`"
|
||||||
|
NEXT_PUBLIC_BASE_URL="https://staging.\${DOMAIN_BASE}"
|
||||||
|
DIRECTUS_URL="https://cms.staging.\${DOMAIN_BASE}"
|
||||||
|
DIRECTUS_HOST="\`cms.staging.\${DOMAIN_BASE}\`"
|
||||||
|
PROJECT_NAME="\${PRJ_ID}-staging"
|
||||||
IS_PROD="false"
|
IS_PROD="false"
|
||||||
GOTIFY_TITLE="🧪 Staging-Deploy (Pre-Release)"
|
GOTIFY_TITLE="🧪 Staging-Deploy (Pre-Release)"
|
||||||
GOTIFY_PRIORITY=5
|
GOTIFY_PRIORITY=5
|
||||||
else
|
else
|
||||||
TARGET="skip"
|
TARGET="skip"
|
||||||
GOTIFY_TITLE="❓ Unbekannter Tag"
|
|
||||||
GOTIFY_PRIORITY=3
|
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
TARGET="skip"
|
TARGET="skip"
|
||||||
@@ -88,161 +111,151 @@ jobs:
|
|||||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||||
echo "env_file=$ENV_FILE" >> $GITHUB_OUTPUT
|
echo "env_file=$ENV_FILE" >> $GITHUB_OUTPUT
|
||||||
echo "traefik_host=$TRAEFIK_HOST" >> $GITHUB_OUTPUT
|
echo "traefik_host=$TRAEFIK_HOST" >> $GITHUB_OUTPUT
|
||||||
|
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL" >> $GITHUB_OUTPUT
|
||||||
|
echo "directus_url=$DIRECTUS_URL" >> $GITHUB_OUTPUT
|
||||||
|
echo "directus_host=$DIRECTUS_HOST" >> $GITHUB_OUTPUT
|
||||||
|
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
|
||||||
echo "is_prod=$IS_PROD" >> $GITHUB_OUTPUT
|
echo "is_prod=$IS_PROD" >> $GITHUB_OUTPUT
|
||||||
echo "gotify_title=$GOTIFY_TITLE" >> $GITHUB_OUTPUT
|
echo "gotify_title=$GOTIFY_TITLE" >> $GITHUB_OUTPUT
|
||||||
echo "gotify_priority=$GOTIFY_PRIORITY" >> $GITHUB_OUTPUT
|
echo "gotify_priority=$GOTIFY_PRIORITY" >> $GITHUB_OUTPUT
|
||||||
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
|
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
|
||||||
echo "commit_msg=$COMMIT_MSG" >> $GITHUB_OUTPUT
|
echo "commit_msg=$COMMIT_MSG" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: ⏭️ Skip Deployment
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
if: steps.determine.outputs.target == 'skip'
|
# JOB 2: Quality Assurance (Lint & Test)
|
||||||
run: |
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
echo "Deployment übersprungen – kein passender Trigger (main oder v*-Tag)"
|
qa:
|
||||||
exit 0
|
name: 🧪 QA
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.target != 'skip'
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: 🧪 Run Checks
|
||||||
|
if: github.event.inputs.skip_long_checks != 'true'
|
||||||
|
run: |
|
||||||
|
npm run lint
|
||||||
|
npm run typecheck
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 3: Build & Push
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
build:
|
||||||
|
name: 🏗️ Build
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.target != 'skip'
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Registry Login
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
- name: 🔐 Registry Login
|
- name: 🔐 Registry Login
|
||||||
run: |
|
run: |
|
||||||
echo "🔐 Login zu registry.infra.mintel.me ..."
|
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
- name: 🏗️ Docker Build & Push
|
||||||
# Build & Push
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
- name: 🏗️ Docker Image bauen & pushen
|
|
||||||
env:
|
env:
|
||||||
IMAGE_TAG: ${{ steps.determine.outputs.image_tag }}
|
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ steps.determine.outputs.target == 'production' && secrets.NEXT_PUBLIC_BASE_URL || (steps.determine.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_BASE_URL || secrets.TESTING_NEXT_PUBLIC_BASE_URL || secrets.NEXT_PUBLIC_BASE_URL) }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ steps.determine.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (steps.determine.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
|
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ steps.determine.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (steps.determine.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
|
||||||
run: |
|
run: |
|
||||||
echo "🏗️ Building → ${{ steps.determine.outputs.target }} / $IMAGE_TAG"
|
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--pull \
|
--pull \
|
||||||
--platform linux/arm64 \
|
--platform linux/arm64 \
|
||||||
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="$NEXT_PUBLIC_UMAMI_WEBSITE_ID" \
|
-t registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:$IMAGE_TAG \
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL="$NEXT_PUBLIC_UMAMI_SCRIPT_URL" \
|
|
||||||
-t registry.infra.mintel.me/${{ github.repository }}:$IMAGE_TAG \
|
|
||||||
--push .
|
--push .
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# Deploy via SSH
|
# JOB 4: Deploy
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
- name: 🚀 Deploy to ${{ steps.determine.outputs.target }}
|
deploy:
|
||||||
env:
|
name: 🚀 Deploy
|
||||||
IMAGE_TAG: ${{ steps.determine.outputs.image_tag }}
|
needs: [prepare, build, qa]
|
||||||
ENV_FILE: ${{ steps.determine.outputs.env_file }}
|
if: needs.prepare.outputs.target != 'skip'
|
||||||
TRAEFIK_HOST: ${{ steps.determine.outputs.traefik_host }}
|
runs-on: docker
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ steps.determine.outputs.target == 'production' && secrets.NEXT_PUBLIC_BASE_URL || (steps.determine.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_BASE_URL || secrets.TESTING_NEXT_PUBLIC_BASE_URL || secrets.NEXT_PUBLIC_BASE_URL) }}
|
env:
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ steps.determine.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (steps.determine.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
|
TARGET: ${{ needs.prepare.outputs.target }}
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ steps.determine.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (steps.determine.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
steps:
|
||||||
SENTRY_DSN: ${{ steps.determine.outputs.target == 'production' && secrets.SENTRY_DSN || (steps.determine.outputs.target == 'staging' && secrets.STAGING_SENTRY_DSN || secrets.TESTING_SENTRY_DSN || secrets.SENTRY_DSN) }}
|
- name: Checkout repository
|
||||||
MAIL_HOST: ${{ steps.determine.outputs.target == 'production' && secrets.MAIL_HOST || (steps.determine.outputs.target == 'staging' && secrets.STAGING_MAIL_HOST || secrets.TESTING_MAIL_HOST || secrets.MAIL_HOST) }}
|
uses: actions/checkout@v4
|
||||||
MAIL_PORT: ${{ steps.determine.outputs.target == 'production' && secrets.MAIL_PORT || (steps.determine.outputs.target == 'staging' && secrets.STAGING_MAIL_PORT || secrets.TESTING_MAIL_PORT || secrets.MAIL_PORT) }}
|
|
||||||
MAIL_USERNAME: ${{ steps.determine.outputs.target == 'production' && secrets.MAIL_USERNAME || (steps.determine.outputs.target == 'staging' && secrets.STAGING_MAIL_USERNAME || secrets.TESTING_MAIL_USERNAME || secrets.MAIL_USERNAME) }}
|
|
||||||
MAIL_PASSWORD: ${{ steps.determine.outputs.target == 'production' && secrets.MAIL_PASSWORD || (steps.determine.outputs.target == 'staging' && secrets.STAGING_MAIL_PASSWORD || secrets.TESTING_MAIL_PASSWORD || secrets.MAIL_PASSWORD) }}
|
|
||||||
MAIL_FROM: ${{ steps.determine.outputs.target == 'production' && secrets.MAIL_FROM || (steps.determine.outputs.target == 'staging' && secrets.STAGING_MAIL_FROM || secrets.TESTING_MAIL_FROM || secrets.MAIL_FROM) }}
|
|
||||||
MAIL_RECIPIENTS: ${{ steps.determine.outputs.target == 'production' && secrets.MAIL_RECIPIENTS || (steps.determine.outputs.target == 'staging' && secrets.STAGING_MAIL_RECIPIENTS || secrets.TESTING_MAIL_RECIPIENTS || secrets.MAIL_RECIPIENTS) }}
|
|
||||||
run: |
|
|
||||||
echo "Deploying ${{ steps.determine.outputs.target }} → $IMAGE_TAG"
|
|
||||||
|
|
||||||
# SSH vorbereiten
|
- name: 🚀 Deploy via SSH
|
||||||
|
env:
|
||||||
|
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||||
|
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||||
|
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
||||||
|
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||||
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519
|
echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
# .env-Datei erstellen
|
# Generate .env from secrets
|
||||||
cat > /tmp/app.env << EOF
|
cat > /tmp/app.env << EOF
|
||||||
# Generated by CI - ${{ steps.determine.outputs.target }} - $(date -u)
|
# Generated by CI - $TARGET - $(date -u)
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
|
||||||
SENTRY_DSN=$SENTRY_DSN
|
|
||||||
MAIL_HOST=$MAIL_HOST
|
|
||||||
MAIL_PORT=$MAIL_PORT
|
|
||||||
MAIL_USERNAME=$MAIL_USERNAME
|
|
||||||
MAIL_PASSWORD=$MAIL_PASSWORD
|
|
||||||
MAIL_FROM=$MAIL_FROM
|
|
||||||
MAIL_RECIPIENTS=$MAIL_RECIPIENTS
|
|
||||||
|
|
||||||
IMAGE_TAG=$IMAGE_TAG
|
IMAGE_TAG=$IMAGE_TAG
|
||||||
TRAEFIK_HOST=$TRAEFIK_HOST
|
TRAEFIK_HOST=$TRAEFIK_HOST
|
||||||
|
PROJECT_NAME=$PROJECT_NAME
|
||||||
ENV_FILE=$ENV_FILE
|
ENV_FILE=$ENV_FILE
|
||||||
|
|
||||||
|
# App Config
|
||||||
|
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }}
|
||||||
|
|
||||||
|
# Directus Config
|
||||||
|
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
|
||||||
|
DIRECTUS_HOST=${{ needs.prepare.outputs.directus_host }}
|
||||||
|
DIRECTUS_KEY=${{ secrets.DIRECTUS_KEY }}
|
||||||
|
DIRECTUS_SECRET=${{ secrets.DIRECTUS_SECRET }}
|
||||||
|
DIRECTUS_ADMIN_EMAIL=${{ secrets.DIRECTUS_ADMIN_EMAIL }}
|
||||||
|
DIRECTUS_ADMIN_PASSWORD=${{ secrets.DIRECTUS_ADMIN_PASSWORD }}
|
||||||
|
DIRECTUS_DB_PASSWORD=${{ secrets.DIRECTUS_DB_PASSWORD }}
|
||||||
|
|
||||||
|
# Gatekeeper
|
||||||
|
GATEKEEPER_PASSWORD=${{ secrets.GATEKEEPER_PASSWORD || 'mintel' }}
|
||||||
|
AUTH_MIDDLEWARE=$( [[ "$TARGET" == "production" ]] && echo "compress" || echo "$PROJECT_NAME-auth,compress" )
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
APP_DIR="/home/deploy/sites/${{ github.event.repository.name }}"
|
APP_DIR="/home/deploy/sites/${{ github.event.repository.name }}"
|
||||||
ssh -o StrictHostKeyChecking=accept-new root@${{ secrets.SSH_HOST }} "mkdir -p $APP_DIR"
|
ssh root@${{ secrets.SSH_HOST }} "mkdir -p $APP_DIR"
|
||||||
scp -o StrictHostKeyChecking=accept-new /tmp/app.env root@${{ secrets.SSH_HOST }}:$APP_DIR/$ENV_FILE
|
scp /tmp/app.env root@${{ secrets.SSH_HOST }}:$APP_DIR/$ENV_FILE
|
||||||
scp -o StrictHostKeyChecking=accept-new docker-compose.yml root@${{ secrets.SSH_HOST }}:$APP_DIR/docker-compose.yml
|
scp docker-compose.yml root@${{ secrets.SSH_HOST }}:$APP_DIR/docker-compose.yml
|
||||||
|
|
||||||
ssh -o StrictHostKeyChecking=accept-new root@${{ secrets.SSH_HOST }} bash << 'EOF'
|
ssh root@${{ secrets.SSH_HOST }} IMAGE_TAG="$IMAGE_TAG" ENV_FILE="$ENV_FILE" PROJECT_NAME="$PROJECT_NAME" bash << 'EOF'
|
||||||
set -e
|
set -e
|
||||||
APP_DIR="/home/deploy/sites/${{ github.event.repository.name }}"
|
cd "/home/deploy/sites/${{ github.event.repository.name }}"
|
||||||
cd $APP_DIR
|
|
||||||
|
|
||||||
chmod 600 $ENV_FILE
|
|
||||||
chown deploy:deploy $ENV_FILE
|
|
||||||
|
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" pull
|
||||||
echo "→ Pulling image: $IMAGE_TAG"
|
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --remove-orphans
|
||||||
IMAGE_TAG=$IMAGE_TAG ENV_FILE=$ENV_FILE TRAEFIK_HOST="$TRAEFIK_HOST" docker compose --env-file $ENV_FILE pull
|
|
||||||
|
|
||||||
echo "→ Starting containers..."
|
|
||||||
IMAGE_TAG=$IMAGE_TAG ENV_FILE=$ENV_FILE TRAEFIK_HOST="$TRAEFIK_HOST" docker compose --env-file $ENV_FILE up -d
|
|
||||||
|
|
||||||
docker system prune -f --filter "until=168h"
|
docker system prune -f --filter "until=168h"
|
||||||
|
|
||||||
echo "→ Waiting 15s for warmup..."
|
|
||||||
sleep 15
|
|
||||||
|
|
||||||
echo "→ Container status:"
|
|
||||||
docker compose --env-file $ENV_FILE ps
|
|
||||||
|
|
||||||
if ! docker compose --env-file $ENV_FILE ps | grep -q "Up"; then
|
|
||||||
echo "❌ Fehler: Container nicht Up!"
|
|
||||||
docker compose --env-file $ENV_FILE logs --tail=150
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Deployment erfolgreich auf ${{ steps.determine.outputs.target }}!"
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
rm -f /tmp/app.env
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 5: Notifications
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# Summary & Gotify
|
notifications:
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
name: 🔔 Notifications
|
||||||
- name: 📊 Deployment Summary
|
needs: [prepare, deploy]
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
runs-on: docker
|
||||||
echo "┌──────────────────────────────┐"
|
steps:
|
||||||
echo "│ Deployment Summary │"
|
- name: 🔔 Gotify
|
||||||
echo "├──────────────────────────────┤"
|
if: needs.deploy.result == 'success'
|
||||||
echo "│ Status: ${{ job.status }} │"
|
|
||||||
echo "│ Umgebung: ${{ steps.determine.outputs.target || 'skipped' }} │"
|
|
||||||
echo "│ Version: ${{ steps.determine.outputs.image_tag }} │"
|
|
||||||
echo "│ Commit: ${{ steps.determine.outputs.short_sha }} │"
|
|
||||||
echo "│ Message: ${{ steps.determine.outputs.commit_msg }} │"
|
|
||||||
echo "└──────────────────────────────┘"
|
|
||||||
|
|
||||||
- name: 🔔 Gotify - Success
|
|
||||||
if: success()
|
|
||||||
run: |
|
run: |
|
||||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
-F "title=${{ steps.determine.outputs.gotify_title }}" \
|
-F "title=${{ needs.prepare.outputs.gotify_title }}" \
|
||||||
-F "message=Erfolgreich deployt auf **${{ steps.determine.outputs.target }}**\n\nVersion: **${{ steps.determine.outputs.image_tag }}**\nCommit: ${{ steps.determine.outputs.short_sha }} (${{ steps.determine.outputs.commit_msg }})\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}" \
|
-F "message=Erfolgreich deployt auf **${{ needs.prepare.outputs.target }}**" \
|
||||||
-F "priority=${{ steps.determine.outputs.gotify_priority }}" || true
|
-F "priority=4" || true
|
||||||
|
|
||||||
- name: 🔔 Gotify - Failure
|
|
||||||
if: failure()
|
|
||||||
run: |
|
|
||||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
|
||||||
-F "title=❌ Deployment FEHLGESCHLAGEN – ${{ steps.determine.outputs.target || 'unknown' }}" \
|
|
||||||
-F "message=**Fehler beim Deploy auf ${{ steps.determine.outputs.target }}**\n\nVersion: ${{ steps.determine.outputs.image_tag || '?' }}\nCommit: ${{ steps.determine.outputs.short_sha || '?' }}\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}\n\nBitte Logs prüfen!" \
|
|
||||||
-F "priority=8" || true
|
|
||||||
|
|||||||
@@ -7,6 +7,13 @@
|
|||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"docker",
|
"docker",
|
||||||
"gitea"
|
"gitea",
|
||||||
]
|
"templates"
|
||||||
|
],
|
||||||
|
"devDependencies": {
|
||||||
|
"@directus/sdk": "^21.0.0",
|
||||||
|
"@mintel/next-utils": "workspace:*",
|
||||||
|
"@mintel/tsconfig": "workspace:*",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
packages/infra/templates/website/.gitignore
vendored
Normal file
29
packages/infra/templates/website/.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnpm-debug.log*
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
25
packages/infra/templates/website/scripts/setup-directus.ts
Normal file
25
packages/infra/templates/website/scripts/setup-directus.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import client, { ensureAuthenticated } from "../src/lib/directus";
|
||||||
|
import { updateSettings } from "@directus/sdk";
|
||||||
|
|
||||||
|
async function setupBranding() {
|
||||||
|
console.log("🎨 Setup Directus Branding...");
|
||||||
|
await ensureAuthenticated();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.request(
|
||||||
|
updateSettings({
|
||||||
|
project_name: process.env.PROJECT_NAME || "Mintel Project",
|
||||||
|
project_color: process.env.PROJECT_COLOR || "#82ed20",
|
||||||
|
theme_light_overrides: {
|
||||||
|
primary: process.env.PROJECT_COLOR || "#82ed20",
|
||||||
|
borderRadius: "16px",
|
||||||
|
},
|
||||||
|
} as any),
|
||||||
|
);
|
||||||
|
console.log("✨ Branding applied!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Error setting up branding:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupBranding();
|
||||||
45
packages/infra/templates/website/src/app/globals.css
Normal file
45
packages/infra/templates/website/src/app/globals.css
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans:
|
||||||
|
"Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-heading: "Inter", system-ui, sans-serif;
|
||||||
|
--font-body: "Inter", system-ui, sans-serif;
|
||||||
|
|
||||||
|
--color-primary: #001a4d;
|
||||||
|
--color-primary-dark: #000d26;
|
||||||
|
--color-primary-light: #e6ebf5;
|
||||||
|
|
||||||
|
--color-accent: #82ed20;
|
||||||
|
--color-accent-dark: #6bc41a;
|
||||||
|
--color-accent-light: #f0f9e6;
|
||||||
|
|
||||||
|
--color-text-primary: #111827;
|
||||||
|
--color-text-secondary: #4b5563;
|
||||||
|
--color-text-light: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply antialiased bg-white text-text-primary;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
@apply font-heading font-bold tracking-tight text-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility touch-target {
|
||||||
|
min-height: 48px;
|
||||||
|
min-width: 48px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
12
packages/infra/templates/website/src/lib/directus.ts
Normal file
12
packages/infra/templates/website/src/lib/directus.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import {
|
||||||
|
createMintelDirectusClient,
|
||||||
|
ensureDirectusAuthenticated,
|
||||||
|
} from "@mintel/next-utils";
|
||||||
|
|
||||||
|
const client = createMintelDirectusClient(process.env.DIRECTUS_URL);
|
||||||
|
|
||||||
|
export async function ensureAuthenticated() {
|
||||||
|
await ensureDirectusAuthenticated(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default client;
|
||||||
12
packages/infra/tsconfig.json
Normal file
12
packages/infra/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "@mintel/tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": ".",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@mintel/next-utils": ["../next-utils/src/index.ts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
5
packages/next-config/index.d.ts
vendored
Normal file
5
packages/next-config/index.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { NextConfig } from "next";
|
||||||
|
|
||||||
|
declare function mintelNextConfig(config: NextConfig): NextConfig;
|
||||||
|
export default mintelNextConfig;
|
||||||
|
export const baseNextConfig: NextConfig;
|
||||||
@@ -7,6 +7,13 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
"types": "index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./index.d.ts",
|
||||||
|
"default": "./index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next-intl": "^3.0.0",
|
"next-intl": "^3.0.0",
|
||||||
"@sentry/nextjs": "^8.0.0"
|
"@sentry/nextjs": "^8.0.0"
|
||||||
|
|||||||
3
packages/next-utils/eslint.config.mjs
Normal file
3
packages/next-utils/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import baseConfig from "@mintel/eslint-config";
|
||||||
|
|
||||||
|
export default baseConfig;
|
||||||
@@ -11,17 +11,20 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup src/index.ts --format cjs,esm --dts",
|
"build": "tsup src/index.ts --format cjs,esm --dts",
|
||||||
"dev": "tsup src/index.ts --format cjs,esm --watch --dts",
|
"dev": "tsup src/index.ts --format cjs,esm --watch --dts",
|
||||||
"lint": "eslint src/"
|
"lint": "eslint src/",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@directus/sdk": "^21.0.0",
|
||||||
"next": "15.1.6",
|
"next": "15.1.6",
|
||||||
"next-intl": "^3.0.0",
|
"next-intl": "^3.0.0",
|
||||||
"zod": "^3.0.0"
|
"zod": "^3.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tsup": "^8.0.0",
|
"@mintel/eslint-config": "workspace:*",
|
||||||
"typescript": "^5.0.0",
|
|
||||||
"@mintel/tsconfig": "workspace:*",
|
"@mintel/tsconfig": "workspace:*",
|
||||||
"@mintel/eslint-config": "workspace:*"
|
"eslint": "^9.39.2",
|
||||||
|
"tsup": "^8.0.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
packages/next-utils/src/directus.ts
Normal file
47
packages/next-utils/src/directus.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
createDirectus,
|
||||||
|
rest,
|
||||||
|
authentication,
|
||||||
|
DirectusClient,
|
||||||
|
RestClient,
|
||||||
|
AuthenticationClient,
|
||||||
|
} from "@directus/sdk";
|
||||||
|
|
||||||
|
export type MintelDirectusClient = DirectusClient<any> &
|
||||||
|
RestClient<any> &
|
||||||
|
AuthenticationClient<any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Directus client configured with Mintel standards
|
||||||
|
*/
|
||||||
|
export function createMintelDirectusClient(url?: string): MintelDirectusClient {
|
||||||
|
const directusUrl =
|
||||||
|
url || process.env.DIRECTUS_URL || "http://localhost:8055";
|
||||||
|
|
||||||
|
return createDirectus(directusUrl).with(rest()).with(authentication());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the client is authenticated using either a static token or admin credentials
|
||||||
|
*/
|
||||||
|
export async function ensureDirectusAuthenticated(
|
||||||
|
client: MintelDirectusClient,
|
||||||
|
) {
|
||||||
|
const token = process.env.DIRECTUS_API_TOKEN || process.env.DIRECTUS_TOKEN;
|
||||||
|
const email = process.env.DIRECTUS_ADMIN_EMAIL;
|
||||||
|
const password = process.env.DIRECTUS_ADMIN_PASSWORD;
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
client.setToken(token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email && password) {
|
||||||
|
try {
|
||||||
|
await client.login({ email, password });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to authenticate with Directus:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import createMiddleware from 'next-intl/middleware';
|
import createMiddleware from "next-intl/middleware";
|
||||||
import { getRequestConfig } from 'next-intl/server';
|
import { getRequestConfig } from "next-intl/server";
|
||||||
import { NextResponse } from 'next/server';
|
import type { NextRequest } from "next/server";
|
||||||
import type { NextRequest } from 'next/server';
|
|
||||||
|
|
||||||
export interface MintelI18nConfig {
|
export interface MintelI18nConfig {
|
||||||
locales: string[];
|
locales: string[];
|
||||||
@@ -25,7 +24,10 @@ export function createMintelMiddleware(config: MintelI18nConfig) {
|
|||||||
return intlMiddleware(request);
|
return intlMiddleware(request);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (config.logRequests) {
|
if (config.logRequests) {
|
||||||
console.error(`Request failed: ${request.method} ${request.url}`, error);
|
console.error(
|
||||||
|
`Request failed: ${request.method} ${request.url}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -35,7 +37,7 @@ export function createMintelMiddleware(config: MintelI18nConfig) {
|
|||||||
export function createMintelI18nRequestConfig(
|
export function createMintelI18nRequestConfig(
|
||||||
locales: string[],
|
locales: string[],
|
||||||
defaultLocale: string,
|
defaultLocale: string,
|
||||||
importMessages: (locale: string) => Promise<any>
|
importMessages: (locale: string) => Promise<any>,
|
||||||
) {
|
) {
|
||||||
return getRequestConfig(async ({ requestLocale }) => {
|
return getRequestConfig(async ({ requestLocale }) => {
|
||||||
let locale = await requestLocale;
|
let locale = await requestLocale;
|
||||||
@@ -48,19 +50,19 @@ export function createMintelI18nRequestConfig(
|
|||||||
locale,
|
locale,
|
||||||
messages: (await importMessages(locale)).default,
|
messages: (await importMessages(locale)).default,
|
||||||
onError(error: any) {
|
onError(error: any) {
|
||||||
if (error.code === 'MISSING_MESSAGE') {
|
if (error.code === "MISSING_MESSAGE") {
|
||||||
console.error(error.message);
|
console.error(error.message);
|
||||||
} else {
|
} else {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getMessageFallback({ namespace, key, error }: any) {
|
getMessageFallback({ namespace, key, error }: any) {
|
||||||
const path = [namespace, key].filter((part) => part != null).join('.');
|
const path = [namespace, key].filter((part) => part != null).join(".");
|
||||||
if (error.code === 'MISSING_MESSAGE') {
|
if (error.code === "MISSING_MESSAGE") {
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
return 'fallback';
|
return "fallback";
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
10
packages/next-utils/src/index.test.ts
Normal file
10
packages/next-utils/src/index.test.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { isValidLang } from "../src/index";
|
||||||
|
|
||||||
|
describe("next-utils", () => {
|
||||||
|
it("should validate languages correctly", () => {
|
||||||
|
expect(isValidLang("en")).toBe(true);
|
||||||
|
expect(isValidLang("de")).toBe(true);
|
||||||
|
expect(isValidLang("fr")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,11 @@ const submissions: Record<string, number> = {};
|
|||||||
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
|
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
|
||||||
const MAX_SUBMISSIONS_PER_WINDOW = 3;
|
const MAX_SUBMISSIONS_PER_WINDOW = 3;
|
||||||
|
|
||||||
export async function rateLimit(identifier: string, windowMs = RATE_LIMIT_WINDOW, maxSubmissions = MAX_SUBMISSIONS_PER_WINDOW) {
|
export async function rateLimit(
|
||||||
|
identifier: string,
|
||||||
|
windowMs = RATE_LIMIT_WINDOW,
|
||||||
|
maxSubmissions = MAX_SUBMISSIONS_PER_WINDOW,
|
||||||
|
) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Clean up old submissions
|
// Clean up old submissions
|
||||||
@@ -15,7 +19,7 @@ export async function rateLimit(identifier: string, windowMs = RATE_LIMIT_WINDOW
|
|||||||
|
|
||||||
// Check if identifier has exceeded submission limit
|
// Check if identifier has exceeded submission limit
|
||||||
const currentSubmissions = Object.values(submissions).filter(
|
const currentSubmissions = Object.values(submissions).filter(
|
||||||
(timestamp) => now - timestamp <= windowMs
|
(timestamp) => now - timestamp <= windowMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (currentSubmissions.length >= maxSubmissions) {
|
if (currentSubmissions.length >= maxSubmissions) {
|
||||||
@@ -35,3 +39,4 @@ export function isValidLang(lang: string): lang is Lang {
|
|||||||
|
|
||||||
export * from "./i18n";
|
export * from "./i18n";
|
||||||
export * from "./env";
|
export * from "./env";
|
||||||
|
export * from "./directus";
|
||||||
|
|||||||
2118
pnpm-lock.yaml
generated
2118
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
10
tsconfig.json
Normal file
10
tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@mintel/*": ["packages/*/index.js"],
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user