mirror of
https://github.com/OpenSignLabs/OpenSign.git
synced 2026-09-06 09:47:39 +02:00
Merge pull request #1292 from nxglabs/sync-to-public_repo-17730712175
Merge pull request #1291 from nxglabs/staging
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
import { S3Client, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import fs from 'node:fs/promises';
|
||||
import pLimit from 'p-limit';
|
||||
import { serverAppId } from '../../../Utils.js';
|
||||
|
||||
// === Configuration ===
|
||||
const serverHost = new URL(process.env.SERVER_URL).hostname;
|
||||
const LOCAL_HOSTS = ['localhost', '127.0.0.1', serverHost];
|
||||
const CONCURRENCY_LIMIT = 5;
|
||||
|
||||
// === S3 Client Setup ===
|
||||
function createS3Client({ region, accessKeyId, secretAccessKey, endpoint = null }) {
|
||||
const config = {
|
||||
region,
|
||||
credentials: {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
},
|
||||
};
|
||||
|
||||
// Only set custom endpoint if not using AWS
|
||||
if (endpoint && !endpoint.includes('amazonaws.com')) {
|
||||
config.endpoint = `https://${endpoint}`;
|
||||
}
|
||||
|
||||
return new S3Client(config);
|
||||
}
|
||||
|
||||
const s3 = createS3Client({
|
||||
region: process.env.DO_REGION,
|
||||
endpoint: process.env.DO_ENDPOINT,
|
||||
accessKeyId: process.env.DO_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.DO_SECRET_ACCESS_KEY,
|
||||
});
|
||||
|
||||
// === Helpers ===
|
||||
function getS3ParamsFromUrl(fileUrl) {
|
||||
try {
|
||||
const url = new URL(fileUrl);
|
||||
const Bucket = url.hostname.split('.')[0];
|
||||
const Key = decodeURIComponent(url.pathname.slice(1));
|
||||
return { Bucket, Key };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteS3File(fileUrl) {
|
||||
const params = getS3ParamsFromUrl(fileUrl);
|
||||
if (!params) return;
|
||||
|
||||
try {
|
||||
await s3.send(new DeleteObjectCommand(params));
|
||||
// console.log(`✅ Deleted from S3: ${params.Key}`);
|
||||
} catch (err) {
|
||||
console.error(`❌ S3 delete failed: ${params.Key}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteLocalFile(fileUrl) {
|
||||
try {
|
||||
const url = new URL(fileUrl);
|
||||
const filePath = decodeURIComponent(url.pathname);
|
||||
if (!filePath.includes('files')) return;
|
||||
|
||||
const localPath = url?.pathname?.split(`/files/${serverAppId}/`)?.pop();
|
||||
|
||||
if (localPath) {
|
||||
await fs.unlink(`./files/files/${localPath}`);
|
||||
}
|
||||
// console.log(`🗑️ Deleted local file: ${localPath}`);
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
console.warn('⚠️ Local file not found:', fileUrl);
|
||||
} else {
|
||||
console.error('❌ Local delete failed:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFileByUrl(fileUrl) {
|
||||
if (!fileUrl) return;
|
||||
try {
|
||||
const url = new URL(fileUrl);
|
||||
if (LOCAL_HOSTS.includes(url.hostname)) {
|
||||
return deleteLocalFile(fileUrl);
|
||||
} else {
|
||||
return deleteS3File(fileUrl);
|
||||
}
|
||||
} catch {
|
||||
console.warn('⚠️ Invalid URL, skipping:', fileUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// === Main Batch Deletion Function ===
|
||||
export async function deleteInBatches(className, userPointer) {
|
||||
let hasMore = true;
|
||||
const limit = 1000;
|
||||
const limiter = pLimit(CONCURRENCY_LIMIT);
|
||||
|
||||
while (hasMore) {
|
||||
const query = new Parse.Query(className);
|
||||
query.equalTo('CreatedBy', userPointer);
|
||||
query.limit(limit);
|
||||
query.ascending('objectId');
|
||||
|
||||
const results = await query.find({ useMasterKey: true });
|
||||
|
||||
// Step 1: Concurrent file deletions with controlled concurrency
|
||||
const fileDeletePromises = [];
|
||||
|
||||
for (const obj of results) {
|
||||
const urls = ['URL', 'SignedUrl', 'certificateUrl']
|
||||
.map(field => obj.get(field))
|
||||
.filter(Boolean);
|
||||
|
||||
for (const fileUrl of urls) {
|
||||
fileDeletePromises.push(limiter(() => deleteFileByUrl(fileUrl)));
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(fileDeletePromises);
|
||||
|
||||
// Step 2: Delete Parse objects
|
||||
if (results.length > 0) {
|
||||
await Parse.Object.destroyAll(results, { useMasterKey: true });
|
||||
console.log(`🧹 Deleted ${results.length} Parse objects from ${className}`);
|
||||
}
|
||||
|
||||
hasMore = results.length === limit;
|
||||
}
|
||||
|
||||
console.log(`✅ Finished deletion from ${className} for user: ${userPointer.objectId}`);
|
||||
}
|
||||
|
||||
export async function deleteDataFiles(className, userPointer) {
|
||||
let hasMore = true;
|
||||
const limit = 1000;
|
||||
const limiter = pLimit(CONCURRENCY_LIMIT);
|
||||
|
||||
while (hasMore) {
|
||||
const query = new Parse.Query(className);
|
||||
query.equalTo('UserId', userPointer);
|
||||
query.limit(limit);
|
||||
query.ascending('objectId');
|
||||
|
||||
const results = await query.find({ useMasterKey: true });
|
||||
|
||||
// Step 1: Concurrent file deletions with controlled concurrency
|
||||
const fileDeletePromises = [];
|
||||
|
||||
for (const obj of results) {
|
||||
const urls = ['FileUrl'].map(field => obj.get(field)).filter(Boolean);
|
||||
for (const fileUrl of urls) {
|
||||
fileDeletePromises.push(limiter(() => deleteFileByUrl(fileUrl)));
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(fileDeletePromises);
|
||||
|
||||
// Step 2: Delete Parse objects
|
||||
if (results.length > 0) {
|
||||
await Parse.Object.destroyAll(results, { useMasterKey: true });
|
||||
console.log(`🧹 Deleted ${results.length} Parse objects from ${className}`);
|
||||
}
|
||||
|
||||
hasMore = results.length === limit;
|
||||
}
|
||||
|
||||
console.log(`✅ Finished deletion from ${className} for user: ${userPointer.objectId}`);
|
||||
}
|
||||
|
||||
export async function deleteContactsInBatch(className, userPointer) {
|
||||
let hasMore = true;
|
||||
const limit = 1000;
|
||||
|
||||
while (hasMore) {
|
||||
const query = new Parse.Query(className);
|
||||
query.equalTo('CreatedBy', userPointer);
|
||||
query.limit(limit);
|
||||
query.ascending('objectId');
|
||||
const results = await query.find({ useMasterKey: true });
|
||||
if (results?.length > 0) {
|
||||
await Parse.Object.destroyAll(results, { useMasterKey: true });
|
||||
console.log(`🧹 Deleted ${results.length} Parse objects from ${className}`);
|
||||
}
|
||||
|
||||
hasMore = results.length === limit;
|
||||
}
|
||||
|
||||
console.log(`✅ Finished deletion from ${className} for user: ${userPointer.objectId}`);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import axios from 'axios';
|
||||
import { cloudServerUrl, generateId, serverAppId } from '../../../Utils.js';
|
||||
import sendmailtoSupport from '../sendMailToSupport.js';
|
||||
import { deleteContactsInBatch, deleteDataFiles, deleteInBatches } from './deleteFileUrl.js';
|
||||
import { MAX_ATTEMPTS } from './deleteUtils.js';
|
||||
const serverUrl = cloudServerUrl;
|
||||
const appId = serverAppId;
|
||||
|
||||
const deleteSessionsAndUser = async (userPointer, userId) => {
|
||||
const Session = Parse.Object.extend('_Session');
|
||||
const sessionQuery = new Parse.Query(Session);
|
||||
sessionQuery.equalTo('user', userPointer);
|
||||
const sessions = await sessionQuery.find({ useMasterKey: true });
|
||||
if (sessions?.length > 0) await Parse.Object.destroyAll(sessions, { useMasterKey: true });
|
||||
|
||||
const userObj = await new Parse.Query(Parse.User).get(userId, { useMasterKey: true });
|
||||
if (userObj) await userObj.destroy({ useMasterKey: true });
|
||||
};
|
||||
|
||||
const resetPasswordAndDeleteSession = async userId => {
|
||||
const password = generateId(16);
|
||||
const user = await new Parse.Query(Parse.User).get(userId, { useMasterKey: true });
|
||||
user.set('password', password);
|
||||
user.set('emailVerified', false);
|
||||
user.unset('ProfilePic');
|
||||
await user.save(null, { useMasterKey: true });
|
||||
|
||||
// Optional: revoke all existing sessions (forces logout everywhere)
|
||||
const sessionQuery = new Parse.Query('_Session');
|
||||
sessionQuery.equalTo('user', user);
|
||||
const sessions = await sessionQuery.find({ useMasterKey: true });
|
||||
if (sessions.length) {
|
||||
await Parse.Object.destroyAll(sessions, { useMasterKey: true });
|
||||
}
|
||||
};
|
||||
export async function deleteUser(userId, adminId) {
|
||||
const userPointer = { __type: 'Pointer', className: '_User', objectId: userId };
|
||||
let userDetails = {
|
||||
UserRole: 'not found',
|
||||
Name: 'not found',
|
||||
Email: 'not found',
|
||||
UserId: userId || 'not found',
|
||||
objectId: 'not found',
|
||||
TenantId: 'not found',
|
||||
};
|
||||
try {
|
||||
// STEP 1: contracts_Users lookup
|
||||
const Users = Parse.Object.extend('contracts_Users');
|
||||
const userQuery = new Parse.Query(Users);
|
||||
userQuery.equalTo('UserId', userPointer);
|
||||
if (adminId) {
|
||||
userQuery.equalTo('CreatedBy', { __type: 'Pointer', className: '_User', objectId: adminId });
|
||||
}
|
||||
const userResult = await userQuery.first({ useMasterKey: true });
|
||||
userDetails = { ...userDetails, UserId: userId };
|
||||
if (!userResult) {
|
||||
const errorMessage = 'User not found.';
|
||||
sendmailtoSupport(userDetails, errorMessage);
|
||||
return { code: 400, message: errorMessage };
|
||||
}
|
||||
const contractsUserId = userResult.id;
|
||||
const tenantId = userResult.get('TenantId')?.id;
|
||||
const teamIds = userResult.get('TeamIds') || [];
|
||||
const organizationId = userResult.get('OrganizationId')?.id;
|
||||
const isAdmin = userResult?.get('UserRole') === 'contracts_Admin' ? true : false;
|
||||
userDetails = {
|
||||
...userDetails,
|
||||
UserRole: userResult?.get('UserRole'),
|
||||
Name: userResult?.get('Name'),
|
||||
Email: userResult?.get('Email'),
|
||||
UserId: userResult?.get('UserId')?.id,
|
||||
objectId: userResult?.id,
|
||||
TenantId: userResult?.get('TenantId')?.id,
|
||||
};
|
||||
if (adminId && isAdmin) {
|
||||
const errorMessage = 'An error occurred while deleting your account.';
|
||||
sendmailtoSupport(userDetails, errorMessage);
|
||||
return { code: 400, message: errorMessage };
|
||||
}
|
||||
|
||||
// STEP 2: contracts_Document & contracts_Template
|
||||
try {
|
||||
for (const className of ['contracts_Document', 'contracts_Template']) {
|
||||
await deleteInBatches(className, userPointer);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed during contracts_Template cleanup:', err);
|
||||
const errorMessage = 'Failed during contracts_Template cleanup:' + err?.message;
|
||||
sendmailtoSupport(userDetails, errorMessage);
|
||||
return { code: 400, message: errorMessage };
|
||||
}
|
||||
|
||||
// STEP 3: delete Contacts created by user from contactbook class
|
||||
try {
|
||||
await deleteContactsInBatch('contracts_Contactbook', userPointer);
|
||||
} catch (err) {
|
||||
console.error('Failed during contactbook cleanup:', err);
|
||||
const errorMessage = 'Failed during contactbook cleanup:' + err?.message;
|
||||
sendmailtoSupport(userDetails, errorMessage);
|
||||
return { code: 400, message: errorMessage };
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if any rows remain for this UserId
|
||||
const Contactbook = Parse.Object.extend('contracts_Contactbook');
|
||||
const remainingCount = await new Parse.Query(Contactbook)
|
||||
.equalTo('UserId', userPointer)
|
||||
.count({ useMasterKey: true });
|
||||
|
||||
// If no record remains delete from _User class
|
||||
if (remainingCount === 0) {
|
||||
await deleteSessionsAndUser(userPointer, userId);
|
||||
} else {
|
||||
await resetPasswordAndDeleteSession(userId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed during contactbook current user cleanup:', err);
|
||||
const errorMessage =
|
||||
'Failed during contactbook current user cleanup: ' + (err?.message || err);
|
||||
sendmailtoSupport(userDetails, errorMessage);
|
||||
return { code: 400, message: errorMessage };
|
||||
}
|
||||
|
||||
// STEP 4: appToken
|
||||
try {
|
||||
const AppToken = Parse.Object.extend('appToken');
|
||||
const query = new Parse.Query(AppToken);
|
||||
query.equalTo('UserId', userPointer);
|
||||
const tokens = await query.find({ useMasterKey: true });
|
||||
if (tokens?.length) await Parse.Object.destroyAll(tokens, { useMasterKey: true });
|
||||
} catch (err) {
|
||||
console.error('Failed to delete appToken entries:', err);
|
||||
const errorMessage = 'Failed to delete appToken entries:' + err?.message;
|
||||
sendmailtoSupport(userDetails, errorMessage);
|
||||
return { code: 400, message: errorMessage };
|
||||
}
|
||||
|
||||
// STEP 5: partner_DataFiles
|
||||
try {
|
||||
await deleteDataFiles('partners_DataFiles', userPointer);
|
||||
} catch (err) {
|
||||
console.error('Failed during partners_DataFiles cleanup:', err);
|
||||
const errorMessage = 'Failed during partners_DataFiles cleanup:' + err?.message;
|
||||
sendmailtoSupport(userDetails, errorMessage);
|
||||
return { code: 400, message: errorMessage };
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
// STEP 6: contracts_Organizations
|
||||
try {
|
||||
if (organizationId) {
|
||||
const Org = Parse.Object.extend('contracts_Organizations');
|
||||
const query = new Parse.Query(Org);
|
||||
const object = await query.get(organizationId, { useMasterKey: true });
|
||||
await object.destroy({ useMasterKey: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete contracts_Organizations entry:', err);
|
||||
const errorMessage = 'Failed to delete contracts_Organizations entry:' + err?.message;
|
||||
sendmailtoSupport(userDetails, errorMessage);
|
||||
return { code: 400, message: errorMessage };
|
||||
}
|
||||
// STEP 7: Delete each entry in contracts_Teams by objectId from teamIds
|
||||
try {
|
||||
if (teamIds.length > 0) {
|
||||
const Teams = Parse.Object.extend('contracts_Teams');
|
||||
for (const team of teamIds) {
|
||||
try {
|
||||
const teamObj = await new Parse.Query(Teams).get(team.id, { useMasterKey: true });
|
||||
if (teamObj) await teamObj.destroy({ useMasterKey: true });
|
||||
} catch (teamErr) {
|
||||
console.error(`Failed to delete team with ID ${team.id}:`, teamErr);
|
||||
const errorMessage = `Failed to delete team with ID ${team.id}` + teamErr?.message;
|
||||
sendmailtoSupport(userDetails, errorMessage);
|
||||
return { code: 400, message: errorMessage };
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed during contracts_Teams deletion loop:', err);
|
||||
const errorMessage = 'Failed during contracts_Teams deletion loop:' + err?.message;
|
||||
sendmailtoSupport(userDetails, errorMessage);
|
||||
return { code: 400, message: errorMessage };
|
||||
}
|
||||
|
||||
// STEP 8 : partners_Tenant cleanup
|
||||
try {
|
||||
if (tenantId) {
|
||||
const Tenant = Parse.Object.extend('partners_Tenant');
|
||||
const query = new Parse.Query(Tenant);
|
||||
const tenantObj = await query.get(tenantId, { useMasterKey: true });
|
||||
await tenantObj.destroy({ useMasterKey: true });
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = `Failed during partners_Tenant ${'cleanup:'} `;
|
||||
console.error(msg, err);
|
||||
const errorMessage = msg + err?.message;
|
||||
sendmailtoSupport(userDetails, errorMessage);
|
||||
return { code: 400, message: errorMessage };
|
||||
}
|
||||
|
||||
// STEP 9: partners_TenantCredits cleanup
|
||||
try {
|
||||
if (tenantId) {
|
||||
const tenantCredits = Parse.Object.extend('partners_TenantCredits');
|
||||
const subsByTenant = new Parse.Query(tenantCredits);
|
||||
subsByTenant.equalTo('PartnersTenant', {
|
||||
__type: 'Pointer',
|
||||
className: 'partners_Tenant',
|
||||
objectId: tenantId,
|
||||
});
|
||||
const subs = await subsByTenant.find({ useMasterKey: true });
|
||||
await Parse.Object.destroyAll(subs, { useMasterKey: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed during partners_TenantCredits cleanup:', err);
|
||||
const errorMessage = 'Failed during partners_TenantCredits cleanup:' + err?.message;
|
||||
sendmailtoSupport(userDetails, errorMessage);
|
||||
return { code: 400, message: errorMessage };
|
||||
}
|
||||
}
|
||||
// STEP 10: contracts_Signature
|
||||
try {
|
||||
const Signature = Parse.Object.extend('contracts_Signature');
|
||||
const sigQuery = new Parse.Query(Signature);
|
||||
sigQuery.equalTo('UserId', userPointer);
|
||||
const sigResults = await sigQuery.find({ useMasterKey: true });
|
||||
if (sigResults?.length > 0) await Parse.Object.destroyAll(sigResults, { useMasterKey: true });
|
||||
} catch (err) {
|
||||
console.error('Failed during contracts_Signature cleanup:', err);
|
||||
const errorMessage = 'Failed during contracts_Signature cleanup:' + err?.message;
|
||||
sendmailtoSupport(userDetails, errorMessage);
|
||||
return { code: 400, message: errorMessage };
|
||||
}
|
||||
|
||||
// STEP 11: contracts_Users
|
||||
try {
|
||||
await userResult.destroy({ useMasterKey: true });
|
||||
} catch (err) {
|
||||
console.error('Failed to delete contracts_Users entry:', err);
|
||||
const errorMessage = 'Failed to delete contracts_Users entry:' + err?.message;
|
||||
sendmailtoSupport(userDetails, errorMessage);
|
||||
return { code: 400, message: errorMessage };
|
||||
}
|
||||
return { code: 200, message: 'User and all associated data deleted successfully.' };
|
||||
} catch (error) {
|
||||
console.error('User deletion process failed:', error);
|
||||
const errorMessage = `User deletion failed: ${error.message || error}`;
|
||||
sendmailtoSupport(userDetails, errorMessage);
|
||||
return { code: 400, message: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Handle Password Verification and Deletion
|
||||
export const deleteUserPost = async (req, res) => {
|
||||
const { userId } = req.params;
|
||||
const { otp } = req.body;
|
||||
let userDetails = {
|
||||
UserRole: 'not found',
|
||||
Name: 'not found',
|
||||
Email: 'not found',
|
||||
UserId: userId || 'not found',
|
||||
objectId: 'not found',
|
||||
TenantId: 'not found',
|
||||
};
|
||||
if (!userId) return res.status(404).send('Missing userId parameter.');
|
||||
|
||||
try {
|
||||
// 1. Get the user
|
||||
const userQuery = new Parse.Query(Parse.User);
|
||||
userQuery.equalTo('objectId', userId);
|
||||
const user = await userQuery.first({ useMasterKey: true });
|
||||
if (!user) {
|
||||
const errorMessage = 'User not found.';
|
||||
// sendmailtoSupport(userDetails, errorMessage);
|
||||
// return res.status(404).send(errorMessage);
|
||||
return res.send(errorMessage);
|
||||
}
|
||||
const extUserQuery = new Parse.Query('contracts_Users');
|
||||
extUserQuery.equalTo('UserId', { __type: 'Pointer', className: '_User', objectId: userId });
|
||||
|
||||
const extUser = await extUserQuery.first({ useMasterKey: true });
|
||||
if (!extUser) {
|
||||
const errorMessage = 'User not found.';
|
||||
return res.send(errorMessage);
|
||||
}
|
||||
|
||||
// Get stored OTP info
|
||||
const savedOtp = extUser.get('DeleteOTP') || '';
|
||||
const expiry = extUser.get('DeleteOTPExpiry');
|
||||
const tries = Number(extUser.get('DeleteOTPTries') || 0);
|
||||
|
||||
if (tries >= MAX_ATTEMPTS) {
|
||||
return res.status(429).send('Too many invalid attempts. Please resend OTP and try again.');
|
||||
}
|
||||
if (!otp || typeof otp !== 'string') {
|
||||
// Count attempt
|
||||
extUser.set('DeleteOTPTries', tries + 1);
|
||||
await extUser.save(null, { useMasterKey: true });
|
||||
return res.status(400).send('OTP is required.');
|
||||
}
|
||||
if (!savedOtp) {
|
||||
return res.status(400).send('No OTP found. Please request a new OTP.');
|
||||
}
|
||||
if (expiry && Date.now() > expiry.getTime()) {
|
||||
return res.status(400).send('OTP has expired. Please request a new OTP.');
|
||||
}
|
||||
if (otp !== savedOtp) {
|
||||
// Increment tries on mismatch
|
||||
extUser.set('DeleteOTPTries', tries + 1);
|
||||
await extUser.save(null, { useMasterKey: true });
|
||||
return res.status(400).send('Invalid OTP.');
|
||||
}
|
||||
|
||||
// 2. Remove OTP related data
|
||||
try {
|
||||
extUser.unset('DeleteOTP');
|
||||
extUser.unset('DeleteOTPExpiry');
|
||||
extUser.unset('DeleteOTPSentAt');
|
||||
extUser.unset('DeleteOTPTries');
|
||||
await extUser.save(null, { useMasterKey: true });
|
||||
} catch (err) {
|
||||
console.log('err while validating password: ', err?.response?.data || err);
|
||||
// sendmailtoSupport(userDetails, errorMessage);
|
||||
// return res.status(401).send(errorMessage);
|
||||
}
|
||||
|
||||
const response = await deleteUser(userId);
|
||||
const code = response?.code || 500;
|
||||
const message = response?.message || 'An error occurred while deleting your account.';
|
||||
return res.status(code).send(message);
|
||||
} catch (error) {
|
||||
console.error('Account deletion error:', error);
|
||||
const errorMessage = error?.message || 'An error occurred while deleting your account.';
|
||||
sendmailtoSupport(userDetails, errorMessage);
|
||||
return res.status(500).send(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Handle Password Verification and Deletion
|
||||
export const deleteUserByAdmin = async (req, res) => {
|
||||
const sessiontoken = req.headers.sessiontoken;
|
||||
const userId = req.params.userId;
|
||||
let userDetails = {
|
||||
UserRole: 'not found',
|
||||
Name: 'not found',
|
||||
Email: 'not found',
|
||||
UserId: userId || 'not found',
|
||||
objectId: 'not found',
|
||||
TenantId: 'not found',
|
||||
};
|
||||
if (!sessiontoken) return res.status(400).json({ message: 'unauthorized.' });
|
||||
if (!userId || userId === ':userId') {
|
||||
return res.status(400).json({ message: 'Missing userId parameter.' });
|
||||
}
|
||||
try {
|
||||
const axiosRes = await axios.get(serverUrl + '/users/me', {
|
||||
headers: {
|
||||
'X-Parse-Application-Id': appId,
|
||||
'X-Parse-Session-Token': sessiontoken,
|
||||
},
|
||||
});
|
||||
const adminId = axiosRes?.data && axiosRes.data?.objectId;
|
||||
|
||||
if (!adminId) {
|
||||
return res.status(400).json({ message: 'Unauthorized.' });
|
||||
}
|
||||
// 1. Get the user
|
||||
const userQuery = new Parse.Query(Parse.User);
|
||||
userQuery.equalTo('objectId', userId);
|
||||
const user = await userQuery.first({ useMasterKey: true });
|
||||
if (!user) {
|
||||
const errorMessage = 'User not found.';
|
||||
sendmailtoSupport(userDetails, errorMessage);
|
||||
return res.status(400).json({ message: errorMessage });
|
||||
}
|
||||
const response = await deleteUser(userId, adminId);
|
||||
const code = response?.code || 400;
|
||||
const message = response?.message || 'An error occurred while deleting your account.';
|
||||
return res.status(code).json({ message: message });
|
||||
} catch (error) {
|
||||
const code = error?.response?.data?.code || 400;
|
||||
const errorMessage =
|
||||
error?.response?.data?.error ||
|
||||
error?.message ||
|
||||
'An error occurred while deleting your account.';
|
||||
console.error(`Account deletion error:`, errorMessage);
|
||||
sendmailtoSupport(userDetails, errorMessage);
|
||||
return res.status(code).json({ message: errorMessage });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import { OTP_LENGTH, RESEND_COOLDOWN_SEC } from './deleteUtils.js';
|
||||
|
||||
// 1. HTML Password Prompt Page
|
||||
export const deleteUserGet = async (req, res) => {
|
||||
const { userId } = req.params;
|
||||
|
||||
const extUserQuery = new Parse.Query('contracts_Users');
|
||||
extUserQuery.equalTo('UserId', { __type: 'Pointer', className: '_User', objectId: userId });
|
||||
const extUser = await extUserQuery.first({ useMasterKey: true });
|
||||
if (!extUser) return res.status(404).send('User not found.');
|
||||
|
||||
const routePath = process?.env?.SERVER_URL?.includes?.('api') ? '/api' : '';
|
||||
|
||||
const htmlForm = `
|
||||
<html>
|
||||
<head>
|
||||
<title>Delete Account</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f8f9fa; margin: 0; padding: 0; display: flex; justify-content: center; align-items: center; height: 100vh; }
|
||||
.container { background-color: #ffffff; padding: 40px; border-radius: 8px; box-shadow: 0 8px 16px rgba(0,0,0,0.1); width: 100%; max-width: 420px; text-align: center; }
|
||||
h2 { color: #dc3545; margin-bottom: 14px; }
|
||||
p.warning { color: #6c757d; font-size: 14px; margin-top: 0; margin-bottom: 18px; }
|
||||
|
||||
label { display: block; margin-bottom: 10px; font-weight: 600; text-align: left; }
|
||||
input[type="text"] { width: 100%; padding: 12px; margin-bottom: 16px; border: 1px solid #ccc; border-radius: 4px; font-size: 16px; letter-spacing: 0.2em; }
|
||||
|
||||
button { background-color: #d9534f; color: #ffffff; border: none; padding: 12px 16px; font-size: 16px; border-radius: 4px; cursor: pointer; transition: background-color 0.3s ease; }
|
||||
button:hover { background-color: #c9302c; }
|
||||
.secondary { background-color: #6c757d; }
|
||||
.secondary:hover { background-color: #5a6268; }
|
||||
.btn-block { width: 100%; }
|
||||
|
||||
.link-btn { background: transparent; border: none; color: #0d6efd; text-decoration: underline; padding: 0; font-size: 14px; cursor: pointer; }
|
||||
.link-btn[disabled] { color: #6c757d; text-decoration: none; cursor: not-allowed; }
|
||||
|
||||
.muted { color: #6c757d; font-size: 13px; margin-top: 8px; min-height: 18px; }
|
||||
.error { color: #c9302c; font-size: 13px; margin-top: 8px; min-height: 18px; }
|
||||
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>Confirm Account Deletion</h2>
|
||||
|
||||
<!-- Initial view: only Send OTP -->
|
||||
<div id="preOtp">
|
||||
<p class="warning">This action is irreversible. A verification mail will be sent to your account to your registered email.</p>
|
||||
<button type="button" id="sendOtpBtn" class="secondary btn-block">Send OTP</button>
|
||||
<div id="preMsg" class="muted"></div>
|
||||
<div id="preErr" class="error"></div>
|
||||
</div>
|
||||
|
||||
<!-- Shown after OTP is sent -->
|
||||
<form id="otpForm" class="hidden" method="POST" action="${routePath}/delete-account/${userId}">
|
||||
<p class="warning">This action is irreversible. Verify with an OTP sent to your registered email.</p>
|
||||
<label for="otp">One-Time Password (OTP)</label>
|
||||
<input type="text" name="otp" id="otp" placeholder="Enter ${OTP_LENGTH}-digit OTP" required maxlength="${OTP_LENGTH}" inputmode="numeric" />
|
||||
|
||||
<div style="text-align:center; margin-top: 10px;">
|
||||
<button type="submit" id="deleteBtn">Delete My Account</button>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center; margin-top: 10px;">
|
||||
<button type="button" id="resendOtpLink" class="link-btn">Resend OTP</button>
|
||||
</div>
|
||||
|
||||
<div id="timer" class="muted"></div>
|
||||
<div id="msg" class="muted"></div>
|
||||
<div id="err" class="error"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const preOtp = document.getElementById('preOtp');
|
||||
const sendBtn = document.getElementById('sendOtpBtn');
|
||||
const preMsg = document.getElementById('preMsg');
|
||||
const preErr = document.getElementById('preErr');
|
||||
|
||||
const form = document.getElementById('otpForm');
|
||||
const otpInput = document.getElementById('otp');
|
||||
const resendLink = document.getElementById('resendOtpLink');
|
||||
const timerEl = document.getElementById('timer');
|
||||
const msgEl = document.getElementById('msg');
|
||||
const errEl = document.getElementById('err');
|
||||
|
||||
const RESEND_WAIT = ${RESEND_COOLDOWN_SEC};
|
||||
let countdown = 0;
|
||||
let iv = null;
|
||||
|
||||
function setText(el, t){ el.textContent = t || ''; }
|
||||
function updateResendState(){
|
||||
resendLink.disabled = countdown>0;
|
||||
if(countdown>0){
|
||||
resendLink.setAttribute('disabled','true');
|
||||
resendLink.style.pointerEvents='none';
|
||||
}else{
|
||||
resendLink.removeAttribute('disabled');
|
||||
resendLink.style.pointerEvents='auto';
|
||||
}
|
||||
}
|
||||
function tick(){
|
||||
if(countdown<=0){ clearInterval(iv); iv=null; setText(timerEl,'You can resend the OTP now.'); updateResendState(); return; }
|
||||
setText(timerEl,'Resend available in '+countdown+'s'); countdown--; updateResendState();
|
||||
}
|
||||
function startTimer(sec){ countdown=sec||RESEND_WAIT; if(iv) clearInterval(iv); tick(); iv=setInterval(tick,1000); }
|
||||
|
||||
async function sendOtp(showForm){
|
||||
setText(preErr,''); setText(preMsg,'Sending OTP...');
|
||||
try {
|
||||
const resp = await fetch('${routePath}/delete-account/${userId}/otp',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})});
|
||||
const data = await resp.json().catch(()=>({}));
|
||||
if(!resp.ok){
|
||||
if(resp.status===429 && data?.retryAfterSec){
|
||||
startTimer(data.retryAfterSec); setText(preErr,'Please wait '+data.retryAfterSec+'s before resending.'); setText(preMsg,'');
|
||||
return;
|
||||
}
|
||||
throw new Error(data?.error || 'Failed to send OTP.');
|
||||
}
|
||||
startTimer(data?.cooldownSec || RESEND_WAIT);
|
||||
|
||||
// Success: hide pre, show form
|
||||
preOtp.classList.add('hidden');
|
||||
form.classList.remove('hidden');
|
||||
setText(msgEl,'OTP sent to your registered email.');
|
||||
otpInput.focus();
|
||||
|
||||
} catch(e){
|
||||
setText(preErr,e.message||'Error sending OTP.');
|
||||
setText(preMsg,'');
|
||||
}
|
||||
}
|
||||
|
||||
sendBtn.addEventListener('click',()=>sendOtp(true));
|
||||
resendLink.addEventListener('click',()=>{ if(countdown<=0) sendOtp(false); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
res.send(htmlForm);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
generateOtp,
|
||||
msUntil,
|
||||
sendDeleteOtpEmail,
|
||||
OTP_EXPIRES_MIN,
|
||||
RESEND_COOLDOWN_SEC,
|
||||
} from './deleteUtils.js';
|
||||
|
||||
export const deleteUserOtp = async (req, res) => {
|
||||
const { userId } = req.params;
|
||||
|
||||
const extUserQuery = new Parse.Query('contracts_Users');
|
||||
extUserQuery.equalTo('UserId', { __type: 'Pointer', className: '_User', objectId: userId });
|
||||
const extUser = await extUserQuery.first({ useMasterKey: true });
|
||||
if (!extUser) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
const now = Date.now();
|
||||
const lastSentAt = extUser.get('DeleteOTPSentAt')?.getTime?.() || 0;
|
||||
const cooldownEndsAt = lastSentAt + RESEND_COOLDOWN_SEC * 1000;
|
||||
const remainingMs = msUntil(now, cooldownEndsAt);
|
||||
|
||||
if (remainingMs > 0) {
|
||||
return res
|
||||
.status(429)
|
||||
.json({ error: 'Cooldown not finished', retryAfterSec: Math.ceil(remainingMs / 1000) });
|
||||
}
|
||||
|
||||
const otp = generateOtp();
|
||||
const expiresAt = new Date(now + OTP_EXPIRES_MIN * 60 * 1000);
|
||||
|
||||
try {
|
||||
const resp = await sendDeleteOtpEmail(extUser, otp);
|
||||
extUser.set('DeleteOTP', otp);
|
||||
extUser.set('DeleteOTPExpiry', expiresAt);
|
||||
extUser.set('DeleteOTPSentAt', new Date(now));
|
||||
extUser.set('DeleteOTPTries', 0); // reset tries on resend
|
||||
await extUser.save(null, { useMasterKey: true });
|
||||
return res.json({ ok: true, cooldownSec: RESEND_COOLDOWN_SEC, expiresInMin: OTP_EXPIRES_MIN });
|
||||
} catch (err) {
|
||||
console.log('Error sending delete OTP (POST /otp):', err?.response?.data || err);
|
||||
return res.status(500).json({ error: 'Failed to send OTP' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import axios from 'axios';
|
||||
import { appName, cloudServerUrl, serverAppId } from '../../../Utils.js';
|
||||
|
||||
const serverUrl = cloudServerUrl;
|
||||
const appId = serverAppId;
|
||||
const masterKey = process.env.MASTER_KEY;
|
||||
|
||||
// Constants (adjust to your preference)
|
||||
export const OTP_LENGTH = 6;
|
||||
export const OTP_EXPIRES_MIN = 10; // OTP validity in minutes
|
||||
export const RESEND_COOLDOWN_SEC = 30; // Cooldown between OTP sends
|
||||
export const MAX_ATTEMPTS = 5; // Max allowed wrong attempts
|
||||
|
||||
export function generateOtp(len = OTP_LENGTH) {
|
||||
// 6-digit numeric OTP (000000–999999, padded)
|
||||
const n = Math.floor(Math.random() * Math.pow(10, len));
|
||||
return String(n).padStart(len, '0');
|
||||
}
|
||||
|
||||
export async function sendDeleteOtpEmail(extUser, otp) {
|
||||
const params = {
|
||||
extUserId: extUser.id,
|
||||
from: appName,
|
||||
recipient: extUser?.get('Email'),
|
||||
subject: 'OTP for Deletion account request',
|
||||
html: `
|
||||
<html lang="en">
|
||||
<body style="margin:0;padding:0;background:#f6f7fb;font-family:Arial,Helvetica,sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f6f7fb;">
|
||||
<tr>
|
||||
<td align="center" style="padding:24px;">
|
||||
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border:1px solid #e9ecf1;border-radius:8px;padding:20px;">
|
||||
<tr>
|
||||
<td align="left" style="font-size:16px;color:#0f172a;">
|
||||
<div style="font-weight:bold;margin-bottom:8px;">${appName}</div>
|
||||
<div style="font-size:18px;margin:0 0 12px 0;">Your verification code</div>
|
||||
<div style="display:inline-block;border:1px solid #e9ecf1;border-radius:6px;background:#f8fafc;padding:10px 14px;margin-bottom:10px;">
|
||||
<span style="font-family:Consolas,'Courier New',monospace;font-size:24px;letter-spacing:6px;color:#0f172a;">${otp}</span>
|
||||
</div>
|
||||
<p style="margin:8px 0 0 0;font-size:13px;color:#475569;">
|
||||
This code expires in <strong>${OTP_EXPIRES_MIN}</strong> minutes.
|
||||
</p>
|
||||
|
||||
<hr style="border:none;border-top:1px solid #e9ecf1;margin:18px 0;">
|
||||
<p style="margin:0;font-size:12px;color:#64748b;">
|
||||
If you didn’t request this code, you can ignore this email.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div style="font-size:11px;color:#94a3b8;margin-top:12px;">
|
||||
© ${new Date().getFullYear()} ${appName}. All rights reserved.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
};
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': appId,
|
||||
'X-Parse-Master-Key': masterKey,
|
||||
};
|
||||
return axios.post(serverUrl + '/functions/sendmailv3', params, { headers });
|
||||
}
|
||||
|
||||
export function msUntil(nowMs, futureMs) {
|
||||
return Math.max(0, (futureMs || 0) - nowMs);
|
||||
}
|
||||
Reference in New Issue
Block a user