Compare commits

...

27 Commits

Author SHA1 Message Date
Chamika J
abc923a95e refactor(auth): simplify session middleware and remove debug endpoint
- Updated session middleware to remove debug logging and streamline cookie handling for mobile applications.
- Adjusted session cookie configuration to disable secure and domain settings for local development.
- Removed the debug endpoint from the authentication routes to reduce noise in the codebase.
2025-08-06 12:51:21 +05:30
Chamika J
097c281051 feat(auth): enhance session middleware logging and error handling
- Added detailed debug logging to the session middleware for improved visibility into request processing, including URL, method, and header information.
- Updated error handling to log session middleware errors and session ID status after processing.
- Ensured compatibility with mobile applications by refining cookie handling based on header values.
2025-08-06 12:40:11 +05:30
Chamika J
a1aaf9bd59 refactor(auth): remove debug logging from authentication methods
- Eliminated console logs from the `verify` and `googleMobileAuthPassport` methods to streamline the code and reduce noise in the logs.
- Updated session middleware to enhance cookie handling for mobile applications, ensuring proper session management without excessive logging.
- Improved session cookie configuration for production and development environments, maintaining compatibility with mobile app requirements.
2025-08-06 12:37:29 +05:30
Chamika J
66edec201f feat(auth): enhance session ID generation and improve cookie handling
- Replaced the `uid-safe` library with `crypto.randomBytes` for generating session IDs, improving security and randomness.
- Updated session cookie construction to use template literals for better readability.
- Standardized cookie header parsing to use consistent quotation marks, enhancing code clarity.
2025-08-06 12:28:57 +05:30
Chamika J
7ce4ba12ab feat(auth): add cookie-signature dependency and enhance session cookie handling
- Introduced the `@types/cookie-signature` dependency to facilitate proper signing of session cookies.
- Updated session middleware to create a securely signed cookie using the session secret, improving session management for mobile applications.
- Enhanced logging for cookie creation and error handling to aid in debugging session issues.
2025-08-06 11:55:43 +05:30
Chamika J
0959f3f926 feat(auth): enhance session middleware logging and error handling
- Improved logging within the session middleware to provide detailed insights into session ID, cookie headers, and authentication status.
- Added error handling for the session middleware to capture and log any issues during session processing.
- Ensured proper construction of session cookies for mobile applications, maintaining compatibility with existing cookies.
2025-08-06 11:33:14 +05:30
Chamika J
cc68a5e9cc feat(auth): improve session cookie handling and logging in middleware
- Enhanced session middleware to create or replace session cookies based on header values, ensuring proper session management for mobile applications.
- Added detailed logging for cookie headers and session ID usage to facilitate debugging and traceability.
- Updated logic to maintain existing cookies while injecting the session cookie, improving compatibility with other cookies.
2025-08-06 11:06:36 +05:30
Chamika J
3ebf262b8e feat(auth): enhance session handling for mobile compatibility
- Modified session management to allow the use of existing sessions for mobile applications, improving session continuity.
- Added detailed logging for session ID usage, response headers, and session save operations to aid in debugging.
- Updated session middleware to support header-based session IDs, ensuring proper handling when cookies are not available.
- Included additional session information in the response for mobile app integration, facilitating better session management.
2025-08-06 11:02:03 +05:30
Chamika J
57c71357da feat(auth): enhance session management and debugging capabilities
- Improved session regeneration process to enhance security against session fixation attacks.
- Added detailed logging for session regeneration errors and fallback mechanisms.
- Introduced a new debug endpoint to provide insights into session data, cookies, and authentication status for easier troubleshooting.
- Updated response structure to include session ID and cookie name for mobile app integration, ensuring proper session handling.
2025-08-06 10:57:27 +05:30
Chamika J
3cb44e8dc8 fix(auth): correct type assertion for session passport property
- Updated the session passport assignment to use a type assertion for better TypeScript compatibility.
- This change ensures that the session object is correctly recognized, improving type safety in the authentication process.
2025-08-06 10:48:52 +05:30
Chamika J
11a31e5a6d feat(auth): improve session regeneration and response handling in login process
- Enhanced session management by implementing session regeneration to prevent session fixation during login.
- Added detailed logging for session regeneration, save operations, and response headers to aid in debugging.
- Ensured the user is re-established in the new session and included session cookie details in the response for better traceability.
2025-08-06 10:47:33 +05:30
Chamika J
5b00d83847 feat(auth): enhance session handling and response logging in authentication
- Improved session management by ensuring the session is saved before sending the response in the `AuthController`.
- Added detailed logging for session save operations and included the session ID in the response for better debugging.
- Updated session middleware configuration to enhance security and support mobile applications, including adjustments to cookie settings based on the production environment.
2025-08-06 10:35:35 +05:30
Chamika J
8e5d55ce7d feat(auth): add detailed logging for authentication processes
- Introduced console logs in the `verify` and `googleMobileAuthPassport` methods to provide insights into session data, authentication status, and potential errors.
- Enhanced debugging capabilities by logging session IDs, user information, and response details during the authentication flow.
- This update aims to improve visibility and traceability of authentication events for better troubleshooting.
2025-08-06 10:28:28 +05:30
Chamika J
7bb020d448 feat(auth): implement mobile Google authentication using Passport strategy
- Added a new Passport strategy for mobile Google authentication.
- Introduced `googleMobileAuthPassport` method in `AuthController` to handle authentication flow.
- Updated routes to utilize the new Passport strategy for mobile sign-in.
- Added `passport-custom` dependency for custom authentication strategy.
- Updated `package.json` and `package-lock.json` to reflect new dependencies and version requirements.
2025-08-05 17:12:29 +05:30
Chamika J
84f96b7db2 Merge pull request #309 from shancds/upstreame-release-v2.1.4
Origin release v2.1.4
2025-08-05 16:49:37 +05:30
shancds
f87fba96d8 refactor(task-comments): update response structure for comment data 2025-08-05 09:20:14 +05:30
shancds
81d5c8559c feat(task-comments): enhance comment response with user avatar and attachments
- Added functionality to retrieve user avatar URL and comment details, including created_at timestamp.
- Implemented logic to fetch and format comment attachments for the response.
- Transformed the response structure to include avatar, attachments, and other relevant comment data.
2025-08-05 09:18:40 +05:30
shancds
945c52b770 Merge branch 'chore/added-google-login-from-mobile-app' of https://github.com/Worklenz/worklenz into upstreame-release-v2.1.4 2025-08-05 08:25:56 +05:30
Chamika J
f84d834295 feat(auth): add logging for token audience validation in Google authentication
- Introduced console logs in the `googleMobileAuth` method to display the token audience, allowed client IDs, and the status of relevant environment variables.
- This enhancement aids in debugging and ensures better visibility into the authentication process.
2025-08-04 17:02:48 +05:30
Chamika J
01ce34f3d8 feat(auth): enhance token audience validation for Google authentication
- Updated the `googleMobileAuth` method in `AuthController` to accept multiple client IDs (web, Android, iOS) for token audience validation.
- Improved error handling for invalid token audiences, ensuring a more flexible and robust authentication process.
2025-08-04 16:54:17 +05:30
Chamika J
210a9a7aba fix(labels-controller): update color validation to use WorklenzColorShades for label updates
- Modified color validation logic in `updateLabel` method to check against `WorklenzColorShades` instead of `WorklenzColorCodes`.
- Ensured that the color input is validated correctly during label updates, enhancing data integrity.
2025-08-04 15:28:31 +05:30
Chamika J
6e37208f62 fix(password-validator): adjust password length validation to include maximum length of 32 characters 2025-08-04 15:23:58 +05:30
Chamika J
8188b5c381 feat(auth): enhance Google authentication validation
- Added validation for token audience, issuer, and expiry in the `googleMobileAuth` method of `AuthController`.
- Improved error handling for invalid tokens and expired sessions, ensuring robust authentication flow.
2025-08-04 12:44:34 +05:30
Chamika J
0e21eacd52 feat(auth): implement mobile Google authentication endpoint
- Added `googleMobileAuth` method in `AuthController` to handle mobile Google sign-in.
- Validates ID token and checks for email verification before proceeding.
- Handles user registration and login, creating a session for authenticated users.
- Updated API routes to include the new mobile authentication endpoint.
2025-08-04 12:40:06 +05:30
Chamika J
136dac17fb feat(labels): implement label update functionality and enhance UI
- Added `updateLabel` method in `LabelsController` to handle label updates with validation for name and color.
- Updated API routes to include the new label update endpoint.
- Introduced `LabelsDrawer` component for editing labels, including a color picker and form validation.
- Enhanced localization files to support new UI strings for label management.
- Implemented a new `LabelsSettings` page to manage labels with search and edit capabilities.
- Improved color handling with a comprehensive color palette for better user experience.
2025-08-04 12:28:52 +05:30
Chamika J
884cb9c462 refactor(SettingsLayout): streamline layout and improve styling
- Removed unused imports and simplified margin handling in SettingsLayout.
- Updated layout classes for better responsiveness and visual consistency.
- Enhanced sidebar and outlet rendering with improved Flex component usage.
- Streamlined overall layout for a cleaner and more modern appearance.
2025-08-04 09:58:49 +05:30
Chamika J
d1bd36e0a4 refactor(AdminCenterLayout): simplify layout structure and improve styling
- Removed unused imports and simplified margin handling in AdminCenterLayout.
- Updated layout classes for better responsiveness and visual consistency.
- Enhanced sidebar and outlet rendering with improved Flex component usage.
- Streamlined overall layout for a cleaner and more modern appearance.
2025-08-04 09:55:56 +05:30
27 changed files with 1148 additions and 572 deletions

View File

