mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-07 12:06:46 +03:00
updates
This commit is contained in:
@@ -55,7 +55,7 @@ export function LoginForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSSOLogin(domain?: string) {
|
async function handleSSOLogin(domain?: string, providerId?: string) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
if (!domain && !ssoEmail) {
|
if (!domain && !ssoEmail) {
|
||||||
@@ -66,6 +66,7 @@ export function LoginForm() {
|
|||||||
await authClient.signIn.sso({
|
await authClient.signIn.sso({
|
||||||
email: ssoEmail || undefined,
|
email: ssoEmail || undefined,
|
||||||
domain: domain,
|
domain: domain,
|
||||||
|
providerId: providerId,
|
||||||
callbackURL: '/',
|
callbackURL: '/',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -175,7 +176,7 @@ export function LoginForm() {
|
|||||||
key={provider.id}
|
key={provider.id}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => handleSSOLogin(provider.domain)}
|
onClick={() => handleSSOLogin(provider.domain, provider.providerId)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<Globe className="h-4 w-4 mr-2" />
|
<Globe className="h-4 w-4 mr-2" />
|
||||||
@@ -217,7 +218,7 @@ export function LoginForm() {
|
|||||||
<CardFooter>
|
<CardFooter>
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => handleSSOLogin()}
|
onClick={() => handleSSOLogin(undefined, undefined)}
|
||||||
disabled={isLoading || !ssoEmail}
|
disabled={isLoading || !ssoEmail}
|
||||||
>
|
>
|
||||||
{isLoading ? 'Redirecting...' : 'Continue with SSO'}
|
{isLoading ? 'Redirecting...' : 'Continue with SSO'}
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
|
|||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
import { apiRequest, showErrorToast } from '@/lib/utils';
|
import { apiRequest, showErrorToast } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Plus, Trash2, Loader2, AlertCircle, Shield } from 'lucide-react';
|
import { Plus, Trash2, Loader2, AlertCircle, Shield, Edit2 } from 'lucide-react';
|
||||||
import { Skeleton } from '../ui/skeleton';
|
import { Skeleton } from '../ui/skeleton';
|
||||||
import { Badge } from '../ui/badge';
|
import { Badge } from '../ui/badge';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { MultiSelect } from '@/components/ui/multi-select';
|
||||||
|
|
||||||
interface SSOProvider {
|
interface SSOProvider {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -58,8 +59,10 @@ export function SSOSettings() {
|
|||||||
const [providers, setProviders] = useState<SSOProvider[]>([]);
|
const [providers, setProviders] = useState<SSOProvider[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [showProviderDialog, setShowProviderDialog] = useState(false);
|
const [showProviderDialog, setShowProviderDialog] = useState(false);
|
||||||
|
const [addingProvider, setAddingProvider] = useState(false);
|
||||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||||
const [headerAuthEnabled, setHeaderAuthEnabled] = useState(false);
|
const [headerAuthEnabled, setHeaderAuthEnabled] = useState(false);
|
||||||
|
const [editingProvider, setEditingProvider] = useState<SSOProvider | null>(null);
|
||||||
|
|
||||||
// Form states for new provider
|
// Form states for new provider
|
||||||
const [providerType, setProviderType] = useState<'oidc' | 'saml'>('oidc');
|
const [providerType, setProviderType] = useState<'oidc' | 'saml'>('oidc');
|
||||||
@@ -77,7 +80,7 @@ export function SSOSettings() {
|
|||||||
jwksEndpoint: '',
|
jwksEndpoint: '',
|
||||||
userInfoEndpoint: '',
|
userInfoEndpoint: '',
|
||||||
discoveryEndpoint: '',
|
discoveryEndpoint: '',
|
||||||
scopes: ['openid', 'email', 'profile'],
|
scopes: ['openid', 'email', 'profile'] as string[],
|
||||||
pkce: true,
|
pkce: true,
|
||||||
// SAML fields
|
// SAML fields
|
||||||
entryPoint: '',
|
entryPoint: '',
|
||||||
@@ -145,6 +148,7 @@ export function SSOSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createProvider = async () => {
|
const createProvider = async () => {
|
||||||
|
setAddingProvider(true);
|
||||||
try {
|
try {
|
||||||
const requestData: any = {
|
const requestData: any = {
|
||||||
providerId: providerForm.providerId,
|
providerId: providerForm.providerId,
|
||||||
@@ -162,7 +166,7 @@ export function SSOSettings() {
|
|||||||
requestData.jwksEndpoint = providerForm.jwksEndpoint;
|
requestData.jwksEndpoint = providerForm.jwksEndpoint;
|
||||||
requestData.userInfoEndpoint = providerForm.userInfoEndpoint;
|
requestData.userInfoEndpoint = providerForm.userInfoEndpoint;
|
||||||
requestData.discoveryEndpoint = providerForm.discoveryEndpoint;
|
requestData.discoveryEndpoint = providerForm.discoveryEndpoint;
|
||||||
// Don't send scopes - let the backend handle provider-specific defaults
|
requestData.scopes = providerForm.scopes;
|
||||||
requestData.pkce = providerForm.pkce;
|
requestData.pkce = providerForm.pkce;
|
||||||
} else {
|
} else {
|
||||||
requestData.entryPoint = providerForm.entryPoint;
|
requestData.entryPoint = providerForm.entryPoint;
|
||||||
@@ -175,13 +179,26 @@ export function SSOSettings() {
|
|||||||
requestData.identifierFormat = providerForm.identifierFormat;
|
requestData.identifierFormat = providerForm.identifierFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newProvider = await apiRequest<SSOProvider>('/sso/providers', {
|
if (editingProvider) {
|
||||||
method: 'POST',
|
// Update existing provider
|
||||||
data: requestData,
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
setProviders([...providers, newProvider]);
|
|
||||||
setShowProviderDialog(false);
|
setShowProviderDialog(false);
|
||||||
|
setEditingProvider(null);
|
||||||
setProviderForm({
|
setProviderForm({
|
||||||
issuer: '',
|
issuer: '',
|
||||||
domain: '',
|
domain: '',
|
||||||
@@ -194,7 +211,7 @@ export function SSOSettings() {
|
|||||||
jwksEndpoint: '',
|
jwksEndpoint: '',
|
||||||
userInfoEndpoint: '',
|
userInfoEndpoint: '',
|
||||||
discoveryEndpoint: '',
|
discoveryEndpoint: '',
|
||||||
scopes: ['openid', 'email', 'profile'],
|
scopes: ['openid', 'email', 'profile'] as string[],
|
||||||
pkce: true,
|
pkce: true,
|
||||||
entryPoint: '',
|
entryPoint: '',
|
||||||
cert: '',
|
cert: '',
|
||||||
@@ -205,12 +222,39 @@ export function SSOSettings() {
|
|||||||
digestAlgorithm: 'sha256',
|
digestAlgorithm: 'sha256',
|
||||||
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||||
});
|
});
|
||||||
toast.success('SSO provider created successfully');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showErrorToast(error, toast);
|
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) => {
|
const deleteProvider = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await apiRequest(`/sso/providers?id=${id}`, { method: 'DELETE' });
|
await apiRequest(`/sso/providers?id=${id}`, { method: 'DELETE' });
|
||||||
@@ -237,8 +281,8 @@ export function SSOSettings() {
|
|||||||
{/* Header with status indicators */}
|
{/* Header with status indicators */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">Authentication & SSO</h3>
|
<h2 className="text-2xl font-semibold">Authentication & SSO</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Configure how users authenticate with your application
|
Configure how users authenticate with your application
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -251,9 +295,9 @@ export function SSOSettings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Authentication Methods Overview */}
|
{/* Authentication Methods Overview */}
|
||||||
<Card className="mb-6">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Active Authentication Methods</CardTitle>
|
<CardTitle className="text-lg font-semibold">Active Authentication Methods</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -308,8 +352,8 @@ export function SSOSettings() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>External Identity Providers</CardTitle>
|
<CardTitle className="text-lg font-semibold">External Identity Providers</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription className="text-sm">
|
||||||
Connect external OIDC/OAuth providers (Google, Azure AD, etc.) to allow users to sign in with their existing accounts
|
Connect external OIDC/OAuth providers (Google, Azure AD, etc.) to allow users to sign in with their existing accounts
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
@@ -322,9 +366,11 @@ export function SSOSettings() {
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add SSO Provider</DialogTitle>
|
<DialogTitle>{editingProvider ? 'Edit SSO Provider' : 'Add SSO Provider'}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Configure an external identity provider for user authentication
|
{editingProvider
|
||||||
|
? 'Update the configuration for this identity provider'
|
||||||
|
: 'Configure an external identity provider for user authentication'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Tabs value={providerType} onValueChange={(value) => setProviderType(value as 'oidc' | 'saml')}>
|
<Tabs value={providerType} onValueChange={(value) => setProviderType(value as 'oidc' | 'saml')}>
|
||||||
@@ -343,6 +389,7 @@ export function SSOSettings() {
|
|||||||
value={providerForm.providerId}
|
value={providerForm.providerId}
|
||||||
onChange={e => setProviderForm(prev => ({ ...prev, providerId: e.target.value }))}
|
onChange={e => setProviderForm(prev => ({ ...prev, providerId: e.target.value }))}
|
||||||
placeholder="google-sso"
|
placeholder="google-sso"
|
||||||
|
disabled={!!editingProvider}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -430,6 +477,24 @@ export function SSOSettings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex items-center space-x-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="pkce"
|
id="pkce"
|
||||||
@@ -446,7 +511,7 @@ export function SSOSettings() {
|
|||||||
<p>Redirect URL: {window.location.origin}/api/auth/sso/callback/{providerForm.providerId || '{provider-id}'}</p>
|
<p>Redirect URL: {window.location.origin}/api/auth/sso/callback/{providerForm.providerId || '{provider-id}'}</p>
|
||||||
{providerForm.issuer.includes('google.com') && (
|
{providerForm.issuer.includes('google.com') && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Note: Google doesn't support the "offline_access" scope. The system will automatically use appropriate scopes.
|
Note: Google doesn't support the "offline_access" scope. Make sure to exclude it from the selected scopes.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -497,10 +562,49 @@ export function SSOSettings() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setShowProviderDialog(false)}>
|
<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
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={createProvider}>Create Provider</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>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -526,56 +630,83 @@ export function SSOSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{providers.map(provider => (
|
{providers.map(provider => (
|
||||||
<Card key={provider.id}>
|
<div key={provider.id} className="border rounded-lg p-4 hover:bg-muted/50 transition-colors">
|
||||||
<CardHeader>
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex-1 min-w-0">
|
||||||
<div>
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<div className="flex items-center gap-2">
|
<h4 className="font-semibold text-sm">{provider.providerId}</h4>
|
||||||
<h4 className="font-semibold">{provider.providerId}</h4>
|
<Badge variant="outline" className="text-xs">
|
||||||
<Badge variant="outline" className="text-xs">
|
{provider.samlConfig ? 'SAML' : 'OIDC'}
|
||||||
{provider.samlConfig ? 'SAML' : 'OIDC'}
|
</Badge>
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">{provider.domain}</p>
|
|
||||||
</div>
|
</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
|
<Button
|
||||||
variant="destructive"
|
variant="ghost"
|
||||||
size="sm"
|
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)}
|
onClick={() => deleteProvider(provider.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">Issuer</p>
|
|
||||||
<p className="text-muted-foreground break-all">{provider.issuer}</p>
|
|
||||||
</div>
|
|
||||||
{provider.oidcConfig && (
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">Client ID</p>
|
|
||||||
<p className="text-muted-foreground font-mono break-all">{provider.oidcConfig.clientId}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{provider.samlConfig && (
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">Entry Point</p>
|
|
||||||
<p className="text-muted-foreground break-all">{provider.samlConfig.entryPoint}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{provider.organizationId && (
|
|
||||||
<div className="col-span-2">
|
|
||||||
<p className="font-medium">Organization</p>
|
|
||||||
<p className="text-muted-foreground">{provider.organizationId}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
137
src/components/ui/multi-select.tsx
Normal file
137
src/components/ui/multi-select.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command"
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover"
|
||||||
|
|
||||||
|
interface MultiSelectProps {
|
||||||
|
options: { label: string; value: string }[]
|
||||||
|
selected: string[]
|
||||||
|
onChange: (selected: string[]) => void
|
||||||
|
placeholder?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MultiSelect({
|
||||||
|
options,
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
placeholder = "Select items...",
|
||||||
|
className,
|
||||||
|
}: MultiSelectProps) {
|
||||||
|
const [open, setOpen] = React.useState(false)
|
||||||
|
|
||||||
|
const handleUnselect = (item: string) => {
|
||||||
|
onChange(selected.filter((i) => i !== item))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className={`w-full justify-between ${selected.length > 0 ? "h-full" : ""} ${className}`}
|
||||||
|
>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{selected.length > 0 ? (
|
||||||
|
selected.map((item) => (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
key={item}
|
||||||
|
className="mr-1 mb-1"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleUnselect(item)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{options.find((option) => option.value === item)?.label || item}
|
||||||
|
<button
|
||||||
|
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleUnselect(item)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
handleUnselect(item)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">{placeholder}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0" align="start">
|
||||||
|
<Command className={className}>
|
||||||
|
<CommandInput placeholder="Search..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No item found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(
|
||||||
|
selected.includes(option.value)
|
||||||
|
? selected.filter((item) => item !== option.value)
|
||||||
|
: [...selected, option.value]
|
||||||
|
)
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary ${
|
||||||
|
selected.includes(option.value)
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "opacity-50 [&_svg]:invisible"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={3}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -35,8 +35,8 @@ export function useAuthMethods() {
|
|||||||
|
|
||||||
const loadAuthMethods = async () => {
|
const loadAuthMethods = async () => {
|
||||||
try {
|
try {
|
||||||
// Check SSO providers
|
// Check SSO providers - use public endpoint since this is used on login page
|
||||||
const providers = await apiRequest<any[]>('/sso/providers').catch(() => []);
|
const providers = await apiRequest<any[]>('/sso/providers/public').catch(() => []);
|
||||||
const applications = await apiRequest<any[]>('/sso/applications').catch(() => []);
|
const applications = await apiRequest<any[]>('/sso/applications').catch(() => []);
|
||||||
|
|
||||||
setAuthMethods({
|
setAuthMethods({
|
||||||
|
|||||||
@@ -88,22 +88,8 @@ export async function POST(context: APIContext) {
|
|||||||
}
|
}
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
// Handle provider-specific scope defaults
|
// Use provided scopes or default if not specified
|
||||||
let finalScopes = scopes;
|
const finalScopes = scopes || ["openid", "email", "profile"];
|
||||||
if (!finalScopes) {
|
|
||||||
// Check if this is a Google provider
|
|
||||||
const isGoogle = issuer.includes('google.com') ||
|
|
||||||
issuer.includes('googleapis.com') ||
|
|
||||||
domain.includes('google.com');
|
|
||||||
|
|
||||||
if (isGoogle) {
|
|
||||||
// Google doesn't support offline_access scope
|
|
||||||
finalScopes = ["openid", "email", "profile"];
|
|
||||||
} else {
|
|
||||||
// Default scopes for other providers
|
|
||||||
finalScopes = ["openid", "email", "profile", "offline_access"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registrationBody.oidcConfig = {
|
registrationBody.oidcConfig = {
|
||||||
clientId,
|
clientId,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export async function GET(context: APIContext) {
|
|||||||
const formattedProviders = providers.map(provider => ({
|
const formattedProviders = providers.map(provider => ({
|
||||||
...provider,
|
...provider,
|
||||||
oidcConfig: provider.oidcConfig ? JSON.parse(provider.oidcConfig) : undefined,
|
oidcConfig: provider.oidcConfig ? JSON.parse(provider.oidcConfig) : undefined,
|
||||||
samlConfig: provider.samlConfig ? JSON.parse(provider.samlConfig) : undefined,
|
samlConfig: (provider as any).samlConfig ? JSON.parse((provider as any).samlConfig) : undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return new Response(JSON.stringify(formattedProviders), {
|
return new Response(JSON.stringify(formattedProviders), {
|
||||||
@@ -48,6 +48,7 @@ export async function POST(context: APIContext) {
|
|||||||
mapping,
|
mapping,
|
||||||
providerId,
|
providerId,
|
||||||
organizationId,
|
organizationId,
|
||||||
|
scopes,
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
@@ -86,6 +87,7 @@ export async function POST(context: APIContext) {
|
|||||||
tokenEndpoint,
|
tokenEndpoint,
|
||||||
jwksEndpoint,
|
jwksEndpoint,
|
||||||
userInfoEndpoint,
|
userInfoEndpoint,
|
||||||
|
scopes: scopes || ["openid", "email", "profile"],
|
||||||
mapping: mapping || {
|
mapping: mapping || {
|
||||||
id: "sub",
|
id: "sub",
|
||||||
email: "email",
|
email: "email",
|
||||||
@@ -113,7 +115,7 @@ export async function POST(context: APIContext) {
|
|||||||
const formattedProvider = {
|
const formattedProvider = {
|
||||||
...newProvider,
|
...newProvider,
|
||||||
oidcConfig: newProvider.oidcConfig ? JSON.parse(newProvider.oidcConfig) : undefined,
|
oidcConfig: newProvider.oidcConfig ? JSON.parse(newProvider.oidcConfig) : undefined,
|
||||||
samlConfig: newProvider.samlConfig ? JSON.parse(newProvider.samlConfig) : undefined,
|
samlConfig: (newProvider as any).samlConfig ? JSON.parse((newProvider as any).samlConfig) : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
return new Response(JSON.stringify(formattedProvider), {
|
return new Response(JSON.stringify(formattedProvider), {
|
||||||
@@ -125,6 +127,100 @@ export async function POST(context: APIContext) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PUT /api/sso/providers - Update an existing SSO provider
|
||||||
|
export async function PUT(context: APIContext) {
|
||||||
|
try {
|
||||||
|
const { user, response } = await requireAuth(context);
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const url = new URL(context.request.url);
|
||||||
|
const providerId = url.searchParams.get("id");
|
||||||
|
|
||||||
|
if (!providerId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Provider ID is required" }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await context.request.json();
|
||||||
|
const {
|
||||||
|
issuer,
|
||||||
|
domain,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
authorizationEndpoint,
|
||||||
|
tokenEndpoint,
|
||||||
|
jwksEndpoint,
|
||||||
|
userInfoEndpoint,
|
||||||
|
scopes,
|
||||||
|
organizationId,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
// Get existing provider
|
||||||
|
const [existingProvider] = await db
|
||||||
|
.select()
|
||||||
|
.from(ssoProviders)
|
||||||
|
.where(eq(ssoProviders.id, providerId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existingProvider) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Provider not found" }),
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse existing config
|
||||||
|
const existingConfig = JSON.parse(existingProvider.oidcConfig);
|
||||||
|
|
||||||
|
// Create updated OIDC config
|
||||||
|
const updatedOidcConfig = {
|
||||||
|
...existingConfig,
|
||||||
|
clientId: clientId || existingConfig.clientId,
|
||||||
|
clientSecret: clientSecret || existingConfig.clientSecret,
|
||||||
|
authorizationEndpoint: authorizationEndpoint || existingConfig.authorizationEndpoint,
|
||||||
|
tokenEndpoint: tokenEndpoint || existingConfig.tokenEndpoint,
|
||||||
|
jwksEndpoint: jwksEndpoint || existingConfig.jwksEndpoint,
|
||||||
|
userInfoEndpoint: userInfoEndpoint || existingConfig.userInfoEndpoint,
|
||||||
|
scopes: scopes || existingConfig.scopes || ["openid", "email", "profile"],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update provider
|
||||||
|
const [updatedProvider] = await db
|
||||||
|
.update(ssoProviders)
|
||||||
|
.set({
|
||||||
|
issuer: issuer || existingProvider.issuer,
|
||||||
|
domain: domain || existingProvider.domain,
|
||||||
|
oidcConfig: JSON.stringify(updatedOidcConfig),
|
||||||
|
organizationId: organizationId !== undefined ? organizationId : existingProvider.organizationId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(ssoProviders.id, providerId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Parse JSON fields before sending
|
||||||
|
const formattedProvider = {
|
||||||
|
...updatedProvider,
|
||||||
|
oidcConfig: JSON.parse(updatedProvider.oidcConfig),
|
||||||
|
samlConfig: (updatedProvider as any).samlConfig ? JSON.parse((updatedProvider as any).samlConfig) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(formattedProvider), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return createSecureErrorResponse(error, "SSO providers API");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DELETE /api/sso/providers - Delete a provider by ID
|
// DELETE /api/sso/providers - Delete a provider by ID
|
||||||
export async function DELETE(context: APIContext) {
|
export async function DELETE(context: APIContext) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
22
src/pages/api/sso/providers/public.ts
Normal file
22
src/pages/api/sso/providers/public.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { APIContext } from "astro";
|
||||||
|
import { createSecureErrorResponse } from "@/lib/utils";
|
||||||
|
import { db, ssoProviders } from "@/lib/db";
|
||||||
|
|
||||||
|
// GET /api/sso/providers/public - Get public SSO provider information for login page
|
||||||
|
export async function GET(context: APIContext) {
|
||||||
|
try {
|
||||||
|
// Get all providers but only return public information
|
||||||
|
const providers = await db.select({
|
||||||
|
id: ssoProviders.id,
|
||||||
|
providerId: ssoProviders.providerId,
|
||||||
|
domain: ssoProviders.domain,
|
||||||
|
}).from(ssoProviders);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(providers), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return createSecureErrorResponse(error, "Public SSO providers API");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user