Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/web/src/components/admin-allow-list-channel-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function AdminAllowListChannelList({
onRemove,
}: Props) {
return (
<section className="border-t border-border pt-4">
<section className="min-w-0 border-t border-border pt-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-fg">{title}</h2>
Expand All @@ -57,7 +57,7 @@ export function AdminAllowListChannelList({
return (
<div
key={item.url}
className="group relative flex items-center gap-3 border-b border-border px-0 py-3 last:border-b-0"
className="group relative flex min-w-0 items-center gap-3 border-b border-border px-0 py-3 last:border-b-0"
>
<ChannelAvatar
src={item.thumbnailUrl ?? ""}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/admin-allow-list-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function AdminAllowListForm({ title, description, trustedUrls, pending, o
const channels = search.data?.channels ?? [];

return (
<section className="border-t border-border pt-4">
<section className="min-w-0 border-t border-border pt-4">
<div className="mb-3 flex flex-col gap-1">
<h2 className="text-sm font-semibold text-fg">{title}</h2>
<p className="text-xs text-fg-soft">{description}</p>
Expand All @@ -61,7 +61,7 @@ export function AdminAllowListForm({ title, description, trustedUrls, pending, o
{channels.slice(0, 8).map((channel) => {
const alreadyAdded = isTrusted(channel, trusted);
return (
<div key={channel.url} className="flex items-center gap-3 px-3 py-2.5">
<div key={channel.url} className="flex min-w-0 items-center gap-3 px-3 py-2.5">
<ChannelAvatar
src={proxyImage(channel.thumbnailUrl)}
name={channel.name}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/admin-allow-list-playlist-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ function XIcon() {

export function AdminAllowListPlaylistList({ title, playlists, onRemove }: Props) {
return (
<section className="border-t border-border pt-4">
<section className="min-w-0 border-t border-border pt-4">
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-fg">{title}</h2>
<span className="text-xs text-fg-soft">
Expand All @@ -51,7 +51,7 @@ export function AdminAllowListPlaylistList({ title, playlists, onRemove }: Props
return (
<div
key={playlist.url}
className="flex items-center gap-3 border-b border-border px-0 py-3 last:border-b-0"
className="flex min-w-0 items-center gap-3 border-b border-border px-0 py-3 last:border-b-0"
>
<img
src={proxyImage(playlist.thumbnailUrl ?? "")}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/admin-allow-list-playlist-search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function AdminAllowListPlaylistSearch({
}

return (
<section className="border-t border-border pt-4">
<section className="min-w-0 border-t border-border pt-4">
<div className="mb-3 flex flex-col gap-1">
<h2 className="text-sm font-semibold text-fg">{title}</h2>
<p className="text-xs text-fg-soft">{description}</p>
Expand Down Expand Up @@ -108,7 +108,7 @@ export function AdminAllowListPlaylistSearch({
{playlists.slice(0, 8).map((playlist) => {
const alreadyAdded = added.has(playlist.url);
return (
<div key={playlist.url} className="flex items-center gap-3 px-3 py-2.5">
<div key={playlist.url} className="flex min-w-0 items-center gap-3 px-3 py-2.5">
<img
src={proxyImage(playlist.thumbnailUrl)}
alt=""
Expand Down
14 changes: 9 additions & 5 deletions apps/web/src/components/admin-allow-list-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,16 @@ export function AdminAllowListSection({ enabled, onToast }: Props) {
}

return (
<div className="flex flex-col gap-5">
<div className="flex min-w-0 flex-col gap-5">
<section className="border-t border-border pt-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex flex-col gap-1">
<h2 className="text-sm font-semibold text-fg">Entire instance</h2>
<p className="text-xs text-fg-soft">
Restrict everyone to the admin allow list, including guests.
</p>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={adminSettings.update.isPending}
Expand All @@ -86,7 +86,11 @@ export function AdminAllowListSection({ enabled, onToast }: Props) {
</div>
</div>
</section>
<AdminAllowListUsers enabled={enabled} onToast={onToast} />
<AdminAllowListUsers
enabled={enabled}
instanceRestricted={instanceRestricted}
onToast={onToast}
/>
<AdminAllowListForm
title="Add global channel"
description="Search by channel name or handle. Added channels apply to every restricted user."
Expand Down
74 changes: 60 additions & 14 deletions apps/web/src/components/admin-allow-list-user-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,68 @@ import { AdminUserAvatar } from "./admin-user-avatar";

type Props = {
user: AdminAllowListUser;
instanceRestricted: boolean;
onToast: (message: string) => 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"),
},
Expand All @@ -43,19 +88,19 @@ export function AdminAllowListUserDetail({ user, onToast }: Props) {
}

return (
<div className="flex flex-col gap-5">
<div className="flex min-w-0 flex-col gap-5">
<section className="border-t border-border pt-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<AdminUserAvatar
user={{
...selected,
role: "user",
publicUsername: null,
bio: null,
avatarUrl: null,
avatarType: null,
avatarCode: null,
avatarUrl: selected.avatarUrl ?? null,
avatarType: selected.avatarType ?? null,
avatarCode: selected.avatarCode ?? null,
suspended: false,
verified: false,
createdAt: 0,
Expand All @@ -67,17 +112,18 @@ export function AdminAllowListUserDetail({ user, onToast }: Props) {
{selected.name || selected.email}
</p>
<p className="truncate text-xs text-fg-soft">{selected.email}</p>
<p className="truncate text-xs text-fg-muted">{state.label}</p>
</div>
</div>
<button
type="button"
disabled={mutations.userMode.isPending}
onClick={toggleMode}
className={`h-8 border px-3 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
restricted ? "border-fg bg-fg text-app" : "border-border text-fg-soft hover:text-fg"
state.active ? "border-fg bg-fg text-app" : "border-border text-fg-soft hover:text-fg"
}`}
>
{restricted ? "Allow-list" : "Unrestricted"}
{state.action}
</button>
</div>
</section>
Expand Down
86 changes: 63 additions & 23 deletions apps/web/src/components/admin-allow-list-users.tsx
Original file line number Diff line number Diff line change
@@ -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<AdminAllowListUser | null>(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 (
<section className="border-t border-border pt-4">
<section className="min-w-0 border-t border-border pt-4">
<div className="mb-3 flex flex-col gap-1">
<h2 className="text-sm font-semibold text-fg">Specific users</h2>
<p className="text-xs text-fg-soft">Search a user, then configure only that account.</p>
<p className="text-xs text-fg-soft">
Users with admin-managed access appear here. Search to configure another account.
</p>
</div>
<input
value={search}
Expand All @@ -33,38 +56,55 @@ export function AdminAllowListUsers({ enabled, onToast }: Props) {
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"
/>
<div className="mt-3 border-y border-border">
{search.trim().length < 2 ? (
<div className="px-3 py-4 text-sm text-fg-soft">Type at least two characters.</div>
) : query.isLoading ? (
{loading ? (
<div className="px-3 py-4 text-sm text-fg-soft">Searching users...</div>
) : users.length === 0 ? (
<div className="px-3 py-4 text-sm text-fg-soft">No users found.</div>
) : visibleUsers.length === 0 ? (
<div className="px-3 py-4 text-sm text-fg-soft">
{searching ? "No users found." : "No users are managed by allow-list rules yet."}
</div>
) : (
<div className="divide-y divide-border">
{users.map((user) => (
{visibleUsers.map((user) => (
<button
key={user.id}
type="button"
onClick={() => setSelected(user)}
className={`flex w-full items-center gap-3 border-l-2 py-2.5 pl-2 pr-0 text-left transition-colors hover:border-border-strong ${
className={`flex w-full min-w-0 items-center gap-3 border-l-2 py-2.5 pl-2 pr-0 text-left transition-colors hover:border-border-strong ${
selected?.id === user.id ? "border-fg" : "border-transparent"
}`}
>
<AdminUserAvatar user={avatarUser(user)} className="h-9 w-9 shrink-0" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-fg">{user.name || user.email}</p>
<p className="truncate text-xs text-fg-soft">{user.email}</p>
</div>
<span className="text-xs text-fg-soft">
{user.accessMode === "allow_list" ? "Allow-list" : "Unrestricted"}
<span className="shrink-0 text-xs text-fg-soft">
{accessLabel(user, instanceRestricted)}
</span>
</button>
))}
</div>
)}
</div>
{!searching && managedQuery.hasNextPage && (
<button
type="button"
disabled={managedQuery.isFetchingNextPage}
onClick={() => {
void managedQuery.fetchNextPage();
}}
className="mt-3 h-8 border border-border px-3 text-xs text-fg-soft transition-colors hover:border-border-strong hover:text-fg disabled:cursor-not-allowed disabled:opacity-60"
>
{managedQuery.isFetchingNextPage ? "Loading..." : "Load more users"}
</button>
)}
{selected && (
<div className="mt-6">
<AdminAllowListUserDetail user={selected} onToast={onToast} />
<AdminAllowListUserDetail
user={selected}
instanceRestricted={instanceRestricted}
onToast={onToast}
/>
</div>
)}
</section>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/admin-console-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ type Props = {

export function AdminConsoleNav({ items, active, onSelect }: Props) {
return (
<nav className="overflow-x-auto pt-3" aria-label="Admin sections">
<nav className="min-w-0 overflow-x-auto pt-3" aria-label="Admin sections">
<div className="flex min-w-max gap-3 sm:grid sm:min-w-0 sm:grid-cols-5 sm:gap-1">
{items.map((item) => {
const isActive = item.key === active;
Expand Down
Loading