@@ -33,7 +33,6 @@
"express-rate-limit": "^6.8.0",
"express-session": "^1.17.3",
"express-validator": "^6.15.0",
"grunt-cli": "^1.5.0",
"helmet": "^6.2.0",
"hpp": "^0.2.3",
"http-errors": "^2.0.0",
@@ -46,6 +45,7 @@
"morgan": "^1.10.0",
"nanoid": "^3.3.6",
"passport": "^0.7.0",
"passport-custom": "^1.1.1",
"passport-google-oauth2": "^0.2.0",
"passport-google-oauth20": "^2.0.0",
"passport-local": "^1.0.0",
@@ -73,6 +73,7 @@
"@types/compression": "^1.7.2",
"@types/connect-flash": "^0.0.37",
"@types/cookie-parser": "^1.4.3",
"@types/cookie-signature": "^1.1.2",
"@types/cron": "^2.0.1",
"@types/crypto-js": "^4.2.2",
"@types/csurf": "^1.11.2",
@@ -126,7 +127,7 @@
"typescript": "^4.9.5"
},
"engines": {
"node": ">=16.13.0",
"node": ">=20.0.0",
"npm": ">=8.11.0",
"yarn": "WARNING: Please use npm package manager instead of yarn"
}
@@ -5445,6 +5446,16 @@
"@types/express": "*"
}
},
"node_modules/@types/cookie-signature": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@types/cookie-signature/-/cookie-signature-1.1.2.tgz",
"integrity": "sha512-2OhrZV2LVnUAXklUFwuYUTokalh/dUb8rqt70OW6ByMSxYpauPZ+kfNLknX3aJyjY5iu8i3cUyoLZP9Fn37tTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
@@ -6455,30 +6466,12 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/array-each": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz",
"integrity": "sha512-zHjL5SZa68hkKHBFBK6DJCTtr9sfTCPCaph/L7tMSLcTFgy+zX7E+6q5UArbtOtMBCtxdICpfTCspRse+ywyXA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/array-slice": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz",
"integrity": "sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/array-union": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
@@ -6951,6 +6944,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -8056,15 +8050,6 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-file": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz",
"integrity": "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@@ -8924,18 +8909,6 @@
"node": ">=6"
}
},
"node_modules/expand-tilde": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
"integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==",
"license": "MIT",
"dependencies": {
"homedir-polyfill": "^1.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/expect": {
"version": "28.1.3",
"resolved": "https://registry.npmjs.org/expect/-/expect-28.1.3.tgz",
@@ -9088,12 +9061,6 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-csv": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz",
@@ -9222,6 +9189,7 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -9287,46 +9255,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/findup-sync": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-4.0.0.tgz",
"integrity": "sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ==",
"license": "MIT",
"dependencies": {
"detect-file": "^1.0.0",
"is-glob": "^4.0.0",
"micromatch": "^4.0.2",
"resolve-dir": "^1.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/fined": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/fined/-/fined-1.2.0.tgz",
"integrity": "sha512-ZYDqPLGxDkDhDZBjZBb+oD1+j0rA4E0pXY50eplAAOPg2N/gUBSSk5IM1/QhPfyVo19lJ+CvXpqfvk+b2p/8Ng==",
"license": "MIT",
"dependencies": {
"expand-tilde": "^2.0.2",
"is-plain-object": "^2.0.3",
"object.defaults": "^1.1.0",
"object.pick": "^1.2.0",
"parse-filepath": "^1.0.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/flagged-respawn": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.1.tgz",
"integrity": "sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/flat-cache": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
@@ -9427,27 +9355,6 @@
}
}
},
"node_modules/for-in": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
"integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/for-own": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz",
"integrity": "sha512-0OABksIGrxKK8K4kynWkQ7y1zounQxP+CWnyclVwj81KW3vlLlGUx57DKGcP/LH216GzqnstnPocF16Nxs0Ycg==",
"license": "MIT",
"dependencies": {
"for-in": "^1.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@@ -9845,48 +9752,6 @@
"node": ">= 0.10"
}
},
"node_modules/global-modules": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz",
"integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==",
"license": "MIT",
"dependencies": {
"global-prefix": "^1.0.1",
"is-windows": "^1.0.1",
"resolve-dir": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/global-prefix": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz",
"integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==",
"license": "MIT",
"dependencies": {
"expand-tilde": "^2.0.2",
"homedir-polyfill": "^1.0.1",
"ini": "^1.3.4",
"is-windows": "^1.0.1",
"which": "^1.2.14"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/global-prefix/node_modules/which": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"which": "bin/which"
}
},
"node_modules/globals": {
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
@@ -9943,34 +9808,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/grunt-cli": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/grunt-cli/-/grunt-cli-1.5.0.tgz",
"integrity": "sha512-rILKAFoU0dzlf22SUfDtq2R1fosChXXlJM5j7wI6uoW8gwmXDXzbUvirlKZSYCdXl3LXFbR+8xyS+WFo+b6vlA==",
"license": "MIT",
"dependencies": {
"grunt-known-options": "~2.0.0",
"interpret": "~1.1.0",
"liftup": "~3.0.1",
"nopt": "~5.0.0",
"v8flags": "^4.0.1"
},
"bin": {
"grunt": "bin/grunt"
},
"engines": {
"node": ">=10"
}
},
"node_modules/grunt-known-options": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/grunt-known-options/-/grunt-known-options-2.0.0.tgz",
"integrity": "sha512-GD7cTz0I4SAede1/+pAbmJRG44zFLPipVtdL9o3vqx9IEyb7b4/Y3s7r6ofI3CchR5GvYJ+8buCSioDv5dQLiA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -10042,18 +9879,6 @@
"dev": true,
"license": "https://www.highcharts.com/license"
},
"node_modules/homedir-polyfill": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz",
"integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==",
"license": "MIT",
"dependencies": {
"parse-passwd": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/hpp": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/hpp/-/hpp-0.2.3.tgz",
@@ -10263,12 +10088,6 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/interpret": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/interpret/-/interpret-1.1.0.tgz",
"integrity": "sha512-CLM8SNMDu7C5psFCn6Wg/tgpj/bKAg7hc2gWqcuR9OD5Ft9PhBpIu8PLicPeis+xDd6YX2ncI8MCA64I9tftIA==",
"license": "MIT"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -10278,19 +10097,6 @@
"node": ">= 0.10"
}
},
"node_modules/is-absolute": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz",
"integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==",
"license": "MIT",
"dependencies": {
"is-relative": "^1.0.0",
"is-windows": "^1.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@@ -10352,6 +10158,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -10380,6 +10187,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -10392,6 +10200,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -10407,18 +10216,6 @@
"node": ">=8"
}
},
"node_modules/is-plain-object": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
"integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
"license": "MIT",
"dependencies": {
"isobject": "^3.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-promise": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
@@ -10443,18 +10240,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-relative": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz",
"integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==",
"license": "MIT",
"dependencies": {
"is-unc-path": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
@@ -10467,27 +10252,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-unc-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz",
"integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==",
"license": "MIT",
"dependencies": {
"unc-path-regex": "^0.1.2"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-windows": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
"integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@@ -10498,17 +10262,9 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/isobject": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
"integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@@ -11526,15 +11282,6 @@
"json-buffer": "3.0.1"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -11626,25 +11373,6 @@
"immediate": "~3.0.5"
}
},
"node_modules/liftup": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/liftup/-/liftup-3.0.1.tgz",
"integrity": "sha512-yRHaiQDizWSzoXk3APcA71eOI/UuhEkNN9DiW2Tt44mhYzX4joFoCZlxsSOF7RyeLlfqzFLQI1ngFq3ggMPhOw==",
"license": "MIT",
"dependencies": {
"extend": "^3.0.2",
"findup-sync": "^4.0.0",
"fined": "^1.2.0",
"flagged-respawn": "^1.0.1",
"is-plain-object": "^2.0.4",
"object.map": "^1.0.1",
"rechoir": "^0.7.0",
"resolve": "^1.19.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -11883,18 +11611,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/make-iterator": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/make-iterator/-/make-iterator-1.0.1.tgz",
"integrity": "sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw==",
"license": "MIT",
"dependencies": {
"kind-of": "^6.0.2"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/makeerror": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
@@ -11905,15 +11621,6 @@
"tmpl": "1.0.5"
}
},
"node_modules/map-cache": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz",
"integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -11971,6 +11678,7 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@@ -12418,46 +12126,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object.defaults": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz",
"integrity": "sha512-c/K0mw/F11k4dEUBMW8naXUuBuhxRCfG7W+yFy8EcijU/rSmazOUd1XAEEe6bC0OuXY4HUKjTJv7xbxIMqdxrA==",
"license": "MIT",
"dependencies": {
"array-each": "^1.0.1",
"array-slice": "^1.0.0",
"for-own": "^1.0.0",
"isobject": "^3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object.map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz",
"integrity": "sha512-3+mAJu2PLfnSVGHwIWubpOFLscJANBKuB/6A4CxBstc4aqwQY0FWcsppuy4jU5GSB95yES5JHSI+33AWuS4k6w==",
"license": "MIT",
"dependencies": {
"for-own": "^1.0.0",
"make-iterator": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object.pick": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz",
"integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==",
"license": "MIT",
"dependencies": {
"isobject": "^3.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -12620,20 +12288,6 @@
"node": ">=6"
}
},
"node_modules/parse-filepath": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz",
"integrity": "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==",
"license": "MIT",
"dependencies": {
"is-absolute": "^1.0.0",
"map-cache": "^0.2.0",
"path-root": "^0.1.1"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@@ -12653,15 +12307,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse-passwd": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz",
"integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
@@ -12695,6 +12340,18 @@
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-custom": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz",
"integrity": "sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==",
"license": "MIT",
"dependencies": {
"passport-strategy": "1.x.x"
},
"engines": {
"node": ">= 0.10.0"
}
},
"node_modules/passport-google-oauth2": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/passport-google-oauth2/-/passport-google-oauth2-0.2.0.tgz",
@@ -12800,27 +12457,6 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT"
},
"node_modules/path-root": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz",
"integrity": "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==",
"license": "MIT",
"dependencies": {
"path-root-regex": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-root-regex": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz",
"integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-scurry": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
@@ -12968,6 +12604,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -13563,18 +13200,6 @@
"node": ">=8.10.0"
}
},
"node_modules/rechoir": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz",
"integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==",
"license": "MIT",
"dependencies": {
"resolve": "^1.9.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/redis": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
@@ -13726,19 +13351,6 @@
"node": ">=8"
}
},
"node_modules/resolve-dir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz",
"integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==",
"license": "MIT",
"dependencies": {
"expand-tilde": "^2.0.0",
"global-modules": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -14974,6 +14586,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@@ -15494,15 +15107,6 @@
"integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==",
"license": "MIT"
},
"node_modules/unc-path-regex": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz",
"integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
@@ -15732,15 +15336,6 @@
"node": ">=10.12.0"
}
},
"node_modules/v8flags": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/v8flags/-/v8flags-4.0.1.tgz",
"integrity": "sha512-fcRLaS4H/hrZk9hYwbdRM35D0U8IYMfEClhXxCivOojl+yTRAZH3Zy2sSy6qVCiGbV9YAtPssP6jaChqC9vPCg==",
"license": "MIT",
"engines": {
"node": ">= 10.13.0"
}
},
"node_modules/validator": {
"version": "13.15.15",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz",

View File

@@ -80,6 +80,7 @@
"morgan": "^1.10.0",
"nanoid": "^3.3.6",
"passport": "^0.7.0",
"passport-custom": "^1.1.1",
"passport-google-oauth2": "^0.2.0",
"passport-google-oauth20": "^2.0.0",
"passport-local": "^1.0.0",
@@ -107,6 +108,7 @@
"@types/compression": "^1.7.2",
"@types/connect-flash": "^0.0.37",
"@types/cookie-parser": "^1.4.3",
"@types/cookie-signature": "^1.1.2",
"@types/cron": "^2.0.1",
"@types/crypto-js": "^4.2.2",
"@types/csurf": "^1.11.2",

View File

@@ -1,4 +1,6 @@
import bcrypt from "bcrypt";
import passport from "passport";
import {NextFunction} from "express";
import {sendResetEmail, sendResetSuccessEmail} from "../shared/email-templates";
@@ -181,4 +183,162 @@ export default class AuthController extends WorklenzControllerBase {
res.status(500).send(new ServerResponse(false, null, DEFAULT_ERROR_MESSAGE));
}
}
public static googleMobileAuthPassport(req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction) {
const mobileOptions = {
session: true,
failureFlash: true,
failWithError: false
};
passport.authenticate("google-mobile", mobileOptions, (err: any, user: any, info: any) => {
if (err) {
return res.status(500).send({
done: false,
message: "Authentication failed",
body: null
});
}
if (!user) {
return res.status(400).send({
done: false,
message: info?.message || "Authentication failed",
body: null
});
}
// Log the user in (create session)
req.login(user, (loginErr) => {
if (loginErr) {
return res.status(500).send({
done: false,
message: "Session creation failed",
body: null
});
}
// Add build version
user.build_v = FileConstants.getRelease();
// Ensure session is saved and cookie is set
req.session.save((saveErr) => {
if (saveErr) {
return res.status(500).send({
done: false,
message: "Session save failed",
body: null
});
}
// Get session cookie details
const sessionName = process.env.SESSION_NAME || 'connect.sid';
// Return response with session info for mobile app to handle
res.setHeader('X-Session-ID', req.sessionID);
res.setHeader('X-Session-Name', sessionName);
return res.status(200).send({
done: true,
message: "Login successful",
user,
authenticated: true,
sessionId: req.sessionID,
sessionName: sessionName,
newSessionId: req.sessionID
});
});
}); // Close login callback
})(req, res, next);
}
@HandleExceptions({logWithError: "body"})
public static async googleMobileAuth(req: IWorkLenzRequest, res: IWorkLenzResponse) {
const {idToken} = req.body;
if (!idToken) {
return res.status(400).send(new ServerResponse(false, null, "ID token is required"));
}
try {
const response = await axios.get(`https://oauth2.googleapis.com/tokeninfo?id_token=${idToken}`);
const profile = response.data;
// Validate token audience (client ID) - accept web, Android, and iOS client IDs
const allowedClientIds = [
process.env.GOOGLE_CLIENT_ID, // Web client ID
process.env.GOOGLE_ANDROID_CLIENT_ID, // Android client ID
process.env.GOOGLE_IOS_CLIENT_ID, // iOS client ID
].filter(Boolean); // Remove undefined values
console.log("Token audience (aud):", profile.aud);
console.log("Allowed client IDs:", allowedClientIds);
console.log("Environment variables check:");
console.log("- GOOGLE_CLIENT_ID:", process.env.GOOGLE_CLIENT_ID ? "Set" : "Not set");
console.log("- GOOGLE_ANDROID_CLIENT_ID:", process.env.GOOGLE_ANDROID_CLIENT_ID ? "Set" : "Not set");
console.log("- GOOGLE_IOS_CLIENT_ID:", process.env.GOOGLE_IOS_CLIENT_ID ? "Set" : "Not set");
if (!allowedClientIds.includes(profile.aud)) {
return res.status(400).send(new ServerResponse(false, null, "Invalid token audience"));
}
// Validate token issuer
if (!["https://accounts.google.com", "accounts.google.com"].includes(profile.iss)) {
return res.status(400).send(new ServerResponse(false, null, "Invalid token issuer"));
}
// Check token expiry
if (Date.now() >= profile.exp * 1000) {
return res.status(400).send(new ServerResponse(false, null, "Token expired"));
}
if (!profile.email_verified) {
return res.status(400).send(new ServerResponse(false, null, "Email not verified"));
}
// Check for existing local account
const localAccountResult = await db.query("SELECT 1 FROM users WHERE email = $1 AND password IS NOT NULL AND is_deleted IS FALSE;", [profile.email]);
if (localAccountResult.rowCount) {
return res.status(400).send(new ServerResponse(false, null, `No Google account exists for email ${profile.email}.`));
}
// Check if user exists
const userResult = await db.query(
"SELECT id, google_id, name, email, active_team FROM users WHERE google_id = $1 OR email = $2;",
[profile.sub, profile.email]
);
let user: any;
if (userResult.rowCount) {
// Existing user - login
user = userResult.rows[0];
} else {
// New user - register
const googleUserData = {
id: profile.sub,
displayName: profile.name,
email: profile.email,
picture: profile.picture
};
const registerResult = await db.query("SELECT register_google_user($1) AS user;", [JSON.stringify(googleUserData)]);
user = registerResult.rows[0].user;
}
// Create session
req.login(user, (err) => {
if (err) {
log_error(err);
return res.status(500).send(new ServerResponse(false, null, "Authentication failed"));
}
user.build_v = FileConstants.getRelease();
return res.status(200).send(new AuthResponse("Login Successful!", true, user, null, "User successfully logged in"));
});
} catch (error) {
log_error(error);
return res.status(400).send(new ServerResponse(false, null, "Invalid ID token"));
}
}
}

