diff --git a/apps/server/src/modules/workspaces/workspaces.controller.ts b/apps/server/src/modules/workspaces/workspaces.controller.ts index 62c02481..31c0bd8f 100644 --- a/apps/server/src/modules/workspaces/workspaces.controller.ts +++ b/apps/server/src/modules/workspaces/workspaces.controller.ts @@ -102,6 +102,18 @@ export class WorkspacesController { ); } + @Post('suspend') + @UseGuards(AuthGuard, AdminGuard) + async suspendUser( + @WorkspaceD() workspaceId: string, + @Body() userBody: UserBody, + ) { + return await this.workspacesService.suspendUser( + workspaceId, + userBody.userId, + ); + } + @Post('add_users') @UseGuards(AuthGuard) async addUserToWorkspace( diff --git a/apps/server/src/modules/workspaces/workspaces.service.ts b/apps/server/src/modules/workspaces/workspaces.service.ts index f10f746c..ca5d8d56 100644 --- a/apps/server/src/modules/workspaces/workspaces.service.ts +++ b/apps/server/src/modules/workspaces/workspaces.service.ts @@ -367,4 +367,25 @@ export default class WorkspacesService { ); res.status(200).json(invite); } + + async suspendUser(workspaceId: string, userId: string) { + const userOnWorkspace = + await this.prisma.usersOnWorkspaces.findUniqueOrThrow({ + where: { + userId_workspaceId: { + workspaceId, + userId, + }, + }, + }); + + await this.prisma.usersOnWorkspaces.update({ + where: { + id: userOnWorkspace.id, + }, + data: { + status: 'SUSPENDED', + }, + }); + } } diff --git a/apps/webapp/src/common/types/workspace.ts b/apps/webapp/src/common/types/workspace.ts index b0af0f13..c1278b01 100644 --- a/apps/webapp/src/common/types/workspace.ts +++ b/apps/webapp/src/common/types/workspace.ts @@ -17,6 +17,7 @@ export interface UsersOnWorkspaceType { createdAt: string; updatedAt: string; role: string; + status: string; userId: string; workspaceId: string; diff --git a/apps/webapp/src/modules/ai/conversation-item.tsx b/apps/webapp/src/modules/ai/conversation-item.tsx index 9df5ac3a..63ebee09 100644 --- a/apps/webapp/src/modules/ai/conversation-item.tsx +++ b/apps/webapp/src/modules/ai/conversation-item.tsx @@ -1,6 +1,6 @@ import { UserTypeEnum } from '@tegonhq/types'; import { Button } from '@tegonhq/ui/components/button'; -import { defaultExtensions } from '@tegonhq/ui/components/ui/editor/editor-extensions'; +import { defaultExtensions } from '@tegonhq/ui/components/editor/editor-extensions'; import { AI } from '@tegonhq/ui/icons'; import { Editor } from '@tiptap/core'; import { observer } from 'mobx-react-lite'; diff --git a/apps/webapp/src/modules/ai/conversation-textarea.tsx b/apps/webapp/src/modules/ai/conversation-textarea.tsx index a1f5ab15..e682924f 100644 --- a/apps/webapp/src/modules/ai/conversation-textarea.tsx +++ b/apps/webapp/src/modules/ai/conversation-textarea.tsx @@ -1,5 +1,5 @@ -import { AdjustableTextArea } from '@tegonhq/ui/components/ui/adjustable-textarea'; -import { Button } from '@tegonhq/ui/components/ui/button'; +import { AdjustableTextArea } from '@tegonhq/ui/components/adjustable-textarea'; +import { Button } from '@tegonhq/ui/components/button'; import { SendLine } from '@tegonhq/ui/icons'; import { useState } from 'react'; diff --git a/apps/webapp/src/modules/ai/conversation.tsx b/apps/webapp/src/modules/ai/conversation.tsx index 3d2d0e06..7917246e 100644 --- a/apps/webapp/src/modules/ai/conversation.tsx +++ b/apps/webapp/src/modules/ai/conversation.tsx @@ -1,5 +1,5 @@ import { UserTypeEnum } from '@tegonhq/types'; -import { ScrollArea } from '@tegonhq/ui/components/ui/scroll-area'; +import { ScrollArea } from '@tegonhq/ui/components/scroll-area'; import { cn } from '@tegonhq/ui/lib/utils'; import { observer } from 'mobx-react-lite'; import getConfig from 'next/config'; diff --git a/apps/webapp/src/modules/issues/new-issue/new-issue-header.tsx b/apps/webapp/src/modules/issues/new-issue/new-issue-header.tsx index 7f82960d..ce05cfca 100644 --- a/apps/webapp/src/modules/issues/new-issue/new-issue-header.tsx +++ b/apps/webapp/src/modules/issues/new-issue/new-issue-header.tsx @@ -1,4 +1,4 @@ -import { Separator } from '@tegonhq/ui/components/ui/separator'; +import { Separator } from '@tegonhq/ui/components/separator'; import { observer } from 'mobx-react-lite'; import type { IssueType, TeamType } from 'common/types'; diff --git a/apps/webapp/src/modules/issues/single-issue/left-side/file-upload/file-upload.tsx b/apps/webapp/src/modules/issues/single-issue/left-side/file-upload/file-upload.tsx index aec8ad50..dd109c00 100644 --- a/apps/webapp/src/modules/issues/single-issue/left-side/file-upload/file-upload.tsx +++ b/apps/webapp/src/modules/issues/single-issue/left-side/file-upload/file-upload.tsx @@ -1,6 +1,6 @@ import { Button } from '@tegonhq/ui/components/button'; +import { useEditor } from '@tegonhq/ui/components/editor/editor'; import { uploadFileFn, uploadFn } from '@tegonhq/ui/components/editor/utils'; -import { useEditor } from '@tegonhq/ui/components/ui/editor/editor'; import { Paperclip } from 'lucide-react'; interface FileUploadProps { diff --git a/apps/webapp/src/modules/settings/team-settings/members.tsx b/apps/webapp/src/modules/settings/team-settings/members.tsx index 58b654cc..541ae835 100644 --- a/apps/webapp/src/modules/settings/team-settings/members.tsx +++ b/apps/webapp/src/modules/settings/team-settings/members.tsx @@ -11,6 +11,7 @@ import { useCurrentTeam } from 'hooks/teams'; import { useUsersData } from 'hooks/users'; import { useContextStore } from 'store/global-context-provider'; +import { UserContext } from 'store/user-context'; import { ShowMembersDropdown } from './show-members-dropdown'; import { SettingSection } from '../setting-section'; @@ -19,10 +20,16 @@ export const Members = observer(() => { const team = useCurrentTeam(); const { workspaceStore } = useContextStore(); const usersOnWorkspace = workspaceStore.usersOnWorkspaces; + const currentUser = React.useContext(UserContext); + const userRole = workspaceStore.getUserData(currentUser.id).role; const userIds = usersOnWorkspace .filter((uOW: UsersOnWorkspaceType) => { - return uOW.teamIds.includes(team.id) && uOW.role !== RoleEnum.BOT; + return ( + uOW.teamIds.includes(team.id) && + uOW.role !== RoleEnum.BOT && + uOW.status !== 'SUSPENDED' + ); }) .map((uOW: UsersOnWorkspaceType) => uOW.userId); @@ -56,6 +63,7 @@ export const Members = observer(() => { id={userData.id} name={userData.fullname} email={userData.email} + isAdmin={userRole === 'ADMIN'} teamId={team.id} className={ index === users.length - 1 && 'pb-0 !border-b-0' diff --git a/apps/webapp/src/modules/settings/team-settings/overview/preferences.tsx b/apps/webapp/src/modules/settings/team-settings/overview/preferences.tsx index 2434355f..8ecb6654 100644 --- a/apps/webapp/src/modules/settings/team-settings/overview/preferences.tsx +++ b/apps/webapp/src/modules/settings/team-settings/overview/preferences.tsx @@ -1,7 +1,7 @@ import { RiClipboardLine } from '@remixicon/react'; import { Button } from '@tegonhq/ui/components/button'; import { Input } from '@tegonhq/ui/components/input'; -import { useToast } from '@tegonhq/ui/components/ui/use-toast'; +import { useToast } from '@tegonhq/ui/components/use-toast'; import copy from 'copy-to-clipboard'; import { observer } from 'mobx-react-lite'; diff --git a/apps/webapp/src/modules/settings/workspace-settings/labels/labels.tsx b/apps/webapp/src/modules/settings/workspace-settings/labels/labels.tsx index 3cb0a9a6..b9fff23d 100644 --- a/apps/webapp/src/modules/settings/workspace-settings/labels/labels.tsx +++ b/apps/webapp/src/modules/settings/workspace-settings/labels/labels.tsx @@ -62,7 +62,8 @@ export const Labels = observer(() => { {labelsStore.labels .filter( (label: LabelType) => - label.name.includes(searchValue) && !label.teamId, + label.name.toLowerCase().includes(searchValue.toLowerCase()) && + !label.teamId, ) .map((label: LabelType) => { if (editLabelState === label.id) { diff --git a/apps/webapp/src/modules/settings/workspace-settings/members/member-item.tsx b/apps/webapp/src/modules/settings/workspace-settings/members/member-item.tsx index 694eb34e..627556ab 100644 --- a/apps/webapp/src/modules/settings/workspace-settings/members/member-item.tsx +++ b/apps/webapp/src/modules/settings/workspace-settings/members/member-item.tsx @@ -15,10 +15,20 @@ interface MemberItemProps { email: string; id: string; teamId?: string; + isAdmin?: boolean; + isSuspended?: boolean; } export const MemberItem = observer( - ({ name, className, email, id, teamId }: MemberItemProps) => { + ({ + name, + className, + email, + id, + teamId, + isAdmin, + isSuspended, + }: MemberItemProps) => { const { workspaceStore } = useContextStore(); const userOnWorkspace = workspaceStore.usersOnWorkspaces.find( @@ -29,7 +39,8 @@ export const MemberItem = observer(
@@ -44,12 +55,12 @@ export const MemberItem = observer(
{userOnWorkspace?.role}
- {teamId && ( - - )} +
); diff --git a/apps/webapp/src/modules/settings/workspace-settings/members/member-options-dropdown.tsx b/apps/webapp/src/modules/settings/workspace-settings/members/member-options-dropdown.tsx index aba9f1ed..26fba8ae 100644 --- a/apps/webapp/src/modules/settings/workspace-settings/members/member-options-dropdown.tsx +++ b/apps/webapp/src/modules/settings/workspace-settings/members/member-options-dropdown.tsx @@ -7,19 +7,24 @@ import { DropdownMenuTrigger, } from '@tegonhq/ui/components/dropdown-menu'; import { useToast } from '@tegonhq/ui/components/use-toast'; -import { DeleteLine, MoreLine } from '@tegonhq/ui/icons'; +import { CanceledLine, DeleteLine, MoreLine } from '@tegonhq/ui/icons'; import React from 'react'; import { useRemoveTeamMemberMutation } from 'services/team'; +import { useSuspendUserMutation } from 'services/workspace'; interface MemberOptionsDropdownProps { userId: string; teamId: string; + isAdmin: boolean; + isSuspended: boolean; } export function MemberOptionsDropdown({ userId, teamId, + isAdmin, + isSuspended, }: MemberOptionsDropdownProps) { const { toast } = useToast(); const { mutate: removeMember } = useRemoveTeamMemberMutation({ @@ -32,6 +37,19 @@ export function MemberOptionsDropdown({ }, }); + const { mutate: suspendUser } = useSuspendUserMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'User has been suspended', + }); + }, + }); + + if (!isAdmin || isSuspended) { + return null; + } + return (
@@ -48,18 +66,35 @@ export function MemberOptionsDropdown({ - { - removeMember({ - userId, - teamId, - }); - }} - > -
- Remove from team -
-
+ {isAdmin && ( + <> + { + suspendUser({ + userId, + }); + }} + > +
+ Suspend +
+
+ + )} + {teamId && isAdmin && ( + { + removeMember({ + userId, + teamId, + }); + }} + > +
+ Remove from team +
+
+ )}
diff --git a/apps/webapp/src/modules/settings/workspace-settings/members/members.tsx b/apps/webapp/src/modules/settings/workspace-settings/members/members.tsx index 5974cac9..7d0a3765 100644 --- a/apps/webapp/src/modules/settings/workspace-settings/members/members.tsx +++ b/apps/webapp/src/modules/settings/workspace-settings/members/members.tsx @@ -1,4 +1,5 @@ import { Button } from '@tegonhq/ui/components/button'; +import { Input } from '@tegonhq/ui/components/input'; import { Loader } from '@tegonhq/ui/components/loader'; import { observer } from 'mobx-react-lite'; import React from 'react'; @@ -9,12 +10,37 @@ import type { User } from 'common/types'; import { useUsersData } from 'hooks/users'; +import { useContextStore } from 'store/global-context-provider'; +import { UserContext } from 'store/user-context'; + import { AddMemberDialog } from './add-member-dialog'; import { MemberItem } from './member-item'; export const Members = observer(() => { const { users, isLoading } = useUsersData(false); + const { workspaceStore } = useContextStore(); const [newMemberDialog, setNewMemberDialog] = React.useState(false); + const [searchValue, setSearchValue] = React.useState(''); + const currentUser = React.useContext(UserContext); + const userRole = workspaceStore.getUserData(currentUser.id)?.role; + + const getUsers = (isSuspened: boolean = false) => { + const nonSuspendedUsers = users.filter((user) => + isSuspened + ? workspaceStore.getUserData(user.id).status === 'SUSPENDED' + : workspaceStore.getUserData(user.id).status !== 'SUSPENDED', + ); + + if (searchValue) { + return nonSuspendedUsers.filter( + (user) => + user.fullname.toLowerCase().includes(searchValue.toLowerCase()) || + user.email.toLowerCase().includes(searchValue.toLowerCase()), + ); + } + + return nonSuspendedUsers; + }; return ( <> @@ -34,20 +60,47 @@ export const Members = observer(() => { > Add member -

{users.length} Members

+ +
+ setSearchValue(e.currentTarget.value)} + /> +
-
- {users.map((userData: User, index) => ( +
+ {getUsers().map((userData: User, index) => ( ))}
+ + {getUsers(true).length > 0 && ( +
+

Suspended

+ + {getUsers(true).map((userData: User, index) => ( + + ))} +
+ )}
)}
diff --git a/apps/webapp/src/services/workspace/index.ts b/apps/webapp/src/services/workspace/index.ts index 9b313b66..d6756a30 100644 --- a/apps/webapp/src/services/workspace/index.ts +++ b/apps/webapp/src/services/workspace/index.ts @@ -3,3 +3,4 @@ export * from './invite-action'; export * from './update-workspace'; export * from './create-initial-resources'; export * from './update-workspace-preferences'; +export * from './suspend-user'; diff --git a/apps/webapp/src/services/workspace/suspend-user.tsx b/apps/webapp/src/services/workspace/suspend-user.tsx new file mode 100644 index 00000000..f90add14 --- /dev/null +++ b/apps/webapp/src/services/workspace/suspend-user.tsx @@ -0,0 +1,43 @@ +import { suspendUser } from '@tegonhq/services'; +import { useMutation, useQueryClient } from 'react-query'; + +import type { WorkspaceType } from 'common/types'; + +import { GetUserQuery } from 'services/users'; + +interface MutationParams { + onMutate?: () => void; + onSuccess?: (team: WorkspaceType) => void; + onError?: (error: string) => void; +} + +export function useSuspendUserMutation({ + onMutate, + onSuccess, + onError, +}: MutationParams) { + const queryClient = useQueryClient(); + + const onMutationTriggered = () => { + onMutate && onMutate(); + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const onMutationError = (errorResponse: any) => { + const errorText = errorResponse?.errors?.message || 'Error occured'; + + onError && onError(errorText); + }; + + const onMutationSuccess = (team: WorkspaceType) => { + queryClient.invalidateQueries({ queryKey: [GetUserQuery] }); + + onSuccess && onSuccess(team); + }; + + return useMutation(suspendUser, { + onError: onMutationError, + onMutate: onMutationTriggered, + onSuccess: onMutationSuccess, + }); +} diff --git a/apps/webapp/src/store/database.ts b/apps/webapp/src/store/database.ts index 24edc786..1a2bd4d2 100644 --- a/apps/webapp/src/store/database.ts +++ b/apps/webapp/src/store/database.ts @@ -54,7 +54,7 @@ export class TegonDatabase extends Dexie { constructor(databaseName: string) { super(databaseName); - this.version(16).stores({ + this.version(17).stores({ [MODELS.Workspace]: 'id,createdAt,updatedAt,name,slug,preferences', [MODELS.Label]: 'id,createdAt,updatedAt,name,color,description,workspaceId,groupId,teamId', @@ -65,7 +65,7 @@ export class TegonDatabase extends Dexie { [MODELS.Issue]: 'id,createdAt,updatedAt,title,number,description,priority,dueDate,sortOrder,estimate,teamId,createdById,assigneeId,labelIds,parentId,stateId,sourceMetadata.projectId,projectMilestoneId,cycleId', [MODELS.UsersOnWorkspaces]: - 'id,createdAt,updatedAt,userId,workspaceId,teamIds,settings', + 'id,createdAt,updatedAt,userId,workspaceId,teamIds,settings,role,status', [MODELS.IssueHistory]: 'id,createdAt,updatedAt,userId,issueId,assedLabelIds,removedLabelIds,fromPriority,toPriority,fromStateId,toStateId,fromEstimate,toEstimate,fromAssigneeId,toAssigneeId,fromParentId,toParentId,sourceMetadata', [MODELS.IssueComment]: diff --git a/apps/webapp/src/store/workspace/models.ts b/apps/webapp/src/store/workspace/models.ts index dfccbd23..54aba23b 100644 --- a/apps/webapp/src/store/workspace/models.ts +++ b/apps/webapp/src/store/workspace/models.ts @@ -30,6 +30,10 @@ export const UsersOnWorkspace = types.model({ userId: types.string, workspaceId: types.string, role: types.enumeration(['ADMIN', 'USER', 'BOT', 'AGENT']), + status: types.union( + types.undefined, + types.enumeration(['INVITED', 'ACTIVE', 'SUSPENDED']), + ), teamIds: types.array(types.string), settings: types.union( types.model({ diff --git a/apps/webapp/src/store/workspace/save-data.ts b/apps/webapp/src/store/workspace/save-data.ts index 5b9cd0a5..c69e9454 100644 --- a/apps/webapp/src/store/workspace/save-data.ts +++ b/apps/webapp/src/store/workspace/save-data.ts @@ -20,6 +20,7 @@ export async function saveWorkspaceData( workspaceId: record.data.workspaceId, teamIds: record.data.teamIds, role: record.data.role, + status: record.data.status, settings: record.data.settings, }; diff --git a/packages/services/src/workspace/index.ts b/packages/services/src/workspace/index.ts index 063455ff..c6ebc84d 100644 --- a/packages/services/src/workspace/index.ts +++ b/packages/services/src/workspace/index.ts @@ -1 +1,2 @@ export * from './update-workspace-preferences'; +export * from './suspend-user'; diff --git a/packages/services/src/workspace/suspend-user.ts b/packages/services/src/workspace/suspend-user.ts new file mode 100644 index 00000000..3bf7104f --- /dev/null +++ b/packages/services/src/workspace/suspend-user.ts @@ -0,0 +1,7 @@ +import axios from 'axios'; + +export async function suspendUser(updateData: { userId: string }) { + const response = await axios.post(`/api/v1/workspaces/suspend`, updateData); + + return response.data; +}