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