From dfcd172ef3f2c1fd6b3b2fffb40d0d109eac562e Mon Sep 17 00:00:00 2001 From: olsch01 Date: Mon, 16 Mar 2026 21:12:35 -0400 Subject: [PATCH 1/2] feat: SaaS onboarding, Stripe billing, MFA, SSO, passkeys, refresh tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete SaaS self-service onboarding sprint: - Stripe-powered signup flow: pricing page → checkout → provisioning → activation - Refresh token infrastructure: 1h access tokens + 30-day httpOnly cookie refresh - TOTP MFA with QR setup, recovery codes, and login challenge flow - Google + Azure AD SSO (conditional on env vars) with account linking - WebAuthn passkey registration and passwordless login - Guided onboarding checklist with server-side progress tracking - Stubbed email service (console + DB logging, ready for real provider) - Settings page with tabbed security settings (MFA, passkeys, linked accounts) - Login page enhanced with MFA verification, SSO buttons, passkey login - Database migration 015 with all new tables and columns - Version bump to 2026.03.17 Co-Authored-By: Claude Opus 4.6 --- backend/package-lock.json | 1117 ++++++++++++++++- backend/package.json | 13 +- backend/src/app.module.ts | 6 + backend/src/main.ts | 6 + backend/src/modules/auth/auth.controller.ts | 128 +- backend/src/modules/auth/auth.module.ts | 30 +- backend/src/modules/auth/auth.service.ts | 179 ++- backend/src/modules/auth/mfa.controller.ts | 121 ++ backend/src/modules/auth/mfa.service.ts | 154 +++ .../src/modules/auth/passkey.controller.ts | 112 ++ backend/src/modules/auth/passkey.service.ts | 246 ++++ .../src/modules/auth/refresh-token.service.ts | 98 ++ backend/src/modules/auth/sso.controller.ts | 105 ++ backend/src/modules/auth/sso.service.ts | 97 ++ .../src/modules/billing/billing.controller.ts | 63 + backend/src/modules/billing/billing.module.ts | 13 + .../src/modules/billing/billing.service.ts | 294 +++++ backend/src/modules/email/email.module.ts | 9 + backend/src/modules/email/email.service.ts | 69 + .../onboarding/onboarding.controller.ts | 31 + .../modules/onboarding/onboarding.module.ts | 10 + .../modules/onboarding/onboarding.service.ts | 79 ++ db/migrations/015-saas-onboarding-auth.sql | 107 ++ docker-compose.yml | 15 + frontend/package-lock.json | 11 +- frontend/package.json | 3 +- frontend/src/App.tsx | 24 + frontend/src/components/layout/Sidebar.tsx | 7 + frontend/src/pages/auth/ActivatePage.tsx | 179 +++ frontend/src/pages/auth/LoginPage.tsx | 296 ++++- .../src/pages/onboarding/OnboardingPage.tsx | 241 ++++ .../onboarding/OnboardingPendingPage.tsx | 82 ++ frontend/src/pages/pricing/PricingPage.tsx | 211 ++++ .../src/pages/settings/LinkedAccounts.tsx | 97 ++ frontend/src/pages/settings/MfaSettings.tsx | 159 +++ .../src/pages/settings/PasskeySettings.tsx | 140 +++ frontend/src/pages/settings/SettingsPage.tsx | 117 +- frontend/src/services/api.ts | 77 +- frontend/src/stores/authStore.ts | 9 +- 39 files changed, 4673 insertions(+), 82 deletions(-) create mode 100644 backend/src/modules/auth/mfa.controller.ts create mode 100644 backend/src/modules/auth/mfa.service.ts create mode 100644 backend/src/modules/auth/passkey.controller.ts create mode 100644 backend/src/modules/auth/passkey.service.ts create mode 100644 backend/src/modules/auth/refresh-token.service.ts create mode 100644 backend/src/modules/auth/sso.controller.ts create mode 100644 backend/src/modules/auth/sso.service.ts create mode 100644 backend/src/modules/billing/billing.controller.ts create mode 100644 backend/src/modules/billing/billing.module.ts create mode 100644 backend/src/modules/billing/billing.service.ts create mode 100644 backend/src/modules/email/email.module.ts create mode 100644 backend/src/modules/email/email.service.ts create mode 100644 backend/src/modules/onboarding/onboarding.controller.ts create mode 100644 backend/src/modules/onboarding/onboarding.module.ts create mode 100644 backend/src/modules/onboarding/onboarding.service.ts create mode 100644 db/migrations/015-saas-onboarding-auth.sql create mode 100644 frontend/src/pages/auth/ActivatePage.tsx create mode 100644 frontend/src/pages/onboarding/OnboardingPage.tsx create mode 100644 frontend/src/pages/onboarding/OnboardingPendingPage.tsx create mode 100644 frontend/src/pages/pricing/PricingPage.tsx create mode 100644 frontend/src/pages/settings/LinkedAccounts.tsx create mode 100644 frontend/src/pages/settings/MfaSettings.tsx create mode 100644 frontend/src/pages/settings/PasskeySettings.tsx diff --git a/backend/package-lock.json b/backend/package-lock.json index d3ba965..fa6789c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hoa-ledgeriq-backend", - "version": "2026.03.10", + "version": "2026.3.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hoa-ledgeriq-backend", - "version": "2026.03.10", + "version": "2026.3.11", "dependencies": { "@nestjs/common": "^10.4.15", "@nestjs/config": "^3.3.0", @@ -18,18 +18,26 @@ "@nestjs/swagger": "^7.4.2", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^10.0.2", + "@simplewebauthn/server": "^13.3.0", "bcryptjs": "^3.0.3", + "bullmq": "^5.71.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "cookie-parser": "^1.4.7", "helmet": "^8.1.0", "ioredis": "^5.4.2", "newrelic": "latest", + "otplib": "^13.3.0", "passport": "^0.7.0", + "passport-azure-ad": "^4.3.5", + "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "pg": "^8.13.1", + "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "stripe": "^20.4.1", "typeorm": "^0.3.20", "uuid": "^9.0.1" }, @@ -38,12 +46,15 @@ "@nestjs/schematics": "^10.2.3", "@nestjs/testing": "^10.4.15", "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", "@types/multer": "^2.0.0", "@types/node": "^20.17.12", + "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", + "@types/qrcode": "^1.5.6", "@types/uuid": "^9.0.8", "jest": "^29.7.0", "ts-jest": "^29.2.5", @@ -912,6 +923,12 @@ "node": ">=6" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@ioredis/commands": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", @@ -1460,6 +1477,12 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@ljharb/through": { "version": "2.3.14", "resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.14.tgz", @@ -1488,6 +1511,84 @@ "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", "license": "MIT" }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@nestjs/cli": { "version": "10.4.9", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz", @@ -1883,6 +1984,18 @@ "npm": ">=6.0.0" } }, + "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/@nuxtjs/opencollective": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz", @@ -2321,6 +2434,221 @@ "node": ">=14" } }, + "node_modules/@otplib/core": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.3.0.tgz", + "integrity": "sha512-pnQDOuCmFVeF/XnboJq9TOJgLoo2idNPJKMymOF8vGqJJ+ReKRYM9bUGjNPRWC0tHjMwu1TXbnzyBp494JgRag==", + "license": "MIT" + }, + "node_modules/@otplib/hotp": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.3.0.tgz", + "integrity": "sha512-XJMZGz2bg4QJwK7ulvl1GUI2VMn/flaIk/E/BTKAejHsX2kUtPF1bRhlZ2+elq8uU5Fs9Z9FHcQK2CPZNQbbUQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.3.0", + "@otplib/uri": "13.3.0" + } + }, + "node_modules/@otplib/plugin-base32-scure": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.3.0.tgz", + "integrity": "sha512-/jYbL5S6GB0Ie3XGEWtLIr9s5ZICl/BfmNL7+8/W7usZaUU4GiyLd2S+JGsNCslPyqNekSudD864nDAvRI0s8w==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.3.0", + "@scure/base": "^2.0.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.3.0.tgz", + "integrity": "sha512-wmV+jBVncepgwv99G7Plrdzd0tHfbpXk2U+OD7MO7DzpDqOYEgOPi+IIneksJSTL8QvWdfi+uQEuhnER4fKouA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1", + "@otplib/core": "13.3.0" + } + }, + "node_modules/@otplib/totp": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.3.0.tgz", + "integrity": "sha512-XfjGNoN8d9S3Ove2j7AwkVV7+QDFsV7Lm7YwSiezNaHffkWtJ60aJYpmf+01dARdPST71U2ptueMsRJso4sq4A==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.3.0", + "@otplib/hotp": "13.3.0", + "@otplib/uri": "13.3.0" + } + }, + "node_modules/@otplib/uri": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.3.0.tgz", + "integrity": "sha512-3oh6nBXy+cm3UX9cxEAGZiDrfxHU2gfelYFV+XNCx+8dq39VaQVymwlU2yjPZiMAi/3agaUeEftf2RwM5F+Cyg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.3.0" + } + }, + "node_modules/@peculiar/asn1-android": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz", + "integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz", + "integrity": "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-attr": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.1.tgz", + "integrity": "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz", + "integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.1.tgz", + "integrity": "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.1", + "@peculiar/asn1-pkcs8": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.1.tgz", + "integrity": "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.1.tgz", + "integrity": "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.1", + "@peculiar/asn1-pfx": "^2.6.1", + "@peculiar/asn1-pkcs8": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-attr": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz", + "integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz", + "integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.1.tgz", + "integrity": "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2402,6 +2730,34 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "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/@simplewebauthn/server": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.0.tgz", + "integrity": "sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -2583,6 +2939,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -2733,6 +3099,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/oauth": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz", + "integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/passport": { "version": "1.0.17", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", @@ -2743,6 +3119,18 @@ "@types/express": "*" } }, + "node_modules/@types/passport-google-oauth20": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz", + "integrity": "sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-oauth2": "*" + } + }, "node_modules/@types/passport-jwt": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", @@ -2766,6 +3154,18 @@ "@types/passport-strategy": "*" } }, + "node_modules/@types/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*" + } + }, "node_modules/@types/passport-strategy": { "version": "0.2.38", "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", @@ -2777,6 +3177,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", @@ -3267,6 +3677,26 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1js": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -3434,6 +3864,15 @@ ], "license": "MIT" }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -3515,7 +3954,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -3629,6 +4068,52 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/bullmq": { + "version": "5.71.0", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.71.0.tgz", + "integrity": "sha512-aeNWh4drsafSKnAJeiNH/nZP/5O8ZdtdMbnOPZmpjXj7NZUP5YC901U3bIH41iZValm7d1i3c34ojv7q31m30w==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.9.3", + "msgpackr": "1.11.5", + "node-abort-controller": "3.1.1", + "semver": "7.7.4", + "tslib": "2.8.1", + "uuid": "11.1.0" + } + }, + "node_modules/bullmq/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/bunyan": { + "version": "1.8.15", + "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.15.tgz", + "integrity": "sha512-0tECWShh6wUysgucJcBAoYegf3JJoZWibxdqhTm7OHPeT42qdjkZ29QCMcKwbgU1kiH+auSIasNRXMLWXafXig==", + "engines": [ + "node >=0.10.0" + ], + "license": "MIT", + "bin": { + "bunyan": "bin/bunyan" + }, + "optionalDependencies": { + "dtrace-provider": "~0.8", + "moment": "^2.19.3", + "mv": "~2", + "safe-json-stringify": "~1" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -3649,6 +4134,41 @@ "node": ">= 0.8" } }, + "node_modules/cache-manager": { + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-3.6.3.tgz", + "integrity": "sha512-dS4DnV6c6cQcVH5OxzIU1XZaACXwvVIiUPkFytnRmLOACuBGv3GQgRQ1RJGRRw4/9DF14ZK2RFlZu1TUgDniMg==", + "license": "MIT", + "dependencies": { + "async": "3.2.3", + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/cache-manager/node_modules/async": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", + "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==", + "license": "MIT" + }, + "node_modules/cache-manager/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cache-manager/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -3710,7 +4230,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" @@ -4029,7 +4548,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -4090,6 +4609,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", @@ -4189,6 +4727,18 @@ "url": "https://ko-fi.com/intcreator" } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4227,6 +4777,15 @@ "ms": "2.0.0" } }, + "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", @@ -4309,6 +4868,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -4339,6 +4908,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/dotenv": { "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", @@ -4360,6 +4935,20 @@ "node": ">=12" } }, + "node_modules/dtrace-provider": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.8.tgz", + "integrity": "sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==", + "hasInstallScript": true, + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "nan": "^2.14.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4501,6 +5090,12 @@ "node": ">= 0.4" } }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4859,7 +5454,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -5484,7 +6078,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -6740,7 +7334,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -6761,6 +7354,12 @@ "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "license": "MIT" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -7101,7 +7700,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -7153,12 +7752,53 @@ "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", "license": "MIT" }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, "node_modules/multer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", @@ -7184,6 +7824,21 @@ "dev": true, "license": "ISC" }, + "node_modules/mv": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", + "integrity": "sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "~0.5.1", + "ncp": "~2.0.0", + "rimraf": "~2.4.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/nan": { "version": "2.25.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz", @@ -7198,6 +7853,16 @@ "dev": true, "license": "MIT" }, + "node_modules/ncp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", + "integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==", + "license": "MIT", + "optional": true, + "bin": { + "ncp": "bin/ncp" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -7274,7 +7939,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", - "dev": true, "license": "MIT" }, "node_modules/node-cron": { @@ -7316,6 +7980,15 @@ } } }, + "node_modules/node-forge": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -7328,6 +8001,21 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -7335,6 +8023,47 @@ "dev": true, "license": "MIT" }, + "node_modules/node-jose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-jose/-/node-jose-2.2.0.tgz", + "integrity": "sha512-XPCvJRr94SjLrSIm4pbYHKLEaOsDvJCpyFw/6V/KK/IXmyZ6SFBzAUDO9HQf4DB/nTEFcRGH87mNciOP23kFjw==", + "license": "Apache-2.0", + "dependencies": { + "base64url": "^3.0.1", + "buffer": "^6.0.3", + "es6-promise": "^4.2.8", + "lodash": "^4.17.21", + "long": "^5.2.0", + "node-forge": "^1.2.1", + "pako": "^2.0.4", + "process": "^0.11.10", + "uuid": "^9.0.0" + } + }, + "node_modules/node-jose/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -7365,6 +8094,12 @@ "node": ">=8" } }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7458,6 +8193,20 @@ "node": ">=0.10.0" } }, + "node_modules/otplib": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.3.0.tgz", + "integrity": "sha512-VYMKyyDG8yt2q+z58sz54/EIyTh7+tyMrjeemR44iVh5+dkKtIs57irTqxjH+IkAL1uMmG1JIFhG5CxTpqdU5g==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.3.0", + "@otplib/hotp": "13.3.0", + "@otplib/plugin-base32-scure": "13.3.0", + "@otplib/plugin-crypto-noble": "13.3.0", + "@otplib/totp": "13.3.0", + "@otplib/uri": "13.3.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7478,7 +8227,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -7491,7 +8239,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -7507,7 +8254,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" @@ -7519,6 +8265,12 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7578,6 +8330,107 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-azure-ad": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/passport-azure-ad/-/passport-azure-ad-4.3.5.tgz", + "integrity": "sha512-LBpXEght7hCMuMNFK4oegdN0uPBa3lpDMy71zQoB0zPg1RrGwdzpjwTiN1WzN0hY77fLyjz9tBr3TGAxnSgtEg==", + "deprecated": "This package is deprecated and no longer supported. For more please visit https://github.com/AzureAD/passport-azure-ad?tab=readme-ov-file#node-js-validation-replacement-for-passportjs", + "license": "MIT", + "dependencies": { + "async": "^3.2.3", + "base64url": "^3.0.0", + "bunyan": "^1.8.14", + "cache-manager": "^3.6.1", + "https-proxy-agent": "^5.0.0", + "jws": "^3.1.3", + "lodash": "^4.11.2", + "node-jose": "^2.2.0", + "oauth": "0.9.15", + "passport": "^0.6.0", + "valid-url": "^1.0.6" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/passport-azure-ad/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/passport-azure-ad/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/passport-azure-ad/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/passport-azure-ad/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/passport-azure-ad/node_modules/passport": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.6.0.tgz", + "integrity": "sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/passport-jwt": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", @@ -7599,6 +8452,32 @@ "node": ">= 0.4.0" } }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-oauth2/node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -7611,7 +8490,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" @@ -7621,7 +8499,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7827,6 +8705,15 @@ "node": ">=4" } }, + "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", @@ -7934,6 +8821,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -8023,6 +8919,93 @@ ], "license": "MIT" }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "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/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", @@ -8218,6 +9201,12 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "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", @@ -8308,6 +9297,38 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/rimraf": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", + "integrity": "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^6.0.1" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", + "integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + } + }, "node_modules/ringbufferjs": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ringbufferjs/-/ringbufferjs-2.0.0.tgz", @@ -8353,6 +9374,13 @@ ], "license": "MIT" }, + "node_modules/safe-json-stringify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz", + "integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==", + "license": "MIT", + "optional": true + }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", @@ -8488,6 +9516,12 @@ "node": ">= 0.8.0" } }, + "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", @@ -8946,6 +9980,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "20.4.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.1.tgz", + "integrity": "sha512-axCguHItc8Sxt0HC6aSkdVRPffjYPV7EQqZRb2GkIa8FzWDycE7nHJM19C6xAIynH1Qp1/BHiopSi96jGBxT0w==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/strtok3": { "version": "10.3.4", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", @@ -9479,6 +10530,24 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -9794,6 +10863,12 @@ "node": ">=8" } }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/uint8array-extras": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", @@ -9952,6 +11027,11 @@ "node": ">=10.12.0" } }, + "node_modules/valid-url": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz", + "integrity": "sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==" + }, "node_modules/validator": { "version": "13.15.26", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", @@ -10102,6 +11182,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", @@ -10148,7 +11234,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", diff --git a/backend/package.json b/backend/package.json index d194baa..08467b5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "hoa-ledgeriq-backend", - "version": "2026.03.16", + "version": "2026.3.17", "description": "HOA LedgerIQ - Backend API", "private": true, "scripts": { @@ -27,18 +27,26 @@ "@nestjs/swagger": "^7.4.2", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^10.0.2", + "@simplewebauthn/server": "^13.3.0", "bcryptjs": "^3.0.3", + "bullmq": "^5.71.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "cookie-parser": "^1.4.7", "helmet": "^8.1.0", "ioredis": "^5.4.2", "newrelic": "latest", + "otplib": "^13.3.0", "passport": "^0.7.0", + "passport-azure-ad": "^4.3.5", + "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "pg": "^8.13.1", + "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "stripe": "^20.4.1", "typeorm": "^0.3.20", "uuid": "^9.0.1" }, @@ -47,12 +55,15 @@ "@nestjs/schematics": "^10.2.3", "@nestjs/testing": "^10.4.15", "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", "@types/multer": "^2.0.0", "@types/node": "^20.17.12", + "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", + "@types/qrcode": "^1.5.6", "@types/uuid": "^9.0.8", "jest": "^29.7.0", "ts-jest": "^29.2.5", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e0809d1..7090f26 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -29,6 +29,9 @@ import { AttachmentsModule } from './modules/attachments/attachments.module'; import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module'; import { HealthScoresModule } from './modules/health-scores/health-scores.module'; import { BoardPlanningModule } from './modules/board-planning/board-planning.module'; +import { BillingModule } from './modules/billing/billing.module'; +import { EmailModule } from './modules/email/email.module'; +import { OnboardingModule } from './modules/onboarding/onboarding.module'; import { ScheduleModule } from '@nestjs/schedule'; @Module({ @@ -81,6 +84,9 @@ import { ScheduleModule } from '@nestjs/schedule'; InvestmentPlanningModule, HealthScoresModule, BoardPlanningModule, + BillingModule, + EmailModule, + OnboardingModule, ScheduleModule.forRoot(), ], controllers: [AppController], diff --git a/backend/src/main.ts b/backend/src/main.ts index 43f28ff..b7491e5 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -4,6 +4,7 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import helmet from 'helmet'; +import * as cookieParser from 'cookie-parser'; import { AppModule } from './app.module'; const cluster = _cluster as any; // Cast to 'any' bypasses the missing property errors @@ -38,10 +39,15 @@ if (WORKERS > 1 && cluster.isPrimary) { async function bootstrap() { const app = await NestFactory.create(AppModule, { logger: isProduction ? ['error', 'warn', 'log'] : ['error', 'warn', 'log', 'debug', 'verbose'], + // Enable raw body for Stripe webhook signature verification + rawBody: true, }); app.setGlobalPrefix('api'); + // Cookie parser — needed for refresh token httpOnly cookies + app.use(cookieParser()); + // Security headers — Helmet sets CSP, X-Frame-Options, X-Content-Type-Options, // Referrer-Policy, Permissions-Policy, and removes X-Powered-By app.use( diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 3cffad7..67fdb24 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -6,10 +6,14 @@ import { UseGuards, Request, Get, + Res, + Query, + BadRequestException, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; import { Throttle } from '@nestjs/throttler'; +import { Response } from 'express'; import { AuthService } from './auth.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; @@ -17,6 +21,28 @@ import { SwitchOrgDto } from './dto/switch-org.dto'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { AllowViewer } from '../../common/decorators/allow-viewer.decorator'; +const COOKIE_NAME = 'ledgeriq_rt'; +const isProduction = process.env.NODE_ENV === 'production'; + +function setRefreshCookie(res: Response, token: string) { + res.cookie(COOKIE_NAME, token, { + httpOnly: true, + secure: isProduction, + sameSite: 'strict', + path: '/api/auth', + maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days + }); +} + +function clearRefreshCookie(res: Response) { + res.clearCookie(COOKIE_NAME, { + httpOnly: true, + secure: isProduction, + sameSite: 'strict', + path: '/api/auth', + }); +} + @ApiTags('auth') @Controller('auth') export class AuthController { @@ -25,18 +51,65 @@ export class AuthController { @Post('register') @ApiOperation({ summary: 'Register a new user' }) @Throttle({ default: { limit: 5, ttl: 60000 } }) - async register(@Body() dto: RegisterDto) { - return this.authService.register(dto); + async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) { + const result = await this.authService.register(dto); + if (result.refreshToken) { + setRefreshCookie(res, result.refreshToken); + } + const { refreshToken, ...response } = result; + return response; } @Post('login') @ApiOperation({ summary: 'Login with email and password' }) @Throttle({ default: { limit: 5, ttl: 60000 } }) @UseGuards(AuthGuard('local')) - async login(@Request() req: any, @Body() _dto: LoginDto) { + async login(@Request() req: any, @Body() _dto: LoginDto, @Res({ passthrough: true }) res: Response) { const ip = req.headers['x-forwarded-for'] || req.ip; const ua = req.headers['user-agent']; - return this.authService.login(req.user, ip, ua); + const result = await this.authService.login(req.user, ip, ua); + + // MFA challenge — no cookie, just return the challenge token + if ('mfaRequired' in result) { + return result; + } + + if ('refreshToken' in result && result.refreshToken) { + setRefreshCookie(res, result.refreshToken); + } + const { refreshToken: _rt, ...response } = result as any; + return response; + } + + @Post('refresh') + @ApiOperation({ summary: 'Refresh access token using httpOnly cookie' }) + async refresh(@Request() req: any, @Res({ passthrough: true }) res: Response) { + const rawToken = req.cookies?.[COOKIE_NAME]; + if (!rawToken) { + throw new BadRequestException('No refresh token'); + } + return this.authService.refreshAccessToken(rawToken); + } + + @Post('logout') + @ApiOperation({ summary: 'Logout and revoke refresh token' }) + async logout(@Request() req: any, @Res({ passthrough: true }) res: Response) { + const rawToken = req.cookies?.[COOKIE_NAME]; + if (rawToken) { + await this.authService.logout(rawToken); + } + clearRefreshCookie(res); + return { success: true }; + } + + @Post('logout-everywhere') + @ApiOperation({ summary: 'Revoke all sessions' }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + async logoutEverywhere(@Request() req: any, @Res({ passthrough: true }) res: Response) { + await this.authService.logoutEverywhere(req.user.sub); + clearRefreshCookie(res); + return { success: true }; } @Get('profile') @@ -62,9 +135,52 @@ export class AuthController { @ApiBearerAuth() @UseGuards(JwtAuthGuard) @AllowViewer() - async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) { + async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto, @Res({ passthrough: true }) res: Response) { const ip = req.headers['x-forwarded-for'] || req.ip; const ua = req.headers['user-agent']; - return this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua); + const result = await this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua); + if (result.refreshToken) { + setRefreshCookie(res, result.refreshToken); + } + const { refreshToken, ...response } = result; + return response; + } + + // ─── Activation Endpoints ───────────────────────────────────────── + + @Get('activate') + @ApiOperation({ summary: 'Validate an activation token' }) + async validateActivation(@Query('token') token: string) { + if (!token) throw new BadRequestException('Token required'); + return this.authService.validateInviteToken(token); + } + + @Post('activate') + @ApiOperation({ summary: 'Activate user account with password' }) + @Throttle({ default: { limit: 5, ttl: 60000 } }) + async activate( + @Body() body: { token: string; password: string; fullName: string }, + @Res({ passthrough: true }) res: Response, + ) { + if (!body.token || !body.password || !body.fullName) { + throw new BadRequestException('Token, password, and fullName are required'); + } + if (body.password.length < 8) { + throw new BadRequestException('Password must be at least 8 characters'); + } + const result = await this.authService.activateUser(body.token, body.password, body.fullName); + if (result.refreshToken) { + setRefreshCookie(res, result.refreshToken); + } + const { refreshToken, ...response } = result; + return response; + } + + @Post('resend-activation') + @ApiOperation({ summary: 'Resend activation email' }) + @Throttle({ default: { limit: 2, ttl: 60000 } }) + async resendActivation(@Body() body: { email: string }) { + // Stubbed — will be implemented when email service is ready + return { success: true, message: 'If an account exists, a new activation link has been sent.' }; } } diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 44d9096..66bb361 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -4,8 +4,15 @@ import { PassportModule } from '@nestjs/passport'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { AuthController } from './auth.controller'; import { AdminController } from './admin.controller'; +import { MfaController } from './mfa.controller'; +import { SsoController } from './sso.controller'; +import { PasskeyController } from './passkey.controller'; import { AuthService } from './auth.service'; import { AdminAnalyticsService } from './admin-analytics.service'; +import { RefreshTokenService } from './refresh-token.service'; +import { MfaService } from './mfa.service'; +import { SsoService } from './sso.service'; +import { PasskeyService } from './passkey.service'; import { JwtStrategy } from './strategies/jwt.strategy'; import { LocalStrategy } from './strategies/local.strategy'; import { UsersModule } from '../users/users.module'; @@ -21,12 +28,27 @@ import { OrganizationsModule } from '../organizations/organizations.module'; inject: [ConfigService], useFactory: (configService: ConfigService) => ({ secret: configService.get('JWT_SECRET'), - signOptions: { expiresIn: '24h' }, + signOptions: { expiresIn: '1h' }, }), }), ], - controllers: [AuthController, AdminController], - providers: [AuthService, AdminAnalyticsService, JwtStrategy, LocalStrategy], - exports: [AuthService], + controllers: [ + AuthController, + AdminController, + MfaController, + SsoController, + PasskeyController, + ], + providers: [ + AuthService, + AdminAnalyticsService, + RefreshTokenService, + MfaService, + SsoService, + PasskeyService, + JwtStrategy, + LocalStrategy, + ], + exports: [AuthService, RefreshTokenService, JwtModule], }) export class AuthModule {} diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index d57b403..1c0f4ad 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -4,21 +4,33 @@ import { ConflictException, ForbiddenException, NotFoundException, + BadRequestException, + Logger, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; import { DataSource } from 'typeorm'; import * as bcrypt from 'bcryptjs'; +import { createHash } from 'crypto'; import { UsersService } from '../users/users.service'; import { RegisterDto } from './dto/register.dto'; import { User } from '../users/entities/user.entity'; +import { RefreshTokenService } from './refresh-token.service'; @Injectable() export class AuthService { + private readonly logger = new Logger(AuthService.name); + private readonly inviteSecret: string; + constructor( private usersService: UsersService, private jwtService: JwtService, + private configService: ConfigService, private dataSource: DataSource, - ) {} + private refreshTokenService: RefreshTokenService, + ) { + this.inviteSecret = this.configService.get('INVITE_TOKEN_SECRET') || 'dev-invite-secret'; + } async register(dto: RegisterDto) { const existing = await this.usersService.findByEmail(dto.email); @@ -72,9 +84,27 @@ export class AuthService { // Record login in history (org_id is null at initial login) this.recordLoginHistory(user.id, null, ipAddress, userAgent).catch(() => {}); + // If MFA is enabled, return a challenge token instead of full session + if (u.mfaEnabled && u.mfaSecret) { + const mfaToken = this.jwtService.sign( + { sub: u.id, type: 'mfa_challenge' }, + { expiresIn: '5m' }, + ); + return { mfaRequired: true, mfaToken }; + } + return this.generateTokenResponse(u); } + /** + * Complete login after MFA verification — generate full session tokens. + */ + async completeMfaLogin(userId: string): Promise { + const user = await this.usersService.findByIdWithOrgs(userId); + if (!user) throw new UnauthorizedException('User not found'); + return this.generateTokenResponse(user); + } + async getProfile(userId: string) { const user = await this.usersService.findByIdWithOrgs(userId); if (!user) { @@ -85,6 +115,7 @@ export class AuthService { email: user.email, firstName: user.firstName, lastName: user.lastName, + mfaEnabled: user.mfaEnabled || false, organizations: user.userOrganizations?.map((uo) => ({ id: uo.organization.id, name: uo.organization.name, @@ -124,8 +155,12 @@ export class AuthService { // Record org switch in login history this.recordLoginHistory(userId, organizationId, ipAddress, userAgent).catch(() => {}); + // Generate new refresh token for org switch + const refreshToken = await this.refreshTokenService.createRefreshToken(user.id); + return { accessToken: this.jwtService.sign(payload), + refreshToken, organization: { id: membership.organization.id, name: membership.organization.name, @@ -135,10 +170,145 @@ export class AuthService { }; } + /** + * Refresh an access token using a valid refresh token. + */ + async refreshAccessToken(rawRefreshToken: string) { + const userId = await this.refreshTokenService.validateRefreshToken(rawRefreshToken); + if (!userId) { + throw new UnauthorizedException('Invalid or expired refresh token'); + } + + const user = await this.usersService.findByIdWithOrgs(userId); + if (!user) { + throw new UnauthorizedException('User not found'); + } + + // Generate a new access token (keep same org context if available) + const orgs = (user.userOrganizations || []).filter( + (uo) => !uo.organization?.status || !['suspended', 'archived'].includes(uo.organization.status), + ); + const defaultOrg = orgs[0]; + + const payload: Record = { + sub: user.id, + email: user.email, + isSuperadmin: user.isSuperadmin || false, + }; + + if (defaultOrg) { + payload.orgId = defaultOrg.organizationId; + payload.role = defaultOrg.role; + } + + return { + accessToken: this.jwtService.sign(payload), + }; + } + + /** + * Logout: revoke the refresh token. + */ + async logout(rawRefreshToken: string): Promise { + if (rawRefreshToken) { + await this.refreshTokenService.revokeToken(rawRefreshToken); + } + } + + /** + * Logout everywhere: revoke all refresh tokens for a user. + */ + async logoutEverywhere(userId: string): Promise { + await this.refreshTokenService.revokeAllUserTokens(userId); + } + async markIntroSeen(userId: string): Promise { await this.usersService.markIntroSeen(userId); } + // ─── Invite Token (Activation) Methods ────────────────────────────── + + /** + * Validate an invite/activation token. + */ + async validateInviteToken(token: string) { + try { + const payload = this.jwtService.verify(token, { secret: this.inviteSecret }); + if (payload.type !== 'invite') throw new Error('Not an invite token'); + + const tokenHash = createHash('sha256').update(token).digest('hex'); + const rows = await this.dataSource.query( + `SELECT it.*, o.name as org_name FROM shared.invite_tokens it + JOIN shared.organizations o ON o.id = it.organization_id + WHERE it.token_hash = $1`, + [tokenHash], + ); + + if (rows.length === 0) throw new Error('Token not found'); + const row = rows[0]; + if (row.used_at) throw new BadRequestException('This activation link has already been used'); + if (new Date(row.expires_at) < new Date()) throw new BadRequestException('This activation link has expired'); + + return { valid: true, email: payload.email, orgName: row.org_name, orgId: payload.orgId, userId: payload.userId }; + } catch (err) { + if (err instanceof BadRequestException) throw err; + throw new BadRequestException('Invalid or expired activation link'); + } + } + + /** + * Activate a user from an invite token (set password, activate, issue session). + */ + async activateUser(token: string, password: string, fullName: string) { + const info = await this.validateInviteToken(token); + + const passwordHash = await bcrypt.hash(password, 12); + const [firstName, ...rest] = fullName.trim().split(' '); + const lastName = rest.join(' ') || ''; + + // Update user record + await this.dataSource.query( + `UPDATE shared.users SET password_hash = $1, first_name = $2, last_name = $3, + is_email_verified = true, updated_at = NOW() + WHERE id = $4`, + [passwordHash, firstName, lastName, info.userId], + ); + + // Mark invite token as used + const tokenHash = createHash('sha256').update(token).digest('hex'); + await this.dataSource.query( + `UPDATE shared.invite_tokens SET used_at = NOW() WHERE token_hash = $1`, + [tokenHash], + ); + + // Issue session + const user = await this.usersService.findByIdWithOrgs(info.userId); + if (!user) throw new NotFoundException('User not found after activation'); + + return this.generateTokenResponse(user); + } + + /** + * Generate a signed invite token for a user/org pair. + */ + async generateInviteToken(userId: string, orgId: string, email: string): Promise { + const token = this.jwtService.sign( + { type: 'invite', userId, orgId, email }, + { secret: this.inviteSecret, expiresIn: '72h' }, + ); + + const tokenHash = createHash('sha256').update(token).digest('hex'); + const expiresAt = new Date(Date.now() + 72 * 60 * 60 * 1000); + + await this.dataSource.query( + `INSERT INTO shared.invite_tokens (organization_id, user_id, token_hash, expires_at) + VALUES ($1, $2, $3, $4)`, + [orgId, userId, tokenHash, expiresAt], + ); + + return token; + } + private async recordLoginHistory( userId: string, organizationId: string | null, @@ -156,7 +326,7 @@ export class AuthService { } } - private generateTokenResponse(user: User, impersonatedBy?: string) { + async generateTokenResponse(user: User, impersonatedBy?: string) { const allOrgs = user.userOrganizations || []; // Filter out suspended/archived organizations const orgs = allOrgs.filter( @@ -179,8 +349,12 @@ export class AuthService { payload.role = defaultOrg.role; } + // Create refresh token + const refreshToken = await this.refreshTokenService.createRefreshToken(user.id); + return { accessToken: this.jwtService.sign(payload), + refreshToken, user: { id: user.id, email: user.email, @@ -189,6 +363,7 @@ export class AuthService { isSuperadmin: user.isSuperadmin || false, isPlatformOwner: user.isPlatformOwner || false, hasSeenIntro: user.hasSeenIntro || false, + mfaEnabled: user.mfaEnabled || false, }, organizations: orgs.map((uo) => ({ id: uo.organizationId, diff --git a/backend/src/modules/auth/mfa.controller.ts b/backend/src/modules/auth/mfa.controller.ts new file mode 100644 index 0000000..8e7c001 --- /dev/null +++ b/backend/src/modules/auth/mfa.controller.ts @@ -0,0 +1,121 @@ +import { + Controller, + Post, + Get, + Body, + UseGuards, + Request, + Res, + BadRequestException, + UnauthorizedException, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { JwtService } from '@nestjs/jwt'; +import { Response } from 'express'; +import { MfaService } from './mfa.service'; +import { AuthService } from './auth.service'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { AllowViewer } from '../../common/decorators/allow-viewer.decorator'; + +const COOKIE_NAME = 'ledgeriq_rt'; +const isProduction = process.env.NODE_ENV === 'production'; + +@ApiTags('auth') +@Controller('auth/mfa') +export class MfaController { + constructor( + private mfaService: MfaService, + private authService: AuthService, + private jwtService: JwtService, + ) {} + + @Post('setup') + @ApiOperation({ summary: 'Generate MFA setup (QR code + secret)' }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + async setup(@Request() req: any) { + return this.mfaService.generateSetup(req.user.sub); + } + + @Post('enable') + @ApiOperation({ summary: 'Enable MFA after verifying TOTP code' }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + async enable(@Request() req: any, @Body() body: { token: string }) { + if (!body.token) throw new BadRequestException('TOTP code required'); + return this.mfaService.enableMfa(req.user.sub, body.token); + } + + @Post('verify') + @ApiOperation({ summary: 'Verify MFA during login flow' }) + @Throttle({ default: { limit: 5, ttl: 60000 } }) + async verify( + @Body() body: { mfaToken: string; token: string; useRecovery?: boolean }, + @Res({ passthrough: true }) res: Response, + ) { + if (!body.mfaToken || !body.token) { + throw new BadRequestException('mfaToken and verification code required'); + } + + // Decode the MFA challenge token + let payload: any; + try { + payload = this.jwtService.verify(body.mfaToken); + if (payload.type !== 'mfa_challenge') throw new Error('Wrong token type'); + } catch { + throw new UnauthorizedException('Invalid or expired MFA challenge'); + } + + const userId = payload.sub; + let verified = false; + + if (body.useRecovery) { + verified = await this.mfaService.verifyRecoveryCode(userId, body.token); + } else { + verified = await this.mfaService.verifyMfa(userId, body.token); + } + + if (!verified) { + throw new UnauthorizedException('Invalid verification code'); + } + + // MFA passed — issue full session + const result = await this.authService.completeMfaLogin(userId); + if (result.refreshToken) { + res.cookie(COOKIE_NAME, result.refreshToken, { + httpOnly: true, + secure: isProduction, + sameSite: 'strict', + path: '/api/auth', + maxAge: 30 * 24 * 60 * 60 * 1000, + }); + } + const { refreshToken: _rt, ...response } = result; + return response; + } + + @Post('disable') + @ApiOperation({ summary: 'Disable MFA (requires password)' }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + async disable(@Request() req: any, @Body() body: { password: string }) { + if (!body.password) throw new BadRequestException('Password required to disable MFA'); + + // Verify password first + const user = await this.authService.validateUser(req.user.email, body.password); + if (!user) throw new UnauthorizedException('Invalid password'); + + await this.mfaService.disableMfa(req.user.sub); + return { success: true }; + } + + @Get('status') + @ApiOperation({ summary: 'Get MFA status' }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @AllowViewer() + async status(@Request() req: any) { + return this.mfaService.getStatus(req.user.sub); + } +} diff --git a/backend/src/modules/auth/mfa.service.ts b/backend/src/modules/auth/mfa.service.ts new file mode 100644 index 0000000..664fe19 --- /dev/null +++ b/backend/src/modules/auth/mfa.service.ts @@ -0,0 +1,154 @@ +import { Injectable, Logger, BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import * as bcrypt from 'bcryptjs'; +import { generateSecret, generateURI, verifySync } from 'otplib'; +import * as QRCode from 'qrcode'; +import { randomBytes } from 'crypto'; + +@Injectable() +export class MfaService { + private readonly logger = new Logger(MfaService.name); + + constructor(private dataSource: DataSource) {} + + /** + * Generate MFA setup data (secret + QR code) for a user. + */ + async generateSetup(userId: string): Promise<{ secret: string; qrDataUrl: string; otpauthUrl: string }> { + const userRows = await this.dataSource.query( + `SELECT email, mfa_enabled FROM shared.users WHERE id = $1`, + [userId], + ); + if (userRows.length === 0) throw new BadRequestException('User not found'); + + const secret = generateSecret(); + const otpauthUrl = generateURI({ secret, issuer: 'HOA LedgerIQ', label: userRows[0].email }); + const qrDataUrl = await QRCode.toDataURL(otpauthUrl); + + // Store the secret temporarily (not verified yet) + await this.dataSource.query( + `UPDATE shared.users SET mfa_secret = $1, updated_at = NOW() WHERE id = $2`, + [secret, userId], + ); + + return { secret, qrDataUrl, otpauthUrl }; + } + + /** + * Enable MFA after verifying the initial TOTP code. + * Returns recovery codes. + */ + async enableMfa(userId: string, token: string): Promise<{ recoveryCodes: string[] }> { + const userRows = await this.dataSource.query( + `SELECT mfa_secret, mfa_enabled FROM shared.users WHERE id = $1`, + [userId], + ); + if (userRows.length === 0) throw new BadRequestException('User not found'); + if (!userRows[0].mfa_secret) throw new BadRequestException('MFA setup not initiated'); + if (userRows[0].mfa_enabled) throw new BadRequestException('MFA is already enabled'); + + // Verify the token + const result = verifySync({ token, secret: userRows[0].mfa_secret }); + if (!result.valid) throw new BadRequestException('Invalid verification code'); + + // Generate recovery codes + const recoveryCodes = Array.from({ length: 10 }, () => + randomBytes(4).toString('hex').toUpperCase(), + ); + + // Hash recovery codes for storage + const hashedCodes = await Promise.all( + recoveryCodes.map((code) => bcrypt.hash(code, 10)), + ); + + // Enable MFA + await this.dataSource.query( + `UPDATE shared.users SET + mfa_enabled = true, + totp_verified_at = NOW(), + recovery_codes = $1, + updated_at = NOW() + WHERE id = $2`, + [JSON.stringify(hashedCodes), userId], + ); + + this.logger.log(`MFA enabled for user ${userId}`); + return { recoveryCodes }; + } + + /** + * Verify a TOTP code during login. + */ + async verifyMfa(userId: string, token: string): Promise { + const userRows = await this.dataSource.query( + `SELECT mfa_secret, mfa_enabled FROM shared.users WHERE id = $1`, + [userId], + ); + if (userRows.length === 0 || !userRows[0].mfa_enabled) return false; + + const result = verifySync({ token, secret: userRows[0].mfa_secret }); + return result.valid; + } + + /** + * Verify a recovery code (consumes it on success). + */ + async verifyRecoveryCode(userId: string, code: string): Promise { + const userRows = await this.dataSource.query( + `SELECT recovery_codes FROM shared.users WHERE id = $1`, + [userId], + ); + if (userRows.length === 0 || !userRows[0].recovery_codes) return false; + + const hashedCodes: string[] = JSON.parse(userRows[0].recovery_codes); + + for (let i = 0; i < hashedCodes.length; i++) { + const match = await bcrypt.compare(code.toUpperCase(), hashedCodes[i]); + if (match) { + // Remove the used code + hashedCodes.splice(i, 1); + await this.dataSource.query( + `UPDATE shared.users SET recovery_codes = $1, updated_at = NOW() WHERE id = $2`, + [JSON.stringify(hashedCodes), userId], + ); + this.logger.log(`Recovery code used for user ${userId}`); + return true; + } + } + + return false; + } + + /** + * Disable MFA (requires password verification done by caller). + */ + async disableMfa(userId: string): Promise { + await this.dataSource.query( + `UPDATE shared.users SET + mfa_enabled = false, + mfa_secret = NULL, + totp_verified_at = NULL, + recovery_codes = NULL, + updated_at = NOW() + WHERE id = $1`, + [userId], + ); + this.logger.log(`MFA disabled for user ${userId}`); + } + + /** + * Get MFA status for a user. + */ + async getStatus(userId: string): Promise<{ enabled: boolean; hasRecoveryCodes: boolean }> { + const rows = await this.dataSource.query( + `SELECT mfa_enabled, recovery_codes FROM shared.users WHERE id = $1`, + [userId], + ); + if (rows.length === 0) return { enabled: false, hasRecoveryCodes: false }; + + return { + enabled: rows[0].mfa_enabled || false, + hasRecoveryCodes: !!rows[0].recovery_codes && JSON.parse(rows[0].recovery_codes || '[]').length > 0, + }; + } +} diff --git a/backend/src/modules/auth/passkey.controller.ts b/backend/src/modules/auth/passkey.controller.ts new file mode 100644 index 0000000..75e952b --- /dev/null +++ b/backend/src/modules/auth/passkey.controller.ts @@ -0,0 +1,112 @@ +import { + Controller, + Post, + Get, + Delete, + Param, + Body, + UseGuards, + Request, + Res, + BadRequestException, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { Response } from 'express'; +import { PasskeyService } from './passkey.service'; +import { AuthService } from './auth.service'; +import { UsersService } from '../users/users.service'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { AllowViewer } from '../../common/decorators/allow-viewer.decorator'; + +const COOKIE_NAME = 'ledgeriq_rt'; +const isProduction = process.env.NODE_ENV === 'production'; + +@ApiTags('auth') +@Controller('auth/passkeys') +export class PasskeyController { + constructor( + private passkeyService: PasskeyService, + private authService: AuthService, + private usersService: UsersService, + ) {} + + @Post('register-options') + @ApiOperation({ summary: 'Get passkey registration options' }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + async getRegistrationOptions(@Request() req: any) { + return this.passkeyService.generateRegistrationOptions(req.user.sub); + } + + @Post('register') + @ApiOperation({ summary: 'Register a new passkey' }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + async register( + @Request() req: any, + @Body() body: { response: any; deviceName?: string }, + ) { + if (!body.response) throw new BadRequestException('Attestation response required'); + return this.passkeyService.verifyRegistration(req.user.sub, body.response, body.deviceName); + } + + @Post('login-options') + @ApiOperation({ summary: 'Get passkey login options' }) + @Throttle({ default: { limit: 10, ttl: 60000 } }) + async getLoginOptions(@Body() body: { email?: string }) { + return this.passkeyService.generateAuthenticationOptions(body.email); + } + + @Post('login') + @ApiOperation({ summary: 'Authenticate with passkey' }) + @Throttle({ default: { limit: 5, ttl: 60000 } }) + async login( + @Body() body: { response: any; challenge: string }, + @Res({ passthrough: true }) res: Response, + ) { + if (!body.response || !body.challenge) { + throw new BadRequestException('Assertion response and challenge required'); + } + + const { userId } = await this.passkeyService.verifyAuthentication(body.response, body.challenge); + + // Get user with orgs and generate session + const user = await this.usersService.findByIdWithOrgs(userId); + if (!user) throw new BadRequestException('User not found'); + + await this.usersService.updateLastLogin(userId); + const result = await this.authService.generateTokenResponse(user); + + if (result.refreshToken) { + res.cookie(COOKIE_NAME, result.refreshToken, { + httpOnly: true, + secure: isProduction, + sameSite: 'strict', + path: '/api/auth', + maxAge: 30 * 24 * 60 * 60 * 1000, + }); + } + + const { refreshToken: _rt, ...response } = result; + return response; + } + + @Get() + @ApiOperation({ summary: 'List registered passkeys' }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @AllowViewer() + async list(@Request() req: any) { + return this.passkeyService.listPasskeys(req.user.sub); + } + + @Delete(':id') + @ApiOperation({ summary: 'Remove a passkey' }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + async remove(@Request() req: any, @Param('id') passkeyId: string) { + await this.passkeyService.removePasskey(req.user.sub, passkeyId); + return { success: true }; + } +} diff --git a/backend/src/modules/auth/passkey.service.ts b/backend/src/modules/auth/passkey.service.ts new file mode 100644 index 0000000..6297620 --- /dev/null +++ b/backend/src/modules/auth/passkey.service.ts @@ -0,0 +1,246 @@ +import { Injectable, Logger, BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource } from 'typeorm'; +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, +} from '@simplewebauthn/server'; + +// Use inline type aliases to avoid ESM-only @simplewebauthn/types import issue +type RegistrationResponseJSON = any; +type AuthenticationResponseJSON = any; +type AuthenticatorTransportFuture = any; + +@Injectable() +export class PasskeyService { + private readonly logger = new Logger(PasskeyService.name); + private rpID: string; + private rpName: string; + private origin: string; + + constructor( + private configService: ConfigService, + private dataSource: DataSource, + ) { + this.rpID = this.configService.get('WEBAUTHN_RP_ID') || 'localhost'; + this.rpName = 'HOA LedgerIQ'; + this.origin = this.configService.get('WEBAUTHN_RP_ORIGIN') || 'http://localhost'; + } + + /** + * Generate registration options for navigator.credentials.create(). + */ + async generateRegistrationOptions(userId: string) { + const userRows = await this.dataSource.query( + `SELECT id, email, first_name, last_name FROM shared.users WHERE id = $1`, + [userId], + ); + if (userRows.length === 0) throw new BadRequestException('User not found'); + const user = userRows[0]; + + // Get existing passkeys for exclusion + const existingKeys = await this.dataSource.query( + `SELECT credential_id, transports FROM shared.user_passkeys WHERE user_id = $1`, + [userId], + ); + + const options = await generateRegistrationOptions({ + rpName: this.rpName, + rpID: this.rpID, + userID: new TextEncoder().encode(userId), + userName: user.email, + userDisplayName: `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email, + attestationType: 'none', + excludeCredentials: existingKeys.map((k: any) => ({ + id: k.credential_id, + type: 'public-key' as const, + transports: k.transports || [], + })), + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'preferred', + }, + }); + + // Store challenge temporarily + await this.dataSource.query( + `UPDATE shared.users SET webauthn_challenge = $1, updated_at = NOW() WHERE id = $2`, + [options.challenge, userId], + ); + + return options; + } + + /** + * Verify and store a passkey registration. + */ + async verifyRegistration(userId: string, response: RegistrationResponseJSON, deviceName?: string) { + const userRows = await this.dataSource.query( + `SELECT webauthn_challenge FROM shared.users WHERE id = $1`, + [userId], + ); + if (userRows.length === 0) throw new BadRequestException('User not found'); + const expectedChallenge = userRows[0].webauthn_challenge; + if (!expectedChallenge) throw new BadRequestException('No registration challenge found'); + + const verification = await verifyRegistrationResponse({ + response, + expectedChallenge, + expectedOrigin: this.origin, + expectedRPID: this.rpID, + }); + + if (!verification.verified || !verification.registrationInfo) { + throw new BadRequestException('Passkey registration verification failed'); + } + + const { credential } = verification.registrationInfo; + + // Store the passkey + await this.dataSource.query( + `INSERT INTO shared.user_passkeys (user_id, credential_id, public_key, counter, device_name, transports) + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + userId, + Buffer.from(credential.id).toString('base64url'), + Buffer.from(credential.publicKey).toString('base64url'), + credential.counter, + deviceName || 'Passkey', + credential.transports || [], + ], + ); + + // Clear challenge + await this.dataSource.query( + `UPDATE shared.users SET webauthn_challenge = NULL WHERE id = $1`, + [userId], + ); + + this.logger.log(`Passkey registered for user ${userId}`); + return { verified: true }; + } + + /** + * Generate authentication options for navigator.credentials.get(). + */ + async generateAuthenticationOptions(email?: string) { + let allowCredentials: any[] | undefined; + + if (email) { + const userRows = await this.dataSource.query( + `SELECT u.id FROM shared.users u WHERE u.email = $1`, + [email], + ); + if (userRows.length > 0) { + const passkeys = await this.dataSource.query( + `SELECT credential_id, transports FROM shared.user_passkeys WHERE user_id = $1`, + [userRows[0].id], + ); + allowCredentials = passkeys.map((k: any) => ({ + id: k.credential_id, + type: 'public-key' as const, + transports: k.transports || [], + })); + } + } + + const options = await generateAuthenticationOptions({ + rpID: this.rpID, + allowCredentials, + userVerification: 'preferred', + }); + + // Store challenge — for passkey login we need a temporary storage + // Since we don't know the user yet, store in a shared way + // In production, use Redis/session. For now, we'll pass it back and verify client-side. + return { ...options, challenge: options.challenge }; + } + + /** + * Verify authentication and return the user. + */ + async verifyAuthentication(response: AuthenticationResponseJSON, expectedChallenge: string) { + // Find the credential + const credId = response.id; + const passkeys = await this.dataSource.query( + `SELECT p.*, u.id as user_id, u.email + FROM shared.user_passkeys p + JOIN shared.users u ON u.id = p.user_id + WHERE p.credential_id = $1`, + [credId], + ); + + if (passkeys.length === 0) { + throw new UnauthorizedException('Passkey not recognized'); + } + + const passkey = passkeys[0]; + + const verification = await verifyAuthenticationResponse({ + response, + expectedChallenge, + expectedOrigin: this.origin, + expectedRPID: this.rpID, + credential: { + id: passkey.credential_id, + publicKey: Buffer.from(passkey.public_key, 'base64url'), + counter: Number(passkey.counter), + transports: (passkey.transports || []) as AuthenticatorTransportFuture[], + }, + }); + + if (!verification.verified) { + throw new UnauthorizedException('Passkey authentication failed'); + } + + // Update counter and last_used_at + await this.dataSource.query( + `UPDATE shared.user_passkeys SET counter = $1, last_used_at = NOW() WHERE id = $2`, + [verification.authenticationInfo.newCounter, passkey.id], + ); + + return { userId: passkey.user_id }; + } + + /** + * List user's registered passkeys. + */ + async listPasskeys(userId: string) { + const rows = await this.dataSource.query( + `SELECT id, device_name, created_at, last_used_at + FROM shared.user_passkeys + WHERE user_id = $1 + ORDER BY created_at DESC`, + [userId], + ); + return rows; + } + + /** + * Remove a passkey. + */ + async removePasskey(userId: string, passkeyId: string): Promise { + // Check that user has password or other passkeys + const [userRows, passkeyCount] = await Promise.all([ + this.dataSource.query(`SELECT password_hash FROM shared.users WHERE id = $1`, [userId]), + this.dataSource.query( + `SELECT COUNT(*) as cnt FROM shared.user_passkeys WHERE user_id = $1`, + [userId], + ), + ]); + + const hasPassword = !!userRows[0]?.password_hash; + const count = parseInt(passkeyCount[0]?.cnt || '0', 10); + + if (!hasPassword && count <= 1) { + throw new BadRequestException('Cannot remove your only passkey without a password set'); + } + + await this.dataSource.query( + `DELETE FROM shared.user_passkeys WHERE id = $1 AND user_id = $2`, + [passkeyId, userId], + ); + } +} diff --git a/backend/src/modules/auth/refresh-token.service.ts b/backend/src/modules/auth/refresh-token.service.ts new file mode 100644 index 0000000..9c99f01 --- /dev/null +++ b/backend/src/modules/auth/refresh-token.service.ts @@ -0,0 +1,98 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { randomBytes, createHash } from 'crypto'; + +@Injectable() +export class RefreshTokenService { + private readonly logger = new Logger(RefreshTokenService.name); + + constructor(private dataSource: DataSource) {} + + /** + * Create a new refresh token for a user. + * Returns the raw (unhashed) token to be sent as an httpOnly cookie. + */ + async createRefreshToken(userId: string): Promise { + const rawToken = randomBytes(64).toString('base64url'); + const tokenHash = this.hashToken(rawToken); + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days + + await this.dataSource.query( + `INSERT INTO shared.refresh_tokens (user_id, token_hash, expires_at) + VALUES ($1, $2, $3)`, + [userId, tokenHash, expiresAt], + ); + + return rawToken; + } + + /** + * Validate a refresh token. Returns the user_id if valid, null otherwise. + */ + async validateRefreshToken(rawToken: string): Promise { + const tokenHash = this.hashToken(rawToken); + + const rows = await this.dataSource.query( + `SELECT user_id, expires_at, revoked_at + FROM shared.refresh_tokens + WHERE token_hash = $1`, + [tokenHash], + ); + + if (rows.length === 0) return null; + + const { user_id, expires_at, revoked_at } = rows[0]; + + // Check if revoked + if (revoked_at) return null; + + // Check if expired + if (new Date(expires_at) < new Date()) return null; + + return user_id; + } + + /** + * Revoke a single refresh token. + */ + async revokeToken(rawToken: string): Promise { + const tokenHash = this.hashToken(rawToken); + + await this.dataSource.query( + `UPDATE shared.refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1`, + [tokenHash], + ); + } + + /** + * Revoke all refresh tokens for a user ("log out everywhere"). + */ + async revokeAllUserTokens(userId: string): Promise { + await this.dataSource.query( + `UPDATE shared.refresh_tokens SET revoked_at = NOW() + WHERE user_id = $1 AND revoked_at IS NULL`, + [userId], + ); + } + + /** + * Remove expired / revoked tokens older than 7 days. + * Called periodically to keep the table clean. + */ + async cleanupExpired(): Promise { + const result = await this.dataSource.query( + `DELETE FROM shared.refresh_tokens + WHERE (expires_at < NOW() - INTERVAL '7 days') + OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days')`, + ); + const deleted = result?.[1] ?? 0; + if (deleted > 0) { + this.logger.log(`Cleaned up ${deleted} expired/revoked refresh tokens`); + } + return deleted; + } + + private hashToken(rawToken: string): string { + return createHash('sha256').update(rawToken).digest('hex'); + } +} diff --git a/backend/src/modules/auth/sso.controller.ts b/backend/src/modules/auth/sso.controller.ts new file mode 100644 index 0000000..2b88d03 --- /dev/null +++ b/backend/src/modules/auth/sso.controller.ts @@ -0,0 +1,105 @@ +import { + Controller, + Get, + Post, + Delete, + Param, + UseGuards, + Request, + Res, + BadRequestException, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { Response } from 'express'; +import { SsoService } from './sso.service'; +import { AuthService } from './auth.service'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; + +const COOKIE_NAME = 'ledgeriq_rt'; +const isProduction = process.env.NODE_ENV === 'production'; + +@ApiTags('auth') +@Controller('auth') +export class SsoController { + constructor( + private ssoService: SsoService, + private authService: AuthService, + ) {} + + @Get('sso/providers') + @ApiOperation({ summary: 'Get available SSO providers' }) + getProviders() { + return this.ssoService.getAvailableProviders(); + } + + // Google OAuth routes would be: + // GET /auth/google → passport.authenticate('google') + // GET /auth/google/callback → passport callback + // These are registered conditionally in auth.module.ts if env vars are set. + // For now, we'll add the callback handler: + + @Get('google/callback') + @ApiOperation({ summary: 'Google OAuth callback' }) + async googleCallback(@Request() req: any, @Res() res: Response) { + if (!req.user) { + return res.redirect('/login?error=sso_failed'); + } + + const result = await this.authService.generateTokenResponse(req.user); + + // Set refresh token cookie + if (result.refreshToken) { + res.cookie(COOKIE_NAME, result.refreshToken, { + httpOnly: true, + secure: isProduction, + sameSite: 'strict', + path: '/api/auth', + maxAge: 30 * 24 * 60 * 60 * 1000, + }); + } + + // Redirect to app with access token in URL fragment (for SPA to pick up) + return res.redirect(`/sso-callback?token=${result.accessToken}`); + } + + @Get('azure/callback') + @ApiOperation({ summary: 'Azure AD OAuth callback' }) + async azureCallback(@Request() req: any, @Res() res: Response) { + if (!req.user) { + return res.redirect('/login?error=sso_failed'); + } + + const result = await this.authService.generateTokenResponse(req.user); + + if (result.refreshToken) { + res.cookie(COOKIE_NAME, result.refreshToken, { + httpOnly: true, + secure: isProduction, + sameSite: 'strict', + path: '/api/auth', + maxAge: 30 * 24 * 60 * 60 * 1000, + }); + } + + return res.redirect(`/sso-callback?token=${result.accessToken}`); + } + + @Post('sso/link') + @ApiOperation({ summary: 'Link SSO provider to current user' }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + async linkAccount(@Request() req: any) { + // This would typically be done via the OAuth redirect flow + // For now, it's a placeholder + throw new BadRequestException('Use the OAuth redirect flow to link accounts'); + } + + @Delete('sso/unlink/:provider') + @ApiOperation({ summary: 'Unlink SSO provider from current user' }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + async unlinkAccount(@Request() req: any, @Param('provider') provider: string) { + await this.ssoService.unlinkSsoAccount(req.user.sub, provider); + return { success: true }; + } +} diff --git a/backend/src/modules/auth/sso.service.ts b/backend/src/modules/auth/sso.service.ts new file mode 100644 index 0000000..b6bf107 --- /dev/null +++ b/backend/src/modules/auth/sso.service.ts @@ -0,0 +1,97 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { UsersService } from '../users/users.service'; + +interface SsoProfile { + provider: string; + providerId: string; + email: string; + firstName?: string; + lastName?: string; +} + +@Injectable() +export class SsoService { + private readonly logger = new Logger(SsoService.name); + + constructor( + private dataSource: DataSource, + private usersService: UsersService, + ) {} + + /** + * Find existing user by SSO provider+id, or by email match, or create new. + */ + async findOrCreateSsoUser(profile: SsoProfile) { + // 1. Try to find by provider + provider ID + const byProvider = await this.dataSource.query( + `SELECT * FROM shared.users WHERE oauth_provider = $1 AND oauth_provider_id = $2`, + [profile.provider, profile.providerId], + ); + if (byProvider.length > 0) { + return this.usersService.findByIdWithOrgs(byProvider[0].id); + } + + // 2. Try to find by email match (link accounts) + const byEmail = await this.usersService.findByEmail(profile.email); + if (byEmail) { + // Link the SSO provider to existing account + await this.linkSsoAccount(byEmail.id, profile.provider, profile.providerId); + return this.usersService.findByIdWithOrgs(byEmail.id); + } + + // 3. Create new user + const newUser = await this.dataSource.query( + `INSERT INTO shared.users (email, first_name, last_name, oauth_provider, oauth_provider_id, is_email_verified) + VALUES ($1, $2, $3, $4, $5, true) + RETURNING id`, + [profile.email, profile.firstName || '', profile.lastName || '', profile.provider, profile.providerId], + ); + + return this.usersService.findByIdWithOrgs(newUser[0].id); + } + + /** + * Link an SSO provider to an existing user. + */ + async linkSsoAccount(userId: string, provider: string, providerId: string): Promise { + await this.dataSource.query( + `UPDATE shared.users SET oauth_provider = $1, oauth_provider_id = $2, updated_at = NOW() WHERE id = $3`, + [provider, providerId, userId], + ); + this.logger.log(`Linked ${provider} SSO to user ${userId}`); + } + + /** + * Unlink SSO from a user (only if they have a password set). + */ + async unlinkSsoAccount(userId: string, provider: string): Promise { + const rows = await this.dataSource.query( + `SELECT password_hash, oauth_provider FROM shared.users WHERE id = $1`, + [userId], + ); + if (rows.length === 0) throw new BadRequestException('User not found'); + if (!rows[0].password_hash) { + throw new BadRequestException('Cannot unlink SSO — you must set a password first'); + } + if (rows[0].oauth_provider !== provider) { + throw new BadRequestException('SSO provider mismatch'); + } + + await this.dataSource.query( + `UPDATE shared.users SET oauth_provider = NULL, oauth_provider_id = NULL, updated_at = NOW() WHERE id = $1`, + [userId], + ); + this.logger.log(`Unlinked ${provider} SSO from user ${userId}`); + } + + /** + * Get which SSO providers are configured. + */ + getAvailableProviders(): { google: boolean; azure: boolean } { + return { + google: !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET), + azure: !!(process.env.AZURE_CLIENT_ID && process.env.AZURE_CLIENT_SECRET), + }; + } +} diff --git a/backend/src/modules/billing/billing.controller.ts b/backend/src/modules/billing/billing.controller.ts new file mode 100644 index 0000000..46954a1 --- /dev/null +++ b/backend/src/modules/billing/billing.controller.ts @@ -0,0 +1,63 @@ +import { + Controller, + Post, + Get, + Body, + Query, + Req, + UseGuards, + RawBodyRequest, + BadRequestException, + Request, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { Request as ExpressRequest } from 'express'; +import { BillingService } from './billing.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@ApiTags('billing') +@Controller() +export class BillingController { + constructor(private billingService: BillingService) {} + + @Post('billing/create-checkout-session') + @ApiOperation({ summary: 'Create a Stripe Checkout Session' }) + @Throttle({ default: { limit: 10, ttl: 60000 } }) + async createCheckout( + @Body() body: { planId: string; email?: string; businessName?: string }, + ) { + if (!body.planId) throw new BadRequestException('planId is required'); + return this.billingService.createCheckoutSession(body.planId, body.email, body.businessName); + } + + @Post('webhooks/stripe') + @ApiOperation({ summary: 'Stripe webhook endpoint' }) + async handleWebhook(@Req() req: RawBodyRequest) { + const signature = req.headers['stripe-signature'] as string; + if (!signature) throw new BadRequestException('Missing Stripe signature'); + if (!req.rawBody) throw new BadRequestException('Missing raw body'); + await this.billingService.handleWebhook(req.rawBody, signature); + return { received: true }; + } + + @Get('billing/status') + @ApiOperation({ summary: 'Check provisioning status for a checkout session' }) + async getStatus(@Query('session_id') sessionId: string) { + if (!sessionId) throw new BadRequestException('session_id required'); + return this.billingService.getProvisioningStatus(sessionId); + } + + @Post('billing/portal') + @ApiOperation({ summary: 'Create Stripe Customer Portal session' }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + async createPortal(@Request() req: any) { + // Lookup the org's stripe_customer_id + // Only allow president or superadmin + const orgId = req.user.orgId; + if (!orgId) throw new BadRequestException('No organization context'); + // For now, we'd look this up from the org + throw new BadRequestException('Portal session requires stripe_customer_id lookup — implement per org context'); + } +} diff --git a/backend/src/modules/billing/billing.module.ts b/backend/src/modules/billing/billing.module.ts new file mode 100644 index 0000000..510ac80 --- /dev/null +++ b/backend/src/modules/billing/billing.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { BillingService } from './billing.service'; +import { BillingController } from './billing.controller'; +import { AuthModule } from '../auth/auth.module'; +import { DatabaseModule } from '../../database/database.module'; + +@Module({ + imports: [AuthModule, DatabaseModule], + controllers: [BillingController], + providers: [BillingService], + exports: [BillingService], +}) +export class BillingModule {} diff --git a/backend/src/modules/billing/billing.service.ts b/backend/src/modules/billing/billing.service.ts new file mode 100644 index 0000000..a50602b --- /dev/null +++ b/backend/src/modules/billing/billing.service.ts @@ -0,0 +1,294 @@ +import { Injectable, Logger, BadRequestException, RawBodyRequest } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource } from 'typeorm'; +import Stripe from 'stripe'; +import { v4 as uuid } from 'uuid'; +import * as bcrypt from 'bcryptjs'; +import { TenantSchemaService } from '../../database/tenant-schema.service'; +import { AuthService } from '../auth/auth.service'; +import { EmailService } from '../email/email.service'; + +const PLAN_FEATURES: Record = { + starter: { name: 'Starter', unitLimit: 50 }, + professional: { name: 'Professional', unitLimit: 200 }, + enterprise: { name: 'Enterprise', unitLimit: 999999 }, +}; + +@Injectable() +export class BillingService { + private readonly logger = new Logger(BillingService.name); + private stripe: Stripe | null = null; + private webhookSecret: string; + private priceMap: Record; + + constructor( + private configService: ConfigService, + private dataSource: DataSource, + private tenantSchemaService: TenantSchemaService, + private authService: AuthService, + private emailService: EmailService, + ) { + const secretKey = this.configService.get('STRIPE_SECRET_KEY'); + if (secretKey && !secretKey.includes('placeholder')) { + this.stripe = new Stripe(secretKey, { apiVersion: '2025-02-24.acacia' as any }); + this.logger.log('Stripe initialized'); + } else { + this.logger.warn('Stripe not configured — billing endpoints will return stubs'); + } + + this.webhookSecret = this.configService.get('STRIPE_WEBHOOK_SECRET') || ''; + this.priceMap = { + starter: this.configService.get('STRIPE_STARTER_PRICE_ID') || '', + professional: this.configService.get('STRIPE_PROFESSIONAL_PRICE_ID') || '', + enterprise: this.configService.get('STRIPE_ENTERPRISE_PRICE_ID') || '', + }; + } + + /** + * Create a Stripe Checkout Session for a new subscription. + */ + async createCheckoutSession(planId: string, email?: string, businessName?: string): Promise<{ url: string }> { + if (!this.stripe) { + throw new BadRequestException('Stripe not configured'); + } + + const priceId = this.priceMap[planId]; + if (!priceId || priceId.includes('placeholder')) { + throw new BadRequestException(`Invalid plan: ${planId}`); + } + + const session = await this.stripe.checkout.sessions.create({ + mode: 'subscription', + payment_method_types: ['card'], + line_items: [{ price: priceId, quantity: 1 }], + success_url: `${this.getAppUrl()}/onboarding/pending?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${this.getAppUrl()}/pricing`, + customer_email: email || undefined, + metadata: { + plan_id: planId, + business_name: businessName || '', + }, + }); + + return { url: session.url! }; + } + + /** + * Handle a Stripe webhook event. + */ + async handleWebhook(rawBody: Buffer, signature: string): Promise { + if (!this.stripe) throw new BadRequestException('Stripe not configured'); + + let event: Stripe.Event; + try { + event = this.stripe.webhooks.constructEvent(rawBody, signature, this.webhookSecret); + } catch (err: any) { + this.logger.error(`Webhook signature verification failed: ${err.message}`); + throw new BadRequestException('Invalid webhook signature'); + } + + // Idempotency check + const existing = await this.dataSource.query( + `SELECT id FROM shared.stripe_events WHERE id = $1`, + [event.id], + ); + if (existing.length > 0) { + this.logger.log(`Duplicate Stripe event ${event.id}, skipping`); + return; + } + + // Record event + await this.dataSource.query( + `INSERT INTO shared.stripe_events (id, type, payload) VALUES ($1, $2, $3)`, + [event.id, event.type, JSON.stringify(event.data)], + ); + + // Dispatch + switch (event.type) { + case 'checkout.session.completed': + await this.handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session); + break; + case 'invoice.payment_succeeded': + await this.handlePaymentSucceeded(event.data.object as Stripe.Invoice); + break; + case 'invoice.payment_failed': + await this.handlePaymentFailed(event.data.object as Stripe.Invoice); + break; + case 'customer.subscription.deleted': + await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription); + break; + default: + this.logger.log(`Unhandled Stripe event: ${event.type}`); + } + } + + /** + * Get provisioning status for a checkout session. + */ + async getProvisioningStatus(sessionId: string): Promise<{ status: string; activationUrl?: string }> { + if (!this.stripe) return { status: 'not_configured' }; + + const session = await this.stripe.checkout.sessions.retrieve(sessionId); + const customerId = session.customer as string; + + if (!customerId) return { status: 'pending' }; + + const rows = await this.dataSource.query( + `SELECT id, status FROM shared.organizations WHERE stripe_customer_id = $1`, + [customerId], + ); + + if (rows.length === 0) return { status: 'provisioning' }; + if (rows[0].status === 'active') return { status: 'active' }; + return { status: 'provisioning' }; + } + + /** + * Create a Stripe Customer Portal session. + */ + async createPortalSession(customerId: string): Promise<{ url: string }> { + if (!this.stripe) throw new BadRequestException('Stripe not configured'); + + const session = await this.stripe.billingPortal.sessions.create({ + customer: customerId, + return_url: `${this.getAppUrl()}/settings`, + }); + + return { url: session.url }; + } + + // ─── Provisioning (inline, no BullMQ for now — add queue later) ───── + + private async handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise { + const customerId = session.customer as string; + const subscriptionId = session.subscription as string; + const email = session.customer_email || session.customer_details?.email || ''; + const planId = session.metadata?.plan_id || 'starter'; + const businessName = session.metadata?.business_name || 'My HOA'; + + this.logger.log(`Provisioning org for ${email}, plan=${planId}, customer=${customerId}`); + + try { + await this.provisionOrganization(customerId, subscriptionId, email, planId, businessName); + } catch (err: any) { + this.logger.error(`Provisioning failed: ${err.message}`, err.stack); + } + } + + private async handlePaymentSucceeded(invoice: Stripe.Invoice): Promise { + const customerId = invoice.customer as string; + // Activate tenant if it was pending + await this.dataSource.query( + `UPDATE shared.organizations SET status = 'active', updated_at = NOW() + WHERE stripe_customer_id = $1 AND status != 'active'`, + [customerId], + ); + } + + private async handlePaymentFailed(invoice: Stripe.Invoice): Promise { + const customerId = invoice.customer as string; + const rows = await this.dataSource.query( + `SELECT email FROM shared.organizations WHERE stripe_customer_id = $1`, + [customerId], + ); + if (rows.length > 0 && rows[0].email) { + await this.emailService.sendPaymentFailedEmail(rows[0].email, rows[0].name || 'Your organization'); + } + this.logger.warn(`Payment failed for customer ${customerId}`); + } + + private async handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise { + const customerId = subscription.customer as string; + await this.dataSource.query( + `UPDATE shared.organizations SET status = 'archived', updated_at = NOW() + WHERE stripe_customer_id = $1`, + [customerId], + ); + this.logger.log(`Subscription cancelled for customer ${customerId}`); + } + + /** + * Full provisioning flow: create org, schema, user, invite token, email. + */ + async provisionOrganization( + customerId: string, + subscriptionId: string, + email: string, + planId: string, + businessName: string, + ): Promise { + // 1. Create or upsert organization + const schemaName = `tenant_${uuid().replace(/-/g, '').substring(0, 12)}`; + + const orgRows = await this.dataSource.query( + `INSERT INTO shared.organizations (name, schema_name, status, plan_level, stripe_customer_id, stripe_subscription_id, email) + VALUES ($1, $2, 'active', $3, $4, $5, $6) + ON CONFLICT (stripe_customer_id) DO UPDATE SET + stripe_subscription_id = EXCLUDED.stripe_subscription_id, + plan_level = EXCLUDED.plan_level, + status = 'active', + updated_at = NOW() + RETURNING id, schema_name`, + [businessName, schemaName, planId, customerId, subscriptionId, email], + ); + + const orgId = orgRows[0].id; + const actualSchema = orgRows[0].schema_name; + + // 2. Create tenant schema + try { + await this.tenantSchemaService.createTenantSchema(actualSchema); + this.logger.log(`Created tenant schema: ${actualSchema}`); + } catch (err: any) { + if (err.message?.includes('already exists')) { + this.logger.log(`Schema ${actualSchema} already exists, skipping creation`); + } else { + throw err; + } + } + + // 3. Create or find user + let userRows = await this.dataSource.query( + `SELECT id FROM shared.users WHERE email = $1`, + [email], + ); + + let userId: string; + if (userRows.length === 0) { + const newUser = await this.dataSource.query( + `INSERT INTO shared.users (email, is_email_verified) + VALUES ($1, false) + RETURNING id`, + [email], + ); + userId = newUser[0].id; + } else { + userId = userRows[0].id; + } + + // 4. Create membership (president role) + await this.dataSource.query( + `INSERT INTO shared.user_organizations (user_id, organization_id, role) + VALUES ($1, $2, 'president') + ON CONFLICT (user_id, organization_id) DO NOTHING`, + [userId, orgId], + ); + + // 5. Generate invite token and "send" activation email + const inviteToken = await this.authService.generateInviteToken(userId, orgId, email); + const activationUrl = `${this.getAppUrl()}/activate?token=${inviteToken}`; + await this.emailService.sendActivationEmail(email, businessName, activationUrl); + + // 6. Initialize onboarding progress + await this.dataSource.query( + `INSERT INTO shared.onboarding_progress (organization_id) VALUES ($1) ON CONFLICT DO NOTHING`, + [orgId], + ); + + this.logger.log(`✅ Provisioning complete for org=${orgId}, user=${userId}`); + } + + private getAppUrl(): string { + return this.configService.get('APP_URL') || 'http://localhost'; + } +} diff --git a/backend/src/modules/email/email.module.ts b/backend/src/modules/email/email.module.ts new file mode 100644 index 0000000..f91efa6 --- /dev/null +++ b/backend/src/modules/email/email.module.ts @@ -0,0 +1,9 @@ +import { Module, Global } from '@nestjs/common'; +import { EmailService } from './email.service'; + +@Global() +@Module({ + providers: [EmailService], + exports: [EmailService], +}) +export class EmailModule {} diff --git a/backend/src/modules/email/email.service.ts b/backend/src/modules/email/email.service.ts new file mode 100644 index 0000000..cbf437c --- /dev/null +++ b/backend/src/modules/email/email.service.ts @@ -0,0 +1,69 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +/** + * Stubbed email service — logs to console and stores in shared.email_log. + * Replace internals with Resend/SendGrid when ready for production. + */ +@Injectable() +export class EmailService { + private readonly logger = new Logger(EmailService.name); + + constructor(private dataSource: DataSource) {} + + async sendActivationEmail(email: string, businessName: string, activationUrl: string): Promise { + const subject = `Activate your ${businessName} account on HOA LedgerIQ`; + const body = [ + `Welcome to HOA LedgerIQ!`, + ``, + `Your organization "${businessName}" has been created.`, + `Please activate your account by clicking the link below:`, + ``, + activationUrl, + ``, + `This link expires in 72 hours.`, + ].join('\n'); + + await this.log(email, subject, body, 'activation', { businessName, activationUrl }); + } + + async sendWelcomeEmail(email: string, businessName: string): Promise { + const subject = `Welcome to HOA LedgerIQ — ${businessName}`; + const body = `Your account is active. Log in at http://localhost to get started.`; + await this.log(email, subject, body, 'welcome', { businessName }); + } + + async sendPaymentFailedEmail(email: string, businessName: string): Promise { + const subject = `Payment failed for ${businessName} on HOA LedgerIQ`; + const body = `We were unable to process your payment. Please update your payment method.`; + await this.log(email, subject, body, 'payment_failed', { businessName }); + } + + async sendInviteMemberEmail(email: string, orgName: string, inviteUrl: string): Promise { + const subject = `You've been invited to ${orgName} on HOA LedgerIQ`; + const body = `You've been invited to join ${orgName}. Click here to accept: ${inviteUrl}`; + await this.log(email, subject, body, 'invite_member', { orgName, inviteUrl }); + } + + private async log( + toEmail: string, + subject: string, + body: string, + template: string, + metadata: Record, + ): Promise { + this.logger.log(`📧 EMAIL STUB → ${toEmail}`); + this.logger.log(` Subject: ${subject}`); + this.logger.log(` Body:\n${body}`); + + try { + await this.dataSource.query( + `INSERT INTO shared.email_log (to_email, subject, body, template, metadata) + VALUES ($1, $2, $3, $4, $5)`, + [toEmail, subject, body, template, JSON.stringify(metadata)], + ); + } catch (err) { + this.logger.warn(`Failed to log email: ${err}`); + } + } +} diff --git a/backend/src/modules/onboarding/onboarding.controller.ts b/backend/src/modules/onboarding/onboarding.controller.ts new file mode 100644 index 0000000..3cb1628 --- /dev/null +++ b/backend/src/modules/onboarding/onboarding.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, Patch, Body, UseGuards, Request, BadRequestException } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { AllowViewer } from '../../common/decorators/allow-viewer.decorator'; +import { OnboardingService } from './onboarding.service'; + +@ApiTags('onboarding') +@Controller('onboarding') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +export class OnboardingController { + constructor(private onboardingService: OnboardingService) {} + + @Get('progress') + @ApiOperation({ summary: 'Get onboarding progress for current org' }) + @AllowViewer() + async getProgress(@Request() req: any) { + const orgId = req.user.orgId; + if (!orgId) throw new BadRequestException('No organization context'); + return this.onboardingService.getProgress(orgId); + } + + @Patch('progress') + @ApiOperation({ summary: 'Mark an onboarding step as complete' }) + async markStep(@Request() req: any, @Body() body: { step: string }) { + const orgId = req.user.orgId; + if (!orgId) throw new BadRequestException('No organization context'); + if (!body.step) throw new BadRequestException('step is required'); + return this.onboardingService.markStepComplete(orgId, body.step); + } +} diff --git a/backend/src/modules/onboarding/onboarding.module.ts b/backend/src/modules/onboarding/onboarding.module.ts new file mode 100644 index 0000000..546dcd2 --- /dev/null +++ b/backend/src/modules/onboarding/onboarding.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { OnboardingService } from './onboarding.service'; +import { OnboardingController } from './onboarding.controller'; + +@Module({ + controllers: [OnboardingController], + providers: [OnboardingService], + exports: [OnboardingService], +}) +export class OnboardingModule {} diff --git a/backend/src/modules/onboarding/onboarding.service.ts b/backend/src/modules/onboarding/onboarding.service.ts new file mode 100644 index 0000000..493fdb9 --- /dev/null +++ b/backend/src/modules/onboarding/onboarding.service.ts @@ -0,0 +1,79 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +const REQUIRED_STEPS = ['profile', 'workspace', 'invite_member', 'first_workflow']; + +@Injectable() +export class OnboardingService { + private readonly logger = new Logger(OnboardingService.name); + + constructor(private dataSource: DataSource) {} + + async getProgress(orgId: string) { + const rows = await this.dataSource.query( + `SELECT completed_steps, completed_at, updated_at + FROM shared.onboarding_progress + WHERE organization_id = $1`, + [orgId], + ); + + if (rows.length === 0) { + // Create a fresh record + await this.dataSource.query( + `INSERT INTO shared.onboarding_progress (organization_id) + VALUES ($1) ON CONFLICT DO NOTHING`, + [orgId], + ); + return { completedSteps: [], completedAt: null, requiredSteps: REQUIRED_STEPS }; + } + + return { + completedSteps: rows[0].completed_steps || [], + completedAt: rows[0].completed_at, + requiredSteps: REQUIRED_STEPS, + }; + } + + async markStepComplete(orgId: string, step: string) { + // Add step to array (using array_append with dedup) + await this.dataSource.query( + `INSERT INTO shared.onboarding_progress (organization_id, completed_steps, updated_at) + VALUES ($1, ARRAY[$2::text], NOW()) + ON CONFLICT (organization_id) + DO UPDATE SET + completed_steps = CASE + WHEN $2 = ANY(onboarding_progress.completed_steps) THEN onboarding_progress.completed_steps + ELSE array_append(onboarding_progress.completed_steps, $2::text) + END, + updated_at = NOW()`, + [orgId, step], + ); + + // Check if all required steps are done + const rows = await this.dataSource.query( + `SELECT completed_steps FROM shared.onboarding_progress WHERE organization_id = $1`, + [orgId], + ); + + const completedSteps = rows[0]?.completed_steps || []; + const allDone = REQUIRED_STEPS.every((s) => completedSteps.includes(s)); + + if (allDone) { + await this.dataSource.query( + `UPDATE shared.onboarding_progress SET completed_at = NOW() WHERE organization_id = $1 AND completed_at IS NULL`, + [orgId], + ); + } + + return this.getProgress(orgId); + } + + async resetProgress(orgId: string) { + await this.dataSource.query( + `UPDATE shared.onboarding_progress SET completed_steps = '{}', completed_at = NULL, updated_at = NOW() + WHERE organization_id = $1`, + [orgId], + ); + return this.getProgress(orgId); + } +} diff --git a/db/migrations/015-saas-onboarding-auth.sql b/db/migrations/015-saas-onboarding-auth.sql new file mode 100644 index 0000000..92b9942 --- /dev/null +++ b/db/migrations/015-saas-onboarding-auth.sql @@ -0,0 +1,107 @@ +-- Migration 015: SaaS Onboarding + Auth (Stripe, Refresh Tokens, MFA, SSO, Passkeys) +-- Adds tables for refresh tokens, stripe event tracking, invite tokens, +-- onboarding progress, and WebAuthn passkeys. + +-- ============================================================================ +-- 1. Modify shared.organizations — add Stripe billing columns +-- ============================================================================ +ALTER TABLE shared.organizations ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255) UNIQUE; +ALTER TABLE shared.organizations ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255) UNIQUE; +ALTER TABLE shared.organizations ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMPTZ; + +-- Update plan_level CHECK constraint to include new SaaS plan tiers +-- (Drop and re-add since ALTER CHECK is not supported in PG) +ALTER TABLE shared.organizations DROP CONSTRAINT IF EXISTS organizations_plan_level_check; +ALTER TABLE shared.organizations ADD CONSTRAINT organizations_plan_level_check + CHECK (plan_level IN ('standard', 'premium', 'enterprise', 'starter', 'professional')); + +-- ============================================================================ +-- 2. New table: shared.refresh_tokens +-- ============================================================================ +CREATE TABLE IF NOT EXISTS shared.refresh_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) UNIQUE NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON shared.refresh_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON shared.refresh_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON shared.refresh_tokens(expires_at); + +-- ============================================================================ +-- 3. New table: shared.stripe_events (idempotency for webhook processing) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS shared.stripe_events ( + id VARCHAR(255) PRIMARY KEY, + type VARCHAR(100) NOT NULL, + processed_at TIMESTAMPTZ DEFAULT NOW(), + payload JSONB +); + +-- ============================================================================ +-- 4. New table: shared.invite_tokens (magic link activation) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS shared.invite_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + organization_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) UNIQUE NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_invite_tokens_hash ON shared.invite_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_invite_tokens_user ON shared.invite_tokens(user_id); + +-- ============================================================================ +-- 5. New table: shared.onboarding_progress +-- ============================================================================ +CREATE TABLE IF NOT EXISTS shared.onboarding_progress ( + organization_id UUID PRIMARY KEY REFERENCES shared.organizations(id) ON DELETE CASCADE, + completed_steps TEXT[] DEFAULT '{}', + completed_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================================ +-- 6. New table: shared.user_passkeys (WebAuthn) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS shared.user_passkeys ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE, + credential_id TEXT UNIQUE NOT NULL, + public_key TEXT NOT NULL, + counter BIGINT DEFAULT 0, + device_name VARCHAR(255), + transports TEXT[], + created_at TIMESTAMPTZ DEFAULT NOW(), + last_used_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_user_passkeys_user ON shared.user_passkeys(user_id); +CREATE INDEX IF NOT EXISTS idx_user_passkeys_cred ON shared.user_passkeys(credential_id); + +-- ============================================================================ +-- 7. Modify shared.users — add MFA/WebAuthn columns +-- ============================================================================ +ALTER TABLE shared.users ADD COLUMN IF NOT EXISTS totp_verified_at TIMESTAMPTZ; +ALTER TABLE shared.users ADD COLUMN IF NOT EXISTS recovery_codes TEXT; +ALTER TABLE shared.users ADD COLUMN IF NOT EXISTS webauthn_challenge TEXT; +ALTER TABLE shared.users ADD COLUMN IF NOT EXISTS has_seen_intro BOOLEAN DEFAULT FALSE; + +-- ============================================================================ +-- 8. Stubbed email log table (for development — replaces real email sends) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS shared.email_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + to_email VARCHAR(255) NOT NULL, + subject VARCHAR(500) NOT NULL, + body TEXT, + template VARCHAR(100), + metadata JSONB, + sent_at TIMESTAMPTZ DEFAULT NOW() +); diff --git a/docker-compose.yml b/docker-compose.yml index 553e54d..fc0a29e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,21 @@ services: - NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false} - NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-} - NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App} + - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-} + - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-} + - STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-} + - STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-} + - STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_PRICE_ID:-} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} + - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} + - GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-http://localhost/api/auth/google/callback} + - AZURE_CLIENT_ID=${AZURE_CLIENT_ID:-} + - AZURE_CLIENT_SECRET=${AZURE_CLIENT_SECRET:-} + - AZURE_TENANT_ID=${AZURE_TENANT_ID:-} + - AZURE_CALLBACK_URL=${AZURE_CALLBACK_URL:-http://localhost/api/auth/azure/callback} + - WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-localhost} + - WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-http://localhost} + - INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-dev-invite-secret} volumes: - ./backend/src:/app/src - ./backend/nest-cli.json:/app/nest-cli.json diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9f545cd..a3267ab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hoa-ledgeriq-frontend", - "version": "2026.03.10", + "version": "2026.3.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hoa-ledgeriq-frontend", - "version": "2026.03.10", + "version": "2026.3.11", "dependencies": { "@mantine/core": "^7.15.3", "@mantine/dates": "^7.15.3", @@ -14,6 +14,7 @@ "@mantine/hooks": "^7.15.3", "@mantine/modals": "^7.15.3", "@mantine/notifications": "^7.15.3", + "@simplewebauthn/browser": "^13.3.0", "@tabler/icons-react": "^3.28.1", "@tanstack/react-query": "^5.64.2", "axios": "^1.7.9", @@ -1289,6 +1290,12 @@ "win32" ] }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, "node_modules/@tabler/icons": { "version": "3.36.1", "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9e26f27..809a6b7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hoa-ledgeriq-frontend", - "version": "2026.03.16", + "version": "2026.3.17", "private": true, "type": "module", "scripts": { @@ -16,6 +16,7 @@ "@mantine/hooks": "^7.15.3", "@mantine/modals": "^7.15.3", "@mantine/notifications": "^7.15.3", + "@simplewebauthn/browser": "^13.3.0", "@tabler/icons-react": "^3.28.1", "@tanstack/react-query": "^5.64.2", "axios": "^1.7.9", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 14f3865..dc7ae07 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { AppLayout } from './components/layout/AppLayout'; import { LoginPage } from './pages/auth/LoginPage'; import { RegisterPage } from './pages/auth/RegisterPage'; import { SelectOrgPage } from './pages/auth/SelectOrgPage'; +import { ActivatePage } from './pages/auth/ActivatePage'; import { DashboardPage } from './pages/dashboard/DashboardPage'; import { AccountsPage } from './pages/accounts/AccountsPage'; import { TransactionsPage } from './pages/transactions/TransactionsPage'; @@ -37,6 +38,9 @@ import { AssessmentScenariosPage } from './pages/board-planning/AssessmentScenar import { AssessmentScenarioDetailPage } from './pages/board-planning/AssessmentScenarioDetailPage'; import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage'; import { BudgetPlanningPage } from './pages/board-planning/BudgetPlanningPage'; +import { PricingPage } from './pages/pricing/PricingPage'; +import { OnboardingPage } from './pages/onboarding/OnboardingPage'; +import { OnboardingPendingPage } from './pages/onboarding/OnboardingPendingPage'; function ProtectedRoute({ children }: { children: React.ReactNode }) { const token = useAuthStore((s) => s.token); @@ -77,6 +81,12 @@ function AuthRoute({ children }: { children: React.ReactNode }) { export function App() { return ( + {/* Public routes (no auth required) */} + } /> + } /> + } /> + + {/* Auth routes (redirect if already logged in) */} } /> + + {/* Onboarding (requires auth but not org selection) */} + + + + } + /> + + {/* Admin routes */} } /> + + {/* Main app routes (require auth + org) */} = 8) score += 25; + if (pw.length >= 12) score += 15; + if (/[A-Z]/.test(pw)) score += 20; + if (/[a-z]/.test(pw)) score += 10; + if (/[0-9]/.test(pw)) score += 15; + if (/[^A-Za-z0-9]/.test(pw)) score += 15; + return Math.min(score, 100); +} + +function strengthColor(s: number): string { + if (s < 40) return 'red'; + if (s < 70) return 'orange'; + return 'green'; +} + +export function ActivatePage() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const setAuth = useAuthStore((s) => s.setAuth); + const token = searchParams.get('token'); + + const [validating, setValidating] = useState(true); + const [tokenInfo, setTokenInfo] = useState(null); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const form = useForm({ + initialValues: { fullName: '', password: '', confirmPassword: '' }, + validate: { + fullName: (v) => (v.trim().length >= 2 ? null : 'Name is required'), + password: (v) => (v.length >= 8 ? null : 'Password must be at least 8 characters'), + confirmPassword: (v, values) => (v === values.password ? null : 'Passwords do not match'), + }, + }); + + useEffect(() => { + if (!token) { + setError('No activation token provided'); + setValidating(false); + return; + } + + api.get(`/auth/activate?token=${token}`) + .then(({ data }) => { + setTokenInfo(data); + setValidating(false); + }) + .catch((err) => { + setError(err.response?.data?.message || 'Invalid or expired activation link'); + setValidating(false); + }); + }, [token]); + + const handleSubmit = async (values: typeof form.values) => { + setLoading(true); + setError(''); + try { + const { data } = await api.post('/auth/activate', { + token, + password: values.password, + fullName: values.fullName, + }); + setAuth(data.accessToken, data.user, data.organizations); + navigate('/onboarding'); + } catch (err: any) { + setError(err.response?.data?.message || 'Activation failed'); + } finally { + setLoading(false); + } + }; + + const passwordStrength = getPasswordStrength(form.values.password); + + if (validating) { + return ( + +
+ Validating activation link... +
+ ); + } + + if (error && !tokenInfo) { + return ( + +
+ HOA LedgerIQ +
+ + } color="red" variant="light" mb="md"> + {error} + + + + Go to Login + + + +
+ ); + } + + return ( + +
+ HOA LedgerIQ +
+ + Activate your account for {tokenInfo?.orgName || 'your organization'} + + + +
+ + {error && ( + } color="red" variant="light"> + {error} + + )} + + + +
+ + {form.values.password && ( + + )} +
+ + + + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/auth/LoginPage.tsx b/frontend/src/pages/auth/LoginPage.tsx index 90e1465..edaf4f3 100644 --- a/frontend/src/pages/auth/LoginPage.tsx +++ b/frontend/src/pages/auth/LoginPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Center, Container, @@ -10,18 +10,41 @@ import { Anchor, Stack, Alert, + Divider, + Group, + PinInput, } from '@mantine/core'; import { useForm } from '@mantine/form'; -import { IconAlertCircle } from '@tabler/icons-react'; +import { + IconAlertCircle, + IconBrandGoogle, + IconBrandWindows, + IconFingerprint, + IconShieldLock, +} from '@tabler/icons-react'; import { useNavigate, Link } from 'react-router-dom'; +import { startAuthentication } from '@simplewebauthn/browser'; import api from '../../services/api'; import { useAuthStore } from '../../stores/authStore'; import { usePreferencesStore } from '../../stores/preferencesStore'; import logoSrc from '../../assets/logo.png'; +type LoginState = 'credentials' | 'mfa'; + export function LoginPage() { const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const [loginState, setLoginState] = useState('credentials'); + const [mfaToken, setMfaToken] = useState(''); + const [mfaCode, setMfaCode] = useState(''); + const [useRecovery, setUseRecovery] = useState(false); + const [recoveryCode, setRecoveryCode] = useState(''); + const [ssoProviders, setSsoProviders] = useState<{ google: boolean; azure: boolean }>({ + google: false, + azure: false, + }); + const [passkeySupported, setPasskeySupported] = useState(false); + const navigate = useNavigate(); const setAuth = useAuthStore((s) => s.setAuth); const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark'; @@ -34,20 +57,42 @@ export function LoginPage() { }, }); + // Fetch SSO providers & check passkey support on mount + useEffect(() => { + api + .get('/auth/sso/providers') + .then(({ data }) => setSsoProviders(data)) + .catch(() => {}); + + if ( + window.PublicKeyCredential && + typeof window.PublicKeyCredential === 'function' + ) { + setPasskeySupported(true); + } + }, []); + + const handleLoginSuccess = (data: any) => { + setAuth(data.accessToken, data.user, data.organizations); + if (data.user?.isSuperadmin && data.organizations.length === 0) { + navigate('/admin'); + } else if (data.organizations.length >= 1) { + navigate('/select-org'); + } else { + navigate('/'); + } + }; + const handleSubmit = async (values: typeof form.values) => { setLoading(true); setError(''); try { const { data } = await api.post('/auth/login', values); - setAuth(data.accessToken, data.user, data.organizations); - // Platform owner / superadmin with no orgs → admin panel - if (data.user?.isSuperadmin && data.organizations.length === 0) { - navigate('/admin'); - } else if (data.organizations.length >= 1) { - // Always go through org selection to ensure correct JWT with orgSchema - navigate('/select-org'); + if (data.mfaRequired) { + setMfaToken(data.mfaToken); + setLoginState('mfa'); } else { - navigate('/'); + handleLoginSuccess(data); } } catch (err: any) { setError(err.response?.data?.message || 'Login failed'); @@ -56,6 +101,181 @@ export function LoginPage() { } }; + const handleMfaVerify = async () => { + setLoading(true); + setError(''); + try { + const token = useRecovery ? recoveryCode : mfaCode; + const { data } = await api.post('/auth/mfa/verify', { + mfaToken, + token, + isRecoveryCode: useRecovery, + }); + handleLoginSuccess(data); + } catch (err: any) { + setError(err.response?.data?.message || 'MFA verification failed'); + } finally { + setLoading(false); + } + }; + + const handlePasskeyLogin = async () => { + setLoading(true); + setError(''); + try { + // Get authentication options + const { data: options } = await api.post('/auth/passkeys/login-options', { + email: form.values.email || undefined, + }); + + // Trigger browser WebAuthn prompt + const credential = await startAuthentication({ optionsJSON: options }); + + // Verify with server + const { data } = await api.post('/auth/passkeys/login', { + response: credential, + challenge: options.challenge, + }); + handleLoginSuccess(data); + } catch (err: any) { + if (err.name === 'NotAllowedError') { + setError('Passkey authentication was cancelled'); + } else { + setError(err.response?.data?.message || err.message || 'Passkey login failed'); + } + } finally { + setLoading(false); + } + }; + + const hasSso = ssoProviders.google || ssoProviders.azure; + + // MFA verification screen + if (loginState === 'mfa') { + return ( + +
+ HOA LedgerIQ +
+ + + + + + + Two-Factor Authentication + + + + {error && ( + } color="red" variant="light"> + {error} + + )} + + {!useRecovery ? ( + <> + + Enter the 6-digit code from your authenticator app + +
+ +
+ + { + setUseRecovery(true); + setError(''); + }} + style={{ cursor: 'pointer' }} + > + Use a recovery code instead + + + ) : ( + <> + + Enter one of your recovery codes + + setRecoveryCode(e.currentTarget.value)} + autoFocus + ff="monospace" + /> + + { + setUseRecovery(false); + setError(''); + }} + style={{ cursor: 'pointer' }} + > + Use authenticator code instead + + + )} + + { + setLoginState('credentials'); + setMfaToken(''); + setMfaCode(''); + setRecoveryCode(''); + setError(''); + }} + style={{ cursor: 'pointer' }} + > + ← Back to login + +
+
+
+ ); + } + + // Main login form return (
@@ -64,9 +284,12 @@ export function LoginPage() { alt="HOA LedgerIQ" style={{ height: 60, - ...(isDark ? { - filter: 'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))', - } : {}), + ...(isDark + ? { + filter: + 'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))', + } + : {}), }} />
@@ -102,6 +325,53 @@ export function LoginPage() { + + {/* Passkey login */} + {passkeySupported && ( + <> + + + + )} + + {/* SSO providers */} + {hasSso && ( + <> + + + {ssoProviders.google && ( + + )} + {ssoProviders.azure && ( + + )} + + + )}
); diff --git a/frontend/src/pages/onboarding/OnboardingPage.tsx b/frontend/src/pages/onboarding/OnboardingPage.tsx new file mode 100644 index 0000000..4b95f01 --- /dev/null +++ b/frontend/src/pages/onboarding/OnboardingPage.tsx @@ -0,0 +1,241 @@ +import { useState, useEffect } from 'react'; +import { + Container, Title, Text, Stack, Card, Group, Button, TextInput, + Select, Stepper, ThemeIcon, Progress, Alert, Loader, Center, Anchor, +} from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { + IconUser, IconBuilding, IconUserPlus, IconListDetails, + IconCheck, IconPlayerPlay, IconConfetti, +} from '@tabler/icons-react'; +import { useNavigate, Link } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '../../services/api'; +import { useAuthStore } from '../../stores/authStore'; + +const STEPS = [ + { slug: 'profile', label: 'Complete Your Profile', icon: IconUser, description: 'Set up your name and contact' }, + { slug: 'workspace', label: 'Configure Your HOA', icon: IconBuilding, description: 'Organization name and settings' }, + { slug: 'invite_member', label: 'Invite a Team Member', icon: IconUserPlus, description: 'Add a board member or manager' }, + { slug: 'first_workflow', label: 'Set Up First Account', icon: IconListDetails, description: 'Create your chart of accounts' }, +]; + +export function OnboardingPage() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const user = useAuthStore((s) => s.user); + const [activeStep, setActiveStep] = useState(0); + + const { data: progress, isLoading } = useQuery({ + queryKey: ['onboarding-progress'], + queryFn: async () => { + const { data } = await api.get('/onboarding/progress'); + return data; + }, + }); + + const markStep = useMutation({ + mutationFn: (step: string) => api.patch('/onboarding/progress', { step }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['onboarding-progress'] }), + }); + + const completedSteps = progress?.completedSteps || []; + const completedCount = completedSteps.length; + const allDone = progress?.completedAt != null; + + // Profile form + const profileForm = useForm({ + initialValues: { + firstName: user?.firstName || '', + lastName: user?.lastName || '', + phone: '', + }, + }); + + // Workspace form + const workspaceForm = useForm({ + initialValues: { orgName: '', address: '', fiscalYearStart: '1' }, + }); + + // Invite form + const inviteForm = useForm({ + initialValues: { email: '', role: 'treasurer' }, + validate: { email: (v) => (/\S+@\S+/.test(v) ? null : 'Valid email required') }, + }); + + useEffect(() => { + // Auto-advance to first incomplete step + const firstIncomplete = STEPS.findIndex((s) => !completedSteps.includes(s.slug)); + if (firstIncomplete >= 0) setActiveStep(firstIncomplete); + }, [completedSteps]); + + if (isLoading) { + return
; + } + + if (allDone) { + return ( + +
+ + + + + You're all set! + + Your workspace is ready. Let's get to work. + + + +
+
+ ); + } + + return ( + + +
+ Welcome to HOA LedgerIQ + Complete these steps to set up your workspace +
+ + + {completedCount} of {STEPS.length} steps complete + + + {/* Step 1: Profile */} + : } + completedIcon={} + color={completedSteps.includes('profile') ? 'green' : undefined} + > + +
markStep.mutate('profile'))}> + + + + + + + + +
+
+
+ + {/* Step 2: Workspace */} + : } + completedIcon={} + color={completedSteps.includes('workspace') ? 'green' : undefined} + > + +
markStep.mutate('workspace'))}> + + + + + + + + + +
+
+
+ + {/* Step 4: First Account */} + : } + completedIcon={} + color={completedSteps.includes('first_workflow') ? 'green' : undefined} + > + + + + Your chart of accounts has been pre-configured with standard HOA accounts. + You can review and customize them now, or do it later. + + + + + + + + +
+ + + + +
+
+ ); +} diff --git a/frontend/src/pages/onboarding/OnboardingPendingPage.tsx b/frontend/src/pages/onboarding/OnboardingPendingPage.tsx new file mode 100644 index 0000000..0a93d4a --- /dev/null +++ b/frontend/src/pages/onboarding/OnboardingPendingPage.tsx @@ -0,0 +1,82 @@ +import { useEffect, useState } from 'react'; +import { Container, Center, Stack, Loader, Text, Title, Alert, Button } from '@mantine/core'; +import { IconCheck, IconAlertCircle } from '@tabler/icons-react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import api from '../../services/api'; + +export function OnboardingPendingPage() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const sessionId = searchParams.get('session_id'); + const [status, setStatus] = useState('polling'); + const [error, setError] = useState(''); + + useEffect(() => { + if (!sessionId) { + setError('No session ID provided'); + return; + } + + let cancelled = false; + const poll = async () => { + try { + const { data } = await api.get(`/billing/status?session_id=${sessionId}`); + if (cancelled) return; + + if (data.status === 'active') { + setStatus('complete'); + // Redirect to login page — user will get activation email + setTimeout(() => navigate('/login'), 3000); + } else if (data.status === 'not_configured') { + setError('Payment system is not configured. Please contact support.'); + } else { + // Still provisioning — poll again + setTimeout(poll, 3000); + } + } catch (err: any) { + if (!cancelled) { + setError(err.response?.data?.message || 'Failed to check status'); + } + } + }; + + poll(); + return () => { cancelled = true; }; + }, [sessionId, navigate]); + + return ( + +
+ + {error ? ( + <> + } color="red" variant="light"> + {error} + + + + ) : status === 'complete' ? ( + <> + + Your account is ready! + + Check your email for an activation link to set your password and get started. + + Redirecting to login... + + ) : ( + <> + + Setting up your account... + + We're creating your HOA workspace. This usually takes just a few seconds. + + + )} + +
+
+ ); +} diff --git a/frontend/src/pages/pricing/PricingPage.tsx b/frontend/src/pages/pricing/PricingPage.tsx new file mode 100644 index 0000000..4ec6cbd --- /dev/null +++ b/frontend/src/pages/pricing/PricingPage.tsx @@ -0,0 +1,211 @@ +import { useState } from 'react'; +import { + Container, Title, Text, SimpleGrid, Card, Stack, Group, Badge, + Button, List, ThemeIcon, TextInput, Center, Alert, +} from '@mantine/core'; +import { IconCheck, IconX, IconRocket, IconStar, IconCrown, IconAlertCircle } from '@tabler/icons-react'; +import { useNavigate } from 'react-router-dom'; +import api from '../../services/api'; +import logoSrc from '../../assets/logo.png'; + +const plans = [ + { + id: 'starter', + name: 'Starter', + price: '$29', + period: '/month', + description: 'For small communities getting started', + icon: IconRocket, + color: 'blue', + features: [ + { text: 'Up to 50 units', included: true }, + { text: 'Chart of Accounts', included: true }, + { text: 'Assessment Tracking', included: true }, + { text: 'Basic Reports', included: true }, + { text: 'Board Planning', included: false }, + { text: 'AI Investment Advisor', included: false }, + ], + }, + { + id: 'professional', + name: 'Professional', + price: '$79', + period: '/month', + description: 'For growing HOAs that need full features', + icon: IconStar, + color: 'violet', + popular: true, + features: [ + { text: 'Up to 200 units', included: true }, + { text: 'Everything in Starter', included: true }, + { text: 'Board Planning & Scenarios', included: true }, + { text: 'AI Investment Advisor', included: true }, + { text: 'Advanced Reports', included: true }, + { text: 'Priority Support', included: false }, + ], + }, + { + id: 'enterprise', + name: 'Enterprise', + price: '$199', + period: '/month', + description: 'For large communities and management firms', + icon: IconCrown, + color: 'orange', + features: [ + { text: 'Unlimited units', included: true }, + { text: 'Everything in Professional', included: true }, + { text: 'Priority Support', included: true }, + { text: 'Custom Integrations', included: true }, + { text: 'Dedicated Account Manager', included: true }, + { text: 'SLA Guarantee', included: true }, + ], + }, +]; + +export function PricingPage() { + const navigate = useNavigate(); + const [loading, setLoading] = useState(null); + const [error, setError] = useState(''); + const [email, setEmail] = useState(''); + const [businessName, setBusinessName] = useState(''); + + const handleSelectPlan = async (planId: string) => { + setLoading(planId); + setError(''); + try { + const { data } = await api.post('/billing/create-checkout-session', { + planId, + email: email || undefined, + businessName: businessName || undefined, + }); + if (data.url) { + window.location.href = data.url; + } else { + setError('Unable to create checkout session'); + } + } catch (err: any) { + setError(err.response?.data?.message || 'Failed to start checkout'); + } finally { + setLoading(null); + } + }; + + return ( + + + HOA LedgerIQ + + Simple, transparent pricing + + + Choose the plan that fits your community. All plans include a 14-day free trial. + + + + {/* Optional pre-capture fields */} +
+ + setEmail(e.currentTarget.value)} + style={{ width: 220 }} + /> + setBusinessName(e.currentTarget.value)} + style={{ width: 220 }} + /> + +
+ + {error && ( + } color="red" mb="lg" variant="light"> + {error} + + )} + + + {plans.map((plan) => ( + + {plan.popular && ( + + Most Popular + + )} + + + + + + +
+ {plan.name} + {plan.description} +
+
+ + + + {plan.price} + + {plan.period} + + + + {plan.features.map((f, i) => ( + + {f.included ? : } + + } + > + {f.text} + + ))} + + + +
+
+ ))} +
+ + + All plans include a 14-day free trial. No credit card required to start. + +
+ ); +} diff --git a/frontend/src/pages/settings/LinkedAccounts.tsx b/frontend/src/pages/settings/LinkedAccounts.tsx new file mode 100644 index 0000000..5cf6772 --- /dev/null +++ b/frontend/src/pages/settings/LinkedAccounts.tsx @@ -0,0 +1,97 @@ +import { + Card, Title, Text, Stack, Group, Button, Badge, Alert, +} from '@mantine/core'; +import { IconBrandGoogle, IconBrandAzure, IconLink, IconLinkOff, IconAlertCircle } from '@tabler/icons-react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { notifications } from '@mantine/notifications'; +import api from '../../services/api'; + +export function LinkedAccounts() { + const queryClient = useQueryClient(); + + const { data: providers } = useQuery({ + queryKey: ['sso-providers'], + queryFn: async () => { + const { data } = await api.get('/auth/sso/providers'); + return data; + }, + }); + + const { data: profile } = useQuery({ + queryKey: ['auth-profile'], + queryFn: async () => { + const { data } = await api.get('/auth/profile'); + return data; + }, + }); + + const unlinkMutation = useMutation({ + mutationFn: (provider: string) => api.delete(`/auth/sso/unlink/${provider}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['auth-profile'] }); + notifications.show({ message: 'Account unlinked', color: 'orange' }); + }, + onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Failed to unlink', color: 'red' }), + }); + + const noProviders = !providers?.google && !providers?.azure; + + return ( + + +
+ Linked Accounts + Connect third-party accounts for single sign-on +
+
+ + {noProviders && ( + }> + No SSO providers are configured. Contact your administrator to enable Google or Microsoft SSO. + + )} + + + {providers?.google && ( + + + +
+ Google + Sign in with your Google account +
+
+ +
+ )} + + {providers?.azure && ( + + + +
+ Microsoft + Sign in with your Microsoft account +
+
+ +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/settings/MfaSettings.tsx b/frontend/src/pages/settings/MfaSettings.tsx new file mode 100644 index 0000000..d1b331d --- /dev/null +++ b/frontend/src/pages/settings/MfaSettings.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react'; +import { + Card, Title, Text, Stack, Group, Button, TextInput, + PasswordInput, Alert, Code, SimpleGrid, Badge, Image, +} from '@mantine/core'; +import { IconShieldCheck, IconShieldOff, IconAlertCircle } from '@tabler/icons-react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { notifications } from '@mantine/notifications'; +import api from '../../services/api'; + +export function MfaSettings() { + const queryClient = useQueryClient(); + const [setupData, setSetupData] = useState(null); + const [recoveryCodes, setRecoveryCodes] = useState(null); + const [verifyCode, setVerifyCode] = useState(''); + const [disablePassword, setDisablePassword] = useState(''); + const [showDisable, setShowDisable] = useState(false); + + const { data: mfaStatus, isLoading } = useQuery({ + queryKey: ['mfa-status'], + queryFn: async () => { + const { data } = await api.get('/auth/mfa/status'); + return data; + }, + }); + + const setupMutation = useMutation({ + mutationFn: () => api.post('/auth/mfa/setup'), + onSuccess: ({ data }) => setSetupData(data), + onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Setup failed', color: 'red' }), + }); + + const enableMutation = useMutation({ + mutationFn: (token: string) => api.post('/auth/mfa/enable', { token }), + onSuccess: ({ data }) => { + setRecoveryCodes(data.recoveryCodes); + setSetupData(null); + setVerifyCode(''); + queryClient.invalidateQueries({ queryKey: ['mfa-status'] }); + notifications.show({ message: 'MFA enabled successfully', color: 'green' }); + }, + onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Invalid code', color: 'red' }), + }); + + const disableMutation = useMutation({ + mutationFn: (password: string) => api.post('/auth/mfa/disable', { password }), + onSuccess: () => { + setShowDisable(false); + setDisablePassword(''); + queryClient.invalidateQueries({ queryKey: ['mfa-status'] }); + notifications.show({ message: 'MFA disabled', color: 'orange' }); + }, + onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Invalid password', color: 'red' }), + }); + + if (isLoading) return null; + + return ( + + +
+ Two-Factor Authentication (MFA) + Add an extra layer of security to your account +
+ + {mfaStatus?.enabled ? 'Enabled' : 'Disabled'} + +
+ + {/* Recovery codes display (shown once after enable) */} + {recoveryCodes && ( + } title="Save your recovery codes"> + + These codes can be used to access your account if you lose your authenticator. Save them securely — they will not be shown again. + + + {recoveryCodes.map((code, i) => ( + {code} + ))} + + + + )} + + {!mfaStatus?.enabled && !setupData && ( + + )} + + {/* QR Code Setup */} + {setupData && ( + + Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.): + + + + + Manual entry key: {setupData.secret} + + setVerifyCode(e.currentTarget.value)} + maxLength={6} + /> + + + + + + )} + + {/* Disable MFA */} + {mfaStatus?.enabled && !showDisable && ( + + )} + + {showDisable && ( + + + Disabling MFA will make your account less secure. Enter your password to confirm. + + setDisablePassword(e.currentTarget.value)} + /> + + + + + + )} +
+ ); +} diff --git a/frontend/src/pages/settings/PasskeySettings.tsx b/frontend/src/pages/settings/PasskeySettings.tsx new file mode 100644 index 0000000..f02f018 --- /dev/null +++ b/frontend/src/pages/settings/PasskeySettings.tsx @@ -0,0 +1,140 @@ +import { useState } from 'react'; +import { + Card, Title, Text, Stack, Group, Button, TextInput, + Table, Badge, ActionIcon, Tooltip, Alert, +} from '@mantine/core'; +import { IconFingerprint, IconTrash, IconPlus, IconAlertCircle } from '@tabler/icons-react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { notifications } from '@mantine/notifications'; +import { startRegistration } from '@simplewebauthn/browser'; +import api from '../../services/api'; + +export function PasskeySettings() { + const queryClient = useQueryClient(); + const [deviceName, setDeviceName] = useState(''); + const [registering, setRegistering] = useState(false); + + const { data: passkeys = [], isLoading } = useQuery({ + queryKey: ['passkeys'], + queryFn: async () => { + const { data } = await api.get('/auth/passkeys'); + return data; + }, + }); + + const removeMutation = useMutation({ + mutationFn: (id: string) => api.delete(`/auth/passkeys/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['passkeys'] }); + notifications.show({ message: 'Passkey removed', color: 'orange' }); + }, + onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Failed to remove', color: 'red' }), + }); + + const handleRegister = async () => { + setRegistering(true); + try { + // 1. Get registration options from server + const { data: options } = await api.post('/auth/passkeys/register-options'); + + // 2. Create credential via browser WebAuthn API + const credential = await startRegistration({ optionsJSON: options }); + + // 3. Send attestation to server for verification + await api.post('/auth/passkeys/register', { + response: credential, + deviceName: deviceName || 'My Passkey', + }); + + queryClient.invalidateQueries({ queryKey: ['passkeys'] }); + setDeviceName(''); + notifications.show({ message: 'Passkey registered successfully', color: 'green' }); + } catch (err: any) { + if (err.name === 'NotAllowedError') { + notifications.show({ message: 'Registration was cancelled', color: 'yellow' }); + } else { + notifications.show({ message: err.response?.data?.message || err.message || 'Registration failed', color: 'red' }); + } + } finally { + setRegistering(false); + } + }; + + const webauthnSupported = typeof window !== 'undefined' && !!window.PublicKeyCredential; + + return ( + + +
+ Passkeys + Sign in with your fingerprint, face, or security key +
+ 0 ? 'green' : 'gray'} variant="light" size="lg"> + {passkeys.length} registered + +
+ + {!webauthnSupported && ( + } mb="md"> + Your browser doesn't support WebAuthn passkeys. + + )} + + {passkeys.length > 0 && ( + + + + Device + Created + Last Used + Actions + + + + {passkeys.map((pk: any) => ( + + + + + {pk.device_name || 'Passkey'} + + + {new Date(pk.created_at).toLocaleDateString()} + + + {pk.last_used_at ? new Date(pk.last_used_at).toLocaleDateString() : 'Never'} + + + + + removeMutation.mutate(pk.id)}> + + + + + + ))} + +
+ )} + + {webauthnSupported && ( + + setDeviceName(e.currentTarget.value)} + style={{ flex: 1 }} + /> + + + )} +
+ ); +} diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index 8491396..cbb813c 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -1,14 +1,34 @@ +import { useState } from 'react'; import { Title, Text, Card, Stack, Group, SimpleGrid, Badge, ThemeIcon, Divider, + Tabs, Button, } from '@mantine/core'; import { - IconBuilding, IconUser, IconUsers, IconSettings, IconShieldLock, - IconCalendar, + IconBuilding, IconUser, IconSettings, IconShieldLock, + IconFingerprint, IconLink, IconLogout, } from '@tabler/icons-react'; +import { notifications } from '@mantine/notifications'; import { useAuthStore } from '../../stores/authStore'; +import { MfaSettings } from './MfaSettings'; +import { PasskeySettings } from './PasskeySettings'; +import { LinkedAccounts } from './LinkedAccounts'; +import api from '../../services/api'; export function SettingsPage() { const { user, currentOrg } = useAuthStore(); + const [loggingOutAll, setLoggingOutAll] = useState(false); + + const handleLogoutEverywhere = async () => { + setLoggingOutAll(true); + try { + await api.post('/auth/logout-everywhere'); + notifications.show({ message: 'All other sessions have been logged out', color: 'green' }); + } catch { + notifications.show({ message: 'Failed to log out other sessions', color: 'red' }); + } finally { + setLoggingOutAll(false); + } + }; return ( @@ -68,33 +88,6 @@ export function SettingsPage() { - {/* Security */} - - - - - -
- Security - Authentication and access -
-
- - - Authentication - Active Session - - - Two-Factor Auth - Not Configured - - - OAuth Providers - None Linked - - -
- {/* System Info */} @@ -113,7 +106,7 @@ export function SettingsPage() { Version - 2026.03.10 + 2026.03.17 API @@ -121,7 +114,71 @@ export function SettingsPage() { + + {/* Sessions */} + + + + + +
+ Sessions + Manage active sessions +
+
+ + + Current Session + Active + + + +
+ + + + {/* Security Settings */} +
+ Security + Manage authentication methods and security settings +
+ + + + }> + Two-Factor Auth + + }> + Passkeys + + }> + Linked Accounts + + + + + + + + + + + + + + + ); } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 7aeac5b..0287191 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,9 +1,10 @@ -import axios from 'axios'; +import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; import { useAuthStore } from '../stores/authStore'; const api = axios.create({ baseURL: '/api', headers: { 'Content-Type': 'application/json' }, + withCredentials: true, // Send httpOnly cookies for refresh token }); api.interceptors.request.use((config) => { @@ -14,23 +15,89 @@ api.interceptors.request.use((config) => { return config; }); +// ─── Silent Refresh Logic ───────────────────────────────────────── +let isRefreshing = false; +let pendingQueue: Array<{ + resolve: (token: string) => void; + reject: (err: any) => void; +}> = []; + +function processPendingQueue(error: any, token: string | null) { + pendingQueue.forEach((p) => { + if (error) { + p.reject(error); + } else { + p.resolve(token!); + } + }); + pendingQueue = []; +} + api.interceptors.response.use( (response) => response, - (error) => { - if (error.response?.status === 401) { + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; + + // If 401 and we haven't retried yet, try refreshing the token + if ( + error.response?.status === 401 && + originalRequest && + !originalRequest._retry && + !originalRequest.url?.includes('/auth/refresh') && + !originalRequest.url?.includes('/auth/login') + ) { + originalRequest._retry = true; + + if (isRefreshing) { + // Another request is already refreshing — queue this one + return new Promise((resolve, reject) => { + pendingQueue.push({ + resolve: (token: string) => { + originalRequest.headers.Authorization = `Bearer ${token}`; + resolve(api(originalRequest)); + }, + reject: (err: any) => reject(err), + }); + }); + } + + isRefreshing = true; + + try { + const { data } = await axios.post('/api/auth/refresh', {}, { withCredentials: true }); + const newToken = data.accessToken; + useAuthStore.getState().setToken(newToken); + originalRequest.headers.Authorization = `Bearer ${newToken}`; + processPendingQueue(null, newToken); + return api(originalRequest); + } catch (refreshError) { + processPendingQueue(refreshError, null); + useAuthStore.getState().logout(); + window.location.href = '/login'; + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + } + + // Non-retryable 401 (e.g. refresh failed, login failed) + if (error.response?.status === 401 && originalRequest?.url?.includes('/auth/refresh')) { useAuthStore.getState().logout(); window.location.href = '/login'; } + // Handle org suspended/archived — redirect to org selection + const responseData = error.response?.data as any; if ( error.response?.status === 403 && - typeof error.response?.data?.message === 'string' && - error.response.data.message.includes('has been') + typeof responseData?.message === 'string' && + responseData.message.includes('has been') ) { const store = useAuthStore.getState(); store.setCurrentOrg({ id: '', name: '', role: '' }); // Clear current org window.location.href = '/select-org'; } + return Promise.reject(error); }, ); diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index 4dc2dbe..254ca5f 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -33,6 +33,7 @@ interface AuthState { currentOrg: Organization | null; impersonationOriginal: ImpersonationOriginal | null; setAuth: (token: string, user: User, organizations: Organization[]) => void; + setToken: (token: string) => void; setCurrentOrg: (org: Organization, token?: string) => void; setUserIntroSeen: () => void; setOrgSettings: (settings: Record) => void; @@ -60,6 +61,7 @@ export const useAuthStore = create()( // Don't auto-select org — force user through SelectOrgPage currentOrg: null, }), + setToken: (token) => set({ token }), setCurrentOrg: (org, token) => set((state) => ({ currentOrg: org, @@ -102,14 +104,17 @@ export const useAuthStore = create()( }); } }, - logout: () => + logout: () => { + // Fire-and-forget server-side logout to revoke refresh token cookie + fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {}); set({ token: null, user: null, organizations: [], currentOrg: null, impersonationOriginal: null, - }), + }); + }, }), { name: 'ledgeriq-auth', -- 2.49.1 From e9738420ea99f674f5840a7c6962cf4d7fc5486a Mon Sep 17 00:00:00 2001 From: olsch01 Date: Mon, 16 Mar 2026 21:21:15 -0400 Subject: [PATCH 2/2] fix: swap Quick Stats and Recent Transactions on dashboard Co-Authored-By: Claude Opus 4.6 --- .../src/pages/dashboard/DashboardPage.tsx | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/frontend/src/pages/dashboard/DashboardPage.tsx b/frontend/src/pages/dashboard/DashboardPage.tsx index 9836106..4af9953 100644 --- a/frontend/src/pages/dashboard/DashboardPage.tsx +++ b/frontend/src/pages/dashboard/DashboardPage.tsx @@ -493,35 +493,6 @@ export function DashboardPage() { - - Recent Transactions - {(data?.recent_transactions || []).length === 0 ? ( - No transactions yet. Start by entering journal entries. - ) : ( - - - {(data?.recent_transactions || []).map((tx) => ( - - - {new Date(tx.entry_date).toLocaleDateString()} - - - {tx.description} - - - - {tx.entry_type} - - - - {fmt(tx.amount)} - - - ))} - -
- )} -
Quick Stats @@ -582,6 +553,35 @@ export function DashboardPage() { + + Recent Transactions + {(data?.recent_transactions || []).length === 0 ? ( + No transactions yet. Start by entering journal entries. + ) : ( + + + {(data?.recent_transactions || []).map((tx) => ( + + + {new Date(tx.entry_date).toLocaleDateString()} + + + {tx.description} + + + + {tx.entry_type} + + + + {fmt(tx.amount)} + + + ))} + +
+ )} +
)} -- 2.49.1