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:
2026-03-14 01:45:10 +01:00
parent 924e162563
commit 81df6eb8ff
25 changed files with 635 additions and 53 deletions

View File

@@ -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">