Files
silo-server/web/src/pages/AdminUserDetail.tsx
T

1098 lines
40 KiB
TypeScript

import { useMemo, useState } from "react";
import type { FormEvent } from "react";
import { useParams, Link } from "react-router";
import {
type AdminDeviceSetting,
useAdminUser,
useUpdateUser,
useDeleteUser,
useImpersonateUser,
useAdminUserDeviceSettings,
useAdminUserSettings,
useDeleteAdminUserDeviceSetting,
useDeleteAdminUserSetting,
useDeleteAllAdminUserDeviceSettingsForDevice,
useUpdateAdminUserDeviceSetting,
useUpdateAdminUserSetting,
} from "@/hooks/queries/admin/users";
import { useAdminUserProfiles } from "@/hooks/queries/admin/history";
import { useAdminPlaybackHistory } from "@/hooks/queries/admin/history";
import { useAdminLibraries } from "@/hooks/queries/admin/libraries";
import { useUserIPs } from "@/hooks/queries/admin/ips";
import type {
AdminSettingEntry,
AdminUser,
AdminUserProfile,
UpdateUserRequest,
UserIPEntry,
} from "@/api/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { LibraryAccessSelector } from "@/components/LibraryAccessSelector";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
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 { ArrowUpRight, ChevronRight, Pencil, RotateCcw, Settings2, UserCircle } from "lucide-react";
import { useNavigate } from "react-router";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { useAuth } from "@/hooks/useAuth";
import {
PLAYBACK_QUALITY_OPTIONS,
formatPlaybackQualityPreset,
playbackQualityPresetFromValue,
playbackQualityValueFromPreset,
type PlaybackQualityPreset,
} from "@/lib/playback-quality";
import { RegistrySettingControl } from "@/components/settings/RegistrySettingControl";
import { formatSettingValue, getSettingDefinition } from "@/lib/settingsManifest";
import {
DeviceProfileTabs,
PlatformTile,
UNKNOWN_PROFILE_ID,
classifyPlatform,
formatRelative,
platformLabel,
shortenId,
type DeviceProfileTabEntry,
} from "@/components/admin/deviceOverrides";
import { toast } from "sonner";
export default function AdminUserDetail() {
const { id } = useParams<{ id: string }>();
const userId = Number(id);
const navigate = useNavigate();
const { beginImpersonation } = useAuth();
const { data: user, isLoading, error } = useAdminUser(userId);
const [editOpen, setEditOpen] = useState(false);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [confirmImpersonateOpen, setConfirmImpersonateOpen] = useState(false);
const deleteMutation = useDeleteUser();
const impersonateMutation = useImpersonateUser();
if (isLoading) return <div className="page-shell py-8">Loading user...</div>;
if (error || !user)
return <div className="page-shell text-destructive py-8">User not found.</div>;
const impersonationDisabled = user.role === "admin" || !user.enabled;
function handleDelete() {
setConfirmDeleteOpen(true);
}
return (
<div className="page-shell space-y-6 py-4 sm:py-6">
<nav
aria-label="Breadcrumb"
className="text-muted-foreground flex items-center gap-1.5 text-sm"
>
<Link to="/admin" className="hover:text-foreground transition-colors">
Admin
</Link>
<ChevronRight className="h-3.5 w-3.5" />
<Link to="/admin/users" className="hover:text-foreground transition-colors">
Users
</Link>
<ChevronRight className="h-3.5 w-3.5" />
<span className="text-foreground font-medium">{user.username}</span>
</nav>
<div className="page-header gap-5">
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">{user.username}</h1>
<Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role}</Badge>
<Badge variant={user.enabled ? "outline" : "destructive"}>
{user.enabled ? "Active" : "Disabled"}
</Badge>
</div>
<p className="page-subtitle text-sm sm:text-base">{user.email}</p>
</div>
<div className="flex w-full flex-wrap gap-2 sm:w-auto">
<Button
variant="outline"
size="sm"
className="flex-1 sm:flex-none"
onClick={() => setConfirmImpersonateOpen(true)}
disabled={impersonationDisabled || impersonateMutation.isPending}
>
Impersonate
</Button>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="flex-1 sm:flex-none">
<Pencil className="mr-1 h-3.5 w-3.5" /> Edit
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Edit User</DialogTitle>
</DialogHeader>
<EditUserForm user={user} onClose={() => setEditOpen(false)} />
</DialogContent>
</Dialog>
<Button
variant="destructive"
size="sm"
className="flex-1 sm:flex-none"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
Delete
</Button>
</div>
</div>
<Tabs defaultValue="overview">
<TabsList
variant="line"
className="surface-panel-subtle h-auto w-full justify-start gap-1 overflow-x-auto rounded-xl border-0 bg-transparent p-1"
>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="devices">Devices</TabsTrigger>
<TabsTrigger value="profiles">Profiles</TabsTrigger>
<TabsTrigger value="history">Watch History</TabsTrigger>
<TabsTrigger value="ips">IP History</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4">
<OverviewTab user={user} />
</TabsContent>
<TabsContent value="settings" className="mt-4">
<UserSettingsTab userId={userId} />
</TabsContent>
<TabsContent value="devices" className="mt-4">
<DeviceOverridesTab userId={userId} />
</TabsContent>
<TabsContent value="profiles" className="mt-4">
<ProfilesTab userId={userId} />
</TabsContent>
<TabsContent value="history" className="mt-4">
<WatchHistoryTab userId={userId} />
</TabsContent>
<TabsContent value="ips" className="mt-4">
<IPHistoryTab userId={userId} />
</TabsContent>
</Tabs>
<ConfirmDialog
open={confirmImpersonateOpen}
onOpenChange={(open) => {
if (!open) setConfirmImpersonateOpen(false);
}}
title="Impersonate user"
description={`Continue as "${user.username}"? Admin access will be removed until you end impersonation.`}
confirmLabel="Impersonate"
onConfirm={() => {
setConfirmImpersonateOpen(false);
void impersonateMutation
.mutateAsync(user.id)
.then((result) => {
beginImpersonation(result, `/admin/users/${user.id}`);
navigate("/profiles");
})
.catch((error: unknown) => {
toast.error(error instanceof Error ? error.message : "Failed to start impersonation");
});
}}
/>
<ConfirmDialog
open={confirmDeleteOpen}
onOpenChange={(open) => {
if (!open) setConfirmDeleteOpen(false);
}}
title="Delete user"
description={`Delete user "${user.username}"? This cannot be undone.`}
confirmLabel="Delete"
variant="destructive"
onConfirm={() => {
setConfirmDeleteOpen(false);
deleteMutation.mutate(user.id, {
onSuccess: () => navigate("/admin/users"),
});
}}
/>
</div>
);
}
function OverviewTab({ user }: { user: AdminUser }) {
const { data: libraries = [] } = useAdminLibraries();
const libraryNames =
user.library_ids === null
? "All libraries"
: user.library_ids.length === 0
? "None"
: user.library_ids
.map((id) => {
const lib = libraries.find((l) => l.id === id);
return lib ? lib.name : `#${id}`;
})
.join(", ");
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="surface-panel overflow-hidden rounded-2xl border-0">
<div className="border-border border-b px-4 py-3">
<h3 className="text-sm font-medium">Account</h3>
</div>
<div className="divide-border divide-y">
<DetailRow label="Username" value={user.username} />
<DetailRow label="Email" value={user.email} />
<DetailRow label="Role" value={user.role} />
<DetailRow label="Status" value={user.enabled ? "Active" : "Disabled"} />
<DetailRow label="Created" value={formatDate(user.created_at)} />
<DetailRow label="Updated" value={formatDate(user.updated_at)} />
</div>
</div>
<div className="surface-panel overflow-hidden rounded-2xl border-0">
<div className="border-border border-b px-4 py-3">
<h3 className="text-sm font-medium">Permissions & Limits</h3>
</div>
<div className="divide-border divide-y">
<DetailRow label="Library Access" value={libraryNames} />
<DetailRow
label="Max Playback Quality"
value={formatPlaybackQualityPreset(user.max_playback_quality)}
/>
<DetailRow
label="Max Streams"
value={user.max_streams === 0 ? "Unlimited" : String(user.max_streams)}
/>
<DetailRow
label="Max Transcodes"
value={user.max_transcodes === 0 ? "Unlimited" : String(user.max_transcodes)}
/>
<DetailRow label="Max Profiles" value={String(user.max_profiles)} />
<DetailRow label="Downloads" value={user.download_allowed ? "Allowed" : "Not allowed"} />
<DetailRow
label="Download Transcode"
value={user.download_transcode_allowed ? "Allowed" : "Not allowed"}
/>
</div>
</div>
</div>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between px-4 py-2.5">
<span className="text-muted-foreground text-sm">{label}</span>
<span className="text-sm font-medium">{value}</span>
</div>
);
}
function ProfilesTab({ userId }: { userId: number }) {
const { data: profiles, isLoading } = useAdminUserProfiles(userId);
if (isLoading)
return (
<div className="text-muted-foreground py-8 text-center text-sm">Loading profiles...</div>
);
if (!profiles || profiles.length === 0)
return (
<div className="surface-panel text-muted-foreground rounded-2xl py-10 text-center text-sm">
No profiles found for this user.
</div>
);
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{profiles.map((profile: AdminUserProfile) => (
<ProfileCard key={profile.id} profile={profile} />
))}
</div>
);
}
function ProfileCard({ profile }: { profile: AdminUserProfile }) {
return (
<div className="surface-panel flex items-center gap-3 rounded-xl border-0 px-4 py-3">
<UserCircle className="text-muted-foreground h-8 w-8" />
<div>
<div className="text-sm font-medium">{profile.name}</div>
<div className="text-muted-foreground text-xs">{profile.id}</div>
</div>
</div>
);
}
function WatchHistoryTab({ userId }: { userId: number }) {
const {
data: rows = [],
isLoading,
error,
} = useAdminPlaybackHistory({
userId,
limit: 50,
});
if (isLoading)
return (
<div className="text-muted-foreground py-8 text-center text-sm">Loading watch history...</div>
);
if (error)
return (
<div className="text-destructive py-8 text-center text-sm">Failed to load watch history.</div>
);
if (rows.length === 0)
return (
<div className="surface-panel text-muted-foreground rounded-2xl py-10 text-center text-sm">
No playback history for this user.
</div>
);
return (
<div className="surface-panel overflow-x-auto rounded-2xl border-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Media</TableHead>
<TableHead>Profile</TableHead>
<TableHead>Method</TableHead>
<TableHead>Watch Time</TableHead>
<TableHead>Status</TableHead>
<TableHead>Ended</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const title = row.media_title || row.media_item_id || `File #${row.media_file_id}`;
return (
<TableRow key={row.session_id}>
<TableCell>
{row.media_item_id ? (
<Link
to={`/item/${encodeURIComponent(row.media_item_id)}`}
className="hover:text-primary block font-medium transition-colors hover:underline"
>
{title}
</Link>
) : (
<div className="font-medium">{title}</div>
)}
<div className="text-muted-foreground text-xs">{row.media_type || "unknown"}</div>
</TableCell>
<TableCell>
<Link
to={`/admin/history?user_id=${userId}&profile_id=${encodeURIComponent(row.profile_id)}`}
className="hover:text-primary transition-colors hover:underline"
>
{row.profile_name || row.profile_id}
</Link>
</TableCell>
<TableCell>
<Badge variant="secondary">{row.play_method}</Badge>
</TableCell>
<TableCell>
<div>{formatDuration(row.watched_seconds)}</div>
<div className="text-muted-foreground text-xs">
of {formatDuration(row.duration_seconds)}
</div>
</TableCell>
<TableCell>
<Badge variant={row.completed ? "default" : "outline"}>
{row.completed ? "Completed" : "Partial"}
</Badge>
</TableCell>
<TableCell>
<div>{formatDateTime(row.ended_at)}</div>
<div className="text-muted-foreground text-xs">
started {formatRelative(row.started_at)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}
function IPHistoryTab({ userId }: { userId: number }) {
const { data: ips = [], isLoading } = useUserIPs(userId);
if (isLoading)
return (
<div className="text-muted-foreground py-8 text-center text-sm">Loading IP history...</div>
);
if (ips.length === 0)
return (
<div className="surface-panel text-muted-foreground rounded-2xl py-10 text-center text-sm">
No IP history found for this user.
</div>
);
return (
<div className="surface-panel overflow-x-auto rounded-2xl border-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>IP Address</TableHead>
<TableHead>First Seen</TableHead>
<TableHead>Last Seen</TableHead>
<TableHead className="text-right">Requests</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ips.map((entry: UserIPEntry) => (
<TableRow key={entry.client_ip}>
<TableCell className="font-mono text-sm">{entry.client_ip}</TableCell>
<TableCell>{formatDateTime(entry.first_seen)}</TableCell>
<TableCell>{formatDateTime(entry.last_seen)}</TableCell>
<TableCell className="text-right">{entry.request_count.toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
function UserSettingsTab({ userId }: { userId: number }) {
const { data: settings = [], isLoading } = useAdminUserSettings(userId);
const updateSetting = useUpdateAdminUserSetting();
const deleteSetting = useDeleteAdminUserSetting();
if (isLoading) {
return (
<div className="text-muted-foreground py-8 text-center text-sm">Loading settings...</div>
);
}
const renderSettingRow = (entry: AdminSettingEntry) => {
const definition = getSettingDefinition(entry.key);
const label = definition?.label ?? entry.key;
const description = definition?.description ?? "Stored account preference.";
return (
<div
key={entry.key}
className="border-border/50 grid gap-3 border-t py-4 first:border-t-0 first:pt-0 md:grid-cols-[minmax(0,1fr)_auto]"
>
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium">{label}</div>
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[11px] font-medium text-slate-200">
Explicit
</span>
</div>
<p className="text-muted-foreground text-[13px] leading-relaxed">{description}</p>
<p className="text-muted-foreground text-xs">
Current: {formatSettingValue(entry.key, entry.value)}
</p>
</div>
<div className="flex flex-col items-stretch gap-2 md:items-end">
{definition ? (
<RegistrySettingControl
definition={definition}
value={entry.value}
disabled={updateSetting.isPending || deleteSetting.isPending}
onChange={(value) =>
updateSetting.mutate({
userId,
key: entry.key,
value,
})
}
/>
) : (
<Input
key={`${entry.key}:${entry.value}`}
defaultValue={entry.value}
disabled={updateSetting.isPending || deleteSetting.isPending}
className="w-full min-w-[180px] sm:w-[220px]"
onBlur={(event) => {
if (event.currentTarget.value === entry.value) return;
updateSetting.mutate({
userId,
key: entry.key,
value: event.currentTarget.value,
});
}}
/>
)}
<Button
variant="ghost"
size="sm"
className="h-7 rounded-full px-2 text-xs"
onClick={() => deleteSetting.mutate({ userId, key: entry.key })}
disabled={updateSetting.isPending || deleteSetting.isPending}
>
<RotateCcw className="mr-1 h-3 w-3" />
Reset
</Button>
</div>
</div>
);
};
return (
<div className="space-y-4">
<div className="surface-panel overflow-hidden rounded-[1.8rem] border-0">
<div className="border-border/60 flex items-center gap-3 border-b px-5 py-4">
<div className="rounded-full border border-emerald-500/25 bg-emerald-500/10 p-2">
<Settings2 className="h-4 w-4 text-emerald-100" />
</div>
<div>
<h3 className="text-sm font-semibold">User Playback Settings</h3>
<p className="text-muted-foreground text-sm">
Account-wide playback preferences that follow this user across devices.
</p>
</div>
</div>
<div className="space-y-0 px-5 py-4">
{settings.length > 0 ? (
settings.map(renderSettingRow)
) : (
<div className="text-muted-foreground py-6 text-center text-sm">
No account-wide settings are stored for this user.
</div>
)}
</div>
</div>
</div>
);
}
function DeviceOverridesTab({ userId }: { userId: number }) {
const { data: settings = [], isLoading } = useAdminUserDeviceSettings(userId);
const updateSetting = useUpdateAdminUserDeviceSetting();
const deleteSetting = useDeleteAdminUserDeviceSetting();
const deleteDevice = useDeleteAllAdminUserDeviceSettingsForDevice();
const [deviceToReset, setDeviceToReset] = useState<{
deviceId: string;
profileId: string;
profileName?: string;
} | null>(null);
const [settingToReset, setSettingToReset] = useState<AdminDeviceSetting | null>(null);
const [jsonEditor, setJsonEditor] = useState<AdminDeviceSetting | null>(null);
const [jsonValue, setJsonValue] = useState("");
const deviceEntries = useMemo(() => {
const grouped = new Map<string, Map<string, AdminDeviceSetting[]>>();
for (const entry of settings) {
const profileKey = entry.profile_id || UNKNOWN_PROFILE_ID;
let deviceGroup = grouped.get(entry.device_id);
if (!deviceGroup) {
deviceGroup = new Map();
grouped.set(entry.device_id, deviceGroup);
}
const list = deviceGroup.get(profileKey);
if (list) {
list.push(entry);
} else {
deviceGroup.set(profileKey, [entry]);
}
}
return Array.from(grouped, ([deviceId, profileMap]) => ({
deviceId,
profiles: Array.from(profileMap, ([profileId, entries]) => ({ profileId, entries })),
}));
}, [settings]);
if (isLoading) {
return (
<div className="text-muted-foreground py-8 text-center text-sm">
Loading device overrides...
</div>
);
}
const totalOverrides = settings.length;
const totalProfiles = new Set(settings.map((s) => s.profile_id || UNKNOWN_PROFILE_ID)).size;
return (
<div className="space-y-4">
<ConfirmDialog
open={deviceToReset !== null}
onOpenChange={(open) => {
if (!open) setDeviceToReset(null);
}}
title={
deviceToReset?.profileName
? `Reset overrides for ${deviceToReset.profileName}?`
: "Reset profile overrides"
}
description="Every override for this profile on this device will be cleared. Playback falls back to account or default values."
confirmLabel="Reset all"
variant="destructive"
onConfirm={() => {
if (deviceToReset) {
deleteDevice.mutate({
userId,
profileId: deviceToReset.profileId,
deviceId: deviceToReset.deviceId,
});
}
setDeviceToReset(null);
}}
/>
<ConfirmDialog
open={settingToReset !== null}
onOpenChange={(open) => {
if (!open) setSettingToReset(null);
}}
title="Reset this override?"
description="The override will be removed and the device will fall back to the profile default."
confirmLabel="Reset override"
variant="destructive"
onConfirm={() => {
if (settingToReset) {
deleteSetting.mutate({
userId,
profileId: settingToReset.profile_id,
deviceId: settingToReset.device_id,
key: settingToReset.key,
});
}
setSettingToReset(null);
}}
/>
<Dialog
open={jsonEditor !== null}
onOpenChange={(open) => {
if (!open) {
setJsonEditor(null);
setJsonValue("");
}
}}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="font-mono text-sm">{jsonEditor?.key ?? "JSON"}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<p className="text-muted-foreground text-[12.5px]">
Edit the raw value. Invalid JSON is saved as-is and may cause clients to fall back to
defaults.
</p>
<textarea
spellCheck={false}
className="border-border bg-background focus:border-foreground/40 min-h-[260px] w-full rounded-md border px-3 py-2 font-mono text-[13px] leading-relaxed transition-colors outline-none"
value={jsonValue}
onChange={(event) => setJsonValue(event.target.value)}
/>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setJsonEditor(null);
setJsonValue("");
}}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => {
if (!jsonEditor) return;
updateSetting.mutate(
{
userId,
profileId: jsonEditor.profile_id,
deviceId: jsonEditor.device_id,
key: jsonEditor.key,
value: jsonValue,
},
{
onSuccess: () => {
setJsonEditor(null);
setJsonValue("");
},
},
);
}}
>
Save override
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{deviceEntries.length > 0 && (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-[13px] tabular-nums">
<span>
<span className="text-foreground font-medium">{deviceEntries.length}</span>{" "}
{deviceEntries.length === 1 ? "device" : "devices"}
</span>
<span className="text-muted-foreground/40">·</span>
<span>
<span className="text-foreground font-medium">{totalOverrides}</span>{" "}
{totalOverrides === 1 ? "override" : "overrides"}
</span>
<span className="text-muted-foreground/40">·</span>
<span>
<span className="text-foreground font-medium">{totalProfiles}</span>{" "}
{totalProfiles === 1 ? "profile" : "profiles"}
</span>
</div>
)}
{deviceEntries.length === 0 ? (
<div className="surface-panel rounded-xl border-0 px-6 py-12 text-center">
<p className="text-foreground text-sm font-medium">No device overrides</p>
<p className="text-muted-foreground mx-auto mt-1 max-w-sm text-[12.5px] leading-relaxed">
Overrides appear here as soon as this user tunes a per-device playback setting.
</p>
</div>
) : (
<div className="space-y-3">
{deviceEntries.map(({ deviceId, profiles }) => {
const allEntries = profiles.flatMap((p) => p.entries);
const first = allEntries[0];
if (!first) return null;
const lastUpdated = allEntries
.map((entry) => entry.updated_at)
.sort((a, b) => b.localeCompare(a))[0];
const kind = classifyPlatform(first.device_platform);
const profileCount = profiles.length;
const overrideCount = allEntries.length;
return (
<section key={deviceId} className="surface-panel overflow-hidden rounded-xl border-0">
<header className="border-border/60 flex flex-wrap items-start justify-between gap-3 border-b px-4 py-3 sm:px-5">
<div className="flex min-w-0 items-start gap-3">
<PlatformTile kind={kind} />
<div className="min-w-0 space-y-1">
<h3 className="text-foreground text-[14px] leading-tight font-semibold">
{first.device_name || "Unnamed device"}
</h3>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-[11.5px]">
<span className="text-foreground/80 font-mono">
{shortenId(deviceId, 8)}
</span>
<span className="text-muted-foreground/40">·</span>
<span>{platformLabel(first.device_platform)}</span>
{lastUpdated && (
<>
<span className="text-muted-foreground/40">·</span>
<span>updated {formatRelative(lastUpdated)}</span>
</>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<Badge
variant="outline"
className="border-border/60 bg-background/60 rounded-full px-2 py-0.5 text-[10.5px] font-normal tabular-nums"
>
<span className="text-foreground font-medium">{profileCount}</span>
<span className="text-muted-foreground">
{profileCount === 1 ? "profile" : "profiles"}
</span>
</Badge>
<Badge
variant="outline"
className="border-border/60 bg-background/60 rounded-full px-2 py-0.5 text-[10.5px] font-normal tabular-nums"
>
<span className="text-foreground font-medium">{overrideCount}</span>
<span className="text-muted-foreground">
{overrideCount === 1 ? "override" : "overrides"}
</span>
</Badge>
</div>
</div>
</div>
<Button variant="outline" size="sm" asChild>
<Link to={`/admin/devices/${userId}/${encodeURIComponent(deviceId)}`}>
Open device
<ArrowUpRight className="h-3 w-3" />
</Link>
</Button>
</header>
<DeviceProfileTabs
profiles={profiles.map(
({ profileId, entries }): DeviceProfileTabEntry => ({
profileId,
profileName: entries[0]?.profile_name || profileId,
settings: entries,
}),
)}
onResetProfile={(profileId, profileName) =>
setDeviceToReset({ deviceId, profileId, profileName })
}
onEditJson={(setting) => {
setJsonEditor(setting);
setJsonValue(setting.value);
}}
onResetSetting={(setting) => setSettingToReset(setting)}
onChangeSetting={(setting, value) =>
updateSetting.mutate({
userId,
profileId: setting.profile_id,
deviceId: setting.device_id,
key: setting.key,
value,
})
}
updatePending={updateSetting.isPending}
resetPending={deleteDevice.isPending}
/>
</section>
);
})}
</div>
)}
</div>
);
}
function EditUserForm({ user, onClose }: { user: AdminUser; 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);
const [enabled, setEnabled] = useState(user.enabled);
const [libraryIDs, setLibraryIDs] = useState<number[] | null>(user.library_ids);
const [maxStreams, setMaxStreams] = useState(user.max_streams);
const [maxTranscodes, setMaxTranscodes] = useState(user.max_transcodes);
const [maxProfiles, setMaxProfiles] = useState(user.max_profiles);
const [maxPlaybackQualityPreset, setMaxPlaybackQualityPreset] = useState<PlaybackQualityPreset>(
playbackQualityPresetFromValue(user.max_playback_quality),
);
const [downloadAllowed, setDownloadAllowed] = useState(user.download_allowed);
const [downloadTranscodeAllowed, setDownloadTranscodeAllowed] = useState(
user.download_transcode_allowed,
);
const updateMutation = useUpdateUser();
function handleSubmit(e: FormEvent) {
e.preventDefault();
const body: UpdateUserRequest = {
username,
email,
role,
enabled,
library_ids: libraryIDs,
max_streams: maxStreams,
max_transcodes: maxTranscodes,
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 });
}
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>Username</Label>
<Input value={username} onChange={(e) => setUsername(e.target.value)} required />
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label>Password (leave blank to keep current)</Label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Role</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<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 className="text-xs">Enabled</Label>
<Switch 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="grid gap-2 sm:grid-cols-2">
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<Label>Downloads Allowed</Label>
<Switch checked={downloadAllowed} onCheckedChange={setDownloadAllowed} />
</div>
<div className="border-border flex items-center justify-between rounded-md border px-3 py-2">
<Label>Download Transcode Allowed</Label>
<Switch
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>Max Streams</Label>
<Input
type="number"
min={0}
value={maxStreams}
onChange={(e) => setMaxStreams(Number(e.target.value))}
/>
<p className="text-muted-foreground text-xs">0 = unlimited</p>
</div>
<div className="space-y-1">
<Label>Max Transcodes</Label>
<Input
type="number"
min={0}
value={maxTranscodes}
onChange={(e) => setMaxTranscodes(Number(e.target.value))}
/>
<p className="text-muted-foreground text-xs">0 = unlimited</p>
</div>
<div className="space-y-1">
<Label>Max Profiles</Label>
<Input
type="number"
min={1}
value={maxProfiles}
onChange={(e) => setMaxProfiles(Number(e.target.value))}
/>
</div>
<div className="space-y-1 sm:col-span-2">
<Label>Max Playback Quality</Label>
<Select
value={maxPlaybackQualityPreset}
onValueChange={(value) =>
setMaxPlaybackQualityPreset(value as PlaybackQualityPreset)
}
>
<SelectTrigger>
<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={updateMutation.isPending}>
{updateMutation.isPending ? "Saving..." : "Save"}
</Button>
</div>
</form>
);
}
function formatDuration(seconds: number | null) {
if (!seconds || Number.isNaN(seconds)) return "0m";
const rounded = Math.max(0, Math.floor(seconds));
const hours = Math.floor(rounded / 3600);
const minutes = Math.floor((rounded % 3600) / 60);
const secs = rounded % 60;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${secs}s`;
return `${secs}s`;
}
function formatDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
function formatDateTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}