Chore/rewrite roles form with shadcn (#19923)

* Fix new role form

* Rewrite form

* Commit a sin against ts

* Make the afterLabel a ReactNode.

* Fix minor type issues.

* Fix type

* Types

* Use lodash sortBy instead of the mutating array.sort.

* Fix the order of the roles with active connections.

* Sort the roles by lowercased names.

---------

Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
This commit is contained in:
Terry SuttonandIvan Vasilov authored and GitHub committed 2023-12-21 23:45:47 +01:00
1 parent b6fa0f528c
commit c37fa950ea
8 files changed
+220 -177

No files matched your search

@@ -5,11 +5,13 @@ import AlertError from 'components/ui/AlertError'
import MultiSelect from 'components/ui/MultiSelect'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query'
import { sortBy } from 'lodash'
interface PolicyRolesProps {
selectedRoles: string[]
onUpdateSelectedRoles: (roles: string[]) => void
}
type SystemRole = (typeof SYSTEM_ROLES)[number]
const PolicyRoles = ({ selectedRoles, onUpdateSelectedRoles }: PolicyRolesProps) => {
const { project } = useProjectContext()
@@ -17,9 +19,10 @@ const PolicyRoles = ({ selectedRoles, onUpdateSelectedRoles }: PolicyRolesProps)
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const roles = (data ?? [])
.filter((role) => !SYSTEM_ROLES.includes(role.name))
.sort((a, b) => a.name.localeCompare(b.name))
const roles = sortBy(
(data ?? []).filter((role) => !SYSTEM_ROLES.includes(role.name as SystemRole)),
(r) => r.name.toLocaleLowerCase()
)
const formattedRoles = roles.map((role) => {
return {
@@ -1,10 +1,21 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import { useRef } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { SubmitHandler, useForm } from 'react-hook-form'
import toast from 'react-hot-toast'
import { Button, Form, IconHelpCircle, Input, SidePanel, Toggle } from 'ui'
import {
FormControl_Shadcn_,
FormField_Shadcn_,
FormItem_Shadcn_,
FormLabel_Shadcn_,
FormMessage_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
SidePanel,
Switch,
} from 'ui'
import z from 'zod'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms'
import { FormActions } from 'components/ui/Forms'
import { useDatabaseRoleCreateMutation } from 'data/database-roles/database-role-create-mutation'
import { ROLE_PERMISSIONS } from './Roles.constants'
@@ -13,26 +24,34 @@ interface CreateRolePanelProps {
onClose: () => void
}
const FormSchema = z.object({
name: z.string().trim().min(1, 'You must provide a name').default(''),
is_superuser: z.boolean().default(false),
can_login: z.boolean().default(false),
can_create_role: z.boolean().default(false),
can_create_db: z.boolean().default(false),
is_replication_role: z.boolean().default(false),
can_bypass_rls: z.boolean().default(false),
})
const initialValues = {
name: '',
is_superuser: false,
can_login: false,
can_create_role: false,
can_create_db: false,
is_replication_role: false,
can_bypass_rls: false,
}
const CreateRolePanel = ({ visible, onClose }: CreateRolePanelProps) => {
const formId = 'create-new-role'
const submitRef: any = useRef()
const { project } = useProjectContext()
const initialValues = {
name: '',
is_superuser: false,
can_login: false,
can_create_role: false,
can_create_db: false,
is_replication_role: false,
can_bypass_rls: false,
}
const validate = (values: any) => {
const errors: any = {}
if (values.name.length === 0) errors.name = 'Please provide a name for your role'
return errors
}
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
})
const { mutate: createDatabaseRole, isLoading: isCreating } = useDatabaseRoleCreateMutation({
onSuccess: (res) => {
@@ -41,7 +60,7 @@ const CreateRolePanel = ({ visible, onClose }: CreateRolePanelProps) => {
},
})
const onSubmit = async (values: any) => {
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
if (!project) return console.error('Project is required')
createDatabaseRole({
projectRef: project.ref,
@@ -50,6 +69,11 @@ const CreateRolePanel = ({ visible, onClose }: CreateRolePanelProps) => {
})
}
const handleClose = () => {
onClose()
form.reset()
}
return (
<SidePanel
size="large"
@@ -57,106 +81,109 @@ const CreateRolePanel = ({ visible, onClose }: CreateRolePanelProps) => {
header="Create a new role"
className="mr-0 transform transition-all duration-300 ease-in-out"
loading={false}
onCancel={onClose}
onCancel={handleClose}
customFooter={
<div className="flex w-full justify-end space-x-3 border-t border-default px-3 py-4">
<Button
size="tiny"
type="default"
htmlType="button"
onClick={onClose}
disabled={isCreating}
>
Cancel
</Button>
<Button
size="tiny"
type="primary"
htmlType="button"
disabled={isCreating}
loading={isCreating}
onClick={() => submitRef?.current?.click()}
>
Create role
</Button>
<FormActions
form={formId}
isSubmitting={isCreating}
hasChanges={form.formState.isDirty}
handleReset={() => form.reset()}
/>
</div>
}
>
<Form
validateOnBlur
id={formId}
initialValues={initialValues}
validate={validate}
onSubmit={onSubmit}
>
{({ isSubmitting, handleReset, values, initialValues }: any) => {
return (
<div>
<FormSection
header={
<FormSectionLabel className="lg:!col-span-4">Role Configuration</FormSectionLabel>
}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Input id="name" label="Name" />
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<FormSection
header={
<FormSectionLabel className="lg:!col-span-4">Role Privileges</FormSectionLabel>
}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<div className="space-y-[9px]">
{Object.keys(ROLE_PERMISSIONS).map((permission) => (
<Toggle
size="small"
disabled={ROLE_PERMISSIONS[permission].disabled}
className={[
'roles-toggle',
ROLE_PERMISSIONS[permission].disabled ? 'opacity-50' : '',
].join(' ')}
key={permission}
id={permission}
name={permission}
label={ROLE_PERMISSIONS[permission].description}
afterLabel={
ROLE_PERMISSIONS[permission].disabled && (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger type="button">
<IconHelpCircle
size="tiny"
strokeWidth={2}
className="ml-2 relative top-[3px]"
/>
</Tooltip.Trigger>
<Tooltip.Content align="center" side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background space-y-1',
].join(' ')}
>
<span className="text-xs">
This privilege cannot be granted via the dashboard
</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
)
}
/>
))}
</div>
</FormSectionContent>
</FormSection>
<button ref={submitRef} type="submit" className="hidden" />
<Form_Shadcn_ {...form}>
<form
id={formId}
className="grid gap-6 w-full px-8 py-8"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItem_Shadcn_ className="grid gap-2 md:grid md:grid-cols-12 space-y-0">
<FormLabel_Shadcn_ className="flex flex-col space-y-2 col-span-4 text-sm justify-center text-foreground-light">
Name
</FormLabel_Shadcn_>
<FormControl_Shadcn_ className="col-span-8">
<Input_Shadcn_ {...field} className="w-full" />
</FormControl_Shadcn_>
<FormMessage_Shadcn_ className="col-start-5 col-span-8" />
</FormItem_Shadcn_>
)}
/>
<div className="grid gap-2 mt-4 md:grid md:grid-cols-12">
<div className="col-span-4">
<FormLabel_Shadcn_ className="flex flex-col space-y-2 col-span-4 text-sm justify-center text-foreground-light">
Role privileges
</FormLabel_Shadcn_>
</div>
)
}}
</Form>
<div className="col-span-8 grid gap-4">
{(Object.keys(ROLE_PERMISSIONS) as (keyof typeof ROLE_PERMISSIONS)[])
.filter((permissionKey) => ROLE_PERMISSIONS[permissionKey].grant_by_dashboard)
.map((permissionKey) => {
const permission = ROLE_PERMISSIONS[permissionKey]
return (
<FormField_Shadcn_
key={permissionKey}
control={form.control}
name={permissionKey}
render={({ field }) => (
<FormItem_Shadcn_ className="grid gap-2 md:grid md:grid-cols-12 space-y-0">
<FormControl_Shadcn_ className="col-span-8 flex items-center gap-4">
<div className="w-full text-sm">
<Switch checked={field.value} onCheckedChange={field.onChange} />
<FormLabel_Shadcn_>{permission.description}</FormLabel_Shadcn_>
</div>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ className="col-start-5 col-span-8" />
</FormItem_Shadcn_>
)}
/>
)
})}
<SidePanel.Separator />
<div className="grid gap-4">
<p className="text-sm">These privileges cannot be granted via the Dashboard:</p>
{(Object.keys(ROLE_PERMISSIONS) as (keyof typeof ROLE_PERMISSIONS)[])
.filter((permissionKey) => !ROLE_PERMISSIONS[permissionKey].grant_by_dashboard)
.map((permissionKey) => {
const permission = ROLE_PERMISSIONS[permissionKey]
return (
<FormField_Shadcn_
key={permissionKey}
control={form.control}
name={permissionKey}
render={({ field }) => (
<FormItem_Shadcn_ className="space-y-0 opacity-70">
<FormControl_Shadcn_ className="flex items-center gap-4">
<div className="w-full text-sm">
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled
aria-readonly
/>
<FormLabel_Shadcn_>{permission.description}</FormLabel_Shadcn_>
</div>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ className="col-start-5 col-span-8" />
</FormItem_Shadcn_>
)}
/>
)
})}
</div>
</div>
</div>
</form>
</Form_Shadcn_>
</SidePanel>
)
}
@@ -161,49 +161,52 @@ const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps) => {
<Collapsible.Content>
<div className="group border-t border-default bg-surface-100 py-6 px-20 text-foreground">
<div className="py-4 space-y-[9px]">
{Object.keys(ROLE_PERMISSIONS).map((permission) => (
<Toggle
size="small"
key={permission}
id={permission}
name={permission}
label={ROLE_PERMISSIONS[permission].description}
disabled={disabled || ROLE_PERMISSIONS[permission].disabled}
className={[
'roles-toggle',
disabled || ROLE_PERMISSIONS[permission].disabled ? 'opacity-50' : '',
].join(' ')}
afterLabel={
!disabled &&
ROLE_PERMISSIONS[permission].disabled && (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger type="button">
<IconHelpCircle
size="tiny"
strokeWidth={2}
className="ml-2 relative top-[3px]"
/>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content align="center" side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background space-y-1',
].join(' ')}
>
<span className="text-xs">
This privilege cannot be updated via the dashboard
</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
)
}
/>
))}
{(Object.keys(ROLE_PERMISSIONS) as (keyof typeof ROLE_PERMISSIONS)[]).map(
(permission) => (
<Toggle
size="small"
key={permission}
id={permission}
name={permission}
label={ROLE_PERMISSIONS[permission].description}
disabled={disabled || ROLE_PERMISSIONS[permission].disabled}
className={[
'roles-toggle',
disabled || ROLE_PERMISSIONS[permission].disabled ? 'opacity-50' : '',
].join(' ')}
afterLabel={
!disabled && ROLE_PERMISSIONS[permission].disabled ? (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger type="button">
<IconHelpCircle
size="tiny"
strokeWidth={2}
className="ml-2 relative top-[3px]"
/>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content align="center" side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background space-y-1',
].join(' ')}
>
<span className="text-xs">
This privilege cannot be updated via the dashboard
</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
) : (
<></>
)
}
/>
)
)}
</div>
{!disabled && (
<div className="py-4 flex items-center space-x-2 justify-end">
@@ -15,7 +15,7 @@ export const SUPABASE_ROLES = [
'pgsodium_keyiduser',
'pgsodium_keymaker',
'pgtle_admin',
]
] as const
// [Joshen] This was originally in the Roles mobx store
// Just keeping it for now in case we need to differ it from ^ SUPABASE_ROLES
@@ -30,32 +30,38 @@ export const SYSTEM_ROLES = [
'pg_database_owner',
'pg_read_all_data',
'pg_write_all_data',
]
] as const
export const ROLE_PERMISSIONS: any = {
export const ROLE_PERMISSIONS = {
can_login: {
disabled: false,
description: 'User can login',
grant_by_dashboard: true,
},
can_create_role: {
disabled: false,
description: 'User can create roles',
grant_by_dashboard: true,
},
can_create_db: {
disabled: false,
description: 'User can create databases',
grant_by_dashboard: true,
},
can_bypass_rls: {
disabled: true,
description: 'User bypasses every row level security policy',
grant_by_dashboard: false,
},
is_superuser: {
disabled: true,
description: 'User is a Superuser',
grant_by_dashboard: false,
},
is_replication_role: {
disabled: true,
description:
'User can initiate streaming replication and put the system in and out of backup mode',
grant_by_dashboard: false,
},
}
} as const
@@ -1,6 +1,6 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { partition } from 'lodash'
import { partition, sortBy } from 'lodash'
import { useState } from 'react'
import { Badge, Button, IconPlus, IconSearch, IconX, Input } from 'ui'
@@ -17,6 +17,8 @@ import RoleRow from './RoleRow'
import RoleRowSkeleton from './RoleRowSkeleton'
import { SUPABASE_ROLES } from './Roles.constants'
type SUPABASE_ROLE = (typeof SUPABASE_ROLES)[number]
const RolesList = () => {
const { project } = useProjectContext()
@@ -37,21 +39,23 @@ const RolesList = () => {
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const roles = (data ?? []).sort((a, b) => a.name.localeCompare(b.name))
const roles = sortBy(data ?? [], (r) => r.name.toLocaleLowerCase())
const filteredRoles = (
filterType === 'active' ? roles.filter((role) => role.active_connections > 0) : roles
).filter((role) => role.name.includes(filterString))
const [supabaseRoles, otherRoles] = partition(filteredRoles, (role) =>
SUPABASE_ROLES.includes(role.name)
SUPABASE_ROLES.includes(role.name as SUPABASE_ROLE)
)
const totalActiveConnections = roles
.map((role) => role.active_connections)
.reduce((a, b) => a + b, 0)
const rolesWithActiveConnections = roles
.filter((role) => role.active_connections > 0)
.sort((a, b) => b.active_connections - a.active_connections)
// order the roles with active connections by number of connections, most connections first
const rolesWithActiveConnections = sortBy(
roles.filter((role) => role.active_connections > 0),
(r) => -r.active_connections
)
return (
<>
+3 -3
View File
@@ -1,8 +1,8 @@
import clsx from 'clsx'
import { useEffect, useState } from 'react'
import React, { useEffect, useState } from 'react'
import { FormLayout } from '../../lib/Layout/FormLayout'
import { useFormContext } from '../Form/FormContext'
import styleHandler from '../../lib/theme/styleHandler'
import { useFormContext } from '../Form/FormContext'
interface Props extends Omit<React.HTMLAttributes<HTMLButtonElement>, 'size'> {
name?: string
@@ -11,7 +11,7 @@ interface Props extends Omit<React.HTMLAttributes<HTMLButtonElement>, 'size'> {
error?: string
descriptionText?: string | React.ReactNode
label?: string | React.ReactNode
afterLabel?: string
afterLabel?: string | React.ReactNode
beforeLabel?: string
labelOptional?: string
className?: any
@@ -88,7 +88,7 @@ const FormLabel = React.forwardRef<
return (
<Label
ref={ref}
className={cn(error && 'text-destructive', className)}
className={cn(error && 'text-destructive', className, 'leading-normal')}
htmlFor={formItemId}
{...props}
/>
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'
import React from 'react'
// @ts-ignore
import defaultTheme from '../../theme/defaultTheme'
// @ts-ignore
@@ -19,7 +19,7 @@ type Props = {
responsive?: boolean
size?: 'tiny' | 'small' | 'medium' | 'large' | 'xlarge'
beforeLabel?: string
afterLabel?: string
afterLabel?: string | React.ReactNode
nonBoxInput?: boolean
labelLayout?: 'horizontal' | 'vertical'
}