# Claim Mapping Rules Editor Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a UI for managing OIDC claim mapping rules — a modal editor triggered from the OIDC config page with inline CRUD, reordering, and a server-side test panel. **Architecture:** One new backend endpoint (`POST /test`) added to the existing `ClaimMappingAdminController`. On the frontend, a new React Query hooks file for the claim mapping API, a new modal component with table + test panel, and a small modification to `OidcConfigPage` to add the trigger button. All CRUD operations use the existing `/api/v1/admin/claim-mappings` REST API. **Tech Stack:** Spring Boot (backend endpoint), React + TypeScript + React Query (frontend), `@cameleer/design-system` components, CSS modules. --- ### Task 1: Backend — Add test endpoint **Files:** - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ClaimMappingAdminController.java` - [ ] **Step 1: Write the test endpoint** Add the `POST /test` endpoint and response DTO to `ClaimMappingAdminController.java`. Add these inside the class, after the existing `delete` method (line 76): ```java record MatchedRuleResponse(UUID ruleId, int priority, String claim, String matchType, String matchValue, String action, String target) {} record TestResponse(List matchedRules, List effectiveRoles, List effectiveGroups, boolean fallback) {} @PostMapping("/test") @Operation(summary = "Test claim mapping rules against a set of claims") public TestResponse test(@RequestBody Map claims) { List rules = repository.findAll(); List results = claimMappingService.evaluate(rules, claims); List matched = results.stream() .map(r -> new MatchedRuleResponse( r.rule().id(), r.rule().priority(), r.rule().claim(), r.rule().matchType(), r.rule().matchValue(), r.rule().action(), r.rule().target())) .toList(); List effectiveRoles = results.stream() .filter(r -> "assignRole".equals(r.rule().action())) .map(r -> r.rule().target()) .distinct() .toList(); List effectiveGroups = results.stream() .filter(r -> "addToGroup".equals(r.rule().action())) .map(r -> r.rule().target()) .distinct() .toList(); return new TestResponse(matched, effectiveRoles, effectiveGroups, results.isEmpty()); } ``` - [ ] **Step 2: Add ClaimMappingService dependency to the controller** The controller currently only injects `ClaimMappingRepository`. Add `ClaimMappingService` as a constructor parameter. Update the constructor: ```java private final ClaimMappingRepository repository; private final ClaimMappingService claimMappingService; public ClaimMappingAdminController(ClaimMappingRepository repository, ClaimMappingService claimMappingService) { this.repository = repository; this.claimMappingService = claimMappingService; } ``` - [ ] **Step 3: Verify compilation** Run: `mvn clean compile -pl cameleer-server-app -am` Expected: BUILD SUCCESS - [ ] **Step 4: Commit** ```bash git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ClaimMappingAdminController.java git commit -m "feat: add POST /test endpoint for claim mapping rule evaluation" ``` --- ### Task 2: Frontend — API hooks for claim mapping rules **Files:** - Create: `ui/src/api/queries/admin/claim-mappings.ts` - [ ] **Step 1: Create the types and hooks file** Create `ui/src/api/queries/admin/claim-mappings.ts` following the exact pattern from `rbac.ts` — React Query hooks using `adminFetch`: ```typescript import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { adminFetch } from './admin-api'; // ── Types ────────────────────────────────────────────────────────────── export interface ClaimMappingRule { id: string; claim: string; matchType: 'equals' | 'contains' | 'regex'; matchValue: string; action: 'assignRole' | 'addToGroup'; target: string; priority: number; createdAt: string; } export interface CreateRuleRequest { claim: string; matchType: string; matchValue: string; action: string; target: string; priority: number; } export interface MatchedRuleResponse { ruleId: string; priority: number; claim: string; matchType: string; matchValue: string; action: string; target: string; } export interface TestResponse { matchedRules: MatchedRuleResponse[]; effectiveRoles: string[]; effectiveGroups: string[]; fallback: boolean; } // ── Query Hooks ──────────────────────────────────────────────────────── export function useClaimMappingRules() { return useQuery({ queryKey: ['admin', 'claim-mappings'], queryFn: () => adminFetch('/claim-mappings'), }); } // ── Mutation Hooks ───────────────────────────────────────────────────── export function useCreateClaimMappingRule() { const qc = useQueryClient(); return useMutation({ mutationFn: (req: CreateRuleRequest) => adminFetch('/claim-mappings', { method: 'POST', body: JSON.stringify(req), }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin', 'claim-mappings'] }); }, }); } export function useUpdateClaimMappingRule() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, ...req }: CreateRuleRequest & { id: string }) => adminFetch(`/claim-mappings/${id}`, { method: 'PUT', body: JSON.stringify(req), }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin', 'claim-mappings'] }); }, }); } export function useDeleteClaimMappingRule() { const qc = useQueryClient(); return useMutation({ mutationFn: (id: string) => adminFetch(`/claim-mappings/${id}`, { method: 'DELETE' }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin', 'claim-mappings'] }); }, }); } export function useTestClaimMappingRules() { return useMutation({ mutationFn: (claims: Record) => adminFetch('/claim-mappings/test', { method: 'POST', body: JSON.stringify(claims), }), }); } ``` - [ ] **Step 2: Verify TypeScript compiles** Run: `cd ui && npx tsc --noEmit` Expected: no errors - [ ] **Step 3: Commit** ```bash git add ui/src/api/queries/admin/claim-mappings.ts git commit -m "feat: add React Query hooks for claim mapping rules API" ``` --- ### Task 3: Frontend — Rules table modal component **Files:** - Create: `ui/src/pages/Admin/ClaimMappingRulesModal.tsx` - Create: `ui/src/pages/Admin/ClaimMappingRulesModal.module.css` - [ ] **Step 1: Create the CSS module** Create `ui/src/pages/Admin/ClaimMappingRulesModal.module.css`: ```css .overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.6); display: flex; align-items: center; justify-content: center; z-index: 1000; } .modal { background: var(--surface-1); border: 1px solid var(--border); border-radius: 12px; width: min(800px, 90vw); max-height: 85vh; display: flex; flex-direction: column; overflow: hidden; } .header { display: flex; justify-content: space-between; align-items: flex-start; padding: 16px 20px; border-bottom: 1px solid var(--border); } .headerInfo { display: flex; flex-direction: column; gap: 2px; } .title { font-size: 15px; font-weight: 600; color: var(--text); } .subtitle { font-size: 11px; color: var(--text-muted); } .body { overflow-y: auto; flex: 1; } .table { width: 100%; border-collapse: collapse; } .table th { text-align: left; padding: 6px 8px; font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid var(--border); } .table td { padding: 8px; border-bottom: 1px solid var(--border-subtle, var(--border)); font-size: 13px; vertical-align: middle; } .table th:last-child, .table td:last-child { text-align: right; } .matchedRow { background: rgba(var(--success-rgb, 68, 170, 136), 0.06); } .priorityCell { color: var(--text-muted); font-size: 11px; width: 30px; } .claimCode { background: var(--surface-2, var(--surface-1)); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 12px; } .pill { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; white-space: nowrap; } .pillMatch { background: var(--surface-2, var(--surface-1)); color: var(--text-secondary, var(--text-muted)); } .pillAssignRole { background: color-mix(in srgb, var(--success) 15%, transparent); color: var(--success); } .pillAddToGroup { background: color-mix(in srgb, var(--info, var(--primary)) 15%, transparent); color: var(--info, var(--primary)); } .matchValue { font-family: var(--font-mono); font-size: 12px; } .targetCell { font-weight: 500; } .actions { display: flex; gap: 4px; justify-content: flex-end; align-items: center; } .iconBtn { background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 4px; border-radius: 4px; display: flex; align-items: center; justify-content: center; } .iconBtn:hover { color: var(--text); background: var(--surface-2, var(--surface-1)); } .iconBtn:disabled { opacity: 0.3; cursor: default; } .addRow { display: flex; gap: 8px; align-items: center; padding: 12px 20px; border-top: 1px solid var(--border); } .addRow input, .addRow select { font-size: 12px; } .claimInput { width: 100px; } .matchSelect { width: 90px; } .valueInput { flex: 1; } .actionSelect { width: 120px; } .targetInput { width: 120px; } .editRow input, .editRow select { font-size: 12px; } .emptyState { padding: 32px 20px; text-align: center; color: var(--text-muted); font-size: 13px; } .footer { padding: 12px 20px; font-size: 11px; color: var(--text-muted); border-top: 1px solid var(--border); } /* ── Test Panel ─────────────────────────────────────────────────── */ .testToggle { display: flex; align-items: center; gap: 8px; padding: 12px 20px; cursor: pointer; border-top: 2px solid var(--border); user-select: none; } .testToggle:hover { background: var(--surface-2, var(--surface-1)); } .testToggleLabel { font-size: 13px; font-weight: 500; color: var(--text-secondary, var(--text-muted)); } .testToggleHint { font-size: 11px; color: var(--text-muted); } .testPanel { display: flex; gap: 12px; padding: 0 20px 16px; } .testTextarea { flex: 1; display: flex; flex-direction: column; gap: 8px; } .testTextarea textarea { width: 100%; box-sizing: border-box; height: 140px; background: var(--surface-2, var(--surface-1)); border: 1px solid var(--border); border-radius: 6px; padding: 10px; color: var(--text); font-family: var(--font-mono); font-size: 11px; resize: vertical; } .testResults { flex: 1; background: var(--surface-2, var(--surface-1)); border: 1px solid var(--border); border-radius: 6px; padding: 12px; min-height: 140px; box-sizing: border-box; overflow-y: auto; } .testResultsTitle { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; } .testMatch { margin-bottom: 10px; } .testMatchLabel { font-size: 11px; color: var(--success); margin-bottom: 4px; } .testMatchDetail { font-size: 12px; padding-left: 16px; } .testEffective { border-top: 1px solid var(--border); padding-top: 8px; margin-top: 4px; font-size: 11px; color: var(--text-muted); } .testEffectiveValues { color: var(--success); font-weight: 500; } .testFallback { font-size: 12px; color: var(--warning); font-style: italic; } .testError { font-size: 12px; color: var(--error); } .testEmpty { font-size: 12px; color: var(--text-muted); font-style: italic; } .matchCheck { color: var(--success); } ``` - [ ] **Step 2: Create the modal component** Create `ui/src/pages/Admin/ClaimMappingRulesModal.tsx`: ```tsx import { useState } from 'react'; import { ChevronUp, ChevronDown, Pencil, X, Check } from 'lucide-react'; import { Button, Input, Select, ConfirmDialog, useToast } from '@cameleer/design-system'; import { useClaimMappingRules, useCreateClaimMappingRule, useUpdateClaimMappingRule, useDeleteClaimMappingRule, useTestClaimMappingRules, } from '../../api/queries/admin/claim-mappings'; import type { ClaimMappingRule, TestResponse } from '../../api/queries/admin/claim-mappings'; import styles from './ClaimMappingRulesModal.module.css'; const MATCH_OPTIONS = [ { value: 'equals', label: 'equals' }, { value: 'contains', label: 'contains' }, { value: 'regex', label: 'regex' }, ]; const ACTION_OPTIONS = [ { value: 'assignRole', label: 'assign role' }, { value: 'addToGroup', label: 'add to group' }, ]; const EMPTY_FORM = { claim: '', matchType: 'equals', matchValue: '', action: 'assignRole', target: '' }; const TEST_PLACEHOLDER = `{ "sub": "user-42", "email": "jane@acme.com", "department": "engineering", "groups": ["frontend", "design"] }`; interface Props { open: boolean; onClose: () => void; } export default function ClaimMappingRulesModal({ open, onClose }: Props) { const { toast } = useToast(); const { data: rules = [], isLoading } = useClaimMappingRules(); const createRule = useCreateClaimMappingRule(); const updateRule = useUpdateClaimMappingRule(); const deleteRule = useDeleteClaimMappingRule(); const testRules = useTestClaimMappingRules(); // Add form const [addForm, setAddForm] = useState({ ...EMPTY_FORM }); // Edit state const [editingId, setEditingId] = useState(null); const [editForm, setEditForm] = useState({ ...EMPTY_FORM }); // Delete confirm const [deleteTarget, setDeleteTarget] = useState(null); // Test panel const [testOpen, setTestOpen] = useState(false); const [testInput, setTestInput] = useState(''); const [testResult, setTestResult] = useState(null); const [testError, setTestError] = useState(''); if (!open) return null; const sorted = [...rules].sort((a, b) => a.priority - b.priority); const matchedRuleIds = new Set((testResult?.matchedRules ?? []).map((r) => r.ruleId)); // ── Handlers ────────────────────────────────────────────────────── function handleAdd() { const maxPriority = sorted.length > 0 ? Math.max(...sorted.map((r) => r.priority)) : -1; createRule.mutate( { claim: addForm.claim.trim(), matchType: addForm.matchType, matchValue: addForm.matchValue.trim(), action: addForm.action, target: addForm.target.trim(), priority: maxPriority + 1, }, { onSuccess: () => { setAddForm({ ...EMPTY_FORM }); toast({ title: 'Rule created', variant: 'success' }); }, onError: (e) => toast({ title: 'Failed to create rule', description: e.message, variant: 'error', duration: 86_400_000 }), }, ); } function startEdit(rule: ClaimMappingRule) { setEditingId(rule.id); setEditForm({ claim: rule.claim, matchType: rule.matchType, matchValue: rule.matchValue, action: rule.action, target: rule.target, }); } function saveEdit(rule: ClaimMappingRule) { updateRule.mutate( { id: rule.id, claim: editForm.claim.trim(), matchType: editForm.matchType, matchValue: editForm.matchValue.trim(), action: editForm.action, target: editForm.target.trim(), priority: rule.priority, }, { onSuccess: () => { setEditingId(null); toast({ title: 'Rule updated', variant: 'success' }); }, onError: (e) => toast({ title: 'Failed to update rule', description: e.message, variant: 'error', duration: 86_400_000 }), }, ); } function confirmDelete() { if (!deleteTarget) return; deleteRule.mutate(deleteTarget.id, { onSuccess: () => { setDeleteTarget(null); toast({ title: 'Rule deleted', variant: 'warning' }); }, onError: (e) => { setDeleteTarget(null); toast({ title: 'Failed to delete rule', description: e.message, variant: 'error', duration: 86_400_000 }); }, }); } function handleMove(rule: ClaimMappingRule, direction: 'up' | 'down') { const idx = sorted.findIndex((r) => r.id === rule.id); const swapIdx = direction === 'up' ? idx - 1 : idx + 1; if (swapIdx < 0 || swapIdx >= sorted.length) return; const other = sorted[swapIdx]; // Swap priorities updateRule.mutate( { id: rule.id, claim: rule.claim, matchType: rule.matchType, matchValue: rule.matchValue, action: rule.action, target: rule.target, priority: other.priority }, { onSuccess: () => { updateRule.mutate( { id: other.id, claim: other.claim, matchType: other.matchType, matchValue: other.matchValue, action: other.action, target: other.target, priority: rule.priority }, ); }, }, ); } function handleTest() { setTestError(''); setTestResult(null); let claims: Record; try { claims = JSON.parse(testInput); } catch { setTestError('Invalid JSON — paste a decoded JWT claims object'); return; } testRules.mutate(claims, { onSuccess: (result) => setTestResult(result), onError: (e) => setTestError(e.message), }); } // ── Render helpers ──────────────────────────────────────────────── function actionPillClass(action: string) { return action === 'assignRole' ? styles.pillAssignRole : styles.pillAddToGroup; } function actionLabel(action: string) { return action === 'assignRole' ? 'assign role' : 'add to group'; } const addDisabled = !addForm.claim.trim() || !addForm.matchValue.trim() || !addForm.target.trim(); // ── JSX ─────────────────────────────────────────────────────────── return (
e.stopPropagation()}> {/* Header */}
Claim Mapping Rules
Map JWT claims to Cameleer roles and groups. Evaluated on every OIDC login.
{/* Body */}
{isLoading ? (
Loading...
) : sorted.length === 0 ? (
No claim mapping rules configured.
Rules let you assign roles and groups based on JWT claims.
) : ( {sorted.map((rule, idx) => { const isEditing = editingId === rule.id; const isMatched = matchedRuleIds.has(rule.id); return ( {isEditing ? ( <> ) : ( <> )} ); })}
# Claim Match Value Action Target
{idx + 1} setEditForm({ ...editForm, claim: e.target.value })} className={styles.claimInput} /> setEditForm({ ...editForm, matchValue: e.target.value })} /> setEditForm({ ...editForm, target: e.target.value })} />
{rule.claim} {rule.matchType} {rule.matchValue} {actionLabel(rule.action)} {rule.target} {isMatched ? ( ) : (
)}
)} {/* Add row */}
setAddForm({ ...addForm, claim: e.target.value })} className={styles.claimInput} /> setAddForm({ ...addForm, matchValue: e.target.value })} className={styles.valueInput} /> setAddForm({ ...addForm, target: e.target.value })} className={styles.targetInput} />
{/* Test panel */}
setTestOpen(!testOpen)}> {testOpen ? '\u25B2' : '\u25BC'} Test Rules Paste a decoded JWT to preview which rules would fire
{testOpen && (