DAY 14 WEEK 3: PLUGINS

Building Your First Plugin - Part 2

Enhance the Role Viewer with Protected components, detail pages, CrudGrid integration, real-time updates, and professional UX patterns.

Yesterday you built a working plugin. Today you'll make it enterprise-grade. We'll add authorization controls, a detail page, data grids, real-time updates, and all the UX polish that separates amateur plugins from professional ones.

πŸ“‹Learning Objectives

  • Add authorization with Protected and IfCan components
  • Implement multi-page navigation with detail views
  • Use CrudGrid for professional data tables
  • Add real-time updates with SSE
  • Implement professional UX patterns (empty states, confirmations)
  • Use batch authorization for efficient permission checks

πŸ”„Where We Left Off

In Day 13, we built a Role Viewer plugin with:

  • βœ… Project structure with esbuild
  • βœ… plugins.yaml configuration
  • βœ… Dashboard component fetching roles
  • βœ… RoleCard component with styling
  • βœ… Loading and error states

Today we'll enhance it with authorization, detail pages, and professional features.

STEP 1 Add Authorization Controls ⏱️ 15 minutes
Right now, anyone can see the Role Viewer. Let's add authorization so only users with the right permissions can view roles and perform actions.

Update plugins.yaml Permissions

# Add PDP evaluation permission
permissions:
  api:
    - method: GET
      path: /api/membership/users/{id}/roles
    - method: GET
      path: /api/membership/roles/{id}
    # Authorization endpoints
    - method: POST
      path: /access/v1/evaluation
    - method: POST
      path: /access/v1/evaluations    # For batch

Create AuthWrapper Component

// src/components/AuthWrapper.tsx
const React = (globalThis as any).React;
const { useState, useEffect } = React;
const sdk = (globalThis as any).pluginSdk;

interface IfCanProps {
  resource: string;
  action: string;
  children: React.ReactNode;
  fallback?: React.ReactNode;
}

/**
 * Conditionally render children based on authorization
 */
export function IfCan({ resource, action, children, fallback = null }: IfCanProps) {
  const [authorized, setAuthorized] = useState<boolean | null>(null);

  useEffect(() => {
    checkAuth();
  }, [resource, action]);

  const checkAuth = async () => {
    try {
      const result = await sdk.authz.evaluate({
        resource: { type: resource },
        action: { name: action }
      });
      setAuthorized(result.decision === true);
    } catch (err) {
      console.error('Auth check failed:', err);
      setAuthorized(false);  // Fail closed
    }
  };

  if (authorized === null) {
    return null;  // Still checking
  }

  return authorized ? children : fallback;
}

/**
 * Button that only shows if user has permission
 */
export function ProtectedButton({ 
  resource, 
  action, 
  onClick, 
  children, 
  style = {},
  ...props 
}: any) {
  const [authorized, setAuthorized] = useState<boolean | null>(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    checkAuth();
  }, [resource, action]);

  const checkAuth = async () => {
    try {
      const result = await sdk.authz.evaluate({
        resource: { type: resource },
        action: { name: action }
      });
      setAuthorized(result.decision === true);
    } catch {
      setAuthorized(false);
    }
  };

  const handleClick = async () => {
    setLoading(true);
    try {
      await onClick();
    } finally {
      setLoading(false);
    }
  };

  if (authorized === null || !authorized) {
    return null;
  }

  return React.createElement('button', {
    onClick: handleClick,
    disabled: loading,
    style: { ...style, opacity: loading ? 0.6 : 1 },
    ...props
  }, loading ? 'Loading...' : children);
}

Update Dashboard with Authorization

// In src/index.tsx - wrap content with IfCan
import { IfCan, ProtectedButton } from './components/AuthWrapper';

