Compare commits

10 Commits

Author SHA1 Message Date
hsiegeln
58e802e2d4 feat: close modal on successful apply, update design spec
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m24s
CI / docker (push) Successful in 1m10s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 39s
Modal auto-closes after Apply succeeds. Design spec updated to reflect
implemented behavior: local-edit-then-apply pattern, target select
dropdowns, amber pill for add-to-group, close-on-success.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:12:39 +02:00
hsiegeln
9959e30e1e fix: use --amber DS variable for add-to-group pill color
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:09:31 +02:00
hsiegeln
5edefb2180 refactor: switch claim mapping editor to local-edit-then-apply pattern
All edits (add, edit, delete, reorder) now modify local state only.
Cancel discards changes, Apply diffs local vs server and issues the
necessary create/update/delete API calls. Target selects now include
a placeholder option. Footer shows Cancel and Apply buttons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:07:36 +02:00
hsiegeln
0e87161426 feat: use select dropdowns for target role/group in claim mapping editor
Populate target field from existing roles (assign role) or groups
(add to group) instead of free-text input, preventing typos.
Switching action resets the target selection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:02:09 +02:00
hsiegeln
c02fd77c30 fix: use correct DS CSS variables for modal background
Replace non-existent --surface-1/--surface-2 with --bg-raised (modal)
and --bg-hover (subtle backgrounds) from the design system.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:59:50 +02:00
hsiegeln
a3ec0aaef3 fix: address code review findings for claim mapping rules editor
- Bump all font sizes from 11px/10px to 12px (project minimum)
- Fix handleMove race condition: use mutateAsync + Promise.all
- Clear stale test results after rule create/edit/delete/reorder
- Replace inline styles with CSS module classes in OidcConfigPage
- Remove dead .editRow CSS class
- Replace inline chevron with Lucide icon

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:58:06 +02:00
hsiegeln
3985bb8a43 feat: wire claim mapping rules modal into OIDC config page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:51:28 +02:00
hsiegeln
e8a697d185 feat: add claim mapping rules editor modal component
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:50:00 +02:00
hsiegeln
344700e29e feat: add React Query hooks for claim mapping rules API
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:46:40 +02:00
hsiegeln
f110169d54 feat: add POST /test endpoint for claim mapping rule evaluation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:42:54 +02:00
7 changed files with 1127 additions and 1 deletions

View File

@@ -2,6 +2,7 @@ package com.cameleer3.server.app.controller;
import com.cameleer3.server.core.rbac.ClaimMappingRepository;
import com.cameleer3.server.core.rbac.ClaimMappingRule;
import com.cameleer3.server.core.rbac.ClaimMappingService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
@@ -10,6 +11,7 @@ import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@@ -19,9 +21,12 @@ import java.util.UUID;
public class ClaimMappingAdminController {
private final ClaimMappingRepository repository;
private final ClaimMappingService claimMappingService;
public ClaimMappingAdminController(ClaimMappingRepository repository) {
public ClaimMappingAdminController(ClaimMappingRepository repository,
ClaimMappingService claimMappingService) {
this.repository = repository;
this.claimMappingService = claimMappingService;
}
@GetMapping
@@ -74,4 +79,38 @@ public class ClaimMappingAdminController {
repository.delete(id);
return ResponseEntity.noContent().build();
}
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());
}
}

View File

