feat: admin provider UI (types, hooks, guard, settings panel, conditional admin nav)
This commit is contained in:
@@ -0,0 +1,180 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useProviderConfigs, useUpdateProvider, useTestProvider } from "@/hooks/use-admin-providers";
|
||||||
|
import { useConfig } from "@/hooks/use-config";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { CheckCircle, XCircle, Loader2 } from "lucide-react";
|
||||||
|
import { ApiRequestError } from "@/lib/api";
|
||||||
|
|
||||||
|
const PROVIDER_FIELDS: Record<
|
||||||
|
string,
|
||||||
|
Array<{ key: string; label: string; type?: string; required?: boolean }>
|
||||||
|
> = {
|
||||||
|
jellyfin: [
|
||||||
|
{ key: "base_url", label: "Base URL", required: true },
|
||||||
|
{ key: "api_key", label: "API Key", type: "password", required: true },
|
||||||
|
{ key: "user_id", label: "User ID", required: true },
|
||||||
|
],
|
||||||
|
local_files: [
|
||||||
|
{ key: "files_dir", label: "Files Directory", required: true },
|
||||||
|
{ key: "transcode_dir", label: "Transcode Directory" },
|
||||||
|
{ key: "cleanup_ttl_hours", label: "Cleanup TTL Hours" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ProviderCardProps {
|
||||||
|
providerType: string;
|
||||||
|
existingConfig?: { config_json: Record<string, string>; enabled: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderCard({ providerType, existingConfig }: ProviderCardProps) {
|
||||||
|
const fields = PROVIDER_FIELDS[providerType] ?? [];
|
||||||
|
const [formValues, setFormValues] = useState<Record<string, string>>(
|
||||||
|
() => existingConfig?.config_json ?? {},
|
||||||
|
);
|
||||||
|
const [enabled, setEnabled] = useState(existingConfig?.enabled ?? true);
|
||||||
|
const [conflictError, setConflictError] = useState(false);
|
||||||
|
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||||
|
|
||||||
|
const updateProvider = useUpdateProvider();
|
||||||
|
const testProvider = useTestProvider();
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setConflictError(false);
|
||||||
|
try {
|
||||||
|
await updateProvider.mutateAsync({
|
||||||
|
type: providerType,
|
||||||
|
payload: { config_json: formValues, enabled },
|
||||||
|
});
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (e instanceof ApiRequestError && e.status === 409) {
|
||||||
|
setConflictError(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
setTestResult(null);
|
||||||
|
const result = await testProvider.mutateAsync({
|
||||||
|
type: providerType,
|
||||||
|
payload: { config_json: formValues, enabled: true },
|
||||||
|
});
|
||||||
|
setTestResult(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-zinc-800 bg-zinc-900">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium capitalize text-zinc-100">
|
||||||
|
{providerType.replace("_", " ")}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-zinc-400">Enabled</span>
|
||||||
|
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{conflictError && (
|
||||||
|
<div className="rounded border border-yellow-600/40 bg-yellow-950/30 px-3 py-2 text-xs text-yellow-400">
|
||||||
|
UI config disabled — set <code>CONFIG_SOURCE=db</code> on the server
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fields.map((field) => (
|
||||||
|
<div key={field.key} className="space-y-1">
|
||||||
|
<Label className="text-xs text-zinc-400">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="ml-1 text-red-400">*</span>}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type={field.type ?? "text"}
|
||||||
|
value={formValues[field.key] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormValues((prev) => ({ ...prev, [field.key]: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
field.type === "password" ? "••••••••" : `Enter ${field.label.toLowerCase()}`
|
||||||
|
}
|
||||||
|
className="h-8 border-zinc-700 bg-zinc-800 text-xs text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{testResult && (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 rounded px-3 py-2 text-xs ${
|
||||||
|
testResult.ok
|
||||||
|
? "bg-green-950/30 text-green-400"
|
||||||
|
: "bg-red-950/30 text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{testResult.ok ? (
|
||||||
|
<CheckCircle className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{testResult.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={testProvider.isPending}
|
||||||
|
className="border-zinc-700 text-xs"
|
||||||
|
>
|
||||||
|
{testProvider.isPending && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
|
||||||
|
Test Connection
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={updateProvider.isPending}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{updateProvider.isPending && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderSettingsPanel() {
|
||||||
|
const { data: config } = useConfig();
|
||||||
|
const { data: providerConfigs = [] } = useProviderConfigs();
|
||||||
|
|
||||||
|
const availableTypes = config?.available_provider_types ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-zinc-100">Provider Configuration</h2>
|
||||||
|
<p className="mt-0.5 text-xs text-zinc-500">
|
||||||
|
Configure media providers. Requires <code>CONFIG_SOURCE=db</code> on the server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{availableTypes.length === 0 ? (
|
||||||
|
<p className="text-xs text-zinc-500">No providers available in this build.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{availableTypes.map((type) => {
|
||||||
|
const existing = providerConfigs.find((c) => c.provider_type === type);
|
||||||
|
return (
|
||||||
|
<ProviderCard
|
||||||
|
key={type}
|
||||||
|
providerType={type}
|
||||||
|
existingConfig={existing}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
k-tv-frontend/app/(main)/admin/layout.tsx
Normal file
27
k-tv-frontend/app/(main)/admin/layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, type ReactNode } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useCurrentUser } from "@/hooks/use-auth";
|
||||||
|
import { useAuthContext } from "@/context/auth-context";
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||||
|
const { token, isLoaded } = useAuthContext();
|
||||||
|
const router = useRouter();
|
||||||
|
const { data: user, isLoading } = useCurrentUser();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoaded) return;
|
||||||
|
if (!token) {
|
||||||
|
router.replace("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isLoading && user && !user.is_admin) {
|
||||||
|
router.replace("/dashboard");
|
||||||
|
}
|
||||||
|
}, [isLoaded, token, user, isLoading, router]);
|
||||||
|
|
||||||
|
if (!isLoaded || isLoading || !user?.is_admin) return null;
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -1,21 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useAuthContext } from "@/context/auth-context";
|
import { useAuthContext } from "@/context/auth-context";
|
||||||
import { useActivityLog, useServerLogs } from "@/hooks/use-admin";
|
import { useActivityLog, useServerLogs } from "@/hooks/use-admin";
|
||||||
import { ServerLogsPanel } from "./components/server-logs-panel";
|
import { ServerLogsPanel } from "./components/server-logs-panel";
|
||||||
import { ActivityLogPanel } from "./components/activity-log-panel";
|
import { ActivityLogPanel } from "./components/activity-log-panel";
|
||||||
|
import { ProviderSettingsPanel } from "./components/provider-settings-panel";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const { token, isLoaded } = useAuthContext();
|
const { token } = useAuthContext();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLoaded && !token) {
|
|
||||||
router.replace("/login");
|
|
||||||
}
|
|
||||||
}, [isLoaded, token, router]);
|
|
||||||
|
|
||||||
const { lines, connected } = useServerLogs(token);
|
const { lines, connected } = useServerLogs(token);
|
||||||
const [localLines, setLocalLines] = useState(lines);
|
const [localLines, setLocalLines] = useState(lines);
|
||||||
@@ -27,8 +21,6 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const { data: events = [], isLoading } = useActivityLog(token);
|
const { data: events = [], isLoading } = useActivityLog(token);
|
||||||
|
|
||||||
if (!isLoaded || !token) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
@@ -37,22 +29,47 @@ export default function AdminPage() {
|
|||||||
<span className="text-xs text-zinc-500">System monitoring & logs</span>
|
<span className="text-xs text-zinc-500">System monitoring & logs</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Two-column layout */}
|
<Tabs defaultValue="logs" className="flex flex-1 flex-col overflow-hidden">
|
||||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
<div className="border-b border-zinc-800 px-6">
|
||||||
{/* Left: server logs */}
|
<TabsList className="h-9 bg-transparent p-0 gap-1">
|
||||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden border-r border-zinc-800">
|
<TabsTrigger
|
||||||
<ServerLogsPanel
|
value="logs"
|
||||||
lines={localLines}
|
className="rounded-none border-b-2 border-transparent px-3 py-1.5 text-xs data-[state=active]:border-zinc-100 data-[state=active]:bg-transparent data-[state=active]:text-zinc-100"
|
||||||
connected={connected}
|
>
|
||||||
onClear={() => setLocalLines([])}
|
Logs
|
||||||
/>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="providers"
|
||||||
|
className="rounded-none border-b-2 border-transparent px-3 py-1.5 text-xs data-[state=active]:border-zinc-100 data-[state=active]:bg-transparent data-[state=active]:text-zinc-100"
|
||||||
|
>
|
||||||
|
Providers
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: activity log */}
|
<TabsContent value="logs" className="flex min-h-0 flex-1 overflow-hidden mt-0">
|
||||||
<div className="flex w-80 shrink-0 flex-col overflow-hidden">
|
{/* Two-column layout */}
|
||||||
<ActivityLogPanel events={events} isLoading={isLoading} />
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||||
</div>
|
{/* Left: server logs */}
|
||||||
</div>
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden border-r border-zinc-800">
|
||||||
|
<ServerLogsPanel
|
||||||
|
lines={localLines}
|
||||||
|
connected={connected}
|
||||||
|
onClear={() => setLocalLines([])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: activity log */}
|
||||||
|
<div className="flex w-80 shrink-0 flex-col overflow-hidden">
|
||||||
|
<ActivityLogPanel events={events} isLoading={isLoading} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="providers" className="flex-1 overflow-auto mt-0">
|
||||||
|
<ProviderSettingsPanel />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
17
k-tv-frontend/app/(main)/components/admin-nav-link.tsx
Normal file
17
k-tv-frontend/app/(main)/components/admin-nav-link.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useCurrentUser } from "@/hooks/use-auth";
|
||||||
|
|
||||||
|
export function AdminNavLink() {
|
||||||
|
const { data: user } = useCurrentUser();
|
||||||
|
if (!user?.is_admin) return null;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className="rounded-md px-3 py-1.5 text-sm text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { NavAuth } from "./components/nav-auth";
|
import { NavAuth } from "./components/nav-auth";
|
||||||
|
import { AdminNavLink } from "./components/admin-nav-link";
|
||||||
|
|
||||||
const NAV_LINKS = [
|
const NAV_LINKS = [
|
||||||
{ href: "/tv", label: "TV" },
|
{ href: "/tv", label: "TV" },
|
||||||
{ href: "/guide", label: "Guide" },
|
{ href: "/guide", label: "Guide" },
|
||||||
{ href: "/dashboard", label: "Dashboard" },
|
{ href: "/dashboard", label: "Dashboard" },
|
||||||
{ href: "/admin", label: "Admin" },
|
|
||||||
{ href: "/docs", label: "Docs" },
|
{ href: "/docs", label: "Docs" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -33,6 +33,9 @@ export default function MainLayout({ children }: { children: ReactNode }) {
|
|||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
<li>
|
||||||
|
<AdminNavLink />
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div className="ml-2 border-l border-zinc-800 pl-2">
|
<div className="ml-2 border-l border-zinc-800 pl-2">
|
||||||
<NavAuth />
|
<NavAuth />
|
||||||
|
|||||||
52
k-tv-frontend/hooks/use-admin-providers.ts
Normal file
52
k-tv-frontend/hooks/use-admin-providers.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useAuthContext } from "@/context/auth-context";
|
||||||
|
|
||||||
|
export function useProviderConfigs() {
|
||||||
|
const { token } = useAuthContext();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["admin", "providers"],
|
||||||
|
queryFn: () => api.admin.providers.getProviders(token!),
|
||||||
|
enabled: !!token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateProvider() {
|
||||||
|
const { token } = useAuthContext();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
type,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
type: string;
|
||||||
|
payload: { config_json: Record<string, string>; enabled: boolean };
|
||||||
|
}) => api.admin.providers.updateProvider(token!, type, payload),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "providers"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteProvider() {
|
||||||
|
const { token } = useAuthContext();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (type: string) =>
|
||||||
|
api.admin.providers.deleteProvider(token!, type),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "providers"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTestProvider() {
|
||||||
|
const { token } = useAuthContext();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
type,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
type: string;
|
||||||
|
payload: { config_json: Record<string, string>; enabled: boolean };
|
||||||
|
}) => api.admin.providers.testProvider(token!, type, payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ import type {
|
|||||||
TranscodeSettings,
|
TranscodeSettings,
|
||||||
TranscodeStats,
|
TranscodeStats,
|
||||||
ActivityEvent,
|
ActivityEvent,
|
||||||
|
ProviderConfig,
|
||||||
|
ProviderTestResult,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
const API_BASE =
|
const API_BASE =
|
||||||
@@ -179,6 +181,36 @@ export const api = {
|
|||||||
admin: {
|
admin: {
|
||||||
activity: (token: string) =>
|
activity: (token: string) =>
|
||||||
request<ActivityEvent[]>("/admin/activity", { token }),
|
request<ActivityEvent[]>("/admin/activity", { token }),
|
||||||
|
|
||||||
|
providers: {
|
||||||
|
getProviders: (token: string) =>
|
||||||
|
request<ProviderConfig[]>("/admin/providers", { token }),
|
||||||
|
|
||||||
|
updateProvider: (
|
||||||
|
token: string,
|
||||||
|
type: string,
|
||||||
|
payload: { config_json: Record<string, string>; enabled: boolean },
|
||||||
|
) =>
|
||||||
|
request<ProviderConfig>(`/admin/providers/${type}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
token,
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteProvider: (token: string, type: string) =>
|
||||||
|
request<void>(`/admin/providers/${type}`, { method: "DELETE", token }),
|
||||||
|
|
||||||
|
testProvider: (
|
||||||
|
token: string,
|
||||||
|
type: string,
|
||||||
|
payload: { config_json: Record<string, string>; enabled: boolean },
|
||||||
|
) =>
|
||||||
|
request<ProviderTestResult>(`/admin/providers/${type}/test`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
token,
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
schedule: {
|
schedule: {
|
||||||
|
|||||||
@@ -131,6 +131,18 @@ export interface ConfigResponse {
|
|||||||
providers: ProviderInfo[];
|
providers: ProviderInfo[];
|
||||||
/** Primary provider capabilities — kept for backward compat. */
|
/** Primary provider capabilities — kept for backward compat. */
|
||||||
provider_capabilities: ProviderCapabilities;
|
provider_capabilities: ProviderCapabilities;
|
||||||
|
available_provider_types: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderConfig {
|
||||||
|
provider_type: string;
|
||||||
|
config_json: Record<string, string>;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderTestResult {
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
@@ -145,6 +157,7 @@ export interface UserResponse {
|
|||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
is_admin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channels
|
// Channels
|
||||||
|
|||||||
Reference in New Issue
Block a user