Skip to content

Commit

Permalink
Enable adding and removing SSO config from web (#4097)
Browse files Browse the repository at this point in the history
Co-authored-by: Alexei Mochalov <[email protected]>
  • Loading branch information
fiskus and nl0 authored Aug 16, 2024
1 parent c2ae590 commit 095dbc9
Show file tree
Hide file tree
Showing 9 changed files with 137 additions and 69 deletions.
35 changes: 16 additions & 19 deletions catalog/app/containers/Admin/UsersAndRoles/Roles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import ROLE_UPDATE_UNMANAGED_MUTATION from './gql/RoleUpdateUnmanaged.generated'
import ROLE_DELETE_MUTATION from './gql/RoleDelete.generated'
import ROLE_SET_DEFAULT_MUTATION from './gql/RoleSetDefault.generated'
import { RoleSelectionFragment as Role } from './gql/RoleSelection.generated'
import SSO_CONFIG_QUERY from './gql/SsoConfig.generated'

const columns: Table.Column<Role>[] = [
{
Expand Down Expand Up @@ -705,9 +704,6 @@ export default function Roles() {
const defaultRoleId = data.defaultRole?.id
const isDefaultRoleSettingDisabled = !!data.admin?.isDefaultRoleSettingDisabled

const ssoConfigData = GQL.useQueryS(SSO_CONFIG_QUERY)
const hasSsoConfig = !!ssoConfigData.admin?.ssoConfig

const filtering = Table.useFiltering({
rows,
filterBy: ({ name }) => name,
Expand All @@ -718,21 +714,22 @@ export default function Roles() {
})
const dialogs = Dialogs.use()

const createAction = {
title: 'Create',
icon: <M.Icon>add</M.Icon>,
fn: React.useCallback(() => {
dialogs.open(({ close }) => <Create {...{ close }} />)
}, [dialogs.open]), // eslint-disable-line react-hooks/exhaustive-deps
}
const ssoConfigAction = {
title: 'SSO Config',
icon: <M.Icon>assignment_ind</M.Icon>,
fn: React.useCallback(() => {
dialogs.open(({ close }) => <SsoConfig {...{ close }} />)
}, [dialogs.open]), // eslint-disable-line react-hooks/exhaustive-deps
}
const toolbarActions = hasSsoConfig ? [ssoConfigAction, createAction] : [createAction]
const toolbarActions = [
{
title: 'SSO Config',
icon: <M.Icon>assignment_ind</M.Icon>,
fn: React.useCallback(() => {
dialogs.open(({ close }) => <SsoConfig {...{ close }} />)
}, [dialogs.open]), // eslint-disable-line react-hooks/exhaustive-deps
},
{
title: 'Create',
icon: <M.Icon>add</M.Icon>,
fn: React.useCallback(() => {
dialogs.open(({ close }) => <Create {...{ close }} />)
}, [dialogs.open]), // eslint-disable-line react-hooks/exhaustive-deps
},
]

const inlineActions = (role: Role) => [
role.arn
Expand Down
135 changes: 103 additions & 32 deletions catalog/app/containers/Admin/UsersAndRoles/SsoConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,41 @@ import * as FF from 'final-form'
import * as React from 'react'
import * as RF from 'react-final-form'
import * as M from '@material-ui/core'
import * as Lab from '@material-ui/lab'

import Lock from 'components/Lock'
import { loadMode } from 'components/FileEditor/loader'
import { docs } from 'constants/urls'
import type * as Model from 'model'
import type * as Dialogs from 'utils/GlobalDialogs'
import * as GQL from 'utils/GraphQL'
import StyledLink from 'utils/StyledLink'
import assertNever from 'utils/assertNever'
import { mkFormError, mapInputErrors } from 'utils/formTools'
import * as validators from 'utils/validators'

import { FormError } from '../Form'

import SET_SSO_CONFIG_MUTATION from './gql/SetSsoConfig.generated'
import SSO_CONFIG_QUERY from './gql/SsoConfig.generated'

const TextEditor = React.lazy(() => import('components/FileEditor/TextEditor'))

const TEXT_FIELD_ERRORS = {
required: 'Enter an SSO config',
}

const FORM_ERRORS = {
unexpected: 'Unable to update SSO config: something went wrong',
}

type TextFieldProps = RF.FieldRenderProps<string> & M.TextFieldProps

const TEXT_EDITOR_TYPE = { brace: 'yaml' as const }

const ERRORS = {
required: 'Enter an SSO config',
}

function TextField({ errors, input, meta }: TextFieldProps) {
// TODO: lint yaml
const errorMessage = meta.submitFailed && errors[meta.error]
const error = meta.error || meta.submitError
const errorMessage = meta.submitFailed && error ? errors[error] || error : undefined
return (
<TextEditor
error={errorMessage ? new Error(errorMessage) : null}
Expand All @@ -39,36 +48,45 @@ function TextField({ errors, input, meta }: TextFieldProps) {
}

const useStyles = M.makeStyles((t) => ({
lock: {
bottom: t.spacing(6.5),
top: t.spacing(8),
delete: {
background: t.palette.error.light,
color: t.palette.error.contrastText,
marginRight: 'auto',
'&:hover': {
background: t.palette.error.main,
},
},
error: {
marginTop: t.spacing(2),
},
lock: {
bottom: t.spacing(6.5),
top: t.spacing(8),
},
}))

type FormValues = Record<'config', string>

interface FormProps {
formApi: RF.FormRenderProps<FormValues>
close: Dialogs.Close<string | void>
formApi: RF.FormRenderProps<FormValues>
onDelete: () => Promise<void>
ssoConfig: Pick<Model.GQLTypes.SsoConfig, 'text'> | null
error: null | Error
}

function Form({
close,
error,
ssoConfig,
formApi: {
dirtySinceLastSubmit,
error,
handleSubmit,
hasValidationErrors,
pristine,
submitError,
submitFailed,
submitting,
},
onDelete,
ssoConfig,
}: FormProps) {
const classes = useStyles()
return (
Expand All @@ -79,19 +97,34 @@ function Form({
<M.DialogContent>
<RF.Field
component={TextField}
errors={ERRORS}
errors={TEXT_FIELD_ERRORS}
initialValue={ssoConfig?.text}
label="SSO config"
name="config"
validate={validators.required as FF.FieldValidator<any>}
/>
{!!error && !dirtySinceLastSubmit && (
<Lab.Alert className={classes.error} severity="error">
{error.message}
</Lab.Alert>
{submitFailed && (
<>
<FormError error={error || submitError} errors={FORM_ERRORS} />
<M.Typography variant="body2">
Learn more about{' '}
<StyledLink href={`${docs}/advanced/sso-permissions`} target="_blank">
SSO permissions mapping
</StyledLink>
.
</M.Typography>
</>
)}
</M.DialogContent>
<M.DialogActions>
<M.Button
onClick={onDelete}
color="inherit"
disabled={submitting}
className={classes.delete}
>
Delete
</M.Button>
<M.Button onClick={() => close('cancel')} color="primary" disabled={submitting}>
Cancel
</M.Button>
Expand Down Expand Up @@ -121,38 +154,70 @@ function Data({ children, close }: DataProps) {
const data = GQL.useQueryS(SSO_CONFIG_QUERY)
loadMode('yaml')
const setSsoConfig = GQL.useMutation(SET_SSO_CONFIG_MUTATION)
const [error, setError] = React.useState<null | Error>(null)

const onSubmit = React.useCallback(
async ({ config }: FormValues) => {
const submitConfig = React.useCallback(
async (config: string | null) => {
try {
if (!config) {
throw new Error('Enter an SSO config')
}
const {
admin: { setSsoConfig: r },
} = await setSsoConfig({ config })
if (!r && !config) {
close('submit')
return undefined
}
if (!r) return assertNever(r as never)
switch (r.__typename) {
case 'SsoConfig':
return close('submit')
close('submit')
return undefined
case 'InvalidInput':
return setError(new Error('Unable to update SSO config'))
return mapInputErrors(r.errors)
case 'OperationError':
return setError(new Error(`Unable to update SSO config: ${r.message}`))
return mkFormError(r.message)
default:
assertNever(r)
return assertNever(r)
}
} catch (e) {
return setError(e instanceof Error ? e : new Error('Error updating SSO config'))
// eslint-disable-next-line no-console
console.error('Error updating SSO config')
// eslint-disable-next-line no-console
console.error(e)
return mkFormError('unexpected')
}
},
[close, setSsoConfig],
)
const onSubmit = React.useCallback(
({ config }: FormValues) => (config ? submitConfig(config) : { config: 'required' }),
[submitConfig],
)
const [deleting, setDeleting] = React.useState<
FF.SubmissionErrors | boolean | undefined
>()
const onDelete = React.useCallback(async (): Promise<void> => {
setDeleting(true)
const errors = await submitConfig(null)
setDeleting(errors)
}, [submitConfig])

return (
<RF.Form onSubmit={onSubmit}>
{(formApi) =>
children({ formApi, close, error: error, ssoConfig: data.admin?.ssoConfig })
children({
onDelete,
// eslint-disable-next-line no-nested-ternary
formApi: !deleting
? formApi
: deleting === true
? { ...formApi, submitting: true }
: {
...formApi,
submitError: deleting[FF.FORM_ERROR] || formApi.submitError,
submitFailed: true,
},
close,
ssoConfig: data.admin?.ssoConfig,
})
}
</RF.Form>
)
Expand All @@ -164,7 +229,13 @@ interface SuspendedProps {

export default function Suspended({ close }: SuspendedProps) {
return (
<React.Suspense fallback={<M.CircularProgress size={80} />}>
<React.Suspense
fallback={
<M.Box m="32px auto">
<M.CircularProgress size={80} />
</M.Box>
}
>
<Data close={close}>{(props) => <Form {...props} />}</Data>
</React.Suspense>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import * as Types from '../../../../model/graphql/types.generated'

export type containers_Admin_UsersAndRoles_gql_SetSsoConfigMutationVariables =
Types.Exact<{
config: Types.Scalars['String']
config: Types.Maybe<Types.Scalars['String']>
}>

export type containers_Admin_UsersAndRoles_gql_SetSsoConfigMutation = {
readonly __typename: 'Mutation'
} & {
readonly admin: { readonly __typename: 'AdminMutations' } & {
readonly setSsoConfig:
readonly setSsoConfig: Types.Maybe<
| ({ readonly __typename: 'SsoConfig' } & Pick<
Types.SsoConfig,
'timestamp' | 'text'
Expand All @@ -28,6 +28,7 @@ export type containers_Admin_UsersAndRoles_gql_SetSsoConfigMutation = {
Types.OperationError,
'message'
>)
>
}
}

Expand All @@ -42,10 +43,7 @@ export const containers_Admin_UsersAndRoles_gql_SetSsoConfigDocument = {
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'config' } },
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
},
],
selectionSet: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
mutation ($config: String!) {
mutation ($config: String) {
admin {
setSsoConfig(config: $config) {
__typename
Expand Down
9 changes: 3 additions & 6 deletions catalog/app/model/graphql/schema.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,9 @@ export default {
{
name: 'setSsoConfig',
type: {
kind: 'NON_NULL',
ofType: {
kind: 'UNION',
name: 'SetSsoConfigResult',
ofType: null,
},
kind: 'UNION',
name: 'SetSsoConfigResult',
ofType: null,
},
args: [
{
Expand Down
2 changes: 1 addition & 1 deletion catalog/app/model/graphql/types.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export interface AccessCounts {
export interface AdminMutations {
readonly __typename: 'AdminMutations'
readonly user: UserAdminMutations
readonly setSsoConfig: SetSsoConfigResult
readonly setSsoConfig: Maybe<SetSsoConfigResult>
}

export interface AdminMutationssetSsoConfigArgs {
Expand Down
5 changes: 4 additions & 1 deletion catalog/app/utils/GraphQL/Provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,10 @@ export default function GraphQLProvider({ children }: React.PropsWithChildren<{}
R.evolve({ admin: { user: { list: rmUser } } }),
)
}
if (result.admin?.setSsoConfig?.__typename === 'SsoConfig') {
if (
result.admin?.setSsoConfig?.__typename === 'SsoConfig' ||
result.admin?.setSsoConfig === null
) {
cache.invalidate({ __typename: 'Query' }, 'admin')
cache.invalidate({ __typename: 'Query' }, 'roles')
}
Expand Down
6 changes: 4 additions & 2 deletions catalog/app/utils/formTools.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { FORM_ERROR } from 'final-form'
import { SubmissionErrors, FORM_ERROR } from 'final-form'
import * as React from 'react'

export const mkFormError = (err: React.ReactNode) => ({ [FORM_ERROR]: err })
export const mkFormError = (err: React.ReactNode): SubmissionErrors => ({
[FORM_ERROR]: err,
})

export interface InputError {
path: string | null
Expand Down
2 changes: 1 addition & 1 deletion docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Entries inside each section should be ordered by type:
## Catalog, Lambdas
* [Fixed] **SECURITY**: Remove `polyfill.io` references ([#4038](https://github.com/quiltdata/quilt/pull/4038))
* [Changed] Renamed "Admin settings" to "Admin" ([#4045](https://github.com/quiltdata/quilt/pull/4045))
* [Added] Admin: Support SSO permissions mapping (SSO config editor, disable role assignment for SSO-mapped users) ([#4070](https://github.com/quiltdata/quilt/pull/4070))
* [Added] Admin: Support SSO permissions mapping (SSO config editor, disable role assignment for SSO-mapped users) ([#4070](https://github.com/quiltdata/quilt/pull/4070), [#4097](https://github.com/quiltdata/quilt/pull/4097))

# 6.0.0a5 - 2024-06-25
## Python API
Expand Down

0 comments on commit 095dbc9

Please sign in to comment.