From a94a389364ecfb1a0eee70ef817e9cc43da5ed08 Mon Sep 17 00:00:00 2001 From: J Date: Tue, 30 Jun 2026 20:34:41 +0800 Subject: [PATCH] refactor(autopilots): open access management as a popover from the edit modal (MUL-3893) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standalone 'Manage access' button on the autopilot detail header was redundant — anyone who cannot open Edit also cannot manage access. The first attempt folded it into the edit dialog's sidebar, which read as cluttered. This instead surfaces it as a compact 'Manage access' button in the edit modal header that opens a popover with the grant/revoke list. - Extract the access UI into a reusable AutopilotAccessManager (no Dialog) - Render it inside a header Popover in edit mode, gated on canManageAccess - Drop the detail-page button, ManageAccessDialog, and the now-dead detail.manage_access i18n key (access.* keys are reused by the popover) Co-authored-by: multica-agent --- .../components/autopilot-access-manager.tsx | 162 ++++++++++++++++ .../components/autopilot-detail-page.tsx | 18 +- .../components/autopilot-dialog.tsx | 36 ++++ .../components/manage-access-dialog.tsx | 177 ------------------ packages/views/locales/en/autopilots.json | 1 - packages/views/locales/ja/autopilots.json | 1 - packages/views/locales/ko/autopilots.json | 1 - .../views/locales/zh-Hans/autopilots.json | 1 - 8 files changed, 200 insertions(+), 197 deletions(-) create mode 100644 packages/views/autopilots/components/autopilot-access-manager.tsx delete mode 100644 packages/views/autopilots/components/manage-access-dialog.tsx diff --git a/packages/views/autopilots/components/autopilot-access-manager.tsx b/packages/views/autopilots/components/autopilot-access-manager.tsx new file mode 100644 index 000000000..4074fb9de --- /dev/null +++ b/packages/views/autopilots/components/autopilot-access-manager.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Plus, X } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { useWorkspaceId } from "@multica/core/hooks"; +import { memberListOptions } from "@multica/core/workspace/queries"; +import { useActorName } from "@multica/core/workspace/hooks"; +import { + useGrantAutopilotAccess, + useRevokeAutopilotAccess, +} from "@multica/core/autopilots/mutations"; +import type { AutopilotCollaborator } from "@multica/core/types"; +import { toast } from "sonner"; +import { ActorAvatar } from "../../common/actor-avatar"; +import { + PropertyPicker, + PickerItem, + PickerEmpty, +} from "../../issues/components/pickers/property-picker"; +import { matchesPinyin } from "../../editor/extensions/pinyin-match"; +import { useT } from "../../i18n"; + +// Grant / revoke explicit write access to an autopilot. Members-only, mirroring +// the subscriber picker. Creators and workspace admins always have access and +// are not listed here — this manages the additional, explicitly-granted set. +// Rendered inside the edit dialog's "Manage access" popover; access changes +// commit immediately via their own mutations and are independent of the form's +// Save action. +export function AutopilotAccessManager({ + autopilotId, + collaborators, +}: { + autopilotId: string; + collaborators: AutopilotCollaborator[]; +}) { + const { t } = useT("autopilots"); + const wsId = useWorkspaceId(); + const { data: members = [] } = useQuery(memberListOptions(wsId)); + const { getActorName } = useActorName(); + const grant = useGrantAutopilotAccess(); + const revoke = useRevokeAutopilotAccess(); + const [pickerOpen, setPickerOpen] = useState(false); + const [filter, setFilter] = useState(""); + + const grantedIds = useMemo( + () => new Set(collaborators.map((c) => c.user_id)), + [collaborators], + ); + + const query = filter.trim().toLowerCase(); + const candidates = useMemo( + () => + members.filter( + (m) => + !grantedIds.has(m.user_id) && + (query === "" || + m.name.toLowerCase().includes(query) || + matchesPinyin(m.name, query)), + ), + [members, grantedIds, query], + ); + + const handleGrant = async (userId: string) => { + try { + await grant.mutateAsync({ autopilotId, userId }); + toast.success(t(($) => $.access.toast_granted)); + } catch (e: any) { + toast.error(e?.message || t(($) => $.access.toast_failed)); + } + }; + + const handleRevoke = async (userId: string) => { + try { + await revoke.mutateAsync({ autopilotId, userId }); + toast.success(t(($) => $.access.toast_revoked)); + } catch (e: any) { + toast.error(e?.message || t(($) => $.access.toast_failed)); + } + }; + + return ( +
+
+ + {t(($) => $.access.current_label)} + + { + setPickerOpen(v); + if (!v) setFilter(""); + }} + width="w-64" + align="end" + searchable + searchPlaceholder={t(($) => $.access.search_placeholder)} + onSearchChange={setFilter} + trigger={ + + + {t(($) => $.access.add)} + + } + > + {candidates.length === 0 ? ( + + ) : ( + candidates.map((m) => ( + { + void handleGrant(m.user_id); + setPickerOpen(false); + }} + > + + {m.name} + + )) + )} + +
+ + {collaborators.length === 0 ? ( +

+ {t(($) => $.access.empty)} +

+ ) : ( +
    + {collaborators.map((c) => ( +
  • + + + + {getActorName("member", c.user_id)} + + + +
  • + ))} +
+ )} + +

+ {t(($) => $.access.owner_note)} +

+
+ ); +} diff --git a/packages/views/autopilots/components/autopilot-detail-page.tsx b/packages/views/autopilots/components/autopilot-detail-page.tsx index bcd2dbce9..e65c8ba99 100644 --- a/packages/views/autopilots/components/autopilot-detail-page.tsx +++ b/packages/views/autopilots/components/autopilot-detail-page.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { Zap, Play, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil, Ban, ChevronDown, ChevronRight, - Webhook, Copy, Check, RotateCw, Users, + Webhook, Copy, Check, RotateCw, } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { autopilotDetailOptions, autopilotRunsOptions, autopilotRunOptions } from "@multica/core/autopilots/queries"; @@ -62,7 +62,6 @@ import type { AgentTask } from "@multica/core/types/agent"; import { ReadonlyContent } from "../../editor"; import { TranscriptButton } from "../../common/task-transcript"; import { AutopilotDialog } from "./autopilot-dialog"; -import { ManageAccessDialog } from "./manage-access-dialog"; import { WebhookPayloadPreview } from "./webhook-payload-preview"; import { WebhookDeliveriesSection } from "./webhook-deliveries-section"; import { ProjectIcon } from "../../projects/components/project-icon"; @@ -635,7 +634,6 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) { const [triggerDialogOpen, setTriggerDialogOpen] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false); - const [accessDialogOpen, setAccessDialogOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleting, setDeleting] = useState(false); @@ -760,12 +758,6 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) { actions={ canWrite ? ( <> - {canManageAccess && ( - - )} - - ))} - - )} - - -

- {t(($) => $.access.owner_note)} -

- - - ); -} diff --git a/packages/views/locales/en/autopilots.json b/packages/views/locales/en/autopilots.json index 2af9aa8c0..09d83a460 100644 --- a/packages/views/locales/en/autopilots.json +++ b/packages/views/locales/en/autopilots.json @@ -70,7 +70,6 @@ "pause_aria": "Pause autopilot", "activate_aria": "Activate autopilot", "edit": "Edit", - "manage_access": "Manage access", "run_now": "Run now", "running": "Running...", "toast_triggered": "Autopilot triggered", diff --git a/packages/views/locales/ja/autopilots.json b/packages/views/locales/ja/autopilots.json index 0b1e206d7..6e73b8a7a 100644 --- a/packages/views/locales/ja/autopilots.json +++ b/packages/views/locales/ja/autopilots.json @@ -70,7 +70,6 @@ "pause_aria": "オートパイロットを一時停止", "activate_aria": "オートパイロットを有効化", "edit": "編集", - "manage_access": "アクセス管理", "run_now": "今すぐ実行", "running": "実行中...", "toast_triggered": "オートパイロットを実行しました", diff --git a/packages/views/locales/ko/autopilots.json b/packages/views/locales/ko/autopilots.json index 4bb1c047b..27bf73035 100644 --- a/packages/views/locales/ko/autopilots.json +++ b/packages/views/locales/ko/autopilots.json @@ -70,7 +70,6 @@ "pause_aria": "오토파일럿 일시 중지", "activate_aria": "오토파일럿 활성화", "edit": "수정", - "manage_access": "접근 권한 관리", "run_now": "지금 실행", "running": "실행 중...", "toast_triggered": "오토파일럿을 실행했습니다", diff --git a/packages/views/locales/zh-Hans/autopilots.json b/packages/views/locales/zh-Hans/autopilots.json index e97a3f381..63a5be3aa 100644 --- a/packages/views/locales/zh-Hans/autopilots.json +++ b/packages/views/locales/zh-Hans/autopilots.json @@ -70,7 +70,6 @@ "pause_aria": "暂停自动化", "activate_aria": "启用自动化", "edit": "编辑", - "manage_access": "管理访问", "run_now": "立即运行", "running": "运行中...", "toast_triggered": "已触发自动化",