Merge branch 'master' of http://git.hicglobalsolutions.in/BhawanSingh/va-tracker-middleware
This commit is contained in:
commit
79eff1a6e3
|
|
@ -30,6 +30,7 @@
|
|||
"mongodb": "^6.12.0",
|
||||
"mongoose": "^8.9.3",
|
||||
"node-cache": "^5.1.2",
|
||||
"nodemailer": "^7.0.10",
|
||||
"nodemon": "^3.1.0",
|
||||
"prom-client": "^15.1.3",
|
||||
"puppeteer": "^24.10.1",
|
||||
|
|
@ -45,11 +46,8 @@
|
|||
"@babel/cli": "^7.26.4",
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"babel-loader": "^9.2.1",
|
||||
"copy-webpack-plugin": "^12.0.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"terser-webpack-plugin": "^5.3.11",
|
||||
"webpack": "^5.97.1",
|
||||
"webpack-cli": "^6.0.1",
|
||||
|
|
@ -2549,44 +2547,6 @@
|
|||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.21",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
||||
"integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.24.4",
|
||||
"caniuse-lite": "^1.0.30001702",
|
||||
"fraction.js": "^4.3.7",
|
||||
"normalize-range": "^0.1.2",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"autoprefixer": "bin/autoprefixer"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-sign2": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
|
||||
|
|
@ -4767,20 +4727,6 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||
"integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "patreon",
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
|
|
@ -6659,25 +6605,6 @@
|
|||
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
|
|
@ -6800,6 +6727,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.10",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.10.tgz",
|
||||
"integrity": "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.0.tgz",
|
||||
|
|
@ -6856,16 +6792,6 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-range": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
|
||||
"integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz",
|
||||
|
|
@ -7328,42 +7254,6 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-value-parser": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
|
|
@ -8543,16 +8433,6 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-support": {
|
||||
"version": "0.5.21",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||
|
|
@ -8719,13 +8599,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.1.17",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
|
||||
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
"mongodb": "^6.12.0",
|
||||
"mongoose": "^8.9.3",
|
||||
"node-cache": "^5.1.2",
|
||||
"nodemailer": "^7.0.10",
|
||||
"nodemon": "^3.1.0",
|
||||
"prom-client": "^15.1.3",
|
||||
"puppeteer": "^24.10.1",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
/* auth.css - custom styles for centered card UI */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
text-decoration: none;
|
||||
}
|
||||
/* Page background and centering */
|
||||
.page-center {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8fafc; /* light gray */
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(16, 24, 40, 0.06);
|
||||
padding: 28px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
/* Header area */
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.auth-icon {
|
||||
font-size: 40px;
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.auth-sub {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alert {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #fff5f5;
|
||||
color: #b91c1c;
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #f0fdf4;
|
||||
color: #059669;
|
||||
border-color: #86efac;
|
||||
}
|
||||
|
||||
/* Form controls */
|
||||
.form-row {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e6e9ee;
|
||||
background: #fbfbfd;
|
||||
font-size: 14px;
|
||||
color: #0f172a;
|
||||
box-sizing: border-box;
|
||||
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb; /* blue */
|
||||
box-shadow: 0 0 0 4px rgba(37,99,235,0.08);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: transform 0.08s ease, box-shadow 0.12s ease, background-color 0.08s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover { transform: translateY(-1px); background: #111; }
|
||||
|
||||
.btn-ghost {
|
||||
background: #ffffff;
|
||||
color: #111827;
|
||||
border: 1px solid #e6e9ee;
|
||||
}
|
||||
|
||||
.btn-ghost:hover { background: #fbfbfb; transform: translateY(-1px); }
|
||||
|
||||
/* Secondary small link */
|
||||
.small-link {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.text-center { text-align: center; }
|
||||
.mb-4 { margin-bottom: 16px; }
|
||||
.mt-4 { margin-top: 16px; }
|
||||
|
||||
/* Responsive tweaks */
|
||||
@media (max-width: 480px) {
|
||||
.auth-card { padding: 20px; border-radius: 10px; }
|
||||
.auth-icon { font-size: 36px; }
|
||||
.auth-title { font-size: 18px; }
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ body {
|
|||
.sidebar {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
width: 344px;
|
||||
width: 280px;
|
||||
background: linear-gradient(to bottom, #0b57d0bd, #0b57d0d6, #0b57d0);
|
||||
padding: 24px;
|
||||
border-radius: 14px;
|
||||
|
|
@ -186,6 +186,7 @@ body {
|
|||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
|
|
@ -369,6 +370,9 @@ body {
|
|||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-top: 4px;
|
||||
margin-left: 12px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
|
|
@ -440,7 +444,6 @@ body {
|
|||
color: #070065;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.form-text {
|
||||
|
|
|
|||
|
|
@ -142,10 +142,66 @@ const statesByCountry = {
|
|||
'England', 'Scotland', 'Wales', 'Northern Ireland'
|
||||
],
|
||||
'AU': [
|
||||
'New South Wales', 'Queensland', 'South Australia', 'Tasmania', 'Victoria', 'Western Australia'
|
||||
'New South Wales', 'Queensland', 'South Australia', 'Tasmania', 'Victoria', 'Western Australia',
|
||||
'Australian Capital Territory', 'Northern Territory'
|
||||
],
|
||||
'IN': [
|
||||
'Andhra Pradesh', 'Arunachal Pradesh', 'Assam', 'Bihar', 'Chhattisgarh', 'Goa', 'Gujarat',
|
||||
'Haryana', 'Himachal Pradesh', 'Jharkhand', 'Karnataka', 'Kerala', 'Madhya Pradesh',
|
||||
'Maharashtra', 'Manipur', 'Meghalaya', 'Mizoram', 'Nagaland', 'Odisha', 'Punjab', 'Rajasthan',
|
||||
'Sikkim', 'Tamil Nadu', 'Telangana', 'Tripura', 'Uttar Pradesh', 'Uttarakhand', 'West Bengal',
|
||||
'Andaman and Nicobar Islands', 'Chandigarh', 'Dadra and Nagar Haveli and Daman and Diu',
|
||||
'Delhi', 'Jammu and Kashmir', 'Ladakh', 'Lakshadweep', 'Puducherry'
|
||||
],
|
||||
'DE': [
|
||||
'Baden-Württemberg', 'Bavaria', 'Berlin', 'Brandenburg', 'Bremen', 'Hamburg', 'Hesse',
|
||||
'Lower Saxony', 'Mecklenburg-Vorpommern', 'North Rhine-Westphalia', 'Rhineland-Palatinate',
|
||||
'Saarland', 'Saxony', 'Saxony-Anhalt', 'Schleswig-Holstein', 'Thuringia'
|
||||
],
|
||||
'FR': [
|
||||
'Auvergne-Rhône-Alpes', 'Bourgogne-Franche-Comté', 'Brittany', 'Centre-Val de Loire', 'Corsica',
|
||||
'Grand Est', 'Hauts-de-France', 'Île-de-France', 'Normandy', 'Nouvelle-Aquitaine', 'Occitanie',
|
||||
'Pays de la Loire', 'Provence-Alpes-Côte d’Azur'
|
||||
],
|
||||
'IT': [
|
||||
'Abruzzo', 'Basilicata', 'Calabria', 'Campania', 'Emilia-Romagna', 'Friuli Venezia Giulia',
|
||||
'Lazio', 'Liguria', 'Lombardy', 'Marche', 'Molise', 'Piedmont', 'Sardinia', 'Sicily',
|
||||
'Trentino-Alto Adige', 'Tuscany', 'Umbria', 'Aosta Valley', 'Veneto'
|
||||
],
|
||||
'ES': [
|
||||
'Andalusia', 'Aragon', 'Asturias', 'Balearic Islands', 'Basque Country', 'Canary Islands',
|
||||
'Cantabria', 'Castile and León', 'Castile-La Mancha', 'Catalonia', 'Extremadura', 'Galicia',
|
||||
'La Rioja', 'Madrid', 'Murcia', 'Navarre', 'Valencia'
|
||||
],
|
||||
'BR': [
|
||||
'Acre', 'Alagoas', 'Amapá', 'Amazonas', 'Bahia', 'Ceará', 'Distrito Federal', 'Espírito Santo',
|
||||
'Goiás', 'Maranhão', 'Mato Grosso', 'Mato Grosso do Sul', 'Minas Gerais', 'Pará', 'Paraíba',
|
||||
'Paraná', 'Pernambuco', 'Piauí', 'Rio de Janeiro', 'Rio Grande do Norte', 'Rio Grande do Sul',
|
||||
'Rondônia', 'Roraima', 'Santa Catarina', 'São Paulo', 'Sergipe', 'Tocantins'
|
||||
],
|
||||
'MX': [
|
||||
'Aguascalientes', 'Baja California', 'Baja California Sur', 'Campeche', 'Chiapas', 'Chihuahua',
|
||||
'Coahuila', 'Colima', 'Durango', 'Guanajuato', 'Guerrero', 'Hidalgo', 'Jalisco', 'Mexico State',
|
||||
'Michoacán', 'Morelos', 'Nayarit', 'Nuevo León', 'Oaxaca', 'Puebla', 'Querétaro', 'Quintana Roo',
|
||||
'San Luis Potosí', 'Sinaloa', 'Sonora', 'Tabasco', 'Tamaulipas', 'Tlaxcala', 'Veracruz',
|
||||
'Yucatán', 'Zacatecas'
|
||||
],
|
||||
'CN': [
|
||||
'Anhui', 'Beijing', 'Chongqing', 'Fujian', 'Gansu', 'Guangdong', 'Guangxi', 'Guizhou', 'Hainan',
|
||||
'Hebei', 'Heilongjiang', 'Henan', 'Hubei', 'Hunan', 'Inner Mongolia', 'Jiangsu', 'Jiangxi',
|
||||
'Jilin', 'Liaoning', 'Ningxia', 'Qinghai', 'Shaanxi', 'Shandong', 'Shanghai', 'Shanxi',
|
||||
'Sichuan', 'Tianjin', 'Tibet', 'Xinjiang', 'Yunnan', 'Zhejiang'
|
||||
],
|
||||
'JP': [
|
||||
'Aichi', 'Akita', 'Aomori', 'Chiba', 'Ehime', 'Fukui', 'Fukuoka', 'Fukushima', 'Gifu', 'Gunma',
|
||||
'Hiroshima', 'Hokkaido', 'Hyogo', 'Ibaraki', 'Ishikawa', 'Iwate', 'Kagawa', 'Kagoshima', 'Kanagawa',
|
||||
'Kochi', 'Kumamoto', 'Kyoto', 'Mie', 'Miyagi', 'Miyazaki', 'Nagano', 'Nagasaki', 'Nara', 'Niigata',
|
||||
'Oita', 'Okayama', 'Okinawa', 'Osaka', 'Saga', 'Saitama', 'Shiga', 'Shimane', 'Shizuoka', 'Tochigi',
|
||||
'Tokushima', 'Tokyo', 'Tottori', 'Toyama', 'Wakayama', 'Yamagata', 'Yamaguchi', 'Yamanashi'
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
// Function to navigate to any step (for tab navigation)
|
||||
function goToStep(step) {
|
||||
// Only allow navigation to completed steps or the next step
|
||||
|
|
@ -601,57 +657,67 @@ async function populateStates(countryCode, selectElementId) {
|
|||
option.textContent = state;
|
||||
selectElement.appendChild(option);
|
||||
});
|
||||
// const selectElement = document.getElementById(selectElementId);
|
||||
|
||||
// // Clear existing options except the first empty one
|
||||
// while (selectElement.options.length > 1) {
|
||||
// selectElement.remove(1);
|
||||
// }
|
||||
|
||||
// // Fetch states from API
|
||||
// const states = await fetchStates(countryCode);
|
||||
|
||||
// // Add states as options
|
||||
// states.forEach(state => {
|
||||
// const option = document.createElement('option');
|
||||
// option.value = state.code || state.name;
|
||||
// option.textContent = state.name;
|
||||
// selectElement.appendChild(option);
|
||||
// });
|
||||
}
|
||||
|
||||
// Function to detect user location
|
||||
async function detectLocation() {
|
||||
// Show loading indicators
|
||||
document.getElementById('state-loading').style.display = 'block';
|
||||
const stateLoader = document.getElementById('state-loading');
|
||||
if (stateLoader) stateLoader.style.display = 'block';
|
||||
|
||||
try {
|
||||
const response = await fetch('https://ipapi.co/json/');
|
||||
const data = await response.json();
|
||||
|
||||
// Hide loading indicators
|
||||
document.getElementById('state-loading').style.display = 'none';
|
||||
if (stateLoader) stateLoader.style.display = 'none';
|
||||
|
||||
// const countryCode = data.country_code || 'US';
|
||||
const supportedCountries = Object.keys(statesByCountry);
|
||||
const countryCode = supportedCountries.includes(data.country_code)
|
||||
? data.country_code
|
||||
: 'US';
|
||||
const region = data.region || '';
|
||||
const city = data.city || '';
|
||||
const zip = data.postal || '';
|
||||
|
||||
// Populate city and state based on detected location
|
||||
document.getElementById('city').value = data.city || '';
|
||||
document.getElementById('zip').value = data.postal || '';
|
||||
const cityInput = document.getElementById('city');
|
||||
const zipInput = document.getElementById('zip');
|
||||
if (cityInput) cityInput.value = city;
|
||||
if (zipInput) zipInput.value = zip;
|
||||
|
||||
// Populate states dropdown for the detected country
|
||||
await populateStates(data.country_code, 'state');
|
||||
|
||||
// Set the detected state
|
||||
if (data.region) {
|
||||
document.getElementById('state').value = data.region;
|
||||
document.getElementById('state').dispatchEvent(new Event('change'));
|
||||
await populateStates(countryCode, 'state');
|
||||
await populateStates(countryCode, 'billing-state');
|
||||
|
||||
const stateSelect = document.getElementById('state');
|
||||
const billingStateSelect = document.getElementById('billing-state');
|
||||
|
||||
if (stateSelect && region) {
|
||||
const matchedState = Array.from(stateSelect.options).find(
|
||||
opt => opt.text.toLowerCase() === region.toLowerCase()
|
||||
);
|
||||
if (matchedState) {
|
||||
stateSelect.value = matchedState.value;
|
||||
stateSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
|
||||
// Also populate billing states dropdown
|
||||
await populateStates(data.country_code, 'billing-state');
|
||||
if (billingStateSelect && region) {
|
||||
const matchedBillingState = Array.from(billingStateSelect.options).find(
|
||||
opt => opt.text.toLowerCase() === region.toLowerCase()
|
||||
);
|
||||
if (matchedBillingState) {
|
||||
billingStateSelect.value = matchedBillingState.value;
|
||||
billingStateSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error detecting location:', error);
|
||||
|
||||
// Hide loading indicators
|
||||
document.getElementById('state-loading').style.display = 'none';
|
||||
if (stateLoader) stateLoader.style.display = 'none';
|
||||
|
||||
// Fallback to US states if detection fails
|
||||
await populateStates('US', 'state');
|
||||
|
|
|
|||
|
|
@ -107,10 +107,6 @@
|
|||
}, 500);
|
||||
});
|
||||
|
||||
// Password strength indicator
|
||||
document.getElementById('password').addEventListener('input', function() {
|
||||
updatePasswordStrength(this.value);
|
||||
});
|
||||
|
||||
// Add input event listeners to handle label animation
|
||||
document.querySelectorAll('.input-field').forEach(input => {
|
||||
|
|
@ -529,9 +525,6 @@
|
|||
// Trigger input events to update UI
|
||||
document.getElementById('password').dispatchEvent(new Event('input'));
|
||||
document.getElementById('confirmPassword').dispatchEvent(new Event('input'));
|
||||
|
||||
// Update password strength indicator
|
||||
updatePasswordStrength(password);
|
||||
}
|
||||
|
||||
// Function to toggle password visibility
|
||||
|
|
@ -551,36 +544,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Function to update password strength indicator
|
||||
function updatePasswordStrength(password) {
|
||||
const strengthBar = document.getElementById('password-strength-bar');
|
||||
|
||||
// Reset classes
|
||||
strengthBar.className = 'password-strength-bar';
|
||||
|
||||
if (password.length === 0) {
|
||||
strengthBar.style.width = '0';
|
||||
} else if (password.length < 6) {
|
||||
strengthBar.classList.add('password-weak');
|
||||
} else if (password.length < 10) {
|
||||
strengthBar.classList.add('password-medium');
|
||||
} else {
|
||||
// Check for complexity
|
||||
const hasUpperCase = /[A-Z]/.test(password);
|
||||
const hasLowerCase = /[a-z]/.test(password);
|
||||
const hasNumbers = /\d/.test(password);
|
||||
const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password);
|
||||
|
||||
const complexityScore = [hasUpperCase, hasLowerCase, hasNumbers, hasSpecial].filter(Boolean).length;
|
||||
|
||||
if (complexityScore >= 3) {
|
||||
strengthBar.classList.add('password-strong');
|
||||
} else {
|
||||
strengthBar.classList.add('password-medium');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to submit the form
|
||||
async function submitForm() {
|
||||
// Validate current step
|
||||
|
|
@ -645,89 +608,3 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
// Initialize form interactions
|
||||
// document.addEventListener('DOMContentLoaded', function() {
|
||||
// // Password strength indicator
|
||||
// document.getElementById('password').addEventListener('input', function() {
|
||||
// updatePasswordStrength(this.value);
|
||||
// });
|
||||
|
||||
// // Add input event listeners to handle label animation
|
||||
// document.querySelectorAll('.input-field').forEach(input => {
|
||||
// input.addEventListener('input', function() {
|
||||
// if (this.value) {
|
||||
// this.nextElementSibling.style.top = '4px';
|
||||
// this.nextElementSibling.style.fontSize = '12px';
|
||||
// this.nextElementSibling.style.transform = 'none';
|
||||
// } else {
|
||||
// this.nextElementSibling.style.top = '50%';
|
||||
// this.nextElementSibling.style.fontSize = '14px';
|
||||
// this.nextElementSibling.style.transform = 'translateY(-50%)';
|
||||
// }
|
||||
// });
|
||||
|
||||
// input.addEventListener('focus', function() {
|
||||
// this.nextElementSibling.style.top = '4px';
|
||||
// this.nextElementSibling.style.fontSize = '12px';
|
||||
// this.nextElementSibling.style.transform = 'none';
|
||||
// });
|
||||
|
||||
// input.addEventListener('blur', function() {
|
||||
// if (!this.value) {
|
||||
// this.nextElementSibling.style.top = '50%';
|
||||
// this.nextElementSibling.style.fontSize = '14px';
|
||||
// this.nextElementSibling.style.transform = 'translateY(-50%)';
|
||||
// }
|
||||
// });
|
||||
// });
|
||||
|
||||
// // Handle select field label animation
|
||||
// document.querySelectorAll('.select-field').forEach(select => {
|
||||
// select.addEventListener('change', function() {
|
||||
// if (this.value) {
|
||||
// this.nextElementSibling.style.top = '4px';
|
||||
// this.nextElementSibling.style.fontSize = '12px';
|
||||
// this.nextElementSibling.style.transform = 'none';
|
||||
// } else {
|
||||
// this.nextElementSibling.style.top = '50%';
|
||||
// this.nextElementSibling.style.fontSize = '14px';
|
||||
// this.nextElementSibling.style.transform = 'translateY(-50%)';
|
||||
// }
|
||||
// });
|
||||
|
||||
// select.addEventListener('focus', function() {
|
||||
// this.nextElementSibling.style.top = '4px';
|
||||
// this.nextElementSibling.style.fontSize = '12px';
|
||||
// this.nextElementSibling.style.transform = 'none';
|
||||
// });
|
||||
|
||||
// select.addEventListener('blur', function() {
|
||||
// if (!this.value) {
|
||||
// this.nextElementSibling.style.top = '50%';
|
||||
// this.nextElementSibling.style.fontSize = '14px';
|
||||
// this.nextElementSibling.style.transform = 'translateY(-50%)';
|
||||
// }
|
||||
// });
|
||||
// });
|
||||
|
||||
// // Step navigation for desktop
|
||||
// document.querySelectorAll('.step').forEach(step => {
|
||||
// step.addEventListener('click', function() {
|
||||
// const stepNum = parseInt(this.getAttribute('data-step'));
|
||||
// if (stepNum <= currentStep || stepNum === getMaxCompletedStep() + 1) {
|
||||
// goToStep(stepNum);
|
||||
// }
|
||||
// });
|
||||
// });
|
||||
|
||||
// // Step navigation for mobile
|
||||
// document.querySelectorAll('.mobile-step').forEach(step => {
|
||||
// step.addEventListener('click', function() {
|
||||
// const stepNum = parseInt(this.getAttribute('data-step'));
|
||||
// if (stepNum <= currentStep || stepNum === getMaxCompletedStep() + 1) {
|
||||
// goToStep(stepNum);
|
||||
// }
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import PasswordResetRepository from "../repository/passwordReset.repository.js";
|
||||
import PasswordResetService from "../services/passwordReset.service.js";
|
||||
import EmailService from "../services/email.service.js";
|
||||
import logger from "../config/logger.js";
|
||||
|
||||
const passwordResetService = new PasswordResetService(
|
||||
new PasswordResetRepository(),
|
||||
new EmailService({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: process.env.SMTP_PORT,
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
from: process.env.EMAIL_FROM,
|
||||
})
|
||||
);
|
||||
|
||||
// VA: Request password reset
|
||||
async function va_request_password_reset(req, res) {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
if (!email) {
|
||||
return res.status(400).json({ success: false, message: "Email is required" });
|
||||
}
|
||||
|
||||
const result = await passwordResetService.requestPasswordReset(email, 'va');
|
||||
logger.info(`VA password reset requested for: ${email}`);
|
||||
return res.status(200).json(result);
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`VA PasswordResetController - Request error: ${error.message}`);
|
||||
return res.status(500).json({ success: false, message: "Failed to process password reset request" });
|
||||
}
|
||||
}
|
||||
|
||||
// Client: Request password reset
|
||||
async function client_request_password_reset(req, res) {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
if (!email) {
|
||||
return res.status(400).json({ success: false, message: "Email is required" });
|
||||
}
|
||||
|
||||
const result = await passwordResetService.requestPasswordReset(email, 'client');
|
||||
logger.info(`Client password reset requested for: ${email}`);
|
||||
return res.status(200).json(result);
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Client PasswordResetController - Request error: ${error.message}`);
|
||||
return res.status(500).json({ success: false, message: "Failed to process password reset request" });
|
||||
}
|
||||
}
|
||||
|
||||
// Render reset password form (shared for both VA and Client)
|
||||
async function render_reset_form(req, res) {
|
||||
try {
|
||||
const { token, userType } = req.query;
|
||||
|
||||
if (!token || !userType) {
|
||||
return res.render('auth/reset-password-error', { error: 'Reset token or user type is missing' });
|
||||
}
|
||||
|
||||
// Validate token
|
||||
const validation = await passwordResetService.validateResetToken(token, userType);
|
||||
|
||||
if (!validation.valid) {
|
||||
return res.render('auth/reset-password-error', { error: validation.message });
|
||||
}
|
||||
|
||||
// Render reset form with token
|
||||
res.render('auth/reset-password-form', { token, userType, error: null });
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`PasswordResetController - Render form error: ${error.message}`);
|
||||
res.render('auth/reset-password-error', { error: 'Failed to load reset form' });
|
||||
}
|
||||
}
|
||||
|
||||
// Process password reset form submission (shared for both VA and Client)
|
||||
async function process_reset(req, res) {
|
||||
try {
|
||||
const { token, password, confirmPassword, userType } = req.body;
|
||||
|
||||
if (!token || !password || !confirmPassword || !userType) {
|
||||
return res.render('auth/reset-password-form', {
|
||||
token, userType, error: 'All fields are required'
|
||||
});
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
return res.render('auth/reset-password-form', {
|
||||
token, userType, error: 'Passwords do not match'
|
||||
});
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.render('auth/reset-password-form', {
|
||||
token, userType, error: 'Password must be at least 6 characters long'
|
||||
});
|
||||
}
|
||||
|
||||
// Reset password
|
||||
await passwordResetService.resetPassword(token, password, userType);
|
||||
|
||||
// Render success page
|
||||
res.render('auth/reset-password-success');
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`PasswordResetController - Process reset error: ${error.message}`);
|
||||
res.render('auth/reset-password-form', {
|
||||
token: req.body.token,
|
||||
userType: req.body.userType,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
va_request_password_reset,
|
||||
client_request_password_reset,
|
||||
render_reset_form,
|
||||
process_reset
|
||||
};
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
import { VAModel, ProjectModel, LoggedModel } from "../../../models/vaModel.js";
|
||||
|
||||
// Adjust missing hours by creating new records
|
||||
async function va_timesheet_adjustment_function(req, res) {
|
||||
try {
|
||||
const accessToken = req.headers.token;
|
||||
|
||||
if (!accessToken) {
|
||||
return res.status(401).json({ success: false, message: "Missing token" });
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(accessToken, process.env.JWT_SECRET_ACCESS_TOKEN);
|
||||
if (!decoded || !decoded.email) {
|
||||
return res.status(401).json({ success: false, message: "Invalid token" });
|
||||
}
|
||||
|
||||
const vaEmail = decoded.email;
|
||||
const vaUser = await VAModel.findOne({ "userDetails.email": vaEmail });
|
||||
if (!vaUser) {
|
||||
return res.status(404).json({ success: false, message: "VA not found" });
|
||||
}
|
||||
|
||||
const vaId = vaUser._id;
|
||||
const { projectId, date } = req.params;
|
||||
const { loginTime, logoutTime, reason } = req.body;
|
||||
|
||||
if (!projectId || !date) {
|
||||
return res.status(400).json({ success: false, message: "Project ID and date are required" });
|
||||
}
|
||||
|
||||
if (!loginTime || !logoutTime) {
|
||||
return res.status(400).json({ success: false, message: "Login time and logout time are required" });
|
||||
}
|
||||
|
||||
// Validate project exists and VA is assigned to it
|
||||
const project = await ProjectModel.findOne({
|
||||
_id: projectId,
|
||||
vaAssigned: vaId
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return res.status(404).json({ success: false, message: "Project not found or VA not assigned to this project" });
|
||||
}
|
||||
|
||||
// Parse and validate times
|
||||
const adjustmentDate = new Date(date);
|
||||
const loginDateTime = new Date(loginTime);
|
||||
const logoutDateTime = new Date(logoutTime);
|
||||
|
||||
// Ensure times are within the target date
|
||||
const startOfDay = new Date(adjustmentDate);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfDay = new Date(adjustmentDate);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
if (loginDateTime < startOfDay || loginDateTime > endOfDay ||
|
||||
logoutDateTime < startOfDay || logoutDateTime > endOfDay) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Login and logout times must be within the specified date"
|
||||
});
|
||||
}
|
||||
|
||||
if (loginDateTime >= logoutDateTime) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Login time must be before logout time"
|
||||
});
|
||||
}
|
||||
|
||||
// Check for existing logs that might conflict with this adjustment
|
||||
const existingLogs = await LoggedModel.find({
|
||||
vaId,
|
||||
projectId,
|
||||
time: {
|
||||
$gte: loginDateTime,
|
||||
$lte: logoutDateTime
|
||||
}
|
||||
});
|
||||
|
||||
if (existingLogs.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "There are existing logs in the specified time range. Please choose a different time range."
|
||||
});
|
||||
}
|
||||
|
||||
// Create adjustment logs
|
||||
const loginLog = new LoggedModel({
|
||||
loggedType: "LOGIN",
|
||||
time: loginDateTime,
|
||||
vaId,
|
||||
vaEmail,
|
||||
projectId,
|
||||
isAdjustment: true,
|
||||
adjustmentReason: reason
|
||||
});
|
||||
|
||||
const logoutLog = new LoggedModel({
|
||||
loggedType: "LOGOUT",
|
||||
time: logoutDateTime,
|
||||
vaId,
|
||||
vaEmail,
|
||||
projectId,
|
||||
isAdjustment: true,
|
||||
adjustmentReason: reason
|
||||
});
|
||||
|
||||
// Save both logs in transaction
|
||||
await loginLog.save();
|
||||
await logoutLog.save();
|
||||
|
||||
// Calculate adjusted hours
|
||||
const adjustedSeconds = (logoutDateTime - loginDateTime) / 1000;
|
||||
const formatTime = (seconds) => {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
message: "Timesheet adjustment completed successfully",
|
||||
data: {
|
||||
adjustment: {
|
||||
date: adjustmentDate.toISOString().split('T')[0],
|
||||
loginTime: loginDateTime.toISOString(),
|
||||
logoutTime: logoutDateTime.toISOString(),
|
||||
adjustedHours: formatTime(adjustedSeconds),
|
||||
adjustedHoursDecimal: (adjustedSeconds / 3600).toFixed(2),
|
||||
reason: reason
|
||||
},
|
||||
logs: {
|
||||
loginLogId: loginLog._id,
|
||||
logoutLogId: logoutLog._id
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return res.status(201).json(response);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Timesheet adjustment error:", error);
|
||||
return res.status(500).json({ success: false, message: "Internal Server Error" });
|
||||
}
|
||||
}
|
||||
|
||||
export { va_timesheet_adjustment_function };
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
import { VAModel, ProjectModel, LoggedModel } from "../../../models/vaModel.js";
|
||||
|
||||
async function va_timesheet_summary_function(req, res) {
|
||||
try {
|
||||
const accessToken = req.headers.token;
|
||||
|
||||
if (!accessToken) {
|
||||
return res.status(401).json({ success: false, message: "Missing token" });
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(accessToken, process.env.JWT_SECRET_ACCESS_TOKEN);
|
||||
if (!decoded || !decoded.email) {
|
||||
return res.status(401).json({ success: false, message: "Invalid token" });
|
||||
}
|
||||
|
||||
const vaEmail = decoded.email;
|
||||
const vaUser = await VAModel.findOne({ "userDetails.email": vaEmail });
|
||||
if (!vaUser) {
|
||||
return res.status(404).json({ success: false, message: "VA not found" });
|
||||
}
|
||||
|
||||
const vaId = vaUser._id;
|
||||
const { projectId, date } = req.params;
|
||||
|
||||
if (!projectId || !date) {
|
||||
return res.status(400).json({ success: false, message: "Project ID and date are required" });
|
||||
}
|
||||
|
||||
// Validate project exists and VA is assigned to it
|
||||
const project = await ProjectModel.findOne({
|
||||
_id: projectId,
|
||||
vaAssigned: vaId
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return res.status(404).json({ success: false, message: "Project not found or VA not assigned to this project" });
|
||||
}
|
||||
|
||||
// Calculate date range for the specific day
|
||||
let targetDate;
|
||||
try {
|
||||
// Try parsing as ISO date (YYYY-MM-DD)
|
||||
targetDate = new Date(date);
|
||||
if (isNaN(targetDate.getTime())) {
|
||||
// Try parsing as YYYY-MM-DD format specifically
|
||||
const [year, month, day] = date.split('-');
|
||||
targetDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
||||
}
|
||||
|
||||
if (isNaN(targetDate.getTime())) {
|
||||
return res.status(400).json({ success: false, message: "Invalid date format. Use YYYY-MM-DD" });
|
||||
}
|
||||
} catch (error) {
|
||||
return res.status(400).json({ success: false, message: "Invalid date format" });
|
||||
}
|
||||
|
||||
const startOfDay = new Date(targetDate);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfDay = new Date(targetDate);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
// Get all logs for this VA, project, and date
|
||||
const logs = await LoggedModel.find({
|
||||
vaId,
|
||||
projectId,
|
||||
time: { $gte: startOfDay, $lte: endOfDay }
|
||||
}).sort({ time: 1 }).lean();
|
||||
|
||||
// Calculate work sessions and totals
|
||||
let totalWorkSeconds = 0;
|
||||
let firstLoginTime = null;
|
||||
let lastLogoutTime = null;
|
||||
|
||||
const sessions = [];
|
||||
const loginStack = [];
|
||||
|
||||
for (const log of logs) {
|
||||
const logTime = new Date(log.time);
|
||||
|
||||
if (log.loggedType === "LOGIN") {
|
||||
loginStack.push(logTime);
|
||||
|
||||
// Set first login time
|
||||
if (!firstLoginTime || logTime < firstLoginTime) {
|
||||
firstLoginTime = logTime;
|
||||
}
|
||||
} else if (log.loggedType === "LOGOUT" && lastLogin) {
|
||||
if (loginStack.length > 0) {
|
||||
// Pop the last login from stack
|
||||
const lastLogin = loginStack.pop();
|
||||
const sessionSeconds = (logTime - lastLogin) / 1000;
|
||||
|
||||
totalWorkSeconds += sessionSeconds;
|
||||
|
||||
// Set last logout time
|
||||
if (!lastLogoutTime || logTime > lastLogoutTime) {
|
||||
lastLogoutTime = logTime;
|
||||
}
|
||||
|
||||
sessions.push({
|
||||
login: lastLogin,
|
||||
logout: logTime,
|
||||
durationSeconds: sessionSeconds
|
||||
});
|
||||
|
||||
} else {
|
||||
console.log(`Orphaned LOGOUT at ${logTime.toISOString()} - no matching LOGIN`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle open session (LOGIN without LOGOUT)
|
||||
if (loginStack.length > 0) {
|
||||
|
||||
for (const lastLogin of loginStack) {
|
||||
const sessionSeconds = (endOfDay - lastLogin) / 1000;
|
||||
totalWorkSeconds += sessionSeconds;
|
||||
|
||||
sessions.push({
|
||||
login: lastLogin,
|
||||
logout: null,
|
||||
durationSeconds: sessionSeconds,
|
||||
isOpen: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Convert seconds to time format
|
||||
const formatTime = (seconds) => {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const formatTimeDetailed = (seconds) => {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
|
||||
if (h > 0) {
|
||||
return `${h}h ${m}m ${s}s`;
|
||||
} else if (m > 0) {
|
||||
return `${m}m ${s}s`;
|
||||
} else {
|
||||
return `${s}s`;
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate dedicated hours and pending hours
|
||||
const dedicatedHours = project.dedicatedHours || 0;
|
||||
const totalWorkedHours = totalWorkSeconds / 3600; // Convert to hours
|
||||
const pendingHours = Math.max(0, dedicatedHours - totalWorkedHours);
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
data: {
|
||||
date: targetDate.toISOString().split('T')[0],
|
||||
project: {
|
||||
id: project._id,
|
||||
name: project.name,
|
||||
dedicatedHours: dedicatedHours
|
||||
},
|
||||
va: {
|
||||
id: vaUser._id,
|
||||
name: vaUser.userDetails?.name || 'Unknown',
|
||||
email: vaEmail
|
||||
},
|
||||
summary: {
|
||||
totalWorkedHours: formatTime(totalWorkSeconds),
|
||||
totalWorkedHoursDetailed: formatTimeDetailed(totalWorkSeconds),
|
||||
totalWorkedHoursDecimal: totalWorkedHours.toFixed(4),
|
||||
totalWorkedMinutes: (totalWorkSeconds / 60).toFixed(2),
|
||||
dedicatedHours: dedicatedHours,
|
||||
pendingHours: pendingHours.toFixed(4),
|
||||
firstLoginTime: firstLoginTime ? firstLoginTime.toISOString() : null,
|
||||
lastLogoutTime: lastLogoutTime ? lastLogoutTime.toISOString() : null
|
||||
},
|
||||
sessions: sessions.map(session => ({
|
||||
login: session.login.toISOString(),
|
||||
logout: session.logout ? session.logout.toISOString() : null,
|
||||
duration: formatTime(session.durationSeconds),
|
||||
durationDetailed: formatTimeDetailed(session.durationSeconds),
|
||||
durationSeconds: session.durationSeconds,
|
||||
isOpen: session.isOpen || false
|
||||
})),
|
||||
}
|
||||
};
|
||||
|
||||
return res.status(200).json(response);
|
||||
} catch (error) {
|
||||
console.error("Timesheet summary error:", error);
|
||||
return res.status(500).json({ success: false, message: "Internal Server Error" });
|
||||
}
|
||||
}
|
||||
|
||||
export { va_timesheet_summary_function };
|
||||
|
|
@ -90,6 +90,14 @@ const projectSchema = new Schema({
|
|||
ref: "VA",
|
||||
},
|
||||
],
|
||||
clientId: {
|
||||
type: Types.ObjectId,
|
||||
ref: "Client"
|
||||
},
|
||||
dedicatedHours: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------- Client Details Schema ---------------------- */
|
||||
|
|
@ -198,7 +206,12 @@ const clientSchema = new Schema({
|
|||
refreshToken: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
passwordResetToken: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
|
@ -259,6 +272,10 @@ const vaSchema = new Schema(
|
|||
refreshToken: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
passwordResetToken: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
{ timestamps: true }
|
||||
|
|
@ -308,11 +325,49 @@ const vaWorkTrackSchema = new Schema({
|
|||
{ timestamps: true }
|
||||
)
|
||||
|
||||
const passwordResetSchema = new mongoose.Schema({
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
lowercase: true,
|
||||
index: true
|
||||
},
|
||||
token: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true
|
||||
},
|
||||
userType: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['va', 'client'],
|
||||
index: true
|
||||
},
|
||||
expiresAt: {
|
||||
type: Date,
|
||||
required: true,
|
||||
index: true,
|
||||
expires: 3600 // TTL index for 1 hour
|
||||
},
|
||||
used: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
passwordResetSchema.index({ email: 1, used: 1 });
|
||||
passwordResetSchema.index({ token: 1, used: 1, expiresAt: 1 });
|
||||
passwordResetSchema.index({ userType: 1, email: 1 });
|
||||
|
||||
/* ---------------------- Model Exports ---------------------- */
|
||||
const VAModel = model("VA", vaSchema);
|
||||
const ClientModel = model("Client", clientSchema);
|
||||
const ProjectModel = model("Project", projectSchema);
|
||||
const LoggedModel = model("TrackLog", loggedSchema);
|
||||
const WorkTrackModel = model("TrackWork", vaWorkTrackSchema);
|
||||
const PasswordResetModel = model("PasswordReset", passwordResetSchema);
|
||||
|
||||
export { VAModel, ClientModel, ProjectModel, LoggedModel, WorkTrackModel };
|
||||
export { VAModel, ClientModel, ProjectModel, LoggedModel, WorkTrackModel, PasswordResetModel };
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { PasswordResetModel } from "../models/vaModel.js";
|
||||
|
||||
class PasswordResetRepository {
|
||||
async create(data) {
|
||||
try {
|
||||
const newReset = new PasswordResetModel({...data});
|
||||
const response = await newReset.save();
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findByToken(token) {
|
||||
try {
|
||||
const response = await PasswordResetModel.findOne({ token });
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findByEmail(email) {
|
||||
try {
|
||||
const response = await PasswordResetModel.findOne({
|
||||
email: email.toLowerCase()
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteByEmail(email) {
|
||||
try {
|
||||
const response = await PasswordResetModel.deleteMany({
|
||||
email: email.toLowerCase()
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteByToken(token) {
|
||||
try {
|
||||
const response = await PasswordResetModel.deleteOne({ token });
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteExpiredTokens() {
|
||||
try {
|
||||
const response = await PasswordResetModel.deleteMany({
|
||||
expiresAt: { $lt: new Date() }
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PasswordResetRepository;
|
||||
|
|
@ -6,23 +6,18 @@ import { va_clients_callback } from "../../controllers/va/vaClients/vaClients.co
|
|||
import { va_clients_projects_callback } from "../../controllers/va/vaClients/vaClientProject.controller.js";
|
||||
import { va_login_client } from "../../controllers/client/vaClientLogin.controller.js";
|
||||
import { client_refresh_token_callback } from "../../controllers/client/clientRefreshToken.controller.js";
|
||||
import { client_request_password_reset } from "../../controllers/passwordReset.controllers.js";
|
||||
|
||||
// clients routes
|
||||
clientRoutes.post("/register", va_register_client); // Register a new VA
|
||||
clientRoutes.post("/login", va_login_client); // Login Client
|
||||
clientRoutes.get("/refreshToken", client_refresh_token_callback); // Client Refresh Token
|
||||
|
||||
clientRoutes.post("/forgot-password", client_request_password_reset);
|
||||
|
||||
clientRoutes.get("/:clientID", va_clients_callback); // VA Clients Callback for getting all clients or specific client by ID (NC)
|
||||
clientRoutes.get("/:clientId/projects/:projectId", va_clients_projects_callback); // VA Clients Projects Callback (NC)
|
||||
|
||||
|
||||
// // API endpoint (for frontend to call)
|
||||
// clientRoutes.post("/forgot-password", va_client_request_password_reset);
|
||||
|
||||
// // EJS rendering routes
|
||||
// clientRoutes.get("/reset-form", va_client_render_reset_form);
|
||||
// clientRoutes.post("/reset", va_client_process_reset);
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import express from "express";
|
|||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
import { process_reset } from "../../controllers/passwordReset.controllers.js";
|
||||
|
||||
const v1Routes = express.Router()
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
|
|
@ -22,6 +24,8 @@ v1Routes.use("/auth_callback", authRoutes);
|
|||
v1Routes.use("/va", vaRoutes);
|
||||
v1Routes.use("/client", clientRoutes);
|
||||
|
||||
v1Routes.post("/password/reset", process_reset);
|
||||
|
||||
v1Routes.get("/", function(req, res) {
|
||||
res.json({"connected": true})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ import { va_log_track_work_callback } from "../../controllers/va/vaLog/vaLogTrac
|
|||
import { va_log_work_report_callback } from "../../controllers/va/vaLog/vaLogWorkReport.js";
|
||||
import { va_projects_report_function } from "../../controllers/va/vaProjects/vaProjectReport.controller.js";
|
||||
import { va_projects_log_list_function } from "../../controllers/va/vaProjects/vaProjectLogList.controller.js";
|
||||
import { va_request_password_reset } from "../../controllers/passwordReset.controllers.js";
|
||||
import { va_timesheet_summary_function } from "../../controllers/va/vaLog/vaTimesheetAdjustmentSummary.controller.js";
|
||||
import { va_timesheet_adjustment_function } from "../../controllers/va/vaLog/vaTimesheetAdjustment.controller.js";
|
||||
|
||||
vaRoutes.get("/", va_callback); // VA Callback for getting active VA
|
||||
|
||||
|
|
@ -24,6 +27,7 @@ vaRoutes.post("/login", va_login_callback); // VA Callback for login VA
|
|||
vaRoutes.get("/logout", authValidator, va_logout_callback); // VA Callback for login VA
|
||||
vaRoutes.post("/refreshToken", va_refresh_token_callback); // VA Callback for refresh token
|
||||
vaRoutes.post("/register", va_register); // Register a new VA
|
||||
vaRoutes.post("/forgot-password", va_request_password_reset);
|
||||
|
||||
vaRoutes.get("/profile", authValidator, va_profile_callback); // Get VA Profile
|
||||
vaRoutes.patch("/profile", authValidator, va_profile_update_callback); // Update VA Profile
|
||||
|
|
@ -33,7 +37,8 @@ vaRoutes.patch("/profile", authValidator, va_profile_update_callback); // Update
|
|||
vaRoutes.get("/:userName", va_username_callback); // VA Username Callback (NC)
|
||||
|
||||
|
||||
|
||||
vaRoutes.get("/timesheet/summary/:projectId/:date", authValidator, va_timesheet_summary_function); // Get Timesheet Adjustment for VA on specific project and date
|
||||
vaRoutes.post("/timesheet/adjustment/:projectId/:date", authValidator, va_timesheet_adjustment_function); // Get Timesheet Adjustment for VA on specific project and date
|
||||
|
||||
// Example: GET /va/search?name=john&email=@example.com&from=2025-01-01&to=2025-01-31 (NC)
|
||||
vaRoutes.get("/search", va_filter_search);
|
||||
|
|
@ -55,12 +60,6 @@ vaRoutes.get("/log/project/:projectId/:from?/:to?", authValidator, va_log_callba
|
|||
// VA filter search: supports query params `name`, `email`, `from`, `to`
|
||||
|
||||
|
||||
// // API endpoint (for frontend to call)
|
||||
// vaRoutes.post("/forgot-password", va_request_password_reset);
|
||||
|
||||
// // EJS rendering routes
|
||||
// vaRoutes.get("/reset-form", va_render_reset_form);
|
||||
// vaRoutes.post("/reset", va_process_reset);
|
||||
|
||||
|
||||
export default vaRoutes;
|
||||
|
|
@ -1,25 +1,26 @@
|
|||
import dotenv from "dotenv";
|
||||
dotenv.config()
|
||||
|
||||
|
||||
import express from 'express';
|
||||
import { createServer } from 'http';
|
||||
|
||||
|
||||
import mongoose from "mongoose";
|
||||
import bodyParser from "body-parser";
|
||||
|
||||
import { Server } from 'socket.io';
|
||||
import dotenv from "dotenv";
|
||||
import cors from 'cors';
|
||||
|
||||
import path from 'path';
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import fs from "node:fs"
|
||||
|
||||
|
||||
import apiRoutes from './routes/index.js'
|
||||
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
import { render_reset_form } from "./controllers/passwordReset.controllers.js";
|
||||
|
||||
dotenv.config()
|
||||
|
||||
const app = express();
|
||||
|
||||
|
|
@ -53,6 +54,8 @@ app.get('/clientregister', (req, res) => {
|
|||
res.render('va_client_register', { title: "Client Registration" });
|
||||
});
|
||||
|
||||
app.get('/auth/password/reset-form', render_reset_form);
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(201).send({ status: "Server is healthy" });
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,303 @@
|
|||
// services/email.service.js
|
||||
import nodemailer from "nodemailer";
|
||||
import logger from "../config/logger.js";
|
||||
|
||||
class EmailService {
|
||||
constructor() {
|
||||
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST || "smtp.gmail.com",
|
||||
port: process.env.SMTP_PORT || 587,
|
||||
// secure: false, // Use TLS
|
||||
auth: {
|
||||
user: process.env.SMTP_USER || "aakashtest2007@gmail.com",
|
||||
pass: process.env.SMTP_PASS || "licb yqet bfad kkqy",
|
||||
},
|
||||
// Gmail specific settings
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
ciphers: 'SSLv3'
|
||||
},
|
||||
connectionTimeout: 30000, // Increased timeout for Gmail
|
||||
socketTimeout: 30000,
|
||||
greetingTimeout: 30000,
|
||||
});
|
||||
|
||||
this.verifyConnection();
|
||||
}
|
||||
|
||||
setupDevelopmentFallback() {
|
||||
logger.warn(' Using development fallback - emails will be logged but not sent');
|
||||
|
||||
this.transporter = {
|
||||
verify: async () => {
|
||||
logger.info('Development mode: Gmail SMTP verification skipped');
|
||||
return true;
|
||||
},
|
||||
sendMail: async (mailOptions) => {
|
||||
logger.info('Development mode: Email would be sent via Gmail');
|
||||
logger.info(` To: ${mailOptions.to}`);
|
||||
logger.info(` Subject: ${mailOptions.subject}`);
|
||||
logger.info(` From: ${mailOptions.from}`);
|
||||
|
||||
// Simulate Gmail sending delay
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
return {
|
||||
messageId: 'gmail-dev-' + Date.now(),
|
||||
response: '250 2.0.0 OK - Development mode'
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async verifyConnection() {
|
||||
try {
|
||||
await this.transporter.verify();
|
||||
logger.info(`Gmail SMTP connection verified successfully`);
|
||||
logger.info(`Gmail account: ${process.env.SMTP_USER}`);
|
||||
} catch (error) {
|
||||
logger.error(`Gmail SMTP connection failed: ${error.message}`);
|
||||
this.logGmailTroubleshootingTips(error);
|
||||
}
|
||||
}
|
||||
|
||||
logGmailTroubleshootingTips(error) {
|
||||
logger.info(`Gmail Troubleshooting Tips:`);
|
||||
logger.info(` 1. Enable 2-Factor Authentication in Google Account`);
|
||||
logger.info(` 2. Generate App Password (16 characters) - NOT your regular password`);
|
||||
logger.info(` 3. Allow less secure apps: https://myaccount.google.com/lesssecureapps`);
|
||||
logger.info(` 4. Unlock Captcha: https://accounts.google.com/DisplayUnlockCaptcha`);
|
||||
logger.info(` 5. Check if port 587 is blocked by firewall`);
|
||||
logger.info(` 6. Error details: ${error.message}`);
|
||||
}
|
||||
|
||||
async sendEmail(emailData) {
|
||||
try {
|
||||
const mailOptions = {
|
||||
from: process.env.EMAIL_FROM,
|
||||
to: emailData.to,
|
||||
subject: emailData.subject,
|
||||
html: emailData.html,
|
||||
// Gmail specific headers
|
||||
headers: {
|
||||
'X-Priority': '1',
|
||||
'X-Mailer': 'Node.js Password Reset',
|
||||
'List-Unsubscribe': `<mailto:${process.env.SUPPORT_EMAIL}?subject=Unsubscribe>`,
|
||||
}
|
||||
};
|
||||
|
||||
logger.info(`Sending email via Gmail to: ${emailData.to}`);
|
||||
const result = await this.transporter.sendMail(mailOptions);
|
||||
|
||||
if (result.response && result.response.includes('250')) {
|
||||
logger.info(`Email sent successfully via Gmail to: ${emailData.to}`);
|
||||
logger.debug(`Gmail Response: ${result.response}`);
|
||||
} else {
|
||||
logger.info(`Email processed via Gmail to: ${emailData.to}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Gmail email send failed: ${error.message}`);
|
||||
|
||||
// Enhanced error handling for Gmail specific issues
|
||||
if (error.message.includes('Invalid login')) {
|
||||
throw new Error('Gmail authentication failed. Use App Password, not regular password.');
|
||||
} else if (error.message.includes('ECONNREFUSED')) {
|
||||
throw new Error('Gmail connection refused. Check firewall/port 587.');
|
||||
} else if (error.message.includes('rate limit')) {
|
||||
throw new Error('Gmail rate limit exceeded. Try again later.');
|
||||
} else if (error.message.includes('quota')) {
|
||||
throw new Error('Gmail sending quota exceeded.');
|
||||
} else {
|
||||
throw new Error(`Gmail error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced test email for Gmail
|
||||
async sendTestEmail(toEmail = "test@example.com") {
|
||||
try {
|
||||
const testEmail = {
|
||||
to: toEmail,
|
||||
subject: "Gmail Test - Password Reset System Working",
|
||||
html: this.generateGmailTestTemplate(),
|
||||
};
|
||||
|
||||
const result = await this.sendEmail(testEmail);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Test email sent successfully via Gmail",
|
||||
messageId: result.messageId,
|
||||
response: result.response
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
generateGmailTestTemplate() {
|
||||
const currentTime = new Date().toLocaleString();
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 20px auto;
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #4285F4 0%, #34A853 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
}
|
||||
.content {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
.success-badge {
|
||||
background: #34A853;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 20px;
|
||||
display: inline-block;
|
||||
margin-bottom: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.details-card {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin: 25px 0;
|
||||
border-left: 4px solid #4285F4;
|
||||
}
|
||||
.footer {
|
||||
background: #f1f3f4;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #5f6368;
|
||||
font-size: 12px;
|
||||
}
|
||||
.gmail-logo {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #4285F4;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.info-item {
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div style="font-size: 48px; margin-bottom: 10px;">✉️</div>
|
||||
<h1>Gmail SMTP Test Successful</h1>
|
||||
<p>Your password reset system is now configured with Gmail</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="success-badge"> TEST PASSED</div>
|
||||
|
||||
<h2>Gmail Configuration Verified</h2>
|
||||
<p>Your email service is working perfectly with Gmail SMTP!</p>
|
||||
|
||||
<div class="details-card">
|
||||
<div class="gmail-logo">Gmail</div>
|
||||
<h3 style="margin-top: 0;">Configuration Details</h3>
|
||||
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<strong>🔧 SMTP Host</strong><br>
|
||||
${process.env.SMTP_HOST}
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>📮 Sender</strong><br>
|
||||
${process.env.EMAIL_FROM}
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>🕐 Time</strong><br>
|
||||
${currentTime}
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>⚡ Status</strong><br>
|
||||
Active & Ready
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: #e8f5e8; padding: 15px; border-radius: 8px; border-left: 4px solid #34A853;">
|
||||
<h4 style="margin-top: 0; color: #2e7d32;">🎉 Ready for Production</h4>
|
||||
<p style="margin-bottom: 0;">Your password reset emails will now be delivered reliably via Gmail.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>This is a test email from your Password Reset System</p>
|
||||
<p>Powered by Gmail SMTP • ${currentTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
// Check Gmail sending limits and status
|
||||
getGmailStatus() {
|
||||
return {
|
||||
provider: 'Gmail',
|
||||
host: process.env.SMTP_HOST,
|
||||
port: process.env.SMTP_PORT,
|
||||
fromEmail: process.env.EMAIL_FROM,
|
||||
limits: {
|
||||
daily: '500 emails per day',
|
||||
rate: '100 emails per hour',
|
||||
attachment: '25MB per email'
|
||||
},
|
||||
requirements: [
|
||||
'2-Factor Authentication enabled',
|
||||
'App Password used (16 characters)',
|
||||
'Less secure apps access may need enabling'
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default EmailService;
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
import bcrypt from "bcrypt";
|
||||
import { VAModel, ClientModel } from "../models/vaModel.js";
|
||||
import logger from "../config/logger.js";
|
||||
|
||||
class PasswordResetService {
|
||||
constructor(passwordResetRepository, emailService) {
|
||||
this.passwordResetRepository = passwordResetRepository;
|
||||
this.emailService = emailService;
|
||||
}
|
||||
|
||||
async requestPasswordReset(email, userType) {
|
||||
try {
|
||||
// Determine which model to use
|
||||
const userModel = userType === 'va' ? VAModel : ClientModel;
|
||||
|
||||
// Check if user exists
|
||||
const user = await userModel.findOne({ "userDetails.email": email.toLowerCase() });
|
||||
if (!user) {
|
||||
logger.info(`Password reset requested for non-existent ${userType}: ${email}`);
|
||||
return {
|
||||
success: true,
|
||||
message: "If the email exists, a reset link has been sent"
|
||||
};
|
||||
}
|
||||
|
||||
// Delete any existing reset tokens for this email
|
||||
await this.passwordResetRepository.deleteByEmail(email);
|
||||
|
||||
// Generate JWT token
|
||||
const token = jwt.sign(
|
||||
{
|
||||
email: email.toLowerCase(),
|
||||
userType: userType,
|
||||
type: 'password_reset'
|
||||
},
|
||||
process.env.JWT_RESET_SECRET,
|
||||
{ expiresIn: process.env.RESET_TOKEN_EXPIRY || '1h' }
|
||||
);
|
||||
|
||||
// Create reset record
|
||||
const resetData = {
|
||||
email: email.toLowerCase(),
|
||||
token,
|
||||
userType: userType,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
|
||||
used: false
|
||||
};
|
||||
|
||||
await this.passwordResetRepository.create(resetData);
|
||||
|
||||
// Store token in user document
|
||||
user.passwordResetToken = token;
|
||||
await user.save();
|
||||
|
||||
// Generate reset link
|
||||
const resetLink = `${process.env.BACKEND_URL}/auth/password/reset-form?token=${token}&userType=${userType}`;
|
||||
|
||||
// Send email
|
||||
await this.sendResetEmail(email, resetLink, userType);
|
||||
|
||||
logger.info(`Password reset token created for ${userType}: ${email}`);
|
||||
return {
|
||||
success: true,
|
||||
message: "If the email exists, a reset link has been sent"
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`PasswordReset Service - requestPasswordReset error for ${userType}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async validateResetToken(token, userType) {
|
||||
try {
|
||||
// Verify token
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_RESET_SECRET);
|
||||
} catch (error) {
|
||||
return { valid: false, message: "Invalid or expired token" };
|
||||
}
|
||||
|
||||
// Validate user type
|
||||
if (decoded.userType !== userType) {
|
||||
return { valid: false, message: "Invalid token for this user type" };
|
||||
}
|
||||
|
||||
// Check database
|
||||
const resetRecord = await this.passwordResetRepository.findByToken(token);
|
||||
if (!resetRecord || resetRecord.used || resetRecord.expiresAt < new Date()) {
|
||||
return { valid: false, message: "Invalid or expired token" };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
email: decoded.email,
|
||||
userType: decoded.userType
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`PasswordReset Service - validateResetToken error: ${error.message}`);
|
||||
return { valid: false, message: "Token validation failed" };
|
||||
}
|
||||
}
|
||||
|
||||
async resetPassword(token, newPassword, userType) {
|
||||
try {
|
||||
// Verify token
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_RESET_SECRET);
|
||||
} catch (error) {
|
||||
throw new Error("Invalid or expired reset token");
|
||||
}
|
||||
|
||||
// Validate user type
|
||||
if (decoded.userType !== userType) {
|
||||
throw new Error("Invalid reset token for this user type");
|
||||
}
|
||||
|
||||
// Check if token exists in database and is valid
|
||||
const resetRecord = await this.passwordResetRepository.findByToken(token);
|
||||
if (!resetRecord || resetRecord.used || resetRecord.expiresAt < new Date()) {
|
||||
throw new Error("Invalid or expired reset token");
|
||||
}
|
||||
|
||||
// Determine which model to use
|
||||
const userModel = userType === 'va' ? VAModel : ClientModel;
|
||||
|
||||
// Check if user exists and has matching token
|
||||
const user = await userModel.findOne({
|
||||
"userDetails.email": decoded.email,
|
||||
passwordResetToken: token
|
||||
});
|
||||
if (!user) {
|
||||
throw new Error("User not found or invalid token");
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 12);
|
||||
|
||||
// Update user password
|
||||
user.userDetails.password = hashedPassword;
|
||||
user.passwordResetToken = null;
|
||||
await user.save();
|
||||
|
||||
// Mark token as used
|
||||
resetRecord.used = true;
|
||||
await resetRecord.save();
|
||||
|
||||
// Send confirmation email
|
||||
await this.sendPasswordChangedEmail(decoded.email, userType);
|
||||
|
||||
logger.info(`Password successfully reset for ${userType}: ${decoded.email}`);
|
||||
return {
|
||||
success: true,
|
||||
message: "Password has been reset successfully"
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`PasswordReset Service - resetPassword error for ${userType}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async sendResetEmail(email, resetLink, userType) {
|
||||
try {
|
||||
const userTypeText = userType === 'va' ? 'Virtual Assistant' : 'Client';
|
||||
|
||||
const emailData = {
|
||||
to: email,
|
||||
subject: `${userTypeText} - Password Reset Request`,
|
||||
html: this.generateResetEmailTemplate(resetLink, userTypeText),
|
||||
};
|
||||
|
||||
await this.emailService.sendEmail(emailData);
|
||||
logger.info(`Password reset email sent to ${userType}: ${email}`);
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`PasswordReset Service - sendResetEmail error for ${userType}: ${error.message}`);
|
||||
throw new Error("Failed to send reset email");
|
||||
}
|
||||
}
|
||||
|
||||
async sendPasswordChangedEmail(email, userType) {
|
||||
try {
|
||||
const userTypeText = userType === 'va' ? 'Virtual Assistant' : 'Client';
|
||||
|
||||
const emailData = {
|
||||
to: email,
|
||||
subject: `${userTypeText} - Password Changed Successfully`,
|
||||
html: this.generatePasswordChangedEmailTemplate(userTypeText),
|
||||
};
|
||||
|
||||
await this.emailService.sendEmail(emailData);
|
||||
logger.info(`Password changed confirmation sent to ${userType}: ${email}`);
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`PasswordReset Service - sendPasswordChangedEmail error for ${userType}: ${error.message}`);
|
||||
// Don't throw error here as password was already reset
|
||||
}
|
||||
}
|
||||
|
||||
generateResetEmailTemplate(resetLink, userType) {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.button { background-color: #007bff; color: white !important; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block; }
|
||||
.footer { margin-top: 20px; font-size: 12px; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>${userType} Password Reset Request</h2>
|
||||
<p>You requested to reset your password. Click the button below to create a new password:</p>
|
||||
<p><a href="${resetLink}" class="button">Reset Password</a></p>
|
||||
<p>This link will expire in 1 hour.</p>
|
||||
<p>If you didn't request this reset, please ignore this email.</p>
|
||||
<div class="footer">
|
||||
<p>This is an automated message, please do not reply to this email.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
generatePasswordChangedEmailTemplate(userType) {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.alert { background-color: #d4edda; color: #155724; padding: 12px; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="alert">
|
||||
<h2>${userType} Password Changed Successfully</h2>
|
||||
</div>
|
||||
<p>Your password has been changed successfully.</p>
|
||||
<p>If you did not make this change, please contact our support team immediately at
|
||||
<a href="mailto:${process.env.SUPPORT_EMAIL}">${process.env.SUPPORT_EMAIL}</a>.</p>
|
||||
<p><a href="${process.env.FRONTEND_URL}/login">Login to your account</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export default PasswordResetService;
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<% const layout = 'layouts/main' %>
|
||||
<% const title = 'Password Reset Failed' %>
|
||||
<link href="/public/css/forget-password.css" rel="stylesheet">
|
||||
<div class="page-center">
|
||||
<div class="auth-card text-center">
|
||||
<div class="auth-header">
|
||||
<div class="auth-icon" style="color:#ef4444;">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
</div>
|
||||
<div class="auth-title">Password Reset Link Expired</div>
|
||||
<div class="auth-sub"><%= error %></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="<%= process.env.FRONTEND_URL %>/forgot-password" class="btn btn-primary" style="margin-bottom:8px;">Request New Reset Link</a>
|
||||
<a href="<%= process.env.FRONTEND_URL %>/login" class="btn btn-ghost">Back to Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<% const layout = 'layouts/main' %>
|
||||
<% const title = 'Reset Password' %>
|
||||
<link href="/public/css/forget-password.css" rel="stylesheet">
|
||||
<div class="page-center">
|
||||
<div class="auth-card" role="main">
|
||||
<div class="auth-header">
|
||||
<div class="auth-icon" style="color:#2563eb;">
|
||||
<i class="fas fa-lock"></i>
|
||||
</div>
|
||||
<div class="auth-title">Reset Your Password</div>
|
||||
<div class="auth-sub">Enter your new password below</div>
|
||||
</div>
|
||||
|
||||
<% if (error) { %>
|
||||
<div class="alert alert-error"><strong>Error:</strong> <%= error %></div>
|
||||
<% } %>
|
||||
|
||||
<form action="/api/v1/password/reset" method="POST" id="resetForm">
|
||||
<input type="hidden" name="token" value="<%= token %>">
|
||||
<input type="hidden" name="userType" value="<%= userType %>">
|
||||
|
||||
<div class="form-row">
|
||||
<input type="password" id="password" name="password" required minlength="6"
|
||||
class="form-input" placeholder="New Password">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<input type="password" id="confirmPassword" name="confirmPassword" required minlength="6"
|
||||
class="form-input" placeholder="Confirm Password">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<button type="submit" class="btn btn-primary">Reset Password</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<a href="<%= process.env.FRONTEND_URL %>/login" class="small-link">Back to Login</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('resetForm').addEventListener('submit', function(e){
|
||||
const p = document.getElementById('password').value;
|
||||
const c = document.getElementById('confirmPassword').value;
|
||||
if (p.length < 6) {
|
||||
e.preventDefault();
|
||||
alert('Password must be at least 6 characters long');
|
||||
return;
|
||||
}
|
||||
if (p !== c) {
|
||||
e.preventDefault();
|
||||
alert('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<% const layout = 'layouts/main' %>
|
||||
<% const title = 'Password Reset Success' %>
|
||||
<link href="/public/css/forget-password.css" rel="stylesheet">
|
||||
<div class="page-center">
|
||||
<div class="auth-card text-center">
|
||||
<div class="auth-header">
|
||||
<div class="auth-icon" style="color:#10b981;">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
</div>
|
||||
<div class="auth-title">Password Reset Successful!</div>
|
||||
<div class="auth-sub">Your password has been reset. You can now login.</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="<%= process.env.FRONTEND_URL %>/login" class="btn btn-primary">Go to Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title><%= title %></title>
|
||||
|
||||
<!-- Font Awesome (if you use icons) -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
|
||||
<!-- Custom auth css -->
|
||||
<link href="/public/css/forget-password.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<%- body %>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -178,14 +178,10 @@
|
|||
<input type="password" id="password" class="input-field" placeholder=" " required>
|
||||
<label for="password" class="input-label">Create Password *</label>
|
||||
</div>
|
||||
<div class="password-strength">
|
||||
<div class="password-strength-bar" id="password-strength-bar"></div>
|
||||
</div>
|
||||
<div class="password-actions">
|
||||
<button type="button" class="password-toggle" onclick="togglePasswordVisibility()">Show Password</button>
|
||||
<button type="button" class="password-generate" onclick="generatePassword()">Generate Strong Password</button>
|
||||
</div>
|
||||
<div class="form-text">Use at least 8 characters with a mix of letters, numbers, and symbols</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
|
@ -291,8 +287,7 @@
|
|||
<label for="phone" class="input-label">Phone Number *</label>
|
||||
<div class="input-container">
|
||||
<input type="tel" id="phone" class="input-field" placeholder=" " required>
|
||||
<label for="phone" class="input-label" style="left: 40px;">Phone Number</label>
|
||||
<span style="position: absolute; left: 12px; top: 50%; transform: translateY(-50%);">+</span>
|
||||
<label for="phone" class="input-label">+ Phone Number</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -315,7 +310,6 @@
|
|||
<h2 class="form-title">Payment Details</h2>
|
||||
<form id="payment-form">
|
||||
<div class="form-group">
|
||||
<label class="input-label">Payment Method *</label>
|
||||
<div class="payment-option" onclick="selectPaymentMethod('payoneer')">
|
||||
<h4>Payoneer</h4>
|
||||
<p>Receive payments via Payoneer</p>
|
||||
|
|
|
|||
Loading…
Reference in New Issue