Skip to content

[feat] pluggable extension system#135

Open
stooit wants to merge 3 commits intouselagoon:mainfrom
stooit:feat/extensions
Open

[feat] pluggable extension system#135
stooit wants to merge 3 commits intouselagoon:mainfrom
stooit:feat/extensions

Conversation

@stooit
Copy link

@stooit stooit commented Feb 13, 2026

Summary

Adds a pluggable extension system that lets downstream deployments inject pages, navigation items, and sidebar sections into Lagoon UI without modifying core code.

Extensions are defined as directories under extensions/, each with an extension.json manifest. At build time, build-extensions copies pages and components into the app and generates a merged extensions.json. At runtime, the ExtensionProvider reads this manifest and makes it available to navigation components.

Included change

  • Build script (scripts/build-extensions.ts) discovers extensions, copies pages/components/lib into the app tree, writes merged manifest
  • Manifest schema & validation (src/lib/extensions/schema.ts) validates extension.json at load time
  • Runtime loader (src/lib/extensions/loader.ts) reads extensions.json and provides it via ExtensionContext
  • Navigation integration sidebar (useSidenavItems), project tabs (ProjectNavTabs), environment tabs (EnvironmentNavTabs) all pick up extension nav items
  • RBAC (src/lib/extensions/rbac.ts, ExtensionRouteGuard) role-based access on nav items and pages via requiredRoles/excludeRoles
  • Icon mapping (src/lib/extensions/icons.ts) maps Lucide icon names from manifests to components

Extension structure

extensions/my-extension/
├── extension.json          # manifest
├── pages/                  # → copied to src/app/(routegroups)/
│   └── my-page/
│       └── page.tsx
├── components/             # → copied to src/components/extensions/<name>/
│   └── MyComponent.tsx
└── lib/                    # → copied to src/lib/extensions/<name>/
    └── utils.ts

Manifest format

{
  "meta": { "name": "my-extension", "version": "1.0.0" },
  "navigation": {
    "items": [
      {
        "id": "my-page",
        "label": "My Page",
        "href": "/my-page",
        "icon": "Star",
        "target": "sidebar-projects"
      }
    ],
    "sections": [
      {
        "section": "My Section",
        "position": "end",
        "items": [
          { "id": "item1", "label": "Item", "href": "/item", "icon": "Star" }
        ]
      }
    ]
  },
  "pages": [
    { "route": "my-page", "requiredRoles": ["admin"] }
  ]
}

Navigation targets

Target Location
sidebar-projects Projects section in sidebar
sidebar-deployments Deployments section
sidebar-organizations Organisations section
sidebar-settings Settings section
project-tabs Project page tabs
environment-tabs Environment page tabs

Docker usage

Downstream images will need to optionally inject extensions before build:

COPY my-extensions/ /app/extensions/
RUN yarn build

Working example

A "functional" analytics dashboard example available here: feat/extensions-example:

Copy link
Member

@shreddedbacon shreddedbacon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some things I've found, but there are some things we're working on that may change related to the sidebar and how items may get added

Comment on lines +121 to +126
const navItem = { title: extItem.label, url: extItem.href, icon: resolveIcon(extItem.icon) };
if (extItem.position === 'start') {
items[idx].sectionItems.unshift(navItem);
} else {
items[idx].sectionItems.push(navItem);
}
Copy link
Member

@shreddedbacon shreddedbacon Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're aware that there are some issues with the current sidebar implementation, and are working on a different way the sidebar is managed that will probably change how what I've made in this suggestion work. Probably ignore this suggestion for now until that other work goes through properly.

In saying that, the sidebar doesn't render properly when using project-tabs in the provided example, but also doesn't render properly when using sidebar-projects without changes below

Note: also pretty sure project-tabs,environment-tabs,organization-tabs, and settings-tabs are deprecated, so probably don't need to exist.

Suggested change
const navItem = { title: extItem.label, url: extItem.href, icon: resolveIcon(extItem.icon) };
if (extItem.position === 'start') {
items[idx].sectionItems.unshift(navItem);
} else {
items[idx].sectionItems.push(navItem);
}
let href = extItem.href
if (projectSlug) {
href = href.replace('[projectSlug]', projectSlug as string )
}
if (environmentSlug) {
href = href.replace('[environmentSlug]', environmentSlug as string )
}
const navItem = { title: extItem.label, url: href, icon: resolveIcon(extItem.icon)};
switch (target) {
case 'sidebar-projects':
console.log(items[idx].sectionItems[0])
if (items[idx].sectionItems[0].children) {
if (extItem.subTarget == 'environment') {
if (items[idx].sectionItems[0].children[0].children) {
if (items[idx].sectionItems[0].children[0].children[0].children) {
if (extItem.position === 'start') {
items[idx].sectionItems[0].children[0].children[0].children[0].children?.unshift(navItem);
} else {
items[idx].sectionItems[0].children[0].children[0].children[0].children?.push(navItem);
}
}
}
} else {
if (extItem.position === 'start') {
items[idx].sectionItems[0].children[0].children?.unshift(navItem);
} else {
items[idx].sectionItems[0].children[0].children?.push(navItem);
}
}
}
break;
default:
if (extItem.position === 'start') {
items[idx].sectionItems.unshift(navItem);
} else {
items[idx].sectionItems.push(navItem);
}
break;
}

This would need further work to properly support if needing to extend adding to the environments or others too.

diff --git a/src/lib/extensions/types.ts b/src/lib/extensions/types.ts
index c75187d3..46a81701 100644
--- a/src/lib/extensions/types.ts
+++ b/src/lib/extensions/types.ts
@@ -8,6 +8,9 @@ export type ExtensionNavTarget =
   | 'organization-tabs'
   | 'settings-tabs';
 
+export type ExtensionNavSubTarget =
+  | 'environment';
+
 export type ExtensionSlotLocation =
   | 'project-header'
   | 'project-footer'
@@ -24,6 +27,7 @@ export type ExtensionNavItem = {
   href: string;
   icon?: string;
   target: ExtensionNavTarget;
+  subTarget: ExtensionNavSubTarget;
   position?: 'start' | 'end' | number;
   requiredRoles?: string[];
   excludeRoles?: string[];

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok cool, have cleaned up the deprecated *-tabs things and fixed up the slug replacement given they seem like the safe changes here

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can only ever work for platform level roles, not general user roles.

Lagoon doesn't inject general user roles into the token.

It is also broken, but this is because the current branch doesn't inject the roles from the token properly.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, left this as-is for now on the assumption the platform level roles (at least) come through properly at some point before merge. Obviously more ideal if we can get user roles to tap into too.

stooit and others added 2 commits February 28, 2026 15:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants