diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index 82a6884..efad826 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -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} > @@ -217,7 +218,7 @@ export function LoginForm() { - + @@ -526,56 +630,83 @@ export function SSOSettings() { ) : ( -
+
{providers.map(provider => ( - - -
-
-
-

{provider.providerId}

- - {provider.samlConfig ? 'SAML' : 'OIDC'} - -
-

{provider.domain}

+
+
+
+
+

{provider.providerId}

+ + {provider.samlConfig ? 'SAML' : 'OIDC'} +
+

{provider.domain}

+ +
+
+ Issuer: + {provider.issuer} +
+ + {provider.oidcConfig && ( + <> +
+ Client ID: + {provider.oidcConfig.clientId} +
+ + {provider.oidcConfig.scopes && provider.oidcConfig.scopes.length > 0 && ( +
+ Scopes: +
+ {provider.oidcConfig.scopes.map(scope => ( + + {scope} + + ))} +
+
+ )} + + )} + + {provider.samlConfig && ( +
+ Entry Point: + {provider.samlConfig.entryPoint} +
+ )} + + {provider.organizationId && ( +
+ Organization: + {provider.organizationId} +
+ )} +
+
+ +
+
- - -
-
-

Issuer

-

{provider.issuer}

-
- {provider.oidcConfig && ( -
-

Client ID

-

{provider.oidcConfig.clientId}

-
- )} - {provider.samlConfig && ( -
-

Entry Point

-

{provider.samlConfig.entryPoint}

-
- )} - {provider.organizationId && ( -
-

Organization

-

{provider.organizationId}

-
- )} -
-
- +
+
))}
)} diff --git a/src/components/ui/multi-select.tsx b/src/components/ui/multi-select.tsx new file mode 100644 index 0000000..3cbe3b4 --- /dev/null +++ b/src/components/ui/multi-select.tsx @@ -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 ( + + + + + )) + ) : ( + {placeholder} + )} +
+ + + + + + + No item found. + + {options.map((option) => ( + { + onChange( + selected.includes(option.value) + ? selected.filter((item) => item !== option.value) + : [...selected, option.value] + ) + setOpen(true) + }} + > +
+ + + +
+ {option.label} +
+ ))} +
+
+
+
+ + ) +} \ No newline at end of file diff --git a/src/hooks/useAuthMethods.ts b/src/hooks/useAuthMethods.ts index 9f77a69..273fc1b 100644 --- a/src/hooks/useAuthMethods.ts +++ b/src/hooks/useAuthMethods.ts @@ -35,8 +35,8 @@ export function useAuthMethods() { const loadAuthMethods = async () => { try { - // Check SSO providers - const providers = await apiRequest('/sso/providers').catch(() => []); + // Check SSO providers - use public endpoint since this is used on login page + const providers = await apiRequest('/sso/providers/public').catch(() => []); const applications = await apiRequest('/sso/applications').catch(() => []); setAuthMethods({ diff --git a/src/pages/api/auth/sso/register.ts b/src/pages/api/auth/sso/register.ts index 8635188..ef10489 100644 --- a/src/pages/api/auth/sso/register.ts +++ b/src/pages/api/auth/sso/register.ts @@ -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, diff --git a/src/pages/api/sso/providers.ts b/src/pages/api/sso/providers.ts index 66564e0..5b4eb60 100644 --- a/src/pages/api/sso/providers.ts +++ b/src/pages/api/sso/providers.ts @@ -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 { diff --git a/src/pages/api/sso/providers/public.ts b/src/pages/api/sso/providers/public.ts new file mode 100644 index 0000000..2442766 --- /dev/null +++ b/src/pages/api/sso/providers/public.ts @@ -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"); + } +} \ No newline at end of file