View File

@@ -5,7 +5,7 @@ import db from "../config/db";
import {ServerResponse} from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import {TASK_PRIORITY_COLOR_ALPHA, WorklenzColorCodes} from "../shared/constants";
import {TASK_PRIORITY_COLOR_ALPHA, WorklenzColorCodes, WorklenzColorShades} from "../shared/constants";
export default class LabelsController extends WorklenzControllerBase {
@HandleExceptions()
@@ -73,13 +73,44 @@ export default class LabelsController extends WorklenzControllerBase {
WHERE id = $1
AND team_id = $2;`;
if (!WorklenzColorCodes.includes(req.body.color))
if (!Object.values(WorklenzColorShades).flat().includes(req.body.color))
return res.status(400).send(new ServerResponse(false, null));
const result = await db.query(q, [req.params.id, req.user?.team_id, req.body.color]);
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async updateLabel(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const updates = [];
const values = [req.params.id, req.user?.team_id];
let paramIndex = 3;
if (req.body.name) {
updates.push(`name = $${paramIndex++}`);
values.push(req.body.name);
}
if (req.body.color) {
if (!Object.values(WorklenzColorShades).flat().includes(req.body.color))
return res.status(400).send(new ServerResponse(false, null));
updates.push(`color_code = $${paramIndex++}`);
values.push(req.body.color);
}
if (updates.length === 0) {
return res.status(400).send(new ServerResponse(false, "No valid fields to update"));
}
const q = `UPDATE team_labels
SET ${updates.join(', ')}
WHERE id = $1
AND team_id = $2;`;
const result = await db.query(q, values);
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `DELETE

View File

@@ -217,7 +217,43 @@ export default class TaskCommentsController extends WorklenzControllerBase {
}
}
return res.status(200).send(new ServerResponse(true, data.comment));
// Get user avatar URL from database
const avatarQuery = `SELECT avatar_url FROM users WHERE id = $1`;
const avatarResult = await db.query(avatarQuery, [req.user?.id]);
const avatarUrl = avatarResult.rows[0]?.avatar_url || "";
// Get comment details including created_at
const commentQuery = `SELECT created_at FROM task_comments WHERE id = $1`;
const commentResult = await db.query(commentQuery, [response.id]);
const commentData = commentResult.rows[0];
// Get attachments if any
const attachmentsQuery = `SELECT id, name, type, size FROM task_comment_attachments WHERE comment_id = $1`;
const attachmentsResult = await db.query(attachmentsQuery, [response.id]);
const commentAttachments = attachmentsResult.rows.map(att => ({
id: att.id,
name: att.name,
type: att.type,
size: att.size
}));
const commentdata = {
attachments: commentAttachments,
avatar_url: avatarUrl,
content: req.body.content,
created_at: commentData?.created_at || new Date().toISOString(),
edit: false,
id: response.id,
member_name: req.user?.name || "",
mentions: mentions || [],
rawContent: req.body.content,
reactions: { likes: {} },
team_member_id: req.user?.team_member_id || "",
user_id: req.user?.id || ""
};
return res.status(200).send(new ServerResponse(true, commentdata));
}
@HandleExceptions()

