This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export const ADMIN_SESSION_COOKIE = "moh_admin_session";
|
||||
const ADMIN_FAIL_COOKIE = "moh_admin_fail";
|
||||
const ADMIN_SESSION_VALUE = "superadmin";
|
||||
const MAX_FAILED_ATTEMPTS = 5;
|
||||
const LOCKOUT_SECONDS = 15 * 60;
|
||||
|
||||
function getSecret(): string {
|
||||
return process.env.ADMIN_AUTH_SECRET ?? "";
|
||||
}
|
||||
|
||||
function getPassword(): string {
|
||||
return process.env.ADMIN_PASSWORD ?? "";
|
||||
}
|
||||
|
||||
function signValue(value: string): string {
|
||||
return createHmac("sha256", getSecret()).update(value).digest("hex");
|
||||
}
|
||||
|
||||
function buildToken(): string {
|
||||
return `${ADMIN_SESSION_VALUE}.${signValue(ADMIN_SESSION_VALUE)}`;
|
||||
}
|
||||
|
||||
function verifyToken(token: string): boolean {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [value, signature] = parts;
|
||||
if (value !== ADMIN_SESSION_VALUE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expected = signValue(value);
|
||||
const left = Buffer.from(signature);
|
||||
const right = Buffer.from(expected);
|
||||
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
export function isAdminAuthConfigured(): boolean {
|
||||
return getPassword().length > 0 && getSecret().length > 0;
|
||||
}
|
||||
|
||||
export function isPasswordValid(password: string): boolean {
|
||||
if (!isAdminAuthConfigured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const provided = Buffer.from(password);
|
||||
const expected = Buffer.from(getPassword());
|
||||
|
||||
if (provided.length !== expected.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(provided, expected);
|
||||
}
|
||||
|
||||
export function setAdminSessionCookie(): void {
|
||||
const store = cookies();
|
||||
store.set(ADMIN_SESSION_COOKIE, buildToken(), {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 8,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearAdminSessionCookie(): void {
|
||||
const store = cookies();
|
||||
store.set(ADMIN_SESSION_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
}
|
||||
|
||||
type FailState = {
|
||||
attempts: number;
|
||||
lockUntil: number;
|
||||
};
|
||||
|
||||
function parseFailState(rawValue: string | undefined): FailState {
|
||||
if (!rawValue) {
|
||||
return { attempts: 0, lockUntil: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue) as Partial<FailState>;
|
||||
|
||||
return {
|
||||
attempts: Number(parsed.attempts ?? 0),
|
||||
lockUntil: Number(parsed.lockUntil ?? 0),
|
||||
};
|
||||
} catch {
|
||||
return { attempts: 0, lockUntil: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminLockState(): { locked: boolean; remainingSeconds: number } {
|
||||
const store = cookies();
|
||||
const state = parseFailState(store.get(ADMIN_FAIL_COOKIE)?.value);
|
||||
const now = Date.now();
|
||||
|
||||
if (state.lockUntil > now) {
|
||||
return {
|
||||
locked: true,
|
||||
remainingSeconds: Math.ceil((state.lockUntil - now) / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
return { locked: false, remainingSeconds: 0 };
|
||||
}
|
||||
|
||||
export function registerFailedAdminAttempt(): { locked: boolean; remainingSeconds: number } {
|
||||
const store = cookies();
|
||||
const now = Date.now();
|
||||
const current = parseFailState(store.get(ADMIN_FAIL_COOKIE)?.value);
|
||||
const attempts = current.lockUntil > now ? current.attempts : current.attempts + 1;
|
||||
const locked = attempts >= MAX_FAILED_ATTEMPTS;
|
||||
const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0;
|
||||
|
||||
store.set(ADMIN_FAIL_COOKIE, JSON.stringify({ attempts, lockUntil }), {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: LOCKOUT_SECONDS,
|
||||
});
|
||||
|
||||
return {
|
||||
locked,
|
||||
remainingSeconds: locked ? LOCKOUT_SECONDS : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetAdminFailedAttempts(): void {
|
||||
const store = cookies();
|
||||
store.set(ADMIN_FAIL_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function isAdminAuthenticated(): boolean {
|
||||
if (!isAdminAuthConfigured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const store = cookies();
|
||||
const token = store.get(ADMIN_SESSION_COOKIE)?.value;
|
||||
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return verifyToken(token);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const MAINTENANCE_MODE_KEY = "maintenance_mode";
|
||||
|
||||
export async function getMaintenanceMode(): Promise<boolean> {
|
||||
try {
|
||||
const config = await prisma.appConfig.findUnique({
|
||||
where: { key: MAINTENANCE_MODE_KEY },
|
||||
});
|
||||
|
||||
return config?.value === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setMaintenanceMode(enabled: boolean): Promise<void> {
|
||||
await prisma.appConfig.upsert({
|
||||
where: { key: MAINTENANCE_MODE_KEY },
|
||||
update: {
|
||||
value: enabled ? "true" : "false",
|
||||
},
|
||||
create: {
|
||||
key: MAINTENANCE_MODE_KEY,
|
||||
value: enabled ? "true" : "false",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { Pool } from "pg";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
prismaPool: Pool | undefined;
|
||||
};
|
||||
|
||||
const connectionString =
|
||||
process.env.DATABASE_URL ??
|
||||
"postgresql://postgres:postgres@localhost:5432/moh_sass?schema=public";
|
||||
|
||||
const pool =
|
||||
globalForPrisma.prismaPool ??
|
||||
new Pool({
|
||||
connectionString,
|
||||
});
|
||||
|
||||
const adapter = new PrismaPg(pool);
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
adapter,
|
||||
log: ["warn", "error"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForPrisma.prismaPool = pool;
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
export type AppLocale = "de" | "en";
|
||||
|
||||
type LocalizedText = Record<AppLocale, string>;
|
||||
|
||||
export type PortfolioItem = {
|
||||
slug: string;
|
||||
title: LocalizedText;
|
||||
summary: LocalizedText;
|
||||
category: LocalizedText;
|
||||
year: string;
|
||||
};
|
||||
|
||||
export type ProductItem = {
|
||||
slug: string;
|
||||
name: LocalizedText;
|
||||
summary: LocalizedText;
|
||||
segment: LocalizedText;
|
||||
price: LocalizedText;
|
||||
};
|
||||
|
||||
export const portfolioItems: PortfolioItem[] = [
|
||||
{
|
||||
slug: "brand-redesign",
|
||||
title: {
|
||||
de: "Brand Redesign",
|
||||
en: "Brand Redesign",
|
||||
},
|
||||
summary: {
|
||||
de: "Modernes Redesign fuer eine digitale Marke mit klarer Struktur.",
|
||||
en: "Modern redesign for a digital brand with a clear system.",
|
||||
},
|
||||
category: {
|
||||
de: "Branding",
|
||||
en: "Branding",
|
||||
},
|
||||
year: "2025",
|
||||
},
|
||||
{
|
||||
slug: "commerce-relaunch",
|
||||
title: {
|
||||
de: "Commerce Relaunch",
|
||||
en: "Commerce Relaunch",
|
||||
},
|
||||
summary: {
|
||||
de: "Relaunch eines Shops mit Fokus auf Performance und Conversion.",
|
||||
en: "Store relaunch focused on performance and conversion.",
|
||||
},
|
||||
category: {
|
||||
de: "E-Commerce",
|
||||
en: "E-Commerce",
|
||||
},
|
||||
year: "2024",
|
||||
},
|
||||
{
|
||||
slug: "saas-dashboard",
|
||||
title: {
|
||||
de: "SaaS Dashboard",
|
||||
en: "SaaS Dashboard",
|
||||
},
|
||||
summary: {
|
||||
de: "Admin Dashboard fuer Teams mit klaren KPIs und Reports.",
|
||||
en: "Admin dashboard for teams with clear KPIs and reports.",
|
||||
},
|
||||
category: {
|
||||
de: "Web App",
|
||||
en: "Web App",
|
||||
},
|
||||
year: "2024",
|
||||
},
|
||||
{
|
||||
slug: "campaign-site",
|
||||
title: {
|
||||
de: "Campaign Site",
|
||||
en: "Campaign Site",
|
||||
},
|
||||
summary: {
|
||||
de: "Landing Seite fuer Produktkampagnen mit schneller Iteration.",
|
||||
en: "Landing experience for product campaigns and quick iteration.",
|
||||
},
|
||||
category: {
|
||||
de: "Marketing",
|
||||
en: "Marketing",
|
||||
},
|
||||
year: "2023",
|
||||
},
|
||||
];
|
||||
|
||||
export const productItems: ProductItem[] = [
|
||||
{
|
||||
slug: "starter-kit",
|
||||
name: {
|
||||
de: "Starter Kit",
|
||||
en: "Starter Kit",
|
||||
},
|
||||
summary: {
|
||||
de: "Basis Paket fuer den schnellen Start von neuen Projekten.",
|
||||
en: "Base package for launching new projects quickly.",
|
||||
},
|
||||
segment: {
|
||||
de: "Small Teams",
|
||||
en: "Small Teams",
|
||||
},
|
||||
price: {
|
||||
de: "ab 990 EUR",
|
||||
en: "from 990 EUR",
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "growth-kit",
|
||||
name: {
|
||||
de: "Growth Kit",
|
||||
en: "Growth Kit",
|
||||
},
|
||||
summary: {
|
||||
de: "Skalierbares Paket fuer wachsende Produkte und Prozesse.",
|
||||
en: "Scalable package for growing products and processes.",
|
||||
},
|
||||
segment: {
|
||||
de: "Scaleups",
|
||||
en: "Scaleups",
|
||||
},
|
||||
price: {
|
||||
de: "ab 2490 EUR",
|
||||
en: "from 2490 EUR",
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "enterprise-kit",
|
||||
name: {
|
||||
de: "Enterprise Kit",
|
||||
en: "Enterprise Kit",
|
||||
},
|
||||
summary: {
|
||||
de: "Massgeschneiderte Loesung fuer grosse Teams und komplexe Systeme.",
|
||||
en: "Tailored solution for large teams and complex systems.",
|
||||
},
|
||||
segment: {
|
||||
de: "Enterprise",
|
||||
en: "Enterprise",
|
||||
},
|
||||
price: {
|
||||
de: "auf Anfrage",
|
||||
en: "on request",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function resolveLocale(locale: string): AppLocale {
|
||||
return locale === "en" ? "en" : "de";
|
||||
}
|
||||
|
||||
export function pickText(text: LocalizedText, locale: AppLocale): string {
|
||||
return text[locale];
|
||||
}
|
||||
|
||||
export function getPortfolioItem(slug: string) {
|
||||
return portfolioItems.find((item) => item.slug === slug);
|
||||
}
|
||||
|
||||
export function getProductItem(slug: string) {
|
||||
return productItems.find((item) => item.slug === slug);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user