From 0bad43c8ab8ed214b80fb45521e35caa719367b2 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Wed, 22 Apr 2026 14:16:09 -0400 Subject: [PATCH 1/4] feat: improve OIDC connection behavior --- .../bufservices/sso/deleteOIDCProvider.ts | 2 +- .../check-extensions-config.tsx | 40 +- .../oidc/connect-oidc-provider-dialog.tsx | 118 ++++ .../oidc/disconnect-oidc-provider-dialog.tsx | 118 ++++ studio/src/components/oidc/oidc-card.tsx | 98 +++ studio/src/components/oidc/oidc-form.tsx | 105 +++ .../src/components/oidc/oidc-group-mapper.tsx | 71 ++ .../src/components/oidc/oidc-info-dialog.tsx | 45 ++ .../components/oidc/update-mappers-dialog.tsx | 218 ++++++ studio/src/lib/zod.ts | 41 ++ .../src/pages/[organizationSlug]/settings.tsx | 652 +----------------- 11 files changed, 825 insertions(+), 683 deletions(-) create mode 100644 studio/src/components/oidc/connect-oidc-provider-dialog.tsx create mode 100644 studio/src/components/oidc/disconnect-oidc-provider-dialog.tsx create mode 100644 studio/src/components/oidc/oidc-card.tsx create mode 100644 studio/src/components/oidc/oidc-form.tsx create mode 100644 studio/src/components/oidc/oidc-group-mapper.tsx create mode 100644 studio/src/components/oidc/oidc-info-dialog.tsx create mode 100644 studio/src/components/oidc/update-mappers-dialog.tsx create mode 100644 studio/src/lib/zod.ts diff --git a/controlplane/src/core/bufservices/sso/deleteOIDCProvider.ts b/controlplane/src/core/bufservices/sso/deleteOIDCProvider.ts index 1f08a0ed13..fd4f132a06 100644 --- a/controlplane/src/core/bufservices/sso/deleteOIDCProvider.ts +++ b/controlplane/src/core/bufservices/sso/deleteOIDCProvider.ts @@ -48,7 +48,7 @@ export function deleteOIDCProvider( return { response: { code: EnumStatusCode.ERR_NOT_FOUND, - details: `Organization ${authContext.organizationSlug} doesn't have an oidc identity provider `, + details: `Organization ${authContext.organizationSlug} doesn't have an OIDC provider `, }, }; } diff --git a/studio/src/components/check-extensions/check-extensions-config.tsx b/studio/src/components/check-extensions/check-extensions-config.tsx index 3d7b703dfc..60d0574439 100644 --- a/studio/src/components/check-extensions/check-extensions-config.tsx +++ b/studio/src/components/check-extensions/check-extensions-config.tsx @@ -20,48 +20,12 @@ import { clsx } from 'clsx'; import { useCurrentOrganization } from '@/hooks/use-current-organization'; import { useWorkspace } from '@/hooks/use-workspace'; import Link from 'next/link'; +import { absoluteUrlValidator } from '@/lib/zod'; export type SubgraphCheckExtensionsConfig = Omit, 'namespace'>; const validationSchema = z.object({ - endpoint: z - .string() - .trim() - .superRefine((val, ctx) => { - if (!val) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Must be a valid absolute URL starting with https://', - }); - return; - } - - try { - const url = new URL(val); // Ensure that the value is a valid absolute URL - if (url.hostname === 'localhost') { - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Must be a valid absolute URL starting with http:// or https://', - }); - } - - return; - } - - if (url.protocol !== 'https:') { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Must be a valid absolute URL starting with https://', - }); - } - } catch { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Must be a valid absolute URL starting with https://', - }); - } - }), + endpoint: absoluteUrlValidator, secretKey: z.string().trim().optional(), includeComposedSdl: z.boolean(), includeLintingIssues: z.boolean(), diff --git a/studio/src/components/oidc/connect-oidc-provider-dialog.tsx b/studio/src/components/oidc/connect-oidc-provider-dialog.tsx new file mode 100644 index 0000000000..c36dcebf78 --- /dev/null +++ b/studio/src/components/oidc/connect-oidc-provider-dialog.tsx @@ -0,0 +1,118 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import { createOIDCProvider } from '@wundergraph/cosmo-connect/dist/platform/v1/platform-PlatformService_connectquery'; +import { useState } from 'react'; +import { useIsAdmin } from '@/hooks/use-is-admin'; +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; +import { docsBaseURL } from '@/lib/constants'; +import { OIDCForm, OIDCProviderInput } from './oidc-form'; +import { useMutation } from '@connectrpc/connect-query'; +import { useToast } from '@/components/ui/use-toast'; + +export interface ConnectOIDCProviderDialogProps { + isProviderConnected: boolean; + refetch(): Promise; + onProviderConnected(): void; +} + +export function ConnectOIDCProviderDialog({ + isProviderConnected, + refetch, + onProviderConnected, +}: ConnectOIDCProviderDialogProps) { + const isAdmin = useIsAdmin(); + const [open, setOpen] = useState(false); + const [isPending, setPending] = useState(false); + const [error, setError] = useState(null); + const { toast } = useToast(); + const { mutate } = useMutation(createOIDCProvider); + + const onOpenChangeCallback = (open: boolean) => { + if (isPending && !open) { + return; + } + + setPending(false); + setOpen(open); + setError(null); + }; + + const handleSubmit = (data: OIDCProviderInput) => { + if (!isAdmin || isPending) { + return; + } + + setPending(true); + mutate(data, { + onSuccess(data) { + if (data.response?.code === EnumStatusCode.OK) { + refetch().finally(() => { + setOpen(false); + toast({ + description: 'OIDC provider connected successfully.', + duration: 4000, + }); + + onProviderConnected(); + }); + } else { + setPending(false); + setError( + data.response?.details || 'Could not connect the OIDC provider to the organization. Please try again.', + ); + } + }, + onError() { + setPending(false); + setError('Could not connect the OIDC provider to the organization. Please try again.'); + }, + }); + }; + + return ( + + {!isProviderConnected && ( + + + + )} + + + Connect OpenID Connect Provider + +

+ Connecting an OIDC provider to this organization allows users to automatically log in and be part of this + organization. +

+

Use Okta, Auth0 or any other OAuth2 Open ID Connect compatible provider.

+
+ + Click here{' '} + + for the step by step guide to configure your OIDC provider. +
+
+
+ + {error &&
{error}
} + + setOpen(false)} /> +
+
+ ); +} diff --git a/studio/src/components/oidc/disconnect-oidc-provider-dialog.tsx b/studio/src/components/oidc/disconnect-oidc-provider-dialog.tsx new file mode 100644 index 0000000000..dd222c2719 --- /dev/null +++ b/studio/src/components/oidc/disconnect-oidc-provider-dialog.tsx @@ -0,0 +1,118 @@ +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import { useState } from 'react'; +import { useIsAdmin } from '@/hooks/use-is-admin'; +import { Button, buttonVariants } from '@/components/ui/button'; +import { deleteOIDCProvider } from '@wundergraph/cosmo-connect/dist/platform/v1/platform-PlatformService_connectquery'; +import { useMutation } from '@connectrpc/connect-query'; +import { useToast } from '@/components/ui/use-toast'; + +export interface DisconnectOIDCProviderDialogProps { + isProviderConnected: boolean; + refetch(): Promise; +} + +export function DisconnectOIDCProviderDialog({ isProviderConnected, refetch }: DisconnectOIDCProviderDialogProps) { + const isAdmin = useIsAdmin(); + const [open, setOpen] = useState(false); + const [isPending, setPending] = useState(false); + + const { toast } = useToast(); + const { mutate } = useMutation(deleteOIDCProvider); + const onDialogOpenChange = (open: boolean) => { + if (isPending && !open) { + return; + } + + setOpen(isProviderConnected && open); + if (open) { + setPending(false); + } + }; + + const onSubmit = () => { + if (!isProviderConnected || !isAdmin || isPending) { + return; + } + + setPending(true); + mutate( + {}, + { + onSuccess(data) { + if (data.response?.code === EnumStatusCode.OK) { + refetch().finally(() => { + setOpen(false); + + toast({ + description: 'OIDC provider disconnected successfully.', + duration: 4000, + }); + }); + } else { + setPending(false); + toast({ + description: data.response?.details || 'Could not disconnect the OIDC provider. Please try again.', + duration: 4000, + }); + } + }, + onError() { + setPending(false); + toast({ + description: 'Could not disconnect the OIDC provider. Please try again.', + duration: 4000, + }); + }, + }, + ); + }; + + return ( + + {isProviderConnected && ( + + + + )} + + + + Are you sure you want to disconnect the OIDC provider? + +
+

+ All members who are connected to the OIDC provider will be logged out and downgraded to the viewer role. +

+

Reconnecting will result in a new login url.

+

This action cannot be undone.

+
+
+
+ + Cancel + + +
+
+ ); +} diff --git a/studio/src/components/oidc/oidc-card.tsx b/studio/src/components/oidc/oidc-card.tsx new file mode 100644 index 0000000000..2587d700ca --- /dev/null +++ b/studio/src/components/oidc/oidc-card.tsx @@ -0,0 +1,98 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { useFeature } from '@/hooks/use-feature'; +import { cn } from '@/lib/utils'; +import { Badge } from '@/components/ui/badge'; +import Link from 'next/link'; +import { calURL, docsBaseURL } from '@/lib/constants'; +import { Button } from '@/components/ui/button'; +import { CLI } from '@/components/ui/cli'; +import { ConnectOIDCProviderDialog } from './connect-oidc-provider-dialog'; +import { DisconnectOIDCProviderDialog } from './disconnect-oidc-provider-dialog'; +import { UpdateMappersDialog } from './update-mappers-dialog'; +import { OIDCInfoDialog } from './oidc-info-dialog'; +import { GetOIDCProviderResponse } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; +import { useState } from 'react'; + +export interface OIDCCardProps { + className?: string; + providerData?: GetOIDCProviderResponse; + refetchOIDCProvider(): Promise; +} + +export function OIDCCard({ className, providerData, refetchOIDCProvider }: OIDCCardProps) { + const oidc = useFeature('oidc'); + const [wasProviderJustConnected, setWasProviderJustConnected] = useState(false); + const [forceOpenMappersDialog, setForceOpenMappersDialog] = useState(false); + + return ( + + +
+ + Connect OIDC provider + Enterprise feature + + + Connecting an OIDC provider allows users to automatically log in and be a part of this organization.{' '} + + Learn more + + +
+ {!oidc ? ( + + ) : ( +
+ { + setWasProviderJustConnected(false); + setForceOpenMappersDialog(true); + }} + /> + + setForceOpenMappersDialog(false)} + /> + + setWasProviderJustConnected(true)} + /> + +
+ )} +
+ {providerData?.name && ( + +
+ OIDC provider + +
+
+ Sign in redirect URL + +
+
+ Sign out redirect URL + +
+
+ Login URL + +
+
+ )} +
+ ); +} diff --git a/studio/src/components/oidc/oidc-form.tsx b/studio/src/components/oidc/oidc-form.tsx new file mode 100644 index 0000000000..d2c269195e --- /dev/null +++ b/studio/src/components/oidc/oidc-form.tsx @@ -0,0 +1,105 @@ +import { z } from 'zod'; +import { useZodForm } from '@/hooks/use-form'; +import { Form, FormField, FormLabel, FormMessage, FormItem, FormControl } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { absoluteUrlValidator } from '@/lib/zod'; + +const OIDCProviderInputSchema = z.object({ + name: z.string().min(1), + discoveryEndpoint: absoluteUrlValidator, + clientID: z.string().min(1), + clientSecret: z.string().min(1), +}); + +export type OIDCProviderInput = z.infer; + +export interface OIDCFormProps { + isPending: boolean; + handleSubmit(data: OIDCProviderInput): void; + onCancel(): void; +} + +export function OIDCForm({ isPending, handleSubmit, onCancel }: OIDCFormProps) { + const form = useZodForm({ + schema: OIDCProviderInputSchema, + mode: 'onChange', + }); + + return ( +
+ + ( + + Name + + + + + + )} + /> + + ( + + Discovery Endpoint + + + + + + )} + /> + + ( + + Client ID + + + + + + )} + /> + + ( + + Client ID + + + + + + )} + /> + +
+ + + +
+ + + ); +} diff --git a/studio/src/components/oidc/oidc-group-mapper.tsx b/studio/src/components/oidc/oidc-group-mapper.tsx new file mode 100644 index 0000000000..e147a14e2b --- /dev/null +++ b/studio/src/components/oidc/oidc-group-mapper.tsx @@ -0,0 +1,71 @@ +import { OrganizationGroup } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Cross1Icon } from '@radix-ui/react-icons'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import * as z from 'zod'; + +export interface OIDCGroupMapperProps { + mapper: MapperInput; + availableGroups: OrganizationGroup[]; + isPending: boolean; + onChange(updatedMapper: MapperInput): void; + onRemove(): void; +} + +export const schema = z.object({ + groupId: z.string().trim().uuid(), + ssoGroup: z.string().trim().min(1), +}); + +export type MapperInput = z.infer & { id: string }; + +export function OIDCGroupMapper({ mapper, availableGroups, isPending, onChange, onRemove }: OIDCGroupMapperProps) { + const groupLabel = availableGroups.find((group) => group.groupId === mapper.groupId)?.name ?? 'Select a group'; + + return ( +
+
+ + + {!mapper.groupId.trim() &&

Please select a group

} +
+
+
+ + onChange({ + ...mapper, + ssoGroup: e.currentTarget.value, + }) + } + /> + + {!mapper.ssoGroup.trim() &&

Please enter a value

} +
+ + +
+
+ ); +} diff --git a/studio/src/components/oidc/oidc-info-dialog.tsx b/studio/src/components/oidc/oidc-info-dialog.tsx new file mode 100644 index 0000000000..66b54f2278 --- /dev/null +++ b/studio/src/components/oidc/oidc-info-dialog.tsx @@ -0,0 +1,45 @@ +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import type { GetOIDCProviderResponse } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; +import { CLI } from '@/components/ui/cli'; +import { Button } from '@/components/ui/button'; + +export interface OIDCInfoDialogProps { + open: boolean; + providerData: GetOIDCProviderResponse | undefined; + onClose(): void; +} + +export function OIDCInfoDialog({ open, providerData, onClose }: OIDCInfoDialogProps) { + return ( + + + + + Steps to configure your OIDC provider + + +
+
+ 1. Set your OIDC provider sign-in redirect URI as + +
+
+ 2. Set your OIDC provider sign-out redirect URI as + +
+ +
+ Your users can login to the organization using the below url. + +
+
+ + + + +
+
+ ); +} diff --git a/studio/src/components/oidc/update-mappers-dialog.tsx b/studio/src/components/oidc/update-mappers-dialog.tsx new file mode 100644 index 0000000000..5cf297658e --- /dev/null +++ b/studio/src/components/oidc/update-mappers-dialog.tsx @@ -0,0 +1,218 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { useEffect, useState } from 'react'; +import { GroupMapper } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; +import { + updateIDPMappers, + getOrganizationGroups, +} from '@wundergraph/cosmo-connect/dist/platform/v1/platform-PlatformService_connectquery'; +import { useMutation, useQuery } from '@connectrpc/connect-query'; +import { CgSpinner } from 'react-icons/cg'; +import { OIDCGroupMapper, MapperInput, schema } from './oidc-group-mapper'; +import { PlusIcon } from '@radix-ui/react-icons'; +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'; +import { useToast } from '@/components/ui/use-toast'; + +export interface UpdateMappersDialogProps { + forceOpen: boolean; + isProviderConnected: boolean; + currentMappers: GroupMapper[]; + refetch(): Promise; + onClose(): void; +} + +export function UpdateMappersDialog({ + forceOpen, + isProviderConnected, + currentMappers, + refetch, + onClose, +}: UpdateMappersDialogProps) { + const [open, setOpen] = useState(false); + const [isPending, setPending] = useState(false); + const [mappers, setMappers] = useState([]); + const { toast } = useToast(); + + const { mutate } = useMutation(updateIDPMappers); + const { + data: organizationGroups, + isLoading, + isError, + refetch: refetchOrganizationGroups, + } = useQuery( + getOrganizationGroups, + {}, + { + enabled: open, + }, + ); + + const hasInvalidMappers = mappers.some((mapper) => !schema.safeParse(mapper).success); + const onOpenChangeCallback = (open: boolean) => { + if ((isPending || mappers.length === 0 || (mappers.length === 1 && hasInvalidMappers)) && !open) { + return; + } + + setOpen(open); + if (!open) { + onClose(); + } + }; + + useEffect(() => { + if (forceOpen) { + setOpen(true); + setPending(false); + } + }, [forceOpen]); + + useEffect(() => { + if (open) { + setPending(false); + setMappers( + currentMappers.map((mapper, index) => ({ + id: `${Date.now().toString()}-${index}`, + ...mapper, + })), + ); + } + }, [open, currentMappers]); + + const updateMappers = () => { + if (isPending) { + return; + } + + setPending(true); + mutate( + { mappers }, + { + onSuccess(data) { + if (data.response?.code === EnumStatusCode.OK) { + toast({ + description: 'Group mappers updated successfully.', + duration: 4000, + }); + + refetch().finally(() => { + setOpen(false); + onClose(); + }); + } else { + setPending(false); + toast({ + description: data.response?.details ?? 'Could not update the group mappers. Please try again.', + duration: 4000, + }); + } + }, + onError() { + setPending(false); + toast({ + description: 'Could not update the group mappers. Please try again.', + duration: 4000, + }); + }, + }, + ); + }; + + return ( + + {isProviderConnected && ( + + + + )} + + + Update group mappers + Map your groups to cosmo groups. + + +
+ Group in Cosmo + Group in the provider +
+ + {isLoading ? ( +
+ +
+ ) : isError || !organizationGroups?.groups ? ( +
+
Failed to retrieve the groups for the organization.
+
+ +
+
+ ) : ( + <> +
+ {mappers.length === 0 ? ( +
No mappers have been added.
+ ) : ( + mappers.map((mapper, index) => ( + { + const updatedMappers = [...mappers]; + updatedMappers[index] = updatedMapper; + setMappers(updatedMappers); + }} + onRemove={() => { + const updatedMappers = [...mappers]; + updatedMappers.splice(index, 1); + setMappers(updatedMappers); + }} + /> + )) + )} +
+ +
+ +
+ + )} + + +
+
+ ); +} diff --git a/studio/src/lib/zod.ts b/studio/src/lib/zod.ts new file mode 100644 index 0000000000..15ea94e4db --- /dev/null +++ b/studio/src/lib/zod.ts @@ -0,0 +1,41 @@ +import * as z from 'zod'; + +export const absoluteUrlValidator = z + .string() + .trim() + .min(1, 'Must be a valid absolute URL starting with https://') + .superRefine((val, ctx) => { + if (!val) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Must be a valid absolute URL starting with https://', + }); + return; + } + + try { + const url = new URL(val); // Ensure that the value is a valid absolute URL + if (url.hostname === 'localhost') { + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Must be a valid absolute URL starting with http:// or https://', + }); + } + + return; + } + + if (url.protocol !== 'https:') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Must be a valid absolute URL starting with https://', + }); + } + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Must be a valid absolute URL starting with https://', + }); + } + }); diff --git a/studio/src/pages/[organizationSlug]/settings.tsx b/studio/src/pages/[organizationSlug]/settings.tsx index 429fc4beeb..9ffb369958 100644 --- a/studio/src/pages/[organizationSlug]/settings.tsx +++ b/studio/src/pages/[organizationSlug]/settings.tsx @@ -16,18 +16,9 @@ import { Badge } from '@/components/ui/badge'; import { Button, buttonVariants } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { CLI } from '@/components/ui/cli'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog'; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; import { Loader } from '@/components/ui/loader'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; import { useToast } from '@/components/ui/use-toast'; import { useFeature } from '@/hooks/use-feature'; @@ -37,31 +28,25 @@ import { useIsCreator } from '@/hooks/use-is-creator'; import { useUser } from '@/hooks/use-user'; import { calURL, docsBaseURL, scimBaseURL } from '@/lib/constants'; import { NextPageWithLayout } from '@/lib/page'; -import { MinusCircledIcon, PlusIcon } from '@radix-ui/react-icons'; import { useQuery, useMutation } from '@connectrpc/connect-query'; import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; import { - createOIDCProvider, - deleteOIDCProvider, getOIDCProvider, leaveOrganization, updateFeatureSettings, - updateIDPMappers, updateOrganizationDetails, - getOrganizationGroups, } from '@wundergraph/cosmo-connect/dist/platform/v1/platform-PlatformService_connectquery'; import { Feature, - GetOIDCProviderResponse, - OrganizationGroup, } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { Dispatch, SetStateAction, useContext, useEffect, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; import { FaMagic } from 'react-icons/fa'; import { z } from 'zod'; import { DeleteOrganization } from '@/components/settings/delete-organization'; import { RestoreOrganization } from '@/components/settings/restore-organization'; +import { OIDCCard } from '@/components/oidc/oidc-card'; const OrganizationDetails = () => { const user = useContext(UserContext); @@ -123,7 +108,7 @@ const OrganizationDetails = () => { toast({ description: d.response.details, duration: 3000 }); } }, - onError: (error) => { + onError: () => { toast({ description: 'Could not update the organization details. Please try again.', duration: 3000, @@ -174,630 +159,6 @@ const OrganizationDetails = () => { ); }; -interface Mapper { - groupId: string; - ssoGroup: string; -} - -type MapperInput = Mapper & { - id: number; -}; - -const createMapperSchema = z.object({ - groupId: z.string().uuid(), - ssoGroup: z.string().min(1, { message: 'Please enter a value' }), -}); - -const saveSchema = z.array(createMapperSchema).min(1); - -const NewMapper = ({ - remove, - onChange, - mapper, - availableGroups, -}: { - remove: () => void; - onChange: (secret: Mapper) => void; - mapper: Mapper; - availableGroups: OrganizationGroup[]; -}) => { - type CreateMapperFormInput = z.infer; - - const groupLabel = availableGroups.find((g) => g.groupId === mapper.groupId)?.name || 'Select a group'; - - const { - register, - formState: { errors }, - } = useZodForm({ - mode: 'onChange', - schema: createMapperSchema, - }); - - const { ref, ...groupIdField } = register('groupId'); - - return ( -
-
-
- - {errors.groupId && {errors.groupId.message}} -
-
- { - onChange({ - groupId: mapper.groupId, - ssoGroup: e.currentTarget.value, - }); - }} - /> - {errors.ssoGroup && {errors.ssoGroup.message}} -
-
- -
- ); -}; - -const AddNewMappers = ({ - mappers, - availableGroups, - updateMappers, -}: { - mappers: MapperInput[]; - availableGroups: OrganizationGroup[]; - updateMappers: Dispatch>; -}) => { - return ( - <> - {mappers.length === 0 ? ( -
No mappers have been added.
- ) : ( - mappers.map((mapper, index) => ( - { - const newMappers = [...mappers]; - newMappers.splice(index, 1); - updateMappers(newMappers); - }} - onChange={(newMapper) => { - const newMappers = [...mappers]; - newMappers[index] = { ...newMappers[index], ...newMapper }; - updateMappers(newMappers); - }} - /> - )) - )} - - - ); -}; - -const UpdateIDPMappers = ({ - currentMappers, - refetchProviderData, -}: { - currentMappers: MapperInput[]; - refetchProviderData: () => void; -}) => { - const [open, setOpen] = useState(false); - const { mutate, isPending } = useMutation(updateIDPMappers); - - const { toast } = useToast(); - - const [mappers, updateMappers] = useState(currentMappers); - - const { data: orgMemberGroups } = useQuery(getOrganizationGroups); - - const mutateMappers = () => { - const groupMappers = mappers.map((m) => { - return { groupId: m.groupId, ssoGroup: m.ssoGroup.trim() }; - }); - - mutate( - { - mappers: groupMappers, - }, - { - onSuccess: (d) => { - if (d.response?.code === EnumStatusCode.OK) { - toast({ - description: 'Group mappers updated successfully.', - duration: 3000, - }); - setOpen(false); - refetchProviderData(); - } else if (d.response?.details) { - toast({ description: d.response.details, duration: 4000 }); - setOpen(false); - } - }, - onError: (error) => { - toast({ - description: 'Could not update the group mappers. Please try again.', - duration: 3000, - }); - setOpen(false); - }, - }, - ); - }; - - return ( - { - setOpen(v); - if (v) { - updateMappers(currentMappers); - } - }} - > - - - - { - event.preventDefault(); - }} - > - - Update group mappers - Map your groups to cosmo groups. - -
- Group in cosmo - Group in the provider -
- - -
-
- ); -}; - -const OpenIDConnectProvider = ({ - currentMode, - providerData, - refetch, -}: { - currentMode: 'create' | 'map' | 'result'; - providerData: GetOIDCProviderResponse | undefined; - refetch: () => void; -}) => { - const user = useUser(); - const oidc = useFeature('oidc'); - const [open, setOpen] = useState(false); - const [alertOpen, setAlertOpen] = useState(false); - const [mode, setMode] = useState(currentMode); - const isAdmin = useIsAdmin(); - - const { mutate, isPending, data } = useMutation(createOIDCProvider); - const { mutate: deleteOidcProvider } = useMutation(deleteOIDCProvider); - - const { data: orgMemberGroups } = useQuery(getOrganizationGroups, undefined, { - enabled: mode === 'map', - }); - - const { toast } = useToast(); - - const connectOIDCProviderInputSchema = z.object({ - name: z.string().min(1), - discoveryEndpoint: z.string().startsWith('https://').min(1), - clientID: z.string().min(1), - clientSecret: z.string().min(1), - }); - - type ConnectOIDCProviderInput = z.infer; - - const { - register, - formState: { isValid, errors }, - handleSubmit, - reset, - } = useZodForm({ - mode: 'onBlur', - schema: connectOIDCProviderInputSchema, - }); - - const [mappers, updateMappers] = useState([]); - - const onSubmit: SubmitHandler = (data) => { - const groupMappers = mappers.map((m) => { - return { groupId: m.groupId, ssoGroup: m.ssoGroup.trim() }; - }); - - mutate( - { - clientID: data.clientID, - clientSecrect: data.clientSecret, - discoveryEndpoint: data.discoveryEndpoint, - name: data.name, - mappers: groupMappers, - }, - { - onSuccess: (d) => { - if (d.response?.code === EnumStatusCode.OK) { - toast({ - description: 'OIDC provider connected successfully.', - duration: 3000, - }); - - setMode('result'); - reset(); - updateMappers([]); - } else if (d.response?.details) { - toast({ description: d.response.details, duration: 4000 }); - setMode('create'); - setOpen(false); - } - }, - onError: (error) => { - toast({ - description: 'Could not connect the oidc provider to the organization. Please try again.', - duration: 3000, - }); - setMode('create'); - setOpen(false); - }, - }, - ); - }; - - return ( - - -
- - Connect OIDC provider - Enterprise feature - - - Connecting an OIDC provider allows users to automatically log in and be a part of this organization.{' '} - - Learn more - - -
- {!oidc && ( - - )} - {oidc && ( - <> - {providerData && providerData.name ? ( -
- { - return { - id: Date.now(), - groupId: m.groupId, - ssoGroup: m.ssoGroup, - }; - })} - refetchProviderData={refetch} - /> - - - - - - - Are you sure you want to disconnect the oidc provider? - -
-

- All members who are connected to the SSO will be logged out and downgraded to the viewer - role. -

-

Reconnecting will result in a new login url.

-

This action cannot be undone.

-
-
-
- - Cancel - { - deleteOidcProvider( - {}, - { - onSuccess: (d) => { - if (d.response?.code === EnumStatusCode.OK) { - refetch(); - toast({ - description: 'OIDC provider disconnected successfully.', - duration: 3000, - }); - } else if (d.response?.details) { - toast({ - description: d.response.details, - duration: 4000, - }); - } - }, - onError: (error) => { - toast({ - description: 'Could not disconnect the OIDC provider. Please try again.', - duration: 3000, - }); - }, - }, - ); - }} - > - Disconnect - - -
-
-
- ) : ( - { - setOpen(!open); - if (open) { - setMode('create'); - refetch(); - } - }} - > - - - - { - event.preventDefault(); - }} - > - {isPending ? ( - - ) : ( - <> - - {mode === 'create' && ( - <> - Connect OpenID Connect Provider - -

- Connecting an OIDC provider to this organization allows users to automatically log in - and be part of this organization. -

-

Use Okta, Auth0 or any other OAuth2 Open ID Connect compatible provider.

-
- - Click here{' '} - - for the step by step guide to configure your OIDC provider. -
-
- - )} - {mode === 'map' && ( - <> - Configure group mappers - Map your groups to cosmo groups. - - )} - {mode === 'result' && ( - <> - Steps to configure your OIDC provider - - )} -
- {mode !== 'result' ? ( -
- {mode === 'create' && ( - <> -
- Name - - {errors.name && ( - {errors.name.message} - )} -
- -
- Discovery Endpoint - - {errors.discoveryEndpoint && ( - - {errors.discoveryEndpoint.message} - - )} -
- -
- Client ID - - {errors.clientID && ( - {errors.clientID.message} - )} -
- -
- Client Secret - - {errors.clientSecret && ( - {errors.clientSecret.message} - )} -
- - - - )} - {mode === 'map' && ( - <> -
- Group in cosmo - Group in the provider -
- - - - )} - - ) : ( -
-
- 1. Set your OIDC provider sign-in redirect URI as - -
-
- 2. Set your OIDC provider sign-out redirect URI as - -
- -
- Your users can login to the organization using the below url. - -
-
- )} - - )} -
-
- )} - - )} -
- {providerData && providerData.name && ( - -
- OIDC provider - -
-
- Sign in redirect URL - -
-
- Sign out redirect URL - -
-
- Login URL - -
-
- )} -
- ); -}; - const CosmoAi = () => { const router = useRouter(); const ai = useFeature('ai'); @@ -1227,7 +588,9 @@ const SettingsDashboardPage: NextPageWithLayout = () => { data: providerData, refetch: refetchOIDCProvider, isLoading: fetchingOIDCProvider, - } = useQuery(getOIDCProvider); + } = useQuery(getOIDCProvider, { + enabled: false, + }); const orgs = user?.organizations?.length || 0; @@ -1261,7 +624,8 @@ const SettingsDashboardPage: NextPageWithLayout = () => { - + + {(!isCreator || orgs > 1 || orgIsPendingDeletion) && } From 02100d09ee63cb27f31449dbb7de5ceb64982b34 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Wed, 22 Apr 2026 14:21:06 -0400 Subject: [PATCH 2/4] chore: linting --- studio/src/pages/[organizationSlug]/settings.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/studio/src/pages/[organizationSlug]/settings.tsx b/studio/src/pages/[organizationSlug]/settings.tsx index 9ffb369958..771372151b 100644 --- a/studio/src/pages/[organizationSlug]/settings.tsx +++ b/studio/src/pages/[organizationSlug]/settings.tsx @@ -36,9 +36,7 @@ import { updateFeatureSettings, updateOrganizationDetails, } from '@wundergraph/cosmo-connect/dist/platform/v1/platform-PlatformService_connectquery'; -import { - Feature, -} from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; +import { Feature } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; import Link from 'next/link'; import { useRouter } from 'next/router'; import { useContext, useEffect, useState } from 'react'; From 844accb614eb8979e650a16c99fa60c1d57c1fa7 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Wed, 22 Apr 2026 14:46:11 -0400 Subject: [PATCH 3/4] chore: fix some inconsistencies --- studio/src/components/oidc/oidc-form.tsx | 2 +- studio/src/components/oidc/update-mappers-dialog.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/studio/src/components/oidc/oidc-form.tsx b/studio/src/components/oidc/oidc-form.tsx index d2c269195e..24f3d02444 100644 --- a/studio/src/components/oidc/oidc-form.tsx +++ b/studio/src/components/oidc/oidc-form.tsx @@ -81,7 +81,7 @@ export function OIDCForm({ isPending, handleSubmit, onCancel }: OIDCFormProps) { name="clientSecret" render={({ field }) => ( - Client ID + Client Secret diff --git a/studio/src/components/oidc/update-mappers-dialog.tsx b/studio/src/components/oidc/update-mappers-dialog.tsx index 5cf297658e..53ab1eb914 100644 --- a/studio/src/components/oidc/update-mappers-dialog.tsx +++ b/studio/src/components/oidc/update-mappers-dialog.tsx @@ -161,7 +161,7 @@ export function UpdateMappersDialog({ ) : ( <> -
+
{mappers.length === 0 ? (
No mappers have been added.
) : ( From 6a8076d16c675ce2ba22ff7c67a7006801e97a1e Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Wed, 22 Apr 2026 17:05:24 -0400 Subject: [PATCH 4/4] chore: fix placeholder typo --- studio/src/components/oidc/oidc-form.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/src/components/oidc/oidc-form.tsx b/studio/src/components/oidc/oidc-form.tsx index 24f3d02444..73d0bd1c37 100644 --- a/studio/src/components/oidc/oidc-form.tsx +++ b/studio/src/components/oidc/oidc-form.tsx @@ -53,7 +53,7 @@ export function OIDCForm({ isPending, handleSubmit, onCancel }: OIDCFormProps) {