Compare commits

...

1 Commits

Author SHA1 Message Date
J
656d4f9a74 refactor(autopilots): fold access management into the edit dialog (MUL-3893)
Remove the standalone 'Manage access' button from the autopilot detail
header and surface the grant/revoke list as an 'Access' section inside
the Edit dialog's configuration sidebar. Anyone who cannot open Edit
already cannot manage access, so the separate affordance was redundant.

- Extract the dialog body into a reusable AutopilotAccessManager
- Render it in edit mode only, gated on canManageAccess
- Drop ManageAccessDialog and its now-dead i18n keys

Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 19:04:42 +08:00
8 changed files with 196 additions and 205 deletions

View File

@@ -0,0 +1,157 @@
"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 inline inside the edit dialog's configuration sidebar; 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 (
<div className="space-y-2">
{collaborators.length === 0 ? (
<p className="rounded-md border border-dashed px-3 py-3 text-center text-xs text-muted-foreground">
{t(($) => $.access.empty)}
</p>
) : (
<ul className="space-y-1">
{collaborators.map((c) => (
<li
key={c.user_id}
className="flex items-center justify-between rounded-md border bg-background px-2 py-1.5"
>
<span className="flex min-w-0 items-center gap-2">
<ActorAvatar actorType="member" actorId={c.user_id} size={20} />
<span className="truncate text-sm">
{getActorName("member", c.user_id)}
</span>
</span>
<button
type="button"
onClick={() => void handleRevoke(c.user_id)}
disabled={revoke.isPending}
className="cursor-pointer text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
aria-label={t(($) => $.access.remove_tooltip)}
>
<X className="size-3.5" />
</button>
</li>
))}
</ul>
)}
<PropertyPicker
open={pickerOpen}
onOpenChange={(v) => {
setPickerOpen(v);
if (!v) setFilter("");
}}
width="w-64"
align="start"
searchable
searchPlaceholder={t(($) => $.access.search_placeholder)}
onSearchChange={setFilter}
trigger={
<span className="inline-flex cursor-pointer items-center gap-1 rounded-md border border-dashed px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-primary/40 hover:text-foreground">
<Plus className="size-3" />
{t(($) => $.access.add)}
</span>
}
>
{candidates.length === 0 ? (
<PickerEmpty />
) : (
candidates.map((m) => (
<PickerItem
key={m.user_id}
selected={false}
onClick={() => {
void handleGrant(m.user_id);
setPickerOpen(false);
}}
>
<ActorAvatar actorType="member" actorId={m.user_id} size={18} />
<span className="truncate">{m.name}</span>
</PickerItem>
))
)}
</PropertyPicker>
<p className="text-[11px] text-muted-foreground">
{t(($) => $.access.owner_note)}
</p>
</div>
);
}

View File

@@ -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 && (
<Button size="sm" variant="outline" onClick={() => setAccessDialogOpen(true)} className="px-2 sm:px-2.5" aria-label={t(($) => $.detail.manage_access)}>
<Users className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{t(($) => $.detail.manage_access)}</span>
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setEditDialogOpen(true)} className="px-2 sm:px-2.5" aria-label={t(($) => $.detail.edit)}>
<Pencil className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{t(($) => $.detail.edit)}</span>
@@ -980,14 +972,8 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
.map((s) => s.user_id) ?? [],
}}
triggers={triggers}
/>
)}
{accessDialogOpen && (
<ManageAccessDialog
open={accessDialogOpen}
onOpenChange={setAccessDialogOpen}
autopilotId={autopilot.id}
collaborators={collaborators}
canManageAccess={canManageAccess}
/>
)}
<AlertDialog

View File

