FEAT - TASKER CODEX: manage agent project grants

This commit is contained in:
DCCONSTRUCTIONS
2026-05-16 01:40:45 +03:00
parent 83ea515962
commit 3b96a2613b
29 changed files with 455 additions and 64 deletions
@@ -6,7 +6,7 @@
import { type ChangeEvent, useMemo, useRef, useState } from "react";
import { observer } from "mobx-react";
import { Bot, Check, Copy, KeyRound, Route, ShieldCheck } from "lucide-react";
import { Bot, Check, ChevronDown, Copy, FolderKanban, KeyRound, Route, ShieldCheck } from "lucide-react";
import useSWR from "swr";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
@@ -20,6 +20,7 @@ import { ProjectService } from "@/services/project/project.service";
import {
WorkspaceCodexAgentService,
type TCodexAgent,
type TCodexAgentGrant,
type TCodexAgentSetupPacket,
type TCodexAgentToken,
} from "@/services/workspace-codex-agent.service";
@@ -59,10 +60,17 @@ type TProps = {
type TAgentSetupCard = {
agent: TCodexAgent;
grants: TCodexAgentGrant[];
setup?: TCodexAgentSetupPacket;
tokens: TCodexAgentToken[];
};
type TProjectAccessOption = {
id: string;
identifier?: string | null;
name: string;
};
export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSettingsContent(props: TProps) {
const { showHeading = true, workspaceSlug } = props;
const createAvatarInputRef = useRef<HTMLInputElement | null>(null);
@@ -76,6 +84,9 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
const [revealedTokens, setRevealedTokens] = useState<Record<string, string>>({});
const [updatingAgentIds, setUpdatingAgentIds] = useState<Record<string, boolean>>({});
const [creatingTokenAgentIds, setCreatingTokenAgentIds] = useState<Record<string, boolean>>({});
const [openProjectAccessAgentId, setOpenProjectAccessAgentId] = useState<string | null>(null);
const [projectGrantDrafts, setProjectGrantDrafts] = useState<Record<string, string[]>>({});
const [savingProjectGrantAgentIds, setSavingProjectGrantAgentIds] = useState<Record<string, boolean>>({});
const { currentWorkspace } = useWorkspace();
const { data: nodedcWorkspacePolicy, isLoading } = useSWR(
workspaceSlug ? `NODEDC_WORKSPACE_POLICY_${workspaceSlug}` : null,
@@ -109,13 +120,15 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
async () =>
Promise.all(
activeAgents.map(async (agent) => {
const [tokensPayload, setupPayload] = await Promise.all([
const [tokensPayload, setupPayload, grantsPayload] = await Promise.all([
codexAgentService.listTokens(workspaceSlug, agent.id),
codexAgentService.getSetup(workspaceSlug, agent.id),
codexAgentService.listGrants(workspaceSlug, agent.id),
]);
return {
agent,
grants: grantsPayload.grants,
setup: setupPayload.setup,
tokens: tokensPayload.tokens.filter((token) => token.status === "active"),
};
@@ -200,7 +213,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
display_name: displayName,
avatar_url: newAgentAvatarUrl,
});
await codexAgentService.upsertGrant(workspaceSlug, createResponse.agent.id, {
const grantResponse = await codexAgentService.upsertGrant(workspaceSlug, createResponse.agent.id, {
project_id: effectiveSelectedProjectId,
scopes: TASK_AUTHOR_SCOPES,
mode: "voluntary",
@@ -213,7 +226,9 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
[tokenResponse.token_record.id]: tokenResponse.token,
}));
setCreatedSetupCards((currentCards) =>
upsertSetupCardToken(currentCards, createResponse.agent, tokenResponse.token_record, tokenResponse.setup)
upsertSetupCardToken(currentCards, createResponse.agent, tokenResponse.token_record, tokenResponse.setup, [
grantResponse.grant,
])
);
await mutateCodexAgents();
await mutateSetupCards();
@@ -243,8 +258,9 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
...currentTokens,
[tokenResponse.token_record.id]: tokenResponse.token,
}));
const currentGrants = setupCards.find((card) => card.agent.id === agent.id)?.grants ?? [];
setCreatedSetupCards((currentCards) =>
upsertSetupCardToken(currentCards, agent, tokenResponse.token_record, tokenResponse.setup)
upsertSetupCardToken(currentCards, agent, tokenResponse.token_record, tokenResponse.setup, currentGrants)
);
await mutateSetupCards();
setToast({
@@ -263,6 +279,70 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
}
};
const handleToggleProjectGrant = (agentId: string, currentProjectIds: string[], projectId: string) => {
setProjectGrantDrafts((currentDrafts) => {
const currentDraftProjectIds = currentDrafts[agentId] ?? currentProjectIds;
const nextProjectIds = currentDraftProjectIds.includes(projectId)
? currentDraftProjectIds.filter((currentProjectId) => currentProjectId !== projectId)
: [...currentDraftProjectIds, projectId];
return {
...currentDrafts,
[agentId]: nextProjectIds,
};
});
};
const handleSaveProjectAccess = async (agent: TCodexAgent, selectedProjectIds: string[]) => {
const projectIds = [...new Set(selectedProjectIds.filter(Boolean))];
if (projectIds.length === 0) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Выберите project",
message: "У агента должен быть доступ хотя бы к одному project в workspace.",
});
return;
}
setSavingProjectGrantAgentIds((current) => ({ ...current, [agent.id]: true }));
try {
const grantsResponse = await codexAgentService.replaceProjectGrants(workspaceSlug, agent.id, {
project_ids: projectIds,
scopes: TASK_AUTHOR_SCOPES,
mode: "voluntary",
});
setCreatedSetupCards((currentCards) =>
currentCards.map((card) =>
card.agent.id === agent.id
? {
...card,
grants: mergeAgentGrants(card.grants, grantsResponse.grants, workspaceSlug),
}
: card
)
);
setProjectGrantDrafts((currentDrafts) => {
const nextDrafts = { ...currentDrafts };
delete nextDrafts[agent.id];
return nextDrafts;
});
await mutateSetupCards();
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Доступы Codex обновлены",
message: "Agent token теперь работает только с выбранными projects.",
});
} catch (error: any) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Не удалось обновить доступы",
message: error?.message ?? error?.error ?? "Проверьте project membership и Gateway.",
});
} finally {
setSavingProjectGrantAgentIds((current) => ({ ...current, [agent.id]: false }));
}
};
const handleSaveAgentName = async (agent: TCodexAgent) => {
const displayName = getAgentDraftName(agentDraftNames, agent).trim();
if (!displayName) return;
@@ -431,6 +511,11 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
const isAgentDirty = draftName.trim() !== agent.display_name;
const setupCard = setupCards.find((card) => card.agent.id === agent.id);
const agentTokens = setupCard?.tokens ?? [];
const agentGrants = setupCard?.grants ?? [];
const currentProjectIds = getGrantedProjectIds(agentGrants, workspaceSlug);
const draftProjectIds = projectGrantDrafts[agent.id] ?? currentProjectIds;
const isProjectAccessOpen = openProjectAccessAgentId === agent.id;
const isSavingProjectAccess = savingProjectGrantAgentIds[agent.id] === true;
return (
<section key={agent.id} className="nodedc-settings-card flex flex-col gap-5 px-5 py-5">
@@ -513,9 +598,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
</div>
{areSetupCardsLoading && agentTokens.length === 0 ? (
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
Загрузка токена...
</div>
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">Загрузка токена...</div>
) : agentTokens.length > 0 ? (
<div className="grid gap-4">
{agentTokens.map((token) => {
@@ -550,6 +633,29 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
Токен ещё не выпущен. Нажмите «Новый токен», чтобы получить доступ для локального Codex.
</div>
)}
<AgentProjectAccessPanel
currentProjectIds={currentProjectIds}
draftProjectIds={draftProjectIds}
isOpen={isProjectAccessOpen}
isSaving={isSavingProjectAccess}
projects={projectOptions}
onSave={() => void handleSaveProjectAccess(agent, draftProjectIds)}
onToggleOpen={() => {
setOpenProjectAccessAgentId(isProjectAccessOpen ? null : agent.id);
setProjectGrantDrafts((currentDrafts) =>
currentDrafts[agent.id]
? currentDrafts
: {
...currentDrafts,
[agent.id]: currentProjectIds,
}
);
}}
onToggleProject={(projectId) =>
handleToggleProjectGrant(agent.id, currentProjectIds, projectId)
}
/>
</section>
);
})
@@ -600,11 +706,11 @@ function CodexConnectionGuide(props: TCodexConnectionGuideProps) {
<div className="nodedc-settings-input min-h-36 px-4 py-4 text-13 leading-5 text-secondary">
<div className="mb-2 font-semibold text-primary">1. Найдите config.toml</div>
<p>Windows: откройте файл через проводник или VS Code.</p>
<code className="mt-2 block break-all text-12 text-primary">
<code className="mt-2 block text-12 break-all text-primary">
C:\Users\имя-пользователя\.codex\config.toml
</code>
<p className="mt-3">macOS / Linux:</p>
<code className="mt-2 block break-all text-12 text-primary">~/.codex/config.toml</code>
<code className="mt-2 block text-12 break-all text-primary">~/.codex/config.toml</code>
<p className="mt-3">Если файла нет — создайте его.</p>
</div>
@@ -614,7 +720,7 @@ function CodexConnectionGuide(props: TCodexConnectionGuideProps) {
Создайте пользовательскую переменную окружения <code>{CODEX_TOKEN_ENV_VAR}</code>, заменив токен из примера
на уникальный токен конкретного агента.
</p>
<code className="mt-3 block break-all rounded-2xl bg-black/20 px-3 py-3 text-12 text-primary">
<code className="mt-3 block rounded-2xl bg-black/20 px-3 py-3 text-12 break-all text-primary">
{CODEX_TOKEN_ENV_VAR}=ndcag_...
</code>
</div>
@@ -776,6 +882,109 @@ function AgentAvatarButton(props: {
);
}
type TAgentProjectAccessPanelProps = {
currentProjectIds: string[];
draftProjectIds: string[];
isOpen: boolean;
isSaving: boolean;
onSave: () => void;
onToggleOpen: () => void;
onToggleProject: (projectId: string) => void;
projects: TProjectAccessOption[];
};
function AgentProjectAccessPanel(props: TAgentProjectAccessPanelProps) {
const isDirty = !areProjectSelectionsEqual(props.currentProjectIds, props.draftProjectIds);
const selectedCount = props.draftProjectIds.length;
const summary =
selectedCount === 0
? "Нет выбранных projects"
: selectedCount === 1
? "1 project выбран"
: `${selectedCount} projects выбрано`;
return (
<div className="nodedc-settings-field p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex min-w-0 items-center gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-white/8 text-tertiary">
<FolderKanban className="size-4" />
</span>
<div className="min-w-0">
<div className="text-13 font-semibold text-primary">Доступы к проектам</div>
<div className="mt-1 text-12 text-tertiary">
Выберите projects, куда этот agent token может читать и писать карточки.
</div>
</div>
</div>
<button
type="button"
className="nodedc-settings-chip inline-flex h-11 w-fit items-center gap-2 px-4 text-12 text-primary outline-none focus:outline-none focus-visible:ring-0 focus-visible:outline-none"
onClick={props.onToggleOpen}
>
<span>{summary}</span>
<ChevronDown className={`size-4 transition ${props.isOpen ? "rotate-180" : ""}`} />
</button>
</div>
{props.isOpen && (
<div className="nodedc-project-grants-surface mt-4 rounded-3xl p-3">
{props.projects.length > 0 ? (
<div className="grid max-h-80 gap-0 overflow-y-auto pr-1">
{props.projects.map((project) => {
const isChecked = props.draftProjectIds.includes(project.id);
return (
<button
key={project.id}
type="button"
className="nodedc-project-grants-row flex min-h-12 items-center justify-between gap-3 rounded-2xl px-3 py-2 text-left outline-none focus:outline-none focus-visible:ring-0 focus-visible:outline-none"
onClick={() => props.onToggleProject(project.id)}
>
<span className="min-w-0">
<span className="block truncate text-13 font-medium text-primary">{project.name}</span>
{project.identifier && (
<span className="mt-0.5 block truncate text-11 text-tertiary">{project.identifier}</span>
)}
</span>
<span
className={`nodedc-project-grants-check grid size-5 shrink-0 place-items-center rounded-full transition ${
isChecked
? "bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]"
: "bg-white/5 text-transparent"
}`}
>
<Check className="size-3.5" />
</span>
</button>
);
})}
</div>
) : (
<div className="px-3 py-4 text-13 text-secondary">В workspace нет доступных projects.</div>
)}
<div className="mt-3 flex flex-col gap-2 pt-1 sm:flex-row sm:items-center sm:justify-between">
<div className="text-12 text-tertiary">
Сохранение заменяет grants текущего workspace: снятые галочки сразу отзывают доступ.
</div>
<Button
variant="primary"
size="sm"
className="nodedc-settings-save-button"
disabled={!isDirty || selectedCount === 0}
loading={props.isSaving}
onClick={props.onSave}
>
Сохранить доступы
</Button>
</div>
</div>
)}
</div>
);
}
function mergeSetupCards(persistedCards: TAgentSetupCard[], createdCards: TAgentSetupCard[]): TAgentSetupCard[] {
const cardsByAgentId = new Map<string, TAgentSetupCard>();
@@ -792,6 +1001,7 @@ function mergeSetupCards(persistedCards: TAgentSetupCard[], createdCards: TAgent
cardsByAgentId.set(card.agent.id, {
agent: persistedCard.agent,
grants: mergeAgentGrants(persistedCard.grants, card.grants),
setup: persistedCard.setup ?? card.setup,
tokens: mergeTokens(persistedCard.tokens, card.tokens),
});
@@ -813,17 +1023,19 @@ function upsertSetupCardToken(
cards: TAgentSetupCard[],
agent: TCodexAgent,
token: TCodexAgentToken,
setup?: TCodexAgentSetupPacket
setup?: TCodexAgentSetupPacket,
grants: TCodexAgentGrant[] = []
): TAgentSetupCard[] {
const existingCard = cards.find((card) => card.agent.id === agent.id);
if (!existingCard) {
return [{ agent, setup, tokens: [token] }, ...cards];
return [{ agent, grants, setup, tokens: [token] }, ...cards];
}
return cards.map((card) =>
card.agent.id === agent.id
? {
agent,
grants: mergeAgentGrants(card.grants, grants),
setup: setup ?? card.setup,
tokens: mergeTokens([token], card.tokens),
}
@@ -831,6 +1043,51 @@ function upsertSetupCardToken(
);
}
function getGrantedProjectIds(grants: TCodexAgentGrant[], workspaceSlug: string): string[] {
return [
...new Set(
grants
.filter((grant) => grant.workspace_slug === workspaceSlug && grant.project_id)
.map((grant) => String(grant.project_id))
),
].sort();
}
function mergeAgentGrants(
currentGrants: TCodexAgentGrant[],
nextGrants: TCodexAgentGrant[],
workspaceSlug?: string
): TCodexAgentGrant[] {
const grantsByKey = new Map<string, TCodexAgentGrant>();
for (const grant of currentGrants) {
if (workspaceSlug && grant.workspace_slug === workspaceSlug) continue;
grantsByKey.set(buildGrantKey(grant), grant);
}
for (const grant of nextGrants) {
grantsByKey.set(buildGrantKey(grant), grant);
}
return Array.from(grantsByKey.values());
}
function buildGrantKey(grant: TCodexAgentGrant): string {
return `${grant.workspace_slug}:${grant.project_id ?? "*"}`;
}
function areProjectSelectionsEqual(leftProjectIds: string[], rightProjectIds: string[]): boolean {
const leftSet = new Set(leftProjectIds);
const rightSet = new Set(rightProjectIds);
if (leftSet.size !== rightSet.size) return false;
for (const projectId of leftSet) {
if (!rightSet.has(projectId)) return false;
}
return true;
}
function readAvatarDataUrl(file: File): Promise<string> {
if (!file.type.startsWith("image/")) {
return Promise.reject(new Error("Поддерживаются только изображения PNG, JPG, WEBP или GIF."));