feat: add organization-related models and update schemas for organization management
This commit is contained in:
@@ -22,6 +22,10 @@ import {
|
||||
type listBoardsApiV1BoardsGetResponse,
|
||||
useListBoardsApiV1BoardsGet,
|
||||
} from "@/api/generated/boards/boards";
|
||||
import {
|
||||
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||
} from "@/api/generated/organizations/organizations";
|
||||
import type {
|
||||
ActivityEventRead,
|
||||
AgentRead,
|
||||
@@ -80,6 +84,20 @@ export default function AgentDetailPage() {
|
||||
const agentIdParam = params?.agentId;
|
||||
const agentId = Array.isArray(agentIdParam) ? agentIdParam[0] : agentIdParam;
|
||||
|
||||
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 [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
@@ -88,7 +106,7 @@ export default function AgentDetailPage() {
|
||||
ApiError
|
||||
>(agentId ?? "", {
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn && agentId),
|
||||
enabled: Boolean(isSignedIn && isAdmin && agentId),
|
||||
refetchInterval: 30_000,
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
@@ -102,7 +120,7 @@ export default function AgentDetailPage() {
|
||||
{ limit: 200 },
|
||||
{
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
enabled: Boolean(isSignedIn && isAdmin),
|
||||
refetchInterval: 30_000,
|
||||
retry: false,
|
||||
},
|
||||
@@ -114,7 +132,7 @@ export default function AgentDetailPage() {
|
||||
ApiError
|
||||
>(undefined, {
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
enabled: Boolean(isSignedIn && isAdmin),
|
||||
refetchInterval: 60_000,
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
@@ -186,7 +204,14 @@ export default function AgentDetailPage() {
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<DashboardSidebar />
|
||||
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-8">
|
||||
{!isAdmin ? (
|
||||
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-8">
|
||||
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-6 py-5 text-sm text-muted">
|
||||
Only organization owners and admins can access agents.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-8">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
|
||||
@@ -371,7 +396,8 @@ export default function AgentDetailPage() {
|
||||
Agent not found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SignedIn>
|
||||
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
useListBoardsApiV1BoardsGet,
|
||||
} from "@/api/generated/boards/boards";
|
||||
import { useCreateAgentApiV1AgentsPost } from "@/api/generated/agents/agents";
|
||||
import {
|
||||
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||
} from "@/api/generated/organizations/organizations";
|
||||
import type { BoardRead } from "@/api/generated/model";
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
@@ -80,6 +84,20 @@ export default function NewAgentPage() {
|
||||
const router = useRouter();
|
||||
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 [name, setName] = useState("");
|
||||
const [boardId, setBoardId] = useState<string>("");
|
||||
const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
|
||||
@@ -95,7 +113,7 @@ export default function NewAgentPage() {
|
||||
ApiError
|
||||
>(undefined, {
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
enabled: Boolean(isSignedIn && isAdmin),
|
||||
refetchOnMount: "always",
|
||||
},
|
||||
});
|
||||
@@ -182,15 +200,20 @@ export default function NewAgentPage() {
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm space-y-6"
|
||||
>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Basic configuration
|
||||
</p>
|
||||
<div className="mt-4 space-y-6">
|
||||
{!isAdmin ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
|
||||
Only organization owners and admins can create agents.
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm space-y-6"
|
||||
>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Basic configuration
|
||||
</p>
|
||||
<div className="mt-4 space-y-6">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
@@ -369,6 +392,7 @@ export default function NewAgentPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</SignedIn>
|
||||
|
||||
@@ -42,6 +42,10 @@ import {
|
||||
getListBoardsApiV1BoardsGetQueryKey,
|
||||
useListBoardsApiV1BoardsGet,
|
||||
} from "@/api/generated/boards/boards";
|
||||
import {
|
||||
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||
} from "@/api/generated/organizations/organizations";
|
||||
import type { AgentRead } from "@/api/generated/model";
|
||||
|
||||
const parseTimestamp = (value?: string | null) => {
|
||||
@@ -88,6 +92,20 @@ export default function AgentsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
|
||||
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 [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "name", desc: false },
|
||||
]);
|
||||
@@ -102,7 +120,7 @@ export default function AgentsPage() {
|
||||
ApiError
|
||||
>(undefined, {
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
enabled: Boolean(isSignedIn && isAdmin),
|
||||
refetchInterval: 30_000,
|
||||
refetchOnMount: "always",
|
||||
},
|
||||
@@ -113,7 +131,7 @@ export default function AgentsPage() {
|
||||
ApiError
|
||||
>(undefined, {
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
enabled: Boolean(isSignedIn && isAdmin),
|
||||
refetchInterval: 15_000,
|
||||
refetchOnMount: "always",
|
||||
},
|
||||
@@ -323,9 +341,15 @@ export default function AgentsPage() {
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
{!isAdmin ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
|
||||
Only organization owners and admins can access agents.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
@@ -409,11 +433,13 @@ export default function AgentsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{agentsQuery.error ? (
|
||||
<p className="mt-4 text-sm text-red-500">
|
||||
{agentsQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{agentsQuery.error ? (
|
||||
<p className="mt-4 text-sm text-red-500">
|
||||
{agentsQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</SignedIn>
|
||||
|
||||
@@ -27,9 +27,14 @@ import {
|
||||
streamBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryStreamGet,
|
||||
useListBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGet,
|
||||
} from "@/api/generated/board-group-memory/board-group-memory";
|
||||
import {
|
||||
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||
} from "@/api/generated/organizations/organizations";
|
||||
import type {
|
||||
BoardGroupHeartbeatApplyResult,
|
||||
BoardGroupMemoryRead,
|
||||
OrganizationMemberRead,
|
||||
} from "@/api/generated/model";
|
||||
import type { BoardGroupBoardSnapshot } from "@/api/generated/model";
|
||||
import { Markdown } from "@/components/atoms/Markdown";
|
||||
@@ -96,6 +101,18 @@ const priorityTone = (value?: string | null) => {
|
||||
const safeCount = (snapshot: BoardGroupBoardSnapshot, key: string) =>
|
||||
snapshot.task_counts?.[key] ?? 0;
|
||||
|
||||
const canWriteGroupBoards = (
|
||||
member: OrganizationMemberRead | null,
|
||||
boardIds: Set<string>,
|
||||
) => {
|
||||
if (!member) return false;
|
||||
if (member.all_boards_write) return true;
|
||||
if (!member.board_access || boardIds.size === 0) return false;
|
||||
return member.board_access.some(
|
||||
(access) => access.can_write && boardIds.has(access.board_id),
|
||||
);
|
||||
};
|
||||
|
||||
function GroupChatMessageCard({ message }: { message: BoardGroupMemoryRead }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50/60 p-4">
|
||||
@@ -215,6 +232,34 @@ export default function BoardGroupDetailPage() {
|
||||
snapshotQuery.data?.status === 200 ? snapshotQuery.data.data : null;
|
||||
const group = snapshot?.group ?? null;
|
||||
const boards = useMemo(() => snapshot?.boards ?? [], [snapshot?.boards]);
|
||||
const boardIdSet = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
boards.forEach((item) => {
|
||||
if (item.board?.id) {
|
||||
ids.add(item.board.id);
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
}, [boards]);
|
||||
|
||||
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
|
||||
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||
ApiError
|
||||
>({
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
refetchOnMount: "always",
|
||||
},
|
||||
});
|
||||
|
||||
const member =
|
||||
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
|
||||
const isAdmin = member?.role === "admin" || member?.role === "owner";
|
||||
const canWriteGroup = useMemo(
|
||||
() => canWriteGroupBoards(member, boardIdSet),
|
||||
[boardIdSet, member],
|
||||
);
|
||||
const canManageHeartbeat = Boolean(isAdmin && canWriteGroup);
|
||||
|
||||
const chatHistoryQuery =
|
||||
useListBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGet<
|
||||
@@ -554,6 +599,10 @@ export default function BoardGroupDetailPage() {
|
||||
setChatError("Sign in to send messages.");
|
||||
return false;
|
||||
}
|
||||
if (!canWriteGroup) {
|
||||
setChatError("Read-only access. You cannot post group messages.");
|
||||
return false;
|
||||
}
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
@@ -583,7 +632,7 @@ export default function BoardGroupDetailPage() {
|
||||
setIsChatSending(false);
|
||||
}
|
||||
},
|
||||
[chatBroadcast, groupId, isSignedIn, mergeChatMessages],
|
||||
[canWriteGroup, chatBroadcast, groupId, isSignedIn, mergeChatMessages],
|
||||
);
|
||||
|
||||
const sendGroupNote = useCallback(
|
||||
@@ -592,6 +641,10 @@ export default function BoardGroupDetailPage() {
|
||||
setNoteSendError("Sign in to post.");
|
||||
return false;
|
||||
}
|
||||
if (!canWriteGroup) {
|
||||
setNoteSendError("Read-only access. You cannot post notes.");
|
||||
return false;
|
||||
}
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
@@ -621,7 +674,7 @@ export default function BoardGroupDetailPage() {
|
||||
setIsNoteSending(false);
|
||||
}
|
||||
},
|
||||
[groupId, isSignedIn, mergeNotesMessages, notesBroadcast],
|
||||
[canWriteGroup, groupId, isSignedIn, mergeNotesMessages, notesBroadcast],
|
||||
);
|
||||
|
||||
const applyHeartbeat = useCallback(async () => {
|
||||
@@ -629,6 +682,10 @@ export default function BoardGroupDetailPage() {
|
||||
setHeartbeatApplyError("Sign in to apply.");
|
||||
return;
|
||||
}
|
||||
if (!canManageHeartbeat) {
|
||||
setHeartbeatApplyError("Read-only access. You cannot change agent pace.");
|
||||
return;
|
||||
}
|
||||
const trimmed = heartbeatEvery.trim();
|
||||
if (!trimmed) {
|
||||
setHeartbeatApplyError("Heartbeat cadence is required.");
|
||||
@@ -653,7 +710,7 @@ export default function BoardGroupDetailPage() {
|
||||
} finally {
|
||||
setIsHeartbeatApplying(false);
|
||||
}
|
||||
}, [groupId, heartbeatEvery, includeBoardLeads, isSignedIn]);
|
||||
}, [canManageHeartbeat, groupId, heartbeatEvery, includeBoardLeads, isSignedIn]);
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
@@ -793,7 +850,9 @@ export default function BoardGroupDetailPage() {
|
||||
heartbeatEvery === value
|
||||
? "bg-slate-900 text-white"
|
||||
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
|
||||
!canManageHeartbeat && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
disabled={!canManageHeartbeat}
|
||||
onClick={() => {
|
||||
setHeartbeatAmount(String(preset.amount));
|
||||
setHeartbeatUnit(preset.unit);
|
||||
@@ -812,19 +871,25 @@ export default function BoardGroupDetailPage() {
|
||||
heartbeatEvery
|
||||
? "border-slate-200"
|
||||
: "border-rose-300 focus:border-rose-400 focus:ring-2 focus:ring-rose-100",
|
||||
!canManageHeartbeat && "opacity-60 cursor-not-allowed",
|
||||
)}
|
||||
placeholder="10"
|
||||
inputMode="numeric"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
disabled={!canManageHeartbeat}
|
||||
/>
|
||||
<select
|
||||
value={heartbeatUnit}
|
||||
onChange={(event) =>
|
||||
setHeartbeatUnit(event.target.value as HeartbeatUnit)
|
||||
}
|
||||
className="h-8 rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-900 shadow-sm"
|
||||
className={cn(
|
||||
"h-8 rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-900 shadow-sm",
|
||||
!canManageHeartbeat && "opacity-60 cursor-not-allowed",
|
||||
)}
|
||||
disabled={!canManageHeartbeat}
|
||||
>
|
||||
<option value="s">sec</option>
|
||||
<option value="m">min</option>
|
||||
@@ -839,6 +904,7 @@ export default function BoardGroupDetailPage() {
|
||||
onChange={(event) =>
|
||||
setIncludeBoardLeads(event.target.checked)
|
||||
}
|
||||
disabled={!canManageHeartbeat}
|
||||
/>
|
||||
Include leads
|
||||
</label>
|
||||
@@ -846,11 +912,24 @@ export default function BoardGroupDetailPage() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void applyHeartbeat()}
|
||||
disabled={isHeartbeatApplying || !heartbeatEvery}
|
||||
disabled={
|
||||
isHeartbeatApplying || !heartbeatEvery || !canManageHeartbeat
|
||||
}
|
||||
title={
|
||||
canManageHeartbeat
|
||||
? "Apply heartbeat"
|
||||
: "Read-only access"
|
||||
}
|
||||
>
|
||||
{isHeartbeatApplying ? "Applying…" : "Apply"}
|
||||
</Button>
|
||||
</div>
|
||||
{!canManageHeartbeat ? (
|
||||
<p className="text-xs text-slate-500">
|
||||
Read-only access. You cannot change agent pace for this
|
||||
group.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1035,6 +1114,7 @@ export default function BoardGroupDetailPage() {
|
||||
className="h-4 w-4 rounded border-slate-300 text-blue-600"
|
||||
checked={chatBroadcast}
|
||||
onChange={(event) => setChatBroadcast(event.target.checked)}
|
||||
disabled={!canWriteGroup}
|
||||
/>
|
||||
Broadcast
|
||||
</label>
|
||||
@@ -1072,9 +1152,14 @@ export default function BoardGroupDetailPage() {
|
||||
</div>
|
||||
|
||||
<BoardChatComposer
|
||||
placeholder="Message the whole group. Tag @lead, @name, or @all."
|
||||
placeholder={
|
||||
canWriteGroup
|
||||
? "Message the whole group. Tag @lead, @name, or @all."
|
||||
: "Read-only access. Group chat is disabled."
|
||||
}
|
||||
isSending={isChatSending}
|
||||
onSend={sendGroupChat}
|
||||
disabled={!canWriteGroup}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1115,6 +1200,7 @@ export default function BoardGroupDetailPage() {
|
||||
className="h-4 w-4 rounded border-slate-300 text-blue-600"
|
||||
checked={notesBroadcast}
|
||||
onChange={(event) => setNotesBroadcast(event.target.checked)}
|
||||
disabled={!canWriteGroup}
|
||||
/>
|
||||
Broadcast
|
||||
</label>
|
||||
@@ -1152,9 +1238,14 @@ export default function BoardGroupDetailPage() {
|
||||
</div>
|
||||
|
||||
<BoardChatComposer
|
||||
placeholder="Post a shared note for all linked boards. Tag @lead, @name, or @all."
|
||||
placeholder={
|
||||
canWriteGroup
|
||||
? "Post a shared note for all linked boards. Tag @lead, @name, or @all."
|
||||
: "Read-only access. Notes are disabled."
|
||||
}
|
||||
isSending={isNoteSending}
|
||||
onSend={sendGroupNote}
|
||||
disabled={!canWriteGroup}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
type listGatewaysApiV1GatewaysGetResponse,
|
||||
useListGatewaysApiV1GatewaysGet,
|
||||
} from "@/api/generated/gateways/gateways";
|
||||
import {
|
||||
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||
} from "@/api/generated/organizations/organizations";
|
||||
import type {
|
||||
BoardGroupRead,
|
||||
BoardRead,
|
||||
@@ -59,6 +63,20 @@ export default function EditBoardPage() {
|
||||
const boardIdParam = params?.boardId;
|
||||
const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam;
|
||||
|
||||
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 mainRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const [board, setBoard] = useState<BoardRead | null>(null);
|
||||
@@ -130,7 +148,7 @@ export default function EditBoardPage() {
|
||||
ApiError
|
||||
>(undefined, {
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
enabled: Boolean(isSignedIn && isAdmin),
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
},
|
||||
@@ -141,7 +159,7 @@ export default function EditBoardPage() {
|
||||
ApiError
|
||||
>(undefined, {
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
enabled: Boolean(isSignedIn && isAdmin),
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
},
|
||||
@@ -152,7 +170,7 @@ export default function EditBoardPage() {
|
||||
ApiError
|
||||
>(boardId ?? "", {
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn && boardId),
|
||||
enabled: Boolean(isSignedIn && isAdmin && boardId),
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
},
|
||||
@@ -315,14 +333,20 @@ export default function EditBoardPage() {
|
||||
Update board settings and gateway.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="space-y-6">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
{!isAdmin ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
|
||||
Only organization owners and admins can edit board settings.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
{resolvedBoardType !== "general" &&
|
||||
baseBoard &&
|
||||
!(baseBoard.goal_confirmed ?? false) ? (
|
||||
@@ -495,6 +519,7 @@ export default function EditBoardPage() {
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</SignedIn>
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ApiError } from "@/api/mutator";
|
||||
import { streamAgentsApiV1AgentsStreamGet } from "@/api/generated/agents/agents";
|
||||
import {
|
||||
streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet,
|
||||
@@ -62,6 +63,10 @@ import {
|
||||
createBoardMemoryApiV1BoardsBoardIdMemoryPost,
|
||||
streamBoardMemoryApiV1BoardsBoardIdMemoryStreamGet,
|
||||
} from "@/api/generated/board-memory/board-memory";
|
||||
import {
|
||||
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||
} from "@/api/generated/organizations/organizations";
|
||||
import {
|
||||
createTaskApiV1BoardsBoardIdTasksPost,
|
||||
createTaskCommentApiV1BoardsBoardIdTasksTaskIdCommentsPost,
|
||||
@@ -76,6 +81,7 @@ import type {
|
||||
BoardGroupSnapshot,
|
||||
BoardMemoryRead,
|
||||
BoardRead,
|
||||
OrganizationMemberRead,
|
||||
TaskCardRead,
|
||||
TaskCommentRead,
|
||||
TaskRead,
|
||||
@@ -168,6 +174,47 @@ const formatShortTimestamp = (value: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
type ToastMessage = {
|
||||
id: number;
|
||||
message: string;
|
||||
tone: "error" | "success";
|
||||
};
|
||||
|
||||
const formatActionError = (err: unknown, fallback: string) => {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 403) {
|
||||
return "Read-only access. You do not have permission to make changes.";
|
||||
}
|
||||
return err.message || fallback;
|
||||
}
|
||||
if (err instanceof Error && err.message) {
|
||||
return err.message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const resolveBoardAccess = (
|
||||
member: OrganizationMemberRead | null,
|
||||
boardId?: string | null,
|
||||
) => {
|
||||
if (!member || !boardId) {
|
||||
return { canRead: false, canWrite: false };
|
||||
}
|
||||
if (member.all_boards_write) {
|
||||
return { canRead: true, canWrite: true };
|
||||
}
|
||||
if (member.all_boards_read) {
|
||||
return { canRead: true, canWrite: false };
|
||||
}
|
||||
const entry = member.board_access?.find((access) => access.board_id === boardId);
|
||||
if (!entry) {
|
||||
return { canRead: false, canWrite: false };
|
||||
}
|
||||
const canWrite = Boolean(entry.can_write);
|
||||
const canRead = Boolean(entry.can_read || entry.can_write);
|
||||
return { canRead, canWrite };
|
||||
};
|
||||
|
||||
const TaskCommentCard = memo(function TaskCommentCard({
|
||||
comment,
|
||||
authorLabel,
|
||||
@@ -322,6 +369,31 @@ export default function BoardDetailPage() {
|
||||
const isPageActive = usePageActive();
|
||||
const taskIdFromUrl = searchParams.get("taskId");
|
||||
|
||||
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
|
||||
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||
ApiError
|
||||
>({
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
refetchOnMount: "always",
|
||||
},
|
||||
});
|
||||
|
||||
const boardAccess = useMemo(
|
||||
() =>
|
||||
resolveBoardAccess(
|
||||
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null,
|
||||
boardId,
|
||||
),
|
||||
[membershipQuery.data, boardId],
|
||||
);
|
||||
const isOrgAdmin = useMemo(() => {
|
||||
const member =
|
||||
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
|
||||
return member ? ["owner", "admin"].includes(member.role) : false;
|
||||
}, [membershipQuery.data]);
|
||||
const canWrite = boardAccess.canWrite;
|
||||
|
||||
const [board, setBoard] = useState<Board | null>(null);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
@@ -387,7 +459,10 @@ export default function BoardDetailPage() {
|
||||
const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<"board" | "list">("board");
|
||||
const [isLiveFeedOpen, setIsLiveFeedOpen] = useState(false);
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||
const isLiveFeedOpenRef = useRef(false);
|
||||
const toastIdRef = useRef(0);
|
||||
const toastTimersRef = useRef<Record<number, number>>({});
|
||||
const pushLiveFeed = useCallback((comment: TaskComment) => {
|
||||
const alreadySeen = liveFeedRef.current.some(
|
||||
(item) => item.id === comment.id,
|
||||
@@ -423,6 +498,31 @@ export default function BoardDetailPage() {
|
||||
}, 2200);
|
||||
}, []);
|
||||
|
||||
const dismissToast = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
const timer = toastTimersRef.current[id];
|
||||
if (timer !== undefined) {
|
||||
window.clearTimeout(timer);
|
||||
delete toastTimersRef.current[id];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pushToast = useCallback(
|
||||
(message: string, tone: ToastMessage["tone"] = "error") => {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) return;
|
||||
const id = toastIdRef.current + 1;
|
||||
toastIdRef.current = id;
|
||||
setToasts((prev) => [...prev, { id, message: trimmed, tone }]);
|
||||
if (typeof window !== "undefined") {
|
||||
toastTimersRef.current[id] = window.setTimeout(() => {
|
||||
dismissToast(id);
|
||||
}, 3500);
|
||||
}
|
||||
},
|
||||
[dismissToast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
liveFeedHistoryLoadedRef.current = false;
|
||||
setIsLiveFeedHistoryLoading(false);
|
||||
@@ -448,6 +548,17 @@ export default function BoardDetailPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (typeof window !== "undefined") {
|
||||
Object.values(toastTimersRef.current).forEach((timerId) => {
|
||||
window.clearTimeout(timerId);
|
||||
});
|
||||
}
|
||||
toastTimersRef.current = {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLiveFeedOpen) return;
|
||||
if (!isSignedIn || !boardId) return;
|
||||
@@ -1269,7 +1380,7 @@ export default function BoardDetailPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPageActive) return;
|
||||
if (!isSignedIn || !boardId) return;
|
||||
if (!isSignedIn || !boardId || !isOrgAdmin) return;
|
||||
let isCancelled = false;
|
||||
const abortController = new AbortController();
|
||||
const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF);
|
||||
@@ -1372,7 +1483,7 @@ export default function BoardDetailPage() {
|
||||
window.clearTimeout(reconnectTimeout);
|
||||
}
|
||||
};
|
||||
}, [board, boardId, isPageActive, isSignedIn]);
|
||||
}, [board, boardId, isOrgAdmin, isPageActive, isSignedIn]);
|
||||
|
||||
const resetForm = () => {
|
||||
setTitle("");
|
||||
@@ -1411,9 +1522,9 @@ export default function BoardDetailPage() {
|
||||
setIsDialogOpen(false);
|
||||
resetForm();
|
||||
} catch (err) {
|
||||
setCreateError(
|
||||
err instanceof Error ? err.message : "Something went wrong.",
|
||||
);
|
||||
const message = formatActionError(err, "Something went wrong.");
|
||||
setCreateError(message);
|
||||
pushToast(message);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
@@ -1454,8 +1565,7 @@ export default function BoardDetailPage() {
|
||||
}
|
||||
return { ok: true, error: null };
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Unable to send message.";
|
||||
const message = formatActionError(err, "Unable to send message.");
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
},
|
||||
@@ -1473,6 +1583,7 @@ export default function BoardDetailPage() {
|
||||
if (!result.ok) {
|
||||
if (result.error) {
|
||||
setChatError(result.error);
|
||||
pushToast(result.error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1481,7 +1592,7 @@ export default function BoardDetailPage() {
|
||||
setIsChatSending(false);
|
||||
}
|
||||
},
|
||||
[postBoardChatMessage],
|
||||
[postBoardChatMessage, pushToast],
|
||||
);
|
||||
|
||||
const openAgentsControlDialog = (action: "pause" | "resume") => {
|
||||
@@ -1497,16 +1608,16 @@ export default function BoardDetailPage() {
|
||||
try {
|
||||
const result = await postBoardChatMessage(command);
|
||||
if (!result.ok) {
|
||||
setAgentsControlError(
|
||||
result.error ?? `Unable to send ${command} command.`,
|
||||
);
|
||||
const message = result.error ?? `Unable to send ${command} command.`;
|
||||
setAgentsControlError(message);
|
||||
pushToast(message);
|
||||
return;
|
||||
}
|
||||
setIsAgentsControlDialogOpen(false);
|
||||
} finally {
|
||||
setIsAgentsControlSending(false);
|
||||
}
|
||||
}, [agentsControlAction, postBoardChatMessage]);
|
||||
}, [agentsControlAction, postBoardChatMessage, pushToast]);
|
||||
|
||||
const assigneeById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
@@ -1746,9 +1857,9 @@ export default function BoardDetailPage() {
|
||||
setComments((prev) => [created, ...prev]);
|
||||
setNewComment("");
|
||||
} catch (err) {
|
||||
setPostCommentError(
|
||||
err instanceof Error ? err.message : "Unable to send message.",
|
||||
);
|
||||
const message = formatActionError(err, "Unable to send message.");
|
||||
setPostCommentError(message);
|
||||
pushToast(message);
|
||||
} finally {
|
||||
setIsPostingComment(false);
|
||||
taskCommentInputRef.current?.focus();
|
||||
@@ -1830,9 +1941,9 @@ export default function BoardDetailPage() {
|
||||
setIsEditDialogOpen(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setSaveTaskError(
|
||||
err instanceof Error ? err.message : "Something went wrong.",
|
||||
);
|
||||
const message = formatActionError(err, "Something went wrong.");
|
||||
setSaveTaskError(message);
|
||||
pushToast(message);
|
||||
} finally {
|
||||
setIsSavingTask(false);
|
||||
}
|
||||
@@ -1863,9 +1974,9 @@ export default function BoardDetailPage() {
|
||||
setIsDeleteDialogOpen(false);
|
||||
closeComments();
|
||||
} catch (err) {
|
||||
setDeleteTaskError(
|
||||
err instanceof Error ? err.message : "Something went wrong.",
|
||||
);
|
||||
const message = formatActionError(err, "Something went wrong.");
|
||||
setDeleteTaskError(message);
|
||||
pushToast(message);
|
||||
} finally {
|
||||
setIsDeletingTask(false);
|
||||
}
|
||||
@@ -1936,10 +2047,12 @@ export default function BoardDetailPage() {
|
||||
);
|
||||
} catch (err) {
|
||||
setTasks(previousTasks);
|
||||
setError(err instanceof Error ? err.message : "Unable to move task.");
|
||||
const message = formatActionError(err, "Unable to move task.");
|
||||
setError(message);
|
||||
pushToast(message);
|
||||
}
|
||||
},
|
||||
[boardId, isSignedIn, taskTitleById],
|
||||
[boardId, isSignedIn, pushToast, taskTitleById],
|
||||
);
|
||||
|
||||
const agentInitials = (agent: Agent) =>
|
||||
@@ -2085,6 +2198,10 @@ export default function BoardDetailPage() {
|
||||
const handleApprovalDecision = useCallback(
|
||||
async (approvalId: string, status: "approved" | "rejected") => {
|
||||
if (!isSignedIn || !boardId) return;
|
||||
if (!canWrite) {
|
||||
pushToast("Read-only access. You do not have permission to update approvals.");
|
||||
return;
|
||||
}
|
||||
setApprovalsUpdatingId(approvalId);
|
||||
setApprovalsError(null);
|
||||
try {
|
||||
@@ -2102,14 +2219,14 @@ export default function BoardDetailPage() {
|
||||
prev.map((item) => (item.id === approvalId ? updated : item)),
|
||||
);
|
||||
} catch (err) {
|
||||
setApprovalsError(
|
||||
err instanceof Error ? err.message : "Unable to update approval.",
|
||||
);
|
||||
const message = formatActionError(err, "Unable to update approval.");
|
||||
setApprovalsError(message);
|
||||
pushToast(message);
|
||||
} finally {
|
||||
setApprovalsUpdatingId(null);
|
||||
}
|
||||
},
|
||||
[boardId, isSignedIn],
|
||||
[boardId, canWrite, isSignedIn, pushToast],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -2174,7 +2291,8 @@ export default function BoardDetailPage() {
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
className="h-9 w-9 p-0"
|
||||
aria-label="New task"
|
||||
title="New task"
|
||||
title={canWrite ? "New task" : "Read-only access"}
|
||||
disabled={!canWrite}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -2192,31 +2310,44 @@ export default function BoardDetailPage() {
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
openAgentsControlDialog(
|
||||
isAgentsPaused ? "resume" : "pause",
|
||||
)
|
||||
}
|
||||
disabled={!isSignedIn || !boardId || isAgentsControlSending}
|
||||
className={cn(
|
||||
"h-9 w-9 p-0",
|
||||
isAgentsPaused
|
||||
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-800"
|
||||
: "",
|
||||
)}
|
||||
aria-label={
|
||||
isAgentsPaused ? "Resume agents" : "Pause agents"
|
||||
}
|
||||
title={isAgentsPaused ? "Resume agents" : "Pause agents"}
|
||||
>
|
||||
{isAgentsPaused ? (
|
||||
<Play className="h-4 w-4" />
|
||||
) : (
|
||||
<Pause className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
{isOrgAdmin ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
openAgentsControlDialog(
|
||||
isAgentsPaused ? "resume" : "pause",
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
!isSignedIn ||
|
||||
!boardId ||
|
||||
isAgentsControlSending ||
|
||||
!canWrite
|
||||
}
|
||||
className={cn(
|
||||
"h-9 w-9 p-0",
|
||||
isAgentsPaused
|
||||
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-800"
|
||||
: "",
|
||||
)}
|
||||
aria-label={
|
||||
isAgentsPaused ? "Resume agents" : "Pause agents"
|
||||
}
|
||||
title={
|
||||
canWrite
|
||||
? isAgentsPaused
|
||||
? "Resume agents"
|
||||
: "Pause agents"
|
||||
: "Read-only access"
|
||||
}
|
||||
>
|
||||
{isAgentsPaused ? (
|
||||
<Play className="h-4 w-4" />
|
||||
) : (
|
||||
<Pause className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={openBoardChat}
|
||||
@@ -2235,83 +2366,87 @@ export default function BoardDetailPage() {
|
||||
>
|
||||
<Activity className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/boards/${boardId}/edit`)}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
|
||||
aria-label="Board settings"
|
||||
title="Board settings"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</button>
|
||||
{isOrgAdmin ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/boards/${boardId}/edit`)}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
|
||||
aria-label="Board settings"
|
||||
title="Board settings"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex gap-6 p-6">
|
||||
<aside className="flex h-full w-64 flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Agents
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{sortedAgents.length} total
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/agents/new")}
|
||||
className="rounded-md border border-slate-200 px-2.5 py-1 text-xs font-semibold text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 overflow-y-auto p-3">
|
||||
{sortedAgents.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-slate-200 p-3 text-xs text-slate-500">
|
||||
No agents assigned yet.
|
||||
{isOrgAdmin ? (
|
||||
<aside className="flex h-full w-64 flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Agents
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{sortedAgents.length} total
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
sortedAgents.map((agent) => {
|
||||
const isWorking = workingAgentIds.has(agent.id);
|
||||
return (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-lg border border-transparent px-2 py-2 text-left transition hover:border-slate-200 hover:bg-slate-50",
|
||||
)}
|
||||
onClick={() => router.push(`/agents/${agent.id}`)}
|
||||
>
|
||||
<div className="relative flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-700">
|
||||
{agentAvatarLabel(agent)}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full border-2 border-white",
|
||||
isWorking
|
||||
? "bg-emerald-500"
|
||||
: agent.status === "online"
|
||||
? "bg-green-500"
|
||||
: "bg-slate-300",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-slate-900">
|
||||
{agent.name}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{agentRoleLabel(agent)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/agents/new")}
|
||||
className="rounded-md border border-slate-200 px-2.5 py-1 text-xs font-semibold text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 overflow-y-auto p-3">
|
||||
{sortedAgents.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-slate-200 p-3 text-xs text-slate-500">
|
||||
No agents assigned yet.
|
||||
</div>
|
||||
) : (
|
||||
sortedAgents.map((agent) => {
|
||||
const isWorking = workingAgentIds.has(agent.id);
|
||||
return (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-lg border border-transparent px-2 py-2 text-left transition hover:border-slate-200 hover:bg-slate-50",
|
||||
)}
|
||||
onClick={() => router.push(`/agents/${agent.id}`)}
|
||||
>
|
||||
<div className="relative flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-700">
|
||||
{agentAvatarLabel(agent)}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full border-2 border-white",
|
||||
isWorking
|
||||
? "bg-emerald-500"
|
||||
: agent.status === "online"
|
||||
? "bg-green-500"
|
||||
: "bg-slate-300",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-slate-900">
|
||||
{agent.name}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{agentRoleLabel(agent)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-6">
|
||||
{error && (
|
||||
@@ -2364,16 +2499,18 @@ export default function BoardDetailPage() {
|
||||
>
|
||||
View group
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
router.push(`/boards/${boardId}/edit`)
|
||||
}
|
||||
disabled={!boardId}
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
{isOrgAdmin ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
router.push(`/boards/${boardId}/edit`)
|
||||
}
|
||||
disabled={!boardId}
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2528,7 +2665,8 @@ export default function BoardDetailPage() {
|
||||
<TaskBoard
|
||||
tasks={tasks}
|
||||
onTaskSelect={openComments}
|
||||
onTaskMove={handleTaskMove}
|
||||
onTaskMove={canWrite ? handleTaskMove : undefined}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
@@ -2546,7 +2684,8 @@ export default function BoardDetailPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
disabled={isCreating}
|
||||
disabled={isCreating || !canWrite}
|
||||
title={canWrite ? "New task" : "Read-only access"}
|
||||
>
|
||||
New task
|
||||
</Button>
|
||||
@@ -2660,7 +2799,8 @@ export default function BoardDetailPage() {
|
||||
type="button"
|
||||
onClick={() => setIsEditDialogOpen(true)}
|
||||
className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50"
|
||||
disabled={!selectedTask}
|
||||
disabled={!selectedTask || !canWrite}
|
||||
title={canWrite ? "Edit task" : "Read-only access"}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -2826,7 +2966,10 @@ export default function BoardDetailPage() {
|
||||
onClick={() =>
|
||||
handleApprovalDecision(approval.id, "approved")
|
||||
}
|
||||
disabled={approvalsUpdatingId === approval.id}
|
||||
disabled={
|
||||
approvalsUpdatingId === approval.id || !canWrite
|
||||
}
|
||||
title={canWrite ? "Approve" : "Read-only access"}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
@@ -2836,7 +2979,10 @@ export default function BoardDetailPage() {
|
||||
onClick={() =>
|
||||
handleApprovalDecision(approval.id, "rejected")
|
||||
}
|
||||
disabled={approvalsUpdatingId === approval.id}
|
||||
disabled={
|
||||
approvalsUpdatingId === approval.id || !canWrite
|
||||
}
|
||||
title={canWrite ? "Reject" : "Read-only access"}
|
||||
className="border-slate-300 text-slate-700"
|
||||
>
|
||||
Reject
|
||||
@@ -2861,22 +3007,34 @@ export default function BoardDetailPage() {
|
||||
if (event.key !== "Enter") return;
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
if (event.shiftKey) return;
|
||||
if (!canWrite) return;
|
||||
event.preventDefault();
|
||||
if (isPostingComment) return;
|
||||
if (!newComment.trim()) return;
|
||||
void handlePostComment();
|
||||
}}
|
||||
placeholder="Write a message for the assigned agent…"
|
||||
placeholder={
|
||||
canWrite
|
||||
? "Write a message for the assigned agent…"
|
||||
: "Read-only access. Comments are disabled."
|
||||
}
|
||||
className="min-h-[80px] bg-white"
|
||||
disabled={!canWrite || isPostingComment}
|
||||
/>
|
||||
{postCommentError ? (
|
||||
<p className="text-xs text-rose-600">{postCommentError}</p>
|
||||
) : null}
|
||||
{!canWrite ? (
|
||||
<p className="text-xs text-slate-500">
|
||||
Read-only access. You cannot post comments on this board.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handlePostComment}
|
||||
disabled={isPostingComment || !newComment.trim()}
|
||||
disabled={!canWrite || isPostingComment || !newComment.trim()}
|
||||
title={canWrite ? "Send message" : "Read-only access"}
|
||||
>
|
||||
{isPostingComment ? "Sending…" : "Send message"}
|
||||
</Button>
|
||||
@@ -2956,6 +3114,12 @@ export default function BoardDetailPage() {
|
||||
<BoardChatComposer
|
||||
isSending={isChatSending}
|
||||
onSend={handleSendChat}
|
||||
disabled={!canWrite}
|
||||
placeholder={
|
||||
canWrite
|
||||
? "Message the board lead. Tag agents with @name."
|
||||
: "Read-only access. Chat is disabled."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3052,7 +3216,7 @@ export default function BoardDetailPage() {
|
||||
value={editTitle}
|
||||
onChange={(event) => setEditTitle(event.target.value)}
|
||||
placeholder="Task title"
|
||||
disabled={!selectedTask || isSavingTask}
|
||||
disabled={!selectedTask || isSavingTask || !canWrite}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -3064,7 +3228,7 @@ export default function BoardDetailPage() {
|
||||
onChange={(event) => setEditDescription(event.target.value)}
|
||||
placeholder="Task details"
|
||||
className="min-h-[140px]"
|
||||
disabled={!selectedTask || isSavingTask}
|
||||
disabled={!selectedTask || isSavingTask || !canWrite}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
@@ -3075,7 +3239,7 @@ export default function BoardDetailPage() {
|
||||
<Select
|
||||
value={editStatus}
|
||||
onValueChange={(value) => setEditStatus(value as TaskStatus)}
|
||||
disabled={!selectedTask || isSavingTask}
|
||||
disabled={!selectedTask || isSavingTask || !canWrite}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
@@ -3096,7 +3260,7 @@ export default function BoardDetailPage() {
|
||||
<Select
|
||||
value={editPriority}
|
||||
onValueChange={setEditPriority}
|
||||
disabled={!selectedTask || isSavingTask}
|
||||
disabled={!selectedTask || isSavingTask || !canWrite}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select priority" />
|
||||
@@ -3120,7 +3284,7 @@ export default function BoardDetailPage() {
|
||||
onValueChange={(value) =>
|
||||
setEditAssigneeId(value === "unassigned" ? "" : value)
|
||||
}
|
||||
disabled={!selectedTask || isSavingTask}
|
||||
disabled={!selectedTask || isSavingTask || !canWrite}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Unassigned" />
|
||||
@@ -3155,7 +3319,8 @@ export default function BoardDetailPage() {
|
||||
disabled={
|
||||
!selectedTask ||
|
||||
isSavingTask ||
|
||||
selectedTask.status === "done"
|
||||
selectedTask.status === "done" ||
|
||||
!canWrite
|
||||
}
|
||||
emptyMessage="No other tasks found."
|
||||
/>
|
||||
@@ -3195,8 +3360,14 @@ export default function BoardDetailPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTaskDependency(depId)}
|
||||
className="rounded-full p-0.5 text-slate-500 transition hover:bg-white hover:text-slate-700"
|
||||
className={cn(
|
||||
"rounded-full p-0.5 text-slate-500 transition",
|
||||
canWrite
|
||||
? "hover:bg-white hover:text-slate-700"
|
||||
: "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
aria-label="Remove dependency"
|
||||
disabled={!canWrite}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -3217,21 +3388,26 @@ export default function BoardDetailPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
disabled={!selectedTask || isSavingTask}
|
||||
disabled={!selectedTask || isSavingTask || !canWrite}
|
||||
className="border-rose-200 text-rose-600 hover:border-rose-300 hover:text-rose-700"
|
||||
title={canWrite ? "Delete task" : "Read-only access"}
|
||||
>
|
||||
Delete task
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTaskReset}
|
||||
disabled={!selectedTask || isSavingTask || !hasTaskChanges}
|
||||
disabled={
|
||||
!selectedTask || isSavingTask || !hasTaskChanges || !canWrite
|
||||
}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleTaskSave(true)}
|
||||
disabled={!selectedTask || isSavingTask || !hasTaskChanges}
|
||||
disabled={
|
||||
!selectedTask || isSavingTask || !hasTaskChanges || !canWrite
|
||||
}
|
||||
>
|
||||
{isSavingTask ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
@@ -3262,7 +3438,7 @@ export default function BoardDetailPage() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDeleteTask}
|
||||
disabled={isDeletingTask}
|
||||
disabled={isDeletingTask || !canWrite}
|
||||
className="bg-rose-600 text-white hover:bg-rose-700"
|
||||
>
|
||||
{isDeletingTask ? "Deleting…" : "Delete task"}
|
||||
@@ -3294,6 +3470,7 @@ export default function BoardDetailPage() {
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder="e.g. Prepare launch notes"
|
||||
disabled={!canWrite || isCreating}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -3305,13 +3482,18 @@ export default function BoardDetailPage() {
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="Optional details"
|
||||
className="min-h-[120px]"
|
||||
disabled={!canWrite || isCreating}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-strong">
|
||||
Priority
|
||||
</label>
|
||||
<Select value={priority} onValueChange={setPriority}>
|
||||
<Select
|
||||
value={priority}
|
||||
onValueChange={setPriority}
|
||||
disabled={!canWrite || isCreating}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select priority" />
|
||||
</SelectTrigger>
|
||||
@@ -3334,77 +3516,116 @@ export default function BoardDetailPage() {
|
||||
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateTask} disabled={isCreating}>
|
||||
<Button onClick={handleCreateTask} disabled={!canWrite || isCreating}>
|
||||
{isCreating ? "Creating…" : "Create task"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={isAgentsControlDialogOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setIsAgentsControlDialogOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setAgentsControlError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent aria-label="Agent controls">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{agentsControlAction === "pause"
|
||||
? "Pause agents"
|
||||
: "Resume agents"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{agentsControlAction === "pause"
|
||||
? "Send /pause to every agent on this board."
|
||||
: "Send /resume to every agent on this board."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{agentsControlError ? (
|
||||
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
|
||||
{agentsControlError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
|
||||
<p className="font-semibold text-slate-900">What happens</p>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
This posts{" "}
|
||||
<span className="font-mono">
|
||||
{agentsControlAction === "pause" ? "/pause" : "/resume"}
|
||||
</span>{" "}
|
||||
to board chat.
|
||||
</li>
|
||||
<li>Mission Control forwards it to all agents on this board.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsAgentsControlDialogOpen(false)}
|
||||
disabled={isAgentsControlSending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmAgentsControl}
|
||||
disabled={isAgentsControlSending}
|
||||
>
|
||||
{isAgentsControlSending
|
||||
? "Sending…"
|
||||
: agentsControlAction === "pause"
|
||||
{isOrgAdmin ? (
|
||||
<Dialog
|
||||
open={isAgentsControlDialogOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setIsAgentsControlDialogOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setAgentsControlError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent aria-label="Agent controls">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{agentsControlAction === "pause"
|
||||
? "Pause agents"
|
||||
: "Resume agents"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{agentsControlAction === "pause"
|
||||
? "Send /pause to every agent on this board."
|
||||
: "Send /resume to every agent on this board."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{agentsControlError ? (
|
||||
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
|
||||
{agentsControlError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
|
||||
<p className="font-semibold text-slate-900">What happens</p>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
This posts{" "}
|
||||
<span className="font-mono">
|
||||
{agentsControlAction === "pause" ? "/pause" : "/resume"}
|
||||
</span>{" "}
|
||||
to board chat.
|
||||
</li>
|
||||
<li>
|
||||
Mission Control forwards it to all agents on this board.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsAgentsControlDialogOpen(false)}
|
||||
disabled={isAgentsControlSending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmAgentsControl}
|
||||
disabled={isAgentsControlSending}
|
||||
>
|
||||
{isAgentsControlSending
|
||||
? "Sending…"
|
||||
: agentsControlAction === "pause"
|
||||
? "Pause agents"
|
||||
: "Resume agents"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null}
|
||||
|
||||
{toasts.length ? (
|
||||
<div className="fixed bottom-6 right-6 z-[60] flex w-[320px] max-w-[90vw] flex-col gap-3">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={cn(
|
||||
"rounded-xl border bg-white px-4 py-3 text-sm shadow-lush",
|
||||
toast.tone === "error"
|
||||
? "border-rose-200 text-rose-700"
|
||||
: "border-emerald-200 text-emerald-700",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1 h-2 w-2 rounded-full",
|
||||
toast.tone === "error" ? "bg-rose-500" : "bg-emerald-500",
|
||||
)}
|
||||
/>
|
||||
<p className="flex-1 text-sm text-slate-700">
|
||||
{toast.message}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-slate-400 hover:text-slate-600"
|
||||
onClick={() => dismissToast(toast.id)}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* onboarding moved to board settings */}
|
||||
</DashboardShell>
|
||||
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
type listGatewaysApiV1GatewaysGetResponse,
|
||||
useListGatewaysApiV1GatewaysGet,
|
||||
} from "@/api/generated/gateways/gateways";
|
||||
import {
|
||||
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||
} from "@/api/generated/organizations/organizations";
|
||||
import type { BoardGroupRead } from "@/api/generated/model";
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
@@ -36,6 +40,20 @@ export default function NewBoardPage() {
|
||||
const router = useRouter();
|
||||
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 [name, setName] = useState("");
|
||||
const [gatewayId, setGatewayId] = useState<string>("");
|
||||
const [boardGroupId, setBoardGroupId] = useState<string>("none");
|
||||
@@ -47,7 +65,7 @@ export default function NewBoardPage() {
|
||||
ApiError
|
||||
>(undefined, {
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
enabled: Boolean(isSignedIn && isAdmin),
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
},
|
||||
@@ -58,7 +76,7 @@ export default function NewBoardPage() {
|
||||
ApiError
|
||||
>(undefined, {
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
enabled: Boolean(isSignedIn && isAdmin),
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
},
|
||||
@@ -166,100 +184,106 @@ export default function NewBoardPage() {
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Board name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="e.g. Release operations"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{!isAdmin ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
|
||||
Only organization owners and admins can create boards.
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Board name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="e.g. Release operations"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<SearchableSelect
|
||||
ariaLabel="Select gateway"
|
||||
value={displayGatewayId}
|
||||
onValueChange={setGatewayId}
|
||||
options={gatewayOptions}
|
||||
placeholder="Select gateway"
|
||||
searchPlaceholder="Search gateways..."
|
||||
emptyMessage="No gateways found."
|
||||
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
||||
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<SearchableSelect
|
||||
ariaLabel="Select gateway"
|
||||
value={displayGatewayId}
|
||||
onValueChange={setGatewayId}
|
||||
options={gatewayOptions}
|
||||
placeholder="Select gateway"
|
||||
searchPlaceholder="Search gateways..."
|
||||
emptyMessage="No gateways found."
|
||||
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
||||
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Board group
|
||||
</label>
|
||||
<SearchableSelect
|
||||
ariaLabel="Select board group"
|
||||
value={boardGroupId}
|
||||
onValueChange={setBoardGroupId}
|
||||
options={groupOptions}
|
||||
placeholder="No group"
|
||||
searchPlaceholder="Search groups..."
|
||||
emptyMessage="No groups found."
|
||||
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
||||
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Optional. Groups increase cross-board visibility.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Board group
|
||||
</label>
|
||||
<SearchableSelect
|
||||
ariaLabel="Select board group"
|
||||
value={boardGroupId}
|
||||
onValueChange={setBoardGroupId}
|
||||
options={groupOptions}
|
||||
placeholder="No group"
|
||||
searchPlaceholder="Search groups..."
|
||||
emptyMessage="No groups found."
|
||||
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
||||
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Optional. Groups increase cross-board visibility.
|
||||
{gateways.length === 0 ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
<p>
|
||||
No gateways available. Create one in{" "}
|
||||
<Link
|
||||
href="/gateways"
|
||||
className="font-medium text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Gateways
|
||||
</Link>{" "}
|
||||
to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-red-500">{errorMessage}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => router.push("/boards")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading || !isFormReady}>
|
||||
{isLoading ? "Creating…" : "Create board"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{gateways.length === 0 ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
<p>
|
||||
No gateways available. Create one in{" "}
|
||||
<Link
|
||||
href="/gateways"
|
||||
className="font-medium text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Gateways
|
||||
</Link>{" "}
|
||||
to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-red-500">{errorMessage}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => router.push("/boards")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading || !isFormReady}>
|
||||
{isLoading ? "Creating…" : "Create board"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</SignedIn>
|
||||
|
||||
@@ -25,6 +25,10 @@ import {
|
||||
type listBoardGroupsApiV1BoardGroupsGetResponse,
|
||||
useListBoardGroupsApiV1BoardGroupsGet,
|
||||
} from "@/api/generated/board-groups/board-groups";
|
||||
import {
|
||||
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||
} from "@/api/generated/organizations/organizations";
|
||||
import type { BoardGroupRead, BoardRead } from "@/api/generated/model";
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
@@ -56,6 +60,20 @@ const compactId = (value: string) =>
|
||||
export default function BoardsPage() {
|
||||
const { isSignedIn } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
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 [deleteTarget, setDeleteTarget] = useState<BoardRead | null>(null);
|
||||
|
||||
const boardsKey = getListBoardsApiV1BoardsGetQueryKey();
|
||||
@@ -264,7 +282,7 @@ export default function BoardsPage() {
|
||||
{boards.length === 1 ? "" : "s"} total.
|
||||
</p>
|
||||
</div>
|
||||
{boards.length > 0 ? (
|
||||
{boards.length > 0 && isAdmin ? (
|
||||
<Link
|
||||
href="/boards/new"
|
||||
className={buttonVariants({
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
useGetGatewayApiV1GatewaysGatewayIdGet,
|
||||
useUpdateGatewayApiV1GatewaysGatewayIdPatch,
|
||||
} from "@/api/generated/gateways/gateways";
|
||||
import {
|
||||
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||
} from "@/api/generated/organizations/organizations";
|
||||
import type { GatewayUpdate } from "@/api/generated/model";
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
@@ -50,6 +54,20 @@ export default function EditGatewayPage() {
|
||||
? gatewayIdParam[0]
|
||||
: gatewayIdParam;
|
||||
|
||||
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 [name, setName] = useState<string | undefined>(undefined);
|
||||
const [gatewayUrl, setGatewayUrl] = useState<string | undefined>(undefined);
|
||||
const [gatewayToken, setGatewayToken] = useState<string | undefined>(
|
||||
@@ -77,7 +95,7 @@ export default function EditGatewayPage() {
|
||||
ApiError
|
||||
>(gatewayId ?? "", {
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn && gatewayId),
|
||||
enabled: Boolean(isSignedIn && isAdmin && gatewayId),
|
||||
refetchOnMount: "always",
|
||||
retry: false,
|
||||
},
|
||||
@@ -230,21 +248,26 @@ export default function EditGatewayPage() {
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={resolvedName}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Primary gateway"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{!isAdmin ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
|
||||
Only organization owners and admins can edit gateways.
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={resolvedName}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Primary gateway"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
@@ -361,6 +384,7 @@ export default function EditGatewayPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</SignedIn>
|
||||
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
type listAgentsApiV1AgentsGetResponse,
|
||||
useListAgentsApiV1AgentsGet,
|
||||
} from "@/api/generated/agents/agents";
|
||||
import {
|
||||
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||
} from "@/api/generated/organizations/organizations";
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -49,12 +53,26 @@ export default function GatewayDetailPage() {
|
||||
? gatewayIdParam[0]
|
||||
: gatewayIdParam;
|
||||
|
||||
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 gatewayQuery = useGetGatewayApiV1GatewaysGatewayIdGet<
|
||||
getGatewayApiV1GatewaysGatewayIdGetResponse,
|
||||
ApiError
|
||||
>(gatewayId ?? "", {
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn && gatewayId),
|
||||
enabled: Boolean(isSignedIn && isAdmin && gatewayId),
|
||||
refetchInterval: 30_000,
|
||||
},
|
||||
});
|
||||
@@ -67,7 +85,7 @@ export default function GatewayDetailPage() {
|
||||
ApiError
|
||||
>(gatewayId ? { gateway_id: gatewayId } : undefined, {
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn && gatewayId),
|
||||
enabled: Boolean(isSignedIn && isAdmin && gatewayId),
|
||||
refetchInterval: 15_000,
|
||||
},
|
||||
});
|
||||
@@ -85,7 +103,7 @@ export default function GatewayDetailPage() {
|
||||
ApiError
|
||||
>(statusParams, {
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn && statusParams),
|
||||
enabled: Boolean(isSignedIn && isAdmin && statusParams),
|
||||
refetchInterval: 15_000,
|
||||
},
|
||||
});
|
||||
@@ -142,7 +160,7 @@ export default function GatewayDetailPage() {
|
||||
>
|
||||
Back to gateways
|
||||
</Button>
|
||||
{gatewayId ? (
|
||||
{isAdmin && gatewayId ? (
|
||||
<Button
|
||||
onClick={() => router.push(`/gateways/${gatewayId}/edit`)}
|
||||
>
|
||||
@@ -154,7 +172,11 @@ export default function GatewayDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
{gatewayQuery.isLoading ? (
|
||||
{!isAdmin ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
|
||||
Only organization owners and admins can access gateways.
|
||||
</div>
|
||||
) : gatewayQuery.isLoading ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-500 shadow-sm">
|
||||
Loading gateway…
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
gatewaysStatusApiV1GatewaysStatusGet,
|
||||
useCreateGatewayApiV1GatewaysPost,
|
||||
} from "@/api/generated/gateways/gateways";
|
||||
import {
|
||||
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||
} from "@/api/generated/organizations/organizations";
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -42,6 +46,20 @@ export default function NewGatewayPage() {
|
||||
const { isSignedIn } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
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 [name, setName] = useState("");
|
||||
const [gatewayUrl, setGatewayUrl] = useState("");
|
||||
const [gatewayToken, setGatewayToken] = useState("");
|
||||
@@ -191,21 +209,26 @@ export default function NewGatewayPage() {
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Primary gateway"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{!isAdmin ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
|
||||
Only organization owners and admins can create gateways.
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Primary gateway"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
@@ -320,6 +343,7 @@ export default function NewGatewayPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</SignedIn>
|
||||
|
||||
@@ -35,6 +35,10 @@ import {
|
||||
useDeleteGatewayApiV1GatewaysGatewayIdDelete,
|
||||
useListGatewaysApiV1GatewaysGet,
|
||||
} from "@/api/generated/gateways/gateways";
|
||||
import {
|
||||
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||
} from "@/api/generated/organizations/organizations";
|
||||
import type { GatewayRead } from "@/api/generated/model";
|
||||
|
||||
const truncate = (value?: string | null, max = 24) => {
|
||||
@@ -58,6 +62,20 @@ const formatTimestamp = (value?: string | null) => {
|
||||
export default function GatewaysPage() {
|
||||
const { isSignedIn } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
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 [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "name", desc: false },
|
||||
]);
|
||||
@@ -69,7 +87,7 @@ export default function GatewaysPage() {
|
||||
ApiError
|
||||
>(undefined, {
|
||||
query: {
|
||||
enabled: Boolean(isSignedIn),
|
||||
enabled: Boolean(isSignedIn && isAdmin),
|
||||
refetchInterval: 30_000,
|
||||
refetchOnMount: "always",
|
||||
},
|
||||
@@ -240,7 +258,7 @@ export default function GatewaysPage() {
|
||||
Manage OpenClaw gateway connections used by boards
|
||||
</p>
|
||||
</div>
|
||||
{gateways.length > 0 ? (
|
||||
{isAdmin && gateways.length > 0 ? (
|
||||
<Link
|
||||
href="/gateways/new"
|
||||
className={buttonVariants({
|
||||
@@ -256,9 +274,15 @@ export default function GatewaysPage() {
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
{!isAdmin ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
|
||||
Only organization owners and admins can access gateways.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
@@ -347,11 +371,13 @@ export default function GatewaysPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{gatewaysQuery.error ? (
|
||||
<p className="mt-4 text-sm text-red-500">
|
||||
{gatewaysQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{gatewaysQuery.error ? (
|
||||
<p className="mt-4 text-sm text-red-500">
|
||||
{gatewaysQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</SignedIn>
|
||||
|
||||
141
frontend/src/app/invite/page.tsx
Normal file
141
frontend/src/app/invite/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||
|
||||
import { ApiError } from "@/api/mutator";
|
||||
import { useAcceptOrgInviteApiV1OrganizationsInvitesAcceptPost } from "@/api/generated/organizations/organizations";
|
||||
import { BrandMark } from "@/components/atoms/BrandMark";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export default function InvitePage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { isSignedIn } = useAuth();
|
||||
|
||||
const tokenFromQuery = (searchParams.get("token") ?? "").trim();
|
||||
const [token, setToken] = useState(tokenFromQuery);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setToken(tokenFromQuery);
|
||||
}, [tokenFromQuery]);
|
||||
|
||||
const acceptInviteMutation =
|
||||
useAcceptOrgInviteApiV1OrganizationsInvitesAcceptPost<ApiError>({
|
||||
mutation: {
|
||||
onSuccess: (result) => {
|
||||
if (result.status === 200) {
|
||||
setAccepted(true);
|
||||
setError(null);
|
||||
setTimeout(() => router.push("/organization"), 800);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.message || "Unable to accept invite.");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const handleAccept = (event?: React.FormEvent) => {
|
||||
event?.preventDefault();
|
||||
if (!isSignedIn) return;
|
||||
const trimmed = token.trim();
|
||||
if (!trimmed) {
|
||||
setError("Invite token is required.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
acceptInviteMutation.mutate({ data: { token: trimmed } });
|
||||
};
|
||||
|
||||
const isSubmitting = acceptInviteMutation.isPending;
|
||||
const isReady = Boolean(token.trim());
|
||||
const helperText = useMemo(() => {
|
||||
if (accepted) {
|
||||
return "Invite accepted. Redirecting to your organization…";
|
||||
}
|
||||
if (!token.trim()) {
|
||||
return "Paste the invite token or open the invite link you were sent.";
|
||||
}
|
||||
return "Accept the invite to join the organization.";
|
||||
}, [accepted, token]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-app text-strong">
|
||||
<header className="border-b border-[color:var(--border)] bg-white">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
|
||||
<BrandMark />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-16">
|
||||
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-8 shadow-sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
|
||||
Organization Invite
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold text-strong">
|
||||
Join your team in OpenClaw
|
||||
</h1>
|
||||
<p className="text-sm text-muted">{helperText}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4">
|
||||
<label className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
|
||||
Invite Token
|
||||
</label>
|
||||
<Input
|
||||
value={token}
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
placeholder="Paste invite token"
|
||||
disabled={accepted || isSubmitting}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-2 text-sm text-rose-600">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SignedOut>
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted">
|
||||
<p>Sign in to accept your invite.</p>
|
||||
<SignInButton mode="modal">
|
||||
<Button size="md">Sign in</Button>
|
||||
</SignInButton>
|
||||
</div>
|
||||
</SignedOut>
|
||||
|
||||
<SignedIn>
|
||||
<form className="flex flex-wrap items-center gap-3" onSubmit={handleAccept}>
|
||||
<Button type="submit" size="md" disabled={!isReady || isSubmitting || accepted}>
|
||||
{accepted
|
||||
? "Invite accepted"
|
||||
: isSubmitting
|
||||
? "Accepting…"
|
||||
: "Accept invite"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="md"
|
||||
onClick={() => router.push("/")}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</form>
|
||||
</SignedIn>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1131
frontend/src/app/organization/page.tsx
Normal file
1131
frontend/src/app/organization/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user