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

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

View File

@@ -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 */}