mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 05:58:37 +00:00
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:
@@ -4,6 +4,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { KeyRound } from 'lucide-react';
|
||||
|
||||
interface SSOProvider {
|
||||
provider: string;
|
||||
@@ -34,6 +35,8 @@ function getProviderIcon(provider: string) {
|
||||
<path d="M12 0C5.389 0 0 5.389 0 12s5.389 12 12 12 12-5.389 12-12S18.611 0 12 0zm0 18c-3.314 0-6-2.686-6-6s2.686-6 6-6 6 2.686 6 6-2.686 6-6 6z" />
|
||||
</svg>
|
||||
);
|
||||
case 'oidc_custom':
|
||||
return <KeyRound className="w-4 h-4 mr-2" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
@@ -14,7 +15,7 @@ import { apiFetch } from '@/lib/api';
|
||||
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||
import type { SenchoSettingsChangedDetail } from '@/lib/events';
|
||||
import {
|
||||
Shield, Activity, Bell, Code, Server, Package,
|
||||
Shield, Activity, Bell, Code, Server, Package, X,
|
||||
Info, Crown, Webhook, Users, Zap, Database, LifeBuoy, Lock, Tag, Route,
|
||||
} from 'lucide-react';
|
||||
import { NodeManager } from './NodeManager';
|
||||
@@ -337,7 +338,7 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-[900px] h-[min(650px,85vh)] flex p-0 font-sans shadow-lg bg-background border-border overflow-hidden gap-0">
|
||||
<DialogContent showClose={false} className="sm:max-w-[900px] h-[min(780px,90vh)] flex p-0 font-sans shadow-lg bg-background border-border overflow-hidden gap-0">
|
||||
<VisuallyHidden><DialogTitle>Settings Hub</DialogTitle></VisuallyHidden>
|
||||
<VisuallyHidden><DialogDescription>Configure Sencho settings</DialogDescription></VisuallyHidden>
|
||||
|
||||
@@ -366,7 +367,7 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
|
||||
<NavButton section="users" icon={<Users className="w-4 h-4 mr-2" />} label="Users" locked={!isPaid} />
|
||||
)}
|
||||
{!isRemote && isAdmin && (
|
||||
<NavButton section="sso" icon={<Shield className="w-4 h-4 mr-2" />} label="SSO" locked={!isAdmiral} />
|
||||
<NavButton section="sso" icon={<Shield className="w-4 h-4 mr-2" />} label="SSO" />
|
||||
)}
|
||||
{!isRemote && isAdmin && (
|
||||
<NavButton section="api-tokens" icon={<Zap className="w-4 h-4 mr-2" />} label="API Tokens" locked={!isAdmiral} />
|
||||
@@ -421,11 +422,19 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<ScrollArea viewportRef={contentViewportRef} className="flex-1">
|
||||
<div className="p-6 flex flex-col gap-6">
|
||||
{renderSection()}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex justify-end shrink-0 px-3 pt-3">
|
||||
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" strokeWidth={1.5} />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<ScrollArea viewportRef={contentViewportRef} className="flex-1">
|
||||
<div className="px-6 pb-6 flex flex-col gap-6">
|
||||
{renderSection()}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -99,7 +99,7 @@ export function Combobox({
|
||||
disabled={disabled}
|
||||
onClick={() => { if (!disabled) setOpen(true) }}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-glass-border bg-input px-3 py-2 text-sm shadow-sm transition-colors focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
!value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
@@ -112,7 +112,7 @@ export function Combobox({
|
||||
|
||||
{/* Options list — absolutely positioned overlay */}
|
||||
{open && (
|
||||
<div className="absolute left-0 top-[calc(100%+4px)] z-50 w-full rounded-md border border-glass-border bg-popover text-popover-foreground shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15] animate-in fade-in-0 zoom-in-95 slide-in-from-top-2">
|
||||
<div className="absolute left-0 top-full -mt-px z-50 w-full rounded-md border border-glass-border bg-popover text-popover-foreground shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15] animate-in fade-in-0 zoom-in-95 slide-in-from-top-2">
|
||||
<div className="max-h-[200px] overflow-y-auto overflow-x-hidden p-1">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
// while delegating animation to animate-ui's spring-based dialog
|
||||
const DialogContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof AnimateDialogContent>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentProps<typeof AnimateDialogContent> & { showClose?: boolean }
|
||||
>(({ className, children, showClose = true, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<AnimateDialogOverlay className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm" />
|
||||
<AnimateDialogContent
|
||||
@@ -33,10 +33,12 @@ const DialogContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogClose className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
{showClose && (
|
||||
<DialogClose className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
)}
|
||||
</AnimateDialogContent>
|
||||
</DialogPortal>
|
||||
));
|
||||
|
||||
@@ -87,7 +87,7 @@ export function MultiSelectCombobox({
|
||||
disabled={disabled}
|
||||
onClick={() => { if (!disabled) setOpen(!open) }}
|
||||
className={cn(
|
||||
"flex h-7 items-center gap-1.5 whitespace-nowrap rounded-md border border-input bg-transparent px-2.5 text-xs shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 transition-colors",
|
||||
"flex h-7 items-center gap-1.5 whitespace-nowrap rounded-md border border-glass-border bg-input px-2.5 text-xs shadow-sm transition-colors focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
selected.size > 0 ? "text-foreground" : "text-muted-foreground",
|
||||
open && "ring-1 ring-ring border-ring"
|
||||
)}
|
||||
@@ -97,7 +97,7 @@ export function MultiSelectCombobox({
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 top-[calc(100%+4px)] z-50 min-w-[180px] rounded-md border border-glass-border bg-popover text-popover-foreground shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15] animate-in fade-in-0 zoom-in-95 slide-in-from-top-2">
|
||||
<div className="absolute left-0 top-full -mt-px z-50 min-w-[180px] rounded-md border border-glass-border bg-popover text-popover-foreground shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15] animate-in fade-in-0 zoom-in-95 slide-in-from-top-2">
|
||||
{options.length > 5 && (
|
||||
<div className="p-1.5 border-b border-glass-border">
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user