View File

@@ -1,11 +1,13 @@
import session from "express-session";
import db from "../config/db";
import { isProduction } from "../shared/utils";
import * as cookieSignature from "cookie-signature";
import { randomBytes } from "crypto";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pgSession = require("connect-pg-simple")(session);
export default session({
const sessionConfig = {
name: process.env.SESSION_NAME,
secret: process.env.SESSION_SECRET || "development-secret-key",
proxy: false,
@@ -18,10 +20,56 @@ export default session({
}),
cookie: {
path: "/",
// secure: isProduction(),
// httpOnly: isProduction(),
// sameSite: "none",
// domain: isProduction() ? ".worklenz.com" : undefined,
httpOnly: true,
// For mobile app support in production, use "none", for local development use "lax"
sameSite: "lax" as const,
// Secure only in production (HTTPS required for sameSite: "none")
secure: false,
domain: undefined,
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
},
// Custom session ID handling for mobile apps
genid: () => {
return randomBytes(24).toString("base64url");
}
});
};
const sessionMiddleware = session(sessionConfig);
// Enhanced session middleware that supports both cookies and headers for mobile apps
export default (req: any, res: any, next: any) => {
// Check if mobile app is sending session ID via header (fallback for cookie issues)
const headerSessionId = req.headers["x-session-id"];
const headerSessionName = req.headers["x-session-name"];
// Only process headers if they exist AND there's no existing valid session cookie
if (headerSessionId && headerSessionName) {
const secret = process.env.SESSION_SECRET || "development-secret-key";
try {
// Create a signed cookie using the session secret
const signedSessionId = `s:${cookieSignature.sign(headerSessionId, secret)}`;
const encodedSignedId = encodeURIComponent(signedSessionId);
const sessionCookie = `${headerSessionName}=${encodedSignedId}`;
if (req.headers.cookie) {
// Replace existing session cookie while keeping other cookies
req.headers.cookie = req.headers.cookie
.split(";")
.filter((cookie: string) => !cookie.trim().startsWith(headerSessionName))
.concat(sessionCookie)
.join(";");
} else {
// Set the session cookie from header
req.headers.cookie = sessionCookie;
}
} catch (error) {
// Fallback to the old method
const sessionCookie = `${headerSessionName}=s%3A${headerSessionId}`;
req.headers.cookie = sessionCookie;
}
}
// Always call the original session middleware (handles both cookie and header-converted cases)
sessionMiddleware(req, res, next);
};

View File

@@ -8,7 +8,7 @@ import {PASSWORD_POLICY} from "../../shared/constants";
function isStrongPassword(password: string) {
if (!isProduction()) return true;
const strength = PasswordStrengthChecker.validate(password);
return strength.value >= 2 && strength.length < 32;
return strength.value >= 2 && strength.length <= 32;
}
export default function (req: Request, res: Response, next: NextFunction) {

View File

@@ -4,6 +4,7 @@ import {deserialize} from "./deserialize";
import {serialize} from "./serialize";
import GoogleLogin from "./passport-strategies/passport-google";
import GoogleMobileLogin from "./passport-strategies/passport-google-mobile";
import LocalLogin from "./passport-strategies/passport-local-login";
import LocalSignup from "./passport-strategies/passport-local-signup";
@@ -15,6 +16,7 @@ export default (passport: PassportStatic) => {
passport.use("local-login", LocalLogin);
passport.use("local-signup", LocalSignup);
passport.use(GoogleLogin);
passport.use("google-mobile", GoogleMobileLogin);
passport.serializeUser(serialize);
passport.deserializeUser(deserialize);
};

View File

@@ -0,0 +1,110 @@
import { Strategy as CustomStrategy } from "passport-custom";
import axios from "axios";
import { Request } from "express";
import db from "../../config/db";
import { log_error } from "../../shared/utils";
import { ERROR_KEY } from "./passport-constants";
interface GoogleTokenProfile {
sub: string;
email: string;
name: string;
picture: string;
email_verified: boolean;
aud: string;
iss: string;
exp: number;
}
async function handleMobileGoogleAuth(req: Request, done: any) {
try {
const { idToken } = req.body;
if (!idToken) {
return done(null, false, { message: "ID token is required" });
}
// Verify Google ID token
const response = await axios.get(
`https://oauth2.googleapis.com/tokeninfo?id_token=${idToken}`
);
const profile: GoogleTokenProfile = response.data;
// Validate token audience (client ID)
const allowedClientIds = [
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_ANDROID_CLIENT_ID,
process.env.GOOGLE_IOS_CLIENT_ID,
].filter(Boolean);
if (!allowedClientIds.includes(profile.aud)) {
return done(null, false, { message: "Invalid token audience" });
}
// Validate token issuer
if (
!["https://accounts.google.com", "accounts.google.com"].includes(
profile.iss
)
) {
return done(null, false, { message: "Invalid token issuer" });
}
// Check token expiry
if (Date.now() >= profile.exp * 1000) {
return done(null, false, { message: "Token expired" });
}
if (!profile.email_verified) {
return done(null, false, { message: "Email not verified" });
}
// Check for existing local account
const localAccountResult = await db.query(
"SELECT 1 FROM users WHERE email = $1 AND password IS NOT NULL AND is_deleted IS FALSE;",
[profile.email]
);
if (localAccountResult.rowCount) {
const message = `No Google account exists for email ${profile.email}.`;
return done(null, false, { message });
}
// Check if user exists
const userResult = await db.query(
"SELECT id, google_id, name, email, active_team FROM users WHERE google_id = $1 OR email = $2;",
[profile.sub, profile.email]
);
if (userResult.rowCount) {
// Existing user - login
const user = userResult.rows[0];
return done(null, user, { message: "User successfully logged in" });
}
// New user - register
const googleUserData = {
id: profile.sub,
displayName: profile.name,
email: profile.email,
picture: profile.picture,
};
const registerResult = await db.query(
"SELECT register_google_user($1) AS user;",
[JSON.stringify(googleUserData)]
);
const { user } = registerResult.rows[0];
return done(null, user, {
message: "User successfully registered and logged in",
});
} catch (error: any) {
log_error(error);
if (error.response?.status === 400) {
return done(null, false, { message: "Invalid ID token" });
}
return done(error);
}
}
export default new CustomStrategy(handleMobileGoogleAuth);

View File

@@ -11,6 +11,7 @@ labelsApiRouter.get("/", safeControllerFunction(LabelsController.get));
labelsApiRouter.get("/tasks/:id", idParamValidator, safeControllerFunction(LabelsController.getByTask));
labelsApiRouter.get("/project/:id", idParamValidator, safeControllerFunction(LabelsController.getByProject));
labelsApiRouter.put("/tasks/:id", idParamValidator, teamOwnerOrAdminValidator, safeControllerFunction(LabelsController.updateColor));
labelsApiRouter.put("/team/:id", idParamValidator, teamOwnerOrAdminValidator, safeControllerFunction(LabelsController.updateLabel));
labelsApiRouter.delete("/team/:id", idParamValidator, teamOwnerOrAdminValidator, safeControllerFunction(LabelsController.deleteById));
export default labelsApiRouter;

View File

@@ -8,6 +8,7 @@ import resetEmailValidator from "../../middlewares/validators/reset-email-valida
import updatePasswordValidator from "../../middlewares/validators/update-password-validator";
import passwordValidator from "../../middlewares/validators/password-validator";
import safeControllerFunction from "../../shared/safe-controller-function";
import FileConstants from "../../shared/file-constants";
const authRouter = express.Router();
@@ -55,6 +56,9 @@ authRouter.get("/google/verify", (req, res) => {
})(req, res);
});
// Mobile Google Sign-In using Passport strategy
authRouter.post("/google/mobile", AuthController.googleMobileAuthPassport);
// Passport logout
authRouter.get("/logout", AuthController.logout);

View File