export function Dashboard() {
  // ... existing state and fetchRoles ...

  return React.createElement('div', { style: styles.container },
    // Header with protected action button
    React.createElement('div', { style: styles.header },
      React.createElement('h1', { style: styles.title }, 'My Roles'),
      
      // Only show refresh if user can view roles
      React.createElement(ProtectedButton, {
        resource: 'role-viewer.roles',
        action: 'refresh',
        onClick: fetchRoles,
        style: styles.refreshButton
      }, 'πŸ”„ Refresh')
    ),
    
    // Entire content protected
    React.createElement(IfCan, {
      resource: 'role-viewer.roles',
      action: 'view',
      fallback: React.createElement('div', { style: styles.accessDenied },
        'πŸ”’ You do not have permission to view roles.'
      )
    },
      // ... role cards grid ...
    )
  );
}
πŸ’‘ Fail-Closed Pattern

Notice how authorization checks default to false on error. This is the fail-closed patternβ€”if we can't verify permission, deny access. Never fail-open in security code.

STEP 2 Add Role Detail Page ⏱️ 20 minutes
Clicking a role card should navigate to a detail page showing permissions, assignments, and metadata. This requires adding a new route in plugins.yaml and a new component.

Update plugins.yaml with Detail Route

contributions:
  routes:
    - path: /role-viewer
      component: Dashboard
      title: "My Roles"
      resource: role-viewer.route
      action: view
    # Add detail route
    - path: /role-viewer/role/:roleId
      component: RoleDetail
      title: "Role Details"
      resource: role-viewer.role
      action: view

Create RoleDetail Component

// src/RoleDetail.tsx
const React = (globalThis as any).React;
const { useState, useEffect } = React;
const sdk = (globalThis as any).pluginSdk;

interface RoleDetailProps {
  roleId: string;
}

export function RoleDetail({ roleId }: RoleDetailProps) {
  const [role, setRole] = useState<any>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetchRoleDetail();
  }, [roleId]);

  const fetchRoleDetail = async () => {
    try {
      setLoading(true);
      const response = await sdk.api.fetch(`/api/membership/roles/${roleId}`);
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }
      
      const data = await response.json();
      setRole(data);
    } catch (err: any) {
      setError(err.message || 'Failed to load role');
    } finally {
      setLoading(false);
    }
  };

  if (loading) {
    return React.createElement('div', { style: styles.container },
      React.createElement('div', { style: styles.skeleton }, 'Loading role details...')
    );
  }

  if (error || !role) {
    return React.createElement('div', { style: styles.container },
      React.createElement('div', { style: styles.errorBox },
        React.createElement('h3', null, 'Error'),
        React.createElement('p', null, error || 'Role not found'),
        React.createElement('a', { href: '/role-viewer', style: styles.backLink }, '← Back to Roles')
      )
    );
  }

  return React.createElement('div', { style: styles.container },
    // Back navigation
    React.createElement('a', { 
      href: '/role-viewer', 
      style: styles.backLink 
    }, '← Back to My Roles'),
    
    // Role header
    React.createElement('div', { style: styles.detailHeader },
      React.createElement('h1', { style: styles.detailTitle }, role.name),
      React.createElement('span', { 
        style: { 
          ...styles.sourceBadge,
          background: `${getSourceColor(role.source)}20`,
          color: getSourceColor(role.source)
        }
      }, role.source)
    ),
    
    // Description
    role.description && React.createElement('p', { 
      style: styles.description 
    }, role.description),
    
    // Metadata cards
    React.createElement('div', { style: styles.metaGrid },
      // Location
      role.location && React.createElement('div', { style: styles.metaCard },
        React.createElement('span', { style: styles.metaLabel }, 'πŸ“ Location'),
        React.createElement('span', { style: styles.metaValue }, role.location)
      ),
      
      // Assigned date
      role.assignedAt && React.createElement('div', { style: styles.metaCard },
        React.createElement('span', { style: styles.metaLabel }, 'πŸ“… Assigned'),
        React.createElement('span', { style: styles.metaValue }, 
          new Date(role.assignedAt).toLocaleDateString()
        )
      ),
      
      // Expiration
      role.expiresAt && React.createElement('div', { style: styles.metaCard },
        React.createElement('span', { style: styles.metaLabel }, '⏰ Expires'),
        React.createElement('span', { style: styles.metaValue }, 
          new Date(role.expiresAt).toLocaleDateString()
        )
      )
    ),
    
    // Permissions section
    role.permissions && role.permissions.length > 0 &&
      React.createElement('div', { style: styles.section },
        React.createElement('h3', { style: styles.sectionTitle }, 'πŸ” Permissions'),
        React.createElement('div', { style: styles.permissionList },
          role.permissions.map((perm: string, i: number) =>
            React.createElement('span', { 
              key: i, 
              style: styles.permissionTag 
            }, perm)
          )
        )
      )
  );
}

