From 749c76e72c4ac0f4bf7551a39fb528cc1e70ea0d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 25 Jun 2026 09:39:19 +0200 Subject: [PATCH 01/18] feat: show managed allow-list users --- .../components/admin-allow-list-section.tsx | 14 +-- .../admin-allow-list-user-detail.tsx | 74 +++++++++++++--- .../src/components/admin-allow-list-users.tsx | 86 ++++++++++++++----- .../hooks/use-admin-granular-allow-list.ts | 60 +++++++++++-- apps/web/src/lib/api-admin-allow-list.ts | 10 +++ apps/web/src/types/allow-list.ts | 9 ++ 6 files changed, 203 insertions(+), 50 deletions(-) diff --git a/apps/web/src/components/admin-allow-list-section.tsx b/apps/web/src/components/admin-allow-list-section.tsx index e33f26e..ed05682 100644 --- a/apps/web/src/components/admin-allow-list-section.tsx +++ b/apps/web/src/components/admin-allow-list-section.tsx @@ -57,16 +57,16 @@ export function AdminAllowListSection({ enabled, onToast }: Props) { } return ( -
+
-
-
+
+

Entire instance

Restrict everyone to the admin allow list, including guests.

-
+
- + void; }; -export function AdminAllowListUserDetail({ user, onToast }: Props) { +function accessState(user: AdminAllowListUser, instanceRestricted: boolean) { + if (user.accessMode === "allow_list") { + return { + label: "User-specific allow-list", + action: instanceRestricted ? "Set unrestricted override" : "Unrestrict user", + nextMode: "unrestricted" as const, + toast: instanceRestricted ? "Unrestricted override set" : "User unrestricted", + active: true, + }; + } + if (instanceRestricted && user.adminManagedAccessMode) { + return { + label: "Admin unrestricted override", + action: "Restrict user", + nextMode: "allow_list" as const, + toast: "User restricted", + active: true, + }; + } + if (instanceRestricted) { + return { + label: "Restricted by entire instance", + action: "Set unrestricted override", + nextMode: "unrestricted" as const, + toast: "Unrestricted override set", + active: false, + }; + } + return { + label: "Unrestricted", + action: "Restrict user", + nextMode: "allow_list" as const, + toast: "User restricted", + active: false, + }; +} + +export function AdminAllowListUserDetail({ user, instanceRestricted, 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"; + const selected = data?.user + ? { + ...user, + ...data.user, + adminManagedAccessMode: data.user.adminManagedAccessMode ?? user.adminManagedAccessMode, + avatarUrl: data.user.avatarUrl ?? user.avatarUrl, + avatarType: data.user.avatarType ?? user.avatarType, + avatarCode: data.user.avatarCode ?? user.avatarCode, + } + : user; + const state = accessState(selected, instanceRestricted); function toggleMode() { - const accessMode = restricted ? "unrestricted" : "allow_list"; mutations.userMode.mutate( - { id: selected.id, accessMode }, + { id: selected.id, accessMode: state.nextMode }, { - onSuccess: () => - onToast(accessMode === "allow_list" ? "User restricted" : "User unrestricted"), + onSuccess: () => onToast(state.toast), onError: (error) => onToast(error instanceof Error ? error.message : "Unable to update user"), }, @@ -43,9 +88,9 @@ export function AdminAllowListUserDetail({ user, onToast }: Props) { } return ( -
+
-
+

{selected.email}

+

{state.label}

diff --git a/apps/web/src/components/admin-allow-list-users.tsx b/apps/web/src/components/admin-allow-list-users.tsx index 14606e8..c57d52b 100644 --- a/apps/web/src/components/admin-allow-list-users.tsx +++ b/apps/web/src/components/admin-allow-list-users.tsx @@ -1,30 +1,53 @@ -import { useEffect, useState } from "react"; -import { useAdminUserSearch } from "../hooks/use-admin-granular-allow-list"; +import { useState } from "react"; +import { useAdminAllowListUsers, useAdminUserSearch } from "../hooks/use-admin-granular-allow-list"; import type { AdminAllowListUser } from "../types/allow-list"; import { AdminAllowListUserDetail } from "./admin-allow-list-user-detail"; +import { AdminUserAvatar } from "./admin-user-avatar"; type Props = { enabled: boolean; + instanceRestricted: boolean; onToast: (message: string) => void; }; -export function AdminAllowListUsers({ enabled, onToast }: Props) { +function avatarUser(user: AdminAllowListUser) { + return { + ...user, + role: "user" as const, + publicUsername: null, + bio: null, + avatarUrl: user.avatarUrl ?? null, + avatarType: user.avatarType ?? null, + avatarCode: user.avatarCode ?? null, + suspended: false, + verified: false, + createdAt: 0, + }; +} + +function accessLabel(user: AdminAllowListUser, instanceRestricted: boolean): string { + if (user.accessMode === "allow_list") return "User restricted"; + if (!instanceRestricted) return "Unrestricted"; + return user.adminManagedAccessMode ? "Admin override" : "Instance restricted"; +} + +export function AdminAllowListUsers({ enabled, instanceRestricted, 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]); + const searching = search.trim().length >= 2; + const managedQuery = useAdminAllowListUsers(enabled && !searching); + const searchQuery = useAdminUserSearch(search, enabled && searching); + const managedUsers = managedQuery.data?.pages.flatMap((page) => page.items) ?? []; + const visibleUsers = searching ? (searchQuery.data ?? []) : managedUsers; + const loading = searching ? searchQuery.isLoading : managedQuery.isLoading; return ( -
+

Specific users

-

Search a user, then configure only that account.

+

+ Users with admin-managed access appear here. Search to configure another account. +

- {search.trim().length < 2 ? ( -
Type at least two characters.
- ) : query.isLoading ? ( + {loading ? (
Searching users...
- ) : users.length === 0 ? ( -
No users found.
+ ) : visibleUsers.length === 0 ? ( +
+ {searching ? "No users found." : "No users are managed by allow-list rules yet."} +
) : (
- {users.map((user) => ( + {visibleUsers.map((user) => ( ))}
)}
+ {!searching && managedQuery.hasNextPage && ( + + )} {selected && (
- +
)}
diff --git a/apps/web/src/hooks/use-admin-granular-allow-list.ts b/apps/web/src/hooks/use-admin-granular-allow-list.ts index bf0934f..d3c20da 100644 --- a/apps/web/src/hooks/use-admin-granular-allow-list.ts +++ b/apps/web/src/hooks/use-admin-granular-allow-list.ts @@ -1,9 +1,10 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { addAdminAllowedPlaylist, addAdminUserAllowedChannel, addAdminUserAllowedPlaylist, fetchAdminAllowedPlaylists, + fetchAdminManagedAccessUsers, fetchAdminUserAllowList, removeAdminAllowedPlaylist, removeAdminUserAllowedChannel, @@ -11,11 +12,13 @@ import { searchAdminUsers, updateAdminUserAccessMode, } from "../lib/api-admin-allow-list"; -import type { AllowPlaylistInput } from "../types/allow-list"; +import type { AdminUserAllowList, AllowPlaylistInput } from "../types/allow-list"; import type { ChannelResultItem } from "../types/api"; import type { AccessMode } from "../types/user"; const ADMIN_ALLOWED_PLAYLISTS_KEY = ["admin-allowed-playlists"]; +const ADMIN_MANAGED_ACCESS_USERS_KEY = ["admin-managed-access-users"]; +const ADMIN_USERS_SEARCH_KEY = ["admin-users-search"]; const adminUserAllowListKey = (id: string) => ["admin-user-allow-list", id]; export function useAdminAllowedPlaylists(enabled: boolean) { @@ -29,13 +32,25 @@ export function useAdminAllowedPlaylists(enabled: boolean) { export function useAdminUserSearch(query: string, enabled: boolean) { const q = query.trim(); return useQuery({ - queryKey: ["admin-users-search", q], + queryKey: [...ADMIN_USERS_SEARCH_KEY, q], queryFn: () => searchAdminUsers(q, 20), enabled: enabled && q.length >= 2, staleTime: 30 * 1000, }); } +export function useAdminAllowListUsers(enabled: boolean) { + return useInfiniteQuery({ + queryKey: ADMIN_MANAGED_ACCESS_USERS_KEY, + queryFn: ({ pageParam }: { pageParam: string | undefined }) => + fetchAdminManagedAccessUsers(100, pageParam), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextpage ?? undefined, + enabled, + staleTime: 30 * 1000, + }); +} + export function useAdminUserAllowList(userId: string | null) { return useQuery({ queryKey: userId ? adminUserAllowListKey(userId) : ["admin-user-allow-list", "none"], @@ -49,22 +64,45 @@ export function useAdminAllowListMutations(selectedUserId: string | null) { const refreshUser = () => { if (selectedUserId) qc.invalidateQueries({ queryKey: adminUserAllowListKey(selectedUserId) }); }; + const setUserMode = (id: string, accessMode: AccessMode) => { + qc.setQueryData(adminUserAllowListKey(id), (current) => + current + ? { + ...current, + user: { ...current.user, accessMode, adminManagedAccessMode: true }, + } + : current, + ); + }; + const refreshManagedUsers = () => { + qc.invalidateQueries({ queryKey: ADMIN_MANAGED_ACCESS_USERS_KEY }); + qc.invalidateQueries({ queryKey: ADMIN_USERS_SEARCH_KEY }); + }; const refreshGlobalPlaylists = () => qc.invalidateQueries({ queryKey: ADMIN_ALLOWED_PLAYLISTS_KEY }); const userMode = useMutation({ mutationFn: ({ id, accessMode }: { id: string; accessMode: AccessMode }) => updateAdminUserAccessMode(id, accessMode), - onSuccess: refreshUser, + onSuccess: (accessMode, { id }) => { + setUserMode(id, accessMode); + refreshManagedUsers(); + }, }); const addUserChannel = useMutation({ mutationFn: ({ id, channel }: { id: string; channel: ChannelResultItem }) => addAdminUserAllowedChannel(id, channel.url, channel.name, channel.thumbnailUrl), - onSuccess: refreshUser, + onSuccess: () => { + refreshUser(); + refreshManagedUsers(); + }, }); const removeUserChannel = useMutation({ mutationFn: ({ id, url }: { id: string; url: string }) => removeAdminUserAllowedChannel(id, url), - onSuccess: refreshUser, + onSuccess: () => { + refreshUser(); + refreshManagedUsers(); + }, }); const addGlobalPlaylist = useMutation({ mutationFn: addAdminAllowedPlaylist, @@ -77,12 +115,18 @@ export function useAdminAllowListMutations(selectedUserId: string | null) { const addUserPlaylist = useMutation({ mutationFn: ({ id, playlist }: { id: string; playlist: AllowPlaylistInput }) => addAdminUserAllowedPlaylist(id, playlist), - onSuccess: refreshUser, + onSuccess: () => { + refreshUser(); + refreshManagedUsers(); + }, }); const removeUserPlaylist = useMutation({ mutationFn: ({ id, url }: { id: string; url: string }) => removeAdminUserAllowedPlaylist(id, url), - onSuccess: refreshUser, + onSuccess: () => { + refreshUser(); + refreshManagedUsers(); + }, }); return { userMode, diff --git a/apps/web/src/lib/api-admin-allow-list.ts b/apps/web/src/lib/api-admin-allow-list.ts index c5bc1fe..7526c7b 100644 --- a/apps/web/src/lib/api-admin-allow-list.ts +++ b/apps/web/src/lib/api-admin-allow-list.ts @@ -1,5 +1,6 @@ import type { AdminAllowListUser, + AdminManagedAccessUsersPage, AdminUserAllowList, AllowedPlaylistItem, AllowPlaylistInput, @@ -14,6 +15,15 @@ export function searchAdminUsers(q: string, limit = 20): Promise { + const params = new URLSearchParams({ limit: String(limit) }); + if (page) params.set("page", page); + return authedJson(`${BASE}/admin/users/managed-access?${params}`); +} + export function fetchAdminUserAllowList(id: string): Promise { return authedJson(`${BASE}/admin/users/${encodeURIComponent(id)}/allow-list`); } diff --git a/apps/web/src/types/allow-list.ts b/apps/web/src/types/allow-list.ts index a605640..8db0489 100644 --- a/apps/web/src/types/allow-list.ts +++ b/apps/web/src/types/allow-list.ts @@ -5,6 +5,15 @@ export type AdminAllowListUser = { email: string; name: string; accessMode: AccessMode; + adminManagedAccessMode?: boolean; + avatarUrl?: string | null; + avatarType?: "custom" | "emoji" | null; + avatarCode?: string | null; +}; + +export type AdminManagedAccessUsersPage = { + items: AdminAllowListUser[]; + nextpage: string | null; }; export type AllowedPlaylistItem = { From 5464a810aa6ee7c4d5f41fc509fdb4d95aa3c079 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 25 Jun 2026 09:39:39 +0200 Subject: [PATCH 02/18] fix: prevent allow-list layout overflow --- apps/web/src/components/admin-allow-list-channel-list.tsx | 4 ++-- apps/web/src/components/admin-allow-list-form.tsx | 4 ++-- apps/web/src/components/admin-allow-list-playlist-list.tsx | 4 ++-- apps/web/src/components/admin-allow-list-playlist-search.tsx | 4 ++-- apps/web/src/components/admin-console-nav.tsx | 2 +- apps/web/src/routes/admin-console.tsx | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/admin-allow-list-channel-list.tsx b/apps/web/src/components/admin-allow-list-channel-list.tsx index f58e0ee..627385d 100644 --- a/apps/web/src/components/admin-allow-list-channel-list.tsx +++ b/apps/web/src/components/admin-allow-list-channel-list.tsx @@ -35,7 +35,7 @@ export function AdminAllowListChannelList({ onRemove, }: Props) { return ( -
+

{title}

@@ -57,7 +57,7 @@ export function AdminAllowListChannelList({ return (
+

{title}

{description}

@@ -61,7 +61,7 @@ export function AdminAllowListForm({ title, description, trustedUrls, pending, o {channels.slice(0, 8).map((channel) => { const alreadyAdded = isTrusted(channel, trusted); return ( -
+
+

{title}

@@ -51,7 +51,7 @@ export function AdminAllowListPlaylistList({ title, playlists, onRemove }: Props return (
+

{title}

{description}

@@ -108,7 +108,7 @@ export function AdminAllowListPlaylistSearch({ {playlists.slice(0, 8).map((playlist) => { const alreadyAdded = added.has(playlist.url); return ( -
+
+