Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🚫👤feat: delete user from UI #1526

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
537da7a
initial commit
berry-13 Jan 6, 2024
f0ab4dc
Merge branch 'main' into delete-user
berry-13 Jan 6, 2024
6071449
fix: UserController bugs; fix: lint errors
berry-13 Jan 13, 2024
2173ef5
Merge branch 'main' into delete-user
berry-13 Jan 13, 2024
b66a02e
fix: delete files
berry-13 Jan 13, 2024
5a6ae22
Merge branch 'main' into delete-user
berry-13 Jan 13, 2024
bf70679
language support
Jan 13, 2024
9dfda16
Merge branch 'main' into delete-user
berry-13 Jan 25, 2024
68f0bd6
Merge branch 'main' into delete-user
berry-13 Feb 4, 2024
faca0bf
Merge branch 'main' into delete-user
danny-avila Feb 5, 2024
9bd9a82
Merge branch 'main' into delete-user
berry-13 Feb 25, 2024
8f47ab1
Merge branch 'main' into delete-user
berry-13 Mar 23, 2024
2c13056
style(DeleteAccount): update to the latest style
berry-13 Mar 23, 2024
c9216f5
Merge branch 'main' into delete-user
berry-13 Apr 10, 2024
debef5a
Merge branch 'main' into delete-user
berry-13 Apr 15, 2024
af316c0
Merge branch 'main' into delete-user
berry-13 May 2, 2024
cf484e9
Merge branch 'main' into delete-user
berry-13 May 5, 2024
dba5886
style: fix after merge main
berry-13 May 5, 2024
da3e2b4
Merge branch 'main' into delete-user
berry-13 May 8, 2024
27c7493
Merge branch 'main' into delete-user
berry-13 May 10, 2024
662d5ae
Merge branch 'main' into delete-user
berry-13 May 22, 2024
0e8daf3
Merge branch 'main' into delete-user
berry-13 May 25, 2024
c744860
Merge branch 'delete-user' of https://github.com/danny-avila/librecha…
berry-13 Jun 1, 2024
ce42ad2
chore: Add canDeleteAccount middleware for user deletion endpoint
berry-13 Jun 1, 2024
c1bd4be
chore: renamed to ALLOW_ACCOUNT_DELETION
berry-13 Jun 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ ALLOW_EMAIL_LOGIN=true
ALLOW_REGISTRATION=true
ALLOW_SOCIAL_LOGIN=false
ALLOW_SOCIAL_REGISTRATION=false
ALLOW_ACCOUNT_DELETION=

