🚫👤feat: delete user from UI (#1526)

* initial commit

* fix: UserController bugs; fix: lint errors

* fix: delete files

* language support

* style(DeleteAccount): update to the latest style

* style: fix after merge main

* chore: Add canDeleteAccount middleware for user deletion endpoint

* chore: renamed to ALLOW_ACCOUNT_DELETION

* fix(canDeleteAccount): use uppercase admin role

* chore: imports order

* chore: Enable account deletion by default if omitted/commented out

* chore: Add logging for user account deletion

* chore: Bump data-provider package version to 0.6.6

* chore: Import Transaction model in UserController

* chore: Update CONFIG_VERSION to 1.1.4

* chore: Update user account deletion logging

* chore: Refactor user account deletion logic

---------

Co-authored-by: Berry-13 <root@Berry>
Co-authored-by: Danny Avila <messagedaniel@protonmail.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2024-06-06 01:35:12 +02:00 committed by GitHub
parent f69b317171
commit a7f5b57272
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 348 additions and 17 deletions

View file

@ -319,6 +319,7 @@ ALLOW_EMAIL_LOGIN=true
ALLOW_REGISTRATION=true ALLOW_REGISTRATION=true
ALLOW_SOCIAL_LOGIN=false ALLOW_SOCIAL_LOGIN=false
ALLOW_SOCIAL_REGISTRATION=false ALLOW_SOCIAL_REGISTRATION=false
# ALLOW_ACCOUNT_DELETION=true # note: enabled by default if omitted/commented out
SESSION_EXPIRY=1000 * 60 * 15 SESSION_EXPIRY=1000 * 60 * 15
REFRESH_TOKEN_EXPIRY=(1000 * 60 * 60 * 24) * 7 REFRESH_TOKEN_EXPIRY=(1000 * 60 * 60 * 24) * 7

View file

@ -97,8 +97,12 @@ const deleteFileByFilter = async (filter) => {
* @param {Array<string>} file_ids - The unique identifiers of the files to delete. * @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. * @returns {Promise<Object>} A promise that resolves to the result of the deletion operation.
*/ */
const deleteFiles = async (file_ids) => { const deleteFiles = async (file_ids, user) => {
return await File.deleteMany({ file_id: { $in: file_ids } }); let deleteQuery = { file_id: { $in: file_ids } };
if (user) {
deleteQuery = { user: user };
}
return await File.deleteMany(deleteQuery);
}; };
module.exports = { module.exports = {

View file

@ -1,5 +1,15 @@
const { updateUserPluginsService } = require('~/server/services/UserService'); const {
User,
Session,
Balance,
deleteFiles,
deleteConvos,
deletePresets,
deleteMessages,
} = require('~/models');
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService'); const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService');
const { Transaction } = require('~/models/Transaction');
const { logger } = require('~/config'); const { logger } = require('~/config');
const getUserController = async (req, res) => { const getUserController = async (req, res) => {
@ -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
logger.info(`User deleted account. Email: ${user.email} ID: ${user.id}`);
res.status(200).send({ message: 'User deleted' });
} catch (err) {
logger.error('[deleteUserController]', err);
res.status(500).send({ message: err.message });
}
};
module.exports = { module.exports = {
getUserController, getUserController,
updateUserPluginsController, updateUserPluginsController,
deleteUserController,
}; };

View file

@ -0,0 +1,27 @@
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
/**
* 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;
const { ALLOW_ACCOUNT_DELETION = true } = process.env;
if (user?.role === 'ADMIN' || isEnabled(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;

View file

@ -20,6 +20,7 @@ const validateImageRequest = require('./validateImageRequest');
const moderateText = require('./moderateText'); const moderateText = require('./moderateText');
const noIndex = require('./noIndex'); const noIndex = require('./noIndex');
const importLimiters = require('./importLimiters'); const importLimiters = require('./importLimiters');
const canDeleteAccount = require('./canDeleteAccount');
module.exports = { module.exports = {
...uploadLimiters, ...uploadLimiters,
@ -44,4 +45,5 @@ module.exports = {
noIndex, noIndex,
...importLimiters, ...importLimiters,
checkDomainAllowed, checkDomainAllowed,
canDeleteAccount,
}; };

View file

@ -1,10 +1,16 @@
const express = require('express'); const express = require('express');
const requireJwtAuth = require('../middleware/requireJwtAuth'); 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(); const router = express.Router();
router.get('/', requireJwtAuth, getUserController); router.get('/', requireJwtAuth, getUserController);
router.post('/plugins', requireJwtAuth, updateUserPluginsController); router.post('/plugins', requireJwtAuth, updateUserPluginsController);
router.delete('/delete', requireJwtAuth, canDeleteAccount, deleteUserController);
module.exports = router; module.exports = router;

View file

@ -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 { try {
return await PluginAuth.deleteOne({ userId, authField }); return await PluginAuth.deleteOne({ userId, authField });
} catch (err) { } catch (err) {

View file

@ -15,7 +15,9 @@ const SocialButton = ({ id, enabled, serverDomain, oauthPath, Icon, label }) =>
const handleMouseLeave = () => { const handleMouseLeave = () => {
setIsHovered(false); setIsHovered(false);
if (isPressed) {setIsPressed(false);} if (isPressed) {
setIsPressed(false);
}
}; };
const handleMouseDown = () => { const handleMouseDown = () => {

View file

@ -15,7 +15,6 @@ const ClearConvos = ({ open, onOpenChange }) => {
// Clear all conversations // Clear all conversations
const clearConvos = () => { const clearConvos = () => {
if (confirmClear) { if (confirmClear) {
console.log('Clearing conversations...');
clearConvosMutation.mutate( clearConvosMutation.mutate(
{}, {},
{ {

View file

@ -2,6 +2,7 @@ import React from 'react';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import * as Tabs from '@radix-ui/react-tabs'; import * as Tabs from '@radix-ui/react-tabs';
import { SettingsTabValues } from 'librechat-data-provider'; import { SettingsTabValues } from 'librechat-data-provider';
import DeleteAccount from './DeleteAccount';
import { Switch } from '~/components/ui'; import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import Avatar from './Avatar'; import Avatar from './Avatar';
@ -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"> <div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<Avatar /> <Avatar />
</div> </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 className="flex items-center justify-between">
<div> {localize('com_nav_user_name_display')} </div> <div> {localize('com_nav_user_name_display')} </div>
<Switch <Switch
@ -39,7 +43,6 @@ function Account({ onCheckedChange }: { onCheckedChange?: (value: boolean) => vo
/> />
</div> </div>
</div> </div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600"></div>
</Tabs.Content> </Tabs.Content>
); );
} }

View file

@ -0,0 +1,174 @@
import React, { useState, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogButton,
Input,
} from '~/components/ui';
import { cn, defaultTextProps, removeFocusOutlines } from '~/utils';
import { useDeleteUserMutation } from '~/data-provider';
import { Spinner, LockIcon } from '~/components/svg';
import { useAuthContext } from '~/hooks/AuthContext';
import { useLocalize } from '~/hooks';
const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolean }) => {
const localize = useLocalize();
const { user, logout } = useAuthContext();
const { mutate: deleteUser, isLoading: isDeleting } = useDeleteUserMutation({
onSuccess: () => logout(),
});
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
const [deleteInput, setDeleteInput] = useState('');
const [emailInput, setEmailInput] = useState('');
const [isLocked, setIsLocked] = useState(true);
const onClick = useCallback(() => {
setDialogOpen(true);
}, []);
const handleDeleteUser = () => {
if (!isLocked) {
deleteUser(undefined);
}
};
const handleInputChange = useCallback(
(newEmailInput: string, newDeleteInput: string) => {
const isEmailCorrect =
newEmailInput.trim().toLowerCase() === user?.email?.trim().toLowerCase();
const isDeleteInputCorrect = newDeleteInput === 'DELETE';
setIsLocked(!(isEmailCorrect && isDeleteInputCorrect));
},
[user?.email],
);
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',
user?.email || '',
(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;

View file

@ -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>
);
}

View file

@ -46,6 +46,7 @@ export { default as VolumeIcon } from './VolumeIcon';
export { default as VolumeMuteIcon } from './VolumeMuteIcon'; export { default as VolumeMuteIcon } from './VolumeMuteIcon';
export { default as SendMessageIcon } from './SendMessageIcon'; export { default as SendMessageIcon } from './SendMessageIcon';
export { default as UserIcon } from './UserIcon'; export { default as UserIcon } from './UserIcon';
export { default as LockIcon } from './LockIcon';
export { default as NewChatIcon } from './NewChatIcon'; export { default as NewChatIcon } from './NewChatIcon';
export { default as ExperimentIcon } from './ExperimentIcon'; export { default as ExperimentIcon } from './ExperimentIcon';
export { default as GoogleIconChat } from './GoogleIconChat'; export { default as GoogleIconChat } from './GoogleIconChat';

View file

@ -3,22 +3,22 @@ import {
LocalStorageKeys, LocalStorageKeys,
defaultAssistantsVersion, defaultAssistantsVersion,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import { useSetRecoilState } from 'recoil';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { dataService, MutationKeys, QueryKeys, defaultOrderQuery } from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query'; import type { UseMutationResult } from '@tanstack/react-query';
import type t from 'librechat-data-provider'; import type t from 'librechat-data-provider';
import { import {
addSharedLink,
addConversation, addConversation,
deleteSharedLink,
updateConvoFields,
updateConversation, updateConversation,
deleteConversation, deleteConversation,
updateConvoFields,
deleteSharedLink,
addSharedLink,
} from '~/utils'; } from '~/utils';
import { dataService, MutationKeys, QueryKeys, defaultOrderQuery } from 'librechat-data-provider';
import { useSetRecoilState } from 'recoil';
import store from '~/store';
import { normalizeData } from '~/utils/collection';
import { useConversationsInfiniteQuery, useSharedLinksInfiniteQuery } from './queries'; import { useConversationsInfiniteQuery, useSharedLinksInfiniteQuery } from './queries';
import { normalizeData } from '~/utils/collection';
import store from '~/store';
/** Conversations */ /** Conversations */
export const useGenTitleMutation = (): UseMutationResult< export const useGenTitleMutation = (): UseMutationResult<
@ -609,6 +609,30 @@ export const useUploadAvatarMutation = (
}); });
}; };
export const useDeleteUserMutation = (
options?: t.MutationOptions<unknown, undefined>,
): UseMutationResult<unknown, unknown, undefined, unknown> => {
const queryClient = useQueryClient();
const setDefaultPreset = useSetRecoilState(store.defaultPreset);
return useMutation([MutationKeys.deleteUser], {
mutationFn: () => dataService.deleteUser(),
...(options || {}),
onSuccess: (...args) => {
options?.onSuccess?.(...args);
},
onMutate: (...args) => {
setDefaultPreset(null);
queryClient.removeQueries();
localStorage.removeItem(LocalStorageKeys.LAST_CONVO_SETUP);
localStorage.removeItem(LocalStorageKeys.LAST_MODEL);
localStorage.removeItem(LocalStorageKeys.LAST_TOOLS);
localStorage.removeItem(LocalStorageKeys.FILES_TO_DELETE);
options?.onMutate?.(...args);
},
});
};
/* Speech to text */ /* Speech to text */
export const useSpeechToTextMutation = ( export const useSpeechToTextMutation = (
options?: t.SpeechToTextOptions, options?: t.SpeechToTextOptions,

View file

@ -238,6 +238,7 @@ export default {
com_ui_preview: 'Preview', com_ui_preview: 'Preview',
com_ui_upload: 'Upload', com_ui_upload: 'Upload',
com_ui_connect: 'Connect', com_ui_connect: 'Connect',
com_ui_locked: 'Locked',
com_ui_upload_delay: com_ui_upload_delay:
'Uploading "{0}" is taking more time than anticipated. Please wait while the file finishes indexing for retrieval.', 'Uploading "{0}" is taking more time than anticipated. Please wait while the file finishes indexing for retrieval.',
com_ui_privacy_policy: 'Privacy policy', com_ui_privacy_policy: 'Privacy policy',
@ -546,6 +547,14 @@ export default {
com_nav_help_faq: 'Help & FAQ', com_nav_help_faq: 'Help & FAQ',
com_nav_settings: 'Settings', com_nav_settings: 'Settings',
com_nav_search_placeholder: 'Search messages', com_nav_search_placeholder: 'Search messages',
com_nav_delete_account: 'Delete account',
com_nav_delete_account_confirm: 'Delete account - are you sure?',
com_nav_delete_account_button: 'Permanently delete my account',
com_nav_delete_account_email_placeholder: 'Please enter your account email',
com_nav_delete_account_confirm_placeholder: 'To proceed, type "DELETE" in the input field below',
com_nav_delete_warning: 'WARNING: This will permanently delete your account.',
com_nav_delete_data_info: 'All your data will be deleted.',
com_nav_delete_help_center: 'For more information, please visit our Help Center.',
com_nav_conversation_mode: 'Conversation Mode', com_nav_conversation_mode: 'Conversation Mode',
com_nav_auto_send_text: 'Auto send text (after 3 sec)', com_nav_auto_send_text: 'Auto send text (after 3 sec)',
com_nav_auto_transcribe_audio: 'Auto transcribe audio', com_nav_auto_transcribe_audio: 'Auto transcribe audio',

View file

@ -212,6 +212,7 @@ export default {
com_ui_preview: 'Anteprima', com_ui_preview: 'Anteprima',
com_ui_upload: 'Carica', com_ui_upload: 'Carica',
com_ui_connect: 'Connetti', com_ui_connect: 'Connetti',
com_ui_locked: 'Bloccato',
com_ui_upload_delay: com_ui_upload_delay:
'Il caricamento di "{0}" sta richiedendo più tempo del previsto. Attendi il completamento dell\'indicizzazione per il recupero.', 'Il caricamento di "{0}" sta richiedendo più tempo del previsto. Attendi il completamento dell\'indicizzazione per il recupero.',
com_ui_privacy_policy: 'Informativa sulla privacy', com_ui_privacy_policy: 'Informativa sulla privacy',
@ -520,8 +521,17 @@ export default {
com_nav_help_faq: 'Guida e FAQ', com_nav_help_faq: 'Guida e FAQ',
com_nav_settings: 'Impostazioni', com_nav_settings: 'Impostazioni',
com_nav_search_placeholder: 'Cerca messaggi', com_nav_search_placeholder: 'Cerca messaggi',
com_nav_setting_general: 'Generali', com_nav_delete_account: 'Elimina account',
com_nav_setting_beta: 'Funzionalità beta', com_nav_delete_account_confirm: 'Sei sicuro di voler eliminare il tuo account?',
com_nav_delete_account_button: 'Elimina permanentemente il mio account',
com_nav_delete_account_email_placeholder: 'Inserisci la tua email',
com_nav_delete_account_confirm_placeholder:
'Per procedere, digita "DELETE" nel campo di input sottostante',
com_dialog_delete_warning: 'ATTENZIONE: Questo cancellerà permanentemente il tuo account.',
com_dialog_delete_data_info: 'Tutti i tuoi dati verranno eliminati.',
com_dialog_delete_help_center: 'Per più informazioni, visita il nostro centro assistenza.',
com_nav_setting_general: 'Generale',
com_nav_setting_beta: 'Funzioni Beta',
com_nav_setting_data: 'Controlli dati', com_nav_setting_data: 'Controlli dati',
com_nav_setting_speech: 'Voce', com_nav_setting_speech: 'Voce',
com_nav_setting_account: 'Account', com_nav_setting_account: 'Account',

View file

@ -6,6 +6,8 @@ export const balance = () => '/api/balance';
export const userPlugins = () => '/api/user/plugins'; export const userPlugins = () => '/api/user/plugins';
export const deleteUser = () => '/api/user/delete';
export const messages = (conversationId: string, messageId?: string) => export const messages = (conversationId: string, messageId?: string) =>
`/api/messages/${conversationId}${messageId ? `/${messageId}` : ''}`; `/api/messages/${conversationId}${messageId ? `/${messageId}` : ''}`;

View file

@ -24,6 +24,10 @@ export function revokeAllUserKeys(): Promise<unknown> {
return request.delete(endpoints.revokeAllUserKeys()); return request.delete(endpoints.revokeAllUserKeys());
} }
export function deleteUser(): Promise<s.TPreset> {
return request.delete(endpoints.deleteUser());
}
export function getMessagesByConvoId(conversationId: string): Promise<s.TMessage[]> { export function getMessagesByConvoId(conversationId: string): Promise<s.TMessage[]> {
if (conversationId === 'new') { if (conversationId === 'new') {
return Promise.resolve([]); return Promise.resolve([]);

View file

@ -41,4 +41,5 @@ export enum MutationKeys {
assistantAvatarUpload = 'assistantAvatarUpload', assistantAvatarUpload = 'assistantAvatarUpload',
updateAction = 'updateAction', updateAction = 'updateAction',
deleteAction = 'deleteAction', deleteAction = 'deleteAction',
deleteUser = 'deleteUser',
} }