diff --git a/ui/src/pages/Admin/ClaimMappingRulesModal.module.css b/ui/src/pages/Admin/ClaimMappingRulesModal.module.css new file mode 100644 index 00000000..e75c40d5 --- /dev/null +++ b/ui/src/pages/Admin/ClaimMappingRulesModal.module.css @@ -0,0 +1,328 @@ +.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); +} + +.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); +} diff --git a/ui/src/pages/Admin/ClaimMappingRulesModal.tsx b/ui/src/pages/Admin/ClaimMappingRulesModal.tsx new file mode 100644 index 00000000..881db962 --- /dev/null +++ b/ui/src/pages/Admin/ClaimMappingRulesModal.tsx @@ -0,0 +1,353 @@ +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 ? ( + <> + + + + + + + + ) : ( + <> + + + + + + + + )} + + ); + })} + +
#ClaimMatchValueActionTarget
{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 && ( +
+
+