feat: add claim mapping rules editor modal component

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-14 16:50:00 +02:00
parent 344700e29e
commit e8a697d185
2 changed files with 681 additions and 0 deletions

View File

@@ -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);
}

View File

@@ -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<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>
);
}