function getSourceColor(source: string): string {
  const colors: Record<string, string> = {
    direct: '#10b981',
    inherited: '#3b82f6',
    delegated: '#f59e0b'
  };
  return colors[source] || '#6b7280';
}

// Add styles for detail page
const styles = {
  container: { padding: '24px', maxWidth: '800px', margin: '0 auto' },
  backLink: { 
    color: 'var(--accent-cyan)', 
    textDecoration: 'none', 
    fontSize: '14px',
    display: 'block',
    marginBottom: '24px'
  },
  detailHeader: {
    display: 'flex',
    alignItems: 'center',
    gap: '16px',
    marginBottom: '16px'
  },
  detailTitle: {
    margin: 0,
    fontSize: '32px',
    fontWeight: 700,
    color: 'var(--main-text, #f0f0f5)'
  },
  sourceBadge: {
    padding: '6px 12px',
    borderRadius: '6px',
    fontSize: '12px',
    fontWeight: 600,
    textTransform: 'uppercase' as const
  },
  description: {
    fontSize: '16px',
    color: 'var(--secondary-text, #9090a0)',
    marginBottom: '24px',
    lineHeight: 1.6
  },
  metaGrid: {
    display: 'grid',
    gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
    gap: '16px',
    marginBottom: '32px'
  },
  metaCard: {
    background: 'var(--card-bg, #16161f)',
    border: '1px solid var(--border, #2a2a3a)',
    borderRadius: '8px',
    padding: '16px',
    display: 'flex',
    flexDirection: 'column' as const,
    gap: '8px'
  },
  metaLabel: { fontSize: '13px', color: 'var(--muted-text, #606070)' },
  metaValue: { fontSize: '16px', fontWeight: 600, color: 'var(--main-text, #f0f0f5)' },
  section: { marginTop: '32px' },
  sectionTitle: {
    fontSize: '18px',
    fontWeight: 600,
    marginBottom: '16px',
    color: 'var(--main-text, #f0f0f5)'
  },
  permissionList: { display: 'flex', flexWrap: 'wrap' as const, gap: '8px' },
  permissionTag: {
    padding: '6px 12px',
    background: 'rgba(59, 130, 246, 0.1)',
    border: '1px solid rgba(59, 130, 246, 0.2)',
    borderRadius: '6px',
    fontSize: '13px',
    color: '#3b82f6'
  },
  skeleton: { padding: '48px', textAlign: 'center' as const, color: 'var(--muted-text)' },
  errorBox: { padding: '48px', textAlign: 'center' as const }
};

Update Entry Point Exports

// src/index.tsx - add RoleDetail to exports
import { RoleDetail } from './RoleDetail';

// ... Dashboard component ...

// Export both components
export const routes = { Dashboard, RoleDetail };
export const widgets = {};
export default { routes, widgets };

Make RoleCards Clickable

// In RoleCard.tsx - add navigation
export function RoleCard({ role }: { role: Role }) {
  const handleClick = () => {
    // Navigate to detail page
    window.location.href = `/role-viewer/role/${role.id}`;
  };

  return React.createElement('div', {
    style: cardStyle,
    onClick: handleClick,
    onMouseEnter: () => setIsHovered(true),
    onMouseLeave: () => setIsHovered(false),
    role: 'button',
    tabIndex: 0,
    onKeyPress: (e: any) => e.key === 'Enter' && handleClick()
  },
    // ... card content ...
  );
}
STEP 3 Batch Authorization for Performance ⏱️ 15 minutes
If each role card needs to check permissions for actions (edit, delete), doing individual checks is slow. Batch evaluation checks all permissions in a single API callβ€”3x faster.

