mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-07 03:56: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);
|
||||
try {
|
||||
if (!domain && !ssoEmail) {
|
||||
@@ -66,6 +66,7 @@ export function LoginForm() {
|
||||
await authClient.signIn.sso({
|
||||
email: ssoEmail || undefined,
|
||||
domain: domain,
|
||||
providerId: providerId,
|
||||
callbackURL: '/',
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -175,7 +176,7 @@ export function LoginForm() {
|
||||
key={provider.id}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => handleSSOLogin(provider.domain)}
|
||||
onClick={() => handleSSOLogin(provider.domain, provider.providerId)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Globe className="h-4 w-4 mr-2" />
|
||||
@@ -217,7 +218,7 @@ export function LoginForm() {
|
||||
<CardFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => handleSSOLogin()}
|
||||
onClick={() => handleSSOLogin(undefined, undefined)}
|
||||
disabled={isLoading || !ssoEmail}
|
||||
>
|
||||
{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 { apiRequest, showErrorToast } from '@/lib/utils';
|
||||
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 { 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';
|
||||
|
||||
interface SSOProvider {
|
||||
id: string;
|
||||
@@ -58,8 +59,10 @@ 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');
|
||||
@@ -77,7 +80,7 @@ export function SSOSettings() {
|
||||
jwksEndpoint: '',
|
||||
userInfoEndpoint: '',
|
||||
discoveryEndpoint: '',
|
||||
scopes: ['openid', 'email', 'profile'],
|
||||
scopes: ['openid', 'email', 'profile'] as string[],
|
||||
pkce: true,
|
||||
// SAML fields
|
||||
entryPoint: '',
|
||||
@@ -145,6 +148,7 @@ export function SSOSettings() {
|
||||
};
|
||||
|
||||
const createProvider = async () => {
|
||||
setAddingProvider(true);
|
||||
try {
|
||||
const requestData: any = {
|
||||
providerId: providerForm.providerId,
|
||||
@@ -162,7 +166,7 @@ export function SSOSettings() {
|
||||
requestData.jwksEndpoint = providerForm.jwksEndpoint;
|
||||
requestData.userInfoEndpoint = providerForm.userInfoEndpoint;
|
||||
requestData.discoveryEndpoint = providerForm.discoveryEndpoint;
|
||||
// Don't send scopes - let the backend handle provider-specific defaults
|
||||
requestData.scopes = providerForm.scopes;
|
||||
requestData.pkce = providerForm.pkce;
|
||||
} else {
|
||||
requestData.entryPoint = providerForm.entryPoint;
|
||||
@@ -175,13 +179,26 @@ export function SSOSettings() {
|
||||
requestData.identifierFormat = providerForm.identifierFormat;
|
||||
}
|
||||
|
||||
const newProvider = await apiRequest<SSOProvider>('/sso/providers', {
|
||||
method: 'POST',
|
||||
data: requestData,
|
||||
});
|
||||
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');
|
||||
}
|
||||
|
||||
setProviders([...providers, newProvider]);
|
||||
setShowProviderDialog(false);
|
||||
setEditingProvider(null);
|
||||
setProviderForm({
|
||||
issuer: '',
|
||||
domain: '',
|
||||
@@ -194,7 +211,7 @@ export function SSOSettings() {
|
||||
jwksEndpoint: '',
|
||||
userInfoEndpoint: '',
|
||||
discoveryEndpoint: '',
|
||||
scopes: ['openid', 'email', 'profile'],
|
||||
scopes: ['openid', 'email', 'profile'] as string[],
|
||||
pkce: true,
|
||||
entryPoint: '',
|
||||
cert: '',
|
||||
@@ -205,12 +222,39 @@ export function SSOSettings() {
|
||||
digestAlgorithm: 'sha256',
|
||||
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||
});
|
||||
toast.success('SSO provider created successfully');
|
||||
} 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' });
|
||||
@@ -237,8 +281,8 @@ export function SSOSettings() {
|
||||
{/* Header with status indicators */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Authentication & SSO</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<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>
|
||||
@@ -251,9 +295,9 @@ export function SSOSettings() {
|
||||
</div>
|
||||
|
||||
{/* Authentication Methods Overview */}
|
||||
<Card className="mb-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Active Authentication Methods</CardTitle>
|
||||
<CardTitle className="text-lg font-semibold">Active Authentication Methods</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
@@ -308,8 +352,8 @@ export function SSOSettings() {
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>External Identity Providers</CardTitle>
|
||||
<CardDescription>
|
||||
<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>
|
||||
@@ -322,9 +366,11 @@ export function SSOSettings() {
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add SSO Provider</DialogTitle>
|
||||
<DialogTitle>{editingProvider ? 'Edit SSO Provider' : 'Add SSO Provider'}</DialogTitle>
|
||||
<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>
|
||||
</DialogHeader>
|
||||
<Tabs value={providerType} onValueChange={(value) => setProviderType(value as 'oidc' | 'saml')}>
|
||||
@@ -343,6 +389,7 @@ export function SSOSettings() {
|
||||
value={providerForm.providerId}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, providerId: e.target.value }))}
|
||||
placeholder="google-sso"
|
||||
disabled={!!editingProvider}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -430,6 +477,24 @@ export function SSOSettings() {
|
||||
/>
|
||||
</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"
|
||||
@@ -446,7 +511,7 @@ export function SSOSettings() {
|
||||
<p>Redirect URL: {window.location.origin}/api/auth/sso/callback/{providerForm.providerId || '{provider-id}'}</p>
|
||||
{providerForm.issuer.includes('google.com') && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
@@ -497,10 +562,49 @@ export function SSOSettings() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<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
|
||||
</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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -526,56 +630,83 @@ export function SSOSettings() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{providers.map(provider => (
|
||||
<Card key={provider.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-semibold">{provider.providerId}</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{provider.samlConfig ? 'SAML' : 'OIDC'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{provider.domain}</p>
|
||||
<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="destructive"
|
||||
size="sm"
|
||||
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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
))}
|
||||
</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 () => {
|
||||
try {
|
||||
// Check SSO providers
|
||||
const providers = await apiRequest<any[]>('/sso/providers').catch(() => []);
|
||||
// Check SSO providers - use public endpoint since this is used on login page
|
||||
const providers = await apiRequest<any[]>('/sso/providers/public').catch(() => []);
|
||||
const applications = await apiRequest<any[]>('/sso/applications').catch(() => []);
|
||||
|
||||
setAuthMethods({
|
||||
|
||||
@@ -88,22 +88,8 @@ export async function POST(context: APIContext) {
|
||||
}
|
||||
} = body;
|
||||
|
||||
// Handle provider-specific scope defaults
|
||||
let finalScopes = scopes;
|
||||
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"];
|
||||
}
|
||||
}
|
||||
// Use provided scopes or default if not specified
|
||||
const finalScopes = scopes || ["openid", "email", "profile"];
|
||||
|
||||
registrationBody.oidcConfig = {
|
||||
clientId,
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function GET(context: APIContext) {
|
||||
const formattedProviders = providers.map(provider => ({
|
||||
...provider,
|
||||
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), {
|
||||
@@ -48,6 +48,7 @@ export async function POST(context: APIContext) {
|
||||
mapping,
|
||||
providerId,
|
||||
organizationId,
|
||||
scopes,
|
||||
} = body;
|
||||
|
||||
// Validate required fields
|
||||
@@ -86,6 +87,7 @@ export async function POST(context: APIContext) {
|
||||
tokenEndpoint,
|
||||
jwksEndpoint,
|
||||
userInfoEndpoint,
|
||||
scopes: scopes || ["openid", "email", "profile"],
|
||||
mapping: mapping || {
|
||||
id: "sub",
|
||||
email: "email",
|
||||
@@ -113,7 +115,7 @@ export async function POST(context: APIContext) {
|
||||
const formattedProvider = {
|
||||
...newProvider,
|
||||
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), {
|
||||
@@ -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
|
||||
export async function DELETE(context: APIContext) {
|
||||
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