Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 84 additions & 1 deletion apps/web/src/components/app-shell/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ import {
} from "@/lib/doc-api";
import { sprintsQueryOptions, updateTask } from "@/lib/interaction-api";
import { ExtensionPoint } from "@/lib/plugins/extension-point";
import { resolvePluginIcon } from "@/lib/plugins/icon-resolver";
import { usePluginRegistry } from "@/lib/plugins/registry";
import { projectQueryOptions, projectsQueryOptions } from "@/lib/project-api";
import { cn } from "@/lib/utils";
import { UserMenu } from "./user-menu";
Expand Down Expand Up @@ -818,14 +820,21 @@ function NavItem({
icon: Icon,
label,
badge,
exact,
}: {
to: string;
icon: ComponentType<{ className?: string }>;
label: string;
badge?: string;
/** Match only the exact path, not any sub-route below it. Use this when
* a deeper route (e.g. a plugin page under this segment) has its own
* distinct nav item and shouldn't also light up this one. */
exact?: boolean;
}) {
const location = useRouterState({ select: (s) => s.location.pathname });
const isActive = location === to || location.startsWith(`${to}/`);
const isActive = exact
? location === to
: location === to || location.startsWith(`${to}/`);

return (
<SidebarMenuItem>
Expand Down Expand Up @@ -979,6 +988,77 @@ function ProjectNavItems({
);
}

// ── Plugin-contributed pages ────────────────────────────────────────────────

/** Sidebar nav items for plugin `project.page` extension points (e.g. a
* project-wide time-tracking view), routed to
* /projects/:projectId/plugins/:pluginId/:slug. */
function PluginProjectPages({ projectId }: { projectId: string }) {
const { t } = useTranslation("appShell");
const { getNavItems } = usePluginRegistry();
const location = useRouterState({ select: (s) => s.location.pathname });
const navItems = getNavItems("project");
if (navItems.length === 0) return null;

return (
<SidebarGroup>
<SidebarGroupLabel>{t("nav.plugins")}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{navItems.map((item) => {
const Icon = resolvePluginIcon(item.icon);
const to = `/projects/${projectId}/plugins/${item.pluginId}/${item.slug}`;
const isActive = location === to || location.startsWith(`${to}/`);
return (
<SidebarMenuItem key={`${item.pluginId}:${item.slug}`}>
<SidebarMenuButton
isActive={isActive}
tooltip={item.label}
render={<Link to={to} />}
className={cn(
"relative transition-all duration-150",
isActive
? "bg-primary/10 text-primary font-medium before:absolute before:left-0 before:inset-y-2 before:w-0.75 before:rounded-full before:bg-primary"
: "hover:bg-sidebar-accent/60",
)}
>
<Icon className="size-4" />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}

/** Admin-sidebar nav items for plugin `admin.page` extension points (e.g. a
* cross-project time-tracking summary), routed to
* /admin/plugins/:pluginId/:slug. Rendered inline in the existing
* "Administration" SidebarMenu, so no extra group wrapper here. */
function PluginAdminPages() {
const { getNavItems } = usePluginRegistry();
const navItems = getNavItems("admin");
return (
<>
{navItems.map((item) => {
const Icon = resolvePluginIcon(item.icon);
const to = `/admin/plugins/${item.pluginId}/${item.slug}`;
return (
<NavItem
key={`${item.pluginId}:${item.slug}`}
to={to}
icon={Icon}
label={item.label}
/>
);
})}
</>
);
}

// ── Project Interactions Section ───────────────────────────────────────────────
function ProjectInteractionsSection({
projectId,
Expand Down Expand Up @@ -1345,6 +1425,7 @@ export function AppSidebar() {
componentProps={{ projectId }}
/>
<SidebarSeparator />
<PluginProjectPages projectId={projectId} />
<ProjectNavItems projectId={projectId} isAnonymous={isAnonymous} />
</>
) : (
Expand Down Expand Up @@ -1389,8 +1470,10 @@ export function AppSidebar() {
to="/admin/plugins"
icon={Puzzle}
label={t("nav.plugins")}
exact
/>
) : null}
<PluginAdminPages />
Comment thread
pullfrog[bot] marked this conversation as resolved.
Outdated
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/plugins/PluginPreferencesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const EXTENSION_POINT_LABEL_KEYS = {
"task.detail.section": "preferences.extensionPoints.taskDetail",
"project.settings.tab": "preferences.extensionPoints.projectSettingsTab",
view: "preferences.extensionPoints.customView",
"project.page": "preferences.extensionPoints.projectPage",
"admin.page": "preferences.extensionPoints.adminPage",
} as const satisfies Record<ExtensionPointId, string>;

const ALL_POINTS: ExtensionPointId[] = [
Expand All @@ -25,6 +27,8 @@ const ALL_POINTS: ExtensionPointId[] = [
"task.detail.section",
"project.settings.tab",
"view",
"project.page",
"admin.page",
];

interface DraggableItemProps {
Expand Down
40 changes: 32 additions & 8 deletions apps/web/src/components/projects/roles/ProjectRoleFormDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Shield } from "lucide-react";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";

import { Button } from "@/components/ui/button";
Expand All @@ -22,6 +22,10 @@ import {
expandWildcardPermissions,
normalizePermissionsToWildcards,
} from "@/lib/permissions";
import {
collectPluginCustomPermissions,
pluginsQueryOptions,
} from "@/lib/plugin-api";
import {
createProjectRole,
type ProjectRole,
Expand All @@ -30,8 +34,10 @@ import {
} from "@/lib/project-api";

import {
type KnownPermission,
PROJECT_KNOWN_PERMISSIONS,
PROJECT_PERMISSION_GROUPS,
toPluginKnownPermissions,
} from "./permissions";

interface ProjectRoleFormDialogProps {
Expand All @@ -51,11 +57,22 @@ export function ProjectRoleFormDialog({
const queryClient = useQueryClient();
const isEdit = !!role;

const { data: plugins = [] } = useQuery(pluginsQueryOptions);
const allKnownPermissions = useMemo<KnownPermission[]>(
() => [
...PROJECT_KNOWN_PERMISSIONS,
...toPluginKnownPermissions(
collectPluginCustomPermissions(plugins, "project"),
),
],
[plugins],
);

const [name, setName] = useState(role?.role_name ?? "");
Comment thread
pullfrog[bot] marked this conversation as resolved.
const [permissions, setPermissions] = useState<Record<string, boolean>>(
expandWildcardPermissions(
role?.permissions as Record<string, boolean> | undefined,
PROJECT_KNOWN_PERMISSIONS,
allKnownPermissions,
),
);
const [error, setError] = useState<string | null>(null);
Expand All @@ -66,7 +83,7 @@ export function ProjectRoleFormDialog({
setPermissions(
expandWildcardPermissions(
role?.permissions as Record<string, boolean> | undefined,
PROJECT_KNOWN_PERMISSIONS,
allKnownPermissions,
),
);
setError(null);
Expand All @@ -77,7 +94,7 @@ export function ProjectRoleFormDialog({
mutationFn: async () => {
const normalized = normalizePermissionsToWildcards(
permissions,
PROJECT_KNOWN_PERMISSIONS,
allKnownPermissions,
);
if (isEdit && role) {
return updateProjectRole(projectId, role.id, {
Expand Down Expand Up @@ -125,6 +142,12 @@ export function ProjectRoleFormDialog({
},
});

const permissionLabel = (permission: KnownPermission): string =>
permission.rawLabel ?? t(permission.labelKey as never);

const permissionDescription = (permission: KnownPermission): string =>
permission.rawDescription ?? t(permission.descriptionKey as never);

const togglePermission = (key: string, checked: boolean) => {
setPermissions((prev) => ({ ...prev, [key]: checked }));
};
Expand Down Expand Up @@ -200,9 +223,10 @@ export function ProjectRoleFormDialog({

<div className="flex flex-col gap-4 rounded-lg border bg-muted/20 p-4">
{PROJECT_PERMISSION_GROUPS.map((group, groupIndex) => {
const groupPerms = PROJECT_KNOWN_PERMISSIONS.filter(
const groupPerms = allKnownPermissions.filter(
(p) => p.domain === group.domain,
);
if (groupPerms.length === 0) return null;
const { Icon } = group;
return (
<div key={group.domain}>
Expand All @@ -220,10 +244,10 @@ export function ProjectRoleFormDialog({
<div className="flex items-center justify-between py-1">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t(permission.labelKey)}
{permissionLabel(permission)}
</span>
<span className="text-xs text-muted-foreground">
{t(permission.descriptionKey)}
{permissionDescription(permission)}
</span>
</div>
<Switch
Expand Down
41 changes: 41 additions & 0 deletions apps/web/src/components/projects/roles/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,30 @@ import {
Layers,
ListTodo,
type LucideIcon,
Puzzle,
Settings,
Shield,
Users,
Workflow,
} from "lucide-react";

import type { PluginCustomPermission } from "@/lib/plugin-api";

export interface KnownPermission {
key: string;
labelKey: string;
descriptionKey: string;
domain: string;
/**
* When set, the role editor renders this literal string instead of
* running `labelKey` through i18n. Used for plugin-declared custom
* permissions, whose label text comes from the plugin manifest (a
* plugin has no access to the host's i18n catalog) rather than a
* translation key.
*/
rawLabel?: string;
/** Same as `rawLabel` but for the description text. */
rawDescription?: string;
}

export const PROJECT_KNOWN_PERMISSIONS = [
Expand Down Expand Up @@ -169,4 +182,32 @@ export const PROJECT_PERMISSION_GROUPS = [
labelKey: "roles.permissionGroups.workflows",
Icon: Workflow,
},
{
domain: "plugins",
labelKey: "roles.permissionGroups.plugins",
Icon: Puzzle,
},
] as const satisfies PermissionGroup[];

/**
* Convert plugin-declared custom permissions (as returned by
* `collectPluginCustomPermissions`) into `KnownPermission` entries under the
* synthetic "plugins" domain, so they render in the role editor alongside
* built-in permissions using the same `PROJECT_PERMISSION_GROUPS` layout.
* Label/description come straight from the plugin manifest (`rawLabel`/
* `rawDescription`) since plugins can't contribute host i18n keys.
*/
export function toPluginKnownPermissions(
pluginPermissions: Array<PluginCustomPermission & { pluginName: string }>,
): KnownPermission[] {
return pluginPermissions.map((perm) => ({
key: perm.key,
labelKey: "",
descriptionKey: "",
domain: "plugins",
rawLabel: perm.label,
rawDescription: perm.description
? `${perm.description} (${perm.pluginName})`
: perm.pluginName,
}));
}
4 changes: 3 additions & 1 deletion apps/web/src/i18n/locales/en/plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@
"sidebarProject": "Sidebar — Project",
"taskDetail": "Task Detail",
"projectSettingsTab": "Project Settings Tab",
"customView": "Custom View"
"customView": "Custom View",
"projectPage": "Project Page",
"adminPage": "Admin Page"
}
}
}
3 changes: 2 additions & 1 deletion apps/web/src/i18n/locales/en/projects.json
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,8 @@
"sprints": "Sprints",
"documents": "Documents",
"aiAgents": "AI Agents",
"workflows": "Automation"
"workflows": "Automation",
"plugins": "Plugins"
}
},
"settings": {
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/i18n/locales/es/plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@
"sidebarProject": "Barra lateral — Proyecto",
"taskDetail": "Detalle de tarea",
"projectSettingsTab": "Pestaña de configuración del proyecto",
"customView": "Vista personalizada"
"customView": "Vista personalizada",
"projectPage": "Página del Proyecto",
"adminPage": "Página de Administración"
}
}
}
3 changes: 2 additions & 1 deletion apps/web/src/i18n/locales/es/projects.json
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,8 @@
"sprints": "Sprints",
"documents": "Documentos",
"aiAgents": "Agentes de IA",
"workflows": "Automatización"
"workflows": "Automatización",
"plugins": "Plugins"
}
},
"settings": {
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/i18n/locales/fr/plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@
"sidebarGeneral": "Barre latérale — Général",
"sidebarProject": "Barre latérale — Projet",
"taskDetail": "Détail de la tâche",
"projectSettingsTab": "Onglet Paramètres du projet",
"customView": "Vue personnalisée"
"projectSettingsTab": "Onglet Paramètres du Projet",
"customView": "Vue personnalisée",
"projectPage": "Page du Projet",
"adminPage": "Page d'Administration"
}
}
}
3 changes: 2 additions & 1 deletion apps/web/src/i18n/locales/fr/projects.json
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,8 @@
"sprints": "Sprints",
"documents": "Documents",
"aiAgents": "Agents IA",
"workflows": "Automatisation"
"workflows": "Automatisation",
"plugins": "Plugins"
}
},
"settings": {
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/i18n/locales/ja/plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@
"sidebarProject": "サイドバー — プロジェクト",
"taskDetail": "タスク詳細",
"projectSettingsTab": "プロジェクト設定タブ",
"customView": "カスタムビュー"
"customView": "カスタムビュー",
"projectPage": "プロジェクトページ",
"adminPage": "管理者ページ"
}
}
}
Loading
Loading