diff --git a/keyserver/src/deleters/role-deleters.js b/keyserver/src/deleters/role-deleters.js --- a/keyserver/src/deleters/role-deleters.js +++ b/keyserver/src/deleters/role-deleters.js @@ -1,6 +1,22 @@ // @flow +import { threadPermissions } from 'lib/types/thread-permission-types.js'; +import type { + RoleDeletionRequest, + RoleDeletionResult, +} from 'lib/types/thread-types.js'; +import { updateTypes } from 'lib/types/update-types-enum.js'; +import { ServerError } from 'lib/utils/errors.js'; + +import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; +import { + fetchServerThreadInfos, + rawThreadInfosFromServerThreadInfos, +} from '../fetchers/thread-fetchers.js'; +import { checkThreadPermission } from '../fetchers/thread-permission-fetchers.js'; +import type { Viewer } from '../session/viewer.js'; +import { updateRole } from '../updaters/thread-updaters.js'; async function deleteOrphanedRoles(): Promise { await dbQuery(SQL` @@ -12,4 +28,96 @@ `); } -export { deleteOrphanedRoles }; +async function deleteRole( + viewer: Viewer, + request: RoleDeletionRequest, +): Promise { + const hasPermission = checkThreadPermission( + viewer, + request.community, + threadPermissions.CHANGE_ROLE, + ); + if (!hasPermission) { + throw new ServerError('invalid_credentials'); + } + + const { community, roleID } = request; + + const defaultRoleQuery = SQL` + SELECT default_role + FROM threads + WHERE id = ${community} + `; + + const membersWithRoleQuery = SQL` + SELECT user + FROM memberships + WHERE thread = ${community} + AND role = ${roleID} + `; + + const [[defaultRoleResult], [membersWithRoleResult]] = await Promise.all([ + dbQuery(defaultRoleQuery), + dbQuery(membersWithRoleQuery), + ]); + const defaultRole = defaultRoleResult[0].default_role.toString(); + const membersWithRole = membersWithRoleResult.map(result => result.user); + + if (roleID === defaultRole) { + throw new ServerError('invalid_parameters'); + } + + if (membersWithRole.length > 0) { + await updateRole(viewer, { + threadID: community, + memberIDs: membersWithRole, + role: defaultRole, + }); + } + + const deleteFromRolesQuery = SQL` + DELETE FROM roles + WHERE id = ${roleID} + AND thread = ${community} + `; + + await dbQuery(deleteFromRolesQuery); + + const fetchServerThreadInfosResult = await fetchServerThreadInfos({ + threadID: community, + }); + const { threadInfos: serverThreadInfos } = fetchServerThreadInfosResult; + const serverThreadInfo = serverThreadInfos[community]; + + const time = Date.now(); + + const updateDatas = []; + for (const memberInfo of serverThreadInfo.members) { + updateDatas.push({ + type: updateTypes.UPDATE_THREAD, + userID: memberInfo.id, + time, + threadID: community, + }); + } + + const { viewerUpdates } = await createUpdates(updateDatas, { + viewer, + updatesForCurrentSession: 'return', + }); + + const { threadInfos: rawThreadInfos } = rawThreadInfosFromServerThreadInfos( + viewer, + fetchServerThreadInfosResult, + ); + const rawThreadInfo = rawThreadInfos[community]; + + return { + threadInfo: rawThreadInfo, + updatesResult: { + newUpdates: viewerUpdates, + }, + }; +} + +export { deleteOrphanedRoles, deleteRole }; diff --git a/keyserver/src/endpoints.js b/keyserver/src/endpoints.js --- a/keyserver/src/endpoints.js +++ b/keyserver/src/endpoints.js @@ -60,6 +60,7 @@ threadJoinResponder, toggleMessagePinResponder, roleModificationResponder, + roleDeletionResponder, } from './responders/thread-responders.js'; import { userSubscriptionUpdateResponder, @@ -144,6 +145,10 @@ responder: entryDeletionResponder, requiredPolicies: baseLegalPolicies, }, + delete_community_role: { + responder: roleDeletionResponder, + requiredPolicies: baseLegalPolicies, + }, delete_thread: { responder: threadDeletionResponder, requiredPolicies: baseLegalPolicies, diff --git a/keyserver/src/responders/thread-responders.js b/keyserver/src/responders/thread-responders.js --- a/keyserver/src/responders/thread-responders.js +++ b/keyserver/src/responders/thread-responders.js @@ -28,6 +28,8 @@ type ToggleMessagePinResult, type RoleModificationRequest, type RoleModificationResult, + type RoleDeletionRequest, + type RoleDeletionResult, rawThreadInfoValidator, } from 'lib/types/thread-types.js'; import { serverUpdateInfoValidator } from 'lib/types/update-types.js'; @@ -48,6 +50,7 @@ } from './entry-responders.js'; import { modifyRole } from '../creators/role-creator.js'; import { createThread } from '../creators/thread-creator.js'; +import { deleteRole } from '../deleters/role-deleters.js'; import { deleteThread } from '../deleters/thread-deleters.js'; import { fetchMediaForThread } from '../fetchers/upload-fetchers.js'; import type { Viewer } from '../session/viewer.js'; @@ -384,6 +387,36 @@ ); } +const roleDeletionRequestInputValidator = tShape({ + community: tID, + roleID: tID, +}); + +export const roleDeletionResultValidator: TInterface = + tShape({ + threadInfo: t.maybe(rawThreadInfoValidator), + updatesResult: tShape({ + newUpdates: t.list(serverUpdateInfoValidator), + }), + }); + +async function roleDeletionResponder( + viewer: Viewer, + input: mixed, +): Promise { + const request = await validateInput( + viewer, + roleDeletionRequestInputValidator, + input, + ); + const response = await deleteRole(viewer, request); + return validateOutput( + viewer.platformDetails, + roleDeletionResultValidator, + response, + ); +} + export { threadDeletionResponder, roleUpdateResponder, @@ -396,4 +429,5 @@ newThreadRequestInputValidator, toggleMessagePinResponder, roleModificationResponder, + roleDeletionResponder, }; diff --git a/lib/actions/thread-actions.js b/lib/actions/thread-actions.js --- a/lib/actions/thread-actions.js +++ b/lib/actions/thread-actions.js @@ -16,6 +16,8 @@ ToggleMessagePinResult, RoleModificationRequest, RoleModificationPayload, + RoleDeletionRequest, + RoleDeletionPayload, } from '../types/thread-types.js'; import type { CallServerEndpoint } from '../utils/call-server-endpoint.js'; import { values } from '../utils/objects.js'; @@ -212,6 +214,23 @@ }; }; +const deleteCommunityRoleActionTypes = Object.freeze({ + started: 'DELETE_COMMUNITY_ROLE_STARTED', + success: 'DELETE_COMMUNITY_ROLE_SUCCESS', + failed: 'DELETE_COMMUNITY_ROLE_FAILED', +}); +const deleteCommunityRole = + ( + callServerEndpoint: CallServerEndpoint, + ): ((request: RoleDeletionRequest) => Promise) => + async request => { + const response = await callServerEndpoint('delete_community_role', request); + return { + threadInfo: response.threadInfo, + updatesResult: response.updatesResult, + }; + }; + export { deleteThreadActionTypes, deleteThread, @@ -232,4 +251,6 @@ toggleMessagePin, modifyCommunityRoleActionTypes, modifyCommunityRole, + deleteCommunityRoleActionTypes, + deleteCommunityRole, }; diff --git a/lib/reducers/thread-reducer.js b/lib/reducers/thread-reducer.js --- a/lib/reducers/thread-reducer.js +++ b/lib/reducers/thread-reducer.js @@ -18,6 +18,7 @@ joinThreadActionTypes, leaveThreadActionTypes, modifyCommunityRoleActionTypes, + deleteCommunityRoleActionTypes, } from '../actions/thread-actions.js'; import { logOutActionTypes, @@ -222,7 +223,8 @@ action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType || action.type === newThreadActionTypes.success || - action.type === modifyCommunityRoleActionTypes.success + action.type === modifyCommunityRoleActionTypes.success || + action.type === deleteCommunityRoleActionTypes.success ) { const { newUpdates } = action.payload.updatesResult; if (newUpdates.length === 0) { diff --git a/lib/types/endpoints.js b/lib/types/endpoints.js --- a/lib/types/endpoints.js +++ b/lib/types/endpoints.js @@ -57,6 +57,7 @@ CREATE_TEXT_MESSAGE: 'create_text_message', CREATE_THREAD: 'create_thread', DELETE_ENTRY: 'delete_entry', + DELETE_COMMUNITY_ROLE: 'delete_community_role', DELETE_THREAD: 'delete_thread', DELETE_UPLOAD: 'delete_upload', DISABLE_INVITE_LINK: 'disable_invite_link', diff --git a/lib/types/redux-types.js b/lib/types/redux-types.js --- a/lib/types/redux-types.js +++ b/lib/types/redux-types.js @@ -108,6 +108,7 @@ ThreadJoinPayload, ToggleMessagePinResult, RoleModificationPayload, + RoleDeletionPayload, } from './thread-types.js'; import type { ClientUpdatesResultWithUserInfos } from './update-types.js'; import type { CurrentUserInfo, UserStore } from './user-types.js'; @@ -1184,6 +1185,22 @@ +error: true, +payload: Error, +loadingInfo: LoadingInfo, + } + | { + +type: 'DELETE_COMMUNITY_ROLE_STARTED', + +loadingInfo?: LoadingInfo, + +payload?: void, + } + | { + +type: 'DELETE_COMMUNITY_ROLE_SUCCESS', + +payload: RoleDeletionPayload, + +loadingInfo: LoadingInfo, + } + | { + +type: 'DELETE_COMMUNITY_ROLE_FAILED', + +error: true, + +payload: Error, + +loadingInfo: LoadingInfo, }; export type ActionPayload = ?(Object | Array<*> | $ReadOnlyArray<*> | string); diff --git a/lib/types/thread-types.js b/lib/types/thread-types.js --- a/lib/types/thread-types.js +++ b/lib/types/thread-types.js @@ -448,6 +448,25 @@ }, }; +export type RoleDeletionRequest = { + +community: string, + +roleID: string, +}; + +export type RoleDeletionResult = { + +threadInfo: RawThreadInfo, + +updatesResult: { + +newUpdates: $ReadOnlyArray, + }, +}; + +export type RoleDeletionPayload = { + +threadInfo: RawThreadInfo, + +updatesResult: { + +newUpdates: $ReadOnlyArray, + }, +}; + // We can show a max of 3 sidebars inline underneath their parent in the chat // tab. If there are more, we show a button that opens a modal to see the rest export const maxReadSidebars = 3; diff --git a/native/roles/role-panel-entry.react.js b/native/roles/role-panel-entry.react.js --- a/native/roles/role-panel-entry.react.js +++ b/native/roles/role-panel-entry.react.js @@ -9,6 +9,7 @@ import type { UserSurfacedPermission } from 'lib/types/thread-permission-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; +import { useDisplayDeleteRoleAlert } from './role-utils.react.js'; import type { RolesNavigationProp } from './roles-navigator.react.js'; import CommIcon from '../components/comm-icon.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; @@ -38,6 +39,18 @@ ); invariant(existingRoleID, 'Role ID must exist for an existing role'); + const defaultRoleID = Object.keys(threadInfo.roles).find( + roleID => threadInfo.roles[roleID].isDefault, + ); + invariant(defaultRoleID, 'Default role ID must exist'); + + const displayDeleteRoleAlert = useDisplayDeleteRoleAlert( + threadInfo, + existingRoleID, + defaultRoleID, + memberCount, + ); + const menuButton = React.useMemo(() => { if (roleName === 'Admins') { return ; @@ -54,12 +67,18 @@ const options = React.useMemo(() => { const availableOptions = ['Edit role']; + // Since the `Members` role is able to be renamed, we need to check if the + // default role ID is the same as the existing role ID. + if (defaultRoleID !== existingRoleID) { + availableOptions.push('Delete role'); + } + if (Platform.OS === 'ios') { availableOptions.push('Cancel'); } return availableOptions; - }, []); + }, [defaultRoleID, existingRoleID]); const onOptionSelected = React.useCallback( (index: ?number) => { @@ -77,6 +96,8 @@ roleName, rolePermissions, }); + } else if (selectedOption === 'Delete role') { + displayDeleteRoleAlert(); } }, [ @@ -86,6 +107,7 @@ roleName, rolePermissions, threadInfo, + displayDeleteRoleAlert, ], ); diff --git a/native/roles/role-utils.react.js b/native/roles/role-utils.react.js new file mode 100644 --- /dev/null +++ b/native/roles/role-utils.react.js @@ -0,0 +1,70 @@ +// @flow + +import * as React from 'react'; +import { Alert } from 'react-native'; + +import { + deleteCommunityRole, + deleteCommunityRoleActionTypes, +} from 'lib/actions/thread-actions.js'; +import type { ThreadInfo } from 'lib/types/thread-types.js'; +import { + useDispatchActionPromise, + useServerCall, +} from 'lib/utils/action-utils.js'; + +function useDisplayDeleteRoleAlert( + threadInfo: ThreadInfo, + existingRoleID: string, + defaultRoleID: string, + memberCount: number, +): () => void { + const defaultRoleName = threadInfo.roles[defaultRoleID].name; + const callDeleteCommunityRole = useServerCall(deleteCommunityRole); + const dispatchActionPromise = useDispatchActionPromise(); + + const onDeleteRole = React.useCallback(() => { + dispatchActionPromise( + deleteCommunityRoleActionTypes, + callDeleteCommunityRole({ + community: threadInfo.id, + roleID: existingRoleID, + }), + ); + }, [ + callDeleteCommunityRole, + dispatchActionPromise, + existingRoleID, + threadInfo.id, + ]); + + let message; + if (memberCount === 0) { + message = 'Are you sure you want to delete this role?'; + } else { + const messageNoun = memberCount === 1 ? 'member' : 'members'; + const messageVerb = memberCount === 1 ? 'is' : 'are'; + message = + `There ${messageVerb} currently ${memberCount} ${messageNoun} with ` + + `this role. Deleting the role will automatically assign the members ` + + `affected to the ${defaultRoleName} role.`; + } + + return React.useCallback( + () => + Alert.alert('Delete role', message, [ + { + text: 'Yes, delete role', + style: 'destructive', + onPress: onDeleteRole, + }, + { + text: 'Cancel', + style: 'cancel', + }, + ]), + [message, onDeleteRole], + ); +} + +export { useDisplayDeleteRoleAlert };