Compare commits
10 Commits
90ae1d6a14
...
58e802e2d4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58e802e2d4 | ||
|
|
9959e30e1e | ||
|
|
5edefb2180 | ||
|
|
0e87161426 | ||
|
|
c02fd77c30 | ||
|
|
a3ec0aaef3 | ||
|
|
3985bb8a43 | ||
|
|
e8a697d185 | ||
|
|
344700e29e | ||
|
|
f110169d54 |
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
101
ui/src/api/queries/admin/claim-mappings.ts
Normal file
101
ui/src/api/queries/admin/claim-mappings.ts
Normal 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),
|
||||
}),
|
||||
});
|
||||
}
|
||||
337
ui/src/pages/Admin/ClaimMappingRulesModal.module.css
Normal file
337
ui/src/pages/Admin/ClaimMappingRulesModal.module.css
Normal 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);
|
||||
}
|
||||
440
ui/src/pages/Admin/ClaimMappingRulesModal.tsx
Normal file
440
ui/src/pages/Admin/ClaimMappingRulesModal.tsx
Normal 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}>✓ 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 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user