Files
silo-server/web/src/pages/AdminUsers.tsx
T
28c6ddc237 feat(playback): add per-user transcoding controls (#375)
* feat(playback): add per-user transcoding controls

* fix(playback): enforce forced video transcode permission

* chore: address transcode control review feedback

* fix(playback): recheck transcode permission on audio switch

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-07-10 22:30:04 -04:00

810 lines
29 KiB
TypeScript

import { useState, useId, useMemo } from "react";
import type { FormEvent, ReactNode } from "react";
import { Link } from "react-router";
import type { AdminUser, CreateUserRequest, UpdateUserRequest } from "@/api/types";
import {
useAdminUsers,
useCreateUser,
useUpdateUser,
useDeleteUser,
} from "@/hooks/queries/admin/users";
import { useAdminLibraries } from "@/hooks/queries/admin/libraries";
import { LibraryAccessSelector } from "@/components/LibraryAccessSelector";
import { UserTranscodeLimitField } from "@/components/UserTranscodeLimitField";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
ChevronDown,
ChevronUp,
History,
Plus,
Pencil,
Trash2,
Settings2,
Search,
X,
} from "lucide-react";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { Skeleton } from "@/components/ui/skeleton";
import InviteCodesTab from "./admin-settings/InviteCodesTab";
import {
PLAYBACK_QUALITY_OPTIONS,
playbackQualityPresetFromValue,
playbackQualityValueFromPreset,
type PlaybackQualityPreset,
} from "@/lib/playback-quality";
import {
PERMISSION_MARKER_EDIT,
PERMISSION_METADATA_CURATION,
hasAssignedPermission,
setAssignedPermission,
} from "@/lib/permissions";
import { formatDateTime as formatDateTimePreferred } from "@/lib/datetime";
const PAGE_SIZE_OPTIONS = ["25", "50", "100"] as const;
type UserSortField = "username" | "email" | "role" | "enabled" | "created_at" | "last_active_at";
type SortDirection = "asc" | "desc";
export default function AdminUsers() {
const { data: users = [], isLoading } = useAdminUsers();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [confirmDeleteUser, setConfirmDeleteUser] = useState<AdminUser | null>(null);
const deleteMutation = useDeleteUser();
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(25);
const [sortField, setSortField] = useState<UserSortField>("username");
const [sortDir, setSortDir] = useState<SortDirection>("asc");
const filteredUsers = useMemo(() => {
if (!search) return users;
const q = search.toLowerCase();
return users.filter(
(u) => u.username?.toLowerCase().includes(q) || u.email?.toLowerCase().includes(q),
);
}, [users, search]);
const sortedUsers = useMemo(
() => sortAdminUsers(filteredUsers, sortField, sortDir),
[filteredUsers, sortField, sortDir],
);
const total = sortedUsers.length;
const paginatedUsers = sortedUsers.slice(page * pageSize, (page + 1) * pageSize);
function handleSort(field: UserSortField) {
setPage(0);
if (field === sortField) {
setSortDir((current) => (current === "asc" ? "desc" : "asc"));
return;
}
setSortField(field);
setSortDir(field === "created_at" || field === "last_active_at" ? "desc" : "asc");
}
function handleDelete(u: AdminUser) {
setConfirmDeleteUser(u);
}
if (isLoading)
return (
<div className="space-y-3">
<Skeleton className="h-10 w-full rounded-lg" />
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
);
return (
<div className="space-y-6">
<ConfirmDialog
open={confirmDeleteUser !== null}
onOpenChange={(open) => {
if (!open) setConfirmDeleteUser(null);
}}
title="Delete user"
description={`Delete user "${confirmDeleteUser?.username}"? This action cannot be undone.`}
confirmLabel="Delete"
variant="destructive"
onConfirm={() => {
if (confirmDeleteUser) deleteMutation.mutate(confirmDeleteUser.id);
setConfirmDeleteUser(null);
}}
/>
<div className="page-header">
<div className="space-y-3">
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Users</h1>
<p className="page-subtitle text-sm sm:text-base">
Manage access, defaults, and invite flow for the people using Silo.
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" asChild>
<Link to="/admin/access-groups">
<Settings2 className="mr-1 h-4 w-4" /> Access Groups
</Link>
</Button>
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) setEditingUser(null);
}}
>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="mr-1 h-4 w-4" /> Add User
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{editingUser ? "Edit User" : "Create User"}</DialogTitle>
</DialogHeader>
<UserForm
user={editingUser}
onClose={() => {
setDialogOpen(false);
setEditingUser(null);
}}
/>
</DialogContent>
</Dialog>
</div>
</div>
<Tabs defaultValue="users">
<TabsList variant="line" className="border-border w-full justify-start border-b">
<TabsTrigger value="users">Users</TabsTrigger>
<TabsTrigger value="invite-codes">Invite Codes</TabsTrigger>
</TabsList>
<TabsContent value="users" className="pt-4">
<div className="relative mb-4">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="Search by username or email..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
className="pr-9 pl-9"
/>
{search && (
<button
type="button"
aria-label="Clear search"
onClick={() => {
setSearch("");
setPage(0);
}}
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<div className="surface-panel overflow-x-auto rounded-2xl border-0">
<Table>
<TableHeader>
<TableRow>
<SortableUserHead
field="username"
activeField={sortField}
activeDir={sortDir}
onSort={handleSort}
>
Username
</SortableUserHead>
<SortableUserHead
field="email"
activeField={sortField}
activeDir={sortDir}
onSort={handleSort}
>
Email
</SortableUserHead>
<SortableUserHead
field="role"
activeField={sortField}
activeDir={sortDir}
onSort={handleSort}
>
Role
</SortableUserHead>
<SortableUserHead
field="enabled"
activeField={sortField}
activeDir={sortDir}
onSort={handleSort}
>
Status
</SortableUserHead>
<SortableUserHead
field="created_at"
activeField={sortField}
activeDir={sortDir}
onSort={handleSort}
>
Created
</SortableUserHead>
<SortableUserHead
field="last_active_at"
activeField={sortField}
activeDir={sortDir}
onSort={handleSort}
>
Last Active
</SortableUserHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedUsers.map((u) => (
<TableRow key={u.id}>
<TableCell>
<Link to={`/admin/users/${u.id}`} className="font-medium hover:underline">
{u.username}
</Link>
</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell>
<Badge variant={u.role === "admin" ? "default" : "secondary"}>{u.role}</Badge>
</TableCell>
<TableCell>
<Badge variant={u.enabled ? "outline" : "destructive"}>
{u.enabled ? "Active" : "Disabled"}
</Badge>
</TableCell>
<TableCell title={formatFullDateTime(u.created_at)}>
{formatDateTime(u.created_at)}
</TableCell>
<TableCell title={u.last_active_at ? formatFullDateTime(u.last_active_at) : ""}>
{formatRelativeTime(u.last_active_at, "Never")}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button asChild variant="ghost" size="icon" className="h-7 w-7">
<Link
to={`/admin/history?user_id=${u.id}`}
aria-label={`View ${u.username} playback history`}
>
<History className="h-3 w-3" aria-hidden="true" />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label={`Edit ${u.username}`}
onClick={() => {
setEditingUser(u);
setDialogOpen(true);
}}
>
<Pencil className="h-3 w-3" aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label={`Delete ${u.username}`}
onClick={() => handleDelete(u)}
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{total > pageSize && (
<div className="flex items-center justify-between px-4 py-4">
<div className="flex items-center gap-4">
<span className="text-muted-foreground text-sm">
Showing {page * pageSize + 1}-{Math.min((page + 1) * pageSize, total)} of{" "}
{total}
</span>
<Select
value={String(pageSize)}
onValueChange={(v) => {
setPageSize(Number(v));
setPage(0);
}}
>
<SelectTrigger className="h-8 w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={size}>
{size} rows
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => p - 1)}
disabled={page === 0}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => p + 1)}
disabled={(page + 1) * pageSize >= total}
>
Next
</Button>
</div>
</div>
)}
</div>
</TabsContent>
<TabsContent value="invite-codes" className="pt-4">
<InviteCodesTab />
</TabsContent>
</Tabs>
</div>
);
}
function SortableUserHead({
field,
activeField,
activeDir,
onSort,
children,
}: {
field: UserSortField;
activeField: UserSortField;
activeDir: SortDirection;
onSort: (field: UserSortField) => void;
children: ReactNode;
}) {
const active = field === activeField;
return (
<TableHead aria-sort={active ? (activeDir === "asc" ? "ascending" : "descending") : "none"}>
<button
type="button"
className="hover:text-foreground inline-flex items-center gap-1 transition-colors"
onClick={() => onSort(field)}
>
{children}
{active ? (
activeDir === "asc" ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)
) : (
<ChevronDown className="h-3 w-3 opacity-0" />
)}
</button>
</TableHead>
);
}
function sortAdminUsers(users: AdminUser[], field: UserSortField, dir: SortDirection) {
const direction = dir === "asc" ? 1 : -1;
return [...users].sort((a, b) => {
let result = 0;
switch (field) {
case "username":
result = compareText(a.username, b.username);
break;
case "email":
result = compareText(a.email, b.email);
break;
case "role":
result = compareText(a.role, b.role);
break;
case "enabled":
result = compareText(a.enabled ? "active" : "disabled", b.enabled ? "active" : "disabled");
break;
case "created_at":
result = compareTime(a.created_at, b.created_at, dir);
break;
case "last_active_at":
result = compareTime(a.last_active_at, b.last_active_at, dir);
break;
}
if (result !== 0) {
return field === "created_at" || field === "last_active_at" ? result : result * direction;
}
return compareText(a.username, b.username) || a.id - b.id;
});
}
function compareText(a?: string | null, b?: string | null) {
return (a ?? "").localeCompare(b ?? "", undefined, { numeric: true, sensitivity: "base" });
}
function compareTime(a?: string | null, b?: string | null, dir: SortDirection = "asc") {
const aTime = parseTime(a);
const bTime = parseTime(b);
if (aTime === null && bTime === null) return 0;
if (aTime === null) return 1;
if (bTime === null) return -1;
return (aTime - bTime) * (dir === "asc" ? 1 : -1);
}
function parseTime(value?: string | null) {
const timestamp = Date.parse(value ?? "");
return Number.isNaN(timestamp) ? null : timestamp;
}
function formatDateTime(value?: string | null, fallback = "-") {
const timestamp = parseTime(value);
if (timestamp === null) return fallback;
return formatDateTimePreferred(timestamp, { dateStyle: "medium", seconds: false });
}
function formatFullDateTime(value?: string | null) {
const timestamp = parseTime(value);
if (timestamp === null) return "";
return formatDateTimePreferred(timestamp);
}
function formatRelativeTime(value?: string | null, fallback = "-") {
const timestamp = parseTime(value);
if (timestamp === null) return fallback;
const seconds = Math.round((timestamp - Date.now()) / 1000);
const ranges: Array<[Intl.RelativeTimeFormatUnit, number]> = [
["year", 60 * 60 * 24 * 365],
["month", 60 * 60 * 24 * 30],
["week", 60 * 60 * 24 * 7],
["day", 60 * 60 * 24],
["hour", 60 * 60],
["minute", 60],
["second", 1],
];
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "always" });
for (const [unit, secondsPerUnit] of ranges) {
if (Math.abs(seconds) >= secondsPerUnit || unit === "second") {
return formatter.format(Math.round(seconds / secondsPerUnit), unit);
}
}
return fallback;
}
function UserForm({ user, onClose }: { user: AdminUser | null; onClose: () => void }) {
const { data: libraries = [] } = useAdminLibraries();
const [username, setUsername] = useState(user?.username ?? "");
const [email, setEmail] = useState(user?.email ?? "");
const [password, setPassword] = useState("");
const [role, setRole] = useState(user?.role ?? "user");
const [enabled, setEnabled] = useState(user?.enabled ?? true);
const [permissions, setPermissions] = useState<string[]>(
user?.permissions ?? [PERMISSION_MARKER_EDIT],
);
const [libraryIDs, setLibraryIDs] = useState<number[] | null>(user?.library_ids ?? null);
// New users start unrestricted at the user layer (0 / any / allowed); the
// access group is the source of default policy and composes on top.
const [maxStreams, setMaxStreams] = useState<number>(user?.max_streams ?? 0);
const [maxTranscodes, setMaxTranscodes] = useState<number>(user?.max_transcodes ?? 0);
const [transcodeAllowed, setTranscodeAllowed] = useState(user?.transcode_allowed ?? true);
const [audioTranscodeAllowed, setAudioTranscodeAllowed] = useState(
user?.audio_transcode_allowed ?? true,
);
const [maxProfiles, setMaxProfiles] = useState<number>(user?.max_profiles ?? 5);
const [maxPlaybackQualityPreset, setMaxPlaybackQualityPreset] = useState<PlaybackQualityPreset>(
playbackQualityPresetFromValue(user?.max_playback_quality),
);
const [downloadAllowed, setDownloadAllowed] = useState(user?.download_allowed ?? true);
const [downloadTranscodeAllowed, setDownloadTranscodeAllowed] = useState(
user?.download_transcode_allowed ?? true,
);
const usernameId = useId();
const emailId = useId();
const passwordId = useId();
const roleId = useId();
const enabledId = useId();
const markerEditId = useId();
const metadataCurationId = useId();
const downloadAllowedId = useId();
const downloadTranscodeAllowedId = useId();
const maxStreamsId = useId();
const maxTranscodesId = useId();
const maxProfilesId = useId();
const maxPlaybackQualityId = useId();
const createMutation = useCreateUser();
const updateMutation = useUpdateUser();
const isPending = createMutation.isPending || updateMutation.isPending;
function handleSubmit(e: FormEvent) {
e.preventDefault();
if (user) {
const body: UpdateUserRequest = {
username,
email,
role,
permissions,
enabled,
library_ids: libraryIDs,
max_streams: maxStreams,
max_transcodes: maxTranscodes,
transcode_allowed: transcodeAllowed,
audio_transcode_allowed: audioTranscodeAllowed,
max_profiles: maxProfiles,
max_playback_quality: playbackQualityValueFromPreset(maxPlaybackQualityPreset),
download_allowed: downloadAllowed,
download_transcode_allowed: downloadTranscodeAllowed,
};
if (password) body.password = password;
updateMutation.mutate({ id: user.id, body }, { onSuccess: onClose });
} else {
const body: CreateUserRequest = {
username,
email,
password,
role,
permissions,
create_default_profile: true,
max_streams: maxStreams,
max_transcodes: maxTranscodes,
transcode_allowed: transcodeAllowed,
audio_transcode_allowed: audioTranscodeAllowed,
max_profiles: maxProfiles,
max_playback_quality: playbackQualityValueFromPreset(maxPlaybackQualityPreset) || undefined,
download_allowed: downloadAllowed,
download_transcode_allowed: downloadTranscodeAllowed,
};
if (libraryIDs !== null) body.library_ids = libraryIDs;
createMutation.mutate(body, { onSuccess: onClose });
}
}
return (
<form onSubmit={handleSubmit} className="flex max-h-[70vh] flex-col">
<Tabs defaultValue="account" className="min-h-0 flex-1">
<TabsList variant="line" className="border-border mb-4 w-full justify-start border-b pb-1">
<TabsTrigger value="account" className="flex-none px-1">
Account
</TabsTrigger>
<TabsTrigger value="access" className="flex-none px-1">
Access
</TabsTrigger>
<TabsTrigger value="limits" className="flex-none px-1">
Limits
</TabsTrigger>
</TabsList>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<TabsContent value="account" className="mt-0 space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={usernameId}>Username</Label>
<Input
id={usernameId}
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor={emailId}>Email</Label>
<Input
id={emailId}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor={passwordId}>
Password {user && "(leave blank to keep current)"}
</Label>
<Input
id={passwordId}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required={!user}
/>
</div>
<div className="space-y-2">
<Label htmlFor={roleId}>Role</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger id={roleId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{user && (
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<div>
<div className="text-sm font-medium">Account status</div>
<div className="text-muted-foreground text-xs">
Disable access without deleting the user.
</div>
</div>
<div className="flex items-center gap-2">
<Label htmlFor={enabledId} className="text-xs">
Enabled
</Label>
<Switch id={enabledId} checked={enabled} onCheckedChange={setEnabled} />
</div>
</div>
)}
</TabsContent>
<TabsContent value="access" className="mt-0 space-y-4">
<LibraryAccessSelector
libraries={libraries}
value={libraryIDs}
onChange={setLibraryIDs}
/>
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<div>
<Label htmlFor={markerEditId}>Marker Editing</Label>
<p className="text-muted-foreground text-xs">
Edit intro, recap, credits, and preview markers within assigned libraries.
</p>
</div>
<Switch
id={markerEditId}
checked={hasAssignedPermission(permissions, PERMISSION_MARKER_EDIT)}
onCheckedChange={(checked) =>
setPermissions((current) =>
setAssignedPermission(current, PERMISSION_MARKER_EDIT, checked),
)
}
/>
</div>
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<div>
<Label htmlFor={metadataCurationId}>Metadata Curation</Label>
<p className="text-muted-foreground text-xs">
Edit, refresh, and rematch metadata within assigned libraries.
</p>
</div>
<Switch
id={metadataCurationId}
checked={hasAssignedPermission(permissions, PERMISSION_METADATA_CURATION)}
onCheckedChange={(checked) =>
setPermissions((current) =>
setAssignedPermission(current, PERMISSION_METADATA_CURATION, checked),
)
}
/>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<Label htmlFor={downloadAllowedId}>Downloads Allowed</Label>
<Switch
id={downloadAllowedId}
checked={downloadAllowed}
onCheckedChange={setDownloadAllowed}
/>
</div>
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<Label htmlFor={downloadTranscodeAllowedId}>Download Transcode Allowed</Label>
<Switch
id={downloadTranscodeAllowedId}
checked={downloadTranscodeAllowed}
onCheckedChange={setDownloadTranscodeAllowed}
/>
</div>
</div>
</TabsContent>
<TabsContent value="limits" className="mt-0 space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor={maxStreamsId}>Max Streams</Label>
<Input
id={maxStreamsId}
type="number"
min={0}
value={maxStreams}
onChange={(e) => setMaxStreams(Number(e.target.value))}
/>
<p className="text-muted-foreground text-xs">0 = unlimited</p>
</div>
<UserTranscodeLimitField
id={maxTranscodesId}
maxTranscodes={maxTranscodes}
onMaxTranscodesChange={setMaxTranscodes}
transcodeAllowed={transcodeAllowed}
onTranscodeAllowedChange={setTranscodeAllowed}
audioTranscodeAllowed={audioTranscodeAllowed}
onAudioTranscodeAllowedChange={setAudioTranscodeAllowed}
/>
<div className="space-y-1">
<Label htmlFor={maxProfilesId}>Max Profiles</Label>
<Input
id={maxProfilesId}
type="number"
min={1}
value={maxProfiles}
onChange={(e) => setMaxProfiles(Number(e.target.value))}
/>
</div>
<div className="space-y-1 sm:col-span-2">
<Label htmlFor={maxPlaybackQualityId}>Max Playback Quality</Label>
<Select
value={maxPlaybackQualityPreset}
onValueChange={(value) =>
setMaxPlaybackQualityPreset(value as PlaybackQualityPreset)
}
>
<SelectTrigger id={maxPlaybackQualityId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PLAYBACK_QUALITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
{
PLAYBACK_QUALITY_OPTIONS.find(
(option) => option.value === maxPlaybackQualityPreset,
)?.description
}
</p>
</div>
</div>
</TabsContent>
</div>
</Tabs>
<div className="border-border mt-4 border-t pt-4">
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</Button>
</div>
</form>
);
}