console: add permissions form to work with GDC

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/6558
Co-authored-by: Vijay Prasanna <11921040+vijayprasanna13@users.noreply.github.com>
GitOrigin-RevId: 6ec7ca366a9a1aea987ca1a2c14811d4c44e4d60
This commit is contained in:
Matt Hardman 2022-10-27 16:49:01 -05:00 committed by hasura-bot
parent b30dc55321
commit 72dbafc319
67 changed files with 1829 additions and 3793 deletions

View File

@ -51,7 +51,6 @@ export const getTableColumns = async (props: GetTableColumnsProps) => {
sourceCustomization: metadataSource?.customization,
configuration: metadataTable.configuration,
});
console.log(queryRoot);
// eslint-disable-next-line no-underscore-dangle
const graphQLFields =
introspectionResult.data.__schema.types.find(

View File

@ -33,6 +33,7 @@ import {
exportMetadata,
getDriverPrefix,
NetworkArgs,
runIntrospectionQuery,
RunSQLResponse,
} from './api';
import { getAllSourceKinds } from './common/getAllSourceKinds';
@ -411,4 +412,5 @@ export {
getTableName,
RunSQLResponse,
getDriverPrefix,
runIntrospectionQuery,
};

View File

@ -10,4 +10,5 @@ export * from './network';
export * from './restEndpoints';
export * from './apiLimits';
export * from './graphqlSchemaIntrospection';
export * from './permissions';
export * from './metadata';

View File

@ -0,0 +1 @@
export * from './permissions';

View File

@ -0,0 +1,51 @@
export type Permission =
| InsertPermission
| SelectPermission
| UpdatePermission
| DeletePermission;
type BasePermission = {
role: string;
};
export interface InsertPermission extends BasePermission {
permission: InsertPermissionDefinition;
}
export interface InsertPermissionDefinition {
check?: Record<string, unknown>;
set?: Record<string, unknown>;
columns?: string[];
backend_only?: boolean;
}
export interface SelectPermission extends BasePermission {
permission: SelectPermissionDefinition;
}
export interface SelectPermissionDefinition {
columns?: string[];
filter?: Record<string, unknown>;
allow_aggregations?: boolean;
query_root_fields?: string[];
subscription_root_fields?: string[];
limit?: number;
}
export interface UpdatePermission extends BasePermission {
permission: UpdatePermissionDefinition;
}
export interface UpdatePermissionDefinition {
columns?: string[];
filter?: Record<string, unknown>;
check?: Record<string, unknown>;
set?: Record<string, unknown>;
backend_only?: boolean;
}
export interface DeletePermission extends BasePermission {
permission: DeletePermissionDefinition;
}
export interface DeletePermissionDefinition {
filter?: Record<string, unknown>;
backend_only?: boolean;
}

View File

@ -1,3 +1,9 @@
import {
InsertPermission,
SelectPermission,
UpdatePermission,
DeletePermission,
} from '../permissions';
import {
Legacy_SourceToRemoteSchemaRelationship,
LocalTableArrayRelationship,
@ -73,4 +79,9 @@ export type MetadataTable = {
| ManualArrayRelationship
| LocalTableArrayRelationship
)[];
insert_permissions?: InsertPermission[];
select_permissions?: SelectPermission[];
update_permissions?: UpdatePermission[];
delete_permissions?: DeletePermission[];
};

View File

@ -13,20 +13,12 @@ export default {
decorators: [ReactQueryDecorator()],
} as Meta;
const dataLeaf = {
type: 'schema',
name: 'users',
leaf: {
type: 'table',
name: 'users',
},
};
export const Primary: Story<BulkDeleteProps> = args => {
return <BulkDelete {...args} />;
};
Primary.args = {
dataLeaf,
currentSource: 'postgres',
dataSourceName: 'default',
roles: ['user'],
handleClose: () => {},
};

View File

@ -2,25 +2,26 @@ import React from 'react';
import { Button } from '@/new-components/Button';
import { useBulkDeletePermissions } from './hooks';
import { DataLeaf } from '../PermissionsTab/types/types';
import { useDataSource } from '../PermissionsTab/types/useDataSource';
export interface BulkDeleteProps {
currentSource: string;
dataSourceName: string;
roles: string[];
dataLeaf: DataLeaf;
table: unknown;
handleClose: () => void;
}
export const BulkDelete: React.FC<BulkDeleteProps> = ({
currentSource,
dataSourceName,
roles,
dataLeaf,
table,
handleClose,
}) => {
const dataSource = useDataSource();
const { submit, isLoading, isError } = useBulkDeletePermissions({
dataSource,
dataLeaf,
currentSource,
dataSourceName,
table,
});
const handleDelete = async () => {

View File

@ -1,32 +0,0 @@
@import '../../../components/Common/Common.module';
.qb_select,
.qb_input {
border: none;
background-color: transparent;
border-bottom: 2px dotted;
border-radius: 0;
//color: #CB3837
}
.qb_select {
cursor: pointer;
}
.qb_select:focus,
.qb_input:focus {
outline: none;
}
.qb_container {
// display: inline-block;
min-width: 50%;
}
.qb_input_suggestion {
margin-left: 10px;
color: #080;
font-size: 10px;
font-weight: bold;
cursor: pointer;
}

View File

@ -1,81 +0,0 @@
import React from 'react';
import styles from './PermissionBuilder.module.scss';
import { addToPrefix } from './utils';
export type OptGroup = { optGroupTitle: string; options: string[] };
interface SelectGroupProps {
selectDispatchFunc: (value: string) => void;
value: string;
values: OptGroup[];
prefix?: string;
disabledValues?: string[];
}
const optGroupSortFn = (a: OptGroup, b: OptGroup) => {
if (a.optGroupTitle === 'root') return 1;
if (b.optGroupTitle === 'root') return -1;
return 0;
};
const SelectGroup: React.FC<SelectGroupProps> = ({
selectDispatchFunc,
value,
values,
prefix = '',
disabledValues = [],
}) => {
const dispatchSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
selectDispatchFunc(e.target.value);
};
const selectOptions = [];
selectOptions.push(
<option value={addToPrefix(prefix, '--')} key={0} disabled>
--
</option>
);
values.sort(optGroupSortFn).forEach(({ optGroupTitle, options }, i) => {
if (options?.length) {
selectOptions.push(
<optgroup label={optGroupTitle} key={i + 1}>
{options.map((option, j) => (
<option
value={addToPrefix(prefix, option)}
key={j}
disabled={disabledValues.includes(option)}
>
{option || '--'}
</option>
))}
</optgroup>
);
}
});
const selectedValue = addToPrefix(prefix, value || '--');
return (
<select
value={selectedValue}
name={prefix}
onChange={dispatchSelect}
className={styles.qb_select}
data-test="qb-select"
>
{selectOptions}
</select>
);
};
export default SelectGroup;
export const QuotedSelectGroup: React.FC<SelectGroupProps> = props => {
return (
<span>
&quot;&nbsp;
<SelectGroup {...props} />
&quot;&nbsp;
</span>
);
};

View File

@ -1,357 +0,0 @@
/* Constants */
import { PermissionColumnCategories } from '@/dataSources/types';
// TODO: generate using SQL query to handle all types
const operatorTypeTypesMap = {
comparision: [
'boolean',
'character',
'dateTime',
'numeric',
'uuid',
'user_defined',
],
pattern_match: ['character'],
jsonb: ['jsonb'],
geometric: ['geometry'],
geometric_geographic: ['geometry', 'geography'],
};
export type BoolOperators = keyof typeof boolOperatorsInfo;
const boolOperatorsInfo = {
_and: {
type: 'bool',
inputStructure: 'array',
},
_or: {
type: 'bool',
inputStructure: 'array',
},
_not: {
type: 'bool',
inputStructure: 'object',
},
};
export type ColumnOperators = keyof typeof columnOperatorsInfo;
const columnOperatorsInfo = {
_eq: {
type: 'comparision',
inputStructure: 'object',
inputType: null,
},
_ne: {
type: 'comparision',
inputStructure: 'object',
inputType: null,
},
_neq: {
type: 'comparision',
inputStructure: 'object',
inputType: null,
},
_in: {
type: 'comparision',
inputStructure: 'array',
inputType: null,
},
_nin: {
type: 'comparision',
inputStructure: 'array',
inputType: null,
},
_gt: {
type: 'comparision',
inputStructure: 'object',
inputType: null,
},
_lt: {
type: 'comparision',
inputStructure: 'object',
inputType: null,
},
_gte: {
type: 'comparision',
inputStructure: 'object',
inputType: null,
},
_lte: {
type: 'comparision',
inputStructure: 'object',
inputType: null,
},
_ceq: {
type: 'comparision',
inputStructure: 'array',
inputType: 'column',
},
_cne: {
type: 'comparision',
inputStructure: 'array',
inputType: 'column',
},
_cgt: {
type: 'comparision',
inputStructure: 'array',
inputType: 'column',
},
_clt: {
type: 'comparision',
inputStructure: 'array',
inputType: 'column',
},
_cgte: {
type: 'comparision',
inputStructure: 'array',
inputType: 'column',
},
_clte: {
type: 'comparision',
inputStructure: 'array',
inputType: 'column',
},
_is_null: {
type: 'is_null',
inputStructure: 'object',
inputType: 'boolean',
},
_like: {
type: 'pattern_match',
inputStructure: 'object',
inputType: null,
},
_nlike: {
type: 'pattern_match',
inputStructure: 'object',
inputType: null,
},
_ilike: {
type: 'pattern_match',
inputStructure: 'object',
inputType: null,
},
_nilike: {
type: 'pattern_match',
inputStructure: 'object',
inputType: null,
},
_similar: {
type: 'pattern_match',
inputStructure: 'object',
inputType: null,
},
_nsimilar: {
type: 'pattern_match',
inputStructure: 'object',
inputType: null,
},
_regex: {
type: 'pattern_match',
inputStructure: 'object',
inputType: null,
},
_iregex: {
type: 'pattern_match',
inputStructure: 'object',
inputType: null,
},
_nregex: {
type: 'pattern_match',
inputStructure: 'object',
inputType: null,
},
_niregex: {
type: 'pattern_match',
inputStructure: 'object',
inputType: null,
},
_contains: {
type: 'jsonb',
inputStructure: 'object',
inputType: null,
},
_contained_in: {
type: 'jsonb',
inputStructure: 'object',
inputType: null,
},
_has_key: {
type: 'jsonb',
inputStructure: 'object',
inputType: 'character',
},
_has_keys_any: {
type: 'jsonb',
inputStructure: 'array',
inputType: 'character',
},
_has_keys_all: {
type: 'jsonb',
inputStructure: 'array',
inputType: 'character',
},
_st_contains: {
type: 'geometric',
inputStructure: 'object',
inputType: null,
},
_st_crosses: {
type: 'geometric',
inputStructure: 'object',
inputType: 'json',
},
_st_equals: {
type: 'geometric',
inputStructure: 'object',
inputType: 'json',
},
_st_overlaps: {
type: 'geometric',
inputStructure: 'object',
inputType: 'json',
},
_st_touches: {
type: 'geometric',
inputStructure: 'object',
inputType: 'json',
},
_st_within: {
type: 'geometric',
inputStructure: 'object',
inputType: 'json',
},
_st_d_within: {
type: 'geometric_geographic',
inputStructure: 'object',
inputType: 'json',
},
_st_intersects: {
type: 'geometric_geographic',
inputStructure: 'object',
inputType: 'json',
},
};
export const getPermissionOperators = (
supportedOperators: ColumnOperators[],
typeMap: Partial<PermissionColumnCategories> | null
) => {
let modifiedColumnOperatorsInfo = columnOperatorsInfo;
if (Array.isArray(supportedOperators)) {
modifiedColumnOperatorsInfo = supportedOperators.reduce(
(ops, opName) => ({
...ops,
[opName]: columnOperatorsInfo[opName],
}),
{} as typeof columnOperatorsInfo
);
}
const operatorMap = {
...operatorTypeTypesMap,
is_null: Object.keys(typeMap ?? {}),
};
type OperatorMapKey = keyof typeof operatorMap;
const operators: Partial<typeof operatorMap> = {};
Object.keys(modifiedColumnOperatorsInfo).forEach(op => {
const key = modifiedColumnOperatorsInfo[op as ColumnOperators].type;
operatorMap[key as OperatorMapKey].forEach(type => {
operators[type as OperatorMapKey] =
operators[type as OperatorMapKey] || [];
operators[type as OperatorMapKey]?.push(op);
});
});
return operators;
};
export const boolOperators = Object.keys(boolOperatorsInfo);
const columnOperators = Object.keys(columnOperatorsInfo);
export const existOperators = ['_exists'];
export const allOperators = boolOperators
.concat(columnOperators)
.concat(existOperators);
export const TABLE_KEY = '_table';
export const WHERE_KEY = '_where';
/* Util functions */
export const isBoolOperator = (operator: BoolOperators) => {
return boolOperators.includes(operator);
};
export const isExistOperator = (operator: string) => {
return existOperators.includes(operator);
};
export const isArrayBoolOperator = (operator: BoolOperators) => {
const arrayBoolOperators = Object.keys(boolOperatorsInfo).filter(
op => boolOperatorsInfo[op as BoolOperators].inputStructure === 'array'
);
return arrayBoolOperators.includes(operator);
};
export const isColumnOperator = (operator: ColumnOperators) => {
return columnOperators.includes(operator);
};
export const isArrayColumnOperator = (operator: string) => {
const arrayColumnOperators = Object.keys(columnOperatorsInfo).filter(
op => columnOperatorsInfo[op as ColumnOperators].inputStructure === 'array'
);
return arrayColumnOperators.includes(operator);
};
export const getOperatorInputType = (operator: ColumnOperators) => {
return columnOperatorsInfo[operator]
? columnOperatorsInfo[operator].inputType
: null;
};
export const getRootType = (
type: string,
typeMap: Partial<PermissionColumnCategories> | null
) => {
const typeMapKeys = Object.keys(typeMap ?? {});
let rootType = typeMapKeys.find(rType =>
typeMap?.[rType as keyof PermissionColumnCategories]?.includes(type)
);
if (!rootType) {
rootType = 'user_defined';
}
return rootType;
};
export function getLegacyOperator(operator: string) {
return operator.replace('_', '$');
}
export function addToPrefix(prefix: string, value: string | number) {
let newPrefix;
if (prefix !== null && prefix.toString()) {
if (
prefix[prefix.length - 1] === '^' ||
prefix[prefix.length - 1] === '#'
) {
newPrefix = prefix + value;
} else {
newPrefix = `${prefix}.${value}`;
}
} else {
newPrefix = value as string;
}
return newPrefix;
}

View File

@ -16,75 +16,21 @@ export default {
const roleName = 'user';
const dataLeaf = {
type: 'schema',
name: 'users',
leaf: {
type: 'table',
name: 'users',
},
};
export const Showcase: Story<PermissionsFormProps> = () => {
return (
<>
<p className="font-bold py-4">Query Type: Insert</p>
<PermissionsForm
dataLeaf={dataLeaf}
roleName={roleName}
accessType="partialAccess"
queryType="insert"
handleClose={() => {}}
/>
<p className="font-bold py-4">Query Type: Select</p>
<PermissionsForm
dataLeaf={dataLeaf}
roleName={roleName}
accessType="partialAccess"
queryType="select"
handleClose={() => {}}
/>
<p className="font-bold py-4">Query Type: Update</p>
<PermissionsForm
dataLeaf={dataLeaf}
roleName={roleName}
accessType="noAccess"
queryType="update"
handleClose={() => {}}
/>
<p className="font-bold py-4">Query Type: Delete</p>
<PermissionsForm
dataLeaf={dataLeaf}
roleName={roleName}
accessType="noAccess"
queryType="delete"
handleClose={() => {}}
/>
</>
);
};
export const Insert: Story<PermissionsFormProps> = args => (
<PermissionsForm {...args} />
);
Insert.args = {
dataLeaf,
roleName,
accessType: 'partialAccess',
currentSource: 'postgres',
dataSourceName: 'default',
queryType: 'insert',
table: {
schema: 'public',
name: 'user',
},
roleName,
handleClose: () => {},
};
Insert.parameters = {
// Disable storybook for Insert stories
chromatic: { disableSnapshot: true },
};
export const Select: Story<PermissionsFormProps> = args => (
<PermissionsForm {...args} />
@ -95,13 +41,25 @@ Select.args = {
};
Select.parameters = Insert.parameters;
export const GDCSelect: Story<PermissionsFormProps> = args => (
<PermissionsForm {...args} />
);
GDCSelect.args = {
currentSource: 'sqlite',
dataSourceName: 'sqlite',
queryType: 'select',
table: ['Artist'],
roleName,
handleClose: () => {},
};
GDCSelect.parameters = Insert.parameters;
export const Update: Story<PermissionsFormProps> = args => (
<PermissionsForm {...args} />
);
Update.args = {
...Insert.args,
queryType: 'update',
accessType: 'noAccess',
};
Update.parameters = Insert.parameters;
@ -111,6 +69,5 @@ export const Delete: Story<PermissionsFormProps> = args => (
Delete.args = {
...Insert.args,
queryType: 'delete',
accessType: 'noAccess',
};
Delete.parameters = Insert.parameters;

View File

@ -1,64 +1,43 @@
import React from 'react';
import { FieldValues, useFormContext, UseFormProps } from 'react-hook-form';
import { Form } from '@/new-components/Form';
import { Button } from '@/new-components/Button';
import {
RowPermissionsSectionWrapper,
RowPermissionsSection,
ColumnPermissionsSection,
ColumnPresetsSection,
AggregationSection,
BackendOnlySection,
ClonePermissionsSection,
} from './components';
import { DataLeaf } from '../PermissionsTab/types/types';
import { useDataSource } from '../PermissionsTab/types/useDataSource';
import { useDefaultValues, useFormData, useUpdatePermissions } from './hooks';
import { schema } from './utils/formSchema';
import { AccessType, FormOutput, QueryType } from './types';
import {
AggregationSection,
BackendOnlySection,
ColumnPermissionsSection,
ColumnPresetsSection,
RowPermissionsSection,
RowPermissionsSectionWrapper,
} from './components';
import { useFormData, useDefaultValues, useUpdatePermissions } from './hooks';
export interface PermissionsFormProps {
dataLeaf: DataLeaf;
currentSource: string;
dataSourceName: string;
table: unknown;
queryType: QueryType;
roleName: string;
accessType: AccessType;
handleClose: () => void;
}
interface ResetterProps {
defaultValues: FormOutput;
}
export const PermissionsForm = (props: PermissionsFormProps) => {
const {
currentSource,
dataSourceName,
table,
queryType,
roleName,
accessType,
handleClose,
} = props;
// required to update the default values when the form switches between query types
// for example from update to select
const Resetter: React.FC<ResetterProps> = ({ defaultValues }) => {
const { reset } = useFormContext();
const initialRender = React.useRef(true);
React.useEffect(() => {
if (initialRender.current) {
initialRender.current = false;
} else {
reset(defaultValues);
}
}, [defaultValues, reset]);
return null;
};
export const PermissionsForm: React.FC<PermissionsFormProps> = ({
dataLeaf,
queryType,
roleName,
accessType,
handleClose,
}) => {
const dataSource = useDataSource();
// loads all information about selected table
// e.g. column names, supported queries etc.
const {
@ -66,41 +45,36 @@ export const PermissionsForm: React.FC<PermissionsFormProps> = ({
isLoading: loadingFormData,
isError: formDataError,
} = useFormData({
dataTarget: {
dataSource,
dataLeaf,
},
dataSourceName,
table,
queryType,
roleName,
});
// loads any existing permissions from the metadata
const {
data: defaults,
data: defaultValues,
isLoading: defaultValuesLoading,
isError: defaultValuesError,
} = useDefaultValues({
dataTarget: {
dataSource,
dataLeaf,
},
dataSourceName,
table,
roleName,
queryType,
});
// functions fired when the form is submitted
const { updatePermissions, deletePermissions } = useUpdatePermissions({
dataTarget: {
dataSource,
dataLeaf,
},
currentSource,
dataSourceName,
table,
queryType,
roleName,
accessType,
});
const handleSubmit = async (formData: Record<string, unknown>) => {
updatePermissions.submit(formData as FormOutput);
await updatePermissions.submit(formData as FormOutput);
handleClose();
};
@ -116,28 +90,14 @@ export const PermissionsForm: React.FC<PermissionsFormProps> = ({
const isLoading = loadingFormData || defaultValuesLoading;
const { allFunctions, roles, tables, tableNames, supportedQueries, columns } =
data;
// allRowChecks relates to other queries and is for duplicating from others
// therefore it shouldn't be passed to the form as a default value
const { allRowChecks, ...defaultValues } = defaults;
const allRowChecks = defaultValues?.allRowChecks;
// for update it is possible to set pre update and post update row checks
const rowPermissions = queryType === 'update' ? ['pre', 'post'] : [queryType];
// add new role to list of roles for clone permissions
const allRoles = React.useMemo(() => {
if (roles) {
return !roles.includes(roleName) ? [...roles, roleName] : roles;
}
return [roleName];
}, [roleName, roles]);
// these will be replaced by components once spec is decided
if (isSubmittingError) {
return <div>Error submitting form data</div>;
return <div>Error submitting form</div>;
}
// these will be replaced by components once spec is decided
@ -152,110 +112,113 @@ export const PermissionsForm: React.FC<PermissionsFormProps> = ({
return (
<Form
key={`${queryType}-${roleName}-${accessType}`}
onSubmit={handleSubmit}
schema={schema}
options={{ defaultValues } as UseFormProps<FieldValues>}
className="p-4"
options={{ defaultValues }}
>
{() => (
<div className="bg-white rounded p-md border border-gray-300">
<div className="pb-4 flex items-center gap-4">
<Button type="button" onClick={handleClose}>
Close
</Button>
<h3 data-testid="form-title">
<strong>Role:</strong> {roleName} <strong>Action:</strong>{' '}
{queryType}
</h3>
</div>
<Resetter defaultValues={defaultValues} />
{options => {
console.log('form values---->', options.getValues());
console.log('form errors---->', options.formState.errors);
return (
<div className="bg-white rounded p-md border border-gray-300">
<div className="pb-4 flex items-center gap-4">
<Button type="button" onClick={handleClose}>
Close
</Button>
<h3 data-testid="form-title">
<strong>Role:</strong> {roleName} <strong>Action:</strong>{' '}
{queryType}
</h3>
</div>
<RowPermissionsSectionWrapper
roleName={roleName}
queryType={queryType}
defaultOpen
>
{rowPermissions.map(permissionName => (
<React.Fragment key={permissionName}>
{/* if queryType is update 2 row permissions sections are rendered (pre and post) */}
{/* therefore they need titles */}
{queryType === 'update' && (
<p className="my-2">
<strong>
{permissionName === 'pre' ? 'Pre-update' : 'Post-update'}
&nbsp; check
</strong>
&nbsp;
{permissionName === 'Post-update' && '(optional)'}
</p>
)}
<RowPermissionsSection
dataLeaf={dataLeaf}
queryType={queryType}
subQueryType={
queryType === 'update' ? permissionName : undefined
}
allRowChecks={allRowChecks}
allSchemas={tables}
allFunctions={allFunctions}
/>
</React.Fragment>
))}
</RowPermissionsSectionWrapper>
{queryType !== 'delete' && (
<ColumnPermissionsSection
<RowPermissionsSectionWrapper
roleName={roleName}
queryType={queryType}
columns={columns}
/>
)}
defaultOpen
>
{rowPermissions.map(permissionName => (
<React.Fragment key={permissionName}>
{queryType === 'update' && (
<p className="my-2">
<strong>
{permissionName === 'pre'
? 'Pre-update'
: 'Post-update'}
&nbsp; check
</strong>
&nbsp;
{permissionName === 'Post-update' && '(optional)'}
</p>
)}
<RowPermissionsSection
table={table}
queryType={queryType}
subQueryType={
queryType === 'update' ? permissionName : undefined
}
allRowChecks={allRowChecks || []}
/>
</React.Fragment>
))}
</RowPermissionsSectionWrapper>
{['insert', 'update'].includes(queryType) && (
<ColumnPresetsSection queryType={queryType} columns={columns} />
)}
{queryType !== 'delete' && (
<ColumnPermissionsSection
roleName={roleName}
queryType={queryType}
columns={data?.columns}
/>
)}
{queryType === 'select' && (
<AggregationSection queryType={queryType} roleName={roleName} />
)}
{['insert', 'update'].includes(queryType) && (
<ColumnPresetsSection
queryType={queryType}
columns={data?.columns}
/>
)}
{['insert', 'update', 'delete'].includes(queryType) && (
<BackendOnlySection queryType={queryType} />
)}
{queryType === 'select' && (
<AggregationSection queryType={queryType} roleName={roleName} />
)}
<hr className="my-4" />
{['insert', 'update', 'delete'].includes(queryType) && (
<BackendOnlySection queryType={queryType} />
)}
{!!tableNames?.length && (
<hr className="my-4" />
{/* {!!tableNames?.length && (
<ClonePermissionsSection
queryType={queryType}
tables={tableNames}
supportedQueryTypes={supportedQueries}
roles={allRoles}
/>
)}
)} */}
<hr className="my-4" />
<div className="pt-2 flex gap-2">
<Button
type="submit"
mode="primary"
isLoading={updatePermissions.isLoading}
>
Save Permissions
</Button>
<div className="pt-2 flex gap-2">
<Button
type="submit"
mode="primary"
isLoading={updatePermissions.isLoading}
>
Save Permissions
</Button>
<Button
type="button"
disabled={accessType === 'noAccess'}
mode="destructive"
isLoading={deletePermissions.isLoading}
onClick={handleDelete}
>
Delete Permissions
</Button>
<Button
type="button"
disabled={accessType === 'noAccess'}
mode="destructive"
isLoading={deletePermissions.isLoading}
onClick={handleDelete}
>
Delete Permissions
</Button>
</div>
</div>
</div>
)}
);
}}
</Form>
);
};

View File

@ -1,100 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`update metadata in cache 1`] = `
Object {
"metadata": Object {
"inherited_roles": Array [],
"sources": Array [
Object {
"configuration": Object {
"connection_info": Object {
"database_url": Object {
"from_env": "HASURA_GRAPHQL_DATABASE_URL",
},
"isolation_level": "read-committed",
"pool_settings": Object {
"connection_lifetime": 600,
"idle_timeout": 180,
"max_connections": 50,
"retries": 1,
},
"use_prepared_statements": true,
},
},
"functions": Array [
Object {
"function": Object {
"name": "search_user2",
"schema": "public",
},
},
],
"kind": "postgres",
"name": "default",
"tables": Array [
Object {
"table": Object {
"name": "a_table",
"schema": "public",
},
},
Object {
"insert_permissions": Array [
Object {
"permission": Object {
"allow_aggregations": false,
"backend_only": false,
"check": Object {
"id": Object {
"_eq": 1,
},
},
"columns": Array [
"email",
"type",
],
"computed_fields": Array [],
"filter": Object {},
"limit": 0,
"presets": Object {},
},
"role": "user",
"source": "default",
"table": Object {
"name": "users",
"schema": "public",
},
},
],
"select_permissions": Array [
Object {
"permission": Object {
"allow_aggregations": true,
"columns": Array [
"email",
"id",
"type",
],
"filter": Object {
"id": Object {
"_eq": 1,
},
},
"limit": 5,
},
"role": "user",
},
],
"table": Object {
"name": "users",
"schema": "public",
},
},
],
},
],
"version": 3,
},
"resource_version": 30,
}
`;

View File

@ -1,47 +0,0 @@
import type { InsertBodyResult } from '../api';
import { updateTablePermission } from '../cache';
import { metadata } from '../../mocks/dataStubs';
const data: InsertBodyResult = {
type: 'bulk',
resource_version: 30,
args: [
{
type: 'pg_create_insert_permission',
args: {
table: {
name: 'users',
schema: 'public',
},
role: 'user',
permission: {
columns: ['email', 'type'],
presets: {},
computed_fields: [],
backend_only: false,
limit: 0,
allow_aggregations: false,
check: {
id: {
_eq: 1,
},
},
filter: {},
},
source: 'default',
},
},
],
};
test('update metadata in cache', () => {
const result = updateTablePermission({
key: 'insert_permissions',
tableName: 'users',
roleName: 'user',
metadata,
data,
});
expect(result).toMatchSnapshot();
});

View File

@ -1,11 +1,12 @@
import { allowedMetadataTypes } from '@/features/MetadataAPI';
import { NewDataTarget } from '../../PermissionsTab/types/types';
import { AccessType, FormOutput, QueryType } from '../types';
import { createInsertArgs, driverPrefixes } from './utils';
import { createInsertArgs } from './utils';
interface CreateBodyArgs {
dataTarget: NewDataTarget;
currentSource: string;
dataSourceName: string;
table: unknown;
roleName: string;
resourceVersion: number;
}
@ -15,7 +16,9 @@ interface CreateDeleteBodyArgs extends CreateBodyArgs {
}
const createDeleteBody = ({
dataTarget,
currentSource,
dataSourceName,
table,
roleName,
resourceVersion,
queries,
@ -25,18 +28,16 @@ const createDeleteBody = ({
resource_version: number;
args: BulkArgs[];
} => {
const driverPrefix = driverPrefixes[dataTarget.dataSource.driver];
if (!['postgres', 'mssql'].includes(dataTarget.dataSource.driver)) {
throw new Error(`${dataTarget.dataSource.driver} not supported`);
}
// if (!['postgres', 'mssql'].includes(currentSource)) {
// throw new Error(`${currentSource} not supported`);
// }
const args = queries.map(queryType => ({
type: `${driverPrefix}_drop_${queryType}_permission` as allowedMetadataTypes,
type: `${currentSource}_drop_${queryType}_permission` as allowedMetadataTypes,
args: {
table: dataTarget.dataLeaf.leaf?.name || '',
table,
role: roleName,
source: dataTarget.dataSource.database,
source: dataSourceName,
},
}));
@ -50,17 +51,23 @@ const createDeleteBody = ({
return body;
};
interface CreateBulkDeleteBodyArgs extends CreateBodyArgs {
roleList?: Array<{ roleName: string; queries: QueryType[] }>;
interface CreateBulkDeleteBodyArgs {
source: string;
dataSourceName: string;
table: unknown;
resourceVersion: number;
roleList?: Array<{ roleName: string; queries: string[] }>;
}
interface BulkArgs {
type: allowedMetadataTypes;
args: Record<string, string | allowedMetadataTypes>;
args: Record<string, string | allowedMetadataTypes | unknown>;
}
const createBulkDeleteBody = ({
dataTarget,
source,
dataSourceName,
table,
resourceVersion,
roleList,
}: CreateBulkDeleteBodyArgs): {
@ -69,21 +76,19 @@ const createBulkDeleteBody = ({
resource_version: number;
args: BulkArgs[];
} => {
const driverPrefix = driverPrefixes[dataTarget.dataSource.driver];
if (!['postgres', 'mssql'].includes(dataTarget.dataSource.driver)) {
throw new Error(`${dataTarget.dataSource.driver} not supported`);
}
// if (!['postgres', 'mssql'].includes(source)) {
// throw new Error(`${dataSourceName} not supported`);
// }
const args =
roleList?.reduce<BulkArgs[]>((acc, role) => {
role.queries.forEach(queryType => {
acc.push({
type: `${driverPrefix}_drop_${queryType}_permission` as allowedMetadataTypes,
type: `${source}_drop_${queryType}_permission` as allowedMetadataTypes,
args: {
table: dataTarget.dataLeaf.leaf?.name || '',
table,
role: role.roleName,
source: dataTarget.dataSource.database,
source: dataSourceName,
},
});
});
@ -93,7 +98,7 @@ const createBulkDeleteBody = ({
const body = {
type: 'bulk' as allowedMetadataTypes,
source: dataTarget.dataSource.database,
source: dataSourceName,
resource_version: resourceVersion,
args: args ?? [],
};
@ -115,27 +120,28 @@ export interface InsertBodyResult {
}
const createInsertBody = ({
dataTarget,
currentSource,
dataSourceName,
table,
queryType,
roleName,
formData,
// accessType,
accessType,
resourceVersion,
existingPermissions,
}: CreateInsertBodyArgs): InsertBodyResult => {
const driverPrefix = driverPrefixes[dataTarget.dataSource.driver];
if (!['postgres', 'mssql'].includes(dataTarget.dataSource.driver)) {
throw new Error(`${dataTarget.dataSource.driver} not supported`);
}
// if (!['postgres', 'mssql'].includes(currentSource)) {
// throw new Error(`${currentSource} not supported`);
// }
const args = createInsertArgs({
driverPrefix,
database: dataTarget.dataSource.database,
table: dataTarget.dataLeaf.leaf?.name || '',
currentSource,
dataSourceName,
table,
queryType,
role: roleName,
formData,
accessType,
existingPermissions,
});

View File

@ -1,118 +0,0 @@
import { useQueryClient } from 'react-query';
import produce from 'immer';
import {
DeletePermissionEntry,
InsertPermissionEntry,
SelectPermissionEntry,
UpdatePermissionEntry,
} from '@/metadata/types';
import { MetadataResponse } from '../../MetadataAPI';
import { api } from './api';
import { AccessType, QueryType } from '../types';
type PermissionEntry =
| InsertPermissionEntry
| SelectPermissionEntry
| UpdatePermissionEntry
| DeletePermissionEntry;
type MetadataKeys =
| 'insert_permissions'
| 'select_permissions'
| 'update_permissions'
| 'delete_permissions';
interface UpdateTablePermissionArgs {
key: MetadataKeys;
tableName: string;
roleName: string;
metadata: MetadataResponse;
data: ReturnType<typeof api.createInsertBody>;
}
export const updateTablePermission = ({
key,
tableName,
roleName,
metadata,
data,
}: UpdateTablePermissionArgs) => {
// find the arg
const newMetadataItem = data.args.find(arg => arg.type.includes('create'));
// find and update the relevant piece of metadata that needs updating
const nextState = produce(metadata, draft => {
// find the table that is being edited
const selectedTable = draft.metadata.sources[0].tables.find(
({ table }) => table.name === tableName
);
// find the queryType that is being edited
const selectedPermission = selectedTable?.[key];
// find the index of the role that is being edited
const selectedRolePermissionIndex = selectedPermission?.findIndex(
(permission: PermissionEntry) => permission.role === roleName
);
// if the selected permission already exists replace it
if (
selectedRolePermissionIndex !== undefined &&
selectedPermission &&
newMetadataItem
) {
selectedPermission[selectedRolePermissionIndex] = newMetadataItem?.args;
} else if (newMetadataItem) {
selectedPermission?.push(newMetadataItem.args);
}
});
return nextState;
};
interface HandleUpdateArgs {
args: {
tableName: string;
schemaName: string;
roleName: string;
queryType: QueryType;
accessType: AccessType;
};
response: {
headers: any;
body: ReturnType<typeof api.createInsertBody>;
};
}
const useUpdateTablePermissionCache = () => {
const client = useQueryClient();
const handleUpdate = ({ args, response }: HandleUpdateArgs) => {
const metadata = client.getQueryData<MetadataResponse>(['metadata']);
const { tableName, roleName, queryType } = args;
if (metadata) {
// update cache
const result = updateTablePermission({
key: `${queryType}_permissions`,
tableName,
roleName,
metadata,
data: response.body,
});
client.setQueryData('metadata', result);
}
return { metadata };
};
return { handleUpdate };
};
export const cache = {
useUpdateTablePermissionCache,
};

View File

@ -1,16 +1,17 @@
import { CreateInsertArgs, createInsertArgs } from '../utils';
import { CreateInsertArgs, createInsertArgs } from './utils';
const insertArgs: CreateInsertArgs = {
driverPrefix: 'pg' as const,
database: 'default',
currentSource: 'postgres',
dataSourceName: 'default',
accessType: 'fullAccess',
table: 'users',
queryType: 'insert',
role: 'user',
formData: {
checkType: 'custom',
filterType: 'none',
check: '{"id":{"_eq":1}}',
filter: '',
check: { id: { _eq: 1 } },
filter: {},
rowCount: '0',
columns: {
id: false,
@ -55,7 +56,7 @@ test('create insert args object from form data', () => {
expect(result).toEqual([
{
type: 'pg_drop_insert_permission',
type: 'postgres_drop_insert_permission',
args: {
table: 'users',
role: 'user',
@ -63,7 +64,7 @@ test('create insert args object from form data', () => {
},
},
{
type: 'pg_create_insert_permission',
type: 'postgres_create_insert_permission',
args: {
table: 'users',
role: 'user',
@ -72,7 +73,6 @@ test('create insert args object from form data', () => {
presets: {},
computed_fields: [],
backend_only: false,
limit: 0,
allow_aggregations: false,
check: {
id: {
@ -88,16 +88,17 @@ test('create insert args object from form data', () => {
});
const insertArgsWithClonePermissions: CreateInsertArgs = {
driverPrefix: 'pg' as const,
database: 'default',
currentSource: 'postgres',
dataSourceName: 'default',
accessType: 'fullAccess',
table: 'users',
queryType: 'insert',
role: 'user',
formData: {
checkType: 'custom',
filterType: 'none',
check: '{"id":{"_eq":1}}',
filter: '',
check: { id: { _eq: 1 } },
filter: {},
rowCount: '0',
columns: {
id: false,
@ -147,7 +148,7 @@ test('create insert args object from form data with clone permissions', () => {
expect(result).toEqual([
{
type: 'pg_drop_insert_permission',
type: 'postgres_drop_insert_permission',
args: {
table: 'users',
role: 'user',
@ -155,7 +156,7 @@ test('create insert args object from form data with clone permissions', () => {
},
},
{
type: 'pg_create_insert_permission',
type: 'postgres_create_insert_permission',
args: {
table: 'users',
role: 'user',
@ -164,7 +165,6 @@ test('create insert args object from form data with clone permissions', () => {
presets: {},
computed_fields: [],
backend_only: false,
limit: 0,
allow_aggregations: false,
check: {
id: {
@ -177,7 +177,7 @@ test('create insert args object from form data with clone permissions', () => {
},
},
{
type: 'pg_create_select_permission',
type: 'postgres_create_select_permission',
args: {
table: 'a_table',
role: 'user',
@ -186,7 +186,6 @@ test('create insert args object from form data with clone permissions', () => {
presets: {},
computed_fields: [],
backend_only: false,
limit: 0,
allow_aggregations: false,
check: {
id: {

View File

@ -1,2 +1 @@
export * from './api';
export * from './cache';

View File

@ -2,19 +2,18 @@ import produce from 'immer';
import { allowedMetadataTypes } from '@/features/MetadataAPI';
import { FormOutput } from '../types';
import { AccessType, FormOutput } from '../types';
export const driverPrefixes = {
postgres: 'pg',
mysql: 'mysql',
mssql: 'mssql',
bigquery: 'bigquery',
citus: 'citus',
cockroach: 'cockroach',
} as const;
type DriverPrefixKeys = keyof typeof driverPrefixes;
type DriverPrefixValues = typeof driverPrefixes[DriverPrefixKeys];
interface PermissionArgs {
columns: string[];
presets?: Record<string, string | number>;
computed_fields: string[];
backend_only: boolean;
allow_aggregations: boolean;
check: Record<string, unknown>;
filter: Record<string, unknown>;
limit?: number;
}
/**
* creates the permissions object for the server
@ -35,42 +34,47 @@ const createPermission = (formData: FormOutput) => {
.map(([key]) => key);
// return permissions object for args
return {
const permissionObject: PermissionArgs = {
columns,
presets,
computed_fields: [],
backend_only: formData.backendOnly,
limit: parseInt(formData.rowCount, 10),
allow_aggregations: formData.aggregationEnabled,
check: JSON.parse(formData.check || '{}'),
filter: JSON.parse(formData.filter || '{}'),
check: formData.check,
filter: formData.filter,
};
if (formData.rowCount && formData.rowCount !== '0') {
permissionObject.limit = parseInt(formData.rowCount, 10);
}
return permissionObject;
};
export interface CreateInsertArgs {
driverPrefix: DriverPrefixValues;
database: string;
table: string;
currentSource: string;
dataSourceName: string;
table: unknown;
queryType: string;
role: string;
accessType: AccessType;
formData: FormOutput;
existingPermissions: ExistingPermission[];
}
interface ExistingPermission {
table: string;
table: unknown;
role: string;
queryType: string;
}
/**
* creates the insert arguments to update permissions
* adds cloned permissions
* and creates drop arguments where permissions already exist
*/
export const createInsertArgs = ({
driverPrefix,
database,
currentSource,
dataSourceName,
table,
queryType,
role,
@ -82,12 +86,12 @@ export const createInsertArgs = ({
// create args object with args from form
const initialArgs = [
{
type: `${driverPrefix}_create_${queryType}_permission` as allowedMetadataTypes,
type: `${currentSource}_create_${queryType}_permission` as allowedMetadataTypes,
args: {
table,
role,
permission,
source: database,
source: dataSourceName,
},
},
];
@ -96,7 +100,7 @@ export const createInsertArgs = ({
// determine if args from form already exist
const permissionExists = existingPermissions.find(
existingPermission =>
existingPermission.table === table &&
JSON.stringify(existingPermission.table) === JSON.stringify(table) &&
existingPermission.role === role &&
existingPermission.queryType === queryType
);
@ -104,11 +108,11 @@ export const createInsertArgs = ({
// if the permission already exists it needs to be dropped
if (permissionExists) {
draft.unshift({
type: `${driverPrefix}_drop_${queryType}_permission` as allowedMetadataTypes,
type: `${currentSource}_drop_${queryType}_permission` as allowedMetadataTypes,
args: {
table,
role,
source: database,
source: dataSourceName,
},
} as typeof initialArgs[0]);
}
@ -133,19 +137,20 @@ export const createInsertArgs = ({
);
// add each closed permission to args
draft.push({
type: `${driverPrefix}_create_${clonedPermission.queryType}_permission` as allowedMetadataTypes,
type: `${currentSource}_create_${clonedPermission.queryType}_permission` as allowedMetadataTypes,
args: {
table: clonedPermission.tableName || '',
role: clonedPermission.roleName || '',
permission: permissionWithColumnsAndPresetsRemoved,
source: database,
source: dataSourceName,
},
});
// determined if the cloned permission already exists
const clonedPermissionExists = existingPermissions.find(
existingPermission =>
existingPermission.table === clonedPermission.tableName &&
JSON.stringify(existingPermission.table) ===
JSON.stringify(clonedPermission.tableName) &&
existingPermission.role === clonedPermission.roleName &&
existingPermission.queryType === clonedPermission.queryType
);
@ -153,11 +158,11 @@ export const createInsertArgs = ({
// if it already exists drop it
if (clonedPermissionExists) {
draft.unshift({
type: `${driverPrefix}_drop_${clonedPermission.queryType}_permission` as allowedMetadataTypes,
type: `${currentSource}_drop_${clonedPermission.queryType}_permission` as allowedMetadataTypes,
args: {
table: clonedPermission.tableName,
role: clonedPermission.roleName,
source: database,
source: dataSourceName,
},
} as typeof initialArgs[0]);
}

View File

@ -37,7 +37,7 @@ export const AggregationSection: React.FC<AggregationProps> = ({
status={enabled ? 'Enabled' : 'Disabled'}
data-test="toggle-agg-permission"
disabled={disabled}
defaultOpen={defaultOpen}
defaultOpen={defaultOpen || enabled}
>
<Collapse.Content>
<div title={disabled ? 'Set row permissions first' : ''}>

View File

@ -18,7 +18,7 @@ export const BackendOnlySection: React.FC<BackEndOnlySectionProps> = ({
const enabled = watch('backendOnly');
return (
<Collapse defaultOpen={defaultOpen}>
<Collapse defaultOpen={defaultOpen || enabled}>
<Collapse.Header
title="Backend only"
tooltip={`When enabled, this ${queryType} mutation is accessible only via

View File

@ -10,7 +10,7 @@ import {
RowPermissionsWrapperProps,
} from './RowPermissions';
import { allSchemas, allFunctions } from '../mocks/mockData';
// import { allSchemas, allFunctions } from '../mocks/mockData';
import { QueryType } from '../types';
export default {
@ -18,7 +18,7 @@ export default {
component: RowPermissionsSection,
decorators: [
(StoryComponent: React.FC) => (
<Form schema={z.any()} onSubmit={() => {}} className="p-4">
<Form schema={z.any()} onSubmit={() => {}}>
{() => <StoryComponent />}
</Form>
),
@ -28,15 +28,6 @@ export default {
const roleName = 'two';
const dataLeaf = {
type: 'schema',
name: 'users',
leaf: {
type: 'table',
name: 'users',
},
};
// this will be moved into a utils folder
const allRowChecks = [
{
@ -62,11 +53,14 @@ export const Insert: Story<Props> = args => (
Insert.args = {
wrapper: { roleName, queryType: 'insert', defaultOpen: true },
section: {
dataLeaf,
table: {
schema: 'public',
name: 'user',
},
queryType: 'delete',
allRowChecks,
allSchemas,
allFunctions,
// allSchemas,
// allFunctions,
},
};

View File

@ -1,22 +1,18 @@
import React from 'react';
import { useFormContext, Controller } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import 'brace/mode/json';
import 'brace/theme/github';
import { NormalizedTable, Table } from '@/dataSources/types';
import { PGFunction } from '@/dataSources/services/postgresql/types';
import { generateTableDef } from '@/dataSources';
import { InputField } from '@/new-components/Form';
import { IconTooltip } from '@/new-components/Tooltip';
import { Collapse } from '@/new-components/deprecated';
import { getIngForm } from '../../../components/Services/Data/utils';
import JSONEditor from './JSONEditor';
import PermissionBuilder from '../PermissionBuilder/PermissionBuilder';
import { RowPermissionBuilder } from './RowPermissionsBuilder';
import { QueryType } from '../types';
import { DataLeaf } from '../../PermissionsTab/types/types';
const NoChecksLabel = () => (
<span data-test="without-checks">
@ -37,13 +33,10 @@ const CustomLabel = () => (
);
export interface RowPermissionsProps {
dataLeaf: DataLeaf;
table: unknown;
queryType: QueryType;
subQueryType?: string;
allRowChecks: Array<{ queryType: QueryType; value: string }>;
allSchemas?: NormalizedTable[];
allFunctions?: PGFunction[];
}
enum SelectedSection {
@ -87,15 +80,36 @@ const getRowPermissionCheckType = (
return 'filterType';
};
const isGDCTable = (table: unknown): table is string[] => {
return Array.isArray(table);
};
const hasTableName = (table: unknown): table is { name: string } => {
return typeof table === 'object' && 'name' in (table || {});
};
const getTableName = (table: unknown) => {
const gdcTable = isGDCTable(table);
if (gdcTable) {
return table[table.length - 1];
}
const tableName = hasTableName(table);
if (tableName) {
return table.name;
}
throw new Error('cannot read table');
};
export const RowPermissionsSection: React.FC<RowPermissionsProps> = ({
table,
queryType,
subQueryType,
dataLeaf,
allRowChecks,
allSchemas,
allFunctions,
}) => {
const { control, register, watch, setValue } = useFormContext();
const tableName = getTableName(table);
const { register, watch, setValue } = useFormContext();
// determines whether the inputs should be pointed at `check` or `filter`
const rowPermissions = getRowPermission(queryType, subQueryType);
// determines whether the check type should be pointer at `checkType` or `filterType`
@ -108,15 +122,10 @@ export const RowPermissionsSection: React.FC<RowPermissionsProps> = ({
const disabled =
queryType === 'update' && subQueryType === 'post' && !watch('check');
const schemaList = React.useMemo(
() => allSchemas?.map(({ table_schema }) => table_schema),
[allSchemas]
);
const selectedSection = watch(rowPermissionsCheckType);
return (
<fieldset className="grid gap-2">
<fieldset key={queryType} className="grid gap-2">
<div>
<label className="flex items-center gap-2">
<input
@ -126,7 +135,7 @@ export const RowPermissionsSection: React.FC<RowPermissionsProps> = ({
disabled={disabled}
onClick={() => {
setValue(rowPermissionsCheckType, SelectedSection.NoChecks);
setValue(rowPermissions, '{}');
setValue(rowPermissions, {});
}}
{...register(rowPermissionsCheckType)}
/>
@ -194,39 +203,7 @@ export const RowPermissionsSection: React.FC<RowPermissionsProps> = ({
{selectedSection === SelectedSection.Custom && (
<div className="pt-4">
<Controller
control={control}
name={rowPermissions}
render={({ field: { onChange, value } }) => (
<>
<JSONEditor
data={value || '{}'}
onChange={output => {
onChange(output);
}}
initData="{}"
/>
{allSchemas && allFunctions && schemaList && (
<PermissionBuilder
dispatchFuncSetFilter={output => {
onChange(output);
}}
loadSchemasFunc={() => {}}
tableDef={generateTableDef(
dataLeaf.leaf?.name || '',
dataLeaf.name
)}
allTableSchemas={allSchemas as Table[]}
allFunctions={allFunctions}
schemaList={schemaList}
filter={value || '{}'}
dispatch={() => console.log('output')}
/>
)}
</>
)}
/>
<RowPermissionBuilder tableName={tableName} nesting={['filter']} />
</div>
)}
</div>

View File

@ -1,32 +0,0 @@
import { AxiosInstance } from 'axios';
import { introspectionQuery } from './introspectionQuery';
export interface NetworkArgs {
httpClient: AxiosInstance;
}
type Args = { operationName: string; query: string } & NetworkArgs;
export const runGraphQL = async ({
operationName,
query,
httpClient,
}: Args) => {
try {
const result = await httpClient.post('v1/graphql', {
query,
operationName,
});
return result.data;
} catch (err) {
throw err;
}
};
export const runIntrospectionQuery = async ({ httpClient }: NetworkArgs) => {
return runGraphQL({
operationName: 'IntrospectionQuery',
query: introspectionQuery,
httpClient,
});
};

View File

@ -1,96 +0,0 @@
export const introspectionQuery = `query IntrospectionQuery {
__schema {
queryType {
name
}
mutationType {
name
}
subscriptionType {
name
}
types {
...FullType
}
directives {
name
description
locations
args {
...InputValue
}
}
}
}
fragment FullType on __Type {
kind
name
description
fields(includeDeprecated: true) {
name
description
args {
...InputValue
}
type {
...TypeRef
}
isDeprecated
deprecationReason
}
inputFields {
...InputValue
}
interfaces {
...TypeRef
}
enumValues(includeDeprecated: true) {
name
description
isDeprecated
deprecationReason
}
possibleTypes {
...TypeRef
}
}
fragment InputValue on __InputValue {
name
description
type {
...TypeRef
}
defaultValue
}
fragment TypeRef on __Type {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
}
}
}
}
}
}
}
}`;

View File

@ -155,7 +155,6 @@ export const Builder = (props: Props) => {
if (dropDownState?.name === '_and' || dropDownState?.name === '_or') {
setValue(permissionsKey, undefined);
}
// when the dropdown changes both the permissions object
// and operators object need to be unregistered below this level
unregister(permissionsKey);

View File

@ -95,6 +95,7 @@ export const RenderFormElement = (props: Props) => {
})}
/>
<button
type="button"
className="text-blue-800 font-bold text-sm"
onClick={() => setValue(columnKey, 'X-Hasura-User-Id')}
>

View File

@ -1,9 +1,9 @@
import React from 'react';
import { buildClientSchema, GraphQLSchema, IntrospectionQuery } from 'graphql';
import { useHttpClient } from '@/features/Network';
import { runIntrospectionQuery } from '../api';
import { runIntrospectionQuery } from '@/features/DataSource';
import { getAllColumnsAndOperators } from '../utils';
import { createDefaultValues, getAllColumnsAndOperators } from '../utils';
/**
*
@ -50,3 +50,18 @@ export const useData = ({ tableName, schema }: Args) => {
const data = getAllColumnsAndOperators({ tableName, schema });
return { data };
};
interface A {
tableName: string;
existingPermission: Record<string, any>;
}
export const useCreateRowPermissionsDefaults = () => {
const { data: schema } = useIntrospectSchema();
const fetchDefaults = async ({ tableName, existingPermission }: A) => {
createDefaultValues({ tableName, schema, existingPermission });
};
return fetchDefaults;
};

View File

@ -1 +1,2 @@
export * from './RowPermissionBuilder';
export * from './utils/createDefaultValues';

View File

@ -81,11 +81,8 @@ export interface CreateDefaultsArgs {
existingPermission?: Record<string, any>;
}
export const createDefaultValues = ({
tableName,
schema,
existingPermission,
}: CreateDefaultsArgs) => {
export const createDefaultValues = (props: CreateDefaultsArgs) => {
const { tableName, schema, existingPermission } = props;
if (!existingPermission) {
return {};
}

View File

@ -1,46 +0,0 @@
import { setupServer } from 'msw/node';
import { renderHook } from '@testing-library/react-hooks';
import { wrapper } from '../../../../hooks/__tests__/common/decorator';
import { handlers } from '../../mocks/handlers.mock';
import { useBulkDeletePermissions } from '../submitHooks';
const mocks = handlers('http://localhost');
const server = setupServer(...mocks);
beforeAll(() => server.listen());
afterAll(() => server.close());
const dataLeaf = {
type: 'schema',
name: 'users',
leaf: {
type: 'table',
name: 'users',
},
};
const dataTarget = {
dataSource: {
driver: 'postgres' as const,
database: 'default',
},
dataLeaf,
};
describe("useBulkDeletePermissions hooks' postgres test", () => {
test('bulk delete permissions submits correctly', async () => {
const { result, waitFor } = renderHook(
() => useBulkDeletePermissions(dataTarget),
{ wrapper }
);
const roles = ['user'];
await waitFor(() => result.current.isLoading === false);
await result.current.submit(roles);
expect(result.current.data).toEqual([{ message: 'success' }]);
});
});

View File

@ -1,79 +0,0 @@
import { setupServer } from 'msw/node';
import { renderHook } from '@testing-library/react-hooks';
import { wrapper } from '../../../../hooks/__tests__/common/decorator';
import { handlers } from '../../mocks/handlers.mock';
import { useDefaultValues } from '../dataFetchingHooks';
const [query, metadata] = handlers('http://localhost');
const server = setupServer();
server.use(metadata);
server.use(query);
const dataLeaf = {
type: 'schema',
name: 'public',
leaf: {
type: 'table',
name: 'users',
},
};
const dataTarget = {
dataSource: {
driver: 'postgres' as const,
database: 'default',
},
dataLeaf,
};
beforeAll(() => server.listen());
afterAll(() => server.close());
describe("useDefaultValues hooks' postgres test", () => {
test('useDefaultValues fetches data correctly', async () => {
const roleName = 'user';
const { result, waitForValueToChange, waitFor } = renderHook(
() =>
useDefaultValues({
dataTarget,
roleName,
queryType: 'insert',
}),
{ wrapper }
);
await waitForValueToChange(() => result.current.data);
await waitFor(() => result.current.isLoading === false);
expect(result.current.data!).toMatchInlineSnapshot(`
Object {
"aggregationEnabled": false,
"allRowChecks": Array [
Object {
"queryType": "select",
"value": "{\\"id\\":{\\"_eq\\":1}}",
},
],
"backendOnly": false,
"check": "{\\"id\\":{\\"_eq\\":1}}",
"checkType": "custom",
"clonePermissions": Array [],
"columns": Object {
"email": true,
"id": false,
"name": false,
"type": true,
"username": false,
},
"filter": "",
"filterType": "none",
"presets": Array [],
"rowCount": "0",
}
`);
});
});

View File

@ -1,67 +0,0 @@
import { setupServer } from 'msw/node';
import { renderHook } from '@testing-library/react-hooks';
import { wrapper } from '../../../../hooks/__tests__/common/decorator';
import { handlers } from '../../mocks/handlers.mock';
import { useFormData } from '../dataFetchingHooks';
const [query, metadata] = handlers('http://localhost');
const server = setupServer();
server.use(metadata);
server.use(query);
const dataLeaf = {
type: 'schema',
name: 'public',
leaf: {
type: 'table',
name: 'users',
},
};
const dataTarget = {
dataSource: {
driver: 'postgres' as const,
database: 'default',
},
dataLeaf,
};
beforeAll(() => server.listen());
afterAll(() => server.close());
describe("useFormData hooks' postgres test", () => {
test('useFormData fetches data correctly', async () => {
const roleName = 'user';
const { result, waitForValueToChange } = renderHook(
() =>
useFormData({
dataTarget,
roleName,
queryType: 'insert',
}),
{ wrapper }
);
await waitForValueToChange(() => result.current.data);
expect(result.current.data?.table?.table_name).toBe('users');
expect(result.current.data?.columns).toEqual([
'id',
'email',
'name',
'type',
'username',
]);
expect(result.current.data?.supportedQueries).toEqual([
'insert',
'select',
'update',
'delete',
]);
expect(result.current.data?.roles).toEqual(['user']);
});
});

View File

@ -1,103 +0,0 @@
import { setupServer } from 'msw/node';
import { renderHook } from '@testing-library/react-hooks';
import { wrapper } from '../../../../hooks/__tests__/common/decorator';
import { handlers } from '../../mocks/handlers.mock';
import { useUpdatePermissions } from '../submitHooks';
const mocks = handlers('http://localhost');
const server = setupServer(...mocks);
beforeAll(() => server.listen());
afterAll(() => server.close());
const dataLeaf = {
type: 'schema',
name: 'users',
leaf: {
type: 'table',
name: 'users',
},
};
const dataTarget = {
dataSource: {
driver: 'postgres' as const,
database: 'default',
},
dataLeaf,
};
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => null);
});
afterEach(() => {
jest.spyOn(console, 'error').mockRestore();
});
describe("useUpdatePermissions hooks' postgres test", () => {
test('update permissions submits correctly', async () => {
const roleName = 'user';
const { result, waitFor } = renderHook(
() =>
useUpdatePermissions({
dataTarget,
roleName,
queryType: 'insert',
accessType: 'partialAccess',
}),
{ wrapper }
);
await waitFor(() => result.current.updatePermissions.isLoading === false);
const formData = {
checkType: 'custom',
filterType: 'none',
check: '{"id":{"_eq":1}}',
filter: '',
rowCount: '0',
columns: {
id: false,
email: true,
name: false,
type: true,
username: false,
},
presets: [],
backendOnly: false,
aggregationEnabled: false,
clonePermissions: [],
allRowChecks: [{ queryType: 'select', value: '{"id":{"_eq":1}}' }],
};
await result.current.updatePermissions.submit(formData);
expect(result.current.updatePermissions.data).toEqual([
{ message: 'success' },
]);
});
test('delete permissions submits correctly', async () => {
const roleName = 'user';
const { result, waitFor } = renderHook(
() =>
useUpdatePermissions({
dataTarget,
roleName,
queryType: 'insert',
accessType: 'partialAccess',
}),
{ wrapper }
);
await waitFor(() => result.current.updatePermissions.isLoading === false);
await result.current.deletePermissions.submit(['insert']);
expect(result.current.deletePermissions.data).toEqual([
{ message: 'success' },
]);
});
});

View File

@ -1,2 +1,2 @@
export * from './useDefaultValues';
export * from './useFormData';
export * from './useFormData/useFormData';

View File

@ -1,80 +0,0 @@
import React from 'react';
import ReactJson from 'react-json-view';
import { Story, Meta } from '@storybook/react';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { useDefaultValues, UseDefaultValuesArgs } from './useDefaultValues';
import { handlers } from '../../mocks/handlers.mock';
const dataLeaf = {
type: 'schema',
name: 'users',
leaf: {
type: 'table',
name: 'users',
},
};
const UseDefaultValuesComponent = ({
dataTarget,
roleName,
queryType,
}: UseDefaultValuesArgs) => {
const results = useDefaultValues({
dataTarget,
roleName,
queryType,
});
return <ReactJson src={results} />;
};
export default {
title: 'Features/Permissions Form/hooks/useDefaultValues',
component: UseDefaultValuesComponent,
decorators: [ReactQueryDecorator()],
parameters: {
msw: handlers(),
chromatic: { disableSnapshot: true },
},
} as Meta;
const roleName = 'user';
export const Insert: Story<UseDefaultValuesArgs> = args => (
<UseDefaultValuesComponent {...args} />
);
Insert.args = {
dataTarget: {
dataSource: {
driver: 'postgres',
database: 'default',
},
dataLeaf,
},
roleName,
queryType: 'insert',
};
export const Select: Story<UseDefaultValuesArgs> = args => (
<UseDefaultValuesComponent {...args} />
);
Select.args = {
...Insert.args,
queryType: 'select',
};
export const Update: Story<UseDefaultValuesArgs> = args => (
<UseDefaultValuesComponent {...args} />
);
Update.args = {
...Insert.args,
queryType: 'update',
};
export const Delete: Story<UseDefaultValuesArgs> = args => (
<UseDefaultValuesComponent {...args} />
);
Delete.args = {
...Insert.args,
queryType: 'delete',
};

View File

@ -1,198 +0,0 @@
import { NormalizedTable } from '@/dataSources/types';
import { useMetadataTablePermissions } from '@/features/MetadataAPI';
import { useSingleTable } from '@/hooks';
import { currentDriver } from '@/dataSources';
import { getCurrentRole } from '../../utils';
import { QueryType } from '../../types';
import { NewDataTarget } from '../../../PermissionsTab/types/types';
namespace Format {
export const getCheckType = (check?: string | null) => {
if (!check) {
return 'none';
}
if (check === '{}') {
return 'no_checks';
}
return 'custom';
};
interface GetRowCountArgs {
currentQueryPermissions?: Record<string, any>;
}
export const getRowCount = ({ currentQueryPermissions }: GetRowCountArgs) => {
return `${currentQueryPermissions?.limit ?? 0}`;
};
interface GetCheckArgs {
currentQueryPermissions?: Record<string, any>;
type: 'check' | 'filter';
}
export const getCheck = ({ currentQueryPermissions, type }: GetCheckArgs) => {
const check = currentQueryPermissions?.[type];
return check ? JSON.stringify(check) : '';
};
interface FormatColumnsArgs {
table?: NormalizedTable | null;
currentQueryPermissions?: Record<string, any>;
}
export const formatColumns = ({
table,
currentQueryPermissions,
}: FormatColumnsArgs) => {
const allColumns = table?.columns?.map(({ column_name }) => column_name);
const selectedColumns = currentQueryPermissions?.columns;
if (!allColumns || !selectedColumns) {
return {};
}
return allColumns?.reduce((acc, column) => {
const selected = selectedColumns?.includes(column);
acc[column] = selected;
return acc;
}, {} as Record<typeof selectedColumns, boolean>);
};
interface GetPresetArgs {
currentQueryPermissions?: Record<string, any>;
}
export const getPresets = ({ currentQueryPermissions }: GetPresetArgs) => {
const set = Object.entries(currentQueryPermissions?.set || {}) as Array<
[string, string]
>;
return set.map(([columnName, value]) => {
return {
columnName,
presetType: value.startsWith('x-hasura')
? 'from session variable'
: 'static',
value,
};
});
};
export const getAllRowChecks = (
currentQuery: QueryType,
permissions?: Record<string, any>
) => {
const allChecks = Object.entries(permissions || {}) as [QueryType, any];
return allChecks
.filter(([queryType]) => queryType !== currentQuery)
.map(([queryType, permission]) => {
if (['insert', 'update'].includes(queryType)) {
return { queryType, value: JSON.stringify(permission.check || {}) };
}
return {
queryType,
value: JSON.stringify(permission.filter || {}),
};
});
};
}
export interface UseDefaultValuesArgs {
dataTarget: NewDataTarget;
roleName: string;
queryType: QueryType;
}
const useLoadPermissions = ({
dataTarget,
roleName,
queryType,
}: UseDefaultValuesArgs) => {
const table = {
name: dataTarget.dataLeaf.leaf?.name || '',
schema: dataTarget.dataLeaf.name,
};
const {
data: tableData,
isLoading: tableLoading,
isError: tableError,
} = useSingleTable({
source: dataTarget.dataSource.database,
driver: currentDriver,
table,
});
const {
data: permissions,
isLoading: permissionsLoading,
isError: permissionsError,
} = useMetadataTablePermissions(table, dataTarget.dataSource.database);
const currentRolePermissions = getCurrentRole({ permissions, roleName });
const currentQueryPermissions =
currentRolePermissions?.permissions?.[queryType];
const allRowChecks = Format.getAllRowChecks(
queryType,
currentRolePermissions?.permissions
);
const isLoading = tableLoading || permissionsLoading;
const isError = tableError || permissionsError;
return {
data: {
currentQueryPermissions,
allRowChecks,
table: tableData,
},
isLoading,
isError,
};
};
export const useDefaultValues = (args: UseDefaultValuesArgs) => {
const {
data: { currentQueryPermissions, table, allRowChecks },
isLoading,
isError,
} = useLoadPermissions(args);
const check = Format.getCheck({ currentQueryPermissions, type: 'check' });
const filter = Format.getCheck({
currentQueryPermissions,
type: 'filter',
});
const checkType = Format.getCheckType(check);
const filterType = Format.getCheckType(filter);
const rowCount = Format.getRowCount({ currentQueryPermissions });
const columns = Format.formatColumns({ table, currentQueryPermissions });
const presets = Format.getPresets({ currentQueryPermissions });
return {
data: {
checkType,
filterType,
check,
filter,
rowCount,
columns,
presets,
backendOnly: currentQueryPermissions?.backend_only || false,
aggregationEnabled: currentQueryPermissions?.allow_aggregations || false,
clonePermissions: [],
allRowChecks,
},
isLoading,
isError,
};
};

View File

@ -0,0 +1 @@
export * from './useDefaultValues';

View File

@ -0,0 +1,48 @@
import { createDefaultValues } from '../..';
import { schema } from '../../../../components/RowPermissionsBuilder/mocks';
export const input: Parameters<typeof createDefaultValues>[0] = {
queryType: 'select' as const,
roleName: 'user',
tableColumns: [
{
name: 'ArtistId',
dataType: 'number',
nullable: false,
isPrimaryKey: true,
graphQLProperties: {
name: 'ArtistId',
scalarType: 'decimal',
},
},
{
name: 'Name',
dataType: 'string',
nullable: true,
isPrimaryKey: false,
graphQLProperties: {
name: 'Name',
scalarType: 'String',
},
},
],
selectedTable: {
table: ['Artist'],
select_permissions: [
{
role: 'user',
permission: {
columns: ['Name'],
filter: {
ArtistId: {
_gt: 5,
},
},
limit: 3,
allow_aggregations: true,
},
},
],
},
schema,
};

View File

@ -0,0 +1,36 @@
import { createDefaultValues } from './useDefaultValues';
import { input } from './mock';
const mockResult: ReturnType<typeof createDefaultValues> = {
aggregationEnabled: true,
allRowChecks: [],
backendOnly: false,
check: {},
checkType: 'none',
clonePermissions: [],
columns: {
ArtistId: false,
Name: true,
},
filter: {
ArtistId: {
_gt: 5,
},
},
filterType: 'custom',
operators: {
filter: {
columnOperator: '_gt',
name: 'ArtistId',
type: 'column',
typeName: 'ArtistId',
},
},
presets: [],
rowCount: '3',
};
test('use default values returns values correctly', () => {
const result = createDefaultValues(input);
expect(result).toEqual(mockResult);
});

View File

@ -0,0 +1,141 @@
import { buildClientSchema, GraphQLSchema } from 'graphql';
import { useQuery, UseQueryResult } from 'react-query';
import isEqual from 'lodash.isequal';
import {
DataSource,
exportMetadata,
runIntrospectionQuery,
TableColumn,
} from '@/features/DataSource';
import { useHttpClient } from '@/features/Network';
import { Metadata, MetadataTable } from '@/features/MetadataAPI';
import { PermissionsSchema } from '../../../utils';
import type { QueryType } from '../../../types';
import {
createPermissionsObject,
getRowPermissionsForAllOtherQueriesMatchingSelectedRole,
} from './utils';
interface GetMetadataTableArgs {
dataSourceName: string;
table: unknown;
metadata: Metadata;
}
const getMetadataTable = ({
dataSourceName,
table,
metadata,
}: GetMetadataTableArgs) => {
const trackedTables = metadata.metadata?.sources?.find(
source => source.name === dataSourceName
)?.tables;
// find selected table
const currentTable = trackedTables?.find(trackedTable =>
isEqual(trackedTable.table, table)
);
return currentTable;
};
interface CreateDefaultValuesArgs {
queryType: QueryType;
roleName: string;
selectedTable?: MetadataTable;
tableColumns: TableColumn[];
schema: GraphQLSchema;
}
export const createDefaultValues = ({
queryType,
roleName,
selectedTable,
tableColumns,
schema,
}: CreateDefaultValuesArgs) => {
const allRowChecks = getRowPermissionsForAllOtherQueriesMatchingSelectedRole(
queryType,
roleName,
selectedTable
);
const baseDefaultValues: DefaultValues = {
checkType: 'none',
filterType: 'none',
check: {},
filter: {},
columns: {},
presets: [],
backendOnly: false,
aggregationEnabled: false,
clonePermissions: [],
allRowChecks,
};
if (selectedTable) {
const permissionsObject = createPermissionsObject({
queryType,
selectedTable,
roleName,
tableColumns,
schema,
});
return { ...baseDefaultValues, ...permissionsObject };
}
return baseDefaultValues;
};
type DefaultValues = PermissionsSchema & {
allRowChecks: { queryType: QueryType; value: string }[];
};
export interface Args {
dataSourceName: string;
table: unknown;
roleName: string;
queryType: QueryType;
}
export const useDefaultValues = ({
dataSourceName,
table,
roleName,
queryType,
}: Args): UseQueryResult<DefaultValues> => {
const httpClient = useHttpClient();
return useQuery<any, Error>({
queryKey: [dataSourceName, 'permissionDefaultValues', roleName, queryType],
queryFn: async () => {
const introspectionResult = await runIntrospectionQuery({ httpClient });
const schema = buildClientSchema(introspectionResult.data);
const metadata = await exportMetadata({ httpClient });
// get table columns for metadata table from db introspection
const tableColumns = await DataSource(httpClient).getTableColumns({
dataSourceName,
table,
});
const selectedTable = getMetadataTable({
dataSourceName,
table,
metadata,
});
return createDefaultValues({
queryType,
roleName,
selectedTable,
tableColumns,
schema,
});
},
refetchOnWindowFocus: false,
});
};

View File

@ -0,0 +1,294 @@
import isEqual from 'lodash.isequal';
import { TableColumn } from '@/features/DataSource';
import type {
DeletePermissionDefinition,
InsertPermissionDefinition,
MetadataTable,
Permission,
SelectPermissionDefinition,
UpdatePermissionDefinition,
} from '@/features/MetadataAPI';
import { isPermission, keyToPermission, permissionToKey } from '../utils';
import { createDefaultValues } from '../../../components/RowPermissionsBuilder';
import type { QueryType } from '../../../types';
export const getCheckType = (
check?: Record<string, unknown> | null
): 'none' | 'no_checks' | 'custom' => {
if (!check) {
return 'none';
}
if (isEqual(check, {})) {
return 'no_checks';
}
return 'custom';
};
interface GetRowCountArgs {
currentQueryPermissions?: Record<string, any>;
}
export const getRowCount = ({ currentQueryPermissions }: GetRowCountArgs) => {
return `${currentQueryPermissions?.limit ?? 0}`;
};
interface GetCheckArgs {
currentQueryPermissions?: Record<string, any>;
type: 'check' | 'filter';
}
export const getCheck = ({ currentQueryPermissions, type }: GetCheckArgs) => {
const check = currentQueryPermissions?.[type];
return check ? JSON.stringify(check) : '';
};
interface GetPresetArgs {
currentQueryPermissions?: Record<string, any>;
}
export const getPresets = ({ currentQueryPermissions }: GetPresetArgs) => {
const set = Object.entries(currentQueryPermissions?.set || {}) as Array<
[string, string]
>;
return set.map(([columnName, value]) => {
return {
columnName,
presetType: value.startsWith('x-hasura')
? 'from session variable'
: 'static',
value,
};
});
};
const getColumns = (
permissionColumns: string[],
tableColumns: TableColumn[]
) => {
return tableColumns.reduce<Record<string, boolean>>((acc, each) => {
const columnIncluded = permissionColumns?.includes(each.name);
acc[each.name] = !!columnIncluded;
return acc;
}, {});
};
export const getAllRowChecks = (
currentQuery: QueryType,
allChecks: Array<{ queryType: QueryType; value: any }> = []
) => {
return allChecks
.filter(({ queryType }) => queryType !== currentQuery)
.map(({ queryType, value }) => {
if (['insert', 'update'].includes(queryType)) {
return { queryType, value: JSON.stringify(value.check || {}) };
}
return {
queryType,
value: JSON.stringify(value.filter || {}),
};
});
};
export interface UseDefaultValuesArgs {
dataSourceName: string;
table: unknown;
roleName: string;
queryType: QueryType;
}
export const getRowPermissionsForAllOtherQueriesMatchingSelectedRole = (
selectedQuery: QueryType,
selectedRole: string,
table?: MetadataTable
) => {
const res = Object.entries(table || {}).reduce<
Array<{ queryType: QueryType; value: any }>
>((acc, [key, value]) => {
const props = { key, value };
// check object key of metadata is a permission
if (isPermission(props)) {
// add each role from each permission to the set
props.value.forEach(permission => {
if (permission.role === selectedRole) {
acc.push({
queryType: keyToPermission[props.key],
value: permission.permission,
});
}
});
}
return acc;
}, []);
return getAllRowChecks(selectedQuery, res);
};
export const createPermission = {
insert: (
permission: InsertPermissionDefinition,
tableColumns: TableColumn[]
) => {
const check = JSON.stringify(permission.check) || '';
const checkType = getCheckType(permission.check);
const presets = getPresets({
currentQueryPermissions: permission,
});
const columns = getColumns(permission?.columns || [], tableColumns);
const backendOnly: boolean = permission?.backend_only || false;
return { check, checkType, presets, columns, backendOnly };
},
select: (
permission: SelectPermissionDefinition,
tableColumns: TableColumn[],
schema: any
) => {
const { filter, operators } = createDefaultValues({
tableName: 'Artist',
existingPermission: permission.filter,
schema,
});
const filterType = getCheckType(permission?.filter);
const presets = getPresets({
currentQueryPermissions: permission,
});
const columns = getColumns(permission?.columns || [], tableColumns);
const rowCount = getRowCount({
currentQueryPermissions: permission,
});
const aggregationEnabled: boolean = permission?.allow_aggregations || false;
const selectPermissions = {
filter,
filterType,
presets,
columns,
rowCount,
aggregationEnabled,
operators,
};
if (rowCount) {
selectPermissions.rowCount = rowCount;
}
return selectPermissions;
},
update: (
permission: UpdatePermissionDefinition,
tableColumns: TableColumn[]
) => {
const check = JSON.stringify(permission?.check) || '';
const filter = JSON.stringify(permission?.filter) || '';
const checkType = getCheckType(permission?.check);
const filterType = getCheckType(permission?.filter);
const presets = getPresets({
currentQueryPermissions: permission,
});
const columns = getColumns(permission?.columns || [], tableColumns);
const rowCount = getRowCount({
currentQueryPermissions: permission,
});
return { check, checkType, filter, filterType, presets, columns, rowCount };
},
delete: (permission: DeletePermissionDefinition) => {
const filter = JSON.stringify(permission?.filter) || '';
const filterType = getCheckType(permission?.filter);
const presets = getPresets({
currentQueryPermissions: permission,
});
const rowCount = getRowCount({
currentQueryPermissions: permission,
});
return { filter, filterType, presets, rowCount };
},
};
interface GetCurrentPermissionArgs {
table?: MetadataTable;
roleName: string;
queryType: QueryType;
}
export const getCurrentPermission = ({
table,
roleName,
queryType,
}: GetCurrentPermissionArgs) => {
const key = permissionToKey[queryType];
const currentPermission = table?.[key] as Permission[];
const currentPermissionsForSelectedRole = currentPermission?.find(
permission => permission.role === roleName
);
if (currentPermissionsForSelectedRole) {
return {
queryType,
permission: currentPermissionsForSelectedRole?.permission,
};
}
return {
queryType,
permission: {},
};
};
interface ObjArgs {
queryType: QueryType;
selectedTable: MetadataTable;
tableColumns: TableColumn[];
roleName: string;
schema: any;
}
export const createPermissionsObject = ({
queryType,
selectedTable,
tableColumns,
roleName,
schema,
}: ObjArgs) => {
const selectedPermission = getCurrentPermission({
table: selectedTable,
queryType,
roleName,
});
switch (selectedPermission.queryType) {
case 'insert':
return createPermission.insert(
selectedPermission.permission,
tableColumns
);
case 'select':
return createPermission.select(
selectedPermission.permission as SelectPermissionDefinition,
tableColumns,
schema
);
case 'update':
return createPermission.update(
selectedPermission.permission,
tableColumns
);
case 'delete':
return createPermission.delete(selectedPermission.permission);
default:
throw new Error('Case not handled');
}
};

View File

@ -1,51 +0,0 @@
import React from 'react';
import ReactJson from 'react-json-view';
import { Story, Meta } from '@storybook/react';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { useFormData, UseFormDataArgs } from './useFormData';
import { handlers } from '../../mocks/handlers.mock';
const dataLeaf = {
type: 'schema',
name: 'users',
leaf: {
type: 'table',
name: 'users',
},
};
const UseFormDataComponent = ({
dataTarget,
roleName,
queryType,
}: UseFormDataArgs) => {
const results = useFormData({ dataTarget, roleName, queryType });
return <ReactJson src={results} />;
};
export default {
title: 'Features/Permissions Form/hooks/useFormData',
component: UseFormDataComponent,
decorators: [ReactQueryDecorator()],
parameters: {
msw: handlers(),
},
} as Meta;
const roleName = 'user';
export const Primary: Story<UseFormDataArgs> = args => (
<UseFormDataComponent {...args} />
);
Primary.args = {
dataTarget: {
dataSource: {
driver: 'postgres',
database: 'default',
},
dataLeaf,
},
roleName,
queryType: 'insert',
};

View File

@ -1,124 +0,0 @@
import React from 'react';
import {
useTrackableFunctions,
useDataSourceTables,
useSingleTable,
} from '@/hooks';
import { currentDriver, dataSource } from '@/dataSources';
import { useRoles } from '../../../MetadataAPI';
import { QueryType } from '../../types';
import { NewDataTarget } from '../../../PermissionsTab/types/types';
export interface UseFormDataArgs {
dataTarget: NewDataTarget;
roleName: string;
queryType: QueryType;
}
const useLoadSchemas = ({ dataTarget }: UseFormDataArgs) => {
const schemaName = dataTarget.dataLeaf.name;
const tableName = dataTarget.dataLeaf.leaf?.name || '';
const source = dataTarget.dataSource.database;
const {
data: tables,
isLoading: tablesLoading,
isError: tablesError,
} = useDataSourceTables(
{ schemas: [schemaName], source, driver: currentDriver },
{
enabled: !!schemaName,
retry: 0,
}
);
const {
data: table,
isLoading: tableLoading,
isError: tableError,
} = useSingleTable({
source,
driver: currentDriver,
table: { name: tableName, schema: schemaName },
});
const { data: roles } = useRoles();
const { data: allFunctions } = useTrackableFunctions(
{ schemas: [schemaName], source, driver: currentDriver },
{
enabled: !!schemaName,
retry: 0,
}
);
const isError = tablesError || tableError;
const isLoading = tablesLoading || tableLoading;
if (isError) {
return {
data: {},
isLoading: false,
isError: true,
};
}
if (isLoading) {
return {
data: {},
isLoading: true,
isError: false,
};
}
return {
data: { tables, table, roles, allFunctions },
isLoading: false,
isError: false,
};
};
export const useFormData = (props: UseFormDataArgs) => {
const {
data: { table, tables: allTables, roles, allFunctions },
isLoading,
isError,
} = useLoadSchemas(props);
const tables = React.useMemo(
() => allTables?.filter(({ is_table_tracked }) => is_table_tracked),
[allTables]
);
const tableNames = React.useMemo(
() => tables?.map(({ table_name }) => table_name),
[tables]
);
const columns = React.useMemo(
() => table?.columns.map(({ column_name }) => column_name),
[table]
);
let supportedQueries: string[] = [];
if (table) {
supportedQueries = dataSource.getTableSupportedQueries(table);
}
return {
data: {
table,
tables,
tableNames,
columns,
allFunctions,
roles,
supportedQueries,
},
isLoading,
isError,
};
};

View File

@ -0,0 +1 @@
export * from './useFormData';

View File

@ -0,0 +1,83 @@
import { TableColumn } from '@/features/DataSource';
import { Metadata } from '@/features/MetadataAPI';
interface Input {
dataSourceName: string;
table: unknown;
metadata: Metadata;
tableColumns: TableColumn[];
}
const metadata: Metadata = {
resource_version: 1,
metadata: {
version: 3,
sources: [
{
name: 'sqlite',
kind: 'sqlite',
tables: [
{
table: ['Album'],
},
{
table: ['Artist'],
select_permissions: [
{
role: 'user',
permission: {
columns: ['ArtistId', 'Name'],
filter: {},
allow_aggregations: true,
},
},
],
},
],
configuration: {
template: null,
timeout: null,
value: {
db: '/chinook.db',
include_sqlite_meta_tables: false,
},
},
},
],
backend_configs: {
dataconnector: {
sqlite: {
uri: 'http://host.docker.internal:8100',
},
},
},
},
};
export const input: Input = {
dataSourceName: 'sqlite',
table: ['Artist'],
metadata,
tableColumns: [
{
name: 'ArtistId',
dataType: 'number',
nullable: false,
isPrimaryKey: true,
graphQLProperties: {
name: 'ArtistId',
scalarType: 'decimal',
},
},
{
name: 'Name',
dataType: 'string',
nullable: true,
isPrimaryKey: false,
graphQLProperties: {
name: 'Name',
scalarType: 'String',
},
},
],
};

View File

@ -0,0 +1,14 @@
import { createFormData } from './useFormData';
import { input } from './mock';
const mockResult: ReturnType<typeof createFormData> = {
columns: ['ArtistId', 'Name'],
roles: ['user'],
supportedQueries: ['insert', 'select', 'update', 'delete'],
tableNames: [['Album'], ['Artist']],
};
test('returns correctly formatted formData', () => {
const result = createFormData(input);
expect(result).toEqual(mockResult);
});

View File

@ -0,0 +1,139 @@
import { useQuery } from 'react-query';
import { DataSource, exportMetadata, TableColumn } from '@/features/DataSource';
import { useHttpClient } from '@/features/Network';
import { Metadata, MetadataTable } from '@/features/MetadataAPI';
import { isPermission } from '../utils';
type Operation = 'insert' | 'select' | 'update' | 'delete';
const supportedQueries: Operation[] = ['insert', 'select', 'update', 'delete'];
export const getAllowedFilterKeys = (
query: 'insert' | 'select' | 'update' | 'delete'
): ('check' | 'filter')[] => {
switch (query) {
case 'insert':
return ['check'];
case 'update':
return ['filter', 'check'];
default:
return ['filter'];
}
};
type GetMetadataTableArgs = {
dataSourceName: string;
table: unknown;
metadata: Metadata;
};
const getMetadataTable = (args: GetMetadataTableArgs) => {
const { dataSourceName, table, metadata } = args;
const trackedTables = metadata.metadata?.sources?.find(
source => source.name === dataSourceName
)?.tables;
const selectedTable = trackedTables?.find(
trackedTable => JSON.stringify(trackedTable.table) === JSON.stringify(table)
);
// find selected table
return {
table: selectedTable,
tables: trackedTables,
// for gdc tables will be an array of strings so this needs updating
tableNames: trackedTables?.map(each => each.table),
};
};
const getRoles = (metadataTables?: MetadataTable[]) => {
// go through all tracked tables
const res = metadataTables?.reduce<Set<string>>((acc, each) => {
// go through all permissions
Object.entries(each).forEach(([key, value]) => {
const props = { key, value };
// check object key of metadata is a permission
if (isPermission(props)) {
// add each role from each permission to the set
props.value.forEach(permission => {
acc.add(permission.role);
});
}
});
return acc;
}, new Set());
return Array.from(res || []);
};
interface CreateFormDataArgs {
dataSourceName: string;
table: unknown;
metadata: Metadata;
tableColumns: TableColumn[];
}
export const createFormData = (props: CreateFormDataArgs) => {
const { dataSourceName, table, metadata, tableColumns } = props;
// find the specific metadata table
const metadataTable = getMetadataTable({
dataSourceName,
table,
metadata,
});
const roles = getRoles(metadataTable.tables);
return {
roles,
supportedQueries,
tableNames: metadataTable.tableNames,
columns: tableColumns?.map(({ name }) => name),
};
};
export type UseFormDataArgs = {
dataSourceName: string;
table: unknown;
roleName: string;
queryType: 'select' | 'insert' | 'update' | 'delete';
};
type ReturnValue = {
roles: string[];
supportedQueries: Operation[];
tableNames: unknown;
columns: string[];
};
/**
*
* creates data for displaying in the form e.g. column names, roles etc.
*/
export const useFormData = ({ dataSourceName, table }: UseFormDataArgs) => {
const httpClient = useHttpClient();
return useQuery<ReturnValue, Error>({
queryKey: [dataSourceName, 'permissionFormData'],
queryFn: async () => {
const metadata = await exportMetadata({ httpClient });
// get table columns for metadata table from db introspection
const tableColumns = await DataSource(httpClient).getTableColumns({
dataSourceName,
table,
});
return createFormData({
dataSourceName,
table,
metadata,
tableColumns,
});
},
refetchOnWindowFocus: false,
});
};

View File

@ -0,0 +1,29 @@
export const permissionToKey = {
insert: 'insert_permissions',
select: 'select_permissions',
update: 'update_permissions',
delete: 'delete_permissions',
} as const;
export const metadataPermissionKeys = [
'insert_permissions',
'select_permissions',
'update_permissions',
'delete_permissions',
] as const;
export const keyToPermission = {
insert_permissions: 'insert',
select_permissions: 'select',
update_permissions: 'update',
delete_permissions: 'delete',
} as const;
export const isPermission = (props: {
key: string;
value: any;
}): props is {
key: typeof metadataPermissionKeys[number];
// value: Permission[];
value: any[];
} => props.key in keyToPermission;

View File

@ -1,61 +1,84 @@
import {
useMetadataTablePermissions,
useMetadataMigration,
useMetadataVersion,
} from '@/features/MetadataAPI';
import { NewDataTarget } from '../../../PermissionsTab/types/types';
import { useQueryClient } from 'react-query';
import { AxiosInstance } from 'axios';
import { exportMetadata } from '@/features/DataSource';
import { useHttpClient } from '@/features/Network';
import { Permission, useMetadataMigration } from '@/features/MetadataAPI';
import { api } from '../../api';
import { QueryType } from '../../types';
const useDataTarget = (dataTarget: NewDataTarget) => {
const table = {
name: dataTarget.dataLeaf.leaf?.name || '',
schema: dataTarget.dataLeaf.name,
};
interface GetMetadataTableArgs {
dataSourceName: string;
table: unknown;
httpClient: AxiosInstance;
}
const {
data: resourceVersion,
isLoading: metadataLoading,
isError: metadataError,
} = useMetadataVersion();
const getMetadataTable = async ({
dataSourceName,
table,
httpClient,
}: GetMetadataTableArgs) => {
// get all metadata
const { metadata, resource_version } = await exportMetadata({ httpClient });
if (!resourceVersion && !metadataLoading) {
throw new Error('No resource version');
}
// find current source
const currentMetadataSource = metadata?.sources?.find(
source => source.name === dataSourceName
);
const {
data: permissions,
isLoading: permissionsLoading,
isError: permissionsError,
} = useMetadataTablePermissions(table, dataTarget.dataSource.database);
if (!currentMetadataSource)
throw Error(`useRolePermissions.metadataSource not found`);
const isLoading = permissionsLoading || metadataLoading;
const isError = permissionsError || metadataError;
const trackedTables = currentMetadataSource.tables;
// find selected table
return {
dataTarget,
resourceVersion,
permissions,
isLoading,
isError,
metadataTable: trackedTables.find(
trackedTable =>
JSON.stringify(trackedTable.table) === JSON.stringify(table)
),
resourceVersion: resource_version,
};
};
const metadataPermissionKeys = [
'insert_permissions',
'select_permissions',
'update_permissions',
'delete_permissions',
] as const;
export const keyToPermission = {
insert_permissions: 'insert',
select_permissions: 'select',
update_permissions: 'update',
delete_permissions: 'delete',
} as const;
interface RoleList {
roleName: string;
queries: QueryType[];
}
export const useBulkDeletePermissions = (dataTarget: NewDataTarget) => {
const {
resourceVersion,
permissions,
isLoading: dataTargetLoading,
isError: dataTargetError,
} = useDataTarget(dataTarget);
const isPermission = (props: {
key: string;
value: any;
}): props is {
key: typeof metadataPermissionKeys[number];
value: Permission[];
} => props.key in keyToPermission;
interface Args {
currentSource: string;
dataSourceName: string;
table: unknown;
}
export const useBulkDeletePermissions = ({
currentSource,
dataSourceName,
table,
}: Args) => {
const {
mutateAsync,
isLoading: mutationLoading,
@ -63,26 +86,52 @@ export const useBulkDeletePermissions = (dataTarget: NewDataTarget) => {
...rest
} = useMetadataMigration();
const submit = async (roles: string[]) => {
if (!resourceVersion) {
console.error('No resource version');
return;
}
const httpClient = useHttpClient();
const queryClient = useQueryClient();
const roleList = permissions?.reduce<RoleList[]>((acc, each) => {
if (roles.includes(each.role_name)) {
acc.push({
roleName: each.role_name,
queries: Object.keys(each.permissions) as QueryType[],
const submit = async (roles: string[]) => {
const { metadataTable, resourceVersion } = await getMetadataTable({
dataSourceName,
table,
httpClient,
});
const permissions = Object.entries(metadataTable || {}).reduce<
Record<string, QueryType[]>
>((acc, [key, value]) => {
const props = { key, value };
// check if metadata key is related to permissions
if (isPermission(props)) {
props.value.forEach(permissionObject => {
// only add role if it is one of the selected roles for deletion
if (roles.includes(permissionObject.role)) {
if (!acc[permissionObject.role]) {
acc[permissionObject.role] = [];
}
acc[permissionObject.role].push(keyToPermission[props.key]);
}
});
}
return acc;
}, []);
}, {});
const roleList = Object.entries(permissions).reduce<RoleList[]>(
(acc, [key, value]) => {
acc.push({
roleName: key,
queries: value,
});
return acc;
},
[]
);
const body = api.createBulkDeleteBody({
dataTarget,
roleName: '',
source: currentSource,
dataSourceName,
table,
resourceVersion,
roleList,
});
@ -90,10 +139,12 @@ export const useBulkDeletePermissions = (dataTarget: NewDataTarget) => {
await mutateAsync({
query: body,
});
queryClient.invalidateQueries([dataSourceName, 'permissionsTable']);
};
const isLoading = dataTargetLoading || mutationLoading;
const isError = dataTargetError || mutationError;
const isLoading = mutationLoading;
const isError = mutationError;
return {
submit,

View File

@ -1,60 +1,43 @@
import {
useMetadataVersion,
useMetadataMigration,
} from '@/features/MetadataAPI';
import { useAppSelector } from '@/store';
import { currentDriver } from '@/dataSources';
import { useQueryClient } from 'react-query';
import { useMetadataMigration } from '@/features/MetadataAPI';
import { exportMetadata } from '@/features/DataSource';
import { useHttpClient } from '@/features/Network';
import { QueryType } from '../../types';
import { api } from '../../api';
import { NewDataTarget } from '../../../PermissionsTab/types/types';
export interface UseDeletePermissionArgs {
dataTarget: NewDataTarget;
currentSource: string;
dataSourceName: string;
table: unknown;
roleName: string;
}
const useDataTarget = () => {
const dataSource: string = useAppSelector(
state => state.tables.currentDataSource || 'default'
);
const driver = currentDriver;
const { data: resourceVersion, isLoading, isError } = useMetadataVersion();
if (!resourceVersion && !isLoading) {
throw new Error('No resource version');
}
return {
driver,
dataSource,
resourceVersion,
isLoading,
isError,
};
};
export const useDeletePermission = ({
dataTarget,
currentSource,
dataSourceName,
table,
roleName,
}: UseDeletePermissionArgs) => {
const {
resourceVersion,
isLoading: dataTargetLoading,
isError: dataTargetError,
} = useDataTarget();
const mutate = useMetadataMigration();
const httpClient = useHttpClient();
const queryClient = useQueryClient();
const submit = async (queries: QueryType[]) => {
const { resource_version: resourceVersion } = await exportMetadata({
httpClient,
});
if (!resourceVersion) {
console.error('No resource version');
return;
}
const body = api.createDeleteBody({
dataTarget,
currentSource,
dataSourceName,
table,
roleName,
resourceVersion,
queries,
@ -63,10 +46,12 @@ export const useDeletePermission = ({
await mutate.mutateAsync({
query: body,
});
queryClient.invalidateQueries([dataSourceName, 'permissionsTable']);
};
const isLoading = mutate.isLoading || dataTargetLoading;
const isError = mutate.isError || dataTargetError;
const isLoading = mutate.isLoading;
const isError = mutate.isError;
return {
submit,

View File

@ -1,35 +1,110 @@
import { useQueryClient } from 'react-query';
import { AxiosInstance } from 'axios';
import {
useMetadataMigration,
useMetadataPermissions,
useMetadataVersion,
} from '@/features/MetadataAPI';
import { exportMetadata } from '@/features/DataSource';
import { useHttpClient } from '@/features/Network';
import { AccessType, FormOutput, QueryType } from '../../types';
import { api } from '../../api';
import { NewDataTarget } from '../../../PermissionsTab/types/types';
export interface UseSubmitFormArgs {
dataTarget: NewDataTarget;
currentSource: string;
dataSourceName: string;
table: unknown;
roleName: string;
queryType: QueryType;
accessType: AccessType;
}
const metadataPermissionKeys = [
'insert_permissions',
'select_permissions',
'update_permissions',
'delete_permissions',
] as const;
export const keyToPermission = {
insert_permissions: 'insert',
select_permissions: 'select',
update_permissions: 'update',
delete_permissions: 'delete',
} as const;
const isPermission = (props: {
key: string;
value: any;
}): props is {
key: typeof metadataPermissionKeys[number];
// value: Permission[];
value: any[];
} => props.key in keyToPermission;
interface ExistingPermissions {
role: string;
queryType: QueryType;
table: unknown;
}
interface GetAllPermissionsArgs {
dataSourceName: string;
httpClient: AxiosInstance;
}
const getAllPermissions = async ({
dataSourceName,
httpClient,
}: GetAllPermissionsArgs) => {
const { metadata } = await exportMetadata({ httpClient });
// find current source
const currentMetadataSource = metadata?.sources?.find(
source => source.name === dataSourceName
);
return currentMetadataSource?.tables.reduce<ExistingPermissions[]>(
(acc, metadataTable) => {
Object.entries(metadataTable).forEach(([key, value]) => {
const props = { key, value };
if (isPermission(props)) {
props.value.forEach(permission => {
acc.push({
role: permission.role,
queryType: keyToPermission[props.key],
table: metadataTable.table,
});
});
}
});
return acc;
},
[]
);
};
export const useSubmitForm = (args: UseSubmitFormArgs) => {
const { dataTarget, roleName, queryType, accessType } = args;
const {
currentSource,
dataSourceName,
table,
roleName,
queryType,
accessType,
} = args;
const {
data: resourceVersion,
isLoading: resourceVersionLoading,
isError: resourceVersionError,
} = useMetadataVersion();
const mutate = useMetadataMigration();
const queryClient = useQueryClient();
const httpClient = useHttpClient();
const {
data: existingPermissions,
isLoading: existingPermissionsLoading,
isError: existingPermissionsError,
} = useMetadataPermissions(args.dataTarget.dataSource.database);
const mutate = useMetadataMigration();
const submit = async (formData: FormOutput) => {
if (!resourceVersion) {
@ -37,8 +112,15 @@ export const useSubmitForm = (args: UseSubmitFormArgs) => {
return;
}
const existingPermissions = await getAllPermissions({
dataSourceName,
httpClient,
});
const body = api.createInsertBody({
dataTarget,
currentSource,
dataSourceName,
table,
roleName,
queryType,
accessType,
@ -50,12 +132,18 @@ export const useSubmitForm = (args: UseSubmitFormArgs) => {
await mutate.mutateAsync({
query: body,
});
await queryClient.invalidateQueries([
dataSourceName,
'permissionDefaultValues',
roleName,
queryType,
]);
await queryClient.invalidateQueries([dataSourceName, 'permissionsTable']);
};
const isLoading =
mutate.isLoading || resourceVersionLoading || existingPermissionsLoading;
const isError =
mutate.isError || resourceVersionError || existingPermissionsError;
const isLoading = mutate.isLoading || resourceVersionLoading;
const isError = mutate.isError || resourceVersionError;
return {
submit,

View File

@ -1,101 +0,0 @@
import React from 'react';
import ReactJson from 'react-json-view';
import { Story, Meta } from '@storybook/react';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { Button } from '@/new-components/Button';
import { handlers } from '../../mocks/handlers.mock';
import {
useUpdatePermissions,
UseUpdatePermissionsArgs,
} from './useUpdatePermissions';
import { useDefaultValues } from '..';
const dataLeaf = {
type: 'schema',
name: 'users',
leaf: {
type: 'table',
name: 'users',
},
};
const UseUpdatePermissionsComponent = ({
dataTarget,
roleName,
queryType,
accessType,
}: UseUpdatePermissionsArgs) => {
const { data } = useDefaultValues({
dataTarget,
roleName,
queryType,
});
const { updatePermissions, deletePermissions } = useUpdatePermissions({
dataTarget,
roleName,
queryType,
accessType,
});
return (
<div>
<p>
<strong>Data to submit</strong>
</p>
{!!data && <ReactJson src={data} />}
<div className="flex gap-2 mt-2">
<Button
mode="primary"
isLoading={updatePermissions.isLoading}
onClick={() => updatePermissions.submit(data!)}
>
Submit
</Button>
<Button
mode="destructive"
isLoading={deletePermissions.isLoading}
onClick={() => deletePermissions.submit(['insert'])}
>
Delete
</Button>
</div>
<div className="mt-2">
{!!updatePermissions.data && <ReactJson src={updatePermissions.data} />}
{!!deletePermissions.data && <ReactJson src={deletePermissions.data} />}
</div>
</div>
);
};
export default {
title: 'Features/Permissions Form/hooks/useUpdatePermissions',
component: UseUpdatePermissionsComponent,
decorators: [ReactQueryDecorator()],
parameters: {
msw: handlers,
chromatic: { disableSnapshot: true },
},
} as Meta;
const roleName = 'user';
export const Primary: Story<UseUpdatePermissionsArgs> = args => (
<UseUpdatePermissionsComponent {...args} />
);
Primary.args = {
dataTarget: {
dataSource: {
driver: 'postgres',
database: 'default',
},
dataLeaf,
},
roleName,
queryType: 'insert',
accessType: 'fullAccess',
};

View File

@ -2,32 +2,42 @@ import { useSubmitForm } from './useSubmitForm';
import { useDeletePermission } from './useDeletePermission';
import { AccessType, QueryType } from '../../types';
import { NewDataTarget } from '../../../PermissionsTab/types/types';
export interface UseUpdatePermissionsArgs {
dataTarget: NewDataTarget;
currentSource: string;
dataSourceName: string;
table: unknown;
roleName: string;
queryType: QueryType;
accessType: AccessType;
}
export const useUpdatePermissions = ({
dataTarget,
currentSource,
dataSourceName,
table,
roleName,
queryType,
accessType,
}: UseUpdatePermissionsArgs) => {
const updatePermissions = useSubmitForm({
dataTarget,
currentSource,
dataSourceName,
table,
roleName,
queryType,
accessType,
});
const deletePermissions = useDeletePermission({
dataTarget,
currentSource,
dataSourceName,
table,
roleName,
});
return { updatePermissions, deletePermissions };
return {
updatePermissions,
deletePermissions,
};
};

View File

@ -1,4 +1,4 @@
import { MetadataResponse } from '../../MetadataAPI';
import { Metadata } from '@/features/MetadataAPI';
export const schemaList = {
result_type: 'TuplesOk',
@ -15,7 +15,26 @@ export const query = {
],
};
export const metadata: MetadataResponse = {
export const metadataTable = {
name: ['Artist'],
columns: [
{
name: 'ArtistId',
type: 'number',
nullable: false,
},
{
name: 'Name',
type: 'string',
nullable: true,
},
],
primary_key: ['ArtistId'],
description:
'CREATE TABLE [Artist]\n(\n [ArtistId] INTEGER NOT NULL,\n [Name] NVARCHAR(120),\n CONSTRAINT [PK_Artist] PRIMARY KEY ([ArtistId])\n)',
};
export const metadata: Metadata = {
resource_version: 30,
metadata: {
inherited_roles: [],
@ -27,7 +46,7 @@ export const metadata: MetadataResponse = {
tables: [
{ table: { schema: 'public', name: 'a_table' } },
{
table: { schema: 'public', name: 'users' },
table: { schema: 'public', name: 'user' },
insert_permissions: [
{
role: 'user',
@ -53,6 +72,7 @@ export const metadata: MetadataResponse = {
},
],
functions: [{ function: { schema: 'public', name: 'search_user2' } }],
configuration: {
connection_info: {
use_prepared_statements: true,
@ -67,6 +87,36 @@ export const metadata: MetadataResponse = {
},
},
},
{
name: 'sqlite',
kind: 'sqlite',
tables: [
{
table: ['Album'],
},
{
table: ['Artist'],
select_permissions: [
{
role: 'user',
permission: {
columns: ['ArtistId', 'Name'],
filter: {},
allow_aggregations: true,
},
},
],
},
],
configuration: {
template: null,
timeout: null,
value: {
db: '/chinook.db',
include_sqlite_meta_tables: false,
},
},
},
],
},
};

View File

@ -1,12 +1,15 @@
import { rest } from 'msw';
import { results } from '../components/RowPermissionsBuilder/mocks';
import { metadata, metadataTable } from './dataStubs';
const baseUrl = 'http://localhost:8080';
export const handlers = (url = baseUrl) => [
rest.post(`${url}/v2/query`, (req, res, ctx) => {
const body = req.body as Record<string, any>;
rest.post(`${url}/v2/query`, async (req, res, ctx) => {
const body = (await req.json()) as Record<string, any>;
const isUseSchemaList = body?.args?.sql?.includes('SELECT schema_name');
const isColumnsQuery = body?.args?.sql?.includes('column_name');
if (isUseSchemaList) {
return res(
@ -17,6 +20,20 @@ export const handlers = (url = baseUrl) => [
);
}
if (isColumnsQuery) {
return res(
ctx.json({
result_type: 'TuplesOk',
result: [
['column_name', 'data_type'],
['id', 'integer'],
['name', 'text'],
['email', 'text'],
],
})
);
}
return res(
ctx.json({
result_type: 'TuplesOk',
@ -29,70 +46,21 @@ export const handlers = (url = baseUrl) => [
})
);
}),
rest.post(`${url}/v1/metadata`, async (req, res, ctx) => {
const body = (await req.json()) as Record<string, any>;
rest.post(`${url}/v1/metadata`, (req, res, ctx) => {
const body = req.body as Record<string, any>;
const isGetTableInfo = body.type === 'get_table_info';
if (isGetTableInfo) {
return res(ctx.json(metadataTable));
}
if (body.type === 'export_metadata') {
return res(
ctx.json({
resource_version: 30,
metadata: {
version: 3,
sources: [
{
name: 'default',
kind: 'postgres',
tables: [
{ table: { schema: 'public', name: 'a_table' } },
{
table: { schema: 'public', name: 'users' },
insert_permissions: [
{
role: 'user',
permission: {
check: { id: { _eq: 1 } },
columns: ['email', 'type'],
backend_only: false,
},
},
],
select_permissions: [
{
role: 'user',
permission: {
columns: ['email', 'id', 'type'],
filter: { id: { _eq: 1 } },
limit: 5,
allow_aggregations: true,
},
},
],
},
],
functions: [
{ function: { schema: 'public', name: 'search_user2' } },
],
configuration: {
connection_info: {
use_prepared_statements: true,
database_url: { from_env: 'HASURA_GRAPHQL_DATABASE_URL' },
isolation_level: 'read-committed',
pool_settings: {
connection_lifetime: 600,
retries: 1,
idle_timeout: 180,
max_connections: 50,
},
},
},
},
],
},
})
);
return res(ctx.json(metadata));
}
return res(ctx.json([{ message: 'success' }]));
}),
rest.post(`${url}/v1/graphql`, (req, res, ctx) => {
return res(ctx.json(results));
}),
];

View File

@ -3,9 +3,9 @@ import * as z from 'zod';
export const schema = z.object({
checkType: z.string(),
filterType: z.string(),
check: z.string(),
filter: z.string(),
rowCount: z.string(),
check: z.any(),
filter: z.any(),
rowCount: z.string().optional(),
columns: z.record(z.optional(z.boolean())),
presets: z.optional(
z.array(
@ -28,3 +28,5 @@ export const schema = z.object({
)
),
});
export type PermissionsSchema = z.infer<typeof schema>;

View File

@ -1,7 +1,5 @@
import React from 'react';
import { Story, Meta } from '@storybook/react';
import { within, waitFor, userEvent } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
@ -18,105 +16,32 @@ export const Primary: Story<PermissionsTabProps> = args => (
<PermissionsTab {...args} />
);
Primary.args = {
tableType: 'table',
dataLeaf: {
type: 'schema',
name: 'public',
leaf: { type: 'table', name: 'users' },
currentSource: 'postgres',
dataSourceName: 'default',
table: {
name: 'user',
schema: 'public',
},
};
Primary.parameters = {
export const GDC: Story<PermissionsTabProps> = args => (
<PermissionsTab {...args} />
);
GDC.args = {
currentSource: 'sqlite',
dataSourceName: 'sqlite',
table: ['Artist'],
};
GDC.parameters = {
msw: handlers(),
};
export const BasicInteraction: Story<PermissionsTabProps> = args => (
export const GDCNoMocks: Story<PermissionsTabProps> = args => (
<PermissionsTab {...args} />
);
BasicInteraction.args = Primary.args;
BasicInteraction.parameters = Primary.parameters;
BasicInteraction.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
// open user insert section
const userInsertButton = await canvas.findByLabelText('user-insert');
userEvent.click(userInsertButton);
// change input of row section
const input: HTMLInputElement = await waitFor(() =>
canvas.findByDisplayValue('1')
);
userEvent.clear(input);
userEvent.type(input, '2');
// change selections of selected columns
const nameCheckbox: HTMLInputElement = await canvas.findByLabelText('name');
userEvent.click(nameCheckbox);
// toggle all options on
const toggleAllBtn = await canvas.getByRole('button', { name: 'Toggle All' });
userEvent.click(toggleAllBtn);
// open backend only section and select
const backendOnly = await canvas.findByText('Backend only');
userEvent.click(backendOnly);
const backendOnlyCheckbox: HTMLInputElement = await canvas.findByLabelText(
'Allow from backends only'
);
userEvent.click(backendOnlyCheckbox);
// check interactions were successful
expect(input.value).toEqual('2');
expect(backendOnlyCheckbox.checked).toBe(true);
expect(nameCheckbox.checked).toBe(true);
};
export const MultipleInteractions: Story<PermissionsTabProps> = args => (
<PermissionsTab {...args} />
);
MultipleInteractions.args = Primary.args;
MultipleInteractions.parameters = Primary.parameters;
MultipleInteractions.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
// click on new role item
const newRoleInsertButton = await canvas.findByLabelText('-insert');
userEvent.click(newRoleInsertButton);
await new Promise(r => setTimeout(r, 200));
// check role input becomes focused
const newRoleInput: HTMLInputElement = await canvas.findByPlaceholderText(
'Create new role...'
);
expect(document.activeElement).toEqual(newRoleInput);
userEvent.type(newRoleInput, 'new_role');
userEvent.click(newRoleInsertButton);
await new Promise(r => setTimeout(r, 200));
// expect form to now be open
const closeBtn = canvas.getByRole('button', { name: 'Close' });
expect(closeBtn).toBeTruthy();
const noChecksLabel = await canvas.findByLabelText('Without any checks');
userEvent.click(noChecksLabel);
// click on bulk update
const userCheckBox = await canvas.findByLabelText('user');
userEvent.click(userCheckBox);
// expect new role name to be cleared
expect(newRoleInput.value).toEqual('');
// expect bulk update to be open
const removePermissionsBtn = canvas.getByRole('button', {
name: 'Remove All Permissions',
});
expect(removePermissionsBtn).toBeTruthy();
// expect main for to be closed
expect(canvas.queryByText('Close')).toBeFalsy();
GDCNoMocks.args = {
currentSource: 'sqlite',
dataSourceName: 'sqlite',
table: ['Artist'],
};

View File

@ -1,35 +1,48 @@
import React from 'react';
import { useTableMachine, PermissionsTable } from '../PermissionsTable';
import { PermissionsForm, BulkDelete } from '../PermissionsForm';
import { BulkDelete } from '../PermissionsForm';
import { PermissionsForm } from '../PermissionsForm/PermissionsForm';
import { AccessType } from '../PermissionsForm/types';
import { DataLeaf } from './types/types';
export interface PermissionsTabProps {
dataLeaf: DataLeaf;
tableType: 'table' | 'view';
currentSource: string;
dataSourceName: string;
table: unknown;
}
export const PermissionsTab: React.FC<PermissionsTabProps> = ({ dataLeaf }) => {
export const PermissionsTab: React.FC<PermissionsTabProps> = ({
currentSource,
dataSourceName,
table,
}) => {
const machine = useTableMachine();
const [state, send] = machine;
return (
<div className="p-4">
<div className="grid gap-4">
<PermissionsTable dataLeaf={dataLeaf} machine={machine} />
<PermissionsTable
dataSourceName={dataSourceName}
table={table}
machine={machine}
/>
{state.value === 'bulkOpen' &&
!!state.context.bulkSelections.length && (
<BulkDelete
roles={state.context.bulkSelections}
dataLeaf={dataLeaf}
currentSource={currentSource}
dataSourceName={dataSourceName}
table={table}
handleClose={() => send('CLOSE')}
/>
)}
{state.value === 'formOpen' && (
<PermissionsForm
dataLeaf={dataLeaf}
currentSource={currentSource}
dataSourceName={dataSourceName}
table={table}
roleName={state.context.selectedForm.roleName || ''}
accessType={state.context.selectedForm.accessType as AccessType}
queryType={state.context.selectedForm.queryType || 'insert'}

View File

@ -10,6 +10,9 @@ export default {
title: 'Features/Permissions Table/Table',
component: PermissionsTable,
decorators: [ReactQueryDecorator()],
parameters: {
msw: handlers(),
},
} as Meta;
export const Default: Story<PermissionsTableProps> = args => {
@ -19,16 +22,20 @@ export const Default: Story<PermissionsTableProps> = args => {
};
Default.args = {
dataLeaf: {
type: 'schema',
name: 'public',
leaf: {
type: 'table',
name: 'users',
},
dataSourceName: 'default',
table: {
schema: 'public',
name: 'user',
},
};
Default.parameters = {
msw: handlers(),
export const GDCTable: Story<PermissionsTableProps> = args => {
const machine = useTableMachine();
return <PermissionsTable {...args} machine={machine} />;
};
GDCTable.args = {
dataSourceName: 'sqlite',
table: ['Artist'],
};

View File

@ -1,22 +1,17 @@
import React from 'react';
import { FaInfo } from 'react-icons/fa';
import { QUERY_TYPES, Operations } from '@/dataSources';
import { arrayDiff } from '../../components/Common/utils/jsUtils';
import { useRolePermissions } from './hooks/usePermissions';
import { PermissionsLegend } from './components/PermissionsLegend';
import { EditableCell, InputCell } from './components/Cells';
import { TableMachine } from './hooks';
import { DataLeaf } from '../PermissionsTab/types/types';
import { useDataSource } from '../PermissionsTab/types/useDataSource';
type QueryType = 'insert' | 'select' | 'update' | 'delete';
const queryType = ['insert', 'select', 'update', 'delete'] as const;
interface ViewPermissionsNoteProps {
viewsSupported: boolean;
supportedQueryTypes: Operations[];
supportedQueryTypes: QueryType[];
}
export const ViewPermissionsNote: React.FC<ViewPermissionsNoteProps> = ({
@ -27,7 +22,9 @@ export const ViewPermissionsNote: React.FC<ViewPermissionsNoteProps> = ({
return null;
}
const unsupportedQueryTypes = arrayDiff(QUERY_TYPES, supportedQueryTypes);
const unsupportedQueryTypes = queryType.filter(
query => !supportedQueryTypes.includes(query)
);
if (unsupportedQueryTypes.length) {
return (
@ -42,7 +39,8 @@ export const ViewPermissionsNote: React.FC<ViewPermissionsNoteProps> = ({
};
export interface PermissionsTableProps {
dataLeaf: DataLeaf;
dataSourceName: string;
table: unknown;
machine: ReturnType<TableMachine>;
}
@ -54,16 +52,22 @@ export interface Selection {
}
export const PermissionsTable: React.FC<PermissionsTableProps> = ({
dataLeaf,
dataSourceName,
table,
machine,
}) => {
const { data } = useRolePermissions({
dataSourceName,
table,
});
const [state, send] = machine;
const dataSource = useDataSource();
const { supportedQueries, rolePermissions } = useRolePermissions({
dataSource,
dataLeaf,
});
if (!data) {
return null;
}
const { supportedQueries, rolePermissions } = data;
return (
<>
@ -100,7 +104,9 @@ export const PermissionsTable: React.FC<PermissionsTableProps> = ({
/>
{permissionTypes.map(({ permissionType, access }) => {
const isEditable = roleName !== 'admin';
// only select is possible on GDC as mutations are not available yet
const isEditable =
roleName !== 'admin' && permissionType === 'select';
if (isNewRole) {
return (

View File

@ -16,7 +16,7 @@ export default {
component: InputCell,
decorators: [
(StoryComponent: React.FC) => (
<Form schema={z.any()} onSubmit={() => {}} className="p-4">
<Form schema={z.any()} onSubmit={() => {}}>
{() => <StoryComponent />}
</Form>
),

View File

@ -1,5 +1,4 @@
import React from 'react';
import { Button } from '@/new-components/Button';
import { TableMachine } from '../hooks';
import { PermissionsIcon } from './PermissionsIcons';
@ -82,7 +81,7 @@ export const EditableCell: React.FC<EditableCellProps> = ({
}) => {
if (!isEditable) {
return (
<td className="p-md whitespace-nowrap text-center">
<td className="p-md whitespace-nowrap text-center cursor-not-allowed">
<PermissionsIcon type={access} selected={isCurrentEdit} />
</td>
);
@ -90,15 +89,15 @@ export const EditableCell: React.FC<EditableCellProps> = ({
return (
<td>
<Button
<button
type="submit"
className={`cursor-pointer h-20 border-none w-full whitespace-nowrap text-center ${
isCurrentEdit ? 'bg-secondary' : 'hover:bg-indigo-50'
isCurrentEdit ? 'bg-amber-300' : 'hover:bg-indigo-50'
}`}
{...rest}
>
<PermissionsIcon type={access} selected={isCurrentEdit} />
</Button>
</button>
</td>
);
};

View File

@ -1,23 +1,42 @@
import { dataSource, Operations } from '@/dataSources';
import { ComputedField, TableColumn } from '@/dataSources/types';
import {
useMetadataTableComputedFields,
useMetadataTablePermissions,
} from '@/features/MetadataAPI';
import { useAllFunctions, useSchemaList, useSingleTable } from '@/hooks';
import { AxiosInstance } from 'axios';
import isEqual from 'lodash.isequal';
import { DataSource, exportMetadata } from '@/features/DataSource';
import type { TableColumn } from '@/features/DataSource';
import { NewDataTarget } from '../../PermissionsTab/types/types';
import { useQuery } from 'react-query';
import { useHttpClient } from '@/features/Network';
export type RolePermissions = {
[role: string]: {
[query in 'insert' | 'select' | 'update' | 'delete']: {
columns: (string | '*')[];
computed_fields: (string | '*')[];
} & {
[key in 'check' | 'filter']: Record<string, any>;
};
interface RolePermission {
roleName: string;
isNewRole: boolean;
permissionTypes: {
permissionType: QueryType;
access: Access;
}[];
bulkSelect: {
isSelectable: boolean;
isDisabled: boolean;
};
};
}
const metadataPermissionKeys = [
'insert_permissions',
'select_permissions',
'update_permissions',
'delete_permissions',
] as const;
export const keyToPermission = {
insert_permissions: 'insert',
select_permissions: 'select',
update_permissions: 'update',
delete_permissions: 'delete',
} as const;
type QueryType = 'insert' | 'select' | 'update' | 'delete';
type Access = 'fullAccess' | 'partialAccess' | 'noAccess';
const supportedQueries: QueryType[] = ['insert', 'select', 'update', 'delete'];
export const getAllowedFilterKeys = (
query: 'insert' | 'select' | 'update' | 'delete'
@ -32,154 +51,233 @@ export const getAllowedFilterKeys = (
}
};
export const getRolePermission = (
role: 'admin' | string,
rolePermissions: RolePermissions,
query: 'insert' | 'select' | 'update' | 'delete',
schemaColumns: TableColumn[],
computedFields: { scalar: ComputedField[] }
): 'fullAccess' | 'partialAccess' | 'noAccess' => {
if (role === 'admin') {
return 'fullAccess';
}
type GetAccessTypeArgs = {
QueryType: QueryType;
permission: any;
// permission: Permission['permission'];
tableColumns: TableColumn[];
};
if (!rolePermissions[role]) {
return 'noAccess';
}
const getAccessType = ({
QueryType,
permission,
tableColumns,
}: GetAccessTypeArgs): Access => {
const filterKeys = getAllowedFilterKeys(QueryType);
const checkColumns = QueryType !== 'delete';
// const checkComputedFields = QueryType === 'select';
const permissions = rolePermissions[role][query];
if (!permissions) {
return 'noAccess';
}
const filterKeys = getAllowedFilterKeys(query);
const checkColumns = query !== 'delete';
const checkComputedFields = query === 'select';
if (!filterKeys.every(key => JSON.stringify(permissions[key]) === '{}')) {
// if any permissions are set for any of the filter keys then
// the user only has partial access to that QueryType
const hasRowPermissionsSet = !filterKeys.every(
key => JSON.stringify(permission[key]) === '{}'
);
if (hasRowPermissionsSet) {
return 'partialAccess';
}
if (
checkColumns &&
(!permissions.columns ||
(!permissions.columns.includes('*') &&
permissions.columns.length !== schemaColumns.length))
) {
return 'partialAccess';
}
// unless all columns are selected
// the user only has partial access to that QueryType
const noColumnsChecked = !permission.columns;
const allColumnsChecked =
permission.columns?.includes('*') ||
permission.columns?.length === tableColumns.length;
if (
checkComputedFields &&
computedFields.scalar.length &&
(!permissions.computed_fields ||
(permissions.computed_fields.includes('*') &&
permissions.computed_fields.length !== computedFields.scalar.length))
) {
const hasLimitedAccessToColumns =
checkColumns && (noColumnsChecked || !allColumnsChecked);
if (hasLimitedAccessToColumns) {
return 'partialAccess';
}
return 'fullAccess';
};
interface RolePermission {
roleName: string;
isNewRole: boolean;
permissionTypes: {
permissionType: Operations;
access: 'fullAccess' | 'partialAccess' | 'noAccess';
}[];
bulkSelect: {
isSelectable: boolean;
isDisabled: boolean;
};
}
type GetMetadataTableArgs = {
dataSourceName: string;
table: unknown;
httpClient: AxiosInstance;
};
export const useRolePermissions = (dataTarget: NewDataTarget) => {
const table = {
name: dataTarget.dataLeaf.leaf?.name || '',
schema: dataTarget.dataLeaf.name,
};
const getMetadataTable = async ({
httpClient,
dataSourceName,
table,
}: GetMetadataTableArgs) => {
// get all metadata
const { metadata } = await exportMetadata({ httpClient });
const { data: schemas } = useSchemaList({
source: dataTarget.dataSource.database,
driver: dataTarget.dataSource.driver,
});
const { data: currentTableSchema } = useSingleTable({
table,
source: dataTarget.dataSource.database,
driver: dataTarget.dataSource.driver,
});
const { data: permissions } = useMetadataTablePermissions(
table,
dataTarget.dataSource.database
);
const { data: computedFields } = useMetadataTableComputedFields(
table,
dataTarget.dataSource.database
);
const { data: allFunctions } = useAllFunctions(
{
schemas: schemas!,
driver: dataTarget.dataSource.driver,
source: dataTarget.dataSource.database,
},
{ enabled: !!schemas }
// find current source
const currentMetadataSource = metadata?.sources?.find(
source => source.name === dataSourceName
);
if (!permissions || !allFunctions) {
return { supportedQueries: [], rolePermissions: [] };
}
if (!currentMetadataSource)
throw Error(`useRolePermissions.metadataSource not found`);
const currentRolePermissions = permissions.reduce((acc, p) => {
// only add the role if it exists on the current table
if (p.table_name === table.name) {
acc[p.role_name] = p.permissions;
}
const trackedTables = currentMetadataSource.tables;
// find selected table
return trackedTables.find(trackedTable => isEqual(trackedTable.table, table));
};
type SupportedQueriesObject = Partial<Record<QueryType, Access>>;
const createSupportedQueryObject = (access: Access) =>
supportedQueries.reduce<SupportedQueriesObject>((acc, supportedQuery) => {
acc[supportedQuery] = access;
return acc;
}, {} as Record<string, any>);
}, {});
let supportedQueries: Operations[] = [];
if (currentTableSchema) {
supportedQueries = dataSource.getTableSupportedQueries(currentTableSchema);
}
const isPermission = (props: {
key: string;
value: any;
}): props is {
key: typeof metadataPermissionKeys[number];
value: any[];
// value: Permission[];
} => props.key in keyToPermission;
const groupedComputedFields = dataSource.getGroupedTableComputedFields(
computedFields ?? [],
allFunctions
type CreateRoleTableDataArgs = {
metadataTable: any;
tableColumns?: TableColumn[];
};
type RoleToPermissionsMap = Record<string, Partial<Record<QueryType, Access>>>;
const createRoleTableData = async ({
metadataTable,
tableColumns,
}: CreateRoleTableDataArgs): Promise<RolePermission[]> => {
if (!metadataTable) return [];
// create object with key of role
// and value describing permissions attached to that role
const roleToPermissionsMap = Object.entries(
metadataTable
).reduce<RoleToPermissionsMap>((acc, [key, value]) => {
const props = { key, value };
// check if metadata key is related to permissions
if (isPermission(props)) {
const QueryType = keyToPermission[props.key];
props.value.forEach(permissionObject => {
if (!acc[permissionObject.role]) {
// add all supported queries to the object
acc[permissionObject.role] = createSupportedQueryObject('noAccess');
}
// if permission exists on metadata for a particular QueryType
// find out the access type for that QueryType
// and replace the access type on the object
acc[permissionObject.role][QueryType] = getAccessType({
QueryType,
permission: permissionObject.permission,
tableColumns: tableColumns || [],
});
});
}
return acc;
}, {});
// create the array that has the relevant information for each row of the table
const permissions = Object.entries(roleToPermissionsMap).map(
([roleName, permission]) => {
const permissionEntries = Object.entries(permission) as [
QueryType,
Access
][];
const permissionTypes = permissionEntries.map(([key, value]) => ({
permissionType: key,
access: value,
}));
const isNewRole = roleName === 'newRole';
return {
roleName: isNewRole ? '' : roleName,
isNewRole,
permissionTypes,
bulkSelect: {
isSelectable: roleName !== 'admin' && !isNewRole,
isDisabled: false,
},
};
}
);
const currentRoles = Object.keys(currentRolePermissions).map(roleName => ({
roleName,
isNewRole: false,
}));
const roleList = [
{ roleName: 'admin', isNewRole: false },
...currentRoles,
{ roleName: '', isNewRole: true },
];
const rolePermissions: RolePermission[] = roleList.map(
({ roleName, isNewRole }) => ({
roleName,
isNewRole,
permissionTypes: supportedQueries.map(queryType => ({
permissionType: queryType,
access: getRolePermission(
roleName,
currentRolePermissions,
queryType,
currentTableSchema?.columns || [],
groupedComputedFields
),
// add admin row
// and row for adding a new role
const finalPermissions = [
{
roleName: 'admin',
isNewRole: false,
permissionTypes: Object.entries(
createSupportedQueryObject('fullAccess')
).map(([key, value]) => ({
permissionType: key as QueryType,
access: value,
})),
bulkSelect: {
isSelectable: roleName !== 'admin' && !isNewRole,
isDisabled: !Object.keys(currentRolePermissions).includes(roleName),
isSelectable: false,
isDisabled: false,
},
})
);
},
...permissions,
{
roleName: 'newRole',
isNewRole: true,
permissionTypes: Object.entries(
createSupportedQueryObject('noAccess')
).map(([key, value]) => ({
permissionType: key as QueryType,
access: value,
})),
bulkSelect: {
isSelectable: true,
isDisabled: false,
},
},
];
return { supportedQueries, rolePermissions };
return finalPermissions;
};
type UseRolePermissionsArgs = {
dataSourceName: string;
table: unknown;
};
export const useRolePermissions = ({
dataSourceName,
table,
}: UseRolePermissionsArgs) => {
const httpClient = useHttpClient();
return useQuery<
{ supportedQueries: QueryType[]; rolePermissions: RolePermission[] },
Error
>({
queryKey: [dataSourceName, 'permissionsTable'],
queryFn: async () => {
// find the specific metadata table
const metadataTable = await getMetadataTable({
httpClient,
dataSourceName,
table,
});
// get table columns for metadata table from db introspection
const tableColumns = await DataSource(httpClient).getTableColumns({
dataSourceName,
table,
});
// // extract the permissions data in the format required for the table
const rolePermissions = await createRoleTableData({
metadataTable,
tableColumns,
});
return { rolePermissions, supportedQueries };
},
refetchOnWindowFocus: false,
});
};

View File

@ -2,12 +2,6 @@
export interface Typegen0 {
'@@xstate/typegen': true;
eventsCausingActions: {
formCloseEffect: 'CLOSE' | '';
formOpenEffect: 'FORM_OPEN';
bulkUpdateEffect: 'BULK_OPEN';
updateRoleNameEffect: 'NEW_ROLE_NAME' | '';
};
internalEvents: {
'': { type: '' };
'xstate.init': { type: 'xstate.init' };
@ -19,12 +13,18 @@ export interface Typegen0 {
guards: never;
delays: never;
};
eventsCausingActions: {
bulkUpdateEffect: 'BULK_OPEN';
formCloseEffect: '' | 'CLOSE' | 'xstate.init';
formOpenEffect: 'FORM_OPEN';
updateRoleNameEffect: '' | 'NEW_ROLE_NAME';
};
eventsCausingServices: {};
eventsCausingGuards: {
newRoleEmpty: '';
bulkIsEmpty: '';
newRoleEmpty: '';
};
eventsCausingDelays: {};
matchesStates: 'closed' | 'formOpen' | 'bulkOpen' | 'updateRoleName';
matchesStates: 'bulkOpen' | 'closed' | 'formOpen' | 'updateRoleName';
tags: never;
}