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:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 > 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user