Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
59 changes: 48 additions & 11 deletions apps/web/src/components/admin/global-roles/RoleFormDialog.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 { Shield } from "lucide-react";
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";

import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -28,8 +28,17 @@ import {
expandWildcardPermissions,
normalizePermissionsToWildcards,
} from "@/lib/permissions";
import {
collectPluginCustomPermissions,
pluginsQueryOptions,
} from "@/lib/plugin-api";

import { KNOWN_PERMISSIONS, PERMISSION_GROUPS } from "./permissions";
import {
KNOWN_PERMISSIONS,
type KnownPermission,
PERMISSION_GROUPS,
toPluginKnownPermissions,
} from "./permissions";

interface RoleFormDialogProps {
role?: GlobalRole;
Expand All @@ -46,17 +55,38 @@ export function RoleFormDialog({
const queryClient = useQueryClient();
const isEdit = !!role;

const [name, setName] = useState(role?.name ?? "");
const [permissions, setPermissions] = useState<Record<string, boolean>>(
expandWildcardPermissions(role?.permissions, KNOWN_PERMISSIONS),
const { data: plugins = [] } = useQuery(pluginsQueryOptions);
const allKnownPermissions = useMemo<KnownPermission[]>(
() => [
...KNOWN_PERMISSIONS,
...toPluginKnownPermissions(
collectPluginCustomPermissions(plugins, "global"),
),
],
[plugins],
);

const [name, setName] = useState(role?.name ?? "");
const [permissions, setPermissions] = useState<Record<string, boolean>>({});
const [error, setError] = useState<string | null>(null);
const [nameError, setNameError] = useState<string | null>(null);

// Re-derive `permissions` whenever the dialog opens or the known-permission
// set changes (e.g. plugin data finishes loading after the dialog already
// opened), rather than only at first mount — otherwise plugin-declared
// permissions loaded after mount would never make it into the editor and
// saving the role would silently drop them.
useEffect(() => {
if (!open) return;
setPermissions(
expandWildcardPermissions(role?.permissions, allKnownPermissions),
);
}, [open, allKnownPermissions, role?.permissions]);

const reset = () => {
setName(role?.name ?? "");
setPermissions(
expandWildcardPermissions(role?.permissions, KNOWN_PERMISSIONS),
expandWildcardPermissions(role?.permissions, allKnownPermissions),
);
setError(null);
setNameError(null);
Expand All @@ -70,7 +100,7 @@ export function RoleFormDialog({
name: name.trim(),
permissions: normalizePermissionsToWildcards(
permissions,
KNOWN_PERMISSIONS,
allKnownPermissions,
),
};
if (isEdit && role) {
Expand Down Expand Up @@ -113,6 +143,12 @@ export function RoleFormDialog({
},
});

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 @@ -188,9 +224,10 @@ export function RoleFormDialog({

<div className="flex flex-col gap-4 rounded-lg border bg-muted/20 p-4">
{PERMISSION_GROUPS.map((group, groupIndex) => {
const groupPerms = KNOWN_PERMISSIONS.filter(
const groupPerms = allKnownPermissions.filter(
(permission) => permission.domain === group.domain,
);
if (groupPerms.length === 0) return null;
const { Icon } = group;
return (
<div key={group.domain}>
Expand All @@ -210,10 +247,10 @@ export function RoleFormDialog({
<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
23 changes: 16 additions & 7 deletions apps/web/src/components/admin/global-roles/permissions.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { FolderKanban, type LucideIcon, Shield, Users } from "lucide-react";
import {
FolderKanban,
type LucideIcon,
Puzzle,
Shield,
Users,
} from "lucide-react";

export interface KnownPermission {
key: string;
labelKey: string;
descriptionKey: string;
domain: string;
}
import type { PluginKnownPermission } from "@/lib/plugin-api";

export { toPluginKnownPermissions } from "@/lib/plugin-api";
export type KnownPermission = PluginKnownPermission;

export const KNOWN_PERMISSIONS = [
{
Expand Down Expand Up @@ -116,4 +120,9 @@ export const PERMISSION_GROUPS = [
labelKey: "globalRoles.permissionGroups.projects",
Icon: FolderKanban,
},
{
domain: "plugins",
labelKey: "globalRoles.permissionGroups.plugins",
Icon: Puzzle,
},
] as const satisfies PermissionGroup[];
107 changes: 105 additions & 2 deletions apps/web/src/components/app-shell/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ import {
updateFolder,
} from "@/lib/doc-api";
import { sprintsQueryOptions, updateTask } from "@/lib/interaction-api";
import type { PluginNavRegistration } from "@/lib/plugin-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 +821,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 +989,82 @@ 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 { hasProjectPermission } = useProjectPermissions(projectId);
const location = useRouterState({ select: (s) => s.location.pathname });
const navItems = getNavItems("project").filter(
(item) =>
!item.requiredPermission || hasProjectPermission(item.requiredPermission),
);
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. `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. */
function PluginAdminPages({ navItems }: { navItems: PluginNavRegistration[] }) {
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 @@ -1269,6 +1355,7 @@ export function AppSidebar() {
const { resolvedMode } = useThemeMode();
const { projectId } = useParams({ strict: false });
const { data: user } = useQuery(currentUserOptionalQueryOptions);
const { getNavItems } = usePluginRegistry();

const canAccessGlobalRoles =
hasPermission("global_roles.read") || hasPermission("global_roles.write");
Expand All @@ -1280,8 +1367,21 @@ export function AppSidebar() {

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(
Comment thread
pikann marked this conversation as resolved.
(item) =>
!item.requiredPermission || hasPermission(item.requiredPermission),
);

const showAdminSection =
canAccessGlobalRoles || canAccessUsers || canAccessPlugins;
canAccessGlobalRoles ||
canAccessUsers ||
canAccessPlugins ||
adminPluginNavItems.length > 0;
const isProjectContext = !!projectId;
const isAnonymous = !user;

Expand Down Expand Up @@ -1345,6 +1445,7 @@ export function AppSidebar() {
componentProps={{ projectId }}
/>
<SidebarSeparator />
<PluginProjectPages projectId={projectId} />
<ProjectNavItems projectId={projectId} isAnonymous={isAnonymous} />
</>
) : (
Expand Down Expand Up @@ -1389,8 +1490,10 @@ export function AppSidebar() {
to="/admin/plugins"
icon={Puzzle}
label={t("nav.plugins")}
exact
/>
) : null}
<PluginAdminPages navItems={adminPluginNavItems} />
</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
Loading
Loading