Files
kandev/apps/web/app/settings/workspace/workspace-edit-client.tsx
T
2026-09-22 21:24:43 +01:00

606 lines
20 KiB
TypeScript

"use client";
import { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import Link from "@/components/routing/app-link";
import { useRouter } from "@/lib/routing/client-router";
import { runWithNavigationBlockerBypassed } from "@/lib/routing/navigation-guard";
import { IconTrash } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Separator } from "@kandev/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import { updateWorkspaceAction, deleteWorkspaceAction } from "@/app/actions/workspaces";
import type { TFunction } from "i18next";
import type { Executor } from "@/lib/types/http";
import type { AgentProfileOption, WorkspaceState } from "@/lib/state/slices";
import { isSelectableAgentProfile } from "@/lib/state/slices/settings/types";
type Workspace = WorkspaceState["items"][number];
import { useRequest } from "@/lib/http/use-request";
import { useToast } from "@/components/toast-provider";
import { useAppStore } from "@/components/state-provider";
import { useFeature } from "@/hooks/domains/features/use-feature";
import { SettingsGroup } from "@/components/settings/settings-group";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { SettingsTarget } from "@/components/settings/settings-target";
import { workspaceDiscoveryTarget } from "@/lib/settings-discovery/dynamic-targets";
import { WorkspaceSectionHeader } from "@/components/settings/workspaces/workspace-section-header";
import { WorkspacePlacementCard } from "@/components/settings/workspaces/workspace-placement-card";
import { WorkspaceTeamAccessCard } from "@/components/settings/workspaces/workspace-team-access-card";
import { hasScope, SCOPE } from "@/lib/types/team-access";
type WorkspaceEditClientProps = {
workspaceId: string;
};
export function WorkspaceEditClient({ workspaceId }: WorkspaceEditClientProps) {
const workspace = useAppStore(
(state) => state.workspaces.items.find((item: Workspace) => item.id === workspaceId) ?? null,
);
const { t } = useTranslation();
if (!workspace) {
return (
<div>
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">{t("workspaces:workspaceNotFound")}</p>
<Button className="mt-4" asChild>
<Link href="/settings/workspaces">{t("workspaces:backToWorkspaces")}</Link>
</Button>
</CardContent>
</Card>
</div>
);
}
return <WorkspaceEditForm key={workspace.id} workspace={workspace} />;
}
type WorkspaceEditFormProps = {
workspace: Workspace;
};
type SelectFieldProps = {
label: string;
// The placeholder was `Select ${label.toLowerCase()}`. Lowercasing a translated
// label is an English-only transformation, and building the sentence at the
// call site leaves a translator with no way to reorder it — so it is its own
// message, passed in by the caller.
placeholder: string;
value: string;
isDirty: boolean;
onChange: (v: string) => void;
options: { id: string; name: string }[];
emptyLabel: string;
emptyValue: string;
discoveryTargetId: string;
};
function SelectField({
label,
placeholder,
value,
isDirty,
onChange,
options,
emptyLabel,
emptyValue,
discoveryTargetId,
}: SelectFieldProps) {
const { t } = useTranslation();
return (
<SettingsTarget targetId={discoveryTargetId} className="space-y-2">
<Label>{label}</Label>
<Select value={value || "none"} onValueChange={(v) => onChange(v === "none" ? "" : v)}>
<SelectTrigger className="w-full" data-settings-dirty={isDirty}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t("workspaces:noDefault")}</SelectItem>
{options.map((opt) => (
<SelectItem key={opt.id} value={opt.id}>
{opt.name}
</SelectItem>
))}
{options.length === 0 && (
<SelectItem value={emptyValue} disabled>
{emptyLabel}
</SelectItem>
)}
</SelectContent>
</Select>
</SettingsTarget>
);
}
type WorkspaceSettingsCardProps = {
canManage: boolean;
workspaceId: string;
workspaceNameDraft: string;
nameIsDirty: boolean;
onNameChange: (value: string) => void;
defaultExecutorId: string;
executorIsDirty: boolean;
onExecutorChange: (value: string) => void;
activeExecutors: Executor[];
executorsEmpty: boolean;
defaultAgentProfileId: string;
agentProfileIsDirty: boolean;
onAgentProfileChange: (value: string) => void;
agentProfiles: AgentProfileOption[];
};
function WorkspaceSettingsCard({
canManage,
workspaceId,
workspaceNameDraft,
nameIsDirty,
onNameChange,
defaultExecutorId,
executorIsDirty,
onExecutorChange,
activeExecutors,
executorsEmpty,
defaultAgentProfileId,
agentProfileIsDirty,
onAgentProfileChange,
agentProfiles,
}: WorkspaceSettingsCardProps) {
const { t } = useTranslation();
const dynamicRoutingEnabled = useFeature("dynamicAgentRouting");
const executorOptions = activeExecutors.map((e: Executor) => ({ id: e.id, name: e.name }));
const profileOptions = agentProfiles
.filter((profile) => isSelectableAgentProfile(profile, dynamicRoutingEnabled))
.map((p: AgentProfileOption) => ({
id: p.id,
name: p.label,
}));
return (
<SettingsGroup
title={t("workspaces:workspaceSettings")}
isDirty={nameIsDirty || executorIsDirty || agentProfileIsDirty}
contentClassName="space-y-4 divide-y-0"
>
<div className="space-y-4">
<SettingsTarget
targetId={workspaceDiscoveryTarget(workspaceId, "name")}
className="space-y-2"
>
<Label htmlFor="workspace-name">{t("workspaces:name")}</Label>
<Input
id="workspace-name"
value={workspaceNameDraft}
disabled={!canManage}
data-settings-dirty={nameIsDirty}
onChange={(e) => onNameChange(e.target.value)}
/>
</SettingsTarget>
<SelectField
label={t("workspaces:defaultExecutor")}
placeholder={t("workspaces:selectDefaultExecutor")}
value={defaultExecutorId}
isDirty={executorIsDirty}
onChange={onExecutorChange}
options={executorsEmpty ? [] : executorOptions}
emptyLabel={t("workspaces:noExecutorsAvailable")}
emptyValue=""
discoveryTargetId={workspaceDiscoveryTarget(workspaceId, "default-executor")}
/>
<SelectField
label={t("workspaces:defaultAgentProfile")}
placeholder={t("workspaces:selectDefaultAgentProfile")}
value={defaultAgentProfileId}
isDirty={agentProfileIsDirty}
onChange={onAgentProfileChange}
options={profileOptions}
emptyLabel={t("workspaces:noAgentProfilesAvailable")}
emptyValue="empty-agent-profiles"
discoveryTargetId={workspaceDiscoveryTarget(workspaceId, "default-agent-profile")}
/>
</div>
</SettingsGroup>
);
}
type DeleteWorkspaceCardProps = {
canManage: boolean;
workspaceName: string;
deleteDialogOpen: boolean;
setDeleteDialogOpen: (open: boolean) => void;
deleteConfirmText: string;
setDeleteConfirmText: (text: string) => void;
onDelete: () => void;
};
function DeleteWorkspaceCard({
canManage,
workspaceName,
deleteDialogOpen,
setDeleteDialogOpen,
deleteConfirmText,
setDeleteConfirmText,
onDelete,
}: DeleteWorkspaceCardProps) {
const { t } = useTranslation();
// Deleting a workspace is owner-only. The API refuses a member either way;
// hiding the card keeps the UI from offering an action it will reject.
if (!canManage) return null;
return (
<>
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">{t("workspaces:deleteWorkspace")}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{t("workspaces:deleteThisWorkspace")}</p>
<p className="text-xs text-muted-foreground">
{t("workspaces:thisActionCannotBeUndone")}
</p>
</div>
<Button
variant="destructive"
onClick={() => setDeleteDialogOpen(true)}
className="cursor-pointer"
data-testid="workspace-settings-delete-button"
>
<IconTrash className="h-4 w-4 mr-2" />
{t("workspaces:delete")}
</Button>
</div>
</CardContent>
</Card>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("workspaces:deleteWorkspace")}</DialogTitle>
<DialogDescription>
{/* The workspace name is user data and is what the input is compared
against, so it travels as an interpolated value — never as part
of the message a translator edits. */}
<Trans
i18nKey="workspaces:typeWorkspaceNameToConfirm"
values={{ name: workspaceName }}
>
<span className="font-medium" />
</Trans>
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="confirm-delete">{t("workspaces:confirmDelete")}</Label>
<Input
id="confirm-delete"
value={deleteConfirmText}
onChange={(event) => setDeleteConfirmText(event.target.value)}
placeholder={workspaceName}
autoComplete="off"
data-testid="workspace-settings-delete-confirm-input"
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
className="cursor-pointer"
>
{t("common:cancel")}
</Button>
<Button
variant="destructive"
onClick={onDelete}
disabled={deleteConfirmText !== workspaceName}
className="cursor-pointer"
data-testid="workspace-settings-delete-confirm-button"
>
{t("workspaces:delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
type SavedState = {
name: string;
executorId: string;
agentProfileId: string;
};
function buildWorkspaceUpdates(
draft: { name: string; executorId: string; agentProfileId: string },
saved: SavedState,
): Record<string, string | undefined> {
const updates: Record<string, string | undefined> = {};
if (draft.name.trim() !== saved.name) updates.name = draft.name.trim();
if (draft.executorId !== saved.executorId) updates.default_executor_id = draft.executorId;
if (draft.agentProfileId !== saved.agentProfileId)
updates.default_agent_profile_id = draft.agentProfileId;
return updates;
}
type WorkspaceDraftState = {
workspaceNameDraft: string;
defaultExecutorId: string;
defaultAgentProfileId: string;
};
type SaveRequestLike = {
run: (id: string, updates: Record<string, string | undefined>) => Promise<Workspace>;
};
type WorkspaceSaveHandlerOptions = {
currentWorkspace: Workspace;
draft: WorkspaceDraftState;
savedState: SavedState;
isDirty: boolean;
setSavedState: (s: SavedState) => void;
setCurrentWorkspace: (fn: (prev: Workspace) => Workspace) => void;
workspaces: Workspace[];
setWorkspaces: (items: Workspace[]) => void;
saveWorkspaceRequest: SaveRequestLike;
toast: ReturnType<typeof useToast>["toast"];
t: TFunction;
};
function buildSaveHandler({
currentWorkspace,
draft,
savedState,
isDirty,
setSavedState,
setCurrentWorkspace,
workspaces,
setWorkspaces,
saveWorkspaceRequest,
toast,
t,
}: WorkspaceSaveHandlerOptions) {
return async () => {
if (!isDirty) return;
try {
const updates = buildWorkspaceUpdates(
{
name: draft.workspaceNameDraft,
executorId: draft.defaultExecutorId,
agentProfileId: draft.defaultAgentProfileId,
},
savedState,
);
const updated = await saveWorkspaceRequest.run(currentWorkspace.id, updates);
setCurrentWorkspace((prev) => ({ ...prev, ...updated }));
setSavedState({
name: updated.name ?? draft.workspaceNameDraft.trim(),
executorId: updated.default_executor_id ?? "",
agentProfileId: updated.default_agent_profile_id ?? "",
});
setWorkspaces(
workspaces.map((ws: Workspace) =>
ws.id === updated.id
? {
...ws,
name: updated.name,
default_executor_id: updated.default_executor_id ?? null,
default_environment_id: updated.default_environment_id ?? null,
default_agent_profile_id: updated.default_agent_profile_id ?? null,
}
: ws,
),
);
} catch (error) {
toast({
title: t("workspaces:failedToSaveWorkspace"),
description: error instanceof Error ? error.message : t("common:requestFailed"),
variant: "error",
});
throw error;
}
};
}
function useWorkspaceEditForm(workspace: Workspace) {
const router = useRouter();
const { toast } = useToast();
const { t } = useTranslation();
const [currentWorkspace, setCurrentWorkspace] = useState<Workspace>(workspace);
const [workspaceNameDraft, setWorkspaceNameDraft] = useState(workspace.name ?? "");
const [defaultExecutorId, setDefaultExecutorId] = useState(workspace.default_executor_id ?? "");
const [defaultAgentProfileId, setDefaultAgentProfileId] = useState(
workspace.default_agent_profile_id ?? "",
);
const [savedState, setSavedState] = useState<SavedState>({
name: workspace.name ?? "",
executorId: workspace.default_executor_id ?? "",
agentProfileId: workspace.default_agent_profile_id ?? "",
});
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const executors = useAppStore((state) => state.executors.items);
const agentProfiles = useAppStore((state) => state.agentProfiles.items);
const workspaces = useAppStore((state) => state.workspaces.items);
const setWorkspaces = useAppStore((state) => state.setWorkspaces);
const saveWorkspaceRequest = useRequest(updateWorkspaceAction);
const deleteWorkspaceRequest = useRequest(deleteWorkspaceAction);
// Selects the delete route: the office endpoint only exists while the feature
// is on. See `deleteWorkspaceAction`.
const officeEnabled = useFeature("office");
const activeExecutors = executors.filter((executor: Executor) => executor.status === "active");
const isDirty =
workspaceNameDraft.trim() !== savedState.name ||
defaultExecutorId !== savedState.executorId ||
defaultAgentProfileId !== savedState.agentProfileId;
const handleSave = buildSaveHandler({
currentWorkspace,
draft: { workspaceNameDraft, defaultExecutorId, defaultAgentProfileId },
savedState,
isDirty,
setSavedState,
setCurrentWorkspace,
workspaces,
setWorkspaces,
saveWorkspaceRequest,
toast,
t,
});
const handleDeleteWorkspace = async () => {
if (deleteConfirmText !== currentWorkspace.name) return;
try {
await deleteWorkspaceRequest.run(currentWorkspace.id, currentWorkspace.name, officeEnabled);
setWorkspaces(workspaces.filter((ws: Workspace) => ws.id !== currentWorkspace.id));
runWithNavigationBlockerBypassed(() => router.push("/settings/workspaces"));
} catch (error) {
toast({
title: t("workspaces:failedToDeleteWorkspace"),
description: error instanceof Error ? error.message : t("common:requestFailed"),
variant: "error",
});
}
};
// Clears pre-fill so Cancel-then-reopen can't silently bypass the re-type requirement.
const handleDeleteDialogOpenChange = (open: boolean) => {
setDeleteDialogOpen(open);
if (!open) setDeleteConfirmText("");
};
const handleDiscard = () => {
setWorkspaceNameDraft(savedState.name);
setDefaultExecutorId(savedState.executorId);
setDefaultAgentProfileId(savedState.agentProfileId);
};
return {
currentWorkspace,
workspaceNameDraft,
setWorkspaceNameDraft,
defaultExecutorId,
setDefaultExecutorId,
defaultAgentProfileId,
setDefaultAgentProfileId,
deleteDialogOpen,
setDeleteDialogOpen: handleDeleteDialogOpenChange,
deleteConfirmText,
setDeleteConfirmText,
activeExecutors,
executors,
agentProfiles,
savedState,
isDirty,
handleSave,
handleDiscard,
handleDeleteWorkspace,
};
}
function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) {
const {
currentWorkspace,
workspaceNameDraft,
setWorkspaceNameDraft,
defaultExecutorId,
setDefaultExecutorId,
defaultAgentProfileId,
setDefaultAgentProfileId,
deleteDialogOpen,
setDeleteDialogOpen,
deleteConfirmText,
setDeleteConfirmText,
activeExecutors,
executors,
agentProfiles,
savedState,
isDirty,
handleSave,
handleDiscard,
handleDeleteWorkspace,
} = useWorkspaceEditForm(workspace);
// Owner-only controls are gated on the server-issued scope, never on an
// owner-id comparison, so the UI and the API agree on one answer.
const canManage = hasScope(workspace.scopes, SCOPE.workspaceManage);
const { t } = useTranslation();
useSettingsSaveContributor({
id: `workspace:${currentWorkspace.id}`,
revision: JSON.stringify({
workspaceNameDraft,
defaultExecutorId,
defaultAgentProfileId,
}),
isDirty,
canSave: Boolean(workspaceNameDraft.trim()),
invalidReason: workspaceNameDraft.trim() ? undefined : t("workspaces:workspaceNameIsRequired"),
save: handleSave,
discard: handleDiscard,
});
return (
<div className="space-y-8">
<WorkspaceSectionHeader tab="overview" description={t("workspaces:manageWorkspaceDetails")} />
<Separator />
<WorkspaceSettingsCard
canManage={canManage}
workspaceId={currentWorkspace.id}
workspaceNameDraft={workspaceNameDraft}
nameIsDirty={workspaceNameDraft.trim() !== savedState.name}
onNameChange={setWorkspaceNameDraft}
defaultExecutorId={defaultExecutorId}
executorIsDirty={defaultExecutorId !== savedState.executorId}
onExecutorChange={setDefaultExecutorId}
activeExecutors={activeExecutors}
executorsEmpty={executors.length === 0}
defaultAgentProfileId={defaultAgentProfileId}
agentProfileIsDirty={defaultAgentProfileId !== savedState.agentProfileId}
onAgentProfileChange={setDefaultAgentProfileId}
agentProfiles={agentProfiles}
/>
<Separator />
{/*
Reads the live store item rather than the form's captured snapshot:
scopes arrive with hydration, which can land after this
form mounts, and a snapshot would freeze the owner out of their own
workspace.
*/}
<WorkspacePlacementCard
workspaceId={workspace.id}
unitId={workspace.unit_id}
scopes={workspace.scopes}
/>
<Separator />
<WorkspaceTeamAccessCard
workspaceId={workspace.id}
ownerId={workspace.owner_id ?? ""}
scopes={workspace.scopes}
/>
<Separator />
<DeleteWorkspaceCard
canManage={canManage}
workspaceName={currentWorkspace.name}
deleteDialogOpen={deleteDialogOpen}
setDeleteDialogOpen={setDeleteDialogOpen}
deleteConfirmText={deleteConfirmText}
setDeleteConfirmText={setDeleteConfirmText}
onDelete={handleDeleteWorkspace}
/>
</div>
);
}