diff --git a/apps/web/app/(app)/settings/ai/page.tsx b/apps/web/app/(app)/settings/ai/page.tsx new file mode 100644 index 0000000..3408a44 --- /dev/null +++ b/apps/web/app/(app)/settings/ai/page.tsx @@ -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 ( +
+
+
+

Your API Keys (BYOK)

+

+ Use your own API keys instead of the app's shared quota. Keys are encrypted at rest. +

+
+ k.provider)} /> +
+ +
+
+

Model Preferences

+

+ Choose which model to use for each task. Defaults to your first configured provider. +

+
+ +
+
+ ); +} diff --git a/apps/web/app/(app)/settings/api-keys/page.tsx b/apps/web/app/(app)/settings/api-keys/page.tsx new file mode 100644 index 0000000..9ada60d --- /dev/null +++ b/apps/web/app/(app)/settings/api-keys/page.tsx @@ -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 ( +
+
+
+

API Keys

+

+ Manage API keys for programmatic access to the Epicure API. +

+
+ ({ + id: k.id, + name: k.name, + lastUsedAt: k.lastUsedAt ? k.lastUsedAt.toISOString() : null, + createdAt: k.createdAt.toISOString(), + }))} + /> +
+
+ ); +} diff --git a/apps/web/app/(app)/settings/layout.tsx b/apps/web/app/(app)/settings/layout.tsx new file mode 100644 index 0000000..a3fcc19 --- /dev/null +++ b/apps/web/app/(app)/settings/layout.tsx @@ -0,0 +1,16 @@ +import { SettingsSidebar } from "@/components/settings/settings-sidebar"; + +export default function SettingsLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+

Settings

+

Manage your account, preferences, and integrations.

+
+
+ +
{children}
+
+
+ ); +} diff --git a/apps/web/app/(app)/settings/notifications/page.tsx b/apps/web/app/(app)/settings/notifications/page.tsx new file mode 100644 index 0000000..e32a192 --- /dev/null +++ b/apps/web/app/(app)/settings/notifications/page.tsx @@ -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 ( +
+
+
+

Push Notifications

+

+ Get notified when someone comments on your recipes or likes your content. +

+
+ +
+
+ ); +} diff --git a/apps/web/app/(app)/settings/nutrition/page.tsx b/apps/web/app/(app)/settings/nutrition/page.tsx new file mode 100644 index 0000000..944eef8 --- /dev/null +++ b/apps/web/app/(app)/settings/nutrition/page.tsx @@ -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 ( +
+
+
+

Daily Nutrition Goals

+

+ Set your daily targets. These are shown as progress bars on your meal plan. +

+
+ +
+
+ ); +} diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx new file mode 100644 index 0000000..f034450 --- /dev/null +++ b/apps/web/app/(app)/settings/page.tsx @@ -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 ( + + ); +} diff --git a/apps/web/app/(app)/settings/security/page.tsx b/apps/web/app/(app)/settings/security/page.tsx new file mode 100644 index 0000000..8ef89fc --- /dev/null +++ b/apps/web/app/(app)/settings/security/page.tsx @@ -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 ; +} diff --git a/apps/web/app/(app)/settings/webhooks/docs/page.tsx b/apps/web/app/(app)/settings/webhooks/docs/page.tsx new file mode 100644 index 0000000..2f35f21 --- /dev/null +++ b/apps/web/app/(app)/settings/webhooks/docs/page.tsx @@ -0,0 +1,142 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Webhook Docs — Epicure", +}; + +export default function WebhookDocsPage() { + return ( +
+
+

Webhook Documentation

+

+ Learn how to integrate Epicure webhooks with your own tools, Zapier, and Make. +

+
+ + {/* Section 1: Webhook Events */} +
+

Webhook Events

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EventDescriptionPayload Fields
recipe.createdFired when a recipe is createdid, title, authorId
recipe.updatedFired when a recipe is updatedid, title, authorId
recipe.publishedFired when recipe visibility becomes publicid, title, authorId
recipe.deletedFired when a recipe is deletedid, title
meal_plan.updatedFired when a meal plan entry changesweekStart, day, mealType
shopping_list.completedFired when shopping list marked completeid, name
comment.addedFired when a comment is added to your recipecommentId, recipeId, recipeTitle
+
+
+ + {/* Section 2: Payload Format */} +
+

Payload Format

+

+ Every webhook request is a POST with a JSON body in the following shape: +

+
{`{
+  "event": "recipe.created",
+  "payload": { "id": "...", "title": "Pasta Carbonara", "authorId": "..." },
+  "timestamp": "2024-01-15T10:30:00.000Z"
+}`}
+
+ + {/* Section 3: Signature Verification */} +
+

Signature Verification

+

+ Every request includes an{" "} + X-Epicure-Signature{" "} + header containing an HMAC-SHA256 digest of the raw request body, signed with your webhook + secret. Always verify this signature before processing the payload. +

