From bd969b172bb3dedc9a7b0378d26a831dcff5ddf9 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 22 Jun 2026 17:16:37 +0200 Subject: [PATCH 01/12] feat: add granular allow-list data layer --- .../hooks/use-admin-granular-allow-list.ts | 96 +++++++++++++++++++ apps/web/src/hooks/use-allowed-channels.ts | 33 +++++++ apps/web/src/lib/api-admin-allow-list.ts | 91 ++++++++++++++++++ apps/web/src/lib/api-admin.ts | 4 +- apps/web/src/types/admin.ts | 3 + apps/web/src/types/allow-list.ts | 32 +++++++ apps/web/src/types/auth.ts | 3 + apps/web/src/types/user.ts | 10 ++ 8 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/hooks/use-admin-granular-allow-list.ts create mode 100644 apps/web/src/hooks/use-allowed-channels.ts create mode 100644 apps/web/src/lib/api-admin-allow-list.ts create mode 100644 apps/web/src/types/allow-list.ts diff --git a/apps/web/src/hooks/use-admin-granular-allow-list.ts b/apps/web/src/hooks/use-admin-granular-allow-list.ts new file mode 100644 index 0000000..970b507 --- /dev/null +++ b/apps/web/src/hooks/use-admin-granular-allow-list.ts @@ -0,0 +1,96 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + addAdminAllowedPlaylist, + addAdminUserAllowedChannel, + addAdminUserAllowedPlaylist, + fetchAdminAllowedPlaylists, + fetchAdminUserAllowList, + removeAdminAllowedPlaylist, + removeAdminUserAllowedChannel, + removeAdminUserAllowedPlaylist, + searchAdminUsers, + updateAdminUserAccessMode, +} from "../lib/api-admin-allow-list"; +import type { AllowPlaylistInput } from "../types/allow-list"; +import type { ChannelResultItem } from "../types/api"; +import type { AccessMode } from "../types/user"; + +export const ADMIN_ALLOWED_PLAYLISTS_KEY = ["admin-allowed-playlists"]; +export const adminUserAllowListKey = (id: string) => ["admin-user-allow-list", id]; + +export function useAdminAllowedPlaylists(enabled: boolean) { + return useQuery({ + queryKey: ADMIN_ALLOWED_PLAYLISTS_KEY, + queryFn: fetchAdminAllowedPlaylists, + enabled, + }); +} + +export function useAdminUserSearch(query: string, enabled: boolean) { + const q = query.trim(); + return useQuery({ + queryKey: ["admin-users-search", q], + queryFn: () => searchAdminUsers(q, 20), + enabled: enabled && q.length >= 2, + staleTime: 30 * 1000, + }); +} + +export function useAdminUserAllowList(userId: string | null) { + return useQuery({ + queryKey: userId ? adminUserAllowListKey(userId) : ["admin-user-allow-list", "none"], + queryFn: () => fetchAdminUserAllowList(userId ?? ""), + enabled: Boolean(userId), + }); +} + +export function useAdminAllowListMutations(selectedUserId: string | null) { + const qc = useQueryClient(); + const refreshUser = () => { + if (selectedUserId) qc.invalidateQueries({ queryKey: adminUserAllowListKey(selectedUserId) }); + }; + const refreshGlobalPlaylists = () => + qc.invalidateQueries({ queryKey: ADMIN_ALLOWED_PLAYLISTS_KEY }); + const userMode = useMutation({ + mutationFn: ({ id, accessMode }: { id: string; accessMode: AccessMode }) => + updateAdminUserAccessMode(id, accessMode), + onSuccess: refreshUser, + }); + const addUserChannel = useMutation({ + mutationFn: ({ id, channel }: { id: string; channel: ChannelResultItem }) => + addAdminUserAllowedChannel(id, channel.url, channel.name, channel.thumbnailUrl), + onSuccess: refreshUser, + }); + const removeUserChannel = useMutation({ + mutationFn: ({ id, url }: { id: string; url: string }) => + removeAdminUserAllowedChannel(id, url), + onSuccess: refreshUser, + }); + const addGlobalPlaylist = useMutation({ + mutationFn: addAdminAllowedPlaylist, + onSuccess: refreshGlobalPlaylists, + }); + const removeGlobalPlaylist = useMutation({ + mutationFn: removeAdminAllowedPlaylist, + onSuccess: refreshGlobalPlaylists, + }); + const addUserPlaylist = useMutation({ + mutationFn: ({ id, playlist }: { id: string; playlist: AllowPlaylistInput }) => + addAdminUserAllowedPlaylist(id, playlist), + onSuccess: refreshUser, + }); + const removeUserPlaylist = useMutation({ + mutationFn: ({ id, url }: { id: string; url: string }) => + removeAdminUserAllowedPlaylist(id, url), + onSuccess: refreshUser, + }); + return { + userMode, + addUserChannel, + removeUserChannel, + addGlobalPlaylist, + removeGlobalPlaylist, + addUserPlaylist, + removeUserPlaylist, + }; +} diff --git a/apps/web/src/hooks/use-allowed-channels.ts b/apps/web/src/hooks/use-allowed-channels.ts new file mode 100644 index 0000000..e53453d --- /dev/null +++ b/apps/web/src/hooks/use-allowed-channels.ts @@ -0,0 +1,33 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { allowChannel, disallowChannel, fetchAllowedChannels } from "../lib/api-collections"; +import { useAuth } from "./use-auth"; + +export const ALLOWED_CHANNELS_KEY = ["allowed-channels"]; + +type AllowChannelArgs = { + url: string; + name?: string | null; + thumbnailUrl?: string | null; + global?: boolean; +}; + +export function useAllowedChannels() { + const qc = useQueryClient(); + const { authReady, isAuthed } = useAuth(); + const query = useQuery({ + queryKey: ALLOWED_CHANNELS_KEY, + queryFn: fetchAllowedChannels, + enabled: authReady && isAuthed, + staleTime: 5 * 60 * 1000, + }); + const add = useMutation({ + mutationFn: ({ url, name, thumbnailUrl, global }: AllowChannelArgs) => + isAuthed ? allowChannel(url, name, thumbnailUrl, global) : Promise.resolve(null), + onSuccess: () => qc.invalidateQueries({ queryKey: ALLOWED_CHANNELS_KEY }), + }); + const remove = useMutation({ + mutationFn: (url: string) => (isAuthed ? disallowChannel(url) : Promise.resolve()), + onSuccess: () => qc.invalidateQueries({ queryKey: ALLOWED_CHANNELS_KEY }), + }); + return { query, add, remove }; +} diff --git a/apps/web/src/lib/api-admin-allow-list.ts b/apps/web/src/lib/api-admin-allow-list.ts new file mode 100644 index 0000000..c5bc1fe --- /dev/null +++ b/apps/web/src/lib/api-admin-allow-list.ts @@ -0,0 +1,91 @@ +import type { + AdminAllowListUser, + AdminUserAllowList, + AllowedPlaylistItem, + AllowPlaylistInput, +} from "../types/allow-list"; +import type { AccessMode } from "../types/user"; +import { ApiError } from "./api"; +import { authed, authedJson } from "./authed"; +import { API_BASE as BASE } from "./env"; + +export function searchAdminUsers(q: string, limit = 20): Promise { + const params = new URLSearchParams({ q, limit: String(limit) }); + return authedJson(`${BASE}/admin/users/search?${params}`); +} + +export function fetchAdminUserAllowList(id: string): Promise { + return authedJson(`${BASE}/admin/users/${encodeURIComponent(id)}/allow-list`); +} + +export async function updateAdminUserAccessMode( + id: string, + accessMode: AccessMode, +): Promise { + const res = await authed(`${BASE}/admin/users/${encodeURIComponent(id)}/access-mode`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accessMode }), + }); + const body = (await res.json().catch(() => ({ accessMode }))) as Partial<{ + accessMode: AccessMode; + }>; + if (!res.ok) throw new ApiError("Failed to update access mode", res.status); + return body.accessMode === "allow_list" ? "allow_list" : "unrestricted"; +} + +export function addAdminUserAllowedChannel( + id: string, + url: string, + name?: string | null, + thumbnailUrl?: string | null, +) { + return authedJson(`${BASE}/admin/users/${encodeURIComponent(id)}/allowed/channels`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url, name, thumbnailUrl }), + }); +} + +export async function removeAdminUserAllowedChannel(id: string, channelUrl: string): Promise { + const res = await authed( + `${BASE}/admin/users/${encodeURIComponent(id)}/allowed/channels/${encodeURIComponent(channelUrl)}`, + { method: "DELETE" }, + ); + if (!res.ok && res.status !== 404) throw new ApiError("Failed to remove channel", res.status); +} + +export function fetchAdminAllowedPlaylists(): Promise { + return authedJson(`${BASE}/admin/allowed/playlists`); +} + +export function addAdminAllowedPlaylist(input: AllowPlaylistInput): Promise { + return authedJson(`${BASE}/admin/allowed/playlists`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); +} + +export async function removeAdminAllowedPlaylist(url: string): Promise { + const res = await authed(`${BASE}/admin/allowed/playlists/${encodeURIComponent(url)}`, { + method: "DELETE", + }); + if (!res.ok && res.status !== 404) throw new ApiError("Failed to remove playlist", res.status); +} + +export function addAdminUserAllowedPlaylist(id: string, input: AllowPlaylistInput) { + return authedJson(`${BASE}/admin/users/${encodeURIComponent(id)}/allowed/playlists`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); +} + +export async function removeAdminUserAllowedPlaylist(id: string, url: string): Promise { + const res = await authed( + `${BASE}/admin/users/${encodeURIComponent(id)}/allowed/playlists/${encodeURIComponent(url)}`, + { method: "DELETE" }, + ); + if (!res.ok && res.status !== 404) throw new ApiError("Failed to remove playlist", res.status); +} diff --git a/apps/web/src/lib/api-admin.ts b/apps/web/src/lib/api-admin.ts index 737de1b..b16393d 100644 --- a/apps/web/src/lib/api-admin.ts +++ b/apps/web/src/lib/api-admin.ts @@ -27,7 +27,8 @@ function isAdminSettings(value: unknown): value is AdminSettings { typeof record.activeSessionsEnabled === "boolean" && typeof record.localLoginEnabled === "boolean" && typeof record.oidcAutoRedirect === "boolean" && - typeof record.youtubeRemoteLoginEnabled === "boolean" + typeof record.youtubeRemoteLoginEnabled === "boolean" && + (record.accessMode === "unrestricted" || record.accessMode === "allow_list") ); } @@ -39,6 +40,7 @@ function normalizeAdminUser(user: AuthUser): AuthUser { avatarUrl: user.avatarUrl ?? null, avatarType: user.avatarType ?? null, avatarCode: user.avatarCode ?? null, + accessMode: user.accessMode === "allow_list" ? "allow_list" : "unrestricted", }; } diff --git a/apps/web/src/types/admin.ts b/apps/web/src/types/admin.ts index 34c93a2..0da7bff 100644 --- a/apps/web/src/types/admin.ts +++ b/apps/web/src/types/admin.ts @@ -1,3 +1,5 @@ +import type { AccessMode } from "./user"; + export type AdminSettings = { name: string; tagline: string | null; @@ -11,6 +13,7 @@ export type AdminSettings = { localLoginEnabled: boolean; oidcAutoRedirect: boolean; youtubeRemoteLoginEnabled: boolean; + accessMode: AccessMode; }; type AdminSessionNowPlaying = { diff --git a/apps/web/src/types/allow-list.ts b/apps/web/src/types/allow-list.ts new file mode 100644 index 0000000..a605640 --- /dev/null +++ b/apps/web/src/types/allow-list.ts @@ -0,0 +1,32 @@ +import type { AccessMode, AllowedChannelItem } from "./user"; + +export type AdminAllowListUser = { + id: string; + email: string; + name: string; + accessMode: AccessMode; +}; + +export type AllowedPlaylistItem = { + url: string; + title: string | null; + thumbnailUrl: string | null; + uploaderName: string | null; + allowedAt: number; + global: boolean | null; +}; + +export type AdminUserAllowList = { + user: AdminAllowListUser; + globalChannels: AllowedChannelItem[]; + userChannels: AllowedChannelItem[]; + globalPlaylists: AllowedPlaylistItem[]; + userPlaylists: AllowedPlaylistItem[]; +}; + +export type AllowPlaylistInput = { + url: string; + title?: string | null; + thumbnailUrl?: string | null; + uploaderName?: string | null; +}; diff --git a/apps/web/src/types/auth.ts b/apps/web/src/types/auth.ts index 6c1eff5..6b0f711 100644 --- a/apps/web/src/types/auth.ts +++ b/apps/web/src/types/auth.ts @@ -1,3 +1,5 @@ +import type { AccessMode } from "./user"; + export type AuthRole = "admin" | "moderator" | "user"; export type AuthMe = { @@ -35,6 +37,7 @@ export type AuthUser = { avatarCode: string | null; suspended: boolean; verified: boolean; + accessMode: AccessMode; createdAt: number | string; }; diff --git a/apps/web/src/types/user.ts b/apps/web/src/types/user.ts index 3bab67b..f9a76f5 100644 --- a/apps/web/src/types/user.ts +++ b/apps/web/src/types/user.ts @@ -1,6 +1,7 @@ export type ServiceId = 0 | 5 | 6; export type SponsorBlockMode = "auto_skip" | "mark_only" | "disabled"; export type SponsorBlockCategoryAction = "auto_skip" | "mark_only" | "disabled"; +export type AccessMode = "unrestricted" | "allow_list"; type SponsorBlockCategoryActions = Record; export type HistoryItem = { @@ -107,6 +108,7 @@ export type SettingsItem = { hideRelatedVideos: boolean; hideComments: boolean; hideShorts: boolean; + accessMode: AccessMode; captionStyles: CaptionStyles; }; @@ -123,3 +125,11 @@ export type BlockedItem = { blockedAt: number; global?: boolean; }; + +export type AllowedChannelItem = { + url: string; + name: string | null; + thumbnailUrl: string | null; + allowedAt: number; + global?: boolean; +}; From 0b2274287769fa56d2662ea790ca827f754940d3 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 22 Jun 2026 17:16:54 +0200 Subject: [PATCH 02/12] feat: add admin allow-list channel controls --- .../admin-allow-list-channel-list.tsx | 98 +++++++++++++++++ .../src/components/admin-allow-list-form.tsx | 101 ++++++++++++++++++ .../src/components/allow-channel-button.tsx | 35 ++++++ apps/web/src/lib/channel-route-url.ts | 9 +- 4 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/admin-allow-list-channel-list.tsx create mode 100644 apps/web/src/components/admin-allow-list-form.tsx create mode 100644 apps/web/src/components/allow-channel-button.tsx diff --git a/apps/web/src/components/admin-allow-list-channel-list.tsx b/apps/web/src/components/admin-allow-list-channel-list.tsx new file mode 100644 index 0000000..f58e0ee --- /dev/null +++ b/apps/web/src/components/admin-allow-list-channel-list.tsx @@ -0,0 +1,98 @@ +import { channelRoutePath } from "../lib/channel-route-url"; +import type { AllowedChannelItem } from "../types/user"; +import { ChannelAvatar } from "./channel-avatar"; +import { ChannelRouteLink } from "./channel-route-link"; + +function XIcon() { + return ( + + ); +} + +type Props = { + title?: string; + channels: AllowedChannelItem[]; + onRemove?: (url: string) => void; +}; + +export function AdminAllowListChannelList({ + title = "Allowed channels", + channels, + onRemove, +}: Props) { + return ( +
+
+
+

{title}

+

+ Channels available when allow-list mode is enabled. +

+
+ + {channels.length} {channels.length === 1 ? "channel" : "channels"} + +
+ {channels.length === 0 ? ( +

No channels added.

+ ) : ( +
+ {channels.map((item) => { + const label = item.name ?? item.url; + const typeTypeUrl = channelRoutePath(item.url); + return ( +
+ +
+ + {label} + + + {typeTypeUrl} + +
+ {onRemove && ( + + )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/admin-allow-list-form.tsx b/apps/web/src/components/admin-allow-list-form.tsx new file mode 100644 index 0000000..64c87b5 --- /dev/null +++ b/apps/web/src/components/admin-allow-list-form.tsx @@ -0,0 +1,101 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useDebouncedValue } from "../hooks/use-debounced-value"; +import { fetchSearch } from "../lib/api-discovery"; +import { normalizeChannelUrl } from "../lib/channel-url"; +import { formatSubscribers } from "../lib/format"; +import { proxyImage } from "../lib/proxy"; +import type { ChannelResultItem } from "../types/api"; +import { ChannelAvatar } from "./channel-avatar"; +import { ChannelRouteLink } from "./channel-route-link"; + +function isTrusted(channel: ChannelResultItem, trustedUrls: Set): boolean { + return trustedUrls.has(normalizeChannelUrl(channel.url)); +} + +type Props = { + title: string; + description: string; + trustedUrls: string[]; + pending: boolean; + onAdd: (channel: ChannelResultItem) => void; +}; + +export function AdminAllowListForm({ title, description, trustedUrls, pending, onAdd }: Props) { + const [term, setTerm] = useState(""); + const debounced = useDebouncedValue(term.trim(), 300); + const trusted = new Set(trustedUrls.map(normalizeChannelUrl)); + const search = useQuery({ + queryKey: ["admin-allow-list-channel-search", debounced], + queryFn: () => fetchSearch(debounced, 0), + enabled: debounced.length >= 2, + staleTime: 60 * 1000, + }); + const channels = search.data?.channels ?? []; + + return ( +
+
+

{title}

+

{description}

+
+ setTerm(event.target.value)} + placeholder="Channel name or @handle" + className="h-10 w-full border border-border bg-app px-3 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-border-strong" + /> +
+ {debounced.length < 2 ? ( +
+ Type at least two characters to search channels. +
+ ) : search.isLoading ? ( +
Searching channels...
+ ) : channels.length === 0 ? ( +
+ No channels found. Try the exact channel name or handle. +
+ ) : ( +
+ {channels.slice(0, 8).map((channel) => { + const alreadyAdded = isTrusted(channel, trusted); + return ( +
+ +
+ + {channel.name} + +

+ {formatSubscribers(channel.subscriberCount)} +

+
+ +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/allow-channel-button.tsx b/apps/web/src/components/allow-channel-button.tsx new file mode 100644 index 0000000..c2374e1 --- /dev/null +++ b/apps/web/src/components/allow-channel-button.tsx @@ -0,0 +1,35 @@ +import { useAllowedChannels } from "../hooks/use-allowed-channels"; +import { useAuth } from "../hooks/use-auth"; +import { normalizeChannelUrl } from "../lib/channel-url"; + +type Props = { + url: string; + name?: string | null; + thumbnailUrl?: string | null; + compact?: boolean; +}; + +export function AllowChannelButton({ url, name, thumbnailUrl, compact = false }: Props) { + const { authReady, isAuthed } = useAuth(); + const { canGlobalBlock } = useAuth(); + const { query, add } = useAllowedChannels(); + if (!authReady || !isAuthed || !canGlobalBlock) return null; + const normalizedUrl = normalizeChannelUrl(url); + const allowed = (query.data ?? []).some( + (item) => normalizeChannelUrl(item.url) === normalizedUrl || item.name === name, + ); + return ( + + ); +} diff --git a/apps/web/src/lib/channel-route-url.ts b/apps/web/src/lib/channel-route-url.ts index 79aa556..95148ba 100644 --- a/apps/web/src/lib/channel-route-url.ts +++ b/apps/web/src/lib/channel-route-url.ts @@ -1,4 +1,4 @@ -import type { ChannelSort } from "./api"; +import type { ChannelSort } from "./api-discovery"; const YOUTUBE_CHANNEL_ID_PATTERN = /^UC[A-Za-z0-9_-]{22}$/; const YOUTUBE_HANDLE_PATTERN = /^@[A-Za-z0-9._-]{2,48}$/; @@ -78,3 +78,10 @@ export function channelLegacySearch( ): ChannelLegacySearch { return { url: toPublicChannelParam(sourceUrl), ...channelPathSearch(sort, query, tab) }; } + +export function channelRoutePath(sourceUrl: string): string { + const pathParam = toChannelPathParam(sourceUrl); + if (pathParam) return `/channel/${encodeURIComponent(pathParam)}`; + const params = new URLSearchParams({ url: toPublicChannelParam(sourceUrl) }); + return `/channel?${params.toString()}`; +} From aa4f4fc01c21c39af5177c9db7e2729d4c5ca59b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 22 Jun 2026 17:17:06 +0200 Subject: [PATCH 03/12] feat: add admin allow-list playlist controls --- .../admin-allow-list-playlist-list.tsx | 96 +++++++++++ .../admin-allow-list-playlist-search.tsx | 149 ++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 apps/web/src/components/admin-allow-list-playlist-list.tsx create mode 100644 apps/web/src/components/admin-allow-list-playlist-search.tsx diff --git a/apps/web/src/components/admin-allow-list-playlist-list.tsx b/apps/web/src/components/admin-allow-list-playlist-list.tsx new file mode 100644 index 0000000..6597c57 --- /dev/null +++ b/apps/web/src/components/admin-allow-list-playlist-list.tsx @@ -0,0 +1,96 @@ +import { Link } from "@tanstack/react-router"; +import { proxyImage } from "../lib/proxy"; +import type { AllowedPlaylistItem } from "../types/allow-list"; + +type Props = { + title: string; + playlists: AllowedPlaylistItem[]; + onRemove?: (url: string) => void; +}; + +function playlistPath(url: string): string { + const params = new URLSearchParams({ url }); + return `/playlist?${params.toString()}`; +} + +function XIcon() { + return ( + + ); +} + +export function AdminAllowListPlaylistList({ title, playlists, onRemove }: Props) { + return ( +
+
+

{title}

+ + {playlists.length} {playlists.length === 1 ? "playlist" : "playlists"} + +
+ {playlists.length === 0 ? ( +

No playlists added.

+ ) : ( +
+ {playlists.map((playlist) => { + const label = playlist.title ?? playlist.url; + return ( +
+ +
+ + {label} + + + {playlistPath(playlist.url)} + +
+ {onRemove && ( + + )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/admin-allow-list-playlist-search.tsx b/apps/web/src/components/admin-allow-list-playlist-search.tsx new file mode 100644 index 0000000..071862d --- /dev/null +++ b/apps/web/src/components/admin-allow-list-playlist-search.tsx @@ -0,0 +1,149 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { useState } from "react"; +import { useDebouncedValue } from "../hooks/use-debounced-value"; +import { fetchSearch } from "../lib/api-discovery"; +import { proxyImage } from "../lib/proxy"; +import type { AllowPlaylistInput } from "../types/allow-list"; +import type { PublicPlaylistInfo } from "../types/playlist"; + +type Props = { + title: string; + description: string; + addedUrls: string[]; + pending: boolean; + onAdd: (playlist: AllowPlaylistInput) => void; +}; + +function playlistPath(url: string): string { + const params = new URLSearchParams({ url }); + return `/playlist?${params.toString()}`; +} + +function playlistSourceUrl(input: string): string { + const trimmed = input.trim(); + if (trimmed.length === 0) return ""; + try { + const parsed = new URL(trimmed, "http://typetype.local"); + const sourceUrl = parsed.pathname === "/playlist" ? parsed.searchParams.get("url") : null; + return sourceUrl?.trim() || trimmed; + } catch { + return trimmed; + } +} + +export function AdminAllowListPlaylistSearch({ + title, + description, + addedUrls, + pending, + onAdd, +}: Props) { + const [term, setTerm] = useState(""); + const [url, setUrl] = useState(""); + const debounced = useDebouncedValue(term.trim(), 300); + const added = new Set(addedUrls); + const search = useQuery({ + queryKey: ["admin-allow-list-playlist-search", debounced], + queryFn: () => fetchSearch(debounced, 0), + enabled: debounced.length >= 2, + staleTime: 60 * 1000, + }); + const playlists = search.data?.playlists ?? []; + const playlistUrl = playlistSourceUrl(url); + const urlAlreadyAdded = added.has(playlistUrl); + + function addUrl() { + if (playlistUrl.length === 0 || urlAlreadyAdded || pending) return; + onAdd({ url: playlistUrl, title: null, thumbnailUrl: null, uploaderName: null }); + setUrl(""); + } + + function addPlaylist(playlist: PublicPlaylistInfo) { + onAdd({ + url: playlist.url, + title: playlist.title, + thumbnailUrl: playlist.thumbnailUrl, + uploaderName: playlist.uploaderName, + }); + } + + return ( +
+
+

{title}

+

{description}

+
+
+ setUrl(event.target.value)} + placeholder="Playlist URL" + className="h-10 flex-1 border border-border bg-app px-3 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-border-strong" + /> + +
+ setTerm(event.target.value)} + placeholder="Playlist name" + className="h-10 w-full border border-border bg-app px-3 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-border-strong" + /> +
+ {debounced.length < 2 ? ( +
Type at least two characters.
+ ) : search.isLoading ? ( +
Searching playlists...
+ ) : playlists.length === 0 ? ( +
No playlists found.
+ ) : ( +
+ {playlists.slice(0, 8).map((playlist) => { + const alreadyAdded = added.has(playlist.url); + return ( +
+ +
+ + {playlist.title} + +

{playlistPath(playlist.url)}

+
+ +
+ ); + })} +
+ )} +
+
+ ); +} From 2dba3ff93047896b57dca0717d90c608f7031318 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 22 Jun 2026 17:17:21 +0200 Subject: [PATCH 04/12] feat: add user scoped allow-list controls --- .../admin-allow-list-user-detail.tsx | 118 ++++++++++++++++++ .../src/components/admin-allow-list-users.tsx | 72 +++++++++++ 2 files changed, 190 insertions(+) create mode 100644 apps/web/src/components/admin-allow-list-user-detail.tsx create mode 100644 apps/web/src/components/admin-allow-list-users.tsx diff --git a/apps/web/src/components/admin-allow-list-user-detail.tsx b/apps/web/src/components/admin-allow-list-user-detail.tsx new file mode 100644 index 0000000..f8935e5 --- /dev/null +++ b/apps/web/src/components/admin-allow-list-user-detail.tsx @@ -0,0 +1,118 @@ +import { + useAdminAllowListMutations, + useAdminUserAllowList, +} from "../hooks/use-admin-granular-allow-list"; +import type { AdminAllowListUser } from "../types/allow-list"; +import { AdminAllowListChannelList } from "./admin-allow-list-channel-list"; +import { AdminAllowListForm } from "./admin-allow-list-form"; +import { AdminAllowListPlaylistList } from "./admin-allow-list-playlist-list"; +import { AdminAllowListPlaylistSearch } from "./admin-allow-list-playlist-search"; +import { AdminUserAvatar } from "./admin-user-avatar"; + +type Props = { + user: AdminAllowListUser; + onToast: (message: string) => void; +}; + +export function AdminAllowListUserDetail({ user, onToast }: Props) { + const detail = useAdminUserAllowList(user.id); + const mutations = useAdminAllowListMutations(user.id); + const data = detail.data; + const selected = data?.user ?? user; + const restricted = selected.accessMode === "allow_list"; + + function toggleMode() { + const accessMode = restricted ? "unrestricted" : "allow_list"; + mutations.userMode.mutate( + { id: selected.id, accessMode }, + { + onSuccess: () => + onToast(accessMode === "allow_list" ? "User restricted" : "User unrestricted"), + onError: (error) => + onToast(error instanceof Error ? error.message : "Unable to update user"), + }, + ); + } + + if (detail.isLoading || !data) { + return ( +
+ Loading user allow list... +
+ ); + } + + return ( +
+
+
+
+ +
+

+ {selected.name || selected.email} +

+

{selected.email}

+
+
+ +
+
+ + + + + item.url)} + pending={mutations.addUserChannel.isPending} + onAdd={(channel) => mutations.addUserChannel.mutate({ id: selected.id, channel })} + /> + mutations.removeUserChannel.mutate({ id: selected.id, url })} + /> + + item.url)} + pending={mutations.addUserPlaylist.isPending} + onAdd={(playlist) => mutations.addUserPlaylist.mutate({ id: selected.id, playlist })} + /> + mutations.removeUserPlaylist.mutate({ id: selected.id, url })} + /> +
+ ); +} diff --git a/apps/web/src/components/admin-allow-list-users.tsx b/apps/web/src/components/admin-allow-list-users.tsx new file mode 100644 index 0000000..14606e8 --- /dev/null +++ b/apps/web/src/components/admin-allow-list-users.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from "react"; +import { useAdminUserSearch } from "../hooks/use-admin-granular-allow-list"; +import type { AdminAllowListUser } from "../types/allow-list"; +import { AdminAllowListUserDetail } from "./admin-allow-list-user-detail"; + +type Props = { + enabled: boolean; + onToast: (message: string) => void; +}; + +export function AdminAllowListUsers({ enabled, onToast }: Props) { + const [search, setSearch] = useState(""); + const [selected, setSelected] = useState(null); + const query = useAdminUserSearch(search, enabled); + const users = query.data ?? []; + + useEffect(() => { + if (!selected) return; + if (users.some((user) => user.id === selected.id)) return; + setSelected(null); + }, [selected, users]); + + return ( +
+
+

Specific users

+

Search a user, then configure only that account.

+
+ setSearch(event.target.value)} + placeholder="Search by email or name" + className="h-10 w-full border border-border bg-app px-3 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-border-strong" + /> +
+ {search.trim().length < 2 ? ( +
Type at least two characters.
+ ) : query.isLoading ? ( +
Searching users...
+ ) : users.length === 0 ? ( +
No users found.
+ ) : ( +
+ {users.map((user) => ( + + ))} +
+ )} +
+ {selected && ( +
+ +
+ )} +
+ ); +} From d401207fb2c40e21c393770d2aed32c6dfced76a Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 22 Jun 2026 17:17:38 +0200 Subject: [PATCH 05/12] feat: add admin allow-list section --- .../components/admin-allow-list-section.tsx | 119 ++++++++++++++++++ .../src/components/admin-console-header.tsx | 4 +- apps/web/src/components/admin-console-nav.tsx | 4 +- .../src/components/admin-settings-panel.tsx | 6 +- .../src/components/admin-settings-section.tsx | 15 ++- apps/web/src/lib/admin-console-section.ts | 24 ++++ apps/web/src/routes/admin-console.tsx | 26 ++-- 7 files changed, 182 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/components/admin-allow-list-section.tsx create mode 100644 apps/web/src/lib/admin-console-section.ts diff --git a/apps/web/src/components/admin-allow-list-section.tsx b/apps/web/src/components/admin-allow-list-section.tsx new file mode 100644 index 0000000..e33f26e --- /dev/null +++ b/apps/web/src/components/admin-allow-list-section.tsx @@ -0,0 +1,119 @@ +import { + useAdminAllowedPlaylists, + useAdminAllowListMutations, +} from "../hooks/use-admin-granular-allow-list"; +import { useAdminSettings } from "../hooks/use-admin-settings"; +import { useAllowedChannels } from "../hooks/use-allowed-channels"; +import { AdminAllowListChannelList } from "./admin-allow-list-channel-list"; +import { AdminAllowListForm } from "./admin-allow-list-form"; +import { AdminAllowListPlaylistList } from "./admin-allow-list-playlist-list"; +import { AdminAllowListPlaylistSearch } from "./admin-allow-list-playlist-search"; +import { AdminAllowListUsers } from "./admin-allow-list-users"; + +const MODE_BUTTON = + "h-8 border border-border px-3 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-60"; + +type Props = { + enabled: boolean; + onToast: (message: string) => void; +}; + +export function AdminAllowListSection({ enabled, onToast }: Props) { + const adminSettings = useAdminSettings(enabled); + const { query, add, remove } = useAllowedChannels(); + const globalPlaylists = useAdminAllowedPlaylists(enabled); + const mutations = useAdminAllowListMutations(null); + const settings = adminSettings.query.data; + const channels = (query.data ?? []).filter((item) => item.global === true); + const playlists = globalPlaylists.data ?? []; + const instanceRestricted = settings?.accessMode === "allow_list"; + + function setMode(accessMode: "unrestricted" | "allow_list") { + if (!settings) return; + adminSettings.update.mutate( + { ...settings, accessMode }, + { + onSuccess: () => onToast("Allow list mode updated"), + onError: (error) => + onToast(error instanceof Error ? error.message : "Unable to update mode"), + }, + ); + } + + if (adminSettings.query.isPending) { + return ( +
+ Loading allow list... +
+ ); + } + + if (!settings || adminSettings.query.isError) { + return ( +
+ Unable to load allow list settings. +
+ ); + } + + return ( +
+
+
+
+

Entire instance

+

+ Restrict everyone to the admin allow list, including guests. +

+
+
+ + +
+
+
+ + item.url)} + pending={add.isPending} + onAdd={(channel) => + add.mutate({ + url: channel.url, + name: channel.name, + thumbnailUrl: channel.thumbnailUrl, + global: true, + }) + } + /> + + item.url)} + pending={mutations.addGlobalPlaylist.isPending} + onAdd={(playlist) => mutations.addGlobalPlaylist.mutate(playlist)} + /> + +
+ ); +} diff --git a/apps/web/src/components/admin-console-header.tsx b/apps/web/src/components/admin-console-header.tsx index dab936f..5e95cc0 100644 --- a/apps/web/src/components/admin-console-header.tsx +++ b/apps/web/src/components/admin-console-header.tsx @@ -1,4 +1,4 @@ -type AdminSection = "settings" | "users" | "sessions" | "issues"; +import type { AdminSection } from "../lib/admin-console-section"; type Props = { section: AdminSection; @@ -6,6 +6,7 @@ type Props = { const TITLES: Record = { settings: "Admin Settings", + "allow-list": "Allow List", users: "User Management", sessions: "Active Sessions", issues: "Issue Triage", @@ -13,6 +14,7 @@ const TITLES: Record = { const DESCRIPTIONS: Record = { settings: "Global moderation and platform switches.", + "allow-list": "Control which channels are available in allow-list mode.", users: "Roles, suspension, and account recovery tools.", sessions: "Connected clients, playback state, and recent activity.", issues: "Bug reports, diagnostics, status updates, and GitHub sync.", diff --git a/apps/web/src/components/admin-console-nav.tsx b/apps/web/src/components/admin-console-nav.tsx index 60f89a0..f0a6e6d 100644 --- a/apps/web/src/components/admin-console-nav.tsx +++ b/apps/web/src/components/admin-console-nav.tsx @@ -1,4 +1,4 @@ -type AdminSection = "settings" | "users" | "sessions" | "issues"; +import type { AdminSection } from "../lib/admin-console-section"; type Item = { key: AdminSection; @@ -14,7 +14,7 @@ type Props = { export function AdminConsoleNav({ items, active, onSelect }: Props) { return (