Files
cameleer-server/docs/superpowers/plans/2026-04-14-claim-mapping-rules-editor.md
hsiegeln cb3ebfea7c
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 18s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
chore: rename cameleer3 to cameleer
Rename Java packages from com.cameleer3 to com.cameleer, module
directories from cameleer3-* to cameleer-*, and all references
throughout workflows, Dockerfiles, docs, migrations, and pom.xml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:28:42 +02:00

31 KiB

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):

record MatchedRuleResponse(UUID ruleId, int priority, String claim, String matchType,
                           String matchValue, String action, String target) {}

record TestResponse(List<MatchedRuleResponse> matchedRules, List<String> effectiveRoles,
                    List<String> effectiveGroups, boolean fallback) {}

@PostMapping("/test")
@Operation(summary = "Test claim mapping rules against a set of claims")
public TestResponse test(@RequestBody Map<String, Object> claims) {
    List<ClaimMappingRule> rules = repository.findAll();
    List<ClaimMappingService.MappingResult> results = claimMappingService.evaluate(rules, claims);

    List<MatchedRuleResponse> 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<String> effectiveRoles = results.stream()
            .filter(r -> "assignRole".equals(r.rule().action()))
            .map(r -> r.rule().target())
            .distinct()
            .toList();

    List<String> 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:

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
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:

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<ClaimMappingRule[]>('/claim-mappings'),
  });
}

// ── Mutation Hooks ─────────────────────────────────────────────────────

export function useCreateClaimMappingRule() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (req: CreateRuleRequest) =>
      adminFetch<ClaimMappingRule>('/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<ClaimMappingRule>(`/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<void>(`/claim-mappings/${id}`, { method: 'DELETE' }),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['admin', 'claim-mappings'] });
    },
  });
}

export function useTestClaimMappingRules() {
  return useMutation({
    mutationFn: (claims: Record<string, unknown>) =>
      adminFetch<TestResponse>('/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
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:

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

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<string | null>(null);
  const [editForm, setEditForm] = useState({ ...EMPTY_FORM });

  // Delete confirm
  const [deleteTarget, setDeleteTarget] = useState<ClaimMappingRule | null>(null);

  // Test panel
  const [testOpen, setTestOpen] = useState(false);
  const [testInput, setTestInput] = useState('');
  const [testResult, setTestResult] = useState<TestResponse | null>(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<string, unknown>;
    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 (
    <div className={styles.overlay} onClick={onClose}>
      <div className={styles.modal} onClick={(e) => e.stopPropagation()}>
        {/* Header */}
        <div className={styles.header}>
          <div className={styles.headerInfo}>
            <div className={styles.title}>Claim Mapping Rules</div>
            <div className={styles.subtitle}>Map JWT claims to Cameleer roles and groups. Evaluated on every OIDC login.</div>
          </div>
          <button className={styles.iconBtn} onClick={onClose}><X size={16} /></button>
        </div>

        {/* Body */}
        <div className={styles.body}>
          {isLoading ? (
            <div className={styles.emptyState}>Loading...</div>
          ) : sorted.length === 0 ? (
            <div className={styles.emptyState}>
              No claim mapping rules configured.<br />
              Rules let you assign roles and groups based on JWT claims.
            </div>
          ) : (
            <table className={styles.table}>
              <thead>
                <tr>
                  <th>#</th>
                  <th>Claim</th>
                  <th>Match</th>
                  <th>Value</th>
                  <th>Action</th>
                  <th>Target</th>
                  <th></th>
                </tr>
              </thead>
              <tbody>
                {sorted.map((rule, idx) => {
                  const isEditing = editingId === rule.id;
                  const isMatched = matchedRuleIds.has(rule.id);
                  return (
                    <tr key={rule.id} className={isMatched ? styles.matchedRow : undefined}>
                      <td className={styles.priorityCell}>{idx + 1}</td>
                      {isEditing ? (
                        <>
                          <td><Input value={editForm.claim} onChange={(e) => setEditForm({ ...editForm, claim: e.target.value })} className={styles.claimInput} /></td>
                          <td><Select options={MATCH_OPTIONS} value={editForm.matchType} onChange={(e) => setEditForm({ ...editForm, matchType: e.target.value })} /></td>
                          <td><Input value={editForm.matchValue} onChange={(e) => setEditForm({ ...editForm, matchValue: e.target.value })} /></td>
                          <td><Select options={ACTION_OPTIONS} value={editForm.action} onChange={(e) => setEditForm({ ...editForm, action: e.target.value })} /></td>
                          <td><Input value={editForm.target} onChange={(e) => setEditForm({ ...editForm, target: e.target.value })} /></td>
                          <td>
                            <div className={styles.actions}>
                              <button className={styles.iconBtn} onClick={() => saveEdit(rule)} title="Save"><Check size={14} /></button>
                              <button className={styles.iconBtn} onClick={() => setEditingId(null)} title="Cancel"><X size={14} /></button>
                            </div>
                          </td>
                        </>
                      ) : (
                        <>
                          <td><code className={styles.claimCode}>{rule.claim}</code></td>
                          <td><span className={`${styles.pill} ${styles.pillMatch}`}>{rule.matchType}</span></td>
                          <td><span className={styles.matchValue}>{rule.matchValue}</span></td>
                          <td><span className={`${styles.pill} ${actionPillClass(rule.action)}`}>{actionLabel(rule.action)}</span></td>
                          <td className={styles.targetCell}>{rule.target}</td>
                          <td>
                            {isMatched ? (
                              <span className={styles.matchCheck}><Check size={14} /></span>
                            ) : (
                              <div className={styles.actions}>
                                <button className={styles.iconBtn} onClick={() => handleMove(rule, 'up')} disabled={idx === 0} title="Move up"><ChevronUp size={14} /></button>
                                <button className={styles.iconBtn} onClick={() => handleMove(rule, 'down')} disabled={idx === sorted.length - 1} title="Move down"><ChevronDown size={14} /></button>
                                <button className={styles.iconBtn} onClick={() => startEdit(rule)} title="Edit"><Pencil size={14} /></button>
                                <button className={styles.iconBtn} onClick={() => setDeleteTarget(rule)} title="Delete"><X size={14} /></button>
                              </div>
                            )}
                          </td>
                        </>
                      )}
                    </tr>
                  );
                })}
              </tbody>
            </table>
          )}

          {/* Add row */}
          <div className={styles.addRow}>
            <Input placeholder="Claim name" value={addForm.claim} onChange={(e) => setAddForm({ ...addForm, claim: e.target.value })} className={styles.claimInput} />
            <Select options={MATCH_OPTIONS} value={addForm.matchType} onChange={(e) => setAddForm({ ...addForm, matchType: e.target.value })} className={styles.matchSelect} />
            <Input placeholder="Match value" value={addForm.matchValue} onChange={(e) => setAddForm({ ...addForm, matchValue: e.target.value })} className={styles.valueInput} />
            <Select options={ACTION_OPTIONS} value={addForm.action} onChange={(e) => setAddForm({ ...addForm, action: e.target.value })} className={styles.actionSelect} />
            <Input
              placeholder={addForm.action === 'assignRole' ? 'Role name' : 'Group name'}
              value={addForm.target}
              onChange={(e) => setAddForm({ ...addForm, target: e.target.value })}
              className={styles.targetInput}
            />
            <Button size="sm" variant="primary" onClick={handleAdd} disabled={addDisabled} loading={createRule.isPending}>+ Add</Button>
          </div>

          {/* Test panel */}
          <div className={styles.testToggle} onClick={() => setTestOpen(!testOpen)}>
            <span style={{ fontSize: 10, color: 'var(--text-muted)' }}>{testOpen ? '\u25B2' : '\u25BC'}</span>
            <span className={styles.testToggleLabel}>Test Rules</span>
            <span className={styles.testToggleHint}>Paste a decoded JWT to preview which rules would fire</span>
          </div>

          {testOpen && (
            <div className={styles.testPanel}>
              <div className={styles.testTextarea}>
                <textarea
                  placeholder={TEST_PLACEHOLDER}
                  value={testInput}
                  onChange={(e) => setTestInput(e.target.value)}
                />
                <Button size="sm" variant="secondary" onClick={handleTest} loading={testRules.isPending}>
                  Test
                </Button>
              </div>
              <div className={styles.testResults}>
                <div className={styles.testResultsTitle}>Result</div>
                {testError && <div className={styles.testError}>{testError}</div>}
                {!testResult && !testError && <div className={styles.testEmpty}>Paste claims and click Test</div>}
                {testResult && testResult.fallback && (
                  <div className={styles.testFallback}>No rules matched  would fall back to default roles</div>
                )}
                {testResult && !testResult.fallback && (
                  <>
                    {testResult.matchedRules.map((m) => (
                      <div key={m.ruleId} className={styles.testMatch}>
                        <div className={styles.testMatchLabel}>&#10003; Rule #{sorted.findIndex((r) => r.id === m.ruleId) + 1} matched</div>
                        <div className={styles.testMatchDetail}>
                          <code className={styles.claimCode}>{m.claim}</code>{' '}
                          {m.matchType} <code>{m.matchValue}</code>{' '}
                          &rarr; {actionLabel(m.action)} <strong>{m.target}</strong>
                        </div>
                      </div>
                    ))}
                    <div className={styles.testEffective}>
                      {testResult.effectiveRoles.length > 0 && (
                        <div>Roles: <span className={styles.testEffectiveValues}>{testResult.effectiveRoles.join(', ')}</span></div>
                      )}
                      {testResult.effectiveGroups.length > 0 && (
                        <div>Groups: <span className={styles.testEffectiveValues}>{testResult.effectiveGroups.join(', ')}</span></div>
                      )}
                    </div>
                  </>
                )}
              </div>
            </div>
          )}
        </div>

        {/* Footer */}
        <div className={styles.footer}>
          Rules are evaluated top-to-bottom by priority on every OIDC login. If no rules match, the server falls back to roles from the token, then to default roles.
        </div>
      </div>

      <ConfirmDialog
        open={deleteTarget !== null}
        onClose={() => setDeleteTarget(null)}
        onConfirm={confirmDelete}
        message={`Delete this rule? (${deleteTarget?.claim} ${deleteTarget?.matchType} "${deleteTarget?.matchValue}")`}
        confirmText="delete"
        loading={deleteRule.isPending}
      />
    </div>
  );
}
  • Step 3: Verify TypeScript compiles

Run: cd ui && npx tsc --noEmit Expected: no errors

  • Step 4: Commit
git add ui/src/pages/Admin/ClaimMappingRulesModal.tsx ui/src/pages/Admin/ClaimMappingRulesModal.module.css
git commit -m "feat: add claim mapping rules editor modal component"

Task 4: Frontend — Wire modal into OIDC config page

Files:

  • Modify: ui/src/pages/Admin/OidcConfigPage.tsx

  • Step 1: Add imports and state

At the top of OidcConfigPage.tsx, add after the existing imports (line 8):

import ClaimMappingRulesModal from './ClaimMappingRulesModal';
import { useClaimMappingRules } from '../../api/queries/admin/claim-mappings';

Inside the OidcConfigPage component function, after the existing state declarations (after line 52), add:

const [rulesModalOpen, setRulesModalOpen] = useState(false);
const { data: claimRules = [] } = useClaimMappingRules();
  • Step 2: Add the trigger button to the Claim Mapping section

After the Display Name Claim FormField closing tag (after line 273), add the trigger button and modal before the </section> closing tag:

        <div style={{ marginTop: 18, paddingTop: 14, borderTop: '1px solid var(--border)' }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
            <div>
              <span style={{ fontSize: 13, color: 'var(--text-secondary, var(--text-muted))' }}>Advanced Rules</span>
              {claimRules.length > 0 && (
                <span style={{ fontSize: 11, color: 'var(--text-muted)', marginLeft: 8 }}>
                  {claimRules.length} active rule{claimRules.length !== 1 ? 's' : ''}
                </span>
              )}
            </div>
            <Button size="sm" variant="secondary" onClick={() => setRulesModalOpen(true)}>
              Edit Rules
            </Button>
          </div>
        </div>
        <ClaimMappingRulesModal open={rulesModalOpen} onClose={() => setRulesModalOpen(false)} />
  • Step 3: Verify TypeScript compiles

Run: cd ui && npx tsc --noEmit Expected: no errors

  • Step 4: Commit
git add ui/src/pages/Admin/OidcConfigPage.tsx
git commit -m "feat: wire claim mapping rules modal into OIDC config page"

Task 5: Compile, verify, and push

Files: (none — verification only)

  • Step 1: Full backend compilation

Run: mvn clean compile test-compile Expected: BUILD SUCCESS

  • Step 2: Frontend type check

Run: cd ui && npx tsc --noEmit Expected: no errors

  • Step 3: Push
git push