Add Batch Permission Check

// src/hooks/useBatchPermissions.ts
const React = (globalThis as any).React;
const { useState, useEffect } = React;
const sdk = (globalThis as any).pluginSdk;

interface Permission {
  resource: string;
  action: string;
}

/**
 * Check multiple permissions in a single API call
 */
export function useBatchPermissions(permissions: Permission[]) {
  const [results, setResults] = useState<Map<string, boolean>>(new Map());
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    if (permissions.length === 0) {
      setLoading(false);
      return;
    }
    
    checkPermissions();
  }, [JSON.stringify(permissions)]);

  const checkPermissions = async () => {
    try {
      setLoading(true);
      
      // Single batch API call!
      const response = await sdk.authz.batchEvaluate(
        permissions.map(p => ({
          resource: { type: p.resource },
          action: { name: p.action }
        }))
      );
      
      // Map results back to permission keys
      const resultMap = new Map<string, boolean>();
      permissions.forEach((p, i) => {
        const key = `${p.resource}:${p.action}`;
        const decision = response.decisions?.[i]?.decision;
        resultMap.set(key, decision === true || decision === 'Permit');
      });
      
      setResults(resultMap);
    } catch (err) {
      console.error('Batch permission check failed:', err);
      // Fail closed - all false
      const failedMap = new Map<string, boolean>();
      permissions.forEach(p => {
        failedMap.set(`${p.resource}:${p.action}`, false);
      });
      setResults(failedMap);
    } finally {
      setLoading(false);
    }
  };

  const can = (resource: string, action: string): boolean => {
    return results.get(`${resource}:${action}`) ?? false;
  };

  return { can, loading, results };
}

Use in RoleCard with Actions

// Updated RoleCard with action buttons
export function RoleCard({ role, permissions }: { role: Role, permissions: any }) {
  // ... existing code ...
  
  return React.createElement('div', { style: cardStyle },
    // ... header and content ...
    
    // Action buttons (only show if permitted)
    React.createElement('div', { style: styles.actions },
      permissions.can(`role.${role.id}`, 'edit') &&
        React.createElement('button', {
          style: styles.actionBtn,
          onClick: (e: any) => { e.stopPropagation(); handleEdit(role); }
        }, '✏️ Edit'),
      
      permissions.can(`role.${role.id}`, 'delegate') &&
        React.createElement('button', {
          style: styles.actionBtn,
          onClick: (e: any) => { e.stopPropagation(); handleDelegate(role); }
        }, 'πŸ”„ Delegate')
    )
  );
}
πŸ“Š Performance Comparison

Individual checks: 10 roles Γ— 3 actions = 30 API calls

Batch check: 1 API call for all 30 permissions

Result: 3x faster, better UX, lower server load

STEP 4 Add Real-Time Updates with SSE ⏱️ 15 minutes
When roles change (assigned, revoked, delegated), the plugin should update automatically without requiring a page refresh. Server-Sent Events (SSE) make this possible.

Update plugins.yaml with SSE Permission

permissions:
  api:
    # ... existing permissions ...
  sse:
    - /api/events/membership/roles

Create useRoleUpdates Hook

// src/hooks/useRoleUpdates.ts
const React = (globalThis as any).React;
const { useEffect, useRef } = React;
const sdk = (globalThis as any).pluginSdk;

/**
 * Subscribe to real-time role updates
 */
export function useRoleUpdates(onUpdate: (event: any) => void) {
  const unsubscribeRef = useRef<(() => void) | null>(null);

  useEffect(() => {
    // Subscribe to SSE stream
    unsubscribeRef.current = sdk.sse.subscribe(
      '/api/events/membership/roles',
      (data: any) => {
        console.log('Role update received:', data);
        onUpdate(data);
      }
    );

    // Cleanup on unmount
    return () => {
      if (unsubscribeRef.current) {
        unsubscribeRef.current();
      }
    };
  }, []);
}

