Add OIDC admin config page with auto-signup toggle
Backend: add autoSignup field to OidcConfig, ClickHouse schema, repository, and admin controller. Gate OIDC login when auto-signup is disabled and user is not pre-created (returns 403). Frontend: add OIDC admin page with full CRUD (save/test/delete), role-gated Admin nav link parsed from JWT, and matching design system styles. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -80,7 +80,8 @@ public class OidcConfigAdminController {
|
||||
request.clientId() != null ? request.clientId() : "",
|
||||
clientSecret,
|
||||
request.rolesClaim() != null ? request.rolesClaim() : "realm_access.roles",
|
||||
request.defaultRoles() != null ? request.defaultRoles() : List.of("VIEWER")
|
||||
request.defaultRoles() != null ? request.defaultRoles() : List.of("VIEWER"),
|
||||
request.autoSignup()
|
||||
);
|
||||
|
||||
configRepository.save(config);
|
||||
@@ -134,6 +135,7 @@ public class OidcConfigAdminController {
|
||||
map.put("clientSecretSet", !config.clientSecret().isBlank());
|
||||
map.put("rolesClaim", config.rolesClaim());
|
||||
map.put("defaultRoles", config.defaultRoles());
|
||||
map.put("autoSignup", config.autoSignup());
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -143,6 +145,7 @@ public class OidcConfigAdminController {
|
||||
String clientId,
|
||||
String clientSecret,
|
||||
String rolesClaim,
|
||||
List<String> defaultRoles
|
||||
List<String> defaultRoles,
|
||||
boolean autoSignup
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -90,8 +90,15 @@ public class OidcAuthController {
|
||||
String issuerHost = URI.create(config.get().issuerUri()).getHost();
|
||||
String provider = "oidc:" + issuerHost;
|
||||
|
||||
// Check auto-signup gate: if disabled, user must already exist
|
||||
Optional<UserInfo> existingUser = userRepository.findById(userId);
|
||||
if (!config.get().autoSignup() && existingUser.isEmpty()) {
|
||||
return ResponseEntity.status(403)
|
||||
.body(Map.of("message", "Account not provisioned. Contact your administrator."));
|
||||
}
|
||||
|
||||
// Resolve roles: DB override > OIDC claim > default
|
||||
List<String> roles = resolveRoles(userId, oidcUser.roles(), config.get());
|
||||
List<String> roles = resolveRoles(existingUser, oidcUser.roles(), config.get());
|
||||
|
||||
userRepository.upsert(new UserInfo(
|
||||
userId, provider, oidcUser.email(), oidcUser.name(), roles, Instant.now()));
|
||||
@@ -110,8 +117,7 @@ public class OidcAuthController {
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> resolveRoles(String userId, List<String> oidcRoles, OidcConfig config) {
|
||||
Optional<UserInfo> existing = userRepository.findById(userId);
|
||||
private List<String> resolveRoles(Optional<UserInfo> existing, List<String> oidcRoles, OidcConfig config) {
|
||||
if (existing.isPresent() && !existing.get().roles().isEmpty()) {
|
||||
return existing.get().roles();
|
||||
}
|
||||
|
||||
@@ -73,7 +73,8 @@ public class SecurityBeanConfig {
|
||||
envOidc.getClientId(),
|
||||
envOidc.getClientSecret() != null ? envOidc.getClientSecret() : "",
|
||||
envOidc.getRolesClaim(),
|
||||
envOidc.getDefaultRoles()
|
||||
envOidc.getDefaultRoles(),
|
||||
true
|
||||
);
|
||||
configRepository.save(config);
|
||||
log.info("OIDC config seeded from environment variables: issuer={}", envOidc.getIssuerUri());
|
||||
|
||||
@@ -27,7 +27,7 @@ public class ClickHouseOidcConfigRepository implements OidcConfigRepository {
|
||||
@Override
|
||||
public Optional<OidcConfig> find() {
|
||||
List<OidcConfig> results = jdbc.query(
|
||||
"SELECT enabled, issuer_uri, client_id, client_secret, roles_claim, default_roles "
|
||||
"SELECT enabled, issuer_uri, client_id, client_secret, roles_claim, default_roles, auto_signup "
|
||||
+ "FROM oidc_config FINAL WHERE config_id = 'default'",
|
||||
this::mapRow
|
||||
);
|
||||
@@ -37,14 +37,15 @@ public class ClickHouseOidcConfigRepository implements OidcConfigRepository {
|
||||
@Override
|
||||
public void save(OidcConfig config) {
|
||||
jdbc.update(
|
||||
"INSERT INTO oidc_config (config_id, enabled, issuer_uri, client_id, client_secret, roles_claim, default_roles, updated_at) "
|
||||
+ "VALUES ('default', ?, ?, ?, ?, ?, ?, now64(3, 'UTC'))",
|
||||
"INSERT INTO oidc_config (config_id, enabled, issuer_uri, client_id, client_secret, roles_claim, default_roles, auto_signup, updated_at) "
|
||||
+ "VALUES ('default', ?, ?, ?, ?, ?, ?, ?, now64(3, 'UTC'))",
|
||||
config.enabled(),
|
||||
config.issuerUri(),
|
||||
config.clientId(),
|
||||
config.clientSecret(),
|
||||
config.rolesClaim(),
|
||||
config.defaultRoles().toArray(new String[0])
|
||||
config.defaultRoles().toArray(new String[0]),
|
||||
config.autoSignup()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,7 +62,8 @@ public class ClickHouseOidcConfigRepository implements OidcConfigRepository {
|
||||
rs.getString("client_id"),
|
||||
rs.getString("client_secret"),
|
||||
rs.getString("roles_claim"),
|
||||
Arrays.asList(rolesArray)
|
||||
Arrays.asList(rolesArray),
|
||||
rs.getBoolean("auto_signup")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import java.util.List;
|
||||
* @param clientSecret OAuth2 client secret (stored server-side only)
|
||||
* @param rolesClaim dot-separated path to roles in the id_token (e.g. {@code realm_access.roles})
|
||||
* @param defaultRoles fallback roles for new users with no OIDC role claim
|
||||
* @param autoSignup whether new OIDC users are automatically created on first login
|
||||
*/
|
||||
public record OidcConfig(
|
||||
boolean enabled,
|
||||
@@ -18,9 +19,10 @@ public record OidcConfig(
|
||||
String clientId,
|
||||
String clientSecret,
|
||||
String rolesClaim,
|
||||
List<String> defaultRoles
|
||||
List<String> defaultRoles,
|
||||
boolean autoSignup
|
||||
) {
|
||||
public static OidcConfig disabled() {
|
||||
return new OidcConfig(false, "", "", "", "realm_access.roles", List.of("VIEWER"));
|
||||
return new OidcConfig(false, "", "", "", "realm_access.roles", List.of("VIEWER"), true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS oidc_config (
|
||||
client_secret String DEFAULT '',
|
||||
roles_claim String DEFAULT 'realm_access.roles',
|
||||
default_roles Array(LowCardinality(String)),
|
||||
auto_signup Bool DEFAULT true,
|
||||
updated_at DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC')
|
||||
) ENGINE = ReplacingMergeTree(updated_at)
|
||||
ORDER BY (config_id);
|
||||
|
||||
80
ui/src/api/queries/oidc-admin.ts
Normal file
80
ui/src/api/queries/oidc-admin.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { config } from '../../config';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
|
||||
export interface OidcConfigResponse {
|
||||
configured: boolean;
|
||||
enabled?: boolean;
|
||||
issuerUri?: string;
|
||||
clientId?: string;
|
||||
clientSecretSet?: boolean;
|
||||
rolesClaim?: string;
|
||||
defaultRoles?: string[];
|
||||
autoSignup?: boolean;
|
||||
}
|
||||
|
||||
export interface OidcConfigRequest {
|
||||
enabled: boolean;
|
||||
issuerUri: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
rolesClaim: string;
|
||||
defaultRoles: string[];
|
||||
autoSignup: boolean;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
status: string;
|
||||
authorizationEndpoint: string;
|
||||
}
|
||||
|
||||
async function adminFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
const res = await fetch(`${config.apiBaseUrl}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
if (res.status === 204) return undefined as T;
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.message || `Request failed (${res.status})`);
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export function useOidcConfig() {
|
||||
return useQuery<OidcConfigResponse>({
|
||||
queryKey: ['admin', 'oidc'],
|
||||
queryFn: () => adminFetch('/admin/oidc'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveOidcConfig() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: OidcConfigRequest) =>
|
||||
adminFetch<OidcConfigResponse>('/admin/oidc', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'oidc'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestOidcConnection() {
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
adminFetch<TestResult>('/admin/oidc/test', { method: 'POST' }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteOidcConfig() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
adminFetch<void>('/admin/oidc', { method: 'DELETE' }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'oidc'] }),
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ interface AuthState {
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
username: string | null;
|
||||
roles: string[];
|
||||
isAuthenticated: boolean;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
@@ -13,6 +14,15 @@ interface AuthState {
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
function parseRolesFromJwt(token: string): string[] {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
return Array.isArray(payload.roles) ? payload.roles : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function loadTokens() {
|
||||
return {
|
||||
accessToken: localStorage.getItem('cameleer-access-token'),
|
||||
@@ -39,6 +49,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
accessToken: initial.accessToken,
|
||||
refreshToken: initial.refreshToken,
|
||||
username: initial.username,
|
||||
roles: initial.accessToken ? parseRolesFromJwt(initial.accessToken) : [],
|
||||
isAuthenticated: !!initial.accessToken,
|
||||
error: null,
|
||||
loading: false,
|
||||
@@ -61,6 +72,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
username,
|
||||
roles: parseRolesFromJwt(accessToken),
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
});
|
||||
@@ -88,6 +100,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
set({
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
roles: parseRolesFromJwt(data.accessToken),
|
||||
isAuthenticated: true,
|
||||
});
|
||||
return true;
|
||||
@@ -102,6 +115,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
username: null,
|
||||
roles: [],
|
||||
isAuthenticated: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import styles from './TopNav.module.css';
|
||||
|
||||
export function TopNav() {
|
||||
const { theme, toggle } = useThemeStore();
|
||||
const { username, logout } = useAuthStore();
|
||||
const { username, roles, logout } = useAuthStore();
|
||||
|
||||
return (
|
||||
<nav className={styles.topnav}>
|
||||
@@ -23,6 +23,13 @@ export function TopNav() {
|
||||
Transactions
|
||||
</NavLink>
|
||||
</li>
|
||||
{roles.includes('ADMIN') && (
|
||||
<li>
|
||||
<NavLink to="/admin/oidc" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
||||
Admin
|
||||
</NavLink>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<div className={styles.navRight}>
|
||||
|
||||
335
ui/src/pages/admin/OidcAdminPage.module.css
Normal file
335
ui/src/pages/admin/OidcAdminPage.module.css
Normal file
@@ -0,0 +1,335 @@
|
||||
.page {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.toggleRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.toggleInfo {
|
||||
flex: 1;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.toggleLabel {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toggleDesc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--text-muted);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.toggleOn {
|
||||
background: var(--amber);
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
.toggleOn::after {
|
||||
transform: translateX(20px);
|
||||
background: #0a0e17;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--amber-dim);
|
||||
box-shadow: 0 0 0 3px var(--amber-glow);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 99px;
|
||||
padding: 4px 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tagRemove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tagRemove:hover {
|
||||
color: var(--rose);
|
||||
}
|
||||
|
||||
.tagInput {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tagInput .input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tagAddBtn {
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tagAddBtn:hover {
|
||||
border-color: var(--amber-dim);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.btnPrimary {
|
||||
padding: 10px 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--amber);
|
||||
background: var(--amber);
|
||||
color: #0a0e17;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btnPrimary:hover {
|
||||
background: var(--amber-hover);
|
||||
border-color: var(--amber-hover);
|
||||
}
|
||||
|
||||
.btnPrimary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btnOutline {
|
||||
padding: 10px 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btnOutline:hover {
|
||||
border-color: var(--amber-dim);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btnOutline:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btnDanger {
|
||||
margin-left: auto;
|
||||
padding: 10px 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
border: 1px solid var(--rose-dim);
|
||||
color: var(--rose);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btnDanger:hover {
|
||||
background: var(--rose-glow);
|
||||
}
|
||||
|
||||
.btnDanger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.confirmBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--rose-glow);
|
||||
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
color: var(--rose);
|
||||
}
|
||||
|
||||
.confirmBar button {
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.confirmActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.successMsg {
|
||||
margin-top: 16px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.errorMsg {
|
||||
margin-top: 16px;
|
||||
padding: 10px 12px;
|
||||
background: var(--rose-glow);
|
||||
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
color: var(--rose);
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
background: var(--bg-raised);
|
||||
border-radius: var(--radius-sm);
|
||||
height: 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skeletonWide {
|
||||
composes: skeleton;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.skeletonMedium {
|
||||
composes: skeleton;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.accessDenied {
|
||||
text-align: center;
|
||||
padding: 64px 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
335
ui/src/pages/admin/OidcAdminPage.tsx
Normal file
335
ui/src/pages/admin/OidcAdminPage.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import {
|
||||
useOidcConfig,
|
||||
useSaveOidcConfig,
|
||||
useTestOidcConnection,
|
||||
useDeleteOidcConfig,
|
||||
type OidcConfigRequest,
|
||||
} from '../../api/queries/oidc-admin';
|
||||
import styles from './OidcAdminPage.module.css';
|
||||
|
||||
interface FormData {
|
||||
enabled: boolean;
|
||||
autoSignup: boolean;
|
||||
issuerUri: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
rolesClaim: string;
|
||||
defaultRoles: string[];
|
||||
}
|
||||
|
||||
const emptyForm: FormData = {
|
||||
enabled: false,
|
||||
autoSignup: true,
|
||||
issuerUri: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
rolesClaim: 'realm_access.roles',
|
||||
defaultRoles: ['VIEWER'],
|
||||
};
|
||||
|
||||
export function OidcAdminPage() {
|
||||
const roles = useAuthStore((s) => s.roles);
|
||||
|
||||
if (!roles.includes('ADMIN')) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.accessDenied}>
|
||||
Access Denied — this page requires the ADMIN role.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <OidcAdminForm />;
|
||||
}
|
||||
|
||||
function OidcAdminForm() {
|
||||
const { data, isLoading } = useOidcConfig();
|
||||
const saveMutation = useSaveOidcConfig();
|
||||
const testMutation = useTestOidcConnection();
|
||||
const deleteMutation = useDeleteOidcConfig();
|
||||
|
||||
const [form, setForm] = useState<FormData>(emptyForm);
|
||||
const [secretTouched, setSecretTouched] = useState(false);
|
||||
const [newRole, setNewRole] = useState('');
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [status, setStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||
const statusTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
if (data.configured) {
|
||||
setForm({
|
||||
enabled: data.enabled ?? false,
|
||||
autoSignup: data.autoSignup ?? true,
|
||||
issuerUri: data.issuerUri ?? '',
|
||||
clientId: data.clientId ?? '',
|
||||
clientSecret: '',
|
||||
rolesClaim: data.rolesClaim ?? 'realm_access.roles',
|
||||
defaultRoles: data.defaultRoles ?? ['VIEWER'],
|
||||
});
|
||||
setSecretTouched(false);
|
||||
} else {
|
||||
setForm(emptyForm);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
function showStatus(type: 'success' | 'error', message: string) {
|
||||
setStatus({ type, message });
|
||||
clearTimeout(statusTimer.current);
|
||||
statusTimer.current = setTimeout(() => setStatus(null), 5000);
|
||||
}
|
||||
|
||||
function updateField<K extends keyof FormData>(key: K, value: FormData[K]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const payload: OidcConfigRequest = {
|
||||
...form,
|
||||
clientSecret: secretTouched ? form.clientSecret : '********',
|
||||
};
|
||||
try {
|
||||
await saveMutation.mutateAsync(payload);
|
||||
showStatus('success', 'Configuration saved.');
|
||||
} catch (e) {
|
||||
showStatus('error', e instanceof Error ? e.message : 'Failed to save.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
try {
|
||||
const result = await testMutation.mutateAsync();
|
||||
showStatus('success', `Provider reachable. Authorization endpoint: ${result.authorizationEndpoint}`);
|
||||
} catch (e) {
|
||||
showStatus('error', e instanceof Error ? e.message : 'Test failed.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteMutation.mutateAsync();
|
||||
setForm(emptyForm);
|
||||
setSecretTouched(false);
|
||||
setShowDeleteConfirm(false);
|
||||
showStatus('success', 'Configuration deleted.');
|
||||
} catch (e) {
|
||||
showStatus('error', e instanceof Error ? e.message : 'Failed to delete.');
|
||||
}
|
||||
}
|
||||
|
||||
function addRole() {
|
||||
const role = newRole.trim().toUpperCase();
|
||||
if (role && !form.defaultRoles.includes(role)) {
|
||||
updateField('defaultRoles', [...form.defaultRoles, role]);
|
||||
}
|
||||
setNewRole('');
|
||||
}
|
||||
|
||||
function removeRole(role: string) {
|
||||
updateField('defaultRoles', form.defaultRoles.filter((r) => r !== role));
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<h1 className={styles.pageTitle}>OIDC Configuration</h1>
|
||||
<p className={styles.subtitle}>Configure external identity provider</p>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.skeletonWide} />
|
||||
<div className={styles.skeletonMedium} />
|
||||
<div className={styles.skeletonWide} />
|
||||
<div className={styles.skeletonWide} />
|
||||
<div className={styles.skeletonMedium} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isConfigured = data?.configured ?? false;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<h1 className={styles.pageTitle}>OIDC Configuration</h1>
|
||||
<p className={styles.subtitle}>Configure external identity provider</p>
|
||||
|
||||
<div className={styles.card}>
|
||||
<div className={styles.toggleRow}>
|
||||
<div className={styles.toggleInfo}>
|
||||
<div className={styles.toggleLabel}>Enabled</div>
|
||||
<div className={styles.toggleDesc}>
|
||||
Allow users to sign in with the configured OIDC identity provider
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.toggle} ${form.enabled ? styles.toggleOn : ''}`}
|
||||
onClick={() => updateField('enabled', !form.enabled)}
|
||||
aria-label="Toggle OIDC enabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.toggleRow}>
|
||||
<div className={styles.toggleInfo}>
|
||||
<div className={styles.toggleLabel}>Auto Sign-Up</div>
|
||||
<div className={styles.toggleDesc}>
|
||||
Automatically create accounts for new OIDC users. When disabled, an admin must
|
||||
pre-create the user before they can sign in.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.toggle} ${form.autoSignup ? styles.toggleOn : ''}`}
|
||||
onClick={() => updateField('autoSignup', !form.autoSignup)}
|
||||
aria-label="Toggle auto sign-up"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Issuer URI</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="url"
|
||||
value={form.issuerUri}
|
||||
onChange={(e) => updateField('issuerUri', e.target.value)}
|
||||
placeholder="https://auth.example.com/realms/main"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Client ID</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="text"
|
||||
value={form.clientId}
|
||||
onChange={(e) => updateField('clientId', e.target.value)}
|
||||
placeholder="cameleer3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Client Secret</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="password"
|
||||
value={form.clientSecret}
|
||||
onChange={(e) => {
|
||||
updateField('clientSecret', e.target.value);
|
||||
setSecretTouched(true);
|
||||
}}
|
||||
placeholder={data?.clientSecretSet ? 'Secret is configured' : 'Enter client secret'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Roles Claim</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="text"
|
||||
value={form.rolesClaim}
|
||||
onChange={(e) => updateField('rolesClaim', e.target.value)}
|
||||
placeholder="realm_access.roles"
|
||||
/>
|
||||
<div className={styles.hint}>
|
||||
Dot-separated path to roles array in the ID token
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Default Roles</label>
|
||||
<div className={styles.tags}>
|
||||
{form.defaultRoles.map((role) => (
|
||||
<span key={role} className={styles.tag}>
|
||||
{role}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tagRemove}
|
||||
onClick={() => removeRole(role)}
|
||||
aria-label={`Remove ${role}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.tagInput}>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="text"
|
||||
value={newRole}
|
||||
onChange={(e) => setNewRole(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addRole();
|
||||
}
|
||||
}}
|
||||
placeholder="Add role..."
|
||||
/>
|
||||
<button type="button" className={styles.tagAddBtn} onClick={addRole}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnPrimary}
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnOutline}
|
||||
onClick={handleTest}
|
||||
disabled={!isConfigured || testMutation.isPending}
|
||||
>
|
||||
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnDanger}
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={!isConfigured || deleteMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<div className={styles.confirmBar}>
|
||||
<span>Delete OIDC configuration? This cannot be undone.</span>
|
||||
<div className={styles.confirmActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnOutline}
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnDanger}
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div className={status.type === 'success' ? styles.successMsg : styles.errorMsg}>
|
||||
{status.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { AppShell } from './components/layout/AppShell';
|
||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||
import { LoginPage } from './auth/LoginPage';
|
||||
import { ExecutionExplorer } from './pages/executions/ExecutionExplorer';
|
||||
import { OidcAdminPage } from './pages/admin/OidcAdminPage';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@@ -17,6 +18,7 @@ export const router = createBrowserRouter([
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/executions" replace /> },
|
||||
{ path: 'executions', element: <ExecutionExplorer /> },
|
||||
{ path: 'admin/oidc', element: <OidcAdminPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user