@@ -6,7 +6,7 @@ export const DEFAULT_ERROR_MESSAGE = "Unknown error has occurred.";
export const SessionsStatus = {
IDLE: "IDLE",
STARTED: "STARTED",
ENDED: "ENDED"
ENDED: "ENDED",
};
export const LOG_DESCRIPTIONS = {
@@ -18,6 +18,33 @@ export const LOG_DESCRIPTIONS = {
PROJECT_MEMBER_REMOVED: "was removed from the project by",
};
export const WorklenzColorShades = {
"#154c9b": ["#0D2A50", "#112E54", "#153258", "#19365C", "#1D3A60", "#213E64", "#254268", "#29466C", "#2D4A70", "#314E74"],
"#3b7ad4": ["#224884", "#26528A", "#2A5C90", "#2E6696", "#32709C", "#367AA2", "#3A84A8", "#3E8EAE", "#4298B4", "#46A2BA"],
"#70a6f3": ["#3D5D8A", "#46679E", "#5071B2", "#597BC6", "#6385DA", "#6C8FEE", "#7699F2", "#7FA3F6", "#89ADFA", "#92B7FE"],
"#7781ca": ["#42486F", "#4C5283", "#565C97", "#6066AB", "#6A70BF", "#747AD3", "#7E84E7", "#888EFB", "#9298FF", "#9CA2FF"],
"#9877ca": ["#542D70", "#6E3A8A", "#8847A4", "#A254BE", "#BC61D8", "#D66EF2", "#E07BFC", "#EA88FF", "#F495FF", "#FEA2FF"],
"#c178c9": ["#6A2E6F", "#843B89", "#9E48A3", "#B855BD", "#D262D7", "#EC6FF1", "#F67CFB", "#FF89FF", "#FF96FF", "#FFA3FF"],
"#ee87c5": ["#832C6A", "#9D3984", "#B7469E", "#D153B8", "#EB60D2", "#FF6DEC", "#FF7AF6", "#FF87FF", "#FF94FF", "#FFA1FF"],
"#ca7881": ["#6F2C3E", "#893958", "#A34672", "#BD538C", "#D760A6", "#F16DC0", "#FB7ADA", "#FF87F4", "#FF94FF", "#FFA1FF"],
"#75c9c0": ["#3F6B66", "#497E7A", "#53918E", "#5DA4A2", "#67B7B6", "#71CBCA", "#7BDEDE", "#85F2F2", "#8FFFFF", "#99FFFF"],
"#75c997": ["#3F6B54", "#497E6A", "#53917F", "#5DA495", "#67B7AA", "#71CBBF", "#7BDED4", "#85F2E9", "#8FFFFF", "#99FFFF"],
"#80ca79": ["#456F3E", "#5A804D", "#6F935C", "#84A66B", "#99B97A", "#AECC89", "#C3DF98", "#D8F2A7", "#EDFFB6", "#FFFFC5"],
"#aacb78": ["#5F6F3E", "#7A804D", "#94935C", "#AFA66B", "#CAB97A", "#E5CC89", "#FFDF98", "#FFF2A7", "#FFFFB6", "#FFFFC5"],
"#cbbc78": ["#6F5D3E", "#8A704D", "#A4835C", "#BF966B", "#DAA97A", "#F5BC89", "#FFCF98", "#FFE2A7", "#FFF5B6", "#FFFFC5"],
"#cb9878": ["#704D3E", "#8B604D", "#A6735C", "#C1866B", "#DC997A", "#F7AC89", "#FFBF98", "#FFD2A7", "#FFE5B6", "#FFF8C5"],
"#bb774c": ["#653D27", "#80502C", "#9B6331", "#B67636", "#D1893B", "#EC9C40", "#FFAF45", "#FFC24A", "#FFD54F", "#FFE854"],
"#905b39": ["#4D2F1A", "#623C23", "#774A2C", "#8C5735", "#A1643E", "#B67147", "#CB7E50", "#E08B59", "#F59862", "#FFA56B"],
"#903737": ["#4D1A1A", "#622323", "#772C2C", "#8C3535", "#A13E3E", "#B64747", "#CB5050", "#E05959", "#F56262", "#FF6B6B"],
"#bf4949": ["#661212", "#801B1B", "#992424", "#B32D2D", "#CC3636", "#E63F3F", "#FF4848", "#FF5151", "#FF5A5A", "#FF6363"],
"#f37070": ["#853A3A", "#A04D4D", "#BA6060", "#D47373", "#EF8686", "#FF9999", "#FFA3A3", "#FFACAC", "#FFB6B6", "#FFBFBF"],
"#ff9c3c": ["#8F5614", "#AA6F1F", "#C48829", "#DFA233", "#F9BB3D", "#FFC04E", "#FFC75F", "#FFCE70", "#FFD581", "#FFDB92"],
"#fbc84c": ["#8F6D14", "#AA862F", "#C4A029", "#DFB933", "#F9D23D", "#FFD74E", "#FFDC5F", "#FFE170", "#FFE681", "#FFEB92"],
"#cbc8a1": ["#6F6D58", "#8A886F", "#A4A286", "#BFBC9D", "#DAD6B4", "#F5F0CB", "#FFFEDE", "#FFFFF2", "#FFFFCD", "#FFFFCD"],
"#a9a9a9": ["#5D5D5D", "#757575", "#8D8D8D", "#A5A5A5", "#BDBDBD", "#D5D5D5", "#EDEDED", "#F5F5F5", "#FFFFFF", "#FFFFFF"],
"#767676": ["#404040", "#4D4D4D", "#5A5A5A", "#676767", "#747474", "#818181", "#8E8E8E", "#9B9B9B", "#A8A8A8", "#B5B5B5"]
} as const;
export const WorklenzColorCodes = [
"#154c9b",
"#3b7ad4",
@@ -46,33 +73,33 @@ export const WorklenzColorCodes = [
];
export const AvatarNamesMap: { [x: string]: string } = {
"A": "#154c9b",
"B": "#3b7ad4",
"C": "#70a6f3",
"D": "#7781ca",
"E": "#9877ca",
"F": "#c178c9",
"G": "#ee87c5",
"H": "#ca7881",
"I": "#75c9c0",
"J": "#75c997",
"K": "#80ca79",
"L": "#aacb78",
"M": "#cbbc78",
"N": "#cb9878",
"O": "#bb774c",
"P": "#905b39",
"Q": "#903737",
"R": "#bf4949",
"S": "#f37070",
"T": "#ff9c3c",
"U": "#fbc84c",
"V": "#cbc8a1",
"W": "#a9a9a9",
"X": "#767676",
"Y": "#cb9878",
"Z": "#903737",
"+": "#9e9e9e"
A: "#154c9b",
B: "#3b7ad4",
C: "#70a6f3",
D: "#7781ca",
E: "#9877ca",
F: "#c178c9",
G: "#ee87c5",
H: "#ca7881",
I: "#75c9c0",
J: "#75c997",
K: "#80ca79",
L: "#aacb78",
M: "#cbbc78",
N: "#cb9878",
O: "#bb774c",
P: "#905b39",
Q: "#903737",
R: "#bf4949",
S: "#f37070",
T: "#ff9c3c",
U: "#fbc84c",
V: "#cbc8a1",
W: "#a9a9a9",
X: "#767676",
Y: "#cb9878",
Z: "#903737",
"+": "#9e9e9e",
};
export const NumbersColorMap: { [x: string]: string } = {
@@ -85,19 +112,19 @@ export const NumbersColorMap: { [x: string]: string } = {
"6": "#ee87c5",
"7": "#ca7881",
"8": "#75c9c0",
"9": "#75c997"
"9": "#75c997",
};
export const PriorityColorCodes: { [x: number]: string; } = {
export const PriorityColorCodes: { [x: number]: string } = {
0: "#2E8B57",
1: "#DAA520",
2: "#CD5C5C"
2: "#CD5C5C",
};
export const PriorityColorCodesDark: { [x: number]: string; } = {
export const PriorityColorCodesDark: { [x: number]: string } = {
0: "#3CB371",
1: "#B8860B",
2: "#F08080"
2: "#F08080",
};
export const TASK_STATUS_TODO_COLOR = "#a9a9a9";
@@ -113,7 +140,6 @@ export const TASK_DUE_UPCOMING_COLOR = "#70a6f3";
export const TASK_DUE_OVERDUE_COLOR = "#f37070";
export const TASK_DUE_NO_DUE_COLOR = "#a9a9a9";
export const DEFAULT_PAGE_SIZE = 20;
// S3 Credentials
@@ -125,7 +151,8 @@ export const S3_SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY || "";
// Azure Blob Storage Credentials
export const STORAGE_PROVIDER = process.env.STORAGE_PROVIDER || "s3";
export const AZURE_STORAGE_ACCOUNT_NAME = process.env.AZURE_STORAGE_ACCOUNT_NAME;
export const AZURE_STORAGE_ACCOUNT_NAME =
process.env.AZURE_STORAGE_ACCOUNT_NAME;
export const AZURE_STORAGE_CONTAINER = process.env.AZURE_STORAGE_CONTAINER;
export const AZURE_STORAGE_ACCOUNT_KEY = process.env.AZURE_STORAGE_ACCOUNT_KEY;
export const AZURE_STORAGE_URL = process.env.AZURE_STORAGE_URL;
@@ -150,12 +177,16 @@ export const TEAM_MEMBER_TREE_MAP_COLOR_ALPHA = "40";
// LICENSING SERVER URLS
export const LOCAL_URL = "http://localhost:3001";
export const UAT_SERVER_URL = process.env.UAT_SERVER_URL || "https://your-uat-server-url";
export const DEV_SERVER_URL = process.env.DEV_SERVER_URL || "https://your-dev-server-url";
export const PRODUCTION_SERVER_URL = process.env.PRODUCTION_SERVER_URL || "https://your-production-server-url";
export const UAT_SERVER_URL =
process.env.UAT_SERVER_URL || "https://your-uat-server-url";
export const DEV_SERVER_URL =
process.env.DEV_SERVER_URL || "https://your-dev-server-url";
export const PRODUCTION_SERVER_URL =
process.env.PRODUCTION_SERVER_URL || "https://your-production-server-url";
// *Sync with the client
export const PASSWORD_POLICY = "Minimum of 8 characters, with upper and lowercase and a number and a symbol.";
export const PASSWORD_POLICY =
"Minimum of 8 characters, with upper and lowercase and a number and a symbol.";
// paddle status to exclude
export const statusExclude = ["past_due", "paused", "deleted"];
@@ -172,5 +203,5 @@ export const DATE_RANGES = {
LAST_WEEK: "LAST_WEEK",
LAST_MONTH: "LAST_MONTH",
LAST_QUARTER: "LAST_QUARTER",
ALL_TIME: "ALL_TIME"
ALL_TIME: "ALL_TIME",
};

View File

@@ -7,5 +7,9 @@
"searchPlaceholder": "Kërko sipas emrit",
"emptyText": "Etiketat mund të krijohen gjatë përditësimit ose krijimit të detyrave.",
"pinTooltip": "Klikoni për ta fiksuar në menynë kryesore",
"colorChangeTooltip": "Klikoni për të ndryshuar ngjyrën"
"colorChangeTooltip": "Klikoni për të ndryshuar ngjyrën",
"pageTitle": "Menaxho Etiketat",
"deleteConfirmTitle": "Jeni i sigurt që dëshironi ta fshini këtë?",
"deleteButton": "Fshi",
"cancelButton": "Anulo"
}

View File

@@ -7,5 +7,9 @@
"searchPlaceholder": "Nach Name suchen",
"emptyText": "Labels können beim Aktualisieren oder Erstellen von Aufgaben erstellt werden.",
"pinTooltip": "Zum Anheften an das Hauptmenü klicken",
"colorChangeTooltip": "Zum Ändern der Farbe klicken"
"colorChangeTooltip": "Zum Ändern der Farbe klicken",
"pageTitle": "Labels verwalten",
"deleteConfirmTitle": "Sind Sie sicher, dass Sie dies löschen möchten?",
"deleteButton": "Löschen",
"cancelButton": "Abbrechen"
}

View File

@@ -7,5 +7,9 @@
"searchPlaceholder": "Search by name",
"emptyText": "Labels can be created while updating or creating tasks.",
"pinTooltip": "Click to pin this into the main menu",
"colorChangeTooltip": "Click to change color"
"colorChangeTooltip": "Click to change color",
"pageTitle": "Manage Labels",
"deleteConfirmTitle": "Are you sure you want to delete this?",
"deleteButton": "Delete",
"cancelButton": "Cancel"
}

View File

@@ -7,5 +7,9 @@
"searchPlaceholder": "Buscar por nombre",
"emptyText": "Las etiquetas se pueden crear al actualizar o crear tareas.",
"pinTooltip": "Haz clic para fijar esto en el menú principal",
"colorChangeTooltip": "Haz clic para cambiar el color"
"colorChangeTooltip": "Haz clic para cambiar el color",
"pageTitle": "Administrar Etiquetas",
"deleteConfirmTitle": "¿Estás seguro de que quieres eliminar esto?",
"deleteButton": "Eliminar",
"cancelButton": "Cancelar"
}

View File

@@ -7,5 +7,9 @@
"searchPlaceholder": "Pesquisar por nome",
"emptyText": "Os rótulos podem ser criados ao atualizar ou criar tarefas.",
"pinTooltip": "Clique para fixar isso no menu principal",
"colorChangeTooltip": "Clique para mudar a cor"
"colorChangeTooltip": "Clique para mudar a cor",
"pageTitle": "Gerenciar Rótulos",
"deleteConfirmTitle": "Tem certeza de que deseja excluir isto?",
"deleteButton": "Excluir",
"cancelButton": "Cancelar"
}

