DAY 13 WEEK 3: PLUGINS

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.

Today you stop reading about plugins and start building one. We'll create a Role Viewer plugin that displays a user's assigned roles using the Membership service. By the end, you'll have a working plugin with authorization, data loading, and professional stylingβ€”all in about 80 lines of code.

πŸ“‹Learning Objectives

  • Initialize a new plugin project with the correct structure
  • Configure plugins.yaml with 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 Protected components
  • Build and deploy the plugin to the Experience platform

🎯What We're Building

Our Role Viewer plugin will fetch and display the current user's roles from the Membership service. It demonstrates every pattern you learned in Days 11-12: SDK usage, authorization, API calls, and professional UI.

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-Id header stamping
  • Automatic session cookie handling
  • Automatic telemetry
  • Fail-closed authorization
πŸ’‘ Code Comparison

Traditional approach: 650+ lines, 2-3 days, manual everything

SDK-first approach: ~80 lines, 90 minutes, enterprise-grade features

STEP 1 Project Setup ⏱️ 5 minutes

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

role-viewer/ └── 1.0.0/ β”œβ”€β”€ package.json β”œβ”€β”€ tsconfig.json β”œβ”€β”€ build.mjs # Build script β”‚ β”œβ”€β”€ src/ β”‚ β”œβ”€β”€ index.tsx # Entry point (REQUIRED) β”‚ β”œβ”€β”€ RoleCard.tsx # Role display component β”‚ └── styles.ts # Shared styles β”‚ └── dist/ # Build output └── index.esm.js
STEP 2 Configure Plugin Manifest ⏱️ 5 minutes
Remember: the manifest + index.yaml are the Source of Truth. Create a per-plugin manifest at 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

FieldPurpose
pdp_applicationIsolates this plugin's policies from other plugins
bundle.fileWhere 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
STEP 3 Build Entry Point ⏱️ 15 minutes
The entry point is where your plugin meets the platform. It must use the platform's React instance (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 };
⚠️ Critical: React Access

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.

STEP 4 Create RoleCard Component ⏱️ 10 minutes

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()
    )
  );
}
STEP 5 Create Styles Module ⏱️ 5 minutes

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)'
  }
};
STEP 6 Build Configuration ⏱️ 5 minutes

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"
  }
}
STEP 7 Build & Deploy ⏱️ 10 minutes

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

  1. Navigate to https://experience.self.empowernow.ai/role-viewer
  2. Open DevTools β†’ Console (should have no errors)
  3. Open DevTools β†’ Network β†’ check API calls succeed (200)
βœ… Success!

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

1 Why do we use globalThis.React instead of importing React? β–Ό

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.

2 What's the relationship between component name and plugins.yaml? β–Ό

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 }.

3 Why mark react as external in esbuild? β–Ό

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."

4 What does sdk.api.fetch() do differently than native fetch()? β–Ό
  • Automatically adds X-Plugin-Id header
  • Includes session cookies (credentials: 'include')
  • Sends telemetry on success/error
  • Gets validated against allowlist
5 When do you need to regenerate the integrity hash? β–Ό

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
πŸŽ‰ What You Built

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.