@@ -0,0 +1,166 @@
# Claim Mapping Rules Editor
## Problem
The server has a claim mapping rules engine (`ClaimMappingService`) that maps arbitrary JWT claims to Cameleer roles and groups during OIDC login. The backend API exists (`/api/v1/admin/claim-mappings` CRUD), but there is no UI for managing these rules. Admins must use curl or Swagger to create, edit, and delete rules.
## Solution
Add a rules editor modal to the existing OIDC config page (`/admin/oidc`), triggered from the Claim Mapping section. The modal contains a compact rules table with inline CRUD and a collapsible server-side test panel.
## Design
### Trigger
An "Edit Rules" button added to the bottom of the existing Claim Mapping section on the OIDC config page. Displays a badge with the active rule count (e.g. "3 active rules"). Visible in both read and edit mode of the OIDC form — the rules modal manages its own state independently of the OIDC form's edit/save lifecycle.
### Modal — Rules Table
A full-width modal dialog with the title "Claim Mapping Rules" and a subtitle explaining their purpose.
**Table columns:**
| Column | Content | Width |
|--------|---------|-------|
| `#` | Priority number (display only, derived from order) | 30px |
| Claim | Claim name in monospace code style | auto |
| Match | Match type pill: `equals`, `contains`, `regex` | auto |
| Value | Match value in monospace | auto |
| Action | Action pill: `assign role` (green/success), `add to group` (amber) | auto |
| Target | Role name or group name | auto |
| Actions | Reorder arrows + edit pencil + delete x | ~80px |
**Action icons per row:**
- Up/down arrow buttons for reordering. Up hidden on first row, down hidden on last row. Moving a rule swaps priorities locally (saved to server on Apply).
- Pencil icon switches the row to inline edit mode (same field layout as the add row).
- X icon deletes the rule locally after a `ConfirmDialog` confirmation (saved to server on Apply).
**Inline add form:**
A row at the bottom of the table with input fields matching the table columns:
- `Input` — claim name (text, placeholder "Claim name")
- `Select` — match type (equals / contains / regex)
- `Input` — match value (text, placeholder "Match value")
- `Select` — action (assign role / add to group)
- `Select` — target, populated from existing roles (when action is "assign role") or existing groups (when action is "add to group"). Includes a placeholder option ("Select role..." / "Select group..."). Switching action resets the target selection.
- `Button` — "+ Add" (disabled until claim, value, and target are filled)
New rules are assigned a priority one higher than the current maximum.
**Inline edit mode:**
When the pencil icon is clicked, the row's static cells become editable inputs (same controls as the add form, including the target Select dropdown). The action icons change to a checkmark (save) and x (cancel). Saving updates local state only — persisted to server on Apply.
**Empty state:**
When no rules exist, show a centered message: "No claim mapping rules configured. Rules let you assign roles and groups based on JWT claims." with the add form below.
### Modal — Test Panel
A collapsible section at the bottom of the modal, separated by a heavier border. Collapsed by default. Toggle via a clickable header row showing "Test Rules — Paste a decoded JWT to preview which rules would fire".
**Layout when expanded:**
Side-by-side split:
- **Left:** `textarea` for pasting decoded JWT claims as JSON. Placeholder shows a sample JSON object.
- **Right:** Results panel with:
- Each matched rule listed: rule number, claim name, match description, arrow, action and target
- "Effective" summary line: combined roles and groups
- If no rules matched: "No rules matched — would fall back to default roles: [roles]"
**"Test" button** below the textarea triggers `POST /api/v1/admin/claim-mappings/test`. The evaluation runs server-side using `ClaimMappingService.evaluate()` — the same code path as the production OIDC login flow in `OidcAuthController.applyClaimMappings()`.
**Visual feedback:** While test results are displayed, matched rows in the table above get a subtle green background tint and a checkmark in place of their action icons.
### Local Edit + Apply Pattern
All changes (add, edit, delete, reorder) modify local state only. No API calls are made until the admin clicks Apply.
- **On modal open:** server rules are cloned into local state.
- **Cancel:** discards all local changes and closes the modal.
- **Apply:** diffs local state against server state — creates new rules (temp IDs), updates changed rules, deletes removed rules. On success, shows a toast and closes the modal. On failure, shows an error toast and keeps the modal open.
### Reordering
Up/down arrow buttons on each row. Clicking an arrow swaps the priority values of two adjacent rules in local state.
Priority is a server-side integer. The UI displays it as the row number (`#` column) — admins never edit the number directly.
### New Backend Endpoint
```
POST /api/v1/admin/claim-mappings/test
Content-Type: application/json
Authorization: Bearer <admin-token>
{
"sub": "user-42",
"email": "jane@acme.com",
"department": "engineering",
"groups": ["frontend", "design"]
}
Response 200:
{
"matchedRules": [
{
"ruleId": "uuid",
"priority": 1,
"claim": "email",
"matchType": "regex",
"matchValue": ".*@acme\\.com$",
"action": "assignRole",
"target": "OPERATOR"
}
],
"effectiveRoles": ["OPERATOR"],
"effectiveGroups": [],
"fallback": false
}
```
The endpoint:
- Requires ADMIN role (same as other claim mapping endpoints)
- Loads all rules from the database
- Calls `ClaimMappingService.evaluate(rules, claims)` — production code path
- Maps `MappingResult` list to the response DTO
- Returns `fallback: true` when no rules matched (UI shows default roles message)
- Read-only — no side effects, no user creation, no role assignment
### Error Handling
- **Invalid JSON in test textarea:** Show inline validation error, disable Test button
- **API errors on CRUD:** Toast notifications (consistent with rest of admin UI)
- **ConfirmDialog on delete:** Type-to-confirm not needed for rules (they're low-risk, easily recreated). Simple "Delete this rule?" confirm/cancel.
## Files to Create/Modify
### Backend
- `ClaimMappingAdminController.java` — add `POST /test` endpoint
- New DTO for test response (inline record or separate class)
### Frontend
- New component: `ClaimMappingRulesModal.tsx` — the modal with table, inline CRUD, and test panel
- New CSS module: `ClaimMappingRulesModal.module.css`
- New API hooks: `useClaimMappingRules()`, `useCreateRule()`, `useUpdateRule()`, `useDeleteRule()`, `useTestRules()` — React Query hooks for the claim mapping API
- Modify: `OidcConfigPage.tsx` — add "Edit Rules" button in the Claim Mapping section, render the modal
### Not in Scope
- Drag-and-drop reordering (up/down arrows are sufficient for 1-10 rules)
- Groups Claim field (rules can already match any claim including group claims)
- Bulk import/export of rules
- Rule templates or presets
## Visual Reference
Mockups available in `.superpowers/brainstorm/172364-1776174996/content/`:
- `claim-rules-modal.html` — initial layout (trigger button + table)
- `claim-rules-modal-v2.html` — full modal with test panel expanded and match highlighting

View File

@@ -0,0 +1,101 @@
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),
}),
});
}

