feat(ui): add routed operator workflows
This commit is contained in:
+256
-139
@@ -1,159 +1,276 @@
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
type BackupToolClient,
|
||||
type Components,
|
||||
isApiError,
|
||||
} from "../api/generated/client";
|
||||
import { AuditPage, BackupsPage, NotificationsPage, SecurityPage } from "./M13Workflows";
|
||||
Component,
|
||||
type ErrorInfo,
|
||||
type ReactNode,
|
||||
lazy,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import {
|
||||
Navigate,
|
||||
NavLink,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
} from "react-router-dom";
|
||||
|
||||
import type { BackupToolClient, Components } from "../api/generated/client";
|
||||
import type { OperatorPageProps } from "./OperatorPages";
|
||||
|
||||
type SessionUser = Components["schemas"]["SessionUser"];
|
||||
type SourceSummary = Components["schemas"]["SourceSummary"];
|
||||
type RepositorySummary = Components["schemas"]["RepositorySummary"];
|
||||
type JobSummary = Components["schemas"]["JobSummary"];
|
||||
type ExecutionSummary = Components["schemas"]["ExecutionSummary"];
|
||||
type Page = "dashboard" | "sources" | "repositories" | "jobs" | "executions" | "backups" | "security" | "notifications" | "audit";
|
||||
type LoadState<T> =
|
||||
| { kind: "loading" }
|
||||
| { kind: "ready"; items: T }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
const pages: Array<{ id: Page; label: string }> = [
|
||||
{ id: "dashboard", label: "Dashboard" },
|
||||
{ id: "sources", label: "Sources" },
|
||||
{ id: "repositories", label: "Repositories" },
|
||||
{ id: "jobs", label: "Jobs & schedules" },
|
||||
{ id: "executions", label: "Executions" },
|
||||
{ id: "backups", label: "Backups" },
|
||||
{ id: "security", label: "Security & recovery" },
|
||||
{ id: "notifications", label: "Notifications" },
|
||||
{ id: "audit", label: "Audit" },
|
||||
type RouteDefinition = {
|
||||
path: string;
|
||||
label: string;
|
||||
title: string;
|
||||
component: React.LazyExoticComponent<React.ComponentType<OperatorPageProps>>;
|
||||
};
|
||||
|
||||
const DashboardPage = lazy(() =>
|
||||
import("./OperatorPages").then(({ DashboardPage: Page }) => ({
|
||||
default: Page,
|
||||
})),
|
||||
);
|
||||
const SourcesPage = lazy(() =>
|
||||
import("./OperatorPages").then(({ SourcesPage: Page }) => ({
|
||||
default: Page,
|
||||
})),
|
||||
);
|
||||
const RepositoriesPage = lazy(() =>
|
||||
import("./OperatorPages").then(({ RepositoriesPage: Page }) => ({
|
||||
default: Page,
|
||||
})),
|
||||
);
|
||||
const JobsPage = lazy(() =>
|
||||
import("./OperatorPages").then(({ JobsPage: Page }) => ({ default: Page })),
|
||||
);
|
||||
const ExecutionsPage = lazy(() =>
|
||||
import("./OperatorPages").then(({ ExecutionsPage: Page }) => ({
|
||||
default: Page,
|
||||
})),
|
||||
);
|
||||
const BackupsPage = lazy(() =>
|
||||
import("./M13Workflows").then(({ BackupsPage: Page }) => ({ default: Page })),
|
||||
);
|
||||
const SecurityPage = lazy(() =>
|
||||
import("./M13Workflows").then(({ SecurityPage: Page }) => ({
|
||||
default: Page,
|
||||
})),
|
||||
);
|
||||
const NotificationsPage = lazy(() =>
|
||||
import("./M13Workflows").then(({ NotificationsPage: Page }) => ({
|
||||
default: Page,
|
||||
})),
|
||||
);
|
||||
const AuditPage = lazy(() =>
|
||||
import("./M13Workflows").then(({ AuditPage: Page }) => ({ default: Page })),
|
||||
);
|
||||
const AdministrationPage = lazy(() =>
|
||||
import("./AdministrationPage").then(({ AdministrationPage: Page }) => ({
|
||||
default: Page,
|
||||
})),
|
||||
);
|
||||
|
||||
const routes: RouteDefinition[] = [
|
||||
{
|
||||
path: "/dashboard",
|
||||
label: "Dashboard",
|
||||
title: "Dashboard",
|
||||
component: DashboardPage,
|
||||
},
|
||||
{
|
||||
path: "/sources",
|
||||
label: "Sources",
|
||||
title: "Sources",
|
||||
component: SourcesPage,
|
||||
},
|
||||
{
|
||||
path: "/repositories",
|
||||
label: "Repositories",
|
||||
title: "Repositories",
|
||||
component: RepositoriesPage,
|
||||
},
|
||||
{
|
||||
path: "/jobs",
|
||||
label: "Jobs & schedules",
|
||||
title: "Jobs & schedules",
|
||||
component: JobsPage,
|
||||
},
|
||||
{
|
||||
path: "/executions",
|
||||
label: "Executions",
|
||||
title: "Executions",
|
||||
component: ExecutionsPage,
|
||||
},
|
||||
{
|
||||
path: "/backups",
|
||||
label: "Backups",
|
||||
title: "Backups",
|
||||
component: BackupsPage,
|
||||
},
|
||||
{
|
||||
path: "/security",
|
||||
label: "Security & recovery",
|
||||
title: "Security & recovery",
|
||||
component: SecurityPage,
|
||||
},
|
||||
{
|
||||
path: "/notifications",
|
||||
label: "Notifications",
|
||||
title: "Notifications",
|
||||
component: NotificationsPage,
|
||||
},
|
||||
{ path: "/audit", label: "Audit", title: "Audit", component: AuditPage },
|
||||
{
|
||||
path: "/administration",
|
||||
label: "Administration",
|
||||
title: "Administration",
|
||||
component: AdministrationPage,
|
||||
},
|
||||
];
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (isApiError(error)) return error.problem?.detail ?? "The server could not complete that request.";
|
||||
return "We could not reach the Backup Tool service. Check your connection and try again.";
|
||||
}
|
||||
class RouteErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ error?: Error }
|
||||
> {
|
||||
state: { error?: Error } = {};
|
||||
|
||||
function isSessionExpired(error: unknown): boolean {
|
||||
return isApiError(error) && error.status === 401;
|
||||
}
|
||||
|
||||
function ErrorPanel({ message, retry }: { message: string; retry: () => void }) {
|
||||
return <div className="mt-4 rounded-lg border border-rose-400/50 bg-rose-950/40 p-4"><p role="alert">{message}</p><button className="mt-3 rounded-md border border-slate-500 px-3 py-2 font-semibold hover:bg-slate-800" onClick={retry} type="button">Try again</button></div>;
|
||||
}
|
||||
|
||||
function Loading({ label }: { label: string }) {
|
||||
return <p aria-live="polite" className="mt-4 text-slate-300" role="status">Loading {label}…</p>;
|
||||
}
|
||||
|
||||
function ResourceSection({ children, title }: { children: ReactNode; title: string }) {
|
||||
return <section aria-labelledby="page-title" className="mx-auto max-w-6xl p-4 sm:p-6"><h2 className="text-xl font-semibold" id="page-title">{title}</h2>{children}</section>;
|
||||
}
|
||||
|
||||
function DashboardPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
|
||||
const [state, setState] = useState<LoadState<RepositorySummary[]>>({ kind: "loading" });
|
||||
async function load() {
|
||||
setState({ kind: "loading" });
|
||||
try { setState({ kind: "ready", items: (await client.listRepositories()).items }); }
|
||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(_error: Error, _info: ErrorInfo) {}
|
||||
|
||||
componentDidUpdate(previousProps: Readonly<{ children: ReactNode }>) {
|
||||
if (this.state.error && previousProps.children !== this.props.children)
|
||||
this.setState({ error: undefined });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error)
|
||||
return (
|
||||
<section className="mx-auto max-w-6xl p-4 sm:p-6">
|
||||
<h2 className="text-xl font-semibold">Page unavailable</h2>
|
||||
<p className="mt-3" role="alert">
|
||||
This page could not be loaded. Try another page or refresh your
|
||||
browser.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
return this.props.children;
|
||||
}
|
||||
useEffect(() => { void load(); }, []);
|
||||
return <ResourceSection title="Dashboard">
|
||||
<p className="mt-2 text-slate-300">Repository availability at a glance.</p>
|
||||
{state.kind === "loading" ? <Loading label="dashboard data" /> : null}
|
||||
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
|
||||
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No repositories have been configured.</p> : null}
|
||||
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured repositories" className="mt-4 grid gap-3 sm:grid-cols-2">{state.items.map((repository) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={repository.id}><h3 className="font-semibold">{repository.name}</h3><p className="mt-2 text-sm text-slate-300">{repository.state} · {repository.encryption}</p></li>)}</ul> : null}
|
||||
</ResourceSection>;
|
||||
}
|
||||
|
||||
function SourcesPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
|
||||
const [state, setState] = useState<LoadState<SourceSummary[]>>({ kind: "loading" });
|
||||
async function load() {
|
||||
setState({ kind: "loading" });
|
||||
try { setState({ kind: "ready", items: (await client.listSources()).items }); }
|
||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
|
||||
}
|
||||
useEffect(() => { void load(); }, []);
|
||||
return <ResourceSection title="Sources">
|
||||
{state.kind === "loading" ? <Loading label="sources" /> : null}
|
||||
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
|
||||
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No sources have been configured.</p> : null}
|
||||
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured sources" className="mt-4 space-y-3">{state.items.map((source) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={source.id}><h3 className="font-semibold">{source.name}</h3><p className="mt-1 text-sm text-slate-300">{source.kind} · {source.state}</p></li>)}</ul> : null}
|
||||
</ResourceSection>;
|
||||
function RouteLoading() {
|
||||
return (
|
||||
<section className="mx-auto max-w-6xl p-4 sm:p-6">
|
||||
<p aria-live="polite" role="status">
|
||||
Loading page…
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RepositoriesPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
|
||||
const [state, setState] = useState<LoadState<RepositorySummary[]>>({ kind: "loading" });
|
||||
async function load() {
|
||||
setState({ kind: "loading" });
|
||||
try { setState({ kind: "ready", items: (await client.listRepositories()).items }); }
|
||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
|
||||
}
|
||||
useEffect(() => { void load(); }, []);
|
||||
return <ResourceSection title="Repositories">
|
||||
{state.kind === "loading" ? <Loading label="repositories" /> : null}
|
||||
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
|
||||
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No repositories have been configured.</p> : null}
|
||||
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured repositories" className="mt-4 space-y-3">{state.items.map((repository) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={repository.id}><h3 className="font-semibold">{repository.name}</h3><p className="mt-1 text-sm text-slate-300">Format {repository.format_version} · {repository.encryption} · {repository.state}</p></li>)}</ul> : null}
|
||||
</ResourceSection>;
|
||||
function SkipLink() {
|
||||
return (
|
||||
<a
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-10 focus:rounded focus:bg-slate-100 focus:px-4 focus:py-2 focus:text-slate-950"
|
||||
href="#main-content"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
document.getElementById("main-content")?.focus();
|
||||
}}
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function JobsPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
|
||||
const [state, setState] = useState<LoadState<JobSummary[]>>({ kind: "loading" });
|
||||
async function load() {
|
||||
setState({ kind: "loading" });
|
||||
try { setState({ kind: "ready", items: (await client.listJobs()).items }); }
|
||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
|
||||
}
|
||||
useEffect(() => { void load(); }, []);
|
||||
return <ResourceSection title="Jobs & schedules">
|
||||
{state.kind === "loading" ? <Loading label="jobs" /> : null}
|
||||
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
|
||||
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No jobs have been configured.</p> : null}
|
||||
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Configured jobs" className="mt-4 space-y-3">{state.items.map((job) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={job.id}><h3 className="font-semibold">{job.name}</h3><p className="mt-1 text-sm text-slate-300">{job.requested_mode} · {job.enabled ? "enabled" : "disabled"}</p><p className="mt-2 text-sm text-slate-300">{job.schedule ? `${job.schedule.cron} (${job.schedule.timezone})` : "No schedule configured."}</p></li>)}</ul> : null}
|
||||
</ResourceSection>;
|
||||
function PageRoute({
|
||||
component: Page,
|
||||
...props
|
||||
}: RouteDefinition & OperatorPageProps) {
|
||||
return (
|
||||
<RouteErrorBoundary>
|
||||
<Suspense fallback={<RouteLoading />}>
|
||||
<Page {...props} />
|
||||
</Suspense>
|
||||
</RouteErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function ExecutionsPage({ client, onSessionExpired }: { client: BackupToolClient; onSessionExpired: () => void }) {
|
||||
const [state, setState] = useState<LoadState<ExecutionSummary[]>>({ kind: "loading" });
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [detail, setDetail] = useState<LoadState<ExecutionSummary> | undefined>();
|
||||
async function load() {
|
||||
setState({ kind: "loading" });
|
||||
try { setState({ kind: "ready", items: (await client.listExecutions()).items }); }
|
||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setState({ kind: "error", message: errorMessage(error) }); }
|
||||
}
|
||||
async function loadDetail(executionId: string) {
|
||||
setSelectedId(executionId); setDetail({ kind: "loading" });
|
||||
try { setDetail({ kind: "ready", items: await client.getExecution({ path: { execution_id: executionId } }) }); }
|
||||
catch (error) { if (isSessionExpired(error)) onSessionExpired(); else setDetail({ kind: "error", message: errorMessage(error) }); }
|
||||
}
|
||||
useEffect(() => { void load(); }, []);
|
||||
function RouteEffects() {
|
||||
const { pathname } = useLocation();
|
||||
const hasMounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId || typeof EventSource === "undefined") return;
|
||||
const stream = new EventSource(client.executionEventsUrl({ path: { execution_id: selectedId } }));
|
||||
stream.onmessage = (event) => {
|
||||
try { setDetail({ kind: "ready", items: JSON.parse(event.data) as ExecutionSummary }); }
|
||||
catch { setDetail({ kind: "error", message: "Live execution update was invalid. Reconnecting…" }); }
|
||||
};
|
||||
stream.onerror = () => { setDetail({ kind: "error", message: "Live updates disconnected. Reconnecting…" }); };
|
||||
return () => stream.close();
|
||||
}, [client, selectedId]);
|
||||
return <ResourceSection title="Executions">
|
||||
{state.kind === "loading" ? <Loading label="executions" /> : null}
|
||||
{state.kind === "error" ? <ErrorPanel message={state.message} retry={() => { void load(); }} /> : null}
|
||||
{state.kind === "ready" && state.items.length === 0 ? <p className="mt-4 rounded-lg border border-slate-700 bg-slate-900 p-4 text-slate-300">No executions have been queued.</p> : null}
|
||||
{state.kind === "ready" && state.items.length > 0 ? <ul aria-label="Executions" className="mt-4 space-y-3">{state.items.map((execution) => <li className="rounded-lg border border-slate-700 bg-slate-900 p-4" key={execution.id}><button aria-current={selectedId === execution.id ? "true" : undefined} className="text-left font-semibold hover:text-emerald-300" onClick={() => { void loadDetail(execution.id); }} type="button">Execution {execution.id}</button><p className="mt-1 text-sm text-slate-300">{execution.state} · attempt {execution.attempt}</p></li>)}</ul> : null}
|
||||
{detail?.kind === "loading" ? <Loading label="execution details" /> : null}
|
||||
{detail?.kind === "error" && selectedId ? <ErrorPanel message={detail.message} retry={() => { void loadDetail(selectedId); }} /> : null}
|
||||
{detail?.kind === "ready" ? <section aria-labelledby="execution-detail-heading" className="mt-6 rounded-lg border border-slate-700 bg-slate-900 p-4"><h3 id="execution-detail-heading" className="text-lg font-semibold">Execution detail</h3><dl className="mt-3 grid gap-2 text-sm sm:grid-cols-2"><div><dt className="text-slate-400">State</dt><dd>{detail.items.state}</dd></div><div><dt className="text-slate-400">Attempt</dt><dd>{detail.items.attempt}</dd></div><div><dt className="text-slate-400">Reason</dt><dd>{detail.items.reason_code ?? "None"}</dd></div></dl></section> : null}
|
||||
</ResourceSection>;
|
||||
const route = routes.find((item) => item.path === pathname);
|
||||
document.title = `${route?.title ?? "Dashboard"} | Backup Tool`;
|
||||
if (hasMounted.current) document.getElementById("main-content")?.focus();
|
||||
hasMounted.current = true;
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function OperatorViews({ client, onSessionExpired, user }: { client: BackupToolClient; onSessionExpired: () => void; user: SessionUser }) {
|
||||
const [page, setPage] = useState<Page>("dashboard");
|
||||
const common = { client, onSessionExpired };
|
||||
return <main className="min-h-screen bg-slate-950 text-slate-100"><header className="border-b border-slate-800 bg-slate-900"><div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6"><div><p className="text-sm font-semibold uppercase tracking-widest text-emerald-400">Backup Tool</p><h1 className="mt-1 text-2xl font-bold">Operator console</h1></div><p className="text-sm text-slate-300"><span className="sr-only">Signed in as </span>{user.username}</p></div><nav aria-label="Primary" className="mx-auto flex max-w-6xl gap-1 overflow-x-auto px-4 pb-3 sm:px-6">{pages.map((item) => <button aria-current={page === item.id ? "page" : undefined} className="whitespace-nowrap rounded-md px-3 py-2 text-sm font-semibold hover:bg-slate-800" key={item.id} onClick={() => setPage(item.id)} type="button">{item.label}</button>)}</nav></header>{page === "dashboard" ? <DashboardPage {...common} /> : null}{page === "sources" ? <SourcesPage {...common} /> : null}{page === "repositories" ? <RepositoriesPage {...common} /> : null}{page === "jobs" ? <JobsPage {...common} /> : null}{page === "executions" ? <ExecutionsPage {...common} /> : null}{page === "backups" ? <BackupsPage {...common} /> : null}{page === "security" ? <SecurityPage {...common} /> : null}{page === "notifications" ? <NotificationsPage {...common} /> : null}{page === "audit" ? <AuditPage {...common} /> : null}</main>;
|
||||
export function OperatorViews({
|
||||
client,
|
||||
onSessionExpired,
|
||||
user,
|
||||
}: {
|
||||
client: BackupToolClient;
|
||||
onSessionExpired: () => void;
|
||||
user: SessionUser;
|
||||
}) {
|
||||
const pageProps = { client, onSessionExpired };
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100">
|
||||
<SkipLink />
|
||||
<header className="border-b border-slate-800 bg-slate-900">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-widest text-emerald-400">
|
||||
Backup Tool
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl font-bold">Operator console</h1>
|
||||
</div>
|
||||
<p className="text-sm text-slate-300">
|
||||
<span className="sr-only">Signed in as </span>
|
||||
{user.username}
|
||||
</p>
|
||||
</div>
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
className="mx-auto flex max-w-6xl gap-1 overflow-x-auto px-4 pb-3 sm:px-6"
|
||||
>
|
||||
{routes.map((item) => (
|
||||
<NavLink
|
||||
className={({ isActive }) =>
|
||||
`whitespace-nowrap rounded-md px-3 py-2 text-sm font-semibold hover:bg-slate-800 focus-visible:bg-slate-800 ${isActive ? "bg-slate-800 text-emerald-300" : ""}`
|
||||
}
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
<main id="main-content" tabIndex={-1}>
|
||||
<RouteEffects />
|
||||
<Routes>
|
||||
<Route element={<Navigate replace to="/dashboard" />} path="/" />
|
||||
{routes.map((route) => (
|
||||
<Route
|
||||
element={<PageRoute {...route} {...pageProps} />}
|
||||
key={route.path}
|
||||
path={route.path}
|
||||
/>
|
||||
))}
|
||||
<Route element={<Navigate replace to="/dashboard" />} path="*" />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user