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