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>
);
}
@@ -0,0 +1,172 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
type ApiKey = {
id: string;
name: string;
lastUsedAt: string | null;
createdAt: string;
};
type CreateApiKeyResponse = {
id: string;
name: string;
key: string;
createdAt: string;
};
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
export function ApiKeysManager({ initialKeys }: { initialKeys: ApiKey[] }) {
const [keys, setKeys] = useState<ApiKey[]>(initialKeys);
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
const [newKey, setNewKey] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) return;
setCreating(true);
try {
const res = await fetch("/api/v1/api-keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim() }),
});
if (!res.ok) {
const data = await res.json() as { error?: string };
throw new Error(data.error ?? "Failed to create API key");
}
const data = await res.json() as CreateApiKeyResponse;
setNewKey(data.key);
setKeys((prev) => [
{ id: data.id, name: data.name, lastUsedAt: null, createdAt: data.createdAt },
...prev,
]);
setName("");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to create API key");
} finally {
setCreating(false);
}
}
async function handleRevoke(id: string) {
try {
const res = await fetch(`/api/v1/api-keys/${id}`, { method: "DELETE" });
if (!res.ok) {
const data = await res.json() as { error?: string };
throw new Error(data.error ?? "Failed to revoke API key");
}
setKeys((prev) => prev.filter((k) => k.id !== id));
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to revoke API key");
}
}
async function handleCopy() {
if (!newKey) return;
try {
await navigator.clipboard.writeText(newKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Failed to copy to clipboard");
}
}
return (
<div className="space-y-8">
{/* Create form */}
<form onSubmit={handleCreate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="key-name">Key name</Label>
<div className="flex gap-2">
<Input
id="key-name"
placeholder="e.g. My app"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={100}
required
/>
<Button type="submit" disabled={creating || !name.trim()}>
{creating ? "Creating…" : "Create"}
</Button>
</div>
</div>
</form>
{/* New key reveal */}
{newKey && (
<div className="rounded-md border border-yellow-400 bg-yellow-50 p-4 space-y-3 dark:bg-yellow-950 dark:border-yellow-700">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Save this key it will not be shown again.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 rounded bg-white dark:bg-black border px-3 py-2 text-sm font-mono break-all">
{newKey}
</code>
<Button type="button" variant="outline" onClick={handleCopy}>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setNewKey(null)}
className="text-muted-foreground"
>
Dismiss
</Button>
</div>
)}
{/* Keys list */}
{keys.length === 0 ? (
<p className="text-sm text-muted-foreground">No API keys yet.</p>
) : (
<div className="divide-y rounded-md border">
{keys.map((k) => (
<div key={k.id} className="flex items-center justify-between px-4 py-3 gap-4">
<div className="min-w-0 flex-1 space-y-1">
<p className="text-sm font-medium truncate">{k.name}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Created {formatDate(k.createdAt)}</span>
<span>·</span>
{k.lastUsedAt ? (
<span>Last used {formatDate(k.lastUsedAt)}</span>
) : (
<Badge variant="secondary" className="text-xs">Never used</Badge>
)}
</div>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => { void handleRevoke(k.id); }}
>
Revoke
</Button>
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,119 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Key, Trash2, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
type Provider = "openai" | "anthropic" | "openrouter" | "ollama";
const PROVIDERS: { id: Provider; label: string; placeholder: string }[] = [
{ id: "openai", label: "OpenAI", placeholder: "sk-..." },
{ id: "anthropic", label: "Anthropic", placeholder: "sk-ant-..." },
{ id: "openrouter", label: "OpenRouter", placeholder: "sk-or-..." },
{ id: "ollama", label: "Ollama (local)", placeholder: "Base URL uses env var" },
];
export function ByokManager({ initialKeys }: { initialKeys: string[] }) {
const [configuredKeys, setConfiguredKeys] = useState<Set<string>>(new Set(initialKeys));
const [inputs, setInputs] = useState<Partial<Record<Provider, string>>>({});
const [saving, setSaving] = useState<Provider | null>(null);
const [removing, setRemoving] = useState<Provider | null>(null);
async function saveKey(provider: Provider) {
const apiKey = inputs[provider]?.trim();
if (!apiKey) return;
setSaving(provider);
try {
const res = await fetch("/api/v1/ai-keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, apiKey }),
});
if (res.ok) {
setConfiguredKeys((prev) => new Set([...prev, provider]));
setInputs((prev) => ({ ...prev, [provider]: "" }));
toast.success(`${provider} key saved`);
} else {
toast.error("Failed to save key");
}
} finally {
setSaving(null);
}
}
async function removeKey(provider: Provider) {
setRemoving(provider);
try {
const res = await fetch(`/api/v1/ai-keys/${provider}`, { method: "DELETE" });
if (res.ok) {
setConfiguredKeys((prev) => { const s = new Set(prev); s.delete(provider); return s; });
toast.success(`${provider} key removed`);
} else {
toast.error("Failed to remove key");
}
} finally {
setRemoving(null);
}
}
return (
<div className="space-y-4">
{PROVIDERS.map((p) => {
const hasKey = configuredKeys.has(p.id);
const isOllama = p.id === "ollama";
return (
<div key={p.id} className="flex items-center gap-3">
<div className="w-28 shrink-0 flex items-center gap-2">
<Key className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm font-medium">{p.label}</span>
</div>
{hasKey ? (
<div className="flex items-center gap-2 flex-1">
<Badge variant="secondary" className="gap-1">
<Check className="h-3 w-3" />
Configured
</Badge>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive h-7"
disabled={removing === p.id}
onClick={() => { void removeKey(p.id); }}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
) : isOllama ? (
<span className="text-sm text-muted-foreground">Set via OLLAMA_BASE_URL env var</span>
) : (
<div className="flex items-center gap-2 flex-1">
<Input
type="password"
placeholder={p.placeholder}
value={inputs[p.id] ?? ""}
onChange={(e) => setInputs((prev) => ({ ...prev, [p.id]: e.target.value }))}
className="h-8 text-sm"
onKeyDown={(e) => { if (e.key === "Enter") { void saveKey(p.id); } }}
/>
<Button
size="sm"
className="h-8"
disabled={saving === p.id || !inputs[p.id]?.trim()}
onClick={() => { void saveKey(p.id); }}
>
{saving === p.id ? "Saving…" : "Save"}
</Button>
</div>
)}
</div>
);
})}
<p className="text-xs text-muted-foreground">
Your keys are encrypted at rest. When set, they override the app&apos;s default keys for your account.
</p>
</div>
);
}
@@ -0,0 +1,169 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
type Provider = "openai" | "anthropic" | "openrouter" | "ollama" | "";
type UseCase = {
key: "text" | "vision" | "mealPlan";
label: string;
description: string;
};
const USE_CASES: UseCase[] = [
{ key: "text", label: "Text generation", description: "Recipe generation, variations, translations, adapt" },
{ key: "vision", label: "Vision / photo import", description: "Dish recognition, recipe import from photo" },
{ key: "mealPlan", label: "Meal planning", description: "Weekly meal plan generation" },
];
const PRESET_MODELS: Record<string, { label: string; models: { value: string; label: string }[] }> = {
openai: {
label: "OpenAI",
models: [
{ value: "gpt-4o", label: "GPT-4o" },
{ value: "gpt-4o-mini", label: "GPT-4o mini (faster)" },
{ value: "o3-mini", label: "o3-mini (reasoning)" },
{ value: "gpt-4-turbo", label: "GPT-4 Turbo" },
],
},
anthropic: {
label: "Anthropic",
models: [
{ value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
{ value: "claude-opus-4-8", label: "Claude Opus 4.8 (most capable)" },
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (fastest)" },
],
},
};
type Prefs = {
textProvider?: string | null;
textModel?: string | null;
visionProvider?: string | null;
visionModel?: string | null;
mealPlanProvider?: string | null;
mealPlanModel?: string | null;
};
export function ModelPrefsForm({ initialPrefs }: { initialPrefs: Prefs | null }) {
const [prefs, setPrefs] = useState<Prefs>(initialPrefs ?? {});
const [saving, setSaving] = useState(false);
function setField(field: keyof Prefs, value: string | null) {
setPrefs((prev) => ({ ...prev, [field]: value || null }));
}
async function handleSave() {
setSaving(true);
try {
const res = await fetch("/api/v1/users/me/model-prefs", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(prefs),
});
if (!res.ok) throw new Error("Save failed");
toast.success("Model preferences saved");
} catch {
toast.error("Failed to save");
} finally {
setSaving(false);
}
}
return (
<div className="space-y-6">
{USE_CASES.map(({ key, label, description }) => {
const providerKey = `${key}Provider` as keyof Prefs;
const modelKey = `${key}Model` as keyof Prefs;
const provider = (prefs[providerKey] ?? "") as Provider;
const model = (prefs[modelKey] ?? "") as string;
const presets = provider && PRESET_MODELS[provider]?.models;
return (
<div key={key} className="rounded-lg border p-4 space-y-3">
<div>
<p className="font-medium text-sm">{label}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Provider</Label>
<Select
value={provider || "default"}
onValueChange={(v) => {
const val = v === "default" ? null : v;
setField(providerKey, val);
setField(modelKey, null);
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
<span className="text-muted-foreground">Default (auto)</span>
</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="openrouter">OpenRouter</SelectItem>
<SelectItem value="ollama">Ollama (local)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Model</Label>
{presets ? (
<Select
value={model || "default"}
onValueChange={(v) => setField(modelKey, v === "default" ? null : v)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Default" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
<span className="text-muted-foreground">Default</span>
</SelectItem>
<SelectGroup>
<SelectLabel>{PRESET_MODELS[provider]?.label}</SelectLabel>
{presets.map((m) => (
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : (
<Input
className="h-8 text-sm"
placeholder={provider ? "e.g. llama3.2" : "—"}
value={model}
onChange={(e) => setField(modelKey, e.target.value)}
disabled={!provider}
/>
)}
</div>
</div>
</div>
);
})}
<Button onClick={() => { void handleSave(); }} disabled={saving} size="sm">
{saving ? "Saving…" : "Save model preferences"}
</Button>
</div>
);
}
@@ -0,0 +1,143 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "next-intl";
import { authClient } from "@/lib/auth/client";
export function SecurityForm({ currentEmail }: { currentEmail: string }) {
const t = useTranslations("settingsForm");
const [newEmail, setNewEmail] = useState("");
const [sendingEmail, setSendingEmail] = useState(false);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [changingPassword, setChangingPassword] = useState(false);
async function handleChangeEmail(e: React.FormEvent) {
e.preventDefault();
if (!newEmail.trim()) return;
setSendingEmail(true);
try {
const { error } = await authClient.changeEmail({
newEmail: newEmail.trim(),
callbackURL: "/settings",
});
if (error) {
toast.error(error.message ?? t("emailChangeFailed"));
} else {
toast.success(t("emailChangeSent"));
setNewEmail("");
}
} finally {
setSendingEmail(false);
}
}
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
if (newPassword !== confirmPassword) {
toast.error(t("passwordMismatch"));
return;
}
setChangingPassword(true);
try {
const { error } = await authClient.changePassword({
currentPassword,
newPassword,
revokeOtherSessions: false,
});
if (error) {
toast.error(error.message ?? t("passwordChangeFailed"));
} else {
toast.success(t("passwordChanged"));
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
}
} finally {
setChangingPassword(false);
}
}
return (
<div className="space-y-6">
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">{t("changeEmail")}</h2>
<p className="text-sm text-muted-foreground mt-1">{t("changeEmailDescription")}</p>
</div>
<form onSubmit={handleChangeEmail} className="space-y-3">
<div className="space-y-2">
<Label htmlFor="new-email">{t("newEmail")}</Label>
<Input
id="new-email"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder={currentEmail}
required
/>
</div>
<Button type="submit" disabled={sendingEmail || !newEmail.trim()}>
{sendingEmail ? t("sendingVerification") : t("sendVerification")}
</Button>
</form>
</section>
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">{t("changePassword")}</h2>
</div>
<form onSubmit={handleChangePassword} className="space-y-3">
<div className="space-y-2">
<Label htmlFor="current-password">{t("currentPassword")}</Label>
<Input
id="current-password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">{t("newPassword")}</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">{t("confirmPassword")}</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
required
/>
</div>
<Button
type="submit"
disabled={changingPassword || !currentPassword || !newPassword || !confirmPassword}
>
{changingPassword ? t("changingPassword") : t("changePasswordButton")}
</Button>
</form>
</section>
</div>
);
}
@@ -0,0 +1,77 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useTranslations } from "next-intl";
import { useLocale, SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/provider";
type UserProps = {
name: string;
email: string;
image: string | null;
locale: string;
};
export function SettingsForm({ user }: { user: UserProps }) {
const t = useTranslations("settingsForm");
const t_common = useTranslations("common");
const { setLocale } = useLocale();
const [name, setName] = useState(user.name);
const [saving, setSaving] = useState(false);
async function saveProfile() {
setSaving(true);
try {
const res = await fetch("/api/v1/users/me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (res.ok) toast.success(t_common("saved"));
else toast.error(t_common("saveFailed"));
} finally {
setSaving(false);
}
}
return (
<div className="space-y-6">
<section className="rounded-xl border p-6 space-y-4">
<h2 className="font-semibold text-lg">{t("profile")}</h2>
<div className="space-y-2">
<Label>{t("displayName")}</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>{t("email")}</Label>
<Input value={user.email} disabled className="opacity-70" />
</div>
<Button onClick={saveProfile} disabled={saving || name === user.name}>
{saving ? t("saving") : t_common("save")}
</Button>
</section>
<section className="rounded-xl border p-6 space-y-4">
<h2 className="font-semibold text-lg">{t("language")}</h2>
<p className="text-sm text-muted-foreground">{t("languageDescription")}</p>
<Select defaultValue={user.locale} onValueChange={(v) => setLocale(v as Locale)}>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPORTED_LOCALES.map((l) => (
<SelectItem key={l.code} value={l.code}>
{l.label}
</SelectItem>
))}
</SelectContent>
</Select>
</section>
</div>
);
}
@@ -0,0 +1,11 @@
"use client";
import { useTranslations } from "next-intl";
export function SettingsPageHeader() {
const t = useTranslations("settingsForm");
return (
<div>
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground mt-1">{t("subtitle")}</p>
</div>
);
}
@@ -0,0 +1,46 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { User, Shield, Bot, Bell, Apple, Key, Webhook } from "lucide-react";
import { cn } from "@/lib/utils";
const NAV_ITEMS = [
{ href: "/settings", label: "Profile", icon: User, exact: true },
{ href: "/settings/security", label: "Security", icon: Shield },
{ href: "/settings/ai", label: "AI & Models", icon: Bot },
{ href: "/settings/notifications", label: "Notifications", icon: Bell },
{ href: "/settings/nutrition", label: "Nutrition", icon: Apple },
{ href: "/settings/api-keys", label: "API Keys", icon: Key },
{ href: "/settings/webhooks", label: "Webhooks", icon: Webhook },
];
export function SettingsSidebar() {
const pathname = usePathname();
return (
<nav className="w-48 shrink-0 sticky top-6">
<ul className="space-y-0.5">
{NAV_ITEMS.map(({ href, label, icon: Icon, exact }) => {
const active = exact ? pathname === href : pathname.startsWith(href);
return (
<li key={href}>
<Link
href={href}
className={cn(
"flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-colors",
active
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
)}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
</li>
);
})}
</ul>
</nav>
);
}
@@ -0,0 +1,365 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { toast } from "sonner";
import { ChevronDown, ChevronUp, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
const ALL_EVENTS = [
"recipe.created",
"recipe.updated",
"recipe.published",
"recipe.deleted",
"meal_plan.updated",
"shopping_list.completed",
"comment.added",
] as const;
type WebhookEventType = (typeof ALL_EVENTS)[number];
type Webhook = {
id: string;
url: string;
events: string[];
active: boolean;
createdAt: string;
};
type Delivery = {
id: string;
event: string;
statusCode: number | null;
success: boolean;
attempts: number;
createdAt: string;
};
type CreateWebhookResponse = {
id: string;
url: string;
events: string[];
secret: string;
active: boolean;
createdAt: string;
};
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
function formatDateTime(iso: string) {
return new Date(iso).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export function WebhooksManager({ initialWebhooks }: { initialWebhooks: Webhook[] }) {
const [webhookList, setWebhookList] = useState<Webhook[]>(initialWebhooks);
const [url, setUrl] = useState("");
const [selectedEvents, setSelectedEvents] = useState<WebhookEventType[]>([]);
const [creating, setCreating] = useState(false);
const [newSecret, setNewSecret] = useState<{ id: string; secret: string } | null>(null);
const [copied, setCopied] = useState(false);
const [deliveries, setDeliveries] = useState<Record<string, Delivery[]>>({});
const [loadingDeliveries, setLoadingDeliveries] = useState<string | null>(null);
const [expandedDeliveries, setExpandedDeliveries] = useState<Set<string>>(new Set());
const [redelivering, setRedelivering] = useState<string | null>(null);
function toggleEvent(event: WebhookEventType) {
setSelectedEvents((prev) =>
prev.includes(event) ? prev.filter((e) => e !== event) : [...prev, event]
);
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!url.trim()) return;
setCreating(true);
try {
const res = await fetch("/api/v1/webhooks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: url.trim(), events: selectedEvents }),
});
if (!res.ok) {
const data = await res.json() as { error?: string };
throw new Error(data.error ?? "Failed to create webhook");
}
const data = await res.json() as CreateWebhookResponse;
setNewSecret({ id: data.id, secret: data.secret });
setWebhookList((prev) => [
{ id: data.id, url: data.url, events: data.events, active: data.active, createdAt: data.createdAt },
...prev,
]);
setUrl("");
setSelectedEvents([]);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to create webhook");
} finally {
setCreating(false);
}
}
async function handleDelete(id: string) {
try {
const res = await fetch(`/api/v1/webhooks/${id}`, { method: "DELETE" });
if (!res.ok && res.status !== 204) {
const data = await res.json() as { error?: string };
throw new Error(data.error ?? "Failed to delete webhook");
}
setWebhookList((prev) => prev.filter((w) => w.id !== id));
if (newSecret?.id === id) setNewSecret(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to delete webhook");
}
}
async function handleToggleActive(id: string, currentActive: boolean) {
try {
const res = await fetch(`/api/v1/webhooks/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active: !currentActive }),
});
if (!res.ok) {
const data = await res.json() as { error?: string };
throw new Error(data.error ?? "Failed to update webhook");
}
setWebhookList((prev) =>
prev.map((w) => (w.id === id ? { ...w, active: !currentActive } : w))
);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to update webhook");
}
}
async function handleCopySecret() {
if (!newSecret) return;
try {
await navigator.clipboard.writeText(newSecret.secret);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Failed to copy to clipboard");
}
}
async function toggleDeliveries(webhookId: string) {
const isExpanded = expandedDeliveries.has(webhookId);
if (isExpanded) {
setExpandedDeliveries((prev) => { const s = new Set(prev); s.delete(webhookId); return s; });
return;
}
setExpandedDeliveries((prev) => new Set([...prev, webhookId]));
if (deliveries[webhookId]) return;
setLoadingDeliveries(webhookId);
try {
const res = await fetch(`/api/v1/webhooks/${webhookId}/deliveries`);
if (res.ok) {
const data = await res.json() as Delivery[];
setDeliveries((prev) => ({ ...prev, [webhookId]: data }));
}
} finally {
setLoadingDeliveries(null);
}
}
async function handleRedeliver(webhookId: string, deliveryId: string) {
setRedelivering(deliveryId);
try {
const res = await fetch(`/api/v1/webhooks/${webhookId}/redeliver`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deliveryId }),
});
if (res.ok) {
toast.success("Redelivery queued");
} else {
toast.error("Failed to redeliver");
}
} finally {
setRedelivering(null);
}
}
return (
<div className="space-y-8">
<div className="text-sm text-muted-foreground mb-4">
<Link href="/settings/webhooks/docs" className="text-primary hover:underline">
View webhook docs &amp; Zapier integration
</Link>
</div>
{/* Create form */}
<form onSubmit={handleCreate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="webhook-url">Endpoint URL</Label>
<Input
id="webhook-url"
type="url"
placeholder="https://example.com/webhook"
value={url}
onChange={(e) => setUrl(e.target.value)}
maxLength={2048}
required
/>
</div>
<div className="space-y-2">
<Label>Events</Label>
<p className="text-xs text-muted-foreground">
Select which events trigger this webhook. Leave all unchecked to receive all events.
</p>
<div className="flex flex-wrap gap-3">
{ALL_EVENTS.map((event) => {
const checked = selectedEvents.includes(event);
return (
<label key={event} className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
className="rounded"
checked={checked}
onChange={() => toggleEvent(event)}
/>
<span className="text-sm font-mono">{event}</span>
</label>
);
})}
</div>
</div>
<Button type="submit" disabled={creating || !url.trim()}>
{creating ? "Adding…" : "Add webhook"}
</Button>
</form>
{/* New secret reveal */}
{newSecret && (
<div className="rounded-md border border-yellow-400 bg-yellow-50 p-4 space-y-3 dark:bg-yellow-950 dark:border-yellow-700">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Save this signing secret it will not be shown again.
</p>
<p className="text-xs text-yellow-700 dark:text-yellow-300">
Use it to verify the <code className="font-mono">X-Epicure-Signature</code> header on incoming requests.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 rounded bg-white dark:bg-black border px-3 py-2 text-sm font-mono break-all">
{newSecret.secret}
</code>
<Button type="button" variant="outline" onClick={() => { void handleCopySecret(); }}>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
<Button type="button" variant="ghost" size="sm" onClick={() => setNewSecret(null)} className="text-muted-foreground">
Dismiss
</Button>
</div>
)}
{/* Webhook list */}
{webhookList.length === 0 ? (
<p className="text-sm text-muted-foreground">No webhooks yet.</p>
) : (
<div className="divide-y rounded-md border">
{webhookList.map((w) => {
const isExpanded = expandedDeliveries.has(w.id);
const wDeliveries = deliveries[w.id] ?? [];
return (
<div key={w.id} className="px-4 py-3 space-y-2">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-1">
<p className="text-sm font-mono truncate">{w.url}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Added {formatDate(w.createdAt)}</span>
<span>·</span>
<Badge variant={w.active ? "default" : "secondary"} className="text-xs">
{w.active ? "Active" : "Inactive"}
</Badge>
</div>
{w.events.length > 0 && (
<div className="flex flex-wrap gap-1 pt-1">
{w.events.map((ev) => (
<Badge key={ev} variant="outline" className="text-xs font-mono">{ev}</Badge>
))}
</div>
)}
{w.events.length === 0 && (
<p className="text-xs text-muted-foreground pt-1">All events</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => { void toggleDeliveries(w.id); }}
className="text-muted-foreground gap-1"
>
{isExpanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
Deliveries
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => { void handleToggleActive(w.id, w.active); }}>
{w.active ? "Disable" : "Enable"}
</Button>
<Button type="button" variant="destructive" size="sm" onClick={() => { void handleDelete(w.id); }}>
Delete
</Button>
</div>
</div>
{/* Delivery history */}
{isExpanded && (
<div className="mt-2 rounded-md border bg-muted/30">
{loadingDeliveries === w.id ? (
<p className="text-xs text-muted-foreground px-3 py-2">Loading</p>
) : wDeliveries.length === 0 ? (
<p className="text-xs text-muted-foreground px-3 py-2">No deliveries yet.</p>
) : (
<div className="divide-y">
{wDeliveries.map((d) => (
<div key={d.id} className="flex items-center gap-3 px-3 py-2 text-xs">
<Badge
variant={d.success ? "default" : "destructive"}
className="text-xs shrink-0 w-14 justify-center"
>
{d.statusCode ?? "err"}
</Badge>
<span className="font-mono text-muted-foreground shrink-0">{d.event}</span>
<span className="text-muted-foreground flex-1 text-right">{formatDateTime(d.createdAt)}</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 shrink-0"
disabled={redelivering === d.id}
onClick={() => { void handleRedeliver(w.id, d.id); }}
>
<RotateCcw className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}