mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-14 17:02:49 +03:00
Logical Model Permissions
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9276 Co-authored-by: Erik Magnusson <32518962+ejkkan@users.noreply.github.com> GitOrigin-RevId: ee1dddf813fac3f3a29c5444644e589a8e67d3cb
This commit is contained in:
parent
6102270f11
commit
921b148dc8
@ -1,4 +1,4 @@
|
||||
import { IndexRedirect, IndexRoute, Route } from 'react-router';
|
||||
import { IndexRedirect, IndexRoute, Redirect, Route } from 'react-router';
|
||||
|
||||
import { SERVER_CONSOLE_MODE } from '../../../constants';
|
||||
import globals from '../../../Globals';
|
||||
@ -39,6 +39,7 @@ import { ModifyTableContainer } from './TableModify/ModifyTableContainer';
|
||||
import { LandingPageRoute as NativeQueries } from '../../../features/Data/LogicalModels/LandingPage/LandingPage';
|
||||
|
||||
import { TrackStoredProcedureRoute } from '../../../features/Data/LogicalModels/StoredProcedures/StoredProcedureWidget.route';
|
||||
import { LogicalModelPermissionsRoute } from '../../../features/Data/LogicalModels/LogicalModelPermissions/LogicalModelPermissionsPage';
|
||||
import { ManageFunction } from '../../../features/Data/ManageFunction/ManageFunction';
|
||||
import {
|
||||
UpdateNativeQueryRoute,
|
||||
@ -90,7 +91,16 @@ const makeDataRouter = (
|
||||
path="native-query/:source/:name"
|
||||
component={UpdateNativeQueryRoute}
|
||||
/>
|
||||
<Route path="logical-models" component={NativeQueries} />
|
||||
<Route path="logical-models">
|
||||
<IndexRoute component={NativeQueries} />
|
||||
<Redirect from=":source" to="/data/native-queries/logical-models" />
|
||||
<Route path=":source">
|
||||
<Route
|
||||
path=":name/permissions"
|
||||
component={LogicalModelPermissionsRoute}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="stored-procedures" component={NativeQueries} />
|
||||
<Route
|
||||
path="stored-procedures/track"
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { InjectedRouter, Link, withRouter } from 'react-router';
|
||||
import { useDestructiveAlert } from '../../../../new-components/Alert';
|
||||
import { Button } from '../../../../new-components/Button';
|
||||
@ -9,14 +9,14 @@ import { useMetadata } from '../../../hasura-metadata-api';
|
||||
import { useTrackLogicalModel } from '../../hooks/useTrackLogicalModel';
|
||||
import { useTrackNativeQuery } from '../../hooks/useTrackNativeQuery';
|
||||
import { LogicalModelWidget } from '../LogicalModelWidget/LogicalModelWidget';
|
||||
import { RouteWrapper } from '../components/RouteWrapper';
|
||||
|
||||
import { LogicalModelWithSource, NativeQueryWithSource } from '../types';
|
||||
import { extractModelsAndQueriesFromMetadata } from '../utils';
|
||||
import { ListLogicalModels } from './components/ListLogicalModels';
|
||||
import { ListNativeQueries } from './components/ListNativeQueries';
|
||||
import { ListStoredProcedures } from './components/ListStoredProcedures';
|
||||
import { NATIVE_QUERY_ROUTES } from '../constants';
|
||||
import { extractModelsAndQueriesFromMetadata } from '../../../hasura-metadata-api/selectors';
|
||||
import { RouteWrapper } from '../components/RouteWrapper';
|
||||
|
||||
export const LandingPage = ({ pathname }: { pathname: string }) => {
|
||||
const push = usePushRoute();
|
||||
@ -149,8 +149,10 @@ export const LandingPage = ({ pathname }: { pathname: string }) => {
|
||||
<ListLogicalModels
|
||||
logicalModels={logicalModels}
|
||||
isLoading={isLoading}
|
||||
onEditClick={() => {
|
||||
push?.('/data/native-queries/logical-models/permissions');
|
||||
onEditClick={model => {
|
||||
push?.(
|
||||
`/data/native-queries/logical-models/${model.source.name}/${model.name}/permissions`
|
||||
);
|
||||
}}
|
||||
onRemoveClick={handleRemoveLogicalModel}
|
||||
/>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { StoryObj, Meta } from '@storybook/react';
|
||||
import { buildMetadata } from '../../mocks/metadata';
|
||||
import { extractModelsAndQueriesFromMetadata } from '../../utils';
|
||||
import { ListLogicalModels } from './ListLogicalModels';
|
||||
import { extractModelsAndQueriesFromMetadata } from '../../../../hasura-metadata-api/selectors';
|
||||
|
||||
export default {
|
||||
component: ListLogicalModels,
|
||||
|
@ -39,8 +39,9 @@ export const ListLogicalModels = ({
|
||||
header: 'Actions',
|
||||
cell: ({ cell, row }) => (
|
||||
<div className="flex flex-row gap-2">
|
||||
{/* Re add once we implement Edit functionality */}
|
||||
{/* <Button onClick={() => onEditClick(row.original)}>Edit</Button> */}
|
||||
<Button onClick={() => onEditClick(row.original)}>
|
||||
Edit Permissions
|
||||
</Button>
|
||||
<Button
|
||||
mode="destructive"
|
||||
onClick={() => onRemoveClick(row.original)}
|
||||
|
@ -2,7 +2,7 @@ import { StoryObj, Meta } from '@storybook/react';
|
||||
|
||||
import { ListNativeQueries } from './ListNativeQueries';
|
||||
import { buildMetadata } from '../../mocks/metadata';
|
||||
import { extractModelsAndQueriesFromMetadata } from '../../utils';
|
||||
import { extractModelsAndQueriesFromMetadata } from '../../../../hasura-metadata-api/selectors';
|
||||
|
||||
export default {
|
||||
component: ListNativeQueries,
|
||||
|
@ -0,0 +1,173 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { LogicalModelPermissions } from './LogicalModelPermissions';
|
||||
import { comparators } from '../../../Permissions/PermissionsForm/components/RowPermissionsBuilder/components/__tests__/fixtures/comparators';
|
||||
import { userEvent, within } from '@storybook/testing-library';
|
||||
import { expect } from '@storybook/jest';
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
export default {
|
||||
title: 'Features/Permissions/Form/Logical Model Permissions',
|
||||
component: LogicalModelPermissions,
|
||||
} as Meta;
|
||||
|
||||
type Story = StoryObj<typeof LogicalModelPermissions>;
|
||||
|
||||
const noPermissionArgs: Partial<
|
||||
ComponentProps<typeof LogicalModelPermissions>
|
||||
> = {
|
||||
logicalModels: [
|
||||
{
|
||||
source: {
|
||||
name: 'default',
|
||||
kind: 'postgres',
|
||||
configuration: {},
|
||||
tables: [],
|
||||
},
|
||||
fields: [
|
||||
{ name: 'one', nullable: false, type: 'text' },
|
||||
{ name: 'two', nullable: false, type: 'text' },
|
||||
],
|
||||
name: 'hello_world',
|
||||
},
|
||||
],
|
||||
logicalModelName: 'hello_world',
|
||||
comparators,
|
||||
};
|
||||
|
||||
const existingPermissionArgs: Partial<
|
||||
ComponentProps<typeof LogicalModelPermissions>
|
||||
> = {
|
||||
logicalModels: [
|
||||
{
|
||||
source: {
|
||||
name: 'default',
|
||||
kind: 'postgres',
|
||||
configuration: {},
|
||||
tables: [],
|
||||
},
|
||||
fields: [
|
||||
{ name: 'one', nullable: false, type: 'text' },
|
||||
{ name: 'two', nullable: false, type: 'text' },
|
||||
],
|
||||
name: 'hello_world',
|
||||
select_permissions: [
|
||||
{
|
||||
permission: {
|
||||
columns: ['one'],
|
||||
filter: { one: { _eq: 'eqone' } },
|
||||
},
|
||||
role: 'user',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
logicalModelName: 'hello_world',
|
||||
comparators,
|
||||
};
|
||||
|
||||
export const NewPermission: Story = {
|
||||
args: noPermissionArgs,
|
||||
play: async ({ canvasElement }) => {
|
||||
// Should allow writing new role name and then clicking select should open form
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.type(canvas.getByTestId('new-role-input'), 'reader');
|
||||
await userEvent.click(canvas.getByTestId('reader-select-permissions-cell'));
|
||||
await expect(canvas.getByTestId('permissions-form')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const ExistingPermission: Story = {
|
||||
args: existingPermissionArgs,
|
||||
play: async ({ canvasElement, args: { onSave } }) => {
|
||||
// Clicking select on existing permission should open form with correct role (user) and action (select)
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByTestId('user-select-permissions-cell'));
|
||||
await expect(canvas.getByTestId('permissions-form')).toBeInTheDocument();
|
||||
await expect((await canvas.findByTestId('role-pill')).textContent).toBe(
|
||||
'user'
|
||||
);
|
||||
await expect((await canvas.findByTestId('action-pill')).textContent).toBe(
|
||||
'select'
|
||||
);
|
||||
|
||||
// It should send correct existing permission (`{"one":{"_eq":"eqone"}}`) when clicking save
|
||||
await userEvent.click(canvas.getByTestId('save-permissions-button'));
|
||||
await expect(onSave).toHaveBeenCalledWith({
|
||||
roleName: 'user',
|
||||
filter: {
|
||||
one: {
|
||||
_eq: 'eqone',
|
||||
},
|
||||
},
|
||||
columns: ['one'],
|
||||
action: 'select',
|
||||
isNew: false,
|
||||
source: 'default',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const UpdatingPermissions: Story = {
|
||||
args: existingPermissionArgs,
|
||||
play: async ({ canvasElement, args: { onSave } }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByTestId('user-select-permissions-cell'));
|
||||
|
||||
// Clicking "Without any checks" should set permission to `{}`
|
||||
await userEvent.click(canvas.getByTestId('without-filter'));
|
||||
// Clicking `two` should select that column
|
||||
await userEvent.click(canvas.getByTestId('column-two-checkbox'));
|
||||
|
||||
await userEvent.click(canvas.getByTestId('save-permissions-button'));
|
||||
await expect(onSave).toHaveBeenCalledWith({
|
||||
roleName: 'user',
|
||||
filter: {},
|
||||
columns: ['one', 'two'],
|
||||
action: 'select',
|
||||
isNew: false,
|
||||
source: 'default',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const ToggleAllColumns: Story = {
|
||||
args: existingPermissionArgs,
|
||||
play: async ({ canvasElement, args: { onSave } }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByTestId('user-select-permissions-cell'));
|
||||
|
||||
await userEvent.click(canvas.getByTestId('toggle-all-columns'));
|
||||
|
||||
await userEvent.click(canvas.getByTestId('save-permissions-button'));
|
||||
await expect(onSave).toHaveBeenCalledWith({
|
||||
roleName: 'user',
|
||||
filter: { one: { _eq: 'eqone' } },
|
||||
columns: ['one', 'two'],
|
||||
action: 'select',
|
||||
isNew: false,
|
||||
source: 'default',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const UntoggleAllColumns: Story = {
|
||||
args: existingPermissionArgs,
|
||||
play: async ({ canvasElement, args: { onSave } }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByTestId('user-select-permissions-cell'));
|
||||
|
||||
await userEvent.click(canvas.getByTestId('toggle-all-columns'));
|
||||
await userEvent.click(canvas.getByTestId('toggle-all-columns'));
|
||||
|
||||
await userEvent.click(canvas.getByTestId('save-permissions-button'));
|
||||
await expect(onSave).toHaveBeenCalledWith({
|
||||
roleName: 'user',
|
||||
filter: { one: { _eq: 'eqone' } },
|
||||
columns: [],
|
||||
action: 'select',
|
||||
isNew: false,
|
||||
source: 'default',
|
||||
});
|
||||
},
|
||||
};
|
@ -0,0 +1,125 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
Comparators,
|
||||
RowPermissionsInput,
|
||||
} from '../../../Permissions/PermissionsForm/components/RowPermissionsBuilder/components';
|
||||
import {
|
||||
LogicalModelWithPermissions,
|
||||
OnDelete,
|
||||
OnSave,
|
||||
Permission,
|
||||
RowSelectPermissionsType,
|
||||
} from './components/types';
|
||||
import {
|
||||
PermissionsTable,
|
||||
PermissionsTableProps,
|
||||
} from './components/PermissionsTable';
|
||||
import { PermissionsForm } from './components/PermissionsForm';
|
||||
import { usePermissionsFormContext } from './hooks/usePermissionForm';
|
||||
import { LogicalModelPermissionsFormProvider } from './components/LogicalModelPermissionsFormProvider';
|
||||
|
||||
export type LogicalModelPermissionsProps = {
|
||||
logicalModelName: string;
|
||||
logicalModels: LogicalModelWithPermissions[];
|
||||
onSave: OnSave;
|
||||
onDelete: OnDelete;
|
||||
comparators: Comparators;
|
||||
isCreating?: boolean;
|
||||
isRemoving?: boolean;
|
||||
};
|
||||
|
||||
export type LogicalModelPermissionsState = {
|
||||
permissions: Permission[];
|
||||
activePermission: number | null;
|
||||
rowSelectPermissions: RowSelectPermissionsType;
|
||||
columns: string[];
|
||||
};
|
||||
|
||||
export const LogicalModelPermissions = ({
|
||||
logicalModelName,
|
||||
logicalModels,
|
||||
onSave,
|
||||
onDelete,
|
||||
comparators,
|
||||
isCreating,
|
||||
isRemoving,
|
||||
}: LogicalModelPermissionsProps) => {
|
||||
const logicalModel = logicalModels.find(
|
||||
model => model.name === logicalModelName
|
||||
);
|
||||
|
||||
return (
|
||||
<LogicalModelPermissionsFormProvider logicalModel={logicalModel}>
|
||||
<PureLogicalModelPermissions
|
||||
onSave={onSave}
|
||||
onDelete={onDelete}
|
||||
isCreating={isCreating}
|
||||
isRemoving={isRemoving}
|
||||
comparators={comparators}
|
||||
logicalModelName={logicalModelName}
|
||||
logicalModels={logicalModels}
|
||||
/>
|
||||
</LogicalModelPermissionsFormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const PureLogicalModelPermissions = ({
|
||||
onSave: onSaveProp,
|
||||
onDelete: onDeleteProp,
|
||||
logicalModelName,
|
||||
comparators,
|
||||
logicalModels,
|
||||
isCreating,
|
||||
isRemoving,
|
||||
}: LogicalModelPermissionsProps) => {
|
||||
const { activePermission, permissions, setPermission } =
|
||||
usePermissionsFormContext();
|
||||
const permission =
|
||||
activePermission === null ? null : permissions[activePermission];
|
||||
const onSave = useCallback(async () => {
|
||||
if (!permission) {
|
||||
return;
|
||||
}
|
||||
await onSaveProp(permission);
|
||||
}, [onSaveProp, permission]);
|
||||
const onDelete = useCallback(async () => {
|
||||
if (!permission) {
|
||||
return;
|
||||
}
|
||||
await onDeleteProp(permission);
|
||||
}, [onDeleteProp, permission]);
|
||||
const allowedActions: PermissionsTableProps['allowedActions'] = ['select'];
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="grid gap-4">
|
||||
<PermissionsTable
|
||||
allowedActions={allowedActions}
|
||||
permissions={permissions}
|
||||
/>
|
||||
{permission && (
|
||||
<PermissionsForm
|
||||
permission={permission}
|
||||
onSave={onSave}
|
||||
onDelete={onDelete}
|
||||
isCreating={isCreating}
|
||||
isRemoving={isRemoving}
|
||||
PermissionsInput={
|
||||
<RowPermissionsInput
|
||||
table={undefined}
|
||||
forbidden={['exists']}
|
||||
tables={[]}
|
||||
onPermissionsChange={permissionFilter => {
|
||||
setPermission(permission.roleName, permissionFilter);
|
||||
}}
|
||||
logicalModel={logicalModelName}
|
||||
logicalModels={logicalModels}
|
||||
permissions={permission.filter}
|
||||
comparators={comparators}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,106 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { LogicalModelPermissionsPage } from './LogicalModelPermissionsPage';
|
||||
import { ReactQueryDecorator } from '../../../../storybook/decorators/react-query';
|
||||
import { RouteWrapper } from '../components/RouteWrapper';
|
||||
import { handlers, deleteHandlers } from './mocks';
|
||||
|
||||
const name = 'LogicalModel';
|
||||
const source = 'Postgres';
|
||||
|
||||
export default {
|
||||
title: 'Features/Permissions/Form/Logical Model Permissions Page',
|
||||
component: params => {
|
||||
return (
|
||||
<RouteWrapper
|
||||
route={
|
||||
'/data/native-queries/logical-models/{{source}}/{{name}}/permissions'
|
||||
}
|
||||
itemSourceName={source}
|
||||
itemName={name}
|
||||
>
|
||||
<LogicalModelPermissionsPage {...params} />
|
||||
</RouteWrapper>
|
||||
);
|
||||
},
|
||||
decorators: [ReactQueryDecorator()],
|
||||
} as Meta;
|
||||
|
||||
type Story = StoryObj<typeof LogicalModelPermissionsPage>;
|
||||
|
||||
export const SavePermission: Story = {
|
||||
args: {
|
||||
name,
|
||||
source,
|
||||
},
|
||||
// Play function deleted because it was failing in chromatic
|
||||
// It works locally and in storybook, but not in chromatic
|
||||
// TODO: Figure out the issue
|
||||
// play: async ({ canvasElement }) => {
|
||||
// const canvas = within(canvasElement);
|
||||
// // Wait for both tabs and loading to be present because otherwise story fails in chromatic
|
||||
// expect(
|
||||
// await canvas.findByTestId(
|
||||
// 'logical-model-permissions-tab',
|
||||
// {},
|
||||
// {
|
||||
// timeout: 10000,
|
||||
// }
|
||||
// )
|
||||
// ).toBeInTheDocument();
|
||||
// expect(
|
||||
// await canvas.findByTestId('loading-logical-model-permissions')
|
||||
// ).toBeInTheDocument();
|
||||
// await canvas.findByTestId('permissions-table');
|
||||
// await userEvent.click(
|
||||
// await canvas.findByTestId('editor-select-permissions-cell')
|
||||
// );
|
||||
// await userEvent.click(await canvas.findByTestId('save-permissions-button'));
|
||||
// await expect(
|
||||
// await canvas.findByText('Permissions saved successfully!')
|
||||
// ).toBeInTheDocument();
|
||||
// },
|
||||
parameters: {
|
||||
msw: handlers(),
|
||||
consoleType: 'pro',
|
||||
},
|
||||
};
|
||||
|
||||
export const DeletePermission: Story = {
|
||||
args: {
|
||||
name,
|
||||
source,
|
||||
},
|
||||
// Play function deleted because it was failing in chromatic
|
||||
// It works locally and in storybook, but not in chromatic
|
||||
// TODO: Figure out the issue
|
||||
// play: async ({ canvasElement }) => {
|
||||
// const canvas = within(canvasElement);
|
||||
// // Wait for both tabs and loading to be present because otherwise story fails in chromatic
|
||||
// expect(
|
||||
// await canvas.findByTestId(
|
||||
// 'logical-model-permissions-tab',
|
||||
// {},
|
||||
// {
|
||||
// timeout: 10000,
|
||||
// }
|
||||
// )
|
||||
// );
|
||||
// expect(
|
||||
// await canvas.findByTestId('loading-logical-model-permissions')
|
||||
// ).toBeInTheDocument();
|
||||
// await canvas.findByTestId('permissions-table');
|
||||
// await userEvent.click(
|
||||
// await canvas.findByTestId('editor-select-permissions-cell')
|
||||
// );
|
||||
// await userEvent.click(
|
||||
// await canvas.findByTestId('delete-permissions-button')
|
||||
// );
|
||||
// await expect(
|
||||
// await canvas.findByText('Permissions successfully deleted!')
|
||||
// ).toBeInTheDocument();
|
||||
// },
|
||||
parameters: {
|
||||
msw: deleteHandlers(),
|
||||
consoleType: 'pro',
|
||||
},
|
||||
};
|
@ -0,0 +1,109 @@
|
||||
import { InjectedRouter, withRouter } from 'react-router';
|
||||
import { RouteWrapper } from '../components/RouteWrapper';
|
||||
import { Tabs } from '../../../../new-components/Tabs';
|
||||
import { LogicalModelPermissions } from './LogicalModelPermissions';
|
||||
import { useCreateLogicalModelsPermissions } from './hooks/useCreateLogicalModelsPermissions';
|
||||
import { useRemoveLogicalModelsPermissions } from './hooks/useRemoveLogicalModelsPermissions';
|
||||
import { useMetadata } from '../../../hasura-metadata-api';
|
||||
import { usePermissionComparators } from '../../../Permissions/PermissionsForm/components/RowPermissionsBuilder/hooks/usePermissionComparators';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
import { extractModelsAndQueriesFromMetadata } from '../../../hasura-metadata-api/selectors';
|
||||
|
||||
export const LogicalModelPermissionsPage = ({
|
||||
source,
|
||||
name,
|
||||
}: {
|
||||
source: string;
|
||||
name: string;
|
||||
}) => {
|
||||
const { data, isLoading } = useMetadata(m =>
|
||||
extractModelsAndQueriesFromMetadata(m)
|
||||
);
|
||||
const comparators = usePermissionComparators();
|
||||
const logicalModels = data?.models ?? [];
|
||||
const logicalModel = logicalModels.find(
|
||||
model => model.name === name && model.source.name === source
|
||||
);
|
||||
const { create, isLoading: isCreating } = useCreateLogicalModelsPermissions({
|
||||
logicalModels,
|
||||
source: logicalModel?.source,
|
||||
});
|
||||
const { remove, isLoading: isRemoving } = useRemoveLogicalModelsPermissions({
|
||||
logicalModels,
|
||||
source: logicalModel?.source,
|
||||
});
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
// Recreate the key when the logical model permissions change to reset the form
|
||||
key={logicalModel?.select_permissions?.length}
|
||||
data-testid="logical-model-permissions-tab"
|
||||
defaultValue={'logical-model-permissions'}
|
||||
items={[
|
||||
{
|
||||
value: 'logical-model-permissions',
|
||||
label: `Permissions`,
|
||||
content: (
|
||||
<div className="mt-md">
|
||||
{isLoading ? (
|
||||
<div
|
||||
className="flex items-center justify-center h-64"
|
||||
data-testid="loading-logical-model-permissions"
|
||||
>
|
||||
<Skeleton />
|
||||
</div>
|
||||
) : !logicalModel ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<span className="text-gray-500">
|
||||
Logical model with name {name} and driver {source} not found
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<LogicalModelPermissions
|
||||
onSave={async permission => {
|
||||
create({
|
||||
logicalModelName: logicalModel?.name,
|
||||
permission,
|
||||
});
|
||||
}}
|
||||
onDelete={async permission => {
|
||||
remove({
|
||||
logicalModelName: logicalModel?.name,
|
||||
permission,
|
||||
});
|
||||
}}
|
||||
isCreating={isCreating}
|
||||
isRemoving={isRemoving}
|
||||
comparators={comparators}
|
||||
logicalModelName={logicalModel?.name}
|
||||
logicalModels={logicalModels}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const LogicalModelPermissionsRoute = withRouter<{
|
||||
location: Location;
|
||||
router: InjectedRouter;
|
||||
params: {
|
||||
source: string;
|
||||
name: string;
|
||||
};
|
||||
}>(({ params }) => {
|
||||
return (
|
||||
<RouteWrapper
|
||||
route={
|
||||
'/data/native-queries/logical-models/{{source}}/{{name}}/permissions'
|
||||
}
|
||||
itemSourceName={params.source}
|
||||
itemName={params.name}
|
||||
>
|
||||
<LogicalModelPermissionsPage source={params.source} name={params.name} />
|
||||
</RouteWrapper>
|
||||
);
|
||||
});
|
@ -0,0 +1,15 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { LogicalModelWithPermissions } from './types';
|
||||
import { useLogicalModelPermissionsForm } from '../hooks/usePermissionForm';
|
||||
import { FormProvider } from 'react-hook-form';
|
||||
|
||||
export function LogicalModelPermissionsFormProvider({
|
||||
children,
|
||||
logicalModel,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
logicalModel: LogicalModelWithPermissions | undefined;
|
||||
}) {
|
||||
const methods = useLogicalModelPermissionsForm(logicalModel);
|
||||
return <FormProvider {...methods}>{children}</FormProvider>;
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
import { PermissionsIcon } from '../../../../Permissions/PermissionsTable/components/PermissionsIcons';
|
||||
|
||||
interface PermissionAccessCellProps extends React.ComponentProps<'button'> {
|
||||
access: 'fullAccess' | 'partialAccess' | 'noAccess';
|
||||
isEditable: boolean;
|
||||
isCurrentEdit: boolean;
|
||||
}
|
||||
|
||||
export const PermissionAccessCell: React.FC<PermissionAccessCellProps> = ({
|
||||
access,
|
||||
isEditable,
|
||||
isCurrentEdit,
|
||||
...rest
|
||||
}) => {
|
||||
if (!isEditable) {
|
||||
return (
|
||||
<td className="p-md whitespace-nowrap text-center cursor-not-allowed opacity-30">
|
||||
<PermissionsIcon type={access} selected={isCurrentEdit} />
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<td>
|
||||
<button
|
||||
type="submit"
|
||||
className={`cursor-pointer h-20 border-none w-full whitespace-nowrap text-center ${
|
||||
isCurrentEdit ? 'bg-amber-300' : 'hover:bg-indigo-50'
|
||||
}`}
|
||||
{...rest}
|
||||
>
|
||||
<PermissionsIcon type={access} selected={isCurrentEdit} />
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
};
|
@ -0,0 +1,191 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Button } from '../../../../../new-components/Button';
|
||||
import { IconTooltip } from '../../../../../new-components/Tooltip';
|
||||
import { usePermissionsFormContext } from '../hooks/usePermissionForm';
|
||||
import { Permission } from './types';
|
||||
import { Collapse } from '../../../../../new-components/deprecated';
|
||||
import { getEdForm } from '../../../../../components/Services/Data/utils';
|
||||
import { Badge } from '../../../../../new-components/Badge';
|
||||
|
||||
type PermissionsFormProps = {
|
||||
permission: Permission;
|
||||
onSave: () => void;
|
||||
onDelete: () => void;
|
||||
PermissionsInput: ReactNode;
|
||||
isCreating?: boolean;
|
||||
isRemoving?: boolean;
|
||||
};
|
||||
|
||||
export const PermissionsForm = ({
|
||||
permission,
|
||||
onDelete,
|
||||
onSave,
|
||||
PermissionsInput,
|
||||
isCreating,
|
||||
isRemoving,
|
||||
}: PermissionsFormProps) => {
|
||||
const {
|
||||
unsetActivePermission,
|
||||
rowSelectPermissions,
|
||||
setRowSelectPermissions,
|
||||
columns,
|
||||
toggleColumn,
|
||||
toggleAllColumns,
|
||||
columnPermissionsStatus,
|
||||
setPermission,
|
||||
} = usePermissionsFormContext();
|
||||
return (
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
onSave();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded p-md border border-gray-300"
|
||||
data-testid="permissions-form"
|
||||
>
|
||||
<div className="pb-4 flex items-center gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
unsetActivePermission();
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<h3 data-testid="form-title">
|
||||
<strong>Role:</strong>
|
||||
<Badge className="mx-2" data-testid="role-pill">
|
||||
{permission.roleName}
|
||||
</Badge>
|
||||
<strong>Action:</strong>
|
||||
<Badge className="mx-2" data-testid="action-pill">
|
||||
{permission.action}
|
||||
</Badge>
|
||||
</h3>
|
||||
</div>
|
||||
<fieldset className="grid gap-2">
|
||||
<div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
id={'without_filter'}
|
||||
type="radio"
|
||||
value={'without_filter'}
|
||||
checked={rowSelectPermissions === 'without_filter'}
|
||||
data-testid="without-filter"
|
||||
onClick={() => {
|
||||
setRowSelectPermissions('without_filter');
|
||||
setPermission(permission.roleName, {});
|
||||
}}
|
||||
/>
|
||||
<NoChecksLabel />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
id={'with_custom_filter'}
|
||||
type="radio"
|
||||
value={rowSelectPermissions}
|
||||
checked={rowSelectPermissions === 'with_custom_filter'}
|
||||
onClick={() => {
|
||||
setRowSelectPermissions('with_custom_filter');
|
||||
}}
|
||||
/>
|
||||
<CustomLabel />
|
||||
</label>
|
||||
|
||||
{rowSelectPermissions === 'with_custom_filter' && (
|
||||
<div className="pt-4">
|
||||
<div>{PermissionsInput}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<Collapse defaultOpen>
|
||||
<Collapse.Header
|
||||
title={`Column ${permission.action} permissions`}
|
||||
tooltip={`Choose columns allowed to be ${getEdForm(
|
||||
permission.action
|
||||
)}`}
|
||||
status={columnPermissionsStatus(permission)}
|
||||
disabledMessage="Set row permissions first"
|
||||
/>
|
||||
<Collapse.Content>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<p>
|
||||
Allow role <strong>{permission.roleName}</strong> to access{' '}
|
||||
<strong>columns</strong>:
|
||||
</p>
|
||||
</div>
|
||||
<fieldset className="flex gap-4 flex-wrap">
|
||||
{columns?.map(column => (
|
||||
<label key={column} className="flex gap-2 items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid={`column-${column}-checkbox`}
|
||||
style={{ marginTop: '0px !important' }}
|
||||
className="rounded shadow-sm border border-gray-300 hover:border-gray-400 focus:ring-yellow-400"
|
||||
checked={permission.columns.includes(column)}
|
||||
onChange={() => {
|
||||
toggleColumn(permission, column);
|
||||
}}
|
||||
/>
|
||||
<i>{column}</i>
|
||||
</label>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => toggleAllColumns(permission)}
|
||||
data-testid="toggle-all-columns"
|
||||
>
|
||||
Toggle All
|
||||
</Button>
|
||||
</fieldset>
|
||||
</div>
|
||||
</Collapse.Content>
|
||||
</Collapse>
|
||||
|
||||
<div className="pt-2 flex gap-2 mt-4" id="form-buttons-container">
|
||||
<Button
|
||||
isLoading={isCreating}
|
||||
disabled={isRemoving || isCreating}
|
||||
type="submit"
|
||||
mode="primary"
|
||||
title={'Submit'}
|
||||
data-testid="save-permissions-button"
|
||||
>
|
||||
Save Permissions
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
isLoading={isRemoving}
|
||||
disabled={isRemoving || isCreating}
|
||||
type="button"
|
||||
mode="destructive"
|
||||
onClick={onDelete}
|
||||
data-testid="delete-permissions-button"
|
||||
>
|
||||
Delete Permissions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const NoChecksLabel = () => (
|
||||
<span data-test="without-checks">Without any checks </span>
|
||||
);
|
||||
|
||||
const CustomLabel = () => (
|
||||
<span data-test="custom-check" className="flex items-center">
|
||||
With custom check:
|
||||
<IconTooltip message="Create custom check using permissions builder" />
|
||||
</span>
|
||||
);
|
@ -0,0 +1,66 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { Action, Permission } from './types';
|
||||
import { PermissionsRowName } from './PermissionsRowName';
|
||||
import { PermissionAccessCell } from './PermissionAccessCell';
|
||||
import { usePermissionsFormContext } from '../hooks/usePermissionForm';
|
||||
|
||||
type PermissionsRowProps = {
|
||||
permission: Permission;
|
||||
index: number;
|
||||
actions: string[];
|
||||
allowedActions: Action[];
|
||||
};
|
||||
|
||||
export const PermissionsRow = forwardRef<HTMLInputElement, PermissionsRowProps>(
|
||||
({ permission, actions, allowedActions, index }, ref) => {
|
||||
const {
|
||||
setActivePermission,
|
||||
activePermission,
|
||||
unsetActivePermission,
|
||||
permissionAccess,
|
||||
} = usePermissionsFormContext();
|
||||
return (
|
||||
<tr className="group divide-x divide-gray-300">
|
||||
<PermissionsRowName
|
||||
ref={ref}
|
||||
roleName={permission.roleName}
|
||||
isNew={permission.isNew}
|
||||
/>
|
||||
{actions.map(actionName => {
|
||||
const action = allowedActions.find(
|
||||
allowedAction => allowedAction === actionName
|
||||
);
|
||||
return (
|
||||
<PermissionAccessCell
|
||||
key={actionName}
|
||||
isEditable={Boolean(action)}
|
||||
access={permissionAccess(action, permission)}
|
||||
isCurrentEdit={
|
||||
activePermission === index && permission.action === action
|
||||
}
|
||||
data-testid={`${permission.roleName}-${actionName}-permissions-cell`}
|
||||
onClick={() => {
|
||||
// Close form if user clicks cell for empty permission
|
||||
if (
|
||||
activePermission !== index &&
|
||||
permission.isNew &&
|
||||
permission.roleName === ''
|
||||
) {
|
||||
unsetActivePermission();
|
||||
}
|
||||
// Focus on input so that user can enter new role name
|
||||
if (permission.isNew && permission.roleName === '') {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
ref.current?.focus();
|
||||
} else {
|
||||
setActivePermission(index);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
);
|
@ -0,0 +1,40 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { Role } from './types';
|
||||
import { usePermissionsFormContext } from '../hooks/usePermissionForm';
|
||||
|
||||
interface PermissionsRowNameProps {
|
||||
roleName: Role['name'];
|
||||
isNew: Role['isNew'];
|
||||
}
|
||||
|
||||
export const PermissionsRowName = forwardRef<
|
||||
HTMLInputElement,
|
||||
PermissionsRowNameProps
|
||||
>(({ roleName, isNew }, inputRef) => {
|
||||
const { setNewRoleName } = usePermissionsFormContext();
|
||||
if (isNew) {
|
||||
return (
|
||||
<td className="w-0 bg-gray-50 p-sm font-semibold text-muted">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="block w-64 h-input px-md shadow-sm rounded border border-gray-300 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400"
|
||||
value={roleName}
|
||||
aria-label="create-new-role"
|
||||
data-testid="new-role-input"
|
||||
placeholder="Create new role..."
|
||||
onChange={e => {
|
||||
setNewRoleName(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<td className="w-0 bg-gray-50 p-md font-semibold text-muted">
|
||||
<div className="flex items-center">
|
||||
<label className="flex items-center ml-sm">{roleName}</label>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
});
|
@ -0,0 +1,68 @@
|
||||
import { createRef } from 'react';
|
||||
import { PermissionsIcon } from '../../../../Permissions/PermissionsTable/components/PermissionsIcons';
|
||||
import { Action, Permission } from './types';
|
||||
import { PermissionsRow } from './PermissionsRow';
|
||||
|
||||
export type PermissionsTableProps = {
|
||||
allowedActions: Action[];
|
||||
permissions: Permission[];
|
||||
};
|
||||
|
||||
export const PermissionsTable = ({
|
||||
allowedActions,
|
||||
permissions,
|
||||
}: PermissionsTableProps) => {
|
||||
const actions = ['insert', 'select', 'update', 'delete'];
|
||||
const inputRef = createRef<HTMLInputElement>();
|
||||
return (
|
||||
<div className="grid gap-2" data-testid="permissions-table">
|
||||
<div className="flex gap-4">
|
||||
<span>
|
||||
<PermissionsIcon type="fullAccess" />
|
||||
- full access
|
||||
</span>
|
||||
<span>
|
||||
<PermissionsIcon type="noAccess" />
|
||||
- no access
|
||||
</span>
|
||||
<span>
|
||||
<PermissionsIcon type="partialAccess" />
|
||||
- partial access
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto border border-gray-300 rounded">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-left">
|
||||
<thead>
|
||||
<tr className="divide-x divide-gray-300">
|
||||
<th className="w-0 bg-gray-50 border-r border-gray-200 px-md py-sm text-sm font-semibold text-muted uppercase tracking-wider">
|
||||
ROLE
|
||||
</th>
|
||||
{actions.map(action => (
|
||||
<th
|
||||
className="bg-gray-50 px-md py-sm text-sm font-semibold text-muted text-center uppercase tracking-wider"
|
||||
key={action}
|
||||
>
|
||||
{action.toUpperCase()}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="bg-white divide-y divide-gray-300">
|
||||
{permissions.map((permission, index) => (
|
||||
// Using index as key instead of role.name because role.name is editable
|
||||
<PermissionsRow
|
||||
key={index}
|
||||
index={index}
|
||||
ref={inputRef}
|
||||
permission={permission}
|
||||
actions={actions}
|
||||
allowedActions={allowedActions}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,44 @@
|
||||
import { LogicalModel, Source } from '../../../../hasura-metadata-types';
|
||||
|
||||
export type LogicalModelWithSourceName = LogicalModel & {
|
||||
source: Source;
|
||||
};
|
||||
|
||||
export type Permission = {
|
||||
roleName: string;
|
||||
source: string;
|
||||
action: Action;
|
||||
filter: Record<string, any>;
|
||||
columns: string[];
|
||||
isNew: boolean;
|
||||
};
|
||||
|
||||
export type PermissionId = {
|
||||
source: Permission['source'];
|
||||
name: Permission['roleName'];
|
||||
};
|
||||
|
||||
export type Action = 'select' | 'insert' | 'update' | 'delete';
|
||||
|
||||
export type LogicalModelWithPermissions = LogicalModelWithSourceName & {
|
||||
select_permissions?: {
|
||||
role: string;
|
||||
permission: {
|
||||
columns: string[];
|
||||
filter: Record<string, any>;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
export type Role = {
|
||||
name: string;
|
||||
isNew?: boolean;
|
||||
};
|
||||
|
||||
export type AccessType = 'fullAccess' | 'noAccess' | 'partialAccess';
|
||||
|
||||
export type OnSave = (permission: Permission) => Promise<void>;
|
||||
|
||||
export type OnDelete = (permission: Permission) => Promise<void>;
|
||||
|
||||
export type RowSelectPermissionsType = 'with_custom_filter' | 'without_filter';
|
@ -0,0 +1,86 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMetadataMigration } from '../../../../MetadataAPI';
|
||||
import { exportMetadata } from '../../../../DataSource';
|
||||
import { useHttpClient } from '../../../../Network';
|
||||
import { getCreateLogicalModelBody } from './utils/getCreateLogicalModelBody';
|
||||
import { LogicalModel, Source } from '../../../../hasura-metadata-types';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useFireNotification } from '../../../../../new-components/Notifications/index';
|
||||
import { METADATA_QUERY_KEY } from '../../../../hasura-metadata-api/useMetadata';
|
||||
import { errorTransform } from './utils/errorTransform';
|
||||
|
||||
const useCreateLogicalModelsPermissions = ({
|
||||
logicalModels,
|
||||
source,
|
||||
}: {
|
||||
logicalModels: LogicalModel[];
|
||||
source: Source | undefined;
|
||||
}) => {
|
||||
const mutate = useMetadataMigration({
|
||||
errorTransform,
|
||||
});
|
||||
const { fireNotification } = useFireNotification();
|
||||
const httpClient = useHttpClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const create = useCallback(
|
||||
async ({ permission, logicalModelName, onSuccess }) => {
|
||||
const { resource_version } = await exportMetadata({
|
||||
httpClient,
|
||||
});
|
||||
if (!source) return;
|
||||
|
||||
const body = getCreateLogicalModelBody({
|
||||
permission,
|
||||
logicalModelName,
|
||||
logicalModels,
|
||||
source,
|
||||
});
|
||||
|
||||
try {
|
||||
await mutate.mutateAsync(
|
||||
{
|
||||
query: { type: 'bulk', args: body, resource_version },
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
fireNotification({
|
||||
type: 'success',
|
||||
title: 'Success!',
|
||||
message: 'Permissions saved successfully!',
|
||||
});
|
||||
},
|
||||
onError: err => {
|
||||
fireNotification({
|
||||
type: 'error',
|
||||
title: 'Error!',
|
||||
message:
|
||||
err?.message ??
|
||||
'Something went wrong while saving permissions',
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries([METADATA_QUERY_KEY]);
|
||||
onSuccess?.();
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
fireNotification({
|
||||
type: 'error',
|
||||
title: 'Error!',
|
||||
message:
|
||||
error?.message ?? 'Something went wrong while saving permissions',
|
||||
});
|
||||
}
|
||||
},
|
||||
[logicalModels, source]
|
||||
);
|
||||
|
||||
return {
|
||||
create,
|
||||
...mutate,
|
||||
};
|
||||
};
|
||||
|
||||
export { useCreateLogicalModelsPermissions };
|
@ -0,0 +1,173 @@
|
||||
import { useForm, useFormContext } from 'react-hook-form';
|
||||
import { LogicalModelPermissionsState } from '../LogicalModelPermissions';
|
||||
import {
|
||||
AccessType,
|
||||
Action,
|
||||
LogicalModelWithPermissions,
|
||||
Permission,
|
||||
RowSelectPermissionsType,
|
||||
} from '../components/types';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { permissionColumnAccess, permissionRowAccess } from '../utils';
|
||||
|
||||
export function useLogicalModelPermissionsForm(
|
||||
logicalModel: LogicalModelWithPermissions | undefined
|
||||
) {
|
||||
const defaultPermissions: Permission[] = [
|
||||
...(logicalModel?.select_permissions?.map(permission => ({
|
||||
roleName: permission.role,
|
||||
filter: permission.permission.filter,
|
||||
columns: permission.permission.columns,
|
||||
action: 'select' as const,
|
||||
isNew: false,
|
||||
source: logicalModel?.source.name,
|
||||
})) ?? []),
|
||||
{
|
||||
roleName: '',
|
||||
filter: {},
|
||||
columns: [],
|
||||
action: 'select' as const,
|
||||
isNew: true,
|
||||
source: logicalModel?.source.name || '',
|
||||
},
|
||||
];
|
||||
const methods = useForm<LogicalModelPermissionsState>({
|
||||
defaultValues: {
|
||||
activePermission: null,
|
||||
rowSelectPermissions: 'without_filter',
|
||||
permissions: defaultPermissions,
|
||||
columns: logicalModel?.fields?.map(field => field.name) ?? [],
|
||||
},
|
||||
});
|
||||
return methods;
|
||||
}
|
||||
|
||||
export function usePermissionsFormContext() {
|
||||
const { setValue, watch } = useFormContext<LogicalModelPermissionsState>();
|
||||
return {
|
||||
permissions: watch('permissions'),
|
||||
setPermission: (roleName: string, filter: Record<string, any>) => {
|
||||
const permissions = watch('permissions');
|
||||
setValue(
|
||||
'permissions',
|
||||
permissions.map(permission => {
|
||||
if (permission.roleName === roleName) {
|
||||
return {
|
||||
...permission,
|
||||
filter,
|
||||
};
|
||||
}
|
||||
return permission;
|
||||
})
|
||||
);
|
||||
},
|
||||
rowSelectPermissions: watch('rowSelectPermissions'),
|
||||
setRowSelectPermissions: (rowSelectPermissions: RowSelectPermissionsType) =>
|
||||
setValue('rowSelectPermissions', rowSelectPermissions),
|
||||
columns: watch('columns'),
|
||||
toggleColumn: (permission: Permission, column: string) => {
|
||||
const permissions = watch('permissions');
|
||||
setValue(
|
||||
'permissions',
|
||||
permissions.map(p => {
|
||||
if (
|
||||
p.roleName === permission.roleName &&
|
||||
p.source === permission.source
|
||||
) {
|
||||
const columns = p.columns.includes(column)
|
||||
? p.columns.filter(c => c !== column)
|
||||
: [...p.columns, column];
|
||||
return {
|
||||
...p,
|
||||
columns,
|
||||
};
|
||||
}
|
||||
return p;
|
||||
})
|
||||
);
|
||||
},
|
||||
toggleAllColumns: (permission: Permission) => {
|
||||
const permissions = watch('permissions');
|
||||
setValue(
|
||||
'permissions',
|
||||
permissions.map(p => {
|
||||
if (
|
||||
p.roleName === permission.roleName &&
|
||||
p.source === permission.source
|
||||
) {
|
||||
const columns =
|
||||
p.columns.length === watch('columns').length
|
||||
? []
|
||||
: watch('columns');
|
||||
return {
|
||||
...p,
|
||||
columns,
|
||||
};
|
||||
}
|
||||
return p;
|
||||
})
|
||||
);
|
||||
},
|
||||
columnPermissionsStatus: (
|
||||
permission: Permission
|
||||
): '' | 'No columns' | 'All columns' | 'Partial columns' => {
|
||||
if (!permission) {
|
||||
return '';
|
||||
}
|
||||
if (permission.columns.length === 0) {
|
||||
return 'No columns';
|
||||
}
|
||||
if (permission.columns.length === watch('columns').length) {
|
||||
return 'All columns';
|
||||
}
|
||||
return 'Partial columns';
|
||||
},
|
||||
setActivePermission: (index: number) => {
|
||||
const permission = watch('permissions')[index];
|
||||
const rowSelectPermissions: RowSelectPermissionsType =
|
||||
permission.isNew || isEmpty(permission.filter)
|
||||
? 'without_filter'
|
||||
: 'with_custom_filter';
|
||||
|
||||
setValue('activePermission', index);
|
||||
setValue('rowSelectPermissions', rowSelectPermissions);
|
||||
},
|
||||
unsetActivePermission: () => setValue('activePermission', null),
|
||||
activePermission: watch('activePermission'),
|
||||
setNewRoleName: (roleName: string) => {
|
||||
const permissions = watch('permissions');
|
||||
setValue(
|
||||
'permissions',
|
||||
permissions.map(permission => {
|
||||
if (permission.isNew) {
|
||||
return {
|
||||
...permission,
|
||||
roleName,
|
||||
};
|
||||
}
|
||||
return permission;
|
||||
})
|
||||
);
|
||||
},
|
||||
/**
|
||||
* Returns the access type of the permission for the given action
|
||||
*/
|
||||
permissionAccess: (
|
||||
action: Action | undefined,
|
||||
permission: Permission
|
||||
): AccessType => {
|
||||
if (action !== 'select') {
|
||||
return 'noAccess';
|
||||
}
|
||||
const rowAccess = permissionRowAccess(permission);
|
||||
const columnAccess = permissionColumnAccess(permission, watch('columns'));
|
||||
if (rowAccess === 'noAccess' && columnAccess === 'noAccess') {
|
||||
return 'noAccess';
|
||||
}
|
||||
if (rowAccess === 'fullAccess' && columnAccess === 'fullAccess') {
|
||||
return 'fullAccess';
|
||||
}
|
||||
return 'partialAccess';
|
||||
},
|
||||
};
|
||||
}
|
@ -0,0 +1,94 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMetadataMigration } from '../../../../MetadataAPI';
|
||||
import { exportMetadata } from '../../../../DataSource';
|
||||
import { useHttpClient } from '../../../../Network';
|
||||
import { LogicalModel, Source } from '../../../../hasura-metadata-types';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useFireNotification } from '../../../../../new-components/Notifications/index';
|
||||
import { METADATA_QUERY_KEY } from '../../../../hasura-metadata-api/useMetadata';
|
||||
import { errorTransform } from './utils/errorTransform';
|
||||
import { Permission } from '../components/types';
|
||||
import { getDeleteLogicalModelBody } from './utils/getDeleteLogicalModelBody';
|
||||
|
||||
const useRemoveLogicalModelsPermissions = ({
|
||||
logicalModels,
|
||||
source,
|
||||
}: {
|
||||
logicalModels: LogicalModel[];
|
||||
source: Source | undefined;
|
||||
}) => {
|
||||
const mutate = useMetadataMigration({
|
||||
errorTransform,
|
||||
});
|
||||
const { fireNotification } = useFireNotification();
|
||||
const httpClient = useHttpClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const remove = useCallback(
|
||||
async ({
|
||||
permission,
|
||||
logicalModelName,
|
||||
onSuccess,
|
||||
}: {
|
||||
permission: Permission;
|
||||
logicalModelName: string;
|
||||
onSuccess?: () => void;
|
||||
}) => {
|
||||
const { resource_version } = await exportMetadata({
|
||||
httpClient,
|
||||
});
|
||||
if (!source) return;
|
||||
|
||||
const body = getDeleteLogicalModelBody({
|
||||
permission,
|
||||
logicalModelName,
|
||||
source,
|
||||
});
|
||||
|
||||
try {
|
||||
await mutate.mutateAsync(
|
||||
{
|
||||
query: { type: 'bulk', args: body, resource_version },
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
fireNotification({
|
||||
type: 'success',
|
||||
title: 'Success!',
|
||||
message: 'Permissions successfully deleted!',
|
||||
});
|
||||
},
|
||||
onError: err => {
|
||||
fireNotification({
|
||||
type: 'error',
|
||||
title: 'Error!',
|
||||
message:
|
||||
err?.message ??
|
||||
'Something went wrong while deleting permissions',
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries([METADATA_QUERY_KEY]);
|
||||
onSuccess?.();
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
fireNotification({
|
||||
type: 'error',
|
||||
title: 'Error!',
|
||||
message:
|
||||
error?.message ?? 'Something went wrong while saving permissions',
|
||||
});
|
||||
}
|
||||
},
|
||||
[source]
|
||||
);
|
||||
|
||||
return {
|
||||
remove,
|
||||
...mutate,
|
||||
};
|
||||
};
|
||||
|
||||
export { useRemoveLogicalModelsPermissions };
|
@ -0,0 +1,16 @@
|
||||
export function errorTransform(error: any) {
|
||||
const err = error as Record<string, any>;
|
||||
|
||||
let message = '';
|
||||
let name = `Error code: ${err.code}`;
|
||||
|
||||
if ('internal' in err) {
|
||||
name = err?.internal?.[0]?.name ?? name;
|
||||
message = err?.internal?.[0]?.reason ?? 'Internal error';
|
||||
} else message = err.error;
|
||||
|
||||
return {
|
||||
name,
|
||||
message,
|
||||
};
|
||||
}
|
@ -0,0 +1,73 @@
|
||||
import { getPermissionValues } from './getPermissionValues';
|
||||
import { LogicalModel, Source } from '../../../../../hasura-metadata-types';
|
||||
import { Permission } from '../../components/types';
|
||||
import { mapPostgresToPg } from '.';
|
||||
|
||||
export interface CreateLogicalModalBodyArgs {
|
||||
logicalModels: LogicalModel[];
|
||||
logicalModelName: string;
|
||||
permission: Permission;
|
||||
source: Source;
|
||||
}
|
||||
|
||||
type PermissionArgsType = {
|
||||
name: string;
|
||||
role: string;
|
||||
permission?: Record<string, unknown>;
|
||||
source: string;
|
||||
};
|
||||
type PermissionBodyType = {
|
||||
type: string;
|
||||
args: PermissionArgsType;
|
||||
};
|
||||
|
||||
const doesRoleExist = (logicalModel: LogicalModel, roleName: string) => {
|
||||
const permissionKeys = ['select_permissions'] as ['select_permissions'];
|
||||
return permissionKeys.some(
|
||||
key =>
|
||||
Array.isArray(logicalModel[key]) &&
|
||||
logicalModel[key]?.some(permission => permission.role === roleName)
|
||||
);
|
||||
};
|
||||
|
||||
export const getCreateLogicalModelBody = ({
|
||||
logicalModelName,
|
||||
permission,
|
||||
logicalModels,
|
||||
source,
|
||||
}: CreateLogicalModalBodyArgs): PermissionBodyType[] => {
|
||||
const permissionValues = getPermissionValues(permission);
|
||||
const args = [
|
||||
{
|
||||
type: `${mapPostgresToPg(source.kind)}_create_logical_model_${
|
||||
permission.action
|
||||
}_permission`,
|
||||
args: {
|
||||
name: logicalModelName,
|
||||
role: permission.roleName,
|
||||
permission: permissionValues,
|
||||
source: source.name,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const permissionAlreadyExists = logicalModels.find((model: LogicalModel) =>
|
||||
doesRoleExist(model, permission.roleName)
|
||||
);
|
||||
|
||||
if (permissionAlreadyExists) {
|
||||
args.unshift({
|
||||
type: `${mapPostgresToPg(source.kind)}_drop_logical_model_${
|
||||
permission.action
|
||||
}_permission`,
|
||||
args: {
|
||||
permission: {},
|
||||
name: logicalModelName,
|
||||
role: permission.roleName,
|
||||
source: source.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return args;
|
||||
};
|
@ -0,0 +1,40 @@
|
||||
import { mapPostgresToPg } from '.';
|
||||
import { Source } from '../../../../../hasura-metadata-types';
|
||||
import { Permission } from '../../components/types';
|
||||
export interface DeleteLogicalModalBodyArgs {
|
||||
logicalModelName: string;
|
||||
permission: Permission;
|
||||
source: Source;
|
||||
}
|
||||
|
||||
type PermissionArgsType = {
|
||||
name: string;
|
||||
role: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
type PermissionBodyType = {
|
||||
type: string;
|
||||
args: PermissionArgsType;
|
||||
};
|
||||
|
||||
export const getDeleteLogicalModelBody = ({
|
||||
logicalModelName,
|
||||
permission,
|
||||
source,
|
||||
}: DeleteLogicalModalBodyArgs): PermissionBodyType[] => {
|
||||
const args = [
|
||||
{
|
||||
type: `${mapPostgresToPg(source.kind)}_drop_logical_model_${
|
||||
permission.action
|
||||
}_permission`,
|
||||
args: {
|
||||
name: logicalModelName,
|
||||
role: permission.roleName,
|
||||
source: source.name,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return args;
|
||||
};
|
@ -0,0 +1,36 @@
|
||||
type GetPermissionValuesInputType = {
|
||||
roleName: string;
|
||||
filter?: Record<string, unknown>;
|
||||
check?: Record<string, unknown>;
|
||||
columns?: string[];
|
||||
action: string;
|
||||
isNew: boolean;
|
||||
source: string;
|
||||
};
|
||||
|
||||
type GetPermissionValuesOutputType = {
|
||||
filter?: Record<string, unknown>;
|
||||
check?: Record<string, unknown>;
|
||||
columns?: string[];
|
||||
};
|
||||
|
||||
export const getPermissionValues = (
|
||||
obj: GetPermissionValuesInputType
|
||||
): GetPermissionValuesOutputType => {
|
||||
const { filter, check, columns } = obj;
|
||||
const result: GetPermissionValuesOutputType = {};
|
||||
|
||||
if (filter !== undefined) {
|
||||
result.filter = filter;
|
||||
}
|
||||
|
||||
if (check !== undefined) {
|
||||
result.check = check;
|
||||
}
|
||||
|
||||
if (columns !== undefined) {
|
||||
result.columns = columns;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
export function mapPostgresToPg(kind: string) {
|
||||
return kind === 'postgres' ? 'pg' : kind;
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
export default {
|
||||
version: '12345',
|
||||
is_function_permissions_inferred: true,
|
||||
is_remote_schema_permissions_enabled: false,
|
||||
is_admin_secret_set: true,
|
||||
is_auth_hook_set: false,
|
||||
is_jwt_set: false,
|
||||
jwt: [],
|
||||
is_allow_list_enabled: false,
|
||||
live_queries: { batch_size: 100, refetch_delay: 1 },
|
||||
streaming_queries: { batch_size: 100, refetch_delay: 1 },
|
||||
console_assets_dir: null,
|
||||
experimental_features: ['naming_convention'],
|
||||
is_prometheus_metrics_enabled: false,
|
||||
default_naming_convention: 'hasura-default',
|
||||
feature_flags: [
|
||||
{
|
||||
name: 'stored-procedures',
|
||||
description: 'Expose stored procedures support',
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
name: 'native-query-interface',
|
||||
description:
|
||||
'Expose custom views, permissions and advanced SQL functionality via custom queries',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: 'test-flag',
|
||||
description: 'Testing feature flag integration',
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
};
|
@ -0,0 +1,23 @@
|
||||
const payload = {
|
||||
type: 'bulk',
|
||||
args: [
|
||||
{
|
||||
type: 'pg_drop_logical_model_select_permission',
|
||||
args: {
|
||||
name: 'LogicalModel',
|
||||
role: 'editor',
|
||||
source: 'Postgres',
|
||||
},
|
||||
},
|
||||
],
|
||||
resource_version: 222,
|
||||
};
|
||||
|
||||
const response = {
|
||||
message: 'success',
|
||||
};
|
||||
|
||||
export default {
|
||||
payload,
|
||||
response,
|
||||
};
|
@ -0,0 +1,125 @@
|
||||
import { rest } from 'msw';
|
||||
import config from './config';
|
||||
import metadata from './metadata';
|
||||
import save from './save';
|
||||
import deleteMocks from './delete';
|
||||
|
||||
export const handlers = () => [
|
||||
rest.get('http://localhost:8080/v1alpha1/config', async (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(config));
|
||||
}),
|
||||
rest.post('http://localhost:8080/v1/metadata', async (req, res, ctx) => {
|
||||
const reqBody = await req.json<{
|
||||
type: string;
|
||||
args: any;
|
||||
}>();
|
||||
if (reqBody.type === 'export_metadata') {
|
||||
return res(ctx.status(200), ctx.json({ metadata }));
|
||||
}
|
||||
if (
|
||||
reqBody.type === 'bulk' &&
|
||||
reqBody.args.length === 2 &&
|
||||
reqBody.args[0].type === 'pg_drop_logical_model_select_permission' &&
|
||||
reqBody.args[1].type === 'pg_create_logical_model_select_permission'
|
||||
) {
|
||||
return res(ctx.status(200), ctx.json(save.response));
|
||||
}
|
||||
if (
|
||||
reqBody.type === 'bulk' &&
|
||||
reqBody.args.length === 1 &&
|
||||
reqBody.args[0].type === 'pg_drop_logical_model_select_permission'
|
||||
) {
|
||||
return res(ctx.status(200), ctx.json(deleteMocks.response));
|
||||
}
|
||||
}),
|
||||
rest.get('http://localhost:8080/v1/entitlement', async (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
metadata_db_id: '58a9e616-5fe9-4277-95fa-e27f9d45e177',
|
||||
status: 'none',
|
||||
})
|
||||
);
|
||||
}),
|
||||
];
|
||||
|
||||
export const deleteHandlers = () => {
|
||||
let hasDeleted = false;
|
||||
return [
|
||||
rest.get('http://localhost:8080/v1alpha1/config', async (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(config));
|
||||
}),
|
||||
rest.post('http://localhost:8080/v1/metadata', async (req, res, ctx) => {
|
||||
const reqBody = await req.json<{
|
||||
type: string;
|
||||
args: any;
|
||||
}>();
|
||||
if (reqBody.type === 'export_metadata' && hasDeleted) {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
metadata: {
|
||||
...metadata,
|
||||
sources: [
|
||||
...metadata.sources.map(source => {
|
||||
if (source.name !== 'Postgres') {
|
||||
return source;
|
||||
}
|
||||
return {
|
||||
...source,
|
||||
logical_models: source.logical_models.map(logical_model => {
|
||||
if (logical_model.name !== 'LogicalModel') {
|
||||
return logical_model;
|
||||
}
|
||||
return {
|
||||
fields: logical_model.fields,
|
||||
name: logical_model.name,
|
||||
// Omit select_permissions to simulate deletion
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
if (reqBody.type === 'export_metadata') {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
metadata,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (reqBody.type === 'export_metadata') {
|
||||
return res(ctx.status(200), ctx.json({ metadata }));
|
||||
}
|
||||
if (
|
||||
reqBody.type === 'bulk' &&
|
||||
reqBody.args.length === 2 &&
|
||||
reqBody.args[0].type === 'pg_drop_logical_model_select_permission' &&
|
||||
reqBody.args[1].type === 'pg_create_logical_model_select_permission'
|
||||
) {
|
||||
return res(ctx.status(200), ctx.json(save.response));
|
||||
}
|
||||
if (
|
||||
reqBody.type === 'bulk' &&
|
||||
reqBody.args.length === 1 &&
|
||||
reqBody.args[0].type === 'pg_drop_logical_model_select_permission'
|
||||
) {
|
||||
hasDeleted = true;
|
||||
return res(ctx.status(200), ctx.json(deleteMocks.response));
|
||||
}
|
||||
}),
|
||||
rest.get('http://localhost:8080/v1/entitlement', async (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
metadata_db_id: '58a9e616-5fe9-4277-95fa-e27f9d45e177',
|
||||
status: 'none',
|
||||
})
|
||||
);
|
||||
}),
|
||||
];
|
||||
};
|
@ -0,0 +1,175 @@
|
||||
export default {
|
||||
version: 3,
|
||||
sources: [
|
||||
{
|
||||
name: 'MS',
|
||||
kind: 'mssql',
|
||||
tables: [],
|
||||
logical_models: [
|
||||
{
|
||||
fields: [],
|
||||
name: 'Model',
|
||||
},
|
||||
],
|
||||
configuration: {
|
||||
connection_info: {
|
||||
connection_string:
|
||||
'Driver={ODBC Driver 18 for SQL Server};Server=tcp:host.docker.internal,1433;Database=tempdb;Uid=sa;Pwd=Password!;Encrypt=optional',
|
||||
pool_settings: {
|
||||
idle_timeout: 5,
|
||||
max_connections: null,
|
||||
total_max_connections: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Postgres',
|
||||
kind: 'postgres',
|
||||
tables: [
|
||||
{
|
||||
table: {
|
||||
name: 'Album',
|
||||
schema: 'public',
|
||||
},
|
||||
object_relationships: [
|
||||
{
|
||||
name: 'Album_Artist',
|
||||
using: {
|
||||
foreign_key_constraint_on: 'ArtistId',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'D',
|
||||
using: {
|
||||
manual_configuration: {
|
||||
column_mapping: {
|
||||
ArtistId: 'ArtistId',
|
||||
},
|
||||
insertion_order: null,
|
||||
remote_table: {
|
||||
name: 'Artist',
|
||||
schema: 'public',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
remote_relationships: [
|
||||
{
|
||||
definition: {
|
||||
to_source: {
|
||||
field_mapping: {
|
||||
ArtistId: 'ArtistId',
|
||||
},
|
||||
relationship_type: 'object',
|
||||
source: 'Postgres',
|
||||
table: {
|
||||
name: 'Artist',
|
||||
schema: 'public',
|
||||
},
|
||||
},
|
||||
},
|
||||
name: 'A',
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
to_source: {
|
||||
field_mapping: {
|
||||
ArtistId: 'ArtistId',
|
||||
},
|
||||
relationship_type: 'object',
|
||||
source: 'Postgres',
|
||||
table: {
|
||||
name: 'Artist',
|
||||
schema: 'public',
|
||||
},
|
||||
},
|
||||
},
|
||||
name: 'B',
|
||||
},
|
||||
{
|
||||
definition: {
|
||||
to_source: {
|
||||
field_mapping: {
|
||||
ArtistId: 'ArtistId',
|
||||
},
|
||||
relationship_type: 'object',
|
||||
source: 'Postgres',
|
||||
table: {
|
||||
name: 'Artist',
|
||||
schema: 'public',
|
||||
},
|
||||
},
|
||||
},
|
||||
name: 'C',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
table: {
|
||||
name: 'Artist',
|
||||
schema: 'public',
|
||||
},
|
||||
},
|
||||
],
|
||||
logical_models: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
nullable: true,
|
||||
type: 'integer',
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
nullable: true,
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
name: 'LogicalModel',
|
||||
select_permissions: [
|
||||
{
|
||||
permission: {
|
||||
columns: ['id'],
|
||||
filter: {},
|
||||
},
|
||||
role: 'editor',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: [],
|
||||
name: 'M',
|
||||
select_permissions: [
|
||||
{
|
||||
permission: {
|
||||
columns: [],
|
||||
filter: {
|
||||
_or: [{}],
|
||||
},
|
||||
},
|
||||
role: 'M',
|
||||
},
|
||||
{
|
||||
permission: {
|
||||
columns: [],
|
||||
filter: {
|
||||
_and: [{}],
|
||||
},
|
||||
},
|
||||
role: 'reader',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
configuration: {
|
||||
connection_info: {
|
||||
database_url: 'postgres://postgres:pass@postgres:5432/chinook',
|
||||
isolation_level: 'read-committed',
|
||||
use_prepared_statements: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
@ -0,0 +1,38 @@
|
||||
const payload = {
|
||||
type: 'bulk',
|
||||
args: [
|
||||
{
|
||||
type: 'pg_drop_logical_model_select_permission',
|
||||
args: {
|
||||
permission: {},
|
||||
name: 'LogicalModel',
|
||||
role: 'editor',
|
||||
source: 'Postgres',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'pg_create_logical_model_select_permission',
|
||||
args: {
|
||||
name: 'LogicalModel',
|
||||
role: 'editor',
|
||||
permission: {
|
||||
filter: {},
|
||||
columns: [],
|
||||
},
|
||||
source: 'Postgres',
|
||||
},
|
||||
},
|
||||
],
|
||||
resource_version: 221,
|
||||
};
|
||||
|
||||
const response = [
|
||||
{
|
||||
message: 'success',
|
||||
},
|
||||
{
|
||||
message: 'success',
|
||||
},
|
||||
];
|
||||
|
||||
export default { payload, response };
|
@ -0,0 +1,23 @@
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { AccessType, Permission } from './components/types';
|
||||
|
||||
export function permissionRowAccess(permission: Permission): AccessType {
|
||||
if (!permission.filter || isEmpty(permission.filter)) {
|
||||
return 'noAccess';
|
||||
} else {
|
||||
return 'fullAccess';
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionColumnAccess(
|
||||
permission: Permission,
|
||||
selectedColumns: string[]
|
||||
): AccessType {
|
||||
if (permission.columns.length === 0) {
|
||||
return 'noAccess';
|
||||
} else if (permission.columns.length > selectedColumns.length) {
|
||||
return 'partialAccess';
|
||||
} else {
|
||||
return 'fullAccess';
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import startCase from 'lodash/startCase';
|
||||
import { ReactNode } from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
import { useServerConfig } from '../../../../hooks';
|
||||
import { Breadcrumbs } from '../../../../new-components/Breadcrumbs';
|
||||
|
@ -49,4 +49,9 @@ export const NATIVE_QUERY_ROUTES = {
|
||||
title: 'Track Stored Procedure',
|
||||
subtitle: 'Expose your stored SQL procedures via the GraphQL API',
|
||||
},
|
||||
'/data/native-queries/logical-models/{{source}}/{{name}}/permissions': {
|
||||
title: 'Logical Models Permissions',
|
||||
subtitle:
|
||||
'Add permissions to your Logical Models to control access to your data',
|
||||
},
|
||||
};
|
||||
|
@ -1,28 +0,0 @@
|
||||
import { Metadata } from '../../hasura-metadata-types';
|
||||
import { LogicalModelWithSource, NativeQueryWithSource } from './types';
|
||||
|
||||
export const extractModelsAndQueriesFromMetadata = (
|
||||
m: Metadata
|
||||
): { queries: NativeQueryWithSource[]; models: LogicalModelWithSource[] } => {
|
||||
const sources = m.metadata.sources;
|
||||
let models: LogicalModelWithSource[] = [];
|
||||
let queries: NativeQueryWithSource[] = [];
|
||||
|
||||
sources.forEach(s => {
|
||||
if (s.logical_models && s.logical_models.length > 0) {
|
||||
models = [...models, ...s.logical_models.map(m => ({ ...m, source: s }))];
|
||||
}
|
||||
|
||||
if (s.native_queries && s.native_queries.length > 0) {
|
||||
queries = [
|
||||
...queries,
|
||||
...s.native_queries.map(q => ({ ...q, source: s })),
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
models,
|
||||
queries,
|
||||
};
|
||||
};
|
@ -41,6 +41,8 @@ export const RowPermissionBuilder = ({
|
||||
}}
|
||||
table={table}
|
||||
tables={tables}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={value}
|
||||
comparators={comparators}
|
||||
/>
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { useContext } from 'react';
|
||||
import { rowPermissionsContext } from './RowPermissionsProvider';
|
||||
import { useOperators } from './utils/comparatorsFromSchema';
|
||||
import Select from 'react-select';
|
||||
import Select, { components } from 'react-select';
|
||||
import { FiChevronDown } from 'react-icons/fi';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export const Comparator = ({
|
||||
comparator,
|
||||
@ -21,7 +23,20 @@ export const Comparator = ({
|
||||
inputId={`${comparatorLevelId}-select-value`}
|
||||
isSearchable
|
||||
aria-label={comparatorLevelId}
|
||||
components={{ DropdownIndicator: null }}
|
||||
components={{
|
||||
DropdownIndicator: props => {
|
||||
const { className } = props;
|
||||
return (
|
||||
<components.DropdownIndicator
|
||||
{...props}
|
||||
className={clsx(className, '!text-gray-500 hover:!text-gray-500')}
|
||||
>
|
||||
<FiChevronDown className="w-5 h-5" />
|
||||
</components.DropdownIndicator>
|
||||
);
|
||||
},
|
||||
IndicatorSeparator: () => null,
|
||||
}}
|
||||
options={operators.map(o => ({
|
||||
value: o.name,
|
||||
label: o.name,
|
||||
|
@ -5,6 +5,7 @@ import { isColumnComparator } from './utils';
|
||||
import { ColumnComparatorEntry } from './EntryTypes/ColumnComparatorEntry';
|
||||
import { useOperators } from './utils/comparatorsFromSchema';
|
||||
import { ValueInput } from './ValueInput';
|
||||
import { useForbiddenFeatures } from './ForbiddenFeaturesProvider';
|
||||
|
||||
export const EntryType = ({
|
||||
k,
|
||||
@ -17,6 +18,7 @@ export const EntryType = ({
|
||||
}) => {
|
||||
const operators = useOperators({ path });
|
||||
const operator = operators.find(o => o.name === k);
|
||||
const { hasFeature } = useForbiddenFeatures();
|
||||
if (isColumnComparator(k)) {
|
||||
return <ColumnComparatorEntry k={k} path={path} v={v} />;
|
||||
}
|
||||
@ -27,6 +29,9 @@ export const EntryType = ({
|
||||
return <ArrayEntry k={k} v={v} path={path} />;
|
||||
}
|
||||
if (k === '_exists') {
|
||||
if (!hasFeature('exists')) {
|
||||
return null;
|
||||
}
|
||||
return <ExistsEntry k={k} v={v} path={path} />;
|
||||
}
|
||||
if (
|
||||
|
@ -3,8 +3,8 @@ import { isComparator } from '../utils/helpers';
|
||||
import { tableContext } from '../TableProvider';
|
||||
import { typesContext } from '../TypesProvider';
|
||||
import { rowPermissionsContext } from '../RowPermissionsProvider';
|
||||
import { areTablesEqual } from '../../../../../../hasura-metadata-api';
|
||||
import { createWrapper } from './utils';
|
||||
import { rootTableContext } from '../RootTableProvider';
|
||||
|
||||
export function ColumnComparatorEntry({
|
||||
k,
|
||||
@ -107,9 +107,9 @@ function RootColumnsSelect({
|
||||
v: any;
|
||||
path: string[];
|
||||
}) {
|
||||
const { table, tables, setValue } = useContext(rowPermissionsContext);
|
||||
const { setValue } = useContext(rowPermissionsContext);
|
||||
const value = v.find((v: any) => v !== '$');
|
||||
const rootTable = tables.find(t => areTablesEqual(t.table, table));
|
||||
const { rootTable } = useContext(rootTableContext);
|
||||
const testId = `${path.join('.')}-root-column-comparator-entry`;
|
||||
return (
|
||||
<select
|
||||
|
@ -0,0 +1,39 @@
|
||||
import { ReactNode, createContext, useCallback, useContext } from 'react';
|
||||
|
||||
export type Feature = 'exists';
|
||||
|
||||
const forbiddenFeaturesContext = createContext<{
|
||||
forbidden: Feature[];
|
||||
hasFeature: (feature: Feature) => boolean;
|
||||
}>({
|
||||
forbidden: [],
|
||||
hasFeature: () => {
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export function useForbiddenFeatures() {
|
||||
return useContext(forbiddenFeaturesContext);
|
||||
}
|
||||
|
||||
export function ForbiddenFeaturesProvider({
|
||||
forbidden = [],
|
||||
children,
|
||||
}: {
|
||||
forbidden: Feature[] | undefined;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const hasFeature = useCallback(
|
||||
function hasFeature(feature: Feature) {
|
||||
return !forbidden.includes(feature);
|
||||
},
|
||||
[forbidden]
|
||||
);
|
||||
return (
|
||||
<forbiddenFeaturesContext.Provider
|
||||
value={{ forbidden: forbidden, hasFeature }}
|
||||
>
|
||||
{children}
|
||||
</forbiddenFeaturesContext.Provider>
|
||||
);
|
||||
}
|
@ -3,11 +3,11 @@ import { useContext } from 'react';
|
||||
|
||||
import { getTableDisplayName } from '../../../../../DatabaseRelationships';
|
||||
import { rowPermissionsContext } from './RowPermissionsProvider';
|
||||
import { rootTableContext } from './RootTableProvider';
|
||||
|
||||
export const JsonEditor = () => {
|
||||
const { permissions, table, setPermissions } = useContext(
|
||||
rowPermissionsContext
|
||||
);
|
||||
const { permissions, setPermissions } = useContext(rowPermissionsContext);
|
||||
const { table } = useContext(rootTableContext);
|
||||
return (
|
||||
<div className="p-6 rounded-lg bg-white border border-gray-200 min-h-32 w-full">
|
||||
<AceEditor
|
||||
|
@ -0,0 +1,57 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { RowPermissionsInput } from './RowPermissionsInput';
|
||||
import { comparators } from './__tests__/fixtures/comparators';
|
||||
import { handlers } from './__tests__/fixtures/jsonb/handlers';
|
||||
import { ReactQueryDecorator } from '../../../../../../storybook/decorators/react-query';
|
||||
|
||||
export default {
|
||||
title: 'Features/Permissions/Form/Logical Model Permissions Input',
|
||||
component: RowPermissionsInput,
|
||||
parameters: {
|
||||
msw: handlers(),
|
||||
},
|
||||
decorators: [ReactQueryDecorator()],
|
||||
} as Meta;
|
||||
|
||||
type Story = StoryObj<typeof RowPermissionsInput>;
|
||||
|
||||
export const Basic: Story = {
|
||||
args: {
|
||||
table: undefined,
|
||||
tables: [],
|
||||
onPermissionsChange: action('onPermissionsChange'),
|
||||
logicalModels: [
|
||||
{
|
||||
fields: [
|
||||
{ name: 'one', nullable: false, type: 'text' },
|
||||
{ name: 'two', nullable: false, type: 'text' },
|
||||
],
|
||||
name: 'hello_world',
|
||||
source: {
|
||||
name: 'default',
|
||||
kind: 'postgres',
|
||||
configuration: {},
|
||||
tables: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{ name: 'a', nullable: false, type: 'text' },
|
||||
{ name: 'b', nullable: false, type: 'text' },
|
||||
],
|
||||
name: 'logical_model',
|
||||
source: {
|
||||
name: 'default',
|
||||
kind: 'postgres',
|
||||
configuration: {},
|
||||
tables: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
logicalModel: 'hello_world',
|
||||
comparators,
|
||||
permissions: { one: { _eq: 'eqone' } },
|
||||
},
|
||||
};
|
@ -3,6 +3,8 @@ import { useContext } from 'react';
|
||||
import { rowPermissionsContext } from './RowPermissionsProvider';
|
||||
import { tableContext } from './TableProvider';
|
||||
import { PermissionType } from './types';
|
||||
import { logicalModelContext } from './RootLogicalModelProvider';
|
||||
import { useForbiddenFeatures } from './ForbiddenFeaturesProvider';
|
||||
|
||||
export const Operator = ({
|
||||
operator,
|
||||
@ -15,12 +17,14 @@ export const Operator = ({
|
||||
}) => {
|
||||
const { operators, setKey } = useContext(rowPermissionsContext);
|
||||
const { columns, table, relationships } = useContext(tableContext);
|
||||
const { rootLogicalModel } = useContext(logicalModelContext);
|
||||
const parent = path[path.length - 1];
|
||||
const operatorLevelId = `${path?.join('.')}-operator`;
|
||||
const { hasFeature } = useForbiddenFeatures();
|
||||
return (
|
||||
<select
|
||||
data-testid={operatorLevelId || 'root-operator-picker'}
|
||||
className="border border-gray-200 rounded-md p-2"
|
||||
className="border border-gray-200 rounded-md p-2 pr-4"
|
||||
value={operator}
|
||||
disabled={parent === '_where' && isEmpty(table)}
|
||||
onChange={e => {
|
||||
@ -55,7 +59,20 @@ export const Operator = ({
|
||||
))}
|
||||
</optgroup>
|
||||
) : null}
|
||||
{operators.exist?.items.length ? (
|
||||
{rootLogicalModel?.fields.length ? (
|
||||
<optgroup label="Columns">
|
||||
{rootLogicalModel?.fields.map((field, index) => (
|
||||
<option
|
||||
data-type="column"
|
||||
key={'column' + index}
|
||||
value={field.name}
|
||||
>
|
||||
{field.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
) : null}
|
||||
{hasFeature('exists') && operators.exist?.items.length ? (
|
||||
<optgroup label="Exist operators">
|
||||
{operators.exist.items.map((item, index) => (
|
||||
<option data-type="exist" key={'exist' + index} value={item.value}>
|
||||
|
@ -0,0 +1,35 @@
|
||||
import { createContext } from 'react';
|
||||
import { LogicalModel } from '../../../../../hasura-metadata-types';
|
||||
|
||||
type LogicalModelState = {
|
||||
rootLogicalModel: LogicalModel | undefined;
|
||||
logicalModels: LogicalModel[];
|
||||
logicalModel: LogicalModel['name'] | undefined;
|
||||
};
|
||||
|
||||
export const logicalModelContext = createContext<LogicalModelState>({
|
||||
logicalModel: '',
|
||||
logicalModels: [],
|
||||
rootLogicalModel: undefined,
|
||||
});
|
||||
|
||||
export const RootLogicalModelProvider = ({
|
||||
children,
|
||||
logicalModel,
|
||||
logicalModels,
|
||||
}: Omit<LogicalModelState, 'rootLogicalModel'> & {
|
||||
children?: React.ReactNode | undefined;
|
||||
}) => {
|
||||
const rootLogicalModel = logicalModels.find(t => t.name === logicalModel);
|
||||
return (
|
||||
<logicalModelContext.Provider
|
||||
value={{
|
||||
logicalModel,
|
||||
logicalModels,
|
||||
rootLogicalModel,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</logicalModelContext.Provider>
|
||||
);
|
||||
};
|
@ -0,0 +1,45 @@
|
||||
import { createContext } from 'react';
|
||||
import { Table } from '../../../../../hasura-metadata-types';
|
||||
import { areTablesEqual } from '../../../../../hasura-metadata-api';
|
||||
import { Tables } from './types';
|
||||
|
||||
type RootTableState = {
|
||||
tables: Tables;
|
||||
table: Table;
|
||||
rootTable: Tables[number] | undefined;
|
||||
};
|
||||
|
||||
export const rootTableContext = createContext<RootTableState>({
|
||||
table: {},
|
||||
tables: [],
|
||||
rootTable: undefined,
|
||||
});
|
||||
|
||||
/**
|
||||
* RootTableProvider
|
||||
*
|
||||
* Provides tables array, table definition (just name) and root table (full table) in context so it can be accessed in any of its children
|
||||
* Not to be confused with TableProvider, which provides the current table
|
||||
* RootTableProvider is meant to be used just once, at the top of the tree,
|
||||
* whereas there are many TableProvider in the tree, used mainly for selecting relationships
|
||||
*/
|
||||
export const RootTableProvider = ({
|
||||
children,
|
||||
table,
|
||||
tables,
|
||||
}: Omit<RootTableState, 'rootTable'> & {
|
||||
children?: React.ReactNode | undefined;
|
||||
}) => {
|
||||
const rootTable = tables.find(t => areTablesEqual(t.table, table));
|
||||
return (
|
||||
<rootTableContext.Provider
|
||||
value={{
|
||||
table,
|
||||
tables,
|
||||
rootTable,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</rootTableContext.Provider>
|
||||
);
|
||||
};
|
@ -38,6 +38,8 @@ export const SetRootLevelPermission: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{}}
|
||||
/>
|
||||
),
|
||||
@ -56,6 +58,8 @@ export const SetExistsPermission: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{}}
|
||||
/>
|
||||
),
|
||||
@ -95,6 +99,8 @@ export const SetMultilevelExistsPermission: StoryObj<
|
||||
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{}}
|
||||
/>
|
||||
),
|
||||
@ -142,6 +148,8 @@ export const SetAndPermission: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{}}
|
||||
/>
|
||||
),
|
||||
@ -176,6 +184,8 @@ export const SetMultilevelAndPermission: StoryObj<typeof RowPermissionsInput> =
|
||||
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{}}
|
||||
/>
|
||||
),
|
||||
@ -218,6 +228,8 @@ export const SetNotPermission: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{}}
|
||||
/>
|
||||
),
|
||||
@ -244,6 +256,8 @@ export const SetOrPermission: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{}}
|
||||
/>
|
||||
),
|
||||
@ -276,6 +290,8 @@ export const SetMultilevelOrPermission: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{}}
|
||||
/>
|
||||
),
|
||||
@ -317,6 +333,8 @@ export const Empty: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{}}
|
||||
/>
|
||||
),
|
||||
@ -329,6 +347,8 @@ export const Exists: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{
|
||||
_exists: {
|
||||
_table: {},
|
||||
@ -347,6 +367,8 @@ export const SetDisabledExistsPermission: StoryObj<typeof RowPermissionsInput> =
|
||||
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{
|
||||
_exists: {
|
||||
_table: {},
|
||||
@ -378,6 +400,8 @@ export const ExistsWhere: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{
|
||||
_exists: {
|
||||
_table: { dataset: 'bigquery_sample', name: 'sample_table' },
|
||||
@ -400,6 +424,8 @@ export const EmptyExists: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{
|
||||
_exists: {
|
||||
_table: {},
|
||||
@ -417,6 +443,8 @@ export const And: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{
|
||||
_and: [
|
||||
{ STATUS: { _eq: 'X-Hasura-User-Id' } },
|
||||
@ -434,6 +462,8 @@ export const EmptyAnd: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{
|
||||
_and: [{}],
|
||||
}}
|
||||
@ -448,6 +478,8 @@ export const Not: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{
|
||||
_not: { STATUS: { _eq: 'X-Hasura-User-Id' } },
|
||||
}}
|
||||
@ -462,6 +494,8 @@ export const EmptyNot: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{
|
||||
_not: {},
|
||||
}}
|
||||
@ -476,6 +510,8 @@ export const Relationships: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={['Album']}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{ Author: { name: { _eq: '' } } }}
|
||||
/>
|
||||
),
|
||||
@ -488,6 +524,8 @@ export const RelationshipsColumns: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={['Album']}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{ Label: { id: { _eq: '' } } }}
|
||||
/>
|
||||
),
|
||||
@ -500,6 +538,8 @@ export const ColumnTypes: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{ Series_reference: { _eq: '' } }}
|
||||
/>
|
||||
),
|
||||
@ -512,6 +552,8 @@ export const BooleanArrayType: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={['Album']}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{ Author: { _ceq: ['name'] } }}
|
||||
/>
|
||||
),
|
||||
@ -536,6 +578,8 @@ export const BooleanArrayTypeRoot: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={['Album']}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{
|
||||
Author: {
|
||||
_ceq: [
|
||||
@ -589,6 +633,8 @@ export const StringObjectType: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={{ name: 'user_location', schema: 'public' }}
|
||||
tables={tableWithGeolocationSupport}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{
|
||||
location: {
|
||||
_st_d_within: {
|
||||
@ -619,6 +665,8 @@ export const NumericValue: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={['Album']}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{ id: { _eq: '' } }}
|
||||
/>
|
||||
),
|
||||
@ -638,6 +686,8 @@ export const NumericIntValue: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={['Album']}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{ id: { _eq: 0 } }}
|
||||
/>
|
||||
),
|
||||
@ -650,6 +700,8 @@ export const NumericFloatValue: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={['Album']}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{ id: { _eq: 0.9 } }}
|
||||
/>
|
||||
),
|
||||
@ -670,6 +722,8 @@ export const JsonbColumns: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={{ schema: 'public', name: 'Stuff' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{ jason: { _contained_in: { a: 'b' } } }}
|
||||
/>
|
||||
);
|
||||
@ -710,6 +764,8 @@ export const JsonbColumnsHasKeys: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={{ schema: 'public', name: 'Stuff' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{ jason: { _has_keys_all: [''] } }}
|
||||
/>
|
||||
);
|
||||
@ -737,6 +793,8 @@ export const StringColumns: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={{ schema: 'public', name: 'Stuff' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={permissions}
|
||||
/>
|
||||
);
|
||||
@ -752,13 +810,7 @@ export const StringColumns: StoryObj<typeof RowPermissionsInput> = {
|
||||
// Write a number in the input
|
||||
await userEvent.type(canvas.getByTestId('name._eq-value-input'), '1337');
|
||||
|
||||
const onPermissionsChangeMock = args.onPermissionsChange as jest.Mock;
|
||||
|
||||
const latestPermissions =
|
||||
onPermissionsChangeMock?.mock.calls[
|
||||
onPermissionsChangeMock?.mock.calls.length - 1
|
||||
][0];
|
||||
expect(latestPermissions).toEqual({
|
||||
expect(args.onPermissionsChange).toHaveBeenCalledWith({
|
||||
name: {
|
||||
_eq: 1337,
|
||||
},
|
||||
@ -787,6 +839,8 @@ export const NumberColumns: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={{ schema: 'public', name: 'Stuff' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={permissions}
|
||||
/>
|
||||
);
|
||||
@ -802,13 +856,7 @@ export const NumberColumns: StoryObj<typeof RowPermissionsInput> = {
|
||||
// // Write a number in the input
|
||||
await userEvent.type(canvas.getByTestId('id._eq-value-input'), '1337');
|
||||
|
||||
const onPermissionsChangeMock = args.onPermissionsChange as jest.Mock;
|
||||
|
||||
const latestPermissions =
|
||||
onPermissionsChangeMock?.mock.calls[
|
||||
onPermissionsChangeMock?.mock.calls.length - 1
|
||||
][0];
|
||||
expect(latestPermissions).toEqual({
|
||||
expect(args.onPermissionsChange).toHaveBeenCalledWith({
|
||||
id: {
|
||||
_eq: 12341337,
|
||||
},
|
||||
@ -823,6 +871,8 @@ export const OperatorDropdownHandling: StoryObj<typeof RowPermissionsInput> = {
|
||||
table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
logicalModel={undefined}
|
||||
logicalModels={[]}
|
||||
permissions={{
|
||||
_not: { STATUS: { _eq: 'X-Hasura-User-Id' } },
|
||||
}}
|
||||
|
@ -1,23 +1,36 @@
|
||||
import { Table } from '../../../../../hasura-metadata-types';
|
||||
import { LogicalModel, Table } from '../../../../../hasura-metadata-types';
|
||||
import { Tables, Operators, Permissions, Comparators } from './types';
|
||||
import { RowPermissionsProvider } from './RowPermissionsProvider';
|
||||
import { TypesProvider } from './TypesProvider';
|
||||
import { TableProvider } from './TableProvider';
|
||||
import { RootInput } from './RootInput';
|
||||
import { JsonEditor } from './JsonEditor';
|
||||
import { RootTableProvider } from './RootTableProvider';
|
||||
import { RootLogicalModelProvider } from './RootLogicalModelProvider';
|
||||
import { LogicalModelWithSourceName } from '../../../../../Data/LogicalModels/LogicalModelPermissions/components/types';
|
||||
import {
|
||||
ForbiddenFeaturesProvider,
|
||||
Feature,
|
||||
} from './ForbiddenFeaturesProvider';
|
||||
|
||||
export const RowPermissionsInput = ({
|
||||
permissions,
|
||||
tables,
|
||||
table,
|
||||
logicalModel,
|
||||
logicalModels,
|
||||
onPermissionsChange,
|
||||
comparators,
|
||||
forbidden,
|
||||
}: {
|
||||
permissions: Permissions;
|
||||
tables: Tables;
|
||||
table: Table;
|
||||
table: Table | undefined;
|
||||
logicalModels: LogicalModelWithSourceName[];
|
||||
logicalModel: LogicalModel['name'] | undefined;
|
||||
onPermissionsChange?: (permissions: Permissions) => void;
|
||||
comparators: Comparators;
|
||||
forbidden?: Feature[];
|
||||
}) => {
|
||||
const operators: Operators = {
|
||||
boolean: {
|
||||
@ -34,11 +47,15 @@ export const RowPermissionsInput = ({
|
||||
},
|
||||
};
|
||||
return (
|
||||
<ForbiddenFeaturesProvider forbidden={forbidden}>
|
||||
<RootLogicalModelProvider
|
||||
logicalModel={logicalModel}
|
||||
logicalModels={logicalModels}
|
||||
>
|
||||
<RootTableProvider table={table} tables={tables}>
|
||||
<RowPermissionsProvider
|
||||
operators={operators}
|
||||
permissions={permissions}
|
||||
table={table}
|
||||
tables={tables}
|
||||
comparators={comparators}
|
||||
onPermissionsChange={onPermissionsChange}
|
||||
>
|
||||
@ -51,5 +68,8 @@ export const RowPermissionsInput = ({
|
||||
</TableProvider>
|
||||
</TypesProvider>
|
||||
</RowPermissionsProvider>
|
||||
</RootTableProvider>
|
||||
</RootLogicalModelProvider>
|
||||
</ForbiddenFeaturesProvider>
|
||||
);
|
||||
};
|
||||
|
@ -4,8 +4,6 @@ import { Permissions, RowPermissionsState } from './types';
|
||||
import { updateKey } from './utils/helpers';
|
||||
|
||||
export const rowPermissionsContext = createContext<RowPermissionsState>({
|
||||
table: {},
|
||||
tables: [],
|
||||
comparators: {},
|
||||
operators: {},
|
||||
permissions: {},
|
||||
@ -18,14 +16,9 @@ export const RowPermissionsProvider = ({
|
||||
children,
|
||||
operators,
|
||||
permissions,
|
||||
table,
|
||||
tables,
|
||||
comparators,
|
||||
onPermissionsChange,
|
||||
}: Pick<
|
||||
RowPermissionsState,
|
||||
'permissions' | 'operators' | 'table' | 'tables' | 'comparators'
|
||||
> & {
|
||||
}: Pick<RowPermissionsState, 'permissions' | 'operators' | 'comparators'> & {
|
||||
children?: React.ReactNode | undefined;
|
||||
onPermissionsChange?: (permissions: Permissions) => void;
|
||||
}) => {
|
||||
@ -79,9 +72,7 @@ export const RowPermissionsProvider = ({
|
||||
...permissionsState,
|
||||
setValue,
|
||||
setKey,
|
||||
table,
|
||||
setPermissions,
|
||||
tables,
|
||||
comparators,
|
||||
}}
|
||||
>
|
||||
|
@ -4,6 +4,7 @@ import { Table } from '../../../../../hasura-metadata-types';
|
||||
import { getTableDisplayName } from '../../../../../DatabaseRelationships';
|
||||
import { tableContext } from './TableProvider';
|
||||
import { rowPermissionsContext } from './RowPermissionsProvider';
|
||||
import { rootTableContext } from './RootTableProvider';
|
||||
|
||||
export function SelectTable({
|
||||
componentLevelId,
|
||||
@ -16,7 +17,8 @@ export function SelectTable({
|
||||
}) {
|
||||
const comparatorName = path[path.length - 1];
|
||||
const { table, setTable, setComparator } = useContext(tableContext);
|
||||
const { setValue, tables } = useContext(rowPermissionsContext);
|
||||
const { setValue } = useContext(rowPermissionsContext);
|
||||
const { tables } = useContext(rootTableContext);
|
||||
const stringifiedTable = JSON.stringify(table);
|
||||
// Sync table name with ColumnsContext table value
|
||||
useEffect(() => {
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { useState, useContext, useEffect, createContext } from 'react';
|
||||
import { areTablesEqual } from '../../../../../hasura-metadata-api';
|
||||
import { Table } from '../../../../../hasura-metadata-types';
|
||||
import { rowPermissionsContext } from './RowPermissionsProvider';
|
||||
import { Columns, Relationships, TableContext } from './types';
|
||||
import { rootTableContext } from './RootTableProvider';
|
||||
import { areTablesEqual } from '../../../../../hasura-metadata-api';
|
||||
|
||||
export const tableContext = createContext<TableContext>({
|
||||
table: {},
|
||||
@ -26,7 +26,7 @@ export const TableProvider = ({
|
||||
const [comparator, setComparator] = useState<string | undefined>();
|
||||
const [columns, setColumns] = useState<Columns>([]);
|
||||
const [relationships, setRelationships] = useState<Relationships>([]);
|
||||
const { tables } = useContext(rowPermissionsContext);
|
||||
const { tables } = useContext(rootTableContext);
|
||||
// Stringify values to get a stable value for useEffect
|
||||
const stringifiedTable = JSON.stringify(table);
|
||||
const stringifiedTables = JSON.stringify(tables);
|
||||
|
@ -10,6 +10,7 @@ import { rowPermissionsContext } from './RowPermissionsProvider';
|
||||
import set from 'lodash/set';
|
||||
import unset from 'lodash/unset';
|
||||
import { getPermissionTypes } from './utils/typeProviderHelpers';
|
||||
import { rootTableContext } from './RootTableProvider';
|
||||
|
||||
export const typesContext = createContext<TypesContext>({
|
||||
types: {},
|
||||
@ -22,7 +23,8 @@ export const TypesProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [types, setTypes] = useState<Record<string, { type: PermissionType }>>(
|
||||
{}
|
||||
);
|
||||
const { permissions, tables, table } = useContext(rowPermissionsContext);
|
||||
const { permissions } = useContext(rowPermissionsContext);
|
||||
const { table, tables } = useContext(rootTableContext);
|
||||
const setType = useCallback(
|
||||
({
|
||||
type,
|
||||
|
@ -46,8 +46,6 @@ export type RowPermissionsState = {
|
||||
operators: Operators;
|
||||
permissions: Permissions;
|
||||
comparators: Comparators;
|
||||
table: Table;
|
||||
tables: Tables;
|
||||
setValue: (path: string[], value: any) => void;
|
||||
setKey: (props: { path: string[]; key: any; type: PermissionType }) => void;
|
||||
setPermissions: (permissions: Permissions) => void;
|
||||
|
@ -8,6 +8,7 @@ import { Table } from '../../../../../../hasura-metadata-types';
|
||||
import { useContext } from 'react';
|
||||
import { rowPermissionsContext } from '../RowPermissionsProvider';
|
||||
import { sourceDataTypes, SourceDataTypes } from './sourceDataTypes';
|
||||
import { rootTableContext } from '../RootTableProvider';
|
||||
|
||||
function columnOperators(): Array<Operator> {
|
||||
return Object.keys(columnOperatorsInfo).reduce((acc, key) => {
|
||||
@ -136,7 +137,8 @@ export const mapScalarDataType = (
|
||||
};
|
||||
|
||||
export function useOperators({ path }: { path: string[] }) {
|
||||
const { comparators, tables } = useContext(rowPermissionsContext);
|
||||
const { comparators } = useContext(rowPermissionsContext);
|
||||
const { tables } = useContext(rootTableContext);
|
||||
const { columns, table } = useContext(tableContext);
|
||||
|
||||
const columnName = path[path.length - 2];
|
||||
|
@ -69,7 +69,6 @@ export const createDefaultValues = ({
|
||||
queryType: 'select',
|
||||
filterType: 'none',
|
||||
columns: {},
|
||||
|
||||
supportedOperators,
|
||||
};
|
||||
|
||||
|
@ -67,7 +67,8 @@ export const useFormData = ({
|
||||
dataSourceName,
|
||||
})) as Operator[];
|
||||
|
||||
const defaultValues = createDefaultValues({
|
||||
const defaultValues = {
|
||||
...createDefaultValues({
|
||||
queryType,
|
||||
roleName,
|
||||
dataSourceName,
|
||||
@ -77,7 +78,8 @@ export const useFormData = ({
|
||||
defaultQueryRoot,
|
||||
metadataSource,
|
||||
supportedOperators: supportedOperators ?? [],
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
const formData = createFormData({
|
||||
dataSourceName,
|
||||
|
@ -1,3 +1,7 @@
|
||||
import {
|
||||
LogicalModelWithSource,
|
||||
NativeQueryWithSource,
|
||||
} from '../Data/LogicalModels/types';
|
||||
import { Metadata, Table } from '../hasura-metadata-types';
|
||||
import * as utils from './utils';
|
||||
|
||||
@ -31,3 +35,29 @@ export const findTable =
|
||||
utils.findMetadataTable(dataSourceName, table, m);
|
||||
|
||||
export const resourceVersion = () => (m: Metadata) => m?.resource_version;
|
||||
|
||||
export const extractModelsAndQueriesFromMetadata = (
|
||||
m: Metadata
|
||||
): { queries: NativeQueryWithSource[]; models: LogicalModelWithSource[] } => {
|
||||
const sources = m.metadata.sources;
|
||||
let models: LogicalModelWithSource[] = [];
|
||||
let queries: NativeQueryWithSource[] = [];
|
||||
|
||||
sources.forEach(s => {
|
||||
if (s.logical_models && s.logical_models.length > 0) {
|
||||
models = [...models, ...s.logical_models.map(m => ({ ...m, source: s }))];
|
||||
}
|
||||
|
||||
if (s.native_queries && s.native_queries.length > 0) {
|
||||
queries = [
|
||||
...queries,
|
||||
...s.native_queries.map(q => ({ ...q, source: s })),
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
models,
|
||||
queries,
|
||||
};
|
||||
};
|
||||
|
@ -7,4 +7,11 @@ export type LogicalModelField = {
|
||||
export type LogicalModel = {
|
||||
fields: LogicalModelField[];
|
||||
name: string;
|
||||
select_permissions?: {
|
||||
permission: {
|
||||
columns: string[];
|
||||
filter: Record<string, any>;
|
||||
};
|
||||
role: string;
|
||||
}[];
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user