@@ -51,6 +51,7 @@ import { buildAutopilotWebhookUrl } from "@multica/core/autopilots";
import { api } from "@multica/core/api";
import type {
AutopilotAssigneeType,
AutopilotCollaborator,
AutopilotExecutionMode,
AutopilotTrigger,
} from "@multica/core/types";
@@ -60,6 +61,7 @@ import { ProjectPicker } from "../../projects/components/project-picker";
import { ProjectIcon } from "../../projects/components/project-icon";
import { AgentPicker, type AssigneeSelection } from "./pickers/agent-picker";
import { SubscriberMultiSelect } from "./subscriber-multi-select";
import { AutopilotAccessManager } from "./autopilot-access-manager";
import {
getDefaultTriggerConfig,
getLocalTimezone,
@@ -102,6 +104,8 @@ export type AutopilotDialogProps =
autopilotId: string;
initial: AutopilotInitial;
triggers: AutopilotTrigger[];
collaborators: AutopilotCollaborator[];
canManageAccess: boolean;
};
// ---------------------------------------------------------------------------
@@ -665,6 +669,13 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
/>
)}
{!isCreate && props.canManageAccess && (
<AccessSection
autopilotId={props.autopilotId}
collaborators={props.collaborators}
/>
)}
{isCreate && (
<TriggerKindSection kind={triggerKind} onChange={setTriggerKind} />
)}
@@ -912,6 +923,28 @@ function SubscribersSection({
);
}
function AccessSection({
autopilotId,
collaborators,
}: {
autopilotId: string;
collaborators: AutopilotCollaborator[];
}) {
const { t } = useT("autopilots");
return (
<div>
<SectionLabel>{t(($) => $.dialog.section_access)}</SectionLabel>
<p className="mb-2 text-[11px] text-muted-foreground">
{t(($) => $.access.description)}
</p>
<AutopilotAccessManager
autopilotId={autopilotId}
collaborators={collaborators}
/>
</div>
);
}
function ScheduleSection({
config,
onChange,

View File

@@ -1,177 +0,0 @@
"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 {
Dialog,
DialogContent,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
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.
export function ManageAccessDialog({
open,
onOpenChange,
autopilotId,
collaborators,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogTitle>{t(($) => $.access.title)}</DialogTitle>
<p className="text-sm text-muted-foreground">
{t(($) => $.access.description)}
</p>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t(($) => $.access.current_label)}
</span>
<PropertyPicker
open={pickerOpen}
onOpenChange={(v) => {
setPickerOpen(v);
if (!v) setFilter("");
}}
width="w-64"
align="start"
searchable
searchPlaceholder={t(($) => $.access.search_placeholder)}
onSearchChange={setFilter}
trigger={
<span className="inline-flex cursor-pointer items-center gap-1 rounded-md border border-dashed px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-primary/40 hover:text-foreground">
<Plus className="size-3" />
{t(($) => $.access.add)}
</span>
}
>
{candidates.length === 0 ? (
<PickerEmpty />
) : (
candidates.map((m) => (
<PickerItem
key={m.user_id}
selected={false}
onClick={() => {
void handleGrant(m.user_id);
setPickerOpen(false);
}}
>
<ActorAvatar actorType="member" actorId={m.user_id} size={18} />
<span className="truncate">{m.name}</span>
</PickerItem>
))
)}
</PropertyPicker>
</div>
{collaborators.length === 0 ? (
<p className="rounded-md border border-dashed px-3 py-4 text-center text-sm text-muted-foreground">
{t(($) => $.access.empty)}
</p>
) : (
<ul className="space-y-1">
{collaborators.map((c) => (
<li
key={c.user_id}
className="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<span className="flex min-w-0 items-center gap-2">
<ActorAvatar actorType="member" actorId={c.user_id} size={20} />
<span className="truncate text-sm">
{getActorName("member", c.user_id)}
</span>
</span>
<button
type="button"
onClick={() => void handleRevoke(c.user_id)}
disabled={revoke.isPending}
className="cursor-pointer text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
aria-label={t(($) => $.access.remove_tooltip)}
>
<X className="size-3.5" />
</button>
</li>
))}
</ul>
)}
</div>
<p className="text-xs text-muted-foreground">
{t(($) => $.access.owner_note)}
</p>
</DialogContent>
</Dialog>
);
}

View File

@@ -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",
@@ -103,9 +102,7 @@
}
},
"access": {
"title": "Manage access",
"description": "Members you add can edit, run, and manage this autopilot's triggers and webhooks.",
"current_label": "With access",
"add": "Add member",
"search_placeholder": "Search members…",
"no_results": "No members found",
@@ -274,6 +271,7 @@
"section_output_mode": "Output mode",
"section_subscribers": "Subscribers",
"subscribers_hint": "Auto-subscribed to every issue this autopilot creates",
"section_access": "Access",
"subscribers_empty": "No subscribers — add a teammate to notify them on every run",
"subscribers_add": "Add subscriber",
"subscribers_search_placeholder": "Search members by name…",

View File

@@ -70,7 +70,6 @@
"pause_aria": "オートパイロットを一時停止",
"activate_aria": "オートパイロットを有効化",
"edit": "編集",
"manage_access": "アクセス管理",
"run_now": "今すぐ実行",
"running": "実行中...",
"toast_triggered": "オートパイロットを実行しました",
@@ -103,9 +102,7 @@
}
},
"access": {
"title": "アクセス管理",
"description": "追加したメンバーは、このオートパイロットの編集・実行や、トリガー・Webhook の管理ができます。",
"current_label": "アクセス権あり",
"add": "メンバーを追加",
"search_placeholder": "メンバーを検索…",
"no_results": "メンバーが見つかりません",
@@ -273,6 +270,7 @@
"no_project": "プロジェクトなし",
"section_subscribers": "購読者",
"subscribers_hint": "このオートパイロットが作成するすべてのイシューを自動で購読します",
"section_access": "アクセス",
"subscribers_empty": "購読者はいません。チームメイトを追加すると、毎回通知されます",
"subscribers_add": "購読者を追加",
"subscribers_search_placeholder": "名前でメンバーを検索…",

View File

@@ -70,7 +70,6 @@
"pause_aria": "오토파일럿 일시 중지",
"activate_aria": "오토파일럿 활성화",
"edit": "수정",
"manage_access": "접근 권한 관리",
"run_now": "지금 실행",
"running": "실행 중...",
"toast_triggered": "오토파일럿을 실행했습니다",
@@ -103,9 +102,7 @@
}
},
"access": {
"title": "접근 권한 관리",
"description": "추가한 멤버는 이 오토파일럿을 수정·실행하고 트리거와 webhook을 관리할 수 있습니다.",
"current_label": "접근 가능",
"add": "멤버 추가",
"search_placeholder": "멤버 검색…",
"no_results": "멤버를 찾을 수 없습니다",
@@ -273,6 +270,7 @@
"no_project": "프로젝트 없음",
"section_subscribers": "구독자",
"subscribers_hint": "이 오토파일럿이 만드는 모든 이슈를 자동으로 구독합니다",
"section_access": "접근 권한",
"subscribers_empty": "구독자가 없습니다. 팀원을 추가하면 매번 알림을 받습니다",
"subscribers_add": "구독자 추가",
"subscribers_search_placeholder": "이름으로 멤버 검색…",

View File

@@ -70,7 +70,6 @@
"pause_aria": "暂停自动化",
"activate_aria": "启用自动化",
"edit": "编辑",
"manage_access": "管理访问",
"run_now": "立即运行",
"running": "运行中...",
"toast_triggered": "已触发自动化",
@@ -103,9 +102,7 @@
}
},
"access": {
"title": "管理访问",
"description": "你添加的成员可以编辑、运行并管理这个自动化的触发器和 webhook。",
"current_label": "已授权",
"add": "添加成员",
"search_placeholder": "搜索成员…",
"no_results": "未找到成员",
@@ -274,6 +271,7 @@
"section_output_mode": "输出模式",
"section_subscribers": "订阅者",
"subscribers_hint": "每次跑出来的 issue 默认订阅",
"section_access": "访问权限",
"subscribers_empty": "暂无订阅者——添加成员后,每次触发都会通知到他",
"subscribers_add": "添加订阅者",
"subscribers_search_placeholder": "按名称搜索成员……",