From a4307a15949224b1ff2c70c177b1244102aed85f Mon Sep 17 00:00:00 2001 From: Antoine Jaussoin Date: Sun, 11 Dec 2022 18:26:00 +0000 Subject: [PATCH] Admin: Merging Users (#439) --- .github/workflows/alpha.yml | 2 +- backend/src/admin/router.ts | 46 +++++++--- backend/src/common/payloads.ts | 5 ++ backend/src/db/actions/merge.ts | 98 +++++++++++++++++++++ backend/src/db/actions/users.ts | 6 +- backend/src/db/entities/SessionView.ts | 1 + frontend/src/common/payloads.ts | 5 ++ frontend/src/views/admin/AdminPage.tsx | 79 ++++++++++++++--- frontend/src/views/admin/MergeModal.tsx | 112 ++++++++++++++++++++++++ frontend/src/views/admin/api.ts | 15 +++- 10 files changed, 337 insertions(+), 32 deletions(-) create mode 100644 backend/src/db/actions/merge.ts create mode 100644 frontend/src/views/admin/MergeModal.tsx diff --git a/.github/workflows/alpha.yml b/.github/workflows/alpha.yml index c30d9543d..281b0c880 100644 --- a/.github/workflows/alpha.yml +++ b/.github/workflows/alpha.yml @@ -2,7 +2,7 @@ name: 'Alpha Build' on: push: - branches: [v4163/deps] + branches: [v4180/merge] jobs: build: diff --git a/backend/src/admin/router.ts b/backend/src/admin/router.ts index 31e726abe..7340c585e 100644 --- a/backend/src/admin/router.ts +++ b/backend/src/admin/router.ts @@ -1,17 +1,34 @@ -import express from 'express'; +import express, { NextFunction, Response, Request } from 'express'; import { - getAllPasswordUsers, + getAllNonDeletedUsers, getPasswordIdentityByUserId, updateIdentity, } from '../db/actions/users'; import config from '../config'; import { isLicenced } from '../security/is-licenced'; -import { AdminChangePasswordPayload, BackendCapabilities } from '../common'; +import { + AdminChangePasswordPayload, + BackendCapabilities, + MergeUsersPayload, +} from '../common'; import { getIdentityFromRequest, hashPassword } from '../utils'; import { canSendEmails } from '../email/utils'; +import { mergeUsers } from '../db/actions/merge'; const router = express.Router(); +async function isSelfHostAdmin( + req: Request, + res: Response, + next: NextFunction +) { + const authIdentity = await getIdentityFromRequest(req); + if (!authIdentity || authIdentity.user.email !== config.SELF_HOSTED_ADMIN) { + return res.status(403).send('You are not allowed to do this'); + } + next(); +} + router.get('/self-hosting', async (_, res) => { const licence = await isLicenced(); const payload: BackendCapabilities = { @@ -38,20 +55,12 @@ router.get('/self-hosting', async (_, res) => { res.status(200).send(payload); }); -router.get('/users', async (req, res) => { - const identity = await getIdentityFromRequest(req); - if (!identity || identity.user.email !== config.SELF_HOSTED_ADMIN) { - return res.status(403).send('You are not allowed to do this'); - } - const users = await getAllPasswordUsers(); +router.get('/users', isSelfHostAdmin, async (req, res) => { + const users = await getAllNonDeletedUsers(); res.send(users.map((u) => u.toJson())); }); -router.patch('/user', async (req, res) => { - const authIdentity = await getIdentityFromRequest(req); - if (!authIdentity || authIdentity.user.email !== config.SELF_HOSTED_ADMIN) { - return res.status(403).send('You are not allowed to do this'); - } +router.patch('/user', isSelfHostAdmin, async (req, res) => { const payload = req.body as AdminChangePasswordPayload; const identity = await getPasswordIdentityByUserId(payload.userId); if (identity) { @@ -66,4 +75,13 @@ router.patch('/user', async (req, res) => { res.status(403).send('Cannot update users password'); }); +router.post('/merge', isSelfHostAdmin, async (req, res) => { + const payload = req.body as MergeUsersPayload; + const worked = await mergeUsers(payload.main, payload.merged); + if (!worked) { + return res.status(403).send('Cannot merge users. Something went wrong'); + } + res.status(200).send(); +}); + export default router; diff --git a/backend/src/common/payloads.ts b/backend/src/common/payloads.ts index 2ec3a302f..f1754bb13 100644 --- a/backend/src/common/payloads.ts +++ b/backend/src/common/payloads.ts @@ -85,3 +85,8 @@ export interface ChatMessagePayload { export interface ChangeUserNamePayload { name: string; } + +export interface MergeUsersPayload { + main: string; + merged: string[]; +} diff --git a/backend/src/db/actions/merge.ts b/backend/src/db/actions/merge.ts new file mode 100644 index 000000000..6f3ee693f --- /dev/null +++ b/backend/src/db/actions/merge.ts @@ -0,0 +1,98 @@ +import { UserView } from '../entities'; +import { getUserView } from './users'; +import { transaction } from './transaction'; +import { + PostGroupRepository, + PostRepository, + SessionRepository, + VoteRepository, +} from '../repositories'; +import { deleteAccount } from './delete'; + +export async function mergeUsers( + mainUserId: string, + mergedUserIds: string[] +): Promise { + console.log('Merging users', mainUserId, mergedUserIds); + + for (const target of mergedUserIds) { + await mergeOne(mainUserId, target); + } + + return true; +} + +async function mergeOne(main: string, target: string) { + console.log('Merge ', main, target); + const mainUser = await getUserView(main); + const targetUser = await getUserView(target); + + if (mainUser && targetUser) { + if (targetUser.id === mainUser.id) { + console.error( + ' >>> You should not merge one identity to another of the same account', + mainUser.id, + mainUser.identityId, + targetUser.identityId + ); + return; + } + await migrateOne(mainUser, targetUser); + await deleteOne(targetUser); + } else { + console.error(' >>> Could not find users', mainUser, targetUser); + } +} + +async function deleteOne(target: UserView) { + console.log(` > Deleting migrated user ${target.id} (${target.name})`); + await deleteAccount(target, { + deletePosts: true, + deleteSessions: true, + deleteVotes: true, + }); +} + +async function migrateOne(main: UserView, target: UserView) { + console.log( + ` > Migrating data from ${target.id} (${target.name}) to ${main.id} (${main.name})` + ); + return await transaction(async (manager) => { + const voteRepo = manager.getCustomRepository(VoteRepository); + const postRepo = manager.getCustomRepository(PostRepository); + const groupRepo = manager.getCustomRepository(PostGroupRepository); + const sessionRepo = manager.getCustomRepository(SessionRepository); + + await manager.query('update messages set user_id = $1 where user_id = $2', [ + main.id, + target.id, + ]); + + await manager.query( + `update visitors set users_id = $1 where users_id = $2 + and not exists ( + select 1 from visitors v + where v.sessions_id = visitors.sessions_id and v.users_id = $1 + ) + `, + [main.id, target.id] + ); + + await voteRepo.update( + { user: { id: target.id } }, + { user: { id: main.id } } + ); + await postRepo.update( + { user: { id: target.id } }, + { user: { id: main.id } } + ); + await groupRepo.update( + { user: { id: target.id } }, + { user: { id: main.id } } + ); + await sessionRepo.update( + { createdBy: { id: target.id } }, + { createdBy: { id: main.id } } + ); + }); +} diff --git a/backend/src/db/actions/users.ts b/backend/src/db/actions/users.ts index 81df929d4..7507229f1 100644 --- a/backend/src/db/actions/users.ts +++ b/backend/src/db/actions/users.ts @@ -1,5 +1,5 @@ import { UserEntity, UserView } from '../entities'; -import { EntityManager } from 'typeorm'; +import { EntityManager, Not } from 'typeorm'; import { UserIdentityRepository, UserRepository } from '../repositories'; import { ALL_FIELDS } from '../entities/User'; import { ALL_FIELDS as ALL_FIELDS_IDENTITY } from '../entities/UserIdentity'; @@ -24,11 +24,11 @@ export async function getIdentity( }); } -export async function getAllPasswordUsers(): Promise { +export async function getAllNonDeletedUsers(): Promise { return await transaction(async (manager) => { const userRepository = manager.getRepository(UserView); const users = await userRepository.find({ - where: { accountType: 'password' }, + where: { name: Not('(deleted user)') }, }); return users; }); diff --git a/backend/src/db/entities/SessionView.ts b/backend/src/db/entities/SessionView.ts index 020aaeb15..625f8104d 100644 --- a/backend/src/db/entities/SessionView.ts +++ b/backend/src/db/entities/SessionView.ts @@ -74,6 +74,7 @@ export default class SessionView { toJson(userId: string): SessionMetadata { return { ...this, + participants: this.participants ?? [], canBeDeleted: userId === this.createdBy.id, lockedForUser: this.locked && this.participants diff --git a/frontend/src/common/payloads.ts b/frontend/src/common/payloads.ts index 2ec3a302f..f1754bb13 100644 --- a/frontend/src/common/payloads.ts +++ b/frontend/src/common/payloads.ts @@ -85,3 +85,8 @@ export interface ChatMessagePayload { export interface ChangeUserNamePayload { name: string; } + +export interface MergeUsersPayload { + main: string; + merged: string[]; +} diff --git a/frontend/src/views/admin/AdminPage.tsx b/frontend/src/views/admin/AdminPage.tsx index 482b2c685..f9a3ec614 100644 --- a/frontend/src/views/admin/AdminPage.tsx +++ b/frontend/src/views/admin/AdminPage.tsx @@ -1,5 +1,5 @@ -import { Alert, Button } from '@mui/material'; -import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { Alert, Button, Checkbox } from '@mui/material'; +import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid'; import { FullUser } from 'common'; import useUser from '../../auth/useUser'; import useStateFetch from '../../hooks/useStateFetch'; @@ -7,18 +7,27 @@ import { useCallback, useMemo, useState } from 'react'; import styled from '@emotion/styled'; import ChangePassword from './ChangePassword'; import useBackendCapabilities from '../../global/useBackendCapabilities'; -import { Add, Search } from '@mui/icons-material'; +import { Add, CallMerge, Search } from '@mui/icons-material'; import useModal from 'hooks/useModal'; import { NewAccountModal } from './NewAccountModal'; import Input from 'components/Input'; import { DeleteAccount } from './DeleteAccount'; +import { uniq } from 'lodash'; +import MergeModal from './MergeModal'; +import { mergeUsers } from './api'; export default function AdminPage() { const user = useUser(); const backend = useBackendCapabilities(); const [users, setUsers] = useStateFetch('/api/admin/users', []); const [addOpened, handleAddOpen, handleAddClose] = useModal(); + const [mergeOpened, handleOpenMerge, handleCloseMerge] = useModal(); const [search, setSearch] = useState(''); + const [selectedIds, setSelectedIds] = useState([]); + + const selected = useMemo(() => { + return users.filter((u) => selectedIds.includes(u.id)); + }, [selectedIds, users]); const onAdd = useCallback( (user: FullUser) => { @@ -35,6 +44,16 @@ export default function AdminPage() { [setUsers] ); + const onMerge = useCallback( + (main: FullUser, merged: FullUser[]) => { + handleCloseMerge(); + const removedIds = merged.map((u) => u.id); + setUsers((prev) => prev.filter((u) => !removedIds.includes(u.id))); + mergeUsers(main, merged); + }, + [setUsers, handleCloseMerge] + ); + const filteredUsers = useMemo(() => { if (!search) { return users; @@ -46,23 +65,46 @@ export default function AdminPage() { ); }, [search, users]); - const columns: GridColDef[] = useMemo(() => { + const columns: GridColDef[] = useMemo(() => { return [ + { + field: '', + headerName: '', + renderCell: (p: GridRenderCellParams) => { + return ( + + checked + ? setSelectedIds((prev) => uniq([...prev, p.row.id])) + : setSelectedIds((prev) => + prev.filter((id) => id !== p.row.id) + ) + } + /> + ); + }, + }, + { field: 'id', headerName: 'ID' }, { field: 'email', headerName: 'Email', width: 300, filterable: true }, { field: 'name', headerName: 'Name', width: 300 }, + { field: 'accountType', headerName: 'Acct Type', filterable: true }, { - field: '', headerName: 'Actions', width: 300, - renderCell: (p) => ( - - - - - ), + renderCell: (p: GridRenderCellParams) => { + return ( + + {p.row.accountType === 'password' ? ( + + ) : null} + + + ); + }, }, ] as GridColDef[]; - }, [onDelete]); + }, [onDelete, selectedIds]); if (!backend.selfHosted) { return ( @@ -92,6 +134,13 @@ export default function AdminPage() { + + ); } diff --git a/frontend/src/views/admin/MergeModal.tsx b/frontend/src/views/admin/MergeModal.tsx new file mode 100644 index 000000000..fac906ea3 --- /dev/null +++ b/frontend/src/views/admin/MergeModal.tsx @@ -0,0 +1,112 @@ +import { + Alert, + Button, + colors, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + List, + ListItem, + ListItemButton, + ListItemText, + ListSubheader, +} from '@mui/material'; +import { FullUser } from 'common'; +import { noop } from 'lodash'; +import { useSnackbar } from 'notistack'; +import { useEffect, useState } from 'react'; + +type MergeModalProps = { + users: FullUser[]; + open: boolean; + onClose: () => void; + onMerge: (into: FullUser, merged: FullUser[]) => void; +}; + +export default function MergeModal({ + users, + open, + onClose, + onMerge, +}: MergeModalProps) { + const [main, setMain] = useState(null); + const { enqueueSnackbar } = useSnackbar(); + + useEffect(() => { + setMain(null); + }, [users]); + + return ( + + Merge Users + + You are about to merge users. This cannot be undone. Be very careful. + + + +
+ Choose the user to merge into: + } + > + {users.map((u) => ( + { + if (u.accountType !== 'anonymous') { + setMain(u); + } else { + enqueueSnackbar( + 'You cannot merge into an anonymous account. Please select another account.', + { variant: 'error' } + ); + } + }} + > + + + + + ))} + +
+
+ {main ? ( + + You are going to merge all users in this list into{' '} + + {main.name} ({main.email}) + + .
+ This means all users in this list will be deleted,{' '} + except {main.email}, and all their dashboards, posts, votes + etc. transferred to {main.email}. +
+ ) : null} + + + + +
+ ); +} diff --git a/frontend/src/views/admin/api.ts b/frontend/src/views/admin/api.ts index f578b6544..5b7bb9cec 100644 --- a/frontend/src/views/admin/api.ts +++ b/frontend/src/views/admin/api.ts @@ -1,5 +1,9 @@ -import { fetchPatch } from '../../api/fetch'; -import { AdminChangePasswordPayload } from 'common'; +import { fetchPatch, fetchPost } from '../../api/fetch'; +import { + AdminChangePasswordPayload, + FullUser, + MergeUsersPayload, +} from 'common'; export async function changePassword(userId: string, password: string) { return await fetchPatch('/api/admin/user', { @@ -7,3 +11,10 @@ export async function changePassword(userId: string, password: string) { password, }); } + +export async function mergeUsers(main: FullUser, merged: FullUser[]) { + return await fetchPost('/api/admin/merge', { + main: main.identityId, + merged: merged.map((u) => u.identityId), + }); +}