Compare commits

...

4 Commits

Author SHA1 Message Date
6501eac38a fix: gatekeeper access error 2026-02-07 09:46:31 +01:00
7f9206ae77 feat: Remove hardcoded /gatekeeper base path, update image paths, and introduce configurable base URL and cookie domain for improved routing and session management.
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 3m4s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m3s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 5m18s
2026-02-06 13:54:47 +01:00
6229f8e886 feat: Add Next.js basePath and relocate the login page to /login. 2026-02-06 13:39:38 +01:00
8ac090aff3 feat: Set up new Tailwind CSS configuration, global styling, and initial application pages.
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m36s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m35s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 5m18s
2026-02-06 00:20:55 +01:00
14 changed files with 351 additions and 172 deletions

View File

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

View File

@@ -24,6 +24,7 @@
"@mintel/eslint-config": "workspace:*",
"@mintel/next-config": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^20.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",

View File

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -19,7 +19,11 @@ export async function GET(req: NextRequest) {
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)}`;
const gatekeeperUrl =
process.env.NEXT_PUBLIC_BASE_URL || `${proto}://gatekeeper.${host}`;
const absoluteOriginalUrl = `${proto}://${host}${originalUrl}`;
const loginUrl = `${gatekeeperUrl}/login?redirect=${encodeURIComponent(absoluteOriginalUrl)}`;
return NextResponse.redirect(loginUrl);
}

View File

@@ -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]">
&copy; 2026 {projectName} Infrastructure
</p>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,133 @@
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 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;
const cookieDomain = process.env.COOKIE_DOMAIN;
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",
...(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-6">
<input type="hidden" name="redirect" value={redirectUrl} />
<div className="relative group">
<input
type="password"
name="password"
required
autoFocus
autoComplete="current-password"
placeholder="GATEKEEPER 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>
<button
type="submit"
className="btn btn-primary w-full py-5 rounded-2xl text-[10px] shadow-lg shadow-slate-100"
>
Unlock Access
<ArrowRight className="ml-3 w-3 h-3 group-hover:translate-x-1 transition-transform" />
</button>
</form>
{/* Bottom Section - Full Branding Parity */}
<div className="pt-12 sm:pt-20 flex flex-col items-center gap-6 sm:gap-8">
<div className="h-px w-8 bg-slate-100" />
<div className="opacity-80 transition-opacity hover:opacity-100">
<Image
src="/logo-black.svg"
alt={projectName}
width={140}
height={40}
className="h-7 sm:h-auto grayscale contrast-125 w-auto"
/>
</div>
<p className="text-[8px] font-sans font-bold text-slate-300 uppercase tracking-[0.4em] sm:tracking-[0.5em] text-center">
&copy; 2026 MINTEL
</p>
</div>
</div>
</div>
</main>
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -25,9 +25,12 @@ ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/packages/gatekeeper/public ./packages/gatekeeper/public
COPY --from=builder /app/packages/gatekeeper/.next/standalone ./
COPY --from=builder /app/packages/gatekeeper/.next/static ./packages/gatekeeper/.next/static
# Set the correct permission for prerender cache
RUN mkdir -p packages/gatekeeper/.next && chown nextjs:nodejs packages/gatekeeper/.next
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/public ./packages/gatekeeper/public
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/.next/static ./packages/gatekeeper/.next/static
USER nextjs
EXPOSE 3000

24
pnpm-lock.yaml generated
View File

@@ -200,6 +200,9 @@ importers:
'@mintel/tsconfig':
specifier: workspace:*
version: link:../tsconfig
'@tailwindcss/typography':
specifier: ^0.5.19
version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))
'@types/node':
specifier: ^20.0.0
version: 20.19.30
@@ -1832,6 +1835,11 @@ packages:
'@swc/types@0.1.25':
resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==}
'@tailwindcss/typography@0.5.19':
resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==}
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@@ -3138,6 +3146,7 @@ packages:
glob@9.3.5:
resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==}
engines: {node: '>=16 || 14 >=14.17'}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
global-directory@4.0.1:
resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==}
@@ -3741,6 +3750,7 @@ packages:
next@15.1.6:
resolution: {integrity: sha512-Hch4wzbaX0vKQtalpXvUiw5sYivBy4cm5rzUKrBnUB/y436LGrvOUqYvlSeNVCWFO/770gDlltR9gqZH62ct4Q==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
@@ -3991,6 +4001,10 @@ packages:
peerDependencies:
postcss: ^8.2.14
postcss-selector-parser@6.0.10:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
engines: {node: '>=4'}
postcss-selector-parser@6.1.2:
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
engines: {node: '>=4'}
@@ -6498,6 +6512,11 @@ snapshots:
dependencies:
'@swc/counter': 0.1.3
'@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
postcss-selector-parser: 6.0.10
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.28.6
@@ -8890,6 +8909,11 @@ snapshots:
postcss: 8.5.6
postcss-selector-parser: 6.1.2
postcss-selector-parser@6.0.10:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss-selector-parser@6.1.2:
dependencies:
cssesc: 3.0.0