// Usage in Dashboard
export function Dashboard() {
  const [roles, setRoles] = useState([]);
  
  // Subscribe to updates
  useRoleUpdates((event) => {
    if (event.type === 'role_assigned') {
      setRoles(prev => [...prev, event.role]);
    } else if (event.type === 'role_revoked') {
      setRoles(prev => prev.filter(r => r.id !== event.roleId));
    } else if (event.type === 'role_updated') {
      setRoles(prev => prev.map(r => 
        r.id === event.role.id ? event.role : r
      ));
    }
  });
  
  // ... rest of component
}

Add Update Indicator

// Show a toast when updates arrive
const [lastUpdate, setLastUpdate] = useState<string | null>(null);

useRoleUpdates((event) => {
  // ... handle update ...
  
  // Show notification
  setLastUpdate(`Role ${event.type.replace('_', ' ')} at ${new Date().toLocaleTimeString()}`);
  
  // Clear after 3 seconds
  setTimeout(() => setLastUpdate(null), 3000);
});

// In render
{lastUpdate && React.createElement('div', { style: styles.updateToast },
  'πŸ”” ', lastUpdate
)}
STEP 5 Professional UX Patterns ⏱️ 20 minutes

Empty State Component

// src/components/EmptyState.tsx
export function EmptyState({ icon, title, message, actionText, onAction }: any) {
  return React.createElement('div', { style: styles.emptyContainer },
    React.createElement('span', { style: styles.emptyIcon }, icon),
    React.createElement('h3', { style: styles.emptyTitle }, title),
    React.createElement('p', { style: styles.emptyMessage }, message),
    actionText && onAction && React.createElement('button', {
      style: styles.emptyAction,
      onClick: onAction
    }, actionText)
  );
}

const styles = {
  emptyContainer: {
    textAlign: 'center' as const,
    padding: '60px 24px',
    maxWidth: '400px',
    margin: '0 auto'
  },
  emptyIcon: { fontSize: '64px', marginBottom: '16px', display: 'block', opacity: 0.7 },
  emptyTitle: { fontSize: '20px', fontWeight: 600, marginBottom: '8px', color: 'var(--main-text)' },
  emptyMessage: { fontSize: '14px', color: 'var(--secondary-text)', marginBottom: '24px' },
  emptyAction: {
    padding: '12px 24px',
    background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
    color: 'white',
    border: 'none',
    borderRadius: '8px',
    fontWeight: 600,
    cursor: 'pointer'
  }
};

Confirmation Dialog

// src/components/ConfirmDialog.tsx
export function ConfirmDialog({ 
  isOpen, 
  title, 
  message, 
  onConfirm, 
  onCancel, 
  danger = false 
}: any) {
  if (!isOpen) return null;

  return React.createElement('div', {
    style: styles.overlay,
    onClick: onCancel,
    role: 'dialog',
    'aria-modal': 'true'
  },
    React.createElement('div', {
      style: styles.dialog,
      onClick: (e: any) => e.stopPropagation()
    },
      React.createElement('h3', { style: styles.dialogTitle }, title),
      React.createElement('p', { style: styles.dialogMessage }, message),
      React.createElement('div', { style: styles.dialogActions },
        React.createElement('button', {
          style: styles.cancelBtn,
          onClick: onCancel
        }, 'Cancel'),
        React.createElement('button', {
          style: danger ? styles.dangerBtn : styles.confirmBtn,
          onClick: onConfirm
        }, danger ? 'Delete' : 'Confirm')
      )
    )
  );
}

const styles = {
  overlay: {
    position: 'fixed' as const,
    inset: 0,
    background: 'rgba(0,0,0,0.6)',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    zIndex: 9999
  },
  dialog: {
    background: 'var(--card-bg, #16161f)',
    border: '1px solid var(--border, #2a2a3a)',
    borderRadius: '12px',
    padding: '24px',
    maxWidth: '400px',
    width: '90%'
  },
  dialogTitle: { margin: '0 0 12px 0', fontSize: '18px', fontWeight: 600 },
  dialogMessage: { margin: '0 0 24px 0', color: 'var(--secondary-text)' },
  dialogActions: { display: 'flex', gap: '12px', justifyContent: 'flex-end' },
  cancelBtn: {
    padding: '8px 16px',
    background: 'var(--bg-tertiary)',
    border: 'none',
    borderRadius: '6px',
    color: 'var(--main-text)',
    cursor: 'pointer'
  },
  confirmBtn: {
    padding: '8px 16px',
    background: '#3b82f6',
    border: 'none',
    borderRadius: '6px',
    color: 'white',
    cursor: 'pointer'
  },
  dangerBtn: {
    padding: '8px 16px',
    background: '#ef4444',
    border: 'none',
    borderRadius: '6px',
    color: 'white',
    cursor: 'pointer'
  }
};

