diff --git a/app/(pages)/settings/_components/account-section.tsx b/app/(pages)/settings/_components/account-section.tsx
new file mode 100644
index 0000000..0c8800f
--- /dev/null
+++ b/app/(pages)/settings/_components/account-section.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { IconLogout, IconUser } from "@tabler/icons-react";
+import { useRouter } from "next/navigation";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardTitle,
+} from "@/components/ui/card";
+import { signOut } from "@/lib/auth/client";
+
+export function AccountSection({
+ user,
+}: {
+ user: { name: string; email: string; createdAt: string; role?: string };
+}) {
+ const router = useRouter();
+
+ const memberSince = new Date(user.createdAt).toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "long",
+ });
+ const initial = user.name?.charAt(0).toUpperCase() ?? "?";
+
+ return (
+
+
+
+
+ Account
+
+
+
+
+
+ {initial}
+
+
+
+ {user.name}
+ {user.role === "admin" && (
+
+ Admin
+
+ )}
+
+
{user.email}
+
+ Member since {memberSince}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/(pages)/settings/_components/actions.ts b/app/(pages)/settings/_components/actions.ts
new file mode 100644
index 0000000..7cab218
--- /dev/null
+++ b/app/(pages)/settings/_components/actions.ts
@@ -0,0 +1,140 @@
+"use server";
+
+import crypto from "node:crypto";
+import { and, eq } from "drizzle-orm";
+import { headers } from "next/headers";
+import { auth } from "@/lib/auth/server";
+import { db } from "@/lib/db/client";
+import { webhookConnections } from "@/lib/db/schema";
+import { setSetting } from "@/lib/services/settings";
+
+async function getSession() {
+ const session = await auth.api.getSession({ headers: await headers() });
+ if (!session) throw new Error("Unauthorized");
+ return session;
+}
+
+export async function saveWebhookConnection(
+ provider: "plex" | "jellyfin",
+ mediaServerUsername: string,
+ enabled?: boolean,
+) {
+ const session = await getSession();
+
+ if (!["plex", "jellyfin"].includes(provider)) {
+ throw new Error("Invalid provider");
+ }
+ if (!mediaServerUsername?.trim()) {
+ throw new Error("Media server username is required");
+ }
+
+ const existing = await db
+ .select()
+ .from(webhookConnections)
+ .where(
+ and(
+ eq(webhookConnections.userId, session.user.id),
+ eq(webhookConnections.provider, provider),
+ ),
+ )
+ .get();
+
+ if (existing) {
+ const connection = await db
+ .update(webhookConnections)
+ .set({
+ mediaServerUsername: mediaServerUsername.trim(),
+ enabled: typeof enabled === "boolean" ? enabled : existing.enabled,
+ })
+ .where(eq(webhookConnections.id, existing.id))
+ .returning()
+ .get();
+ return {
+ ...connection,
+ lastEventAt: connection.lastEventAt?.toISOString() ?? null,
+ createdAt: connection.createdAt.toISOString(),
+ };
+ }
+
+ const token = crypto.randomBytes(32).toString("hex");
+ const now = new Date();
+
+ const connection = await db
+ .insert(webhookConnections)
+ .values({
+ userId: session.user.id,
+ provider,
+ token,
+ mediaServerUsername: mediaServerUsername.trim(),
+ enabled: true,
+ createdAt: now,
+ })
+ .returning()
+ .get();
+
+ return {
+ ...connection,
+ lastEventAt: connection.lastEventAt?.toISOString() ?? null,
+ createdAt: connection.createdAt.toISOString(),
+ };
+}
+
+export async function deleteWebhookConnection(provider: "plex" | "jellyfin") {
+ const session = await getSession();
+
+ if (!["plex", "jellyfin"].includes(provider)) {
+ throw new Error("Invalid provider");
+ }
+
+ await db
+ .delete(webhookConnections)
+ .where(
+ and(
+ eq(webhookConnections.userId, session.user.id),
+ eq(webhookConnections.provider, provider),
+ ),
+ )
+ .run();
+}
+
+export async function regenerateWebhookToken(provider: "plex" | "jellyfin") {
+ const session = await getSession();
+
+ if (!["plex", "jellyfin"].includes(provider)) {
+ throw new Error("Invalid provider");
+ }
+
+ const newToken = crypto.randomBytes(32).toString("hex");
+
+ const connection = await db
+ .update(webhookConnections)
+ .set({ token: newToken })
+ .where(
+ and(
+ eq(webhookConnections.userId, session.user.id),
+ eq(webhookConnections.provider, provider),
+ ),
+ )
+ .returning()
+ .get();
+
+ if (!connection) {
+ throw new Error("Connection not found");
+ }
+
+ return {
+ ...connection,
+ lastEventAt: connection.lastEventAt?.toISOString() ?? null,
+ createdAt: connection.createdAt.toISOString(),
+ };
+}
+
+export async function toggleRegistration(open: boolean) {
+ const session = await getSession();
+
+ if (session.user.role !== "admin") {
+ throw new Error("Forbidden");
+ }
+
+ await setSetting("registrationOpen", String(open));
+}
diff --git a/app/(pages)/settings/_components/integrations-section.tsx b/app/(pages)/settings/_components/integrations-section.tsx
new file mode 100644
index 0000000..061efa2
--- /dev/null
+++ b/app/(pages)/settings/_components/integrations-section.tsx
@@ -0,0 +1,103 @@
+"use client";
+
+import { IconWebhook } from "@tabler/icons-react";
+import { useState } from "react";
+import {
+ deleteWebhookConnection,
+ regenerateWebhookToken,
+ saveWebhookConnection,
+} from "./actions";
+import { WebhookCard, type WebhookConnection } from "./webhook-card";
+
+export function IntegrationsSection({
+ initialConnections,
+}: {
+ initialConnections: WebhookConnection[];
+}) {
+ const [connections, setConnections] =
+ useState(initialConnections);
+
+ const plexConnection = connections.find((c) => c.provider === "plex") ?? null;
+ const jellyfinConnection =
+ connections.find((c) => c.provider === "jellyfin") ?? null;
+
+ async function handleSave(provider: "plex" | "jellyfin", username: string) {
+ const result = await saveWebhookConnection(provider, username);
+ setConnections((prev) => {
+ const existing = prev.find((c) => c.provider === provider);
+ if (existing) {
+ return prev.map((c) =>
+ c.provider === provider
+ ? { ...result, recentEvents: existing.recentEvents }
+ : c,
+ );
+ }
+ return [...prev, { ...result, recentEvents: [] }];
+ });
+ }
+
+ async function handleDelete(provider: "plex" | "jellyfin") {
+ const previous = connections;
+ setConnections((prev) => prev.filter((c) => c.provider !== provider));
+ try {
+ await deleteWebhookConnection(provider);
+ } catch {
+ setConnections(previous);
+ }
+ }
+
+ async function handleRegenerateToken(provider: "plex" | "jellyfin") {
+ const result = await regenerateWebhookToken(provider);
+ setConnections((prev) =>
+ prev.map((c) =>
+ c.provider === provider ? { ...c, token: result.token } : c,
+ ),
+ );
+ }
+
+ async function handleToggle(provider: "plex" | "jellyfin", enabled: boolean) {
+ const previous = connections;
+ setConnections((prev) =>
+ prev.map((c) => (c.provider === provider ? { ...c, enabled } : c)),
+ );
+ try {
+ const conn = connections.find((c) => c.provider === provider);
+ await saveWebhookConnection(
+ provider,
+ conn?.mediaServerUsername ?? "",
+ enabled,
+ );
+ } catch {
+ setConnections(previous);
+ }
+ }
+
+ return (
+
+
+
+
+ Integrations
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/(pages)/settings/_components/server-section.tsx b/app/(pages)/settings/_components/server-section.tsx
new file mode 100644
index 0000000..1bc0c6b
--- /dev/null
+++ b/app/(pages)/settings/_components/server-section.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import { IconShieldLock, IconUserPlus } from "@tabler/icons-react";
+import { useState } from "react";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardTitle,
+} from "@/components/ui/card";
+import { Switch } from "@/components/ui/switch";
+import { toggleRegistration } from "./actions";
+
+export function ServerSection({
+ initialRegistrationOpen,
+}: {
+ initialRegistrationOpen: boolean;
+}) {
+ const [registrationOpen, setRegistrationOpen] = useState(
+ initialRegistrationOpen,
+ );
+ const [toggling, setToggling] = useState(false);
+
+ async function handleToggle(checked: boolean) {
+ const previous = registrationOpen;
+ setRegistrationOpen(checked);
+ setToggling(true);
+ try {
+ await toggleRegistration(checked);
+ } catch {
+ setRegistrationOpen(previous);
+ } finally {
+ setToggling(false);
+ }
+ }
+
+ return (
+
+
+
+
+ Server
+
+
+ Admin
+
+
+
+
+
+
+
+
+
+
+ Open registration
+
+ Allow new users to create accounts. Useful for adding
+ household members.
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/(pages)/settings/_components/settings-shell.tsx b/app/(pages)/settings/_components/settings-shell.tsx
new file mode 100644
index 0000000..3e95e0c
--- /dev/null
+++ b/app/(pages)/settings/_components/settings-shell.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import { IconSettings } from "@tabler/icons-react";
+import { motion } from "motion/react";
+import { Children, type ReactNode } from "react";
+
+const sectionVariants = {
+ hidden: { opacity: 0, y: 20 },
+ visible: {
+ opacity: 1,
+ y: 0,
+ transition: { type: "spring" as const, stiffness: 200, damping: 24 },
+ },
+};
+
+export function SettingsShell({ children }: { children: ReactNode }) {
+ return (
+
+
+
+
+
Settings
+
+
+ Manage your account and preferences
+
+
+
+ {Children.map(children, (child) => (
+ {child}
+ ))}
+
+ );
+}
diff --git a/app/(pages)/settings/_components/webhook-card.tsx b/app/(pages)/settings/_components/webhook-card.tsx
new file mode 100644
index 0000000..c2b53b2
--- /dev/null
+++ b/app/(pages)/settings/_components/webhook-card.tsx
@@ -0,0 +1,343 @@
+"use client";
+
+import {
+ IconCheck,
+ IconChevronDown,
+ IconCopy,
+ IconInfoCircle,
+ IconRefresh,
+ IconTrash,
+} from "@tabler/icons-react";
+import { AnimatePresence, motion } from "motion/react";
+import { useState } from "react";
+import { JellyfinIcon, PlexIcon } from "@/components/icons/media-servers";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { Input } from "@/components/ui/input";
+import { Switch } from "@/components/ui/switch";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+export interface WebhookConnection {
+ id: string;
+ provider: "plex" | "jellyfin";
+ token: string;
+ mediaServerUsername: string;
+ enabled: boolean;
+ lastEventAt: string | null;
+ recentEvents: {
+ id: string;
+ eventType: string | null;
+ mediaType: string | null;
+ mediaTitle: string | null;
+ status: "success" | "ignored" | "error";
+ receivedAt: string;
+ }[];
+}
+
+export function timeAgo(dateStr: string): string {
+ const diff = Date.now() - new Date(dateStr).getTime();
+ const mins = Math.floor(diff / 60000);
+ if (mins < 1) return "just now";
+ if (mins < 60) return `${mins}m ago`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ return `${days}d ago`;
+}
+
+export function WebhookCard({
+ provider,
+ connection,
+ onSave,
+ onDelete,
+ onRegenerateToken,
+ onToggle,
+}: {
+ provider: "plex" | "jellyfin";
+ connection: WebhookConnection | null;
+ onSave: (provider: "plex" | "jellyfin", username: string) => Promise;
+ onDelete: (provider: "plex" | "jellyfin") => Promise;
+ onRegenerateToken: (provider: "plex" | "jellyfin") => Promise;
+ onToggle: (provider: "plex" | "jellyfin", enabled: boolean) => Promise;
+}) {
+ const [username, setUsername] = useState(
+ connection?.mediaServerUsername ?? "",
+ );
+ const [saving, setSaving] = useState(false);
+ const [copied, setCopied] = useState(false);
+ const [setupOpen, setSetupOpen] = useState(false);
+ const [cardOpen, setCardOpen] = useState(false);
+
+ const isPlex = provider === "plex";
+ const label = isPlex ? "Plex" : "Jellyfin";
+ const Icon = isPlex ? PlexIcon : JellyfinIcon;
+
+ const webhookUrl = connection
+ ? `${window.location.origin}/api/webhooks/${connection.token}`
+ : null;
+
+ async function handleSave() {
+ if (!username.trim()) return;
+ setSaving(true);
+ try {
+ await onSave(provider, username.trim());
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function handleCopy() {
+ if (!webhookUrl) return;
+ await navigator.clipboard.writeText(webhookUrl);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {label}
+
+ {connection
+ ? connection.lastEventAt
+ ? `Last event ${timeAgo(connection.lastEventAt)}`
+ : "Connected — no events yet"
+ : "Not configured"}
+
+
+
+
+
+
+
+
+
+ {connection && (
+
+
+ Webhook {connection.enabled ? "enabled" : "disabled"}
+
+ onToggle(provider, checked)}
+ />
+
+ )}
+
+ {isPlex && (
+
+
+
+ Plex webhooks require an active{" "}
+ Plex Pass{" "}
+ subscription.
+
+
+ )}
+
+
+
+
+ setUsername(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleSave()}
+ />
+ {!connection ? (
+
+ ) : (
+ username.trim() !== connection.mediaServerUsername && (
+
+ )
+ )}
+
+
+
+
+ {webhookUrl && (
+
+
+
+
+
+
+
+ }
+ >
+ {copied ? (
+
+ ) : (
+
+ )}
+
+ Copy URL
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+ Setup instructions
+
+
+
+ {isPlex ? (
+
+ -
+ Open Plex, go to{" "}
+
+ Settings > Webhooks
+
+
+ -
+ Click{" "}
+
+ Add Webhook
+ {" "}
+ and paste the URL above
+
+ -
+ Sofa will automatically log movies and episodes when you
+ finish watching them
+
+ -
+ Make sure the username above matches your Plex account
+ name
+
+
+ ) : (
+
+ -
+ Install the{" "}
+
+ Webhook plugin
+ {" "}
+ from Jellyfin's plugin catalog
+
+ -
+ Go to{" "}
+
+ Dashboard > Plugins > Webhook
+
+
+ -
+ Add a{" "}
+
+ Generic Destination
+ {" "}
+ and paste the URL above
+
+ -
+ Enable the{" "}
+
+ Playback Stop
+ {" "}
+ notification type
+
+ -
+ Make sure the username above matches your Jellyfin
+ username
+
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/(pages)/settings/loading.tsx b/app/(pages)/settings/loading.tsx
new file mode 100644
index 0000000..f557de5
--- /dev/null
+++ b/app/(pages)/settings/loading.tsx
@@ -0,0 +1,26 @@
+import { Skeleton } from "@/components/ui/skeleton";
+
+export default function SettingsLoading() {
+ return (
+
+ {/* Header */}
+
+
+
+
+
+ {/* Account card */}
+
+
+
+
+
+ {/* Media server cards */}
+
+
+
+
+
+
+ );
+}
diff --git a/app/(pages)/settings/page.tsx b/app/(pages)/settings/page.tsx
index c575ee7..eee888b 100644
--- a/app/(pages)/settings/page.tsx
+++ b/app/(pages)/settings/page.tsx
@@ -1,612 +1,75 @@
-"use client";
+import { desc, eq } from "drizzle-orm";
+import { headers } from "next/headers";
+import { redirect } from "next/navigation";
+import { auth } from "@/lib/auth/server";
+import { db } from "@/lib/db/client";
+import { webhookConnections, webhookEventLog } from "@/lib/db/schema";
+import { getSetting } from "@/lib/services/settings";
+import { AccountSection } from "./_components/account-section";
+import { IntegrationsSection } from "./_components/integrations-section";
+import { ServerSection } from "./_components/server-section";
+import { SettingsShell } from "./_components/settings-shell";
-import {
- IconCheck,
- IconChevronDown,
- IconCopy,
- IconInfoCircle,
- IconLogout,
- IconRefresh,
- IconSettings,
- IconShieldLock,
- IconTrash,
- IconUser,
- IconUserPlus,
- IconWebhook,
-} from "@tabler/icons-react";
-import { AnimatePresence, motion } from "motion/react";
-import { useRouter } from "next/navigation";
-import { useCallback, useEffect, useState } from "react";
-import { JellyfinIcon, PlexIcon } from "@/components/icons/media-servers";
-import { Button } from "@/components/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardTitle,
-} from "@/components/ui/card";
-import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from "@/components/ui/collapsible";
-import { Input } from "@/components/ui/input";
-import { Switch } from "@/components/ui/switch";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import { signOut, useSession } from "@/lib/auth/client";
+export default async function SettingsPage() {
+ const session = await auth.api.getSession({ headers: await headers() });
+ if (!session?.user) redirect("/login");
-const sectionVariants = {
- hidden: { opacity: 0, y: 20 },
- visible: {
- opacity: 1,
- y: 0,
- transition: { type: "spring" as const, stiffness: 200, damping: 24 },
- },
-};
+ const isAdmin = session.user.role === "admin";
-interface WebhookConnection {
- id: string;
- provider: "plex" | "jellyfin";
- token: string;
- mediaServerUsername: string;
- enabled: boolean;
- lastEventAt: string | null;
- recentEvents: {
- id: string;
- eventType: string | null;
- mediaType: string | null;
- mediaTitle: string | null;
- status: "success" | "ignored" | "error";
- receivedAt: string;
- }[];
-}
+ const [connections, registrationOpen] = await Promise.all([
+ (async () => {
+ const rows = await db
+ .select()
+ .from(webhookConnections)
+ .where(eq(webhookConnections.userId, session.user.id))
+ .all();
-function timeAgo(dateStr: string): string {
- const diff = Date.now() - new Date(dateStr).getTime();
- const mins = Math.floor(diff / 60000);
- if (mins < 1) return "just now";
- if (mins < 60) return `${mins}m ago`;
- const hours = Math.floor(mins / 60);
- if (hours < 24) return `${hours}h ago`;
- const days = Math.floor(hours / 24);
- return `${days}d ago`;
-}
+ return Promise.all(
+ rows.map(async (conn) => {
+ const events = await db
+ .select()
+ .from(webhookEventLog)
+ .where(eq(webhookEventLog.connectionId, conn.id))
+ .orderBy(desc(webhookEventLog.receivedAt))
+ .limit(10)
+ .all();
-function WebhookCard({
- provider,
- connection,
- onSave,
- onDelete,
- onRegenerateToken,
- onToggle,
-}: {
- provider: "plex" | "jellyfin";
- connection: WebhookConnection | null;
- onSave: (provider: "plex" | "jellyfin", username: string) => Promise;
- onDelete: (provider: "plex" | "jellyfin") => Promise;
- onRegenerateToken: (provider: "plex" | "jellyfin") => Promise;
- onToggle: (provider: "plex" | "jellyfin", enabled: boolean) => Promise;
-}) {
- const [username, setUsername] = useState(
- connection?.mediaServerUsername ?? "",
- );
- const [saving, setSaving] = useState(false);
- const [copied, setCopied] = useState(false);
- const [setupOpen, setSetupOpen] = useState(false);
- const [cardOpen, setCardOpen] = useState(false);
-
- const isPlex = provider === "plex";
- const label = isPlex ? "Plex" : "Jellyfin";
- const Icon = isPlex ? PlexIcon : JellyfinIcon;
-
- const webhookUrl = connection
- ? `${window.location.origin}/api/webhooks/${connection.token}`
- : null;
-
- async function handleSave() {
- if (!username.trim()) return;
- setSaving(true);
- try {
- await onSave(provider, username.trim());
- } finally {
- setSaving(false);
- }
- }
-
- async function handleCopy() {
- if (!webhookUrl) return;
- await navigator.clipboard.writeText(webhookUrl);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- }
+ return {
+ id: conn.id,
+ provider: conn.provider,
+ token: conn.token,
+ mediaServerUsername: conn.mediaServerUsername,
+ enabled: conn.enabled,
+ lastEventAt: conn.lastEventAt?.toISOString() ?? null,
+ recentEvents: events.map((e) => ({
+ id: e.id,
+ eventType: e.eventType,
+ mediaType: e.mediaType,
+ mediaTitle: e.mediaTitle,
+ status: e.status,
+ receivedAt: e.receivedAt.toISOString(),
+ })),
+ };
+ }),
+ );
+ })(),
+ isAdmin
+ ? getSetting("registrationOpen").then((v) => v === "true")
+ : Promise.resolve(false),
+ ]);
return (
-
-
-
-
-
-
-
-
-
- {label}
-
- {connection
- ? connection.lastEventAt
- ? `Last event ${timeAgo(connection.lastEventAt)}`
- : "Connected — no events yet"
- : "Not configured"}
-
-
-
-
-
-
-
-
-
- {connection && (
-
-
- Webhook {connection.enabled ? "enabled" : "disabled"}
-
- onToggle(provider, checked)}
- />
-
- )}
-
- {isPlex && (
-
-
-
- Plex webhooks require an active{" "}
- Plex Pass{" "}
- subscription.
-
-
- )}
-
-
-
-
- setUsername(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && handleSave()}
- />
- {!connection ? (
-
- ) : (
- username.trim() !== connection.mediaServerUsername && (
-
- )
- )}
-
-
-
-
- {webhookUrl && (
-
-
-
-
-
-
-
- }
- >
- {copied ? (
-
- ) : (
-
- )}
-
- Copy URL
-
-
-
-
-
-
-
-
-
- )}
-
-
-
-
-
- Setup instructions
-
-
-
- {isPlex ? (
-
- -
- Open Plex, go to{" "}
-
- Settings > Webhooks
-
-
- -
- Click{" "}
-
- Add Webhook
- {" "}
- and paste the URL above
-
- -
- Sofa will automatically log movies and episodes when you
- finish watching them
-
- -
- Make sure the username above matches your Plex account
- name
-
-
- ) : (
-
- -
- Install the{" "}
-
- Webhook plugin
- {" "}
- from Jellyfin's plugin catalog
-
- -
- Go to{" "}
-
- Dashboard > Plugins > Webhook
-
-
- -
- Add a{" "}
-
- Generic Destination
- {" "}
- and paste the URL above
-
- -
- Enable the{" "}
-
- Playback Stop
- {" "}
- notification type
-
- -
- Make sure the username above matches your Jellyfin
- username
-
-
- )}
-
-
-
-
-
-
-
- );
-}
-
-export default function SettingsPage() {
- const { data: session, isPending } = useSession();
- const router = useRouter();
- const [registrationOpen, setRegistrationOpen] = useState(false);
- const [loadingSettings, setLoadingSettings] = useState(true);
- const [toggling, setToggling] = useState(false);
- const [webhookConnections, setWebhookConnections] = useState<
- WebhookConnection[]
- >([]);
-
- const isAdmin = session?.user?.role === "admin";
-
- const fetchSettings = useCallback(async () => {
- try {
- const res = await fetch("/api/admin/settings");
- if (res.ok) {
- const data = await res.json();
- setRegistrationOpen(data.registrationOpen);
- }
- } finally {
- setLoadingSettings(false);
- }
- }, []);
-
- const fetchWebhooks = useCallback(async () => {
- try {
- const res = await fetch("/api/settings/webhooks");
- if (res.ok) {
- const data = await res.json();
- setWebhookConnections(data.connections);
- }
- } catch {}
- }, []);
-
- useEffect(() => {
- if (isPending) return;
- fetchWebhooks();
- if (session?.user?.role === "admin") {
- fetchSettings();
- } else {
- setLoadingSettings(false);
- }
- }, [session, isPending, fetchSettings, fetchWebhooks]);
-
- async function handleToggleRegistration(checked: boolean) {
- setToggling(true);
- try {
- const res = await fetch("/api/admin/settings", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ registrationOpen: checked }),
- });
- if (res.ok) {
- setRegistrationOpen(checked);
- }
- } finally {
- setToggling(false);
- }
- }
-
- async function handleSaveWebhook(
- provider: "plex" | "jellyfin",
- mediaServerUsername: string,
- ) {
- const res = await fetch("/api/settings/webhooks", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ provider, mediaServerUsername }),
- });
- if (res.ok) await fetchWebhooks();
- }
-
- async function handleDeleteWebhook(provider: "plex" | "jellyfin") {
- const res = await fetch("/api/settings/webhooks", {
- method: "DELETE",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ provider }),
- });
- if (res.ok) await fetchWebhooks();
- }
-
- async function handleRegenerateToken(provider: "plex" | "jellyfin") {
- const res = await fetch("/api/settings/webhooks/regenerate-token", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ provider }),
- });
- if (res.ok) await fetchWebhooks();
- }
-
- async function handleToggleWebhook(
- provider: "plex" | "jellyfin",
- enabled: boolean,
- ) {
- await fetch("/api/settings/webhooks", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- provider,
- mediaServerUsername:
- webhookConnections.find((c) => c.provider === provider)
- ?.mediaServerUsername ?? "",
- enabled,
- }),
- });
- await fetchWebhooks();
- }
-
- if (isPending || loadingSettings) {
- return ;
- }
-
- if (!session?.user) return null;
-
- const memberSince = new Date(session.user.createdAt).toLocaleDateString(
- undefined,
- { year: "numeric", month: "long" },
- );
- const initial = session.user.name?.charAt(0).toUpperCase() ?? "?";
-
- const plexConnection =
- webhookConnections.find((c) => c.provider === "plex") ?? null;
- const jellyfinConnection =
- webhookConnections.find((c) => c.provider === "jellyfin") ?? null;
-
- return (
-
-
-
-
-
Settings
-
-
- Manage your account and preferences
-
-
-
- {/* Account section */}
-
-
-
-
- Account
-
-
-
-
-
- {initial}
-
-
-
{session.user.name}
-
{session.user.email}
-
- Member since {memberSince}
-
-
-
-
-
-
-
-
-
- {/* Media Servers section */}
-
-
-
-
- Media Servers
-
-
-
-
-
-
-
-
- {/* Administration section — admin only */}
- {isAdmin && (
-
-
-
-
- Administration
-
-
- Admin
-
-
-
-
-
-
-
-
-
-
- Open registration
-
- Allow new users to create accounts. Useful for adding
- household members.
-
-
-
-
-
-
-
-
- )}
-
+
+
+
+ {isAdmin && }
+
);
}
diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts
deleted file mode 100644
index 093d435..0000000
--- a/app/api/admin/settings/route.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { headers } from "next/headers";
-import { NextResponse } from "next/server";
-import { auth } from "@/lib/auth/server";
-import { getSetting, setSetting } from "@/lib/services/settings";
-
-export async function GET() {
- const session = await auth.api.getSession({ headers: await headers() });
- if (!session)
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- if (session.user.role !== "admin")
- return NextResponse.json({ error: "Forbidden" }, { status: 403 });
-
- const registrationOpen = (await getSetting("registrationOpen")) === "true";
- return NextResponse.json({ registrationOpen });
-}
-
-export async function POST(request: Request) {
- const session = await auth.api.getSession({ headers: await headers() });
- if (!session)
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- if (session.user.role !== "admin")
- return NextResponse.json({ error: "Forbidden" }, { status: 403 });
-
- const body = await request.json();
- if (typeof body.registrationOpen === "boolean") {
- await setSetting("registrationOpen", String(body.registrationOpen));
- }
-
- return NextResponse.json({ success: true });
-}
diff --git a/app/api/settings/webhooks/regenerate-token/route.ts b/app/api/settings/webhooks/regenerate-token/route.ts
deleted file mode 100644
index d533d36..0000000
--- a/app/api/settings/webhooks/regenerate-token/route.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import crypto from "node:crypto";
-import { and, eq } from "drizzle-orm";
-import { headers } from "next/headers";
-import { NextResponse } from "next/server";
-import { auth } from "@/lib/auth/server";
-import { db } from "@/lib/db/client";
-import { webhookConnections } from "@/lib/db/schema";
-
-export async function POST(request: Request) {
- const session = await auth.api.getSession({ headers: await headers() });
- if (!session)
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-
- const body = await request.json();
- const { provider } = body;
-
- if (!provider || !["plex", "jellyfin"].includes(provider)) {
- return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
- }
-
- const newToken = crypto.randomBytes(32).toString("hex");
-
- const connection = await db
- .update(webhookConnections)
- .set({ token: newToken })
- .where(
- and(
- eq(webhookConnections.userId, session.user.id),
- eq(webhookConnections.provider, provider),
- ),
- )
- .returning()
- .get();
-
- if (!connection) {
- return NextResponse.json(
- { error: "Connection not found" },
- { status: 404 },
- );
- }
-
- return NextResponse.json({ connection });
-}
diff --git a/app/api/settings/webhooks/route.ts b/app/api/settings/webhooks/route.ts
deleted file mode 100644
index f3f09a5..0000000
--- a/app/api/settings/webhooks/route.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-import crypto from "node:crypto";
-import { and, desc, eq } from "drizzle-orm";
-import { headers } from "next/headers";
-import { NextResponse } from "next/server";
-import { auth } from "@/lib/auth/server";
-import { db } from "@/lib/db/client";
-import { webhookConnections, webhookEventLog } from "@/lib/db/schema";
-
-export async function GET() {
- const session = await auth.api.getSession({ headers: await headers() });
- if (!session)
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-
- const connections = await db
- .select()
- .from(webhookConnections)
- .where(eq(webhookConnections.userId, session.user.id))
- .all();
-
- // Fetch recent events for each connection
- const connectionsWithEvents = await Promise.all(
- connections.map(async (conn) => {
- const events = await db
- .select()
- .from(webhookEventLog)
- .where(eq(webhookEventLog.connectionId, conn.id))
- .orderBy(desc(webhookEventLog.receivedAt))
- .limit(10)
- .all();
- return { ...conn, recentEvents: events };
- }),
- );
-
- return NextResponse.json({ connections: connectionsWithEvents });
-}
-
-export async function POST(request: Request) {
- const session = await auth.api.getSession({ headers: await headers() });
- if (!session)
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-
- const body = await request.json();
- const { provider, mediaServerUsername, enabled } = body;
-
- if (!provider || !["plex", "jellyfin"].includes(provider)) {
- return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
- }
- if (!mediaServerUsername || typeof mediaServerUsername !== "string") {
- return NextResponse.json(
- { error: "Media server username is required" },
- { status: 400 },
- );
- }
-
- // Check if connection already exists
- const existing = await db
- .select()
- .from(webhookConnections)
- .where(
- and(
- eq(webhookConnections.userId, session.user.id),
- eq(webhookConnections.provider, provider),
- ),
- )
- .get();
-
- if (existing) {
- // Update existing — preserve token, update username and enabled
- const connection = await db
- .update(webhookConnections)
- .set({
- mediaServerUsername: mediaServerUsername.trim(),
- enabled: typeof enabled === "boolean" ? enabled : existing.enabled,
- })
- .where(eq(webhookConnections.id, existing.id))
- .returning()
- .get();
- return NextResponse.json({ connection });
- }
-
- // Create new connection
- const token = crypto.randomBytes(32).toString("hex");
- const now = new Date();
-
- const connection = await db
- .insert(webhookConnections)
- .values({
- userId: session.user.id,
- provider,
- token,
- mediaServerUsername: mediaServerUsername.trim(),
- enabled: true,
- createdAt: now,
- })
- .returning()
- .get();
-
- return NextResponse.json({ connection });
-}
-
-export async function DELETE(request: Request) {
- const session = await auth.api.getSession({ headers: await headers() });
- if (!session)
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-
- const body = await request.json();
- const { provider } = body;
-
- if (!provider || !["plex", "jellyfin"].includes(provider)) {
- return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
- }
-
- await db
- .delete(webhookConnections)
- .where(
- and(
- eq(webhookConnections.userId, session.user.id),
- eq(webhookConnections.provider, provider),
- ),
- )
- .run();
-
- return NextResponse.json({ ok: true });
-}