feat: add access control to channels with various modes
- Introduced AccessMode enum to define channel access levels: Public, PasswordProtected, AccountRequired, and OwnerOnly. - Updated Channel and ProgrammingBlock entities to include access_mode and access_password_hash fields. - Enhanced create and update channel functionality to handle access mode and password. - Implemented access checks in channel routes based on the defined access modes. - Modified frontend components to support channel creation and editing with access control options. - Added ChannelPasswordModal for handling password input when accessing restricted channels. - Updated API calls to include channel and block passwords as needed. - Created database migrations to add access_mode and access_password_hash columns to channels table.
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { AccessMode } from "@/lib/types";
|
||||
|
||||
interface CreateChannelDialogProps {
|
||||
open: boolean;
|
||||
@@ -17,6 +18,8 @@ interface CreateChannelDialogProps {
|
||||
name: string;
|
||||
timezone: string;
|
||||
description: string;
|
||||
access_mode?: AccessMode;
|
||||
access_password?: string;
|
||||
}) => void;
|
||||
isPending: boolean;
|
||||
error?: string | null;
|
||||
@@ -32,10 +35,18 @@ export function CreateChannelDialog({
|
||||
const [name, setName] = useState("");
|
||||
const [timezone, setTimezone] = useState("UTC");
|
||||
const [description, setDescription] = useState("");
|
||||
const [accessMode, setAccessMode] = useState<AccessMode>("public");
|
||||
const [accessPassword, setAccessPassword] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit({ name, timezone, description });
|
||||
onSubmit({
|
||||
name,
|
||||
timezone,
|
||||
description,
|
||||
access_mode: accessMode !== "public" ? accessMode : undefined,
|
||||
access_password: accessMode === "password_protected" && accessPassword ? accessPassword : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
@@ -45,6 +56,8 @@ export function CreateChannelDialog({
|
||||
setName("");
|
||||
setTimezone("UTC");
|
||||
setDescription("");
|
||||
setAccessMode("public");
|
||||
setAccessPassword("");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -99,6 +112,33 @@ export function CreateChannelDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-zinc-400">Access</label>
|
||||
<select
|
||||
value={accessMode}
|
||||
onChange={(e) => setAccessMode(e.target.value as AccessMode)}
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-zinc-500 focus:outline-none"
|
||||
>
|
||||
<option value="public">Public</option>
|
||||
<option value="password_protected">Password protected</option>
|
||||
<option value="account_required">Account required</option>
|
||||
<option value="owner_only">Owner only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{accessMode === "password_protected" && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-zinc-400">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={accessPassword}
|
||||
onChange={(e) => setAccessPassword(e.target.value)}
|
||||
placeholder="Channel password"
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SeriesPicker } from "./series-picker";
|
||||
import { FilterPreview } from "./filter-preview";
|
||||
import { useCollections, useSeries, useGenres } from "@/hooks/use-library";
|
||||
import type {
|
||||
AccessMode,
|
||||
ChannelResponse,
|
||||
ProgrammingBlock,
|
||||
BlockContent,
|
||||
@@ -42,6 +43,8 @@ const mediaFilterSchema = z.object({
|
||||
search_term: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const accessModeSchema = z.enum(["public", "password_protected", "account_required", "owner_only"]);
|
||||
|
||||
const blockSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1, "Block name is required"),
|
||||
@@ -60,6 +63,8 @@ const blockSchema = z.object({
|
||||
]),
|
||||
loop_on_finish: z.boolean().optional(),
|
||||
ignore_recycle_policy: z.boolean().optional(),
|
||||
access_mode: accessModeSchema.optional(),
|
||||
access_password: z.string().optional(),
|
||||
});
|
||||
|
||||
const channelFormSchema = z.object({
|
||||
@@ -73,6 +78,8 @@ const channelFormSchema = z.object({
|
||||
min_available_ratio: z.number().min(0, "Must be ≥ 0").max(1, "Must be ≤ 1"),
|
||||
}),
|
||||
auto_schedule: z.boolean(),
|
||||
access_mode: accessModeSchema.optional(),
|
||||
access_password: z.string().optional(),
|
||||
});
|
||||
|
||||
type FieldErrors = Record<string, string | undefined>;
|
||||
@@ -216,6 +223,7 @@ function defaultBlock(startMins = 20 * 60, durationMins = 60): ProgrammingBlock
|
||||
content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" },
|
||||
loop_on_finish: true,
|
||||
ignore_recycle_policy: false,
|
||||
access_mode: "public",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -603,6 +611,29 @@ function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemo
|
||||
<p className="text-[11px] text-zinc-600">One Jellyfin item ID per line, played in order.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Block-level access control */}
|
||||
<div className="space-y-2 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Block access</p>
|
||||
<NativeSelect
|
||||
value={block.access_mode ?? "public"}
|
||||
onChange={(v) => onChange({ ...block, access_mode: v as AccessMode })}
|
||||
>
|
||||
<option value="public">Public</option>
|
||||
<option value="password_protected">Password protected</option>
|
||||
<option value="account_required">Account required</option>
|
||||
<option value="owner_only">Owner only</option>
|
||||
</NativeSelect>
|
||||
{(block.access_mode === "password_protected") && (
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Block password (leave blank to keep existing)"
|
||||
value={block.access_password ?? ""}
|
||||
onChange={(e) => onChange({ ...block, access_password: e.target.value })}
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -678,6 +709,8 @@ interface EditChannelSheetProps {
|
||||
schedule_config: { blocks: ProgrammingBlock[] };
|
||||
recycle_policy: RecyclePolicy;
|
||||
auto_schedule: boolean;
|
||||
access_mode?: AccessMode;
|
||||
access_password?: string;
|
||||
},
|
||||
) => void;
|
||||
isPending: boolean;
|
||||
@@ -702,6 +735,8 @@ export function EditChannelSheet({
|
||||
min_available_ratio: 0.1,
|
||||
});
|
||||
const [autoSchedule, setAutoSchedule] = useState(false);
|
||||
const [accessMode, setAccessMode] = useState<AccessMode>("public");
|
||||
const [accessPassword, setAccessPassword] = useState("");
|
||||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
|
||||
@@ -713,6 +748,8 @@ export function EditChannelSheet({
|
||||
setBlocks(channel.schedule_config.blocks);
|
||||
setRecyclePolicy(channel.recycle_policy);
|
||||
setAutoSchedule(channel.auto_schedule);
|
||||
setAccessMode(channel.access_mode ?? "public");
|
||||
setAccessPassword("");
|
||||
setSelectedBlockId(null);
|
||||
setFieldErrors({});
|
||||
}
|
||||
@@ -723,7 +760,8 @@ export function EditChannelSheet({
|
||||
if (!channel) return;
|
||||
|
||||
const result = channelFormSchema.safeParse({
|
||||
name, description, timezone, blocks, recycle_policy: recyclePolicy, auto_schedule: autoSchedule,
|
||||
name, description, timezone, blocks, recycle_policy: recyclePolicy,
|
||||
auto_schedule: autoSchedule, access_mode: accessMode, access_password: accessPassword,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
@@ -739,6 +777,8 @@ export function EditChannelSheet({
|
||||
schedule_config: { blocks },
|
||||
recycle_policy: recyclePolicy,
|
||||
auto_schedule: autoSchedule,
|
||||
access_mode: accessMode !== "public" ? accessMode : "public",
|
||||
access_password: accessPassword || "",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -822,6 +862,25 @@ export function EditChannelSheet({
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<Field label="Channel access">
|
||||
<NativeSelect value={accessMode} onChange={(v) => { setAccessMode(v as AccessMode); setAccessPassword(""); }}>
|
||||
<option value="public">Public</option>
|
||||
<option value="password_protected">Password protected</option>
|
||||
<option value="account_required">Account required</option>
|
||||
<option value="owner_only">Owner only</option>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
{accessMode === "password_protected" && (
|
||||
<Field label="Channel password" hint="Leave blank to keep existing password">
|
||||
<TextInput
|
||||
value={accessPassword}
|
||||
onChange={setAccessPassword}
|
||||
placeholder="New password…"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Programming blocks */}
|
||||
|
||||
@@ -107,9 +107,17 @@ export default function DashboardPage() {
|
||||
name: string;
|
||||
timezone: string;
|
||||
description: string;
|
||||
access_mode?: import("@/lib/types").AccessMode;
|
||||
access_password?: string;
|
||||
}) => {
|
||||
createChannel.mutate(
|
||||
{ name: data.name, timezone: data.timezone, description: data.description || undefined },
|
||||
{
|
||||
name: data.name,
|
||||
timezone: data.timezone,
|
||||
description: data.description || undefined,
|
||||
access_mode: data.access_mode,
|
||||
access_password: data.access_password,
|
||||
},
|
||||
{ onSuccess: () => setCreateOpen(false) },
|
||||
);
|
||||
};
|
||||
@@ -123,6 +131,8 @@ export default function DashboardPage() {
|
||||
schedule_config: { blocks: ProgrammingBlock[] };
|
||||
recycle_policy: RecyclePolicy;
|
||||
auto_schedule: boolean;
|
||||
access_mode?: import("@/lib/types").AccessMode;
|
||||
access_password?: string;
|
||||
},
|
||||
) => {
|
||||
updateChannel.mutate(
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
interface ChannelPasswordModalProps {
|
||||
label: string;
|
||||
onSubmit: (password: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ChannelPasswordModal({
|
||||
label,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: ChannelPasswordModalProps) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (value.trim()) onSubmit(value.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex w-80 flex-col gap-4 rounded-xl border border-zinc-700 bg-zinc-900 p-6 shadow-2xl"
|
||||
>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-semibold uppercase tracking-widest text-zinc-400">
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
className="rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/60 px-4 py-2 text-xs text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-zinc-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!value.trim()}
|
||||
className="flex-1 rounded-md bg-zinc-100 px-4 py-2 text-xs font-medium text-zinc-900 transition-colors hover:bg-white disabled:opacity-40"
|
||||
>
|
||||
Unlock
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,3 +16,5 @@ export type { UpNextBannerProps } from "./up-next-banner";
|
||||
|
||||
export { NoSignal } from "./no-signal";
|
||||
export type { NoSignalProps, NoSignalVariant } from "./no-signal";
|
||||
|
||||
export { ChannelPasswordModal } from "./channel-password-modal";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WifiOff, AlertTriangle, Loader2 } from "lucide-react";
|
||||
import { WifiOff, AlertTriangle, Loader2, Lock } from "lucide-react";
|
||||
|
||||
type NoSignalVariant = "no-signal" | "error" | "loading";
|
||||
type NoSignalVariant = "no-signal" | "error" | "loading" | "locked";
|
||||
|
||||
interface NoSignalProps {
|
||||
variant?: NoSignalVariant;
|
||||
@@ -27,6 +27,11 @@ const VARIANTS: Record<
|
||||
heading: "Loading",
|
||||
defaultMessage: "Tuning in…",
|
||||
},
|
||||
locked: {
|
||||
icon: <Lock className="h-10 w-10 text-zinc-600" />,
|
||||
heading: "Access Restricted",
|
||||
defaultMessage: "You don't have permission to watch this channel.",
|
||||
},
|
||||
};
|
||||
|
||||
export function NoSignal({ variant = "no-signal", message, children }: NoSignalProps) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ScheduleOverlay,
|
||||
UpNextBanner,
|
||||
NoSignal,
|
||||
ChannelPasswordModal,
|
||||
} from "./components";
|
||||
import type { SubtitleTrack } from "./components/video-player";
|
||||
import { Maximize2, Minimize2, Volume1, Volume2, VolumeX } from "lucide-react";
|
||||
@@ -76,6 +77,18 @@ function TvPageContent() {
|
||||
// Video ref — used to resume playback if autoplay was blocked on load
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Access control — persisted per channel in localStorage
|
||||
const [channelPasswords, setChannelPasswords] = useState<Record<string, string>>(() => {
|
||||
try { return JSON.parse(localStorage.getItem("channel_passwords") ?? "{}"); } catch { return {}; }
|
||||
});
|
||||
const [blockPasswords, setBlockPasswords] = useState<Record<string, string>>(() => {
|
||||
try { return JSON.parse(localStorage.getItem("block_passwords") ?? "{}"); } catch { return {}; }
|
||||
});
|
||||
const [showChannelPasswordModal, setShowChannelPasswordModal] = useState(false);
|
||||
const [showBlockPasswordModal, setShowBlockPasswordModal] = useState(false);
|
||||
|
||||
const channelPassword = channel ? channelPasswords[channel.id] : undefined;
|
||||
|
||||
// Stream error recovery
|
||||
const [streamError, setStreamError] = useState(false);
|
||||
|
||||
@@ -160,10 +173,13 @@ function TvPageContent() {
|
||||
}, []);
|
||||
|
||||
// Per-channel data
|
||||
const { data: broadcast, isLoading: isLoadingBroadcast } =
|
||||
useCurrentBroadcast(channel?.id ?? "");
|
||||
const { data: epgSlots } = useEpg(channel?.id ?? "");
|
||||
const { data: streamUrl } = useStreamUrl(channel?.id, token, broadcast?.slot.id);
|
||||
const { data: broadcast, isLoading: isLoadingBroadcast, error: broadcastError } =
|
||||
useCurrentBroadcast(channel?.id ?? "", channelPassword);
|
||||
const blockPassword = broadcast?.slot.id ? blockPasswords[broadcast.slot.id] : undefined;
|
||||
const { data: epgSlots } = useEpg(channel?.id ?? "", undefined, undefined, channelPassword);
|
||||
const { data: streamUrl, error: streamUrlError } = useStreamUrl(
|
||||
channel?.id, token, broadcast?.slot.id, channelPassword, blockPassword,
|
||||
);
|
||||
|
||||
// iOS Safari: track fullscreen state via webkit video element events.
|
||||
// Re-run when streamUrl changes so we catch the video element after it mounts.
|
||||
@@ -180,6 +196,20 @@ function TvPageContent() {
|
||||
};
|
||||
}, [streamUrl]);
|
||||
|
||||
// Show channel password modal when broadcast returns password_required
|
||||
useEffect(() => {
|
||||
if ((broadcastError as Error)?.message === "password_required") {
|
||||
setShowChannelPasswordModal(true);
|
||||
}
|
||||
}, [broadcastError]);
|
||||
|
||||
// Show block password modal when stream URL fetch returns password_required
|
||||
useEffect(() => {
|
||||
if ((streamUrlError as Error)?.message === "password_required") {
|
||||
setShowBlockPasswordModal(true);
|
||||
}
|
||||
}, [streamUrlError]);
|
||||
|
||||
// Clear transient states when a new slot is detected
|
||||
useEffect(() => {
|
||||
setStreamError(false);
|
||||
@@ -354,6 +384,24 @@ function TvPageContent() {
|
||||
setStreamError(false);
|
||||
}, [queryClient, channel?.id, broadcast?.slot.id]);
|
||||
|
||||
const submitChannelPassword = useCallback((password: string) => {
|
||||
if (!channel) return;
|
||||
const next = { ...channelPasswords, [channel.id]: password };
|
||||
setChannelPasswords(next);
|
||||
try { localStorage.setItem("channel_passwords", JSON.stringify(next)); } catch {}
|
||||
setShowChannelPasswordModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["broadcast", channel.id] });
|
||||
}, [channel, channelPasswords, queryClient]);
|
||||
|
||||
const submitBlockPassword = useCallback((password: string) => {
|
||||
if (!broadcast?.slot.id) return;
|
||||
const next = { ...blockPasswords, [broadcast.slot.id]: password };
|
||||
setBlockPasswords(next);
|
||||
try { localStorage.setItem("block_passwords", JSON.stringify(next)); } catch {}
|
||||
setShowBlockPasswordModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["stream-url", channel?.id, broadcast.slot.id] });
|
||||
}, [broadcast?.slot.id, blockPasswords, channel?.id, queryClient]);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Render helpers
|
||||
// ------------------------------------------------------------------
|
||||
@@ -370,6 +418,16 @@ function TvPageContent() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Channel-level access errors (not password — those show a modal)
|
||||
const broadcastErrMsg = (broadcastError as Error)?.message;
|
||||
if (broadcastErrMsg === "auth_required") {
|
||||
return <NoSignal variant="locked" message="Sign in to watch this channel." />;
|
||||
}
|
||||
if (broadcastErrMsg && broadcastError && (broadcastError as { status?: number }).status === 403) {
|
||||
return <NoSignal variant="locked" message="This channel is owner-only." />;
|
||||
}
|
||||
|
||||
if (isLoadingBroadcast) {
|
||||
return <NoSignal variant="loading" message="Tuning in…" />;
|
||||
}
|
||||
@@ -381,6 +439,16 @@ function TvPageContent() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Block-level access errors (not password — those show a modal overlay)
|
||||
const streamErrMsg = (streamUrlError as Error)?.message;
|
||||
if (streamErrMsg === "auth_required") {
|
||||
return <NoSignal variant="locked" message="Sign in to watch this block." />;
|
||||
}
|
||||
if (streamUrlError && (streamUrlError as { status?: number }).status === 403) {
|
||||
return <NoSignal variant="locked" message="This block is owner-only." />;
|
||||
}
|
||||
|
||||
if (streamError) {
|
||||
return (
|
||||
<NoSignal variant="error" message="Stream failed to load.">
|
||||
@@ -431,6 +499,24 @@ function TvPageContent() {
|
||||
{/* ── Base layer ─────────────────────────────────────────────── */}
|
||||
<div className="absolute inset-0">{renderBase()}</div>
|
||||
|
||||
{/* ── Channel password modal ──────────────────────────────────── */}
|
||||
{showChannelPasswordModal && (
|
||||
<ChannelPasswordModal
|
||||
label="Channel password required"
|
||||
onSubmit={submitChannelPassword}
|
||||
onCancel={() => setShowChannelPasswordModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Block password modal ────────────────────────────────────── */}
|
||||
{showBlockPasswordModal && (
|
||||
<ChannelPasswordModal
|
||||
label="Block password required"
|
||||
onSubmit={submitBlockPassword}
|
||||
onCancel={() => setShowBlockPasswordModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Autoplay blocked prompt ─────────────────────────────────── */}
|
||||
{needsInteraction && (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
||||
|
||||
@@ -26,11 +26,15 @@ export async function GET(
|
||||
) {
|
||||
const { channelId } = await params;
|
||||
const token = request.nextUrl.searchParams.get("token");
|
||||
const channelPassword = request.nextUrl.searchParams.get("channel_password");
|
||||
const blockPassword = request.nextUrl.searchParams.get("block_password");
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
if (channelPassword) headers["X-Channel-Password"] = channelPassword;
|
||||
if (blockPassword) headers["X-Block-Password"] = blockPassword;
|
||||
res = await fetch(`${API_URL}/channels/${channelId}/stream`, {
|
||||
headers,
|
||||
redirect: "manual",
|
||||
@@ -43,6 +47,11 @@ export async function GET(
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
return Response.json(body, { status: res.status });
|
||||
}
|
||||
|
||||
if (res.status === 307 || res.status === 302 || res.status === 301) {
|
||||
const location = res.headers.get("Location");
|
||||
if (location) {
|
||||
|
||||
Reference in New Issue
Block a user