From 5b3724c31574c5ce1ff35047108b5197eb392856 Mon Sep 17 00:00:00 2001 From: Hassan Oladipupo <109126045+Hassan-oladipupo@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:05:47 +0100 Subject: [PATCH] `feat(auth): add 2FA auth flow, members scaffolding, and notifications history` If you want a slightly more complete one: `feat: add notifications history page, 2FA backend wiring, and member management scaffolding` --- backend/package-lock.json | 311 ++++++++++++++++-- backend/package.json | 3 + backend/src/auth/auth.controller.ts | 52 +++ backend/src/auth/auth.module.ts | 6 + backend/src/auth/auth.service.ts | 40 +++ backend/src/auth/dto/disable-2fa.dto.ts | 8 + backend/src/auth/dto/setup-2fa.dto.ts | 7 + backend/src/auth/dto/use-backup-code.dto.ts | 11 + backend/src/auth/dto/verify-totp.dto.ts | 11 + backend/src/auth/helper/jwt-helper.ts | 31 ++ .../src/auth/providers/manageTotp.provider.ts | 60 ++++ .../src/auth/providers/setupTotp.provider.ts | 78 +++++ .../src/auth/providers/verifyTotp.provider.ts | 92 ++++++ .../notifications/notifications.controller.ts | 15 + .../notifications/notifications.service.ts | 4 + .../mark-notification-read.provider.ts | 17 + backend/src/users/dto/member-query.dto.ts | 38 +++ .../src/users/dto/update-member-status.dto.ts | 10 + backend/src/users/entities/user.entity.ts | 16 + .../src/users/enums/membership-status.enum.ts | 5 + backend/src/users/members.controller.ts | 7 + backend/src/users/users.module.ts | 3 +- frontend/app/admin/bookings/page.tsx | 186 ++++++----- frontend/app/notifications/page.tsx | 256 ++++++++++++++ .../components/workspaces/WorkspaceCard.tsx | 60 ++++ frontend/hooks/invoices/useGetMyInvoices.ts | 4 +- frontend/lib/hooks/useGetAllBookings.ts | 100 ++++++ .../hooks/bookings/usePriceEstimate.ts | 3 +- frontend/lib/react-query/hooks/index.ts | 9 +- .../hooks/invoices/useGetInvoice.ts | 1 + .../hooks/invoices/useGetMyInvoices.ts | 2 + .../notifications/useGetNotifications.ts | 19 ++ .../hooks/notifications/useMarkAllRead.ts | 24 ++ .../notifications/useMarkNotificationRead.ts | 25 ++ .../hooks/workspaces/useGetWorkspaces.ts | 41 +++ frontend/lib/react-query/keys/queryKeys.ts | 54 ++- frontend/lib/types/booking.ts | 36 ++ frontend/lib/types/invoice.ts | 38 +++ frontend/lib/types/notification.ts | 23 ++ frontend/middleware.ts | 1 + frontend/package-lock.json | 18 +- 41 files changed, 1570 insertions(+), 155 deletions(-) create mode 100644 backend/src/auth/dto/disable-2fa.dto.ts create mode 100644 backend/src/auth/dto/setup-2fa.dto.ts create mode 100644 backend/src/auth/dto/use-backup-code.dto.ts create mode 100644 backend/src/auth/dto/verify-totp.dto.ts create mode 100644 backend/src/auth/providers/manageTotp.provider.ts create mode 100644 backend/src/auth/providers/setupTotp.provider.ts create mode 100644 backend/src/auth/providers/verifyTotp.provider.ts create mode 100644 backend/src/users/dto/member-query.dto.ts create mode 100644 backend/src/users/dto/update-member-status.dto.ts create mode 100644 backend/src/users/enums/membership-status.enum.ts create mode 100644 backend/src/users/members.controller.ts create mode 100644 frontend/app/notifications/page.tsx create mode 100644 frontend/components/workspaces/WorkspaceCard.tsx create mode 100644 frontend/lib/hooks/useGetAllBookings.ts create mode 100644 frontend/lib/react-query/hooks/invoices/useGetInvoice.ts create mode 100644 frontend/lib/react-query/hooks/invoices/useGetMyInvoices.ts create mode 100644 frontend/lib/react-query/hooks/notifications/useGetNotifications.ts create mode 100644 frontend/lib/react-query/hooks/notifications/useMarkAllRead.ts create mode 100644 frontend/lib/react-query/hooks/notifications/useMarkNotificationRead.ts create mode 100644 frontend/lib/react-query/hooks/workspaces/useGetWorkspaces.ts create mode 100644 frontend/lib/types/booking.ts create mode 100644 frontend/lib/types/invoice.ts create mode 100644 frontend/lib/types/notification.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index efb4abf6..db24452c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -53,11 +53,13 @@ "nestjs-i18n": "^10.5.1", "nodemailer": "^7.0.12", "nodemailer-mjml": "^1.6.0", + "otplib": "^13.4.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "pdfkit": "^0.17.2", "pg": "^8.16.3", + "qrcode": "^1.5.4", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "sharp": "^0.34.5", @@ -75,6 +77,7 @@ "@types/nodemailer": "^7.0.5", "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", + "@types/qrcode": "^1.5.6", "@types/supertest": "^6.0.0", "@types/swagger-ui-express": "^4.1.8", "@typescript-eslint/eslint-plugin": "^8.0.0", @@ -3761,7 +3764,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.22.tgz", "integrity": "sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "20.4.1", "iterare": "1.2.1", @@ -3808,7 +3810,6 @@ "integrity": "sha512-6IX9+VwjiKtCjx+mXVPncpkQ5ZjKfmssOZPFexmT+6T9H9wZ3svpYACAo7+9e7Nr9DZSoRZw3pffkJP7Z0UjaA==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -3889,7 +3890,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz", "integrity": "sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==", "license": "MIT", - "peer": true, "dependencies": { "body-parser": "1.20.4", "cors": "2.8.5", @@ -4293,6 +4293,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -4333,6 +4334,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -4347,6 +4349,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=8.6" }, @@ -4530,6 +4533,74 @@ "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", "license": "MIT" }, + "node_modules/@otplib/core": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.0.tgz", + "integrity": "sha512-JqOGcvZQi2wIkEQo8f3/iAjstavpXy6gouIDMHygjNuH6Q0FjbHOiXMdcE94RwfgDNMABhzwUmvaPsxvgm9NYw==", + "license": "MIT" + }, + "node_modules/@otplib/hotp": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.0.tgz", + "integrity": "sha512-MJjE0x06mn2ptymz5qZmQveb+vWFuaIftqE0b5/TZZqUOK7l97cV8lRTmid5BpAQMwJDNLW6RnYxGeCRiNdekw==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@otplib/uri": "13.4.0" + } + }, + "node_modules/@otplib/plugin-base32-scure": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.0.tgz", + "integrity": "sha512-/t9YWJmMbB8bF5z8mXrBZc2FXBe8B/3hG5FhWr9K8cFwFhyxScbPysmZe8s1UTzSA6N+s8Uv8aIfCtVXPNjJWw==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@scure/base": "^2.0.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.0.tgz", + "integrity": "sha512-KrvE4m7Zv+TT1944HzgqFJWJpKb6AyoxDbvhPStmBqdMlv5Gekb80d66cuFRL08kkPgJ5gXUSb5SFpYeB+bACg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1", + "@otplib/core": "13.4.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@otplib/totp": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.0.tgz", + "integrity": "sha512-dK+vl0f0ekzf6mCENRI9AKS2NJUC7OjI3+X8e7QSnhQ2WM7I+i4PGpb3QxKi5hxjTtwVuoZwXR2CFtXdcRtNdQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@otplib/hotp": "13.4.0", + "@otplib/uri": "13.4.0" + } + }, + "node_modules/@otplib/uri": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.0.tgz", + "integrity": "sha512-x1ozBa5bPbdZCrrTL/HK21qchiK7jYElTu+0ft22abeEhiLYgH1+SIULvOcVk3CK8YwF4kdcidvkq4ciejucJA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -4563,6 +4634,15 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sendgrid/client": { "version": "8.1.6", "resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-8.1.6.tgz", @@ -5528,7 +5608,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -5555,7 +5634,6 @@ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -5693,7 +5771,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5752,6 +5829,16 @@ "@types/passport": "*" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -5883,7 +5970,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -6280,7 +6366,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6328,7 +6413,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -6668,7 +6752,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", @@ -7022,7 +7105,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7127,7 +7209,6 @@ "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz", "integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==", "license": "MIT", - "peer": true, "dependencies": { "cron-parser": "^4.9.0", "get-port": "^5.1.1", @@ -7166,7 +7247,6 @@ "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.8.tgz", "integrity": "sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==", "license": "MIT", - "peer": true, "dependencies": { "@cacheable/utils": "^2.3.3", "keyv": "^5.5.5" @@ -7289,7 +7369,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7402,7 +7481,6 @@ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -7458,15 +7536,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.14.4", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -8002,6 +8078,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dedent": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", @@ -8169,6 +8254,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -8588,7 +8679,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -8645,7 +8735,6 @@ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -10472,7 +10561,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -11538,7 +11626,6 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -13000,7 +13087,6 @@ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", "license": "MIT-0", - "peer": true, "engines": { "node": ">=6.0.0" } @@ -13171,6 +13257,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/otplib": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.0.tgz", + "integrity": "sha512-RUcYcRMCgRWhUE/XabRppXpUwCwaWBNHe5iPXhdvP8wwDGpGpsIf/kxX/ec3zFsOaM1Oq8lEhUqDwk6W7DHkwg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@otplib/hotp": "13.4.0", + "@otplib/plugin-base32-scure": "13.4.0", + "@otplib/plugin-crypto-noble": "13.4.0", + "@otplib/totp": "13.4.0", + "@otplib/uri": "13.4.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -13216,7 +13316,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -13324,7 +13423,6 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -13371,7 +13469,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13465,7 +13562,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.12.0", @@ -13661,6 +13757,15 @@ "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz", "integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==" }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -13725,7 +13830,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13848,6 +13952,127 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -14063,6 +14288,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -14287,7 +14518,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -14359,7 +14589,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -14452,6 +14681,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -15496,7 +15731,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -15674,7 +15908,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -15899,7 +16132,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16390,6 +16622,7 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -16407,6 +16640,7 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -16420,6 +16654,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -16429,6 +16664,7 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -16438,6 +16674,7 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -16450,6 +16687,7 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -16489,6 +16727,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", @@ -16621,7 +16865,6 @@ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "license": "MIT", - "peer": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", diff --git a/backend/package.json b/backend/package.json index 052de58b..e325954f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -72,11 +72,13 @@ "nestjs-i18n": "^10.5.1", "nodemailer": "^7.0.12", "nodemailer-mjml": "^1.6.0", + "otplib": "^13.4.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "pdfkit": "^0.17.2", "pg": "^8.16.3", + "qrcode": "^1.5.4", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "sharp": "^0.34.5", @@ -94,6 +96,7 @@ "@types/nodemailer": "^7.0.5", "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", + "@types/qrcode": "^1.5.6", "@types/supertest": "^6.0.0", "@types/swagger-ui-express": "^4.1.8", "@typescript-eslint/eslint-plugin": "^8.0.0", diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index e0e0a4bb..520fa9f5 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -21,6 +21,10 @@ import { VerifyOtpDto } from './dto/verify-otp.dto'; import { ResetPasswordDto } from './dto/reset-password.dto'; import { ResendOtpDto } from './dto/resend-otp.dto'; import { SendPasswordResetOtpDto } from './dto/send-password-reset-otp.dto'; +import { VerifyTotpDto } from './dto/verify-totp.dto'; +import { UseBackupCodeDto } from './dto/use-backup-code.dto'; +import { Setup2faDto } from './dto/setup-2fa.dto'; +import { Disable2faDto } from './dto/disable-2fa.dto'; @Controller('auth') export class AuthController { @@ -73,6 +77,54 @@ export class AuthController { return user; } + @Get('2fa/status') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtAuthGuard) + get2faStatus(@CurrentUser() user: User) { + return this.authService.get2faStatus(user.id); + } + + @Post('2fa/setup') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtAuthGuard) + setup2fa(@CurrentUser() user: User) { + return this.authService.initiate2faSetup(user.id); + } + + @Post('2fa/confirm') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtAuthGuard) + confirm2fa(@CurrentUser() user: User, @Body() setup2faDto: Setup2faDto) { + return this.authService.confirm2faSetup(user.id, setup2faDto.token); + } + + @Public() + @Post('2fa/verify') + @HttpCode(HttpStatus.OK) + verify2fa(@Body() verifyTotpDto: VerifyTotpDto) { + return this.authService.verifyTotpLogin( + verifyTotpDto.token, + verifyTotpDto.tempToken, + ); + } + + @Public() + @Post('2fa/backup-code') + @HttpCode(HttpStatus.OK) + useBackupCode(@Body() useBackupCodeDto: UseBackupCodeDto) { + return this.authService.verifyBackupCode( + useBackupCodeDto.backupCode, + useBackupCodeDto.tempToken, + ); + } + + @Post('2fa/disable') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtAuthGuard) + disable2fa(@CurrentUser() user: User, @Body() disable2faDto: Disable2faDto) { + return this.authService.disable2fa(user.id, disable2faDto.password); + } + @Public() @Post('forgot-password') @HttpCode(HttpStatus.OK) diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 4fac2dc6..c52cde77 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -14,6 +14,9 @@ import { HashingProvider } from './providers/hashing.provider'; import { GenerateTokensProvider } from './providers/generateTokens.provider'; import { RefreshTokenRepositoryOperations } from './providers/refreshToken.repository'; import { RefreshToken } from './entities/refreshToken.entity'; +import { SetupTotpProvider } from './providers/setupTotp.provider'; +import { VerifyTotpProvider } from './providers/verifyTotp.provider'; +import { ManageTotpProvider } from './providers/manageTotp.provider'; @Module({ imports: [ @@ -40,6 +43,9 @@ import { RefreshToken } from './entities/refreshToken.entity'; HashingProvider, GenerateTokensProvider, RefreshTokenRepositoryOperations, + SetupTotpProvider, + VerifyTotpProvider, + ManageTotpProvider, ], exports: [ AuthService, diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 9fcd5a81..6996bd1c 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -21,6 +21,9 @@ import { SendPasswordResetOtpDto } from './dto/send-password-reset-otp.dto'; import { ResendOtpDto } from './dto/resend-otp.dto'; import { ResetPasswordDto } from './dto/reset-password.dto'; import { EmailService } from '../email/email.service'; +import { SetupTotpProvider } from './providers/setupTotp.provider'; +import { VerifyTotpProvider } from './providers/verifyTotp.provider'; +import { ManageTotpProvider } from './providers/manageTotp.provider'; @Injectable() export class AuthService { @@ -30,6 +33,9 @@ export class AuthService { private readonly userHelper: UserHelper, private readonly jwtHelper: JwtHelper, private readonly emailService: EmailService, + private readonly setupTotpProvider: SetupTotpProvider, + private readonly verifyTotpProvider: VerifyTotpProvider, + private readonly manageTotpProvider: ManageTotpProvider, ) {} async createUser(createUserDto: CreateUserDto) { @@ -208,12 +214,46 @@ export class AuthService { user: this.userHelper.formatUserResponse(user), }; } + + if (user.twoFactorEnabled === true) { + const tempToken = this.jwtHelper.generateTempToken(user.id); + + return { + requiresTwoFactor: true, + tempToken, + }; + } + const { accessToken } = this.jwtHelper.generateTokens(user); return { user: this.userHelper.formatUserResponse(user), accessToken, }; } + + async get2faStatus(userId: string) { + return this.manageTotpProvider.get2faStatus(userId); + } + + async initiate2faSetup(userId: string) { + return this.setupTotpProvider.initiate2faSetup(userId); + } + + async confirm2faSetup(userId: string, token: string) { + return this.setupTotpProvider.confirm2faSetup(userId, token); + } + + async verifyTotpLogin(token: string, tempToken: string) { + return this.verifyTotpProvider.verifyTotpLogin(token, tempToken); + } + + async verifyBackupCode(backupCode: string, tempToken: string) { + return this.verifyTotpProvider.verifyBackupCode(backupCode, tempToken); + } + + async disable2fa(userId: string, password: string) { + return this.manageTotpProvider.disable2fa(userId, password); + } async refreshToken(refreshToken: string) { const userId = this.jwtHelper.validateRefreshToken(refreshToken); const user = await this.userRepository.findOne({ diff --git a/backend/src/auth/dto/disable-2fa.dto.ts b/backend/src/auth/dto/disable-2fa.dto.ts new file mode 100644 index 00000000..94c709c4 --- /dev/null +++ b/backend/src/auth/dto/disable-2fa.dto.ts @@ -0,0 +1,8 @@ +import { IsNotEmpty, IsString, MinLength } from 'class-validator'; + +export class Disable2faDto { + @IsString() + @IsNotEmpty() + @MinLength(8) + password: string; +} diff --git a/backend/src/auth/dto/setup-2fa.dto.ts b/backend/src/auth/dto/setup-2fa.dto.ts new file mode 100644 index 00000000..258f328c --- /dev/null +++ b/backend/src/auth/dto/setup-2fa.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class Setup2faDto { + @IsString() + @IsNotEmpty() + token: string; +} diff --git a/backend/src/auth/dto/use-backup-code.dto.ts b/backend/src/auth/dto/use-backup-code.dto.ts new file mode 100644 index 00000000..525d8fa9 --- /dev/null +++ b/backend/src/auth/dto/use-backup-code.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class UseBackupCodeDto { + @IsString() + @IsNotEmpty() + backupCode: string; + + @IsString() + @IsNotEmpty() + tempToken: string; +} diff --git a/backend/src/auth/dto/verify-totp.dto.ts b/backend/src/auth/dto/verify-totp.dto.ts new file mode 100644 index 00000000..1fcda733 --- /dev/null +++ b/backend/src/auth/dto/verify-totp.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class VerifyTotpDto { + @IsString() + @IsNotEmpty() + token: string; + + @IsString() + @IsNotEmpty() + tempToken: string; +} diff --git a/backend/src/auth/helper/jwt-helper.ts b/backend/src/auth/helper/jwt-helper.ts index bea9ccc8..0bc9b0c4 100644 --- a/backend/src/auth/helper/jwt-helper.ts +++ b/backend/src/auth/helper/jwt-helper.ts @@ -60,4 +60,35 @@ export class JwtHelper { refreshToken: this.generateRefreshToken(user), }; } + + public generateTempToken(userId: string): string { + return this.jwtService.sign( + { sub: userId, type: '2fa-temp' }, + { + secret: (process.env.JWT_TEMP_SECRET ?? + process.env.JWT_SECRET) as string, + expiresIn: (process.env.JWT_TEMP_EXPIRATION ?? '10m') as JwtExpiry, + }, + ); + } + + public validateTempToken(tempToken: string): string { + try { + const payload = this.jwtService.verify( + tempToken, + { + secret: (process.env.JWT_TEMP_SECRET ?? + process.env.JWT_SECRET) as string, + }, + ); + + if (payload.type !== '2fa-temp' || !payload.sub) { + throw new UnauthorizedException('Invalid temp token'); + } + + return payload.sub; + } catch (error) { + throw new UnauthorizedException('Invalid or expired temp token'); + } + } } diff --git a/backend/src/auth/providers/manageTotp.provider.ts b/backend/src/auth/providers/manageTotp.provider.ts new file mode 100644 index 00000000..71f80553 --- /dev/null +++ b/backend/src/auth/providers/manageTotp.provider.ts @@ -0,0 +1,60 @@ +import { + BadRequestException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { UserHelper } from '../helper/user-helper'; + +@Injectable() +export class ManageTotpProvider { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly userHelper: UserHelper, + ) {} + + async get2faStatus(userId: string) { + const user = await this.userRepository.findOne({ where: { id: userId } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + return { + twoFactorEnabled: !!user.twoFactorEnabled, + hasBackupCodes: !!user.totpBackupCodes?.length, + }; + } + + async disable2fa(userId: string, password: string) { + const user = await this.userRepository.findOne({ where: { id: userId } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + const isPasswordValid = await this.userHelper.verifyPassword( + password, + user.password, + ); + + if (!isPasswordValid) { + throw new UnauthorizedException('Invalid password'); + } + + if (!user.twoFactorEnabled && !user.totpSecret && !user.totpBackupCodes) { + throw new BadRequestException('2FA is not enabled'); + } + + user.twoFactorEnabled = false; + user.totpSecret = null; + user.totpBackupCodes = null; + await this.userRepository.save(user); + + return { disabled: true }; + } +} diff --git a/backend/src/auth/providers/setupTotp.provider.ts b/backend/src/auth/providers/setupTotp.provider.ts new file mode 100644 index 00000000..160b1caf --- /dev/null +++ b/backend/src/auth/providers/setupTotp.provider.ts @@ -0,0 +1,78 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { generateSecret, generateURI, verifySync } from 'otplib'; +import * as QRCode from 'qrcode'; +import { Repository } from 'typeorm'; +import * as bcrypt from 'bcrypt'; +import { User } from '../../users/entities/user.entity'; + +@Injectable() +export class SetupTotpProvider { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + async initiate2faSetup(userId: string) { + const user = await this.userRepository.findOne({ where: { id: userId } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + const secret = generateSecret(); + const appName = process.env.TOTP_APP_NAME ?? 'ManageHub'; + const otpauthUrl = generateURI({ + secret, + issuer: appName, + label: user.email, + strategy: 'totp', + }); + const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl); + + user.totpSecret = secret; + await this.userRepository.save(user); + + return { secret, qrCodeDataUrl }; + } + + async confirm2faSetup(userId: string, token: string) { + const user = await this.userRepository.findOne({ where: { id: userId } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (!user.totpSecret) { + throw new BadRequestException('2FA setup has not been initiated'); + } + + const isValid = verifySync({ + token, + secret: user.totpSecret, + strategy: 'totp', + }); + + if (!isValid) { + throw new BadRequestException('Invalid TOTP code'); + } + + const backupCodes = Array.from({ length: 8 }, () => + Math.random().toString(36).slice(-10).toUpperCase() + ); + + const hashedBackupCodes = await Promise.all( + backupCodes.map((backupCode) => bcrypt.hash(backupCode, 10)) + ); + + user.twoFactorEnabled = true; + user.totpBackupCodes = hashedBackupCodes; + await this.userRepository.save(user); + + return { backupCodes }; + } +} diff --git a/backend/src/auth/providers/verifyTotp.provider.ts b/backend/src/auth/providers/verifyTotp.provider.ts new file mode 100644 index 00000000..4612fd94 --- /dev/null +++ b/backend/src/auth/providers/verifyTotp.provider.ts @@ -0,0 +1,92 @@ +import { + BadRequestException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { verifySync } from 'otplib'; +import { Repository } from 'typeorm'; +import * as bcrypt from 'bcrypt'; +import { User } from '../../users/entities/user.entity'; +import { JwtHelper } from '../helper/jwt-helper'; +import { UserHelper } from '../helper/user-helper'; + +@Injectable() +export class VerifyTotpProvider { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly jwtHelper: JwtHelper, + private readonly userHelper: UserHelper, + ) {} + + async verifyTotpLogin(token: string, tempToken: string) { + const userId = this.jwtHelper.validateTempToken(tempToken); + const user = await this.userRepository.findOne({ where: { id: userId } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (!user.twoFactorEnabled || !user.totpSecret) { + throw new BadRequestException('2FA is not enabled for this user'); + } + + const isValid = verifySync({ + token, + secret: user.totpSecret, + strategy: 'totp', + }); + + if (!isValid) { + throw new UnauthorizedException('Invalid TOTP code'); + } + + return { + user: this.userHelper.formatUserResponse(user), + accessToken: this.jwtHelper.generateAccessToken(user), + }; + } + + async verifyBackupCode(backupCode: string, tempToken: string) { + const userId = this.jwtHelper.validateTempToken(tempToken); + const user = await this.userRepository.findOne({ where: { id: userId } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + const hashedCodes = user.totpBackupCodes ?? []; + const matchedIndex = await this.findMatchingBackupCodeIndex( + backupCode, + hashedCodes, + ); + + if (matchedIndex === -1) { + throw new UnauthorizedException('Invalid backup code'); + } + + hashedCodes.splice(matchedIndex, 1); + user.totpBackupCodes = hashedCodes; + await this.userRepository.save(user); + + return { + user: this.userHelper.formatUserResponse(user), + accessToken: this.jwtHelper.generateAccessToken(user), + }; + } + + private async findMatchingBackupCodeIndex( + backupCode: string, + hashedCodes: string[], + ) { + for (let index = 0; index < hashedCodes.length; index += 1) { + if (await bcrypt.compare(backupCode, hashedCodes[index])) { + return index; + } + } + + return -1; + } +} diff --git a/backend/src/notifications/notifications.controller.ts b/backend/src/notifications/notifications.controller.ts index de8fca7a..ef31e7bf 100644 --- a/backend/src/notifications/notifications.controller.ts +++ b/backend/src/notifications/notifications.controller.ts @@ -52,4 +52,19 @@ export class NotificationsController { const notification = await this.notificationsService.markAsRead(id, userId); return { message: 'Notification marked as read', data: notification }; } + + @Patch('read-all') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Mark all notifications as read', + description: + 'Marks every unread notification belonging to the current user as read.', + }) + async markAllAsRead(@GetCurrentUser('id') userId: string) { + const result = await this.notificationsService.markAllAsRead(userId); + return { + message: 'All notifications marked as read', + data: result, + }; + } } diff --git a/backend/src/notifications/notifications.service.ts b/backend/src/notifications/notifications.service.ts index 95953f82..cbf998b1 100644 --- a/backend/src/notifications/notifications.service.ts +++ b/backend/src/notifications/notifications.service.ts @@ -25,4 +25,8 @@ export class NotificationsService { markAsRead(id: string, userId: string): Promise { return this.markNotificationReadProvider.markAsRead(id, userId); } + + markAllAsRead(userId: string): Promise<{ updatedCount: number }> { + return this.markNotificationReadProvider.markAllAsRead(userId); + } } diff --git a/backend/src/notifications/providers/mark-notification-read.provider.ts b/backend/src/notifications/providers/mark-notification-read.provider.ts index dfef5ed6..6b6a87ce 100644 --- a/backend/src/notifications/providers/mark-notification-read.provider.ts +++ b/backend/src/notifications/providers/mark-notification-read.provider.ts @@ -36,4 +36,21 @@ export class MarkNotificationReadProvider { notification.isRead = true; return this.notificationRepository.save(notification); } + + async markAllAsRead(userId: string): Promise<{ updatedCount: number }> { + const unreadNotifications = await this.notificationRepository.find({ + where: { userId, isRead: false }, + }); + + if (unreadNotifications.length === 0) { + return { updatedCount: 0 }; + } + + await this.notificationRepository.update( + { userId, isRead: false }, + { isRead: true }, + ); + + return { updatedCount: unreadNotifications.length }; + } } diff --git a/backend/src/users/dto/member-query.dto.ts b/backend/src/users/dto/member-query.dto.ts new file mode 100644 index 00000000..93aa08d8 --- /dev/null +++ b/backend/src/users/dto/member-query.dto.ts @@ -0,0 +1,38 @@ +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsInt, IsOptional, IsString, Min } from 'class-validator'; +import { UserRole } from '../enums/userRoles.enum'; +import { MembershipStatus } from '../enums/membership-status.enum'; + +export class MemberQueryDto { + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 15 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + limit?: number = 15; + + @ApiPropertyOptional({ + description: 'Search by firstname, lastname, or email', + }) + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ enum: UserRole }) + @IsOptional() + @IsEnum(UserRole) + role?: UserRole; + + @ApiPropertyOptional({ enum: MembershipStatus }) + @IsOptional() + @IsEnum(MembershipStatus) + status?: MembershipStatus; +} diff --git a/backend/src/users/dto/update-member-status.dto.ts b/backend/src/users/dto/update-member-status.dto.ts new file mode 100644 index 00000000..89ef26e7 --- /dev/null +++ b/backend/src/users/dto/update-member-status.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn } from 'class-validator'; + +export class UpdateMemberStatusDto { + @ApiProperty({ + enum: ['suspend', 'activate', 'promote', 'demote'], + }) + @IsIn(['suspend', 'activate', 'promote', 'demote']) + action: 'suspend' | 'activate' | 'promote' | 'demote'; +} diff --git a/backend/src/users/entities/user.entity.ts b/backend/src/users/entities/user.entity.ts index 723ffa28..04ebe7d6 100644 --- a/backend/src/users/entities/user.entity.ts +++ b/backend/src/users/entities/user.entity.ts @@ -10,6 +10,7 @@ import { import { Exclude } from 'class-transformer'; import { RefreshToken } from '../../auth/entities/refreshToken.entity'; import { UserRole } from '../enums/userRoles.enum'; +import { MembershipStatus } from '../enums/membership-status.enum'; @Entity('users') export class User { @@ -39,6 +40,13 @@ export class User { }) role: UserRole; + @Column({ + type: 'enum', + enum: MembershipStatus, + default: MembershipStatus.ACTIVE, + }) + membershipStatus: MembershipStatus; + @Exclude() @Column({ nullable: true }) passwordResetToken?: string; @@ -110,6 +118,14 @@ export class User { @Column({ default: false }) twoFactorEnabled: boolean; + @Exclude() + @Column({ nullable: true, type: 'varchar', length: 255 }) + totpSecret?: string | null; + + @Exclude() + @Column({ type: 'jsonb', nullable: true }) + totpBackupCodes?: string[] | null; + @DeleteDateColumn() deletedAt: Date; get fullName(): string { diff --git a/backend/src/users/enums/membership-status.enum.ts b/backend/src/users/enums/membership-status.enum.ts new file mode 100644 index 00000000..640dbff0 --- /dev/null +++ b/backend/src/users/enums/membership-status.enum.ts @@ -0,0 +1,5 @@ +export enum MembershipStatus { + ACTIVE = 'ACTIVE', + SUSPENDED = 'SUSPENDED', + PENDING = 'PENDING', +} diff --git a/backend/src/users/members.controller.ts b/backend/src/users/members.controller.ts new file mode 100644 index 00000000..63c70b42 --- /dev/null +++ b/backend/src/users/members.controller.ts @@ -0,0 +1,7 @@ +import { Controller } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; + +@ApiTags('members') +@ApiBearerAuth() +@Controller('members') +export class MembersController {} diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index 08cf0e46..f98fe1c7 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -1,6 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { UsersController } from './users.controller'; +import { MembersController } from './members.controller'; import { UsersService } from './providers/users.service'; import { AuthModule } from '../auth/auth.module'; import { User } from './entities/user.entity'; @@ -24,7 +25,7 @@ import { FindAdminByIdProvider } from './providers/findAdminById.provider'; forwardRef(() => AuthModule), CloudinaryModule, ], - controllers: [UsersController], + controllers: [UsersController, MembersController], providers: [ UsersService, CreateUserProvider, diff --git a/frontend/app/admin/bookings/page.tsx b/frontend/app/admin/bookings/page.tsx index e0115bc3..0b4f102a 100644 --- a/frontend/app/admin/bookings/page.tsx +++ b/frontend/app/admin/bookings/page.tsx @@ -1,19 +1,8 @@ "use client"; import { useState } from "react"; -import DashboardLayout from "@/app/(dashboard)/layout"; -import { useGetAllBookings } from "@/lib/hooks/useGetAllBookings"; // from Issue #22 -import { - Card, - CardHeader, - CardTitle, - CardContent, -} from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Badge } from "@/components/ui/badge"; -import { Table, TableHeader, TableRow, TableCell, TableBody } from "@/components/ui/table"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import DashboardLayout from "@/components/dashboard/DashboardLayout"; +import { useGetAllBookings } from "@/lib/hooks/useGetAllBookings"; import { toast } from "sonner"; type Status = "ALL" | "PENDING" | "CONFIRMED" | "COMPLETED" | "CANCELLED"; @@ -47,14 +36,21 @@ export default function AdminBookingsPage() { {/* Status filter tabs */}
{(["ALL", "PENDING", "CONFIRMED", "COMPLETED", "CANCELLED"] as Status[]).map((status) => ( - + + {counts[status.toLowerCase()] ?? 0} + + ))}
@@ -62,7 +58,7 @@ export default function AdminBookingsPage() { {loading && (
{Array.from({ length: 5 }).map((_, i) => ( - +
))}
)} @@ -75,96 +71,128 @@ export default function AdminBookingsPage() { {/* Table */} {!loading && bookings.length > 0 && ( - - - - Member - Workspace - Plan - Dates - Seats - Total (₦) - Status - Actions - - - - {bookings.map((b) => ( - - {b.memberName} - {b.workspaceName} - {b.planType} - {b.startDate} - {b.endDate} - {b.seats} - ₦{b.totalAmount} - - {b.status} - - +
+
+ + + + + + + + + + + + + + {bookings.map((b) => ( + + + + + + + + +
MemberWorkspacePlanDatesSeatsTotal (₦)StatusActions
{b.memberName ?? "Unknown member"}{b.workspaceName ?? "Unknown workspace"}{b.planType}{b.startDate} - {b.endDate}{b.seats ?? b.seatCount}₦{b.totalAmount} + + {b.status} + + {b.status === "PENDING" && (
- - + +
)} {b.status === "CONFIRMED" && (
- - +
)} - - - ))} - -
+ + + ))} + + +
)} {/* Pagination controls */} {pagination.totalPages > 1 && (
- - + +
)} {/* Confirmation dialog */} - setConfirmDialog({ ...confirmDialog, open })}> - - - Confirm Action - -

+ {confirmDialog.open && ( +

+
+

Confirm Action

+

Are you sure you want to {confirmDialog.action} this booking? This action cannot be undone. -

- - - + - - -
+ + + + + )} ); diff --git a/frontend/app/notifications/page.tsx b/frontend/app/notifications/page.tsx new file mode 100644 index 00000000..d4d10ece --- /dev/null +++ b/frontend/app/notifications/page.tsx @@ -0,0 +1,256 @@ +"use client"; + +import { useState } from "react"; +import DashboardLayout from "@/components/dashboard/DashboardLayout"; +import { + useGetNotifications, + useMarkAllRead, + useMarkNotificationRead, +} from "@/lib/react-query/hooks"; +import { Notification } from "@/lib/types/notification"; + +const PAGE_SIZE = 10; + +function formatRelativeTime(dateString: string) { + const date = new Date(dateString); + const now = new Date(); + const diffInSeconds = Math.round((date.getTime() - now.getTime()) / 1000); + + const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [ + ["year", 60 * 60 * 24 * 365], + ["month", 60 * 60 * 24 * 30], + ["week", 60 * 60 * 24 * 7], + ["day", 60 * 60 * 24], + ["hour", 60 * 60], + ["minute", 60], + ["second", 1], + ]; + + const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); + + for (const [unit, secondsInUnit] of units) { + if (Math.abs(diffInSeconds) >= secondsInUnit || unit === "second") { + return formatter.format( + Math.round(diffInSeconds / secondsInUnit), + unit + ); + } + } + + return "just now"; +} + +function NotificationSkeleton() { + return ( +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+
+
+
+
+
+ ))} +
+ ); +} + +function EmptyState() { + return ( +
+

+ No notifications yet +

+

+ When activity happens across your workspace, your full history will show + up here. +

+
+ ); +} + +function Pagination({ + page, + totalPages, + onPageChange, +}: { + page: number; + totalPages: number; + onPageChange: (page: number) => void; +}) { + if (totalPages <= 1) { + return null; + } + + return ( +
+ +

+ Page {page} of {totalPages} +

+ +
+ ); +} + +function NotificationRow({ + notification, + onMarkAsRead, + isMarkingRead, +}: { + notification: Notification; + onMarkAsRead: (notificationId: string) => void; + isMarkingRead: boolean; +}) { + return ( +
+
+
+
+ {!notification.isRead && ( + + )} +

+ {notification.title} +

+
+

+ {notification.message} +

+

+ {formatRelativeTime(notification.createdAt)} +

+
+ + {!notification.isRead && ( + + )} +
+
+ ); +} + +export default function NotificationsPage() { + const [page, setPage] = useState(1); + const notificationsQuery = useGetNotifications(page, PAGE_SIZE); + const markNotificationRead = useMarkNotificationRead(); + const markAllRead = useMarkAllRead(); + + const notifications = notificationsQuery.data?.data ?? []; + const meta = notificationsQuery.data?.meta; + const unreadCount = notificationsQuery.data?.unreadCount ?? 0; + + return ( + +
+
+
+

+ Notification Center +

+

+ All notifications +

+

+ Review your full notification history and clear unread updates + when you are done. +

+
+ + {unreadCount > 0 && ( + + )} +
+ +
+

+ {unreadCount > 0 + ? `${unreadCount} unread notification${unreadCount === 1 ? "" : "s"}` + : "Everything is up to date"} +

+ {meta && meta.total > 0 && ( +

+ Showing {(meta.page - 1) * meta.limit + 1}- + {Math.min(meta.page * meta.limit, meta.total)} of {meta.total} +

+ )} +
+ + {notificationsQuery.isLoading ? ( + + ) : notificationsQuery.isError ? ( +
+ {notificationsQuery.error instanceof Error + ? notificationsQuery.error.message + : "Unable to load notifications right now."} +
+ ) : notifications.length === 0 ? ( + + ) : ( +
+ {notifications.map((notification) => ( + + markNotificationRead.mutate(notificationId) + } + isMarkingRead={ + markNotificationRead.isPending && + markNotificationRead.variables === notification.id + } + /> + ))} +
+ )} + + +
+
+ ); +} diff --git a/frontend/components/workspaces/WorkspaceCard.tsx b/frontend/components/workspaces/WorkspaceCard.tsx new file mode 100644 index 00000000..9b82bb97 --- /dev/null +++ b/frontend/components/workspaces/WorkspaceCard.tsx @@ -0,0 +1,60 @@ +"use client"; + +import Link from "next/link"; +import { Workspace } from "@/lib/types/workspace"; + +const TYPE_LABELS: Record = { + COWORKING: "Coworking", + PRIVATE_OFFICE: "Private Office", + MEETING_ROOM: "Meeting Room", + HOT_DESK: "Hot Desk", + DEDICATED_DESK: "Dedicated Desk", +}; + +function formatCurrency(amount: number) { + return new Intl.NumberFormat("en-NG", { + style: "currency", + currency: "NGN", + minimumFractionDigits: 0, + }).format(amount / 100); +} + +export default function WorkspaceCard({ workspace }: { workspace: Workspace }) { + return ( +
+
+
+
+
+

+ {TYPE_LABELS[workspace.type]} +

+

+ {workspace.name} +

+
+ + {workspace.availableSeats}/{workspace.totalSeats} seats + +
+ +

+ {workspace.description || "A well-equipped workspace ready for your next session."} +

+ +
+

+ {formatCurrency(workspace.hourlyRate)} + / hour +

+ + View workspace + +
+
+
+ ); +} diff --git a/frontend/hooks/invoices/useGetMyInvoices.ts b/frontend/hooks/invoices/useGetMyInvoices.ts index dcde63a7..b90f3dfd 100644 --- a/frontend/hooks/invoices/useGetMyInvoices.ts +++ b/frontend/hooks/invoices/useGetMyInvoices.ts @@ -47,7 +47,7 @@ export function useGetMyInvoices({ return apiClient.get(`/invoices?${qs}`); }, // Keep previous page data visible while the next page loads - placeholderData: (prev) => prev, + placeholderData: (prev: InvoiceListResponse | undefined) => prev, staleTime: 30_000, // 30 s — invoices don't change frequently }); -} \ No newline at end of file +} diff --git a/frontend/lib/hooks/useGetAllBookings.ts b/frontend/lib/hooks/useGetAllBookings.ts new file mode 100644 index 00000000..2f59ddc7 --- /dev/null +++ b/frontend/lib/hooks/useGetAllBookings.ts @@ -0,0 +1,100 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; +import { Booking, BookingStatus } from "@/lib/types/booking"; + +type AdminBookingStatus = BookingStatus | "ALL"; + +interface BookingsResponse { + data: Booking[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} + +function getActionEndpoint(bookingId: string, action: "cancel" | "complete" | "confirm") { + if (action === "confirm") { + return `/bookings/${bookingId}/confirm`; + } + + if (action === "complete") { + return `/bookings/${bookingId}/complete`; + } + + return `/bookings/${bookingId}/cancel`; +} + +export const useGetAllBookings = (status: AdminBookingStatus) => { + const [page, setPage] = useState(1); + const queryClient = useQueryClient(); + + const params = new URLSearchParams({ + page: String(page), + limit: "10", + }); + + if (status !== "ALL") { + params.set("status", status); + } + + const bookingsQuery = useQuery({ + queryKey: ["admin-bookings", { status, page }] as const, + queryFn: () => apiClient.get(`/bookings?${params}`), + }); + + const mutation = useMutation({ + mutationFn: ({ + bookingId, + action, + }: { + bookingId: string; + action: "cancel" | "complete" | "confirm"; + }) => apiClient.patch(getActionEndpoint(bookingId, action)), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["admin-bookings"] }); + }, + }); + + const bookings = bookingsQuery.data?.data ?? []; + const counts = useMemo( + () => + bookings.reduce>( + (acc, booking) => { + const key = booking.status.toLowerCase(); + acc[key] = (acc[key] ?? 0) + 1; + return acc; + }, + { + all: bookings.length, + pending: 0, + confirmed: 0, + completed: 0, + cancelled: 0, + } + ), + [bookings] + ); + + return { + bookings, + counts, + loading: bookingsQuery.isLoading, + error: bookingsQuery.error, + mutateBooking: (bookingId: string, action: "cancel" | "complete" | "confirm") => + mutation.mutateAsync({ bookingId, action }), + pagination: { + page, + totalPages: bookingsQuery.data?.meta.totalPages ?? 1, + prev: () => setPage((currentPage) => Math.max(1, currentPage - 1)), + next: () => + setPage((currentPage) => + Math.min(bookingsQuery.data?.meta.totalPages ?? 1, currentPage + 1) + ), + }, + }; +}; diff --git a/frontend/lib/react-query/hooks/bookings/usePriceEstimate.ts b/frontend/lib/react-query/hooks/bookings/usePriceEstimate.ts index 3f465cdc..a833bbca 100644 --- a/frontend/lib/react-query/hooks/bookings/usePriceEstimate.ts +++ b/frontend/lib/react-query/hooks/bookings/usePriceEstimate.ts @@ -5,7 +5,7 @@ import { apiClient } from "@/lib/apiClient"; import { queryKeys } from "@/lib/react-query/keys/queryKeys"; import { PlanType } from "@/lib/types/booking"; -interface PriceEstimateParams { +interface PriceEstimateParams extends Record { workspaceId: string; planType: PlanType; startDate: string; @@ -53,4 +53,3 @@ export const usePriceEstimate = (params: PriceEstimateParams | null) => { }; - diff --git a/frontend/lib/react-query/hooks/index.ts b/frontend/lib/react-query/hooks/index.ts index 0161a661..c0c1d7c5 100644 --- a/frontend/lib/react-query/hooks/index.ts +++ b/frontend/lib/react-query/hooks/index.ts @@ -1,3 +1,6 @@ -export { useRegisterUser } from "./auth/useRegisterUser"; -export { useLoginUser } from "./auth/useLoginUser"; -export { useForgotPassword } from "./auth/useForgotPassword"; +export { useRegisterUser } from "./auth/useRegisterUser"; +export { useLoginUser } from "./auth/useLoginUser"; +export { useForgotPassword } from "./auth/useForgotPassword"; +export { useGetNotifications } from "./notifications/useGetNotifications"; +export { useMarkNotificationRead } from "./notifications/useMarkNotificationRead"; +export { useMarkAllRead } from "./notifications/useMarkAllRead"; diff --git a/frontend/lib/react-query/hooks/invoices/useGetInvoice.ts b/frontend/lib/react-query/hooks/invoices/useGetInvoice.ts new file mode 100644 index 00000000..4d809121 --- /dev/null +++ b/frontend/lib/react-query/hooks/invoices/useGetInvoice.ts @@ -0,0 +1 @@ +export { useGetInvoice } from "@/hooks/invoices/useGetInvoice"; diff --git a/frontend/lib/react-query/hooks/invoices/useGetMyInvoices.ts b/frontend/lib/react-query/hooks/invoices/useGetMyInvoices.ts new file mode 100644 index 00000000..0c314b66 --- /dev/null +++ b/frontend/lib/react-query/hooks/invoices/useGetMyInvoices.ts @@ -0,0 +1,2 @@ +export { useGetMyInvoices } from "@/hooks/invoices/useGetMyInvoices"; +export type { UseGetMyInvoicesParams } from "@/hooks/invoices/useGetMyInvoices"; diff --git a/frontend/lib/react-query/hooks/notifications/useGetNotifications.ts b/frontend/lib/react-query/hooks/notifications/useGetNotifications.ts new file mode 100644 index 00000000..9947c6bb --- /dev/null +++ b/frontend/lib/react-query/hooks/notifications/useGetNotifications.ts @@ -0,0 +1,19 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; +import { queryKeys } from "@/lib/react-query/keys/queryKeys"; +import { NotificationsResponse } from "@/lib/types/notification"; + +export const useGetNotifications = (page = 1, limit = 20) => { + const params = new URLSearchParams({ + page: String(page), + limit: String(limit), + }); + + return useQuery({ + queryKey: queryKeys.notifications.list({ page, limit }), + queryFn: () => + apiClient.get(`/notifications?${params}`), + }); +}; diff --git a/frontend/lib/react-query/hooks/notifications/useMarkAllRead.ts b/frontend/lib/react-query/hooks/notifications/useMarkAllRead.ts new file mode 100644 index 00000000..00d3902f --- /dev/null +++ b/frontend/lib/react-query/hooks/notifications/useMarkAllRead.ts @@ -0,0 +1,24 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; +import { queryKeys } from "@/lib/react-query/keys/queryKeys"; +import { toast } from "sonner"; + +export const useMarkAllRead = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => + apiClient.patch<{ message: string; data: { updatedCount: number } }>( + "/notifications/read-all" + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.notifications.all }); + toast.success("All notifications marked as read"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to mark all notifications as read"); + }, + }); +}; diff --git a/frontend/lib/react-query/hooks/notifications/useMarkNotificationRead.ts b/frontend/lib/react-query/hooks/notifications/useMarkNotificationRead.ts new file mode 100644 index 00000000..5f7d20a1 --- /dev/null +++ b/frontend/lib/react-query/hooks/notifications/useMarkNotificationRead.ts @@ -0,0 +1,25 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; +import { queryKeys } from "@/lib/react-query/keys/queryKeys"; +import { Notification } from "@/lib/types/notification"; +import { toast } from "sonner"; + +export const useMarkNotificationRead = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (notificationId: string) => + apiClient.patch<{ message: string; data: Notification }>( + `/notifications/${notificationId}/read` + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.notifications.all }); + toast.success("Notification marked as read"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update notification"); + }, + }); +}; diff --git a/frontend/lib/react-query/hooks/workspaces/useGetWorkspaces.ts b/frontend/lib/react-query/hooks/workspaces/useGetWorkspaces.ts new file mode 100644 index 00000000..734d74e5 --- /dev/null +++ b/frontend/lib/react-query/hooks/workspaces/useGetWorkspaces.ts @@ -0,0 +1,41 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; +import { queryKeys } from "@/lib/react-query/keys/queryKeys"; +import { Workspace, WorkspaceQuery } from "@/lib/types/workspace"; + +interface WorkspacesResponse { + data: Workspace[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} + +export const useGetWorkspaces = ({ + search, + type, + page = 1, + limit = 9, +}: WorkspaceQuery) => { + const params = new URLSearchParams({ + page: String(page), + limit: String(limit), + }); + + if (search) { + params.set("search", search); + } + + if (type) { + params.set("type", type); + } + + return useQuery({ + queryKey: queryKeys.workspaces.list({ search, type, page, limit }), + queryFn: () => apiClient.get(`/workspaces?${params}`), + }); +}; diff --git a/frontend/lib/react-query/keys/queryKeys.ts b/frontend/lib/react-query/keys/queryKeys.ts index e4abbbcd..d370f23c 100644 --- a/frontend/lib/react-query/keys/queryKeys.ts +++ b/frontend/lib/react-query/keys/queryKeys.ts @@ -1,26 +1,44 @@ -// frontend/lib/react-query/keys/queryKeys.ts -// Add the invoiceKeys block below to your existing queryKeys object. -// If the file doesn't exist yet, use the full export below as a starting point. - export const queryKeys = { - // ── existing keys (keep whatever is already here) ────────────────────────── + bookings: { + all: ["bookings"] as const, + mine: (params: { page?: number; limit?: number }) => + ["bookings", "mine", params] as const, + priceEstimate: (params: Record) => + ["bookings", "price-estimate", params] as const, + }, + + workspaceTracking: { + active: ["workspace-tracking", "active"] as const, + }, + + workspaces: { + all: ["workspaces"] as const, + list: (params: { + search?: string; + type?: string; + page?: number; + limit?: number; + }) => ["workspaces", "list", params] as const, + }, + + admin: { + workspaces: { + all: ["admin", "workspaces"] as const, + list: (params: { page?: number; limit?: number; search?: string }) => + ["admin", "workspaces", "list", params] as const, + }, + }, + + notifications: { + all: ["notifications"] as const, + list: (params: { page?: number; limit?: number }) => + ["notifications", "list", params] as const, + }, - // ── #42 Invoice keys ──────────────────────────────────────────────────────── invoices: { - /** Base key — used for invalidating all invoice queries at once */ all: ["invoices"] as const, - - /** - * Paginated list key. - * @example queryKeys.invoices.list({ page: 1, limit: 10, status: "PAID" }) - */ list: (params: { page?: number; limit?: number; status?: string }) => ["invoices", "list", params] as const, - - /** - * Single invoice key. - * @example queryKeys.invoices.detail("abc-123") - */ detail: (id: string) => ["invoices", "detail", id] as const, }, -}; \ No newline at end of file +} as const; diff --git a/frontend/lib/types/booking.ts b/frontend/lib/types/booking.ts new file mode 100644 index 00000000..e024efc2 --- /dev/null +++ b/frontend/lib/types/booking.ts @@ -0,0 +1,36 @@ +export type BookingStatus = + | "PENDING" + | "CONFIRMED" + | "COMPLETED" + | "CANCELLED"; + +export type PlanType = + | "HOURLY" + | "DAILY" + | "WEEKLY" + | "MONTHLY" + | "CUSTOM"; + +export interface Booking { + id: string; + workspaceId: string; + workspaceName?: string; + memberName?: string; + planType: PlanType | string; + startDate: string; + endDate: string; + seatCount: number; + seats?: number; + totalAmount: number; + status: BookingStatus; + createdAt?: string; + updatedAt?: string; +} + +export interface CreateBookingDto { + workspaceId: string; + planType: PlanType; + startDate: string; + endDate: string; + seatCount?: number; +} diff --git a/frontend/lib/types/invoice.ts b/frontend/lib/types/invoice.ts new file mode 100644 index 00000000..d4166b37 --- /dev/null +++ b/frontend/lib/types/invoice.ts @@ -0,0 +1,38 @@ +export type InvoiceStatus = "PAID" | "PENDING" | "CANCELLED"; + +export interface Invoice { + id: string; + invoiceNumber: string; + status: InvoiceStatus; + amount: number; + currency: string; + issueDate: string; + paymentDate?: string; + createdAt: string; + updatedAt: string; + member?: { + id: string; + name: string; + email: string; + }; + booking?: { + id: string; + workspaceName: string; + planType: string; + startDate: string; + endDate: string; + seatCount: number; + }; +} + +export interface InvoiceMeta { + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface InvoiceListResponse { + data: Invoice[]; + meta: InvoiceMeta; +} diff --git a/frontend/lib/types/notification.ts b/frontend/lib/types/notification.ts new file mode 100644 index 00000000..03256822 --- /dev/null +++ b/frontend/lib/types/notification.ts @@ -0,0 +1,23 @@ +export interface Notification { + id: string; + userId: string; + type: string; + title: string; + message: string; + isRead: boolean; + metadata?: Record | null; + createdAt: string; + updatedAt: string; +} + +export interface NotificationsResponse { + message: string; + data: Notification[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; + unreadCount: number; +} diff --git a/frontend/middleware.ts b/frontend/middleware.ts index 222e9292..c875d66b 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -4,6 +4,7 @@ const publicRoutes = ["/", "/login", "/register", "/forgot-password"]; const protectedRoutes = { "/dashboard": ["users", "admin"], + "/notifications": ["users", "admin"], "/profile": ["users", "admin"], "/settings": ["users", "admin"], "/users": ["admin"], diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1214fc77..a33203cc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1488,7 +1488,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "5.90.20" }, @@ -1635,7 +1634,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1646,7 +1644,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -1702,7 +1699,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -2215,7 +2211,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2697,8 +2692,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/d3-array": { "version": "3.2.4", @@ -3214,7 +3208,6 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5606,7 +5599,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5616,7 +5608,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -5629,7 +5620,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -5670,7 +5660,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -5723,8 +5712,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -6431,7 +6419,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6591,7 +6578,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver"