mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
Replace client-side session checks (useSession + router.replace) with a two-layer server-side approach: Next.js middleware performs a fast cookie presence check for all protected routes, and the (pages) layout does a full server-side session validation before rendering. Move login/register into a dedicated (auth) route group with its own minimal layout so they sit outside the authenticated shell. Lift the Toaster to the root layout so it remains available across all route groups.
572 lines
18 KiB
TypeScript
572 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
IconCheck,
|
|
IconChevronDown,
|
|
IconCopy,
|
|
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 { signOut, useSession } from "@/lib/auth/client";
|
|
|
|
const sectionVariants = {
|
|
hidden: { opacity: 0, y: 20 },
|
|
visible: {
|
|
opacity: 1,
|
|
y: 0,
|
|
transition: { type: "spring" as const, stiffness: 200, damping: 24 },
|
|
},
|
|
};
|
|
|
|
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;
|
|
}[];
|
|
}
|
|
|
|
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`;
|
|
}
|
|
|
|
function WebhookCard({
|
|
provider,
|
|
connection,
|
|
onSave,
|
|
onDelete,
|
|
onRegenerateToken,
|
|
onToggle,
|
|
}: {
|
|
provider: "plex" | "jellyfin";
|
|
connection: WebhookConnection | null;
|
|
onSave: (provider: "plex" | "jellyfin", username: string) => Promise<void>;
|
|
onDelete: (provider: "plex" | "jellyfin") => Promise<void>;
|
|
onRegenerateToken: (provider: "plex" | "jellyfin") => Promise<void>;
|
|
onToggle: (provider: "plex" | "jellyfin", enabled: boolean) => Promise<void>;
|
|
}) {
|
|
const [username, setUsername] = useState(
|
|
connection?.mediaServerUsername ?? "",
|
|
);
|
|
const [saving, setSaving] = useState(false);
|
|
const [copied, setCopied] = useState(false);
|
|
const [setupOpen, setSetupOpen] = 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 (
|
|
<Card>
|
|
<CardContent>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
|
<Icon className="size-4 text-primary" />
|
|
</div>
|
|
<div>
|
|
<CardTitle>{label}</CardTitle>
|
|
<CardDescription>
|
|
{connection
|
|
? connection.lastEventAt
|
|
? `Last event ${timeAgo(connection.lastEventAt)}`
|
|
: "Connected — no events yet"
|
|
: "Not configured"}
|
|
</CardDescription>
|
|
</div>
|
|
</div>
|
|
{connection && (
|
|
<Switch
|
|
checked={connection.enabled}
|
|
onCheckedChange={(checked) => onToggle(provider, checked)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
|
|
<CardContent className="space-y-3 pt-0">
|
|
<div>
|
|
<label
|
|
htmlFor={`${provider}-username`}
|
|
className="mb-1 block text-xs text-muted-foreground"
|
|
>
|
|
{label} username
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id={`${provider}-username`}
|
|
placeholder={`Your ${label} username`}
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
|
/>
|
|
{!connection ? (
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={saving || !username.trim()}
|
|
size="lg"
|
|
>
|
|
{saving ? "Saving..." : "Connect"}
|
|
</Button>
|
|
) : (
|
|
username.trim() !== connection.mediaServerUsername && (
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={saving || !username.trim()}
|
|
variant="outline"
|
|
size="lg"
|
|
>
|
|
Update
|
|
</Button>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{webhookUrl && (
|
|
<motion.div
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: "auto" }}
|
|
exit={{ opacity: 0, height: 0 }}
|
|
className="space-y-3 overflow-hidden"
|
|
>
|
|
<div>
|
|
<label
|
|
htmlFor={`${provider}-webhook-url`}
|
|
className="mb-1 block text-xs text-muted-foreground"
|
|
>
|
|
Webhook URL
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id={`${provider}-webhook-url`}
|
|
readOnly
|
|
value={webhookUrl}
|
|
className="font-mono text-[10px] text-muted-foreground"
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="icon-lg"
|
|
onClick={handleCopy}
|
|
title="Copy URL"
|
|
>
|
|
{copied ? (
|
|
<IconCheck size={14} className="text-green-400" />
|
|
) : (
|
|
<IconCopy size={14} />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onRegenerateToken(provider)}
|
|
>
|
|
<IconRefresh size={12} />
|
|
Regenerate URL
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => onDelete(provider)}
|
|
>
|
|
<IconTrash size={12} />
|
|
Disconnect
|
|
</Button>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<Collapsible open={setupOpen} onOpenChange={setSetupOpen}>
|
|
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded-md py-1 text-xs text-muted-foreground transition-colors hover:text-foreground">
|
|
<IconChevronDown
|
|
size={12}
|
|
className={`transition-transform ${setupOpen ? "rotate-0" : "-rotate-90"}`}
|
|
/>
|
|
Setup instructions
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="mt-2 rounded-lg bg-muted/30 p-3 text-xs leading-relaxed text-muted-foreground">
|
|
{isPlex ? (
|
|
<ol className="list-inside list-decimal space-y-1.5">
|
|
<li>
|
|
Open Plex, go to{" "}
|
|
<span className="font-medium text-foreground">
|
|
Settings > Webhooks
|
|
</span>
|
|
</li>
|
|
<li>
|
|
Click{" "}
|
|
<span className="font-medium text-foreground">
|
|
Add Webhook
|
|
</span>{" "}
|
|
and paste the URL above
|
|
</li>
|
|
<li>
|
|
Sofa will automatically log movies and episodes when you
|
|
finish watching them
|
|
</li>
|
|
<li>
|
|
Make sure the username above matches your Plex account name
|
|
</li>
|
|
</ol>
|
|
) : (
|
|
<ol className="list-inside list-decimal space-y-1.5">
|
|
<li>
|
|
Install the{" "}
|
|
<span className="font-medium text-foreground">
|
|
Webhook plugin
|
|
</span>{" "}
|
|
from Jellyfin's plugin catalog
|
|
</li>
|
|
<li>
|
|
Go to{" "}
|
|
<span className="font-medium text-foreground">
|
|
Dashboard > Plugins > Webhook
|
|
</span>
|
|
</li>
|
|
<li>
|
|
Add a{" "}
|
|
<span className="font-medium text-foreground">
|
|
Generic Destination
|
|
</span>{" "}
|
|
and paste the URL above
|
|
</li>
|
|
<li>
|
|
Enable the{" "}
|
|
<span className="font-medium text-foreground">
|
|
Playback Stop
|
|
</span>{" "}
|
|
notification type
|
|
</li>
|
|
<li>
|
|
Make sure the username above matches your Jellyfin username
|
|
</li>
|
|
</ol>
|
|
)}
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
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 <div className="min-h-[60vh]" />;
|
|
}
|
|
|
|
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 (
|
|
<motion.div
|
|
className="mx-auto max-w-2xl space-y-8"
|
|
initial="hidden"
|
|
animate="visible"
|
|
variants={{
|
|
hidden: {},
|
|
visible: { transition: { staggerChildren: 0.15 } },
|
|
}}
|
|
>
|
|
<motion.div variants={sectionVariants}>
|
|
<div className="flex items-center gap-2">
|
|
<IconSettings size={20} className="text-primary" />
|
|
<h1 className="font-display text-3xl tracking-tight">Settings</h1>
|
|
</div>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
Manage your account and preferences
|
|
</p>
|
|
</motion.div>
|
|
|
|
{/* Account section */}
|
|
<motion.div variants={sectionVariants}>
|
|
<div className="mb-3 flex items-center gap-2">
|
|
<IconUser size={16} className="text-muted-foreground" />
|
|
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
Account
|
|
</h2>
|
|
</div>
|
|
<Card>
|
|
<CardContent className="flex items-center gap-4">
|
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-primary/10 font-display text-lg text-primary">
|
|
{initial}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<CardTitle>{session.user.name}</CardTitle>
|
|
<CardDescription>{session.user.email}</CardDescription>
|
|
<p className="mt-0.5 text-xs text-muted-foreground/60">
|
|
Member since {memberSince}
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
<CardContent className="pt-0">
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
await signOut();
|
|
router.push("/");
|
|
router.refresh();
|
|
}}
|
|
className="inline-flex h-9 items-center gap-2 rounded-lg border border-border/50 px-4 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
>
|
|
<IconLogout size={14} />
|
|
Sign out
|
|
</button>
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
|
|
{/* Media Servers section */}
|
|
<motion.div variants={sectionVariants}>
|
|
<div className="mb-3 flex items-center gap-2">
|
|
<IconWebhook size={16} className="text-muted-foreground" />
|
|
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
Media Servers
|
|
</h2>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<WebhookCard
|
|
provider="plex"
|
|
connection={plexConnection}
|
|
onSave={handleSaveWebhook}
|
|
onDelete={handleDeleteWebhook}
|
|
onRegenerateToken={handleRegenerateToken}
|
|
onToggle={handleToggleWebhook}
|
|
/>
|
|
<WebhookCard
|
|
provider="jellyfin"
|
|
connection={jellyfinConnection}
|
|
onSave={handleSaveWebhook}
|
|
onDelete={handleDeleteWebhook}
|
|
onRegenerateToken={handleRegenerateToken}
|
|
onToggle={handleToggleWebhook}
|
|
/>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Administration section — admin only */}
|
|
{isAdmin && (
|
|
<motion.div variants={sectionVariants}>
|
|
<div className="mb-3 flex items-center gap-2">
|
|
<IconShieldLock size={16} className="text-muted-foreground" />
|
|
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
Administration
|
|
</h2>
|
|
<span className="rounded-md bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
|
Admin
|
|
</span>
|
|
</div>
|
|
<Card className="border-l-2 border-l-primary/30">
|
|
<CardContent>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="flex items-start gap-3">
|
|
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
|
<IconUserPlus size={16} className="text-primary" />
|
|
</div>
|
|
<div>
|
|
<CardTitle>Open registration</CardTitle>
|
|
<CardDescription>
|
|
Allow new users to create accounts. Useful for adding
|
|
household members.
|
|
</CardDescription>
|
|
</div>
|
|
</div>
|
|
<Switch
|
|
checked={registrationOpen}
|
|
onCheckedChange={handleToggleRegistration}
|
|
disabled={toggling}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
)}
|
|
</motion.div>
|
|
);
|
|
}
|