Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions seerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4433,6 +4433,51 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/User'
/user/{userId}/merge:
post:
summary: Merge user into another user
description: |
Merges the user with the provided userId into another user. All requests,
issues, watchlist items, and other data will be transferred to the target user.
The source user will be deleted after the merge.

Requires the `MANAGE_USERS` permission. Cannot merge the owner account (ID 1).
tags:
- users
parameters:
- in: path
name: userId
required: true
schema:
type: number
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- targetUserId
properties:
targetUserId:
type: number
description: The ID of the user to merge into
example: 1
responses:
'200':
description: User successfully merged
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'400':
description: Bad request (e.g., merging user into themselves)
'403':
description: Forbidden (e.g., trying to merge admin without owner permission)
'404':
description: Source or target user not found
'405':
description: Not allowed (e.g., trying to merge owner account)
/user/{userId}/requests:
get:
summary: Get requests for a specific user
Expand Down
180 changes: 180 additions & 0 deletions server/routes/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user';
import dataSource, { getRepository } from '@server/datasource';
import Issue from '@server/entity/Issue';
import IssueComment from '@server/entity/IssueComment';
import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest';
import OverrideRule from '@server/entity/OverrideRule';
import { User } from '@server/entity/User';
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
import { Watchlist } from '@server/entity/Watchlist';
Expand Down Expand Up @@ -578,6 +581,183 @@ router.delete<{ id: string }>(
}
);

router.post<{ id: string }, Partial<User>, { targetUserId: number }>(
'/:id/merge',
isAuthenticated(Permission.MANAGE_USERS),
async (req, res, next) => {
try {
const sourceUserId = Number(req.params.id);
const targetUserId = req.body.targetUserId;

if (!targetUserId) {
return next({
status: 400,
message: 'Target user ID is required.',
});
}

if (sourceUserId === targetUserId) {
return next({
status: 400,
message: 'Cannot merge a user into themselves.',
});
}

if (sourceUserId === 1) {
return next({
status: 405,
message: 'The owner account cannot be merged into another user.',
});
}

if (targetUserId === 1 && req.user?.id !== 1) {
return next({
status: 403,
message: 'Only the owner can merge users into the owner account.',
});
}

const userRepository = getRepository(User);

const sourceUser = await userRepository.findOne({
where: { id: sourceUserId },
});

if (!sourceUser) {
return next({ status: 404, message: 'Source user not found.' });
}

const targetUser = await userRepository.findOne({
where: { id: targetUserId },
});

if (!targetUser) {
return next({ status: 404, message: 'Target user not found.' });
}

if (sourceUser.hasPermission(Permission.ADMIN) && req.user?.id !== 1) {
return next({
status: 403,
message: 'You cannot merge users with administrative privileges.',
});
}

// Perform the merge in a transaction
await dataSource.transaction(async (transactionalEntityManager) => {
// Reassign MediaRequests (requestedBy)
await transactionalEntityManager
.createQueryBuilder()
.update(MediaRequest)
.set({ requestedBy: targetUser })
.where('requestedById = :sourceId', { sourceId: sourceUserId })
.execute();

// Reassign MediaRequests (modifiedBy)
await transactionalEntityManager
.createQueryBuilder()
.update(MediaRequest)
.set({ modifiedBy: targetUser })
.where('modifiedById = :sourceId', { sourceId: sourceUserId })
.execute();

// Reassign Issues (createdBy)
await transactionalEntityManager
.createQueryBuilder()
.update(Issue)
.set({ createdBy: targetUser })
.where('createdById = :sourceId', { sourceId: sourceUserId })
.execute();

// Reassign Issues (modifiedBy)
await transactionalEntityManager
.createQueryBuilder()
.update(Issue)
.set({ modifiedBy: targetUser })
.where('modifiedById = :sourceId', { sourceId: sourceUserId })
.execute();

// Reassign IssueComments
await transactionalEntityManager
.createQueryBuilder()
.update(IssueComment)
.set({ user: targetUser })
.where('userId = :sourceId', { sourceId: sourceUserId })
.execute();

// Handle Watchlist - need to avoid duplicates due to unique constraint
const sourceWatchlistItems = await transactionalEntityManager.find(
Watchlist,
{
where: { requestedBy: { id: sourceUserId } },
}
);

const targetWatchlistTmdbIds = (
await transactionalEntityManager.find(Watchlist, {
where: { requestedBy: { id: targetUserId } },
select: ['tmdbId'],
})
).map((w) => w.tmdbId);

for (const watchlistItem of sourceWatchlistItems) {
if (targetWatchlistTmdbIds.includes(watchlistItem.tmdbId)) {
// Duplicate - delete source's watchlist item
await transactionalEntityManager.delete(Watchlist, watchlistItem.id);
} else {
// Not a duplicate - reassign to target
await transactionalEntityManager.update(
Watchlist,
watchlistItem.id,
{ requestedBy: targetUser }
);
}
}

// Update OverrideRules that reference the source user
const overrideRules = await transactionalEntityManager.find(OverrideRule);
for (const rule of overrideRules) {
if (rule.users) {
const userIds = rule.users.split(',').map((id) => id.trim());
const sourceIndex = userIds.indexOf(String(sourceUserId));
if (sourceIndex !== -1) {
// Replace source user ID with target user ID
userIds[sourceIndex] = String(targetUserId);
// Remove duplicates
const uniqueUserIds = [...new Set(userIds)];
await transactionalEntityManager.update(OverrideRule, rule.id, {
users: uniqueUserIds.join(','),
});
}
}
}

// Delete the source user (cascades UserSettings, UserPushSubscription)
await transactionalEntityManager.delete(User, sourceUserId);
});

logger.info('User merged successfully', {
label: 'User Management',
sourceUserId,
targetUserId,
mergedBy: req.user?.id,
});

return res.status(200).json(targetUser.filter());
} catch (e) {
logger.error('Something went wrong merging users', {
label: 'API',
sourceUserId: req.params.id,
targetUserId: req.body.targetUserId,
errorMessage: e.message,
});
return next({
status: 500,
message: 'Something went wrong merging the users.',
});
}
}
);

