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:
328
ui/src/pages/Admin/ClaimMappingRulesModal.module.css
Normal file
328
ui/src/pages/Admin/ClaimMappingRulesModal.module.css
Normal 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);
|
||||
}
|
||||
353
ui/src/pages/Admin/ClaimMappingRulesModal.tsx
Normal file
353
ui/src/pages/Admin/ClaimMappingRulesModal.tsx
Normal 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}>✓ 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>{' '}
|
||||
→ {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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user