1008 lines
31 KiB
Markdown
1008 lines
31 KiB
Markdown
|
|
# 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):
|
||
|
|
|
||
|
|
```java
|
||
|
|
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:
|
||
|
|
|
||
|
|
```java
|
||
|
|
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**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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`:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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`:
|
||
|
|
|
||
|
|
```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`:
|
||
|
|
|
||
|
|
```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}>✓ 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>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Verify TypeScript compiles**
|
||
|
|
|
||
|
|
Run: `cd ui && npx tsc --noEmit`
|
||
|
|
Expected: no errors
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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):
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
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:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
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:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
<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**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git push
|
||
|
|
```
|