feat(web): Next.js 15 app shell, auth, i18n, base UI

App Router setup with next-intl (en/fr), Better Auth wiring, shadcn/ui components,
Tailwind, AES-256-GCM encrypt util, Redis rate limiter, tier limit checker,
site_settings helper for runtime .env overrides.
This commit is contained in:
Arnaud
2026-07-01 08:09:31 +02:00
parent add9365250
commit 5b40968c9d
50 changed files with 4274 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
import crypto from "node:crypto";
import { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth/server";
import { db, apiKeys, users, eq } from "@epicure/db";
import { applyRateLimit } from "@/lib/rate-limit";
export async function requireSession() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
return { session: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
}
return { session, response: null };
}
export async function requireAdmin() {
const { session, response } = await requireSession();
if (response) return { session: null, response };
if (session!.user.role !== "admin") {
return { session: null, response: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
}
return { session, response: null };
}
type SessionLike = {
user: {
id: string;
email: string;
name: string;
tier: string;
role?: string;
image?: string | null;
};
};
type RateLimitOpts = { limit: number; windowSeconds: number };
export async function requireSessionOrApiKey(
req: NextRequest,
opts?: { rateLimit?: RateLimitOpts }
): Promise<{ session: SessionLike; response: null } | { session: null; response: NextResponse }> {
// 1. Try Bearer API key
const authHeader = req.headers.get("authorization");
if (authHeader?.startsWith("Bearer ")) {
const rawKey = authHeader.slice(7).trim();
if (rawKey.startsWith("ek_")) {
const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
const [keyRow] = await db
.select({ id: apiKeys.id, userId: apiKeys.userId })
.from(apiKeys)
.where(eq(apiKeys.keyHash, keyHash))
.limit(1);
if (keyRow) {
// Update lastUsedAt asynchronously — don't block response
void db
.update(apiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(apiKeys.id, keyRow.id));
const [user] = await db
.select({
id: users.id,
email: users.email,
name: users.name,
tier: users.tier,
role: users.role,
})
.from(users)
.where(eq(users.id, keyRow.userId))
.limit(1);
if (user) {
// Apply rate limit when requested (API key path only)
if (opts?.rateLimit) {
const { limit, windowSeconds } = opts.rateLimit;
const rateLimitResponse = await applyRateLimit(
`rl:api:${user.id}`,
limit,
windowSeconds
);
if (rateLimitResponse) {
return { session: null, response: rateLimitResponse };
}
}
return {
session: { user: { ...user, image: null } },
response: null,
};
}
}
return {
session: null,
response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
};
}
}
// 2. Fall back to session cookie
return requireSession();
}
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient();
export const {
signIn,
signUp,
signOut,
useSession,
getSession,
} = authClient;
+113
View File
@@ -0,0 +1,113 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db, users, sessions, accounts, verifications, eq, count } from "@epicure/db";
import { sendEmail, verifyEmailHtml, resetPasswordHtml, welcomeHtml } from "@/lib/email";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
schema: { user: users, session: sessions, account: accounts, verification: verifications },
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendResetPassword: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: "Reset your Epicure password",
html: resetPasswordHtml(url),
});
},
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: "Verify your Epicure email",
html: verifyEmailHtml(url),
});
},
},
socialProviders: {
google: {
clientId: process.env["GOOGLE_CLIENT_ID"] ?? "",
clientSecret: process.env["GOOGLE_CLIENT_SECRET"] ?? "",
},
},
session: {
cookieCache: {
enabled: true,
maxAge: 60 * 5,
},
},
databaseHooks: {
user: {
create: {
after: async (user) => {
// First registered user becomes admin
const result = await db.select({ total: count() }).from(users);
if ((result[0]?.total ?? 0) === 1) {
await db.update(users).set({ role: "admin" }).where(eq(users.id, user.id));
}
// Welcome email (fire and forget)
sendEmail({
to: user.email,
subject: "Welcome to Epicure",
html: welcomeHtml(user.name),
}).catch(() => {});
},
},
},
},
user: {
additionalFields: {
role: {
type: "string",
defaultValue: "user",
input: false,
},
tier: {
type: "string",
defaultValue: "free",
input: false,
},
username: {
type: "string",
required: false,
},
bio: {
type: "string",
required: false,
},
unitPref: {
type: "string",
defaultValue: "metric",
},
locale: {
type: "string",
defaultValue: "en",
},
},
changeEmail: {
enabled: true,
sendChangeEmailVerification: async ({ newEmail, url }) => {
await sendEmail({
to: newEmail,
subject: "Verify your new Epicure email",
html: verifyEmailHtml(url),
});
},
},
},
});
export type Session = typeof auth.$Infer.Session;
export type User = typeof auth.$Infer.Session.user;
+30
View File
@@ -0,0 +1,30 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash } from "crypto";
const ALGORITHM = "aes-256-gcm";
function getKey(): Buffer {
const secret = process.env["BETTER_AUTH_SECRET"] ?? "dev-secret-needs-32-bytes-padding!";
return createHash("sha256").update(secret).digest();
}
export function encrypt(plaintext: string): string {
const key = getKey();
const iv = randomBytes(12);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const authTag = (cipher as ReturnType<typeof createCipheriv> & { getAuthTag(): Buffer }).getAuthTag();
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
}
export function decrypt(ciphertext: string): string {
const parts = ciphertext.split(":");
if (parts.length !== 3) throw new Error("Invalid ciphertext format");
const [ivHex, authTagHex, encryptedHex] = parts as [string, string, string];
const key = getKey();
const iv = Buffer.from(ivHex, "hex");
const authTag = Buffer.from(authTagHex, "hex");
const encrypted = Buffer.from(encryptedHex, "hex");
const decipher = createDecipheriv(ALGORITHM, key, iv);
(decipher as ReturnType<typeof createDecipheriv> & { setAuthTag(tag: Buffer): void }).setAuthTag(authTag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { NextIntlClientProvider } from "next-intl";
export const SUPPORTED_LOCALES = [
{ code: "en", label: "English" },
{ code: "fr", label: "Français" },
] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number]["code"];
const STORAGE_KEY = "epicure-locale";
const LocaleContext = createContext<{
locale: Locale;
setLocale: (locale: Locale) => void;
}>({ locale: "en", setLocale: () => {} });
export function useLocale() {
return useContext(LocaleContext);
}
export function I18nProvider({
children,
messages,
initialLocale,
}: {
children: React.ReactNode;
messages: Record<string, Record<string, unknown>>;
initialLocale?: Locale;
}) {
const resolved = initialLocale ?? ("en" as Locale);
const [locale, setLocaleState] = useState<Locale>(resolved);
const [currentMessages, setCurrentMessages] = useState(messages[resolved] ?? messages["en"]!);
useEffect(() => {
// If no server-side locale, fall back to localStorage
if (!initialLocale) {
const stored = localStorage.getItem(STORAGE_KEY) as Locale | null;
if (stored && messages[stored]) {
setLocaleState(stored);
setCurrentMessages(messages[stored]!);
}
}
}, [messages, initialLocale]);
function setLocale(next: Locale) {
setLocaleState(next);
setCurrentMessages(messages[next]!);
localStorage.setItem(STORAGE_KEY, next);
fetch("/api/v1/users/me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ locale: next }),
}).catch(() => {});
}
return (
<LocaleContext.Provider value={{ locale, setLocale }}>
<NextIntlClientProvider locale={locale} messages={currentMessages} timeZone="UTC">
{children}
</NextIntlClientProvider>
</LocaleContext.Provider>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { getRedis } from "./redis";
import { NextResponse } from "next/server";
export async function rateLimit(
key: string,
limit: number,
windowSeconds: number
): Promise<{ ok: boolean; remaining: number; reset: number }> {
const redis = getRedis();
const now = Date.now();
const windowStart = now - windowSeconds * 1000;
const reset = Math.ceil((now + windowSeconds * 1000) / 1000);
const pipeline = redis.pipeline();
pipeline.zremrangebyscore(key, "-inf", windowStart);
pipeline.zadd(key, now, `${now}-${Math.random()}`);
pipeline.zcard(key);
pipeline.expire(key, windowSeconds);
const results = await pipeline.exec();
const count = (results?.[2]?.[1] as number) ?? 0;
const remaining = Math.max(0, limit - count);
const ok = count <= limit;
return { ok, remaining, reset };
}
export async function applyRateLimit(
key: string,
limit: number,
windowSeconds: number
): Promise<NextResponse | null> {
const { ok, remaining, reset } = await rateLimit(key, limit, windowSeconds);
if (!ok) {
return NextResponse.json(
{ error: "Too many requests", retryAfter: reset },
{
status: 429,
headers: {
"X-RateLimit-Limit": String(limit),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": String(reset),
"Retry-After": String(reset - Math.floor(Date.now() / 1000)),
},
}
);
}
return null;
}
+90
View File
@@ -0,0 +1,90 @@
import { db, siteSettings, eq } from "@epicure/db";
import { encrypt, decrypt } from "@/lib/encrypt";
export type SiteSettingKey =
| "OPENAI_API_KEY"
| "ANTHROPIC_API_KEY"
| "OPENROUTER_API_KEY"
| "OPENROUTER_DEFAULT_MODEL"
| "OLLAMA_BASE_URL"
| "NEXT_PUBLIC_VAPID_PUBLIC_KEY"
| "VAPID_PRIVATE_KEY";
const SECRET_KEYS: SiteSettingKey[] = [
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"OPENROUTER_API_KEY",
"VAPID_PRIVATE_KEY",
];
export function isSecretKey(key: SiteSettingKey): boolean {
return SECRET_KEYS.includes(key);
}
export async function getSiteSetting(key: SiteSettingKey): Promise<string | null> {
try {
const row = await db.query.siteSettings.findFirst({ where: eq(siteSettings.key, key) });
if (row?.value) {
return row.isSecret ? decrypt(row.value) : row.value;
}
} catch {
// fall through to env
}
return process.env[key] ?? null;
}
export async function getAllSiteSettings(): Promise<Record<string, { value: string | null; isSecret: boolean; fromDb: boolean }>> {
const rows = await db.query.siteSettings.findMany();
const dbMap = new Map(rows.map((r) => [r.key, r]));
const KNOWN_KEYS: SiteSettingKey[] = [
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"OPENROUTER_API_KEY",
"OPENROUTER_DEFAULT_MODEL",
"OLLAMA_BASE_URL",
"NEXT_PUBLIC_VAPID_PUBLIC_KEY",
"VAPID_PRIVATE_KEY",
];
const result: Record<string, { value: string | null; isSecret: boolean; fromDb: boolean }> = {};
for (const k of KNOWN_KEYS) {
const row = dbMap.get(k);
const secret = isSecretKey(k);
if (row) {
result[k] = {
value: secret ? "••••••••••••" : (row.value ?? null),
isSecret: secret,
fromDb: true,
};
} else {
const envVal = process.env[k];
result[k] = {
value: envVal ? (secret ? "••••••••••••" : envVal) : null,
isSecret: secret,
fromDb: false,
};
}
}
return result;
}
export async function setSiteSetting(
key: SiteSettingKey,
value: string | null,
updatedById: string
): Promise<void> {
if (value === null || value === "") {
await db.delete(siteSettings).where(eq(siteSettings.key, key));
return;
}
const secret = isSecretKey(key);
const stored = secret ? encrypt(value) : value;
await db
.insert(siteSettings)
.values({ key, value: stored, isSecret: secret, updatedById, updatedAt: new Date() })
.onConflictDoUpdate({
target: siteSettings.key,
set: { value: stored, updatedAt: new Date(), updatedById },
});
}
+82
View File
@@ -0,0 +1,82 @@
import { db } from "@epicure/db";
import { tierDefinitions, userUsage } from "@epicure/db";
import { eq, and, sql } from "@epicure/db";
function currentMonth() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
export type LimitKey = "recipe" | "aiCall" | "storage";
export class TierLimitError extends Error {
constructor(public readonly limit: LimitKey, public readonly tier: string) {
super(`Tier limit reached: ${limit} (tier: ${tier})`);
this.name = "TierLimitError";
}
}
export async function checkTierLimit(
userId: string,
userTier: "free" | "pro",
key: LimitKey
): Promise<void> {
const [tierDef] = await db
.select()
.from(tierDefinitions)
.where(eq(tierDefinitions.tier, userTier));
if (!tierDef) return;
const month = currentMonth();
const [usage] = await db
.select()
.from(userUsage)
.where(and(eq(userUsage.userId, userId), eq(userUsage.month, month)));
const current = usage ?? {
aiCallsUsed: 0,
recipeCount: 0,
storageUsedMb: 0,
};
if (key === "recipe" && current.recipeCount >= tierDef.maxRecipes) {
throw new TierLimitError("recipe", userTier);
}
if (key === "aiCall" && current.aiCallsUsed >= tierDef.aiCallsPerMonth) {
throw new TierLimitError("aiCall", userTier);
}
}
export async function incrementUsage(
userId: string,
key: LimitKey,
amount = 1
): Promise<void> {
const month = currentMonth();
const id = `${userId}-${month}`;
const initialValues = {
id,
userId,
month,
aiCallsUsed: key === "aiCall" ? amount : 0,
recipeCount: key === "recipe" ? amount : 0,
storageUsedMb: key === "storage" ? amount : 0,
};
const incrementSet =
key === "aiCall"
? { aiCallsUsed: sql`${userUsage.aiCallsUsed} + ${amount}` }
: key === "recipe"
? { recipeCount: sql`${userUsage.recipeCount} + ${amount}` }
: { storageUsedMb: sql`${userUsage.storageUsedMb} + ${amount}` };
await db
.insert(userUsage)
.values(initialValues)
.onConflictDoUpdate({
target: [userUsage.userId, userUsage.month],
set: incrementSet,
});
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}