Compare commits

...

1 Commits

Author SHA1 Message Date
Bohan Jiang
87f9d0fdd3 refactor(autopilots): open access management as a popover from the edit modal (MUL-3893) (#4765)
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: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 23:11:29 +08:00
8 changed files with 200 additions and 197 deletions

View File

@@ -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 (
<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="end"
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>
)}
<p className="text-xs 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

@@ -15,6 +15,7 @@ import {
Minimize2,
Play,
Rocket,
Users,
Webhook,
X as XIcon,
Zap,
@@ -27,6 +28,14 @@ import {
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverHeader,
PopoverTitle,
PopoverDescription,
} from "@multica/ui/components/ui/popover";
import { Button } from "@multica/ui/components/ui/button";
import {
Select,
@@ -51,6 +60,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 +70,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 +113,8 @@ export type AutopilotDialogProps =
autopilotId: string;
initial: AutopilotInitial;
triggers: AutopilotTrigger[];
collaborators: AutopilotCollaborator[];
canManageAccess: boolean;
};
// ---------------------------------------------------------------------------
@@ -555,6 +568,29 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
)}
</div>
<div className="flex items-center gap-1">
{!isCreate && props.canManageAccess && (
<>
<Popover>
<PopoverTrigger className="flex items-center gap-1.5 rounded-sm px-2 py-1 text-xs text-muted-foreground opacity-90 transition-all hover:bg-accent/60 hover:text-foreground hover:opacity-100 cursor-pointer">
<Users className="size-3.5" />
<span>{t(($) => $.access.title)}</span>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={6} keepMounted className="w-80">
<PopoverHeader>
<PopoverTitle>{t(($) => $.access.title)}</PopoverTitle>
<PopoverDescription className="text-xs">
{t(($) => $.access.description)}
</PopoverDescription>
</PopoverHeader>
<AutopilotAccessManager
autopilotId={props.autopilotId}
collaborators={props.collaborators}
/>
</PopoverContent>
</Popover>
<span className="mx-0.5 h-4 w-px bg-border" />
</>
)}
<Tooltip>
<TooltipTrigger
render={

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",

View File

@@ -70,7 +70,6 @@
"pause_aria": "オートパイロットを一時停止",
"activate_aria": "オートパイロットを有効化",
"edit": "編集",
"manage_access": "アクセス管理",
"run_now": "今すぐ実行",
"running": "実行中...",
"toast_triggered": "オートパイロットを実行しました",

View File

@@ -70,7 +70,6 @@
"pause_aria": "오토파일럿 일시 중지",
"activate_aria": "오토파일럿 활성화",
"edit": "수정",
"manage_access": "접근 권한 관리",
"run_now": "지금 실행",
"running": "실행 중...",
"toast_triggered": "오토파일럿을 실행했습니다",

View File

@@ -70,7 +70,6 @@
"pause_aria": "暂停自动化",
"activate_aria": "启用自动化",
"edit": "编辑",
"manage_access": "管理访问",
"run_now": "立即运行",
"running": "运行中...",
"toast_triggered": "已触发自动化",