mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 18:17:44 +02:00
- staleTime: 0 on fetchQuery after leave/delete so fresh data is fetched - setQueryData before switchWorkspace in createWorkspace so sidebar is consistent on first render - seed workspaceKeys.list() cache in login, Google callback, and settings save so the first useQuery(workspaceListOptions()) hit is free - remove dead onError from WorkspaceStoreOptions (used only by the deleted refreshWorkspaces action) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
259 lines
9.2 KiB
TypeScript
259 lines
9.2 KiB
TypeScript
"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 { useWorkspaceStore } from "@multica/core/workspace";
|
|
import { useLeaveWorkspace, useDeleteWorkspace } from "@multica/core/workspace/mutations";
|
|
import { useWorkspaceId } from "@multica/core/hooks";
|
|
import { memberListOptions, workspaceKeys } from "@multica/core/workspace/queries";
|
|
import { api } from "@multica/core/api";
|
|
import type { Workspace } from "@multica/core/types";
|
|
|
|
export function WorkspaceTab() {
|
|
const user = useAuthStore((s) => s.user);
|
|
const workspace = useWorkspaceStore((s) => s.workspace);
|
|
const wsId = useWorkspaceId();
|
|
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
|
const qc = useQueryClient();
|
|
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
|
|
const leaveWorkspace = useLeaveWorkspace();
|
|
const deleteWorkspace = useDeleteWorkspace();
|
|
|
|
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<string | null>(null);
|
|
const [confirmAction, setConfirmAction] = useState<{
|
|
title: string;
|
|
description: string;
|
|
variant?: "destructive";
|
|
onConfirm: () => Promise<void>;
|
|
} | null>(null);
|
|
|
|
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
|
|
const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin";
|
|
const isOwner = currentMember?.role === "owner";
|
|
|
|
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,
|
|
});
|
|
updateWorkspace(updated);
|
|
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");
|
|
try {
|
|
await leaveWorkspace.mutateAsync(workspace.id);
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Failed to leave workspace");
|
|
} finally {
|
|
setActionId(null);
|
|
}
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleDeleteWorkspace = () => {
|
|
if (!workspace) return;
|
|
setConfirmAction({
|
|
title: "Delete workspace",
|
|
description: `Delete ${workspace.name}? This cannot be undone. All issues, agents, and data will be permanently removed.`,
|
|
variant: "destructive",
|
|
onConfirm: async () => {
|
|
setActionId("delete-workspace");
|
|
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 (
|
|
<div className="space-y-8">
|
|
{/* Workspace settings */}
|
|
<section className="space-y-4">
|
|
<h2 className="text-sm font-semibold">General</h2>
|
|
|
|
<Card>
|
|
<CardContent className="space-y-3">
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">Name</Label>
|
|
<Input
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
disabled={!canManageWorkspace}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">Description</Label>
|
|
<Textarea
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
rows={3}
|
|
disabled={!canManageWorkspace}
|
|
className="mt-1 resize-none"
|
|
placeholder="What does this workspace focus on?"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">Context</Label>
|
|
<Textarea
|
|
value={context}
|
|
onChange={(e) => setContext(e.target.value)}
|
|
rows={4}
|
|
disabled={!canManageWorkspace}
|
|
className="mt-1 resize-none"
|
|
placeholder="Background information and context for AI agents working in this workspace"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">Slug</Label>
|
|
<div className="mt-1 rounded-md border bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
|
|
{workspace.slug}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2 pt-1">
|
|
<Button
|
|
size="sm"
|
|
onClick={handleSave}
|
|
disabled={saving || !name.trim() || !canManageWorkspace}
|
|
>
|
|
<Save className="h-3 w-3" />
|
|
{saving ? "Saving..." : "Save"}
|
|
</Button>
|
|
</div>
|
|
{!canManageWorkspace && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Only admins and owners can update workspace settings.
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</section>
|
|
|
|
{/* Danger Zone */}
|
|
<section className="space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<LogOut className="h-4 w-4 text-muted-foreground" />
|
|
<h2 className="text-sm font-semibold">Danger Zone</h2>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardContent className="space-y-3">
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium">Leave workspace</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Remove yourself from this workspace.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleLeaveWorkspace}
|
|
disabled={actionId === "leave"}
|
|
>
|
|
{actionId === "leave" ? "Leaving..." : "Leave workspace"}
|
|
</Button>
|
|
</div>
|
|
|
|
{isOwner && (
|
|
<div className="flex flex-col gap-2 border-t pt-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-destructive">Delete workspace</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Permanently delete this workspace and its data.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={handleDeleteWorkspace}
|
|
disabled={actionId === "delete-workspace"}
|
|
>
|
|
{actionId === "delete-workspace" ? "Deleting..." : "Delete workspace"}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</section>
|
|
|
|
<AlertDialog open={!!confirmAction} onOpenChange={(v) => { if (!v) setConfirmAction(null); }}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{confirmAction?.title}</AlertDialogTitle>
|
|
<AlertDialogDescription>{confirmAction?.description}</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
variant={confirmAction?.variant === "destructive" ? "destructive" : "default"}
|
|
onClick={async () => {
|
|
await confirmAction?.onConfirm();
|
|
setConfirmAction(null);
|
|
}}
|
|
>
|
|
Confirm
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
}
|