View File

@@ -7,5 +7,9 @@
"searchPlaceholder": "按名称搜索",
"emptyText": "标签可以在更新或创建任务时创建。",
"pinTooltip": "点击将其固定到主菜单",
"colorChangeTooltip": "点击更改颜色"
"colorChangeTooltip": "点击更改颜色",
"pageTitle": "管理标签",
"deleteConfirmTitle": "您确定要删除这个吗?",
"deleteButton": "删除",
"cancelButton": "取消"
}

View File

@@ -27,12 +27,17 @@ export const labelsApiService = {
updateColor: async (labelId: string, color: string): Promise<IServerResponse<ITaskLabel>> => {
const response = await apiClient.put<IServerResponse<ITaskLabel>>(
`${rootUrl}/tasks/${labelId}/color`,
`${rootUrl}/tasks/${labelId}`,
{ color }
);
return response.data;
},
updateLabel: async (labelId: string, data: { name?: string; color?: string }): Promise<IServerResponse<ITaskLabel>> => {
const response = await apiClient.put<IServerResponse<ITaskLabel>>(`${rootUrl}/team/${labelId}`, data);
return response.data;
},
deleteById: async (labelId: string): Promise<IServerResponse<void>> => {
const response = await apiClient.delete<IServerResponse<void>>(`${rootUrl}/team/${labelId}`);
return response.data;

View File

@@ -11,9 +11,7 @@ import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallba
const HomePage = lazy(() => import('@/pages/home/home-page'));
const ProjectList = lazy(() => import('@/pages/projects/project-list'));
const Schedule = lazy(() => import('@/pages/schedule/schedule'));
const ProjectTemplateEditView = lazy(
() => import('@/pages/settings/project-templates/projectTemplateEditView/ProjectTemplateEditView')
);
const LicenseExpired = lazy(() => import('@/pages/license-expired/license-expired'));
const ProjectView = lazy(() => import('@/pages/projects/projectView/project-view'));
const Unauthorized = lazy(() => import('@/pages/unauthorized/unauthorized'));
@@ -91,14 +89,6 @@ const mainRoutes: RouteObject[] = [
</Suspense>
),
},
{
path: `settings/project-templates/edit/:templateId/:templateName`,
element: (
<Suspense fallback={<SuspenseFallback />}>
<ProjectTemplateEditView />
</Suspense>
),
},
{
path: 'unauthorized',
element: (

View File

@@ -2,8 +2,10 @@ import { Tag, Typography } from '@/shared/antd-imports';
import { colors } from '@/styles/colors';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
import { ALPHA_CHANNEL } from '@/shared/constants';
import { useAppSelector } from '@/hooks/useAppSelector';
const CustomColorLabel = ({ label }: { label: ITaskLabel | null }) => {
const themeMode = useAppSelector(state => state.themeReducer.mode);
return (
<Tag
key={label?.id}
@@ -17,7 +19,7 @@ const CustomColorLabel = ({ label }: { label: ITaskLabel | null }) => {
fontSize: 11,
}}
>
<Typography.Text style={{ fontSize: 11, color: colors.darkGray }}>
<Typography.Text style={{ fontSize: 11, color: themeMode === 'dark' ? 'rgba(255, 255, 255, 0.85)' : colors.darkGray }}>
{label?.name}
</Typography.Text>
</Tag>

View File

@@ -4,40 +4,25 @@ import { Outlet } from 'react-router-dom';
import { useMediaQuery } from 'react-responsive';
import AdminCenterSidebar from '@/pages/admin-center/sidebar/sidebar';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
const AdminCenterLayout: React.FC = () => {
const dispatch = useAppDispatch();
const isTablet = useMediaQuery({ query: '(min-width:768px)' });
const isMarginAvailable = useMediaQuery({ query: '(min-width: 1000px)' });
const { t } = useTranslation('admin-center/sidebar');
return (
<div
style={{
marginBlock: 96,
minHeight: '90vh',
marginLeft: `${isMarginAvailable ? '5%' : ''}`,
marginRight: `${isMarginAvailable ? '5%' : ''}`,
}}
>
<div className="my-6">
<Typography.Title level={4}>{t('adminCenter')}</Typography.Title>
{isTablet ? (
<Flex
gap={24}
align="flex-start"
style={{
width: '100%',
marginBlockStart: 24,
}}
className="w-full mt-6"
>
<Flex style={{ width: '100%', maxWidth: 240 }}>
<Flex className="w-full max-w-60">
<AdminCenterSidebar />
</Flex>
<Flex style={{ width: '100%' }}>
<Flex className="w-full">
<Outlet />
</Flex>
</Flex>
@@ -45,9 +30,7 @@ const AdminCenterLayout: React.FC = () => {
<Flex
vertical
gap={24}
style={{
marginBlockStart: 24,
}}
className="mt-6"
>
<AdminCenterSidebar />
<Outlet />

View File

@@ -1,35 +1,25 @@
import { Flex, Typography } from '@/shared/antd-imports';
import SettingsSidebar from '../pages/settings/sidebar/settings-sidebar';
import { Outlet, useNavigate } from 'react-router-dom';
import { Outlet } from 'react-router-dom';
import { useMediaQuery } from 'react-responsive';
import { useEffect } from 'react';
import { useAuthService } from '@/hooks/useAuth';
const SettingsLayout = () => {
const isTablet = useMediaQuery({ query: '(min-width: 768px)' });
const { getCurrentSession } = useAuthService();
const currentSession = getCurrentSession();
const navigate = useNavigate();
return (
<div style={{ marginBlock: 96, minHeight: '90vh' }}>
<div className="my-6 min-h-[90vh]">
<Typography.Title level={4}>Settings</Typography.Title>
{isTablet ? (
<Flex
gap={24}
align="flex-start"
style={{
width: '100%',
marginBlockStart: 24,
}}
className="w-full mt-6"
>
<Flex style={{ width: '100%', maxWidth: 240 }}>
<Flex className="w-full max-w-60">
<SettingsSidebar />
</Flex>
<Flex style={{ width: '100%' }}>
<Flex className="w-full">
<Outlet />
</Flex>
</Flex>
@@ -37,9 +27,7 @@ const SettingsLayout = () => {
<Flex
vertical
gap={24}
style={{
marginBlockStart: 24,
}}
className="mt-6"
>
<SettingsSidebar />
<Outlet />

View File

@@ -18,7 +18,7 @@ const ProfileSettings = lazy(() => import('../../pages/settings/profile/profile-
const NotificationsSettings = lazy(() => import('../../pages/settings/notifications/notifications-settings'));
const ClientsSettings = lazy(() => import('../../pages/settings/clients/clients-settings'));
const JobTitlesSettings = lazy(() => import('@/pages/settings/job-titles/job-titles-settings'));
const LabelsSettings = lazy(() => import('../../pages/settings/labels/labels-settings'));
const LabelsSettings = lazy(() => import('../../pages/settings/labels/LabelsSettings'));
const CategoriesSettings = lazy(() => import('../../pages/settings/categories/categories-settings'));
const ProjectTemplatesSettings = lazy(() => import('@/pages/settings/project-templates/project-templates-settings'));
const TaskTemplatesSettings = lazy(() => import('@/pages/settings/task-templates/task-templates-settings'));

View File

@@ -8,22 +8,28 @@ import {
TableProps,
Tooltip,
Typography,
DeleteOutlined,
ExclamationCircleFilled,
SearchOutlined,
EditOutlined,
} from '@/shared/antd-imports';
import { useEffect, useMemo, useState } from 'react';
import PinRouteToNavbarButton from '../../../components/PinRouteToNavbarButton';
import PinRouteToNavbarButton from '@/components/PinRouteToNavbarButton';
import { useTranslation } from 'react-i18next';
import { DeleteOutlined, ExclamationCircleFilled, SearchOutlined } from '@/shared/antd-imports';
import { ITaskLabel } from '@/types/label.type';
import { labelsApiService } from '@/api/taskAttributes/labels/labels.api.service';
import CustomColorLabel from '@components/task-list-common/labelsSelector/custom-color-label';
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
import logger from '@/utils/errorLogger';
import LabelsDrawer from './labels-drawer';
const LabelsSettings = () => {
const { t } = useTranslation('settings/labels');
useDocumentTitle('Manage Labels');
useDocumentTitle(t('pageTitle', 'Manage Labels'));
const [selectedLabelId, setSelectedLabelId] = useState<string | null>(null);
const [showDrawer, setShowDrawer] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [labels, setLabels] = useState<ITaskLabel[]>([]);
const [loading, setLoading] = useState(false);
@@ -64,32 +70,62 @@ const LabelsSettings = () => {
}
};
const handleEditClick = (id: string) => {
setSelectedLabelId(id);
setShowDrawer(true);
};
const handleDrawerClose = () => {
setSelectedLabelId(null);
setShowDrawer(false);
getLabels();
};
// table columns
const columns: TableProps['columns'] = [
{
key: 'label',
title: t('labelColumn'),
title: t('labelColumn', 'Label'),
onCell: record => ({
onClick: () => handleEditClick(record.id!),
}),
render: (record: ITaskLabel) => <CustomColorLabel label={record} />,
},
{
key: 'associatedTask',
title: t('associatedTaskColumn'),
title: t('associatedTaskColumn', 'Associated Task Count'),
render: (record: ITaskLabel) => <Typography.Text>{record.usage}</Typography.Text>,
},
{
key: 'actionBtns',
width: 60,
width: 100,
render: (record: ITaskLabel) => (
<div className="action-button opacity-0 transition-opacity duration-200">
<Flex gap={4}>
<Tooltip title={t('editTooltip', 'Edit')}>
<Button
shape="default"
icon={<EditOutlined />}
size="small"
onClick={(e) => {
e.stopPropagation();
handleEditClick(record.id!);
}}
/>
</Tooltip>
<Popconfirm
title="Are you sure you want to delete this?"
title={t('deleteConfirmTitle', 'Are you sure you want to delete this?')}
icon={<ExclamationCircleFilled style={{ color: '#ff9800' }} />}
okText="Delete"
cancelText="Cancel"
okText={t('deleteButton', 'Delete')}
cancelText={t('cancelButton', 'Cancel')}
onConfirm={() => deleteLabel(record.id!)}
>
<Tooltip title={t('deleteTooltip', 'Delete')}>
<Button shape="default" icon={<DeleteOutlined />} size="small" />
</Tooltip>
</Popconfirm>
</Flex>
</div>
),
},
@@ -104,12 +140,12 @@ const LabelsSettings = () => {
<Input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder={t('searchPlaceholder')}
placeholder={t('searchPlaceholder', 'Search by name')}
style={{ maxWidth: 232 }}
suffix={<SearchOutlined />}
/>
<Tooltip title={t('pinTooltip')} trigger={'hover'}>
<Tooltip title={t('pinTooltip', 'Click to pin this into the main menu')} trigger={'hover'}>
{/* this button pin this route to navbar */}
<PinRouteToNavbarButton name="labels" path="/worklenz/settings/labels" />
</Tooltip>
@@ -119,13 +155,17 @@ const LabelsSettings = () => {
>
<Table
locale={{
emptyText: <Typography.Text>{t('emptyText')}</Typography.Text>,
emptyText: <Typography.Text>{t('emptyText', 'Labels can be created while updating or creating tasks.')}</Typography.Text>,
}}
loading={loading}
className="custom-two-colors-row-table"
dataSource={filteredData}
columns={columns}
rowKey={record => record.id!}
onRow={(record) => ({
style: { cursor: 'pointer' },
onClick: () => handleEditClick(record.id!),
})}
pagination={{
showSizeChanger: true,
defaultPageSize: 20,
@@ -133,6 +173,12 @@ const LabelsSettings = () => {
size: 'small',
}}
/>
<LabelsDrawer
drawerOpen={showDrawer}
labelId={selectedLabelId}
drawerClosed={handleDrawerClose}
/>
</Card>
);
};

View File

@@ -0,0 +1,228 @@
import { Button, Drawer, Form, Input, message, Typography, Flex, Dropdown } from '@/shared/antd-imports';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { theme } from 'antd';
import { labelsApiService } from '@/api/taskAttributes/labels/labels.api.service';
const WorklenzColorShades = {
"#154c9b": ["#0D2A50", "#112E54", "#153258", "#19365C", "#1D3A60", "#213E64", "#254268", "#29466C", "#2D4A70", "#314E74"],
"#3b7ad4": ["#224884", "#26528A", "#2A5C90", "#2E6696", "#32709C", "#367AA2", "#3A84A8", "#3E8EAE", "#4298B4", "#46A2BA"],
"#70a6f3": ["#3D5D8A", "#46679E", "#5071B2", "#597BC6", "#6385DA", "#6C8FEE", "#7699F2", "#7FA3F6", "#89ADFA", "#92B7FE"],
"#7781ca": ["#42486F", "#4C5283", "#565C97", "#6066AB", "#6A70BF", "#747AD3", "#7E84E7", "#888EFB", "#9298FF", "#9CA2FF"],
"#9877ca": ["#542D70", "#6E3A8A", "#8847A4", "#A254BE", "#BC61D8", "#D66EF2", "#E07BFC", "#EA88FF", "#F495FF", "#FEA2FF"],
"#c178c9": ["#6A2E6F", "#843B89", "#9E48A3", "#B855BD", "#D262D7", "#EC6FF1", "#F67CFB", "#FF89FF", "#FF96FF", "#FFA3FF"],
"#ee87c5": ["#832C6A", "#9D3984", "#B7469E", "#D153B8", "#EB60D2", "#FF6DEC", "#FF7AF6", "#FF87FF", "#FF94FF", "#FFA1FF"],
"#ca7881": ["#6F2C3E", "#893958", "#A34672", "#BD538C", "#D760A6", "#F16DC0", "#FB7ADA", "#FF87F4", "#FF94FF", "#FFA1FF"],
"#75c9c0": ["#3F6B66", "#497E7A", "#53918E", "#5DA4A2", "#67B7B6", "#71CBCA", "#7BDEDE", "#85F2F2", "#8FFFFF", "#99FFFF"],
"#75c997": ["#3F6B54", "#497E6A", "#53917F", "#5DA495", "#67B7AA", "#71CBBF", "#7BDED4", "#85F2E9", "#8FFFFF", "#99FFFF"],
"#80ca79": ["#456F3E", "#5A804D", "#6F935C", "#84A66B", "#99B97A", "#AECC89", "#C3DF98", "#D8F2A7", "#EDFFB6", "#FFFFC5"],
"#aacb78": ["#5F6F3E", "#7A804D", "#94935C", "#AFA66B", "#CAB97A", "#E5CC89", "#FFDF98", "#FFF2A7", "#FFFFB6", "#FFFFC5"],
"#cbbc78": ["#6F5D3E", "#8A704D", "#A4835C", "#BF966B", "#DAA97A", "#F5BC89", "#FFCF98", "#FFE2A7", "#FFF5B6", "#FFFFC5"],
"#cb9878": ["#704D3E", "#8B604D", "#A6735C", "#C1866B", "#DC997A", "#F7AC89", "#FFBF98", "#FFD2A7", "#FFE5B6", "#FFF8C5"],
"#bb774c": ["#653D27", "#80502C", "#9B6331", "#B67636", "#D1893B", "#EC9C40", "#FFAF45", "#FFC24A", "#FFD54F", "#FFE854"],
"#905b39": ["#4D2F1A", "#623C23", "#774A2C", "#8C5735", "#A1643E", "#B67147", "#CB7E50", "#E08B59", "#F59862", "#FFA56B"],
"#903737": ["#4D1A1A", "#622323", "#772C2C", "#8C3535", "#A13E3E", "#B64747", "#CB5050", "#E05959", "#F56262", "#FF6B6B"],
"#bf4949": ["#661212", "#801B1B", "#992424", "#B32D2D", "#CC3636", "#E63F3F", "#FF4848", "#FF5151", "#FF5A5A", "#FF6363"],
"#f37070": ["#853A3A", "#A04D4D", "#BA6060", "#D47373", "#EF8686", "#FF9999", "#FFA3A3", "#FFACAC", "#FFB6B6", "#FFBFBF"],
"#ff9c3c": ["#8F5614", "#AA6F1F", "#C48829", "#DFA233", "#F9BB3D", "#FFC04E", "#FFC75F", "#FFCE70", "#FFD581", "#FFDB92"],
"#fbc84c": ["#8F6D14", "#AA862F", "#C4A029", "#DFB933", "#F9D23D", "#FFD74E", "#FFDC5F", "#FFE170", "#FFE681", "#FFEB92"],
"#cbc8a1": ["#6F6D58", "#8A886F", "#A4A286", "#BFBC9D", "#DAD6B4", "#F5F0CB", "#FFFEDE", "#FFFFF2", "#FFFFCD", "#FFFFCD"],
"#a9a9a9": ["#5D5D5D", "#757575", "#8D8D8D", "#A5A5A5", "#BDBDBD", "#D5D5D5", "#EDEDED", "#F5F5F5", "#FFFFFF", "#FFFFFF"],
"#767676": ["#404040", "#4D4D4D", "#5A5A5A", "#676767", "#747474", "#818181", "#8E8E8E", "#9B9B9B", "#A8A8A8", "#B5B5B5"]
} as const;
// Flatten the color shades into a single array for the color picker
const WorklenzColorCodes = Object.values(WorklenzColorShades).flat();
type LabelsDrawerProps = {
drawerOpen: boolean;
labelId: string | null;
drawerClosed: () => void;
};
const LabelsDrawer = ({
drawerOpen = false,
labelId = null,
drawerClosed,
}: LabelsDrawerProps) => {
const { t } = useTranslation('settings/labels');
const { token } = theme.useToken();
const [form] = Form.useForm();
useEffect(() => {
if (labelId) {
getLabelById(labelId);
} else {
form.resetFields();
form.setFieldsValue({ color_code: Object.keys(WorklenzColorShades)[0] }); // Set default color
}
}, [labelId, form]);
const getLabelById = async (id: string) => {
try {
const response = await labelsApiService.getLabels();
if (response.done) {
const label = response.body.find((l: any) => l.id === id);
if (label) {
form.setFieldsValue({
name: label.name,
color_code: label.color_code
});
}
}
} catch (error) {
message.error(t('fetchLabelErrorMessage', 'Failed to fetch label'));
}
};
const handleFormSubmit = async (values: { name: string; color_code: string }) => {
try {
if (labelId) {
const response = await labelsApiService.updateLabel(labelId, {
name: values.name,
color: values.color_code,
});
if (response.done) {
message.success(t('updateLabelSuccessMessage', 'Label updated successfully'));
drawerClosed();
}
} else {
// For creating new labels, we'd need a create API endpoint
message.info(t('createNotSupported', 'Creating new labels is done through tasks'));
drawerClosed();
}
} catch (error) {
message.error(labelId ? t('updateLabelErrorMessage', 'Failed to update label') : t('createLabelErrorMessage', 'Failed to create label'));
}
};
const handleClose = () => {
form.resetFields();
drawerClosed();
};
const ColorPicker = ({ value, onChange }: { value?: string; onChange?: (color: string) => void }) => (
<Dropdown
dropdownRender={() => (
<div style={{
padding: 16,
backgroundColor: token.colorBgElevated,
borderRadius: token.borderRadius,
boxShadow: token.boxShadowSecondary,
border: `1px solid ${token.colorBorder}`,
width: 400,
maxHeight: 500,
overflowY: 'auto'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(10, 1fr)',
gap: 6,
justifyItems: 'center'
}}>
{WorklenzColorCodes.map((color) => (
<div
key={color}
style={{
width: 18,
height: 18,
backgroundColor: color,
borderRadius: 2,
border: value === color ? `2px solid ${token.colorPrimary}` : `1px solid ${token.colorBorder}`,
cursor: 'pointer',
transition: 'all 0.2s ease',
flexShrink: 0
}}
onClick={() => onChange?.(color)}
onMouseEnter={(e) => {
if (value !== color) {
e.currentTarget.style.transform = 'scale(1.2)';
e.currentTarget.style.boxShadow = token.boxShadow;
e.currentTarget.style.zIndex = '10';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.zIndex = '1';
}}
/>
))}
</div>
</div>
)}
trigger={['click']}
>
<div
style={{
width: 40,
height: 40,
backgroundColor: value || Object.keys(WorklenzColorShades)[0],
borderRadius: 4,
border: `1px solid ${token.colorBorder}`,
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = token.boxShadow;
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = 'none';
}}
/>
</Dropdown>
);
return (
<Drawer
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{labelId ? t('updateLabelDrawerTitle', 'Edit Label') : t('createLabelDrawerTitle', 'Create Label')}
</Typography.Text>
}
open={drawerOpen}
onClose={handleClose}
destroyOnClose
width={400}
>
<Form form={form} layout="vertical" onFinish={handleFormSubmit}>
<Form.Item
name="name"
label={t('nameLabel', 'Name')}
rules={[
{
required: true,
message: t('nameRequiredMessage', 'Please enter a label name'),
},
]}
>
<Input placeholder={t('namePlaceholder', 'Enter label name')} />
</Form.Item>
<Form.Item
name="color_code"
label={t('colorLabel', 'Color')}
rules={[
{
required: true,
message: t('colorRequiredMessage', 'Please select a color'),
},
]}
>
<ColorPicker />
</Form.Item>
<Flex justify="end" gap={8}>
<Button onClick={handleClose}>
{t('cancelButton', 'Cancel')}
</Button>
<Button type="primary" htmlType="submit">
{labelId ? t('updateButton', 'Update') : t('createButton', 'Create')}
</Button>
</Flex>
</Form>
</Drawer>
);
};
export default LabelsDrawer;

View File

@@ -313,3 +313,293 @@ export const durations: IRPTDuration[] = [
dates: '',
},
];
export const WorklenzColorCodes = [
// Row 1: Slate/Gray spectrum
'#0f172a',
'#1e293b',
'#334155',
'#475569',
'#64748b',
'#94a3b8',
'#cbd5e1',
'#e2e8f0',
'#f1f5f9',
'#f8fafc',
'#ffffff',
'#000000',
'#1a1a1a',
'#2d2d30',
'#3e3e42',
'#525252',
// Row 2: Blue spectrum - dark to light
'#0c4a6e',
'#075985',
'#0369a1',
'#0284c7',
'#0ea5e9',
'#38bdf8',
'#7dd3fc',
'#bae6fd',
'#e0f2fe',
'#f0f9ff',
'#1e3a8a',
'#1d4ed8',
'#2563eb',
'#3b82f6',
'#60a5fa',
'#93c5fd',
// Row 3: Indigo/Violet spectrum
'#312e81',
'#3730a3',
'#4338ca',
'#4f46e5',
'#6366f1',
'#818cf8',
'#a5b4fc',
'#c7d2fe',
'#e0e7ff',
'#eef2ff',
'#581c87',
'#6b21a8',
'#7c3aed',
'#8b5cf6',
'#a78bfa',
'#c4b5fd',
// Row 4: Purple/Fuchsia spectrum
'#701a75',
'#86198f',
'#a21caf',
'#c026d3',
'#d946ef',
'#e879f9',
'#f0abfc',
'#f3e8ff',
'#faf5ff',
'#fdf4ff',
'#831843',
'#be185d',
'#e11d48',
'#f43f5e',
'#fb7185',
'#fda4af',
// Row 5: Pink/Rose spectrum
'#9f1239',
'#be123c',
'#e11d48',
'#f43f5e',
'#fb7185',
'#fda4af',
'#fecdd3',
'#fed7d7',
'#fef2f2',
'#fff1f2',
'#450a0a',
'#7f1d1d',
'#991b1b',
'#dc2626',
'#ef4444',
'#f87171',
// Row 6: Red spectrum
'#7f1d1d',
'#991b1b',
'#dc2626',
'#ef4444',
'#f87171',
'#fca5a5',
'#fecaca',
'#fef2f2',
'#fffbeb',
'#fefce8',
'#92400e',
'#a16207',
'#ca8a04',
'#eab308',
'#facc15',
'#fef08a',
// Row 7: Orange spectrum
'#9a3412',
'#c2410c',
'#ea580c',
'#f97316',
'#fb923c',
'#fdba74',
'#fed7aa',
'#ffedd5',
'#fff7ed',
'#fffbeb',
'#78350f',
'#92400e',
'#c2410c',
'#ea580c',
'#f97316',
'#fb923c',
// Row 8: Amber/Yellow spectrum
'#451a03',
'#78350f',
'#92400e',
'#a16207',
'#ca8a04',
'#eab308',
'#facc15',
'#fef08a',
'#fefce8',
'#fffbeb',
'#365314',
'#4d7c0f',
'#65a30d',
'#84cc16',
'#a3e635',
'#bef264',
// Row 9: Lime/Green spectrum
'#1a2e05',
'#365314',
'#4d7c0f',
'#65a30d',
'#84cc16',
'#a3e635',
'#bef264',
'#d9f99d',
'#ecfccb',
'#f7fee7',
'#14532d',
'#166534',
'#15803d',
'#16a34a',
'#22c55e',
'#4ade80',
// Row 10: Emerald spectrum
'#064e3b',
'#065f46',
'#047857',
'#059669',
'#10b981',
'#34d399',
'#6ee7b7',
'#a7f3d0',
'#d1fae5',
'#ecfdf5',
'#0f766e',
'#0d9488',
'#14b8a6',
'#2dd4bf',
'#5eead4',
'#99f6e4',
// Row 11: Teal/Cyan spectrum
'#134e4a',
'#155e75',
'#0891b2',
'#0e7490',
'#0284c7',
'#0ea5e9',
'#22d3ee',
'#67e8f9',
'#a5f3fc',
'#cffafe',
'#164e63',
'#0c4a6e',
'#075985',
'#0369a1',
'#0284c7',
'#0ea5e9',
// Row 12: Sky spectrum
'#0c4a6e',
'#075985',
'#0369a1',
'#0284c7',
'#0ea5e9',
'#38bdf8',
'#7dd3fc',
'#bae6fd',
'#e0f2fe',
'#f0f9ff',
'#1e40af',
'#1d4ed8',
'#2563eb',
'#3b82f6',
'#60a5fa',
'#93c5fd',
// Row 13: Warm grays and browns
'#292524',
'#44403c',
'#57534e',
'#78716c',
'#a8a29e',
'#d6d3d1',
'#e7e5e4',
'#f5f5f4',
'#fafaf9',
'#ffffff',
'#7c2d12',
'#9a3412',
'#c2410c',
'#ea580c',
'#f97316',
'#fb923c',
// Row 14: Cool grays
'#111827',
'#1f2937',
'#374151',
'#4b5563',
'#6b7280',
'#9ca3af',
'#d1d5db',
'#e5e7eb',
'#f3f4f6',
'#f9fafb',
'#030712',
'#0c0a09',
'#1c1917',
'#292524',
'#44403c',
'#57534e',
// Row 15: Neutral spectrum
'#171717',
'#262626',
'#404040',
'#525252',
'#737373',
'#a3a3a3',
'#d4d4d4',
'#e5e5e5',
'#f5f5f5',
'#fafafa',
'#09090b',
'#18181b',
'#27272a',
'#3f3f46',
'#52525b',
'#71717a',
// Row 16: Extended colors
'#a1a1aa',
'#d4d4d8',
'#e4e4e7',
'#f4f4f5',
'#fafafa',
'#27272a',
'#3f3f46',
'#52525b',
'#71717a',
'#a1a1aa',
'#d4d4d8',
'#e4e4e7',
'#f4f4f5',
'#fafafa',
'#ffffff',
'#000000',
];