Add database authorization checks

This commit is contained in:
Hakan Shehu
2024-11-13 09:49:12 +01:00
parent 68dc8278f3
commit 55ce7e59f4
18 changed files with 210 additions and 81 deletions

View File

@@ -7,7 +7,7 @@ import {
RecordNode,
} from '@colanode/core';
import { compareString, isStringArray } from '@/lib/utils';
import { generateNodeIndex } from './nodes';
import { generateNodeIndex } from '@/lib/nodes';
export const getDefaultFieldWidth = (type: FieldType): number => {
if (!type) return 0;

View File

@@ -1,14 +1,15 @@
import { DatabaseNode } from '@colanode/core';
import { DatabaseNode, NodeRole } from '@colanode/core';
import { DatabaseViews } from '@/renderer/components/databases/database-views';
import { Database } from '@/renderer/components/databases/database';
interface DatabaseBodyProps {
database: DatabaseNode;
role: NodeRole;
}
export const DatabaseBody = ({ database }: DatabaseBodyProps) => {
export const DatabaseBody = ({ database, role }: DatabaseBodyProps) => {
return (
<Database databaseId={database.id}>
<Database database={database} role={role}>
<DatabaseViews />
</Database>
);

View File

@@ -31,7 +31,7 @@ export const DatabaseContainer = ({ nodeId }: DatabaseContainerProps) => {
return (
<div className="flex h-full w-full flex-col">
<DatabaseHeader nodes={nodes} database={database} role={role} />
<DatabaseBody database={database} />
<DatabaseBody database={database} role={role} />
</div>
);
};

View File

@@ -1,44 +1,45 @@
import React from 'react';
import { DatabaseContext } from '@/renderer/contexts/database';
import { useQuery } from '@/renderer/hooks/use-query';
import { useWorkspace } from '@/renderer/contexts/workspace';
import { useMutation } from '@/renderer/hooks/use-mutation';
import {
DatabaseNode,
hasCollaboratorAccess,
hasEditorAccess,
NodeRole,
} from '@colanode/core';
interface DatabaseProps {
databaseId: string;
database: DatabaseNode;
role: NodeRole;
children: React.ReactNode;
}
export const Database = ({ databaseId, children }: DatabaseProps) => {
export const Database = ({ database, role, children }: DatabaseProps) => {
const workspace = useWorkspace();
const { data, isPending } = useQuery({
type: 'node_get',
nodeId: databaseId,
userId: workspace.userId,
});
const { mutate } = useMutation();
const { mutate, isPending: isMutating } = useMutation();
if (isPending || isMutating || !data) {
return null;
}
if (data.type !== 'database') {
return null;
}
const canEdit = hasEditorAccess(role);
const canCreateRecord = hasCollaboratorAccess(role);
return (
<DatabaseContext.Provider
value={{
id: data.id,
name: data.attributes.name,
fields: Object.values(data.attributes.fields),
views: Object.values(data.attributes.views),
id: database.id,
name: database.attributes.name,
fields: Object.values(database.attributes.fields),
views: Object.values(database.attributes.views),
canEdit,
canCreateRecord,
createField: (type, name) => {
if (!canEdit) {
return;
}
mutate({
input: {
type: 'field_create',
databaseId: data.id,
databaseId: database.id,
name,
fieldType: type,
userId: workspace.userId,
@@ -46,10 +47,14 @@ export const Database = ({ databaseId, children }: DatabaseProps) => {
});
},
renameField: (id, name) => {
if (!canEdit) {
return;
}
mutate({
input: {
type: 'node_attribute_set',
nodeId: data.id,
nodeId: database.id,
path: `fields.${id}.name`,
value: name,
userId: workspace.userId,
@@ -57,20 +62,28 @@ export const Database = ({ databaseId, children }: DatabaseProps) => {
});
},
deleteField: (id) => {
if (!canEdit) {
return;
}
mutate({
input: {
type: 'node_attribute_delete',
nodeId: data.id,
nodeId: database.id,
path: `fields.${id}`,
userId: workspace.userId,
},
});
},
createSelectOption: (fieldId, name, color) => {
if (!canEdit) {
return;
}
mutate({
input: {
type: 'select_option_create',
databaseId: data.id,
databaseId: database.id,
fieldId,
name,
color,
@@ -79,10 +92,14 @@ export const Database = ({ databaseId, children }: DatabaseProps) => {
});
},
updateSelectOption: (fieldId, attributes) => {
if (!canEdit) {
return;
}
mutate({
input: {
type: 'node_attribute_set',
nodeId: data.id,
nodeId: database.id,
path: `fields.${fieldId}.options.${attributes.id}`,
value: attributes,
userId: workspace.userId,
@@ -90,10 +107,14 @@ export const Database = ({ databaseId, children }: DatabaseProps) => {
});
},
deleteSelectOption: (fieldId, optionId) => {
if (!canEdit) {
return;
}
mutate({
input: {
type: 'node_attribute_delete',
nodeId: data.id,
nodeId: database.id,
path: `fields.${fieldId}.options.${optionId}`,
userId: workspace.userId,
},

View File

@@ -83,6 +83,10 @@ export const FieldCreatePopover = () => {
});
};
if (!database.canEdit) {
return null;
}
return (
<Popover open={open} onOpenChange={setOpen} modal={true}>
<PopoverTrigger>

View File

@@ -13,6 +13,7 @@ export const FieldRenameInput = ({ field }: FieldRenameInputProps) => {
<div className="w-full p-1">
<SmartTextInput
value={field.name}
readOnly={!database.canEdit}
onChange={(newName) => {
if (newName === field.name) return;
database.renameField(field.id, newName);

View File

@@ -14,6 +14,7 @@ import { useView } from '@/renderer/contexts/view';
import { FieldIcon } from '@/renderer/components/databases/fields/field-icon';
import { ArrowDownAz, ArrowDownZa, EyeOff, Filter, Trash2 } from 'lucide-react';
import { ViewField } from '@/types/databases';
import { useDatabase } from '@/renderer/contexts/database';
interface TableViewFieldHeaderProps {
viewField: ViewField;
@@ -22,17 +23,15 @@ interface TableViewFieldHeaderProps {
export const TableViewFieldHeader = ({
viewField,
}: TableViewFieldHeaderProps) => {
const database = useDatabase();
const view = useView();
const canEditDatabase = true;
const canEditView = true;
const [showDeleteDialog, setShowDeleteDialog] = React.useState(false);
const [, dragRef] = useDrag<ViewField>({
type: 'table-field-header',
item: viewField,
canDrag: () => canEditView,
canDrag: () => database.canEdit,
end: (_item, monitor) => {
const dropResult = monitor.getDropResult<{ after: string }>();
if (!dropResult?.after) return;
@@ -76,7 +75,7 @@ export const TableViewFieldHeader = ({
bottomLeft: false,
bottomRight: false,
left: false,
right: canEditView,
right: database.canEdit,
top: false,
topLeft: false,
topRight: false,
@@ -151,7 +150,7 @@ export const TableViewFieldHeader = ({
<span>Filter</span>
</div>
<Separator />
{canEditView && (
{database.canEdit && (
<div
className="flex cursor-pointer flex-row items-center gap-2 p-1 hover:bg-gray-100"
onClick={() => {
@@ -162,7 +161,7 @@ export const TableViewFieldHeader = ({
<span>Hide in view</span>
</div>
)}
{canEditDatabase && (
{database.canEdit && (
<div
className="flex cursor-pointer flex-row items-center gap-2 p-1 hover:bg-gray-100"
onClick={() => {

View File

@@ -2,8 +2,10 @@ import { useView } from '@/renderer/contexts/view';
import { TableViewNameHeader } from '@/renderer/components/databases/tables/table-view-name-header';
import { TableViewFieldHeader } from '@/renderer/components/databases/tables/table-view-field-header';
import { FieldCreatePopover } from '@/renderer/components/databases/fields/field-create-popover';
import { useDatabase } from '@/renderer/contexts/database';
export const TableViewHeader = () => {
const database = useDatabase();
const view = useView();
return (
@@ -13,7 +15,7 @@ export const TableViewHeader = () => {
{view.fields.map((field) => {
return <TableViewFieldHeader viewField={field} key={field.field.id} />;
})}
<FieldCreatePopover />
{database.canEdit && <FieldCreatePopover />}
</div>
);
};

View File

@@ -1,3 +1,4 @@
import React from 'react';
import { Resizable } from 're-resizable';
import {
Popover,
@@ -10,12 +11,12 @@ import { useView } from '@/renderer/contexts/view';
import { useDrop } from 'react-dnd';
import { cn } from '@/lib/utils';
import { ArrowDownAz, ArrowDownZa, Filter, Type } from 'lucide-react';
import { useDatabase } from '@/renderer/contexts/database';
export const TableViewNameHeader = () => {
const database = useDatabase();
const view = useView();
const canEditView = true;
const [dropMonitor, dropRef] = useDrop({
accept: 'table-field-header',
drop: () => ({
@@ -41,7 +42,7 @@ export const TableViewNameHeader = () => {
bottomLeft: false,
bottomRight: false,
left: false,
right: canEditView,
right: database.canEdit,
top: false,
topLeft: false,
topRight: false,
@@ -86,8 +87,8 @@ export const TableViewNameHeader = () => {
/>
</div>
<Separator />
{true && (
<>
{database.canEdit && (
<React.Fragment>
<div
className="flex cursor-pointer flex-row items-center gap-2 p-1 hover:bg-gray-100"
onClick={() => {
@@ -117,7 +118,7 @@ export const TableViewNameHeader = () => {
<ArrowDownZa className="size-4" />
<span>Sort descending</span>
</div>
</>
</React.Fragment>
)}
<div className="flex cursor-pointer flex-row items-center gap-2 p-1 hover:bg-gray-100">
<Filter className="size-4" />

View File

@@ -8,6 +8,10 @@ export const TableViewRecordCreateRow = () => {
const database = useDatabase();
const { mutate, isPending } = useMutation();
if (!database.canCreateRecord) {
return null;
}
return (
<button
type="button"

View File

@@ -35,10 +35,6 @@ export const TableViewSettingsPopover = () => {
const [openDelete, setOpenDelete] = React.useState(false);
const [deleteFieldId, setDeleteFieldId] = React.useState<string | null>(null);
const canEditDatabase = true;
const canEditView = true;
const canDeleteView = true;
return (
<React.Fragment>
<Popover open={open} onOpenChange={setOpen}>
@@ -49,22 +45,33 @@ export const TableViewSettingsPopover = () => {
</PopoverTrigger>
<PopoverContent className="mr-4 flex w-[600px] flex-col gap-1.5 p-2">
<div className="flex flex-row items-center gap-2">
<AvatarPopover
onPick={(avatar) => {
if (isPending) return;
if (avatar === view.avatar) return;
{database.canEdit ? (
<AvatarPopover
onPick={(avatar) => {
if (isPending) return;
if (avatar === view.avatar) return;
mutate({
input: {
type: 'node_attribute_set',
nodeId: view.id,
path: 'avatar',
value: avatar,
userId: workspace.userId,
},
});
}}
>
mutate({
input: {
type: 'node_attribute_set',
nodeId: view.id,
path: 'avatar',
value: avatar,
userId: workspace.userId,
},
});
}}
>
<Button type="button" variant="outline" size="icon">
<Avatar
id={view.id}
name={view.name}
avatar={view.avatar}
className="h-6 w-6"
/>
</Button>
</AvatarPopover>
) : (
<Button type="button" variant="outline" size="icon">
<Avatar
id={view.id}
@@ -73,9 +80,10 @@ export const TableViewSettingsPopover = () => {
className="h-6 w-6"
/>
</Button>
</AvatarPopover>
)}
<SmartTextInput
value={view.name}
readOnly={!database.canEdit}
onChange={(newName) => {
if (isPending) return;
if (newName === view.name) return;
@@ -117,10 +125,10 @@ export const TableViewSettingsPopover = () => {
<TooltipTrigger>
<span
className={cn(
canEditView ? 'cursor-pointer' : 'opacity-50'
database.canEdit ? 'cursor-pointer' : 'opacity-50'
)}
onClick={() => {
if (!canEditView) return;
if (!database.canEdit) return;
view.setFieldDisplay(field.id, !isHidden);
}}
@@ -138,13 +146,13 @@ export const TableViewSettingsPopover = () => {
: 'Hide field from this view'}
</TooltipContent>
</Tooltip>
{canEditDatabase && (
{database.canEdit && (
<Tooltip>
<TooltipTrigger asChild>
<Trash2
className={cn(
'size-4',
canEditView ? 'cursor-pointer' : 'opacity-50'
database.canEdit ? 'cursor-pointer' : 'opacity-50'
)}
onClick={() => {
setDeleteFieldId(field.id);
@@ -162,7 +170,7 @@ export const TableViewSettingsPopover = () => {
);
})}
</div>
{canEditView && canDeleteView && (
{database.canEdit && (
<React.Fragment>
<Separator />
<div className="flex flex-col gap-2 text-sm">

View File

@@ -129,6 +129,10 @@ export const ViewCreateDialog = ({
});
};
if (!database.canEdit) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>

View File

@@ -27,6 +27,10 @@ export const ViewDeleteDialog = ({
const database = useDatabase();
const { mutate, isPending } = useMutation();
if (!database.canEdit) {
return null;
}
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>

View File

@@ -1,8 +1,10 @@
import { ViewTab } from '@/renderer/components/databases/view-tab';
import { ViewCreateButton } from '@/renderer/components/databases/view-create-button';
import { useDatabaseViews } from '@/renderer/contexts/database-views';
import { useDatabase } from '@/renderer/contexts/database';
export const ViewTabs = () => {
const database = useDatabase();
const databaseViews = useDatabaseViews();
return (
@@ -15,7 +17,7 @@ export const ViewTabs = () => {
onClick={() => databaseViews.setActiveViewId(view.id)}
/>
))}
<ViewCreateButton />
{database.canEdit && <ViewCreateButton />}
</div>
);
};

View File

@@ -67,6 +67,10 @@ export const View = ({ view }: ViewProps) => {
isSearchBarOpened,
isSortsOpened,
setFieldDisplay: (id: string, display: boolean) => {
if (!database.canEdit) {
return;
}
const viewField = view.fields[id];
if (viewField && viewField.display === display) {
return;
@@ -92,6 +96,10 @@ export const View = ({ view }: ViewProps) => {
});
},
resizeField: (id: string, width: number) => {
if (!database.canEdit) {
return;
}
const viewField = view.fields[id];
if (viewField && viewField.width === width) {
return;
@@ -117,6 +125,10 @@ export const View = ({ view }: ViewProps) => {
});
},
resizeName: (width: number) => {
if (!database.canEdit) {
return;
}
if (view.nameWidth === width) {
return;
}
@@ -134,6 +146,10 @@ export const View = ({ view }: ViewProps) => {
});
},
moveField: (id: string, after: string) => {
if (!database.canEdit) {
return;
}
const newIndex = generateViewFieldIndex(
database.fields,
Object.values(view.fields),
@@ -166,6 +182,10 @@ export const View = ({ view }: ViewProps) => {
isFieldFilterOpened: (fieldId: string) =>
openedFieldFilters.includes(fieldId),
initFieldFilter: (fieldId: string) => {
if (!database.canEdit) {
return;
}
if (view.filters[fieldId]) {
setOpenedFieldFilters((prev) => [...prev, fieldId]);
return;
@@ -202,6 +222,10 @@ export const View = ({ view }: ViewProps) => {
});
},
updateFilter: (id: string, filter: ViewFilterAttributes) => {
if (!database.canEdit) {
return;
}
if (!view.filters[id]) {
return;
}
@@ -222,6 +246,10 @@ export const View = ({ view }: ViewProps) => {
});
},
removeFilter: (id: string) => {
if (!database.canEdit) {
return;
}
if (!view.filters[id]) {
return;
}
@@ -242,6 +270,10 @@ export const View = ({ view }: ViewProps) => {
});
},
initFieldSort: (fieldId: string) => {
if (!database.canEdit) {
return;
}
if (view.sorts[fieldId]) {
return;
}
@@ -274,6 +306,10 @@ export const View = ({ view }: ViewProps) => {
});
},
updateSort: (id: string, sort: ViewSortAttributes) => {
if (!database.canEdit) {
return;
}
if (!view.sorts[id]) {
return;
}
@@ -295,6 +331,10 @@ export const View = ({ view }: ViewProps) => {
});
},
removeSort: (id: string) => {
if (!database.canEdit) {
return;
}
if (!view.sorts[id]) {
return;
}

View File

@@ -4,18 +4,30 @@ import { ScrollArea } from '@/renderer/components/ui/scroll-area';
import { Document } from '@/renderer/components/documents/document';
import { Separator } from '@/renderer/components/ui/separator';
import { RecordProvider } from '@/renderer/components/records/record-provider';
import { hasEditorAccess, NodeRole, RecordNode } from '@colanode/core';
import {
DatabaseNode,
hasEditorAccess,
NodeRole,
RecordNode,
} from '@colanode/core';
interface RecordBodyProps {
record: RecordNode;
role: NodeRole;
recordRole: NodeRole;
database: DatabaseNode;
databaseRole: NodeRole;
}
export const RecordBody = ({ record, role }: RecordBodyProps) => {
const canEdit = hasEditorAccess(role);
export const RecordBody = ({
record,
recordRole,
database,
databaseRole,
}: RecordBodyProps) => {
const canEdit = hasEditorAccess(recordRole);
return (
<Database databaseId={record.attributes.databaseId}>
<Database database={database} role={databaseRole}>
<ScrollArea className="h-full max-h-full w-full overflow-y-auto px-10 pb-12">
<RecordProvider record={record}>
<RecordAttributes />

View File

@@ -22,16 +22,40 @@ export const RecordContainer = ({ nodeId }: RecordContainerProps) => {
const nodes = data ?? [];
const record = nodes.find((node) => node.id === nodeId);
const role = extractNodeRole(nodes, workspace.userId);
if (!record || record.type !== 'record') {
return null;
}
if (!record || record.type !== 'record' || !role) {
const databaseIndex = nodes.findIndex(
(node) => node.id === record.attributes.databaseId
);
if (databaseIndex === -1) {
return null;
}
const database = nodes[databaseIndex];
if (!database || database.type !== 'database') {
return null;
}
const databaseAncestors = nodes.slice(0, databaseIndex);
const recordRole = extractNodeRole(nodes, workspace.userId);
const databaseRole = extractNodeRole(databaseAncestors, workspace.userId);
if (!recordRole || !databaseRole) {
return null;
}
return (
<div className="flex h-full w-full flex-col">
<RecordHeader nodes={nodes} record={record} role={role} />
<RecordBody record={record} role={role} />
<RecordHeader nodes={nodes} record={record} role={recordRole} />
<RecordBody
record={record}
recordRole={recordRole}
database={database}
databaseRole={databaseRole}
/>
</div>
);
};

View File

@@ -11,6 +11,8 @@ interface DatabaseContext {
name: string;
fields: FieldAttributes[];
views: ViewAttributes[];
canEdit: boolean;
canCreateRecord: boolean;
createField: (type: FieldType, name: string) => void;
renameField: (id: string, name: string) => void;
deleteField: (id: string) => void;