mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-06 11:36:44 +03:00
727 lines
32 KiB
TypeScript
727 lines
32 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
|
import { apiRequest, showErrorToast } from '@/lib/utils';
|
|
import { toast } from 'sonner';
|
|
import { Plus, Trash2, Loader2, AlertCircle, Shield, Edit2 } from 'lucide-react';
|
|
import { Skeleton } from '../ui/skeleton';
|
|
import { Badge } from '../ui/badge';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { MultiSelect } from '@/components/ui/multi-select';
|
|
|
|
function isTrustedIssuer(issuer: string, allowedHosts: string[]): boolean {
|
|
try {
|
|
const url = new URL(issuer);
|
|
return allowedHosts.some(host => url.hostname === host || url.hostname.endsWith(`.${host}`));
|
|
} catch {
|
|
return false; // Return false if the URL is invalid
|
|
}
|
|
}
|
|
interface SSOProvider {
|
|
id: string;
|
|
issuer: string;
|
|
domain: string;
|
|
providerId: string;
|
|
organizationId?: string;
|
|
oidcConfig?: {
|
|
clientId: string;
|
|
clientSecret: string;
|
|
authorizationEndpoint: string;
|
|
tokenEndpoint: string;
|
|
jwksEndpoint?: string;
|
|
userInfoEndpoint?: string;
|
|
discoveryEndpoint?: string;
|
|
scopes?: string[];
|
|
pkce?: boolean;
|
|
};
|
|
samlConfig?: {
|
|
entryPoint: string;
|
|
cert: string;
|
|
callbackUrl?: string;
|
|
audience?: string;
|
|
wantAssertionsSigned?: boolean;
|
|
signatureAlgorithm?: string;
|
|
digestAlgorithm?: string;
|
|
identifierFormat?: string;
|
|
};
|
|
mapping?: {
|
|
id: string;
|
|
email: string;
|
|
emailVerified?: string;
|
|
name?: string;
|
|
image?: string;
|
|
firstName?: string;
|
|
lastName?: string;
|
|
};
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export function SSOSettings() {
|
|
const [providers, setProviders] = useState<SSOProvider[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [showProviderDialog, setShowProviderDialog] = useState(false);
|
|
const [addingProvider, setAddingProvider] = useState(false);
|
|
const [isDiscovering, setIsDiscovering] = useState(false);
|
|
const [headerAuthEnabled, setHeaderAuthEnabled] = useState(false);
|
|
const [editingProvider, setEditingProvider] = useState<SSOProvider | null>(null);
|
|
|
|
// Form states for new provider
|
|
const [providerType, setProviderType] = useState<'oidc' | 'saml'>('oidc');
|
|
const [providerForm, setProviderForm] = useState({
|
|
// Common fields
|
|
issuer: '',
|
|
domain: '',
|
|
providerId: '',
|
|
organizationId: '',
|
|
// OIDC fields
|
|
clientId: '',
|
|
clientSecret: '',
|
|
authorizationEndpoint: '',
|
|
tokenEndpoint: '',
|
|
jwksEndpoint: '',
|
|
userInfoEndpoint: '',
|
|
discoveryEndpoint: '',
|
|
scopes: ['openid', 'email', 'profile'] as string[],
|
|
pkce: true,
|
|
// SAML fields
|
|
entryPoint: '',
|
|
cert: '',
|
|
callbackUrl: '',
|
|
audience: '',
|
|
wantAssertionsSigned: true,
|
|
signatureAlgorithm: 'sha256',
|
|
digestAlgorithm: 'sha256',
|
|
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
|
});
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, []);
|
|
|
|
const loadData = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const [providersRes, headerAuthStatus] = await Promise.all([
|
|
apiRequest<SSOProvider[] | { providers: SSOProvider[] }>('/sso/providers'),
|
|
apiRequest<{ enabled: boolean }>('/auth/header-status').catch(() => ({ enabled: false }))
|
|
]);
|
|
|
|
setProviders(Array.isArray(providersRes) ? providersRes : providersRes?.providers || []);
|
|
setHeaderAuthEnabled(headerAuthStatus.enabled);
|
|
} catch (error) {
|
|
showErrorToast(error, toast);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const discoverOIDC = async () => {
|
|
if (!providerForm.issuer) {
|
|
toast.error('Please enter an issuer URL');
|
|
return;
|
|
}
|
|
|
|
setIsDiscovering(true);
|
|
try {
|
|
const discovered = await apiRequest<any>('/sso/discover', {
|
|
method: 'POST',
|
|
data: { issuer: providerForm.issuer },
|
|
});
|
|
|
|
setProviderForm(prev => ({
|
|
...prev,
|
|
authorizationEndpoint: discovered.authorizationEndpoint || '',
|
|
tokenEndpoint: discovered.tokenEndpoint || '',
|
|
jwksEndpoint: discovered.jwksEndpoint || '',
|
|
userInfoEndpoint: discovered.userInfoEndpoint || '',
|
|
discoveryEndpoint: discovered.discoveryEndpoint || `${providerForm.issuer}/.well-known/openid-configuration`,
|
|
domain: discovered.suggestedDomain || prev.domain,
|
|
}));
|
|
|
|
toast.success('OIDC configuration discovered successfully');
|
|
} catch (error) {
|
|
showErrorToast(error, toast);
|
|
} finally {
|
|
setIsDiscovering(false);
|
|
}
|
|
};
|
|
|
|
const createProvider = async () => {
|
|
setAddingProvider(true);
|
|
try {
|
|
const requestData: any = {
|
|
providerId: providerForm.providerId,
|
|
issuer: providerForm.issuer,
|
|
domain: providerForm.domain,
|
|
organizationId: providerForm.organizationId || undefined,
|
|
providerType,
|
|
};
|
|
|
|
if (providerType === 'oidc') {
|
|
requestData.clientId = providerForm.clientId;
|
|
requestData.clientSecret = providerForm.clientSecret;
|
|
requestData.authorizationEndpoint = providerForm.authorizationEndpoint;
|
|
requestData.tokenEndpoint = providerForm.tokenEndpoint;
|
|
requestData.jwksEndpoint = providerForm.jwksEndpoint;
|
|
requestData.userInfoEndpoint = providerForm.userInfoEndpoint;
|
|
requestData.discoveryEndpoint = providerForm.discoveryEndpoint;
|
|
requestData.scopes = providerForm.scopes;
|
|
requestData.pkce = providerForm.pkce;
|
|
} else {
|
|
requestData.entryPoint = providerForm.entryPoint;
|
|
requestData.cert = providerForm.cert;
|
|
requestData.callbackUrl = providerForm.callbackUrl || `${window.location.origin}/api/auth/sso/saml2/callback/${providerForm.providerId}`;
|
|
requestData.audience = providerForm.audience || window.location.origin;
|
|
requestData.wantAssertionsSigned = providerForm.wantAssertionsSigned;
|
|
requestData.signatureAlgorithm = providerForm.signatureAlgorithm;
|
|
requestData.digestAlgorithm = providerForm.digestAlgorithm;
|
|
requestData.identifierFormat = providerForm.identifierFormat;
|
|
}
|
|
|
|
if (editingProvider) {
|
|
// Update existing provider
|
|
const updatedProvider = await apiRequest<SSOProvider>(`/sso/providers?id=${editingProvider.id}`, {
|
|
method: 'PUT',
|
|
data: requestData,
|
|
});
|
|
setProviders(providers.map(p => p.id === editingProvider.id ? updatedProvider : p));
|
|
toast.success('SSO provider updated successfully');
|
|
} else {
|
|
// Create new provider
|
|
const newProvider = await apiRequest<SSOProvider>('/sso/providers', {
|
|
method: 'POST',
|
|
data: requestData,
|
|
});
|
|
setProviders([...providers, newProvider]);
|
|
toast.success('SSO provider created successfully');
|
|
}
|
|
|
|
setShowProviderDialog(false);
|
|
setEditingProvider(null);
|
|
setProviderForm({
|
|
issuer: '',
|
|
domain: '',
|
|
providerId: '',
|
|
organizationId: '',
|
|
clientId: '',
|
|
clientSecret: '',
|
|
authorizationEndpoint: '',
|
|
tokenEndpoint: '',
|
|
jwksEndpoint: '',
|
|
userInfoEndpoint: '',
|
|
discoveryEndpoint: '',
|
|
scopes: ['openid', 'email', 'profile'] as string[],
|
|
pkce: true,
|
|
entryPoint: '',
|
|
cert: '',
|
|
callbackUrl: '',
|
|
audience: '',
|
|
wantAssertionsSigned: true,
|
|
signatureAlgorithm: 'sha256',
|
|
digestAlgorithm: 'sha256',
|
|
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
|
});
|
|
} catch (error) {
|
|
showErrorToast(error, toast);
|
|
} finally {
|
|
setAddingProvider(false);
|
|
}
|
|
};
|
|
|
|
const startEditProvider = (provider: SSOProvider) => {
|
|
setEditingProvider(provider);
|
|
setProviderType(provider.samlConfig ? 'saml' : 'oidc');
|
|
|
|
if (provider.oidcConfig) {
|
|
setProviderForm({
|
|
...providerForm,
|
|
providerId: provider.providerId,
|
|
issuer: provider.issuer,
|
|
domain: provider.domain,
|
|
organizationId: provider.organizationId || '',
|
|
clientId: provider.oidcConfig.clientId || '',
|
|
clientSecret: provider.oidcConfig.clientSecret || '',
|
|
authorizationEndpoint: provider.oidcConfig.authorizationEndpoint || '',
|
|
tokenEndpoint: provider.oidcConfig.tokenEndpoint || '',
|
|
jwksEndpoint: provider.oidcConfig.jwksEndpoint || '',
|
|
userInfoEndpoint: provider.oidcConfig.userInfoEndpoint || '',
|
|
discoveryEndpoint: provider.oidcConfig.discoveryEndpoint || '',
|
|
scopes: provider.oidcConfig.scopes || ['openid', 'email', 'profile'],
|
|
pkce: provider.oidcConfig.pkce !== false,
|
|
});
|
|
}
|
|
|
|
setShowProviderDialog(true);
|
|
};
|
|
|
|
const deleteProvider = async (id: string) => {
|
|
try {
|
|
await apiRequest(`/sso/providers?id=${id}`, { method: 'DELETE' });
|
|
setProviders(providers.filter(p => p.id !== id));
|
|
toast.success('Provider deleted successfully');
|
|
} catch (error) {
|
|
showErrorToast(error, toast);
|
|
}
|
|
};
|
|
|
|
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<Skeleton className="h-10 w-full" />
|
|
<Skeleton className="h-64 w-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header with status indicators */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-2xl font-semibold">Authentication & SSO</h2>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Configure how users authenticate with your application
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className={`h-2 w-2 rounded-full ${providers.length > 0 ? 'bg-green-500' : 'bg-muted'}`} />
|
|
<span className="text-sm text-muted-foreground">
|
|
{providers.length} Provider{providers.length !== 1 ? 's' : ''} configured
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Authentication Methods Overview */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg font-semibold">Active Authentication Methods</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
{/* Email & Password - Always enabled */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-2 w-2 rounded-full bg-green-500" />
|
|
<span className="text-sm font-medium">Email & Password</span>
|
|
<Badge variant="secondary" className="text-xs">Default</Badge>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">Always enabled</span>
|
|
</div>
|
|
|
|
{/* Header Authentication Status */}
|
|
{headerAuthEnabled && (
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-2 w-2 rounded-full bg-green-500" />
|
|
<span className="text-sm font-medium">Header Authentication</span>
|
|
<Badge variant="secondary" className="text-xs">Auto-login</Badge>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">Via reverse proxy</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* SSO Providers Status */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<div className={`h-2 w-2 rounded-full ${providers.length > 0 ? 'bg-green-500' : 'bg-muted'}`} />
|
|
<span className="text-sm font-medium">SSO/OIDC Providers</span>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">
|
|
{providers.length > 0 ? `${providers.length} provider${providers.length !== 1 ? 's' : ''} configured` : 'Not configured'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Header Auth Info */}
|
|
{headerAuthEnabled && (
|
|
<Alert className="mt-4">
|
|
<Shield className="h-4 w-4" />
|
|
<AlertDescription className="text-xs">
|
|
Header authentication is enabled. Users authenticated by your reverse proxy will be automatically logged in.
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* SSO Providers */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle className="text-lg font-semibold">External Identity Providers</CardTitle>
|
|
<CardDescription className="text-sm">
|
|
Connect external OIDC/OAuth providers (Google, Azure AD, etc.) to allow users to sign in with their existing accounts
|
|
</CardDescription>
|
|
</div>
|
|
<Dialog open={showProviderDialog} onOpenChange={setShowProviderDialog}>
|
|
<DialogTrigger asChild>
|
|
<Button>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Add Provider
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="max-w-2xl max-h-[90vh] md:max-h-[85vh] lg:max-h-[90vh] overflow-hidden flex flex-col">
|
|
<DialogHeader className="flex-shrink-0">
|
|
<DialogTitle>{editingProvider ? 'Edit SSO Provider' : 'Add SSO Provider'}</DialogTitle>
|
|
<DialogDescription>
|
|
{editingProvider
|
|
? 'Update the configuration for this identity provider'
|
|
: 'Configure an external identity provider for user authentication'}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex-1 overflow-y-auto px-1 -mx-1">
|
|
<Tabs value={providerType} onValueChange={(value) => setProviderType(value as 'oidc' | 'saml')}>
|
|
<TabsList className="grid w-full grid-cols-2 sticky top-0 z-10 bg-background">
|
|
<TabsTrigger value="oidc">OIDC / OAuth2</TabsTrigger>
|
|
<TabsTrigger value="saml">SAML 2.0</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* Common Fields */}
|
|
<div className="space-y-4 mt-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="providerId">Provider ID</Label>
|
|
<Input
|
|
id="providerId"
|
|
value={providerForm.providerId}
|
|
onChange={e => setProviderForm(prev => ({ ...prev, providerId: e.target.value }))}
|
|
placeholder="google-sso"
|
|
disabled={!!editingProvider}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="domain">Email Domain</Label>
|
|
<Input
|
|
id="domain"
|
|
value={providerForm.domain}
|
|
onChange={e => setProviderForm(prev => ({ ...prev, domain: e.target.value }))}
|
|
placeholder="example.com"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="issuer">Issuer URL</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="issuer"
|
|
value={providerForm.issuer}
|
|
onChange={e => setProviderForm(prev => ({ ...prev, issuer: e.target.value }))}
|
|
placeholder={providerType === 'oidc' ? "https://accounts.google.com" : "https://idp.example.com"}
|
|
/>
|
|
{providerType === 'oidc' && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={discoverOIDC}
|
|
disabled={isDiscovering}
|
|
>
|
|
{isDiscovering ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Discover'}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="organizationId">Organization ID (Optional)</Label>
|
|
<Input
|
|
id="organizationId"
|
|
value={providerForm.organizationId}
|
|
onChange={e => setProviderForm(prev => ({ ...prev, organizationId: e.target.value }))}
|
|
placeholder="org_123"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">Link this provider to an organization for automatic user provisioning</p>
|
|
</div>
|
|
</div>
|
|
|
|
<TabsContent value="oidc" className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="clientId">Client ID</Label>
|
|
<Input
|
|
id="clientId"
|
|
value={providerForm.clientId}
|
|
onChange={e => setProviderForm(prev => ({ ...prev, clientId: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="clientSecret">Client Secret</Label>
|
|
<Input
|
|
id="clientSecret"
|
|
type="password"
|
|
value={providerForm.clientSecret}
|
|
onChange={e => setProviderForm(prev => ({ ...prev, clientSecret: e.target.value }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="authEndpoint">Authorization Endpoint</Label>
|
|
<Input
|
|
id="authEndpoint"
|
|
value={providerForm.authorizationEndpoint}
|
|
onChange={e => setProviderForm(prev => ({ ...prev, authorizationEndpoint: e.target.value }))}
|
|
placeholder="https://accounts.google.com/o/oauth2/auth"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tokenEndpoint">Token Endpoint</Label>
|
|
<Input
|
|
id="tokenEndpoint"
|
|
value={providerForm.tokenEndpoint}
|
|
onChange={e => setProviderForm(prev => ({ ...prev, tokenEndpoint: e.target.value }))}
|
|
placeholder="https://oauth2.googleapis.com/token"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="scopes">OAuth Scopes</Label>
|
|
<MultiSelect
|
|
options={[
|
|
{ label: "OpenID", value: "openid" },
|
|
{ label: "Email", value: "email" },
|
|
{ label: "Profile", value: "profile" },
|
|
{ label: "Offline Access", value: "offline_access" },
|
|
]}
|
|
selected={providerForm.scopes}
|
|
onChange={(scopes) => setProviderForm(prev => ({ ...prev, scopes }))}
|
|
placeholder="Select scopes..."
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Select the OAuth scopes to request from the provider
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Switch
|
|
id="pkce"
|
|
checked={providerForm.pkce}
|
|
onCheckedChange={(checked) => setProviderForm(prev => ({ ...prev, pkce: checked }))}
|
|
/>
|
|
<Label htmlFor="pkce">Enable PKCE</Label>
|
|
</div>
|
|
|
|
<Alert>
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription>
|
|
<div className="space-y-2">
|
|
<p>Redirect URL: {window.location.origin}/api/auth/sso/callback/{providerForm.providerId || '{provider-id}'}</p>
|
|
{isTrustedIssuer(providerForm.issuer, ['google.com']) && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Note: Google doesn't support the "offline_access" scope. Make sure to exclude it from the selected scopes.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</AlertDescription>
|
|
</Alert>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="saml" className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="entryPoint">SAML Entry Point</Label>
|
|
<Input
|
|
id="entryPoint"
|
|
value={providerForm.entryPoint}
|
|
onChange={e => setProviderForm(prev => ({ ...prev, entryPoint: e.target.value }))}
|
|
placeholder="https://idp.example.com/sso"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="cert">X.509 Certificate</Label>
|
|
<Textarea
|
|
id="cert"
|
|
value={providerForm.cert}
|
|
onChange={e => setProviderForm(prev => ({ ...prev, cert: e.target.value }))}
|
|
placeholder="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
|
|
rows={6}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Switch
|
|
id="wantAssertionsSigned"
|
|
checked={providerForm.wantAssertionsSigned}
|
|
onCheckedChange={(checked) => setProviderForm(prev => ({ ...prev, wantAssertionsSigned: checked }))}
|
|
/>
|
|
<Label htmlFor="wantAssertionsSigned">Require Signed Assertions</Label>
|
|
</div>
|
|
|
|
<Alert>
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription>
|
|
<div className="space-y-1">
|
|
<p>Callback URL: {window.location.origin}/api/auth/sso/saml2/callback/{providerForm.providerId || '{provider-id}'}</p>
|
|
<p>SP Metadata: {window.location.origin}/api/auth/sso/saml2/sp/metadata?providerId={providerForm.providerId || '{provider-id}'}</p>
|
|
</div>
|
|
</AlertDescription>
|
|
</Alert>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
<DialogFooter className="flex-shrink-0 pt-4 border-t">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setShowProviderDialog(false);
|
|
setEditingProvider(null);
|
|
// Reset form
|
|
setProviderForm({
|
|
issuer: '',
|
|
domain: '',
|
|
providerId: '',
|
|
organizationId: '',
|
|
clientId: '',
|
|
clientSecret: '',
|
|
authorizationEndpoint: '',
|
|
tokenEndpoint: '',
|
|
jwksEndpoint: '',
|
|
userInfoEndpoint: '',
|
|
discoveryEndpoint: '',
|
|
scopes: ['openid', 'email', 'profile'] as string[],
|
|
pkce: true,
|
|
entryPoint: '',
|
|
cert: '',
|
|
callbackUrl: '',
|
|
audience: '',
|
|
wantAssertionsSigned: true,
|
|
signatureAlgorithm: 'sha256',
|
|
digestAlgorithm: 'sha256',
|
|
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
|
});
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={createProvider} disabled={addingProvider}>
|
|
{addingProvider ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
{editingProvider ? 'Updating...' : 'Creating...'}
|
|
</>
|
|
) : (
|
|
editingProvider ? 'Update Provider' : 'Create Provider'
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{providers.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<div className="mx-auto h-12 w-12 text-muted-foreground/50">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="mt-4 text-lg font-medium">No SSO providers configured</h3>
|
|
<p className="mt-2 text-sm text-muted-foreground max-w-sm mx-auto">
|
|
Enable Single Sign-On by adding an external identity provider like Google, Azure AD, or any OIDC-compliant service.
|
|
</p>
|
|
<div className="mt-6">
|
|
<Button onClick={() => setShowProviderDialog(true)}>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Add Your First Provider
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{providers.map(provider => (
|
|
<div key={provider.id} className="border rounded-lg p-4 hover:bg-muted/50 transition-colors">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<h4 className="font-semibold text-sm">{provider.providerId}</h4>
|
|
<Badge variant="outline" className="text-xs">
|
|
{provider.samlConfig ? 'SAML' : 'OIDC'}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground mb-3">{provider.domain}</p>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-start gap-2 text-sm">
|
|
<span className="text-muted-foreground min-w-[80px]">Issuer:</span>
|
|
<span className="text-muted-foreground break-all">{provider.issuer}</span>
|
|
</div>
|
|
|
|
{provider.oidcConfig && (
|
|
<>
|
|
<div className="flex items-start gap-2 text-sm">
|
|
<span className="text-muted-foreground min-w-[80px]">Client ID:</span>
|
|
<span className="font-mono text-xs text-muted-foreground break-all">{provider.oidcConfig.clientId}</span>
|
|
</div>
|
|
|
|
{provider.oidcConfig.scopes && provider.oidcConfig.scopes.length > 0 && (
|
|
<div className="flex items-start gap-2 text-sm">
|
|
<span className="text-muted-foreground min-w-[80px]">Scopes:</span>
|
|
<div className="flex flex-wrap gap-1">
|
|
{provider.oidcConfig.scopes.map(scope => (
|
|
<Badge key={scope} variant="secondary" className="text-xs">
|
|
{scope}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{provider.samlConfig && (
|
|
<div className="flex items-start gap-2 text-sm">
|
|
<span className="text-muted-foreground min-w-[80px]">Entry Point:</span>
|
|
<span className="text-muted-foreground break-all">{provider.samlConfig.entryPoint}</span>
|
|
</div>
|
|
)}
|
|
|
|
{provider.organizationId && (
|
|
<div className="flex items-start gap-2 text-sm">
|
|
<span className="text-muted-foreground min-w-[80px]">Organization:</span>
|
|
<span className="text-muted-foreground">{provider.organizationId}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-muted-foreground hover:text-foreground"
|
|
onClick={() => startEditProvider(provider)}
|
|
>
|
|
<Edit2 className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-muted-foreground hover:text-destructive"
|
|
onClick={() => deleteProvider(provider.id)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
} |