diff --git a/apps/admin/app/(all)/(dashboard)/authentication/oidc/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/oidc/form.tsx new file mode 100644 index 00000000000..3dbe81fa317 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/oidc/form.tsx @@ -0,0 +1,248 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState } from "react"; +import { isEmpty } from "lodash-es"; +import Link from "next/link"; +import { Controller, useForm } from "react-hook-form"; +// plane internal packages +import { API_BASE_URL } from "@plane/constants"; +import { Button } from "@makeplane/propel/components/button"; +import { Switch } from "@makeplane/propel/components/switch"; +import { TOAST_TYPE, setToast } from "@/providers/toast"; +import type { IFormattedInstanceConfiguration, TInstanceOIDCAuthenticationConfigurationKeys } from "@plane/types"; +// components +import { CodeBlock } from "@/components/common/code-block"; +import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal"; +import type { TControllerInputFormField } from "@/components/common/controller-input"; +import type { TControllerSwitchFormField } from "@/components/common/controller-switch"; +import { ControllerSwitch } from "@/components/common/controller-switch"; +import { ControllerInput } from "@/components/common/controller-input"; +import type { TCopyField } from "@/components/common/copy-field"; +import { CopyField } from "@/components/common/copy-field"; +// hooks +import { useInstance } from "@/hooks/store"; + +type Props = { + config: IFormattedInstanceConfiguration; +}; + +type OIDCConfigFormValues = Record; + +const OIDC_FORM_SWITCH_FIELD: TControllerSwitchFormField = { + name: "ENABLE_OIDC_SYNC", + label: "your identity provider", +}; + +export function InstanceOIDCConfigForm(props: Props) { + const { config } = props; + // states + const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false); + // store hooks + const { updateInstanceConfigurations } = useInstance(); + // form data + const { + handleSubmit, + control, + reset, + formState: { errors, isDirty, isSubmitting }, + } = useForm({ + defaultValues: { + OIDC_PROVIDER_NAME: config["OIDC_PROVIDER_NAME"] || "SSO", + OIDC_ISSUER_URL: config["OIDC_ISSUER_URL"], + OIDC_CLIENT_ID: config["OIDC_CLIENT_ID"], + OIDC_CLIENT_SECRET: config["OIDC_CLIENT_SECRET"], + ENABLE_OIDC_SYNC: config["ENABLE_OIDC_SYNC"] || "0", + OIDC_ALLOW_UNVERIFIED_EMAIL: config["OIDC_ALLOW_UNVERIFIED_EMAIL"] || "0", + }, + }); + + const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : ""; + + const OIDC_FORM_FIELDS: TControllerInputFormField[] = [ + { + key: "OIDC_PROVIDER_NAME", + type: "text", + label: "Provider name", + description: <>The name shown on the sign-in button, for example Okta, Entra ID, or Keycloak., + placeholder: "SSO", + error: Boolean(errors.OIDC_PROVIDER_NAME), + required: false, + }, + { + key: "OIDC_ISSUER_URL", + type: "text", + label: "Issuer URL", + description: ( + <> + Your provider's issuer identifier. Plane reads every endpoint it needs from{" "} + {"/.well-known/openid-configuration"}, so this must be an{" "} + https URL and must match the iss claim your provider issues. + + ), + placeholder: "https://your-org.okta.com", + error: Boolean(errors.OIDC_ISSUER_URL), + required: true, + }, + { + key: "OIDC_CLIENT_ID", + type: "text", + label: "Client ID", + description: <>The client ID of the application you registered with your identity provider., + placeholder: "0oa1b2c3d4e5f6g7h8i9", + error: Boolean(errors.OIDC_CLIENT_ID), + required: true, + }, + { + key: "OIDC_CLIENT_SECRET", + type: "password", + label: "Client secret", + description: <>The client secret issued alongside the client ID., + placeholder: "*****************************", + error: Boolean(errors.OIDC_CLIENT_SECRET), + required: true, + }, + ]; + + const OIDC_SERVICE_FIELD: TCopyField[] = [ + { + key: "Callback_URL", + label: "Callback URL", + url: `${originURL}/auth/oidc/callback/`, + description: ( + <> + We will auto-generate this. Paste it into the Redirect URI (also called + sign-in redirect URI) field of the application you registered with your identity provider. + + ), + }, + { + key: "Spaces_Callback_URL", + label: "Callback URL for Spaces", + url: `${originURL}/auth/spaces/oidc/callback/`, + description: ( + <> + Add this as a second Redirect URI on the same application. Signing in to + published Spaces comes back here instead, and providers reject any redirect URI they have not been given. + + ), + }, + ]; + + const onSubmit = async (formData: OIDCConfigFormValues) => { + const payload: Partial = { ...formData }; + + try { + const response = await updateInstanceConfigurations(payload); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Done!", + message: "Your OIDC authentication is configured. You should test it now.", + }); + reset({ + OIDC_PROVIDER_NAME: response.find((item) => item.key === "OIDC_PROVIDER_NAME")?.value, + OIDC_ISSUER_URL: response.find((item) => item.key === "OIDC_ISSUER_URL")?.value, + OIDC_CLIENT_ID: response.find((item) => item.key === "OIDC_CLIENT_ID")?.value, + OIDC_CLIENT_SECRET: response.find((item) => item.key === "OIDC_CLIENT_SECRET")?.value, + ENABLE_OIDC_SYNC: response.find((item) => item.key === "ENABLE_OIDC_SYNC")?.value, + OIDC_ALLOW_UNVERIFIED_EMAIL: response.find((item) => item.key === "OIDC_ALLOW_UNVERIFIED_EMAIL")?.value, + }); + } catch (err) { + console.error(err); + } + }; + + const handleGoBack = (e: React.MouseEvent) => { + if (isDirty) { + e.preventDefault(); + setIsDiscardChangesModalOpen(true); + } + }; + + return ( + <> + setIsDiscardChangesModalOpen(false)} + /> +
+
+
+
Provider-provided details for Plane
+ {OIDC_FORM_FIELDS.map((field) => ( + + ))} + + + {/* Kept separate from the sync switch: this one relaxes a security check, + so it needs to say plainly what turning it on means. */} +
+
+

