1077 lines
41 KiB
TypeScript
1077 lines
41 KiB
TypeScript
import { useId, useMemo, useState } from "react";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Bell,
|
|
BookOpen,
|
|
Bot,
|
|
Check,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Copy,
|
|
ExternalLink,
|
|
Inbox,
|
|
KeyRound,
|
|
Loader2,
|
|
Mail,
|
|
Megaphone,
|
|
MonitorSmartphone,
|
|
RadioTower,
|
|
Rss,
|
|
Send,
|
|
TriangleAlert,
|
|
Webhook,
|
|
Workflow,
|
|
} from "lucide-react";
|
|
import type { LucideIcon } from "lucide-react";
|
|
import { Link } from "react-router";
|
|
import { toast } from "sonner";
|
|
import { api } from "@/api/client";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { adminKeys } from "@/hooks/queries/keys";
|
|
import { useServerNotificationChannels } from "@/hooks/queries/admin/serverNotificationChannels";
|
|
import { useSettingsForm } from "@/hooks/useSettingsForm";
|
|
import { cn } from "@/lib/utils";
|
|
import { FieldGroup } from "./FieldGroup";
|
|
import { SaveBar } from "./SaveBar";
|
|
import { SettingField } from "./SettingField";
|
|
import ServerNotificationChannels from "./ServerNotificationChannels";
|
|
|
|
const KEYS = [
|
|
"notifications.release_events_enabled",
|
|
"notifications.fanout_enabled",
|
|
"notifications.ui_enabled",
|
|
"notifications.webhooks_enabled",
|
|
"notifications.web_push_enabled",
|
|
"notifications.apple_push_delivery_enabled",
|
|
// Relay lifecycle fields are read for status but are never edited through
|
|
// the shared settings form; credential endpoints replace them atomically.
|
|
"notifications.push_relay_url",
|
|
"notifications.push_relay_deployment_id",
|
|
"notifications.push_relay_expires_at",
|
|
"notifications.push_relay_key_prefix",
|
|
"notifications.push_relay_reregistration_required",
|
|
"notifications.fanout.settle_seconds",
|
|
"notifications.fanout.max_series_burst",
|
|
"notifications.fanout.max_event_age_hours",
|
|
"notifications.webhooks.max_per_profile",
|
|
"notifications.webhooks.allow_private_destinations",
|
|
"notifications.webhooks.deliveries_per_minute_per_profile",
|
|
"notifications.email_enabled",
|
|
"notifications.email.allow_per_episode",
|
|
"notifications.email.digest_hour",
|
|
"notifications.email.external_url",
|
|
"notifications.discord_enabled",
|
|
"notifications.discord.allow_per_episode",
|
|
"notifications.discord.digest_hour",
|
|
"notifications.discord.poster_mode",
|
|
"notifications.server_channels_enabled",
|
|
"notifications.server_channels.batch_seconds",
|
|
"notifications.server_channels.mention_requesters",
|
|
"discord.client_id",
|
|
"discord.client_secret",
|
|
"discord.bot_token",
|
|
"notifications.retention.read_days",
|
|
"notifications.retention.unread_days",
|
|
"notifications.retention.event_days",
|
|
];
|
|
|
|
interface DiscordTestResult {
|
|
ok: boolean;
|
|
duration_ms: number;
|
|
message?: string;
|
|
}
|
|
|
|
interface AppleRelayRegisterResult {
|
|
relay_url: string;
|
|
deployment_id: string;
|
|
key_prefix: string;
|
|
api_key_configured: boolean;
|
|
relay_request_id?: string;
|
|
apns_topics?: string[];
|
|
expires_at: string;
|
|
}
|
|
|
|
const DEFAULT_PUSH_RELAY_URL = "https://push.siloserver.org";
|
|
|
|
/**
|
|
* Invite link for adding the bot to a Discord server. Membership alone is
|
|
* enough to DM, so no permissions are requested.
|
|
*/
|
|
function discordInviteUrl(clientId: string): string {
|
|
return `https://discord.com/oauth2/authorize?client_id=${encodeURIComponent(clientId)}&scope=bot&permissions=0`;
|
|
}
|
|
|
|
function digestHourLabel(raw: string): string {
|
|
const hour = Number.parseInt(raw, 10);
|
|
const valid = Number.isInteger(hour) && hour >= 0 && hour <= 23;
|
|
return `${String(valid ? hour : 8).padStart(2, "0")}:00`;
|
|
}
|
|
|
|
/** Small status pill shown next to a channel title while the card is collapsed. */
|
|
function Chip({
|
|
tone = "neutral",
|
|
children,
|
|
}: {
|
|
tone?: "neutral" | "positive" | "warning";
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium whitespace-nowrap",
|
|
tone === "neutral" && "border-border/70 text-muted-foreground",
|
|
tone === "positive" && "border-emerald-500/30 bg-emerald-500/10 text-emerald-500",
|
|
tone === "warning" && "border-amber-500/30 bg-amber-500/10 text-amber-500",
|
|
)}
|
|
>
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function ZoneHeading({ title, description }: { title: string; description?: string }) {
|
|
return (
|
|
<div className="space-y-1 px-1">
|
|
<h3 className="text-muted-foreground text-xs font-semibold tracking-[0.22em] uppercase">
|
|
{title}
|
|
</h3>
|
|
{description && <p className="text-muted-foreground/80 text-xs">{description}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Sub-grouping label inside an expanded channel card. */
|
|
function SubsectionLabel({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div className="text-muted-foreground/80 pt-4 pb-1 text-[11px] font-semibold tracking-wider uppercase">
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface PipelineStageProps {
|
|
icon: LucideIcon;
|
|
title: string;
|
|
description: string;
|
|
/** Visually de-emphasize the stage when an upstream stage is switched off. */
|
|
dimmed?: boolean;
|
|
control: React.ReactNode;
|
|
}
|
|
|
|
function PipelineStage({ icon: Icon, title, description, dimmed, control }: PipelineStageProps) {
|
|
return (
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className={cn("min-w-0 transition-opacity", dimmed && "opacity-50")}>
|
|
<div className="flex items-center gap-2">
|
|
<Icon className="text-muted-foreground h-4 w-4 shrink-0" />
|
|
<span className="text-sm font-medium">{title}</span>
|
|
</div>
|
|
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">{description}</p>
|
|
</div>
|
|
{control}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PipelineArrow() {
|
|
return (
|
|
<div className="hidden items-center md:flex">
|
|
<ChevronRight className="text-muted-foreground/50 h-4 w-4" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ChannelCardProps {
|
|
icon: LucideIcon;
|
|
title: string;
|
|
description: string;
|
|
enabled: boolean;
|
|
onEnabledChange: (enabled: boolean) => void;
|
|
chips?: React.ReactNode;
|
|
children?: React.ReactNode;
|
|
}
|
|
|
|
/**
|
|
* One delivery channel: an always-visible header row (icon, title, status
|
|
* chips, enable switch) with settings tucked behind an expandable body.
|
|
* Settings stay editable while the channel is off so admins can configure
|
|
* before enabling.
|
|
*/
|
|
function ChannelCard({
|
|
icon: Icon,
|
|
title,
|
|
description,
|
|
enabled,
|
|
onEnabledChange,
|
|
chips,
|
|
children,
|
|
}: ChannelCardProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const bodyId = useId();
|
|
const expandable = children != null;
|
|
|
|
const header = (
|
|
<>
|
|
<span
|
|
className={cn(
|
|
"flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition-colors",
|
|
enabled ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground",
|
|
)}
|
|
>
|
|
<Icon className="h-5 w-5" />
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
<span className="text-sm font-medium">{title}</span>
|
|
{chips}
|
|
</span>
|
|
<span className="text-muted-foreground mt-0.5 block text-xs leading-relaxed">
|
|
{description}
|
|
</span>
|
|
</span>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<div className="surface-panel overflow-hidden rounded-2xl border-0">
|
|
<div className="flex items-center gap-3 p-4 sm:px-5">
|
|
{expandable ? (
|
|
<button
|
|
type="button"
|
|
aria-expanded={open}
|
|
aria-controls={bodyId}
|
|
onClick={() => setOpen((current) => !current)}
|
|
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 text-left"
|
|
>
|
|
{header}
|
|
<ChevronDown
|
|
className={cn(
|
|
"text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200",
|
|
open && "rotate-180",
|
|
)}
|
|
/>
|
|
</button>
|
|
) : (
|
|
<div className="flex min-w-0 flex-1 items-center gap-3">{header}</div>
|
|
)}
|
|
<Switch
|
|
checked={enabled}
|
|
onCheckedChange={(value) => {
|
|
onEnabledChange(value);
|
|
// Enabling a channel usually means configuring it next.
|
|
if (value && expandable) setOpen(true);
|
|
}}
|
|
aria-label={`Enable ${title}`}
|
|
/>
|
|
</div>
|
|
{expandable && open && (
|
|
<div
|
|
id={bodyId}
|
|
className="border-border/60 animate-in fade-in-0 slide-in-from-top-1 border-t px-4 pt-1 pb-4 duration-200 sm:px-5"
|
|
>
|
|
{children}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DiscordSetupGuide() {
|
|
const [open, setOpen] = useState(false);
|
|
const guideId = useId();
|
|
|
|
return (
|
|
<div className="py-2">
|
|
<button
|
|
type="button"
|
|
aria-expanded={open}
|
|
aria-controls={guideId}
|
|
onClick={() => setOpen((current) => !current)}
|
|
className="text-muted-foreground hover:text-foreground inline-flex cursor-pointer items-center gap-1.5 text-xs font-medium transition-colors"
|
|
>
|
|
<BookOpen className="h-3.5 w-3.5" />
|
|
{open ? "Hide setup guide" : "Show setup guide"}
|
|
<ChevronDown
|
|
className={cn("h-3.5 w-3.5 transition-transform duration-200", open && "rotate-180")}
|
|
/>
|
|
</button>
|
|
{open && (
|
|
<div
|
|
id={guideId}
|
|
className="text-muted-foreground animate-in fade-in-0 mt-3 space-y-1.5 text-xs leading-relaxed duration-200"
|
|
>
|
|
<p>Set up at discord.com/developers/applications:</p>
|
|
<ol className="list-decimal space-y-1.5 pl-4">
|
|
<li>Create an application (or open an existing one).</li>
|
|
<li>
|
|
OAuth2 page: copy the <strong>Client ID</strong>, reset and copy the{" "}
|
|
<strong>Client Secret</strong>, and under Redirects add
|
|
<code className="bg-muted mx-1 rounded px-1">
|
|
{"<public URL>"}/api/v1/notifications/discord/link/callback
|
|
</code>
|
|
using this server's public URL (SILO_PUBLIC_URL) — it must match exactly.
|
|
</li>
|
|
<li>
|
|
Bot page: reset and copy the <strong>Token</strong>. Leave all Privileged Gateway
|
|
Intents (Presence, Server Members, Message Content) <strong>off</strong> — Silo never
|
|
connects to the gateway; it only sends DMs.
|
|
</li>
|
|
<li>
|
|
Keep <strong>Requires OAuth2 Code Grant</strong> off, or the invite link below
|
|
won't work. Enable <strong>Public Bot</strong> only if someone other than the
|
|
application owner will be inviting it.
|
|
</li>
|
|
<li>
|
|
Paste the three credentials below, save, then use the invite button to add the bot to
|
|
your Discord server. It needs <strong>no role permissions</strong> — membership alone
|
|
lets it DM members, and users must share that server with the bot to receive DMs.
|
|
</li>
|
|
</ol>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InviteBotRow({ clientId }: { clientId: string }) {
|
|
const [copied, setCopied] = useState(false);
|
|
const trimmed = clientId.trim();
|
|
|
|
return (
|
|
<div className="space-y-2 py-2">
|
|
<div className="flex flex-wrap gap-1.5">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={!trimmed}
|
|
onClick={() => window.open(discordInviteUrl(trimmed), "_blank", "noopener,noreferrer")}
|
|
>
|
|
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
|
Invite bot to server
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={!trimmed}
|
|
onClick={() => {
|
|
void navigator.clipboard.writeText(discordInviteUrl(trimmed));
|
|
setCopied(true);
|
|
toast.success("Invite link copied");
|
|
window.setTimeout(() => setCopied(false), 2000);
|
|
}}
|
|
>
|
|
{copied ? (
|
|
<Check className="mr-1.5 h-3.5 w-3.5" />
|
|
) : (
|
|
<Copy className="mr-1.5 h-3.5 w-3.5" />
|
|
)}
|
|
Copy link
|
|
</Button>
|
|
</div>
|
|
<div className="text-muted-foreground text-xs">
|
|
{trimmed
|
|
? "Anyone with Manage Server permission on your Discord server can open this link to add the bot. Users must be members of that server to receive DMs."
|
|
: "Enter the Client ID above to generate the invite link."}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TestDiscordRow({ unsaved }: { unsaved: boolean }) {
|
|
const [pending, setPending] = useState(false);
|
|
const [result, setResult] = useState<DiscordTestResult | null>(null);
|
|
|
|
const runTest = async () => {
|
|
setPending(true);
|
|
setResult(null);
|
|
try {
|
|
const response = await api<DiscordTestResult>("/admin/notifications/discord/test", {
|
|
method: "POST",
|
|
});
|
|
setResult(response);
|
|
if (response.ok) {
|
|
toast.success("Discord bot connected");
|
|
}
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "Test request failed");
|
|
} finally {
|
|
setPending(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-2 py-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={pending || unsaved}
|
|
onClick={() => void runTest()}
|
|
>
|
|
{pending ? (
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Send className="mr-1.5 h-3.5 w-3.5" />
|
|
)}
|
|
Test bot token
|
|
</Button>
|
|
{unsaved && (
|
|
<div className="text-muted-foreground text-xs">
|
|
Save your changes first — the test uses the saved credentials.
|
|
</div>
|
|
)}
|
|
{result && (
|
|
<div className={`text-xs ${result.ok ? "text-emerald-500" : "text-amber-500"}`}>
|
|
{result.ok ? "Success" : "Failed"} ({result.duration_ms}ms)
|
|
{result.message ? ` — ${result.message}` : ""}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RegisterRelayRow({
|
|
relayURL,
|
|
deploymentID,
|
|
keyPrefix,
|
|
expiresAt,
|
|
reregistrationRequired,
|
|
urlEdited,
|
|
onRegistered,
|
|
}: {
|
|
relayURL: string;
|
|
deploymentID: string;
|
|
keyPrefix: string;
|
|
expiresAt: string;
|
|
reregistrationRequired: boolean;
|
|
urlEdited: boolean;
|
|
onRegistered: () => void;
|
|
}) {
|
|
const queryClient = useQueryClient();
|
|
const [pending, setPending] = useState(false);
|
|
const [result, setResult] = useState<AppleRelayRegisterResult | null>(null);
|
|
|
|
const configured = deploymentID.trim() !== "";
|
|
const actionLabel = reregistrationRequired
|
|
? "Re-register relay"
|
|
: configured
|
|
? "Rotate credential"
|
|
: "Register relay";
|
|
const expiration = expiresAt ? new Date(expiresAt) : null;
|
|
const expirationValid = expiration != null && !Number.isNaN(expiration.getTime());
|
|
const renewalStatus = expirationValid
|
|
? expiration.getTime() <= Date.now()
|
|
? "Expired; Silo will renew before the next delivery when the relay grace period permits."
|
|
: `Expires ${expiration.toLocaleString()}; Silo renews automatically during the final week with per-server jitter.`
|
|
: configured
|
|
? "Expiration is unknown; the credential will be refreshed on its next lifecycle operation."
|
|
: "No relay credential is registered.";
|
|
|
|
const registerRelay = async () => {
|
|
if (pending) return;
|
|
setPending(true);
|
|
setResult(null);
|
|
try {
|
|
const response = await api<AppleRelayRegisterResult>(
|
|
"/admin/notifications/push/relay/register",
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
relay_url: relayURL,
|
|
}),
|
|
},
|
|
);
|
|
setResult(response);
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: adminKeys.serverSettings() }),
|
|
queryClient.invalidateQueries({
|
|
queryKey: [...adminKeys.serverSettings(), "sensitive-status"] as const,
|
|
}),
|
|
]);
|
|
onRegistered();
|
|
toast.success("Push relay registered");
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "Relay registration failed");
|
|
} finally {
|
|
setPending(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3 py-3">
|
|
<SettingField
|
|
label="Deployment ID"
|
|
hint="Opaque relay account for this Silo server; created during registration"
|
|
type="text"
|
|
value={deploymentID}
|
|
onChange={() => {}}
|
|
disabled
|
|
/>
|
|
<div className="flex flex-wrap items-center gap-2 py-2">
|
|
<Button variant="outline" size="sm" disabled={pending} onClick={() => void registerRelay()}>
|
|
{pending ? (
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<KeyRound className="mr-1.5 h-3.5 w-3.5" />
|
|
)}
|
|
{actionLabel}
|
|
</Button>
|
|
</div>
|
|
{reregistrationRequired && (
|
|
<div className="text-xs text-amber-500">
|
|
The current capability was rejected or revoked. Automatic registration is disabled;
|
|
re-register explicitly to create a new relay deployment.
|
|
</div>
|
|
)}
|
|
<div className="text-muted-foreground space-y-1 text-xs">
|
|
{keyPrefix && <div>Credential: {keyPrefix}</div>}
|
|
<div>{renewalStatus}</div>
|
|
</div>
|
|
{urlEdited && (
|
|
<div className="text-muted-foreground text-xs">
|
|
The relay URL change is applied when you register; credentials are stored immediately.
|
|
</div>
|
|
)}
|
|
{result && (
|
|
<div className="text-xs text-emerald-500">
|
|
Credential ready for {result.deployment_id}
|
|
{result.key_prefix ? ` — key ${result.key_prefix}` : ""}
|
|
{result.relay_request_id ? ` — relay ${result.relay_request_id}` : ""}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ApplePushPrivacyDisclosure() {
|
|
return (
|
|
<div className="space-y-2 py-3">
|
|
<div className="text-sm font-medium">Privacy disclosure</div>
|
|
<div className="text-muted-foreground space-y-2 text-xs leading-relaxed">
|
|
<p>
|
|
If you enable push notifications, your Silo Server sends a content-free request to Silo's
|
|
push relay so Silo can deliver notifications through Apple Push Notification service.
|
|
</p>
|
|
<p>
|
|
The relay does not receive notification titles, message bodies, media names, user names,
|
|
profile names, or your server URL. It does process technical metadata needed to deliver
|
|
and operate the service, including an opaque deployment identifier, push delivery timing,
|
|
request status, app topic, the IP address your self-hosted Silo Server uses to contact the
|
|
relay, and a hashed device push token. Apple may also process standard APNs delivery
|
|
metadata.
|
|
</p>
|
|
<p>
|
|
Push notifications are generic; the app fetches private content directly from your Silo
|
|
Server after receiving the push.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function NotificationsAdminSettings() {
|
|
const form = useSettingsForm({ keys: useMemo(() => KEYS, []) });
|
|
const { data: serverChannels } = useServerNotificationChannels();
|
|
// Local draft for the relay URL; null means "show the saved value".
|
|
const [pushRelayURLDraft, setPushRelayURLDraft] = useState<string | null>(null);
|
|
|
|
if (form.isLoading) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-7 w-44" />
|
|
<Skeleton className="h-4 w-full max-w-xl" />
|
|
</div>
|
|
<Skeleton className="h-36 w-full rounded-2xl" />
|
|
<div className="space-y-3">
|
|
{[0, 1, 2, 3].map((i) => (
|
|
<Skeleton key={i} className="h-[72px] w-full rounded-2xl" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Kill switches default to enabled when unset; the backend treats any
|
|
// unrecognized value as the default, so an empty stored value means "on".
|
|
const toggleValue = (key: string) => form.getValue(key) || "true";
|
|
const isOn = (key: string) => toggleValue(key) === "true";
|
|
const setToggle = (key: string) => (value: boolean) =>
|
|
form.setValue(key, value ? "true" : "false");
|
|
// Numeric settings fall back to their server-side defaults when unset;
|
|
// surface the effective default instead of an empty input.
|
|
const numberValue = (key: string, fallback: string) => form.getValue(key) || fallback;
|
|
|
|
const releaseEventsOn = isOn("notifications.release_events_enabled");
|
|
const fanoutOn = isOn("notifications.fanout_enabled");
|
|
const uiOn = isOn("notifications.ui_enabled");
|
|
const webPushOn = isOn("notifications.web_push_enabled");
|
|
const emailOn = isOn("notifications.email_enabled");
|
|
const serverChannelsOn = isOn("notifications.server_channels_enabled");
|
|
// Mobile push, Discord, and personal webhooks are opt-in (default off).
|
|
const applePushOn = form.getValue("notifications.apple_push_delivery_enabled") === "true";
|
|
const discordOn = form.getValue("notifications.discord_enabled") === "true";
|
|
const webhooksOn = form.getValue("notifications.webhooks_enabled") === "true";
|
|
|
|
// The relay URL is not part of the settings form: the server only persists
|
|
// it through the registration endpoint, alongside the credentials it mints.
|
|
const savedPushRelayURL = form.getValue("notifications.push_relay_url") || DEFAULT_PUSH_RELAY_URL;
|
|
const pushRelayURL = pushRelayURLDraft ?? savedPushRelayURL;
|
|
const pushRelayURLEdited = pushRelayURL !== savedPushRelayURL;
|
|
const pushRelayDeploymentID = form.getValue("notifications.push_relay_deployment_id");
|
|
const pushRelayKeyPrefix = form.getValue("notifications.push_relay_key_prefix");
|
|
const pushRelayExpiresAt = form.getValue("notifications.push_relay_expires_at");
|
|
const pushRelayReregistrationRequired =
|
|
form.getValue("notifications.push_relay_reregistration_required") === "true";
|
|
const pushRelayAPIKeyReady = form.sensitiveConfigured.includes(
|
|
"notifications.push_relay_api_key",
|
|
);
|
|
const allowPrivate =
|
|
form.getValue("notifications.webhooks.allow_private_destinations") === "true";
|
|
// The test endpoint reads the SAVED credentials; testing with unsaved
|
|
// edits in the form would silently test the old values.
|
|
const discordCredentialsDirty = [
|
|
"discord.client_id",
|
|
"discord.client_secret",
|
|
"discord.bot_token",
|
|
].some(form.isDirty);
|
|
const discordCredentialsReady =
|
|
form.getValue("discord.client_id").trim() !== "" &&
|
|
["discord.client_secret", "discord.bot_token"].every(
|
|
(key) => form.getValue(key) !== "" || form.sensitiveConfigured.includes(key),
|
|
);
|
|
|
|
const channelStates = [
|
|
uiOn,
|
|
webPushOn,
|
|
applePushOn,
|
|
emailOn,
|
|
discordOn,
|
|
webhooksOn,
|
|
serverChannelsOn,
|
|
];
|
|
const enabledChannelCount = channelStates.filter(Boolean).length;
|
|
|
|
const failingServerChannels = (serverChannels ?? []).filter(
|
|
(channel) =>
|
|
channel.last_failure_at != null &&
|
|
(channel.last_success_at == null || channel.last_failure_at > channel.last_success_at),
|
|
).length;
|
|
|
|
const pausedMessage = !releaseEventsOn
|
|
? "Release events are off — nothing new is recorded, so no notifications are produced."
|
|
: !fanoutOn
|
|
? "Fanout is paused — events queue until it is re-enabled or they expire."
|
|
: null;
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<div className="mb-6 space-y-2">
|
|
<h2 className="text-xl font-semibold tracking-tight">Notifications</h2>
|
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
Operational controls for the notification system. All settings apply live — no restart
|
|
needed. Per-profile preferences live in each user's own notification settings.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex-1 space-y-7">
|
|
{/* ── Pipeline: the master switches, framed as the flow they gate ── */}
|
|
<div className="surface-panel rounded-2xl border-0 p-4 sm:p-5">
|
|
<div className="text-muted-foreground mb-4 text-xs font-semibold tracking-[0.22em] uppercase">
|
|
Pipeline
|
|
</div>
|
|
<div className="grid gap-4 md:grid-cols-[1fr_auto_1fr_auto_1fr] md:gap-3">
|
|
<PipelineStage
|
|
icon={Rss}
|
|
title="Record events"
|
|
description="Log new-content availability during library scans. Off stops notifications at the source."
|
|
control={
|
|
<Switch
|
|
checked={releaseEventsOn}
|
|
onCheckedChange={setToggle("notifications.release_events_enabled")}
|
|
aria-label="Enable release events"
|
|
/>
|
|
}
|
|
/>
|
|
<PipelineArrow />
|
|
<PipelineStage
|
|
icon={Workflow}
|
|
title="Fan out"
|
|
description="Match recorded events to interested profiles and queue deliveries."
|
|
dimmed={!releaseEventsOn}
|
|
control={
|
|
<Switch
|
|
checked={fanoutOn}
|
|
onCheckedChange={setToggle("notifications.fanout_enabled")}
|
|
aria-label="Enable fanout"
|
|
/>
|
|
}
|
|
/>
|
|
<PipelineArrow />
|
|
<PipelineStage
|
|
icon={Bell}
|
|
title="Deliver"
|
|
description="Hand off to the delivery channels below."
|
|
dimmed={!releaseEventsOn || !fanoutOn}
|
|
control={
|
|
<Chip>
|
|
{enabledChannelCount}/{channelStates.length} channels on
|
|
</Chip>
|
|
}
|
|
/>
|
|
</div>
|
|
{pausedMessage && (
|
|
<div className="mt-4 flex items-start gap-2 text-xs text-amber-500">
|
|
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
|
|
<p>{pausedMessage}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Delivery channels ── */}
|
|
<section className="space-y-3">
|
|
<ZoneHeading
|
|
title="Delivery Channels"
|
|
description="Where notifications go once fanout queues them. Channels can be configured while switched off."
|
|
/>
|
|
|
|
<ChannelCard
|
|
icon={Inbox}
|
|
title="In-App"
|
|
description="Advertise the notification inbox and preferences to web and client apps."
|
|
enabled={uiOn}
|
|
onEnabledChange={setToggle("notifications.ui_enabled")}
|
|
/>
|
|
|
|
<ChannelCard
|
|
icon={MonitorSmartphone}
|
|
title="Web Push"
|
|
description="Browser push notifications to subscribed devices."
|
|
enabled={webPushOn}
|
|
onEnabledChange={setToggle("notifications.web_push_enabled")}
|
|
/>
|
|
|
|
<ChannelCard
|
|
icon={RadioTower}
|
|
title="Silo Push Relay"
|
|
description="Mobile push delivery through Silo's relay. Apple devices use APNs today; Android support will use the same relay when available."
|
|
enabled={applePushOn}
|
|
onEnabledChange={setToggle("notifications.apple_push_delivery_enabled")}
|
|
chips={
|
|
pushRelayReregistrationRequired ? (
|
|
<Chip tone="warning">Re-registration required</Chip>
|
|
) : pushRelayAPIKeyReady ? (
|
|
<Chip tone="positive">Relay configured</Chip>
|
|
) : (
|
|
<Chip tone={applePushOn ? "warning" : "neutral"}>Relay registration required</Chip>
|
|
)
|
|
}
|
|
>
|
|
<div className="divide-border divide-y">
|
|
<ApplePushPrivacyDisclosure />
|
|
<SettingField
|
|
label="Relay URL"
|
|
hint="Public relay endpoint used by this Silo server; stored when you register"
|
|
type="text"
|
|
value={pushRelayURL}
|
|
onChange={(v) => setPushRelayURLDraft(v)}
|
|
/>
|
|
<RegisterRelayRow
|
|
relayURL={pushRelayURL}
|
|
deploymentID={pushRelayDeploymentID}
|
|
keyPrefix={pushRelayKeyPrefix}
|
|
expiresAt={pushRelayExpiresAt}
|
|
reregistrationRequired={pushRelayReregistrationRequired}
|
|
urlEdited={pushRelayURLEdited}
|
|
onRegistered={() => setPushRelayURLDraft(null)}
|
|
/>
|
|
</div>
|
|
</ChannelCard>
|
|
|
|
<ChannelCard
|
|
icon={Mail}
|
|
title="Email"
|
|
description="Notifications by email for accounts that opt in, as a daily digest or per episode."
|
|
enabled={emailOn}
|
|
onEnabledChange={setToggle("notifications.email_enabled")}
|
|
chips={
|
|
<Chip>
|
|
Digest at {digestHourLabel(form.getValue("notifications.email.digest_hour"))}
|
|
</Chip>
|
|
}
|
|
>
|
|
<div className="text-muted-foreground border-border/60 border-b py-3 text-xs">
|
|
Requires working SMTP — configure it on the{" "}
|
|
<Link
|
|
to="?tab=email"
|
|
className="text-foreground font-medium underline-offset-2 hover:underline"
|
|
>
|
|
Email settings page
|
|
</Link>
|
|
.
|
|
</div>
|
|
<div className="divide-border divide-y">
|
|
<SettingField
|
|
label="Allow Per-Episode Email"
|
|
hint="Let users choose an email per episode instead of the daily digest. Off coerces those accounts to the digest."
|
|
type="toggle"
|
|
value={toggleValue("notifications.email.allow_per_episode")}
|
|
onChange={(v) => form.setValue("notifications.email.allow_per_episode", v)}
|
|
/>
|
|
<SettingField
|
|
label="Digest Hour"
|
|
hint="Hour of day (0-23, server time) when daily digest emails go out (default 8)"
|
|
type="number"
|
|
value={numberValue("notifications.email.digest_hour", "8")}
|
|
onChange={(v) => form.setValue("notifications.email.digest_hour", v)}
|
|
/>
|
|
<SettingField
|
|
label="External URL"
|
|
hint="Public base URL of this server (e.g. https://silo.example.com) used for links inside emails. Empty sends emails without links."
|
|
type="text"
|
|
value={form.getValue("notifications.email.external_url")}
|
|
onChange={(v) => form.setValue("notifications.email.external_url", v)}
|
|
/>
|
|
</div>
|
|
</ChannelCard>
|
|
|
|
<ChannelCard
|
|
icon={Bot}
|
|
title="Discord"
|
|
description="Bot DMs for linked accounts, plus appearance for every Discord delivery surface."
|
|
enabled={discordOn}
|
|
onEnabledChange={setToggle("notifications.discord_enabled")}
|
|
chips={
|
|
discordCredentialsReady ? (
|
|
<Chip tone="positive">Credentials configured</Chip>
|
|
) : (
|
|
<Chip tone={discordOn ? "warning" : "neutral"}>Setup required</Chip>
|
|
)
|
|
}
|
|
>
|
|
<SubsectionLabel>Bot Setup</SubsectionLabel>
|
|
<DiscordSetupGuide />
|
|
<div className="divide-border divide-y">
|
|
<SettingField
|
|
label="Client ID"
|
|
hint="The Discord application's OAuth2 client ID (used for account linking)"
|
|
type="text"
|
|
value={form.getValue("discord.client_id")}
|
|
onChange={(v) => form.setValue("discord.client_id", v)}
|
|
/>
|
|
<InviteBotRow clientId={form.getValue("discord.client_id")} />
|
|
<SettingField
|
|
label="Client Secret"
|
|
hint="The Discord application's OAuth2 client secret"
|
|
type="password"
|
|
value={form.getValue("discord.client_secret")}
|
|
sensitiveConfigured={form.sensitiveConfigured.includes("discord.client_secret")}
|
|
onChange={(v) => form.setValue("discord.client_secret", v)}
|
|
/>
|
|
<SettingField
|
|
label="Bot Token"
|
|
hint="The bot user's token (used to send DMs)"
|
|
type="password"
|
|
value={form.getValue("discord.bot_token")}
|
|
sensitiveConfigured={form.sensitiveConfigured.includes("discord.bot_token")}
|
|
onChange={(v) => form.setValue("discord.bot_token", v)}
|
|
/>
|
|
<TestDiscordRow unsaved={discordCredentialsDirty} />
|
|
</div>
|
|
<SubsectionLabel>Delivery</SubsectionLabel>
|
|
<div className="divide-border divide-y">
|
|
<SettingField
|
|
label="Allow Per-Episode DMs"
|
|
hint="Let users choose a DM per episode instead of the daily digest. Off coerces those accounts to the digest."
|
|
type="toggle"
|
|
value={toggleValue("notifications.discord.allow_per_episode")}
|
|
onChange={(v) => form.setValue("notifications.discord.allow_per_episode", v)}
|
|
/>
|
|
<SettingField
|
|
label="Digest Hour"
|
|
hint="Hour of day (0-23, server time) when daily digest DMs go out (default 8)"
|
|
type="number"
|
|
value={numberValue("notifications.discord.digest_hour", "8")}
|
|
onChange={(v) => form.setValue("notifications.discord.digest_hour", v)}
|
|
/>
|
|
</div>
|
|
<SubsectionLabel>Appearance — All Discord Surfaces</SubsectionLabel>
|
|
<SettingField
|
|
label="Embed Posters"
|
|
hint="Artwork in outgoing Discord messages (personal webhooks, bot DMs, server channels). Provider CDNs serve posters from TMDB/TVDB and never reveal this server. Server storage additionally serves locally cached posters from this server's image storage — its URL becomes visible to message recipients and must be reachable from the internet."
|
|
type="select"
|
|
value={form.getValue("notifications.discord.poster_mode") || "provider"}
|
|
options={[
|
|
{ value: "provider", label: "Provider CDNs only (default)" },
|
|
{ value: "server", label: "Provider CDNs + server storage" },
|
|
{ value: "off", label: "No images" },
|
|
]}
|
|
onChange={(v) => form.setValue("notifications.discord.poster_mode", v)}
|
|
/>
|
|
</ChannelCard>
|
|
|
|
<ChannelCard
|
|
icon={Webhook}
|
|
title="Personal Webhooks"
|
|
description="User-created webhooks (Discord or generic) that receive their personal notifications — the server sends requests to user-chosen URLs."
|
|
enabled={webhooksOn}
|
|
onEnabledChange={setToggle("notifications.webhooks_enabled")}
|
|
chips={
|
|
allowPrivate ? <Chip tone="warning">Private destinations allowed</Chip> : undefined
|
|
}
|
|
>
|
|
<div className="divide-border divide-y">
|
|
<SettingField
|
|
label="Max Webhooks Per Profile"
|
|
hint="How many webhooks a single profile may create (default 10)"
|
|
type="number"
|
|
value={numberValue("notifications.webhooks.max_per_profile", "10")}
|
|
onChange={(v) => form.setValue("notifications.webhooks.max_per_profile", v)}
|
|
/>
|
|
<SettingField
|
|
label="Deliveries Per Minute Per Profile"
|
|
hint="Webhook delivery rate limit; over-limit notifications still reach the inbox (default 60)"
|
|
type="number"
|
|
value={numberValue(
|
|
"notifications.webhooks.deliveries_per_minute_per_profile",
|
|
"60",
|
|
)}
|
|
onChange={(v) =>
|
|
form.setValue("notifications.webhooks.deliveries_per_minute_per_profile", v)
|
|
}
|
|
/>
|
|
<SettingField
|
|
label="Allow Private Destinations"
|
|
hint="Disables the SSRF guard so webhooks may target private and LAN addresses. Development only."
|
|
type="toggle"
|
|
value={form.getValue("notifications.webhooks.allow_private_destinations")}
|
|
onChange={(v) =>
|
|
form.setValue("notifications.webhooks.allow_private_destinations", v)
|
|
}
|
|
/>
|
|
{allowPrivate && (
|
|
<div className="flex items-start gap-2 py-3 text-xs text-amber-500">
|
|
<TriangleAlert className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
|
<p>
|
|
Private destinations are allowed: any user with webhook access can make this
|
|
server send requests to internal network addresses. Leave this off outside
|
|
development.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ChannelCard>
|
|
|
|
<ChannelCard
|
|
icon={Megaphone}
|
|
title="Server Channels"
|
|
description="Admin-created broadcasts that post server-wide events (new content, request activity) to shared destinations, like a community Discord channel."
|
|
enabled={serverChannelsOn}
|
|
onEnabledChange={setToggle("notifications.server_channels_enabled")}
|
|
chips={
|
|
<>
|
|
{serverChannels != null && (
|
|
<Chip>
|
|
{serverChannels.length} destination{serverChannels.length === 1 ? "" : "s"}
|
|
</Chip>
|
|
)}
|
|
{failingServerChannels > 0 && (
|
|
<Chip tone="warning">{failingServerChannels} failing</Chip>
|
|
)}
|
|
</>
|
|
}
|
|
>
|
|
<div className="divide-border divide-y">
|
|
<SettingField
|
|
label="Batch Window (seconds)"
|
|
hint="How long new-content events collect before posting, so a season pack lands as one grouped message (default 300, minimum 120)"
|
|
type="number"
|
|
value={numberValue("notifications.server_channels.batch_seconds", "300")}
|
|
onChange={(v) => form.setValue("notifications.server_channels.batch_seconds", v)}
|
|
/>
|
|
<SettingField
|
|
label="Mention Requesters on Discord"
|
|
hint="Request posts to Discord destinations @mention the requesting user when their account has linked Discord (in user notification settings). Unlinked accounts show their Silo username without a mention."
|
|
type="toggle"
|
|
value={form.getValue("notifications.server_channels.mention_requesters")}
|
|
onChange={(v) =>
|
|
form.setValue("notifications.server_channels.mention_requesters", v)
|
|
}
|
|
/>
|
|
<div className="pt-3">
|
|
<ServerNotificationChannels />
|
|
</div>
|
|
</div>
|
|
</ChannelCard>
|
|
</section>
|
|
|
|
{/* ── Advanced ── */}
|
|
<section className="space-y-3">
|
|
<ZoneHeading
|
|
title="Advanced"
|
|
description="Batching, flood control, and cleanup. The defaults work well for most servers."
|
|
/>
|
|
<div className="grid gap-3 xl:grid-cols-2 xl:gap-6">
|
|
<FieldGroup label="Fanout Tuning">
|
|
<SettingField
|
|
label="Settle Delay (seconds)"
|
|
hint="How long an event must sit before fanout claims it, so one scan's episodes batch together (default 30)"
|
|
type="number"
|
|
value={numberValue("notifications.fanout.settle_seconds", "30")}
|
|
onChange={(v) => form.setValue("notifications.fanout.settle_seconds", v)}
|
|
/>
|
|
<SettingField
|
|
label="Max Series Burst"
|
|
hint="Max notifications per series per batch; the rest are suppressed to avoid floods (default 3)"
|
|
type="number"
|
|
value={numberValue("notifications.fanout.max_series_burst", "3")}
|
|
onChange={(v) => form.setValue("notifications.fanout.max_series_burst", v)}
|
|
/>
|
|
<SettingField
|
|
label="Max Event Age (hours)"
|
|
hint="Events older than this are dropped instead of delivered late, e.g. after extended downtime (default 72)"
|
|
type="number"
|
|
value={numberValue("notifications.fanout.max_event_age_hours", "72")}
|
|
onChange={(v) => form.setValue("notifications.fanout.max_event_age_hours", v)}
|
|
/>
|
|
</FieldGroup>
|
|
|
|
<FieldGroup label="Retention">
|
|
<SettingField
|
|
label="Read Notifications (days)"
|
|
hint="How long read inbox entries are kept (default 90)"
|
|
type="number"
|
|
value={numberValue("notifications.retention.read_days", "90")}
|
|
onChange={(v) => form.setValue("notifications.retention.read_days", v)}
|
|
/>
|
|
<SettingField
|
|
label="Unread Notifications (days)"
|
|
hint="How long unread inbox entries are kept (default 180)"
|
|
type="number"
|
|
value={numberValue("notifications.retention.unread_days", "180")}
|
|
onChange={(v) => form.setValue("notifications.retention.unread_days", v)}
|
|
/>
|
|
<SettingField
|
|
label="Processed Events (days)"
|
|
hint="How long processed release events are kept for debugging (default 30)"
|
|
type="number"
|
|
value={numberValue("notifications.retention.event_days", "30")}
|
|
onChange={(v) => form.setValue("notifications.retention.event_days", v)}
|
|
/>
|
|
</FieldGroup>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<SaveBar
|
|
dirtyCount={form.dirtyCount}
|
|
onSave={form.save}
|
|
onDiscard={form.discard}
|
|
isSaving={form.isSaving}
|
|
restartRequired={form.restartRequired}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|