View File

@@ -0,0 +1,337 @@
.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(--bg-raised);
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: 12px;
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: 12px;
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: 12px;
width: 30px;
}
.claimCode {
background: var(--bg-hover);
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: 12px;
white-space: nowrap;
}
.pillMatch {
background: var(--bg-hover);
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(--amber) 15%, transparent);
color: var(--amber);
}
.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(--bg-hover);
}
.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;
}
.emptyState {
padding: 32px 20px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
.footer {
padding: 12px 20px;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.footerHint {
font-size: 12px;
color: var(--text-muted);
flex: 1;
}
.footerActions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.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(--bg-hover);
}
.testToggleLabel {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary, var(--text-muted));
}
.testToggleHint {
font-size: 12px;
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(--bg-hover);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px;
color: var(--text);
font-family: var(--font-mono);
font-size: 12px;
resize: vertical;
}
.testResults {
flex: 1;
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
min-height: 140px;
box-sizing: border-box;
overflow-y: auto;
}
.testResultsTitle {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 10px;
}
.testMatch {
margin-bottom: 10px;
}
.testMatchLabel {
font-size: 12px;
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: 12px;
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);
}

View File

@@ -0,0 +1,440 @@
import { useState, useEffect, useCallback } 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 { useRoles, useGroups } from '../../api/queries/admin/rbac';
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"]
}`;
// Local rule type — server rules have string IDs, new rules get temp- prefixed IDs
interface LocalRule {
id: string;
claim: string;
matchType: string;
matchValue: string;
action: string;
target: string;
priority: number;
}
function toLocal(rule: ClaimMappingRule): LocalRule {
return { id: rule.id, claim: rule.claim, matchType: rule.matchType, matchValue: rule.matchValue, action: rule.action, target: rule.target, priority: rule.priority };
}
interface Props {
open: boolean;
onClose: () => void;
}
export default function ClaimMappingRulesModal({ open, onClose }: Props) {
const { toast } = useToast();
const { data: serverRules = [], isLoading } = useClaimMappingRules();
const createRule = useCreateClaimMappingRule();
const updateRule = useUpdateClaimMappingRule();
const deleteRule = useDeleteClaimMappingRule();
const testRules = useTestClaimMappingRules();
// Local working copy — only saved to server on Apply
const [localRules, setLocalRules] = useState<LocalRule[]>([]);
const [dirty, setDirty] = useState(false);
// 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<LocalRule | null>(null);
// Apply state
const [applying, setApplying] = useState(false);
// Test panel
const [testOpen, setTestOpen] = useState(false);
const [testInput, setTestInput] = useState('');
const [testResult, setTestResult] = useState<TestResponse | null>(null);
const [testError, setTestError] = useState('');
// Roles and groups for target dropdown
const { data: roles = [] } = useRoles(open);
const { data: groups = [] } = useGroups(open);
const roleOptions = roles.map((r) => ({ value: r.name, label: r.name }));
const groupOptions = groups.map((g) => ({ value: g.name, label: g.name }));
function targetOptions(action: string) {
const opts = action === 'assignRole' ? roleOptions : groupOptions;
return [{ value: '', label: action === 'assignRole' ? 'Select role...' : 'Select group...' }, ...opts];
}
// Initialize local rules from server when modal opens or server data arrives
useEffect(() => {
if (open && !dirty) {
setLocalRules(serverRules.map(toLocal));
}
}, [open, serverRules, dirty]);
// Reset state when modal closes
const handleClose = useCallback(() => {
setDirty(false);
setEditingId(null);
setEditForm({ ...EMPTY_FORM });
setAddForm({ ...EMPTY_FORM });
setDeleteTarget(null);
setTestResult(null);
setTestError('');
onClose();
}, [onClose]);
if (!open) return null;
const sorted = [...localRules].sort((a, b) => a.priority - b.priority);
const matchedRuleIds = new Set((testResult?.matchedRules ?? []).map((r) => r.ruleId));
// ── Local Handlers (no API calls) ─────────────────────────────────
function handleAdd() {
const maxPriority = sorted.length > 0 ? Math.max(...sorted.map((r) => r.priority)) : -1;
const newRule: LocalRule = {
id: `temp-${crypto.randomUUID()}`,
claim: addForm.claim.trim(),
matchType: addForm.matchType,
matchValue: addForm.matchValue.trim(),
action: addForm.action,
target: addForm.target,
priority: maxPriority + 1,
};
setLocalRules((prev) => [...prev, newRule]);
setAddForm({ ...EMPTY_FORM });
setDirty(true);
setTestResult(null);
}
function startEdit(rule: LocalRule) {
setEditingId(rule.id);
setEditForm({
claim: rule.claim,
matchType: rule.matchType,
matchValue: rule.matchValue,
action: rule.action,
target: rule.target,
});
}
function saveEdit(rule: LocalRule) {
setLocalRules((prev) =>
prev.map((r) =>
r.id === rule.id
? { ...r, claim: editForm.claim.trim(), matchType: editForm.matchType, matchValue: editForm.matchValue.trim(), action: editForm.action, target: editForm.target }
: r,
),
);
setEditingId(null);
setDirty(true);
setTestResult(null);
}
function confirmDelete() {
if (!deleteTarget) return;
setLocalRules((prev) => prev.filter((r) => r.id !== deleteTarget.id));
setDeleteTarget(null);
setDirty(true);
setTestResult(null);
}
function handleMove(rule: LocalRule, 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];
setLocalRules((prev) =>
prev.map((r) => {
if (r.id === rule.id) return { ...r, priority: other.priority };
if (r.id === other.id) return { ...r, priority: rule.priority };
return r;
}),
);
setDirty(true);
setTestResult(null);
}
// ── Apply (save all changes to server) ────────────────────────────
async function handleApply() {
setApplying(true);
try {
const serverIds = new Set(serverRules.map((r) => r.id));
const localIds = new Set(localRules.map((r) => r.id));
// Delete: rules on server but not in local
const toDelete = serverRules.filter((r) => !localIds.has(r.id));
for (const r of toDelete) {
await deleteRule.mutateAsync(r.id);
}
// Create: rules in local with temp- prefix
for (const r of localRules) {
if (r.id.startsWith('temp-')) {
await createRule.mutateAsync({
claim: r.claim, matchType: r.matchType, matchValue: r.matchValue,
action: r.action, target: r.target, priority: r.priority,
});
}
}
// Update: rules that exist on both sides but have changed
for (const local of localRules) {
if (local.id.startsWith('temp-')) continue;
if (!serverIds.has(local.id)) continue;
const server = serverRules.find((r) => r.id === local.id);
if (!server) continue;
const changed = server.claim !== local.claim || server.matchType !== local.matchType
|| server.matchValue !== local.matchValue || server.action !== local.action
|| server.target !== local.target || server.priority !== local.priority;
if (changed) {
await updateRule.mutateAsync({
id: local.id, claim: local.claim, matchType: local.matchType,
matchValue: local.matchValue, action: local.action, target: local.target,
priority: local.priority,
});
}
}
toast({ title: 'Rules saved', variant: 'success' });
handleClose();
} catch (e: any) {
toast({ title: 'Failed to save rules', description: e.message, variant: 'error', duration: 86_400_000 });
} finally {
setApplying(false);
}
}
// ── Test ──────────────────────────────────────────────────────────
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;
// ── JSX ───────────────────────────────────────────────────────────
return (
<div className={styles.overlay} onClick={handleClose}>
<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={handleClose}><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, target: '' })} /></td>
<td><Select options={targetOptions(editForm.action)} 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, target: '' })} className={styles.actionSelect} />
<Select options={targetOptions(addForm.action)} value={addForm.target} onChange={(e) => setAddForm({ ...addForm, target: e.target.value })} className={styles.targetInput} />
<Button size="sm" variant="primary" onClick={handleAdd} disabled={addDisabled}>+ Add</Button>
</div>
{/* Test panel */}
<div className={styles.testToggle} onClick={() => setTestOpen(!testOpen)}>
{testOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
<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 with Cancel / Apply */}
<div className={styles.footer}>
<span className={styles.footerHint}>
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.
</span>
<div className={styles.footerActions}>
<Button size="sm" variant="ghost" onClick={handleClose}>Cancel</Button>
<Button size="sm" variant="primary" onClick={handleApply} disabled={!dirty} loading={applying}>Apply</Button>
</div>
</div>
</div>
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={confirmDelete}
message={`Delete this rule? (${deleteTarget?.claim} ${deleteTarget?.matchType} "${deleteTarget?.matchValue}")`}
confirmText="delete"
/>
</div>
);
}

View File

@@ -44,3 +44,26 @@
.roleInput {
width: 200px;
}
.advancedRulesRow {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--border);
}
.advancedRulesInner {
display: flex;
justify-content: space-between;
align-items: center;
}
.advancedRulesLabel {
font-size: 13px;
color: var(--text-secondary, var(--text-muted));
}
.advancedRulesCount {
font-size: 12px;
color: var(--text-muted);
margin-left: 8px;
}

View File

@@ -6,6 +6,8 @@ import {
import { useToast } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader';
import { adminFetch } from '../../api/queries/admin/admin-api';
import ClaimMappingRulesModal from './ClaimMappingRulesModal';
import { useClaimMappingRules } from '../../api/queries/admin/claim-mappings';
import styles from './OidcConfigPage.module.css';
import sectionStyles from '../../styles/section-card.module.css';
@@ -50,6 +52,8 @@ export default function OidcConfigPage() {
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const { toast } = useToast();
const [rulesModalOpen, setRulesModalOpen] = useState(false);
const { data: claimRules = [] } = useClaimMappingRules();
useEffect(() => {
adminFetch<Partial<OidcFormData> & { configured?: boolean }>('/oidc')
@@ -271,6 +275,22 @@ export default function OidcConfigPage() {
disabled={!editing}
/>
</FormField>
<div className={styles.advancedRulesRow}>
<div className={styles.advancedRulesInner}>
<div>
<span className={styles.advancedRulesLabel}>Advanced Rules</span>
{claimRules.length > 0 && (
<span className={styles.advancedRulesCount}>
{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)} />
</section>
<section className={sectionStyles.section}>