Accept accounts your provider has not verified

+

+ Plane matches accounts by email address. Leave this off unless your provider never sends an{" "} + email_verified claim (Entra ID commonly omits it). Turning it on means trusting + your provider to only ever assert addresses it controls. +

+
+
+ { + const isOn = value === "1"; + return onChange(isOn ? "0" : "1")} size="sm" />; + }} + /> +
+
+ +
+
+
+
+
+
+
+
Plane-provided details for your provider
+ {OIDC_SERVICE_FIELD.map((field) => ( + + ))} +
+
+
+
+ + ); +} diff --git a/apps/admin/app/(all)/(dashboard)/authentication/oidc/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/oidc/page.tsx new file mode 100644 index 00000000000..9912d8cb881 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/oidc/page.tsx @@ -0,0 +1,120 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// icons +import { ShieldCheck } from "lucide-react"; +// plane internal packages +import { Switch } from "@makeplane/propel/components/switch"; +// components +import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +import { PageWrapper } from "@/components/common/page-wrapper"; +import { Skeleton } from "@/components/common/skeleton"; +import { setPromiseToast } from "@/providers/toast"; +// hooks +import { useInstance } from "@/hooks/store"; +// types +import type { Route } from "./+types/page"; +// local +import { InstanceOIDCConfigForm } from "./form"; + +const InstanceOIDCAuthenticationPage = observer(function InstanceOIDCAuthenticationPage(_props: Route.ComponentProps) { + // store + const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); + // state + const [isSubmitting, setIsSubmitting] = useState(false); + // config + const enableOIDCConfig = formattedConfig?.IS_OIDC_ENABLED ?? ""; + const isEnabled = Boolean(parseInt(enableOIDCConfig)); + // Every endpoint is discovered from the issuer, so without it plus the client + // credentials the method cannot work at all. + const isOIDCConfigured = + !!formattedConfig?.OIDC_ISSUER_URL && !!formattedConfig?.OIDC_CLIENT_ID && !!formattedConfig?.OIDC_CLIENT_SECRET; + // Block turning it *on* before it is configured, which would leave the instance + // advertising a sign-in button that can only fail. Turning it off stays available + // whatever the config says — otherwise clearing a field would strand the method + // enabled with no way back. + const cannotEnableYet = !isOIDCConfigured && !isEnabled; + + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + const updateConfig = async (key: "IS_OIDC_ENABLED", value: string) => { + setIsSubmitting(true); + + const payload = { + [key]: value, + }; + + const updateConfigPromise = updateInstanceConfigurations(payload); + + setPromiseToast(updateConfigPromise, { + loading: "Saving Configuration", + success: { + title: "Configuration saved", + message: () => `OIDC authentication is now ${value === "1" ? "active" : "disabled"}.`, + }, + error: { + title: "Error", + message: () => "Failed to save configuration", + }, + }); + + // try/finally rather than the .then/.catch pair the sibling provider pages use: + // that shape trips oxlint's promise(always-return), which lint-staged runs with + // --deny-warnings, and this reads better anyway — one place resets the flag. + try { + await updateConfigPromise; + } catch (err) { + console.error(err); + } finally { + setIsSubmitting(false); + } + }; + + return ( + } + config={ + + { + updateConfig("IS_OIDC_ENABLED", isEnabled ? "0" : "1"); + }} + size="sm" + disabled={isSubmitting || !formattedConfig || cannotEnableYet} + /> + + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> + } + > + {formattedConfig ? ( + + ) : ( + + + + + + + + )} + + ); +}); + +export const meta: Route.MetaFunction = () => [{ title: "OIDC Authentication - God Mode" }]; + +export default InstanceOIDCAuthenticationPage; diff --git a/apps/admin/app/routes.ts b/apps/admin/app/routes.ts index 184bed205a7..48bb06f5c5f 100644 --- a/apps/admin/app/routes.ts +++ b/apps/admin/app/routes.ts @@ -19,6 +19,7 @@ export default [ route("authentication/gitlab", "./(all)/(dashboard)/authentication/gitlab/page.tsx"), route("authentication/google", "./(all)/(dashboard)/authentication/google/page.tsx"), route("authentication/gitea", "./(all)/(dashboard)/authentication/gitea/page.tsx"), + route("authentication/oidc", "./(all)/(dashboard)/authentication/oidc/page.tsx"), route("ai", "./(all)/(dashboard)/ai/page.tsx"), route("image", "./(all)/(dashboard)/image/page.tsx"), ]), diff --git a/apps/admin/components/authentication/oidc-config.tsx b/apps/admin/components/authentication/oidc-config.tsx new file mode 100644 index 00000000000..d499f806216 --- /dev/null +++ b/apps/admin/components/authentication/oidc-config.tsx @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import Link from "next/link"; +// icons +import { SettingsOutline } from "@makeplane/propel/icons"; +// plane internal packages +import { AnchorButton } from "@makeplane/propel/components/anchor-button"; +import { Button } from "@makeplane/propel/components/button"; +import { Switch } from "@makeplane/propel/components/switch"; +import type { TInstanceAuthenticationMethodKeys } from "@plane/types"; +// hooks +import { useInstance } from "@/hooks/store"; + +type Props = { + disabled: boolean; + updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; +}; + +export const OIDCConfiguration = observer(function OIDCConfiguration(props: Props) { + const { disabled, updateConfig } = props; + // store + const { formattedConfig } = useInstance(); + // derived values + const enableOIDCConfig = formattedConfig?.IS_OIDC_ENABLED ?? ""; + // The issuer is what every endpoint is discovered from, so it is required + // alongside the client credentials before the method can be switched on. + const isOIDCConfigured = + !!formattedConfig?.OIDC_ISSUER_URL && !!formattedConfig?.OIDC_CLIENT_ID && !!formattedConfig?.OIDC_CLIENT_SECRET; + + return ( + <> + {isOIDCConfigured ? ( +
+ } + label="Edit" + /> + { + const newEnableOIDCConfig = Boolean(parseInt(enableOIDCConfig)) === true ? "0" : "1"; + updateConfig("IS_OIDC_ENABLED", newEnableOIDCConfig); + }} + size="sm" + disabled={disabled} + /> +
+ ) : ( +