console: add create "delete" permissions

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/8071
GitOrigin-RevId: efb545e134908b61eef71f79a477061753a4bb1c
This commit is contained in:
Erik Magnusson 2023-02-24 14:19:08 +01:00 committed by hasura-bot
parent 318cf1b692
commit 938423ec54
11 changed files with 294 additions and 108 deletions

View File

@ -1,4 +1,4 @@
import React, { useEffect } from 'react'; import React, { useEffect, useRef } from 'react';
import { useConsoleForm } from '../../../new-components/Form'; import { useConsoleForm } from '../../../new-components/Form';
import { Button } from '../../../new-components/Button'; import { Button } from '../../../new-components/Button';
import { IndicatorCard } from '../../../new-components/IndicatorCard'; import { IndicatorCard } from '../../../new-components/IndicatorCard';
@ -21,11 +21,11 @@ import {
RowPermissionsSectionWrapper, RowPermissionsSectionWrapper,
} from './components'; } from './components';
import { ReturnValue, useFormData, useUpdatePermissions } from './hooks'; import { useFormData, useUpdatePermissions } from './hooks';
import ColumnRootFieldPermissions from './components/RootFieldPermissions/RootFieldPermissions'; import ColumnRootFieldPermissions from './components/RootFieldPermissions/RootFieldPermissions';
import { useListAllTableColumns } from '../../Data'; import { useListAllTableColumns } from '../../Data';
import { useMetadataSource } from '../../MetadataAPI'; import { useMetadataSource } from '../../MetadataAPI';
import { omit } from 'lodash'; import useScrollIntoView from './hooks/useScrollIntoView';
export interface ComponentProps { export interface ComponentProps {
dataSourceName: string; dataSourceName: string;
@ -37,17 +37,6 @@ export interface ComponentProps {
data: ReturnType<typeof useFormData>['data']; data: ReturnType<typeof useFormData>['data'];
} }
const getCanSave = (
defaultValues: ReturnValue['defaultValues'],
newValues: Record<string, any>
) => {
const cloneWithoutClonePermissions = omit(newValues, 'clonePermissions');
return (
JSON.stringify(cloneWithoutClonePermissions) ===
JSON.stringify(defaultValues)
);
};
const Component = (props: ComponentProps) => { const Component = (props: ComponentProps) => {
const { const {
dataSourceName, dataSourceName,
@ -58,6 +47,8 @@ const Component = (props: ComponentProps) => {
handleClose, handleClose,
data, data,
} = props; } = props;
const permissionSectionRef = useRef(null);
useScrollIntoView(permissionSectionRef, [roleName], { behavior: 'smooth' });
const { data: metadataTables } = useMetadata( const { data: metadataTables } = useMetadata(
MetadataSelector.getTables(dataSourceName) MetadataSelector.getTables(dataSourceName)
@ -199,7 +190,12 @@ const Component = (props: ComponentProps) => {
tables={tables} tables={tables}
roles={roles} roles={roles}
/> />
<div className="pt-2 flex gap-2">
<div
ref={permissionSectionRef}
className="pt-2 flex gap-2"
id="form-buttons-container"
>
<Button <Button
type="submit" type="submit"
mode="primary" mode="primary"
@ -208,7 +204,6 @@ const Component = (props: ComponentProps) => {
? 'You must select an option for row permissions' ? 'You must select an option for row permissions'
: 'Submit' : 'Submit'
} }
disabled={getCanSave(defaultValues, getValues())}
isLoading={updatePermissions.isLoading} isLoading={updatePermissions.isLoading}
> >
Save Permissions Save Permissions

View File

@ -8,7 +8,7 @@ import { Table } from '../../../hasura-metadata-types';
interface CreateBodyArgs { interface CreateBodyArgs {
dataSourceName: string; dataSourceName: string;
table: Table; table: Table;
roleName: string; role: string;
resourceVersion: number; resourceVersion: number;
} }
@ -21,7 +21,7 @@ const createDeleteBody = ({
driver, driver,
dataSourceName, dataSourceName,
table, table,
roleName, role,
resourceVersion, resourceVersion,
queries, queries,
}: CreateDeleteBodyArgs): { }: CreateDeleteBodyArgs): {
@ -34,7 +34,7 @@ const createDeleteBody = ({
type: `${driver}_drop_${queryType}_permission` as allowedMetadataTypes, type: `${driver}_drop_${queryType}_permission` as allowedMetadataTypes,
args: { args: {
table, table,
role: roleName, role,
source: dataSourceName, source: dataSourceName,
}, },
})); }));
@ -107,6 +107,9 @@ interface CreateInsertBodyArgs extends CreateBodyArgs {
existingPermissions: ExistingPermission[]; existingPermissions: ExistingPermission[];
driver: string; driver: string;
tables: Table[]; tables: Table[];
dataSourceName: string;
table: Table;
role: string;
} }
export interface InsertBodyResult { export interface InsertBodyResult {
@ -119,7 +122,7 @@ const createInsertBody = ({
dataSourceName, dataSourceName,
table, table,
queryType, queryType,
roleName, role,
formData, formData,
accessType, accessType,
resourceVersion, resourceVersion,
@ -132,7 +135,7 @@ const createInsertBody = ({
dataSourceName, dataSourceName,
table, table,
queryType, queryType,
role: roleName, role,
formData, formData,
accessType, accessType,
existingPermissions, existingPermissions,

View File

@ -1,74 +1,78 @@
import { CreateInsertArgs, createInsertArgs } from './utils'; import { createInsertArgs } from './utils';
import {
const selectArgs: CreateInsertArgs = { selectArgs,
driver: 'postgres', deleteArgs,
dataSourceName: 'default', insertArgs,
accessType: 'fullAccess', } from '../mocks/createPermissionsData.mock';
table: 'users',
queryType: 'insert',
role: 'user',
tables: [],
formData: {
queryType: 'select',
filterType: 'none',
query_root_fields: null,
subscription_root_fields: null,
filter: {},
rowCount: '0',
columns: {
id: false,
email: true,
name: false,
type: true,
username: false,
},
aggregationEnabled: false,
clonePermissions: [
{
tableName: '',
queryType: '',
roleName: '',
},
],
},
existingPermissions: [
{
table: 'users',
role: 'user',
queryType: 'insert',
},
{
table: 'users',
role: 'user',
queryType: 'select',
},
],
};
test('create select args object from form data', () => { test('create select args object from form data', () => {
const result = createInsertArgs(selectArgs); const result = createInsertArgs(selectArgs);
expect(result).toEqual([ expect(result).toEqual([
{ {
args: { type: 'sqlagent_drop_select_permission',
role: 'user', args: { table: ['Album'], role: 'user', source: 'Chinook' },
source: 'default',
table: 'users',
},
type: 'postgres_drop_insert_permission',
}, },
{ {
type: 'sqlagent_create_select_permission',
args: { args: {
permission: { table: ['Album'],
allow_aggregations: false,
columns: ['email', 'type'],
filter: {},
set: [],
},
role: 'user', role: 'user',
source: 'default', permission: {
table: 'users', columns: ['AlbumId', 'Title', 'ArtistId'],
filter: { _not: { AlbumId: { _eq: 'X-Hasura-User-Id' } } },
set: [],
allow_aggregations: false,
},
source: 'Chinook',
},
},
]);
});
test('create delete args object from form data', () => {
const result = createInsertArgs(deleteArgs);
expect(result).toEqual([
{
type: 'sqlagent_drop_delete_permission',
args: { table: ['Album'], role: 'user', source: 'Chinook' },
},
{
type: 'sqlagent_create_delete_permission',
args: {
table: ['Album'],
role: 'user',
permission: { backend_only: false, filter: { Title: { _eq: 'Test' } } },
source: 'Chinook',
},
},
]);
});
test('create insert args object from form data', () => {
const result = createInsertArgs(insertArgs);
expect(result).toEqual([
{
type: 'sqlagent_create_insert_permission',
args: {
table: ['Album'],
role: 'user',
permission: {
columns: [],
check: {
_and: [
{},
{ AlbumId: { _eq: '1337' } },
{ _not: { ArtistId: { _eq: '1338' } } },
],
},
allow_upsert: true,
set: {},
backend_only: false,
},
source: 'Chinook',
}, },
type: 'postgres_create_insert_permission',
}, },
]); ]);
}); });

View File

@ -3,11 +3,29 @@ import produce from 'immer';
import { allowedMetadataTypes } from '../../../MetadataAPI'; import { allowedMetadataTypes } from '../../../MetadataAPI';
import { AccessType } from '../../types'; import { AccessType } from '../../types';
import { PermissionsSchema } from '../../schema'; import { PermissionsSchema, Presets } from '../../schema';
import { areTablesEqual } from '../../../hasura-metadata-api'; import { areTablesEqual } from '../../../hasura-metadata-api';
import { Table } from '../../../hasura-metadata-types'; import { Table } from '../../../hasura-metadata-types';
import { getTableDisplayName } from '../../../DatabaseRelationships'; import { getTableDisplayName } from '../../../DatabaseRelationships';
const formatFilterValues = (formFilter: Record<string, any>[]) => {
return Object.entries(formFilter).reduce<Record<string, any>>(
(acc, [operator, value]) => {
if (operator === '_and' || operator === '_or') {
const filteredEmptyObjects = (value as any[]).filter(
p => Object.keys(p).length !== 0
);
acc[operator] = filteredEmptyObjects;
return acc;
}
acc[operator] = value;
return acc;
},
{}
);
};
type SelectPermissionMetadata = { type SelectPermissionMetadata = {
columns: string[]; columns: string[];
set: Record<string, any>; set: Record<string, any>;
@ -24,24 +42,7 @@ const createSelectObject = (input: PermissionsSchema) => {
.filter(({ 1: value }) => value) .filter(({ 1: value }) => value)
.map(([key]) => key); .map(([key]) => key);
// in row permissions builder an extra input is rendered automatically const filter = formatFilterValues(input.filter);
// this will always be empty and needs to be removed
const filter = Object.entries(input.filter).reduce<Record<string, any>>(
(acc, [operator, value]) => {
if (operator === '_and' || operator === '_or') {
const filteredEmptyObjects = (value as any[]).filter(
p => Object.keys(p).length !== 0
);
acc[operator] = filteredEmptyObjects;
return acc;
}
acc[operator] = value;
return acc;
},
{}
);
const permissionObject: SelectPermissionMetadata = { const permissionObject: SelectPermissionMetadata = {
columns, columns,
@ -102,6 +103,28 @@ const createInsertObject = (input: PermissionsSchema) => {
throw new Error('Case not handled'); throw new Error('Case not handled');
}; };
export type DeletePermissionMetadata = {
columns?: string[];
set?: Record<string, any>;
backend_only: boolean;
filter: Record<string, any>;
};
const createDeleteObject = (input: PermissionsSchema) => {
if (input.queryType === 'delete') {
const filter = formatFilterValues(input.filter);
const permissionObject: DeletePermissionMetadata = {
backend_only: input.backendOnly || false,
filter,
};
return permissionObject;
}
throw new Error('Case not handled');
};
/** /**
* creates the permissions object for the server * creates the permissions object for the server
*/ */
@ -114,7 +137,7 @@ const createPermission = (formData: PermissionsSchema) => {
case 'update': case 'update':
throw new Error('Case not handled'); throw new Error('Case not handled');
case 'delete': case 'delete':
throw new Error('Case not handled'); return createDeleteObject(formData);
default: default:
throw new Error('Case not handled'); throw new Error('Case not handled');
} }

View File

@ -237,7 +237,7 @@ export const createPermission = {
}; };
}, },
delete: (permission: DeletePermissionDefinition) => { delete: (permission: DeletePermissionDefinition) => {
const filter = JSON.stringify(permission?.filter) || ''; const filter = permission?.filter || {};
const filterType = getCheckType(permission?.filter); const filterType = getCheckType(permission?.filter);
const presets = getPresets({ const presets = getPresets({
currentQueryPermissions: permission, currentQueryPermissions: permission,

View File

@ -43,7 +43,7 @@ export const useDeletePermission = ({
driver, driver,
dataSourceName, dataSourceName,
table, table,
roleName, role: roleName,
resourceVersion, resourceVersion,
queries, queries,
}); });

View File

@ -96,7 +96,7 @@ export const useSubmitForm = (args: UseSubmitFormArgs) => {
driver: metadataSource.kind, driver: metadataSource.kind,
table, table,
tables, tables,
roleName, role: roleName,
queryType, queryType,
accessType, accessType,
resourceVersion: resource_version, resourceVersion: resource_version,

View File

@ -0,0 +1,40 @@
import { useEffect } from 'react';
export enum ScrollIntoViewBehavior {
auto = 'auto',
smooth = 'smooth',
}
export enum ScrollIntoViewBlock {
start = 'start',
center = 'center',
end = 'end',
nearest = 'nearest',
}
export enum ScrollIntoViewInline {
start = 'start',
center = 'center',
end = 'end',
nearest = 'nearest',
}
export type ScrollIntoViewOptions = {
behavior?: keyof typeof ScrollIntoViewBehavior;
block?: keyof typeof ScrollIntoViewBlock;
inline?: keyof typeof ScrollIntoViewInline;
};
const useScrollIntoView = (
ref: React.RefObject<any>,
deps: string[],
options: ScrollIntoViewOptions | boolean = {}
) => {
useEffect(() => {
if (ref && ref?.current?.scrollIntoView) {
ref.current.scrollIntoView(options);
}
}, [...deps, ref]);
};
export default useScrollIntoView;

View File

@ -0,0 +1,122 @@
import { CreateInsertArgs } from '../api/utils';
export const selectArgs: CreateInsertArgs = {
driver: 'sqlagent',
dataSourceName: 'Chinook',
table: ['Album'],
queryType: 'select',
role: 'user',
formData: {
queryType: 'select',
filterType: 'custom',
filter: { _not: { AlbumId: { _eq: 'X-Hasura-User-Id' } } },
columns: { AlbumId: true, Title: true, ArtistId: true },
presets: [],
rowCount: '0',
aggregationEnabled: false,
clonePermissions: [{ tableName: '', queryType: '', roleName: '' }],
query_root_fields: null,
subscription_root_fields: null,
supportedOperators: [
{ name: 'equals', value: '_eq' },
{ name: 'not equals', value: '_neq' },
{ name: '>', value: '_gt' },
{ name: '<', value: '_lt' },
{ name: '>=', value: '_gte' },
{ name: '<=', value: '_lte' },
],
},
accessType: 'partialAccess',
existingPermissions: [
{ role: 'asdf', queryType: 'select', table: ['Album'] },
{ role: 'new', queryType: 'select', table: ['Album'] },
{ role: 'sdfsf', queryType: 'select', table: ['Album'] },
{ role: 'testrole', queryType: 'select', table: ['Album'] },
{ role: 'user', queryType: 'select', table: ['Album'] },
{ role: 'user', queryType: 'delete', table: ['Album'] },
{ role: 'asdf', queryType: 'select', table: ['Artist'] },
{ role: 'testrole', queryType: 'select', table: ['Artist'] },
{ role: 'user', queryType: 'select', table: ['Artist'] },
],
tables: [['Album'], ['Artist']],
};
export const deleteArgs: CreateInsertArgs = {
driver: 'sqlagent',
dataSourceName: 'Chinook',
table: ['Album'],
queryType: 'delete',
role: 'user',
formData: {
queryType: 'delete',
filterType: 'custom',
filter: { Title: { _eq: 'Test' } },
supportedOperators: [
{ name: 'equals', value: '_eq' },
{ name: 'not equals', value: '_neq' },
{ name: '>', value: '_gt' },
{ name: '<', value: '_lt' },
{ name: '>=', value: '_gte' },
{ name: '<=', value: '_lte' },
],
clonePermissions: [{ tableName: '', queryType: '', roleName: '' }],
},
accessType: 'partialAccess',
existingPermissions: [
{ role: 'asdf', queryType: 'select', table: ['Album'] },
{ role: 'new', queryType: 'select', table: ['Album'] },
{ role: 'sdfsf', queryType: 'select', table: ['Album'] },
{ role: 'testrole', queryType: 'select', table: ['Album'] },
{ role: 'user', queryType: 'select', table: ['Album'] },
{ role: 'user', queryType: 'delete', table: ['Album'] },
{ role: 'asdf', queryType: 'select', table: ['Artist'] },
{ role: 'testrole', queryType: 'select', table: ['Artist'] },
{ role: 'user', queryType: 'select', table: ['Artist'] },
],
tables: [['Album'], ['Artist']],
};
export const insertArgs: CreateInsertArgs = {
driver: 'sqlagent',
dataSourceName: 'Chinook',
table: ['Album'],
queryType: 'insert',
role: 'user',
formData: {
queryType: 'insert',
checkType: 'custom',
filterType: 'none',
check: {
_and: [
{},
{ AlbumId: { _eq: '1337' } },
{ _not: { ArtistId: { _eq: '1338' } } },
],
},
columns: { AlbumId: false, Title: false, ArtistId: false },
presets: [{ columnName: 'default', presetType: 'static', columnValue: '' }],
backendOnly: false,
supportedOperators: [
{ name: 'equals', value: '_eq' },
{ name: 'not equals', value: '_neq' },
{ name: '>', value: '_gt' },
{ name: '<', value: '_lt' },
{ name: '>=', value: '_gte' },
{ name: '<=', value: '_lte' },
],
clonePermissions: [{ tableName: '', queryType: '', roleName: '' }],
},
accessType: 'noAccess',
existingPermissions: [
{ role: 'asdf', queryType: 'select', table: ['Album'] },
{ role: 'new', queryType: 'select', table: ['Album'] },
{ role: 'sdfsf', queryType: 'select', table: ['Album'] },
{ role: 'testrole', queryType: 'select', table: ['Album'] },
{ role: 'user', queryType: 'select', table: ['Album'] },
{ role: 'user', queryType: 'delete', table: ['Album'] },
{ role: 'asdf', queryType: 'select', table: ['Artist'] },
{ role: 'testrole', queryType: 'select', table: ['Artist'] },
{ role: 'user', queryType: 'select', table: ['Artist'] },
],
tables: [['Album'], ['Artist']],
};

View File

@ -1,5 +1,3 @@
import React from 'react';
import { Story, Meta } from '@storybook/react'; import { Story, Meta } from '@storybook/react';
import { ReactQueryDecorator } from '../../../storybook/decorators/react-query'; import { ReactQueryDecorator } from '../../../storybook/decorators/react-query';

View File

@ -121,7 +121,8 @@ export const PermissionsTable: React.FC<PermissionsTableProps> = ({
// only select is possible on GDC as mutations are not available yet // only select is possible on GDC as mutations are not available yet
const isEditable = const isEditable =
(roleName !== 'admin' && permissionType === 'select') || (roleName !== 'admin' && permissionType === 'select') ||
(roleName !== 'admin' && permissionType === 'insert'); (roleName !== 'admin' && permissionType === 'insert') ||
(roleName !== 'admin' && permissionType === 'delete');
if (isNewRole) { if (isNewRole) {
return ( return (