Implement create and delete roles

This commit is contained in:
Joshen Lim committed 2023-02-16 15:21:20 +08:00
1 parent 1291d6d18a
commit c5be23964c
12 files changed
+473 -198

No files matched your search

+6 -1
View File
@@ -125,7 +125,12 @@ const Modal = ({
<Button type="default" onClick={onCancel} disabled={loading}>
{cancelText}
</Button>
<Button onClick={onConfirm} loading={loading} danger={variant === 'danger'}>
<Button
onClick={onConfirm}
disabled={loading}
loading={loading}
danger={variant === 'danger'}
>
{confirmText}
</Button>
</Space>
+1 -1
View File
@@ -11,7 +11,7 @@ interface Props extends Omit<React.HTMLAttributes<HTMLButtonElement>, 'size'> {
layout?: 'horizontal' | 'vertical' | 'flex'
error?: string
descriptionText?: string
label?: string
label?: string | React.ReactNode
afterLabel?: string
beforeLabel?: string
labelOptional?: string
@@ -86,9 +86,7 @@ export function FormLayout({
} else {
dataInputContainerClasses.push(__styles.data_input_vertical_layout)
if (align === 'right') {
dataInputContainerClasses.push(
__styles.data_input_vertical_layout__align_right
)
dataInputContainerClasses.push(__styles.data_input_vertical_layout__align_right)
}
}
@@ -128,10 +126,7 @@ export function FormLayout({
const renderDescription = descriptionText && (
<p
className={[
__styles.description.base,
__styles.description.size[size],
].join(' ')}
className={[__styles.description.base, __styles.description.size[size]].join(' ')}
id={id + '-description'}
>
{descriptionText}
@@ -153,19 +148,14 @@ export function FormLayout({
>
{labelled && (
<label
className={[
__styles.label.base,
__styles.label.size[size],
'break-all',
].join(' ')}
className={[__styles.label.base, __styles.label.size[size], 'break-all'].join(' ')}
htmlFor={id}
>
{beforeLabel && (
<span
className={[
__styles.label_before.base,
__styles.label_before.size[size],
].join(' ')}
className={[__styles.label_before.base, __styles.label_before.size[size]].join(
' '
)}
id={id + '-before'}
>
{beforeLabel}
@@ -174,10 +164,7 @@ export function FormLayout({
{label}
{afterLabel && (
<span
className={[
__styles.label_after.base,
__styles.label_after.size[size],
].join(' ')}
className={[__styles.label_after.base, __styles.label_after.size[size]].join(' ')}
id={id + '-after'}
>
{afterLabel}
@@ -187,10 +174,9 @@ export function FormLayout({
)}
{labelOptional && (
<span
className={[
__styles.label_optional.base,
__styles.label_optional.size[size],
].join(' ')}
className={[__styles.label_optional.base, __styles.label_optional.size[size]].join(
' '
)}
id={id + '-optional'}
>
{labelOptional}
+2 -2
View File
@@ -1146,13 +1146,13 @@ export default {
},
},
label_before: {
base: 'text-scale-500 ',
base: 'text-scale-1000 ',
size: {
...defaults.size.text,
},
},
label_after: {
base: 'text-scale-500',
base: 'text-scale-1000',
size: {
...defaults.size.text,
},
@@ -0,0 +1,171 @@
import Link from 'next/link'
import { FC } from 'react'
import { useRouter } from 'next/router'
import * as Tooltip from '@radix-ui/react-tooltip'
import { Button, Form, Input, IconArrowLeft, IconExternalLink, Toggle, IconHelpCircle } from 'ui'
import { useParams, useStore } from 'hooks'
import {
FormActions,
FormPanel,
FormsContainer,
FormSection,
FormSectionLabel,
FormSectionContent,
} from 'components/ui/Forms'
import { ROLE_PERMISSIONS } from './Roles.constants'
interface Props {}
// [Joshen] There are a lot of other parameters that pg/pg-meta supports for creating a role
// but these are just the minimal parameters for now
// https://github.com/supabase/postgres-meta/blob/3b4c3a0ea052389599cf5e2900f7185870d6f60c/src/lib/types.ts#L224
const CreateRole: FC<Props> = ({}) => {
const router = useRouter()
const { ref } = useParams()
const { ui, meta } = useStore()
const formId = 'create-new-role'
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 onSubmit = async (values: any, { setSubmitting }: any) => {
setSubmitting(true)
const res = await meta.roles.create(values)
if (res.error) {
setSubmitting(false)
return ui.setNotification({
category: 'error',
message: `Failed to create role: ${res.error.message}`,
})
} else {
ui.setNotification({
category: 'success',
message: `Successfully created new role: ${res.name}`,
})
router.push(`/project/${ref}/database/roles`)
}
}
return (
<FormsContainer>
<div>
<div className="relative flex items-center justify-between mb-6">
<div
className={[
'transition cursor-pointer',
'absolute -left-20 top-1 opacity-75 hover:opacity-100',
].join(' ')}
>
<Link href={`/project/${ref}/database/roles`}>
<a>
<div className="flex items-center space-x-2">
<IconArrowLeft strokeWidth={1.5} size={14} />
<p className="text-sm">Back</p>
</div>
</a>
</Link>
</div>
<h3 className="mb-2 text-xl text-scale-1200">Create a new role</h3>
{/* <div className="flex items-center space-x-2">
<Link href="https://supabase.github.io/wrappers/stripe/">
<a target="_blank">
<Button type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
Documentation
</Button>
</a>
</Link>
</div> */}
</div>
<Form id={formId} initialValues={initialValues} onSubmit={onSubmit}>
{({ isSubmitting, handleReset, values, initialValues }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
return (
<FormPanel
disabled={false}
footer={
<div className="flex px-8 py-4">
<FormActions
form={formId}
isSubmitting={isSubmitting}
hasChanges={hasChanges}
handleReset={handleReset}
helper={
!true
? 'You need additional permissions to create a database role'
: undefined
}
/>
</div>
}
>
<FormSection header={<FormSectionLabel>Role Configuration</FormSectionLabel>}>
<FormSectionContent loading={false}>
<Input id="name" label="Name" />
</FormSectionContent>
</FormSection>
<FormSection header={<FormSectionLabel>Role Privileges</FormSectionLabel>}>
<FormSectionContent loading={false}>
<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-scale-100 py-1 px-2 leading-none shadow',
'border border-scale-200 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>
</FormPanel>
)
}}
</Form>
</div>
</FormsContainer>
)
}
export default CreateRole
@@ -0,0 +1,58 @@
import { FC, useState } from 'react'
import { Modal } from 'ui'
import { PostgresRole } from '@supabase/postgres-meta'
import { useStore } from 'hooks'
interface Props {
role: PostgresRole
visible: boolean
onClose: () => void
}
const DeleteRoleModal: FC<Props> = ({ role, visible, onClose }) => {
const { ui, meta } = useStore()
const [isDeleting, setIsDeleting] = useState(false)
const deleteRole = async () => {
if (!role) return console.error('Failed to delete role: role is missing')
setIsDeleting(true)
const res: any = await meta.roles.del(role.id)
setIsDeleting(false)
if (res.error) {
return ui.setNotification({
category: 'error',
message: `Failed to delete role: ${res.error.message}`,
})
} else {
ui.setNotification({
category: 'success',
message: `Successfully deleted role: "${role.name}"`,
})
onClose()
}
}
return (
<Modal
size="small"
alignFooter="right"
visible={visible}
onCancel={onClose}
onConfirm={deleteRole}
header={<h3>Confirm to delete role "{role?.name}"</h3>}
loading={isDeleting}
>
<div className="py-4">
<Modal.Content>
<p className="text-sm">
This will automatically revoke any membership of this role in other roles, and this
action cannot be undone.
</p>
</Modal.Content>
</div>
</Modal>
)
}
export default DeleteRoleModal
@@ -1,40 +1,27 @@
import { FC, useState, useRef } from 'react'
import { FC, useState } from 'react'
import * as Tooltip from '@radix-ui/react-tooltip'
import { PostgresRole } from '@supabase/postgres-meta'
import {
Button,
Form,
Dropdown,
IconChevronUp,
IconCheck,
IconX,
Toggle,
Collapsible,
IconEdit3,
IconTrash,
IconHelpCircle,
IconMoreVertical,
Checkbox,
} from 'ui'
import { ROLE_PERMISSIONS } from './Roles.constants'
interface Props {
role: PostgresRole
onSelectDelete: (role: PostgresRole) => void
}
const ROLE_PERMISSIONS: any = {
is_superuser: 'User is a Superuser',
can_login: 'User can login',
can_create_role: 'User can create roles',
can_create_db: 'User can create databases',
is_replication_role:
'User can initiate streaming replication and put the system in and out of backup mode',
can_bypass_rls: 'User bypasses every row level security policy',
}
const RoleRow: FC<Props> = ({ role }) => {
const RoleRow: FC<Props> = ({ role, onSelectDelete }) => {
const [isExpanded, setIsExpanded] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const isEditingRef = useRef<boolean>()
const {
is_superuser,
@@ -45,19 +32,6 @@ const RoleRow: FC<Props> = ({ role }) => {
can_bypass_rls,
} = role
const onSelectEdit = (event: any) => {
event.stopPropagation()
setIsExpanded(true)
setIsEditing(true)
isEditingRef.current = true
}
const onCancelEdit = (event: any) => {
event.stopPropagation()
setIsEditing(false)
isEditingRef.current = false
}
const onSaveChanges = async (values: any) => {
setIsSaving(true)
console.log(values)
@@ -89,69 +63,50 @@ const RoleRow: FC<Props> = ({ role }) => {
'last:rounded-bl last:rounded-br',
].join(' ')}
>
{({ handleReset }: any) => (
<Collapsible
open={isExpanded}
onOpenChange={() => {
if (!isEditingRef.current) setIsExpanded(!isExpanded)
}}
>
<Collapsible.Trigger asChild>
<button
type="button"
className="group flex w-full items-center justify-between rounded py-3 px-6 text-scale-1200"
>
<div className="flex items-start space-x-3">
<IconChevronUp
className="text-scale-800 transition data-open-parent:rotate-0 data-closed-parent:rotate-180"
strokeWidth={2}
width={14}
/>
<div className="space-x-2 flex items-center">
<p className="text-left text-sm">{role.name}</p>
<p className="text-left text-sm text-scale-1000">(ID: {role.id})</p>
</div>
</div>
<div className="flex items-center space-x-4">
<p
className={`text-sm ${
role.active_connections > 0 ? 'text-scale-1100' : 'text-scale-1000'
}`}
>
{role.active_connections} connections
</p>
{isEditing ? (
<div className="flex items-center space-x-2">
<Button
type="default"
disabled={isSaving}
onClick={(event) => {
handleReset()
onCancelEdit(event)
}}
>
Cancel
</Button>
<Button type="primary" disabled={isSaving} loading={isSaving} htmlType="submit">
Save changes
</Button>
{({ values, initialValues, handleReset }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
return (
<Collapsible open={isExpanded}>
<Collapsible.Trigger asChild>
<button
id="collapsible-trigger"
type="button"
className="group flex w-full items-center justify-between rounded py-3 px-6 text-scale-1200"
onClick={(event: any) => {
if (event.target.id === 'collapsible-trigger') setIsExpanded(!isExpanded)
}}
>
<div className="flex items-start space-x-3">
<IconChevronUp
className="text-scale-800 transition data-open-parent:rotate-0 data-closed-parent:rotate-180"
strokeWidth={2}
width={14}
/>
<div className="space-x-2 flex items-center">
<p className="text-left text-sm">{role.name}</p>
<p className="text-left text-sm text-scale-1000">(ID: {role.id})</p>
</div>
) : (
</div>
<div className="flex items-center space-x-4">
<p
className={`text-sm ${
role.active_connections > 0 ? 'text-scale-1100' : 'text-scale-1000'
}`}
>
{role.active_connections} connections
</p>
<Dropdown
side="bottom"
className="w-[120px]"
overlay={
<>
<Dropdown.Item
icon={<IconEdit3 size="tiny" strokeWidth={2} />}
onClick={onSelectEdit}
>
Edit
</Dropdown.Item>
<Dropdown.Separator />
<Dropdown.Item
icon={<IconTrash className="text-red-800" size="tiny" strokeWidth={2} />}
onClick={() => setIsDeleting(true)}
onClick={(event: any) => {
event.stopPropagation()
onSelectDelete(role)
}}
>
Delete
</Dropdown.Item>
@@ -160,41 +115,70 @@ const RoleRow: FC<Props> = ({ role }) => {
>
<Button as="span" type="default" className="px-1" icon={<IconMoreVertical />} />
</Dropdown>
)}
</div>
</button>
</Collapsible.Trigger>
<Collapsible.Content>
<div className="group border-t border-scale-500 bg-scale-100 py-6 px-6 text-scale-1200 dark:bg-scale-300">
{isEditing ? (
<div className="mx-auto py-4 px-6 space-y-[9px]">
</div>
</button>
</Collapsible.Trigger>
<Collapsible.Content>
<div className="group border-t border-scale-500 bg-scale-100 py-6 px-20 text-scale-1200 dark:bg-scale-300">
<div className="py-4 space-y-[9px]">
{Object.keys(ROLE_PERMISSIONS).map((permission) => (
<Checkbox
<Toggle
size="small"
key={permission}
id={permission}
name={permission}
label={ROLE_PERMISSIONS[permission]}
label={ROLE_PERMISSIONS[permission].description}
disabled={ROLE_PERMISSIONS[permission].disabled}
className={[
'roles-toggle',
ROLE_PERMISSIONS[permission].disabled ? 'opacity-50' : '',
].join(' ')}
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-scale-100 py-1 px-2 leading-none shadow',
'border border-scale-200 space-y-1',
].join(' ')}
>
<span className="text-xs">
This privilege cannot be updated via the dashboard
</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
)
}
/>
))}
</div>
) : (
<div className="mx-auto py-4 px-6 space-y-2">
{Object.keys(ROLE_PERMISSIONS).map((permission) => (
<div key={permission} className="flex items-center space-x-2">
{(role as any)[permission] ? (
<IconCheck className="text-brand-900" strokeWidth={2} />
) : (
<IconX className="text-scale-1000" strokeWidth={2} />
)}
<p className="text-sm">{ROLE_PERMISSIONS[permission]}</p>
</div>
))}
<div className="py-4 flex items-center space-x-2 justify-end">
<Button
type="default"
disabled={!hasChanges || isSaving}
onClick={() => handleReset()}
>
Cancel
</Button>
<Button type="primary" disabled={!hasChanges || isSaving} loading={isSaving}>
Save
</Button>
</div>
)}
</div>
</Collapsible.Content>
</Collapsible>
)}
</div>
</Collapsible.Content>
</Collapsible>
)
}}
</Form>
)
}
@@ -0,0 +1,27 @@
export const ROLE_PERMISSIONS: any = {
can_login: {
disabled: false,
description: 'User can login',
},
can_create_role: {
disabled: false,
description: 'User can create roles',
},
can_create_db: {
disabled: false,
description: 'User can create databases',
},
can_bypass_rls: {
disabled: false,
description: 'User bypasses every row level security policy',
},
is_superuser: {
disabled: true,
description: 'User is a Superuser',
},
is_replication_role: {
disabled: true,
description:
'User can initiate streaming replication and put the system in and out of backup mode',
},
}
@@ -1,22 +1,27 @@
import { FC } from 'react'
import { FC, useState } from 'react'
import { observer } from 'mobx-react-lite'
import * as Tooltip from '@radix-ui/react-tooltip'
import { PostgresRole } from '@supabase/postgres-meta'
import { Button, IconPlus } from 'ui'
import { useStore } from 'hooks'
import { useParams, useStore } from 'hooks'
import SparkBar from 'components/ui/SparkBar'
import { FormHeader } from 'components/ui/Forms'
import RoleRow from './RoleRow'
import Link from 'next/link'
import DeleteRoleModal from './DeleteRoleModal'
interface Props {
onSelectRole: (role: any) => void
}
const RolesList: FC<Props> = ({ onSelectRole = () => {} }) => {
const RolesList: FC<Props> = ({}) => {
const { ref } = useParams()
const { meta } = useStore()
const roles = meta.roles.list()
const [selectedRoleToDelete, setSelectedRoleToDelete] = useState<any>()
const connectionLimit = Math.max(...roles.map((role: PostgresRole) => role.connection_limit))
const totalActiveConnections = roles
.map((role: PostgresRole) => role.active_connections)
@@ -26,61 +31,73 @@ const RolesList: FC<Props> = ({ onSelectRole = () => {} }) => {
.sort((a, b) => b.active_connections - a.active_connections)
return (
<div>
<div className="flex items-center justify-between">
<FormHeader
title="Database Roles"
description="Manage access control to your database through users, groups, and permissions"
/>
<div className="flex items-center mb-6 space-x-6">
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<div className="w-42">
<SparkBar
type="horizontal"
max={connectionLimit}
value={totalActiveConnections}
barClass={
totalActiveConnections > 0.9 * connectionLimit
? 'bg-red-800'
: totalActiveConnections > 0.75 * connectionLimit
? 'bg-amber-900'
: 'bg-green-800'
}
labelTop={`${totalActiveConnections}/${connectionLimit}`}
labelBottom="Active connections"
/>
</div>
</Tooltip.Trigger>
<Tooltip.Content align="start" side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
'border border-scale-200 space-y-1',
].join(' ')}
>
<p className="text-xs text-scale-1100 pr-2">Connections by roles:</p>
{rolesWithActiveConnections.map((role: PostgresRole) => (
<div key={role.id} className="text-xs text-scale-1200">
{role.name}: {role.active_connections}
</div>
))}
</div>
</Tooltip.Content>
</Tooltip.Root>
<Button type="primary" icon={<IconPlus size="tiny" />}>
Add role
</Button>
<>
<div>
<div className="flex items-center justify-between">
<FormHeader
title="Database Roles"
description="Manage access control to your database through users, groups, and permissions"
/>
<div className="flex items-center mb-6 space-x-6">
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<div className="w-42">
<SparkBar
type="horizontal"
max={connectionLimit}
value={totalActiveConnections}
barClass={
totalActiveConnections > 0.9 * connectionLimit
? 'bg-red-800'
: totalActiveConnections > 0.75 * connectionLimit
? 'bg-amber-900'
: 'bg-green-800'
}
labelTop={`${totalActiveConnections}/${connectionLimit}`}
labelBottom="Active connections"
/>
</div>
</Tooltip.Trigger>
<Tooltip.Content align="start" side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
'border border-scale-200 space-y-1',
].join(' ')}
>
<p className="text-xs text-scale-1100 pr-2">Connections by roles:</p>
{rolesWithActiveConnections.map((role: PostgresRole) => (
<div key={role.id} className="text-xs text-scale-1200">
{role.name}: {role.active_connections}
</div>
))}
</div>
</Tooltip.Content>
</Tooltip.Root>
<Link href={`/project/${ref}/database/roles/new`}>
<a>
<Button type="primary" icon={<IconPlus size="tiny" />}>
Add role
</Button>
</a>
</Link>
</div>
</div>
<div>
{roles.map((role: PostgresRole, i: number) => (
<RoleRow key={role.id} role={role} onSelectDelete={setSelectedRoleToDelete} />
))}
</div>
</div>
<div>
{roles.map((role: PostgresRole, i: number) => (
<RoleRow key={role.id} role={role} />
))}
</div>
</div>
<DeleteRoleModal
role={selectedRoleToDelete}
visible={selectedRoleToDelete !== undefined}
onClose={() => setSelectedRoleToDelete(undefined)}
/>
</>
)
}
@@ -0,0 +1,21 @@
import { observer } from 'mobx-react-lite'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { DatabaseLayout } from 'components/layouts'
import { NextPageWithLayout } from 'types'
import { checkPermissions } from 'hooks'
import NoPermission from 'components/ui/NoPermission'
import CreateRole from 'components/interfaces/Database/Roles/CreateRole'
const CreateNewRole: NextPageWithLayout = () => {
const canReadRoles = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'roles')
if (!canReadRoles) {
return <NoPermission isFullPage resourceText="view database roles" />
}
return <CreateRole />
}
CreateNewRole.getLayout = (page) => <DatabaseLayout title="Database">{page}</DatabaseLayout>
export default observer(CreateNewRole)
+6
View File
@@ -333,4 +333,10 @@
.pitr-listbox {
@apply md:gap-x-2;
}
.roles-toggle {
label {
@apply mt-1;
}
}