feat: add organization-related models and update schemas for organization management
This commit is contained in:
@@ -8,12 +8,14 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
type BoardChatComposerProps = {
|
||||
placeholder?: string;
|
||||
isSending?: boolean;
|
||||
disabled?: boolean;
|
||||
onSend: (content: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
function BoardChatComposerImpl({
|
||||
placeholder = "Message the board lead. Tag agents with @name.",
|
||||
isSending = false,
|
||||
disabled = false,
|
||||
onSend,
|
||||
}: BoardChatComposerProps) {
|
||||
const [value, setValue] = useState("");
|
||||
@@ -28,7 +30,7 @@ function BoardChatComposerImpl({
|
||||
}, [isSending]);
|
||||
|
||||
const send = useCallback(async () => {
|
||||
if (isSending) return;
|
||||
if (isSending || disabled) return;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
const ok = await onSend(trimmed);
|
||||
@@ -53,12 +55,12 @@ function BoardChatComposerImpl({
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="min-h-[120px]"
|
||||
disabled={isSending}
|
||||
disabled={isSending || disabled}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => void send()}
|
||||
disabled={isSending || !value.trim()}
|
||||
disabled={isSending || disabled || !value.trim()}
|
||||
>
|
||||
{isSending ? "Sending…" : "Send"}
|
||||
</Button>
|
||||
|
||||
@@ -93,8 +93,10 @@ export function TaskCard({
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-slate-900">{title}</p>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<p className="text-sm font-medium text-slate-900 line-clamp-2 break-words">
|
||||
{title}
|
||||
</p>
|
||||
{isBlocked ? (
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-rose-700">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-rose-500" />
|
||||
@@ -114,7 +116,7 @@ export function TaskCard({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="flex flex-shrink-0 flex-col items-end gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
|
||||
|
||||
@@ -8,11 +8,14 @@ import {
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
Folder,
|
||||
Building2,
|
||||
LayoutGrid,
|
||||
Network,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/clerk";
|
||||
import { ApiError } from "@/api/mutator";
|
||||
import { type getMyMembershipApiV1OrganizationsMeMemberGetResponse, useGetMyMembershipApiV1OrganizationsMeMemberGet } from "@/api/generated/organizations/organizations";
|
||||
import {
|
||||
type healthzHealthzGetResponse,
|
||||
useHealthzHealthzGet,
|
||||
@@ -21,6 +24,20 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const pathname = usePathname();
|
||||
const { isSignedIn } = useAuth();
|
||||
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
|
||||
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||
ApiError
|
||||
>({
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
const member =
|
||||
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
|
||||
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
|
||||
const healthQuery = useHealthzHealthzGet<healthzHealthzGetResponse, ApiError>(
|
||||
{
|
||||
query: {
|
||||
@@ -48,7 +65,7 @@ export function DashboardSidebar() {
|
||||
? "System status unavailable"
|
||||
: "System degraded";
|
||||
|
||||
return (
|
||||
return (
|
||||
<aside className="flex h-full w-64 flex-col border-r border-slate-200 bg-white">
|
||||
<div className="flex-1 px-3 py-4">
|
||||
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
@@ -67,18 +84,20 @@ export function DashboardSidebar() {
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href="/gateways"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
|
||||
pathname.startsWith("/gateways")
|
||||
? "bg-blue-100 text-blue-800 font-medium"
|
||||
: "hover:bg-slate-100",
|
||||
)}
|
||||
>
|
||||
<Network className="h-4 w-4" />
|
||||
Gateways
|
||||
</Link>
|
||||
{isAdmin ? (
|
||||
<Link
|
||||
href="/gateways"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
|
||||
pathname.startsWith("/gateways")
|
||||
? "bg-blue-100 text-blue-800 font-medium"
|
||||
: "hover:bg-slate-100",
|
||||
)}
|
||||
>
|
||||
<Network className="h-4 w-4" />
|
||||
Gateways
|
||||
</Link>
|
||||
) : null}
|
||||
<Link
|
||||
href="/board-groups"
|
||||
className={cn(
|
||||
@@ -103,6 +122,18 @@ export function DashboardSidebar() {
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
Boards
|
||||
</Link>
|
||||
<Link
|
||||
href="/organization"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
|
||||
pathname.startsWith("/organization")
|
||||
? "bg-blue-100 text-blue-800 font-medium"
|
||||
: "hover:bg-slate-100",
|
||||
)}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
Organization
|
||||
</Link>
|
||||
<Link
|
||||
href="/approvals"
|
||||
className={cn(
|
||||
@@ -127,18 +158,20 @@ export function DashboardSidebar() {
|
||||
<Activity className="h-4 w-4" />
|
||||
Live feed
|
||||
</Link>
|
||||
<Link
|
||||
href="/agents"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
|
||||
pathname.startsWith("/agents")
|
||||
? "bg-blue-100 text-blue-800 font-medium"
|
||||
: "hover:bg-slate-100",
|
||||
)}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
Agents
|
||||
</Link>
|
||||
{isAdmin ? (
|
||||
<Link
|
||||
href="/agents"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
|
||||
pathname.startsWith("/agents")
|
||||
? "bg-blue-100 text-blue-800 font-medium"
|
||||
: "hover:bg-slate-100",
|
||||
)}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
Agents
|
||||
</Link>
|
||||
) : null}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="border-t border-slate-200 p-4">
|
||||
|
||||
240
frontend/src/components/organisms/OrgSwitcher.tsx
Normal file
240
frontend/src/components/organisms/OrgSwitcher.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Building2, Plus } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/clerk";
|
||||
import { ApiError } from "@/api/mutator";
|
||||
import {
|
||||
type listMyOrganizationsApiV1OrganizationsMeListGetResponse,
|
||||
useCreateOrganizationApiV1OrganizationsPost,
|
||||
useListMyOrganizationsApiV1OrganizationsMeListGet,
|
||||
useSetActiveOrgApiV1OrganizationsMeActivePatch,
|
||||
} from "@/api/generated/organizations/organizations";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export function OrgSwitcher() {
|
||||
const { isSignedIn } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [orgName, setOrgName] = useState("");
|
||||
const [orgError, setOrgError] = useState<string | null>(null);
|
||||
const channelRef = useRef<BroadcastChannel | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!("BroadcastChannel" in window)) return;
|
||||
const channel = new BroadcastChannel("org-switch");
|
||||
channelRef.current = channel;
|
||||
return () => {
|
||||
channel.close();
|
||||
channelRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const orgsQuery = useListMyOrganizationsApiV1OrganizationsMeListGet<
|
||||
listMyOrganizationsApiV1OrganizationsMeListGetResponse,
|
||||
ApiError
|
||||
>({
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
const orgs = orgsQuery.data?.status === 200 ? orgsQuery.data.data : [];
|
||||
const activeOrg = orgs.find((item) => item.is_active) ?? null;
|
||||
const orgValue = activeOrg?.id ?? "personal";
|
||||
|
||||
const announceOrgSwitch = (orgId: string) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const payload = JSON.stringify({ orgId, ts: Date.now() });
|
||||
try {
|
||||
window.localStorage.setItem("openclaw_org_switch", payload);
|
||||
} catch {
|
||||
// Ignore storage failures.
|
||||
}
|
||||
channelRef.current?.postMessage(payload);
|
||||
};
|
||||
|
||||
const setActiveOrgMutation =
|
||||
useSetActiveOrgApiV1OrganizationsMeActivePatch<ApiError>({
|
||||
mutation: {
|
||||
onSuccess: (_result, variables) => {
|
||||
const orgId = variables?.data?.organization_id;
|
||||
if (orgId) {
|
||||
announceOrgSwitch(orgId);
|
||||
}
|
||||
window.location.reload();
|
||||
},
|
||||
onError: (err) => {
|
||||
setOrgError(err.message || "Unable to switch organization.");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createOrgMutation = useCreateOrganizationApiV1OrganizationsPost<ApiError>(
|
||||
{
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
setOrgName("");
|
||||
setOrgError(null);
|
||||
setCreateOpen(false);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["/api/v1/organizations/me/list"],
|
||||
});
|
||||
if (typeof window !== "undefined") {
|
||||
announceOrgSwitch("new");
|
||||
}
|
||||
window.location.reload();
|
||||
},
|
||||
onError: (err) => {
|
||||
setOrgError(err.message || "Unable to create organization.");
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleOrgChange = (value: string) => {
|
||||
if (value === "__create__") {
|
||||
setOrgError(null);
|
||||
setCreateOpen(true);
|
||||
return;
|
||||
}
|
||||
if (!value || value === orgValue) {
|
||||
return;
|
||||
}
|
||||
setActiveOrgMutation.mutate({
|
||||
data: { organization_id: value },
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateOrg = () => {
|
||||
const trimmed = orgName.trim();
|
||||
if (!trimmed) {
|
||||
setOrgError("Organization name is required.");
|
||||
return;
|
||||
}
|
||||
createOrgMutation.mutate({
|
||||
data: { name: trimmed },
|
||||
});
|
||||
};
|
||||
|
||||
if (!isSignedIn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Select value={orgValue} onValueChange={handleOrgChange}>
|
||||
<SelectTrigger className="h-9 w-[220px] rounded-md border-slate-200 bg-white px-3 text-sm font-medium text-slate-900 shadow-none focus:ring-2 focus:ring-blue-500/30 focus:ring-offset-0">
|
||||
<span className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-slate-400" />
|
||||
<SelectValue placeholder="Select organization" />
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[220px] rounded-md border-slate-200 p-1 shadow-xl">
|
||||
<div className="px-3 pb-2 pt-2 text-[10px] font-semibold uppercase tracking-wide text-slate-400">
|
||||
Org switcher
|
||||
</div>
|
||||
{orgs.length ? (
|
||||
orgs.map((org) => (
|
||||
<SelectItem
|
||||
key={org.id}
|
||||
value={org.id}
|
||||
className="rounded-md py-2 pl-7 pr-3 text-sm text-slate-700 data-[state=checked]:bg-slate-50 data-[state=checked]:text-slate-900 focus:bg-slate-100"
|
||||
>
|
||||
{org.name}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem
|
||||
value={orgValue}
|
||||
className="rounded-md py-2 pl-7 pr-3 text-sm text-slate-700"
|
||||
>
|
||||
Organization
|
||||
</SelectItem>
|
||||
)}
|
||||
<SelectSeparator className="my-2" />
|
||||
<SelectItem
|
||||
value="__create__"
|
||||
className="rounded-md py-2 pl-3 pr-3 text-sm font-medium text-slate-600 hover:text-slate-900 focus:bg-slate-100 [&>span:first-child]:hidden"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4 text-slate-400" />
|
||||
Create new org
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{orgError && !createOpen ? (
|
||||
<p className="absolute left-0 top-full mt-1 text-xs text-rose-500">
|
||||
{orgError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent aria-label="Create organization">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a new organization</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will switch you to the new organization as soon as it is
|
||||
created.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 space-y-2">
|
||||
<label
|
||||
htmlFor="org-name"
|
||||
className="text-xs font-semibold uppercase tracking-wide text-muted"
|
||||
>
|
||||
Organization name
|
||||
</label>
|
||||
<Input
|
||||
id="org-name"
|
||||
placeholder="Acme Robotics"
|
||||
value={orgName}
|
||||
onChange={(event) => setOrgName(event.target.value)}
|
||||
/>
|
||||
{orgError ? (
|
||||
<p className="text-sm text-rose-500">{orgError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCreateOrg}
|
||||
disabled={createOrgMutation.isPending}
|
||||
>
|
||||
{createOrgMutation.isPending ? "Creating..." : "Create org"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ type TaskBoardProps = {
|
||||
tasks: Task[];
|
||||
onTaskSelect?: (task: Task) => void;
|
||||
onTaskMove?: (taskId: string, status: TaskStatus) => void | Promise<void>;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
type ReviewBucket = "all" | "approval_needed" | "waiting_lead" | "blocked";
|
||||
@@ -99,6 +100,7 @@ export const TaskBoard = memo(function TaskBoard({
|
||||
tasks,
|
||||
onTaskSelect,
|
||||
onTaskMove,
|
||||
readOnly = false,
|
||||
}: TaskBoardProps) {
|
||||
const boardRef = useRef<HTMLDivElement | null>(null);
|
||||
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
@@ -268,6 +270,10 @@ export const TaskBoard = memo(function TaskBoard({
|
||||
|
||||
const handleDragStart =
|
||||
(task: Task) => (event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (readOnly) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (task.is_blocked) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
@@ -287,6 +293,7 @@ export const TaskBoard = memo(function TaskBoard({
|
||||
|
||||
const handleDrop =
|
||||
(status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (readOnly) return;
|
||||
event.preventDefault();
|
||||
setActiveColumn(null);
|
||||
const raw = event.dataTransfer.getData("text/plain");
|
||||
@@ -303,6 +310,7 @@ export const TaskBoard = memo(function TaskBoard({
|
||||
|
||||
const handleDragOver =
|
||||
(status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (readOnly) return;
|
||||
event.preventDefault();
|
||||
if (activeColumn !== status) {
|
||||
setActiveColumn(status);
|
||||
@@ -310,6 +318,7 @@ export const TaskBoard = memo(function TaskBoard({
|
||||
};
|
||||
|
||||
const handleDragLeave = (status: TaskStatus) => () => {
|
||||
if (readOnly) return;
|
||||
if (activeColumn === status) {
|
||||
setActiveColumn(null);
|
||||
}
|
||||
@@ -368,11 +377,13 @@ export const TaskBoard = memo(function TaskBoard({
|
||||
key={column.title}
|
||||
className={cn(
|
||||
"kanban-column min-h-[calc(100vh-260px)]",
|
||||
activeColumn === column.status && "ring-2 ring-slate-200",
|
||||
activeColumn === column.status &&
|
||||
!readOnly &&
|
||||
"ring-2 ring-slate-200",
|
||||
)}
|
||||
onDrop={handleDrop(column.status)}
|
||||
onDragOver={handleDragOver(column.status)}
|
||||
onDragLeave={handleDragLeave(column.status)}
|
||||
onDrop={readOnly ? undefined : handleDrop(column.status)}
|
||||
onDragOver={readOnly ? undefined : handleDragOver(column.status)}
|
||||
onDragLeave={readOnly ? undefined : handleDragLeave(column.status)}
|
||||
>
|
||||
<div className="column-header sticky top-0 z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white/80 px-4 py-3 backdrop-blur">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -445,10 +456,10 @@ export const TaskBoard = memo(function TaskBoard({
|
||||
isBlocked={task.is_blocked}
|
||||
blockedByCount={task.blocked_by_task_ids?.length ?? 0}
|
||||
onClick={() => onTaskSelect?.(task)}
|
||||
draggable={!task.is_blocked}
|
||||
draggable={!readOnly && !task.is_blocked}
|
||||
isDragging={draggingId === task.id}
|
||||
onDragStart={handleDragStart(task)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={readOnly ? undefined : handleDragStart(task)}
|
||||
onDragEnd={readOnly ? undefined : handleDragEnd}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { SignedIn, useUser } from "@/auth/clerk";
|
||||
|
||||
import { BrandMark } from "@/components/atoms/BrandMark";
|
||||
import { OrgSwitcher } from "@/components/organisms/OrgSwitcher";
|
||||
import { UserMenu } from "@/components/organisms/UserMenu";
|
||||
|
||||
export function DashboardShell({ children }: { children: ReactNode }) {
|
||||
@@ -12,13 +14,46 @@ export function DashboardShell({ children }: { children: ReactNode }) {
|
||||
const displayName =
|
||||
user?.fullName ?? user?.firstName ?? user?.username ?? "Operator";
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key !== "openclaw_org_switch" || !event.newValue) return;
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorage);
|
||||
|
||||
let channel: BroadcastChannel | null = null;
|
||||
if ("BroadcastChannel" in window) {
|
||||
channel = new BroadcastChannel("org-switch");
|
||||
channel.onmessage = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
channel?.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-app text-strong">
|
||||
<header className="sticky top-0 z-40 border-b border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between px-6 py-3">
|
||||
<BrandMark />
|
||||
<div className="grid grid-cols-[260px_1fr_auto] items-center gap-0 py-3">
|
||||
<div className="flex items-center px-6">
|
||||
<BrandMark />
|
||||
</div>
|
||||
<SignedIn>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center">
|
||||
<div className="max-w-[220px]">
|
||||
<OrgSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</SignedIn>
|
||||
<SignedIn>
|
||||
<div className="flex items-center gap-3 px-6">
|
||||
<div className="hidden text-right lg:block">
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{displayName}
|
||||
|
||||
Reference in New Issue
Block a user