From a7f5b5727254c54a76e5e625c62e19058408058c Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 6 Jun 2024 01:35:12 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=AB=F0=9F=91=A4feat:=20delete=20user?= =?UTF-8?q?=20from=20UI=20(#1526)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: Danny Avila Co-authored-by: Danny Avila --- .env.example | 1 + api/models/File.js | 8 +- api/server/controllers/UserController.js | 35 +++- api/server/middleware/canDeleteAccount.js | 27 +++ api/server/middleware/index.js | 2 + api/server/routes/user.js | 8 +- api/server/services/PluginService.js | 12 +- client/src/components/Auth/SocialButton.tsx | 4 +- client/src/components/Nav/ClearConvos.tsx | 1 - .../Nav/SettingsTabs/Account/Account.tsx | 5 +- .../SettingsTabs/Account/DeleteAccount.tsx | 174 ++++++++++++++++++ client/src/components/svg/LockIcon.tsx | 19 ++ client/src/components/svg/index.ts | 1 + client/src/data-provider/mutations.ts | 38 +++- client/src/localization/languages/Eng.ts | 9 + client/src/localization/languages/It.ts | 14 +- packages/data-provider/src/api-endpoints.ts | 2 + packages/data-provider/src/data-service.ts | 4 + packages/data-provider/src/keys.ts | 1 + 19 files changed, 348 insertions(+), 17 deletions(-) create mode 100644 api/server/middleware/canDeleteAccount.js create mode 100644 client/src/components/Nav/SettingsTabs/Account/DeleteAccount.tsx create mode 100644 client/src/components/svg/LockIcon.tsx diff --git a/.env.example b/.env.example index bba381ae7a..6afc2e2cd8 100644 --- a/.env.example +++ b/.env.example @@ -319,6 +319,7 @@ ALLOW_EMAIL_LOGIN=true ALLOW_REGISTRATION=true ALLOW_SOCIAL_LOGIN=false ALLOW_SOCIAL_REGISTRATION=false +# ALLOW_ACCOUNT_DELETION=true # note: enabled by default if omitted/commented out SESSION_EXPIRY=1000 * 60 * 15 REFRESH_TOKEN_EXPIRY=(1000 * 60 * 60 * 24) * 7 diff --git a/api/models/File.js b/api/models/File.js index 16e9ab6a0e..17f8506600 100644 --- a/api/models/File.js +++ b/api/models/File.js @@ -97,8 +97,12 @@ const deleteFileByFilter = async (filter) => { * @param {Array} file_ids - The unique identifiers of the files to delete. * @returns {Promise} 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 = { diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index ac20ca627a..b658395b4a 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -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 { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService'); +const { Transaction } = require('~/models/Transaction'); const { logger } = require('~/config'); 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 = { getUserController, updateUserPluginsController, + deleteUserController, }; diff --git a/api/server/middleware/canDeleteAccount.js b/api/server/middleware/canDeleteAccount.js new file mode 100644 index 0000000000..1abfbc9f8c --- /dev/null +++ b/api/server/middleware/canDeleteAccount.js @@ -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} - 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; diff --git a/api/server/middleware/index.js b/api/server/middleware/index.js index a7663f3d75..2ec953b323 100644 --- a/api/server/middleware/index.js +++ b/api/server/middleware/index.js @@ -20,6 +20,7 @@ const validateImageRequest = require('./validateImageRequest'); const moderateText = require('./moderateText'); const noIndex = require('./noIndex'); const importLimiters = require('./importLimiters'); +const canDeleteAccount = require('./canDeleteAccount'); module.exports = { ...uploadLimiters, @@ -44,4 +45,5 @@ module.exports = { noIndex, ...importLimiters, checkDomainAllowed, + canDeleteAccount, }; diff --git a/api/server/routes/user.js b/api/server/routes/user.js index b90e3d965b..96e09b446d 100644 --- a/api/server/routes/user.js +++ b/api/server/routes/user.js @@ -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; diff --git a/api/server/services/PluginService.js b/api/server/services/PluginService.js index efe0bb03fd..39d1693f87 100644 --- a/api/server/services/PluginService.js +++ b/api/server/services/PluginService.js @@ -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) { diff --git a/client/src/components/Auth/SocialButton.tsx b/client/src/components/Auth/SocialButton.tsx index 8b8318ac3a..c87ccf55ec 100644 --- a/client/src/components/Auth/SocialButton.tsx +++ b/client/src/components/Auth/SocialButton.tsx @@ -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 = () => { diff --git a/client/src/components/Nav/ClearConvos.tsx b/client/src/components/Nav/ClearConvos.tsx index 23a2d5ca56..438361aa51 100644 --- a/client/src/components/Nav/ClearConvos.tsx +++ b/client/src/components/Nav/ClearConvos.tsx @@ -15,7 +15,6 @@ const ClearConvos = ({ open, onOpenChange }) => { // Clear all conversations const clearConvos = () => { if (confirmClear) { - console.log('Clearing conversations...'); clearConvosMutation.mutate( {}, { diff --git a/client/src/components/Nav/SettingsTabs/Account/Account.tsx b/client/src/components/Nav/SettingsTabs/Account/Account.tsx index 73bf10f7f3..f0c6a3ba57 100644 --- a/client/src/components/Nav/SettingsTabs/Account/Account.tsx +++ b/client/src/components/Nav/SettingsTabs/Account/Account.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useRecoilState } from 'recoil'; import * as Tabs from '@radix-ui/react-tabs'; import { SettingsTabValues } from 'librechat-data-provider'; +import DeleteAccount from './DeleteAccount'; import { Switch } from '~/components/ui'; import { useLocalize } from '~/hooks'; import Avatar from './Avatar'; @@ -28,6 +29,9 @@ function Account({ onCheckedChange }: { onCheckedChange?: (value: boolean) => vo
+
+ +
{localize('com_nav_user_name_display')}
vo />
-
); } diff --git a/client/src/components/Nav/SettingsTabs/Account/DeleteAccount.tsx b/client/src/components/Nav/SettingsTabs/Account/DeleteAccount.tsx new file mode 100644 index 0000000000..5944242f7d --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Account/DeleteAccount.tsx @@ -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(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 ( + <> +
+ {localize('com_nav_delete_account')} + +
+ setDialogOpen(false)}> + + + + {localize('com_nav_delete_account_confirm')} + + +
+
    +
  • {localize('com_nav_delete_warning')}
  • +
  • {localize('com_nav_delete_data_info')}
  • +
  • {localize('com_nav_delete_help_center')}
  • +
+
+
+
+ {renderInput( + localize('com_nav_delete_account_email_placeholder'), + 'email-confirm-input', + user?.email || '', + (e) => { + setEmailInput(e.target.value); + handleInputChange(e.target.value, deleteInput); + }, + )} +
+
+ {renderInput( + localize('com_nav_delete_account_confirm_placeholder'), + 'delete-confirm-input', + '', + (e) => { + setDeleteInput(e.target.value); + handleInputChange(emailInput, e.target.value); + }, + )} +
+ {renderDeleteButton(handleDeleteUser, isDeleting, isLocked, localize)} +
+
+
+ + ); +}; + +const renderInput = ( + label: string, + id: string, + value: string, + onChange: (e: React.ChangeEvent) => void, +) => ( +
+ + +
+); + +const renderDeleteButton = ( + handleDeleteUser: () => void, + isDeleting: boolean, + isLocked: boolean, + localize: (key: string) => string, +) => ( + +); + +export default DeleteAccount; diff --git a/client/src/components/svg/LockIcon.tsx b/client/src/components/svg/LockIcon.tsx new file mode 100644 index 0000000000..9de937a44a --- /dev/null +++ b/client/src/components/svg/LockIcon.tsx @@ -0,0 +1,19 @@ +export default function LockIcon() { + return ( + + + + + ); +} diff --git a/client/src/components/svg/index.ts b/client/src/components/svg/index.ts index 509f2c91f7..4c10220c55 100644 --- a/client/src/components/svg/index.ts +++ b/client/src/components/svg/index.ts @@ -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'; diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index b4e42bb16a..6ff3e4ead9 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -3,22 +3,22 @@ import { LocalStorageKeys, defaultAssistantsVersion, } from 'librechat-data-provider'; +import { useSetRecoilState } from 'recoil'; 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 t from 'librechat-data-provider'; import { + addSharedLink, addConversation, + deleteSharedLink, + updateConvoFields, updateConversation, deleteConversation, - updateConvoFields, - deleteSharedLink, - addSharedLink, } 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 { normalizeData } from '~/utils/collection'; +import store from '~/store'; /** Conversations */ export const useGenTitleMutation = (): UseMutationResult< @@ -609,6 +609,30 @@ export const useUploadAvatarMutation = ( }); }; +export const useDeleteUserMutation = ( + options?: t.MutationOptions, +): UseMutationResult => { + 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 */ export const useSpeechToTextMutation = ( options?: t.SpeechToTextOptions, diff --git a/client/src/localization/languages/Eng.ts b/client/src/localization/languages/Eng.ts index c4e4f3fa5c..0fe2c4c779 100644 --- a/client/src/localization/languages/Eng.ts +++ b/client/src/localization/languages/Eng.ts @@ -238,6 +238,7 @@ export default { com_ui_preview: 'Preview', com_ui_upload: 'Upload', com_ui_connect: 'Connect', + com_ui_locked: 'Locked', com_ui_upload_delay: 'Uploading "{0}" is taking more time than anticipated. Please wait while the file finishes indexing for retrieval.', com_ui_privacy_policy: 'Privacy policy', @@ -546,6 +547,14 @@ export default { com_nav_help_faq: 'Help & FAQ', com_nav_settings: 'Settings', 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_auto_send_text: 'Auto send text (after 3 sec)', com_nav_auto_transcribe_audio: 'Auto transcribe audio', diff --git a/client/src/localization/languages/It.ts b/client/src/localization/languages/It.ts index 5004477b16..6c24a60f68 100644 --- a/client/src/localization/languages/It.ts +++ b/client/src/localization/languages/It.ts @@ -212,6 +212,7 @@ export default { com_ui_preview: 'Anteprima', com_ui_upload: 'Carica', com_ui_connect: 'Connetti', + com_ui_locked: 'Bloccato', com_ui_upload_delay: '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', @@ -520,8 +521,17 @@ export default { com_nav_help_faq: 'Guida e FAQ', com_nav_settings: 'Impostazioni', com_nav_search_placeholder: 'Cerca messaggi', - com_nav_setting_general: 'Generali', - com_nav_setting_beta: 'Funzionalità beta', + com_nav_delete_account: 'Elimina account', + 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_speech: 'Voce', com_nav_setting_account: 'Account', diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index aa290bf495..d35e6e93ae 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -6,6 +6,8 @@ export const balance = () => '/api/balance'; export const userPlugins = () => '/api/user/plugins'; +export const deleteUser = () => '/api/user/delete'; + export const messages = (conversationId: string, messageId?: string) => `/api/messages/${conversationId}${messageId ? `/${messageId}` : ''}`; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index d12a8f0732..02722965f9 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -24,6 +24,10 @@ export function revokeAllUserKeys(): Promise { return request.delete(endpoints.revokeAllUserKeys()); } +export function deleteUser(): Promise { + return request.delete(endpoints.deleteUser()); +} + export function getMessagesByConvoId(conversationId: string): Promise { if (conversationId === 'new') { return Promise.resolve([]); diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index 45302947b9..5d80780b60 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -41,4 +41,5 @@ export enum MutationKeys { assistantAvatarUpload = 'assistantAvatarUpload', updateAction = 'updateAction', deleteAction = 'deleteAction', + deleteUser = 'deleteUser', }