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 ? ( +
    +
  1. + Open Plex, go to{" "} + + Settings > Webhooks + +
  2. +
  3. + Click{" "} + + Add Webhook + {" "} + and paste the URL above +
  4. +
  5. + Sofa will automatically log movies and episodes when you + finish watching them +
  6. +
  7. + Make sure the username above matches your Plex account + name +
  8. +
+ ) : ( +
    +
  1. + Install the{" "} + + Webhook plugin + {" "} + from Jellyfin's plugin catalog +
  2. +
  3. + Go to{" "} + + Dashboard > Plugins > Webhook + +
  4. +
  5. + Add a{" "} + + Generic Destination + {" "} + and paste the URL above +
  6. +
  7. + Enable the{" "} + + Playback Stop + {" "} + notification type +
  8. +
  9. + Make sure the username above matches your Jellyfin + username +
  10. +
+ )} +
+
+
+
+
+
+
+ ); +} 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 ? ( -
    -
  1. - Open Plex, go to{" "} - - Settings > Webhooks - -
  2. -
  3. - Click{" "} - - Add Webhook - {" "} - and paste the URL above -
  4. -
  5. - Sofa will automatically log movies and episodes when you - finish watching them -
  6. -
  7. - Make sure the username above matches your Plex account - name -
  8. -
- ) : ( -
    -
  1. - Install the{" "} - - Webhook plugin - {" "} - from Jellyfin's plugin catalog -
  2. -
  3. - Go to{" "} - - Dashboard > Plugins > Webhook - -
  4. -
  5. - Add a{" "} - - Generic Destination - {" "} - and paste the URL above -
  6. -
  7. - Enable the{" "} - - Playback Stop - {" "} - notification type -
  8. -
  9. - Make sure the username above matches your Jellyfin - username -
  10. -
- )} -
-
-
-
-
-
-
- ); -} - -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 }); -}