diff --git a/.github/workflows/api-pr-ci.yml b/.github/workflows/api-pr-ci.yml index 7df7a94fa..5ff94ec7e 100644 --- a/.github/workflows/api-pr-ci.yml +++ b/.github/workflows/api-pr-ci.yml @@ -100,7 +100,14 @@ jobs: cache-dependency-path: services/api/go.sum - name: Run tests (race detector) - run: go test -race -timeout 60s -coverprofile=coverage.out $(go list ./... | grep -v '/test/e2e') + # internal/platform/plugin's suite (WASM JIT-compiling several + # plugin fixtures under -race) runs ~45s on a quiet machine with + # near-zero headroom under the previous 60s budget — enough to tip + # into a hard timeout on a slower/contended CI runner with no + # underlying test hang (reproduces green locally every time). + # 120s matches the budget acp-bridge-pr-ci.yml already uses for the + # same class of race-detector run. + run: go test -race -timeout 120s -coverprofile=coverage.out $(go list ./... | grep -v '/test/e2e') - name: Upload coverage report uses: actions/upload-artifact@v6 diff --git a/apps/mcp/src/__tests__/permissions.test.ts b/apps/mcp/src/__tests__/permissions.test.ts index 0d6c0c13c..e1ee73a8f 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,10 +198,52 @@ describe("getToolPermission", () => { it("returns the correct permission for list_views", () => { const perm = getToolPermission("list_views"); + expect(perm?.permissionKey).toBe("views.read"); + expect(perm?.requiresProject).toBe(true); + }); + + // Regression coverage: redefining a task-type/task-status/custom-field + // (create/update/delete/etc.) is split off tasks.write onto its own + // project.settings.*.write key, since the backend stopped requiring + // tasks.write to edit project schema (see router.go's task-types/task- + // statuses/custom-fields route comments) — these write tools previously + // stayed mapped to tasks.write, 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. Viewing the schema has no such split + // — it stays on tasks.read (see list_task_statuses below). + 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); + }); + + // list_task_types/list_task_statuses/list_custom_fields/get_custom_field + // have no dedicated read permission — viewing project schema is implied + // by tasks.read, same as viewing the tasks that reference it. Only + // redefining it (create/update/delete/set-default/reorder) is its own, + // narrower project.settings.*.write capability — see create_task_type + // and update_custom_field below. + it("returns the correct permission for list_task_statuses", () => { + const perm = getToolPermission("list_task_statuses"); expect(perm?.permissionKey).toBe("tasks.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); + }); + it("returns the correct permission for read_conversation — gated on conversations.read, not left unmapped, even though the backend also enforces its own agent_id match separately", () => { const perm = getToolPermission("read_conversation"); expect(perm?.permissionKey).toBe("conversations.read"); diff --git a/apps/mcp/src/permissions.ts b/apps/mcp/src/permissions.ts index fead77ed1..3216bed92 100644 --- a/apps/mcp/src/permissions.ts +++ b/apps/mcp/src/permissions.ts @@ -152,7 +152,15 @@ export const TOOL_PERMISSIONS: ToolPermission[] = [ requiresProject: true, }, - // Task type tools + // Task type tools — project *schema* (which task types exist). Redefining + // the type list is gated on project.settings.task_types.write, a + // different capability from "edit a task's content" (see router.go's + // task-types route comment / authz. + // PermissionProjectSettingsTaskTypesWrite's doc comment). Viewing the + // list has no dedicated read permission — it's gated on tasks.read like + // its own consumer (a task's type field), not a separate key. A member + // with only tasks.write (no project.settings.task_types.write) can still + // edit tasks via update_task, but not these. { toolName: "list_task_types", permissionKey: "tasks.read", @@ -160,26 +168,31 @@ export const TOOL_PERMISSIONS: ToolPermission[] = [ }, { 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 (view via + // tasks.read, redefine via project.settings.task_statuses.write). 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", @@ -187,50 +200,54 @@ export const TOOL_PERMISSIONS: ToolPermission[] = [ }, { 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,7 +266,9 @@ export const TOOL_PERMISSIONS: ToolPermission[] = [ requiresProject: true, }, - // Custom field tools + // Custom field tools — project schema, same split as task types/statuses + // above (view via tasks.read, redefine via + // project.settings.custom_fields.write). { toolName: "list_custom_fields", permissionKey: "tasks.read", @@ -257,7 +276,7 @@ export const TOOL_PERMISSIONS: ToolPermission[] = [ }, { toolName: "create_custom_field", - permissionKey: "tasks.write", + permissionKey: "project.settings.custom_fields.write", requiresProject: true, }, { @@ -267,12 +286,12 @@ export const TOOL_PERMISSIONS: ToolPermission[] = [ }, { 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 +635,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/app-shell/app-sidebar.tsx b/apps/web/src/components/app-shell/app-sidebar.tsx index 96540bbf3..5e5cd8686 100644 --- a/apps/web/src/components/app-shell/app-sidebar.tsx +++ b/apps/web/src/components/app-shell/app-sidebar.tsx @@ -1030,12 +1030,15 @@ function ProjectNavItems({ function PluginProjectPages({ projectId }: { projectId: string }) { const { t } = useTranslation("appShell"); const { getNavItems } = usePluginRegistry(); - const { hasProjectPermission } = useProjectPermissions(projectId); const location = useRouterState({ select: (s) => s.location.pathname }); - const navItems = getNavItems("project").filter( - (item) => - !item.requiredPermission || hasProjectPermission(item.requiredPermission), - ); + // A nav item's own requiredPermission no longer hides it from the + // sidebar — matching how the built-in project nav (Team, Environments, + // etc. in PROJECT_NAV_ITEMS below) is always shown to any project + // member regardless of their specific permissions. The page it routes + // to renders a no-permission state instead (see ProjectPluginPage), + // consistent with how project settings tabs already behave (e.g. + // TaskTypesSettings). + const navItems = getNavItems("project"); if (navItems.length === 0) return null; return ( @@ -1076,9 +1079,9 @@ function PluginProjectPages({ projectId }: { projectId: string }) { * cross-project time-tracking summary), routed to * /admin/plugins/:pluginId/:slug. Rendered inline in the existing * "Administration" SidebarMenu, so no extra group wrapper here. `navItems` - * is pre-filtered by the caller (each item's own `requiredPermission`, if - * any) so this stays in sync with the `showAdminSection` computation that - * decides whether the enclosing group renders at all. */ + * is unfiltered by permission (see AppSidebar's `adminPluginNavItems`) — a + * caller who lacks an item's `requiredPermission` still sees the link, and + * gets a no-permission state on the page itself. */ function PluginAdminPages({ navItems }: { navItems: PluginNavRegistration[] }) { return ( <> @@ -1572,33 +1575,42 @@ export function AppSidebar() { const canAccessGlobalAgents = hasPermission("agents.read") || hasPermission("agents.write"); - const canAccessPlugins = hasPermission("users.write"); + // plugins.write replaced users.write as a rough "is this someone + // important" proxy once it got its own dedicated permission — see authz. + // PermissionPluginsRead's doc comment on the Go side. This nav-link + // check was never updated when that happened. + const canAccessPlugins = hasPermission("plugins.write"); const canAccessSettings = hasPermission("settings.write"); const canCreateProject = hasPermission("projects.create"); - // Plugin admin nav items are gated by their own declared - // `requiredPermission` (falling back to open access if the plugin didn't - // declare one), never by `canAccessPlugins` — a user shouldn't need - // `users.write` just to reach a plugin page whose author scoped it to a - // narrower, plugin-specific permission. - const adminPluginNavItems = getNavItems("admin").filter( - (item) => - !item.requiredPermission || hasPermission(item.requiredPermission), - ); - + // A plugin admin nav item's own declared `requiredPermission` no longer + // hides it from the sidebar — the page it routes to renders a + // no-permission state instead (see AdminPluginPage), matching how core + // admin pages (Users, Global Roles) already behave: reachable by anyone + // who can already see the Administration section, with the page itself + // enforcing the finer-grained check. + const adminPluginNavItems = getNavItems("admin"); + + // Deliberately does NOT include `adminPluginNavItems.length > 0`: unlike + // before, an item's permission can no longer be satisfied just by + // hiding it, so a plugin with an admin page must not be able to + // single-handedly reveal the "Administration" heading to a user with + // zero admin permissions of any kind — that would surface an entire + // nav section to people who have no reason to ever open it. const showAdminSection = canAccessGlobalRoles || canAccessUsers || canAccessGlobalAgents || canAccessPlugins || - canAccessSettings || - adminPluginNavItems.length > 0; + canAccessSettings; // Plugin-contributed admin pages get their own sidebar section, separate // from core workspace administration — the "Plugins" management link - // itself (canAccessPlugins) stays in Administration. - const showPluginsSection = adminPluginNavItems.length > 0; + // itself (canAccessPlugins) stays in Administration. Gated on + // showAdminSection for the same reason as above: only shown to someone + // who can already see Administration for another reason. + const showPluginsSection = showAdminSection && adminPluginNavItems.length > 0; const isProjectContext = !!projectId; const isAnonymous = !user; 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-to-thread-messages.ts b/apps/web/src/components/projects/agents/conversation-to-thread-messages.ts index 73da81d28..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 @@ -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,46 @@ 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) +// 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 { + 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 f459f94db..f2681573b 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,12 +44,14 @@ 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"; import { ConversationErrorBox } from "./conversation-error-box"; import { canReplyToConversation, + chatSessionAccessDeniedKey, eventsToThreadMessages, extractTextOnlyContent, isEnvironmentReady, @@ -169,17 +172,36 @@ 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, 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, @@ -269,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 () => { @@ -428,6 +460,16 @@ export function ConversationView({ } if (!conversation) { + if (noPermission) { + return ( +
+ +
+ ); + } return (
@@ -436,6 +478,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 @@ -548,6 +606,7 @@ export function ConversationView({ {conversation.error_message && ( )} + {sendError && } ({}); - const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = - useInfiniteQuery( - projectId - ? conversationsQueryOptions(projectId, filters) - : globalConversationsQueryOptions(filters), - ); + const { + data, + isLoading: isDataLoading, + isError, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ + ...(projectId + ? conversationsQueryOptions(projectId, filters) + : globalConversationsQueryOptions(filters)), + enabled: canRead, + }); + // While project permissions are still loading, canRead defaults to false + // same as a confirmed denial — guard on isPermissionsLoading (and fold + // it into isLoading) so the list shows the skeleton instead of flashing + // NoPermissionState first. + const isLoading = isPermissionsLoading || isDataLoading; + const noPermission = + !isPermissionsLoading && (!canRead || (isError && isForbiddenError(error))); const { data: agents = [] } = useQuery( projectId ? agentsQueryOptions(projectId) : chattableAgentsQueryOptions, ); @@ -242,7 +264,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/agents/new-conversation-thread.tsx b/apps/web/src/components/projects/agents/new-conversation-thread.tsx index 7af9d6a60..c2f69d0d0 100644 --- a/apps/web/src/components/projects/agents/new-conversation-thread.tsx +++ b/apps/web/src/components/projects/agents/new-conversation-thread.tsx @@ -28,7 +28,11 @@ import { useEnvironmentPicker, useGlobalAgentPicker, } from "./agent-picker"; -import { extractTextOnlyContent } from "./conversation-to-thread-messages"; +import { ConversationErrorBox } from "./conversation-error-box"; +import { + chatSessionAccessDeniedKey, + extractTextOnlyContent, +} from "./conversation-to-thread-messages"; // Shared between the project-scoped Conversations page's blank-composer // index route and the global one — see conversations-layout.tsx for the @@ -72,6 +76,17 @@ export function NewConversationThread({ }); 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 — 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/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..49fbcbf39 100644 --- a/apps/web/src/components/projects/environments/environment-connect.tsx +++ b/apps/web/src/components/projects/environments/environment-connect.tsx @@ -223,17 +223,25 @@ function AddSSHKeyDialog({ function SSHKeysManager({ projectId, environmentId, - canWrite, + canConnect, + hasAccess, }: { projectId: string; environmentId: string; - canWrite: boolean; + canConnect: 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, @@ -252,7 +260,7 @@ function SSHKeysManager({

{t("environments.detail.sshKeys.count", { count: keys.length })}

- {canWrite && ( + {canConnect && (
- {canWrite && ( + {canConnect && ( +
+ )} + + {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/interactions/interaction-layout.tsx b/apps/web/src/components/projects/interactions/interaction-layout.tsx index 958e9915e..8b59c382b 100644 --- a/apps/web/src/components/projects/interactions/interaction-layout.tsx +++ b/apps/web/src/components/projects/interactions/interaction-layout.tsx @@ -231,6 +231,10 @@ interface InteractionLayoutProps { canCreate: boolean; canEdit: boolean; canManageViews: boolean; + /** Gates New/Start Sprint (backlog context only) — sprints.write, a + * separate permission from canCreate (tasks.write): being able to + * create a task doesn't imply being able to create or start a sprint. */ + canManageSprints: boolean; onTaskClick?: (task: Task) => void; sprintId?: string | null; /** The view context — drives which API bucket is used for views */ @@ -316,6 +320,7 @@ export function InteractionLayout({ canCreate, canEdit, canManageViews, + canManageSprints, onTaskClick, sprintId, context, @@ -1763,7 +1768,7 @@ export function InteractionLayout({ {title} {headerActions} - {context === "backlog" && canCreate && ( + {context === "backlog" && canManageSprints && (
+ {isFullAccess && ( +

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

+ )}
{PROJECT_PERMISSION_GROUPS.map((group, groupIndex) => { @@ -298,7 +366,17 @@ export function ProjectRoleFormDialog({ {t("roles.formDialog.cancel")}
- {isLoading ? ( + {noPermission ? ( + + ) : isLoading ? (
{["cf1", "cf2", "cf3"].map((k) => (
(null); @@ -239,7 +259,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..6628ae746 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,29 @@ export function TaskStatusesSettings({ canWrite: boolean; }) { const { t } = useTranslation("projects"); - const { data: statuses, isLoading } = useQuery( - taskStatusesQueryOptions(projectId), - ); + const { hasProjectPermission, isLoading: isPermissionsLoading } = + useProjectPermissions(projectId); + // No dedicated project.settings.task_statuses.read permission — viewing + // the status list is implied by tasks.read, same as viewing the tasks + // that reference it (see authz.PermissionProjectSettingsTaskTypesWrite's + // doc comment on the Go side). + const canRead = hasProjectPermission("tasks.read"); + const { + data: statuses, + isLoading: isDataLoading, + isError, + error, + } = useQuery({ + ...taskStatusesQueryOptions(projectId), + enabled: canRead, + }); + // While permissions are still loading, canRead defaults to false same as + // a confirmed denial — guard on isPermissionsLoading (and fold it into + // isLoading) so the section shows the skeleton instead of flashing + // NoPermissionState first. + const isLoading = isPermissionsLoading || isDataLoading; + const noPermission = + !isPermissionsLoading && (!canRead || (isError && isForbiddenError(error))); const queryClient = useQueryClient(); const [createOpen, setCreateOpen] = useState(false); const [editStatus, setEditStatus] = useState(null); @@ -159,7 +182,13 @@ export function TaskStatusesSettings({

) : null} - {isLoading ? ( + {noPermission ? ( + + ) : isLoading ? (
{["s1", "s2", "s3"].map((k) => (
(null); @@ -68,7 +90,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/hooks/use-project-permissions.test.tsx b/apps/web/src/hooks/use-project-permissions.test.tsx new file mode 100644 index 000000000..8b6d1d81b --- /dev/null +++ b/apps/web/src/hooks/use-project-permissions.test.tsx @@ -0,0 +1,74 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockUseQuery, mockCheckPermission } = vi.hoisted(() => ({ + mockUseQuery: vi.fn(), + mockCheckPermission: vi.fn(), +})); + +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual( + "@tanstack/react-query", + ); + + return { + ...actual, + useQuery: mockUseQuery, + }; +}); + +vi.mock("@/lib/permissions", () => ({ + hasPermission: mockCheckPermission, +})); + +import { useProjectPermissions } from "./use-project-permissions"; + +describe("useProjectPermissions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("exposes isLoading so callers can distinguish loading from denied", () => { + // While the permissions request is in flight, data isn't back yet — + // same shape a confirmed "no permissions" response would have. A + // caller gating a noPermission render on `hasProjectPermission` + // alone (without also checking isLoading) would flash + // NoPermissionState before the real result comes back. + mockUseQuery.mockReturnValue({ + data: undefined, + isLoading: true, + }); + + const { result } = renderHook(() => useProjectPermissions("proj-1")); + + expect(result.current.isLoading).toBe(true); + }); + + it("reports isLoading false once the permissions map has loaded", () => { + mockUseQuery.mockReturnValue({ + data: { "tasks.write": true }, + isLoading: false, + }); + + const { result } = renderHook(() => useProjectPermissions("proj-1")); + + expect(result.current.isLoading).toBe(false); + }); + + it("delegates hasProjectPermission checks to the permissions helper with only granted keys", () => { + mockUseQuery.mockReturnValue({ + data: { "tasks.write": true, "tasks.delete": false }, + isLoading: false, + }); + mockCheckPermission.mockReturnValue(true); + + const { result } = renderHook(() => useProjectPermissions("proj-1")); + const canWrite = result.current.hasProjectPermission("tasks.write"); + + expect(canWrite).toBe(true); + expect(mockCheckPermission).toHaveBeenCalledWith( + ["tasks.write"], + "tasks.write", + ); + }); +}); diff --git a/apps/web/src/hooks/use-project-permissions.ts b/apps/web/src/hooks/use-project-permissions.ts index 4a342ba9e..82dbedb9b 100644 --- a/apps/web/src/hooks/use-project-permissions.ts +++ b/apps/web/src/hooks/use-project-permissions.ts @@ -12,7 +12,7 @@ import { myProjectPermissionsQueryOptions } from "@/lib/project-api"; * without requiring access to the full members or roles lists. */ export function useProjectPermissions(projectId: string) { - const { data: permissionsMap = {} } = useQuery({ + const { data: permissionsMap = {}, isLoading } = useQuery({ ...myProjectPermissionsQueryOptions(projectId), enabled: !!projectId, }); @@ -26,5 +26,10 @@ export function useProjectPermissions(projectId: string) { const hasProjectPermission = (permission: string): boolean => hasPermission(projectPermissions, permission); - return { hasProjectPermission }; + // Callers gate a `noPermission` (vs. loading) render decision on this — + // while the permissions request is in flight, `permissionsMap` defaults + // to `{}` same as a confirmed "denied", so without `isLoading` a caller + // can't tell "not yet known" from "known and denied" and flashes + // NoPermissionState before the real result comes back. + return { hasProjectPermission, isLoading }; } 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/errors.json b/apps/web/src/i18n/locales/en/errors.json index 4cf9ec8f6..48c43590e 100644 --- a/apps/web/src/i18n/locales/en/errors.json +++ b/apps/web/src/i18n/locales/en/errors.json @@ -1,4 +1,6 @@ { "pluginLoadFailedPrefix": "Plugin", - "pluginLoadFailedSuffix": "failed to load" + "pluginLoadFailedSuffix": "failed to load", + "pluginNoPermissionTitle": "You don't have permission to view this page", + "pluginNoPermissionDescription": "Ask a project owner or admin to grant you access to {{pluginName}}." } diff --git a/apps/web/src/i18n/locales/en/projects.json b/apps/web/src/i18n/locales/en/projects.json index 055a0ce51..d6adcc776 100644 --- a/apps/web/src/i18n/locales/en/projects.json +++ b/apps/web/src/i18n/locales/en/projects.json @@ -12,6 +12,12 @@ "customFields": "Custom Fields", "dangerZone": "Danger Zone", "plugins": "Plugins" + }, + "pluginTab": { + "noPermission": { + "title": "You don't have permission to view this tab", + "description": "Ask a project owner or admin to grant you access to {{pluginName}}." + } } } }, @@ -92,6 +98,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 +115,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 +169,7 @@ "mcpServers": "MCP Servers", "skills": "Skills", "envVars": "Environment", + "access": "Access", "activity": "Activity" }, "avatar": { @@ -290,6 +303,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,12 +338,19 @@ "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", "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…", @@ -418,6 +445,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 +488,8 @@ "tabs": { "overview": "Overview", "folders": "Folders", - "portForwards": "Port forwards" + "portForwards": "Port forwards", + "access": "Access" }, "overview": { "connect": "Connect", @@ -500,6 +534,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 +564,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 +597,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 +651,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 +662,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 +673,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", @@ -659,6 +714,8 @@ "roleNameLabel": "Role Name", "roleNamePlaceholder": "e.g. PROJECT_REVIEWER", "permissionsLabel": "Permissions", + "fullAccessBadge": "Full access", + "fullAccessDescription": "This role automatically includes every permission, including ones added in the future. Changing any toggle below converts it to today's fixed set.", "enabledCount_one": "{{count}} enabled", "enabledCount_other": "{{count}} enabled", "cancel": "Cancel", @@ -674,10 +731,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 +755,18 @@ "label": "Manage Roles", "description": "Create, edit, and delete project roles" }, + "settingsTaskTypesWrite": { + "label": "Manage Task Types", + "description": "Create, edit, delete, and set the default task type" + }, + "settingsTaskStatusesWrite": { + "label": "Manage Task Statuses", + "description": "Create, edit, delete, reorder, and set the default task status" + }, + "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 +783,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 +827,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 +851,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 +903,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 +929,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 +952,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 +976,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 +1099,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 +1199,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 +1409,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 +1453,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 +1582,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 +1837,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/errors.json b/apps/web/src/i18n/locales/es/errors.json index 2c27caf73..51e8bc908 100644 --- a/apps/web/src/i18n/locales/es/errors.json +++ b/apps/web/src/i18n/locales/es/errors.json @@ -1,4 +1,6 @@ { "pluginLoadFailedPrefix": "El plugin", - "pluginLoadFailedSuffix": "no se pudo cargar" + "pluginLoadFailedSuffix": "no se pudo cargar", + "pluginNoPermissionTitle": "No tienes permiso para ver esta página", + "pluginNoPermissionDescription": "Pide a un propietario o administrador del proyecto que te conceda acceso a {{pluginName}}." } diff --git a/apps/web/src/i18n/locales/es/projects.json b/apps/web/src/i18n/locales/es/projects.json index 3982e55ee..3bd1e904c 100644 --- a/apps/web/src/i18n/locales/es/projects.json +++ b/apps/web/src/i18n/locales/es/projects.json @@ -12,6 +12,12 @@ "customFields": "Campos personalizados", "dangerZone": "Zona de peligro", "plugins": "Plugins" + }, + "pluginTab": { + "noPermission": { + "title": "No tienes permiso para ver esta pestaña", + "description": "Pide a un propietario o administrador del proyecto que te conceda acceso a {{pluginName}}." + } } } }, @@ -92,6 +98,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 +119,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 +169,7 @@ "mcpServers": "Servidores MCP", "skills": "Skills", "envVars": "Entorno", + "access": "Access", "activity": "Actividad" }, "avatar": { @@ -290,6 +303,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,12 +338,19 @@ "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", "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…", @@ -418,10 +445,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 +488,8 @@ "tabs": { "overview": "Resumen", "folders": "Carpetas", - "portForwards": "Reenvíos de puertos" + "portForwards": "Reenvíos de puertos", + "access": "Access" }, "overview": { "connect": "Conectar", @@ -500,6 +534,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 +564,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 +597,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 +651,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 +662,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 +673,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", @@ -659,6 +714,8 @@ "roleNameLabel": "Nombre del rol", "roleNamePlaceholder": "p. ej. PROJECT_REVIEWER", "permissionsLabel": "Permisos", + "fullAccessBadge": "Acceso total", + "fullAccessDescription": "Este rol incluye automáticamente todos los permisos, incluidos los que se añadan en el futuro. Cambiar cualquier permiso a continuación lo convertirá en un conjunto fijo con los permisos actuales.", "enabledCount_one": "{{count}} habilitado", "enabledCount_other": "{{count}} habilitados", "cancel": "Cancelar", @@ -674,10 +731,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 +755,18 @@ "label": "Gestionar roles", "description": "Crear, editar y eliminar roles del proyecto" }, + "settingsTaskTypesWrite": { + "label": "Manage Task Types", + "description": "Create, edit, delete, and set the default task type" + }, + "settingsTaskStatusesWrite": { + "label": "Manage Task Statuses", + "description": "Create, edit, delete, reorder, and set the default task status" + }, + "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 +783,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 +827,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 +851,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 +918,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 +942,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 +966,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 +1041,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 +1140,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 +1199,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 +1409,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 +1453,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 +1582,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 +1843,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/errors.json b/apps/web/src/i18n/locales/fr/errors.json index f12978162..e3270ca08 100644 --- a/apps/web/src/i18n/locales/fr/errors.json +++ b/apps/web/src/i18n/locales/fr/errors.json @@ -1,4 +1,6 @@ { "pluginLoadFailedPrefix": "Le plugin", - "pluginLoadFailedSuffix": "n'a pas pu être chargé" + "pluginLoadFailedSuffix": "n'a pas pu être chargé", + "pluginNoPermissionTitle": "Vous n'avez pas la permission de voir cette page", + "pluginNoPermissionDescription": "Demandez à un propriétaire ou administrateur du projet de vous accorder l'accès à {{pluginName}}." } diff --git a/apps/web/src/i18n/locales/fr/projects.json b/apps/web/src/i18n/locales/fr/projects.json index 75cf41957..5cec9348d 100644 --- a/apps/web/src/i18n/locales/fr/projects.json +++ b/apps/web/src/i18n/locales/fr/projects.json @@ -12,6 +12,12 @@ "customFields": "Champs personnalisés", "dangerZone": "Zone de danger", "plugins": "Plugins" + }, + "pluginTab": { + "noPermission": { + "title": "Vous n'avez pas la permission de voir cet onglet", + "description": "Demandez à un propriétaire ou administrateur du projet de vous accorder l'accès à {{pluginName}}." + } } } }, @@ -92,6 +98,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 +119,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 +169,7 @@ "mcpServers": "Serveurs MCP", "skills": "Compétences", "envVars": "Environnement", + "access": "Access", "activity": "Activité" }, "avatar": { @@ -290,6 +303,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,12 +338,19 @@ "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", "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…", @@ -418,10 +445,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 +488,8 @@ "tabs": { "overview": "Aperçu", "folders": "Dossiers", - "portForwards": "Transferts de ports" + "portForwards": "Transferts de ports", + "access": "Access" }, "overview": { "connect": "Se connecter", @@ -500,6 +534,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 +564,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 +597,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 +651,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 +662,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 +673,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é", @@ -659,6 +714,8 @@ "roleNameLabel": "Nom du rôle", "roleNamePlaceholder": "ex. PROJECT_REVIEWER", "permissionsLabel": "Permissions", + "fullAccessBadge": "Accès complet", + "fullAccessDescription": "Ce rôle inclut automatiquement toutes les permissions, y compris celles ajoutées ultérieurement. Modifier un interrupteur ci-dessous le convertira en un ensemble fixe correspondant aux permissions actuelles.", "enabledCount_one": "{{count}} activée", "enabledCount_other": "{{count}} activées", "cancel": "Annuler", @@ -674,10 +731,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 +755,18 @@ "label": "Gérer les rôles", "description": "Créer, modifier et supprimer les rôles du projet" }, + "settingsTaskTypesWrite": { + "label": "Manage Task Types", + "description": "Create, edit, delete, and set the default task type" + }, + "settingsTaskStatusesWrite": { + "label": "Manage Task Statuses", + "description": "Create, edit, delete, reorder, and set the default task status" + }, + "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 +783,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 +827,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 +851,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 +918,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 +942,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 +966,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 +1041,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 +1140,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 +1199,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 +1409,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 +1453,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 +1582,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 +1843,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/errors.json b/apps/web/src/i18n/locales/ja/errors.json index eb30b4edd..2e00a3ca6 100644 --- a/apps/web/src/i18n/locales/ja/errors.json +++ b/apps/web/src/i18n/locales/ja/errors.json @@ -1,4 +1,6 @@ { "pluginLoadFailedPrefix": "プラグイン", - "pluginLoadFailedSuffix": "の読み込みに失敗しました" + "pluginLoadFailedSuffix": "の読み込みに失敗しました", + "pluginNoPermissionTitle": "このページを表示する権限がありません", + "pluginNoPermissionDescription": "{{pluginName}}へのアクセス権限をプロジェクトのオーナーまたは管理者に依頼してください。" } diff --git a/apps/web/src/i18n/locales/ja/projects.json b/apps/web/src/i18n/locales/ja/projects.json index 55420577c..a30fba830 100644 --- a/apps/web/src/i18n/locales/ja/projects.json +++ b/apps/web/src/i18n/locales/ja/projects.json @@ -12,6 +12,12 @@ "customFields": "カスタムフィールド", "dangerZone": "危険な操作", "plugins": "プラグイン" + }, + "pluginTab": { + "noPermission": { + "title": "このタブを表示する権限がありません", + "description": "{{pluginName}}へのアクセス権限をプロジェクトのオーナーまたは管理者に依頼してください。" + } } } }, @@ -92,6 +98,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 +119,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 +169,7 @@ "mcpServers": "MCPサーバー", "skills": "スキル", "envVars": "環境変数", + "access": "Access", "activity": "アクティビティ" }, "avatar": { @@ -290,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": { @@ -318,12 +338,19 @@ "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", "connect": "接続", "conversationEnded": "この会話は終了しました。", "textOnlyMessage": "テキストメッセージのみサポートされています。", + "agentAccessRestricted": "このエージェントはアクセスが制限されています。チャットする前に、プロジェクト管理者にアクセス権を付与してもらってください。", + "environmentAccessRestricted": "この会話の環境はアクセスが制限されています。このエージェントとチャットする前に、プロジェクト管理者にアクセス権を付与してもらってください。", + "chatNoPermission": "この会話でメッセージを送信する権限がありません。", "failed": "会話が失敗しました", "noOutput": "エージェントは出力を生成しませんでした。", "loadingOlder": "読み込み中…", @@ -418,10 +445,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 +488,8 @@ "tabs": { "overview": "概要", "folders": "フォルダー", - "portForwards": "ポートフォワード" + "portForwards": "ポートフォワード", + "access": "Access" }, "overview": { "connect": "接続", @@ -500,6 +534,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 +564,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 +597,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 +651,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 +662,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 +673,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": "保存しました", @@ -659,6 +714,8 @@ "roleNameLabel": "ロール名", "roleNamePlaceholder": "例: PROJECT_REVIEWER", "permissionsLabel": "権限", + "fullAccessBadge": "フルアクセス", + "fullAccessDescription": "このロールには、将来追加される権限を含め、すべての権限が自動的に含まれます。以下のいずれかを切り替えると、現在の権限の固定セットに変換されます。", "enabledCount_one": "{{count}}件有効", "enabledCount_other": "{{count}}件有効", "cancel": "キャンセル", @@ -674,10 +731,6 @@ } }, "permissions": { - "projectsRead": { - "label": "プロジェクトの閲覧", - "description": "プロジェクトの詳細と設定を表示します" - }, "projectsWrite": { "label": "プロジェクトの編集", "description": "プロジェクト名、説明、設定を更新します" @@ -702,6 +755,18 @@ "label": "ロールの管理", "description": "プロジェクトロールの作成・編集・削除" }, + "settingsTaskTypesWrite": { + "label": "Manage Task Types", + "description": "Create, edit, delete, and set the default task type" + }, + "settingsTaskStatusesWrite": { + "label": "Manage Task Statuses", + "description": "Create, edit, delete, reorder, and set the default task status" + }, + "settingsCustomFieldsWrite": { + "label": "Manage Custom Fields", + "description": "Create, edit, and delete custom field definitions" + }, "tasksRead": { "label": "タスクの閲覧", "description": "プロジェクト内のタスクを閲覧します" @@ -718,6 +783,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 +827,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 +851,15 @@ "permissionGroups": { "project": "プロジェクト", "members": "メンバー", - "roles": "ロール", + "settings": "Settings", "tasks": "タスク", "sprints": "スプリント", + "views": "Views", "documents": "ドキュメント", "aiAgents": "AIエージェント", "conversations": "会話", "environments": "環境", + "annotations": "Annotations", "workflows": "自動化", "plugins": "プラグイン" } @@ -831,7 +918,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 +942,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 +966,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 +1041,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 +1140,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 +1199,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 +1409,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 +1453,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 +1582,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 +1843,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/errors.json b/apps/web/src/i18n/locales/ko/errors.json index 3d31a274c..684cf013b 100644 --- a/apps/web/src/i18n/locales/ko/errors.json +++ b/apps/web/src/i18n/locales/ko/errors.json @@ -1,4 +1,6 @@ { "pluginLoadFailedPrefix": "플러그인", - "pluginLoadFailedSuffix": "을(를) 불러오지 못했습니다" + "pluginLoadFailedSuffix": "을(를) 불러오지 못했습니다", + "pluginNoPermissionTitle": "이 페이지를 볼 권한이 없습니다", + "pluginNoPermissionDescription": "{{pluginName}}에 대한 액세스 권한을 프로젝트 소유자 또는 관리자에게 요청하세요." } diff --git a/apps/web/src/i18n/locales/ko/projects.json b/apps/web/src/i18n/locales/ko/projects.json index 4091d13e2..1988697db 100644 --- a/apps/web/src/i18n/locales/ko/projects.json +++ b/apps/web/src/i18n/locales/ko/projects.json @@ -12,6 +12,12 @@ "customFields": "커스텀 필드", "dangerZone": "위험 구역", "plugins": "플러그인" + }, + "pluginTab": { + "noPermission": { + "title": "이 탭을 볼 권한이 없습니다", + "description": "{{pluginName}}에 대한 액세스 권한을 프로젝트 소유자 또는 관리자에게 요청하세요." + } } } }, @@ -92,6 +98,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 +119,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 +169,7 @@ "mcpServers": "MCP 서버", "skills": "스킬", "envVars": "환경 변수", + "access": "Access", "activity": "활동" }, "avatar": { @@ -290,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": { @@ -318,12 +338,19 @@ "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", "connect": "연결", "conversationEnded": "이 대화가 종료되었습니다.", "textOnlyMessage": "텍스트 메시지만 지원됩니다.", + "agentAccessRestricted": "이 에이전트는 액세스가 제한되어 있습니다. 채팅하려면 프로젝트 관리자에게 액세스 권한을 요청하세요.", + "environmentAccessRestricted": "이 대화의 환경은 액세스가 제한되어 있습니다. 이 에이전트와 채팅하려면 프로젝트 관리자에게 액세스 권한을 요청하세요.", + "chatNoPermission": "이 대화에서 메시지를 보낼 권한이 없습니다.", "failed": "대화가 실패했습니다", "noOutput": "에이전트가 출력을 생성하지 않았습니다.", "loadingOlder": "불러오는 중…", @@ -418,10 +445,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 +488,8 @@ "tabs": { "overview": "개요", "folders": "폴더", - "portForwards": "포트 포워드" + "portForwards": "포트 포워드", + "access": "Access" }, "overview": { "connect": "연결", @@ -500,6 +534,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 +564,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 +597,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 +651,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 +662,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 +673,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": "저장됨", @@ -659,6 +714,8 @@ "roleNameLabel": "역할 이름", "roleNamePlaceholder": "예: PROJECT_REVIEWER", "permissionsLabel": "권한", + "fullAccessBadge": "전체 액세스", + "fullAccessDescription": "이 역할에는 향후 추가되는 권한을 포함하여 모든 권한이 자동으로 포함됩니다. 아래에서 하나라도 전환하면 현재 권한의 고정 세트로 변환됩니다.", "enabledCount_one": "{{count}}개 활성화됨", "enabledCount_other": "{{count}}개 활성화됨", "cancel": "취소", @@ -674,10 +731,6 @@ } }, "permissions": { - "projectsRead": { - "label": "프로젝트 조회", - "description": "프로젝트 세부정보 및 설정 보기" - }, "projectsWrite": { "label": "프로젝트 수정", "description": "프로젝트 이름, 설명, 설정 업데이트" @@ -702,6 +755,18 @@ "label": "역할 관리", "description": "프로젝트 역할 생성, 수정, 삭제" }, + "settingsTaskTypesWrite": { + "label": "Manage Task Types", + "description": "Create, edit, delete, and set the default task type" + }, + "settingsTaskStatusesWrite": { + "label": "Manage Task Statuses", + "description": "Create, edit, delete, reorder, and set the default task status" + }, + "settingsCustomFieldsWrite": { + "label": "Manage Custom Fields", + "description": "Create, edit, and delete custom field definitions" + }, "tasksRead": { "label": "작업 보기", "description": "프로젝트의 작업 탐색 및 읽기" @@ -718,6 +783,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 +827,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 +851,15 @@ "permissionGroups": { "project": "프로젝트", "members": "멤버", - "roles": "역할", + "settings": "Settings", "tasks": "작업", "sprints": "스프린트", + "views": "Views", "documents": "문서", "aiAgents": "AI 에이전트", "conversations": "대화", "environments": "환경", + "annotations": "Annotations", "workflows": "자동화", "plugins": "플러그인" } @@ -831,7 +918,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 +942,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 +966,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 +1041,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 +1140,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 +1199,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 +1409,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 +1453,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 +1582,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 +1843,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/errors.json b/apps/web/src/i18n/locales/pt-BR/errors.json index 575d077c9..e7cd780e4 100644 --- a/apps/web/src/i18n/locales/pt-BR/errors.json +++ b/apps/web/src/i18n/locales/pt-BR/errors.json @@ -1,4 +1,6 @@ { "pluginLoadFailedPrefix": "O plugin", - "pluginLoadFailedSuffix": "falhou ao carregar" + "pluginLoadFailedSuffix": "falhou ao carregar", + "pluginNoPermissionTitle": "Você não tem permissão para ver esta página", + "pluginNoPermissionDescription": "Peça a um proprietário ou administrador do projeto para conceder acesso a {{pluginName}}." } diff --git a/apps/web/src/i18n/locales/pt-BR/projects.json b/apps/web/src/i18n/locales/pt-BR/projects.json index fd9aebc79..06960c1b9 100644 --- a/apps/web/src/i18n/locales/pt-BR/projects.json +++ b/apps/web/src/i18n/locales/pt-BR/projects.json @@ -12,6 +12,12 @@ "customFields": "Campos personalizados", "dangerZone": "Zona de perigo", "plugins": "Plugins" + }, + "pluginTab": { + "noPermission": { + "title": "Você não tem permissão para ver esta aba", + "description": "Peça a um proprietário ou administrador do projeto para conceder acesso a {{pluginName}}." + } } } }, @@ -92,6 +98,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 +119,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 +169,7 @@ "mcpServers": "Servidores MCP", "skills": "Skills", "envVars": "Ambiente", + "access": "Access", "activity": "Atividade" }, "avatar": { @@ -290,6 +303,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,12 +338,19 @@ "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", "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…", @@ -418,10 +445,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 +488,8 @@ "tabs": { "overview": "Visão geral", "folders": "Pastas", - "portForwards": "Encaminhamentos de porta" + "portForwards": "Encaminhamentos de porta", + "access": "Access" }, "overview": { "connect": "Conectar", @@ -500,6 +534,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 +564,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 +597,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 +651,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 +662,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 +673,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", @@ -659,6 +714,8 @@ "roleNameLabel": "Nome do papel", "roleNamePlaceholder": "ex.: PROJECT_REVIEWER", "permissionsLabel": "Permissões", + "fullAccessBadge": "Acesso total", + "fullAccessDescription": "Esta função inclui automaticamente todas as permissões, incluindo as adicionadas no futuro. Alterar qualquer opção abaixo irá convertê-la em um conjunto fixo com as permissões atuais.", "enabledCount_one": "{{count}} ativada", "enabledCount_other": "{{count}} ativadas", "cancel": "Cancelar", @@ -674,10 +731,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 +755,18 @@ "label": "Gerenciar papéis", "description": "Criar, editar e excluir papéis do projeto" }, + "settingsTaskTypesWrite": { + "label": "Manage Task Types", + "description": "Create, edit, delete, and set the default task type" + }, + "settingsTaskStatusesWrite": { + "label": "Manage Task Statuses", + "description": "Create, edit, delete, reorder, and set the default task status" + }, + "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 +783,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 +827,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 +851,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 +918,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 +942,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 +966,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 +1041,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 +1140,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 +1199,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 +1409,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 +1453,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 +1582,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 +1843,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/errors.json b/apps/web/src/i18n/locales/ru/errors.json index af12e6716..aeba23ac3 100644 --- a/apps/web/src/i18n/locales/ru/errors.json +++ b/apps/web/src/i18n/locales/ru/errors.json @@ -1,4 +1,6 @@ { "pluginLoadFailedPrefix": "Плагин", - "pluginLoadFailedSuffix": "не удалось загрузить" + "pluginLoadFailedSuffix": "не удалось загрузить", + "pluginNoPermissionTitle": "У вас нет прав для просмотра этой страницы", + "pluginNoPermissionDescription": "Попросите владельца или администратора проекта предоставить вам доступ к {{pluginName}}." } diff --git a/apps/web/src/i18n/locales/ru/projects.json b/apps/web/src/i18n/locales/ru/projects.json index 5b9172c2e..14b624097 100644 --- a/apps/web/src/i18n/locales/ru/projects.json +++ b/apps/web/src/i18n/locales/ru/projects.json @@ -12,6 +12,12 @@ "customFields": "Пользовательские поля", "dangerZone": "Опасная зона", "plugins": "Плагины" + }, + "pluginTab": { + "noPermission": { + "title": "У вас нет прав для просмотра этой вкладки", + "description": "Попросите владельца или администратора проекта предоставить вам доступ к {{pluginName}}." + } } } }, @@ -92,6 +98,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 +119,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 +169,7 @@ "mcpServers": "MCP-серверы", "skills": "Навыки", "envVars": "Окружение", + "access": "Access", "activity": "Активность" }, "avatar": { @@ -296,6 +309,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,12 +344,19 @@ "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", "connect": "Подключиться", "conversationEnded": "Этот диалог завершён.", "textOnlyMessage": "Поддерживаются только текстовые сообщения.", + "agentAccessRestricted": "Этот агент имеет ограниченный доступ. Попросите администратора проекта предоставить вам доступ, прежде чем вы сможете общаться с ним.", + "environmentAccessRestricted": "Окружение этого диалога имеет ограниченный доступ. Попросите администратора проекта предоставить вам доступ, прежде чем вы сможете общаться с этим агентом.", + "chatNoPermission": "У вас нет разрешения отправлять сообщения в этом диалоге.", "failed": "Диалог завершился с ошибкой", "noOutput": "Агент не выдал результат.", "loadingOlder": "Загрузка…", @@ -428,10 +455,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 +498,8 @@ "tabs": { "overview": "Обзор", "folders": "Папки", - "portForwards": "Проброс портов" + "portForwards": "Проброс портов", + "access": "Access" }, "overview": { "connect": "Подключиться", @@ -510,6 +544,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 +574,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 +607,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 +661,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 +672,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 +683,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": "Сохранено", @@ -669,6 +724,8 @@ "roleNameLabel": "Название роли", "roleNamePlaceholder": "например, PROJECT_REVIEWER", "permissionsLabel": "Разрешения", + "fullAccessBadge": "Полный доступ", + "fullAccessDescription": "Эта роль автоматически включает все разрешения, в том числе те, что будут добавлены в будущем. Изменение любого переключателя ниже преобразует её в фиксированный набор текущих разрешений.", "enabledCount_one": "{{count}} включено", "enabledCount_few": "{{count}} включено", "enabledCount_many": "{{count}} включено", @@ -686,10 +743,6 @@ } }, "permissions": { - "projectsRead": { - "label": "Читать проект", - "description": "Просматривать данные и настройки проекта" - }, "projectsWrite": { "label": "Редактировать проект", "description": "Обновлять название, описание и настройки проекта" @@ -714,6 +767,18 @@ "label": "Управлять ролями", "description": "Создавать, редактировать и удалять роли проекта" }, + "settingsTaskTypesWrite": { + "label": "Manage Task Types", + "description": "Create, edit, delete, and set the default task type" + }, + "settingsTaskStatusesWrite": { + "label": "Manage Task Statuses", + "description": "Create, edit, delete, reorder, and set the default task status" + }, + "settingsCustomFieldsWrite": { + "label": "Manage Custom Fields", + "description": "Create, edit, and delete custom field definitions" + }, "tasksRead": { "label": "Просматривать задачи", "description": "Открывать и читать задачи в проекте" @@ -730,6 +795,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 +839,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 +863,15 @@ "permissionGroups": { "project": "Проект", "members": "Участники", - "roles": "Роли", + "settings": "Settings", "tasks": "Задачи", "sprints": "Спринты", + "views": "Views", "documents": "Документы", "aiAgents": "AI-агенты", "conversations": "Беседы", "environments": "Окружения", + "annotations": "Annotations", "workflows": "Автоматизация", "plugins": "Плагины" } @@ -845,7 +932,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 +956,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 +980,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 +1055,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 +1156,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 +1215,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 +1427,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 +1471,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 +1600,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 +1863,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/errors.json b/apps/web/src/i18n/locales/vi/errors.json index 11694e763..849ff6d24 100644 --- a/apps/web/src/i18n/locales/vi/errors.json +++ b/apps/web/src/i18n/locales/vi/errors.json @@ -1,4 +1,6 @@ { "pluginLoadFailedPrefix": "Plugin", - "pluginLoadFailedSuffix": "tải không thành công" + "pluginLoadFailedSuffix": "tải không thành công", + "pluginNoPermissionTitle": "Bạn không có quyền xem trang này", + "pluginNoPermissionDescription": "Hãy yêu cầu chủ sở hữu hoặc quản trị viên dự án cấp cho bạn quyền truy cập vào {{pluginName}}." } diff --git a/apps/web/src/i18n/locales/vi/projects.json b/apps/web/src/i18n/locales/vi/projects.json index 2a907b822..7577da7c9 100644 --- a/apps/web/src/i18n/locales/vi/projects.json +++ b/apps/web/src/i18n/locales/vi/projects.json @@ -12,6 +12,12 @@ "customFields": "Trường tùy chỉnh", "dangerZone": "Khu vực nguy hiểm", "plugins": "Plugin" + }, + "pluginTab": { + "noPermission": { + "title": "Bạn không có quyền xem tab này", + "description": "Hãy yêu cầu chủ sở hữu hoặc quản trị viên dự án cấp cho bạn quyền truy cập vào {{pluginName}}." + } } } }, @@ -92,6 +98,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 +119,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 +169,7 @@ "mcpServers": "Máy chủ MCP", "skills": "Skill", "envVars": "Môi trường", + "access": "Access", "activity": "Hoạt động" }, "avatar": { @@ -290,6 +303,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,12 +338,19 @@ "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", "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…", @@ -418,10 +445,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 +488,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 +534,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 +564,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 +597,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 +651,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 +662,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 +673,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", @@ -659,6 +714,8 @@ "roleNameLabel": "Tên vai trò", "roleNamePlaceholder": "vd: PROJECT_REVIEWER", "permissionsLabel": "Quyền hạn", + "fullAccessBadge": "Toàn quyền truy cập", + "fullAccessDescription": "Vai trò này tự động bao gồm mọi quyền, kể cả những quyền được thêm trong tương lai. Thay đổi bất kỳ công tắc nào bên dưới sẽ chuyển nó thành một tập quyền cố định theo hiện tại.", "enabledCount_one": "{{count}} đã bật", "enabledCount_other": "{{count}} đã bật", "cancel": "Hủy", @@ -674,10 +731,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 +755,18 @@ "label": "Quản lý vai trò", "description": "Tạo, sửa và xóa vai trò dự án" }, + "settingsTaskTypesWrite": { + "label": "Manage Task Types", + "description": "Create, edit, delete, and set the default task type" + }, + "settingsTaskStatusesWrite": { + "label": "Manage Task Statuses", + "description": "Create, edit, delete, reorder, and set the default task status" + }, + "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 +783,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 +827,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 +851,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 +918,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 +942,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 +966,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 +1041,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 +1140,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 +1199,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 +1409,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 +1453,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 +1582,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 +1843,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/errors.json b/apps/web/src/i18n/locales/zh-CN/errors.json index 2d66cd481..8411c966a 100644 --- a/apps/web/src/i18n/locales/zh-CN/errors.json +++ b/apps/web/src/i18n/locales/zh-CN/errors.json @@ -1,4 +1,6 @@ { "pluginLoadFailedPrefix": "插件", - "pluginLoadFailedSuffix": "加载失败" + "pluginLoadFailedSuffix": "加载失败", + "pluginNoPermissionTitle": "您没有权限查看此页面", + "pluginNoPermissionDescription": "请项目所有者或管理员为您授予访问 {{pluginName}} 的权限。" } diff --git a/apps/web/src/i18n/locales/zh-CN/projects.json b/apps/web/src/i18n/locales/zh-CN/projects.json index 01ce2846a..697383142 100644 --- a/apps/web/src/i18n/locales/zh-CN/projects.json +++ b/apps/web/src/i18n/locales/zh-CN/projects.json @@ -12,6 +12,12 @@ "customFields": "自定义字段", "dangerZone": "危险区域", "plugins": "插件" + }, + "pluginTab": { + "noPermission": { + "title": "您没有权限查看此标签页", + "description": "请项目所有者或管理员为您授予访问 {{pluginName}} 的权限。" + } } } }, @@ -92,6 +98,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 +119,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 +169,7 @@ "mcpServers": "MCP 服务器", "skills": "技能", "envVars": "环境变量", + "access": "Access", "activity": "动态" }, "avatar": { @@ -290,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": { @@ -318,12 +338,19 @@ "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", "connect": "连接", "conversationEnded": "此对话已结束。", "textOnlyMessage": "仅支持文本消息。", + "agentAccessRestricted": "此代理已被限制访问。请让项目管理员授予你访问权限后再与其聊天。", + "environmentAccessRestricted": "此对话的环境已被限制访问。请让项目管理员授予你访问权限后再与该代理聊天。", + "chatNoPermission": "你没有权限在此对话中发送消息。", "failed": "对话失败", "noOutput": "代理未生成任何输出。", "loadingOlder": "加载中…", @@ -418,10 +445,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 +488,8 @@ "tabs": { "overview": "概览", "folders": "文件夹", - "portForwards": "端口转发" + "portForwards": "端口转发", + "access": "Access" }, "overview": { "connect": "连接", @@ -500,6 +534,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 +564,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 +597,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 +651,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 +662,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 +673,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": "已保存", @@ -659,6 +714,8 @@ "roleNameLabel": "角色名称", "roleNamePlaceholder": "例如:PROJECT_REVIEWER", "permissionsLabel": "权限", + "fullAccessBadge": "完全访问权限", + "fullAccessDescription": "此角色自动包含所有权限,包括将来添加的权限。切换下方任意权限都会将其转换为当前权限的固定集合。", "enabledCount_one": "已启用 {{count}} 项", "enabledCount_other": "已启用 {{count}} 项", "cancel": "取消", @@ -674,10 +731,6 @@ } }, "permissions": { - "projectsRead": { - "label": "查看项目", - "description": "查看项目详情和设置" - }, "projectsWrite": { "label": "编辑项目", "description": "更新项目名称、描述和设置" @@ -702,6 +755,18 @@ "label": "管理角色", "description": "创建、编辑和删除项目角色" }, + "settingsTaskTypesWrite": { + "label": "Manage Task Types", + "description": "Create, edit, delete, and set the default task type" + }, + "settingsTaskStatusesWrite": { + "label": "Manage Task Statuses", + "description": "Create, edit, delete, reorder, and set the default task status" + }, + "settingsCustomFieldsWrite": { + "label": "Manage Custom Fields", + "description": "Create, edit, and delete custom field definitions" + }, "tasksRead": { "label": "查看任务", "description": "浏览并读取项目中的任务" @@ -718,6 +783,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 +827,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 +851,15 @@ "permissionGroups": { "project": "项目", "members": "成员", - "roles": "角色", + "settings": "Settings", "tasks": "任务", "sprints": "冲刺", + "views": "Views", "documents": "文档", "aiAgents": "AI 智能体", "conversations": "对话", "environments": "环境", + "annotations": "Annotations", "workflows": "自动化", "plugins": "插件" } @@ -831,7 +918,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 +942,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 +966,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 +1041,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 +1140,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 +1199,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 +1409,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 +1453,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 +1582,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 +1843,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..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", @@ -130,6 +142,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/lib/permissions.test.ts b/apps/web/src/lib/permissions.test.ts index fbeb5b30f..e450d9575 100644 --- a/apps/web/src/lib/permissions.test.ts +++ b/apps/web/src/lib/permissions.test.ts @@ -32,12 +32,47 @@ describe("permissions", () => { expect(hasPermission(["project.members.*"], "project.members.read")).toBe( true, ); - expect(hasPermission(["project.*"], "project.members.write")).toBe(false); + // A hypothetical broader "project.*" would, by the same prefix rule, + // cover a narrower "project.members.write" — matches the Go backend's + // own hasPermission (internal/platform/authz/authorizer.go), which + // checks every granted wildcard's prefix, not just the one derived + // from the required key's immediate parent. + expect(hasPermission(["project.*"], "project.members.write")).toBe(true); + // A same-*depth* but unrelated wildcard must not match. expect(hasPermission(["project.roles.*"], "project.members.write")).toBe( false, ); }); + // Regression coverage: project.settings.task_types/task_statuses/ + // custom_fields.read/write nest one level below project.settings.* — a + // role granted only that broader wildcard (e.g. the built-in Admin role) + // was previously read as having none of the narrower permissions, + // because the old implementation only ever checked one wildcard + // candidate derived from the required key's immediate parent + // ("project.settings.task_statuses.*"), never looking at + // "project.settings.*" itself. + it("hasPermission matches a wildcard nested above the immediate parent", () => { + expect( + hasPermission( + ["project.settings.*"], + "project.settings.task_statuses.read", + ), + ).toBe(true); + expect( + hasPermission( + ["project.settings.*"], + "project.settings.task_types.write", + ), + ).toBe(true); + expect( + hasPermission( + ["project.settings.*"], + "project.settings.custom_fields.read", + ), + ).toBe(true); + }); + it("hasAnyPermission returns true if any required permission is granted", () => { expect( hasAnyPermission(["projects.*"], ["users.read", "projects.write"]), @@ -68,6 +103,30 @@ describe("permissions", () => { }); }); + it("expandWildcardPermissions checks a role-editor box for a permission nested above the granted wildcard's immediate parent", () => { + // The exact shape of the Admin-role bug: granted only + // "project.settings.*", every project.settings..read/write + // checkbox must still read as checked. + const settingsPermissions: PermissionDefinition[] = [ + { key: "project.settings.task_types.read", domain: "project.settings" }, + { key: "project.settings.task_types.write", domain: "project.settings" }, + { + key: "project.settings.task_statuses.read", + domain: "project.settings", + }, + ]; + expect( + expandWildcardPermissions( + { "project.settings.*": true }, + settingsPermissions, + ), + ).toEqual({ + "project.settings.task_types.read": true, + "project.settings.task_types.write": true, + "project.settings.task_statuses.read": true, + }); + }); + it("expandWildcardPermissions matches plugin-declared permissions by their own key prefix, not the synthetic UI domain", () => { // Plugin-declared permissions all share the UI domain "plugins" (see // toPluginKnownPermissions), but the wildcard a role actually stores is @@ -142,4 +201,14 @@ describe("permissions", () => { ["*"], ); }); + + it("dedupeGrantedPermissions drops a key covered by a wildcard nested above its immediate parent", () => { + expect( + dedupeGrantedPermissions([ + "project.settings.*", + "project.settings.task_statuses.read", + "project.settings.task_statuses.write", + ]), + ).toEqual(["project.settings.*"]); + }); }); diff --git a/apps/web/src/lib/permissions.ts b/apps/web/src/lib/permissions.ts index bcf5a9f39..ef558f7a6 100644 --- a/apps/web/src/lib/permissions.ts +++ b/apps/web/src/lib/permissions.ts @@ -5,6 +5,19 @@ export interface PermissionDefinition { export type PermissionMap = Record; +/** + * Mirrors the Go backend's own matcher (internal/platform/authz/authorizer.go's + * hasPermission): every granted key ending in ".*" is tried as a prefix + * against requiredPermission, not just one wildcard derived from + * requiredPermission's *immediate* parent. That distinction matters now + * that some domains nest a wildcard below the top level — + * "project.settings.*" must cover "project.settings.task_statuses.read", + * but checking only the immediate-parent candidate + * ("project.settings.task_statuses.*") never looks at "project.settings.*" + * at all, so a role granted just the broader wildcard read as having none + * of its narrower permissions. (apps/mcp/src/permissions.ts had the same + * class of bug, fixed the same way, for the same reason.) + */ export function hasPermission( grantedPermissions: string[], requiredPermission: string, @@ -12,11 +25,11 @@ export function hasPermission( if (grantedPermissions.includes("*")) return true; if (grantedPermissions.includes(requiredPermission)) return true; - const lastDotIndex = requiredPermission.lastIndexOf("."); - if (lastDotIndex === -1) return false; - - const prefix = requiredPermission.slice(0, lastDotIndex); - return grantedPermissions.includes(`${prefix}.*`); + return grantedPermissions.some((granted) => { + if (!granted.endsWith(".*")) return false; + const prefix = granted.slice(0, -1); // strip the trailing "*", keep the dot + return requiredPermission.startsWith(prefix); + }); } export function hasAnyPermission( @@ -41,12 +54,18 @@ export function hasAnyPermission( export function dedupeGrantedPermissions( grantedPermissions: string[], ): string[] { - const granted = new Set(grantedPermissions); - if (granted.has("*")) return ["*"]; + if (grantedPermissions.includes("*")) return ["*"]; return grantedPermissions.filter((key) => { if (key.endsWith(".*")) return true; - return !granted.has(`${keyPrefix(key)}.*`); + // A key is redundant once *any* other granted wildcard's prefix + // covers it — not just the one derived from its immediate parent + // (see hasPermission's doc comment for why a single derived + // candidate misses a broader wildcard like "project.settings.*" + // covering "project.settings.task_statuses.read"). + return !grantedPermissions.some( + (other) => other !== key && hasPermission([other], key), + ); }); } @@ -68,15 +87,16 @@ export function expandWildcardPermissions( ): PermissionMap { if (!source) return {}; - const expanded: PermissionMap = {}; - const hasGlobalWildcard = source["*"] === true; + // Delegates to hasPermission so a role-editor checkbox reads as checked + // under exactly the same rule that actually grants access — including a + // wildcard nested above a permission's immediate parent (e.g. a role + // with just "project.settings.*" checks every project.settings.* box, + // not only ones matching "project.settings..*"). + const granted = Object.keys(source).filter((key) => source[key] === true); + const expanded: PermissionMap = {}; for (const permission of knownPermissions) { - const prefixWildcard = `${keyPrefix(permission.key)}.*`; - expanded[permission.key] = - hasGlobalWildcard || - source[prefixWildcard] === true || - source[permission.key] === true; + expanded[permission.key] = hasPermission(granted, permission.key); } return expanded; diff --git a/apps/web/src/lib/plugin-api.test.ts b/apps/web/src/lib/plugin-api.test.ts new file mode 100644 index 000000000..d795189d3 --- /dev/null +++ b/apps/web/src/lib/plugin-api.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { buildRegistryMap, type Plugin } from "./plugin-api"; + +function makePlugin(overrides: Partial = {}): Plugin { + return { + id: "uuid-1", + name: "com.paca.example", + version: "1.0.0", + enabled: true, + installed_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + manifest: { + id: "com.paca.example", + displayName: "Example", + version: "1.0.0", + frontend: { + remoteEntryUrl: "https://example.test/remoteEntry.js", + extensionPoints: [ + { point: "project.settings.tab", component: "SettingsTab" }, + ], + }, + }, + ...overrides, + }; +} + +describe("buildRegistryMap", () => { + it("carries requiredPermission through from the manifest to the registration", () => { + const plugin = makePlugin({ + manifest: { + id: "com.paca.example", + displayName: "Example", + version: "1.0.0", + frontend: { + remoteEntryUrl: "https://example.test/remoteEntry.js", + extensionPoints: [ + { + point: "project.settings.tab", + component: "SettingsTab", + requiredPermission: "projects.write", + }, + ], + }, + }, + }); + + const registry = buildRegistryMap([plugin]); + const regs = registry.get("project.settings.tab"); + + expect(regs).toHaveLength(1); + expect(regs?.[0].requiredPermission).toBe("projects.write"); + }); + + it("leaves requiredPermission undefined when the manifest omits it", () => { + const registry = buildRegistryMap([makePlugin()]); + const regs = registry.get("project.settings.tab"); + + expect(regs).toHaveLength(1); + expect(regs?.[0].requiredPermission).toBeUndefined(); + }); + + it("excludes registrations from disabled plugins", () => { + const registry = buildRegistryMap([makePlugin({ enabled: false })]); + expect(registry.get("project.settings.tab")).toBeUndefined(); + }); +}); diff --git a/apps/web/src/lib/plugin-api.ts b/apps/web/src/lib/plugin-api.ts index e55c6cd6e..1d86034c0 100644 --- a/apps/web/src/lib/plugin-api.ts +++ b/apps/web/src/lib/plugin-api.ts @@ -19,6 +19,8 @@ export interface ExtensionPointRegistration { component: string; label?: string; order?: number; + /** See `PluginNavItem.requiredPermission` — same semantics, applied to an embedded registration instead of a full page. */ + requiredPermission?: string; } export interface PluginNavItem { @@ -199,6 +201,8 @@ export interface PluginRegistration { component: string; order: number; hidden?: boolean; + /** See `ExtensionPointRegistration.requiredPermission`. */ + requiredPermission?: string; } /** Build a Map from the plugins list. */ @@ -234,6 +238,7 @@ export function buildRegistryMap( component: reg.component, order, hidden: setting?.settings.hidden ?? false, + requiredPermission: reg.requiredPermission, }); map.set(point, regs); } 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..95c2f8f59 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, }); @@ -58,10 +62,22 @@ function GlobalAgentsPage() { const { t } = useTranslation("admin"); const search = Route.useSearch(); const navigate = Route.useNavigate(); - const { hasPermission } = usePermissions(); + const { hasPermission, isLoading: isPermissionsLoading } = usePermissions(); const canWrite = hasPermission("agents.write"); + const canRead = hasPermission("agents.read"); - const { data: agents = [], isLoading } = useQuery(globalAgentsQueryOptions); + const { + data: agents = [], + isLoading: isDataLoading, + isError, + error, + } = useQuery({ ...globalAgentsQueryOptions, enabled: canRead }); + // While permissions are still loading, `canRead` defaults to false same + // as a confirmed denial — guard on isPermissionsLoading so the page + // shows the skeleton instead of flashing NoPermissionState first. + const isLoading = isPermissionsLoading || isDataLoading; + const noPermission = + !isPermissionsLoading && (!canRead || (isError && isForbiddenError(error))); const [createOpen, setCreateOpen] = useState(search.create); const [acpSetupAgent, setAcpSetupAgent] = useState(null); @@ -120,7 +136,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..4568045a5 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,15 +42,21 @@ export const Route = createFileRoute("/_authenticated/admin/global-roles/")({ }); function GlobalRolesPage() { - const { hasPermission } = usePermissions(); + const { t } = useTranslation("admin"); + const { hasPermission, isLoading: isPermissionsLoading } = usePermissions(); const canRead = hasPermission("global_roles.read"); const canWrite = hasPermission("global_roles.write"); const { data: roles = [], - isLoading, + isLoading: isDataLoading, isError, } = useQuery({ ...globalRolesQueryOptions, enabled: canRead }); + // While permissions are still loading, canRead defaults to false same as + // a confirmed denial — fold isPermissionsLoading into isLoading (and + // guard the noPermission check below) so the page shows the skeleton + // instead of flashing NoPermissionState first. + const isLoading = isPermissionsLoading || isDataLoading; const [createOpen, setCreateOpen] = useState(false); const [editRole, setEditRole] = useState(null); @@ -77,8 +85,12 @@ function GlobalRolesPage() { /> )} - {!canRead ? ( - + {!isPermissionsLoading && !canRead ? ( + ) : isLoading ? ( ) : isError ? ( diff --git a/apps/web/src/routes/_authenticated/admin/plugins/$pluginId/$slug.tsx b/apps/web/src/routes/_authenticated/admin/plugins/$pluginId/$slug.tsx index 1701bdf66..df5a8099a 100644 --- a/apps/web/src/routes/_authenticated/admin/plugins/$pluginId/$slug.tsx +++ b/apps/web/src/routes/_authenticated/admin/plugins/$pluginId/$slug.tsx @@ -1,9 +1,11 @@ -import { createFileRoute, notFound, redirect } from "@tanstack/react-router"; +import { useQuery } from "@tanstack/react-query"; +import { createFileRoute, notFound } from "@tanstack/react-router"; import { AlertCircle } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { NoPermissionState } from "@/components/shared/no-permission-state"; import { myPermissionsQueryOptions } from "@/lib/admin-api"; import { hasPermission } from "@/lib/permissions"; -import { buildNavItems, pluginsQueryOptions } from "@/lib/plugin-api"; +import { pluginsQueryOptions } from "@/lib/plugin-api"; import { RemoteComponent } from "@/lib/plugins/loader"; import { usePluginBaseProps } from "@/lib/plugins/plugin-props"; import { usePluginRegistry } from "@/lib/plugins/registry"; @@ -11,31 +13,13 @@ import { usePluginRegistry } from "@/lib/plugins/registry"; export const Route = createFileRoute( "/_authenticated/admin/plugins/$pluginId/$slug", )({ - beforeLoad: async ({ - context: { queryClient }, - params: { pluginId, slug }, - }) => { - const [permissions, plugins] = await Promise.all([ + loader: async ({ context: { queryClient } }) => { + await Promise.all([ + queryClient.ensureQueryData(pluginsQueryOptions), queryClient .fetchQuery(myPermissionsQueryOptions) .catch(() => [] as string[]), - queryClient.ensureQueryData(pluginsQueryOptions).catch(() => []), ]); - - const navItem = buildNavItems(plugins, "admin").find( - (item) => item.pluginId === pluginId && item.slug === slug, - ); - // Nav items without a declared `requiredPermission` fall back to - // `users.write`, matching the blanket gate the built-in "Plugins" - // admin nav item (and this route, previously) already use. - const requiredPermission = navItem?.requiredPermission ?? "users.write"; - - if (!hasPermission(permissions, requiredPermission)) { - throw redirect({ to: "/home" }); - } - }, - loader: async ({ context: { queryClient } }) => { - await queryClient.ensureQueryData(pluginsQueryOptions); }, component: AdminPluginPage, }); @@ -45,6 +29,12 @@ export const Route = createFileRoute( * component for the given plugin/nav-item slug — the admin/global-scope * counterpart to `ProjectPluginPage`. Used for cross-project plugin * dashboards (e.g. a "total logged time across all projects" summary). + * + * The nav item itself is always shown once the Administration section is + * reachable at all (see AppSidebar's `showAdminSection`/`adminPluginNavItems` + * — a plugin's own `requiredPermission` no longer hides the link). A caller + * who lacks the permission still reaches this route and gets a + * no-permission state instead of the plugin's actual page content. */ function AdminPluginPage() { const { t } = useTranslation("errors"); @@ -53,6 +43,7 @@ function AdminPluginPage() { const navItem = getNavItems("admin").find( (item) => item.pluginId === pluginId && item.slug === slug, ); + const { data: permissions = [] } = useQuery(myPermissionsQueryOptions); const baseProps = usePluginBaseProps(navItem?.registration); if (isLoading) return null; @@ -60,6 +51,24 @@ function AdminPluginPage() { throw notFound(); } + // Nav items without a declared `requiredPermission` fall back to + // `plugins.write`, matching the blanket gate the built-in "Plugins" + // admin nav item (and this route, previously via redirect) already use. + const requiredPermission = navItem.requiredPermission ?? "plugins.write"; + + if (!hasPermission(permissions, requiredPermission)) { + return ( +
+ +
+ ); + } + return (
[] as string[]); - if (!hasPermission(permissions, "users.write")) { + // plugins.write replaced users.write as a rough "is this someone + // important" proxy once it got its own dedicated permission — see + // authz.PermissionPluginsRead's doc comment on the Go side. This + // gate was never updated when that happened. + if (!hasPermission(permissions, "plugins.write")) { throw redirect({ to: "/home" }); } }, diff --git a/apps/web/src/routes/_authenticated/admin/users/index.tsx b/apps/web/src/routes/_authenticated/admin/users/index.tsx index 0c357096b..03cabd7f1 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,7 +44,8 @@ export const Route = createFileRoute("/_authenticated/admin/users/")({ }); function UsersManagementPage() { - const { hasPermission } = usePermissions(); + const { t } = useTranslation("admin"); + const { hasPermission, isLoading: isPermissionsLoading } = usePermissions(); const canRead = hasPermission("users.read"); const canWrite = hasPermission("users.write"); @@ -51,9 +54,14 @@ function UsersManagementPage() { const { data: pagedUsers, - isLoading, + isLoading: isDataLoading, isError, } = useQuery({ ...usersQueryOptions(page, pageSize), enabled: canRead }); + // While permissions are still loading, canRead defaults to false same as + // a confirmed denial — fold isPermissionsLoading into isLoading (and + // guard the noPermission check below) so the page shows the skeleton + // instead of flashing NoPermissionState first. + const isLoading = isPermissionsLoading || isDataLoading; const { data: currentUser } = useQuery(currentUserQueryOptions); @@ -83,8 +91,12 @@ function UsersManagementPage() { /> )} - {!canRead ? ( - + {!isPermissionsLoading && !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..c1c23a405 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, }); @@ -46,17 +47,32 @@ function AgentsPage() { const { projectId } = Route.useParams(); const { create } = Route.useSearch(); const navigate = Route.useNavigate(); - const { hasProjectPermission } = useProjectPermissions(projectId); + const { hasProjectPermission, isLoading: isPermissionsLoading } = + 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: isDataLoading, + isError, + error, + } = useQuery({ + ...projectScopedAgentsQueryOptions(projectId), + enabled: canRead, + }); + // While permissions are still loading, canRead defaults to false same as + // a confirmed denial — guard on isPermissionsLoading (and fold it into + // isLoading) so the page shows the skeleton instead of flashing + // NoPermissionState first. + const isLoading = isPermissionsLoading || isDataLoading; + const noPermission = + !isPermissionsLoading && (!canRead || (isError && isForbiddenError(error))); const [createOpen, setCreateOpen] = useState(create); const [acpSetupAgent, setAcpSetupAgent] = useState(null); const [acpSetupToken, setAcpSetupToken] = useState( @@ -116,7 +132,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..a33ba8823 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, }); @@ -54,13 +57,25 @@ function AutomationListPage() { const { projectId } = Route.useParams(); const qc = useQueryClient(); const navigate = useNavigate(); - const { hasProjectPermission } = useProjectPermissions(projectId); + const { hasProjectPermission, isLoading: isPermissionsLoading } = + 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: isDataLoading, + isError, + error, + } = useQuery({ ...automationsQueryOptions(projectId), enabled: canRead }); + // While permissions are still loading, canRead defaults to false same as + // a confirmed denial — guard on isPermissionsLoading (and fold it into + // isLoading) so the page shows the skeleton instead of flashing + // NoPermissionState first. + const isLoading = isPermissionsLoading || isDataLoading; + const noPermission = + !isPermissionsLoading && (!canRead || (isError && isForbiddenError(error))); const [createOpen, setCreateOpen] = useState(false); const [name, setName] = useState(""); @@ -117,15 +132,17 @@ function AutomationListPage() {

- + {canRead && ( + + )} {canManage ? (