SESSION_EXPIRY=1000 * 60 * 15
REFRESH_TOKEN_EXPIRY=(1000 * 60 * 60 * 24) * 7
Expand Down
8 changes: 6 additions & 2 deletions api/models/File.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,12 @@ const deleteFileByFilter = async (filter) => {
* @param {Array<string>} file_ids - The unique identifiers of the files to delete.
* @returns {Promise<Object>} A promise that resolves to the result of the deletion operation.
*/
const deleteFiles = async (file_ids) => {
return await File.deleteMany({ file_id: { $in: file_ids } });
const deleteFiles = async (file_ids, user) => {
let deleteQuery = { file_id: { $in: file_ids } };
if (user) {
deleteQuery = { user: user };
}
return await File.deleteMany(deleteQuery);
};

module.exports = {
Expand Down
35 changes: 34 additions & 1 deletion api/server/controllers/UserController.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
const { updateUserPluginsService } = require('~/server/services/UserService');
const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService');
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
const { logger } = require('~/config');
const {
deleteConvos,
deleteMessages,
deletePresets,
User,
Session,
Balance,
Transaction,
deleteFiles,
} = require('~/models');

const getUserController = async (req, res) => {
res.status(200).send(req.user);
Expand Down Expand Up @@ -53,7 +63,30 @@ const updateUserPluginsController = async (req, res) => {
}
};

const deleteUserController = async (req, res) => {
const { user } = req;

try {
await deleteMessages({ user: user.id }); // delete user messages
await Session.deleteMany({ user: user.id }); // delete user sessions
await Transaction.deleteMany({ user: user.id }); // delete user transactions
await deleteUserKey({ userId: user.id, all: true }); // delete user keys
await Balance.deleteMany({ user: user._id }); // delete user balances
await deletePresets(user.id); // delete user presets
await deleteConvos(user.id); // delete user convos
await deleteUserPluginAuth(user.id, null, true); // delete user plugin auth
await User.deleteOne({ _id: user.id }); // delete user
await deleteFiles(null, user.id); // delete user files

res.status(200).send({ message: 'User deleted' });
} catch (err) {
logger.error('[deleteUserController]', err);
res.status(500).send({ message: err.message });
}
};

module.exports = {
getUserController,
updateUserPluginsController,
deleteUserController,
};
26 changes: 26 additions & 0 deletions api/server/middleware/canDeleteAccount.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const { logger } = require('~/config');
const { isEnabled } = require('~/server/utils');

/**
* Checks if the user can delete their account
*
* @async
* @function
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Next middleware function
*
* @returns {Promise<function|Object>} - Returns a Promise which when resolved calls next middleware if the user can delete their account
*/

const canDeleteAccount = async (req, res, next = () => {}) => {
const { user } = req;
if (user?.role === 'admin' || isEnabled(process.env.ALLOW_ACCOUNT_DELETION)) {
return next();
} else {
logger.error(`[User] [Delete Account] [User cannot delete account] [User: ${user?.id}]`);
return res.status(403).send({ message: 'You do not have permission to delete this account' });
}
};

module.exports = canDeleteAccount;
2 changes: 2 additions & 0 deletions api/server/middleware/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const validateImageRequest = require('./validateImageRequest');
const moderateText = require('./moderateText');
const noIndex = require('./noIndex');
const importLimiters = require('./importLimiters');
const canDeleteAccount = require('./canDeleteAccount');

module.exports = {
...uploadLimiters,
Expand All @@ -42,4 +43,5 @@ module.exports = {
noIndex,
...importLimiters,
checkDomainAllowed,
canDeleteAccount,
};
8 changes: 7 additions & 1 deletion api/server/routes/user.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
const express = require('express');
const requireJwtAuth = require('../middleware/requireJwtAuth');
const { getUserController, updateUserPluginsController } = require('../controllers/UserController');
const canDeleteAccount = require('../middleware/canDeleteAccount');
const {
getUserController,
updateUserPluginsController,
deleteUserController,
} = require('../controllers/UserController');

const router = express.Router();

router.get('/', requireJwtAuth, getUserController);
router.post('/plugins', requireJwtAuth, updateUserPluginsController);
router.delete('/delete', requireJwtAuth, canDeleteAccount, deleteUserController);

module.exports = router;
12 changes: 11 additions & 1 deletion api/server/services/PluginService.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,17 @@ const updateUserPluginAuth = async (userId, authField, pluginKey, value) => {
}
};

const deleteUserPluginAuth = async (userId, authField) => {
const deleteUserPluginAuth = async (userId, authField, all = false) => {
if (all) {
try {
const response = await PluginAuth.deleteMany({ userId });
return response;
} catch (err) {
logger.error('[deleteUserPluginAuth]', err);
return err;
}
}

try {
return await PluginAuth.deleteOne({ userId, authField });
} catch (err) {
Expand Down
4 changes: 3 additions & 1 deletion client/src/components/Auth/SocialButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ const SocialButton = ({ id, enabled, serverDomain, oauthPath, Icon, label }) =>

const handleMouseLeave = () => {
setIsHovered(false);
if (isPressed) {setIsPressed(false);}
if (isPressed) {
setIsPressed(false);
}
};

const handleMouseDown = () => {
Expand Down
1 change: 0 additions & 1 deletion client/src/components/Nav/ClearConvos.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ const ClearConvos = ({ open, onOpenChange }) => {
// Clear all conversations
const clearConvos = () => {
if (confirmClear) {
console.log('Clearing conversations...');
clearConvosMutation.mutate(
{},
{
Expand Down
5 changes: 4 additions & 1 deletion client/src/components/Nav/SettingsTabs/Account/Account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import Avatar from './Avatar';
import store from '~/store';
import DeleteAccount from './DeleteAccount';

function Account({ onCheckedChange }: { onCheckedChange?: (value: boolean) => void }) {
const [UsernameDisplay, setUsernameDisplay] = useRecoilState<boolean>(store.UsernameDisplay);
Expand All @@ -28,6 +29,9 @@ function Account({ onCheckedChange }: { onCheckedChange?: (value: boolean) => vo
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<Avatar />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<DeleteAccount />
</div>
<div className="flex items-center justify-between">
<div> {localize('com_nav_user_name_display')} </div>
<Switch
Expand All @@ -39,7 +43,6 @@ function Account({ onCheckedChange }: { onCheckedChange?: (value: boolean) => vo
/>
</div>
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600"></div>
</Tabs.Content>
);
}
Expand Down
171 changes: 171 additions & 0 deletions client/src/components/Nav/SettingsTabs/Account/DeleteAccount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import React, { useState, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogButton,
Input,
} from '~/components/ui';
import { useDeleteUserMutation } from 'librechat-data-provider/react-query';
import { useLocalize } from '~/hooks';
import { cn, defaultTextProps, removeFocusOutlines } from '~/utils';
import { Spinner, LockIcon } from '~/components/svg';
import { useAuthContext } from '~/hooks/AuthContext';

const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolean }) => {
const localize = useLocalize();
const { user } = useAuthContext();
const userEmail = user?.email;
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
const { mutate: deleteUser, isLoading: isDeleting } = useDeleteUserMutation();
const [emailInput, setEmailInput] = useState('');
const [deleteInput, setDeleteInput] = useState('');
const [isLocked, setIsLocked] = useState(true);

const onClick = useCallback(() => {
setDialogOpen(true);
}, []);

const handleDeleteUser = () => {
if (!isLocked) {
deleteUser({});
}
};

const handleInputChange = useCallback(
(newEmailInput: string, newDeleteInput: string) => {
const isEmailCorrect = newEmailInput.trim().toLowerCase() === userEmail?.trim().toLowerCase();
const isDeleteInputCorrect = newDeleteInput === 'DELETE';
setIsLocked(!(isEmailCorrect && isDeleteInputCorrect));
},
[userEmail],
);

return (
<>
<div className="flex items-center justify-between">
<span>{localize('com_nav_delete_account')}</span>
<label>
<DialogButton
id={'delete-user-account'}
disabled={disabled}
onClick={onClick}
className={cn(
'btn btn-danger relative border-none bg-red-700 text-white hover:bg-red-800 dark:hover:bg-red-800',
)}
>
{localize('com_ui_delete')}
</DialogButton>
</label>
</div>
<Dialog open={isDialogOpen} onOpenChange={() => setDialogOpen(false)}>
<DialogContent
className={cn('shadow-2xl md:h-[500px] md:w-[450px]')}
style={{ borderRadius: '12px', padding: '20px' }}
>
<DialogHeader>
<DialogTitle className="text-lg font-medium leading-6">
{localize('com_nav_delete_account_confirm')}
</DialogTitle>
</DialogHeader>
<div className="mb-20 text-sm text-black dark:text-white">
<ul>
<li>{localize('com_nav_delete_warning')}</li>
<li>{localize('com_nav_delete_data_info')}</li>
<li>{localize('com_nav_delete_help_center')}</li>
</ul>
</div>
<div className="flex-col items-center justify-center">
<div className="mb-4">
{renderInput(
localize('com_nav_delete_account_email_placeholder'),
'email-confirm-input',
userEmail || '',
(e) => {
setEmailInput(e.target.value);
handleInputChange(e.target.value, deleteInput);
},
)}
</div>
<div className="mb-4">
{renderInput(
localize('com_nav_delete_account_confirm_placeholder'),
'delete-confirm-input',
'',
(e) => {
setDeleteInput(e.target.value);
handleInputChange(emailInput, e.target.value);
},
)}
</div>
{renderDeleteButton(handleDeleteUser, isDeleting, isLocked, localize)}
</div>
</DialogContent>
</Dialog>
</>
);
};

const renderInput = (
label: string,
id: string,
value: string,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void,
) => (
<div className="mb-4">
<label className="mb-1 block text-sm font-medium text-black dark:text-white">{label}</label>
<Input
id={id}
onChange={onChange}
placeholder={value}
className={cn(
defaultTextProps,
'h-10 max-h-10 w-full max-w-full rounded-md bg-white px-3 py-2',
removeFocusOutlines,
)}
/>
</div>
);

const renderDeleteButton = (
handleDeleteUser: () => void,
isDeleting: boolean,
isLocked: boolean,
localize: (key: string) => string,
) => (
<button
className={cn(
'mt-4 flex w-full items-center justify-center rounded-lg px-4 py-2 transition-colors duration-200',
isLocked
? 'cursor-not-allowed bg-gray-200 text-gray-300 dark:bg-gray-500 dark:text-gray-600'
: isDeleting
? 'cursor-not-allowed bg-gray-100 text-gray-700 dark:bg-gray-400 dark:text-gray-700'
: 'bg-red-700 text-white hover:bg-red-800 ',
)}
onClick={handleDeleteUser}
disabled={isDeleting || isLocked}
>
{isDeleting ? (
<div className="flex h-6 justify-center">
<Spinner className="icon-sm m-auto" />
</div>
) : (
<>
{isLocked ? (
<>
<LockIcon />
<span className="ml-2">{localize('com_ui_locked')}</span>
</>
) : (
<>
<LockIcon />
<span className="ml-2">{localize('com_nav_delete_account_button')}</span>
</>
)}
</>
)}
</button>
);

export default DeleteAccount;
19 changes: 19 additions & 0 deletions client/src/components/svg/LockIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export default function LockIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-lock"
>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
}
1 change: 1 addition & 0 deletions client/src/components/svg/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export { default as VolumeIcon } from './VolumeIcon';
export { default as VolumeMuteIcon } from './VolumeMuteIcon';
export { default as SendMessageIcon } from './SendMessageIcon';
export { default as UserIcon } from './UserIcon';
export { default as LockIcon } from './LockIcon';
export { default as NewChatIcon } from './NewChatIcon';
export { default as ExperimentIcon } from './ExperimentIcon';
export { default as GoogleIconChat } from './GoogleIconChat';
Expand Down