Building Your First Plugin - Part 1
From zero to a production-ready plugin in one session. Set up the project structure, configure plugins.yaml, build React components, and use the Plugin SDK.
πLearning Objectives
- Initialize a new plugin project with the correct structure
- Configure
plugins.yamlwith routes, permissions, and security settings - Build React components using the platform's React instance
- Use the Plugin SDK for authenticated API calls
- Implement authorization with
Protectedcomponents - Build and deploy the plugin to the Experience platform
π―What We're Building
Features We'll Implement
- β Authorization: Protected components that hide if user lacks permission
- β API Integration: Fetch roles from Membership service via SDK
- β Loading States: Skeleton loader while fetching data
- β Error Handling: Graceful error display with retry
- β Role Cards: Beautiful UI for displaying each role
- β Theme Support: CSS variables for dark/light mode
SDK-First Development
We'll use the SDK for everythingβno direct fetch() calls, no manual authorization checks. This gives us:
- Automatic
X-Plugin-Idheader stamping - Automatic session cookie handling
- Automatic telemetry
- Fail-closed authorization
Traditional approach: 650+ lines, 2-3 days, manual everything
SDK-first approach: ~80 lines, 90 minutes, enterprise-grade features
Create Plugin Directory
# Navigate to plugins directory (replace <YourRepoRoot> with your project root)
# Windows
cd <YourRepoRoot>\ServiceConfigs\BFF\plugins
# macOS/Linux
cd <YourRepoRoot>/ServiceConfigs/BFF/plugins
# Create plugin with version directory
mkdir -p role-viewer\1.0.0\src
cd role-viewer\1.0.0
# Initialize npm project
npm init -y
Install Build Dependencies
# Only need esbuild for building
npm install -D esbuild typescript @types/react
Create TypeScript Config
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"moduleResolution": "node"
},
"include": ["src/**/*"]
}
Final Directory Structure
ServiceConfigs/BFF/config/plugins/manifests/role-viewer.yaml, and register it in index.yaml.
Add Plugin Entry
Create ServiceConfigs/BFF/config/plugins/manifests/role-viewer.yaml:
tenants:
experience.self.empowernow.ai:
# ... existing plugins ...
# Our new plugin
- id: role-viewer
title: "My Roles"
version: "1.0.0"
enabled: true
# π― Platform compatibility
engine:
experience: ">=1.0.0"
# π Security configuration (REQUIRED)
authorization:
pdp_application: "role-viewer"
context:
service_tier: "standard"
compliance_level: "medium"
data_classification: "internal"
# π¦ Bundle configuration
bundle:
file: "/app/plugins/role-viewer/1.0.0/index.esm.js"
# integrity: "sha256:..." # Add after building
# π API permissions (allowlist)
permissions:
api:
- method: GET
path: /api/v1/roles/{identity_id}
- method: POST
path: /access/v1/evaluation
# πΊοΈ Routes
contributions:
routes:
- path: /role-viewer
component: Dashboard
title: "My Roles"
resource: role-viewer.route
action: view
Key Configuration Explained
| Field | Purpose |
|---|---|
pdp_application | Isolates this plugin's policies from other plugins |
bundle.file | Where BFF looks for the compiled JS (inside container) |
permissions.api[] | Only these endpoints are callable from this plugin |
contributions.routes[] | URL paths this plugin handles |
globalThis.React) and export a routes object with component names matching your plugins.yaml.
src/index.tsx β Main Entry Point
// src/index.tsx - Role Viewer Plugin
// Use platform's React (NOT import React from 'react')
const React = (globalThis as any).React;
const { useState, useEffect } = React;
// Access Plugin SDK
const sdk = (globalThis as any).pluginSdk;
// Import components
import { RoleCard } from './RoleCard';
import { styles } from './styles';
/**
* Dashboard Component - Main plugin page
* Fetches and displays user's roles from Membership service
*/
export function Dashboard() {
const [roles, setRoles] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch roles on mount
useEffect(() => {
fetchRoles();
}, []);
const fetchRoles = async () => {
try {
setLoading(true);
setError(null);
// Use SDK for authenticated API call
const response = await sdk.api.fetch('/api/v1/roles/me');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
setRoles(data.roles || []);
} catch (err: any) {
console.error('Failed to fetch roles:', err);
setError(err.message || 'Failed to load roles');
} finally {
setLoading(false);
}
};
// Loading state
if (loading) {
return React.createElement('div', { style: styles.container },
React.createElement('div', { style: styles.header },
React.createElement('h1', { style: styles.title }, 'My Roles'),
React.createElement('div', { style: styles.skeleton }, 'Loading...')
),
React.createElement('div', { style: styles.grid },
// Skeleton cards
[1, 2, 3].map(i =>
React.createElement('div', { key: i, style: styles.skeletonCard })
)
)
);
}
// Error state
if (error) {
return React.createElement('div', { style: styles.container },
React.createElement('div', { style: styles.errorBox },
React.createElement('span', { style: styles.errorIcon }, 'β οΈ'),
React.createElement('h3', null, 'Error Loading Roles'),
React.createElement('p', null, error),
React.createElement('button', {
style: styles.retryButton,
onClick: fetchRoles
}, 'π Retry')
)
);
}
// Success state
return React.createElement('div', { style: styles.container },
// Header
React.createElement('div', { style: styles.header },
React.createElement('h1', { style: styles.title }, 'My Roles'),
React.createElement('span', { style: styles.badge },
`${roles.length} role${roles.length !== 1 ? 's' : ''}`
)
),
// Empty state
roles.length === 0
? React.createElement('div', { style: styles.emptyState },
React.createElement('span', { style: styles.emptyIcon }, 'π'),
React.createElement('h3', null, 'No Roles Assigned'),
React.createElement('p', null, 'Contact your administrator to request role assignments.')
)
: // Role cards grid
React.createElement('div', { style: styles.grid },
roles.map((role: any) =>
React.createElement(RoleCard, {
key: role.id,
role
})
)
),
// Footer info
React.createElement('div', { style: styles.footer },
React.createElement('span', null, 'π‘ Roles determine what actions you can perform in applications.')
)
);
}
// Required export: routes object
export const routes = { Dashboard };
// Optional: widgets (none for this plugin)
export const widgets = {};
// Default export for compatibility
export default { routes, widgets };
Never use import React from 'react' in plugins. This creates a separate React instance which breaks hooks. Always use const React = (globalThis as any).React.
src/RoleCard.tsx
// src/RoleCard.tsx - Individual role display
const React = (globalThis as any).React;
const { useState } = React;
interface Role {
id: string;
name: string;
description?: string;
source: 'direct' | 'inherited' | 'delegated';
location?: string;
expiresAt?: string;
}
export function RoleCard({ role }: { role: Role }) {
const [isHovered, setIsHovered] = useState(false);
// Source badge colors
const sourceColors: Record<string, string> = {
direct: '#10b981', // Green
inherited: '#3b82f6', // Blue
delegated: '#f59e0b' // Amber
};
const sourceLabels: Record<string, string> = {
direct: 'β Direct',
inherited: 'β Inherited',
delegated: 'β Delegated'
};
const cardStyle: React.CSSProperties = {
background: 'var(--card-bg, #16161f)',
border: '1px solid var(--border, #2a2a3a)',
borderRadius: '12px',
padding: '20px',
transition: 'all 0.3s ease',
transform: isHovered ? 'translateY(-4px)' : 'none',
boxShadow: isHovered
? '0 12px 24px rgba(0,0,0,0.2)'
: '0 2px 8px rgba(0,0,0,0.1)',
cursor: 'pointer'
};
return React.createElement('div', {
style: cardStyle,
onMouseEnter: () => setIsHovered(true),
onMouseLeave: () => setIsHovered(false)
},
// Header row: name + source badge
React.createElement('div', {
style: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: '12px'
}
},
React.createElement('h3', {
style: {
margin: 0,
fontSize: '16px',
fontWeight: 600,
color: 'var(--main-text, #f0f0f5)'
}
}, role.name),
React.createElement('span', {
style: {
fontSize: '11px',
fontWeight: 600,
padding: '4px 8px',
borderRadius: '4px',
background: `${sourceColors[role.source]}20`,
color: sourceColors[role.source],
textTransform: 'uppercase',
letterSpacing: '0.5px'
}
}, sourceLabels[role.source] || role.source)
),
// Description
role.description && React.createElement('p', {
style: {
margin: '0 0 12px 0',
fontSize: '14px',
color: 'var(--secondary-text, #9090a0)',
lineHeight: '1.5'
}
}, role.description),
// Location
role.location && React.createElement('div', {
style: {
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '13px',
color: 'var(--secondary-text, #9090a0)'
}
},
React.createElement('span', null, 'π'),
React.createElement('span', null, role.location)
),
// Expiration warning
role.expiresAt && React.createElement('div', {
style: {
marginTop: '12px',
padding: '8px',
background: 'rgba(245, 158, 11, 0.1)',
borderRadius: '6px',
fontSize: '12px',
color: '#f59e0b'
}
},
'β° Expires: ', new Date(role.expiresAt).toLocaleDateString()
)
);
}
src/styles.ts
// src/styles.ts - Shared styles using CSS variables
export const styles = {
container: {
padding: '24px',
maxWidth: '1200px',
margin: '0 auto',
minHeight: '100vh'
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '24px'
},
title: {
margin: 0,
fontSize: '28px',
fontWeight: 700,
color: 'var(--main-text, #f0f0f5)'
},
badge: {
fontSize: '14px',
padding: '6px 12px',
background: 'rgba(16, 185, 129, 0.1)',
color: '#10b981',
borderRadius: '6px',
fontWeight: 600
},
grid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gap: '20px'
},
// Loading states
skeleton: {
background: 'var(--bg-tertiary, #1a1a24)',
borderRadius: '6px',
padding: '8px 16px',
color: 'var(--muted-text, #606070)'
},
skeletonCard: {
height: '140px',
background: 'var(--bg-tertiary, #1a1a24)',
borderRadius: '12px',
animation: 'pulse 2s infinite'
},
// Error state
errorBox: {
textAlign: 'center' as const,
padding: '48px',
background: 'rgba(244, 63, 94, 0.05)',
border: '1px solid rgba(244, 63, 94, 0.2)',
borderRadius: '12px'
},
errorIcon: {
fontSize: '48px',
marginBottom: '16px',
display: 'block'
},
retryButton: {
marginTop: '16px',
padding: '10px 20px',
background: '#f43f5e',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '14px',
fontWeight: 600,
cursor: 'pointer'
},
// Empty state
emptyState: {
textAlign: 'center' as const,
padding: '60px',
color: 'var(--secondary-text, #9090a0)'
},
emptyIcon: {
fontSize: '64px',
marginBottom: '16px',
display: 'block',
opacity: 0.6
},
// Footer
footer: {
marginTop: '32px',
padding: '16px',
textAlign: 'center' as const,
fontSize: '13px',
color: 'var(--muted-text, #606070)'
}
};
build.mjs
// build.mjs
import esbuild from 'esbuild';
await esbuild.build({
entryPoints: ['src/index.tsx'],
bundle: true,
format: 'esm',
platform: 'browser',
target: 'es2020',
outfile: 'dist/index.esm.js',
// Critical: these are provided by the platform
external: [
'react',
'react-dom',
'@empowernow/ui',
'@empowernow/crud-ui',
'@experience/plugin-auth'
],
// Production optimization
minify: true,
sourcemap: true,
treeShaking: true
});
console.log('β
Build complete: dist/index.esm.js');
Update package.json Scripts
{
"name": "role-viewer",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "node build.mjs",
"watch": "node build.mjs --watch",
"integrity": "powershell -Command \"$h=(Get-FileHash dist/index.esm.js -Algorithm SHA256).Hash.ToLower(); Write-Host sha256:$h\""
},
"devDependencies": {
"esbuild": "^0.21.0",
"typescript": "^5.5.0",
"@types/react": "^18.2.0"
}
}
Build the Plugin
# From plugin directory (replace <YourRepoRoot> with your project root)
# Windows
cd <YourRepoRoot>\ServiceConfigs\BFF\plugins\role-viewer\1.0.0
# macOS/Linux
cd <YourRepoRoot>/ServiceConfigs/BFF/plugins/role-viewer/1.0.0
# Install dependencies
npm install
# Build
npm run build
# Generate integrity hash
npm run integrity
# Output: sha256:abc123...def456
Update Integrity Hash
Copy the hash output and update plugins.yaml:
bundle:
file: "/app/plugins/role-viewer/1.0.0/index.esm.js"
integrity: "sha256:abc123...def456" # Paste your hash here
Restart BFF
# Restart to pick up changes (from ServiceConfigs/Deployment; replace <YourRepoRoot> with your project root)
# Windows
cd <YourRepoRoot>\ServiceConfigs\Deployment
docker compose restart bff
# macOS/Linux
cd <YourRepoRoot>/ServiceConfigs/Deployment
docker compose restart bff
# Check logs (PowerShell: Select-String; Bash: grep)
docker compose logs bff | Select-String role-viewer
Verify in Browser
- Navigate to
https://experience.self.empowernow.ai/role-viewer - Open DevTools β Console (should have no errors)
- Open DevTools β Network β check API calls succeed (200)
You should see your Role Viewer plugin displaying roles from the Membership service!
π§Troubleshooting
β "Plugin failed to load"
# Verify bundle exists (replace <YourRepoRoot> with your project root)
# Windows
dir <YourRepoRoot>\ServiceConfigs\BFF\plugins\role-viewer\1.0.0\
# macOS/Linux
ls <YourRepoRoot>/ServiceConfigs/BFF/plugins/role-viewer/1.0.0/
# Check bundle.file path in plugins.yaml matches
β "Component 'Dashboard' not found"
# Ensure export matches plugins.yaml
export function Dashboard() { ... } // Must match exactly
export const routes = { Dashboard }; // Must be in routes object
β "403 Forbidden" on API call
# Add missing permission to plugins.yaml
permissions:
api:
- method: GET
path: /api/v1/roles/{identity_id} # Add your endpoint
β "Cannot read property 'createElement' of undefined"
# You're importing React directly - DON'T
// β import React from 'react'
// β
Use platform's React
const React = (globalThis as any).React;
β "Integrity check failed"
# Regenerate hash after rebuilding
npm run build
npm run integrity
# Update plugins.yaml with new hash
βKnowledge Check
The platform provides a single React instance via globalThis.React. If you import React directly, you create a second instance which breaks hooks ("Invalid hook call" errors) because React requires a single instance per page.
They must match exactly. If plugins.yaml says component: Dashboard, your code must have export function Dashboard() and include it in export const routes = { Dashboard }.
Because the platform provides React globally. If esbuild bundles React, you get duplicate instances. Marking as external tells esbuild "don't include this, it's provided elsewhere."
- Automatically adds
X-Plugin-Idheader - Includes session cookies (
credentials: 'include') - Sends telemetry on success/error
- Gets validated against allowlist
Every time you rebuild the bundle. The hash is a SHA256 of the bundle file, so any code change means a new hash. Always run npm run integrity after npm run build.
πDay 13 Checkpoint
- Created plugin directory structure with version folder
- Configured plugins.yaml with routes, permissions, and security
- Built React components using globalThis.React
- Used Plugin SDK for authenticated API calls
- Implemented loading and error states
- Built with esbuild and generated integrity hash
- Deployed and verified plugin loads in browser
A production-ready plugin with authorization, API integration, loading states, error handling, and professional UIβall in about 80 lines of code using SDK-first development.