+ +

TypeScript / Node.js

+
{`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));
+}`}
+ +

Python

+
{`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)`}
+
+ + {/* Section 4: Zapier Integration */} +
+

Zapier Integration

+
    +
  1. Go to zapier.com and create a new Zap.
  2. +
  3. Choose Webhooks by Zapier as the trigger and select Catch Hook.
  4. +
  5. Copy the webhook URL provided by Zapier.
  6. +
  7. Go to Epicure Settings → Webhooks and add that URL.
  8. +
  9. Test the trigger in Zapier to confirm it receives the payload.
  10. +
+ +

Example workflows

+
    +
  • New Recipe Published → Add row to Notion database
  • +
  • Shopping List Completed → Send Slack message to #groceries
  • +
  • Comment Added → Send email notification via Gmail
  • +
+
+ + {/* Section 5: Make (Integromat) */} +
+

Make (Integromat)

+
    +
  1. Go to make.com and create a new scenario.
  2. +
  3. Add an HTTP > Watch for Incoming Webhooks module as the trigger.
  4. +
  5. Copy the generated webhook URL from the module settings.
  6. +
  7. Go to Epicure Settings → Webhooks and paste that URL.
  8. +
  9. Run the scenario and trigger a test event in Epicure to verify the connection.
  10. +
  11. Add downstream modules (Notion, Slack, Gmail, etc.) to act on the incoming data.
  12. +
+
+
+ ); +} diff --git a/apps/web/app/(app)/settings/webhooks/page.tsx b/apps/web/app/(app)/settings/webhooks/page.tsx new file mode 100644 index 0000000..63b612a --- /dev/null +++ b/apps/web/app/(app)/settings/webhooks/page.tsx @@ -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 ( +
+
+
+

Webhooks

+

+ Receive HTTP callbacks when events happen in your Epicure account. +

+
+ ({ + id: w.id, + url: w.url, + events: w.events, + active: w.active, + createdAt: w.createdAt.toISOString(), + }))} + /> +
+
+ ); +} diff --git a/apps/web/components/settings/api-keys-manager.tsx b/apps/web/components/settings/api-keys-manager.tsx new file mode 100644 index 0000000..2593a6c --- /dev/null +++ b/apps/web/components/settings/api-keys-manager.tsx @@ -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(initialKeys); + const [name, setName] = useState(""); + const [creating, setCreating] = useState(false); + const [newKey, setNewKey] = useState(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 ( +
+ {/* Create form */} +
+
+ +
+ setName(e.target.value)} + maxLength={100} + required + /> + +
+
+
+ + {/* New key reveal */} + {newKey && ( +
+

+ Save this key — it will not be shown again. +

+
+ + {newKey} + + +
+ +
+ )} + + {/* Keys list */} + {keys.length === 0 ? ( +

No API keys yet.

+ ) : ( +
+ {keys.map((k) => ( +
+
+

{k.name}

+
+ Created {formatDate(k.createdAt)} + · + {k.lastUsedAt ? ( + Last used {formatDate(k.lastUsedAt)} + ) : ( + Never used + )} +
+
+ +
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/components/settings/byok-manager.tsx b/apps/web/components/settings/byok-manager.tsx new file mode 100644 index 0000000..0c1c298 --- /dev/null +++ b/apps/web/components/settings/byok-manager.tsx @@ -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>(new Set(initialKeys)); + const [inputs, setInputs] = useState>>({}); + const [saving, setSaving] = useState(null); + const [removing, setRemoving] = useState(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 ( +
+ {PROVIDERS.map((p) => { + const hasKey = configuredKeys.has(p.id); + const isOllama = p.id === "ollama"; + return ( +
+
+ + {p.label} +
+ {hasKey ? ( +
+ + + Configured + + +
+ ) : isOllama ? ( + Set via OLLAMA_BASE_URL env var + ) : ( +
+ setInputs((prev) => ({ ...prev, [p.id]: e.target.value }))} + className="h-8 text-sm" + onKeyDown={(e) => { if (e.key === "Enter") { void saveKey(p.id); } }} + /> + +
+ )} +
+ ); + })} +

+ Your keys are encrypted at rest. When set, they override the app's default keys for your account. +

+
+ ); +} diff --git a/apps/web/components/settings/model-prefs-form.tsx b/apps/web/components/settings/model-prefs-form.tsx new file mode 100644 index 0000000..acc5793 --- /dev/null +++ b/apps/web/components/settings/model-prefs-form.tsx @@ -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 = { + 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(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 ( +
+ {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 ( +
+
+

{label}

+

{description}

+
+
+
+ + +
+ +
+ + {presets ? ( + + ) : ( + setField(modelKey, e.target.value)} + disabled={!provider} + /> + )} +
+
+
+ ); + })} + + +
+ ); +} diff --git a/apps/web/components/settings/security-form.tsx b/apps/web/components/settings/security-form.tsx new file mode 100644 index 0000000..12cf2d9 --- /dev/null +++ b/apps/web/components/settings/security-form.tsx @@ -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 ( +
+
+
+

{t("changeEmail")}

+

{t("changeEmailDescription")}

+
+
+
+ + setNewEmail(e.target.value)} + placeholder={currentEmail} + required + /> +
+ +
+
+ +
+
+

{t("changePassword")}

+
+
+
+ + setCurrentPassword(e.target.value)} + autoComplete="current-password" + required + /> +
+
+ + setNewPassword(e.target.value)} + autoComplete="new-password" + minLength={8} + required + /> +
+
+ + setConfirmPassword(e.target.value)} + autoComplete="new-password" + minLength={8} + required + /> +
+ +
+
+
+ ); +} diff --git a/apps/web/components/settings/settings-form.tsx b/apps/web/components/settings/settings-form.tsx new file mode 100644 index 0000000..5533757 --- /dev/null +++ b/apps/web/components/settings/settings-form.tsx @@ -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 ( +
+
+

{t("profile")}

+
+ + setName(e.target.value)} /> +
+
+ + +
+ +
+ +
+

{t("language")}

+

{t("languageDescription")}

+ +
+
+ ); +} diff --git a/apps/web/components/settings/settings-page-header.tsx b/apps/web/components/settings/settings-page-header.tsx new file mode 100644 index 0000000..5af05a2 --- /dev/null +++ b/apps/web/components/settings/settings-page-header.tsx @@ -0,0 +1,11 @@ +"use client"; +import { useTranslations } from "next-intl"; +export function SettingsPageHeader() { + const t = useTranslations("settingsForm"); + return ( +
+

{t("title")}

+

{t("subtitle")}

+
+ ); +} diff --git a/apps/web/components/settings/settings-sidebar.tsx b/apps/web/components/settings/settings-sidebar.tsx new file mode 100644 index 0000000..ba83c01 --- /dev/null +++ b/apps/web/components/settings/settings-sidebar.tsx @@ -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 ( + + ); +} diff --git a/apps/web/components/settings/webhooks-manager.tsx b/apps/web/components/settings/webhooks-manager.tsx new file mode 100644 index 0000000..e1f27ef --- /dev/null +++ b/apps/web/components/settings/webhooks-manager.tsx @@ -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(initialWebhooks); + const [url, setUrl] = useState(""); + const [selectedEvents, setSelectedEvents] = useState([]); + const [creating, setCreating] = useState(false); + const [newSecret, setNewSecret] = useState<{ id: string; secret: string } | null>(null); + const [copied, setCopied] = useState(false); + const [deliveries, setDeliveries] = useState>({}); + const [loadingDeliveries, setLoadingDeliveries] = useState(null); + const [expandedDeliveries, setExpandedDeliveries] = useState>(new Set()); + const [redelivering, setRedelivering] = useState(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 ( +
+
+ + View webhook docs & Zapier integration → + +
+ + {/* Create form */} +
+
+ + setUrl(e.target.value)} + maxLength={2048} + required + /> +
+ +
+ +

+ Select which events trigger this webhook. Leave all unchecked to receive all events. +

+
+ {ALL_EVENTS.map((event) => { + const checked = selectedEvents.includes(event); + return ( + + ); + })} +
+
+ + +
+ + {/* New secret reveal */} + {newSecret && ( +
+

+ Save this signing secret — it will not be shown again. +

+

+ Use it to verify the X-Epicure-Signature header on incoming requests. +

+
+ + {newSecret.secret} + + +
+ +
+ )} + + {/* Webhook list */} + {webhookList.length === 0 ? ( +

No webhooks yet.

+ ) : ( +
+ {webhookList.map((w) => { + const isExpanded = expandedDeliveries.has(w.id); + const wDeliveries = deliveries[w.id] ?? []; + return ( +
+
+
+

{w.url}

+
+ Added {formatDate(w.createdAt)} + · + + {w.active ? "Active" : "Inactive"} + +
+ {w.events.length > 0 && ( +
+ {w.events.map((ev) => ( + {ev} + ))} +
+ )} + {w.events.length === 0 && ( +

All events

+ )} +
+
+ + + +
+
+ + {/* Delivery history */} + {isExpanded && ( +
+ {loadingDeliveries === w.id ? ( +

Loading…

+ ) : wDeliveries.length === 0 ? ( +

No deliveries yet.

+ ) : ( +
+ {wDeliveries.map((d) => ( +
+ + {d.statusCode ?? "err"} + + {d.event} + {formatDateTime(d.createdAt)} + +
+ ))} +
+ )} +
+ )} +
+ ); + })} +
+ )} +
+ ); +}