router.post(
'/import-from-plex',
isAuthenticated(Permission.MANAGE_USERS),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal';
import type { User } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
import axios from 'axios';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';

const messages = defineMessages(
'components.UserProfile.UserSettings.UserGeneralSettings.MergeUserModal',
{
mergeuser: 'Merge User',
merging: 'Merging…',
merge: 'Merge',
usermerged: 'User merged successfully!',
usermergeerror: 'Something went wrong while merging the user.',
mergeconfirm:
'Select the user to merge into. All requests, issues, and watchlist items will be transferred to the target user, and this account will be deleted.',
targetuser: 'Merge Into',
selectuser: 'Select a user...',
}
);

interface MergeUserModalProps {
user: User;
onComplete: () => void;
onCancel: () => void;
}

const MergeUserModal = ({ user, onComplete, onCancel }: MergeUserModalProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const [isMerging, setMerging] = useState(false);
const [targetUserId, setTargetUserId] = useState<number | null>(null);

// Fetch all users to populate the dropdown
const { data: usersData } = useSWR<UserResultsResponse>(
'/api/v1/user?take=100&skip=0&sort=displayname'
);

const mergeUser = async () => {
if (!targetUserId) return;

setMerging(true);

try {
await axios.post(`/api/v1/user/${user.id}/merge`, {
targetUserId,
});

addToast(intl.formatMessage(messages.usermerged), {
autoDismiss: true,
appearance: 'success',
});
onComplete();
} catch (e) {
addToast(intl.formatMessage(messages.usermergeerror), {
autoDismiss: true,
appearance: 'error',
});
setMerging(false);
}
};

// Filter out the source user from the list of potential targets
const availableTargets =
usersData?.results.filter((u) => u.id !== user.id) ?? [];

return (
<Modal
onOk={() => mergeUser()}
okText={
isMerging
? intl.formatMessage(messages.merging)
: intl.formatMessage(messages.merge)
}
okDisabled={isMerging || !targetUserId}
okButtonType="danger"
onCancel={onCancel}
title={intl.formatMessage(messages.mergeuser)}
subTitle={user.displayName}
>
<p className="mb-4 text-gray-300">
{intl.formatMessage(messages.mergeconfirm)}
</p>
{!usersData ? (
<LoadingSpinner />
) : (
<div className="form-row">
<label htmlFor="targetUser" className="text-label">
{intl.formatMessage(messages.targetuser)}
</label>
<div className="form-input-area">
<select
id="targetUser"
name="targetUser"
value={targetUserId ?? ''}
onChange={(e) =>
setTargetUserId(e.target.value ? Number(e.target.value) : null)
}
className="block w-full rounded-md border border-gray-600 bg-gray-700 text-white focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<option value="">
{intl.formatMessage(messages.selectuser)}
</option>
{availableTargets.map((targetUser) => (
<option key={targetUser.id} value={targetUser.id}>
{targetUser.displayName}
{targetUser.email &&
targetUser.displayName.toLowerCase() !== targetUser.email &&
` (${targetUser.email})`}
</option>
))}
</select>
</div>
</div>
)}
</Modal>
);
};

export default MergeUserModal;
Loading