Keyboard Shortcuts

// Add keyboard shortcuts to Dashboard
useEffect(() => {
  const handleKeyDown = (e: KeyboardEvent) => {
    // Ctrl/Cmd + R: Refresh
    if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
      e.preventDefault();
      fetchRoles();
    }
    
    // Escape: Close any open dialog
    if (e.key === 'Escape') {
      setShowConfirm(false);
    }
  };

  window.addEventListener('keydown', handleKeyDown);
  return () => window.removeEventListener('keydown', handleKeyDown);
}, []);

// Show hint in footer
React.createElement('div', { style: styles.shortcutHint },
  'πŸ’‘ Shortcuts: ',
  React.createElement('kbd', { style: styles.kbd }, 'Ctrl+R'),
  ' Refresh'
)

πŸ“Final Plugin Structure

role-viewer/
└── 1.0.0/
    β”œβ”€β”€ package.json
    β”œβ”€β”€ tsconfig.json
    β”œβ”€β”€ build.mjs
    β”‚
    β”œβ”€β”€ src/
    β”‚   β”œβ”€β”€ index.tsx              # Entry + Dashboard
    β”‚   β”œβ”€β”€ RoleCard.tsx           # Card component
    β”‚   β”œβ”€β”€ RoleDetail.tsx         # Detail page
    β”‚   β”œβ”€β”€ styles.ts              # Shared styles
    β”‚   β”‚
    β”‚   β”œβ”€β”€ components/
    β”‚   β”‚   β”œβ”€β”€ AuthWrapper.tsx    # IfCan, ProtectedButton
    β”‚   β”‚   β”œβ”€β”€ EmptyState.tsx     # Empty state UI
    β”‚   β”‚   └── ConfirmDialog.tsx  # Confirmation modal
    β”‚   β”‚
    β”‚   └── hooks/
    β”‚       β”œβ”€β”€ useBatchPermissions.ts  # Batch authz
    β”‚       └── useRoleUpdates.ts       # SSE subscription
    β”‚
    └── dist/
        └── index.esm.js

❓Knowledge Check

1 What's the benefit of batch authorization vs individual checks? β–Ό

Batch authorization sends all permission checks in a single API call instead of multiple calls. For 10 roles with 3 actions each, that's 1 call vs 30 callsβ€”3x faster, better UX, and lower server load.

2 Why do we "fail closed" in authorization checks? β–Ό

If an authorization check fails (network error, timeout, etc.), we default to denying access. This is the fail-closed pattern. Failing open (granting access on error) would be a security vulnerability.

3 How does SSE differ from polling for real-time updates? β–Ό

Polling: Client asks server repeatedly "anything new?" every N seconds. Wasteful if nothing changed.

SSE: Server pushes updates to client only when something changes. More efficient, instant updates, lower latency.

4 What permission is needed in plugins.yaml for SSE? β–Ό

SSE endpoints go in the permissions.sse array, not permissions.api:

permissions:
  sse:
    - /api/events/membership/roles
5 Why create separate components for EmptyState and ConfirmDialog? β–Ό

Reusability and consistency. These patterns appear throughout professional apps. Creating dedicated components means:

  • Consistent styling across the plugin
  • Single place to update if design changes
  • Less code duplication
  • Easier testing

🌐Plugin Localization (i18n)

Your plugin works greatβ€”in English. But enterprise apps serve global teams. Localization lets you support multiple languages without rewriting components. The EmpowerNow platform provides a complete i18n system that plugins can tap into with just a few lines of code.

