feat: add organization-related models and update schemas for organization management

This commit is contained in:
Abhimanyu Saharan
2026-02-08 21:16:26 +05:30
parent 8422b0ca01
commit e03125a382
86 changed files with 8673 additions and 628 deletions

View File

@@ -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>

View File

@@ -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",

View File

@@ -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">

View 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>
);
}

View File

@@ -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>
))}

View File

@@ -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}