Skip to content

Commit

Permalink
Admin: Merging Users (#439)
Browse files Browse the repository at this point in the history
  • Loading branch information
antoinejaussoin authored Dec 11, 2022
1 parent d02632a commit a4307a1
Show file tree
Hide file tree
Showing 10 changed files with 337 additions and 32 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/alpha.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: 'Alpha Build'

on:
push:
branches: [v4163/deps]
branches: [v4180/merge]

jobs:
build:
Expand Down
46 changes: 32 additions & 14 deletions backend/src/admin/router.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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) {
Expand All @@ -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;
5 changes: 5 additions & 0 deletions backend/src/common/payloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,8 @@ export interface ChatMessagePayload {
export interface ChangeUserNamePayload {
name: string;
}

export interface MergeUsersPayload {
main: string;
merged: string[];
}
98 changes: 98 additions & 0 deletions backend/src/db/actions/merge.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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 } }
);
});
}
6 changes: 3 additions & 3 deletions backend/src/db/actions/users.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -24,11 +24,11 @@ export async function getIdentity(
});
}

export async function getAllPasswordUsers(): Promise<UserView[]> {
export async function getAllNonDeletedUsers(): Promise<UserView[]> {
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;
});
Expand Down
1 change: 1 addition & 0 deletions backend/src/db/entities/SessionView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/common/payloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,8 @@ export interface ChatMessagePayload {
export interface ChangeUserNamePayload {
name: string;
}

export interface MergeUsersPayload {
main: string;
merged: string[];
}
79 changes: 67 additions & 12 deletions frontend/src/views/admin/AdminPage.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,33 @@
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';
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<FullUser[]>('/api/admin/users', []);
const [addOpened, handleAddOpen, handleAddClose] = useModal();
const [mergeOpened, handleOpenMerge, handleCloseMerge] = useModal();
const [search, setSearch] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);

const selected = useMemo(() => {
return users.filter((u) => selectedIds.includes(u.id));
}, [selectedIds, users]);

const onAdd = useCallback(
(user: FullUser) => {
Expand All @@ -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;
Expand All @@ -46,23 +65,46 @@ export default function AdminPage() {
);
}, [search, users]);

const columns: GridColDef[] = useMemo(() => {
const columns: GridColDef<FullUser>[] = useMemo(() => {
return [
{
field: '',
headerName: '',
renderCell: (p: GridRenderCellParams<unknown, FullUser>) => {
return (
<Checkbox
checked={selectedIds.includes(p.row.id)}
onChange={(_, checked) =>
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) => (
<Actions>
<ChangePassword user={p.row} />
<DeleteAccount user={p.row} onDelete={onDelete} />
</Actions>
),
renderCell: (p: GridRenderCellParams<unknown, FullUser>) => {
return (
<Actions>
{p.row.accountType === 'password' ? (
<ChangePassword user={p.row} />
) : null}
<DeleteAccount user={p.row} onDelete={onDelete} />
</Actions>
);
},
},
] as GridColDef[];
}, [onDelete]);
}, [onDelete, selectedIds]);

if (!backend.selfHosted) {
return (
Expand Down Expand Up @@ -92,13 +134,26 @@ export default function AdminPage() {
<Button startIcon={<Add />} onClick={handleAddOpen}>
Add a new user
</Button>
<Button
startIcon={<CallMerge />}
onClick={handleOpenMerge}
disabled={selected.length < 2}
>
Merge
</Button>
</Header>
<DataGrid rows={filteredUsers} columns={columns} filterMode="client" />
<NewAccountModal
open={addOpened}
onClose={handleAddClose}
onAdd={onAdd}
/>
<MergeModal
open={mergeOpened}
onClose={handleCloseMerge}
users={selected}
onMerge={onMerge}
/>
</Container>
);
}
Expand Down
Loading

0 comments on commit a4307a1

Please sign in to comment.