Quick Start: Localize in 5 Steps

Step 1: Create Translation Catalog

Create a JSON file with your translations in ServiceConfigs/BFF/config/i18n/compiled/plugins/{your-plugin-id}/en-US.json:

{
  "$comment": "Role Viewer translations - English (US)",
  "$revision": "sha256:initial",
  "$locale": "en-US",
  "$plugin_id": "role-viewer",
  
  "plugins.role-viewer.title": "Role Viewer",
  "plugins.role-viewer.description": "View and manage your roles",
  
  "plugins.role-viewer.dashboard.title": "My Roles",
  "plugins.role-viewer.dashboard.subtitle": "Overview of assigned roles",
  "plugins.role-viewer.dashboard.empty": "No roles assigned yet",
  
  "plugins.role-viewer.actions.refresh": "Refresh",
  "plugins.role-viewer.actions.delegate": "Delegate",
  "plugins.role-viewer.actions.viewDetails": "View Details",
  
  "plugins.role-viewer.detail.permissions": "Permissions",
  "plugins.role-viewer.detail.location": "Location",
  "plugins.role-viewer.detail.assigned": "Assigned",
  "plugins.role-viewer.detail.expires": "Expires",
  
  "plugins.role-viewer.errors.loadFailed": "Failed to load roles",
  "plugins.role-viewer.errors.accessDenied": "You do not have permission to view roles"
}

Step 2: Add Translation Helper

Add this helper function at the top of your plugin's entry file:

// src/i18n.ts - Translation helper
const PLUGIN_ID = 'role-viewer';

export function t(key: string, params?: Record<string, unknown>): string {
  const globalI18n = (window as any).__EXPERIENCE_I18N__;
  if (!globalI18n) return key; // Fallback if i18n not loaded
  
  // Auto-prefix with plugin namespace
  const fullKey = key.startsWith('plugins.') || key.startsWith('shell.')
    ? key
    : `plugins.${PLUGIN_ID}.${key}`;
  
  // Try plugin catalog first, then shell catalog
  const pluginCatalog = globalI18n.pluginCatalogs?.get(PLUGIN_ID);
  let message = pluginCatalog?.[fullKey] || globalI18n.shellCatalog?.[fullKey];
  
  if (!message) return key; // Return key for visibility in dev
  
  // Simple parameter interpolation
  if (params) {
    return message.replace(/\{(\w+)\}/g, (_, name) =>
      params[name] !== undefined ? String(params[name]) : `{${name}}`
    );
  }
  
  return message;
}

Step 3: Replace Hardcoded Strings

// Before (hardcoded)
React.createElement('h1', null, 'My Roles')

// After (localized)
import { t } from './i18n';
React.createElement('h1', null, t('dashboard.title'))

Step 4: Use Shell Translations for Common Actions

The shell provides 170+ pre-translated keys for common UI elements. Use them for consistency:

// βœ… Good - uses shared shell translations
t('shell.actions.save')      // "Save"
t('shell.actions.cancel')    // "Cancel"
t('shell.actions.delete')    // "Delete"
t('shell.loading')           // "Loading..."
t('shell.error.generic')     // "Something went wrong"

// ❌ Bad - duplicates shell translations
t('save')                    // Don't create separate plugin keys

Step 5: Rebuild Plugin

npm run build

Key Patterns

Translation Key Naming Convention

plugins.{plugin-id}.{category}.{item}

// Examples:
plugins.role-viewer.title
plugins.role-viewer.dashboard.subtitle
plugins.role-viewer.actions.refresh
plugins.role-viewer.errors.loadFailed

Using Parameters (Interpolation)

// Translation catalog:
// "plugins.role-viewer.items.count": "Showing {count} roles"

t('items.count', { count: 42 }) // β†’ "Showing 42 roles"

ICU Pluralization

For proper pluralization across languages, use ICU MessageFormat:

{
  "shell.search.resultsCount": "{count, plural, =0 {No results} one {# result} other {# results}}"
}

