From f1c2f2240351abdca691d8ebd499c60ebe9ff150 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Wed, 9 Sep 2026 14:38:17 +0000 Subject: [PATCH 01/14] feat: add project settings permissions and agent/environment access grants - Introduced new permissions for project settings, allowing project admins to manage task types, statuses, and custom fields separately from task management. - Updated existing roles (PROJECT_OWNER, PROJECT_MANAGER, PROJECT_MEMBER, PROJECT_VIEWER, Admin, Editor, Viewer) to include new permissions for project settings and views. - Added access control for agents and environments, enabling project admins to restrict access to specific project members. - Created new tables for agent and environment access grants, ensuring that permissions can be managed at a granular level. - Updated integration tests to reflect changes in permissions for task types, task statuses, custom fields, and views. --- apps/mcp/src/__tests__/permissions.test.ts | 73 ++++- apps/mcp/src/permissions.ts | 154 +++++---- .../admin/global-roles/GlobalRolesStates.tsx | 18 -- .../admin/users/UsersStates.test.tsx | 16 +- .../components/admin/users/UsersStates.tsx | 18 -- .../components/projects/agents/agent-card.tsx | 19 +- .../projects/agents/agent-detail.tsx | 245 +++++++++++++- .../projects/agents/agent-picker.tsx | 34 +- .../projects/agents/conversation-view.tsx | 36 +++ .../projects/agents/conversations-layout.tsx | 39 ++- .../environments/comment-detail-view.tsx | 19 +- .../environments/environment-connect.tsx | 55 +++- .../environments/environment-detail.tsx | 298 +++++++++++++++++- .../environments/port-forward-detail.tsx | 25 +- .../roles/ProjectRoleFormDialog.test.tsx | 4 +- .../components/projects/roles/permissions.ts | 117 ++++++- .../settings/CustomFieldsSettings.tsx | 27 +- .../projects/settings/RolesSettings.tsx | 26 +- .../settings/TaskStatusesSettings.tsx | 26 +- .../projects/settings/TaskTypesSettings.tsx | 21 +- .../components/route-error-boundary.test.tsx | 112 +++++++ .../src/components/route-error-boundary.tsx | 75 +++-- .../shared/no-permission-state.test.tsx | 29 ++ .../components/shared/no-permission-state.tsx | 35 ++ apps/web/src/i18n/locales/en/admin.json | 4 + apps/web/src/i18n/locales/en/common.json | 6 + apps/web/src/i18n/locales/en/projects.json | 142 ++++++++- apps/web/src/i18n/locales/es/admin.json | 4 + apps/web/src/i18n/locales/es/common.json | 6 + apps/web/src/i18n/locales/es/projects.json | 150 ++++++++- apps/web/src/i18n/locales/fr/admin.json | 4 + apps/web/src/i18n/locales/fr/common.json | 6 + apps/web/src/i18n/locales/fr/projects.json | 150 ++++++++- apps/web/src/i18n/locales/ja/admin.json | 4 + apps/web/src/i18n/locales/ja/common.json | 6 + apps/web/src/i18n/locales/ja/projects.json | 150 ++++++++- apps/web/src/i18n/locales/ko/admin.json | 4 + apps/web/src/i18n/locales/ko/common.json | 6 + apps/web/src/i18n/locales/ko/projects.json | 150 ++++++++- apps/web/src/i18n/locales/pt-BR/admin.json | 4 + apps/web/src/i18n/locales/pt-BR/common.json | 6 + apps/web/src/i18n/locales/pt-BR/projects.json | 150 ++++++++- apps/web/src/i18n/locales/ru/admin.json | 4 + apps/web/src/i18n/locales/ru/common.json | 6 + apps/web/src/i18n/locales/ru/projects.json | 150 ++++++++- apps/web/src/i18n/locales/vi/admin.json | 4 + apps/web/src/i18n/locales/vi/common.json | 6 + apps/web/src/i18n/locales/vi/projects.json | 150 ++++++++- apps/web/src/i18n/locales/zh-CN/admin.json | 4 + apps/web/src/i18n/locales/zh-CN/common.json | 6 + apps/web/src/i18n/locales/zh-CN/projects.json | 150 ++++++++- .../react-query/query-client.test.ts | 35 ++ .../integrations/react-query/query-client.ts | 36 ++- apps/web/src/lib/agent-api.ts | 66 ++++ apps/web/src/lib/api-error.test.ts | 47 +++ apps/web/src/lib/api-error.ts | 21 ++ apps/web/src/lib/environment-api.ts | 78 ++++- apps/web/src/routes/_authenticated.tsx | 18 +- .../_authenticated/admin/agents/index.tsx | 29 +- .../admin/global-roles/index.tsx | 11 +- .../_authenticated/admin/users/index.tsx | 11 +- .../conversations/$conversationId.tsx | 19 +- .../projects/$projectId/agents/index.tsx | 43 ++- .../$projectId/automation/$automationId.tsx | 22 +- .../projects/$projectId/automation/index.tsx | 50 ++- .../projects/$projectId/conversations.tsx | 18 +- .../conversations/$conversationId.tsx | 22 +- .../projects/$projectId/docs/$docId.tsx | 18 +- .../environments/$environmentId/connect.tsx | 19 +- .../environments/$environmentId/index.tsx | 20 +- .../port-forwards/$portForwardId/index.tsx | 20 +- .../$projectId/environments/index.tsx | 52 ++- .../projects/$projectId/settings/index.tsx | 31 +- .../projects/$projectId/tasks/$taskId.tsx | 21 +- .../projects/$projectId/team/index.tsx | 43 ++- services/api/internal/apierr/codes.go | 21 ++ services/api/internal/bootstrap/app.go | 6 +- services/api/internal/domain/agent/entity.go | 39 ++- services/api/internal/domain/agent/errors.go | 16 + .../api/internal/domain/agent/repository.go | 17 + services/api/internal/domain/agent/service.go | 30 ++ .../api/internal/domain/environment/entity.go | 40 ++- .../api/internal/domain/environment/errors.go | 16 + .../internal/domain/environment/repository.go | 16 + .../internal/domain/environment/service.go | 32 ++ .../platform/authz/authorizer_test.go | 62 ++++ .../api/internal/platform/authz/defaults.go | 32 +- .../internal/platform/authz/permissions.go | 39 +++ .../postgres/agent_permission_store.go | 10 +- .../postgres/agent_permission_store_test.go | 99 ++++++ .../repository/postgres/agent_repository.go | 119 ++++++- .../postgres/authz_permission_store.go | 17 +- .../postgres/authz_permission_store_test.go | 116 ++++++- .../postgres/environment_repository.go | 110 ++++++- .../postgres/global_role_repository.go | 10 + .../postgres/global_role_repository_test.go | 25 ++ .../internal/service/agent/agent_service.go | 175 +++++++++- .../service/agent/agent_service_test.go | 187 ++++++++++- .../api/internal/service/auth/auth_service.go | 19 +- .../service/auth/auth_service_test.go | 94 ++++++ .../environment/environment_service.go | 77 +++++ .../environment/environment_service_test.go | 41 +++ .../service/project/project_service.go | 102 +++--- .../internal/transport/http/dto/agent_dto.go | 55 +++- .../transport/http/dto/environment_dto.go | 64 +++- .../transport/http/handler/agent_handler.go | 152 ++++++++- .../http/handler/agent_handler_test.go | 13 + .../http/handler/environment_handler.go | 151 ++++++++- .../transport/http/middleware/authz.go | 115 +++++++ .../transport/http/presenter/response.go | 26 +- .../transport/http/presenter/response_test.go | 45 +++ .../internal/transport/http/router/router.go | 176 ++++++++--- ...00054_add_project_settings_permissions.sql | 61 ++++ ...55_add_agent_environment_access_grants.sql | 102 ++++++ services/api/test/integration/cache_test.go | 12 +- services/api/test/integration/plugin_test.go | 2 +- services/api/test/integration/task_test.go | 36 +-- services/api/test/integration/view_test.go | 48 +-- 118 files changed, 5706 insertions(+), 622 deletions(-) create mode 100644 apps/web/src/components/route-error-boundary.test.tsx create mode 100644 apps/web/src/components/shared/no-permission-state.test.tsx create mode 100644 apps/web/src/components/shared/no-permission-state.tsx create mode 100644 apps/web/src/integrations/react-query/query-client.test.ts create mode 100644 services/api/internal/repository/postgres/agent_permission_store_test.go create mode 100644 services/api/migrations/000054_add_project_settings_permissions.sql create mode 100644 services/api/migrations/000055_add_agent_environment_access_grants.sql diff --git a/apps/mcp/src/__tests__/permissions.test.ts b/apps/mcp/src/__tests__/permissions.test.ts index 0d6c0c13c..eaa486bf3 100644 --- a/apps/mcp/src/__tests__/permissions.test.ts +++ b/apps/mcp/src/__tests__/permissions.test.ts @@ -62,6 +62,33 @@ describe("hasPermission", () => { }; expect(hasPermission(map, "tasks.read", "proj-1")).toBe(false); }); + + // Regression coverage: some permission keys now nest three or more + // segments deep (project.settings.task_types.read), with the granted + // wildcard sitting below the top level (project.settings.*, not a + // bare project.*) — mirrors the Go backend's authorizer, which checks + // every granted ".*" key rather than deriving a single + // candidate from the required key's first segment. + it("grants via a nested domain wildcard (project.settings.*)", () => { + const map: PermissionMap = { + global: { "project.settings.*": true }, + projects: {}, + }; + expect(hasPermission(map, "project.settings.task_types.read")).toBe(true); + expect(hasPermission(map, "project.settings.custom_fields.write")).toBe( + true, + ); + }); + + it("does not grant a nested permission via a same-prefix but unrelated wildcard", () => { + const map: PermissionMap = { + global: { "project.roles.*": true }, + projects: {}, + }; + expect(hasPermission(map, "project.settings.task_types.read")).toBe( + false, + ); + }); }); describe("project-scoped permissions", () => { @@ -104,6 +131,16 @@ describe("hasPermission", () => { }; expect(hasPermission(map, "tasks.read")).toBe(false); }); + + it("grants via a nested project domain wildcard (views.* covering views.write)", () => { + const map: PermissionMap = { + global: {}, + projects: { "proj-1": { "project.settings.*": true } }, + }; + expect( + hasPermission(map, "project.settings.task_statuses.write", "proj-1"), + ).toBe(true); + }); }); describe("precedence", () => { @@ -161,7 +198,41 @@ describe("getToolPermission", () => { it("returns the correct permission for list_views", () => { const perm = getToolPermission("list_views"); - expect(perm?.permissionKey).toBe("tasks.read"); + expect(perm?.permissionKey).toBe("views.read"); + expect(perm?.requiresProject).toBe(true); + }); + + // Regression coverage: task-type/task-status/custom-field tools were + // split off tasks.read/tasks.write onto their own project.settings.* + // keys when the backend stopped requiring tasks.write to edit project + // schema (see router.go's task-types/task-statuses/custom-fields route + // comments) — these tools previously stayed mapped to tasks.*, which + // would show them as available to a member who can edit tasks but was + // never granted schema access, only for the backend to 403 the call. + it("returns the correct permission for create_task_type", () => { + const perm = getToolPermission("create_task_type"); + expect(perm?.permissionKey).toBe("project.settings.task_types.write"); + expect(perm?.requiresProject).toBe(true); + }); + + it("returns the correct permission for list_task_statuses", () => { + const perm = getToolPermission("list_task_statuses"); + expect(perm?.permissionKey).toBe("project.settings.task_statuses.read"); + expect(perm?.requiresProject).toBe(true); + }); + + it("returns the correct permission for update_custom_field", () => { + const perm = getToolPermission("update_custom_field"); + expect(perm?.permissionKey).toBe("project.settings.custom_fields.write"); + expect(perm?.requiresProject).toBe(true); + }); + + // list_task_positions/bulk_move_tasks/move_task stay on tasks.* even + // after the views.* split above — moving a task between statuses within + // a view is still editing a task, not the view or the status list. + it("returns the correct permission for bulk_move_tasks", () => { + const perm = getToolPermission("bulk_move_tasks"); + expect(perm?.permissionKey).toBe("tasks.write"); expect(perm?.requiresProject).toBe(true); }); diff --git a/apps/mcp/src/permissions.ts b/apps/mcp/src/permissions.ts index fead77ed1..c57b300ac 100644 --- a/apps/mcp/src/permissions.ts +++ b/apps/mcp/src/permissions.ts @@ -152,85 +152,99 @@ export const TOOL_PERMISSIONS: ToolPermission[] = [ requiresProject: true, }, - // Task type tools + // Task type tools — project *schema* (which task types exist), gated on + // project.settings.task_types.* rather than tasks.* now that the backend + // splits "edit a task's content" from "redefine the type list" (see + // router.go's task-types route comment / authz. + // PermissionProjectSettingsTaskTypesRead's doc comment). A member with + // only tasks.write (no project.settings.task_types.write) can still + // edit tasks via update_task, but no longer sees these. { toolName: "list_task_types", - permissionKey: "tasks.read", + permissionKey: "project.settings.task_types.read", requiresProject: true, }, { toolName: "create_task_type", - permissionKey: "tasks.write", + permissionKey: "project.settings.task_types.write", requiresProject: true, }, { toolName: "update_task_type", - permissionKey: "tasks.write", + permissionKey: "project.settings.task_types.write", requiresProject: true, }, { toolName: "delete_task_type", - permissionKey: "tasks.write", + permissionKey: "project.settings.task_types.write", requiresProject: true, }, { toolName: "set_default_task_type", - permissionKey: "tasks.write", + permissionKey: "project.settings.task_types.write", requiresProject: true, }, - // Task status tools + // Task status tools — project *schema* (which statuses exist, their + // order, which is the default), same split as task types above. Moving + // a task *between* existing statuses (update_task, move_task/ + // bulk_move_tasks below) stays on tasks.write — that's editing a task, + // not the status list. { toolName: "list_task_statuses", - permissionKey: "tasks.read", + permissionKey: "project.settings.task_statuses.read", requiresProject: true, }, { toolName: "create_task_status", - permissionKey: "tasks.write", + permissionKey: "project.settings.task_statuses.write", requiresProject: true, }, { toolName: "update_task_status", - permissionKey: "tasks.write", + permissionKey: "project.settings.task_statuses.write", requiresProject: true, }, { toolName: "delete_task_status", - permissionKey: "tasks.write", + permissionKey: "project.settings.task_statuses.write", requiresProject: true, }, { toolName: "set_default_task_status", - permissionKey: "tasks.write", + permissionKey: "project.settings.task_statuses.write", requiresProject: true, }, - // View tools + // View tools — gated on their own views.read/write, not a borrowed + // tasks.read/write (there was no dedicated permission for the view + // resource itself before — see router.go's views route comment). + // Moving a *task* within a view (list_task_positions/bulk_move_tasks/ + // move_task below) stays on tasks.* — that's still editing a task. { toolName: "list_views", - permissionKey: "tasks.read", + permissionKey: "views.read", requiresProject: true, }, { toolName: "create_view", - permissionKey: "tasks.write", + permissionKey: "views.write", requiresProject: true, }, { toolName: "reorder_views", - permissionKey: "tasks.write", + permissionKey: "views.write", requiresProject: true, }, - { toolName: "get_view", permissionKey: "tasks.read", requiresProject: true }, + { toolName: "get_view", permissionKey: "views.read", requiresProject: true }, { toolName: "update_view", - permissionKey: "tasks.write", + permissionKey: "views.write", requiresProject: true, }, { toolName: "delete_view", - permissionKey: "tasks.write", + permissionKey: "views.write", requiresProject: true, }, { @@ -249,30 +263,31 @@ export const TOOL_PERMISSIONS: ToolPermission[] = [ requiresProject: true, }, - // Custom field tools + // Custom field tools — project schema, same split as task types/statuses + // above. { toolName: "list_custom_fields", - permissionKey: "tasks.read", + permissionKey: "project.settings.custom_fields.read", requiresProject: true, }, { toolName: "create_custom_field", - permissionKey: "tasks.write", + permissionKey: "project.settings.custom_fields.write", requiresProject: true, }, { toolName: "get_custom_field", - permissionKey: "tasks.read", + permissionKey: "project.settings.custom_fields.read", requiresProject: true, }, { toolName: "update_custom_field", - permissionKey: "tasks.write", + permissionKey: "project.settings.custom_fields.write", requiresProject: true, }, { toolName: "delete_custom_field", - permissionKey: "tasks.write", + permissionKey: "project.settings.custom_fields.write", requiresProject: true, }, @@ -616,61 +631,74 @@ export async function fetchAgentPermissions( return { global, projects }; } -export function hasPermission( - permissionMap: PermissionMap, +/** + * Checks one flat permission map (either the global map or a single + * project's) for an exact match or a covering wildcard, mirroring the Go + * backend's own matcher (internal/platform/authz/authorizer.go's + * hasPermission): every granted key ending in ".*" is tried as a prefix + * against permissionKey, not just one wildcard derived from permissionKey's + * first segment. That distinction matters now that some domains nest a + * wildcard below the top level — project.settings.* (granted to e.g. + * PROJECT_OWNER/PROJECT_MANAGER by default) must cover + * project.settings.task_types.read, but there is no such permission as a + * bare "project.*"; a single derived `${parts[0]}.*` candidate would only + * ever check that non-existent key and never match. Scanning every granted + * wildcard also means this needs no updating if a future permission adds + * another nesting level. + */ +function matchesPermissionMap( + map: Record, permissionKey: string, - projectId?: string, + scopeLabel: string, ): boolean { - if (!permissionKey) return true; - - const { global, projects } = permissionMap; - - if (global["*"] === true) { - console.error(`[permissions] Granting ${permissionKey} via global *`); + if (map["*"] === true) { + console.error( + `[permissions] Granting ${permissionKey} via ${scopeLabel} *`, + ); return true; } - - if (global[permissionKey] === true) { + if (map[permissionKey] === true) { console.error( - `[permissions] Granting ${permissionKey} via global exact match`, + `[permissions] Granting ${permissionKey} via ${scopeLabel} exact match`, ); return true; } - - const parts = permissionKey.split("."); - if (parts.length >= 2) { - const wildcardKey = `${parts[0]}.*`; - if (global[wildcardKey] === true) { + for (const [key, granted] of Object.entries(map)) { + if (!granted || !key.endsWith(".*")) continue; + const prefix = key.slice(0, -1); // strip the trailing "*", keep the dot + if (permissionKey.startsWith(prefix)) { console.error( - `[permissions] Granting ${permissionKey} via global ${wildcardKey}`, + `[permissions] Granting ${permissionKey} via ${scopeLabel} ${key}`, ); return true; } } + return false; +} + +export function hasPermission( + permissionMap: PermissionMap, + permissionKey: string, + projectId?: string, +): boolean { + if (!permissionKey) return true; + + const { global, projects } = permissionMap; + + if (matchesPermissionMap(global, permissionKey, "global")) { + return true; + } if (projectId && projects[projectId]) { - if (projects[projectId]["*"] === true) { - console.error( - `[permissions] Granting ${permissionKey} via project ${projectId} *`, - ); - return true; - } - if (projects[projectId][permissionKey] === true) { - console.error( - `[permissions] Granting ${permissionKey} via project ${projectId} exact match`, - ); + if ( + matchesPermissionMap( + projects[projectId], + permissionKey, + `project ${projectId}`, + ) + ) { return true; } - const parts = permissionKey.split("."); - if (parts.length >= 2) { - const wildcardKey = `${parts[0]}.*`; - if (projects[projectId][wildcardKey] === true) { - console.error( - `[permissions] Granting ${permissionKey} via project ${projectId} ${wildcardKey}`, - ); - return true; - } - } console.error( `[permissions] Denying ${permissionKey} for project ${projectId} - no matching permission`, ); diff --git a/apps/web/src/components/admin/global-roles/GlobalRolesStates.tsx b/apps/web/src/components/admin/global-roles/GlobalRolesStates.tsx index b3642ca83..e9257f262 100644 --- a/apps/web/src/components/admin/global-roles/GlobalRolesStates.tsx +++ b/apps/web/src/components/admin/global-roles/GlobalRolesStates.tsx @@ -49,21 +49,3 @@ export function GlobalRolesErrorState() { ); } - -export function GlobalRolesNoPermissionState() { - const { t } = useTranslation("admin"); - - return ( -
- -
-

- {t("globalRoles.noPermission.title")} -

-

- {t("globalRoles.noPermission.description")} -

