diff --git a/src/pages/permissions/CreateTlsIdentityBtn.tsx b/src/pages/permissions/CreateTlsIdentityBtn.tsx index f1944ad5b3..746f34faea 100644 --- a/src/pages/permissions/CreateTlsIdentityBtn.tsx +++ b/src/pages/permissions/CreateTlsIdentityBtn.tsx @@ -5,19 +5,33 @@ import { useServerEntitlements } from "util/entitlements/server"; interface Props { openPanel: () => void; + className?: string; + onClose?: () => void; } -const CreateTlsIdentityBtn: FC = ({ openPanel }) => { +const CreateTlsIdentityBtn: FC = ({ openPanel, className, onClose }) => { const isSmallScreen = useIsScreenBelow(); const { canCreateIdentities } = useServerEntitlements(); + const handleClick = () => { + openPanel(); + onClose?.(); + }; + + const buttonClassName = className || "u-float-right u-no-margin--bottom"; + const appearance = className?.includes("p-contextual-menu__link") + ? "base" + : "positive"; + const hasIcon = + !isSmallScreen && !className?.includes("p-contextual-menu__link"); + return ( <> diff --git a/src/pages/permissions/OidcConfigurationBtn.tsx b/src/pages/permissions/OidcConfigurationBtn.tsx new file mode 100644 index 0000000000..87daa641be --- /dev/null +++ b/src/pages/permissions/OidcConfigurationBtn.tsx @@ -0,0 +1,48 @@ +import type { FC } from "react"; +import OidcConfigurationModal from "./OidcConfigurationModal"; +import { Button, usePortal } from "@canonical/react-components"; +import { useServerEntitlements } from "util/entitlements/server"; + +interface Props { + isDisabled?: boolean; + className?: string; + onClose?: () => void; +} + +const OidcConfigurationBtn: FC = ({ + isDisabled, + className, + onClose, +}) => { + const { openPortal, closePortal, isOpen, Portal } = usePortal(); + const { canEditServerConfiguration } = useServerEntitlements(); + + const handleClose = () => { + closePortal(); + onClose?.(); + }; + + return ( + <> + {isOpen && ( + + + + )} + + + ); +}; + +export default OidcConfigurationBtn; diff --git a/src/pages/permissions/OidcConfigurationForm.tsx b/src/pages/permissions/OidcConfigurationForm.tsx new file mode 100644 index 0000000000..87e8bee5d8 --- /dev/null +++ b/src/pages/permissions/OidcConfigurationForm.tsx @@ -0,0 +1,109 @@ +import type { FC } from "react"; +import type { MainTableRow } from "@canonical/react-components/dist/components/MainTable/MainTable"; +import { + MainTable, + Notification, + Spinner, + useNotify, +} from "@canonical/react-components"; +import { useQuery } from "@tanstack/react-query"; +import { fetchConfigOptions } from "api/server"; +import NotificationRow from "components/NotificationRow"; +import { useSupportedFeatures } from "context/useSupportedFeatures"; +import { useClusteredSettings } from "context/useClusteredSettings"; +import SsoNotification from "pages/permissions/SsoNotification"; +import { getSettingRow } from "pages/settings/SettingsRow"; +import { toConfigFields } from "util/config"; +import { queryKeys } from "util/queryKeys"; +import { getConfigFieldClusteredValue } from "util/settings"; + +interface Props { + closeModal: () => void; +} + +const OidcConfigurationForm: FC = ({ closeModal }: Props) => { + const notify = useNotify(); + const { + hasMetadataConfiguration, + settings, + isSettingsLoading, + settingsError, + } = useSupportedFeatures(); + const { data: configOptions, isLoading: isConfigOptionsLoading } = useQuery({ + queryKey: [queryKeys.configOptions], + queryFn: async () => fetchConfigOptions(hasMetadataConfiguration), + }); + const { + data: clusteredSettings = [], + isLoading: isClusteredSettingsLoading, + error: clusterError, + } = useClusteredSettings(); + + if (clusterError) { + notify.failure("Loading clustered settings failed", clusterError); + closeModal(); + return null; + } + + if (settingsError) { + notify.failure("Loading settings failed", settingsError); + closeModal(); + return null; + } + + if ( + isConfigOptionsLoading || + isSettingsLoading || + isClusteredSettingsLoading + ) { + return ; + } + + const headers = [ + { content: "Group", className: "u-hide" }, + { content: "Key", className: "key" }, + { content: "Value" }, + ]; + const configFields = toConfigFields(configOptions?.configs?.server ?? {}); + const rows: MainTableRow[] = configFields + .filter((t) => t.key.startsWith("oidc")) + .map((configField) => { + const clusteredValue = getConfigFieldClusteredValue( + clusteredSettings, + configField, + ); + + return getSettingRow( + configField, + false, + clusteredValue, + () => {}, + settings, + (message) => notify.success(message), + ); + }); + + return ( + <> + {!hasMetadataConfiguration && ( + + Update to LXD v5.19.0 or later to access these settings + + )} + + + + + ); +}; + +export default OidcConfigurationForm; diff --git a/src/pages/permissions/OidcConfigurationModal.tsx b/src/pages/permissions/OidcConfigurationModal.tsx new file mode 100644 index 0000000000..f9ef639811 --- /dev/null +++ b/src/pages/permissions/OidcConfigurationModal.tsx @@ -0,0 +1,28 @@ +import type { FC, KeyboardEvent } from "react"; +import { Modal } from "@canonical/react-components"; +import OidcConfigurationForm from "pages/permissions/OidcConfigurationForm"; + +interface Props { + close: () => void; +} + +const OidcConfigurationModal: FC = ({ close }) => { + const handleEscKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + close(); + } + }; + + return ( + + + + ); +}; + +export default OidcConfigurationModal; diff --git a/src/pages/permissions/PermissionIdentities.tsx b/src/pages/permissions/PermissionIdentities.tsx index baa773fc3d..9e87c91c49 100644 --- a/src/pages/permissions/PermissionIdentities.tsx +++ b/src/pages/permissions/PermissionIdentities.tsx @@ -33,16 +33,14 @@ import BulkDeleteIdentitiesBtn from "./actions/BulkDeleteIdentitiesBtn"; import DeleteIdentityBtn from "./actions/DeleteIdentityBtn"; import { useSupportedFeatures } from "context/useSupportedFeatures"; import { isUnrestricted } from "util/helpers"; -import CreateTlsIdentityBtn from "./CreateTlsIdentityBtn"; import { useIdentities } from "context/useIdentities"; import { useIdentityEntitlements } from "util/entitlements/identities"; import { pluralize } from "util/helpers"; import { getIdentityName } from "util/permissionIdentities"; -import CreateTLSIdentity from "./CreateTLSIdentity"; +import CreateTLSIdentity from "pages/permissions/CreateTLSIdentity"; +import PermissionIdentitiesActions from "pages/permissions/PermissionIdentitiesActions"; import ResourceLabel from "components/ResourceLabel"; import type { ResourceIconType } from "components/ResourceIcon"; -import SsoNotification from "pages/permissions/SsoNotification"; -import { AUTH_METHOD } from "util/authentication"; const PermissionIdentities: FC = () => { const notify = useNotify(); @@ -53,7 +51,6 @@ const PermissionIdentities: FC = () => { const [selectedIdentityIds, setSelectedIdentityIds] = useState([]); const { hasAccessManagementTLS } = useSupportedFeatures(); const { canEditIdentity } = useIdentityEntitlements(); - const hasOidc = settings?.auth_methods?.includes(AUTH_METHOD.OIDC); useEffect(() => { const validIdentityIds = new Set(identities.map((identity) => identity.id)); @@ -312,7 +309,7 @@ const PermissionIdentities: FC = () => { )} - @@ -322,7 +319,6 @@ const PermissionIdentities: FC = () => { {!panelParams.panel && (
-
)} diff --git a/src/pages/permissions/PermissionIdentitiesActions.tsx b/src/pages/permissions/PermissionIdentitiesActions.tsx new file mode 100644 index 0000000000..7a5f00296a --- /dev/null +++ b/src/pages/permissions/PermissionIdentitiesActions.tsx @@ -0,0 +1,58 @@ +import type { FC } from "react"; +import { cloneElement } from "react"; +import { ContextualMenu } from "@canonical/react-components"; +import { + largeScreenBreakpoint, + useIsScreenBelow, +} from "context/useIsScreenBelow"; +import OidcConfigurationBtn from "pages/permissions/OidcConfigurationBtn"; +import CreateTlsIdentityBtn from "pages/permissions/CreateTlsIdentityBtn"; + +interface Props { + openPanel: () => void; +} + +const PermissionIdentitiesActions: FC = ({ openPanel }) => { + const isSmallScreen = useIsScreenBelow(largeScreenBreakpoint); + + const classname = isSmallScreen + ? "p-contextual-menu__link" + : "p-segmented-control__button"; + + const menuElements = [ + , + , + ]; + + return ( + <> + {isSmallScreen ? ( + + {(close: () => void) => ( + + {[...menuElements].map((item) => + cloneElement(item, { onClose: close }), + )} + + )} + + ) : ( +
+
{menuElements}
+
+ )} + + ); +}; + +export default PermissionIdentitiesActions; diff --git a/src/pages/permissions/SsoNotification.tsx b/src/pages/permissions/SsoNotification.tsx index d97e4a0dea..e1ff171203 100644 --- a/src/pages/permissions/SsoNotification.tsx +++ b/src/pages/permissions/SsoNotification.tsx @@ -1,48 +1,38 @@ import type { FC } from "react"; -import { useState } from "react"; -import { Notification } from "@canonical/react-components"; +import { List, Notification } from "@canonical/react-components"; import DocLink from "components/DocLink"; -const loadClosed = () => { - const saved = localStorage.getItem("ssoNotificationClosed"); - return Boolean(saved); -}; - -const saveClosed = () => { - localStorage.setItem("ssoNotificationClosed", "yes"); -}; - -interface Props { - hasOidc: boolean; -} - -const SsoNotification: FC = ({ hasOidc }: Props) => { - const [closed, setClosed] = useState(loadClosed()); - - if (closed || hasOidc) { - return null; - } - - const handleClose = () => { - saveClosed(); - setClosed(true); +const SsoNotification: FC = () => { + const PROVIDERS = [ + { name: "Auth0", docPath: "/howto/oidc_auth0/" }, + { name: "Ory Hydra", docPath: "/howto/oidc_ory/" }, + { name: "Keycloak", docPath: "/howto/oidc_keycloak/" }, + { name: "Entra ID", docPath: "/howto/oidc_entra_id/" }, + { name: "Pocket ID", docPath: "/howto/oidc_pocket_id/" }, + ]; + + const getOidcProviderLink = (providerName: string, docPath: string) => { + return {providerName}; }; return ( - <> - - Show me how - , - ]} - > - LXD can be configured to log in using a single sign-on provider. - - + +

+ LXD integrates with external identity providers using{" "} + OpenID Connect (OIDC) to provide centralized login management. +

+

+ Choose an SSO provider to learn how to connect it to LXD: + + getOidcProviderLink(provider.name, provider.docPath), + )} + middot + /> + Your provider is not listed? Check our{" "} + general OIDC documentation. +

+
); }; diff --git a/src/pages/settings/SettingForm.tsx b/src/pages/settings/SettingForm.tsx index ec1ce8be3d..369d9b247b 100644 --- a/src/pages/settings/SettingForm.tsx +++ b/src/pages/settings/SettingForm.tsx @@ -1,4 +1,4 @@ -import type { FC } from "react"; +import type { FC, ReactNode } from "react"; import { useEffect, useRef, useState } from "react"; import { Button, @@ -27,6 +27,7 @@ interface Props { onDelete: (key: string) => void; value?: string; clusteredValue?: ClusterSpecificValues; + onSuccess?: (message: ReactNode) => void; } const SettingForm: FC = ({ @@ -34,6 +35,7 @@ const SettingForm: FC = ({ onDelete, value, clusteredValue, + onSuccess, }) => { const { isRestricted } = useAuth(); const [isEditMode, setEditMode] = useState(false); @@ -68,7 +70,13 @@ const SettingForm: FC = ({ mutation .then(() => { - toastNotify.success(<>Setting {settingLabel} updated.); + const message = <>Setting {settingLabel} updated.; + if (onSuccess) { + onSuccess(message); + } else { + toastNotify.success(message); + } + setEditMode(false); }) .catch((e) => { diff --git a/src/pages/settings/SettingsRow.tsx b/src/pages/settings/SettingsRow.tsx index 61a4882259..b47b1f3bbd 100644 --- a/src/pages/settings/SettingsRow.tsx +++ b/src/pages/settings/SettingsRow.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import type { MainTableRow } from "@canonical/react-components/dist/components/MainTable/MainTable"; import type { ClusterSpecificValues } from "types/cluster"; import SettingForm from "pages/settings/SettingForm"; @@ -21,6 +22,7 @@ export const getSettingRow = ( clusteredValue: ClusterSpecificValues, deleteUserSetting: (key: string) => void, settings?: LxdSettings, + onSuccess?: (message: ReactNode) => void, ): MainTableRow => { const isDefault = !Object.keys(settings?.config ?? {}).some( (key) => key === configField.key, @@ -57,6 +59,7 @@ export const getSettingRow = ( value={value} clusteredValue={clusteredValue} onDelete={deleteUserSetting} + onSuccess={onSuccess} /> ), role: "cell", diff --git a/src/sass/_permission_identities.scss b/src/sass/_permission_identities.scss index 5003bb88e7..1dac7c1ffc 100644 --- a/src/sass/_permission_identities.scss +++ b/src/sass/_permission_identities.scss @@ -1,4 +1,11 @@ .permission-identities-list { + .page-header__base-actions { + .p-segmented-control { + .p-segmented-control__list { + gap: $sph--large; + } + } + } .permission-identities-table { .auth-method { width: 8rem; @@ -44,3 +51,31 @@ } } } + +.edit-oidc-config { + @include large { + .p-modal__dialog { + min-width: 30rem; + } + } + + .p-modal__dialog { + height: calc(100% - 2rem); + + > div { + width: 54rem; + } + } + + .u-loader { + align-items: center; + display: flex; + justify-content: center; + } + + #oidc-configuration-table { + .group { + display: none; + } + } +}