feat: add Custom OIDC provider and move SSO to Community tier (#626)

* feat: add Custom OIDC provider and move SSO to Community tier

Add a generic Custom OIDC provider that works with any spec-compliant
OIDC identity provider (Keycloak, Authentik, Authelia, Zitadel, KanIDM,
Pocket ID, etc.) via standard discovery. Supports configurable claim
mapping for User ID, Username, and Email fields to handle non-standard
providers.

Move all SSO functionality (LDAP and OIDC) from the Admiral tier to the
Community tier so every user has access to identity provider integration.

Backend: add oidc_custom to AuthProvider type, extend SSOService with
claim mapping fields and env-var seeding, add oidc_custom to route
validation, remove requireAdmiral guards from SSO config endpoints.

Frontend: add Custom OIDC card with Display Name, Issuer URL, and claim
mapping fields to SSOSection; add KeyRound icon on login page; remove
AdmiralGate wrapper and lock icon from SSO settings nav.

Tests: update tier guard expectations, add oidc_custom authorize/config/
provisioning tests and claim mapping coverage. All 992 tests pass.

Docs: add Custom OIDC configuration reference, provider-specific setup
examples, troubleshooting section, and updated screenshots.

* fix: settings dialog close button overlap and combobox styling

Reposition the close button in Settings Hub above the scroll area so
it stays fixed when content scrolls. Increase dialog height to
accommodate the growing number of setting sections.

Fix combobox trigger styling to match Input component tokens
(border-glass-border, bg-input) and eliminate the gap between trigger
and dropdown list (top-full -mt-px). Apply the same fixes to
multi-select-combobox for consistency.

Add items-start to the Scopes/Default Role grid so the combobox
aligns with the adjacent input field. Add showClose prop to
DialogContent for consumers that need custom close button placement.

Update SSO doc screenshots at 1920x900.
This commit is contained in:
Anso
2026-04-15 23:15:28 -04:00
committed by GitHub
parent ccc42d2a7f
commit 7c6df0aa5d
14 changed files with 369 additions and 87 deletions
+60 -8
View File
@@ -7,9 +7,7 @@ import { Combobox } from '@/components/ui/combobox';
import { Badge } from '@/components/ui/badge';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { AdmiralGate } from './AdmiralGate';
import { CapabilityGate } from './CapabilityGate';
import { TierBadge } from './TierBadge';
import { Shield, Loader2, CheckCircle, XCircle } from 'lucide-react';
const ROLE_OPTIONS = [
@@ -38,6 +36,10 @@ interface SSOProviderConfig {
oidcAdminClaim?: string;
oidcAdminClaimValue?: string;
oidcDefaultRole?: string;
// Custom OIDC claim mapping
oidcIdClaim?: string;
oidcUsernameClaim?: string;
oidcEmailClaim?: string;
}
const PROVIDERS = [
@@ -45,6 +47,7 @@ const PROVIDERS = [
{ id: 'oidc_google', label: 'Google', type: 'oidc' as const },
{ id: 'oidc_github', label: 'GitHub', type: 'oidc' as const },
{ id: 'oidc_okta', label: 'Okta', type: 'oidc' as const },
{ id: 'oidc_custom', label: 'Custom OIDC', type: 'oidc' as const },
];
function ProviderCard({ providerId, type, label, initialConfig, onSave }: {
@@ -227,14 +230,32 @@ function ProviderCard({ providerId, type, label, initialConfig, onSave }: {
</>
) : (
<>
{providerId === 'oidc_okta' && (
{providerId === 'oidc_custom' && (
<div className="grid gap-2">
<Label className="text-xs text-muted-foreground">Display Name</Label>
<Input
placeholder="My Identity Provider"
value={config.displayName || ''}
onChange={e => update('displayName', e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Name shown on the login button (e.g., "Corporate SSO").
</p>
</div>
)}
{(providerId === 'oidc_okta' || providerId === 'oidc_custom') && (
<div className="grid gap-2">
<Label className="text-xs text-muted-foreground">Issuer URL</Label>
<Input
placeholder="https://dev-123456.okta.com"
placeholder={providerId === 'oidc_okta' ? 'https://dev-123456.okta.com' : 'https://auth.example.com/realms/myrealm'}
value={config.oidcIssuerUrl || ''}
onChange={e => update('oidcIssuerUrl', e.target.value)}
/>
{providerId === 'oidc_custom' && (
<p className="text-xs text-muted-foreground">
Base URL of the OIDC discovery endpoint (without <code className="bg-muted px-1 rounded">/.well-known/openid-configuration</code>).
</p>
)}
</div>
)}
<div className="grid grid-cols-2 gap-3">
@@ -274,7 +295,7 @@ function ProviderCard({ providerId, type, label, initialConfig, onSave }: {
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-2 gap-3 items-start">
<div className="grid gap-2">
<Label className="text-xs text-muted-foreground">Scopes</Label>
<Input
@@ -296,6 +317,39 @@ function ProviderCard({ providerId, type, label, initialConfig, onSave }: {
/>
</div>
</div>
{providerId === 'oidc_custom' && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="grid gap-2">
<Label className="text-xs text-muted-foreground">User ID Claim</Label>
<Input
placeholder="sub"
value={config.oidcIdClaim || ''}
onChange={e => update('oidcIdClaim', e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label className="text-xs text-muted-foreground">Username Claim</Label>
<Input
placeholder="preferred_username"
value={config.oidcUsernameClaim || ''}
onChange={e => update('oidcUsernameClaim', e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label className="text-xs text-muted-foreground">Email Claim</Label>
<Input
placeholder="email"
value={config.oidcEmailClaim || ''}
onChange={e => update('oidcEmailClaim', e.target.value)}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Map claims from your provider's token to Sencho user fields. Leave blank for standard OIDC defaults.
</p>
</>
)}
</>
)}
@@ -341,13 +395,12 @@ export function SSOSection() {
const getConfig = (provider: string) => configs.find(c => c.provider === provider) || null;
return (
<AdmiralGate featureName="SSO Authentication">
<CapabilityGate capability="sso" featureName="SSO Authentication">
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">
<Shield className="w-5 h-5" />
SSO Authentication <TierBadge />
SSO Authentication
</h3>
<p className="text-sm text-muted-foreground mt-1">
Connect your identity provider so team members can sign in with their existing credentials.
@@ -374,6 +427,5 @@ export function SSOSection() {
</div>
</div>
</CapabilityGate>
</AdmiralGate>
);
}