feat: replace tenant OIDC page with Enterprise SSO connector management
- Add LogtoManagementClient methods for SSO connector CRUD + org JIT - Add TenantSsoService with tenant isolation (validates connector-org link) - Add TenantSsoController at /api/tenant/sso with test endpoint - Create SsoPage with provider selection, dynamic config form, test button - Remove old OIDC config endpoints from tenant portal (server OIDC is now platform-managed, set during provisioning) - Sidebar: OIDC -> SSO with Shield icon Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -286,6 +286,118 @@ public class LogtoManagementClient {
|
||||
}
|
||||
}
|
||||
|
||||
// --- SSO Connector Management ---
|
||||
|
||||
/** List all SSO connectors. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<Map<String, Object>> listSsoConnectors() {
|
||||
if (!isAvailable()) return List.of();
|
||||
try {
|
||||
var resp = restClient.get()
|
||||
.uri(config.getLogtoEndpoint() + "/api/sso-connectors?page=1&page_size=100")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.retrieve()
|
||||
.body(List.class);
|
||||
return resp != null ? resp : List.of();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to list SSO connectors: {}", e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
/** Create an SSO connector. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> createSsoConnector(String providerName, String connectorName,
|
||||
Map<String, Object> connectorConfig, List<String> domains) {
|
||||
if (!isAvailable()) return null;
|
||||
var body = new java.util.HashMap<String, Object>();
|
||||
body.put("providerName", providerName);
|
||||
body.put("connectorName", connectorName);
|
||||
if (connectorConfig != null && !connectorConfig.isEmpty()) body.put("config", connectorConfig);
|
||||
if (domains != null && !domains.isEmpty()) body.put("domains", domains);
|
||||
|
||||
return (Map<String, Object>) restClient.post()
|
||||
.uri(config.getLogtoEndpoint() + "/api/sso-connectors")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(body)
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
}
|
||||
|
||||
/** Get an SSO connector by ID. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> getSsoConnector(String connectorId) {
|
||||
if (!isAvailable()) return null;
|
||||
return (Map<String, Object>) restClient.get()
|
||||
.uri(config.getLogtoEndpoint() + "/api/sso-connectors/" + connectorId)
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
}
|
||||
|
||||
/** Update an SSO connector (partial update). */
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> updateSsoConnector(String connectorId, Map<String, Object> updates) {
|
||||
if (!isAvailable()) return null;
|
||||
return (Map<String, Object>) restClient.patch()
|
||||
.uri(config.getLogtoEndpoint() + "/api/sso-connectors/" + connectorId)
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(updates)
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
}
|
||||
|
||||
/** Delete an SSO connector. */
|
||||
public void deleteSsoConnector(String connectorId) {
|
||||
if (!isAvailable()) return;
|
||||
restClient.delete()
|
||||
.uri(config.getLogtoEndpoint() + "/api/sso-connectors/" + connectorId)
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
}
|
||||
|
||||
/** List SSO connectors linked to an organization via JIT provisioning. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<Map<String, Object>> getOrgJitSsoConnectors(String orgId) {
|
||||
if (!isAvailable()) return List.of();
|
||||
try {
|
||||
var resp = restClient.get()
|
||||
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/jit/sso-connectors?page=1&page_size=100")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.retrieve()
|
||||
.body(List.class);
|
||||
return resp != null ? resp : List.of();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to list org JIT SSO connectors for {}: {}", orgId, e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
/** Link an SSO connector to an organization for JIT provisioning. */
|
||||
public void linkSsoConnectorToOrg(String orgId, String connectorId) {
|
||||
if (!isAvailable()) return;
|
||||
restClient.post()
|
||||
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/jit/sso-connectors")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(Map.of("ssoConnectorIds", List.of(connectorId)))
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
}
|
||||
|
||||
/** Unlink an SSO connector from an organization's JIT provisioning. */
|
||||
public void unlinkSsoConnectorFromOrg(String orgId, String connectorId) {
|
||||
if (!isAvailable()) return;
|
||||
restClient.delete()
|
||||
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/jit/sso-connectors/" + connectorId)
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
}
|
||||
|
||||
private static final String MGMT_API_RESOURCE = "https://default.logto.app/api";
|
||||
|
||||
private synchronized String getAccessToken() {
|
||||
|
||||
@@ -46,17 +46,6 @@ public class TenantPortalController {
|
||||
return ResponseEntity.ok(license);
|
||||
}
|
||||
|
||||
@GetMapping("/oidc")
|
||||
public ResponseEntity<Map<String, Object>> getOidcConfig() {
|
||||
return ResponseEntity.ok(portalService.getOidcConfig());
|
||||
}
|
||||
|
||||
@PutMapping("/oidc")
|
||||
public ResponseEntity<Void> updateOidcConfig(@RequestBody Map<String, Object> body) {
|
||||
portalService.updateOidcConfig(body);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@GetMapping("/team")
|
||||
public ResponseEntity<List<Map<String, Object>>> listTeamMembers() {
|
||||
return ResponseEntity.ok(portalService.listTeamMembers());
|
||||
|
||||
@@ -116,24 +116,6 @@ public class TenantPortalService {
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public Map<String, Object> getOidcConfig() {
|
||||
TenantEntity tenant = resolveTenant();
|
||||
String endpoint = tenant.getServerEndpoint();
|
||||
if (endpoint == null || endpoint.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
return serverApiClient.getOidcConfig(endpoint);
|
||||
}
|
||||
|
||||
public void updateOidcConfig(Map<String, Object> config) {
|
||||
TenantEntity tenant = resolveTenant();
|
||||
String endpoint = tenant.getServerEndpoint();
|
||||
if (endpoint == null || endpoint.isBlank()) {
|
||||
throw new IllegalStateException("Tenant has no server endpoint configured");
|
||||
}
|
||||
serverApiClient.pushOidcConfig(endpoint, config);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> listTeamMembers() {
|
||||
TenantEntity tenant = resolveTenant();
|
||||
String orgId = tenant.getLogtoOrgId();
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package net.siegeln.cameleer.saas.portal;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/tenant/sso")
|
||||
public class TenantSsoController {
|
||||
|
||||
private final TenantSsoService ssoService;
|
||||
|
||||
public TenantSsoController(TenantSsoService ssoService) {
|
||||
this.ssoService = ssoService;
|
||||
}
|
||||
|
||||
public record CreateSsoConnectorRequest(
|
||||
String providerName,
|
||||
String connectorName,
|
||||
Map<String, Object> config,
|
||||
List<String> domains
|
||||
) {}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<Map<String, Object>>> list() {
|
||||
return ResponseEntity.ok(ssoService.listConnectors());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<Map<String, Object>> create(@RequestBody CreateSsoConnectorRequest request) {
|
||||
var connector = ssoService.createConnector(
|
||||
request.providerName(), request.connectorName(),
|
||||
request.config(), request.domains());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(connector);
|
||||
}
|
||||
|
||||
@GetMapping("/{connectorId}")
|
||||
public ResponseEntity<Map<String, Object>> get(@PathVariable String connectorId) {
|
||||
return ResponseEntity.ok(ssoService.getConnector(connectorId));
|
||||
}
|
||||
|
||||
@PatchMapping("/{connectorId}")
|
||||
public ResponseEntity<Map<String, Object>> update(@PathVariable String connectorId,
|
||||
@RequestBody Map<String, Object> updates) {
|
||||
return ResponseEntity.ok(ssoService.updateConnector(connectorId, updates));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{connectorId}")
|
||||
public ResponseEntity<Void> delete(@PathVariable String connectorId) {
|
||||
ssoService.deleteConnector(connectorId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{connectorId}/test")
|
||||
public ResponseEntity<Map<String, Object>> test(@PathVariable String connectorId) {
|
||||
return ResponseEntity.ok(ssoService.testConnector(connectorId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package net.siegeln.cameleer.saas.portal;
|
||||
|
||||
import net.siegeln.cameleer.saas.config.TenantContext;
|
||||
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class TenantSsoService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantSsoService.class);
|
||||
|
||||
private final LogtoManagementClient logtoClient;
|
||||
private final TenantService tenantService;
|
||||
|
||||
public TenantSsoService(LogtoManagementClient logtoClient, TenantService tenantService) {
|
||||
this.logtoClient = logtoClient;
|
||||
this.tenantService = tenantService;
|
||||
}
|
||||
|
||||
/** List SSO connectors linked to the current tenant's organization. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<Map<String, Object>> listConnectors() {
|
||||
String orgId = resolveOrgId();
|
||||
List<Map<String, Object>> jitConnectors = logtoClient.getOrgJitSsoConnectors(orgId);
|
||||
Set<String> linkedIds = jitConnectors.stream()
|
||||
.map(c -> String.valueOf(c.get("id")))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
if (linkedIds.isEmpty()) return List.of();
|
||||
|
||||
// Enrich with full connector details
|
||||
List<Map<String, Object>> allConnectors = logtoClient.listSsoConnectors();
|
||||
return allConnectors.stream()
|
||||
.filter(c -> linkedIds.contains(String.valueOf(c.get("id"))))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** Create an SSO connector and link it to the tenant's organization. */
|
||||
public Map<String, Object> createConnector(String providerName, String connectorName,
|
||||
Map<String, Object> config, List<String> domains) {
|
||||
String orgId = resolveOrgId();
|
||||
var connector = logtoClient.createSsoConnector(providerName, connectorName, config, domains);
|
||||
if (connector == null) {
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to create SSO connector");
|
||||
}
|
||||
String connectorId = String.valueOf(connector.get("id"));
|
||||
logtoClient.linkSsoConnectorToOrg(orgId, connectorId);
|
||||
log.info("Created SSO connector '{}' ({}) and linked to org {}", connectorName, connectorId, orgId);
|
||||
return connector;
|
||||
}
|
||||
|
||||
/** Get a single SSO connector (validates it belongs to this tenant). */
|
||||
public Map<String, Object> getConnector(String connectorId) {
|
||||
validateConnectorBelongsToTenant(connectorId);
|
||||
return logtoClient.getSsoConnector(connectorId);
|
||||
}
|
||||
|
||||
/** Update an SSO connector (validates it belongs to this tenant). */
|
||||
public Map<String, Object> updateConnector(String connectorId, Map<String, Object> updates) {
|
||||
validateConnectorBelongsToTenant(connectorId);
|
||||
return logtoClient.updateSsoConnector(connectorId, updates);
|
||||
}
|
||||
|
||||
/** Delete an SSO connector (unlinks from org and deletes). */
|
||||
public void deleteConnector(String connectorId) {
|
||||
String orgId = resolveOrgId();
|
||||
validateConnectorBelongsToTenant(connectorId);
|
||||
logtoClient.unlinkSsoConnectorFromOrg(orgId, connectorId);
|
||||
logtoClient.deleteSsoConnector(connectorId);
|
||||
log.info("Deleted SSO connector {} from org {}", connectorId, orgId);
|
||||
}
|
||||
|
||||
/** Test an SSO connector by fetching its details (validates provider metadata). */
|
||||
public Map<String, Object> testConnector(String connectorId) {
|
||||
validateConnectorBelongsToTenant(connectorId);
|
||||
var connector = logtoClient.getSsoConnector(connectorId);
|
||||
if (connector == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Connector not found");
|
||||
}
|
||||
// Logto resolves providerConfig (OIDC discovery / SAML metadata) when fetching.
|
||||
// If providerConfig is present and non-empty, the IdP is reachable.
|
||||
@SuppressWarnings("unchecked")
|
||||
var providerConfig = (Map<String, Object>) connector.get("providerConfig");
|
||||
boolean reachable = providerConfig != null && !providerConfig.isEmpty();
|
||||
return Map.of(
|
||||
"status", reachable ? "ok" : "unreachable",
|
||||
"providerName", String.valueOf(connector.get("providerName")),
|
||||
"connectorName", String.valueOf(connector.get("connectorName"))
|
||||
);
|
||||
}
|
||||
|
||||
private String resolveOrgId() {
|
||||
UUID tenantId = TenantContext.getTenantId();
|
||||
TenantEntity tenant = tenantService.getById(tenantId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Tenant not found"));
|
||||
String orgId = tenant.getLogtoOrgId();
|
||||
if (orgId == null || orgId.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.PRECONDITION_FAILED, "Tenant has no Logto organization");
|
||||
}
|
||||
return orgId;
|
||||
}
|
||||
|
||||
private void validateConnectorBelongsToTenant(String connectorId) {
|
||||
String orgId = resolveOrgId();
|
||||
List<Map<String, Object>> jitConnectors = logtoClient.getOrgJitSsoConnectors(orgId);
|
||||
boolean linked = jitConnectors.stream()
|
||||
.anyMatch(c -> connectorId.equals(String.valueOf(c.get("id"))));
|
||||
if (!linked) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "SSO connector does not belong to this tenant");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from './client';
|
||||
import type { DashboardData, TenantLicenseData, TenantSettings, AuditLogPage, AuditLogFilters } from '../types/api';
|
||||
import type { DashboardData, TenantLicenseData, TenantSettings, AuditLogPage, AuditLogFilters, SsoConnector, CreateSsoConnectorRequest, SsoTestResult } from '../types/api';
|
||||
|
||||
export function useTenantDashboard() {
|
||||
return useQuery<DashboardData>({
|
||||
@@ -17,18 +17,49 @@ export function useTenantLicense() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useTenantOidc() {
|
||||
return useQuery<Record<string, unknown>>({
|
||||
queryKey: ['tenant', 'oidc'],
|
||||
queryFn: () => api.get('/tenant/oidc'),
|
||||
// SSO connector hooks
|
||||
export function useSsoConnectors() {
|
||||
return useQuery<SsoConnector[]>({
|
||||
queryKey: ['tenant', 'sso'],
|
||||
queryFn: () => api.get('/tenant/sso'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateOidc() {
|
||||
export function useSsoConnector(id: string | null) {
|
||||
return useQuery<SsoConnector>({
|
||||
queryKey: ['tenant', 'sso', id],
|
||||
queryFn: () => api.get(`/tenant/sso/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateSsoConnector() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, Error, Record<string, unknown>>({
|
||||
mutationFn: (config) => api.post('/tenant/oidc', config),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'oidc'] }),
|
||||
return useMutation<SsoConnector, Error, CreateSsoConnectorRequest>({
|
||||
mutationFn: (req) => api.post('/tenant/sso', req),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'sso'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSsoConnector() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<SsoConnector, Error, { id: string; updates: Record<string, unknown> }>({
|
||||
mutationFn: ({ id, updates }) => api.patch(`/tenant/sso/${id}`, updates),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'sso'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSsoConnector() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (id) => api.delete(`/tenant/sso/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'sso'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestSsoConnector() {
|
||||
return useMutation<SsoTestResult, Error, string>({
|
||||
mutationFn: (id) => api.post(`/tenant/sso/${id}/test`),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Sidebar,
|
||||
TopBar,
|
||||
} from '@cameleer/design-system';
|
||||
import { LayoutDashboard, ShieldCheck, Server, Users, Settings, KeyRound, Building, Fingerprint, ScrollText } from 'lucide-react';
|
||||
import { LayoutDashboard, ShieldCheck, Server, Users, Settings, Shield, Building, Fingerprint, ScrollText } from 'lucide-react';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { useScopes } from '../auth/useScopes';
|
||||
import { useOrgStore } from '../auth/useOrganization';
|
||||
@@ -117,11 +117,11 @@ export function Layout() {
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Section
|
||||
icon={<KeyRound size={16} />}
|
||||
label="OIDC"
|
||||
icon={<Shield size={16} />}
|
||||
label="SSO"
|
||||
open={false}
|
||||
active={isActive(location, '/tenant/oidc')}
|
||||
onToggle={() => navigate('/tenant/oidc')}
|
||||
active={isActive(location, '/tenant/sso')}
|
||||
onToggle={() => navigate('/tenant/sso')}
|
||||
>
|
||||
{null}
|
||||
</Sidebar.Section>
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
FormField,
|
||||
Input,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import { useTenantOidc, useUpdateOidc } from '../../api/tenant-hooks';
|
||||
import styles from '../../styles/platform.module.css';
|
||||
|
||||
interface OidcFormState {
|
||||
issuerUri: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
audience: string;
|
||||
rolesClaim: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: OidcFormState = {
|
||||
issuerUri: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
audience: '',
|
||||
rolesClaim: '',
|
||||
};
|
||||
|
||||
function isExternalOidc(config: Record<string, unknown>): boolean {
|
||||
return typeof config['issuerUri'] === 'string' && config['issuerUri'] !== '';
|
||||
}
|
||||
|
||||
export function OidcConfigPage() {
|
||||
const { data: oidcConfig, isLoading, isError } = useTenantOidc();
|
||||
const updateOidc = useUpdateOidc();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [form, setForm] = useState<OidcFormState>(EMPTY_FORM);
|
||||
|
||||
// Pre-fill form when config loads
|
||||
useEffect(() => {
|
||||
if (!oidcConfig) return;
|
||||
setForm({
|
||||
issuerUri: String(oidcConfig['issuerUri'] ?? ''),
|
||||
clientId: String(oidcConfig['clientId'] ?? ''),
|
||||
clientSecret: String(oidcConfig['clientSecret'] ?? ''),
|
||||
audience: String(oidcConfig['audience'] ?? ''),
|
||||
rolesClaim: String(oidcConfig['rolesClaim'] ?? ''),
|
||||
});
|
||||
}, [oidcConfig]);
|
||||
|
||||
function handleChange(field: keyof OidcFormState, value: string) {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await updateOidc.mutateAsync({
|
||||
issuerUri: form.issuerUri,
|
||||
clientId: form.clientId,
|
||||
clientSecret: form.clientSecret,
|
||||
audience: form.audience,
|
||||
rolesClaim: form.rolesClaim,
|
||||
});
|
||||
toast({ title: 'OIDC configuration saved', variant: 'success' });
|
||||
} catch (err) {
|
||||
toast({ title: 'Save failed', description: String(err), variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
try {
|
||||
await updateOidc.mutateAsync({});
|
||||
setForm(EMPTY_FORM);
|
||||
toast({ title: 'Reset to Logto (default)', variant: 'success' });
|
||||
} catch (err) {
|
||||
toast({ title: 'Reset failed', description: String(err), variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Alert variant="error" title="Failed to load OIDC configuration">
|
||||
Could not fetch OIDC config. Please refresh.
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasExternalOidc = oidcConfig ? isExternalOidc(oidcConfig) : false;
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>OIDC Configuration</h1>
|
||||
<Badge
|
||||
label={hasExternalOidc ? 'External OIDC configured' : 'Using Logto (default)'}
|
||||
color={hasExternalOidc ? 'primary' : 'auto'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card title="External OIDC Provider">
|
||||
<p className={styles.description} style={{ marginBottom: 16 }}>
|
||||
Configure an external OIDC provider for your team to sign in. Leave blank to use the
|
||||
default Logto-based authentication.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSave} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<FormField label="Issuer URI" htmlFor="oidc-issuer">
|
||||
<Input
|
||||
id="oidc-issuer"
|
||||
type="url"
|
||||
placeholder="https://your-idp.example.com/oidc"
|
||||
value={form.issuerUri}
|
||||
onChange={(e) => handleChange('issuerUri', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Client ID" htmlFor="oidc-client-id">
|
||||
<Input
|
||||
id="oidc-client-id"
|
||||
placeholder="your-client-id"
|
||||
value={form.clientId}
|
||||
onChange={(e) => handleChange('clientId', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Client Secret" htmlFor="oidc-client-secret">
|
||||
<Input
|
||||
id="oidc-client-secret"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={form.clientSecret}
|
||||
onChange={(e) => handleChange('clientSecret', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Audience" htmlFor="oidc-audience">
|
||||
<Input
|
||||
id="oidc-audience"
|
||||
placeholder="https://api.your-service.example.com"
|
||||
value={form.audience}
|
||||
onChange={(e) => handleChange('audience', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Roles Claim" htmlFor="oidc-roles-claim">
|
||||
<Input
|
||||
id="oidc-roles-claim"
|
||||
placeholder="roles"
|
||||
value={form.rolesClaim}
|
||||
onChange={(e) => handleChange('rolesClaim', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button type="submit" variant="primary" loading={updateOidc.isPending}>
|
||||
Save Configuration
|
||||
</Button>
|
||||
{hasExternalOidc && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleReset}
|
||||
loading={updateOidc.isPending}
|
||||
>
|
||||
Reset to Logto
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
306
ui/src/pages/tenant/SsoPage.tsx
Normal file
306
ui/src/pages/tenant/SsoPage.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Alert, AlertDialog, Badge, Button, Card, DataTable,
|
||||
EmptyState, FormField, Input, Spinner, useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { Shield, Plus, Trash2, FlaskConical } from 'lucide-react';
|
||||
import {
|
||||
useSsoConnectors, useCreateSsoConnector, useDeleteSsoConnector, useTestSsoConnector,
|
||||
} from '../../api/tenant-hooks';
|
||||
import type { SsoConnector } from '../../types/api';
|
||||
|
||||
const PROVIDERS = [
|
||||
{ value: 'OIDC', label: 'OIDC', type: 'oidc' },
|
||||
{ value: 'SAML', label: 'SAML', type: 'saml' },
|
||||
{ value: 'AzureAD', label: 'Azure AD (SAML)', type: 'saml' },
|
||||
{ value: 'AzureAdOidc', label: 'Azure AD (OIDC)', type: 'oidc' },
|
||||
{ value: 'GoogleWorkspace', label: 'Google Workspace', type: 'oidc' },
|
||||
{ value: 'Okta', label: 'Okta', type: 'oidc' },
|
||||
];
|
||||
|
||||
function providerType(name: string): 'oidc' | 'saml' {
|
||||
return PROVIDERS.find((p) => p.value === name)?.type as 'oidc' | 'saml' ?? 'oidc';
|
||||
}
|
||||
|
||||
function providerLabel(name: string): string {
|
||||
return PROVIDERS.find((p) => p.value === name)?.label ?? name;
|
||||
}
|
||||
|
||||
const selectStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '6px 10px', borderRadius: 6,
|
||||
border: '1px solid var(--border)', background: 'var(--bg-surface)',
|
||||
color: 'var(--text-primary)', fontSize: '0.875rem',
|
||||
};
|
||||
|
||||
export function SsoPage() {
|
||||
const { data: connectors, isLoading, isError } = useSsoConnectors();
|
||||
const createConnector = useCreateSsoConnector();
|
||||
const deleteConnector = useDeleteSsoConnector();
|
||||
const testConnector = useTestSsoConnector();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<SsoConnector | null>(null);
|
||||
|
||||
// Create form state
|
||||
const [provider, setProvider] = useState('OIDC');
|
||||
const [connectorName, setConnectorName] = useState('');
|
||||
const [domains, setDomains] = useState('');
|
||||
// OIDC fields
|
||||
const [issuer, setIssuer] = useState('');
|
||||
const [clientId, setClientId] = useState('');
|
||||
const [clientSecret, setClientSecret] = useState('');
|
||||
// SAML fields
|
||||
const [metadataUrl, setMetadataUrl] = useState('');
|
||||
|
||||
function resetForm() {
|
||||
setProvider('OIDC');
|
||||
setConnectorName('');
|
||||
setDomains('');
|
||||
setIssuer('');
|
||||
setClientId('');
|
||||
setClientSecret('');
|
||||
setMetadataUrl('');
|
||||
setShowCreate(false);
|
||||
}
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const type = providerType(provider);
|
||||
const config: Record<string, unknown> = type === 'oidc'
|
||||
? { clientId, clientSecret, issuer: issuer || undefined }
|
||||
: { metadataUrl };
|
||||
|
||||
try {
|
||||
await createConnector.mutateAsync({
|
||||
providerName: provider,
|
||||
connectorName,
|
||||
config,
|
||||
domains: domains.split(',').map((d) => d.trim()).filter(Boolean),
|
||||
});
|
||||
toast({ title: `SSO connector "${connectorName}" created`, variant: 'success' });
|
||||
resetForm();
|
||||
} catch (err) {
|
||||
toast({ title: 'Failed to create connector', description: String(err), variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteConnector.mutateAsync(deleteTarget.id);
|
||||
toast({ title: `Deleted "${deleteTarget.connectorName}"`, variant: 'success' });
|
||||
setDeleteTarget(null);
|
||||
} catch (err) {
|
||||
toast({ title: 'Delete failed', description: String(err), variant: 'error' });
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest(connector: SsoConnector) {
|
||||
try {
|
||||
const result = await testConnector.mutateAsync(connector.id);
|
||||
if (result.status === 'ok') {
|
||||
toast({ title: 'Connection test passed', description: `${connector.connectorName} is reachable`, variant: 'success' });
|
||||
} else {
|
||||
toast({ title: 'Connection test failed', description: 'IdP metadata could not be resolved', variant: 'warning' });
|
||||
}
|
||||
} catch (err) {
|
||||
toast({ title: 'Connection test failed', description: String(err), variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<SsoConnector>[] = [
|
||||
{
|
||||
key: 'connectorName',
|
||||
header: 'Name',
|
||||
render: (_v, row) => row.connectorName,
|
||||
},
|
||||
{
|
||||
key: 'providerName',
|
||||
header: 'Provider',
|
||||
render: (_v, row) => <Badge label={providerLabel(row.providerName)} color="primary" />,
|
||||
},
|
||||
{
|
||||
key: 'domains',
|
||||
header: 'Domains',
|
||||
render: (_v, row) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }}>
|
||||
{row.domains?.join(', ') || '--'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'id',
|
||||
header: 'Actions',
|
||||
render: (_v, row) => (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={(e) => { e.stopPropagation(); handleTest(row); }}
|
||||
disabled={testConnector.isPending}
|
||||
>
|
||||
<FlaskConical size={14} style={{ marginRight: 4 }} />
|
||||
Test
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={(e) => { e.stopPropagation(); setDeleteTarget(row); }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Alert variant="error" title="Failed to load SSO connectors">
|
||||
Could not fetch SSO configuration. Please refresh.
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isOidc = providerType(provider) === 'oidc';
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>Enterprise SSO</h1>
|
||||
<Button variant="primary" onClick={() => setShowCreate((v) => !v)}>
|
||||
<Plus size={16} style={{ marginRight: 6 }} />
|
||||
Add SSO Connection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<Card title="New SSO Connection">
|
||||
<form onSubmit={handleCreate} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<FormField label="Provider" htmlFor="sso-provider">
|
||||
<select id="sso-provider" value={provider} onChange={(e) => setProvider(e.target.value)} style={selectStyle}>
|
||||
{PROVIDERS.map((p) => (
|
||||
<option key={p.value} value={p.value}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Display Name" htmlFor="sso-name">
|
||||
<Input
|
||||
id="sso-name"
|
||||
placeholder="e.g. Acme Corp SSO"
|
||||
value={connectorName}
|
||||
onChange={(e) => setConnectorName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Email Domains" htmlFor="sso-domains" hint="Comma-separated domains for SSO routing (e.g. acme.com)">
|
||||
<Input
|
||||
id="sso-domains"
|
||||
placeholder="acme.com, acme.io"
|
||||
value={domains}
|
||||
onChange={(e) => setDomains(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{isOidc ? (
|
||||
<>
|
||||
{provider !== 'GoogleWorkspace' && (
|
||||
<FormField label="Issuer URI" htmlFor="sso-issuer">
|
||||
<Input
|
||||
id="sso-issuer"
|
||||
type="url"
|
||||
placeholder="https://login.microsoftonline.com/{tenant}/v2.0"
|
||||
value={issuer}
|
||||
onChange={(e) => setIssuer(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="Client ID" htmlFor="sso-client-id">
|
||||
<Input
|
||||
id="sso-client-id"
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Client Secret" htmlFor="sso-client-secret">
|
||||
<Input
|
||||
id="sso-client-secret"
|
||||
type="password"
|
||||
value={clientSecret}
|
||||
onChange={(e) => setClientSecret(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
) : (
|
||||
<FormField label="SAML Metadata URL" htmlFor="sso-metadata">
|
||||
<Input
|
||||
id="sso-metadata"
|
||||
type="url"
|
||||
placeholder="https://idp.acme.com/metadata.xml"
|
||||
value={metadataUrl}
|
||||
onChange={(e) => setMetadataUrl(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button type="submit" variant="primary" loading={createConnector.isPending}>
|
||||
Create
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={resetForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{(!connectors || connectors.length === 0) && !showCreate && (
|
||||
<EmptyState
|
||||
icon={<Shield size={32} />}
|
||||
title="No SSO connections"
|
||||
description="Add an enterprise SSO connection to let your team sign in with their corporate identity provider."
|
||||
action={
|
||||
<Button variant="primary" onClick={() => setShowCreate(true)}>
|
||||
<Plus size={16} style={{ marginRight: 6 }} />
|
||||
Add SSO Connection
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{connectors && connectors.length > 0 && (
|
||||
<DataTable columns={columns} data={connectors} />
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={deleteTarget !== null}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete SSO Connection"
|
||||
description={`Are you sure you want to delete "${deleteTarget?.connectorName ?? ''}"? Users authenticating via this provider will lose access.`}
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
variant="danger"
|
||||
loading={deleteConnector.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { TenantDetailPage } from './pages/vendor/TenantDetailPage';
|
||||
import { VendorAuditPage } from './pages/vendor/VendorAuditPage';
|
||||
import { TenantDashboardPage } from './pages/tenant/TenantDashboardPage';
|
||||
import { TenantLicensePage } from './pages/tenant/TenantLicensePage';
|
||||
import { OidcConfigPage } from './pages/tenant/OidcConfigPage';
|
||||
import { SsoPage } from './pages/tenant/SsoPage';
|
||||
import { TeamPage } from './pages/tenant/TeamPage';
|
||||
import { SettingsPage } from './pages/tenant/SettingsPage';
|
||||
import { TenantAuditPage } from './pages/tenant/TenantAuditPage';
|
||||
@@ -77,7 +77,7 @@ export function AppRouter() {
|
||||
{/* Tenant portal */}
|
||||
<Route path="/tenant" element={<TenantDashboardPage />} />
|
||||
<Route path="/tenant/license" element={<TenantLicensePage />} />
|
||||
<Route path="/tenant/oidc" element={<OidcConfigPage />} />
|
||||
<Route path="/tenant/sso" element={<SsoPage />} />
|
||||
<Route path="/tenant/team" element={<TeamPage />} />
|
||||
<Route path="/tenant/audit" element={<TenantAuditPage />} />
|
||||
<Route path="/tenant/settings" element={<SettingsPage />} />
|
||||
|
||||
@@ -94,6 +94,31 @@ export interface TenantSettings {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// SSO connector types
|
||||
export interface SsoConnector {
|
||||
id: string;
|
||||
providerName: string;
|
||||
connectorName: string;
|
||||
config: Record<string, unknown>;
|
||||
domains: string[];
|
||||
branding?: { displayName?: string; logo?: string };
|
||||
syncProfile: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateSsoConnectorRequest {
|
||||
providerName: string;
|
||||
connectorName: string;
|
||||
config: Record<string, unknown>;
|
||||
domains: string[];
|
||||
}
|
||||
|
||||
export interface SsoTestResult {
|
||||
status: string;
|
||||
providerName: string;
|
||||
connectorName: string;
|
||||
}
|
||||
|
||||
// Audit log types
|
||||
export interface AuditLogEntry {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user