"use client"; export const dynamic = "force-dynamic"; import { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { SignedIn, SignedOut, useAuth } from "@/auth/clerk"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Building2, UserPlus, Users } from "lucide-react"; import { ApiError, customFetch } from "@/api/mutator"; import { type listBoardsApiV1BoardsGetResponse, useListBoardsApiV1BoardsGet, } from "@/api/generated/boards/boards"; import { type getMyOrgApiV1OrganizationsMeGetResponse, type getMyMembershipApiV1OrganizationsMeMemberGetResponse, type getOrgMemberApiV1OrganizationsMeMembersMemberIdGetResponse, getListMyOrganizationsApiV1OrganizationsMeListGetQueryKey, type listOrgInvitesApiV1OrganizationsMeInvitesGetResponse, type listOrgMembersApiV1OrganizationsMeMembersGetResponse, getGetOrgMemberApiV1OrganizationsMeMembersMemberIdGetQueryKey, getListOrgInvitesApiV1OrganizationsMeInvitesGetQueryKey, getListOrgMembersApiV1OrganizationsMeMembersGetQueryKey, useCreateOrgInviteApiV1OrganizationsMeInvitesPost, useGetMyOrgApiV1OrganizationsMeGet, useGetMyMembershipApiV1OrganizationsMeMemberGet, useGetOrgMemberApiV1OrganizationsMeMembersMemberIdGet, useListOrgInvitesApiV1OrganizationsMeInvitesGet, useListOrgMembersApiV1OrganizationsMeMembersGet, useRevokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDelete, useUpdateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPut, useUpdateOrgMemberApiV1OrganizationsMeMembersMemberIdPatch, } from "@/api/generated/organizations/organizations"; import type { BoardRead, OrganizationBoardAccessSpec, OrganizationInviteRead, } from "@/api/generated/model"; import { SignedOutPanel } from "@/components/auth/SignedOutPanel"; import { BoardAccessTable } from "@/components/organization/BoardAccessTable"; import { MembersInvitesTable } from "@/components/organization/MembersInvitesTable"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { DashboardShell } from "@/components/templates/DashboardShell"; import { cn } from "@/lib/utils"; type AccessScope = "all" | "custom"; type BoardAccessState = Record; const buildAccessList = ( access: BoardAccessState, ): OrganizationBoardAccessSpec[] => Object.entries(access) .filter(([, entry]) => entry.read || entry.write) .map(([boardId, entry]) => ({ board_id: boardId, can_read: entry.read || entry.write, can_write: entry.write, })); const defaultBoardAccess: BoardAccessState = {}; function BoardAccessEditor({ boards, scope, onScopeChange, allRead, allWrite, onAllReadChange, onAllWriteChange, access, onAccessChange, disabled, emptyMessage, }: { boards: BoardRead[]; scope: AccessScope; onScopeChange: (scope: AccessScope) => void; allRead: boolean; allWrite: boolean; onAllReadChange: (next: boolean) => void; onAllWriteChange: (next: boolean) => void; access: BoardAccessState; onAccessChange: (next: BoardAccessState) => void; disabled?: boolean; emptyMessage?: string; }) { const handleAllReadToggle = () => { if (disabled) return; const next = !allRead; onAllReadChange(next); if (!next && allWrite) { onAllWriteChange(false); } }; const handleAllWriteToggle = () => { if (disabled) return; const next = !allWrite; onAllWriteChange(next); if (next && !allRead) { onAllReadChange(true); } }; const updateBoardAccess = ( boardId: string, next: { read: boolean; write: boolean }, ) => { onAccessChange({ ...access, [boardId]: { read: next.read || next.write, write: next.write, }, }); }; const handleBoardReadToggle = (boardId: string) => { if (disabled) return; const current = access[boardId] ?? { read: false, write: false }; const nextRead = !current.read; const nextWrite = nextRead ? current.write : false; updateBoardAccess(boardId, { read: nextRead, write: nextWrite }); }; const handleBoardWriteToggle = (boardId: string) => { if (disabled) return; const current = access[boardId] ?? { read: false, write: false }; const nextWrite = !current.write; const nextRead = nextWrite ? true : current.read; updateBoardAccess(boardId, { read: nextRead, write: nextWrite }); }; return (

Board access

{scope === "all" ? (
Write access implies read permissions.
) : (
{boards.length === 0 ? (
{emptyMessage ?? "No boards available yet."}
) : (
)}
)}
); } export default function OrganizationPage() { const { isSignedIn } = useAuth(); const router = useRouter(); const queryClient = useQueryClient(); const [inviteDialogOpen, setInviteDialogOpen] = useState(false); const [inviteEmail, setInviteEmail] = useState(""); const [inviteRole, setInviteRole] = useState("member"); const [inviteScope, setInviteScope] = useState("all"); const [inviteAllRead, setInviteAllRead] = useState(true); const [inviteAllWrite, setInviteAllWrite] = useState(false); const [inviteAccess, setInviteAccess] = useState(defaultBoardAccess); const [inviteError, setInviteError] = useState(null); const [copiedInviteId, setCopiedInviteId] = useState(null); const [accessDialogOpen, setAccessDialogOpen] = useState(false); const [activeMemberId, setActiveMemberId] = useState(null); const [accessScope, setAccessScope] = useState(null); const [accessAllRead, setAccessAllRead] = useState(null); const [accessAllWrite, setAccessAllWrite] = useState(null); const [accessRole, setAccessRole] = useState(null); const [accessMap, setAccessMap] = useState(null); const [accessError, setAccessError] = useState(null); const [deleteOrgOpen, setDeleteOrgOpen] = useState(false); const [removeMemberOpen, setRemoveMemberOpen] = useState(false); const orgQuery = useGetMyOrgApiV1OrganizationsMeGet< getMyOrgApiV1OrganizationsMeGetResponse, ApiError >({ query: { enabled: Boolean(isSignedIn), refetchOnMount: "always", }, }); const membersQuery = useListOrgMembersApiV1OrganizationsMeMembersGet< listOrgMembersApiV1OrganizationsMeMembersGetResponse, ApiError >( { limit: 200 }, { query: { enabled: Boolean(isSignedIn), refetchOnMount: "always", }, }, ); const boardsQuery = useListBoardsApiV1BoardsGet< listBoardsApiV1BoardsGetResponse, ApiError >( { limit: 200 }, { query: { enabled: Boolean(isSignedIn), refetchOnMount: "always", }, }, ); const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet< getMyMembershipApiV1OrganizationsMeMemberGetResponse, ApiError >({ query: { enabled: Boolean(isSignedIn), refetchOnMount: "always", }, }); const membershipRole = membershipQuery.data?.status === 200 ? membershipQuery.data.data.role : null; const isOwner = membershipRole === "owner"; const isAdmin = membershipRole === "admin" || membershipRole === "owner"; const invitesQuery = useListOrgInvitesApiV1OrganizationsMeInvitesGet< listOrgInvitesApiV1OrganizationsMeInvitesGetResponse, ApiError >( { limit: 200 }, { query: { enabled: Boolean(isSignedIn && isAdmin), refetchOnMount: "always", retry: false, }, }, ); const members = useMemo(() => { if (membersQuery.data?.status !== 200) return []; return membersQuery.data.data.items ?? []; }, [membersQuery.data]); const invites = useMemo(() => { if (invitesQuery.data?.status !== 200) return []; return invitesQuery.data.data.items ?? []; }, [invitesQuery.data]); const boards = useMemo(() => { if (boardsQuery.data?.status !== 200) return []; return boardsQuery.data.data.items ?? []; }, [boardsQuery.data]); const memberDetailsQuery = useGetOrgMemberApiV1OrganizationsMeMembersMemberIdGet< getOrgMemberApiV1OrganizationsMeMembersMemberIdGetResponse, ApiError >(activeMemberId ?? "", { query: { enabled: Boolean(activeMemberId && accessDialogOpen), }, }); const memberDetails = memberDetailsQuery.data?.status === 200 ? memberDetailsQuery.data.data : null; const defaultAccess = useMemo(() => { if (!memberDetails) { return { role: "member", scope: "all" as AccessScope, allRead: false, allWrite: false, access: {}, }; } const isAll = memberDetails.all_boards_read || memberDetails.all_boards_write; const nextAccess: BoardAccessState = {}; for (const entry of memberDetails.board_access ?? []) { nextAccess[entry.board_id] = { read: entry.can_read || entry.can_write, write: entry.can_write, }; } return { role: memberDetails.role, scope: isAll ? "all" : ("custom" as AccessScope), allRead: memberDetails.all_boards_read, allWrite: memberDetails.all_boards_write, access: nextAccess, }; }, [memberDetails]); const resolvedAccessRole = accessRole ?? defaultAccess.role; const resolvedAccessScope = accessScope ?? defaultAccess.scope; const resolvedAccessAllRead = accessAllRead ?? defaultAccess.allRead; const resolvedAccessAllWrite = accessAllWrite ?? defaultAccess.allWrite; const resolvedAccessMap = accessMap ?? defaultAccess.access; const createInviteMutation = useCreateOrgInviteApiV1OrganizationsMeInvitesPost({ mutation: { onSuccess: (result) => { if (result.status === 200) { setInviteEmail(""); setInviteRole("member"); setInviteScope("all"); setInviteAllRead(true); setInviteAllWrite(false); setInviteAccess(defaultBoardAccess); setInviteError(null); queryClient.invalidateQueries({ queryKey: getListOrgInvitesApiV1OrganizationsMeInvitesGetQueryKey( { limit: 200, }, ), }); setInviteDialogOpen(false); } }, onError: (err) => { setInviteError(err.message || "Unable to create invite."); }, }, }); const revokeInviteMutation = useRevokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDelete({ mutation: { onSuccess: () => { queryClient.invalidateQueries({ queryKey: getListOrgInvitesApiV1OrganizationsMeInvitesGetQueryKey({ limit: 200, }), }); }, }, }); const updateMemberAccessMutation = useUpdateMemberAccessApiV1OrganizationsMeMembersMemberIdAccessPut( { mutation: { onSuccess: () => { queryClient.invalidateQueries({ queryKey: getListOrgMembersApiV1OrganizationsMeMembersGetQueryKey( { limit: 200, }, ), }); if (activeMemberId) { queryClient.invalidateQueries({ queryKey: getGetOrgMemberApiV1OrganizationsMeMembersMemberIdGetQueryKey( activeMemberId, ), }); } }, }, }, ); const updateMemberRoleMutation = useUpdateOrgMemberApiV1OrganizationsMeMembersMemberIdPatch({ mutation: { onSuccess: () => { queryClient.invalidateQueries({ queryKey: getListOrgMembersApiV1OrganizationsMeMembersGetQueryKey({ limit: 200, }), }); }, }, }); const deleteOrganizationMutation = useMutation< { data: unknown; status: number; headers: Headers }, ApiError >({ mutationFn: async () => customFetch<{ data: unknown; status: number; headers: Headers }>( "/api/v1/organizations/me", { method: "DELETE" }, ), onSuccess: async () => { setDeleteOrgOpen(false); await queryClient.invalidateQueries({ queryKey: getListMyOrganizationsApiV1OrganizationsMeListGetQueryKey(), }); router.push("/dashboard"); router.refresh(); }, }); const removeMemberMutation = useMutation< { data: unknown; status: number; headers: Headers }, ApiError, { memberId: string } >({ mutationFn: async ({ memberId }) => customFetch<{ data: unknown; status: number; headers: Headers }>( `/api/v1/organizations/me/members/${memberId}`, { method: "DELETE" }, ), onSuccess: async () => { setRemoveMemberOpen(false); setAccessDialogOpen(false); setActiveMemberId(null); await queryClient.invalidateQueries({ queryKey: getListOrgMembersApiV1OrganizationsMeMembersGetQueryKey({ limit: 200, }), }); }, }); const resetAccessState = () => { setAccessRole(null); setAccessScope(null); setAccessAllRead(null); setAccessAllWrite(null); setAccessMap(null); setAccessError(null); }; const handleAccessDialogChange = (open: boolean) => { setAccessDialogOpen(open); if (!open) { setActiveMemberId(null); setAccessError(null); return; } resetAccessState(); }; const handleInviteDialogChange = (open: boolean) => { setInviteDialogOpen(open); if (!open) { setInviteError(null); } }; const orgName = orgQuery.data?.status === 200 ? orgQuery.data.data.name : "Organization"; const handleInviteSubmit = (event: React.FormEvent) => { event.preventDefault(); if (!isSignedIn || !isAdmin) return; const trimmedEmail = inviteEmail.trim().toLowerCase(); if (!trimmedEmail || !trimmedEmail.includes("@")) { setInviteError("Enter a valid email address."); return; } const hasAllAccess = inviteScope === "all" && (inviteAllRead || inviteAllWrite); const inviteAccessList = buildAccessList(inviteAccess); const hasCustomAccess = inviteScope === "custom" && inviteAccessList.length > 0; if (!hasAllAccess && !hasCustomAccess) { setInviteError("Select read or write access for at least one board."); return; } setInviteError(null); createInviteMutation.mutate({ data: { invited_email: trimmedEmail, role: inviteRole, all_boards_read: inviteScope === "all" ? inviteAllRead : false, all_boards_write: inviteScope === "all" ? inviteAllWrite : false, board_access: inviteScope === "custom" ? inviteAccessList : [], }, }); }; const handleCopyInvite = async (invite: OrganizationInviteRead) => { try { const baseUrl = typeof window !== "undefined" ? window.location.origin : ""; const inviteUrl = baseUrl ? `${baseUrl}/invite?token=${invite.token}` : invite.token; let copied = false; if (typeof navigator !== "undefined" && navigator.clipboard) { try { await navigator.clipboard.writeText(inviteUrl); copied = true; } catch { copied = false; } } if (!copied && typeof document !== "undefined") { const textarea = document.createElement("textarea"); textarea.value = inviteUrl; textarea.setAttribute("readonly", "true"); textarea.style.position = "absolute"; textarea.style.left = "-9999px"; document.body.appendChild(textarea); textarea.select(); copied = document.execCommand("copy"); document.body.removeChild(textarea); } if (copied) { setCopiedInviteId(invite.id); setTimeout(() => setCopiedInviteId(null), 2000); return; } if (typeof window !== "undefined") { window.prompt("Copy invite link:", inviteUrl); } } catch { setCopiedInviteId(null); } }; const openAccessDialog = (memberId: string) => { setActiveMemberId(memberId); setAccessDialogOpen(true); resetAccessState(); }; const handleSaveAccess = async () => { if (!activeMemberId || !isAdmin) return; const hasAllAccess = resolvedAccessScope === "all" && (resolvedAccessAllRead || resolvedAccessAllWrite); const accessList = buildAccessList(resolvedAccessMap); const hasCustomAccess = resolvedAccessScope === "custom" && accessList.length > 0; if (!hasAllAccess && !hasCustomAccess) { setAccessError("Select read or write access for at least one board."); return; } setAccessError(null); try { if (memberDetails) { if (memberDetails.role !== resolvedAccessRole) { await updateMemberRoleMutation.mutateAsync({ memberId: memberDetails.id, data: { role: resolvedAccessRole }, }); } } await updateMemberAccessMutation.mutateAsync({ memberId: activeMemberId, data: { all_boards_read: resolvedAccessScope === "all" ? resolvedAccessAllRead : false, all_boards_write: resolvedAccessScope === "all" ? resolvedAccessAllWrite : false, board_access: resolvedAccessScope === "custom" ? accessList : [], }, }); setAccessDialogOpen(false); } catch (err) { setAccessError( err instanceof Error ? err.message : "Unable to update member access.", ); } }; const handleDeleteOrganization = () => { if (!isOwner) return; deleteOrganizationMutation.mutate(); }; const activeMemberCanBeRemoved = isAdmin && memberDetails !== null && memberDetails.user_id !== membershipQuery.data?.data.user_id && (isOwner || memberDetails.role !== "owner"); const handleRemoveMember = () => { if (!activeMemberId || !activeMemberCanBeRemoved) return; removeMemberMutation.mutate({ memberId: activeMemberId }); }; return (

Organization

{orgName}

Manage members and board access across your workspace.

{members.length} {" "} members {boards.length} {" "} boards {invites.length} {" "} pending
{isOwner ? ( ) : null}

Members & invites

Invite teammates and tune their board permissions.

{members.length + invites.length} total
revokeInviteMutation.mutate({ inviteId, }) } isRevoking={revokeInviteMutation.isPending} />
Invite a member Grant access to all boards or select specific workspaces. {isAdmin ? (
setInviteEmail(event.target.value)} placeholder="name@company.com" type="email" required />
{inviteError ? (

{inviteError}

) : null} ) : (
Only organization admins can invite new members.
)}
Manage member access Adjust board permissions and role for this teammate. {memberDetailsQuery.isLoading ? (
Loading member access...
) : memberDetailsQuery.data?.status === 200 ? (

{memberDetailsQuery.data.data.user?.name || memberDetailsQuery.data.data.user?.preferred_name || memberDetailsQuery.data.data.user?.email || memberDetailsQuery.data.data.user_id}

{memberDetailsQuery.data.data.user?.email ?? "No email on file"}

{accessError ? (

{accessError}

) : null}
) : (
Unable to load member access.
)} {activeMemberCanBeRemoved ? ( ) : null}
{ setDeleteOrgOpen(open); if (!open) { deleteOrganizationMutation.reset(); } }} ariaLabel="Delete organization" title="Delete organization" description={ <> This will permanently delete {orgName}, including boards, groups, gateways, members, and invites. This action cannot be undone. } errorMessage={deleteOrganizationMutation.error?.message} onConfirm={handleDeleteOrganization} isConfirming={deleteOrganizationMutation.isPending} confirmLabel="Delete organization" confirmingLabel="Deleting…" /> { setRemoveMemberOpen(open); if (!open) { removeMemberMutation.reset(); } }} ariaLabel="Remove organization member" title="Remove member" description={ <> Remove{" "} {memberDetails?.user?.name || memberDetails?.user?.preferred_name || memberDetails?.user?.email || "this member"} {" "} from {orgName}? They will lose access immediately. } errorMessage={removeMemberMutation.error?.message} onConfirm={handleRemoveMember} isConfirming={removeMemberMutation.isPending} confirmLabel="Remove member" confirmingLabel="Removing…" />
); }