diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOConfig.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOConfig.tsx index 5bb9e7b3d..5802278e1 100644 --- a/src/core/client/admin/routes/Configure/sections/Auth/SSOConfig.tsx +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOConfig.tsx @@ -2,12 +2,13 @@ import { Localized } from "@fluent/react/compat"; import React, { FunctionComponent } from "react"; import { graphql } from "react-relay"; -import { PropTypesOf } from "coral-framework/types"; +import { ExternalLink } from "coral-framework/lib/i18n/components"; +import { FormFieldDescription } from "coral-ui/components/v2"; import Header from "../../Header"; import ConfigBoxWithToggleField from "./ConfigBoxWithToggleField"; import RegistrationField from "./RegistrationField"; -import SSOKeyFieldContainer from "./SSOKeyFieldContainer"; +import SSOKeyRotationQuery from "./SSOKeyRotation/SSOKeyRotationQuery"; import TargetFilterField from "./TargetFilterField"; // eslint-disable-next-line no-unused-expressions @@ -28,10 +29,9 @@ graphql` interface Props { disabled?: boolean; - sso: PropTypesOf["sso"]; } -const SSOConfig: FunctionComponent = ({ disabled, sso }) => ( +const SSOConfig: FunctionComponent = ({ disabled }) => ( @@ -44,7 +44,23 @@ const SSOConfig: FunctionComponent = ({ disabled, sso }) => ( > {disabledInside => ( <> - + + } + DocLink={ + + } + > + + To enable integration with your existing authentication system, you + will need to create a JWT Token to connect. You can learn more about + creating a JWT Token with this introduction. See our documentation + for additional information on single sign on. + + + diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOConfigContainer.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOConfigContainer.tsx index 13e9fee12..775eb0441 100644 --- a/src/core/client/admin/routes/Configure/sections/Auth/SSOConfigContainer.tsx +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOConfigContainer.tsx @@ -16,17 +16,13 @@ const SSOConfigContainer: React.FunctionComponent = ({ disabled, auth, }) => { - return ; + return ; }; const enhanced = withFragmentContainer({ auth: graphql` fragment SSOConfigContainer_auth on Auth { - integrations { - sso { - ...SSOKeyFieldContainer_sso - } - } + ...SSOConfig_formValues } `, })(SSOConfigContainer); diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyField.css b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyField.css deleted file mode 100644 index 1b2f8e3c5..000000000 --- a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyField.css +++ /dev/null @@ -1,29 +0,0 @@ -.root { - padding-bottom: var(--spacing-4); -} - -.keyGenerated { - composes: button from "coral-ui/shared/typography.css"; - color: var(--palette-text-secondary); - flex-shrink: 0; -} - -.warnIcon { - color: var(--palette-text-secondary); - flex-shrink: 0; - padding-top: 3px; - padding-right: var(--spacing-1); -} - -.warn { - color: var(--palette-text-secondary); -} - -.warningSection { - padding-top: var(--spacing-1); - padding-bottom: var(--spacing-1); -} - -.regenerateButton { - float: right; -} diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyField.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyField.tsx deleted file mode 100644 index 83d47b43c..000000000 --- a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyField.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { Localized } from "@fluent/react/compat"; -import React, { FunctionComponent } from "react"; - -import { - Button, - Flex, - FormField, - Icon, - Label, - PasswordField, -} from "coral-ui/components/v2"; - -import HelperText from "../../HelperText"; - -import styles from "./SSOKeyField.css"; - -interface Props { - disabled?: boolean; - generatedKey?: string; - keyGeneratedAt?: any; - onRegenerate?: () => void; -} - -const SSOKeyField: FunctionComponent = ({ - generatedKey, - keyGeneratedAt, - disabled, - onRegenerate, -}) => ( - - - - - - {keyGeneratedAt && ( - - - KEY GENERATED AT: {keyGeneratedAt} - - - )} -
- - warning - - - When regenerating a key, tokens signed with the previous key will be - honored for 30 days. - - - -
- - - - -
-); - -export default SSOKeyField; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyFieldContainer.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyFieldContainer.tsx deleted file mode 100644 index 4c1fa0eff..000000000 --- a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyFieldContainer.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from "react"; -import { graphql } from "react-relay"; - -import { - MutationProp, - withFragmentContainer, - withMutation, -} from "coral-framework/lib/relay"; - -import { SSOKeyFieldContainer_sso as SSOData } from "coral-admin/__generated__/SSOKeyFieldContainer_sso.graphql"; - -import RegenerateSSOKeyMutation from "./RegenerateSSOKeyMutation"; -import SSOKeyField from "./SSOKeyField"; - -interface Props { - sso: SSOData; - disabled?: boolean; - regenerateSSOKey: MutationProp; -} - -interface State { - awaitingResponse: boolean; -} - -class SSOKeyFieldContainer extends React.Component { - public state = { - awaitingResponse: false, - }; - - private handleRegenerate = async () => { - this.setState({ awaitingResponse: true }); - await this.props.regenerateSSOKey(); - this.setState({ awaitingResponse: false }); - }; - - public render() { - const { disabled } = this.props; - return ( - - ); - } -} - -const enhanced = withMutation(RegenerateSSOKeyMutation)( - withFragmentContainer({ - sso: graphql` - fragment SSOKeyFieldContainer_sso on SSOAuthIntegration { - key - keyGeneratedAt - } - `, - })(SSOKeyFieldContainer) -); - -export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.css b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.css new file mode 100644 index 000000000..dee544d71 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.css @@ -0,0 +1,10 @@ +.label { + padding-bottom: var(--v2-spacing-2); +} + +.date { + font-family: var(--v2-font-family-primary); + font-weight: var(--v2-font-weight-primary-regular); + font-size: var(--v2-font-size-2); + line-height: var(--v2-line-height-reset); +} diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.tsx new file mode 100644 index 000000000..528875980 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.tsx @@ -0,0 +1,97 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; + +import { Flex, Label } from "coral-ui/components/v2"; + +import { SSOKeyStatus } from "./StatusField"; + +import styles from "./DateField.css"; + +export interface SSOKeyDates { + readonly createdAt: string; + readonly lastUsedAt: string | null; + readonly rotatedAt: string | null; + readonly inactiveAt: string | null; +} + +interface Props { + status: SSOKeyStatus; + dates: SSOKeyDates; +} + +const DateField: FunctionComponent = ({ status, dates }) => { + switch (status) { + case SSOKeyStatus.ACTIVE: + return ( + <> +
+ + + +
+ + {dates.createdAt} + + + ); + case SSOKeyStatus.EXPIRING: + return ( + <> +
+ + + +
+ + + {dates.inactiveAt} + + + + ); + case SSOKeyStatus.EXPIRED: + return ( + <> +
+ + + +
+ + + {dates.inactiveAt} + + + + ); + default: + return null; + } +}; + +export default DateField; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DeactivateSSOKeyMutation.ts b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DeactivateSSOKeyMutation.ts new file mode 100644 index 000000000..16bab846d --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DeactivateSSOKeyMutation.ts @@ -0,0 +1,52 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { DeactivateSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/DeactivateSSOKeyMutation.graphql"; + +const clientMutationId = 0; + +const DeactivateSSOKeyMutation = createMutation( + "deactivateSSOKey", + (environment: Environment, input: MutationInput) => { + return commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation DeactivateSSOKeyMutation($input: DeactivateSSOKeyInput!) { + deactivateSSOKey(input: $input) { + settings { + auth { + integrations { + sso { + enabled + keys { + kid + secret + createdAt + lastUsedAt + rotatedAt + inactiveAt + } + } + } + } + } + clientMutationId + } + } + `, + variables: { + input: { + ...input, + clientMutationId: clientMutationId.toString(), + }, + }, + }); + } +); + +export default DeactivateSSOKeyMutation; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DeleteSSOKeyMutation.ts b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DeleteSSOKeyMutation.ts new file mode 100644 index 000000000..b459854c1 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DeleteSSOKeyMutation.ts @@ -0,0 +1,52 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { DeleteSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/DeleteSSOKeyMutation.graphql"; + +const clientMutationId = 0; + +const DeleteSSOKeyMutation = createMutation( + "deleteSSOKey", + (environment: Environment, input: MutationInput) => { + return commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation DeleteSSOKeyMutation($input: DeleteSSOKeyInput!) { + deleteSSOKey(input: $input) { + settings { + auth { + integrations { + sso { + enabled + keys { + kid + secret + createdAt + lastUsedAt + rotatedAt + inactiveAt + } + } + } + } + } + clientMutationId + } + } + `, + variables: { + input: { + ...input, + clientMutationId: clientMutationId.toString(), + }, + }, + }); + } +); + +export default DeleteSSOKeyMutation; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotateSSOKeyMutation.ts b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotateSSOKeyMutation.ts new file mode 100644 index 000000000..8010a0aa8 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotateSSOKeyMutation.ts @@ -0,0 +1,52 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { RotateSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/RotateSSOKeyMutation.graphql"; + +const clientMutationId = 0; + +const RotateSSOKeyMutation = createMutation( + "rotateSSOKey", + (environment: Environment, input: MutationInput) => { + return commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation RotateSSOKeyMutation($input: RotateSSOKeyInput!) { + rotateSSOKey(input: $input) { + settings { + auth { + integrations { + sso { + enabled + keys { + kid + secret + createdAt + lastUsedAt + rotatedAt + inactiveAt + } + } + } + } + } + clientMutationId + } + } + `, + variables: { + input: { + ...input, + clientMutationId: clientMutationId.toString(), + }, + }, + }); + } +); + +export default RotateSSOKeyMutation; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationDropdown.css b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationDropdown.css new file mode 100644 index 000000000..5f3824668 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationDropdown.css @@ -0,0 +1,3 @@ +.rotate { + margin-right: var(--v2-spacing-1) +} diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationDropdown.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationDropdown.tsx new file mode 100644 index 000000000..f3eea1458 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationDropdown.tsx @@ -0,0 +1,72 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; + +import { + Button, + ClickOutside, + Dropdown, + DropdownButton, + Icon, + Popover, +} from "coral-ui/components/v2"; + +import RotateOption, { RotateOptions } from "./RotationOption"; + +import styles from "./RotationDropdown.css"; + +interface Props { + onRotateKey: (rotation: string) => void; + disabled?: boolean; +} + +const RotationDropDown: FunctionComponent = ({ + onRotateKey, + disabled, +}) => { + return ( + + ( + + + {Object.keys(RotateOptions).map((opt: string) => ( + { + onRotateKey(opt); + toggleVisibility(); + }} + disabled={disabled} + > + + + ))} + + + )} + > + {({ toggleVisibility, ref, visible }) => ( + + )} + + + ); +}; + +export default RotationDropDown; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationOption.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationOption.tsx new file mode 100644 index 000000000..e0a02cbb5 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationOption.tsx @@ -0,0 +1,46 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; + +export enum RotateOptions { + NOW = "NOW", + IN1DAY = "IN1DAY", + IN1WEEK = "IN1WEEK", + IN30DAYS = "IN30DAYS", +} + +interface Props { + value: string; +} + +const RotationOption: FunctionComponent = ({ value }) => { + switch (value) { + case RotateOptions.NOW: { + return Now; + } + case RotateOptions.IN1DAY: { + return ( + + 1 day from now + + ); + } + case RotateOptions.IN1WEEK: { + return ( + + 1 week from now + + ); + } + case RotateOptions.IN30DAYS: { + return ( + + 30 days from now + + ); + } + default: + return Now; + } +}; + +export default RotationOption; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyCard.css b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyCard.css new file mode 100644 index 000000000..2b4e295fe --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyCard.css @@ -0,0 +1,23 @@ +.label { + padding-bottom: var(--v2-spacing-2); +} + +.keySection { + flex-grow: 1; + min-width: 50px; + + padding-right: var(--v2-spacing-3); +} + +.statusSection { + margin-right: var(--v2-spacing-3); +} + +.secretSection { + flex-grow: 1; + min-width: 50px; +} + +.action { + margin-right: var(--v2-spacing-1) +} diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyCard.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyCard.tsx new file mode 100644 index 000000000..39df18340 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyCard.tsx @@ -0,0 +1,187 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent, useCallback } from "react"; +import CopyToClipboard from "react-copy-to-clipboard"; + +import { useMutation } from "coral-framework/lib/relay"; +import { + Button, + Card, + Flex, + HorizontalGutter, + Icon, + Label, + PasswordField, + TextField, +} from "coral-ui/components/v2"; + +import DateField from "./DateField"; +import DeactivateSSOKeyMutation from "./DeactivateSSOKeyMutation"; +import DeleteSSOKeyMutation from "./DeleteSSOKeyMutation"; +import RotateSSOKeyMutation from "./RotateSSOKeyMutation"; +import RotationDropDown from "./RotationDropdown"; +import { RotateOptions } from "./RotationOption"; +import StatusField, { SSOKeyStatus } from "./StatusField"; + +import styles from "./SSOKeyCard.css"; + +export interface SSOKeyDates { + readonly createdAt: string; + readonly lastUsedAt: string | null; + readonly rotatedAt: string | null; + readonly inactiveAt: string | null; +} + +interface Props { + id: string; + secret: string; + status: SSOKeyStatus; + dates: SSOKeyDates; + disabled?: boolean; +} + +function createActionButton( + status: SSOKeyStatus, + onRotateKey: (rotation: string) => void, + onDeactivateKey: () => void, + onDelete: () => void, + disabled?: boolean +) { + switch (status) { + case SSOKeyStatus.ACTIVE: + return ; + case SSOKeyStatus.EXPIRING: + return ( + + + + ); + case SSOKeyStatus.EXPIRED: + return ( + + + + ); + default: + return null; + } +} + +const SSOKeyCard: FunctionComponent = ({ + id, + secret, + status, + dates, + disabled, +}) => { + const rotateSSOKey = useMutation(RotateSSOKeyMutation); + const deactivateSSOKey = useMutation(DeactivateSSOKeyMutation); + const deleteSSOKey = useMutation(DeleteSSOKeyMutation); + + const onRotate = useCallback( + (rotation: string) => { + switch (rotation) { + case RotateOptions.NOW: + rotateSSOKey({ inactiveIn: 0 }); + break; + case RotateOptions.IN1DAY: + rotateSSOKey({ inactiveIn: 24 * 60 * 60 }); + break; + case RotateOptions.IN1WEEK: + rotateSSOKey({ inactiveIn: 7 * 24 * 60 * 60 }); + break; + case RotateOptions.IN30DAYS: + rotateSSOKey({ inactiveIn: 30 * 24 * 60 * 60 }); + break; + default: + rotateSSOKey({ inactiveIn: 0 }); + } + }, + [rotateSSOKey] + ); + const onDeactivate = useCallback(() => { + deactivateSSOKey({ + kid: id, + }); + }, [deactivateSSOKey, id]); + const onDelete = useCallback(() => { + deleteSSOKey({ + kid: id, + }); + }, [deleteSSOKey, id]); + + return ( + + + +
+
+ + + +
+ +
+
+
+ + + +
+ + + + + + +
+
+ + +
+
+ + + +
+ +
+
+ +
+
+ {createActionButton( + status, + onRotate, + onDeactivate, + onDelete, + disabled + )} +
+
+
+ ); +}; + +export default SSOKeyCard; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyRotationContainer.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyRotationContainer.tsx new file mode 100644 index 000000000..34f828982 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyRotationContainer.tsx @@ -0,0 +1,130 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent, useMemo } from "react"; + +import { graphql, withFragmentContainer } from "coral-framework/lib/relay"; +import { Label } from "coral-ui/components/v2"; + +import { SSOKeyRotationContainer_settings } from "coral-admin/__generated__/SSOKeyRotationContainer_settings.graphql"; + +import SSOKeyCard, { SSOKeyDates } from "./SSOKeyCard"; +import { SSOKeyStatus } from "./StatusField"; + +interface Props { + settings: SSOKeyRotationContainer_settings; + disabled?: boolean; +} + +interface Key { + readonly kid: string; + readonly secret: string; + readonly createdAt: string; + readonly lastUsedAt: string | null; + readonly rotatedAt: string | null; + readonly inactiveAt: string | null; +} + +function getStatus(dates: SSOKeyDates) { + if ( + dates.inactiveAt && + dates.rotatedAt && + new Date(dates.inactiveAt) > new Date() + ) { + return SSOKeyStatus.EXPIRING; + } + + if (dates.inactiveAt && new Date(dates.inactiveAt) <= new Date()) { + return SSOKeyStatus.EXPIRED; + } + + return SSOKeyStatus.ACTIVE; +} + +const SSOKeyRotationContainer: FunctionComponent = ({ + disabled, + settings, +}) => { + const { + auth: { + integrations: { + sso: { keys }, + }, + }, + } = settings; + + const sortedKeys = useMemo( + () => + keys + // Copy this map because we don't want to modify the underlying copy. + .map(key => key) + .sort((a: Key, b: Key) => { + // Both active, sort on createdAt date. + if (!a.inactiveAt && !b.inactiveAt) { + return ( + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + } + // A is active, B is not, A comes before B. + if (!a.inactiveAt && b.inactiveAt) { + return -1; + } + // B is active, A is not, B comes before A. + if (a.inactiveAt && !b.inactiveAt) { + return 1; + } + + // Sort primarily on inactiveAt, fall back to createdAt if + // for some reason it's not available. + const aDate = a.inactiveAt + ? new Date(a.inactiveAt) + : new Date(a.createdAt); + const bDate = b.inactiveAt + ? new Date(b.inactiveAt) + : new Date(b.createdAt); + + return bDate.getTime() - aDate.getTime(); + }), + [keys] + ); + + return ( + <> + + + + {sortedKeys.map(key => ( + + ))} + + ); +}; + +const enhanced = withFragmentContainer({ + settings: graphql` + fragment SSOKeyRotationContainer_settings on Settings { + auth { + integrations { + sso { + enabled + keys { + kid + secret + createdAt + lastUsedAt + rotatedAt + inactiveAt + } + } + } + } + } + `, +})(SSOKeyRotationContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyRotationQuery.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyRotationQuery.tsx new file mode 100644 index 000000000..dcf7c62b2 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyRotationQuery.tsx @@ -0,0 +1,54 @@ +import React, { FunctionComponent } from "react"; + +import { + graphql, + QueryRenderData, + QueryRenderer, +} from "coral-framework/lib/relay"; +import { CallOut, Spinner } from "coral-ui/components/v2"; + +import { SSOKeyRotationQuery as QueryTypes } from "coral-admin/__generated__/SSOKeyRotationQuery.graphql"; + +import SSOKeyRotationContainer from "./SSOKeyRotationContainer"; + +interface Props { + disabled?: boolean; +} + +const SSOKeyRotationQuery: FunctionComponent = ({ disabled }) => { + return ( + + query={graphql` + query SSOKeyRotationQuery { + settings { + ...SSOKeyRotationContainer_settings + } + } + `} + variables={{}} + cacheConfig={{ force: true }} + render={({ error, props }: QueryRenderData) => { + if (error) { + return {error.message}; + } + + if (!props) { + return ; + } + + if (!props.settings) { + return ; + } + + return ( + + ); + }} + /> + ); +}; + +export default SSOKeyRotationQuery; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/StatusField.css b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/StatusField.css new file mode 100644 index 000000000..9c8630cd4 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/StatusField.css @@ -0,0 +1,35 @@ +.status { + font-family: var(--v2-font-family-primary); + font-weight: var(--v2-font-weight-primary-regular); + font-size: var(--v2-font-size-2); + line-height: var(--v2-line-height-reset); + + border-radius: 2px; + padding-left: var(--v2-spacing-1); + padding-right: var(--v2-spacing-1); +} + +.active { + background-color: var(--v2-colors-green-500); + color: var(--v2-colors-pure-white); +} + +.expiring { + background-color: var(--v2-colors-yellow-500); + color: var(--v2-colors-mono-500); + + padding-top: var(--v2-spacing-1); + padding-bottom: var(--v2-spacing-1); +} + +.expired { + background-color: var(--v2-colors-red-500); + color: var(--v2-colors-pure-white); + + padding-top: var(--v2-spacing-1); + padding-bottom: var(--v2-spacing-1); +} + +.icon { + padding-right: var(--v2-spacing-1); +} diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/StatusField.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/StatusField.tsx new file mode 100644 index 000000000..c6f835554 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/StatusField.tsx @@ -0,0 +1,117 @@ +import { Localized } from "@fluent/react/compat"; +import cn from "classnames"; +import React, { FunctionComponent } from "react"; + +import { Flex, Icon, Tooltip, TooltipButton } from "coral-ui/components/v2"; + +import styles from "./StatusField.css"; + +export enum SSOKeyStatus { + EXPIRED, + EXPIRING, + ACTIVE, +} + +interface Props { + status: SSOKeyStatus; +} + +const StatusField: FunctionComponent = ({ status }) => { + switch (status) { + case SSOKeyStatus.ACTIVE: + return ( + + + Active + + + ); + case SSOKeyStatus.EXPIRING: + return ( + + + alarm + + Expiring + + + + + An SSO key is expiring when it is scheduled for rotation. + + + } + button={({ toggleVisibility, ref, visible }) => ( + + + + )} + /> + + ); + case SSOKeyStatus.EXPIRED: + return ( + + + + Expired + + + + + An SSO key is expired when it has been rotated out of use. + + + } + button={({ toggleVisibility, ref, visible }) => ( + + + + )} + /> + + ); + default: + return ( + + Unknown + + ); + } +}; + +export default StatusField; diff --git a/src/core/client/admin/test/configure/__snapshots__/auth.spec.tsx.snap b/src/core/client/admin/test/configure/__snapshots__/auth.spec.tsx.snap index 40e92c098..c261b3c9a 100644 --- a/src/core/client/admin/test/configure/__snapshots__/auth.spec.tsx.snap +++ b/src/core/client/admin/test/configure/__snapshots__/auth.spec.tsx.snap @@ -1242,86 +1242,233 @@ integration to register for a new account.
-
- + this introduction + + . See our + + + documentation + + for additional information on single sign on. +

+ +
-
- + +
+
+ +
+
+
+
+ +
+
+
+
+ +
+ +
+
+
+ +
+
+
+
+
+
+
+ +
+ + Active + +
+
+
+ +
+ + 1/1/2020, 1:00 AM + +
+
+
+ +
-
-
- -

- When regenerating a key, tokens signed with the previous key will be honored for 30 days. -

-
-
-
{ expect(within(configureContainer).toJSON()).toMatchSnapshot(); }); -it("regenerate sso key", async () => { +it("rotate sso key", async () => { const { testRenderer } = await createTestRenderer({ resolvers: createResolversStub({ Mutation: { - regenerateSSOKey: () => { + rotateSSOKey: () => { return { settings: pureMerge( settingsWithEmptyAuth, @@ -69,8 +70,22 @@ it("regenerate sso key", async () => { auth: { integrations: { sso: { - key: "==GENERATED_KEY==", - keyGeneratedAt: "2018-11-12T23:26:06.239Z", + enabled: true, + keys: [ + { + kid: "kid-01", + secret: "secret", + createdAt: "2015-01-01T00:00:00.000Z", + lastUsedAt: "2016-01-01T01:45:00.000Z", + rotatedAt: "2016-01-01T01:45:00.000Z", + inactiveAt: "2016-01-01T01:45:00.000Z", + }, + { + kid: "kid-02", + secret: "new-secret", + createdAt: "2019-01-01T01:45:00.000Z", + }, + ], }, }, }, @@ -90,15 +105,40 @@ it("regenerate sso key", async () => { act(() => { within(container) - .getByText("Regenerate", { selector: "button" }) + .getByText("Rotate", { selector: "button" }) .props.onClick(); }); - await wait(() => - expect(within(container).getByLabelText("Key").props.value).toBe( - "==GENERATED_KEY==" - ) - ); + const rotateNow = await waitForElement(() => { + return within(container).getByText("Now", { selector: "button" }); + }); + + act(() => { + rotateNow.props.onClick(); + }); + + await wait(() => { + // Check that we have two SSO Keys that match + // our expected key IDs + const keyIDs = within(container).getAllByTestID("SSO-Key-ID"); + const hasOldKey = keyIDs.some(k => k.props.value === "kid-01"); + const hasNewKey = keyIDs.some(k => k.props.value === "kid-02"); + expect(hasNewKey).toBe(true); + expect(hasOldKey).toBe(true); + + const statuses = within(container).getAllByTestID("SSO-Key-Status"); + expect(statuses.length).toBe(2); + const firstStatus: any = toJSON(statuses[0]); + const firstStatusIsActive = firstStatus.children.some( + (s: string) => s === "Active" + ); + expect(firstStatusIsActive).toBe(true); + const secondStatus: any = toJSON(statuses[1]); + const secondStatusIsActive = secondStatus.children.some( + (s: string) => s === "Active" + ); + expect(secondStatusIsActive).toBe(false); + }); }); it("prevents admin lock out", async () => { diff --git a/src/core/client/admin/test/fixtures.ts b/src/core/client/admin/test/fixtures.ts index a2ca94e2c..ae14ee502 100644 --- a/src/core/client/admin/test/fixtures.ts +++ b/src/core/client/admin/test/fixtures.ts @@ -114,6 +114,16 @@ export const settings = createFixture({ admin: true, stream: true, }, + keys: [ + { + kid: "kid-01", + secret: "secret", + createdAt: "2020-01-01T01:00:00.000Z", + lastUsedAt: undefined, + rotatedAt: undefined, + inactiveAt: undefined, + }, + ], key: "", keyGeneratedAt: null, }, @@ -202,6 +212,16 @@ export const settingsWithEmptyAuth = createFixture( stream: true, }, key: "", + keys: [ + { + kid: "kid-01", + secret: "secret", + createdAt: "2020-01-01T01:00:00.000Z", + lastUsedAt: undefined, + rotatedAt: undefined, + inactiveAt: undefined, + }, + ], keyGeneratedAt: null, }, google: { diff --git a/src/core/server/graph/schema/schema.graphql b/src/core/server/graph/schema/schema.graphql index cf4aeb9bc..2b63a8675 100644 --- a/src/core/server/graph/schema/schema.graphql +++ b/src/core/server/graph/schema/schema.graphql @@ -5970,6 +5970,34 @@ type DeactivateSSOKeyPayload { settings: Settings } +######################### +## deleteSSOKey +######################### + +input DeleteSSOKeyInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + kid is the ID of the SSOKey being deleted. + """ + kid: ID! +} + +type DeleteSSOKeyPayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + settings is the Settings that the SSO key was regenerated on. + """ + settings: Settings +} + ######################### # disableFeatureFlag ######################### @@ -6064,34 +6092,6 @@ type RemoveStoryExpertPayload { story: Story! } -######################### -## deleteSSOKey -######################### - -input DeleteSSOKeyInput { - """ - clientMutationId is required for Relay support. - """ - clientMutationId: String! - - """ - kid is the ID of the SSOKey being deleted. - """ - kid: ID! -} - -type DeleteSSOKeyPayload { - """ - clientMutationId is required for Relay support. - """ - clientMutationId: String! - - """ - settings is the Settings that the SSO key was regenerated on. - """ - settings: Settings -} - ######################### ## updateStoryMode ######################### diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index dd4c99679..36a53526d 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -427,6 +427,51 @@ configure-auth-sso-regenerateAt = KEY GENERATED AT: configure-auth-sso-regenerateHonoredWarning = When regenerating a key, tokens signed with the previous key will be honored for 30 days. +configure-auth-sso-description = + To enable integration with your existing authentication system, + you will need to create a JWT Token to connect. You can learn + more about creating a JWT Token with this introduction. See our + documentation for additional information on single sign on. + +configure-auth-sso-rotate-keys = Keys +configure-auth-sso-rotate-keyID = Key ID +configure-auth-sso-rotate-secret = Secret +configure-auth-sso-rotate-copySecret = + .aria-label = Copy Secret + +configure-auth-sso-rotate-date = + { DATETIME($date, year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric") } +configure-auth-sso-rotate-activeSince = Active Since +configure-auth-sso-rotate-inactiveAt = Inactive At +configure-auth-sso-rotate-inactiveSince = Inactive Since + +configure-auth-sso-rotate-status = Status +configure-auth-sso-rotate-statusActive = Active +configure-auth-sso-rotate-statusExpiring = Expiring +configure-auth-sso-rotate-statusExpired = Expired +configure-auth-sso-rotate-statusUnknown = Unknown + +configure-auth-sso-rotate-expiringTooltip = + An SSO key is expiring when it is scheduled for rotation. +configure-auth-sso-rotate-expiringTooltip-toggleButton = + .aria-label = Toggle expiring tooltip visibility +configure-auth-sso-rotate-expiredTooltip = + An SSO key is expired when it has been rotated out of use. +configure-auth-sso-rotate-expiredTooltip-toggleButton = + Toggle expired tooltip visibility + +configure-auth-sso-rotate-rotate = Rotate +configure-auth-sso-rotate-deactivateNow = Deactivate Now +configure-auth-sso-rotate-delete = Delete + +configure-auth-sso-rotate-now = Now +configure-auth-sso-rotate-10seconds = 10 seconds from now +configure-auth-sso-rotate-1day = 1 day from now +configure-auth-sso-rotate-1week = 1 week from now +configure-auth-sso-rotate-30days = 30 days from now +configure-auth-sso-rotate-dropdown-description = + .description = A dropdown to rotate the SSO key + configure-auth-local-loginWith = Login with email authentication configure-auth-local-useLoginOn = Use email authentication login on