FEAT - TASKER CODEX: manage agent project grants
|
|
@ -7,6 +7,7 @@ from django.urls import path
|
|||
from plane.app.views import (
|
||||
CodexAgentDetailEndpoint,
|
||||
CodexAgentGrantListEndpoint,
|
||||
CodexAgentGrantReplaceEndpoint,
|
||||
CodexAgentListEndpoint,
|
||||
CodexAgentRevokeEndpoint,
|
||||
CodexAgentSetupEndpoint,
|
||||
|
|
@ -36,6 +37,11 @@ urlpatterns = [
|
|||
CodexAgentGrantListEndpoint.as_view(),
|
||||
name="codex-agent-api-agent-grants",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/grants/replace/",
|
||||
CodexAgentGrantReplaceEndpoint.as_view(),
|
||||
name="codex-agent-api-agent-grants-replace",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/codex-agent-api/agents/<uuid:agent_id>/tokens/",
|
||||
CodexAgentTokenListEndpoint.as_view(),
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ from .api import ApiTokenEndpoint
|
|||
from .codex_agents import (
|
||||
CodexAgentDetailEndpoint,
|
||||
CodexAgentGrantListEndpoint,
|
||||
CodexAgentGrantReplaceEndpoint,
|
||||
CodexAgentListEndpoint,
|
||||
CodexAgentRevokeEndpoint,
|
||||
CodexAgentSetupEndpoint,
|
||||
|
|
|
|||
|
|
@ -139,6 +139,41 @@ def validate_project_in_workspace(workspace, project_id, user):
|
|||
return None
|
||||
|
||||
|
||||
def validate_projects_in_workspace(workspace, project_ids, user):
|
||||
if not isinstance(project_ids, list) or len(project_ids) == 0:
|
||||
return Response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "project_ids_required",
|
||||
"message": "Select at least one project for Codex Agent grants.",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
normalized_project_ids = []
|
||||
for project_id in project_ids:
|
||||
project_id = str(project_id or "").strip()
|
||||
if not project_id or project_id in normalized_project_ids:
|
||||
continue
|
||||
|
||||
project_error = validate_project_in_workspace(workspace, project_id, user)
|
||||
if project_error is not None:
|
||||
return project_error
|
||||
normalized_project_ids.append(project_id)
|
||||
|
||||
if not normalized_project_ids:
|
||||
return Response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "project_ids_required",
|
||||
"message": "Select at least one project for Codex Agent grants.",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return normalized_project_ids
|
||||
|
||||
|
||||
def gateway_request(method, path, payload=None):
|
||||
config, error_response = require_gateway_config()
|
||||
if error_response is not None:
|
||||
|
|
@ -284,6 +319,34 @@ class CodexAgentGrantListEndpoint(CodexAgentEntitledEndpoint):
|
|||
return gateway_request("POST", f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/grants", payload)
|
||||
|
||||
|
||||
class CodexAgentGrantReplaceEndpoint(CodexAgentEntitledEndpoint):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def post(self, request, slug, agent_id):
|
||||
entitlement_error = self.require_entitlement(request, slug)
|
||||
if entitlement_error is not None:
|
||||
return entitlement_error
|
||||
|
||||
workspace, workspace_error = require_workspace(slug)
|
||||
if workspace_error is not None:
|
||||
return workspace_error
|
||||
|
||||
project_ids_or_error = validate_projects_in_workspace(workspace, request.data.get("project_ids"), request.user)
|
||||
if isinstance(project_ids_or_error, Response):
|
||||
return project_ids_or_error
|
||||
|
||||
payload = {
|
||||
"workspace_slug": slug,
|
||||
"project_ids": project_ids_or_error,
|
||||
"scopes": request.data.get("scopes") or [],
|
||||
"mode": request.data.get("mode") or "voluntary",
|
||||
}
|
||||
return gateway_request(
|
||||
"POST",
|
||||
f"/api/internal/v1/owners/{owner_path(request.user)}/agents/{agent_path(agent_id)}/grants/replace",
|
||||
payload,
|
||||
)
|
||||
|
||||
|
||||
class CodexAgentTokenListEndpoint(CodexAgentEntitledEndpoint):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def get(self, request, slug, agent_id):
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 466 B After Width: | Height: | Size: 520 B |
|
Before Width: | Height: | Size: 761 B After Width: | Height: | Size: 987 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 13 KiB |
|
|
@ -14,13 +14,6 @@ import { SITE_DESCRIPTION, SITE_NAME } from "@plane/constants";
|
|||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
// assets
|
||||
import favicon16 from "@/app/assets/favicon/favicon-16x16.png?url";
|
||||
import favicon32 from "@/app/assets/favicon/favicon-32x32.png?url";
|
||||
import faviconIco from "@/app/assets/favicon/favicon.ico?url";
|
||||
import icon180 from "@/app/assets/icons/icon-180x180.png?url";
|
||||
import icon512 from "@/app/assets/icons/icon-512x512.png?url";
|
||||
|
||||
// local
|
||||
import { AppProvider } from "./provider";
|
||||
|
||||
|
|
@ -62,10 +55,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta name="theme-color" content="#fff" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href={favicon32} />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href={favicon16} />
|
||||
<link rel="icon" type="image/svg+xml" sizes="any" href="/favicon/icon-adaptive.svg?v=nodedc-adaptive-20260516" />
|
||||
<link rel="manifest" href="/site.webmanifest.json" />
|
||||
<link rel="shortcut icon" href={faviconIco} />
|
||||
{/* Meta info for PWA */}
|
||||
<meta name="application-name" content="NODE.DC" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
|
|
@ -73,9 +64,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||
<meta name="apple-mobile-web-app-title" content={SITE_NAME} />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="apple-touch-icon" href={icon512} />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href={icon180} />
|
||||
<link rel="apple-touch-icon" sizes="512x512" href={icon512} />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-icon" sizes="512x512" href="/icons/icon-512x512.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -14,11 +14,6 @@ import { SITE_DESCRIPTION, SITE_NAME } from "@plane/constants";
|
|||
import { cn } from "@plane/utils";
|
||||
// types
|
||||
// assets
|
||||
import favicon16 from "@/app/assets/favicon/favicon-16x16.png?url";
|
||||
import favicon32 from "@/app/assets/favicon/favicon-32x32.png?url";
|
||||
import faviconIco from "@/app/assets/favicon/favicon.ico?url";
|
||||
import icon180 from "@/app/assets/icons/icon-180x180.png?url";
|
||||
import icon512 from "@/app/assets/icons/icon-512x512.png?url";
|
||||
import ogImage from "@/app/assets/og-image.png?url";
|
||||
import globalStyles from "@/styles/globals.css?url";
|
||||
import type { Route } from "./+types/root";
|
||||
|
|
@ -95,13 +90,11 @@ const designConfigStyle = {
|
|||
} as CSSProperties;
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{ rel: "icon", type: "image/png", sizes: "32x32", href: favicon32 },
|
||||
{ rel: "icon", type: "image/png", sizes: "16x16", href: favicon16 },
|
||||
{ rel: "shortcut icon", href: faviconIco },
|
||||
{ rel: "icon", type: "image/svg+xml", sizes: "any", href: "/favicon/icon-adaptive.svg?v=nodedc-adaptive-20260516" },
|
||||
{ rel: "manifest", href: "/site.webmanifest.json" },
|
||||
{ rel: "apple-touch-icon", href: icon512 },
|
||||
{ rel: "apple-touch-icon", sizes: "180x180", href: icon180 },
|
||||
{ rel: "apple-touch-icon", sizes: "512x512", href: icon512 },
|
||||
{ rel: "apple-touch-icon", href: "/apple-touch-icon.png" },
|
||||
{ rel: "apple-touch-icon", sizes: "180x180", href: "/apple-touch-icon.png" },
|
||||
{ rel: "apple-touch-icon", sizes: "512x512", href: "/icons/icon-512x512.png" },
|
||||
{ rel: "manifest", href: "/manifest.json" },
|
||||
{ rel: "stylesheet", href: globalStyles },
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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."));
|
||||
|
|
|
|||
|
|
@ -72,6 +72,11 @@ export type TCodexAgentTokenListResponse = {
|
|||
tokens: TCodexAgentToken[];
|
||||
};
|
||||
|
||||
export type TCodexAgentGrantListResponse = {
|
||||
ok: boolean;
|
||||
grants: TCodexAgentGrant[];
|
||||
};
|
||||
|
||||
export type TCodexAgentSetupResponse = {
|
||||
ok: boolean;
|
||||
setup?: TCodexAgentSetupPacket;
|
||||
|
|
@ -129,6 +134,30 @@ export class WorkspaceCodexAgentService extends APIService {
|
|||
});
|
||||
}
|
||||
|
||||
async listGrants(workspaceSlug: string, agentId: string): Promise<TCodexAgentGrantListResponse> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/grants/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async replaceProjectGrants(
|
||||
workspaceSlug: string,
|
||||
agentId: string,
|
||||
data: {
|
||||
mode?: TCodexAgentGrantMode;
|
||||
project_ids: string[];
|
||||
scopes: string[];
|
||||
}
|
||||
): Promise<TCodexAgentGrantListResponse> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/codex-agent-api/agents/${agentId}/grants/replace/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async createToken(
|
||||
workspaceSlug: string,
|
||||
agentId: string,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"theme_color": "#3579f6",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone",
|
||||
"theme_color": "#eeeff4",
|
||||
"background_color": "#eeeff4",
|
||||
"display": "browser",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"name": "NODE.DC | Self-hosted task management workspace.",
|
||||
|
|
@ -9,22 +9,17 @@
|
|||
"description": "NODE.DC streamlines task management, projects, and internal workflows.",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192x192.png",
|
||||
"src": "/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-256x256.png",
|
||||
"sizes": "256x256",
|
||||
"src": "/icons/icon-348x348.png",
|
||||
"sizes": "348x348",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512x512.png",
|
||||
"src": "/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
|
@ -0,0 +1,13 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.icon-fav-01 { fill: #333334; } /* Тёмный цвет для светлого таб-бара */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.icon-fav-01 { fill: #FEFEFE; } /* Светлый цвет для тёмного таб-бара */
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
.icon-fav-01 { fill: #000000; } /* Тёмный цвет для светлого таб-бара (для явности) */
|
||||
}
|
||||
</style>
|
||||
<path class="icon-fav-01" d="M17.8738 15.9159L16.0002 18.9718L14.1267 15.9159H17.8738ZM23.6307 12.7864H8.36914L15.9999 25.2308L23.6307 12.7864Z"/>
|
||||
<path class="icon-fav-01" d="M10.9793 19.4872L6.67269 11.5918H25.5344L21.2281 19.4872H25.0581L31.2614 8H0.738281L7.16244 19.4872H10.9793Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 852 B |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
|
@ -0,0 +1,13 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.icon-fav-01 { fill: #333334; } /* Тёмный цвет для светлого таб-бара */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.icon-fav-01 { fill: #FEFEFE; } /* Светлый цвет для тёмного таб-бара */
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
.icon-fav-01 { fill: #000000; } /* Тёмный цвет для светлого таб-бара (для явности) */
|
||||
}
|
||||
</style>
|
||||
<path class="icon-fav-01" d="M17.8738 15.9159L16.0002 18.9718L14.1267 15.9159H17.8738ZM23.6307 12.7864H8.36914L15.9999 25.2308L23.6307 12.7864Z"/>
|
||||
<path class="icon-fav-01" d="M10.9793 19.4872L6.67269 11.5918H25.5344L21.2281 19.4872H25.0581L31.2614 8H0.738281L7.16244 19.4872H10.9793Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 852 B |
|
|
@ -1,11 +1,15 @@
|
|||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"name": "NODE.DC",
|
||||
"short_name": "NODE.DC",
|
||||
"theme_color": "#eeeff4",
|
||||
"background_color": "#eeeff4",
|
||||
"display": "browser",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{ "src": "/favicon/icon-adaptive.svg", "sizes": "any", "type": "image/svg+xml" },
|
||||
{ "src": "/favicon/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/favicon/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
{ "src": "/favicon/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/favicon/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 13 KiB |
|
|
@ -19,8 +19,8 @@
|
|||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#FFFFFF",
|
||||
"background_color": "#FFFFFF",
|
||||
"theme_color": "#eeeff4",
|
||||
"background_color": "#eeeff4",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait"
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@
|
|||
"name": "NODE.DC",
|
||||
"short_name": "NODE.DC",
|
||||
"description": "NODE.DC helps you manage work items, projects, and operational workflows.",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#f9fafb",
|
||||
"theme_color": "#3f76ff",
|
||||
"start_url": "/",
|
||||
"display": "browser",
|
||||
"background_color": "#eeeff4",
|
||||
"theme_color": "#eeeff4",
|
||||
"icons": [
|
||||
{ "src": "/plane-logos/plane-mobile-pwa.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/plane-logos/plane-mobile-pwa.png", "sizes": "512x512", "type": "image/png" }
|
||||
{ "src": "/favicon/icon-adaptive.svg", "sizes": "any", "type": "image/svg+xml" },
|
||||
{ "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/favicon/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2234,6 +2234,30 @@
|
|||
rgba(255, 255, 255, 0.042) !important;
|
||||
}
|
||||
|
||||
.nodedc-project-grants-surface {
|
||||
background: rgba(0, 0, 0, 0.1) !important;
|
||||
border: 0 !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.nodedc-project-grants-row,
|
||||
.nodedc-project-grants-row:hover,
|
||||
.nodedc-project-grants-row:focus,
|
||||
.nodedc-project-grants-row:focus-visible,
|
||||
.nodedc-project-grants-row:active {
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.nodedc-project-grants-check {
|
||||
border: 0 !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.nodedc-settings-primary-button {
|
||||
min-height: 2.75rem;
|
||||
border: 0 !important;
|
||||
|
|
|
|||