feat(settings): sidebar layout with profile, security, AI, notifications, nutrition

Sticky sidebar nav. Sections: Profile (name/language), Security (email/password change),
AI & Models (BYOK keys + per-use-case model prefs), Notifications (push subscribe),
Nutrition goals. Sub-pages: API keys, Webhooks.
This commit is contained in:
Arnaud
2026-07-01 08:10:51 +02:00
parent e179692adf
commit b2d592afe8
17 changed files with 1491 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, userAiKeys, userModelPrefs, eq } from "@epicure/db";
import { ByokManager } from "@/components/settings/byok-manager";
import { ModelPrefsForm } from "@/components/settings/model-prefs-form";
export const metadata: Metadata = { title: "AI & Models Settings" };
export default async function AiSettingsPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const [aiKeys, modelPrefs] = await Promise.all([
db.query.userAiKeys.findMany({
where: eq(userAiKeys.userId, session.user.id),
columns: { provider: true },
}),
db.query.userModelPrefs.findFirst({
where: eq(userModelPrefs.userId, session.user.id),
}),
]);
return (
<div className="space-y-8">
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">Your API Keys (BYOK)</h2>
<p className="text-sm text-muted-foreground mt-1">
Use your own API keys instead of the app&apos;s shared quota. Keys are encrypted at rest.
</p>
</div>
<ByokManager initialKeys={aiKeys.map((k) => k.provider)} />
</section>
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">Model Preferences</h2>
<p className="text-sm text-muted-foreground mt-1">
Choose which model to use for each task. Defaults to your first configured provider.
</p>
</div>
<ModelPrefsForm initialPrefs={modelPrefs ?? null} />
</section>
</div>
);
}
@@ -0,0 +1,43 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, apiKeys, eq } from "@epicure/db";
import { ApiKeysManager } from "@/components/settings/api-keys-manager";
export const metadata: Metadata = { title: "API Keys" };
export default async function ApiKeysPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const keys = await db
.select({
id: apiKeys.id,
name: apiKeys.name,
lastUsedAt: apiKeys.lastUsedAt,
createdAt: apiKeys.createdAt,
})
.from(apiKeys)
.where(eq(apiKeys.userId, session.user.id));
return (
<div className="space-y-8">
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">API Keys</h2>
<p className="text-sm text-muted-foreground mt-1">
Manage API keys for programmatic access to the Epicure API.
</p>
</div>
<ApiKeysManager
initialKeys={keys.map((k) => ({
id: k.id,
name: k.name,
lastUsedAt: k.lastUsedAt ? k.lastUsedAt.toISOString() : null,
createdAt: k.createdAt.toISOString(),
}))}
/>
</section>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { SettingsSidebar } from "@/components/settings/settings-sidebar";
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="max-w-5xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
<p className="text-muted-foreground mt-1">Manage your account, preferences, and integrations.</p>
</div>
<div className="flex gap-8 items-start">
<SettingsSidebar />
<main className="flex-1 min-w-0 space-y-6">{children}</main>
</div>
</div>
);
}
@@ -0,0 +1,20 @@
import type { Metadata } from "next";
import { PushSubscribeButton } from "@/components/pwa/push-subscribe-button";
export const metadata: Metadata = { title: "Notifications Settings" };
export default function NotificationsPage() {
return (
<div className="space-y-8">
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">Push Notifications</h2>
<p className="text-sm text-muted-foreground mt-1">
Get notified when someone comments on your recipes or likes your content.
</p>
</div>
<PushSubscribeButton />
</section>
</div>
);
}
@@ -0,0 +1,41 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, userNutritionGoals, eq } from "@epicure/db";
import { NutritionGoalsForm } from "@/components/nutrition/nutrition-goals-form";
export const metadata: Metadata = { title: "Nutrition Settings" };
export default async function NutritionPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const goals = await db.query.userNutritionGoals.findFirst({
where: eq(userNutritionGoals.userId, session.user.id),
});
return (
<div className="space-y-8">
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">Daily Nutrition Goals</h2>
<p className="text-sm text-muted-foreground mt-1">
Set your daily targets. These are shown as progress bars on your meal plan.
</p>
</div>
<NutritionGoalsForm
initialGoals={
goals
? {
caloriesKcal: goals.caloriesKcal,
proteinG: goals.proteinG,
carbsG: goals.carbsG,
fatG: goals.fatG,
}
: null
}
/>
</section>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { SettingsForm } from "@/components/settings/settings-form";
export const metadata: Metadata = { title: "Profile Settings" };
export default async function SettingsPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
return (
<SettingsForm
user={{
name: session.user.name,
email: session.user.email,
image: session.user.image ?? null,
locale: (session.user as { locale?: string }).locale ?? "en",
}}
/>
);
}
@@ -0,0 +1,13 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { SecurityForm } from "@/components/settings/security-form";
export const metadata: Metadata = { title: "Security Settings" };
export default async function SecurityPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
return <SecurityForm currentEmail={session.user.email} />;
}
@@ -0,0 +1,142 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Webhook Docs — Epicure",
};
export default function WebhookDocsPage() {
return (
<div className="max-w-3xl mx-auto space-y-10 py-8">
<div>
<h1 className="text-2xl font-bold mb-2">Webhook Documentation</h1>
<p className="text-muted-foreground">
Learn how to integrate Epicure webhooks with your own tools, Zapier, and Make.
</p>
</div>
{/* Section 1: Webhook Events */}
<section>
<h2 className="text-xl font-semibold mb-4">Webhook Events</h2>
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-muted">
<th className="text-left p-3 border border-border font-medium">Event</th>
<th className="text-left p-3 border border-border font-medium">Description</th>
<th className="text-left p-3 border border-border font-medium">Payload Fields</th>
</tr>
</thead>
<tbody>
<tr>
<td className="p-3 border border-border font-mono text-sm">recipe.created</td>
<td className="p-3 border border-border text-sm">Fired when a recipe is created</td>
<td className="p-3 border border-border font-mono text-sm text-muted-foreground">id, title, authorId</td>
</tr>
<tr>
<td className="p-3 border border-border font-mono text-sm">recipe.updated</td>
<td className="p-3 border border-border text-sm">Fired when a recipe is updated</td>
<td className="p-3 border border-border font-mono text-sm text-muted-foreground">id, title, authorId</td>
</tr>
<tr>
<td className="p-3 border border-border font-mono text-sm">recipe.published</td>
<td className="p-3 border border-border text-sm">Fired when recipe visibility becomes public</td>
<td className="p-3 border border-border font-mono text-sm text-muted-foreground">id, title, authorId</td>
</tr>
<tr>
<td className="p-3 border border-border font-mono text-sm">recipe.deleted</td>
<td className="p-3 border border-border text-sm">Fired when a recipe is deleted</td>
<td className="p-3 border border-border font-mono text-sm text-muted-foreground">id, title</td>
</tr>
<tr>
<td className="p-3 border border-border font-mono text-sm">meal_plan.updated</td>
<td className="p-3 border border-border text-sm">Fired when a meal plan entry changes</td>
<td className="p-3 border border-border font-mono text-sm text-muted-foreground">weekStart, day, mealType</td>
</tr>
<tr>
<td className="p-3 border border-border font-mono text-sm">shopping_list.completed</td>
<td className="p-3 border border-border text-sm">Fired when shopping list marked complete</td>
<td className="p-3 border border-border font-mono text-sm text-muted-foreground">id, name</td>
</tr>
<tr>
<td className="p-3 border border-border font-mono text-sm">comment.added</td>
<td className="p-3 border border-border text-sm">Fired when a comment is added to your recipe</td>
<td className="p-3 border border-border font-mono text-sm text-muted-foreground">commentId, recipeId, recipeTitle</td>
</tr>
</tbody>
</table>
</div>
</section>
{/* Section 2: Payload Format */}
<section>
<h2 className="text-xl font-semibold mb-4">Payload Format</h2>
<p className="text-sm text-muted-foreground mb-3">
Every webhook request is a POST with a JSON body in the following shape:
</p>
<pre className="bg-muted rounded-lg p-4 overflow-x-auto font-mono text-sm whitespace-pre">{`{
"event": "recipe.created",
"payload": { "id": "...", "title": "Pasta Carbonara", "authorId": "..." },
"timestamp": "2024-01-15T10:30:00.000Z"
}`}</pre>
</section>
{/* Section 3: Signature Verification */}
<section>
<h2 className="text-xl font-semibold mb-4">Signature Verification</h2>
<p className="text-sm text-muted-foreground mb-4">
Every request includes an{" "}
<code className="font-mono bg-muted px-1.5 py-0.5 rounded text-xs">X-Epicure-Signature</code>{" "}
header containing an HMAC-SHA256 digest of the raw request body, signed with your webhook
secret. Always verify this signature before processing the payload.
</p>
<p className="text-sm font-medium mb-2">TypeScript / Node.js</p>
<pre className="bg-muted rounded-lg p-4 overflow-x-auto font-mono text-sm whitespace-pre mb-4">{`import crypto from "crypto";
function verify(body: string, secret: string, signature: string): boolean {
const expected = crypto.createHmac("sha256", secret).update(body).digest("hex");
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}`}</pre>
<p className="text-sm font-medium mb-2">Python</p>
<pre className="bg-muted rounded-lg p-4 overflow-x-auto font-mono text-sm whitespace-pre">{`import hmac, hashlib
def verify(body: bytes, secret: str, signature: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)`}</pre>
</section>
{/* Section 4: Zapier Integration */}
<section>
<h2 className="text-xl font-semibold mb-4">Zapier Integration</h2>
<ol className="list-decimal pl-6 space-y-2 text-sm mb-6">
<li>Go to <span className="font-medium">zapier.com</span> and create a new Zap.</li>
<li>Choose <span className="font-medium">Webhooks by Zapier</span> as the trigger and select <span className="font-medium">Catch Hook</span>.</li>
<li>Copy the webhook URL provided by Zapier.</li>
<li>Go to <span className="font-medium">Epicure Settings Webhooks</span> and add that URL.</li>
<li>Test the trigger in Zapier to confirm it receives the payload.</li>
</ol>
<h3 className="text-base font-semibold mb-3">Example workflows</h3>
<ul className="list-disc pl-6 space-y-2 text-sm text-muted-foreground">
<li><span className="font-medium text-foreground">New Recipe Published</span> Add row to Notion database</li>
<li><span className="font-medium text-foreground">Shopping List Completed</span> Send Slack message to #groceries</li>
<li><span className="font-medium text-foreground">Comment Added</span> Send email notification via Gmail</li>
</ul>
</section>
{/* Section 5: Make (Integromat) */}
<section>
<h2 className="text-xl font-semibold mb-4">Make (Integromat)</h2>
<ol className="list-decimal pl-6 space-y-2 text-sm">
<li>Go to <span className="font-medium">make.com</span> and create a new scenario.</li>
<li>Add an <span className="font-medium">HTTP &gt; Watch for Incoming Webhooks</span> module as the trigger.</li>
<li>Copy the generated webhook URL from the module settings.</li>
<li>Go to <span className="font-medium">Epicure Settings Webhooks</span> and paste that URL.</li>
<li>Run the scenario and trigger a test event in Epicure to verify the connection.</li>
<li>Add downstream modules (Notion, Slack, Gmail, etc.) to act on the incoming data.</li>
</ol>
</section>
</div>
);
}
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, webhooks, eq } from "@epicure/db";
import { WebhooksManager } from "@/components/settings/webhooks-manager";
export const metadata: Metadata = { title: "Webhooks" };
export default async function WebhooksPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const rows = await db
.select({
id: webhooks.id,
url: webhooks.url,
events: webhooks.events,
active: webhooks.active,
createdAt: webhooks.createdAt,
})
.from(webhooks)
.where(eq(webhooks.userId, session.user.id));
return (
<div className="space-y-8">
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">Webhooks</h2>
<p className="text-sm text-muted-foreground mt-1">
Receive HTTP callbacks when events happen in your Epicure account.
</p>
</div>
<WebhooksManager
initialWebhooks={rows.map((w) => ({
id: w.id,
url: w.url,
events: w.events,
active: w.active,
createdAt: w.createdAt.toISOString(),
}))}
/>
</section>
</div>
);
}