-
-
- ); -} diff --git a/apps/web/src/components/admin/users/UsersStates.test.tsx b/apps/web/src/components/admin/users/UsersStates.test.tsx index 288a2451e..25d74b581 100644 --- a/apps/web/src/components/admin/users/UsersStates.test.tsx +++ b/apps/web/src/components/admin/users/UsersStates.test.tsx @@ -2,11 +2,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import { - EmptyUsersState, - UsersErrorState, - UsersNoPermissionState, -} from "./UsersStates"; +import { EmptyUsersState, UsersErrorState } from "./UsersStates"; describe("EmptyUsersState", () => { it("shows empty message", () => { @@ -52,13 +48,3 @@ describe("UsersErrorState", () => { expect(screen.getByText(/please refresh/i)).toBeInTheDocument(); }); }); - -describe("UsersNoPermissionState", () => { - it("renders no-permission message", () => { - render(); - - expect( - screen.getByText(/you don't have permission to view users/i), - ).toBeInTheDocument(); - }); -}); diff --git a/apps/web/src/components/admin/users/UsersStates.tsx b/apps/web/src/components/admin/users/UsersStates.tsx index 7e0f0fb42..650d4007a 100644 --- a/apps/web/src/components/admin/users/UsersStates.tsx +++ b/apps/web/src/components/admin/users/UsersStates.tsx @@ -49,21 +49,3 @@ export function UsersErrorState() { ); } - -export function UsersNoPermissionState() { - const { t } = useTranslation("admin"); - - return ( -
- -
-

- {t("users.noPermission.title")} -

-

- {t("users.noPermission.description")} -

-
-
- ); -} diff --git a/apps/web/src/components/projects/agents/agent-card.tsx b/apps/web/src/components/projects/agents/agent-card.tsx index c786ee0f4..beffcf9ff 100644 --- a/apps/web/src/components/projects/agents/agent-card.tsx +++ b/apps/web/src/components/projects/agents/agent-card.tsx @@ -1,6 +1,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useNavigate } from "@tanstack/react-router"; -import { Loader2, MoreHorizontal, Settings, Trash2, Zap } from "lucide-react"; +import { + Loader2, + Lock, + MoreHorizontal, + Settings, + Trash2, + Zap, +} from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -131,6 +138,16 @@ export function AgentCard({
+ {agent.access_mode === "restricted" && !agent.access_granted && ( + + + {t("agents.card.restricted")} + + )} {isAcp ? (agent.acp_provider ?? "acp") diff --git a/apps/web/src/components/projects/agents/agent-detail.tsx b/apps/web/src/components/projects/agents/agent-detail.tsx index 15395a5c2..bf8112304 100644 --- a/apps/web/src/components/projects/agents/agent-detail.tsx +++ b/apps/web/src/components/projects/agents/agent-detail.tsx @@ -9,6 +9,7 @@ import { ExternalLink, KeyRound, Loader2, + Lock, Plus, Save, Server, @@ -22,6 +23,8 @@ import { DefaultFolderSelect, } from "@/components/projects/environments/environment-folder-select"; import { AvatarUpload } from "@/components/shared/avatar-upload"; +import { EntityAvatarContent } from "@/components/shared/entity-avatar"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button, buttonVariants } from "@/components/ui/button"; import { @@ -51,14 +54,17 @@ import { useProjectPermissions } from "@/hooks/use-project-permissions"; import { type ACPProvider, type Agent, + type AgentAccessMode, type AgentMCPServer, type AgentSkill, + addAgentAccessGrant, addEnvVar, addGlobalEnvVar, addGlobalMCPServer, addGlobalSkill, addMCPServer, addSkill, + agentAccessGrantsQueryOptions, agentEnvVarsQueryOptions, agentMCPServersQueryOptions, agentQueryOptions, @@ -75,6 +81,7 @@ import { globalAgentQueryOptions, globalAgentSkillsQueryOptions, llmModelsQueryOptions, + removeAgentAccessGrant, updateAgent, updateGlobalAgent, updateGlobalMCPServer, @@ -84,8 +91,13 @@ import { verifyCLILogin, } from "@/lib/agent-api"; import { environmentsQueryOptions } from "@/lib/environment-api"; -import { resolveAgentAvatarUrl } from "@/lib/provider-logos"; +import { projectMembersQueryOptions } from "@/lib/project-api"; +import { + resolveAgentAvatarUrl, + resolveMemberAvatarUrl, +} from "@/lib/provider-logos"; import { splitShellCommand } from "@/lib/shell-command"; +import { getInitials } from "@/lib/utils"; import { AcpBridgeSetup } from "./acp-bridge-setup"; import { AgentActivityTab } from "./agent-activity-tab"; @@ -101,7 +113,13 @@ import { AgentActivityTab } from "./agent-activity-tab"; // may be invited into many projects or none — see AgentDetailView's // visibleTabs filtering below. -type Tab = "overview" | "mcp-servers" | "skills" | "env-vars" | "activity"; +type Tab = + | "overview" + | "mcp-servers" + | "skills" + | "env-vars" + | "access" + | "activity"; const CUSTOM = "__custom__"; @@ -1230,6 +1248,212 @@ function MCPServersTab({ ); } +// ── Access Tab ──────────────────────────────────────────────────────────────── +// Project-scoped only (a global agent viewed with no projectId has no single +// project's members to grant against — see AgentDetailView's visibleTabs +// filtering, same reasoning as the Activity tab). Restricting/granting here +// only ever governs *usage* (starting/replying to a chat) — MCP servers, +// skills, and env vars above stay governed purely by agents.write regardless +// of access_mode, same as the backend. + +function AccessTab({ + projectId, + agentId, + accessMode, + canWrite, +}: { + projectId: string; + agentId: string; + accessMode: AgentAccessMode; + canWrite: boolean; +}) { + const { t } = useTranslation("projects"); + const qc = useQueryClient(); + const [selectedMemberId, setSelectedMemberId] = useState(""); + + const agentKey = agentQueryOptions(projectId, agentId).queryKey; + const grantsQuery = agentAccessGrantsQueryOptions(projectId, agentId); + const { data: grants = [] } = useQuery(grantsQuery); + const { data: members = [] } = useQuery( + projectMembersQueryOptions(projectId), + ); + + const toggleModeMutation = useMutation({ + mutationFn: (restricted: boolean) => + updateAgent(projectId, agentId, { + access_mode: restricted ? "restricted" : "open", + }), + onSuccess: () => qc.invalidateQueries({ queryKey: agentKey }), + }); + + const addMutation = useMutation({ + mutationFn: (memberId: string) => + addAgentAccessGrant(projectId, agentId, memberId), + onSuccess: () => { + setSelectedMemberId(""); + qc.invalidateQueries({ queryKey: grantsQuery.queryKey }); + qc.invalidateQueries({ queryKey: agentKey }); + }, + }); + + const removeMutation = useMutation({ + mutationFn: (memberId: string) => + removeAgentAccessGrant(projectId, agentId, memberId), + onSuccess: () => { + qc.invalidateQueries({ queryKey: grantsQuery.queryKey }); + qc.invalidateQueries({ queryKey: agentKey }); + }, + }); + + const memberName = (m: { + member_type?: string; + agent_name?: string; + full_name: string; + username: string; + }) => + m.member_type === "agent" + ? (m.agent_name ?? m.username) + : m.full_name || m.username; + + const grantedMemberIds = new Set(grants.map((g) => g.member_id)); + const availableMembers = members.filter((m) => !grantedMemberIds.has(m.id)); + const memberById = new Map(members.map((m) => [m.id, m])); + + return ( +
+
+
+

+ {t("agents.detail.access.restrictLabel")} +

+

+ {t("agents.detail.access.restrictDescription")} +

+
+ + canWrite && toggleModeMutation.mutate(checked) + } + disabled={!canWrite || toggleModeMutation.isPending} + /> +
+ + {accessMode === "restricted" && ( +
+ {canWrite && ( +
+ + +
+ )} + + {grants.length === 0 ? ( +
+ +

+ {t("agents.detail.access.empty")} +

+
+ ) : ( +
+ {grants.map((g) => { + const member = memberById.get(g.member_id); + const display = member ? memberName(member) : g.member_id; + const isBot = member?.member_type === "agent"; + const avatarUrl = member + ? resolveMemberAvatarUrl(member) + : undefined; + return ( +
+ + {avatarUrl ? : null} + + {isBot ? ( + + ) : ( + getInitials(display) + )} + + +
+

{display}

+ {member && ( +

+ @{member.username} +

+ )} +
+ {canWrite && ( + + )} +
+ ); + })} +
+ )} +
+ )} +
+ ); +} + // ── Skills Tab ──────────────────────────────────────────────────────────────── function AddSkillDialog({ @@ -1718,6 +1942,11 @@ const TABS = [ labelKey: "agents.detail.tabs.envVars", icon: KeyRound, }, + { + id: "access", + labelKey: "agents.detail.tabs.access", + icon: Lock, + }, { id: "activity", labelKey: "agents.detail.tabs.activity", @@ -1808,7 +2037,9 @@ export function AgentDetailView({ agent?.agent_type === "provider_cli" && agent.cli_provider !== "claude-code"; const visibleTabs = TABS.filter((tab) => { - if (tab.id === "activity" && !projectId) return false; + if ((tab.id === "activity" || tab.id === "access") && !projectId) { + return false; + } if (agent?.agent_type === "acp" && acpHiddenTabs.includes(tab.id)) { return false; } @@ -1982,6 +2213,14 @@ export function AgentDetailView({ canWrite={canWrite} /> )} + {activeTab === "access" && projectId && ( + + )} {activeTab === "activity" && projectId && ( )} diff --git a/apps/web/src/components/projects/agents/agent-picker.tsx b/apps/web/src/components/projects/agents/agent-picker.tsx index 213546c63..60c4c31ee 100644 --- a/apps/web/src/components/projects/agents/agent-picker.tsx +++ b/apps/web/src/components/projects/agents/agent-picker.tsx @@ -69,10 +69,22 @@ export function useAgentPicker( projectId: string, options?: { disabled?: boolean; enabled?: boolean }, ) { - const { data: agents = [], isLoading: agentsLoading } = useQuery({ + const { data: allAgents = [], isLoading: agentsLoading } = useQuery({ ...agentsQueryOptions(projectId), enabled: options?.enabled ?? true, }); + // A restricted agent the caller has no grant for is visible on the + // Agents page (so people know it exists and who to ask) but not + // selectable here — starting a chat with it would just fail with + // AGENT_ACCESS_RESTRICTED, so it's left off the composer's own list + // instead of offering a choice guaranteed to error. + const agents = useMemo( + () => + allAgents.filter( + (a) => a.access_mode !== "restricted" || a.access_granted, + ), + [allAgents], + ); const [agentId, setAgentId] = useState(""); // Nothing to actually pick between — auto-select the project's only agent @@ -253,10 +265,22 @@ export function useEnvironmentPicker( options?: { disabled?: boolean; enabled?: boolean }, ) { const queryEnabled = (options?.enabled ?? true) && !!projectId; - const { data: environments = [], isLoading: environmentsLoading } = useQuery({ - ...environmentsQueryOptions(projectId), - enabled: queryEnabled, - }); + const { data: allEnvironments = [], isLoading: environmentsLoading } = + useQuery({ + ...environmentsQueryOptions(projectId), + enabled: queryEnabled, + }); + // Same reasoning as useAgentPicker's own filter: a restricted + // environment the caller has no grant for would just fail to attach + // with ENVIRONMENT_ACCESS_RESTRICTED, so it's left off this list + // rather than offered as a choice guaranteed to error. + const environments = useMemo( + () => + allEnvironments.filter( + (e) => e.access_mode !== "restricted" || e.access_granted, + ), + [allEnvironments], + ); // Fetched only to read default_environment_id — this agent is typically // already warm in the agent picker's own cache once chosen, so this is // usually an instant cache hit rather than a new request. diff --git a/apps/web/src/components/projects/agents/conversation-view.tsx b/apps/web/src/components/projects/agents/conversation-view.tsx index f459f94db..ef8a3aec7 100644 --- a/apps/web/src/components/projects/agents/conversation-view.tsx +++ b/apps/web/src/components/projects/agents/conversation-view.tsx @@ -17,6 +17,7 @@ import { import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Thread } from "@/components/assistant-ui/thread"; +import { NoPermissionState } from "@/components/shared/no-permission-state"; import { Badge } from "@/components/ui/badge"; import { Button, buttonVariants } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; @@ -43,6 +44,7 @@ import { stopConversation, stopGlobalConversation, } from "@/lib/agent-api"; +import { isForbiddenError } from "@/lib/api-error"; import { useContextInjectionStore } from "@/lib/context-injection-store"; import { cn } from "@/lib/utils"; import { useAgentBusyPrompt } from "./agent-busy-dialog"; @@ -175,11 +177,19 @@ export function ConversationView({ data: conversation, isLoading: convLoading, isError, + error: conversationError, } = useQuery( projectId ? conversationQueryOptions(projectId, conversationId) : globalConversationQueryOptions(conversationId), ); + // A conversation that's owner-private to a different member, or whose + // agent is now access-restricted, 403s the same way a genuinely invalid + // conversationId 404s (both leave `data` undefined, or stale data plus + // isError true on a later refetch) — checked so a member who's simply + // not allowed to see it gets told why, instead of a "not found"/"failed" + // message implying the conversation itself is broken or gone. + const noPermission = isError && isForbiddenError(conversationError); const { events, isLoading: eventsLoading, @@ -428,6 +438,16 @@ export function ConversationView({ } if (!conversation) { + if (noPermission) { + return ( +
+ +
+ ); + } return (
@@ -436,6 +456,22 @@ export function ConversationView({ ); } + // A previously-loaded conversation whose access was revoked mid-session + // (or whose agent just became restricted) keeps its last-known data + // while a background refetch 403s — checked ahead of the generic failure + // fallback below so that case reads as a permission message, not as the + // agent run itself having failed. + if (noPermission) { + return ( +
+ +
+ ); + } + // Show the error fallback only when the conversation failed AND produced // no visible messages. When messages exist, render the Thread normally so // the user can trace what happened before the failure — the header's diff --git a/apps/web/src/components/projects/agents/conversations-layout.tsx b/apps/web/src/components/projects/agents/conversations-layout.tsx index bac99368b..4f7825aaf 100644 --- a/apps/web/src/components/projects/agents/conversations-layout.tsx +++ b/apps/web/src/components/projects/agents/conversations-layout.tsx @@ -4,6 +4,7 @@ import type { TFunction } from "i18next"; import { Clock, Coins, MessageSquare, Plus } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { NoPermissionState } from "@/components/shared/no-permission-state"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -22,6 +23,7 @@ import { conversationsQueryOptions, globalConversationsQueryOptions, } from "@/lib/agent-api"; +import { isForbiddenError } from "@/lib/api-error"; import { formatCompactTokens, formatUsageCost } from "@/lib/format-usage"; import { resolveAgentAvatarUrl } from "@/lib/provider-logos"; import { cn } from "@/lib/utils"; @@ -162,21 +164,32 @@ export function ConversationsLayout({ projectId }: { projectId?: string }) { // Global chat (no projectId) is deliberately open to any authenticated // user (see router.go's global chat-session routes), so only gate - // starting a new one when this is a project-scoped conversations list — - // a PROJECT_VIEWER (conversations.read only) may browse this list but - // must not be able to create a conversation. + // starting a new one — and reading the list itself — when this is a + // project-scoped conversations list. A PROJECT_VIEWER (conversations.read + // only) may browse this list but must not be able to create a + // conversation. const { hasProjectPermission } = useProjectPermissions(projectId ?? ""); const canStartConversation = !projectId || hasProjectPermission("conversations.write"); + const canRead = !projectId || hasProjectPermission("conversations.read"); const [filters, setFilters] = useState({}); - const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = - useInfiniteQuery( - projectId - ? conversationsQueryOptions(projectId, filters) - : globalConversationsQueryOptions(filters), - ); + const { + data, + isLoading, + isError, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ + ...(projectId + ? conversationsQueryOptions(projectId, filters) + : globalConversationsQueryOptions(filters)), + enabled: canRead, + }); + const noPermission = !canRead || (isError && isForbiddenError(error)); const { data: agents = [] } = useQuery( projectId ? agentsQueryOptions(projectId) : chattableAgentsQueryOptions, ); @@ -242,7 +255,13 @@ export function ConversationsLayout({ projectId }: { projectId?: string }) { ref={scrollContainerRef} className="flex-1 overflow-y-auto p-2 space-y-1.5" > - {isLoading ? ( + {noPermission ? ( + + ) : isLoading ? ( Array.from({ length: 4 }).map((_, i) => ( // biome-ignore lint/suspicious/noArrayIndexKey: skeleton diff --git a/apps/web/src/components/projects/environments/comment-detail-view.tsx b/apps/web/src/components/projects/environments/comment-detail-view.tsx index 3c73816c2..e2507fe86 100644 --- a/apps/web/src/components/projects/environments/comment-detail-view.tsx +++ b/apps/web/src/components/projects/environments/comment-detail-view.tsx @@ -14,6 +14,7 @@ import { } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { NoPermissionState } from "@/components/shared/no-permission-state"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button, buttonVariants } from "@/components/ui/button"; @@ -35,6 +36,7 @@ import { reopenAnnotation, resolveAnnotation, } from "@/lib/annotation-api"; +import { isForbiddenError } from "@/lib/api-error"; import { environmentConfigQueryOptions, portForwardQueryOptions, @@ -72,7 +74,12 @@ export function CommentDetailView({ portForwardId, annotationId, ).queryKey; - const { data: annotation, isLoading } = useQuery( + const { + data: annotation, + isLoading, + isError, + error: annotationError, + } = useQuery( annotationQueryOptions( projectId, environmentId, @@ -184,6 +191,16 @@ export function CommentDetailView({ } if (!annotation) { + if (isError && isForbiddenError(annotationError)) { + return ( +
+ +
+ ); + } return (
diff --git a/apps/web/src/components/projects/environments/environment-connect.tsx b/apps/web/src/components/projects/environments/environment-connect.tsx index 608c4fe82..d9745e3e2 100644 --- a/apps/web/src/components/projects/environments/environment-connect.tsx +++ b/apps/web/src/components/projects/environments/environment-connect.tsx @@ -224,16 +224,24 @@ function SSHKeysManager({ projectId, environmentId, canWrite, + hasAccess, }: { projectId: string; environmentId: string; canWrite: boolean; + hasAccess: boolean; }) { const { t } = useTranslation("projects"); const qc = useQueryClient(); - const { data: keys = [] } = useQuery( - environmentSSHKeysQueryOptions(projectId, environmentId), - ); + const { data: keys = [] } = useQuery({ + ...environmentSSHKeysQueryOptions(projectId, environmentId), + // Listing (and a fortiori adding/removing) SSH keys is gated on + // RequireEnvironmentAccess when the environment is restricted — this + // component isn't even rendered when !hasAccess (see SSHConnectTab + // below), but `enabled` is a second, defensive guard against ever + // firing the request regardless of how this component is reached. + enabled: hasAccess, + }); const [addOpen, setAddOpen] = useState(false); const keysKey = environmentSSHKeysQueryOptions( projectId, @@ -332,11 +340,13 @@ function WebAppConnectTab({ environment, canWrite, canConnect, + hasAccess, }: { projectId: string; environment: Environment; canWrite: boolean; canConnect: boolean; + hasAccess: boolean; }) { const { t } = useTranslation("projects"); const qc = useQueryClient(); @@ -358,7 +368,7 @@ function WebAppConnectTab({ {t("environments.connect.webApp.description")}

{isRunning ? ( - canConnect ? ( + canConnect && hasAccess ? ( {t("environments.connect.webApp.connect")} + ) : !hasAccess ? ( + // Restricted (environment.access_mode === "restricted", no + // EnvironmentAccessGrant for this caller) — a distinct + // reason from the plain-permission case below, since + // granting environments.connect alone wouldn't fix this; + // the project admin needs to add this member to the + // environment's access list instead (see + // environment-detail.tsx's Access tab). +

+ {t("environments.connect.webApp.restricted")} +

) : ( // The terminal ticket endpoint requires // environments:connect (see router.go) — a member without @@ -412,10 +433,12 @@ function SSHConnectTab({ projectId, environment, canWrite, + hasAccess, }: { projectId: string; environment: Environment; canWrite: boolean; + hasAccess: boolean; }) { const { t } = useTranslation("projects"); const { data: config } = useQuery(environmentConfigQueryOptions()); @@ -429,6 +452,18 @@ function SSHConnectTab({ // disambiguated only by -p. const host = config?.ssh_bastion_host || null; + // Restricted: neither registering a key nor the ssh command itself is + // actionable (they couldn't authenticate even seeing it), so show one + // message instead of the empty shell of both steps — same "one clear + // state" approach WebAppConnectTab takes for the same condition. + if (!hasAccess) { + return ( +

+ {t("environments.connect.ssh.restricted")} +

+ ); + } + return (
@@ -444,6 +479,7 @@ function SSHConnectTab({ projectId={projectId} environmentId={environment.id} canWrite={canWrite} + hasAccess={hasAccess} />
@@ -534,6 +570,15 @@ export function EnvironmentConnectView({ ); } + // Whether this caller may actually use environment right now — always + // true when it's open; only true for a restricted one if they hold an + // EnvironmentAccessGrant. Distinct from canConnect (the plain + // environments.connect permission): both must hold for the terminal + // link, and either one failing needs its own message since only a + // project admin granting access fixes the latter. + const hasAccess = + environment.access_mode !== "restricted" || environment.access_granted; + return (
@@ -596,6 +641,7 @@ export function EnvironmentConnectView({ environment={environment} canWrite={canWrite} canConnect={canConnect} + hasAccess={hasAccess} /> )} {activeTab === "ssh" && ( @@ -603,6 +649,7 @@ export function EnvironmentConnectView({ projectId={projectId} environment={environment} canWrite={canWrite} + hasAccess={hasAccess} /> )}
diff --git a/apps/web/src/components/projects/environments/environment-detail.tsx b/apps/web/src/components/projects/environments/environment-detail.tsx index 07769cb7a..b74759d81 100644 --- a/apps/web/src/components/projects/environments/environment-detail.tsx +++ b/apps/web/src/components/projects/environments/environment-detail.tsx @@ -2,11 +2,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useNavigate } from "@tanstack/react-router"; import { AlertTriangle, + Bot, Check, Copy, ExternalLink, Folder as FolderIcon, Loader2, + Lock, MoreHorizontal, Network, Play, @@ -28,6 +30,9 @@ import { useEnvironmentUsage, } from "@/components/projects/environments/environment-status-ring"; import { FolderCreateDialog } from "@/components/projects/environments/folder-create-dialog"; +import { EntityAvatarContent } from "@/components/shared/entity-avatar"; +import { NoPermissionState } from "@/components/shared/no-permission-state"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button, buttonVariants } from "@/components/ui/button"; import { Dialog, @@ -45,27 +50,42 @@ import { } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; import { useProjectPermissions } from "@/hooks/use-project-permissions"; import { + addEnvironmentAccessGrant, addPortForward, deleteEnvironment, deleteFolder, deletePortForward, type Environment, + type EnvironmentAccessMode, type EnvironmentStatus, + environmentAccessGrantsQueryOptions, environmentConfigQueryOptions, environmentFoldersQueryOptions, environmentPortForwardsQueryOptions, environmentQueryOptions, portForwardUrl, + removeEnvironmentAccessGrant, restartEnvironment, startEnvironment, stopEnvironment, updateEnvironment, } from "@/lib/environment-api"; +import { projectMembersQueryOptions } from "@/lib/project-api"; +import { resolveMemberAvatarUrl } from "@/lib/provider-logos"; import { timeAgo } from "@/lib/time-ago"; +import { getInitials } from "@/lib/utils"; // Shared by the environment detail route // (routes/.../projects/$projectId/environments/$environmentId/index.tsx). @@ -81,7 +101,7 @@ import { timeAgo } from "@/lib/time-ago"; // its own tab — it's config about *this* environment's own row set // (mirrors Folders), not a "how do I reach it" walkthrough like Connect. -type Tab = "overview" | "folders" | "portForwards"; +type Tab = "overview" | "folders" | "portForwards" | "access"; const TRANSITIONAL_STATUSES: EnvironmentStatus[] = [ "creating", @@ -325,17 +345,26 @@ function FoldersTab({ environmentId, environmentStatus, canWrite, + hasAccess, }: { projectId: string; environmentId: string; environmentStatus: EnvironmentStatus; canWrite: boolean; + hasAccess: boolean; }) { const { t } = useTranslation("projects"); const qc = useQueryClient(); - const { data: folders = [] } = useQuery( - environmentFoldersQueryOptions(projectId, environmentId), - ); + const { data: folders = [] } = useQuery({ + ...environmentFoldersQueryOptions(projectId, environmentId), + // Gated on RequireEnvironmentAccess when the environment is + // restricted — the route loader already skips prefetching this for + // a non-granted member (see the index route's own loader), and this + // is the matching client-side guard for whenever this tab renders + // without having gone through that loader (e.g. switching tabs + // client-side after the page already loaded). + enabled: hasAccess, + }); const [addOpen, setAddOpen] = useState(false); const foldersKey = environmentFoldersQueryOptions( projectId, @@ -353,6 +382,15 @@ function FoldersTab({ }, }); + if (!hasAccess) { + return ( + + ); + } + return (
@@ -650,21 +688,232 @@ function RestartEnvironmentDialog({ ); } +// ── Access Tab ──────────────────────────────────────────────────────────────── +// Restricting/granting here only ever governs *usage* (browsing, SSH keys, +// port forwards, the terminal) — the environment's own lifecycle +// (start/stop/restart/delete, header actions above) stays governed purely +// by environments.write regardless of access_mode, same as the backend. + +function AccessTab({ + projectId, + environment, + canWrite, +}: { + projectId: string; + environment: Environment; + canWrite: boolean; +}) { + const { t } = useTranslation("projects"); + const qc = useQueryClient(); + const [selectedMemberId, setSelectedMemberId] = useState(""); + + const envKey = environmentQueryOptions(projectId, environment.id).queryKey; + const grantsQuery = environmentAccessGrantsQueryOptions( + projectId, + environment.id, + ); + const { data: grants = [] } = useQuery(grantsQuery); + const { data: members = [] } = useQuery( + projectMembersQueryOptions(projectId), + ); + + const toggleModeMutation = useMutation({ + mutationFn: (restricted: boolean) => + updateEnvironment(projectId, environment.id, { + access_mode: restricted ? "restricted" : "open", + }), + onSuccess: () => qc.invalidateQueries({ queryKey: envKey }), + }); + + const addMutation = useMutation({ + mutationFn: (memberId: string) => + addEnvironmentAccessGrant(projectId, environment.id, memberId), + onSuccess: () => { + setSelectedMemberId(""); + qc.invalidateQueries({ queryKey: grantsQuery.queryKey }); + qc.invalidateQueries({ queryKey: envKey }); + }, + }); + + const removeMutation = useMutation({ + mutationFn: (memberId: string) => + removeEnvironmentAccessGrant(projectId, environment.id, memberId), + onSuccess: () => { + qc.invalidateQueries({ queryKey: grantsQuery.queryKey }); + qc.invalidateQueries({ queryKey: envKey }); + }, + }); + + const memberName = (m: { + member_type?: string; + agent_name?: string; + full_name: string; + username: string; + }) => + m.member_type === "agent" + ? (m.agent_name ?? m.username) + : m.full_name || m.username; + + const grantedMemberIds = new Set(grants.map((g) => g.member_id)); + const availableMembers = members.filter((m) => !grantedMemberIds.has(m.id)); + const memberById = new Map(members.map((m) => [m.id, m])); + const accessMode: EnvironmentAccessMode = environment.access_mode; + + return ( +
+
+
+

+ {t("environments.detail.access.restrictLabel")} +

+

+ {t("environments.detail.access.restrictDescription")} +

+
+ + canWrite && toggleModeMutation.mutate(checked) + } + disabled={!canWrite || toggleModeMutation.isPending} + /> +
+ + {accessMode === "restricted" && ( +
+ {canWrite && ( +
+ + +
+ )} + + {grants.length === 0 ? ( +
+ +

+ {t("environments.detail.access.empty")} +

+
+ ) : ( +
+ {grants.map((g) => { + const member = memberById.get(g.member_id); + const display = member ? memberName(member) : g.member_id; + const isBot = member?.member_type === "agent"; + const avatarUrl = member + ? resolveMemberAvatarUrl(member) + : undefined; + return ( +
+ + {avatarUrl ? : null} + + {isBot ? ( + + ) : ( + getInitials(display) + )} + + +
+

{display}

+ {member && ( +

+ @{member.username} +

+ )} +
+ {canWrite && ( + + )} +
+ ); + })} +
+ )} +
+ )} +
+ ); +} + function PortForwardsTab({ projectId, environment, canWrite, + hasAccess, }: { projectId: string; environment: Environment; canWrite: boolean; + hasAccess: boolean; }) { const { t } = useTranslation("projects"); const qc = useQueryClient(); const { data: config } = useQuery(environmentConfigQueryOptions()); - const { data: forwards = [] } = useQuery( - environmentPortForwardsQueryOptions(projectId, environment.id), - ); + const { data: forwards = [] } = useQuery({ + ...environmentPortForwardsQueryOptions(projectId, environment.id), + // Same RequireEnvironmentAccess gate as FoldersTab's own query — + // see that one's doc comment. + enabled: hasAccess, + }); const [addOpen, setAddOpen] = useState(false); const [restartOpen, setRestartOpen] = useState(false); const host = config?.port_forward_host || null; @@ -686,6 +935,17 @@ function PortForwardsTab({ }, }); + if (!hasAccess) { + return ( + + ); + } + return (

@@ -861,6 +1121,11 @@ const TABS = [ labelKey: "environments.detail.tabs.portForwards", icon: Network, }, + { + id: "access", + labelKey: "environments.detail.tabs.access", + icon: Lock, + }, ] as const satisfies { id: Tab; labelKey: string; @@ -967,6 +1232,16 @@ export function EnvironmentDetailView({ (environment.status === "stopped" || environment.status === "suspended" || environment.status === "error"); + // Whether this caller may actually use environment right now (browse + // folders, manage port forwards, connect) — always true when it's open; + // only true for a restricted one if they hold an EnvironmentAccessGrant. + // Independent of canWrite: someone who can reconfigure a restricted + // environment isn't automatically allowed to use it (see + // environmentdom.Environment.AccessMode's doc comment) — the Access tab + // below is what still always works regardless, since granting access is + // itself a configuration action. + const hasAccess = + environment.access_mode !== "restricted" || environment.access_granted; return (

@@ -1096,6 +1371,7 @@ export function EnvironmentDetailView({ environmentId={environmentId} environmentStatus={environment.status} canWrite={canWrite} + hasAccess={hasAccess} /> )} {activeTab === "portForwards" && ( @@ -1103,6 +1379,14 @@ export function EnvironmentDetailView({ projectId={projectId} environment={environment} canWrite={canWrite} + hasAccess={hasAccess} + /> + )} + {activeTab === "access" && ( + )}
diff --git a/apps/web/src/components/projects/environments/port-forward-detail.tsx b/apps/web/src/components/projects/environments/port-forward-detail.tsx index 0e8ad63f6..5e3fc5bf2 100644 --- a/apps/web/src/components/projects/environments/port-forward-detail.tsx +++ b/apps/web/src/components/projects/environments/port-forward-detail.tsx @@ -12,6 +12,7 @@ import { import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { PortForwardCommentsTab } from "@/components/projects/environments/port-forward-comments-tab"; +import { NoPermissionState } from "@/components/shared/no-permission-state"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -24,6 +25,7 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { useProjectPermissions } from "@/hooks/use-project-permissions"; import { portForwardAnnotationsQueryOptions } from "@/lib/annotation-api"; +import { isForbiddenError } from "@/lib/api-error"; import { deletePortForward, environmentConfigQueryOptions, @@ -77,9 +79,15 @@ export function PortForwardDetailView({ environmentQueryOptions(projectId, environmentId), ); const { data: config } = useQuery(environmentConfigQueryOptions()); - const { data: portForward, isLoading } = useQuery( + const { + data: portForward, + isLoading, + isError, + error, + } = useQuery( portForwardQueryOptions(projectId, environmentId, portForwardId), ); + const noPermission = isError && isForbiddenError(error); // Only needed to show "this also deletes N comments" in the delete // dialog below — not rendered directly here (PortForwardCommentsTab // fetches its own copy via the same query key once the Comments tab is @@ -141,6 +149,21 @@ export function PortForwardDetailView({ } if (!portForward) { + // A restricted environment's port forward 403s the same way a + // genuinely deleted one 404s (both leave `data` undefined) — checked + // first so a member who's simply not been granted access sees why, + // instead of a misleading "not found" for a port forward that does + // exist. + if (noPermission) { + return ( +
+ +
+ ); + } return (
diff --git a/apps/web/src/components/projects/roles/ProjectRoleFormDialog.test.tsx b/apps/web/src/components/projects/roles/ProjectRoleFormDialog.test.tsx index ca048dcb3..491884467 100644 --- a/apps/web/src/components/projects/roles/ProjectRoleFormDialog.test.tsx +++ b/apps/web/src/components/projects/roles/ProjectRoleFormDialog.test.tsx @@ -345,10 +345,12 @@ describe("ProjectRoleFormDialog", () => { // Each group label should be visible expect(screen.getByText("Project")).toBeInTheDocument(); expect(screen.getByText("Members")).toBeInTheDocument(); - expect(screen.getByText("Roles")).toBeInTheDocument(); + expect(screen.getByText("Settings")).toBeInTheDocument(); expect(screen.getByText("Tasks")).toBeInTheDocument(); expect(screen.getByText("Sprints")).toBeInTheDocument(); + expect(screen.getByText("Views")).toBeInTheDocument(); expect(screen.getByText("Documents")).toBeInTheDocument(); + expect(screen.getByText("Annotations")).toBeInTheDocument(); }); it("pre-selects permissions from the existing role and sends them in the update payload", async () => { diff --git a/apps/web/src/components/projects/roles/permissions.ts b/apps/web/src/components/projects/roles/permissions.ts index ab789f179..54316f560 100644 --- a/apps/web/src/components/projects/roles/permissions.ts +++ b/apps/web/src/components/projects/roles/permissions.ts @@ -2,13 +2,15 @@ import { BookOpen, Bot, Layers, + LayoutGrid, ListTodo, type LucideIcon, MessageSquare, + Pin, Puzzle, Server, Settings, - Shield, + SlidersHorizontal, Users, Workflow, } from "lucide-react"; @@ -19,13 +21,12 @@ export { toPluginKnownPermissions } from "@/lib/plugin-api"; export type KnownPermission = PluginKnownPermission; export const PROJECT_KNOWN_PERMISSIONS = [ - // projects - { - key: "projects.read", - labelKey: "roles.permissions.projectsRead.label", - descriptionKey: "roles.permissions.projectsRead.description", - domain: "projects", - }, + // projects — projects.read is deliberately not offered here: the backend + // grants it to any active project member unconditionally now (see + // AuthzPermissionStore.ListProjectPermissions's doc comment), so a + // per-role toggle for it would do nothing. Global projects.read (an + // admin browsing projects they aren't a member of) is unaffected and + // still has its own toggle in the global role editor. { key: "projects.write", labelKey: "roles.permissions.projectsWrite.label", @@ -51,18 +52,62 @@ export const PROJECT_KNOWN_PERMISSIONS = [ descriptionKey: "roles.permissions.membersWrite.description", domain: "project.members", }, - // project roles + // project roles — grouped under the "project.settings" UI domain + // alongside task types/statuses/custom fields below (a unified + // "Settings" section), even though the permission key itself is + // unchanged (project.roles.*, not renamed) to avoid migrating every + // existing role's stored permission JSONB. { key: "project.roles.read", labelKey: "roles.permissions.rolesRead.label", descriptionKey: "roles.permissions.rolesRead.description", - domain: "project.roles", + domain: "project.settings", }, { key: "project.roles.write", labelKey: "roles.permissions.rolesWrite.label", descriptionKey: "roles.permissions.rolesWrite.description", - domain: "project.roles", + domain: "project.settings", + }, + // project settings — task types, task statuses, and custom field + // definitions. Split out from tasks.write: redefining the project's + // task *schema* is a different capability from editing a task's own + // content, so each area is independently grantable. + { + key: "project.settings.task_types.read", + labelKey: "roles.permissions.settingsTaskTypesRead.label", + descriptionKey: "roles.permissions.settingsTaskTypesRead.description", + domain: "project.settings", + }, + { + key: "project.settings.task_types.write", + labelKey: "roles.permissions.settingsTaskTypesWrite.label", + descriptionKey: "roles.permissions.settingsTaskTypesWrite.description", + domain: "project.settings", + }, + { + key: "project.settings.task_statuses.read", + labelKey: "roles.permissions.settingsTaskStatusesRead.label", + descriptionKey: "roles.permissions.settingsTaskStatusesRead.description", + domain: "project.settings", + }, + { + key: "project.settings.task_statuses.write", + labelKey: "roles.permissions.settingsTaskStatusesWrite.label", + descriptionKey: "roles.permissions.settingsTaskStatusesWrite.description", + domain: "project.settings", + }, + { + key: "project.settings.custom_fields.read", + labelKey: "roles.permissions.settingsCustomFieldsRead.label", + descriptionKey: "roles.permissions.settingsCustomFieldsRead.description", + domain: "project.settings", + }, + { + key: "project.settings.custom_fields.write", + labelKey: "roles.permissions.settingsCustomFieldsWrite.label", + descriptionKey: "roles.permissions.settingsCustomFieldsWrite.description", + domain: "project.settings", }, // tasks { @@ -90,6 +135,19 @@ export const PROJECT_KNOWN_PERMISSIONS = [ descriptionKey: "roles.permissions.sprintsWrite.description", domain: "sprints", }, + // views + { + key: "views.read", + labelKey: "roles.permissions.viewsRead.label", + descriptionKey: "roles.permissions.viewsRead.description", + domain: "views", + }, + { + key: "views.write", + labelKey: "roles.permissions.viewsWrite.label", + descriptionKey: "roles.permissions.viewsWrite.description", + domain: "views", + }, // docs { key: "docs.read", @@ -148,6 +206,27 @@ export const PROJECT_KNOWN_PERMISSIONS = [ descriptionKey: "roles.permissions.environmentsConnect.description", domain: "environments", }, + // annotations — page comments pinned via the Paca browser extension. + // Already enforced server-side; added here so a custom role can + // actually configure it instead of only ever getting it from defaults. + { + key: "annotations.read", + labelKey: "roles.permissions.annotationsRead.label", + descriptionKey: "roles.permissions.annotationsRead.description", + domain: "annotations", + }, + { + key: "annotations.write", + labelKey: "roles.permissions.annotationsWrite.label", + descriptionKey: "roles.permissions.annotationsWrite.description", + domain: "annotations", + }, + { + key: "annotations.resolve", + labelKey: "roles.permissions.annotationsResolve.label", + descriptionKey: "roles.permissions.annotationsResolve.description", + domain: "annotations", + }, // automation workflows { key: "workflows.read", @@ -181,9 +260,9 @@ export const PROJECT_PERMISSION_GROUPS = [ Icon: Users, }, { - domain: "project.roles", - labelKey: "roles.permissionGroups.roles", - Icon: Shield, + domain: "project.settings", + labelKey: "roles.permissionGroups.settings", + Icon: SlidersHorizontal, }, { domain: "tasks", labelKey: "roles.permissionGroups.tasks", Icon: ListTodo }, { @@ -191,6 +270,11 @@ export const PROJECT_PERMISSION_GROUPS = [ labelKey: "roles.permissionGroups.sprints", Icon: Layers, }, + { + domain: "views", + labelKey: "roles.permissionGroups.views", + Icon: LayoutGrid, + }, { domain: "docs", labelKey: "roles.permissionGroups.documents", @@ -207,6 +291,11 @@ export const PROJECT_PERMISSION_GROUPS = [ labelKey: "roles.permissionGroups.environments", Icon: Server, }, + { + domain: "annotations", + labelKey: "roles.permissionGroups.annotations", + Icon: Pin, + }, { domain: "workflows", labelKey: "roles.permissionGroups.workflows", diff --git a/apps/web/src/components/projects/settings/CustomFieldsSettings.tsx b/apps/web/src/components/projects/settings/CustomFieldsSettings.tsx index b0b1fa78c..e46c96d34 100644 --- a/apps/web/src/components/projects/settings/CustomFieldsSettings.tsx +++ b/apps/web/src/components/projects/settings/CustomFieldsSettings.tsx @@ -3,6 +3,7 @@ import type { TFunction } from "i18next"; import { Check, Edit2, Loader2, Plus, Trash2, X } from "lucide-react"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { NoPermissionState } from "@/components/shared/no-permission-state"; import { Button } from "@/components/ui/button"; import { ColorSwatchPicker } from "@/components/ui/color-swatch-picker"; import { @@ -25,7 +26,12 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { ApiErrorCode, getApiErrorCode } from "@/lib/api-error"; +import { useProjectPermissions } from "@/hooks/use-project-permissions"; +import { + ApiErrorCode, + getApiErrorCode, + isForbiddenError, +} from "@/lib/api-error"; import { type CustomFieldDefinition, type CustomFieldOption, @@ -764,9 +770,15 @@ export function CustomFieldsSettings({ canWrite: boolean; }) { const { t } = useTranslation("projects"); - const { data: fields = [], isLoading } = useQuery( - customFieldsQueryOptions(projectId), - ); + const { hasProjectPermission } = useProjectPermissions(projectId); + const canRead = hasProjectPermission("project.settings.custom_fields.read"); + const { + data: fields = [], + isLoading, + isError, + error, + } = useQuery({ ...customFieldsQueryOptions(projectId), enabled: canRead }); + const noPermission = !canRead || (isError && isForbiddenError(error)); const [createOpen, setCreateOpen] = useState(false); const [editField, setEditField] = useState( null, @@ -799,7 +811,12 @@ export function CustomFieldsSettings({ )}
- {isLoading ? ( + {noPermission ? ( + + ) : isLoading ? (
{["cf1", "cf2", "cf3"].map((k) => (
(null); @@ -239,7 +251,13 @@ export function RolesSettings({ ) : null} {/* Table */} - {isLoading ? ( + {noPermission ? ( + + ) : isLoading ? ( ) : !roles?.length ? (
diff --git a/apps/web/src/components/projects/settings/TaskStatusesSettings.tsx b/apps/web/src/components/projects/settings/TaskStatusesSettings.tsx index 8e0455ce0..423e8bec9 100644 --- a/apps/web/src/components/projects/settings/TaskStatusesSettings.tsx +++ b/apps/web/src/components/projects/settings/TaskStatusesSettings.tsx @@ -11,6 +11,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { DeleteTaskStatusDialog } from "@/components/projects/task-statuses/DeleteTaskStatusDialog"; import { TaskStatusFormDialog } from "@/components/projects/task-statuses/TaskStatusFormDialog"; +import { NoPermissionState } from "@/components/shared/no-permission-state"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { @@ -21,6 +22,8 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { useProjectPermissions } from "@/hooks/use-project-permissions"; +import { isForbiddenError } from "@/lib/api-error"; import { reorderTaskStatuses, STATUS_CATEGORY_LABELS, @@ -63,9 +66,18 @@ export function TaskStatusesSettings({ canWrite: boolean; }) { const { t } = useTranslation("projects"); - const { data: statuses, isLoading } = useQuery( - taskStatusesQueryOptions(projectId), - ); + const { hasProjectPermission } = useProjectPermissions(projectId); + const canRead = hasProjectPermission("project.settings.task_statuses.read"); + const { + data: statuses, + isLoading, + isError, + error, + } = useQuery({ + ...taskStatusesQueryOptions(projectId), + enabled: canRead, + }); + const noPermission = !canRead || (isError && isForbiddenError(error)); const queryClient = useQueryClient(); const [createOpen, setCreateOpen] = useState(false); const [editStatus, setEditStatus] = useState(null); @@ -159,7 +171,13 @@ export function TaskStatusesSettings({

) : null} - {isLoading ? ( + {noPermission ? ( + + ) : isLoading ? (
{["s1", "s2", "s3"].map((k) => (
(null); @@ -68,7 +79,13 @@ export function TaskTypesSettings({ ) : null}
- {isLoading ? ( + {noPermission ? ( + + ) : isLoading ? (
{["t1", "t2", "t3"].map((k) => (
{ + it("shows a permission-denied message and no Retry button for a 403", () => { + const error = Object.assign(new Error("insufficient permissions"), { + response: { status: 403, data: { error_code: "FORBIDDEN" } }, + }); + + render(); + + expect( + screen.getByText(/you don't have permission to view this/i), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /retry/i }), + ).not.toBeInTheDocument(); + // The raw "insufficient permissions" server message adds nothing beyond + // the title above, so it's deliberately not shown for this error kind. + expect( + screen.queryByText(/insufficient permissions/i), + ).not.toBeInTheDocument(); + }); + + it("does not treat AUTH_PASSWORD_CHANGE_REQUIRED as a generic permission error", () => { + const error = Object.assign(new Error("must change password"), { + response: { + status: 403, + data: { error_code: "AUTH_PASSWORD_CHANGE_REQUIRED" }, + }, + }); + + render(); + + expect( + screen.queryByText(/you don't have permission to view this/i), + ).not.toBeInTheDocument(); + expect(screen.getByText(/something went wrong/i)).toBeInTheDocument(); + }); + + it("shows a not-found message for a 404", () => { + const error = Object.assign(new Error("task not found"), { + response: { status: 404, data: { error_code: "TASK_NOT_FOUND" } }, + }); + + render(); + + expect(screen.getByText("Not found")).toBeInTheDocument(); + }); + + it("shows a generic message with a Retry button for other errors", () => { + const error = Object.assign(new Error("internal error"), { + response: { status: 500, data: {} }, + }); + + render(); + + expect(screen.getByText(/something went wrong/i)).toBeInTheDocument(); + expect(screen.getByText(/internal error/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); +}); + +describe("RouteErrorComponent wrapped in CatchBoundary", () => { + // Regression coverage for src/routes/_authenticated.tsx's fix: a route's + // `errorComponent` boundary (installed via `Route.options.errorComponent`) + // replaces that *entire* route's own rendered output when a descendant + // throws, sidebar and all — because the boundary wraps the whole + // component, not just its own . The fix wraps only the routed + // content by using `CatchBoundary` (the same primitive TanStack Router's + // own errorComponent machinery is built on, re-exported publicly) inside + // the layout's own JSX, positioned as a sibling to the sidebar rather + // than an ancestor of it. This proves that placement actually preserves + // a sibling instead of also erasing it — the one thing a route-level + // `errorComponent` cannot do. + it("keeps a sibling outside the boundary while replacing only the thrown child", () => { + const error = Object.assign(new Error("insufficient permissions"), { + response: { status: 403, data: { error_code: "FORBIDDEN" } }, + }); + const onError = vi.fn(); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + render( +
+ + "test"} + errorComponent={RouteErrorComponent} + onCatch={onError} + > + + +
, + ); + + expect(screen.getByTestId("sidebar")).toBeInTheDocument(); + expect( + screen.getByText(/you don't have permission to view this/i), + ).toBeInTheDocument(); + expect(onError).toHaveBeenCalled(); + + consoleError.mockRestore(); + }); +}); diff --git a/apps/web/src/components/route-error-boundary.tsx b/apps/web/src/components/route-error-boundary.tsx index 1c4819666..79ab77f37 100644 --- a/apps/web/src/components/route-error-boundary.tsx +++ b/apps/web/src/components/route-error-boundary.tsx @@ -1,6 +1,7 @@ -import { AlertTriangle, RefreshCw } from "lucide-react"; +import { AlertTriangle, Lock, RefreshCw } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; +import { getHttpStatus, isForbiddenError } from "@/lib/api-error"; /** * Generic error fallback for TanStack Router route errors (loader failures, @@ -15,36 +16,72 @@ import { Button } from "@/components/ui/button"; export function RouteErrorComponent({ error }: { error: Error }) { const { t } = useTranslation(); + // A loader forwards whatever its queryFn's axios call rejected with, so + // `error` here carries the same response.status/data.error_code shape + // used everywhere else — checked by HTTP status rather than message + // text, unlike the not-found check below, since a permission error's + // message is free server text, not something to pattern-match on. + const isForbidden = isForbiddenError(error); const isNotFound = - error?.message?.toLowerCase().includes("not found") || - error?.message?.toLowerCase().includes("404"); + !isForbidden && + (getHttpStatus(error) === 404 || + error?.message?.toLowerCase().includes("not found") || + error?.message?.toLowerCase().includes("404")); return (
-
- +
+ {isForbidden ? ( + + ) : ( + + )}
-

- {isNotFound - ? t("common.notFound", "Not found") - : t("common.somethingWentWrong", "Something went wrong")} +

+ {isForbidden + ? t( + "common.noPermissionToView", + "You don't have permission to view this", + ) + : isNotFound + ? t("common.notFound", "Not found") + : t("common.somethingWentWrong", "Something went wrong")}

- {error?.message && ( + {/* A permission error's own message ("insufficient permissions") adds + nothing beyond the title above — only shown for other error kinds, + where the raw server message can carry a genuinely useful detail. */} + {!isForbidden && error?.message && (

{error.message}

)}
- + {/* Retrying a permission error hits the same 403 again — nothing on + this page changed, so there's nothing for Retry to do. */} + {!isForbidden && ( + + )}
); } diff --git a/apps/web/src/components/shared/no-permission-state.test.tsx b/apps/web/src/components/shared/no-permission-state.test.tsx new file mode 100644 index 000000000..ec3282132 --- /dev/null +++ b/apps/web/src/components/shared/no-permission-state.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from "@testing-library/react"; +import { Shield } from "lucide-react"; +import { describe, expect, it } from "vitest"; + +import { NoPermissionState } from "./no-permission-state"; + +describe("NoPermissionState", () => { + it("renders the given title and description", () => { + render(); + + expect(screen.getByText("No access")).toBeInTheDocument(); + expect(screen.getByText("Ask an admin.")).toBeInTheDocument(); + }); + + it("omits the description paragraph when none is given", () => { + const { container } = render(); + + expect(screen.getByText("No access")).toBeInTheDocument(); + expect(container.querySelectorAll("p")).toHaveLength(1); + }); + + it("renders the given icon", () => { + const { container } = render( + , + ); + + expect(container.querySelector("svg")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/shared/no-permission-state.tsx b/apps/web/src/components/shared/no-permission-state.tsx new file mode 100644 index 000000000..79db42318 --- /dev/null +++ b/apps/web/src/components/shared/no-permission-state.tsx @@ -0,0 +1,35 @@ +import type { LucideIcon } from "lucide-react"; +import { Lock } from "lucide-react"; + +/** + * Shown in place of a list/section whose data request came back 403 — + * distinct from a generic fetch failure (server/network trouble, worth + * retrying) and from a genuinely empty list (nothing to show, not a rights + * problem). Generalizes the amber "no permission" card already duplicated + * per feature in admin/users/UsersStates.tsx and + * admin/global-roles/GlobalRolesStates.tsx — same visual treatment, so a + * caller doesn't need to hand-roll it again. + */ +export function NoPermissionState({ + icon: Icon = Lock, + title, + description, +}: { + icon?: LucideIcon; + title: string; + description?: string; +}) { + return ( +
+ +
+

+ {title} +

+ {description && ( +

{description}

+ )} +
+
+ ); +} diff --git a/apps/web/src/i18n/locales/en/admin.json b/apps/web/src/i18n/locales/en/admin.json index 1d74acfa8..acb565cc8 100644 --- a/apps/web/src/i18n/locales/en/admin.json +++ b/apps/web/src/i18n/locales/en/admin.json @@ -9,6 +9,10 @@ "title": "No global agents yet", "description": "Create a global agent to make it available for chat and project invites.", "createAgent": "Create agent" + }, + "noPermission": { + "title": "You don't have permission to view agents", + "description": "You can still create new agents using the button above." } }, "users": { diff --git a/apps/web/src/i18n/locales/en/common.json b/apps/web/src/i18n/locales/en/common.json index 2e7f63249..f73c94384 100644 --- a/apps/web/src/i18n/locales/en/common.json +++ b/apps/web/src/i18n/locales/en/common.json @@ -1,4 +1,10 @@ { + "common": { + "notFound": "Not found", + "somethingWentWrong": "Something went wrong", + "retry": "Retry", + "noPermissionToView": "You don't have permission to view this" + }, "dialog": { "closeLabel": "Close", "closeButton": "Close" diff --git a/apps/web/src/i18n/locales/en/projects.json b/apps/web/src/i18n/locales/en/projects.json index 055a0ce51..761fd0c4e 100644 --- a/apps/web/src/i18n/locales/en/projects.json +++ b/apps/web/src/i18n/locales/en/projects.json @@ -92,6 +92,8 @@ "card": { "configure": "Configure", "delete": "Delete", + "restricted": "Restricted", + "restrictedTooltip": "This agent is restricted — ask a project admin to grant you access before you can chat with it.", "acpStatusConnected": "Online", "acpStatusDisconnected": "Offline", "deleteDialog": { @@ -107,6 +109,10 @@ "title": "AI Agents", "subtitle": "Autonomous agents that work on tasks and chat with your team", "newAgent": "New Agent", + "noPermission": { + "title": "You don't have permission to view agents", + "description": "Ask a project admin to grant you the agents.read permission." + }, "empty": { "title": "No agents yet", "description": "Add an AI agent to automate tasks, review code, write documentation, and more.", @@ -157,6 +163,7 @@ "mcpServers": "MCP Servers", "skills": "Skills", "envVars": "Environment", + "access": "Access", "activity": "Activity" }, "avatar": { @@ -290,6 +297,13 @@ "addSkill": "Add skill" } }, + "access": { + "restrictLabel": "Restrict access", + "restrictDescription": "When on, only members granted access below may chat with this agent. Everyone who can manage agents can still see and configure it.", + "pickMember": "Select a member…", + "grantAccess": "Grant access", + "empty": "No members have been granted access yet." + }, "activity": { "commented": "commented", "sourceType": { @@ -318,6 +332,10 @@ "conversationView": { "stop": "Stop", "notFound": "Conversation not found", + "noPermission": { + "title": "You don't have permission to view this conversation", + "description": "Ask a project admin to grant you the conversations.read permission." + }, "chatSession": "Chat session", "taskSession": "Task session", "pr": "PR", @@ -418,6 +436,12 @@ "title": "Environments", "subtitle": "Named, long-lived sandboxes your agents can attach to across conversations", "newEnvironment": "New Environment", + "restricted": "Restricted", + "restrictedTooltip": "This environment is restricted — ask a project admin to grant you access before you can use it.", + "noPermission": { + "title": "You don't have permission to view environments", + "description": "Ask a project admin to grant you the environments.read permission." + }, "empty": { "title": "No environments yet", "description": "Create a static environment to keep files and background processes running across conversations, instead of a fresh disposable sandbox each time.", @@ -455,7 +479,8 @@ "tabs": { "overview": "Overview", "folders": "Folders", - "portForwards": "Port forwards" + "portForwards": "Port forwards", + "access": "Access" }, "overview": { "connect": "Connect", @@ -500,6 +525,10 @@ "count_one": "{{count}} folder configured", "count_other": "{{count}} folders configured", "addFolder": "Add folder", + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view its folders." + }, "deleteFailed": "Failed to delete folder. Please try again.", "empty": { "title": "No folders added", @@ -526,6 +555,10 @@ "count_one": "{{count}} port forward", "count_other": "{{count}} port forwards", "add": "Add port forward", + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view its port forwards." + }, "deleteFailed": "Failed to delete port forward. Please try again.", "containerPort": "Container port {{port}}", "unassigned": "Not assigned yet", @@ -555,6 +588,13 @@ "confirm": "Restart" } }, + "access": { + "restrictLabel": "Restrict access", + "restrictDescription": "When on, only members granted access below may browse, SSH into, forward ports on, or open a terminal in this environment. Everyone who can manage environments can still see and configure it.", + "pickMember": "Select a member…", + "grantAccess": "Grant access", + "empty": "No members have been granted access yet." + }, "sshKeys": { "connectHint": "Replace with this deployment's configured SSH bastion address.", "connectUnavailable": "SSH access isn't configured on this deployment yet.", @@ -602,6 +642,7 @@ "connect": "Connect", "notRunning": "Start the environment before connecting.", "readOnly": "You don't have permission to open a terminal on this environment.", + "restricted": "This environment is restricted — ask a project admin to grant you access before you can open a terminal.", "hint": "Opens in a new full-page tab.", "pageTitle": "{{name}} — Terminal", "pageTitleLoading": "Terminal", @@ -612,7 +653,8 @@ "step1Description": "Register the public key of the SSH key pair you want to connect with.", "step2Title": "Connect from your terminal", "step2Description": "Run this command from your terminal:", - "notRunning": "Start the environment before connecting." + "notRunning": "Start the environment before connecting.", + "restricted": "This environment is restricted — ask a project admin to grant you access before you can connect via SSH." } } }, @@ -622,6 +664,10 @@ "title": "Document not found", "description": "This document may have been deleted or the link is invalid." }, + "noPermission": { + "title": "You don't have permission to view this document", + "description": "Ask a project admin to grant you the docs.read permission." + }, "unsaved": "Unsaved", "saving": "Saving…", "saved": "Saved", @@ -674,10 +720,6 @@ } }, "permissions": { - "projectsRead": { - "label": "Read Project", - "description": "View project details and settings" - }, "projectsWrite": { "label": "Edit Project", "description": "Update project name, description, and settings" @@ -702,6 +744,30 @@ "label": "Manage Roles", "description": "Create, edit, and delete project roles" }, + "settingsTaskTypesRead": { + "label": "View Task Types", + "description": "View the project's task type definitions" + }, + "settingsTaskTypesWrite": { + "label": "Manage Task Types", + "description": "Create, edit, delete, and set the default task type" + }, + "settingsTaskStatusesRead": { + "label": "View Task Statuses", + "description": "View the project's task status definitions and their order" + }, + "settingsTaskStatusesWrite": { + "label": "Manage Task Statuses", + "description": "Create, edit, delete, reorder, and set the default task status" + }, + "settingsCustomFieldsRead": { + "label": "View Custom Fields", + "description": "View the project's custom field definitions" + }, + "settingsCustomFieldsWrite": { + "label": "Manage Custom Fields", + "description": "Create, edit, and delete custom field definitions" + }, "tasksRead": { "label": "View Tasks", "description": "Browse and read tasks in the project" @@ -718,6 +784,14 @@ "label": "Manage Sprints", "description": "Create, update, and close sprints" }, + "viewsRead": { + "label": "View Boards", + "description": "Browse saved board and list views" + }, + "viewsWrite": { + "label": "Manage Boards", + "description": "Create, edit, delete, and reorder board and list views" + }, "docsRead": { "label": "View Documents", "description": "Browse and read documents in the project" @@ -754,6 +828,18 @@ "label": "Connect to Environments", "description": "Open an interactive terminal session inside a running environment" }, + "annotationsRead": { + "label": "View Annotations", + "description": "View page comments pinned via the browser extension" + }, + "annotationsWrite": { + "label": "Manage Annotations", + "description": "Create, edit, and delete page annotations" + }, + "annotationsResolve": { + "label": "Resolve Annotations", + "description": "Mark a page annotation resolved or reopen it, without authoring or deleting one" + }, "workflowsRead": { "label": "View Automation", "description": "Browse automations and their configuration" @@ -766,13 +852,15 @@ "permissionGroups": { "project": "Project", "members": "Members", - "roles": "Roles", + "settings": "Settings", "tasks": "Tasks", "sprints": "Sprints", + "views": "Views", "documents": "Documents", "aiAgents": "AI Agents", "conversations": "Conversations", "environments": "Environments", + "annotations": "Annotations", "workflows": "Automation", "plugins": "Plugins" } @@ -816,6 +904,10 @@ "description": "Manage roles and permissions for members of this project.", "newRole": "New role", "noPermissionsAssigned": "No permissions assigned", + "noPermission": { + "title": "You don't have permission to view roles", + "description": "Ask a project admin to grant you the project.roles.read permission." + }, "editRole": "Edit role", "deleteRole": "Delete role", "rolesDefined_one": "role defined", @@ -838,6 +930,10 @@ "description": "Define the workflow statuses tasks move through in this project.", "newStatus": "New status", "reorderFailed": "Failed to save the new order. Please try again.", + "noPermission": { + "title": "You don't have permission to view task statuses", + "description": "Ask a project admin to grant you the project.settings.task_statuses.read permission." + }, "empty": { "title": "No statuses defined", "description": "Create statuses to define the workflow for tasks in this project.", @@ -857,6 +953,10 @@ "title": "Task Types", "description": "Categorise tasks with custom types (e.g. Bug, Feature, Story).", "newType": "New type", + "noPermission": { + "title": "You don't have permission to view task types", + "description": "Ask a project admin to grant you the project.settings.task_types.read permission." + }, "empty": { "title": "No task types defined", "description": "Create types to categorise tasks by kind — e.g. Bug, Feature, Story.", @@ -877,6 +977,10 @@ "title": "Custom Fields", "description": "Define project-level custom task fields that extend tasks with additional data specific to your workflow.", "newField": "New custom field", + "noPermission": { + "title": "You don't have permission to view custom fields", + "description": "Ask a project admin to grant you the project.settings.custom_fields.read permission." + }, "empty": { "title": "No custom fields yet", "description": "Custom fields let you capture data specific to your workflow — sprints, severity levels, release tags, and more.", @@ -996,6 +1100,10 @@ "addMember": "Add Member", "memberCount_one": "{{count}} member", "memberCount_other": "{{count}} members", + "noPermission": { + "title": "You don't have permission to view members", + "description": "Ask a project admin to grant you the project.members.read permission." + }, "empty": { "title": "No members yet", "description": "Add teammates or AI agents to this project to get started.", @@ -1092,6 +1200,10 @@ "backToProject": "Back to project", "projectFallback": "Project" }, + "noPermission": { + "title": "You don't have permission to view this task", + "description": "Ask a project admin to grant you the tasks.read permission." + }, "header": { "created": "Created {{date}}", "copied": "Copied!", @@ -1298,6 +1410,10 @@ "ariaLabel": "Port forward detail", "backToEnvironment": "Back to environment" }, + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view this port forward." + }, "tabs": { "overview": "Overview", "comments": "Comments" @@ -1338,6 +1454,10 @@ "ariaLabel": "Comment detail", "backToPortForward": "Back to port forward" }, + "noPermission": { + "title": "You don't have permission to view this comment", + "description": "Ask a project admin to grant you the annotations.read permission." + }, "header": { "createdAt": "Commented {{date}}" }, @@ -1463,6 +1583,10 @@ "clearAll": "Clear filters" }, "list": { + "noPermission": { + "title": "You don't have permission to view conversations", + "description": "Ask a project admin to grant you the conversations.read permission." + }, "empty": { "title": "No conversations yet", "description": "Conversations start when a task is assigned to an agent or someone messages one." @@ -1714,6 +1838,10 @@ "title": "Automation", "subtitle": "React to task events with triggers, conditions, and actions", "newAutomation": "New Automation", + "noPermission": { + "title": "You don't have permission to view automations", + "description": "Ask a project admin to grant you the workflows.read permission." + }, "empty": { "title": "No automations yet", "description": "Create an automation to react to task events with conditions and actions.", diff --git a/apps/web/src/i18n/locales/es/admin.json b/apps/web/src/i18n/locales/es/admin.json index 49fcceaaa..0008e9c0f 100644 --- a/apps/web/src/i18n/locales/es/admin.json +++ b/apps/web/src/i18n/locales/es/admin.json @@ -289,6 +289,10 @@ "title": "Aún no hay agentes globales", "description": "Crea un agente global para que esté disponible para chatear y para invitarlo a proyectos.", "createAgent": "Crear agente" + }, + "noPermission": { + "title": "You don't have permission to view agents", + "description": "You can still create new agents using the button above." } }, "settings": { diff --git a/apps/web/src/i18n/locales/es/common.json b/apps/web/src/i18n/locales/es/common.json index 27cdb6fb0..52cafff72 100644 --- a/apps/web/src/i18n/locales/es/common.json +++ b/apps/web/src/i18n/locales/es/common.json @@ -1,4 +1,10 @@ { + "common": { + "notFound": "Not found", + "somethingWentWrong": "Something went wrong", + "retry": "Retry", + "noPermissionToView": "You don't have permission to view this" + }, "dialog": { "closeLabel": "Cerrar", "closeButton": "Cerrar" diff --git a/apps/web/src/i18n/locales/es/projects.json b/apps/web/src/i18n/locales/es/projects.json index 3982e55ee..2f0722d87 100644 --- a/apps/web/src/i18n/locales/es/projects.json +++ b/apps/web/src/i18n/locales/es/projects.json @@ -92,6 +92,8 @@ "card": { "configure": "Configurar", "delete": "Eliminar", + "restricted": "Restricted", + "restrictedTooltip": "This agent is restricted — ask a project admin to grant you access before you can chat with it.", "acpStatusConnected": "En línea", "acpStatusDisconnected": "Desconectado", "deleteDialog": { @@ -111,6 +113,10 @@ "title": "Aún no hay agentes", "description": "Añade un agente de IA para automatizar tareas, revisar código, escribir documentación y más.", "createFirstAgent": "Crea tu primer agente" + }, + "noPermission": { + "title": "You don't have permission to view agents", + "description": "Ask a project admin to grant you the agents.read permission." } }, "acpSetup": { @@ -157,6 +163,7 @@ "mcpServers": "Servidores MCP", "skills": "Skills", "envVars": "Entorno", + "access": "Access", "activity": "Actividad" }, "avatar": { @@ -290,6 +297,13 @@ "addSkill": "Añadir skill" } }, + "access": { + "restrictLabel": "Restrict access", + "restrictDescription": "When on, only members granted access below may chat with this agent. Everyone who can manage agents can still see and configure it.", + "pickMember": "Select a member…", + "grantAccess": "Grant access", + "empty": "No members have been granted access yet." + }, "activity": { "commented": "comentó", "sourceType": { @@ -318,6 +332,10 @@ "conversationView": { "stop": "Detener", "notFound": "Conversación no encontrada", + "noPermission": { + "title": "You don't have permission to view this conversation", + "description": "Ask a project admin to grant you the conversations.read permission." + }, "chatSession": "Sesión de chat", "taskSession": "Sesión de tarea", "pr": "PR", @@ -418,10 +436,16 @@ "title": "Entornos", "subtitle": "Sandboxes persistentes y con nombre a los que tus agentes pueden conectarse entre conversaciones", "newEnvironment": "Nuevo entorno", + "restricted": "Restricted", + "restrictedTooltip": "This environment is restricted — ask a project admin to grant you access before you can use it.", "empty": { "title": "Aún no hay entornos", "description": "Crea un entorno estático para mantener archivos y procesos en segundo plano activos entre conversaciones, en lugar de un sandbox desechable nuevo cada vez.", "createFirstEnvironment": "Crea tu primer entorno" + }, + "noPermission": { + "title": "You don't have permission to view environments", + "description": "Ask a project admin to grant you the environments.read permission." } }, "createDialog": { @@ -455,7 +479,8 @@ "tabs": { "overview": "Resumen", "folders": "Carpetas", - "portForwards": "Reenvíos de puertos" + "portForwards": "Reenvíos de puertos", + "access": "Access" }, "overview": { "connect": "Conectar", @@ -500,6 +525,10 @@ "count_one": "{{count}} carpeta configurada", "count_other": "{{count}} carpetas configuradas", "addFolder": "Añadir carpeta", + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view its folders." + }, "deleteFailed": "No se pudo eliminar la carpeta. Inténtalo de nuevo.", "empty": { "title": "No se han añadido carpetas", @@ -526,6 +555,10 @@ "count_one": "{{count}} reenvío de puerto", "count_other": "{{count}} reenvíos de puertos", "add": "Añadir reenvío de puerto", + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view its port forwards." + }, "deleteFailed": "No se pudo eliminar el reenvío de puerto. Inténtalo de nuevo.", "containerPort": "Puerto del contenedor {{port}}", "unassigned": "Aún no asignado", @@ -555,6 +588,13 @@ "confirm": "Reiniciar" } }, + "access": { + "restrictLabel": "Restrict access", + "restrictDescription": "When on, only members granted access below may browse, SSH into, forward ports on, or open a terminal in this environment. Everyone who can manage environments can still see and configure it.", + "pickMember": "Select a member…", + "grantAccess": "Grant access", + "empty": "No members have been granted access yet." + }, "sshKeys": { "connectHint": "Sustituye por la dirección del bastión SSH configurado en este despliegue.", "connectUnavailable": "El acceso SSH aún no está configurado en este despliegue.", @@ -602,6 +642,7 @@ "connect": "Conectar", "notRunning": "Inicia el entorno antes de conectarte.", "readOnly": "No tienes permiso para abrir una terminal en este entorno.", + "restricted": "This environment is restricted — ask a project admin to grant you access before you can open a terminal.", "hint": "Se abre en una nueva pestaña a pantalla completa.", "pageTitle": "{{name}} — Terminal", "pageTitleLoading": "Terminal", @@ -612,7 +653,8 @@ "step1Description": "Registra la clave pública del par de claves SSH con el que quieras conectarte.", "step2Title": "Conéctate desde tu terminal", "step2Description": "Ejecuta este comando desde tu terminal:", - "notRunning": "Inicia el entorno antes de conectarte." + "notRunning": "Inicia el entorno antes de conectarte.", + "restricted": "This environment is restricted — ask a project admin to grant you access before you can connect via SSH." } } }, @@ -622,6 +664,10 @@ "title": "Documento no encontrado", "description": "Este documento puede haber sido eliminado o el enlace no es válido." }, + "noPermission": { + "title": "You don't have permission to view this document", + "description": "Ask a project admin to grant you the docs.read permission." + }, "unsaved": "Sin guardar", "saving": "Guardando…", "saved": "Guardado", @@ -674,10 +720,6 @@ } }, "permissions": { - "projectsRead": { - "label": "Leer proyecto", - "description": "Ver los detalles y la configuración del proyecto" - }, "projectsWrite": { "label": "Editar proyecto", "description": "Actualizar el nombre, la descripción y la configuración del proyecto" @@ -702,6 +744,30 @@ "label": "Gestionar roles", "description": "Crear, editar y eliminar roles del proyecto" }, + "settingsTaskTypesRead": { + "label": "View Task Types", + "description": "View the project's task type definitions" + }, + "settingsTaskTypesWrite": { + "label": "Manage Task Types", + "description": "Create, edit, delete, and set the default task type" + }, + "settingsTaskStatusesRead": { + "label": "View Task Statuses", + "description": "View the project's task status definitions and their order" + }, + "settingsTaskStatusesWrite": { + "label": "Manage Task Statuses", + "description": "Create, edit, delete, reorder, and set the default task status" + }, + "settingsCustomFieldsRead": { + "label": "View Custom Fields", + "description": "View the project's custom field definitions" + }, + "settingsCustomFieldsWrite": { + "label": "Manage Custom Fields", + "description": "Create, edit, and delete custom field definitions" + }, "tasksRead": { "label": "Ver tareas", "description": "Explorar y leer las tareas del proyecto" @@ -718,6 +784,14 @@ "label": "Gestionar sprints", "description": "Crear, actualizar y cerrar sprints" }, + "viewsRead": { + "label": "View Boards", + "description": "Browse saved board and list views" + }, + "viewsWrite": { + "label": "Manage Boards", + "description": "Create, edit, delete, and reorder board and list views" + }, "docsRead": { "label": "Ver documentos", "description": "Explorar y leer documentos del proyecto" @@ -754,6 +828,18 @@ "label": "Conectar a entornos", "description": "Abrir una sesión de terminal interactiva dentro de un entorno en ejecución" }, + "annotationsRead": { + "label": "View Annotations", + "description": "View page comments pinned via the browser extension" + }, + "annotationsWrite": { + "label": "Manage Annotations", + "description": "Create, edit, and delete page annotations" + }, + "annotationsResolve": { + "label": "Resolve Annotations", + "description": "Mark a page annotation resolved or reopen it, without authoring or deleting one" + }, "workflowsRead": { "label": "Ver automatización", "description": "Explorar las automatizaciones y su configuración" @@ -766,13 +852,15 @@ "permissionGroups": { "project": "Proyecto", "members": "Miembros", - "roles": "Roles", + "settings": "Settings", "tasks": "Tareas", "sprints": "Sprints", + "views": "Views", "documents": "Documentos", "aiAgents": "Agentes de IA", "conversations": "Conversaciones", "environments": "Entornos", + "annotations": "Annotations", "workflows": "Automatización", "plugins": "Plugins" } @@ -831,7 +919,11 @@ "permissions": "Permisos", "created": "Creado" }, - "systemRolesNote": "Los roles del sistema son plantillas compartidas y no se pueden editar ni eliminar." + "systemRolesNote": "Los roles del sistema son plantillas compartidas y no se pueden editar ni eliminar.", + "noPermission": { + "title": "You don't have permission to view roles", + "description": "Ask a project admin to grant you the project.roles.read permission." + } }, "taskStatuses": { "title": "Estados de tarea", @@ -851,7 +943,11 @@ "default": "Predeterminado", "setAsDefault": "Establecer como estado predeterminado", "editStatus": "Editar estado", - "deleteStatus": "Eliminar estado" + "deleteStatus": "Eliminar estado", + "noPermission": { + "title": "You don't have permission to view task statuses", + "description": "Ask a project admin to grant you the project.settings.task_statuses.read permission." + } }, "taskTypes": { "title": "Tipos de tarea", @@ -871,7 +967,11 @@ "default": "Predeterminado", "setAsDefault": "Establecer como tipo predeterminado", "editType": "Editar tipo", - "deleteType": "Eliminar tipo" + "deleteType": "Eliminar tipo", + "noPermission": { + "title": "You don't have permission to view task types", + "description": "Ask a project admin to grant you the project.settings.task_types.read permission." + } }, "customFields": { "title": "Campos personalizados", @@ -942,6 +1042,10 @@ "confirmTextSuffix": "? Los datos de tareas almacenados en este campo se perderán. Esta acción no se puede deshacer.", "deleteFailed": "No se pudo eliminar el campo. Inténtalo de nuevo.", "deleteField": "Eliminar campo" + }, + "noPermission": { + "title": "You don't have permission to view custom fields", + "description": "Ask a project admin to grant you the project.settings.custom_fields.read permission." } }, "dangerZone": { @@ -1037,6 +1141,10 @@ "removeFailed": "No se pudo eliminar al miembro. Inténtalo de nuevo.", "cancel": "Cancelar", "remove": "Eliminar" + }, + "noPermission": { + "title": "You don't have permission to view members", + "description": "Ask a project admin to grant you the project.members.read permission." } }, "aiChat": { @@ -1092,6 +1200,10 @@ "backToProject": "Volver al proyecto", "projectFallback": "Proyecto" }, + "noPermission": { + "title": "You don't have permission to view this task", + "description": "Ask a project admin to grant you the tasks.read permission." + }, "header": { "created": "Creada el {{date}}", "copied": "¡Copiado!", @@ -1298,6 +1410,10 @@ "ariaLabel": "Detalle del reenvío de puerto", "backToEnvironment": "Volver al entorno" }, + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view this port forward." + }, "tabs": { "overview": "Resumen", "comments": "Comentarios" @@ -1338,6 +1454,10 @@ "ariaLabel": "Detalle del comentario", "backToPortForward": "Volver al reenvío de puerto" }, + "noPermission": { + "title": "You don't have permission to view this comment", + "description": "Ask a project admin to grant you the annotations.read permission." + }, "header": { "createdAt": "Comentado el {{date}}" }, @@ -1463,6 +1583,10 @@ "clearAll": "Borrar filtros" }, "list": { + "noPermission": { + "title": "You don't have permission to view conversations", + "description": "Ask a project admin to grant you the conversations.read permission." + }, "empty": { "title": "Aún no hay conversaciones", "description": "Las conversaciones comienzan cuando se asigna una tarea a un agente o alguien le envía un mensaje." @@ -1720,7 +1844,11 @@ "createFirst": "Crea tu primera automatización" }, "noDescription": "Sin descripción", - "updated": "Actualizado {{time}}" + "updated": "Actualizado {{time}}", + "noPermission": { + "title": "You don't have permission to view automations", + "description": "Ask a project admin to grant you the workflows.read permission." + } }, "status": { "active": "Activo", diff --git a/apps/web/src/i18n/locales/fr/admin.json b/apps/web/src/i18n/locales/fr/admin.json index fc4a9d5b1..3a7c7e08c 100644 --- a/apps/web/src/i18n/locales/fr/admin.json +++ b/apps/web/src/i18n/locales/fr/admin.json @@ -289,6 +289,10 @@ "title": "Aucun agent global pour le moment", "description": "Créez un agent global pour le rendre disponible pour le chat et les invitations à des projets.", "createAgent": "Créer un agent" + }, + "noPermission": { + "title": "You don't have permission to view agents", + "description": "You can still create new agents using the button above." } }, "settings": { diff --git a/apps/web/src/i18n/locales/fr/common.json b/apps/web/src/i18n/locales/fr/common.json index 88812b5cb..717259b38 100644 --- a/apps/web/src/i18n/locales/fr/common.json +++ b/apps/web/src/i18n/locales/fr/common.json @@ -1,4 +1,10 @@ { + "common": { + "notFound": "Not found", + "somethingWentWrong": "Something went wrong", + "retry": "Retry", + "noPermissionToView": "You don't have permission to view this" + }, "dialog": { "closeLabel": "Fermer", "closeButton": "Fermer" diff --git a/apps/web/src/i18n/locales/fr/projects.json b/apps/web/src/i18n/locales/fr/projects.json index 75cf41957..b5ff4af52 100644 --- a/apps/web/src/i18n/locales/fr/projects.json +++ b/apps/web/src/i18n/locales/fr/projects.json @@ -92,6 +92,8 @@ "card": { "configure": "Configurer", "delete": "Supprimer", + "restricted": "Restricted", + "restrictedTooltip": "This agent is restricted — ask a project admin to grant you access before you can chat with it.", "acpStatusConnected": "En ligne", "acpStatusDisconnected": "Hors ligne", "deleteDialog": { @@ -111,6 +113,10 @@ "title": "Aucun agent pour le moment", "description": "Ajoutez un agent IA pour automatiser des tâches, relire du code, rédiger de la documentation, et plus encore.", "createFirstAgent": "Créer votre premier agent" + }, + "noPermission": { + "title": "You don't have permission to view agents", + "description": "Ask a project admin to grant you the agents.read permission." } }, "acpSetup": { @@ -157,6 +163,7 @@ "mcpServers": "Serveurs MCP", "skills": "Compétences", "envVars": "Environnement", + "access": "Access", "activity": "Activité" }, "avatar": { @@ -290,6 +297,13 @@ "addSkill": "Ajouter la compétence" } }, + "access": { + "restrictLabel": "Restrict access", + "restrictDescription": "When on, only members granted access below may chat with this agent. Everyone who can manage agents can still see and configure it.", + "pickMember": "Select a member…", + "grantAccess": "Grant access", + "empty": "No members have been granted access yet." + }, "activity": { "commented": "a commenté", "sourceType": { @@ -318,6 +332,10 @@ "conversationView": { "stop": "Arrêter", "notFound": "Conversation introuvable", + "noPermission": { + "title": "You don't have permission to view this conversation", + "description": "Ask a project admin to grant you the conversations.read permission." + }, "chatSession": "Session de discussion", "taskSession": "Session de tâche", "pr": "PR", @@ -418,10 +436,16 @@ "title": "Environnements", "subtitle": "Sandboxes nommés et persistants auxquels vos agents peuvent se rattacher d'une conversation à l'autre", "newEnvironment": "Nouvel environnement", + "restricted": "Restricted", + "restrictedTooltip": "This environment is restricted — ask a project admin to grant you access before you can use it.", "empty": { "title": "Aucun environnement pour le moment", "description": "Créez un environnement statique pour conserver fichiers et processus en arrière-plan d'une conversation à l'autre, au lieu d'un sandbox jetable à chaque fois.", "createFirstEnvironment": "Créez votre premier environnement" + }, + "noPermission": { + "title": "You don't have permission to view environments", + "description": "Ask a project admin to grant you the environments.read permission." } }, "createDialog": { @@ -455,7 +479,8 @@ "tabs": { "overview": "Aperçu", "folders": "Dossiers", - "portForwards": "Transferts de ports" + "portForwards": "Transferts de ports", + "access": "Access" }, "overview": { "connect": "Se connecter", @@ -500,6 +525,10 @@ "count_one": "{{count}} dossier configuré", "count_other": "{{count}} dossiers configurés", "addFolder": "Ajouter un dossier", + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view its folders." + }, "deleteFailed": "Échec de la suppression du dossier. Veuillez réessayer.", "empty": { "title": "Aucun dossier ajouté", @@ -526,6 +555,10 @@ "count_one": "{{count}} transfert de port", "count_other": "{{count}} transferts de ports", "add": "Ajouter un transfert de port", + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view its port forwards." + }, "deleteFailed": "Échec de la suppression du transfert de port. Veuillez réessayer.", "containerPort": "Port du conteneur {{port}}", "unassigned": "Pas encore assigné", @@ -555,6 +588,13 @@ "confirm": "Redémarrer" } }, + "access": { + "restrictLabel": "Restrict access", + "restrictDescription": "When on, only members granted access below may browse, SSH into, forward ports on, or open a terminal in this environment. Everyone who can manage environments can still see and configure it.", + "pickMember": "Select a member…", + "grantAccess": "Grant access", + "empty": "No members have been granted access yet." + }, "sshKeys": { "connectHint": "Remplacez par l'adresse du bastion SSH configuré sur ce déploiement.", "connectUnavailable": "L'accès SSH n'est pas encore configuré sur ce déploiement.", @@ -602,6 +642,7 @@ "connect": "Se connecter", "notRunning": "Démarrez l'environnement avant de vous connecter.", "readOnly": "Vous n'avez pas la permission d'ouvrir un terminal sur cet environnement.", + "restricted": "This environment is restricted — ask a project admin to grant you access before you can open a terminal.", "hint": "S'ouvre dans un nouvel onglet en plein écran.", "pageTitle": "{{name}} — Terminal", "pageTitleLoading": "Terminal", @@ -612,7 +653,8 @@ "step1Description": "Enregistrez la clé publique de la paire de clés SSH avec laquelle vous souhaitez vous connecter.", "step2Title": "Connectez-vous depuis votre terminal", "step2Description": "Exécutez cette commande depuis votre terminal :", - "notRunning": "Démarrez l'environnement avant de vous connecter." + "notRunning": "Démarrez l'environnement avant de vous connecter.", + "restricted": "This environment is restricted — ask a project admin to grant you access before you can connect via SSH." } } }, @@ -622,6 +664,10 @@ "title": "Document introuvable", "description": "Ce document a peut-être été supprimé ou le lien est invalide." }, + "noPermission": { + "title": "You don't have permission to view this document", + "description": "Ask a project admin to grant you the docs.read permission." + }, "unsaved": "Non enregistré", "saving": "Enregistrement…", "saved": "Enregistré", @@ -674,10 +720,6 @@ } }, "permissions": { - "projectsRead": { - "label": "Lire le projet", - "description": "Voir les détails et paramètres du projet" - }, "projectsWrite": { "label": "Modifier le projet", "description": "Mettre à jour le nom, la description et les paramètres du projet" @@ -702,6 +744,30 @@ "label": "Gérer les rôles", "description": "Créer, modifier et supprimer les rôles du projet" }, + "settingsTaskTypesRead": { + "label": "View Task Types", + "description": "View the project's task type definitions" + }, + "settingsTaskTypesWrite": { + "label": "Manage Task Types", + "description": "Create, edit, delete, and set the default task type" + }, + "settingsTaskStatusesRead": { + "label": "View Task Statuses", + "description": "View the project's task status definitions and their order" + }, + "settingsTaskStatusesWrite": { + "label": "Manage Task Statuses", + "description": "Create, edit, delete, reorder, and set the default task status" + }, + "settingsCustomFieldsRead": { + "label": "View Custom Fields", + "description": "View the project's custom field definitions" + }, + "settingsCustomFieldsWrite": { + "label": "Manage Custom Fields", + "description": "Create, edit, and delete custom field definitions" + }, "tasksRead": { "label": "Voir les tâches", "description": "Parcourir et lire les tâches du projet" @@ -718,6 +784,14 @@ "label": "Gérer les sprints", "description": "Créer, mettre à jour et clôturer des sprints" }, + "viewsRead": { + "label": "View Boards", + "description": "Browse saved board and list views" + }, + "viewsWrite": { + "label": "Manage Boards", + "description": "Create, edit, delete, and reorder board and list views" + }, "docsRead": { "label": "Voir les documents", "description": "Parcourir et lire les documents du projet" @@ -754,6 +828,18 @@ "label": "Se connecter aux environnements", "description": "Ouvrir une session de terminal interactive dans un environnement en cours d'exécution" }, + "annotationsRead": { + "label": "View Annotations", + "description": "View page comments pinned via the browser extension" + }, + "annotationsWrite": { + "label": "Manage Annotations", + "description": "Create, edit, and delete page annotations" + }, + "annotationsResolve": { + "label": "Resolve Annotations", + "description": "Mark a page annotation resolved or reopen it, without authoring or deleting one" + }, "workflowsRead": { "label": "Voir l'automatisation", "description": "Parcourir les automatisations et leur configuration" @@ -766,13 +852,15 @@ "permissionGroups": { "project": "Projet", "members": "Membres", - "roles": "Rôles", + "settings": "Settings", "tasks": "Tâches", "sprints": "Sprints", + "views": "Views", "documents": "Documents", "aiAgents": "Agents IA", "conversations": "Conversations", "environments": "Environnements", + "annotations": "Annotations", "workflows": "Automatisation", "plugins": "Plugins" } @@ -831,7 +919,11 @@ "permissions": "Permissions", "created": "Créé" }, - "systemRolesNote": "Les rôles système sont des modèles partagés et ne peuvent pas être modifiés ou supprimés." + "systemRolesNote": "Les rôles système sont des modèles partagés et ne peuvent pas être modifiés ou supprimés.", + "noPermission": { + "title": "You don't have permission to view roles", + "description": "Ask a project admin to grant you the project.roles.read permission." + } }, "taskStatuses": { "title": "Statuts de tâche", @@ -851,7 +943,11 @@ "default": "Par défaut", "setAsDefault": "Définir comme statut par défaut", "editStatus": "Modifier le statut", - "deleteStatus": "Supprimer le statut" + "deleteStatus": "Supprimer le statut", + "noPermission": { + "title": "You don't have permission to view task statuses", + "description": "Ask a project admin to grant you the project.settings.task_statuses.read permission." + } }, "taskTypes": { "title": "Types de tâche", @@ -871,7 +967,11 @@ "default": "Par défaut", "setAsDefault": "Définir comme type par défaut", "editType": "Modifier le type", - "deleteType": "Supprimer le type" + "deleteType": "Supprimer le type", + "noPermission": { + "title": "You don't have permission to view task types", + "description": "Ask a project admin to grant you the project.settings.task_types.read permission." + } }, "customFields": { "title": "Champs personnalisés", @@ -942,6 +1042,10 @@ "confirmTextSuffix": " ? Les données de tâches stockées dans ce champ seront perdues. Cette action est irréversible.", "deleteFailed": "Échec de la suppression du champ. Veuillez réessayer.", "deleteField": "Supprimer le champ" + }, + "noPermission": { + "title": "You don't have permission to view custom fields", + "description": "Ask a project admin to grant you the project.settings.custom_fields.read permission." } }, "dangerZone": { @@ -1037,6 +1141,10 @@ "removeFailed": "Échec du retrait du membre. Veuillez réessayer.", "cancel": "Annuler", "remove": "Retirer" + }, + "noPermission": { + "title": "You don't have permission to view members", + "description": "Ask a project admin to grant you the project.members.read permission." } }, "aiChat": { @@ -1092,6 +1200,10 @@ "backToProject": "Retour au projet", "projectFallback": "Projet" }, + "noPermission": { + "title": "You don't have permission to view this task", + "description": "Ask a project admin to grant you the tasks.read permission." + }, "header": { "created": "Créée {{date}}", "copied": "Copié !", @@ -1298,6 +1410,10 @@ "ariaLabel": "Détail de la redirection de port", "backToEnvironment": "Retour à l'environnement" }, + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view this port forward." + }, "tabs": { "overview": "Aperçu", "comments": "Commentaires" @@ -1338,6 +1454,10 @@ "ariaLabel": "Détail du commentaire", "backToPortForward": "Retour à la redirection de port" }, + "noPermission": { + "title": "You don't have permission to view this comment", + "description": "Ask a project admin to grant you the annotations.read permission." + }, "header": { "createdAt": "Commenté le {{date}}" }, @@ -1463,6 +1583,10 @@ "clearAll": "Effacer les filtres" }, "list": { + "noPermission": { + "title": "You don't have permission to view conversations", + "description": "Ask a project admin to grant you the conversations.read permission." + }, "empty": { "title": "Aucune conversation pour le moment", "description": "Les conversations démarrent lorsqu'une tâche est attribuée à un agent ou que quelqu'un lui envoie un message." @@ -1720,7 +1844,11 @@ "createFirst": "Créer votre première automatisation" }, "noDescription": "Aucune description", - "updated": "Mis à jour {{time}}" + "updated": "Mis à jour {{time}}", + "noPermission": { + "title": "You don't have permission to view automations", + "description": "Ask a project admin to grant you the workflows.read permission." + } }, "status": { "active": "Actif", diff --git a/apps/web/src/i18n/locales/ja/admin.json b/apps/web/src/i18n/locales/ja/admin.json index a323a8641..57aab6894 100644 --- a/apps/web/src/i18n/locales/ja/admin.json +++ b/apps/web/src/i18n/locales/ja/admin.json @@ -289,6 +289,10 @@ "title": "グローバルエージェントはまだありません", "description": "グローバルエージェントを作成すると、チャットやプロジェクトへの招待に利用できるようになります。", "createAgent": "エージェントを作成" + }, + "noPermission": { + "title": "You don't have permission to view agents", + "description": "You can still create new agents using the button above." } }, "settings": { diff --git a/apps/web/src/i18n/locales/ja/common.json b/apps/web/src/i18n/locales/ja/common.json index 6da65b8be..0220a713b 100644 --- a/apps/web/src/i18n/locales/ja/common.json +++ b/apps/web/src/i18n/locales/ja/common.json @@ -1,4 +1,10 @@ { + "common": { + "notFound": "Not found", + "somethingWentWrong": "Something went wrong", + "retry": "Retry", + "noPermissionToView": "You don't have permission to view this" + }, "dialog": { "closeLabel": "閉じる", "closeButton": "閉じる" diff --git a/apps/web/src/i18n/locales/ja/projects.json b/apps/web/src/i18n/locales/ja/projects.json index 55420577c..f5a727d97 100644 --- a/apps/web/src/i18n/locales/ja/projects.json +++ b/apps/web/src/i18n/locales/ja/projects.json @@ -92,6 +92,8 @@ "card": { "configure": "設定", "delete": "削除", + "restricted": "Restricted", + "restrictedTooltip": "This agent is restricted — ask a project admin to grant you access before you can chat with it.", "acpStatusConnected": "オンライン", "acpStatusDisconnected": "オフライン", "deleteDialog": { @@ -111,6 +113,10 @@ "title": "エージェントはまだいません", "description": "AIエージェントを追加して、タスクの自動化、コードレビュー、ドキュメント作成などを行いましょう。", "createFirstAgent": "最初のエージェントを作成" + }, + "noPermission": { + "title": "You don't have permission to view agents", + "description": "Ask a project admin to grant you the agents.read permission." } }, "acpSetup": { @@ -157,6 +163,7 @@ "mcpServers": "MCPサーバー", "skills": "スキル", "envVars": "環境変数", + "access": "Access", "activity": "アクティビティ" }, "avatar": { @@ -290,6 +297,13 @@ "addSkill": "スキルを追加" } }, + "access": { + "restrictLabel": "Restrict access", + "restrictDescription": "When on, only members granted access below may chat with this agent. Everyone who can manage agents can still see and configure it.", + "pickMember": "Select a member…", + "grantAccess": "Grant access", + "empty": "No members have been granted access yet." + }, "activity": { "commented": "コメントしました", "sourceType": { @@ -318,6 +332,10 @@ "conversationView": { "stop": "停止", "notFound": "会話が見つかりません", + "noPermission": { + "title": "You don't have permission to view this conversation", + "description": "Ask a project admin to grant you the conversations.read permission." + }, "chatSession": "チャットセッション", "taskSession": "タスクセッション", "pr": "PR", @@ -418,10 +436,16 @@ "title": "環境", "subtitle": "エージェントが会話をまたいで接続できる、名前付きの長期稼働サンドボックス", "newEnvironment": "新しい環境", + "restricted": "Restricted", + "restrictedTooltip": "This environment is restricted — ask a project admin to grant you access before you can use it.", "empty": { "title": "まだ環境がありません", "description": "毎回使い捨てのサンドボックスを作成する代わりに、静的環境を作成してファイルやバックグラウンドプロセスを会話をまたいで維持しましょう。", "createFirstEnvironment": "最初の環境を作成" + }, + "noPermission": { + "title": "You don't have permission to view environments", + "description": "Ask a project admin to grant you the environments.read permission." } }, "createDialog": { @@ -455,7 +479,8 @@ "tabs": { "overview": "概要", "folders": "フォルダー", - "portForwards": "ポートフォワード" + "portForwards": "ポートフォワード", + "access": "Access" }, "overview": { "connect": "接続", @@ -500,6 +525,10 @@ "count_one": "{{count}}個のフォルダーが設定されています", "count_other": "{{count}}個のフォルダーが設定されています", "addFolder": "フォルダーを追加", + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view its folders." + }, "deleteFailed": "フォルダーの削除に失敗しました。もう一度お試しください。", "empty": { "title": "フォルダーが追加されていません", @@ -526,6 +555,10 @@ "count_one": "{{count}}件のポートフォワード", "count_other": "{{count}}件のポートフォワード", "add": "ポートフォワードを追加", + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view its port forwards." + }, "deleteFailed": "ポートフォワードの削除に失敗しました。もう一度お試しください。", "containerPort": "コンテナポート {{port}}", "unassigned": "まだ割り当てられていません", @@ -555,6 +588,13 @@ "confirm": "再起動" } }, + "access": { + "restrictLabel": "Restrict access", + "restrictDescription": "When on, only members granted access below may browse, SSH into, forward ports on, or open a terminal in this environment. Everyone who can manage environments can still see and configure it.", + "pickMember": "Select a member…", + "grantAccess": "Grant access", + "empty": "No members have been granted access yet." + }, "sshKeys": { "connectHint": " を、このデプロイで設定されたSSH踏み台のアドレスに置き換えてください。", "connectUnavailable": "このデプロイではまだSSHアクセスが設定されていません。", @@ -602,6 +642,7 @@ "connect": "接続", "notRunning": "接続する前に環境を起動してください。", "readOnly": "この環境でターミナルを開く権限がありません。", + "restricted": "This environment is restricted — ask a project admin to grant you access before you can open a terminal.", "hint": "新しい全画面タブで開きます。", "pageTitle": "{{name}} — ターミナル", "pageTitleLoading": "ターミナル", @@ -612,7 +653,8 @@ "step1Description": "接続に使用するSSH鍵ペアの公開鍵を登録してください。", "step2Title": "ターミナルから接続", "step2Description": "ターミナルで次のコマンドを実行してください:", - "notRunning": "接続する前に環境を起動してください。" + "notRunning": "接続する前に環境を起動してください。", + "restricted": "This environment is restricted — ask a project admin to grant you access before you can connect via SSH." } } }, @@ -622,6 +664,10 @@ "title": "ドキュメントが見つかりません", "description": "このドキュメントは削除されたか、リンクが無効である可能性があります。" }, + "noPermission": { + "title": "You don't have permission to view this document", + "description": "Ask a project admin to grant you the docs.read permission." + }, "unsaved": "未保存", "saving": "保存中…", "saved": "保存しました", @@ -674,10 +720,6 @@ } }, "permissions": { - "projectsRead": { - "label": "プロジェクトの閲覧", - "description": "プロジェクトの詳細と設定を表示します" - }, "projectsWrite": { "label": "プロジェクトの編集", "description": "プロジェクト名、説明、設定を更新します" @@ -702,6 +744,30 @@ "label": "ロールの管理", "description": "プロジェクトロールの作成・編集・削除" }, + "settingsTaskTypesRead": { + "label": "View Task Types", + "description": "View the project's task type definitions" + }, + "settingsTaskTypesWrite": { + "label": "Manage Task Types", + "description": "Create, edit, delete, and set the default task type" + }, + "settingsTaskStatusesRead": { + "label": "View Task Statuses", + "description": "View the project's task status definitions and their order" + }, + "settingsTaskStatusesWrite": { + "label": "Manage Task Statuses", + "description": "Create, edit, delete, reorder, and set the default task status" + }, + "settingsCustomFieldsRead": { + "label": "View Custom Fields", + "description": "View the project's custom field definitions" + }, + "settingsCustomFieldsWrite": { + "label": "Manage Custom Fields", + "description": "Create, edit, and delete custom field definitions" + }, "tasksRead": { "label": "タスクの閲覧", "description": "プロジェクト内のタスクを閲覧します" @@ -718,6 +784,14 @@ "label": "スプリントの管理", "description": "スプリントの作成・更新・終了を行います" }, + "viewsRead": { + "label": "View Boards", + "description": "Browse saved board and list views" + }, + "viewsWrite": { + "label": "Manage Boards", + "description": "Create, edit, delete, and reorder board and list views" + }, "docsRead": { "label": "ドキュメントの閲覧", "description": "プロジェクト内のドキュメントを閲覧します" @@ -754,6 +828,18 @@ "label": "環境への接続", "description": "実行中の環境内でインタラクティブなターミナルセッションを開きます" }, + "annotationsRead": { + "label": "View Annotations", + "description": "View page comments pinned via the browser extension" + }, + "annotationsWrite": { + "label": "Manage Annotations", + "description": "Create, edit, and delete page annotations" + }, + "annotationsResolve": { + "label": "Resolve Annotations", + "description": "Mark a page annotation resolved or reopen it, without authoring or deleting one" + }, "workflowsRead": { "label": "自動化を表示", "description": "自動化とその設定を閲覧する" @@ -766,13 +852,15 @@ "permissionGroups": { "project": "プロジェクト", "members": "メンバー", - "roles": "ロール", + "settings": "Settings", "tasks": "タスク", "sprints": "スプリント", + "views": "Views", "documents": "ドキュメント", "aiAgents": "AIエージェント", "conversations": "会話", "environments": "環境", + "annotations": "Annotations", "workflows": "自動化", "plugins": "プラグイン" } @@ -831,7 +919,11 @@ "permissions": "権限", "created": "作成日" }, - "systemRolesNote": "システムロールは共有テンプレートであり、編集・削除はできません。" + "systemRolesNote": "システムロールは共有テンプレートであり、編集・削除はできません。", + "noPermission": { + "title": "You don't have permission to view roles", + "description": "Ask a project admin to grant you the project.roles.read permission." + } }, "taskStatuses": { "title": "タスクステータス", @@ -851,7 +943,11 @@ "default": "既定", "setAsDefault": "既定のステータスに設定", "editStatus": "ステータスを編集", - "deleteStatus": "ステータスを削除" + "deleteStatus": "ステータスを削除", + "noPermission": { + "title": "You don't have permission to view task statuses", + "description": "Ask a project admin to grant you the project.settings.task_statuses.read permission." + } }, "taskTypes": { "title": "タスクタイプ", @@ -871,7 +967,11 @@ "default": "既定", "setAsDefault": "既定のタイプに設定", "editType": "タイプを編集", - "deleteType": "タイプを削除" + "deleteType": "タイプを削除", + "noPermission": { + "title": "You don't have permission to view task types", + "description": "Ask a project admin to grant you the project.settings.task_types.read permission." + } }, "customFields": { "title": "カスタムフィールド", @@ -942,6 +1042,10 @@ "confirmTextSuffix": "?このフィールドに保存されているタスクデータは失われます。この操作は取り消せません。", "deleteFailed": "フィールドの削除に失敗しました。もう一度お試しください。", "deleteField": "フィールドを削除" + }, + "noPermission": { + "title": "You don't have permission to view custom fields", + "description": "Ask a project admin to grant you the project.settings.custom_fields.read permission." } }, "dangerZone": { @@ -1037,6 +1141,10 @@ "removeFailed": "メンバーの削除に失敗しました。もう一度お試しください。", "cancel": "キャンセル", "remove": "削除" + }, + "noPermission": { + "title": "You don't have permission to view members", + "description": "Ask a project admin to grant you the project.members.read permission." } }, "aiChat": { @@ -1092,6 +1200,10 @@ "backToProject": "プロジェクトに戻る", "projectFallback": "プロジェクト" }, + "noPermission": { + "title": "You don't have permission to view this task", + "description": "Ask a project admin to grant you the tasks.read permission." + }, "header": { "created": "{{date}}に作成", "copied": "コピーしました!", @@ -1298,6 +1410,10 @@ "ariaLabel": "ポートフォワードの詳細", "backToEnvironment": "環境に戻る" }, + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view this port forward." + }, "tabs": { "overview": "概要", "comments": "コメント" @@ -1338,6 +1454,10 @@ "ariaLabel": "コメントの詳細", "backToPortForward": "ポートフォワードに戻る" }, + "noPermission": { + "title": "You don't have permission to view this comment", + "description": "Ask a project admin to grant you the annotations.read permission." + }, "header": { "createdAt": "{{date}} にコメント" }, @@ -1463,6 +1583,10 @@ "clearAll": "フィルターをクリア" }, "list": { + "noPermission": { + "title": "You don't have permission to view conversations", + "description": "Ask a project admin to grant you the conversations.read permission." + }, "empty": { "title": "会話はまだありません", "description": "タスクがエージェントに割り当てられるか、誰かがメッセージを送ると会話が始まります。" @@ -1720,7 +1844,11 @@ "createFirst": "最初の自動化を作成" }, "noDescription": "説明なし", - "updated": "{{time}}に更新" + "updated": "{{time}}に更新", + "noPermission": { + "title": "You don't have permission to view automations", + "description": "Ask a project admin to grant you the workflows.read permission." + } }, "status": { "active": "有効", diff --git a/apps/web/src/i18n/locales/ko/admin.json b/apps/web/src/i18n/locales/ko/admin.json index 2abeec60f..5947e2c90 100644 --- a/apps/web/src/i18n/locales/ko/admin.json +++ b/apps/web/src/i18n/locales/ko/admin.json @@ -289,6 +289,10 @@ "title": "아직 전역 에이전트가 없습니다", "description": "전역 에이전트를 만들면 채팅과 프로젝트 초대에 사용할 수 있습니다.", "createAgent": "에이전트 생성" + }, + "noPermission": { + "title": "You don't have permission to view agents", + "description": "You can still create new agents using the button above." } }, "settings": { diff --git a/apps/web/src/i18n/locales/ko/common.json b/apps/web/src/i18n/locales/ko/common.json index 385edd293..55dbb5220 100644 --- a/apps/web/src/i18n/locales/ko/common.json +++ b/apps/web/src/i18n/locales/ko/common.json @@ -1,4 +1,10 @@ { + "common": { + "notFound": "Not found", + "somethingWentWrong": "Something went wrong", + "retry": "Retry", + "noPermissionToView": "You don't have permission to view this" + }, "dialog": { "closeLabel": "닫기", "closeButton": "닫기" diff --git a/apps/web/src/i18n/locales/ko/projects.json b/apps/web/src/i18n/locales/ko/projects.json index 4091d13e2..2e113245d 100644 --- a/apps/web/src/i18n/locales/ko/projects.json +++ b/apps/web/src/i18n/locales/ko/projects.json @@ -92,6 +92,8 @@ "card": { "configure": "구성", "delete": "삭제", + "restricted": "Restricted", + "restrictedTooltip": "This agent is restricted — ask a project admin to grant you access before you can chat with it.", "acpStatusConnected": "온라인", "acpStatusDisconnected": "오프라인", "deleteDialog": { @@ -111,6 +113,10 @@ "title": "아직 에이전트가 없습니다", "description": "작업 자동화, 코드 리뷰, 문서 작성 등을 위해 AI 에이전트를 추가하세요.", "createFirstAgent": "첫 에이전트 만들기" + }, + "noPermission": { + "title": "You don't have permission to view agents", + "description": "Ask a project admin to grant you the agents.read permission." } }, "acpSetup": { @@ -157,6 +163,7 @@ "mcpServers": "MCP 서버", "skills": "스킬", "envVars": "환경 변수", + "access": "Access", "activity": "활동" }, "avatar": { @@ -290,6 +297,13 @@ "addSkill": "스킬 추가" } }, + "access": { + "restrictLabel": "Restrict access", + "restrictDescription": "When on, only members granted access below may chat with this agent. Everyone who can manage agents can still see and configure it.", + "pickMember": "Select a member…", + "grantAccess": "Grant access", + "empty": "No members have been granted access yet." + }, "activity": { "commented": "댓글을 남겼습니다", "sourceType": { @@ -318,6 +332,10 @@ "conversationView": { "stop": "중지", "notFound": "대화를 찾을 수 없습니다", + "noPermission": { + "title": "You don't have permission to view this conversation", + "description": "Ask a project admin to grant you the conversations.read permission." + }, "chatSession": "채팅 세션", "taskSession": "작업 세션", "pr": "PR", @@ -418,10 +436,16 @@ "title": "환경", "subtitle": "여러 대화에 걸쳐 에이전트가 연결할 수 있는 이름이 지정된 장기 실행 샌드박스", "newEnvironment": "새 환경", + "restricted": "Restricted", + "restrictedTooltip": "This environment is restricted — ask a project admin to grant you access before you can use it.", "empty": { "title": "아직 환경이 없습니다", "description": "매번 새로 만드는 일회용 샌드박스 대신, 대화 간에 파일과 백그라운드 프로세스를 유지하는 정적 환경을 만드세요.", "createFirstEnvironment": "첫 환경 만들기" + }, + "noPermission": { + "title": "You don't have permission to view environments", + "description": "Ask a project admin to grant you the environments.read permission." } }, "createDialog": { @@ -455,7 +479,8 @@ "tabs": { "overview": "개요", "folders": "폴더", - "portForwards": "포트 포워드" + "portForwards": "포트 포워드", + "access": "Access" }, "overview": { "connect": "연결", @@ -500,6 +525,10 @@ "count_one": "폴더 {{count}}개 구성됨", "count_other": "폴더 {{count}}개 구성됨", "addFolder": "폴더 추가", + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view its folders." + }, "deleteFailed": "폴더를 삭제하지 못했습니다. 다시 시도해 주세요.", "empty": { "title": "추가된 폴더가 없습니다", @@ -526,6 +555,10 @@ "count_one": "포트 포워드 {{count}}개", "count_other": "포트 포워드 {{count}}개", "add": "포트 포워드 추가", + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view its port forwards." + }, "deleteFailed": "포트 포워드를 삭제하지 못했습니다. 다시 시도해 주세요.", "containerPort": "컨테이너 포트 {{port}}", "unassigned": "아직 할당되지 않음", @@ -555,6 +588,13 @@ "confirm": "재시작" } }, + "access": { + "restrictLabel": "Restrict access", + "restrictDescription": "When on, only members granted access below may browse, SSH into, forward ports on, or open a terminal in this environment. Everyone who can manage environments can still see and configure it.", + "pickMember": "Select a member…", + "grantAccess": "Grant access", + "empty": "No members have been granted access yet." + }, "sshKeys": { "connectHint": "를 이 배포에 구성된 SSH 배스천 주소로 바꾸세요.", "connectUnavailable": "이 배포에는 아직 SSH 액세스가 구성되지 않았습니다.", @@ -602,6 +642,7 @@ "connect": "연결", "notRunning": "연결하기 전에 환경을 시작하세요.", "readOnly": "이 환경에서 터미널을 열 권한이 없습니다.", + "restricted": "This environment is restricted — ask a project admin to grant you access before you can open a terminal.", "hint": "새 전체 화면 탭에서 열립니다.", "pageTitle": "{{name}} — 터미널", "pageTitleLoading": "터미널", @@ -612,7 +653,8 @@ "step1Description": "연결할 SSH 키 쌍의 공개 키를 등록하세요.", "step2Title": "터미널에서 연결", "step2Description": "터미널에서 다음 명령을 실행하세요:", - "notRunning": "연결하기 전에 환경을 시작하세요." + "notRunning": "연결하기 전에 환경을 시작하세요.", + "restricted": "This environment is restricted — ask a project admin to grant you access before you can connect via SSH." } } }, @@ -622,6 +664,10 @@ "title": "문서를 찾을 수 없습니다", "description": "이 문서는 삭제되었거나 링크가 잘못되었을 수 있습니다." }, + "noPermission": { + "title": "You don't have permission to view this document", + "description": "Ask a project admin to grant you the docs.read permission." + }, "unsaved": "저장되지 않음", "saving": "저장 중…", "saved": "저장됨", @@ -674,10 +720,6 @@ } }, "permissions": { - "projectsRead": { - "label": "프로젝트 조회", - "description": "프로젝트 세부정보 및 설정 보기" - }, "projectsWrite": { "label": "프로젝트 수정", "description": "프로젝트 이름, 설명, 설정 업데이트" @@ -702,6 +744,30 @@ "label": "역할 관리", "description": "프로젝트 역할 생성, 수정, 삭제" }, + "settingsTaskTypesRead": { + "label": "View Task Types", + "description": "View the project's task type definitions" + }, + "settingsTaskTypesWrite": { + "label": "Manage Task Types", + "description": "Create, edit, delete, and set the default task type" + }, + "settingsTaskStatusesRead": { + "label": "View Task Statuses", + "description": "View the project's task status definitions and their order" + }, + "settingsTaskStatusesWrite": { + "label": "Manage Task Statuses", + "description": "Create, edit, delete, reorder, and set the default task status" + }, + "settingsCustomFieldsRead": { + "label": "View Custom Fields", + "description": "View the project's custom field definitions" + }, + "settingsCustomFieldsWrite": { + "label": "Manage Custom Fields", + "description": "Create, edit, and delete custom field definitions" + }, "tasksRead": { "label": "작업 보기", "description": "프로젝트의 작업 탐색 및 읽기" @@ -718,6 +784,14 @@ "label": "스프린트 관리", "description": "스프린트 생성, 업데이트, 종료" }, + "viewsRead": { + "label": "View Boards", + "description": "Browse saved board and list views" + }, + "viewsWrite": { + "label": "Manage Boards", + "description": "Create, edit, delete, and reorder board and list views" + }, "docsRead": { "label": "문서 보기", "description": "프로젝트의 문서 탐색 및 읽기" @@ -754,6 +828,18 @@ "label": "환경에 연결", "description": "실행 중인 환경 내에서 대화형 터미널 세션 열기" }, + "annotationsRead": { + "label": "View Annotations", + "description": "View page comments pinned via the browser extension" + }, + "annotationsWrite": { + "label": "Manage Annotations", + "description": "Create, edit, and delete page annotations" + }, + "annotationsResolve": { + "label": "Resolve Annotations", + "description": "Mark a page annotation resolved or reopen it, without authoring or deleting one" + }, "workflowsRead": { "label": "자동화 보기", "description": "자동화 및 해당 설정 조회" @@ -766,13 +852,15 @@ "permissionGroups": { "project": "프로젝트", "members": "멤버", - "roles": "역할", + "settings": "Settings", "tasks": "작업", "sprints": "스프린트", + "views": "Views", "documents": "문서", "aiAgents": "AI 에이전트", "conversations": "대화", "environments": "환경", + "annotations": "Annotations", "workflows": "자동화", "plugins": "플러그인" } @@ -831,7 +919,11 @@ "permissions": "권한", "created": "생성일" }, - "systemRolesNote": "시스템 역할은 공유 템플릿이며 수정하거나 삭제할 수 없습니다." + "systemRolesNote": "시스템 역할은 공유 템플릿이며 수정하거나 삭제할 수 없습니다.", + "noPermission": { + "title": "You don't have permission to view roles", + "description": "Ask a project admin to grant you the project.roles.read permission." + } }, "taskStatuses": { "title": "작업 상태", @@ -851,7 +943,11 @@ "default": "기본값", "setAsDefault": "기본 상태로 설정", "editStatus": "상태 수정", - "deleteStatus": "상태 삭제" + "deleteStatus": "상태 삭제", + "noPermission": { + "title": "You don't have permission to view task statuses", + "description": "Ask a project admin to grant you the project.settings.task_statuses.read permission." + } }, "taskTypes": { "title": "작업 유형", @@ -871,7 +967,11 @@ "default": "기본값", "setAsDefault": "기본 유형으로 설정", "editType": "유형 수정", - "deleteType": "유형 삭제" + "deleteType": "유형 삭제", + "noPermission": { + "title": "You don't have permission to view task types", + "description": "Ask a project admin to grant you the project.settings.task_types.read permission." + } }, "customFields": { "title": "커스텀 필드", @@ -942,6 +1042,10 @@ "confirmTextSuffix": "? 이 필드에 저장된 작업 데이터가 사라집니다. 이 작업은 되돌릴 수 없습니다.", "deleteFailed": "필드 삭제에 실패했습니다. 다시 시도해 주세요.", "deleteField": "필드 삭제" + }, + "noPermission": { + "title": "You don't have permission to view custom fields", + "description": "Ask a project admin to grant you the project.settings.custom_fields.read permission." } }, "dangerZone": { @@ -1037,6 +1141,10 @@ "removeFailed": "멤버 제거에 실패했습니다. 다시 시도해 주세요.", "cancel": "취소", "remove": "제거" + }, + "noPermission": { + "title": "You don't have permission to view members", + "description": "Ask a project admin to grant you the project.members.read permission." } }, "aiChat": { @@ -1092,6 +1200,10 @@ "backToProject": "프로젝트로 돌아가기", "projectFallback": "프로젝트" }, + "noPermission": { + "title": "You don't have permission to view this task", + "description": "Ask a project admin to grant you the tasks.read permission." + }, "header": { "created": "{{date}}에 생성됨", "copied": "복사됨!", @@ -1298,6 +1410,10 @@ "ariaLabel": "포트 포워드 세부 정보", "backToEnvironment": "환경으로 돌아가기" }, + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view this port forward." + }, "tabs": { "overview": "개요", "comments": "댓글" @@ -1338,6 +1454,10 @@ "ariaLabel": "댓글 세부 정보", "backToPortForward": "포트 포워드로 돌아가기" }, + "noPermission": { + "title": "You don't have permission to view this comment", + "description": "Ask a project admin to grant you the annotations.read permission." + }, "header": { "createdAt": "{{date}}에 댓글 작성됨" }, @@ -1463,6 +1583,10 @@ "clearAll": "필터 초기화" }, "list": { + "noPermission": { + "title": "You don't have permission to view conversations", + "description": "Ask a project admin to grant you the conversations.read permission." + }, "empty": { "title": "아직 대화가 없습니다", "description": "에이전트에게 작업이 배정되거나 누군가 메시지를 보내면 대화가 시작됩니다." @@ -1720,7 +1844,11 @@ "createFirst": "첫 자동화 만들기" }, "noDescription": "설명 없음", - "updated": "{{time}}에 업데이트됨" + "updated": "{{time}}에 업데이트됨", + "noPermission": { + "title": "You don't have permission to view automations", + "description": "Ask a project admin to grant you the workflows.read permission." + } }, "status": { "active": "활성", diff --git a/apps/web/src/i18n/locales/pt-BR/admin.json b/apps/web/src/i18n/locales/pt-BR/admin.json index 31fb0169d..51b667d43 100644 --- a/apps/web/src/i18n/locales/pt-BR/admin.json +++ b/apps/web/src/i18n/locales/pt-BR/admin.json @@ -289,6 +289,10 @@ "title": "Ainda não há agentes globais", "description": "Crie um agente global para disponibilizá-lo para conversas e convites de projeto.", "createAgent": "Criar agente" + }, + "noPermission": { + "title": "You don't have permission to view agents", + "description": "You can still create new agents using the button above." } }, "settings": { diff --git a/apps/web/src/i18n/locales/pt-BR/common.json b/apps/web/src/i18n/locales/pt-BR/common.json index 6e98aa118..c1dd66ba5 100644 --- a/apps/web/src/i18n/locales/pt-BR/common.json +++ b/apps/web/src/i18n/locales/pt-BR/common.json @@ -1,4 +1,10 @@ { + "common": { + "notFound": "Not found", + "somethingWentWrong": "Something went wrong", + "retry": "Retry", + "noPermissionToView": "You don't have permission to view this" + }, "dialog": { "closeLabel": "Fechar", "closeButton": "Fechar" diff --git a/apps/web/src/i18n/locales/pt-BR/projects.json b/apps/web/src/i18n/locales/pt-BR/projects.json index fd9aebc79..b723e6294 100644 --- a/apps/web/src/i18n/locales/pt-BR/projects.json +++ b/apps/web/src/i18n/locales/pt-BR/projects.json @@ -92,6 +92,8 @@ "card": { "configure": "Configurar", "delete": "Excluir", + "restricted": "Restricted", + "restrictedTooltip": "This agent is restricted — ask a project admin to grant you access before you can chat with it.", "acpStatusConnected": "Online", "acpStatusDisconnected": "Offline", "deleteDialog": { @@ -111,6 +113,10 @@ "title": "Nenhum agente ainda", "description": "Adicione um agente de IA para automatizar tarefas, revisar código, escrever documentação e muito mais.", "createFirstAgent": "Crie seu primeiro agente" + }, + "noPermission": { + "title": "You don't have permission to view agents", + "description": "Ask a project admin to grant you the agents.read permission." } }, "acpSetup": { @@ -157,6 +163,7 @@ "mcpServers": "Servidores MCP", "skills": "Skills", "envVars": "Ambiente", + "access": "Access", "activity": "Atividade" }, "avatar": { @@ -290,6 +297,13 @@ "addSkill": "Adicionar skill" } }, + "access": { + "restrictLabel": "Restrict access", + "restrictDescription": "When on, only members granted access below may chat with this agent. Everyone who can manage agents can still see and configure it.", + "pickMember": "Select a member…", + "grantAccess": "Grant access", + "empty": "No members have been granted access yet." + }, "activity": { "commented": "comentou", "sourceType": { @@ -318,6 +332,10 @@ "conversationView": { "stop": "Parar", "notFound": "Conversa não encontrada", + "noPermission": { + "title": "You don't have permission to view this conversation", + "description": "Ask a project admin to grant you the conversations.read permission." + }, "chatSession": "Sessão de chat", "taskSession": "Sessão de tarefa", "pr": "PR", @@ -418,10 +436,16 @@ "title": "Ambientes", "subtitle": "Sandboxes nomeados e de longa duração aos quais seus agentes podem se conectar entre conversas", "newEnvironment": "Novo ambiente", + "restricted": "Restricted", + "restrictedTooltip": "This environment is restricted — ask a project admin to grant you access before you can use it.", "empty": { "title": "Ainda não há ambientes", "description": "Crie um ambiente estático para manter arquivos e processos em segundo plano ativos entre conversas, em vez de um sandbox descartável a cada vez.", "createFirstEnvironment": "Crie seu primeiro ambiente" + }, + "noPermission": { + "title": "You don't have permission to view environments", + "description": "Ask a project admin to grant you the environments.read permission." } }, "createDialog": { @@ -455,7 +479,8 @@ "tabs": { "overview": "Visão geral", "folders": "Pastas", - "portForwards": "Encaminhamentos de porta" + "portForwards": "Encaminhamentos de porta", + "access": "Access" }, "overview": { "connect": "Conectar", @@ -500,6 +525,10 @@ "count_one": "{{count}} pasta configurada", "count_other": "{{count}} pastas configuradas", "addFolder": "Adicionar pasta", + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view its folders." + }, "deleteFailed": "Falha ao excluir a pasta. Tente novamente.", "empty": { "title": "Nenhuma pasta adicionada", @@ -526,6 +555,10 @@ "count_one": "{{count}} encaminhamento de porta", "count_other": "{{count}} encaminhamentos de porta", "add": "Adicionar encaminhamento de porta", + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view its port forwards." + }, "deleteFailed": "Falha ao excluir o encaminhamento de porta. Tente novamente.", "containerPort": "Porta do contêiner {{port}}", "unassigned": "Ainda não atribuída", @@ -555,6 +588,13 @@ "confirm": "Reiniciar" } }, + "access": { + "restrictLabel": "Restrict access", + "restrictDescription": "When on, only members granted access below may browse, SSH into, forward ports on, or open a terminal in this environment. Everyone who can manage environments can still see and configure it.", + "pickMember": "Select a member…", + "grantAccess": "Grant access", + "empty": "No members have been granted access yet." + }, "sshKeys": { "connectHint": "Substitua pelo endereço do bastion SSH configurado nesta implantação.", "connectUnavailable": "O acesso SSH ainda não está configurado nesta implantação.", @@ -602,6 +642,7 @@ "connect": "Conectar", "notRunning": "Inicie o ambiente antes de conectar.", "readOnly": "Você não tem permissão para abrir um terminal neste ambiente.", + "restricted": "This environment is restricted — ask a project admin to grant you access before you can open a terminal.", "hint": "Abre em uma nova guia em tela cheia.", "pageTitle": "{{name}} — Terminal", "pageTitleLoading": "Terminal", @@ -612,7 +653,8 @@ "step1Description": "Registre a chave pública do par de chaves SSH com o qual deseja se conectar.", "step2Title": "Conecte-se pelo terminal", "step2Description": "Execute este comando no seu terminal:", - "notRunning": "Inicie o ambiente antes de conectar." + "notRunning": "Inicie o ambiente antes de conectar.", + "restricted": "This environment is restricted — ask a project admin to grant you access before you can connect via SSH." } } }, @@ -622,6 +664,10 @@ "title": "Documento não encontrado", "description": "Este documento pode ter sido excluído ou o link é inválido." }, + "noPermission": { + "title": "You don't have permission to view this document", + "description": "Ask a project admin to grant you the docs.read permission." + }, "unsaved": "Não salvo", "saving": "Salvando…", "saved": "Salvo", @@ -674,10 +720,6 @@ } }, "permissions": { - "projectsRead": { - "label": "Ler projeto", - "description": "Ver detalhes e configurações do projeto" - }, "projectsWrite": { "label": "Editar projeto", "description": "Atualizar nome, descrição e configurações do projeto" @@ -702,6 +744,30 @@ "label": "Gerenciar papéis", "description": "Criar, editar e excluir papéis do projeto" }, + "settingsTaskTypesRead": { + "label": "View Task Types", + "description": "View the project's task type definitions" + }, + "settingsTaskTypesWrite": { + "label": "Manage Task Types", + "description": "Create, edit, delete, and set the default task type" + }, + "settingsTaskStatusesRead": { + "label": "View Task Statuses", + "description": "View the project's task status definitions and their order" + }, + "settingsTaskStatusesWrite": { + "label": "Manage Task Statuses", + "description": "Create, edit, delete, reorder, and set the default task status" + }, + "settingsCustomFieldsRead": { + "label": "View Custom Fields", + "description": "View the project's custom field definitions" + }, + "settingsCustomFieldsWrite": { + "label": "Manage Custom Fields", + "description": "Create, edit, and delete custom field definitions" + }, "tasksRead": { "label": "Ver tarefas", "description": "Navegar e ler as tarefas do projeto" @@ -718,6 +784,14 @@ "label": "Gerenciar sprints", "description": "Criar, atualizar e encerrar sprints" }, + "viewsRead": { + "label": "View Boards", + "description": "Browse saved board and list views" + }, + "viewsWrite": { + "label": "Manage Boards", + "description": "Create, edit, delete, and reorder board and list views" + }, "docsRead": { "label": "Ver documentos", "description": "Navegar e ler os documentos do projeto" @@ -754,6 +828,18 @@ "label": "Conectar aos ambientes", "description": "Abrir uma sessão de terminal interativa dentro de um ambiente em execução" }, + "annotationsRead": { + "label": "View Annotations", + "description": "View page comments pinned via the browser extension" + }, + "annotationsWrite": { + "label": "Manage Annotations", + "description": "Create, edit, and delete page annotations" + }, + "annotationsResolve": { + "label": "Resolve Annotations", + "description": "Mark a page annotation resolved or reopen it, without authoring or deleting one" + }, "workflowsRead": { "label": "Ver automação", "description": "Navegar pelas automações e suas configurações" @@ -766,13 +852,15 @@ "permissionGroups": { "project": "Projeto", "members": "Membros", - "roles": "Papéis", + "settings": "Settings", "tasks": "Tarefas", "sprints": "Sprints", + "views": "Views", "documents": "Documentos", "aiAgents": "Agentes de IA", "conversations": "Conversas", "environments": "Ambientes", + "annotations": "Annotations", "workflows": "Automação", "plugins": "Plugins" } @@ -831,7 +919,11 @@ "permissions": "Permissões", "created": "Criado em" }, - "systemRolesNote": "Papéis de sistema são modelos compartilhados e não podem ser editados ou excluídos." + "systemRolesNote": "Papéis de sistema são modelos compartilhados e não podem ser editados ou excluídos.", + "noPermission": { + "title": "You don't have permission to view roles", + "description": "Ask a project admin to grant you the project.roles.read permission." + } }, "taskStatuses": { "title": "Status de tarefas", @@ -851,7 +943,11 @@ "default": "Padrão", "setAsDefault": "Definir como status padrão", "editStatus": "Editar status", - "deleteStatus": "Excluir status" + "deleteStatus": "Excluir status", + "noPermission": { + "title": "You don't have permission to view task statuses", + "description": "Ask a project admin to grant you the project.settings.task_statuses.read permission." + } }, "taskTypes": { "title": "Tipos de tarefa", @@ -871,7 +967,11 @@ "default": "Padrão", "setAsDefault": "Definir como tipo padrão", "editType": "Editar tipo", - "deleteType": "Excluir tipo" + "deleteType": "Excluir tipo", + "noPermission": { + "title": "You don't have permission to view task types", + "description": "Ask a project admin to grant you the project.settings.task_types.read permission." + } }, "customFields": { "title": "Campos personalizados", @@ -942,6 +1042,10 @@ "confirmTextSuffix": "? Os dados de tarefa armazenados neste campo serão perdidos. Esta ação não pode ser desfeita.", "deleteFailed": "Falha ao excluir o campo. Tente novamente.", "deleteField": "Excluir campo" + }, + "noPermission": { + "title": "You don't have permission to view custom fields", + "description": "Ask a project admin to grant you the project.settings.custom_fields.read permission." } }, "dangerZone": { @@ -1037,6 +1141,10 @@ "removeFailed": "Falha ao remover o membro. Tente novamente.", "cancel": "Cancelar", "remove": "Remover" + }, + "noPermission": { + "title": "You don't have permission to view members", + "description": "Ask a project admin to grant you the project.members.read permission." } }, "aiChat": { @@ -1092,6 +1200,10 @@ "backToProject": "Voltar ao projeto", "projectFallback": "Projeto" }, + "noPermission": { + "title": "You don't have permission to view this task", + "description": "Ask a project admin to grant you the tasks.read permission." + }, "header": { "created": "Criada em {{date}}", "copied": "Copiado!", @@ -1298,6 +1410,10 @@ "ariaLabel": "Detalhes do encaminhamento de porta", "backToEnvironment": "Voltar ao ambiente" }, + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view this port forward." + }, "tabs": { "overview": "Visão geral", "comments": "Comentários" @@ -1338,6 +1454,10 @@ "ariaLabel": "Detalhes do comentário", "backToPortForward": "Voltar ao encaminhamento de porta" }, + "noPermission": { + "title": "You don't have permission to view this comment", + "description": "Ask a project admin to grant you the annotations.read permission." + }, "header": { "createdAt": "Comentado em {{date}}" }, @@ -1463,6 +1583,10 @@ "clearAll": "Limpar filtros" }, "list": { + "noPermission": { + "title": "You don't have permission to view conversations", + "description": "Ask a project admin to grant you the conversations.read permission." + }, "empty": { "title": "Nenhuma conversa ainda", "description": "As conversas começam quando uma tarefa é atribuída a um agente ou quando alguém envia uma mensagem a ele." @@ -1720,7 +1844,11 @@ "createFirst": "Crie sua primeira automação" }, "noDescription": "Sem descrição", - "updated": "Atualizado {{time}}" + "updated": "Atualizado {{time}}", + "noPermission": { + "title": "You don't have permission to view automations", + "description": "Ask a project admin to grant you the workflows.read permission." + } }, "status": { "active": "Ativo", diff --git a/apps/web/src/i18n/locales/ru/admin.json b/apps/web/src/i18n/locales/ru/admin.json index 21d956a00..0c614d1c1 100644 --- a/apps/web/src/i18n/locales/ru/admin.json +++ b/apps/web/src/i18n/locales/ru/admin.json @@ -295,6 +295,10 @@ "title": "Глобальных агентов пока нет", "description": "Создайте глобального агента, чтобы сделать его доступным для чата и приглашений в проекты.", "createAgent": "Создать агента" + }, + "noPermission": { + "title": "You don't have permission to view agents", + "description": "You can still create new agents using the button above." } }, "settings": { diff --git a/apps/web/src/i18n/locales/ru/common.json b/apps/web/src/i18n/locales/ru/common.json index 17686a5e5..8e101b9ad 100644 --- a/apps/web/src/i18n/locales/ru/common.json +++ b/apps/web/src/i18n/locales/ru/common.json @@ -1,4 +1,10 @@ { + "common": { + "notFound": "Not found", + "somethingWentWrong": "Something went wrong", + "retry": "Retry", + "noPermissionToView": "You don't have permission to view this" + }, "dialog": { "closeLabel": "Закрыть", "closeButton": "Закрыть" diff --git a/apps/web/src/i18n/locales/ru/projects.json b/apps/web/src/i18n/locales/ru/projects.json index 5b9172c2e..b9346437f 100644 --- a/apps/web/src/i18n/locales/ru/projects.json +++ b/apps/web/src/i18n/locales/ru/projects.json @@ -92,6 +92,8 @@ "card": { "configure": "Настроить", "delete": "Удалить", + "restricted": "Restricted", + "restrictedTooltip": "This agent is restricted — ask a project admin to grant you access before you can chat with it.", "acpStatusConnected": "В сети", "acpStatusDisconnected": "Не в сети", "deleteDialog": { @@ -111,6 +113,10 @@ "title": "Агентов пока нет", "description": "Добавьте AI-агента, чтобы автоматизировать задачи, ревью кода, написание документации и другое.", "createFirstAgent": "Создать первого агента" + }, + "noPermission": { + "title": "You don't have permission to view agents", + "description": "Ask a project admin to grant you the agents.read permission." } }, "acpSetup": { @@ -157,6 +163,7 @@ "mcpServers": "MCP-серверы", "skills": "Навыки", "envVars": "Окружение", + "access": "Access", "activity": "Активность" }, "avatar": { @@ -296,6 +303,13 @@ "addSkill": "Добавить навык" } }, + "access": { + "restrictLabel": "Restrict access", + "restrictDescription": "When on, only members granted access below may chat with this agent. Everyone who can manage agents can still see and configure it.", + "pickMember": "Select a member…", + "grantAccess": "Grant access", + "empty": "No members have been granted access yet." + }, "activity": { "commented": "оставил комментарий", "sourceType": { @@ -324,6 +338,10 @@ "conversationView": { "stop": "Остановить", "notFound": "Диалог не найден", + "noPermission": { + "title": "You don't have permission to view this conversation", + "description": "Ask a project admin to grant you the conversations.read permission." + }, "chatSession": "Чат-сессия", "taskSession": "Сессия задачи", "pr": "PR", @@ -428,10 +446,16 @@ "title": "Окружения", "subtitle": "Именованные, долгоживущие песочницы, к которым ваши агенты могут подключаться между разговорами", "newEnvironment": "Новое окружение", + "restricted": "Restricted", + "restrictedTooltip": "This environment is restricted — ask a project admin to grant you access before you can use it.", "empty": { "title": "Пока нет окружений", "description": "Создайте статическое окружение, чтобы файлы и фоновые процессы сохранялись между разговорами, вместо одноразовой песочницы каждый раз.", "createFirstEnvironment": "Создать первое окружение" + }, + "noPermission": { + "title": "You don't have permission to view environments", + "description": "Ask a project admin to grant you the environments.read permission." } }, "createDialog": { @@ -465,7 +489,8 @@ "tabs": { "overview": "Обзор", "folders": "Папки", - "portForwards": "Проброс портов" + "portForwards": "Проброс портов", + "access": "Access" }, "overview": { "connect": "Подключиться", @@ -510,6 +535,10 @@ "count_one": "Настроена {{count}} папка", "count_other": "Настроено {{count}} папок", "addFolder": "Добавить папку", + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view its folders." + }, "deleteFailed": "Не удалось удалить папку. Попробуйте снова.", "empty": { "title": "Папки не добавлены", @@ -536,6 +565,10 @@ "count_one": "{{count}} проброс порта", "count_other": "{{count}} пробросов портов", "add": "Добавить проброс порта", + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view its port forwards." + }, "deleteFailed": "Не удалось удалить проброс порта. Попробуйте снова.", "containerPort": "Порт контейнера {{port}}", "unassigned": "Ещё не назначен", @@ -565,6 +598,13 @@ "confirm": "Перезапустить" } }, + "access": { + "restrictLabel": "Restrict access", + "restrictDescription": "When on, only members granted access below may browse, SSH into, forward ports on, or open a terminal in this environment. Everyone who can manage environments can still see and configure it.", + "pickMember": "Select a member…", + "grantAccess": "Grant access", + "empty": "No members have been granted access yet." + }, "sshKeys": { "connectHint": "Замените адресом SSH-бастиона, настроенным в этом развёртывании.", "connectUnavailable": "SSH-доступ ещё не настроен в этом развёртывании.", @@ -612,6 +652,7 @@ "connect": "Подключиться", "notRunning": "Запустите окружение перед подключением.", "readOnly": "У вас нет прав на открытие терминала в этом окружении.", + "restricted": "This environment is restricted — ask a project admin to grant you access before you can open a terminal.", "hint": "Откроется в новой полноэкранной вкладке.", "pageTitle": "{{name}} — Терминал", "pageTitleLoading": "Терминал", @@ -622,7 +663,8 @@ "step1Description": "Зарегистрируйте открытый ключ пары SSH-ключей, с которой хотите подключаться.", "step2Title": "Подключитесь из терминала", "step2Description": "Выполните эту команду в терминале:", - "notRunning": "Запустите окружение перед подключением." + "notRunning": "Запустите окружение перед подключением.", + "restricted": "This environment is restricted — ask a project admin to grant you access before you can connect via SSH." } } }, @@ -632,6 +674,10 @@ "title": "Документ не найден", "description": "Возможно, документ был удалён или ссылка недействительна." }, + "noPermission": { + "title": "You don't have permission to view this document", + "description": "Ask a project admin to grant you the docs.read permission." + }, "unsaved": "Не сохранено", "saving": "Сохранение…", "saved": "Сохранено", @@ -686,10 +732,6 @@ } }, "permissions": { - "projectsRead": { - "label": "Читать проект", - "description": "Просматривать данные и настройки проекта" - }, "projectsWrite": { "label": "Редактировать проект", "description": "Обновлять название, описание и настройки проекта" @@ -714,6 +756,30 @@ "label": "Управлять ролями", "description": "Создавать, редактировать и удалять роли проекта" }, + "settingsTaskTypesRead": { + "label": "View Task Types", + "description": "View the project's task type definitions" + }, + "settingsTaskTypesWrite": { + "label": "Manage Task Types", + "description": "Create, edit, delete, and set the default task type" + }, + "settingsTaskStatusesRead": { + "label": "View Task Statuses", + "description": "View the project's task status definitions and their order" + }, + "settingsTaskStatusesWrite": { + "label": "Manage Task Statuses", + "description": "Create, edit, delete, reorder, and set the default task status" + }, + "settingsCustomFieldsRead": { + "label": "View Custom Fields", + "description": "View the project's custom field definitions" + }, + "settingsCustomFieldsWrite": { + "label": "Manage Custom Fields", + "description": "Create, edit, and delete custom field definitions" + }, "tasksRead": { "label": "Просматривать задачи", "description": "Открывать и читать задачи в проекте" @@ -730,6 +796,14 @@ "label": "Управлять спринтами", "description": "Создавать, обновлять и закрывать спринты" }, + "viewsRead": { + "label": "View Boards", + "description": "Browse saved board and list views" + }, + "viewsWrite": { + "label": "Manage Boards", + "description": "Create, edit, delete, and reorder board and list views" + }, "docsRead": { "label": "Просматривать документы", "description": "Открывать и читать документы проекта" @@ -766,6 +840,18 @@ "label": "Подключаться к окружениям", "description": "Открывать интерактивный сеанс терминала внутри запущенного окружения" }, + "annotationsRead": { + "label": "View Annotations", + "description": "View page comments pinned via the browser extension" + }, + "annotationsWrite": { + "label": "Manage Annotations", + "description": "Create, edit, and delete page annotations" + }, + "annotationsResolve": { + "label": "Resolve Annotations", + "description": "Mark a page annotation resolved or reopen it, without authoring or deleting one" + }, "workflowsRead": { "label": "Просмотр автоматизации", "description": "Просмотр автоматизаций и их настроек" @@ -778,13 +864,15 @@ "permissionGroups": { "project": "Проект", "members": "Участники", - "roles": "Роли", + "settings": "Settings", "tasks": "Задачи", "sprints": "Спринты", + "views": "Views", "documents": "Документы", "aiAgents": "AI-агенты", "conversations": "Беседы", "environments": "Окружения", + "annotations": "Annotations", "workflows": "Автоматизация", "plugins": "Плагины" } @@ -845,7 +933,11 @@ "permissions": "Разрешения", "created": "Создана" }, - "systemRolesNote": "Системные роли являются общими шаблонами, их нельзя редактировать или удалять." + "systemRolesNote": "Системные роли являются общими шаблонами, их нельзя редактировать или удалять.", + "noPermission": { + "title": "You don't have permission to view roles", + "description": "Ask a project admin to grant you the project.roles.read permission." + } }, "taskStatuses": { "title": "Статусы задач", @@ -865,7 +957,11 @@ "default": "По умолчанию", "setAsDefault": "Сделать статусом по умолчанию", "editStatus": "Редактировать статус", - "deleteStatus": "Удалить статус" + "deleteStatus": "Удалить статус", + "noPermission": { + "title": "You don't have permission to view task statuses", + "description": "Ask a project admin to grant you the project.settings.task_statuses.read permission." + } }, "taskTypes": { "title": "Типы задач", @@ -885,7 +981,11 @@ "default": "По умолчанию", "setAsDefault": "Сделать типом по умолчанию", "editType": "Редактировать тип", - "deleteType": "Удалить тип" + "deleteType": "Удалить тип", + "noPermission": { + "title": "You don't have permission to view task types", + "description": "Ask a project admin to grant you the project.settings.task_types.read permission." + } }, "customFields": { "title": "Пользовательские поля", @@ -956,6 +1056,10 @@ "confirmTextSuffix": "? Данные задач, сохранённые в этом поле, будут потеряны. Это действие нельзя отменить.", "deleteFailed": "Не удалось удалить поле. Попробуйте ещё раз.", "deleteField": "Удалить поле" + }, + "noPermission": { + "title": "You don't have permission to view custom fields", + "description": "Ask a project admin to grant you the project.settings.custom_fields.read permission." } }, "dangerZone": { @@ -1053,6 +1157,10 @@ "removeFailed": "Не удалось удалить участника. Попробуйте ещё раз.", "cancel": "Отмена", "remove": "Удалить" + }, + "noPermission": { + "title": "You don't have permission to view members", + "description": "Ask a project admin to grant you the project.members.read permission." } }, "aiChat": { @@ -1108,6 +1216,10 @@ "backToProject": "Назад к проекту", "projectFallback": "Проект" }, + "noPermission": { + "title": "You don't have permission to view this task", + "description": "Ask a project admin to grant you the tasks.read permission." + }, "header": { "created": "Создана {{date}}", "copied": "Скопировано!", @@ -1316,6 +1428,10 @@ "ariaLabel": "Сведения о переадресации порта", "backToEnvironment": "Назад к среде" }, + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view this port forward." + }, "tabs": { "overview": "Обзор", "comments": "Комментарии" @@ -1356,6 +1472,10 @@ "ariaLabel": "Сведения о комментарии", "backToPortForward": "Назад к переадресации порта" }, + "noPermission": { + "title": "You don't have permission to view this comment", + "description": "Ask a project admin to grant you the annotations.read permission." + }, "header": { "createdAt": "Прокомментировано {{date}}" }, @@ -1481,6 +1601,10 @@ "clearAll": "Сбросить фильтры" }, "list": { + "noPermission": { + "title": "You don't have permission to view conversations", + "description": "Ask a project admin to grant you the conversations.read permission." + }, "empty": { "title": "Диалогов пока нет", "description": "Диалоги начинаются, когда задача назначена агенту или кто-то пишет ему сообщение." @@ -1740,7 +1864,11 @@ "createFirst": "Создать первую автоматизацию" }, "noDescription": "Нет описания", - "updated": "Обновлено {{time}}" + "updated": "Обновлено {{time}}", + "noPermission": { + "title": "You don't have permission to view automations", + "description": "Ask a project admin to grant you the workflows.read permission." + } }, "status": { "active": "Активна", diff --git a/apps/web/src/i18n/locales/vi/admin.json b/apps/web/src/i18n/locales/vi/admin.json index f04eded0f..4045c0294 100644 --- a/apps/web/src/i18n/locales/vi/admin.json +++ b/apps/web/src/i18n/locales/vi/admin.json @@ -289,6 +289,10 @@ "title": "Chưa có agent toàn cục nào", "description": "Tạo một agent toàn cục để có thể trò chuyện và mời vào dự án.", "createAgent": "Tạo agent" + }, + "noPermission": { + "title": "You don't have permission to view agents", + "description": "You can still create new agents using the button above." } }, "settings": { diff --git a/apps/web/src/i18n/locales/vi/common.json b/apps/web/src/i18n/locales/vi/common.json index c8d81cea8..a5bf0b6e8 100644 --- a/apps/web/src/i18n/locales/vi/common.json +++ b/apps/web/src/i18n/locales/vi/common.json @@ -1,4 +1,10 @@ { + "common": { + "notFound": "Not found", + "somethingWentWrong": "Something went wrong", + "retry": "Retry", + "noPermissionToView": "You don't have permission to view this" + }, "dialog": { "closeLabel": "Đóng", "closeButton": "Đóng" diff --git a/apps/web/src/i18n/locales/vi/projects.json b/apps/web/src/i18n/locales/vi/projects.json index 2a907b822..0aff34b72 100644 --- a/apps/web/src/i18n/locales/vi/projects.json +++ b/apps/web/src/i18n/locales/vi/projects.json @@ -92,6 +92,8 @@ "card": { "configure": "Cấu hình", "delete": "Xóa", + "restricted": "Restricted", + "restrictedTooltip": "This agent is restricted — ask a project admin to grant you access before you can chat with it.", "acpStatusConnected": "Trực tuyến", "acpStatusDisconnected": "Ngoại tuyến", "deleteDialog": { @@ -111,6 +113,10 @@ "title": "Chưa có agent nào", "description": "Thêm một AI agent để tự động hóa nhiệm vụ, review code, viết tài liệu và nhiều hơn nữa.", "createFirstAgent": "Tạo agent đầu tiên của bạn" + }, + "noPermission": { + "title": "You don't have permission to view agents", + "description": "Ask a project admin to grant you the agents.read permission." } }, "acpSetup": { @@ -157,6 +163,7 @@ "mcpServers": "Máy chủ MCP", "skills": "Skill", "envVars": "Môi trường", + "access": "Access", "activity": "Hoạt động" }, "avatar": { @@ -290,6 +297,13 @@ "addSkill": "Thêm skill" } }, + "access": { + "restrictLabel": "Restrict access", + "restrictDescription": "When on, only members granted access below may chat with this agent. Everyone who can manage agents can still see and configure it.", + "pickMember": "Select a member…", + "grantAccess": "Grant access", + "empty": "No members have been granted access yet." + }, "activity": { "commented": "đã bình luận", "sourceType": { @@ -318,6 +332,10 @@ "conversationView": { "stop": "Dừng", "notFound": "Không tìm thấy hội thoại", + "noPermission": { + "title": "You don't have permission to view this conversation", + "description": "Ask a project admin to grant you the conversations.read permission." + }, "chatSession": "Phiên trò chuyện", "taskSession": "Phiên nhiệm vụ", "pr": "PR", @@ -418,10 +436,16 @@ "title": "Môi trường", "subtitle": "Sandbox có tên, hoạt động lâu dài mà agent của bạn có thể gắn vào giữa các cuộc hội thoại", "newEnvironment": "Môi trường mới", + "restricted": "Restricted", + "restrictedTooltip": "This environment is restricted — ask a project admin to grant you access before you can use it.", "empty": { "title": "Chưa có môi trường nào", "description": "Tạo một môi trường tĩnh để giữ tệp và tiến trình nền hoạt động xuyên suốt các cuộc hội thoại, thay vì tạo sandbox dùng một lần mỗi lần.", "createFirstEnvironment": "Tạo môi trường đầu tiên của bạn" + }, + "noPermission": { + "title": "You don't have permission to view environments", + "description": "Ask a project admin to grant you the environments.read permission." } }, "createDialog": { @@ -455,7 +479,8 @@ "tabs": { "overview": "Tổng quan", "folders": "Thư mục", - "portForwards": "Chuyển tiếp cổng" + "portForwards": "Chuyển tiếp cổng", + "access": "Access" }, "overview": { "connect": "Kết nối", @@ -500,6 +525,10 @@ "count_one": "Đã cấu hình {{count}} thư mục", "count_other": "Đã cấu hình {{count}} thư mục", "addFolder": "Thêm thư mục", + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view its folders." + }, "deleteFailed": "Không thể xóa thư mục. Vui lòng thử lại.", "empty": { "title": "Chưa có thư mục nào được thêm", @@ -526,6 +555,10 @@ "count_one": "{{count}} cổng chuyển tiếp", "count_other": "{{count}} cổng chuyển tiếp", "add": "Thêm cổng chuyển tiếp", + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view its port forwards." + }, "deleteFailed": "Không thể xóa cổng chuyển tiếp. Vui lòng thử lại.", "containerPort": "Cổng container {{port}}", "unassigned": "Chưa được gán", @@ -555,6 +588,13 @@ "confirm": "Khởi động lại" } }, + "access": { + "restrictLabel": "Restrict access", + "restrictDescription": "When on, only members granted access below may browse, SSH into, forward ports on, or open a terminal in this environment. Everyone who can manage environments can still see and configure it.", + "pickMember": "Select a member…", + "grantAccess": "Grant access", + "empty": "No members have been granted access yet." + }, "sshKeys": { "connectHint": "Thay bằng địa chỉ bastion SSH đã cấu hình trên triển khai này.", "connectUnavailable": "Truy cập SSH chưa được cấu hình trên triển khai này.", @@ -602,6 +642,7 @@ "connect": "Kết nối", "notRunning": "Khởi động môi trường trước khi kết nối.", "readOnly": "Bạn không có quyền mở terminal trên môi trường này.", + "restricted": "This environment is restricted — ask a project admin to grant you access before you can open a terminal.", "hint": "Mở trong một tab toàn màn hình mới.", "pageTitle": "{{name}} — Terminal", "pageTitleLoading": "Terminal", @@ -612,7 +653,8 @@ "step1Description": "Đăng ký khóa công khai của cặp khóa SSH bạn muốn dùng để kết nối.", "step2Title": "Kết nối từ terminal của bạn", "step2Description": "Chạy lệnh này từ terminal của bạn:", - "notRunning": "Khởi động môi trường trước khi kết nối." + "notRunning": "Khởi động môi trường trước khi kết nối.", + "restricted": "This environment is restricted — ask a project admin to grant you access before you can connect via SSH." } } }, @@ -622,6 +664,10 @@ "title": "Không tìm thấy tài liệu", "description": "Tài liệu này có thể đã bị xóa hoặc liên kết không hợp lệ." }, + "noPermission": { + "title": "You don't have permission to view this document", + "description": "Ask a project admin to grant you the docs.read permission." + }, "unsaved": "Chưa lưu", "saving": "Đang lưu…", "saved": "Đã lưu", @@ -674,10 +720,6 @@ } }, "permissions": { - "projectsRead": { - "label": "Xem dự án", - "description": "Xem chi tiết và cài đặt dự án" - }, "projectsWrite": { "label": "Sửa dự án", "description": "Cập nhật tên, mô tả và cài đặt dự án" @@ -702,6 +744,30 @@ "label": "Quản lý vai trò", "description": "Tạo, sửa và xóa vai trò dự án" }, + "settingsTaskTypesRead": { + "label": "View Task Types", + "description": "View the project's task type definitions" + }, + "settingsTaskTypesWrite": { + "label": "Manage Task Types", + "description": "Create, edit, delete, and set the default task type" + }, + "settingsTaskStatusesRead": { + "label": "View Task Statuses", + "description": "View the project's task status definitions and their order" + }, + "settingsTaskStatusesWrite": { + "label": "Manage Task Statuses", + "description": "Create, edit, delete, reorder, and set the default task status" + }, + "settingsCustomFieldsRead": { + "label": "View Custom Fields", + "description": "View the project's custom field definitions" + }, + "settingsCustomFieldsWrite": { + "label": "Manage Custom Fields", + "description": "Create, edit, and delete custom field definitions" + }, "tasksRead": { "label": "Xem nhiệm vụ", "description": "Duyệt và đọc nhiệm vụ trong dự án" @@ -718,6 +784,14 @@ "label": "Quản lý sprint", "description": "Tạo, cập nhật và đóng sprint" }, + "viewsRead": { + "label": "View Boards", + "description": "Browse saved board and list views" + }, + "viewsWrite": { + "label": "Manage Boards", + "description": "Create, edit, delete, and reorder board and list views" + }, "docsRead": { "label": "Xem tài liệu", "description": "Duyệt và đọc tài liệu trong dự án" @@ -754,6 +828,18 @@ "label": "Kết nối đến môi trường", "description": "Mở phiên terminal tương tác bên trong một môi trường đang chạy" }, + "annotationsRead": { + "label": "View Annotations", + "description": "View page comments pinned via the browser extension" + }, + "annotationsWrite": { + "label": "Manage Annotations", + "description": "Create, edit, and delete page annotations" + }, + "annotationsResolve": { + "label": "Resolve Annotations", + "description": "Mark a page annotation resolved or reopen it, without authoring or deleting one" + }, "workflowsRead": { "label": "Xem tự động hóa", "description": "Xem các tự động hóa và cấu hình của chúng" @@ -766,13 +852,15 @@ "permissionGroups": { "project": "Dự án", "members": "Thành viên", - "roles": "Vai trò", + "settings": "Settings", "tasks": "Nhiệm vụ", "sprints": "Sprint", + "views": "Views", "documents": "Tài liệu", "aiAgents": "AI Agent", "conversations": "Cuộc trò chuyện", "environments": "Môi trường", + "annotations": "Annotations", "workflows": "Tự động hóa", "plugins": "Plugin" } @@ -831,7 +919,11 @@ "permissions": "Quyền hạn", "created": "Ngày tạo" }, - "systemRolesNote": "Vai trò hệ thống là mẫu dùng chung và không thể chỉnh sửa hoặc xóa." + "systemRolesNote": "Vai trò hệ thống là mẫu dùng chung và không thể chỉnh sửa hoặc xóa.", + "noPermission": { + "title": "You don't have permission to view roles", + "description": "Ask a project admin to grant you the project.roles.read permission." + } }, "taskStatuses": { "title": "Trạng thái nhiệm vụ", @@ -851,7 +943,11 @@ "default": "Mặc định", "setAsDefault": "Đặt làm trạng thái mặc định", "editStatus": "Chỉnh sửa trạng thái", - "deleteStatus": "Xóa trạng thái" + "deleteStatus": "Xóa trạng thái", + "noPermission": { + "title": "You don't have permission to view task statuses", + "description": "Ask a project admin to grant you the project.settings.task_statuses.read permission." + } }, "taskTypes": { "title": "Loại nhiệm vụ", @@ -871,7 +967,11 @@ "default": "Mặc định", "setAsDefault": "Đặt làm loại mặc định", "editType": "Chỉnh sửa loại", - "deleteType": "Xóa loại" + "deleteType": "Xóa loại", + "noPermission": { + "title": "You don't have permission to view task types", + "description": "Ask a project admin to grant you the project.settings.task_types.read permission." + } }, "customFields": { "title": "Trường tùy chỉnh", @@ -942,6 +1042,10 @@ "confirmTextSuffix": "? Dữ liệu nhiệm vụ lưu trong trường này sẽ bị mất. Hành động này không thể hoàn tác.", "deleteFailed": "Xóa trường thất bại. Vui lòng thử lại.", "deleteField": "Xóa trường" + }, + "noPermission": { + "title": "You don't have permission to view custom fields", + "description": "Ask a project admin to grant you the project.settings.custom_fields.read permission." } }, "dangerZone": { @@ -1037,6 +1141,10 @@ "removeFailed": "Xóa thành viên thất bại. Vui lòng thử lại.", "cancel": "Hủy", "remove": "Xóa" + }, + "noPermission": { + "title": "You don't have permission to view members", + "description": "Ask a project admin to grant you the project.members.read permission." } }, "aiChat": { @@ -1092,6 +1200,10 @@ "backToProject": "Quay lại dự án", "projectFallback": "Dự án" }, + "noPermission": { + "title": "You don't have permission to view this task", + "description": "Ask a project admin to grant you the tasks.read permission." + }, "header": { "created": "Đã tạo {{date}}", "copied": "Đã sao chép!", @@ -1298,6 +1410,10 @@ "ariaLabel": "Chi tiết chuyển tiếp cổng", "backToEnvironment": "Quay lại môi trường" }, + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view this port forward." + }, "tabs": { "overview": "Tổng quan", "comments": "Bình luận" @@ -1338,6 +1454,10 @@ "ariaLabel": "Chi tiết bình luận", "backToPortForward": "Quay lại chuyển tiếp cổng" }, + "noPermission": { + "title": "You don't have permission to view this comment", + "description": "Ask a project admin to grant you the annotations.read permission." + }, "header": { "createdAt": "Đã bình luận {{date}}" }, @@ -1463,6 +1583,10 @@ "clearAll": "Xóa bộ lọc" }, "list": { + "noPermission": { + "title": "You don't have permission to view conversations", + "description": "Ask a project admin to grant you the conversations.read permission." + }, "empty": { "title": "Chưa có hội thoại nào", "description": "Hội thoại bắt đầu khi một nhiệm vụ được giao cho agent hoặc khi có ai đó nhắn tin cho agent đó." @@ -1720,7 +1844,11 @@ "createFirst": "Tạo tự động hóa đầu tiên của bạn" }, "noDescription": "Không có mô tả", - "updated": "Đã cập nhật {{time}}" + "updated": "Đã cập nhật {{time}}", + "noPermission": { + "title": "You don't have permission to view automations", + "description": "Ask a project admin to grant you the workflows.read permission." + } }, "status": { "active": "Đang hoạt động", diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json index d042d36d1..36769137d 100644 --- a/apps/web/src/i18n/locales/zh-CN/admin.json +++ b/apps/web/src/i18n/locales/zh-CN/admin.json @@ -289,6 +289,10 @@ "title": "暂无全局智能体", "description": "创建一个全局智能体,使其可用于聊天和项目邀请。", "createAgent": "创建智能体" + }, + "noPermission": { + "title": "You don't have permission to view agents", + "description": "You can still create new agents using the button above." } }, "settings": { diff --git a/apps/web/src/i18n/locales/zh-CN/common.json b/apps/web/src/i18n/locales/zh-CN/common.json index 4f66c782e..240b7f875 100644 --- a/apps/web/src/i18n/locales/zh-CN/common.json +++ b/apps/web/src/i18n/locales/zh-CN/common.json @@ -1,4 +1,10 @@ { + "common": { + "notFound": "Not found", + "somethingWentWrong": "Something went wrong", + "retry": "Retry", + "noPermissionToView": "You don't have permission to view this" + }, "dialog": { "closeLabel": "关闭", "closeButton": "关闭" diff --git a/apps/web/src/i18n/locales/zh-CN/projects.json b/apps/web/src/i18n/locales/zh-CN/projects.json index 01ce2846a..41ac8dcb6 100644 --- a/apps/web/src/i18n/locales/zh-CN/projects.json +++ b/apps/web/src/i18n/locales/zh-CN/projects.json @@ -92,6 +92,8 @@ "card": { "configure": "配置", "delete": "删除", + "restricted": "Restricted", + "restrictedTooltip": "This agent is restricted — ask a project admin to grant you access before you can chat with it.", "acpStatusConnected": "在线", "acpStatusDisconnected": "离线", "deleteDialog": { @@ -111,6 +113,10 @@ "title": "暂无智能体", "description": "添加一个 AI 智能体来自动处理任务、审查代码、编写文档等。", "createFirstAgent": "创建您的第一个智能体" + }, + "noPermission": { + "title": "You don't have permission to view agents", + "description": "Ask a project admin to grant you the agents.read permission." } }, "acpSetup": { @@ -157,6 +163,7 @@ "mcpServers": "MCP 服务器", "skills": "技能", "envVars": "环境变量", + "access": "Access", "activity": "动态" }, "avatar": { @@ -290,6 +297,13 @@ "addSkill": "添加技能" } }, + "access": { + "restrictLabel": "Restrict access", + "restrictDescription": "When on, only members granted access below may chat with this agent. Everyone who can manage agents can still see and configure it.", + "pickMember": "Select a member…", + "grantAccess": "Grant access", + "empty": "No members have been granted access yet." + }, "activity": { "commented": "发表了评论", "sourceType": { @@ -318,6 +332,10 @@ "conversationView": { "stop": "停止", "notFound": "未找到对话", + "noPermission": { + "title": "You don't have permission to view this conversation", + "description": "Ask a project admin to grant you the conversations.read permission." + }, "chatSession": "聊天会话", "taskSession": "任务会话", "pr": "PR", @@ -418,10 +436,16 @@ "title": "环境", "subtitle": "具名的长期运行沙盒,您的智能体可以在多次对话之间连接使用", "newEnvironment": "新建环境", + "restricted": "Restricted", + "restrictedTooltip": "This environment is restricted — ask a project admin to grant you access before you can use it.", "empty": { "title": "暂无环境", "description": "创建一个静态环境,让文件和后台进程在多次对话之间保持运行,而不是每次都使用一次性沙盒。", "createFirstEnvironment": "创建您的第一个环境" + }, + "noPermission": { + "title": "You don't have permission to view environments", + "description": "Ask a project admin to grant you the environments.read permission." } }, "createDialog": { @@ -455,7 +479,8 @@ "tabs": { "overview": "概览", "folders": "文件夹", - "portForwards": "端口转发" + "portForwards": "端口转发", + "access": "Access" }, "overview": { "connect": "连接", @@ -500,6 +525,10 @@ "count_one": "已配置 {{count}} 个文件夹", "count_other": "已配置 {{count}} 个文件夹", "addFolder": "添加文件夹", + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view its folders." + }, "deleteFailed": "删除文件夹失败,请重试。", "empty": { "title": "尚未添加文件夹", @@ -526,6 +555,10 @@ "count_one": "{{count}} 个端口转发", "count_other": "{{count}} 个端口转发", "add": "添加端口转发", + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view its port forwards." + }, "deleteFailed": "删除端口转发失败,请重试。", "containerPort": "容器端口 {{port}}", "unassigned": "尚未分配", @@ -555,6 +588,13 @@ "confirm": "重启" } }, + "access": { + "restrictLabel": "Restrict access", + "restrictDescription": "When on, only members granted access below may browse, SSH into, forward ports on, or open a terminal in this environment. Everyone who can manage environments can still see and configure it.", + "pickMember": "Select a member…", + "grantAccess": "Grant access", + "empty": "No members have been granted access yet." + }, "sshKeys": { "connectHint": "将 替换为此部署配置的 SSH 堡垒机地址。", "connectUnavailable": "此部署尚未配置 SSH 访问。", @@ -602,6 +642,7 @@ "connect": "连接", "notRunning": "请先启动环境再连接。", "readOnly": "您没有权限在此环境中打开终端。", + "restricted": "This environment is restricted — ask a project admin to grant you access before you can open a terminal.", "hint": "将在新的全屏标签页中打开。", "pageTitle": "{{name}} — 终端", "pageTitleLoading": "终端", @@ -612,7 +653,8 @@ "step1Description": "注册你要用于连接的 SSH 密钥对的公钥。", "step2Title": "从终端连接", "step2Description": "在终端中运行以下命令:", - "notRunning": "请先启动环境再连接。" + "notRunning": "请先启动环境再连接。", + "restricted": "This environment is restricted — ask a project admin to grant you access before you can connect via SSH." } } }, @@ -622,6 +664,10 @@ "title": "未找到文档", "description": "该文档可能已被删除,或链接无效。" }, + "noPermission": { + "title": "You don't have permission to view this document", + "description": "Ask a project admin to grant you the docs.read permission." + }, "unsaved": "未保存", "saving": "正在保存…", "saved": "已保存", @@ -674,10 +720,6 @@ } }, "permissions": { - "projectsRead": { - "label": "查看项目", - "description": "查看项目详情和设置" - }, "projectsWrite": { "label": "编辑项目", "description": "更新项目名称、描述和设置" @@ -702,6 +744,30 @@ "label": "管理角色", "description": "创建、编辑和删除项目角色" }, + "settingsTaskTypesRead": { + "label": "View Task Types", + "description": "View the project's task type definitions" + }, + "settingsTaskTypesWrite": { + "label": "Manage Task Types", + "description": "Create, edit, delete, and set the default task type" + }, + "settingsTaskStatusesRead": { + "label": "View Task Statuses", + "description": "View the project's task status definitions and their order" + }, + "settingsTaskStatusesWrite": { + "label": "Manage Task Statuses", + "description": "Create, edit, delete, reorder, and set the default task status" + }, + "settingsCustomFieldsRead": { + "label": "View Custom Fields", + "description": "View the project's custom field definitions" + }, + "settingsCustomFieldsWrite": { + "label": "Manage Custom Fields", + "description": "Create, edit, and delete custom field definitions" + }, "tasksRead": { "label": "查看任务", "description": "浏览并读取项目中的任务" @@ -718,6 +784,14 @@ "label": "管理冲刺", "description": "创建、更新和结束冲刺" }, + "viewsRead": { + "label": "View Boards", + "description": "Browse saved board and list views" + }, + "viewsWrite": { + "label": "Manage Boards", + "description": "Create, edit, delete, and reorder board and list views" + }, "docsRead": { "label": "查看文档", "description": "浏览并读取项目中的文档" @@ -754,6 +828,18 @@ "label": "连接到环境", "description": "在运行中的环境内打开交互式终端会话" }, + "annotationsRead": { + "label": "View Annotations", + "description": "View page comments pinned via the browser extension" + }, + "annotationsWrite": { + "label": "Manage Annotations", + "description": "Create, edit, and delete page annotations" + }, + "annotationsResolve": { + "label": "Resolve Annotations", + "description": "Mark a page annotation resolved or reopen it, without authoring or deleting one" + }, "workflowsRead": { "label": "查看自动化", "description": "浏览自动化及其配置" @@ -766,13 +852,15 @@ "permissionGroups": { "project": "项目", "members": "成员", - "roles": "角色", + "settings": "Settings", "tasks": "任务", "sprints": "冲刺", + "views": "Views", "documents": "文档", "aiAgents": "AI 智能体", "conversations": "对话", "environments": "环境", + "annotations": "Annotations", "workflows": "自动化", "plugins": "插件" } @@ -831,7 +919,11 @@ "permissions": "权限", "created": "创建时间" }, - "systemRolesNote": "系统角色是共享模板,无法编辑或删除。" + "systemRolesNote": "系统角色是共享模板,无法编辑或删除。", + "noPermission": { + "title": "You don't have permission to view roles", + "description": "Ask a project admin to grant you the project.roles.read permission." + } }, "taskStatuses": { "title": "任务状态", @@ -851,7 +943,11 @@ "default": "默认", "setAsDefault": "设为默认状态", "editStatus": "编辑状态", - "deleteStatus": "删除状态" + "deleteStatus": "删除状态", + "noPermission": { + "title": "You don't have permission to view task statuses", + "description": "Ask a project admin to grant you the project.settings.task_statuses.read permission." + } }, "taskTypes": { "title": "任务类型", @@ -871,7 +967,11 @@ "default": "默认", "setAsDefault": "设为默认类型", "editType": "编辑类型", - "deleteType": "删除类型" + "deleteType": "删除类型", + "noPermission": { + "title": "You don't have permission to view task types", + "description": "Ask a project admin to grant you the project.settings.task_types.read permission." + } }, "customFields": { "title": "自定义字段", @@ -942,6 +1042,10 @@ "confirmTextSuffix": "?此字段中存储的任务数据将丢失。此操作无法撤销。", "deleteFailed": "删除字段失败,请重试。", "deleteField": "删除字段" + }, + "noPermission": { + "title": "You don't have permission to view custom fields", + "description": "Ask a project admin to grant you the project.settings.custom_fields.read permission." } }, "dangerZone": { @@ -1037,6 +1141,10 @@ "removeFailed": "移除成员失败,请重试。", "cancel": "取消", "remove": "移除" + }, + "noPermission": { + "title": "You don't have permission to view members", + "description": "Ask a project admin to grant you the project.members.read permission." } }, "aiChat": { @@ -1092,6 +1200,10 @@ "backToProject": "返回项目", "projectFallback": "项目" }, + "noPermission": { + "title": "You don't have permission to view this task", + "description": "Ask a project admin to grant you the tasks.read permission." + }, "header": { "created": "创建于 {{date}}", "copied": "已复制!", @@ -1298,6 +1410,10 @@ "ariaLabel": "端口转发详情", "backToEnvironment": "返回环境" }, + "noPermission": { + "title": "You don't have access to this environment", + "description": "Ask a project admin to grant you access before you can view this port forward." + }, "tabs": { "overview": "概览", "comments": "评论" @@ -1338,6 +1454,10 @@ "ariaLabel": "评论详情", "backToPortForward": "返回端口转发" }, + "noPermission": { + "title": "You don't have permission to view this comment", + "description": "Ask a project admin to grant you the annotations.read permission." + }, "header": { "createdAt": "评论于 {{date}}" }, @@ -1463,6 +1583,10 @@ "clearAll": "清除筛选" }, "list": { + "noPermission": { + "title": "You don't have permission to view conversations", + "description": "Ask a project admin to grant you the conversations.read permission." + }, "empty": { "title": "暂无对话", "description": "当任务分配给某个智能体或有人向其发送消息时,对话将开始。" @@ -1720,7 +1844,11 @@ "createFirst": "创建你的第一个自动化" }, "noDescription": "暂无描述", - "updated": "更新于 {{time}}" + "updated": "更新于 {{time}}", + "noPermission": { + "title": "You don't have permission to view automations", + "description": "Ask a project admin to grant you the workflows.read permission." + } }, "status": { "active": "已启用", diff --git a/apps/web/src/integrations/react-query/query-client.test.ts b/apps/web/src/integrations/react-query/query-client.test.ts new file mode 100644 index 000000000..2a7dfdacc --- /dev/null +++ b/apps/web/src/integrations/react-query/query-client.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { shouldRetryQuery } from "./query-client"; + +function axiosErrorWithStatus(status: number): unknown { + return { response: { status } }; +} + +describe("shouldRetryQuery", () => { + it("does not retry a 403 (permission denied)", () => { + expect(shouldRetryQuery(0, axiosErrorWithStatus(403))).toBe(false); + }); + + it("does not retry a 401 (unauthenticated)", () => { + expect(shouldRetryQuery(0, axiosErrorWithStatus(401))).toBe(false); + }); + + it("does not retry a 404 (not found)", () => { + expect(shouldRetryQuery(0, axiosErrorWithStatus(404))).toBe(false); + }); + + it("does not retry a 400 (bad request)", () => { + expect(shouldRetryQuery(0, axiosErrorWithStatus(400))).toBe(false); + }); + + it("retries a 500 up to 3 times", () => { + expect(shouldRetryQuery(0, axiosErrorWithStatus(500))).toBe(true); + expect(shouldRetryQuery(2, axiosErrorWithStatus(500))).toBe(true); + expect(shouldRetryQuery(3, axiosErrorWithStatus(500))).toBe(false); + }); + + it("retries a network error with no response (e.g. connection refused)", () => { + expect(shouldRetryQuery(0, new Error("Network Error"))).toBe(true); + expect(shouldRetryQuery(3, new Error("Network Error"))).toBe(false); + }); +}); diff --git a/apps/web/src/integrations/react-query/query-client.ts b/apps/web/src/integrations/react-query/query-client.ts index 6c7b9ded3..f26fd0313 100644 --- a/apps/web/src/integrations/react-query/query-client.ts +++ b/apps/web/src/integrations/react-query/query-client.ts @@ -1,3 +1,37 @@ import { QueryClient } from "@tanstack/react-query"; -export const queryClient = new QueryClient(); +function getHttpStatus(error: unknown): number | undefined { + return (error as { response?: { status?: number } } | undefined)?.response + ?.status; +} + +/** + * Skips retrying 4xx responses (401/403/404/etc.) — those mean the request + * was rejected for a reason another attempt won't fix (missing permission, + * missing resource, bad input), unlike a 5xx or network failure, which might + * genuinely succeed later. Without this, every permission-denied ("403 + * FORBIDDEN") query used React Query's default retry (3 attempts with + * exponential backoff), so components kept hitting the API and sitting in + * a loading state for several seconds before finally reaching their error + * state — same request outcome, several seconds later. + * + * Exported for testing. + */ +export function shouldRetryQuery( + failureCount: number, + error: unknown, +): boolean { + const status = getHttpStatus(error); + if (status !== undefined && status >= 400 && status < 500) { + return false; + } + return failureCount < 3; +} + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: shouldRetryQuery, + }, + }, +}); diff --git a/apps/web/src/lib/agent-api.ts b/apps/web/src/lib/agent-api.ts index 83849a422..5de0278e1 100644 --- a/apps/web/src/lib/agent-api.ts +++ b/apps/web/src/lib/agent-api.ts @@ -214,6 +214,13 @@ export interface Agent { // work in by default — null unless default_environment_id is also set. default_folder_id?: string | null; member_id?: string | null; + // access_mode is "open" (default — any project member who can use agents + // at all may chat with this one) or "restricted" (only members with an + // explicit access grant may). access_granted is per-viewer: whether the + // current user could actually use this agent right now — always true + // when access_mode is "open". Together these drive the locked-agent UI. + access_mode: AgentAccessMode; + access_granted: boolean; mcp_servers?: AgentMCPServer[]; skills?: AgentSkill[]; env_vars?: AgentEnvVar[]; @@ -221,6 +228,16 @@ export interface Agent { updated_at: string; } +export type AgentAccessMode = "open" | "restricted"; + +export interface AgentAccessGrant { + id: string; + agent_id: string; + member_id: string; + granted_by?: string | null; + created_at: string; +} + export type ConversationStatus = | "queued" | "running" @@ -375,6 +392,7 @@ export async function updateAgent( parallelism_limit?: number; default_environment_id?: string | null; default_folder_id?: string | null; + access_mode?: AgentAccessMode; }, ): Promise { const { data } = await apiClient.instance.patch>( @@ -384,6 +402,45 @@ export async function updateAgent( return data.data; } +// ── Agent access grants ────────────────────────────────────────────────────── +// Who may use a restricted agent — see Agent.access_mode's doc comment. +// Managing the grant list itself requires agents.write, same tier as every +// other agent-configuration action; the grants themselves gate the chat +// actions, not this list. + +export async function listAgentAccessGrants( + projectId: string, + agentId: string, +): Promise { + const { data } = await apiClient.instance.get< + SuccessEnvelope<{ items: AgentAccessGrant[] }> + >(`/projects/${projectId}/agents/${agentId}/access-grants`); + return data.data.items; +} + +export async function addAgentAccessGrant( + projectId: string, + agentId: string, + memberId: string, +): Promise { + const { data } = await apiClient.instance.post< + SuccessEnvelope + >(`/projects/${projectId}/agents/${agentId}/access-grants`, { + member_id: memberId, + }); + return data.data; +} + +export async function removeAgentAccessGrant( + projectId: string, + agentId: string, + memberId: string, +): Promise { + await apiClient.instance.delete( + `/projects/${projectId}/agents/${agentId}/access-grants/${memberId}`, + ); +} + // ── Global Agents (admin CRUD) ─────────────────────────────────────────────── // // Global agents have no project — they're managed from /admin/agents @@ -1436,6 +1493,15 @@ export const agentEnvVarsQueryOptions = (projectId: string, agentId: string) => queryFn: () => listEnvVars(projectId, agentId), }); +export const agentAccessGrantsQueryOptions = ( + projectId: string, + agentId: string, +) => + queryOptions({ + queryKey: ["projects", projectId, "agents", agentId, "access-grants"], + queryFn: () => listAgentAccessGrants(projectId, agentId), + }); + export const globalAgentMCPServersQueryOptions = (agentId: string) => queryOptions({ queryKey: ["global-agents", agentId, "mcp-servers"], diff --git a/apps/web/src/lib/api-error.test.ts b/apps/web/src/lib/api-error.test.ts index e04ea899f..061eff950 100644 --- a/apps/web/src/lib/api-error.test.ts +++ b/apps/web/src/lib/api-error.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { ApiErrorCode, getApiErrorCode, + getHttpStatus, + isForbiddenError, isTaskNotFoundError, } from "./api-error"; @@ -74,3 +76,48 @@ describe("isTaskNotFoundError", () => { expect(isTaskNotFoundError(new Error("Network Error"))).toBe(false); }); }); + +describe("getHttpStatus", () => { + it("returns the status from an axios-shaped error", () => { + expect(getHttpStatus({ response: { status: 403 } })).toBe(403); + }); + + it("returns undefined for a plain network error with no response", () => { + expect(getHttpStatus(new Error("Network Error"))).toBeUndefined(); + }); +}); + +describe("isForbiddenError", () => { + it("returns true for a plain 403", () => { + const error = { + response: { status: 403, data: { error_code: "FORBIDDEN" } }, + }; + expect(isForbiddenError(error)).toBe(true); + }); + + it("returns true for a 403 with no error_code at all", () => { + expect(isForbiddenError({ response: { status: 403, data: {} } })).toBe( + true, + ); + }); + + it("returns false for AUTH_PASSWORD_CHANGE_REQUIRED — handled by its own redirect, not a generic permission message", () => { + const error = { + response: { + status: 403, + data: { error_code: ApiErrorCode.PasswordChangeRequired }, + }, + }; + expect(isForbiddenError(error)).toBe(false); + }); + + it("returns false for a 404", () => { + expect(isForbiddenError({ response: { status: 404, data: {} } })).toBe( + false, + ); + }); + + it("returns false for a plain network error with no response", () => { + expect(isForbiddenError(new Error("Network Error"))).toBe(false); + }); +}); diff --git a/apps/web/src/lib/api-error.ts b/apps/web/src/lib/api-error.ts index b4460687e..26667e904 100644 --- a/apps/web/src/lib/api-error.ts +++ b/apps/web/src/lib/api-error.ts @@ -130,6 +130,27 @@ export function isPasswordChangeRequired(err: unknown): boolean { ); } +/** The HTTP status code of an Axios error's response, or undefined for a + * non-HTTP failure (network error, timeout) or a non-Axios error. */ +export function getHttpStatus(error: unknown): number | undefined { + return (error as { response?: { status?: number } } | undefined)?.response + ?.status; +} + +/** + * True for a plain "you don't have permission" 403 — every 403 except the + * one that means something more specific and is already handled elsewhere + * (AUTH_PASSWORD_CHANGE_REQUIRED triggers its own redirect to + * /change-password in api-client.ts's response interceptor, so a component + * reacting to it as a normal permission error would show a confusing + * message for the instant before that redirect lands). Use this to decide + * whether to render a NoPermissionState instead of a generic error state — + * see that component's doc comment. + */ +export function isForbiddenError(error: unknown): boolean { + return getHttpStatus(error) === 403 && !isPasswordChangeRequired(error); +} + /** * Returns true when an Axios error is a 404 TASK_NOT_FOUND — i.e. the API has * authoritatively confirmed the task doesn't exist, as opposed to a network diff --git a/apps/web/src/lib/environment-api.ts b/apps/web/src/lib/environment-api.ts index d5f1c0408..c77d1222f 100644 --- a/apps/web/src/lib/environment-api.ts +++ b/apps/web/src/lib/environment-api.ts @@ -75,11 +75,30 @@ export interface Environment { // Forwarding" section. When true, show a "restart required" prompt // (see restartEnvironment below). ports_pending_restart: boolean; + // access_mode is "open" (default — any project member who can use + // environments at all may use this one) or "restricted" (only members + // with an explicit access grant may browse, SSH, forward ports, or open + // a terminal in it). access_granted is per-viewer: whether the current + // user could actually use this environment right now — always true + // when access_mode is "open". Together these drive the locked- + // environment UI. + access_mode: EnvironmentAccessMode; + access_granted: boolean; created_at: string; updated_at: string; folders: EnvironmentFolder[]; } +export type EnvironmentAccessMode = "open" | "restricted"; + +export interface EnvironmentAccessGrant { + id: string; + environment_id: string; + member_id: string; + granted_by?: string | null; + created_at: string; +} + // EnvironmentStats is one message on the live-usage WebSocket // (environment-status-ring.tsx's useEnvironmentUsage connects to // getStatsTicket's ws_url) — a point-in-time snapshot, not a persisted @@ -168,7 +187,11 @@ export async function createEnvironment( export async function updateEnvironment( projectId: string, environmentId: string, - payload: { name?: string; idle_timeout_minutes?: number }, + payload: { + name?: string; + idle_timeout_minutes?: number; + access_mode?: EnvironmentAccessMode; + }, ): Promise { const { data } = await apiClient.instance.patch>( `/projects/${projectId}/environments/${environmentId}`, @@ -177,6 +200,44 @@ export async function updateEnvironment( return data.data; } +// ── Access grants ───────────────────────────────────────────────────────────── +// Who may use a restricted environment — see Environment.access_mode's doc +// comment. Managing the grant list itself requires environments.write, same +// tier as every other environment-configuration action. + +export async function listEnvironmentAccessGrants( + projectId: string, + environmentId: string, +): Promise { + const { data } = await apiClient.instance.get< + SuccessEnvelope<{ items: EnvironmentAccessGrant[] }> + >(`/projects/${projectId}/environments/${environmentId}/access-grants`); + return data.data.items; +} + +export async function addEnvironmentAccessGrant( + projectId: string, + environmentId: string, + memberId: string, +): Promise { + const { data } = await apiClient.instance.post< + SuccessEnvelope + >(`/projects/${projectId}/environments/${environmentId}/access-grants`, { + member_id: memberId, + }); + return data.data; +} + +export async function removeEnvironmentAccessGrant( + projectId: string, + environmentId: string, + memberId: string, +): Promise { + await apiClient.instance.delete( + `/projects/${projectId}/environments/${environmentId}/access-grants/${memberId}`, + ); +} + export async function deleteEnvironment( projectId: string, environmentId: string, @@ -518,6 +579,21 @@ export const environmentFoldersQueryOptions = ( queryFn: () => listFolders(projectId, environmentId), }); +export const environmentAccessGrantsQueryOptions = ( + projectId: string, + environmentId: string, +) => + queryOptions({ + queryKey: [ + "projects", + projectId, + "environments", + environmentId, + "access-grants", + ], + queryFn: () => listEnvironmentAccessGrants(projectId, environmentId), + }); + export const environmentSSHKeysQueryOptions = ( projectId: string, environmentId: string, diff --git a/apps/web/src/routes/_authenticated.tsx b/apps/web/src/routes/_authenticated.tsx index eea6eae46..7eaf5f809 100644 --- a/apps/web/src/routes/_authenticated.tsx +++ b/apps/web/src/routes/_authenticated.tsx @@ -1,5 +1,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { + CatchBoundary, createFileRoute, Outlet, redirect, @@ -9,6 +10,7 @@ import { lazy, Suspense, useEffect } from "react"; import { AppSidebar } from "@/components/app-shell/app-sidebar"; import { NotificationBell } from "@/components/app-shell/notification-bell"; +import { RouteErrorComponent } from "@/components/route-error-boundary"; import { SidebarInset, SidebarProvider, @@ -145,7 +147,21 @@ function AuthenticatedLayout() { )}
- + {/* Wraps only the routed content, not AppSidebar/header above — + a plain `errorComponent` on this route (or root's own, which + this falls back to today) would catch a descendant's failure + by replacing this route's *entire* rendered output, sidebar + included, since a route's errorComponent boundary wraps its + own component, not just its own . Reset on pathname + change so navigating to a different page — including via the + sidebar itself, still rendered outside this boundary — gets a + fresh attempt instead of showing the previous page's error. */} + pathname} + errorComponent={RouteErrorComponent} + > + +
diff --git a/apps/web/src/routes/_authenticated/admin/agents/index.tsx b/apps/web/src/routes/_authenticated/admin/agents/index.tsx index ba6c2cfdb..4597eaad9 100644 --- a/apps/web/src/routes/_authenticated/admin/agents/index.tsx +++ b/apps/web/src/routes/_authenticated/admin/agents/index.tsx @@ -9,6 +9,7 @@ import { AcpSetupDialog, CreateAgentDialog, } from "@/components/projects/agents/create-agent-dialog"; +import { NoPermissionState } from "@/components/shared/no-permission-state"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { usePermissions } from "@/hooks/use-permissions"; @@ -19,6 +20,7 @@ import { globalAgentsQueryOptions, llmModelsQueryOptions, } from "@/lib/agent-api"; +import { isForbiddenError } from "@/lib/api-error"; import { hasPermission } from "@/lib/permissions"; export const Route = createFileRoute("/_authenticated/admin/agents/")({ @@ -38,11 +40,13 @@ export const Route = createFileRoute("/_authenticated/admin/agents/")({ throw redirect({ to: "/home" }); } }, + // globalAgentsQueryOptions isn't prefetched here — agents.write without + // agents.read is an unusual but valid combination (the beforeLoad check + // above only requires one of the two), and gating in the loader would + // crash this entire page instead of showing NoPermissionState in place + // of just the grid below. loader: async ({ context: { queryClient } }) => { - await Promise.all([ - queryClient.ensureQueryData(globalAgentsQueryOptions), - queryClient.ensureQueryData(llmModelsQueryOptions), - ]); + await queryClient.ensureQueryData(llmModelsQueryOptions); }, component: GlobalAgentsPage, }); @@ -60,8 +64,15 @@ function GlobalAgentsPage() { const navigate = Route.useNavigate(); const { hasPermission } = usePermissions(); const canWrite = hasPermission("agents.write"); + const canRead = hasPermission("agents.read"); - const { data: agents = [], isLoading } = useQuery(globalAgentsQueryOptions); + const { + data: agents = [], + isLoading, + isError, + error, + } = useQuery({ ...globalAgentsQueryOptions, enabled: canRead }); + const noPermission = !canRead || (isError && isForbiddenError(error)); const [createOpen, setCreateOpen] = useState(search.create); const [acpSetupAgent, setAcpSetupAgent] = useState(null); @@ -120,7 +131,13 @@ function GlobalAgentsPage() { {/* Content */}
- {isLoading ? ( + {noPermission ? ( + + ) : isLoading ? (
{Array.from({ length: 3 }).map((_, i) => ( // biome-ignore lint/suspicious/noArrayIndexKey: skeleton diff --git a/apps/web/src/routes/_authenticated/admin/global-roles/index.tsx b/apps/web/src/routes/_authenticated/admin/global-roles/index.tsx index 6319f5f27..29c07c696 100644 --- a/apps/web/src/routes/_authenticated/admin/global-roles/index.tsx +++ b/apps/web/src/routes/_authenticated/admin/global-roles/index.tsx @@ -1,18 +1,20 @@ import { useQuery } from "@tanstack/react-query"; import { createFileRoute, redirect } from "@tanstack/react-router"; +import { Shield } from "lucide-react"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { DeleteRoleDialog } from "@/components/admin/global-roles/DeleteRoleDialog"; import { GlobalRolesHeader } from "@/components/admin/global-roles/GlobalRolesHeader"; import { EmptyRolesState, GlobalRolesErrorState, - GlobalRolesNoPermissionState, } from "@/components/admin/global-roles/GlobalRolesStates"; import { GlobalRolesStats } from "@/components/admin/global-roles/GlobalRolesStats"; import { GlobalRolesTable } from "@/components/admin/global-roles/GlobalRolesTable"; import { RoleFormDialog } from "@/components/admin/global-roles/RoleFormDialog"; import { RolesTableSkeleton } from "@/components/admin/global-roles/RolesTableSkeleton"; import { activePermissions } from "@/components/admin/global-roles/utils"; +import { NoPermissionState } from "@/components/shared/no-permission-state"; import { usePermissions } from "@/hooks/use-permissions"; import { type GlobalRole, @@ -40,6 +42,7 @@ export const Route = createFileRoute("/_authenticated/admin/global-roles/")({ }); function GlobalRolesPage() { + const { t } = useTranslation("admin"); const { hasPermission } = usePermissions(); const canRead = hasPermission("global_roles.read"); const canWrite = hasPermission("global_roles.write"); @@ -78,7 +81,11 @@ function GlobalRolesPage() { )} {!canRead ? ( - + ) : isLoading ? ( ) : isError ? ( diff --git a/apps/web/src/routes/_authenticated/admin/users/index.tsx b/apps/web/src/routes/_authenticated/admin/users/index.tsx index 0c357096b..bb489d517 100644 --- a/apps/web/src/routes/_authenticated/admin/users/index.tsx +++ b/apps/web/src/routes/_authenticated/admin/users/index.tsx @@ -1,6 +1,8 @@ import { useQuery } from "@tanstack/react-query"; import { createFileRoute, redirect } from "@tanstack/react-router"; +import { Users } from "lucide-react"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { DeleteUserDialog } from "@/components/admin/users/DeleteUserDialog"; import { ResetPasswordDialog } from "@/components/admin/users/ResetPasswordDialog"; @@ -9,11 +11,11 @@ import { UsersHeader } from "@/components/admin/users/UsersHeader"; import { EmptyUsersState, UsersErrorState, - UsersNoPermissionState, } from "@/components/admin/users/UsersStates"; import { UsersStats } from "@/components/admin/users/UsersStats"; import { UsersTable } from "@/components/admin/users/UsersTable"; import { UsersTableSkeleton } from "@/components/admin/users/UsersTableSkeleton"; +import { NoPermissionState } from "@/components/shared/no-permission-state"; import { usePermissions } from "@/hooks/use-permissions"; import { myPermissionsQueryOptions, @@ -42,6 +44,7 @@ export const Route = createFileRoute("/_authenticated/admin/users/")({ }); function UsersManagementPage() { + const { t } = useTranslation("admin"); const { hasPermission } = usePermissions(); const canRead = hasPermission("users.read"); const canWrite = hasPermission("users.write"); @@ -84,7 +87,11 @@ function UsersManagementPage() { )} {!canRead ? ( - + ) : isLoading ? ( ) : isError ? ( diff --git a/apps/web/src/routes/_authenticated/conversations/$conversationId.tsx b/apps/web/src/routes/_authenticated/conversations/$conversationId.tsx index a5eab7d8d..3f97af9e7 100644 --- a/apps/web/src/routes/_authenticated/conversations/$conversationId.tsx +++ b/apps/web/src/routes/_authenticated/conversations/$conversationId.tsx @@ -1,21 +1,18 @@ import { createFileRoute } from "@tanstack/react-router"; import { ConversationView } from "@/components/projects/agents/conversation-view"; import { RouteErrorComponent } from "@/components/route-error-boundary"; -import { globalConversationQueryOptions } from "@/lib/agent-api"; export const Route = createFileRoute( "/_authenticated/conversations/$conversationId", )({ - loader: async ({ context: { queryClient }, params: { conversationId } }) => { - // Prefetches the conversation itself (agent, status, etc.) — the events - // window is fetched separately by useConversationEventWindow and opens - // on the newest page on its own, with no dependency on this data. - await queryClient.ensureQueryData( - globalConversationQueryOptions(conversationId), - ); - }, - // Without an errorComponent, a loader failure (e.g. deleted conversation, - // API 500) bubbles up and crashes the router's internal Lazy wrapper. + // The conversation itself isn't prefetched here — ConversationView's own + // useQuery already handles loading/not-found/permission-denied/failed + // states with the right UI for each (NoPermissionState for a 403, + // distinct from a genuinely deleted conversation or one that failed to + // run). Prefetching it here meant a 403 on first load bubbled past that + // handling entirely and showed this route's generic errorComponent + // instead — kept below only as a backstop for a genuinely unexpected + // render crash, not as the normal path for a restricted conversation. errorComponent: ({ error }) => , component: GlobalConversationPage, }); diff --git a/apps/web/src/routes/_authenticated/projects/$projectId/agents/index.tsx b/apps/web/src/routes/_authenticated/projects/$projectId/agents/index.tsx index 3da810a6e..3d1ed258e 100644 --- a/apps/web/src/routes/_authenticated/projects/$projectId/agents/index.tsx +++ b/apps/web/src/routes/_authenticated/projects/$projectId/agents/index.tsx @@ -9,6 +9,7 @@ import { AcpSetupDialog, CreateAgentDialog, } from "@/components/projects/agents/create-agent-dialog"; +import { NoPermissionState } from "@/components/shared/no-permission-state"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { useProjectPermissions } from "@/hooks/use-project-permissions"; @@ -18,10 +19,8 @@ import { llmModelsQueryOptions, projectScopedAgentsQueryOptions, } from "@/lib/agent-api"; -import { - projectQueryOptions, - projectRolesQueryOptions, -} from "@/lib/project-api"; +import { isForbiddenError } from "@/lib/api-error"; +import { projectQueryOptions } from "@/lib/project-api"; export const Route = createFileRoute( "/_authenticated/projects/$projectId/agents/", @@ -29,12 +28,14 @@ export const Route = createFileRoute( validateSearch: (search: Record) => ({ create: search.create === true || search.create === "true", }), - loader: async ({ context: { queryClient }, params: { projectId } }) => { - await Promise.all([ - queryClient.ensureQueryData(projectScopedAgentsQueryOptions(projectId)), - queryClient.ensureQueryData(projectRolesQueryOptions(projectId)), - queryClient.ensureQueryData(llmModelsQueryOptions), - ]); + // Neither projectRolesQueryOptions (see below) nor the agent list itself + // is prefetched here — a role holding only agents.write (no agents.read) + // or only tasks.write (no project.roles.read) is an unusual but valid + // combination, and prefetching either in the loader would crash this + // entire page over one missing permission instead of showing + // NoPermissionState in place of just the agent grid below. + loader: async ({ context: { queryClient } }) => { + await queryClient.ensureQueryData(llmModelsQueryOptions); }, component: AgentsPage, }); @@ -48,15 +49,23 @@ function AgentsPage() { const navigate = Route.useNavigate(); const { hasProjectPermission } = useProjectPermissions(projectId); const canWrite = hasProjectPermission("agents.write"); + const canRead = hasProjectPermission("agents.read"); const { data: project } = useQuery(projectQueryOptions(projectId)); // projectScopedAgentsQueryOptions server-side-filters out global-scope // agents invited into this project as members (see agent-api.ts's // AgentScope doc comment) — this page manages project-owned agents only; // global agents are configured from /admin/agents. - const { data: agents = [], isLoading } = useQuery( - projectScopedAgentsQueryOptions(projectId), - ); + const { + data: agents = [], + isLoading, + isError, + error, + } = useQuery({ + ...projectScopedAgentsQueryOptions(projectId), + enabled: canRead, + }); + const noPermission = !canRead || (isError && isForbiddenError(error)); const [createOpen, setCreateOpen] = useState(create); const [acpSetupAgent, setAcpSetupAgent] = useState(null); const [acpSetupToken, setAcpSetupToken] = useState( @@ -116,7 +125,13 @@ function AgentsPage() { {/* Content */}
- {isLoading ? ( + {noPermission ? ( + + ) : isLoading ? (
{Array.from({ length: 3 }).map((_, i) => ( // biome-ignore lint/suspicious/noArrayIndexKey: skeleton diff --git a/apps/web/src/routes/_authenticated/projects/$projectId/automation/$automationId.tsx b/apps/web/src/routes/_authenticated/projects/$projectId/automation/$automationId.tsx index c7dede603..4efb8f091 100644 --- a/apps/web/src/routes/_authenticated/projects/$projectId/automation/$automationId.tsx +++ b/apps/web/src/routes/_authenticated/projects/$projectId/automation/$automationId.tsx @@ -61,19 +61,23 @@ function extractErrorMessage(err: unknown, fallback: string): string { export const Route = createFileRoute( "/_authenticated/projects/$projectId/automation/$automationId", )({ + // Only the automation graph itself is prefetched (workflows.read). + // taskStatuses/taskTypes/members/customFields below are each used purely + // to render human-readable labels inside node-config summaries (status + // name instead of a bare UUID, etc.) — every one of those `useQuery` + // calls already defaults to [] and is never this route's only content, + // so prefetching them here just meant a role missing any one of + // project.settings.task_statuses/task_types/custom_fields.read or + // project.members.read (all independent permissions a role could lack + // without lacking workflows.read) crashed the whole automation page + // instead of showing raw IDs in a few summaries. loader: async ({ context: { queryClient }, params: { projectId, automationId }, }) => { - await Promise.all([ - queryClient.ensureQueryData( - automationQueryOptions(projectId, automationId), - ), - queryClient.ensureQueryData(taskStatusesQueryOptions(projectId)), - queryClient.ensureQueryData(projectMembersQueryOptions(projectId)), - queryClient.ensureQueryData(customFieldsQueryOptions(projectId)), - queryClient.ensureQueryData(taskTypesQueryOptions(projectId)), - ]); + await queryClient.ensureQueryData( + automationQueryOptions(projectId, automationId), + ); }, component: AutomationBuilderPage, }); diff --git a/apps/web/src/routes/_authenticated/projects/$projectId/automation/index.tsx b/apps/web/src/routes/_authenticated/projects/$projectId/automation/index.tsx index 23841ce53..ffd17b407 100644 --- a/apps/web/src/routes/_authenticated/projects/$projectId/automation/index.tsx +++ b/apps/web/src/routes/_authenticated/projects/$projectId/automation/index.tsx @@ -11,6 +11,7 @@ import { import { useState } from "react"; import { useTranslation } from "react-i18next"; import { AutomationDependencyMap } from "@/components/projects/automation/automation-dependency-map"; +import { NoPermissionState } from "@/components/shared/no-permission-state"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -25,6 +26,7 @@ import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; import { Textarea } from "@/components/ui/textarea"; import { useProjectPermissions } from "@/hooks/use-project-permissions"; +import { isForbiddenError } from "@/lib/api-error"; import { type Automation, automationsQueryOptions, @@ -37,9 +39,10 @@ import { timeAgo } from "@/lib/time-ago"; export const Route = createFileRoute( "/_authenticated/projects/$projectId/automation/", )({ - loader: async ({ context: { queryClient }, params: { projectId } }) => { - await queryClient.ensureQueryData(automationsQueryOptions(projectId)); - }, + // Not prefetched here — workflows.write without workflows.read is a + // valid combination, and gating in the loader would crash this entire + // page instead of showing NoPermissionState in place of just the grid + // below. component: AutomationListPage, }); @@ -56,11 +59,16 @@ function AutomationListPage() { const navigate = useNavigate(); const { hasProjectPermission } = useProjectPermissions(projectId); const canManage = hasProjectPermission("workflows.write"); + const canRead = hasProjectPermission("workflows.read"); const { data: project } = useQuery(projectQueryOptions(projectId)); - const { data: automations = [], isLoading } = useQuery( - automationsQueryOptions(projectId), - ); + const { + data: automations = [], + isLoading, + isError, + error, + } = useQuery({ ...automationsQueryOptions(projectId), enabled: canRead }); + const noPermission = !canRead || (isError && isForbiddenError(error)); const [createOpen, setCreateOpen] = useState(false); const [name, setName] = useState(""); @@ -117,15 +125,17 @@ function AutomationListPage() {

- + {canRead && ( + + )} {canManage ? (
- {canWrite && ( + {canConnect && (
@@ -648,7 +648,7 @@ export function EnvironmentConnectView({ )} diff --git a/apps/web/src/routes/_authenticated/projects/$projectId/environments/$environmentId/connect.tsx b/apps/web/src/routes/_authenticated/projects/$projectId/environments/$environmentId/connect.tsx index 400b7f406..300e32364 100644 --- a/apps/web/src/routes/_authenticated/projects/$projectId/environments/$environmentId/connect.tsx +++ b/apps/web/src/routes/_authenticated/projects/$projectId/environments/$environmentId/connect.tsx @@ -41,10 +41,14 @@ export const Route = createFileRoute( function ProjectEnvironmentConnectPage() { const { projectId, environmentId } = Route.useParams(); const { hasProjectPermission } = useProjectPermissions(projectId); + // Gates the environment lifecycle action (starting a stopped + // environment) on the web-app tab — managing the environment's + // configuration is distinct from being able to open a shell inside it. const canWrite = hasProjectPermission("environments.write"); - // Gates only the terminal-open link (WebAppConnectTab) — opening a - // shell is a distinct capability from managing the environment's - // configuration, see router.go's own environments.connect comment. + // Gates every shell-access affordance: the terminal-open link + // (WebAppConnectTab) and adding/removing SSH keys (SSHConnectTab) — a + // registered key is just another way to reach the same root shell, see + // router.go's own environments.connect comment. const canConnect = hasProjectPermission("environments.connect"); return ( Date: Fri, 11 Sep 2026 03:32:44 +0000 Subject: [PATCH 10/14] feat: enhance error handling for chat session access restrictions and update translations --- .../agents/conversation-to-thread-messages.ts | 43 ++++++ .../projects/agents/conversation-view.tsx | 133 ++++++++++-------- .../agents/new-conversation-thread.tsx | 34 ++++- .../src/components/projects/ai-chat-float.tsx | 34 ++++- apps/web/src/i18n/locales/en/projects.json | 3 + apps/web/src/i18n/locales/es/projects.json | 3 + apps/web/src/i18n/locales/fr/projects.json | 3 + apps/web/src/i18n/locales/ja/projects.json | 3 + apps/web/src/i18n/locales/ko/projects.json | 3 + apps/web/src/i18n/locales/pt-BR/projects.json | 3 + apps/web/src/i18n/locales/ru/projects.json | 3 + apps/web/src/i18n/locales/vi/projects.json | 3 + apps/web/src/i18n/locales/zh-CN/projects.json | 3 + apps/web/src/lib/api-error.ts | 12 ++ 14 files changed, 222 insertions(+), 61 deletions(-) diff --git a/apps/web/src/components/projects/agents/conversation-to-thread-messages.ts b/apps/web/src/components/projects/agents/conversation-to-thread-messages.ts index 73da81d28..db77f7955 100644 --- a/apps/web/src/components/projects/agents/conversation-to-thread-messages.ts +++ b/apps/web/src/components/projects/agents/conversation-to-thread-messages.ts @@ -3,6 +3,11 @@ import type { AgentConversation, AgentConversationEvent, } from "@/lib/agent-api"; +import { + ApiErrorCode, + getApiErrorCode, + isForbiddenError, +} from "@/lib/api-error"; import { parseContextItems } from "@/lib/context-items"; // Our chat runtimes (conversation-view.tsx / ai-chat-float.tsx / the @@ -18,6 +23,44 @@ export function extractTextOnlyContent(message: AppendMessage): string | null { return message.content[0].text; } +// Literal union, not a plain string, so callers can pass this straight into +// react-i18next's `t()` — its typed key argument rejects a widened `string` +// (see the "Type 'string' is not assignable to type ..." error this +// produces if loosened). +type ChatSessionAccessDeniedKey = + | "agents.conversationView.agentAccessRestricted" + | "agents.conversationView.environmentAccessRestricted" + | "agents.conversationView.chatNoPermission"; + +// Classifies a failed chat-session dispatch (startChatSession/sendChatMessage +// and their sibling calls in new-conversation-thread.tsx, +// conversation-view.tsx, ai-chat-float.tsx) into a projects.json translation +// key. Each onNew wraps its dispatch call in try/catch and does +// `const key = chatSessionAccessDeniedKey(err); if (key) throw new +// Error(t(key)); throw err;` — assistant-ui's built-in per-message error +// display (thread.tsx's MessageError) then shows the translated message +// inline, the same mechanism already used for e.g. "select an agent first" +// (see extractTextOnlyContent above). Returns null for anything that isn't a +// 403, so the caller re-throws the original error unchanged rather than +// misreporting a network failure or busy-dialog cancellation as a permission +// problem. Stays i18n-free like the rest of this file — callers own +// translating the returned key, this only classifies. +export function chatSessionAccessDeniedKey( + err: unknown, +): ChatSessionAccessDeniedKey | null { + const code = getApiErrorCode(err); + if (code === ApiErrorCode.AgentAccessRestricted) { + return "agents.conversationView.agentAccessRestricted"; + } + if (code === ApiErrorCode.EnvironmentAccessRestricted) { + return "agents.conversationView.environmentAccessRestricted"; + } + if (isForbiddenError(err)) { + return "agents.conversationView.chatNoPermission"; + } + return null; +} + // Extract plain text from a content block array [{type:"text", text:"..."}] or a bare string. export function extractContentText(content: unknown): string | null { if (Array.isArray(content)) { diff --git a/apps/web/src/components/projects/agents/conversation-view.tsx b/apps/web/src/components/projects/agents/conversation-view.tsx index ef8a3aec7..f2681573b 100644 --- a/apps/web/src/components/projects/agents/conversation-view.tsx +++ b/apps/web/src/components/projects/agents/conversation-view.tsx @@ -51,6 +51,7 @@ import { useAgentBusyPrompt } from "./agent-busy-dialog"; import { ConversationErrorBox } from "./conversation-error-box"; import { canReplyToConversation, + chatSessionAccessDeniedKey, eventsToThreadMessages, extractTextOnlyContent, isEnvironmentReady, @@ -171,8 +172,19 @@ export function ConversationView({ const [conversationId, setConversationId] = useState(routeConversationId); useEffect(() => { setConversationId(routeConversationId); + setSendError(null); }, [routeConversationId]); + // assistant-ui's onNew rejection isn't caught anywhere in its own + // send/append chain (ComposerRuntimeCore.send -> handleSend -> + // ThreadRuntimeCore.append all call the next step unawaited, so a thrown + // Error here becomes an unhandled promise rejection, not a rendered + // MessageError — that primitive reads a message's own persisted + // status.reason==="error", which only a server-confirmed failed turn ever + // has). Driven by local state and rendered via viewportOverlay instead, + // alongside the existing conversation.error_message box below. + const [sendError, setSendError] = useState(null); + const { data: conversation, isLoading: convLoading, @@ -279,67 +291,77 @@ export function ConversationView({ // mid-send can't sneak into this message or get cleared under it. const contextItems = useContextInjectionStore.getState().items; - if (!conversation.chat_session_id) { - // A conversation of a non-chat trigger type (task_assigned, - // comment_mention, etc.) — either ACP, or an LLM conversation - // attached to a static environment (see canReply's own doc - // comment) — reply in place on the same conversation_id rather - // than through a chat session. Routed through the same busy - // prompt as the chat-session branch below: the server enforces - // the exact same parallelism/folder capacity check on this - // resume path (see services/api's resumeConversationMessage). - await sendWithBusyPrompt((onBusy) => + setSendError(null); + try { + if (!conversation.chat_session_id) { + // A conversation of a non-chat trigger type (task_assigned, + // comment_mention, etc.) — either ACP, or an LLM conversation + // attached to a static environment (see canReply's own doc + // comment) — reply in place on the same conversation_id rather + // than through a chat session. Routed through the same busy + // prompt as the chat-session branch below: the server enforces + // the exact same parallelism/folder capacity check on this + // resume path (see services/api's resumeConversationMessage). + await sendWithBusyPrompt((onBusy) => + projectId + ? sendConversationMessage( + projectId, + conversation.id, + text, + contextItems, + onBusy, + ) + : sendGlobalConversationMessage( + conversation.id, + text, + contextItems, + onBusy, + ), + ); + useContextInjectionStore.getState().clear(); + invalidate(); + return; + } + + const chatSessionId = conversation.chat_session_id; + const result = await sendWithBusyPrompt((onBusy) => projectId - ? sendConversationMessage( - projectId, - conversation.id, - text, + ? sendChatMessage(projectId, conversation.agent_id, chatSessionId, { + message: text, contextItems, - onBusy, - ) - : sendGlobalConversationMessage( - conversation.id, - text, + on_busy: onBusy, + }) + : sendGlobalChatMessage(chatSessionId, { + message: text, contextItems, - onBusy, - ), + on_busy: onBusy, + }), ); useContextInjectionStore.getState().clear(); - invalidate(); - return; - } - - const chatSessionId = conversation.chat_session_id; - const result = await sendWithBusyPrompt((onBusy) => - projectId - ? sendChatMessage(projectId, conversation.agent_id, chatSessionId, { - message: text, - contextItems, - on_busy: onBusy, - }) - : sendGlobalChatMessage(chatSessionId, { - message: text, - contextItems, - on_busy: onBusy, - }), - ); - useContextInjectionStore.getState().clear(); - // The previous conversation may have already ended (explicitly - // stopped, or reaped after 3 minutes with no heartbeat) — replying - // then silently starts a fresh conversation server-side. Follow it, - // otherwise this view keeps polling the old (now terminal) - // conversation and the reply appears to vanish. - if (result.id !== conversationId) { - qc.setQueryData( - (projectId - ? conversationQueryOptions(projectId, result.id) - : globalConversationQueryOptions(result.id) - ).queryKey, - result, - ); - setConversationId(result.id); + // The previous conversation may have already ended (explicitly + // stopped, or reaped after 3 minutes with no heartbeat) — replying + // then silently starts a fresh conversation server-side. Follow it, + // otherwise this view keeps polling the old (now terminal) + // conversation and the reply appears to vanish. + if (result.id !== conversationId) { + qc.setQueryData( + (projectId + ? conversationQueryOptions(projectId, result.id) + : globalConversationQueryOptions(result.id) + ).queryKey, + result, + ); + setConversationId(result.id); + } + invalidate(result.id); + } catch (err) { + const key = chatSessionAccessDeniedKey(err); + if (key) { + setSendError(t(key)); + return; + } + throw err; } - invalidate(result.id); }; const onCancel = async () => { @@ -584,6 +606,7 @@ export function ConversationView({ {conversation.error_message && ( )} + {sendError && } handleSend -> + // ThreadRuntimeCore.append all call the next step unawaited, so a thrown + // Error here becomes an unhandled promise rejection, not a rendered + // MessageError — that primitive reads a message's own persisted + // status.reason==="error", which only a server-confirmed failed turn + // ever has). Driven by local state and rendered via viewportOverlay + // instead — the same ConversationErrorBox mechanism already used for + // conversation.error_message — so a dispatch failure is actually visible + // rather than silently dropped. + const [sendError, setSendError] = useState(null); // Global chat (no projectId) is deliberately open to any authenticated // user (see router.go's global chat-session routes); only gate starting a @@ -99,6 +114,7 @@ export function NewConversationThread({ // Guards against a fast double-Enter firing two chat sessions before // the first request resolves and this component navigates away. setIsSubmitting(true); + setSendError(null); try { if (projectId) { const result = await sendWithBusyPrompt((onBusy) => @@ -143,6 +159,13 @@ export function NewConversationThread({ params: { conversationId: result.conversation.id }, }); } + } catch (err) { + const key = chatSessionAccessDeniedKey(err); + if (key) { + setSendError(t(key)); + return; + } + throw err; } finally { setIsSubmitting(false); } @@ -163,7 +186,14 @@ export function NewConversationThread({ - + + ) : undefined + } + /> {agentBusyDialog} diff --git a/apps/web/src/components/projects/ai-chat-float.tsx b/apps/web/src/components/projects/ai-chat-float.tsx index 1399984a0..bb15ba2f5 100644 --- a/apps/web/src/components/projects/ai-chat-float.tsx +++ b/apps/web/src/components/projects/ai-chat-float.tsx @@ -32,6 +32,7 @@ import { cn } from "@/lib/utils"; import { ConversationErrorBox } from "./agents/conversation-error-box"; import { canReplyToConversation, + chatSessionAccessDeniedKey, eventsToThreadMessages, extractTextOnlyContent, isEnvironmentReady, @@ -86,6 +87,15 @@ export function AIChatFloat({ projectId }: AIChatFloatProps) { const [open, setOpen] = useState(false); const [conversationId, setConversationId] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + // assistant-ui's onNew rejection isn't caught anywhere in its own + // send/append chain (ComposerRuntimeCore.send -> handleSend -> + // ThreadRuntimeCore.append all call the next step unawaited, so a thrown + // Error here becomes an unhandled promise rejection, not a rendered + // MessageError — that primitive reads a message's own persisted + // status.reason==="error", which only a server-confirmed failed turn ever + // has). Driven by local state and rendered via viewportOverlay instead, + // alongside the existing conversation.error_message box below. + const [sendError, setSendError] = useState(null); const qc = useQueryClient(); // Locked once a conversation exists — the agent is fixed for its @@ -144,6 +154,7 @@ export function AIChatFloat({ projectId }: AIChatFloatProps) { // Guards against a fast double-Enter firing two requests (e.g. two // chat sessions) before the first one resolves and flips isRunning. setIsSubmitting(true); + setSendError(null); try { if (!conversationId) { if (!agentId) throw new Error(t("aiChat.selectAgentFirst")); @@ -203,6 +214,13 @@ export function AIChatFloat({ projectId }: AIChatFloatProps) { setConversationId(result.id); } invalidate(result.id); + } catch (err) { + const key = chatSessionAccessDeniedKey(err); + if (key) { + setSendError(t(key)); + return; + } + throw err; } finally { setIsSubmitting(false); } @@ -261,6 +279,7 @@ export function AIChatFloat({ projectId }: AIChatFloatProps) { function handleNewConversation() { if (conversationId) endConversation(conversationId); setConversationId(null); + setSendError(null); } function handleToggleOpen() { @@ -364,10 +383,17 @@ export function AIChatFloat({ projectId }: AIChatFloatProps) { // composer, which read as small print easy to miss) so a // failure with a visible message still explains itself. viewportOverlay={ - conversation?.error_message ? ( - + conversation?.error_message || sendError ? ( + <> + {conversation?.error_message && ( + + )} + {sendError && ( + + )} + ) : undefined } /> diff --git a/apps/web/src/i18n/locales/en/projects.json b/apps/web/src/i18n/locales/en/projects.json index 1e9c893aa..d8c8412e8 100644 --- a/apps/web/src/i18n/locales/en/projects.json +++ b/apps/web/src/i18n/locales/en/projects.json @@ -348,6 +348,9 @@ "connect": "Connect", "conversationEnded": "This conversation has ended.", "textOnlyMessage": "Only text messages are supported.", + "agentAccessRestricted": "This agent is restricted. Ask a project admin to grant you access before you can chat with it.", + "environmentAccessRestricted": "This conversation's environment is restricted. Ask a project admin to grant you access before you can chat with this agent.", + "chatNoPermission": "You don't have permission to send messages in this conversation.", "failed": "Conversation failed", "noOutput": "The agent did not produce any output.", "loadingOlder": "Loading…", diff --git a/apps/web/src/i18n/locales/es/projects.json b/apps/web/src/i18n/locales/es/projects.json index e67bf9ab8..03c5e76fb 100644 --- a/apps/web/src/i18n/locales/es/projects.json +++ b/apps/web/src/i18n/locales/es/projects.json @@ -348,6 +348,9 @@ "connect": "Conectar", "conversationEnded": "Esta conversación ha finalizado.", "textOnlyMessage": "Solo se admiten mensajes de texto.", + "agentAccessRestricted": "Este agente tiene acceso restringido. Pide a un administrador del proyecto que te conceda acceso antes de poder chatear con él.", + "environmentAccessRestricted": "El entorno de esta conversación tiene acceso restringido. Pide a un administrador del proyecto que te conceda acceso antes de poder chatear con este agente.", + "chatNoPermission": "No tienes permiso para enviar mensajes en esta conversación.", "failed": "La conversación falló", "noOutput": "El agente no produjo ninguna salida.", "loadingOlder": "Cargando…", diff --git a/apps/web/src/i18n/locales/fr/projects.json b/apps/web/src/i18n/locales/fr/projects.json index bdebb11bc..a1563532f 100644 --- a/apps/web/src/i18n/locales/fr/projects.json +++ b/apps/web/src/i18n/locales/fr/projects.json @@ -348,6 +348,9 @@ "connect": "Se connecter", "conversationEnded": "Cette conversation est terminée.", "textOnlyMessage": "Seuls les messages texte sont pris en charge.", + "agentAccessRestricted": "Cet agent est à accès restreint. Demandez à un administrateur du projet de vous accorder l'accès avant de pouvoir discuter avec lui.", + "environmentAccessRestricted": "L'environnement de cette conversation est à accès restreint. Demandez à un administrateur du projet de vous accorder l'accès avant de pouvoir discuter avec cet agent.", + "chatNoPermission": "Vous n'avez pas la permission d'envoyer des messages dans cette conversation.", "failed": "La conversation a échoué", "noOutput": "L'agent n'a produit aucun résultat.", "loadingOlder": "Chargement…", diff --git a/apps/web/src/i18n/locales/ja/projects.json b/apps/web/src/i18n/locales/ja/projects.json index b6a1136c1..10e95956e 100644 --- a/apps/web/src/i18n/locales/ja/projects.json +++ b/apps/web/src/i18n/locales/ja/projects.json @@ -348,6 +348,9 @@ "connect": "接続", "conversationEnded": "この会話は終了しました。", "textOnlyMessage": "テキストメッセージのみサポートされています。", + "agentAccessRestricted": "このエージェントはアクセスが制限されています。チャットする前に、プロジェクト管理者にアクセス権を付与してもらってください。", + "environmentAccessRestricted": "この会話の環境はアクセスが制限されています。このエージェントとチャットする前に、プロジェクト管理者にアクセス権を付与してもらってください。", + "chatNoPermission": "この会話でメッセージを送信する権限がありません。", "failed": "会話が失敗しました", "noOutput": "エージェントは出力を生成しませんでした。", "loadingOlder": "読み込み中…", diff --git a/apps/web/src/i18n/locales/ko/projects.json b/apps/web/src/i18n/locales/ko/projects.json index fac0993cf..0d7731d8c 100644 --- a/apps/web/src/i18n/locales/ko/projects.json +++ b/apps/web/src/i18n/locales/ko/projects.json @@ -348,6 +348,9 @@ "connect": "연결", "conversationEnded": "이 대화가 종료되었습니다.", "textOnlyMessage": "텍스트 메시지만 지원됩니다.", + "agentAccessRestricted": "이 에이전트는 액세스가 제한되어 있습니다. 채팅하려면 프로젝트 관리자에게 액세스 권한을 요청하세요.", + "environmentAccessRestricted": "이 대화의 환경은 액세스가 제한되어 있습니다. 이 에이전트와 채팅하려면 프로젝트 관리자에게 액세스 권한을 요청하세요.", + "chatNoPermission": "이 대화에서 메시지를 보낼 권한이 없습니다.", "failed": "대화가 실패했습니다", "noOutput": "에이전트가 출력을 생성하지 않았습니다.", "loadingOlder": "불러오는 중…", diff --git a/apps/web/src/i18n/locales/pt-BR/projects.json b/apps/web/src/i18n/locales/pt-BR/projects.json index b9093dee2..3a5755634 100644 --- a/apps/web/src/i18n/locales/pt-BR/projects.json +++ b/apps/web/src/i18n/locales/pt-BR/projects.json @@ -348,6 +348,9 @@ "connect": "Conectar", "conversationEnded": "Esta conversa foi encerrada.", "textOnlyMessage": "Apenas mensagens de texto são suportadas.", + "agentAccessRestricted": "Este agente tem acesso restrito. Peça a um administrador do projeto para conceder acesso antes de poder conversar com ele.", + "environmentAccessRestricted": "O ambiente desta conversa tem acesso restrito. Peça a um administrador do projeto para conceder acesso antes de poder conversar com este agente.", + "chatNoPermission": "Você não tem permissão para enviar mensagens nesta conversa.", "failed": "A conversa falhou", "noOutput": "O agente não produziu nenhuma saída.", "loadingOlder": "Carregando…", diff --git a/apps/web/src/i18n/locales/ru/projects.json b/apps/web/src/i18n/locales/ru/projects.json index 2ec18eba5..295ba7ede 100644 --- a/apps/web/src/i18n/locales/ru/projects.json +++ b/apps/web/src/i18n/locales/ru/projects.json @@ -354,6 +354,9 @@ "connect": "Подключиться", "conversationEnded": "Этот диалог завершён.", "textOnlyMessage": "Поддерживаются только текстовые сообщения.", + "agentAccessRestricted": "Этот агент имеет ограниченный доступ. Попросите администратора проекта предоставить вам доступ, прежде чем вы сможете общаться с ним.", + "environmentAccessRestricted": "Окружение этого диалога имеет ограниченный доступ. Попросите администратора проекта предоставить вам доступ, прежде чем вы сможете общаться с этим агентом.", + "chatNoPermission": "У вас нет разрешения отправлять сообщения в этом диалоге.", "failed": "Диалог завершился с ошибкой", "noOutput": "Агент не выдал результат.", "loadingOlder": "Загрузка…", diff --git a/apps/web/src/i18n/locales/vi/projects.json b/apps/web/src/i18n/locales/vi/projects.json index 84a1e0633..0a8a1abba 100644 --- a/apps/web/src/i18n/locales/vi/projects.json +++ b/apps/web/src/i18n/locales/vi/projects.json @@ -348,6 +348,9 @@ "connect": "Kết nối", "conversationEnded": "Cuộc trò chuyện này đã kết thúc.", "textOnlyMessage": "Chỉ hỗ trợ tin nhắn văn bản.", + "agentAccessRestricted": "Agent này bị giới hạn quyền truy cập. Hãy nhờ quản trị viên dự án cấp quyền truy cập trước khi bạn có thể trò chuyện với agent này.", + "environmentAccessRestricted": "Môi trường của cuộc trò chuyện này bị giới hạn quyền truy cập. Hãy nhờ quản trị viên dự án cấp quyền truy cập trước khi bạn có thể trò chuyện với agent này.", + "chatNoPermission": "Bạn không có quyền gửi tin nhắn trong cuộc trò chuyện này.", "failed": "Cuộc trò chuyện thất bại", "noOutput": "Agent không tạo ra kết quả nào.", "loadingOlder": "Đang tải…", diff --git a/apps/web/src/i18n/locales/zh-CN/projects.json b/apps/web/src/i18n/locales/zh-CN/projects.json index 681a24a77..c7af61bd1 100644 --- a/apps/web/src/i18n/locales/zh-CN/projects.json +++ b/apps/web/src/i18n/locales/zh-CN/projects.json @@ -348,6 +348,9 @@ "connect": "连接", "conversationEnded": "此对话已结束。", "textOnlyMessage": "仅支持文本消息。", + "agentAccessRestricted": "此代理已被限制访问。请让项目管理员授予你访问权限后再与其聊天。", + "environmentAccessRestricted": "此对话的环境已被限制访问。请让项目管理员授予你访问权限后再与该代理聊天。", + "chatNoPermission": "你没有权限在此对话中发送消息。", "failed": "对话失败", "noOutput": "代理未生成任何输出。", "loadingOlder": "加载中…", diff --git a/apps/web/src/lib/api-error.ts b/apps/web/src/lib/api-error.ts index 26667e904..3cd7ec8ef 100644 --- a/apps/web/src/lib/api-error.ts +++ b/apps/web/src/lib/api-error.ts @@ -111,6 +111,18 @@ export const ApiErrorCode = { // agent-busy-dialog.tsx/useAgentBusyPrompt only ever sends one of those // three values. AgentOnBusyInvalid: "AGENT_ON_BUSY_INVALID", + // Sent instead of dispatching a chat turn when the agent itself is + // access_mode=restricted and the caller holds no grant for it. See + // conversation-to-thread-messages.ts's chatSessionAccessDeniedKey. + AgentAccessRestricted: "AGENT_ACCESS_RESTRICTED", + // Sent instead of dispatching a chat turn when the environment the + // conversation would attach to (explicit override, or the agent's own + // DefaultEnvironmentID) is access_mode=restricted and the caller holds + // no grant for it — a separate resource from the agent above, so a + // separate code/remedy ("ask for environment access", not agent + // access). See conversation-to-thread-messages.ts's + // chatSessionAccessDeniedKey. + EnvironmentAccessRestricted: "ENVIRONMENT_ACCESS_RESTRICTED", // Generic / request errors. BadRequest: "BAD_REQUEST", From 6f28b6e7c8dd3e3df6056c8dbc93b50e75759810 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Fri, 11 Sep 2026 08:08:19 +0000 Subject: [PATCH 11/14] feat: Enhance Project Role Management with Full Access Tracking - Updated ProjectRoleFormDialog to track full access roles and manage permissions more effectively. - Introduced isFullAccess state to differentiate between wildcard permissions and explicit toggles. - Adjusted permission handling to ensure that changes to individual permissions reflect accurately in the role saving process. - Enhanced UI to display full access status and provide descriptive feedback to users. - Updated translations for new UI elements related to full access roles in multiple languages. - Modified backend role definitions to ensure proper handling of permissions, including new capabilities for project members. - Added tests to cover new behavior for restricted agents and ensure proper access control in various scenarios. --- .../agents/conversation-to-thread-messages.ts | 20 ++- .../roles/ProjectRoleFormDialog.test.tsx | 62 +++++++ .../projects/roles/ProjectRoleFormDialog.tsx | 124 ++++++++++--- apps/web/src/i18n/locales/en/projects.json | 2 + apps/web/src/i18n/locales/es/projects.json | 2 + apps/web/src/i18n/locales/fr/projects.json | 2 + apps/web/src/i18n/locales/ja/projects.json | 2 + apps/web/src/i18n/locales/ko/projects.json | 2 + apps/web/src/i18n/locales/pt-BR/projects.json | 2 + apps/web/src/i18n/locales/ru/projects.json | 2 + apps/web/src/i18n/locales/vi/projects.json | 2 + apps/web/src/i18n/locales/zh-CN/projects.json | 2 + .../api/internal/platform/authz/defaults.go | 9 + .../internal/service/agent/agent_service.go | 44 ++++- .../service/agent/agent_service_test.go | 164 ++++++++++++++++++ .../service/project/project_member_service.go | 19 +- .../project/project_member_service_test.go | 14 +- .../internal/service/task/activity_service.go | 11 +- .../http/handler/environment_handler.go | 12 ++ .../http/handler/environment_handler_test.go | 63 +++++++ .../internal/transport/http/router/router.go | 41 +++-- 21 files changed, 535 insertions(+), 66 deletions(-) diff --git a/apps/web/src/components/projects/agents/conversation-to-thread-messages.ts b/apps/web/src/components/projects/agents/conversation-to-thread-messages.ts index db77f7955..095543074 100644 --- a/apps/web/src/components/projects/agents/conversation-to-thread-messages.ts +++ b/apps/web/src/components/projects/agents/conversation-to-thread-messages.ts @@ -36,15 +36,17 @@ type ChatSessionAccessDeniedKey = // and their sibling calls in new-conversation-thread.tsx, // conversation-view.tsx, ai-chat-float.tsx) into a projects.json translation // key. Each onNew wraps its dispatch call in try/catch and does -// `const key = chatSessionAccessDeniedKey(err); if (key) throw new -// Error(t(key)); throw err;` — assistant-ui's built-in per-message error -// display (thread.tsx's MessageError) then shows the translated message -// inline, the same mechanism already used for e.g. "select an agent first" -// (see extractTextOnlyContent above). Returns null for anything that isn't a -// 403, so the caller re-throws the original error unchanged rather than -// misreporting a network failure or busy-dialog cancellation as a permission -// problem. Stays i18n-free like the rest of this file — callers own -// translating the returned key, this only classifies. +// `const key = chatSessionAccessDeniedKey(err); if (key) +// setSendError(t(key)); else throw err;`, rendering the translated message +// via a local `sendError` state + `` — NOT by +// throwing and letting assistant-ui catch it: a thrown error from onNew +// becomes an unhandled promise rejection rather than a rendered message, so +// re-throwing is reserved for cases the caller still wants propagated. +// Returns null for anything that isn't a 403, so the caller re-throws the +// original error unchanged rather than misreporting a network failure or +// busy-dialog cancellation as a permission problem. Stays i18n-free like +// the rest of this file — callers own translating the returned key, this +// only classifies. export function chatSessionAccessDeniedKey( err: unknown, ): ChatSessionAccessDeniedKey | null { diff --git a/apps/web/src/components/projects/roles/ProjectRoleFormDialog.test.tsx b/apps/web/src/components/projects/roles/ProjectRoleFormDialog.test.tsx index 491884467..c434c9eb1 100644 --- a/apps/web/src/components/projects/roles/ProjectRoleFormDialog.test.tsx +++ b/apps/web/src/components/projects/roles/ProjectRoleFormDialog.test.tsx @@ -389,5 +389,67 @@ describe("ProjectRoleFormDialog", () => { expect(Object.keys(payload.permissions ?? {})).toHaveLength(0); }); }); + + // A project's seeded "Admin" role is stored as the bare wildcard + // {"*": true} (see 000056_set_admin_role_wildcard_permission.sql). + // Without isFullAccess tracking this separately from the per-checkbox + // state expandWildcardPermissions derives for display, saving this + // role untouched would silently re-derive it as today's enumerated + // wildcards via normalizePermissionsToWildcards, undoing 000056's + // future-proofing. + const fullAccessRole: ProjectRole = { + ...existingRole, + role_name: "Admin", + permissions: { "*": true }, + }; + + it("shows a full-access badge and explanation for a role stored as the bare wildcard", () => { + renderEdit(fullAccessRole); + + expect(screen.getByText("Full access")).toBeInTheDocument(); + expect( + screen.getByText(/includes every permission/i), + ).toBeInTheDocument(); + }); + + it("saves an untouched full-access role as the bare wildcard, not enumerated permissions", async () => { + mockUpdateProjectRole.mockResolvedValue(fullAccessRole); + renderEdit(fullAccessRole); + + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + + await waitFor(() => { + const payload = mockUpdateProjectRole.mock.calls[0][2] as { + permissions: Record; + }; + expect(payload.permissions).toEqual({ "*": true }); + }); + }); + + it("exits full-access mode and saves the narrowed enumerated set once any permission is toggled", async () => { + mockUpdateProjectRole.mockResolvedValue(fullAccessRole); + renderEdit(fullAccessRole); + + // Every switch reads as checked under the wildcard; toggling any one + // of them off is the "an owner narrows a delegated Admin" scenario + // 000056's guard is meant to allow. + const switches = screen.getAllByRole("switch"); + await userEvent.click(switches[0]); + + expect(screen.queryByText("Full access")).not.toBeInTheDocument(); + + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + + await waitFor(() => { + const payload = mockUpdateProjectRole.mock.calls[0][2] as { + permissions: Record; + }; + expect(payload.permissions?.["*"]).toBeUndefined(); + }); + }); }); }); diff --git a/apps/web/src/components/projects/roles/ProjectRoleFormDialog.tsx b/apps/web/src/components/projects/roles/ProjectRoleFormDialog.tsx index a1e796f8d..635844e9c 100644 --- a/apps/web/src/components/projects/roles/ProjectRoleFormDialog.tsx +++ b/apps/web/src/components/projects/roles/ProjectRoleFormDialog.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Shield } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; @@ -77,51 +77,101 @@ export function ProjectRoleFormDialog({ const [name, setName] = useState(role?.role_name ?? ""); const [permissions, setPermissions] = useState>({}); + // isFullAccess tracks whether the role's *stored* permissions are the + // bare wildcard ({"*": true}, e.g. a project's seeded "Admin" role — + // see 000056_set_admin_role_wildcard_permission.sql) rather than an + // enumerated set. expandWildcardPermissions below turns "*" into every + // known permission's checkbox reading true for display, which is + // correct for rendering but loses the "*" itself — without tracking it + // separately, saving an untouched full-access role would silently + // re-derive it as today's enumerated wildcards via + // normalizePermissionsToWildcards, undoing 000056's future-proofing + // (any permission added later would need its own backfill again). Reset + // to false the moment the admin touches any individual toggle — at that + // point they're making an explicit choice, and the resulting save + // should reflect exactly what's checked, not the original "*". + const [isFullAccess, setIsFullAccess] = useState(false); const [error, setError] = useState(null); const [nameError, setNameError] = useState(null); - // Re-derive `permissions` whenever the dialog opens or the known-permission - // set changes (e.g. plugin data finishes loading after the dialog already - // opened), rather than only at first mount — otherwise plugin-declared - // permissions loaded after mount would never make it into the editor and - // saving the role would silently drop them. + // Mirrors the latest allKnownPermissions for the full-reset effect below + // to read without depending on it — see that effect's comment. + const allKnownPermissionsRef = useRef(allKnownPermissions); + allKnownPermissionsRef.current = allKnownPermissions; + + // Full reset of `permissions`/`isFullAccess` from the role's stored + // permissions — but only on a fresh open or when the role's own + // permissions actually change (switching which role is being edited, or + // a save completing and refetching). Deliberately does NOT depend on + // allKnownPermissions: that reference also changes whenever the + // unrelated plugins query settles (see EMPTY_PLUGINS' comment above), + // and re-running a full reset on every such settle would silently + // discard whatever the admin has already toggled mid-edit — isFullAccess + // included, since a role stored as {"*": true} would just get + // re-flagged full-access again over the admin's own narrowing. Newly + // discovered permission keys (e.g. plugin data finishing after mount) + // are instead merged in by the effect below, which doesn't touch + // anything already present. useEffect(() => { if (!open) return; + const rolePermissions = role?.permissions as + | Record + | undefined; setPermissions( - expandWildcardPermissions( - role?.permissions as Record | undefined, - allKnownPermissions, - ), + expandWildcardPermissions(rolePermissions, allKnownPermissionsRef.current), ); + setIsFullAccess(rolePermissions?.["*"] === true); + // biome-ignore lint/correctness/useExhaustiveDependencies: allKnownPermissions is read via allKnownPermissionsRef so this effect isn't re-triggered by every plugins-query settle — see the comment above + }, [open, role?.permissions]); + + // Merges in any permission keys not yet tracked in `permissions` — + // handles plugin-declared permissions that load after the dialog is + // already open, without resetting permissions (or isFullAccess) the + // admin has already touched, unlike a full re-derive would. + useEffect(() => { + if (!open) return; + const rolePermissions = role?.permissions as + | Record + | undefined; + setPermissions((prev) => { + const missing = allKnownPermissions.filter((p) => !(p.key in prev)); + if (missing.length === 0) return prev; + return { + ...prev, + ...expandWildcardPermissions(rolePermissions, missing), + }; + }); }, [open, allKnownPermissions, role?.permissions]); const reset = () => { + const rolePermissions = role?.permissions as + | Record + | undefined; setName(role?.role_name ?? ""); setPermissions( - expandWildcardPermissions( - role?.permissions as Record | undefined, - allKnownPermissions, - ), + expandWildcardPermissions(rolePermissions, allKnownPermissions), ); + setIsFullAccess(rolePermissions?.["*"] === true); setError(null); setNameError(null); }; const mutation = useMutation({ - mutationFn: async () => { - const normalized = normalizePermissionsToWildcards( - permissions, - allKnownPermissions, - ); + // Takes the permissions to submit as an explicit argument, computed by + // the caller at click-time (see the submit button below), rather than + // reading `permissions`/`isFullAccess` from this closure — a stale + // mutationFn closure otherwise risks submitting an isFullAccess value + // from an earlier render than the one that just fired. + mutationFn: async (normalizedPermissions: Record) => { if (isEdit && role) { return updateProjectRole(projectId, role.id, { role_name: name.trim(), - permissions: normalized, + permissions: normalizedPermissions, }); } return createProjectRole(projectId, { role_name: name.trim(), - permissions: normalized, + permissions: normalizedPermissions, }); }, onSuccess: () => { @@ -167,6 +217,9 @@ export function ProjectRoleFormDialog({ const togglePermission = (key: string, checked: boolean) => { setPermissions((prev) => ({ ...prev, [key]: checked })); + // Any explicit toggle exits full-access mode for this editing session + // — see isFullAccess's doc comment above. + setIsFullAccess(false); }; const enabledCount = Object.values(permissions).filter(Boolean).length; @@ -231,12 +284,23 @@ export function ProjectRoleFormDialog({ {t("roles.formDialog.permissionsLabel")} - {enabledCount > 0 && ( - - {t("roles.formDialog.enabledCount", { count: enabledCount })} + {isFullAccess ? ( + + {t("roles.formDialog.fullAccessBadge")} + ) : ( + enabledCount > 0 && ( + + {t("roles.formDialog.enabledCount", { count: enabledCount })} + + ) )}
+ {isFullAccess && ( +

+ {t("roles.formDialog.fullAccessDescription")} +

+ )}
{PROJECT_PERMISSION_GROUPS.map((group, groupIndex) => { @@ -298,7 +362,17 @@ export function ProjectRoleFormDialog({ {t("roles.formDialog.cancel")}