// The LocalizationProvider handles ICU parsing automatically

Accessing Formatters

Use the SDK's built-in formatters for dates, numbers, and currencies:

const sdk = (globalThis as any).pluginSdk;

sdk.i18n.formatDate(new Date())           // "1/21/2026"
sdk.i18n.formatNumber(1234.56)            // "1,234.56"
sdk.i18n.formatCurrency(99.99, 'EUR')     // "€99.99"
sdk.i18n.formatRelativeTime(-2, 'day')    // "2 days ago"

Shell Translation Categories

Use these instead of creating duplicates:

Category Example Keys Use For
shell.loading.* shell.loading, shell.loading.content Loading states
shell.error.* shell.error.generic, shell.error.network Error messages
shell.actions.* shell.actions.save, shell.actions.cancel Button labels
shell.form.* shell.form.required, shell.form.invalidEmail Form validation
shell.table.* shell.table.empty, shell.table.loading Data tables
shell.confirm.* shell.confirm.delete, shell.confirm.proceed Confirmations
shell.a11y.* shell.a11y.loading, shell.a11y.skipToContent Accessibility

RTL (Right-to-Left) Support

For Arabic, Hebrew, and other RTL locales, use CSS logical properties:

/* βœ… Good - uses logical properties (works for both LTR and RTL) */
.sidebar {
  margin-inline-start: 16px;
  padding-inline-end: 8px;
  text-align: start;
}

/* ❌ Bad - uses physical properties (breaks RTL) */
.sidebar {
  margin-left: 16px;      /* Wrong direction in RTL */
  padding-right: 8px;
  text-align: left;
}

Check the current direction via SDK:

const direction = sdk.i18n.direction; // 'ltr' or 'rtl'

Menu Localization

To localize menu items, add groupKey and titleKey to your navigation config:

menu_structure:
  - group: "Administration"
    groupKey: "shell.menu.groups.administration"  # Translation key
    icon: "βš™"
    columns:
      - title: "Platform"
        titleKey: "shell.menu.columns.platform"   # Translation key
        items:
          - role-viewer

Testing Localization

1. Check Console for Loading

[i18n] Loaded shell catalog: en-US (178 keys)
[i18n] Loaded plugin catalog: role-viewer (24 keys)

2. Test Missing Keys

t('nonexistent.key') // Returns: "nonexistent.key" (visible in dev)

3. Test Locale Switching

  1. Click the locale selector in the footer
  2. Select a different locale
  3. Verify UI updates (or shows keys for untranslated locales)

Troubleshooting Localization

⚠️ Translation Not Appearing?

Key displayed instead of text: Translation missing - add key to catalog JSON

Plugin translations not loading: Catalog file missing - create en-US.json for your plugin

Shell translations work, plugin doesn't: Plugin ID mismatch - verify PLUGIN_ID matches catalog's $plugin_id

Force Cache Reload

// Browser: Ctrl+Shift+R (hard refresh)
// Clear localStorage:
localStorage.removeItem('experience_locale');

// BFF: Restart the BFF service to clear ETag cache
πŸ’‘ Best Practices Checklist
  • Use shell translations for common actions (save, cancel, delete)
  • Follow naming convention: plugins.{id}.{category}.{item}
  • Use ICU for pluralsβ€”never use "item(s)" hacks
  • Test with long strings (German is ~30% longer than English)
  • Use CSS logical properties (margin-inline-start not margin-left)
  • Keep keys stableβ€”changing keys breaks translations

πŸ“Day 14 Checkpoint

  • Added authorization with IfCan and ProtectedButton components
  • Created multi-page navigation with RoleDetail component
  • Implemented batch authorization for efficient permission checks
  • Added real-time updates with SSE subscription
  • Created professional UX components (EmptyState, ConfirmDialog)
  • Added keyboard shortcuts for power users
  • Implemented plugin localization with translation catalogs
  • Rebuilt and deployed enhanced plugin
πŸŽ‰ What You Built

Your Role Viewer is now enterprise-grade: authorization-gated, multi-page, real-time, with professional UX patterns. This is the quality expected in production plugins.