Improve workspace user role checks

This commit is contained in:
Hakan Shehu
2024-11-11 12:03:44 +01:00
parent 80110d64e9
commit 3cbf14c296
19 changed files with 273 additions and 122 deletions

View File

@@ -67,6 +67,7 @@ export class ServerNodeSyncMutationHandler
socketManager.sendMessage(workspace.account_id, {
type: 'local_node_sync',
nodeId: input.id,
userId: userId,
versionId: input.versionId,
workspaceId: input.workspaceId,
});
@@ -112,6 +113,7 @@ export class ServerNodeSyncMutationHandler
socketManager.sendMessage(workspace.account_id, {
type: 'local_node_sync',
nodeId: input.id,
userId: userId,
versionId: input.versionId,
workspaceId: input.workspaceId,
});

View File

@@ -1,6 +1,7 @@
export type LocalNodeSyncMessageInput = {
type: 'local_node_sync';
nodeId: string;
userId: string;
versionId: string;
workspaceId: string;
};

View File

@@ -36,6 +36,7 @@ interface WorkspaceFormProps {
isSaving: boolean;
onCancel?: () => void;
saveText: string;
readOnly?: boolean;
}
export const WorkspaceForm = ({
@@ -44,6 +45,7 @@ export const WorkspaceForm = ({
isSaving,
onCancel,
saveText,
readOnly = false,
}: WorkspaceFormProps) => {
const account = useAccount();
@@ -71,7 +73,7 @@ export const WorkspaceForm = ({
<div
className="group relative cursor-pointer"
onClick={async () => {
if (isPending || isFileDialogOpen) {
if (isPending || isFileDialogOpen || readOnly) {
return;
}
@@ -122,7 +124,8 @@ export const WorkspaceForm = ({
<div
className={cn(
`absolute left-0 top-0 hidden h-32 w-32 items-center justify-center overflow-hidden bg-gray-50 group-hover:inline-flex`,
isPending ? 'inline-flex' : 'hidden'
isPending ? 'inline-flex' : 'hidden',
readOnly && 'hidden group-hover:hidden'
)}
>
{isPending ? (
@@ -141,7 +144,7 @@ export const WorkspaceForm = ({
<FormItem className="flex-1">
<FormLabel>Name *</FormLabel>
<FormControl>
<Input placeholder="Name" {...field} />
<Input readOnly={readOnly} placeholder="Name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -155,6 +158,7 @@ export const WorkspaceForm = ({
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
readOnly={readOnly}
placeholder="Write a short description about the workspace"
{...field}
/>
@@ -165,26 +169,27 @@ export const WorkspaceForm = ({
/>
</div>
</div>
{!readOnly && (
<div className="flex flex-row justify-end gap-2">
{onCancel && (
<Button
type="button"
disabled={isPending || isSaving}
variant="outline"
onClick={() => {
onCancel();
}}
>
Cancel
</Button>
)}
<div className="flex flex-row justify-end gap-2">
{onCancel && (
<Button
type="button"
disabled={isPending || isSaving}
variant="outline"
onClick={() => {
onCancel();
}}
>
Cancel
<Button type="submit" disabled={isPending || isSaving}>
{isSaving && <Spinner className="mr-1" />}
{saveText}
</Button>
)}
<Button type="submit" disabled={isPending || isSaving}>
{isSaving && <Spinner className="mr-1" />}
{saveText}
</Button>
</div>
</div>
)}
</form>
</Form>
);

View File

@@ -35,6 +35,8 @@ export const WorkspaceSettingsDialog = ({
const workspace = useWorkspace();
const [tab, setTab] = React.useState<'info' | 'users' | 'delete'>('info');
const canDelete = workspace.role === 'owner';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
@@ -87,15 +89,17 @@ export const WorkspaceSettingsDialog = ({
<span>Users</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={tab === 'delete'}
onClick={() => setTab('delete')}
>
<Trash2 className="mr-2 size-4" />
<span>Delete</span>
</SidebarMenuButton>
</SidebarMenuItem>
{canDelete && (
<SidebarMenuItem>
<SidebarMenuButton
isActive={tab === 'delete'}
onClick={() => setTab('delete')}
>
<Trash2 className="mr-2 size-4" />
<span>Delete</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>

View File

@@ -6,9 +6,11 @@ import { WorkspaceForm } from './workspace-form';
export const WorkspaceUpdate = () => {
const workspace = useWorkspace();
const { mutate, isPending } = useMutation();
const canEdit = workspace.role === 'owner';
return (
<WorkspaceForm
readOnly={!canEdit}
values={{
name: workspace.name,
description: workspace.description ?? '',

View File

@@ -20,15 +20,15 @@ interface WorkspaceRoleItem {
const roles: WorkspaceRoleItem[] = [
{
name: 'Admin',
value: 'admin',
description: 'Administration access',
name: 'Owner',
value: 'owner',
description: 'Full access',
enabled: true,
},
{
name: 'Editor',
value: 'editor',
description: 'Can edit content',
name: 'Admin',
value: 'admin',
description: 'Administration access',
enabled: true,
},
{
@@ -38,26 +38,42 @@ const roles: WorkspaceRoleItem[] = [
enabled: true,
},
{
name: 'Viewer',
value: 'viewer',
name: 'Guest',
value: 'guest',
description: 'Can view content',
enabled: true,
},
{
name: 'No access',
value: 'none',
description: 'No access to workspace',
enabled: true,
},
];
interface WorkspaceUserRoleDropdownProps {
userId: string;
value: WorkspaceRole;
canEdit: boolean;
}
export const WorkspaceUserRoleDropdown = ({
userId,
value,
canEdit,
}: WorkspaceUserRoleDropdownProps) => {
const workspace = useWorkspace();
const { mutate, isPending } = useMutation();
console.log('canEdit', canEdit);
const currentRole = roles.find((role) => role.value === value);
if (!canEdit) {
return (
<p className="p-1 text-sm text-muted-foreground">{currentRole?.name}</p>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -70,46 +86,52 @@ export const WorkspaceUserRoleDropdown = ({
)}
</p>
</DropdownMenuTrigger>
<DropdownMenuContent>
{roles.map((role) => (
<DropdownMenuItem
key={role.value}
onSelect={() => {
if (isPending) {
return;
}
<DropdownMenuContent className="w-56">
{roles
.filter((role) => role.enabled)
.map((role) => (
<DropdownMenuItem
key={role.value}
onSelect={() => {
if (isPending) {
return;
}
mutate({
input: {
type: 'workspace_user_role_update',
userToUpdateId: userId,
role: role.value,
userId: workspace.userId,
},
onError() {
toast({
title: 'Failed to update role',
description:
'Something went wrong updating user role. Please try again!',
variant: 'destructive',
});
},
});
}}
>
<div className="flex w-full flex-row items-center justify-between">
<div className="flex w-full flex-col">
<p className="mb-1 text-sm font-medium leading-none">
{role.name}
</p>
<p className="text-sm text-muted-foreground">
{role.description}
</p>
if (role.value === value) {
return;
}
mutate({
input: {
type: 'workspace_user_role_update',
userToUpdateId: userId,
role: role.value,
userId: workspace.userId,
},
onError() {
toast({
title: 'Failed to update role',
description:
'Something went wrong updating user role. Please try again!',
variant: 'destructive',
});
},
});
}}
>
<div className="flex w-full flex-row items-center justify-between">
<div className="flex flex-1 w-full flex-col">
<p className="mb-1 text-sm font-medium leading-none">
{role.name}
</p>
<p className="text-sm text-muted-foreground">
{role.description}
</p>
</div>
{value === role.value && <Check className="size-4" />}
</div>
{value === role.value && <Check className="mr-2 size-4" />}
</div>
</DropdownMenuItem>
))}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -1,3 +1,4 @@
import React from 'react';
import { useInfiniteQuery } from '@/renderer/hooks/use-infinite-query';
import { Separator } from '@/renderer/components/ui/separator';
import { WorkspaceUserInvite } from '@/renderer/components/workspaces/workspace-user-invite';
@@ -12,6 +13,7 @@ const USERS_PER_PAGE = 50;
export const WorkspaceUsers = () => {
const workspace = useWorkspace();
const canEditUsers = workspace.role === 'owner' || workspace.role === 'admin';
const { data, isPending, hasNextPage, fetchNextPage, isFetchingNextPage } =
useInfiniteQuery({
@@ -47,8 +49,12 @@ export const WorkspaceUsers = () => {
const users = data?.flatMap((page) => page) ?? [];
return (
<div className="flex flex-col space-y-4">
<WorkspaceUserInvite />
<Separator />
{canEditUsers && (
<React.Fragment>
<WorkspaceUserInvite />
<Separator />
</React.Fragment>
)}
<div>
<p>Users</p>
<p className="text-sm text-muted-foreground">
@@ -75,7 +81,11 @@ export const WorkspaceUsers = () => {
</p>
<p className="text-sm text-muted-foreground">{email}</p>
</div>
<WorkspaceUserRoleDropdown userId={user.id} value={role} />
<WorkspaceUserRoleDropdown
userId={user.id}
value={role}
canEdit={canEditUsers}
/>
</div>
);
})}

View File

@@ -3,9 +3,9 @@ import { ServerNode } from '@/types/nodes';
export type WorkspaceRole =
| 'owner'
| 'admin'
| 'editor'
| 'collaborator'
| 'viewer';
| 'guest'
| 'none';
export type Workspace = {
id: string;
@@ -41,6 +41,10 @@ export type WorkspaceUserRoleUpdateOutput = {
user: ServerNode;
};
export type WorkspaceUserRemoveOutput = {
user: ServerNode;
};
export type SidebarNode = {
id: string;
type: string;

View File

@@ -234,13 +234,18 @@ const createDeviceNodesTable: Migration = {
await db.schema
.createTable('device_nodes')
.addColumn('device_id', 'varchar(30)', (col) => col.notNull())
.addColumn('user_id', 'varchar(30)', (col) => col.notNull())
.addColumn('node_id', 'varchar(30)', (col) => col.notNull())
.addColumn('workspace_id', 'varchar(30)', (col) => col.notNull())
.addColumn('node_version_id', 'varchar(30)')
.addColumn('user_node_version_id', 'varchar(30)')
.addColumn('node_synced_at', 'timestamptz')
.addColumn('user_node_synced_at', 'timestamptz')
.addPrimaryKeyConstraint('device_nodes_pkey', ['device_id', 'node_id'])
.addPrimaryKeyConstraint('device_nodes_pkey', [
'device_id',
'user_id',
'node_id',
])
.execute();
},
down: async (db) => {

View File

@@ -132,8 +132,9 @@ export type CreateUserNode = Insertable<UserNodeTable>;
export type UpdateUserNode = Updateable<UserNodeTable>;
interface DeviceNodeTable {
node_id: ColumnType<string, string, never>;
device_id: ColumnType<string, string, never>;
user_id: ColumnType<string, string, never>;
node_id: ColumnType<string, string, never>;
workspace_id: ColumnType<string, string, string>;
node_version_id: ColumnType<string | null, string | null, string | null>;
user_node_version_id: ColumnType<string | null, string | null, string | null>;

View File

@@ -59,7 +59,7 @@ export const createDefaultWorkspace = async (account: SelectAccount) => {
id: user.id,
account_id: account.id,
workspace_id: workspaceId,
role: WorkspaceRole.Owner,
role: 'owner',
created_at: createdAt,
created_by: account.id,
status: WorkspaceUserStatus.Active,

View File

@@ -3,7 +3,12 @@ import { redisConfig } from '@/data/redis';
import { CreateUserNode } from '@/data/schema';
import { filesStorage } from '@/data/storage';
import { BUCKET_NAMES } from '@/data/storage';
import { generateId, IdType, NodeAttributes, NodeTypes } from '@colanode/core';
import {
extractNodeCollaborators,
generateId,
IdType,
NodeTypes,
} from '@colanode/core';
import { fetchNodeCollaborators, fetchWorkspaceUsers } from '@/lib/nodes';
import { synapse } from '@/services/synapse';
import {
@@ -15,6 +20,7 @@ import {
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { Job, Queue, Worker } from 'bullmq';
import { difference } from 'lodash-es';
import { enqueueTask } from './tasks';
const eventQueue = new Queue('events', {
connection: {
@@ -71,6 +77,8 @@ const handleNodeUpdatedEvent = async (
event: NodeUpdatedEvent
): Promise<void> => {
await checkForCollaboratorsChange(event);
await checkForUserRoleChange(event);
await synapse.sendSynapseMessage({
type: 'node_update',
nodeId: event.id,
@@ -194,8 +202,16 @@ const createUserNodes = async (event: NodeCreatedEvent): Promise<void> => {
const checkForCollaboratorsChange = async (
event: NodeUpdatedEvent
): Promise<void> => {
const beforeCollaborators = extractCollaboratorIds(event.beforeAttributes);
const afterCollaborators = extractCollaboratorIds(event.afterAttributes);
const beforeCollaborators = Object.keys(
extractNodeCollaborators(event.beforeAttributes)
);
const afterCollaborators = Object.keys(
extractNodeCollaborators(event.afterAttributes)
);
if (beforeCollaborators.length === 0 && afterCollaborators.length === 0) {
return;
}
const addedCollaborators = difference(
afterCollaborators,
@@ -289,10 +305,28 @@ const checkForCollaboratorsChange = async (
}
};
const extractCollaboratorIds = (collaborators: NodeAttributes) => {
if ('collaborators' in collaborators && collaborators.collaborators) {
return Object.keys(collaborators.collaborators).sort();
const checkForUserRoleChange = async (
event: NodeUpdatedEvent
): Promise<void> => {
if (
event.beforeAttributes.type !== 'user' ||
event.afterAttributes.type !== 'user'
) {
return;
}
return [];
const beforeRole = event.beforeAttributes.role;
const afterRole = event.afterAttributes.role;
if (beforeRole === afterRole) {
return;
}
if (afterRole === 'none') {
await enqueueTask({
type: 'clean_user_device_nodes',
userId: event.id,
workspaceId: event.workspaceId,
});
}
};

View File

@@ -1,6 +1,11 @@
import { database } from '@/data/database';
import { redisConfig } from '@/data/redis';
import { CleanDeviceDataTask, SendEmailTask, Task } from '@/types/tasks';
import {
CleanDeviceDataTask,
CleanUserDeviceNodesTask,
SendEmailTask,
Task,
} from '@/types/tasks';
import { Job, Queue, Worker } from 'bullmq';
import { sendEmail } from '@/services/email';
@@ -39,11 +44,13 @@ const handleTaskJob = async (job: Job) => {
return handleCleanDeviceDataTask(task);
case 'send_email':
return handleSendEmailTask(task);
case 'clean_user_device_nodes':
return handleCleanUserDeviceNodesTask(task);
}
};
const handleCleanDeviceDataTask = async (
task: CleanDeviceDataTask,
task: CleanDeviceDataTask
): Promise<void> => {
const device = await database
.selectFrom('devices')
@@ -62,8 +69,39 @@ const handleCleanDeviceDataTask = async (
.execute();
};
const handleSendEmailTask = async (
task: SendEmailTask
): Promise<void> => {
const handleSendEmailTask = async (task: SendEmailTask): Promise<void> => {
await sendEmail(task.message);
}
};
const handleCleanUserDeviceNodesTask = async (
task: CleanUserDeviceNodesTask
): Promise<void> => {
const workspaceUser = await database
.selectFrom('workspace_users')
.where('id', '=', task.userId)
.where('workspace_id', '=', task.workspaceId)
.selectAll()
.executeTakeFirst();
if (!workspaceUser) {
return;
}
const devices = await database
.selectFrom('devices')
.select('id')
.where('account_id', '=', task.userId)
.execute();
if (devices.length === 0) {
return;
}
const deviceIds = devices.map((d) => d.id);
await database
.deleteFrom('device_nodes')
.where('device_id', 'in', deviceIds)
.where('user_id', '=', task.userId)
.execute();
};

View File

@@ -27,7 +27,7 @@ import { getNameFromEmail } from '@/lib/utils';
import { AccountStatus } from '@/types/accounts';
import { ServerNode } from '@/types/nodes';
import { mapServerNode } from '@/lib/nodes';
import { NodeCreatedEvent } from '@/types/events';
import { NodeCreatedEvent, NodeUpdatedEvent } from '@/types/events';
import { enqueueEvent } from '@/queues/events';
import { YDoc } from '@colanode/crdt';
@@ -91,7 +91,7 @@ workspacesRouter.post(
avatar: account.avatar,
email: account.email,
accountId: account.id,
role: WorkspaceRole.Owner,
role: 'owner',
parentId: workspaceId,
};
@@ -118,7 +118,7 @@ workspacesRouter.post(
id: userId,
account_id: account.id,
workspace_id: workspaceId,
role: WorkspaceRole.Owner,
role: 'owner',
created_at: createdAt,
created_by: account.id,
status: WorkspaceUserStatus.Active,
@@ -190,7 +190,7 @@ workspacesRouter.post(
user: {
id: userId,
accountId: account.id,
role: WorkspaceRole.Owner,
role: 'owner',
},
};
@@ -238,7 +238,7 @@ workspacesRouter.put(
});
}
if (workspaceUser.role !== WorkspaceRole.Owner) {
if (workspaceUser.role !== 'owner') {
return res.status(403).json({
code: ApiError.Forbidden,
message: 'Forbidden.',
@@ -321,7 +321,7 @@ workspacesRouter.delete(
});
}
if (workspaceUser.role !== WorkspaceRole.Owner) {
if (workspaceUser.role !== 'owner') {
return res.status(403).json({
code: ApiError.Forbidden,
message: 'Forbidden.',
@@ -492,10 +492,7 @@ workspacesRouter.post(
});
}
if (
workspaceUser.role !== WorkspaceRole.Owner &&
workspaceUser.role !== WorkspaceRole.Admin
) {
if (workspaceUser.role !== 'owner' && workspaceUser.role !== 'admin') {
return res.status(403).json({
code: ApiError.Forbidden,
message: 'Forbidden.',
@@ -593,7 +590,7 @@ workspacesRouter.post(
name: account!.name,
avatar: account!.avatar,
email: account!.email,
role: WorkspaceRole.Collaborator,
role: 'collaborator',
accountId: account!.id,
parentId: workspace.id,
};
@@ -604,7 +601,7 @@ workspacesRouter.post(
id: userId,
account_id: account!.id,
workspace_id: workspace.id,
role: WorkspaceRole.Collaborator,
role: 'collaborator',
created_at: new Date(),
created_by: req.account.id,
status: WorkspaceUserStatus.Active,
@@ -724,8 +721,8 @@ workspacesRouter.put(
}
if (
currentWorkspaceUser.role !== WorkspaceRole.Owner &&
currentWorkspaceUser.role !== WorkspaceRole.Admin
currentWorkspaceUser.role !== 'owner' &&
currentWorkspaceUser.role !== 'admin'
) {
return res.status(403).json({
code: ApiError.Forbidden,
@@ -817,6 +814,20 @@ workspacesRouter.put(
.execute();
});
const event: NodeUpdatedEvent = {
type: 'node_updated',
id: userNode.id,
workspaceId: userNode.workspaceId,
beforeAttributes: attributes,
afterAttributes: userDoc.getAttributes(),
updatedBy: currentWorkspaceUser.id,
updatedAt: updatedAt.toISOString(),
serverUpdatedAt: updatedAt.toISOString(),
versionId: userNode.versionId,
};
await enqueueEvent(event);
return res.status(200).json({
user: userNode,
});

View File

@@ -112,6 +112,7 @@ class SynapseService {
.insertInto('device_nodes')
.values({
node_id: message.nodeId,
user_id: message.userId,
device_id: connection.deviceId,
node_version_id: message.versionId,
user_node_version_id: null,
@@ -120,7 +121,7 @@ class SynapseService {
node_synced_at: new Date(),
})
.onConflict((cb) =>
cb.columns(['node_id', 'device_id']).doUpdateSet({
cb.columns(['node_id', 'user_id', 'device_id']).doUpdateSet({
workspace_id: message.workspaceId,
node_version_id: message.versionId,
node_synced_at: new Date(),
@@ -132,6 +133,7 @@ class SynapseService {
.insertInto('device_nodes')
.values({
node_id: message.nodeId,
user_id: message.userId,
device_id: connection.deviceId,
node_version_id: null,
user_node_version_id: message.versionId,
@@ -140,7 +142,7 @@ class SynapseService {
node_synced_at: new Date(),
})
.onConflict((cb) =>
cb.columns(['node_id', 'device_id']).doUpdateSet({
cb.columns(['node_id', 'user_id', 'device_id']).doUpdateSet({
workspace_id: message.workspaceId,
user_node_version_id: message.versionId,
user_node_synced_at: new Date(),

View File

@@ -1,6 +1,7 @@
export type LocalNodeSyncMessageInput = {
type: 'local_node_sync';
nodeId: string;
userId: string;
versionId: string;
workspaceId: string;
};

View File

@@ -1,6 +1,9 @@
import { EmailMessage } from '@/types/email';
export type Task = CleanDeviceDataTask | SendEmailTask;
export type Task =
| CleanDeviceDataTask
| SendEmailTask
| CleanUserDeviceNodesTask;
export type CleanDeviceDataTask = {
type: 'clean_device_data';
@@ -10,4 +13,10 @@ export type CleanDeviceDataTask = {
export type SendEmailTask = {
type: 'send_email';
message: EmailMessage;
};
};
export type CleanUserDeviceNodesTask = {
type: 'clean_user_device_nodes';
userId: string;
workspaceId: string;
};

View File

@@ -3,12 +3,12 @@ export enum WorkspaceStatus {
Inactive = 2,
}
export enum WorkspaceRole {
Owner = 'owner',
Admin = 'admin',
Collaborator = 'collaborator',
Viewer = 'viewer',
}
export type WorkspaceRole =
| 'owner'
| 'admin'
| 'collaborator'
| 'guest'
| 'none';
export enum WorkspaceUserStatus {
Active = 1,

View File

@@ -8,7 +8,7 @@ export const userAttributesSchema = z.object({
email: z.string().email(),
avatar: z.string().nullable(),
accountId: z.string(),
role: z.enum(['owner', 'admin', 'editor', 'collaborator', 'viewer']),
role: z.enum(['owner', 'admin', 'collaborator', 'guest', 'none']),
});
export type UserAttributes = z.infer<typeof userAttributesSchema>;