"use client"; import { useEffect, useState } from "react"; import { Save, LogOut } from "lucide-react"; import { Input } from "@multica/ui/components/ui/input"; import { Textarea } from "@multica/ui/components/ui/textarea"; import { Label } from "@multica/ui/components/ui/label"; import { Button } from "@multica/ui/components/ui/button"; import { Card, CardContent } from "@multica/ui/components/ui/card"; import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, } from "@multica/ui/components/ui/alert-dialog"; import { toast } from "sonner"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuthStore } from "@multica/core/auth"; import { useLeaveWorkspace, useDeleteWorkspace } from "@multica/core/workspace/mutations"; import { useWorkspaceId } from "@multica/core/hooks"; import { useCurrentWorkspace } from "@multica/core/paths"; import { memberListOptions, workspaceKeys, workspaceListOptions, } from "@multica/core/workspace/queries"; import { api } from "@multica/core/api"; import { paths } from "@multica/core/paths"; import { setCurrentWorkspace } from "@multica/core/platform"; import type { Workspace } from "@multica/core/types"; import { useNavigation } from "../../navigation"; import { DeleteWorkspaceDialog } from "./delete-workspace-dialog"; export function WorkspaceTab() { const user = useAuthStore((s) => s.user); const workspace = useCurrentWorkspace(); const wsId = useWorkspaceId(); const { data: members = [], isFetched: membersFetched } = useQuery(memberListOptions(wsId)); const qc = useQueryClient(); const leaveWorkspace = useLeaveWorkspace(); const deleteWorkspace = useDeleteWorkspace(); const navigation = useNavigation(); /** * Send the user to a safe URL BEFORE the leave/delete mutation fires. * The destination is computed from the current cached workspace list, * minus the workspace that's about to go away. * * Why navigate first, not after: * 1. The backend broadcasts `workspace:deleted` / `member:removed` the * moment the mutation lands. If the user is still on the soon-to- * be-deleted workspace's URL when that event arrives, the realtime * handler in `use-realtime-sync.ts` also triggers a relocation — * and both code paths race with the mutation's own * `invalidateQueries` refetch. The loser's in-flight fetch gets * cancelled, surfacing as an unhandled `CancelledError`. * 2. Navigating first means by the time the WS event fires, the * active workspace is already something else; the realtime * handler's "current === deleted" check fails and its relocate * branch no-ops. * 3. UX: the destructive flow feels instant (dialog closes → new * workspace appears) even though the API hasn't responded yet. */ const navigateAwayFromCurrentWorkspace = () => { const cachedList = qc.getQueryData(workspaceListOptions().queryKey) ?? []; const remaining = cachedList.filter((w) => w.id !== workspace?.id); const next = remaining[0]; // Clear the workspace-context singleton BEFORE navigating and BEFORE // the mutation fires. Three downstream consumers read it: // 1. Realtime `workspace:deleted` handler's "current === deleted" // check — if the singleton still points at the deleting workspace // when the WS event arrives, it fires a parallel relocate that // races the mutation's invalidate and the settings page's own // navigate, surfacing a CancelledError and a full-page reload. // 2. Chrome gating (`{slug && }` on desktop) — if the // singleton lingers, the sidebar stays mounted while the deleted // workspace is no longer in the list, and `useWorkspaceId` throws. // 3. API client's `X-Workspace-Slug` header — stale header post- // delete is at best a 404, at worst leaks into the next query. // WorkspaceRouteLayout re-sets the singleton when a new workspace's // route mounts; clearing here is safe — either the next workspace // takes over immediately, or the new-workspace overlay takes over // (which has no workspace context, so null is correct). setCurrentWorkspace(null, null); navigation.push( next ? paths.workspace(next.slug).issues() : paths.newWorkspace(), ); }; const [name, setName] = useState(workspace?.name ?? ""); const [description, setDescription] = useState(workspace?.description ?? ""); const [context, setContext] = useState(workspace?.context ?? ""); const [saving, setSaving] = useState(false); const [actionId, setActionId] = useState(null); const [confirmAction, setConfirmAction] = useState<{ title: string; description: string; variant?: "destructive"; onConfirm: () => Promise; } | null>(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const currentMember = members.find((m) => m.user_id === user?.id) ?? null; const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin"; const isOwner = currentMember?.role === "owner"; // Mirror the backend invariant (server/internal/handler/workspace.go:569): // a workspace must always have at least one owner, so the sole owner can't // leave. Pre-flight here instead of letting the 400 round-trip become a // confusing toast — disable Leave and tell the user what they need to do. const ownerCount = members.filter((m) => m.role === "owner").length; const isSoleOwner = isOwner && ownerCount <= 1; const isSoleMember = members.length <= 1; useEffect(() => { setName(workspace?.name ?? ""); setDescription(workspace?.description ?? ""); setContext(workspace?.context ?? ""); }, [workspace]); const handleSave = async () => { if (!workspace) return; setSaving(true); try { const updated = await api.updateWorkspace(workspace.id, { name, description, context, }); qc.setQueryData(workspaceKeys.list(), (old: Workspace[] | undefined) => old?.map((ws) => (ws.id === updated.id ? updated : ws)), ); toast.success("Workspace settings saved"); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to save workspace settings"); } finally { setSaving(false); } }; const handleLeaveWorkspace = () => { if (!workspace) return; setConfirmAction({ title: "Leave workspace", description: `Leave ${workspace.name}? You will lose access until re-invited.`, variant: "destructive", onConfirm: async () => { setActionId("leave"); // Navigate away FIRST so the realtime handler's // "current-workspace-deleted" branch doesn't race the mutation. navigateAwayFromCurrentWorkspace(); try { await leaveWorkspace.mutateAsync(workspace.id); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to leave workspace"); } finally { setActionId(null); } }, }); }; const handleConfirmDelete = async () => { if (!workspace) return; setActionId("delete-workspace"); // Close the dialog and navigate away FIRST. See navigateAwayFromCurrentWorkspace // comment for why: keeps the realtime `workspace:deleted` handler out // of the race so we don't end up with concurrent refetches cancelling // each other and surfacing CancelledError. setDeleteDialogOpen(false); navigateAwayFromCurrentWorkspace(); try { await deleteWorkspace.mutateAsync(workspace.id); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to delete workspace"); } finally { setActionId(null); } }; if (!workspace) return null; return (
{/* Workspace settings */}

General

setName(e.target.value)} disabled={!canManageWorkspace} className="mt-1" />