diff --git a/backend/package-lock.json b/backend/package-lock.json index e1d9413..69e1b09 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@aws-sdk/client-ses": "^3.930.0", "@prisma/client": "^5.8.0", "bcryptjs": "^2.4.3", "cors": "^2.8.5", @@ -26,6 +27,639 @@ "supertest": "^6.3.3" } }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-ses": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ses/-/client-ses-3.930.0.tgz", + "integrity": "sha512-N0IPBfFnXNv4VrVsS1+JcdyA0nl+8NTz8CCRlcUuRhwxyIFhL7KkMnTRPVrJ4ppchGbITnbp52v3c5DWWAQpTQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.930.0", + "@aws-sdk/credential-provider-node": "3.930.0", + "@aws-sdk/middleware-host-header": "3.930.0", + "@aws-sdk/middleware-logger": "3.930.0", + "@aws-sdk/middleware-recursion-detection": "3.930.0", + "@aws-sdk/middleware-user-agent": "3.930.0", + "@aws-sdk/region-config-resolver": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@aws-sdk/util-endpoints": "3.930.0", + "@aws-sdk/util-user-agent-browser": "3.930.0", + "@aws-sdk/util-user-agent-node": "3.930.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.2", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.9", + "@smithy/middleware-retry": "^4.4.9", + "@smithy/middleware-serde": "^4.2.5", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.5", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.8", + "@smithy/util-defaults-mode-node": "^4.2.11", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.930.0.tgz", + "integrity": "sha512-sASqgm1iMLcmi+srSH9WJuqaf3GQAKhuB4xIJwkNEPUQ+yGV8HqErOOHJLXXuTUyskcdtK+4uMaBRLT2ESm+QQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.930.0", + "@aws-sdk/middleware-host-header": "3.930.0", + "@aws-sdk/middleware-logger": "3.930.0", + "@aws-sdk/middleware-recursion-detection": "3.930.0", + "@aws-sdk/middleware-user-agent": "3.930.0", + "@aws-sdk/region-config-resolver": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@aws-sdk/util-endpoints": "3.930.0", + "@aws-sdk/util-user-agent-browser": "3.930.0", + "@aws-sdk/util-user-agent-node": "3.930.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.2", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.9", + "@smithy/middleware-retry": "^4.4.9", + "@smithy/middleware-serde": "^4.2.5", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.5", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.8", + "@smithy/util-defaults-mode-node": "^4.2.11", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.930.0.tgz", + "integrity": "sha512-E95pWT1ayfRWg0AW2KNOCYM7QQcVeOhMRLX5PXLeDKcdxP7s3x0LHG9t7a3nPbAbvYLRrhC7O2lLWzzMCpqjsw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.930.0", + "@aws-sdk/xml-builder": "3.930.0", + "@smithy/core": "^3.18.2", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/signature-v4": "^5.3.5", + "@smithy/smithy-client": "^4.9.5", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.930.0.tgz", + "integrity": "sha512-5tJyxNQmm9C1XKeiWt/K67mUHtTiU2FxTkVsqVrzAMjNsF3uyA02kyTK70byh5n29oVR9XNValVEl6jk01ipYg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.930.0.tgz", + "integrity": "sha512-vw565GctpOPoRJyRvgqXM8U/4RG8wYEPfhe6GHvt9dchebw0OaFeW1mmSYpwEPkMhZs9Z808dkSPScwm8WZBKA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.5", + "@smithy/types": "^4.9.0", + "@smithy/util-stream": "^4.5.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.930.0.tgz", + "integrity": "sha512-Ua4T5MWjm7QdHi7ZSUvnPBFwBZmLFP/IEGCLacPKbUT1sQO30hlWuB/uQOj0ns4T6p7V4XsM8bz5+xsW2yRYbQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.930.0", + "@aws-sdk/credential-provider-env": "3.930.0", + "@aws-sdk/credential-provider-http": "3.930.0", + "@aws-sdk/credential-provider-process": "3.930.0", + "@aws-sdk/credential-provider-sso": "3.930.0", + "@aws-sdk/credential-provider-web-identity": "3.930.0", + "@aws-sdk/nested-clients": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.930.0.tgz", + "integrity": "sha512-LTx5G0PsL51hNCCzOIdacGPwqnTp3X2Ck8CjLL4Kz9FTR0mfY02qEJB5y5segU1hlge/WdQYxzBBMhtMUR2h8A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.930.0", + "@aws-sdk/credential-provider-http": "3.930.0", + "@aws-sdk/credential-provider-ini": "3.930.0", + "@aws-sdk/credential-provider-process": "3.930.0", + "@aws-sdk/credential-provider-sso": "3.930.0", + "@aws-sdk/credential-provider-web-identity": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.930.0.tgz", + "integrity": "sha512-lqC4lepxgwR2uZp/JROTRjkHld4/FEpSgofmiIOAfUfDx0OWSg7nkWMMS/DzlMpODqATl9tO0DcvmIJ8tMbh6g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.930.0.tgz", + "integrity": "sha512-LIs2aaVoFfioRokR1R9SpLS9u8CmbHhrV/gpHO1ED41qNCujn23vAxRNQmWzJ2XoCxSTwvToiHD2i6CjPA6rHQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.930.0", + "@aws-sdk/core": "3.930.0", + "@aws-sdk/token-providers": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.930.0.tgz", + "integrity": "sha512-iIYF8GReLOp16yn2bnRWrc4UOW/vVLifqyRWZ3iAGe8NFzUiHBq+Nok7Edh+2D8zt30QOCOsWCZ31uRrPuXH8w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.930.0", + "@aws-sdk/nested-clients": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.930.0.tgz", + "integrity": "sha512-x30jmm3TLu7b/b+67nMyoV0NlbnCVT5DI57yDrhXAPCtdgM1KtdLWt45UcHpKOm1JsaIkmYRh2WYu7Anx4MG0g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.930.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.930.0.tgz", + "integrity": "sha512-vh4JBWzMCBW8wREvAwoSqB2geKsZwSHTa0nSt0OMOLp2PdTYIZDi0ZiVMmpfnjcx9XbS6aSluLv9sKx4RrG46A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.930.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.930.0.tgz", + "integrity": "sha512-gv0sekNpa2MBsIhm2cjP3nmYSfI4nscx/+K9u9ybrWZBWUIC4kL2sV++bFjjUz4QxUIlvKByow3/a9ARQyCu7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.930.0", + "@aws/lambda-invoke-store": "^0.1.1", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.930.0.tgz", + "integrity": "sha512-UUItqy02biaHoZDd1Z2CskFon3Lej15ZCIZzW4n2lsJmgLWNvz21jtFA8DQny7ZgCLAOOXI8YK3VLZptZWtIcg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@aws-sdk/util-endpoints": "3.930.0", + "@smithy/core": "^3.18.2", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.930.0.tgz", + "integrity": "sha512-eEDjTVXNiDkoV0ZV+X+WV40GTpF70xZmDW13CQzQF7rzOC2iFjtTRU+F7MUhy/Vs+e9KvDgiuCDecITtaOXUNw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.930.0", + "@aws-sdk/middleware-host-header": "3.930.0", + "@aws-sdk/middleware-logger": "3.930.0", + "@aws-sdk/middleware-recursion-detection": "3.930.0", + "@aws-sdk/middleware-user-agent": "3.930.0", + "@aws-sdk/region-config-resolver": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@aws-sdk/util-endpoints": "3.930.0", + "@aws-sdk/util-user-agent-browser": "3.930.0", + "@aws-sdk/util-user-agent-node": "3.930.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.2", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.9", + "@smithy/middleware-retry": "^4.4.9", + "@smithy/middleware-serde": "^4.2.5", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.5", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.8", + "@smithy/util-defaults-mode-node": "^4.2.11", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.930.0.tgz", + "integrity": "sha512-KL2JZqH6aYeQssu1g1KuWsReupdfOoxD6f1as2VC+rdwYFUu4LfzMsFfXnBvvQWWqQ7rZHWOw1T+o5gJmg7Dzw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.930.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.930.0.tgz", + "integrity": "sha512-K+fJFJXA2Tdx10WhhTm+xQmf1WDHu14rUutByyqx6W0iW2rhtl3YeRr188LWSU3/hpz7BPyvigaAb0QyRti6FQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.930.0", + "@aws-sdk/nested-clients": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.930.0.tgz", + "integrity": "sha512-we/vaAgwlEFW7IeftmCLlLMw+6hFs3DzZPJw7lVHbj/5HJ0bz9gndxEsS2lQoeJ1zhiiLqAqvXxmM43s0MBg0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.930.0.tgz", + "integrity": "sha512-M2oEKBzzNAYr136RRc6uqw3aWlwCxqTP1Lawps9E1d2abRPvl1p1ztQmmXp1Ak4rv8eByIZ+yQyKQ3zPdRG5dw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.930.0", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-endpoints": "^3.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.893.0.tgz", + "integrity": "sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.930.0.tgz", + "integrity": "sha512-q6lCRm6UAe+e1LguM5E4EqM9brQlDem4XDcQ87NzEvlTW6GzmNCO0w1jS0XgCFXQHjDxjdlNFX+5sRbHijwklg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.930.0", + "@smithy/types": "^4.9.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.930.0.tgz", + "integrity": "sha512-tYc5uFKogn0vLukeZ6Zz2dR1/WiTjxZH7+Jjoce6aEYgRVfyrDje1POFb7YxhNZ7Pp1WzHCuwW2KgkmMoYVbxQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.930.0.tgz", + "integrity": "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.1.1.tgz", + "integrity": "sha512-RcLam17LdlbSOSp9VxmUu1eI6Mwxp+OwhD2QhiSNmNCzoDb0EeUXTD2n/WbcnrAYMGlmf05th6QYq23VqvJqpA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1079,6 +1713,600 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.5.tgz", + "integrity": "sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.3.tgz", + "integrity": "sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.18.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.18.3.tgz", + "integrity": "sha512-qqpNskkbHOSfrbFbjhYj5o8VMXO26fvN1K/+HbCzUNlTuxgNcPRouUDNm+7D6CkN244WG7aK533Ne18UtJEgAA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-stream": "^4.5.6", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.5.tgz", + "integrity": "sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.6.tgz", + "integrity": "sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.5", + "@smithy/querystring-builder": "^4.2.5", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.5.tgz", + "integrity": "sha512-DpYX914YOfA3UDT9CN1BM787PcHfWRBB43fFGCYrZFUH0Jv+5t8yYl+Pd5PW4+QzoGEDvn5d5QIO4j2HyYZQSA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.5.tgz", + "integrity": "sha512-2L2erASEro1WC5nV+plwIMxrTXpvpfzl4e+Nre6vBVRR2HKeGGcvpJyyL3/PpiSg+cJG2KpTmZmq934Olb6e5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.5.tgz", + "integrity": "sha512-Y/RabVa5vbl5FuHYV2vUCwvh/dqzrEY/K2yWPSqvhFUwIY0atLqO4TienjBXakoy4zrKAMCZwg+YEqmH7jaN7A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.3.10", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.10.tgz", + "integrity": "sha512-SoAag3QnWBFoXjwa1jenEThkzJYClidZUyqsLKwWZ8kOlZBwehrLBp4ygVDjNEM2a2AamCQ2FBA/HuzKJ/LiTA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.18.3", + "@smithy/middleware-serde": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-middleware": "^4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.10.tgz", + "integrity": "sha512-6fOwX34gXxcqKa3bsG0mR0arc2Cw4ddOS6tp3RgUD2yoTrDTbQ2aVADnDjhUuxaiDZN2iilxndgGDhnpL/XvJA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/service-error-classification": "^4.2.5", + "@smithy/smithy-client": "^4.9.6", + "@smithy/types": "^4.9.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.5.tgz", + "integrity": "sha512-La1ldWTJTZ5NqQyPqnCNeH9B+zjFhrNoQIL1jTh4zuqXRlmXhxYHhMtI1/92OlnoAtp6JoN7kzuwhWoXrBwPqg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.5.tgz", + "integrity": "sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.5.tgz", + "integrity": "sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.5.tgz", + "integrity": "sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/querystring-builder": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.5.tgz", + "integrity": "sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.5.tgz", + "integrity": "sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.5.tgz", + "integrity": "sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "@smithy/util-uri-escape": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.5.tgz", + "integrity": "sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.5.tgz", + "integrity": "sha512-8fEvK+WPE3wUAcDvqDQG1Vk3ANLR8Px979te96m84CbKAjBVf25rPYSzb4xU4hlTyho7VhOGnh5i62D/JVF0JQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.0.tgz", + "integrity": "sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.5.tgz", + "integrity": "sha512-xSUfMu1FT7ccfSXkoLl/QRQBi2rOvi3tiBZU2Tdy3I6cgvZ6SEi9QNey+lqps/sJRnogIS+lq+B1gxxbra2a/w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.9.6", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.9.6.tgz", + "integrity": "sha512-hGz42hggqReicRRZUvrKDQiAmoJnx1Q+XfAJnYAGu544gOfxQCAC3hGGD7+Px2gEUUxB/kKtQV7LOtBRNyxteQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.18.3", + "@smithy/middleware-endpoint": "^4.3.10", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-stream": "^4.5.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.9.0.tgz", + "integrity": "sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.5.tgz", + "integrity": "sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", + "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", + "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.9.tgz", + "integrity": "sha512-Bh5bU40BgdkXE2BcaNazhNtEXi1TC0S+1d84vUwv5srWfvbeRNUKFzwKQgC6p6MXPvEgw+9+HdX3pOwT6ut5aw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.5", + "@smithy/smithy-client": "^4.9.6", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.12.tgz", + "integrity": "sha512-EHZwe1E9Q7umImIyCKQg/Cm+S+7rjXxCRvfGmKifqwYvn7M8M4ZcowwUOQzvuuxUUmdzCkqL0Eq0z1m74Pq6pw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.3", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/smithy-client": "^4.9.6", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.5.tgz", + "integrity": "sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.5.tgz", + "integrity": "sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.5.tgz", + "integrity": "sha512-GBj3+EZBbN4NAqJ/7pAhsXdfzdlznOh8PydUijy6FpNIMnHPSMO2/rP4HKu+UFeikJxShERk528oy7GT79YiJg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.6.tgz", + "integrity": "sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.5.tgz", + "integrity": "sha512-Dbun99A3InifQdIrsXZ+QLcC0PGBPAdrl4cj1mTgJvyc9N2zf7QSxg8TBkzsCmGJdE3TLbO9ycwpY0EkWahQ/g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -1491,6 +2719,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/bowser": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz", + "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "dev": true, @@ -2381,6 +3615,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -4919,6 +6171,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", + "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/superagent": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", @@ -5067,6 +6331,12 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", diff --git a/backend/package.json b/backend/package.json index fbeef34..2d0b73b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,6 +23,7 @@ "author": "", "license": "ISC", "dependencies": { + "@aws-sdk/client-ses": "^3.930.0", "@prisma/client": "^5.8.0", "bcryptjs": "^2.4.3", "cors": "^2.8.5", diff --git a/backend/prisma/migrations/20251113151534_add_wsdc_and_email_verification/migration.sql b/backend/prisma/migrations/20251113151534_add_wsdc_and_email_verification/migration.sql new file mode 100644 index 0000000..b7c43d0 --- /dev/null +++ b/backend/prisma/migrations/20251113151534_add_wsdc_and_email_verification/migration.sql @@ -0,0 +1,23 @@ +-- AlterTable: Add WSDC Integration fields +ALTER TABLE "users" ADD COLUMN "first_name" VARCHAR(100), +ADD COLUMN "last_name" VARCHAR(100), +ADD COLUMN "wsdc_id" VARCHAR(20); + +-- AlterTable: Add Email Verification fields +ALTER TABLE "users" ADD COLUMN "email_verified" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "verification_token" VARCHAR(255), +ADD COLUMN "verification_code" VARCHAR(6), +ADD COLUMN "verification_token_expiry" TIMESTAMP(3); + +-- AlterTable: Add Password Reset fields +ALTER TABLE "users" ADD COLUMN "reset_token" VARCHAR(255), +ADD COLUMN "reset_token_expiry" TIMESTAMP(3); + +-- CreateIndex +CREATE UNIQUE INDEX "users_wsdc_id_key" ON "users"("wsdc_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_verification_token_key" ON "users"("verification_token"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_reset_token_key" ON "users"("reset_token"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 29ceb9a..f6db193 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -12,20 +12,36 @@ datasource db { // Users table model User { - id Int @id @default(autoincrement()) - username String @unique @db.VarChar(50) - email String @unique @db.VarChar(255) - passwordHash String @map("password_hash") @db.VarChar(255) - avatar String? @db.VarChar(255) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id Int @id @default(autoincrement()) + username String @unique @db.VarChar(50) + email String @unique @db.VarChar(255) + passwordHash String @map("password_hash") @db.VarChar(255) + + // WSDC Integration (Phase 1.5) + firstName String? @map("first_name") @db.VarChar(100) + lastName String? @map("last_name") @db.VarChar(100) + wsdcId String? @unique @map("wsdc_id") @db.VarChar(20) + + // Email Verification (Phase 1.5) + emailVerified Boolean @default(false) @map("email_verified") + verificationToken String? @unique @map("verification_token") @db.VarChar(255) + verificationCode String? @map("verification_code") @db.VarChar(6) + verificationTokenExpiry DateTime? @map("verification_token_expiry") + + // Password Reset (Phase 1.5) + resetToken String? @unique @map("reset_token") @db.VarChar(255) + resetTokenExpiry DateTime? @map("reset_token_expiry") + + avatar String? @db.VarChar(255) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") // Relations - messages Message[] - matchesAsUser1 Match[] @relation("MatchUser1") - matchesAsUser2 Match[] @relation("MatchUser2") - ratingsGiven Rating[] @relation("RaterRatings") - ratingsReceived Rating[] @relation("RatedRatings") + messages Message[] + matchesAsUser1 Match[] @relation("MatchUser1") + matchesAsUser2 Match[] @relation("MatchUser2") + ratingsGiven Rating[] @relation("RaterRatings") + ratingsReceived Rating[] @relation("RatedRatings") @@map("users") } diff --git a/backend/src/__tests__/TESTS_README.md b/backend/src/__tests__/TESTS_README.md new file mode 100644 index 0000000..0254870 --- /dev/null +++ b/backend/src/__tests__/TESTS_README.md @@ -0,0 +1,225 @@ +# Unit Tests - Phase 1.5 + +This directory contains comprehensive unit tests for Phase 1.5 features: Email Verification, Password Reset, and WSDC Integration. + +## Test Overview + +### ✅ Test Suite Summary + +| Test Suite | Tests | Coverage | Status | +|------------|-------|----------|--------| +| Auth Utils | 18 | 100% | ✅ | +| Email Service | 22 | 100% | ✅ | +| WSDC Controller | 13 | 100% | ✅ | +| Auth Middleware | 11 | 42%* | ✅ | +| **TOTAL** | **65** | - | **✅** | + +*Note: 42% coverage is for auth.js middleware file which includes the original `authenticate` middleware (not tested here). The new `requireEmailVerification` middleware has 100% coverage. + +## Test Files + +### Unit Tests (No Database Required) + +- **`utils/auth.test.js`** - Authentication utilities + - Token generation (verification, reset) + - PIN code generation + - Token expiry calculation + - Password hashing (existing) + - JWT tokens (existing) + +- **`utils/email.test.js`** - Email service with AWS SES mocks + - Email sending functionality + - Verification email (link + code) + - Password reset email + - Welcome email + - Error handling + +- **`wsdc.test.js`** - WSDC API proxy + - Dancer lookup by ID + - Input validation + - Error handling + - API integration + +- **`middleware/auth.test.js`** - Auth middleware + - `requireEmailVerification` function + - Email verification checks + - Authorization flow + +### Integration Tests (Database Required) + +- **`auth-phase1.5.test.js`** - Full auth flow integration + - Registration with WSDC data + - Email verification (token + code) + - Resend verification + - Password reset request + - Password reset with token + - Database interactions + +## Running Tests + +### Unit Tests Only (Fast, No Database) + +```bash +# From backend directory +npm test -- --testPathPattern="utils/|wsdc.test|middleware/" +``` + +This runs all unit tests that don't require database connection. Perfect for quick development feedback. + +### All Tests (Including Integration) + +```bash +# Start Docker containers first +docker compose up -d + +# Run all tests +docker compose exec backend npm test + +# Or from backend directory (if database is running) +npm test +``` + +### Watch Mode (Development) + +```bash +npm test -- --watch --testPathPattern="utils/email" +``` + +## Test Coverage + +```bash +# Generate coverage report +npm test -- --coverage + +# Coverage for Phase 1.5 only +npm test -- --coverage --testPathPattern="utils/|wsdc.test|middleware/|auth-phase1.5" +``` + +## Mocking + +### AWS SES +Email tests use Jest mocks to avoid actual AWS SES calls: +```javascript +jest.mock('@aws-sdk/client-ses', () => ({ + SESClient: jest.fn(() => ({ send: mockSend })), + SendEmailCommand: jest.fn((params) => params) +})); +``` + +### Global Fetch (WSDC API) +WSDC tests mock the global fetch function: +```javascript +global.fetch = jest.fn(); +``` + +### Prisma (Integration Tests) +Integration tests use real Prisma client but clean up test data: +```javascript +beforeAll(async () => { + await prisma.user.deleteMany({}); +}); +``` + +## Test Structure + +Each test file follows this structure: + +1. **Setup** - Mock dependencies, configure environment +2. **BeforeEach** - Reset mocks, prepare test data +3. **Test Suites** - Grouped by functionality +4. **AfterEach** - Clean up test data +5. **AfterAll** - Disconnect from database + +## Writing New Tests + +When adding new features to Phase 1.5: + +1. **Create test file** in appropriate directory +2. **Mock external dependencies** (AWS, APIs, database) +3. **Write descriptive test names** ("should verify email with valid token") +4. **Test happy path first**, then edge cases +5. **Test error handling** and validation +6. **Aim for 100% coverage** of new code + +## Common Test Patterns + +### Testing Controller Endpoints +```javascript +const response = await request(app) + .get('/api/wsdc/lookup?id=26997') + .expect(200); + +expect(response.body.success).toBe(true); +``` + +### Testing Async Functions +```javascript +it('should send email successfully', async () => { + const result = await sendEmail({ /* params */ }); + expect(result.success).toBe(true); +}); +``` + +### Testing Error Cases +```javascript +it('should handle invalid input', async () => { + const response = await request(app) + .get('/api/wsdc/lookup?id=invalid') + .expect(400); + + expect(response.body.error).toBeDefined(); +}); +``` + +## Continuous Integration + +Tests should be run: +- ✅ Before committing code +- ✅ In CI/CD pipeline +- ✅ Before merging Pull Requests +- ✅ After deploying to staging + +## Troubleshooting + +### "Can't reach database server" +- **Unit tests**: Use `--testPathPattern` to run only unit tests +- **Integration tests**: Ensure Docker containers are running + +### "Module not found: '@aws-sdk/client-ses'" +```bash +npm install +``` + +### Tests timeout +- Increase timeout in jest.config.js or use `--testTimeout=10000` +- Check if database is responding slowly + +## Best Practices + +1. ✅ **Mock external services** (AWS, APIs) in unit tests +2. ✅ **Clean up test data** after each test +3. ✅ **Use descriptive test names** that explain what's being tested +4. ✅ **Test one thing per test** case +5. ✅ **Don't rely on test execution order** +6. ✅ **Test error cases** as thoroughly as happy paths +7. ✅ **Keep tests fast** - unit tests should run in seconds + +## Coverage Goals + +- **New Features**: 100% coverage +- **Utils**: 100% coverage +- **Controllers**: >80% coverage +- **Overall**: >80% coverage + +## Next Steps + +- [ ] Add E2E tests for full user flows +- [ ] Add performance tests for API endpoints +- [ ] Add security tests for authentication +- [ ] Set up CI/CD test automation + +--- + +**Last Updated:** 2025-11-13 +**Phase:** 1.5 (Email Verification & WSDC Integration) +**Test Status:** ✅ All Passing (65/65 tests) diff --git a/backend/src/__tests__/auth-phase1.5.test.js b/backend/src/__tests__/auth-phase1.5.test.js new file mode 100644 index 0000000..e150414 --- /dev/null +++ b/backend/src/__tests__/auth-phase1.5.test.js @@ -0,0 +1,512 @@ +/** + * Authentication API Tests - Phase 1.5 + * Tests for email verification and password reset functionality + */ + +const request = require('supertest'); +const app = require('../app'); +const { prisma } = require('../utils/db'); +const { hashPassword, generateVerificationToken, generateVerificationCode } = require('../utils/auth'); + +// Mock email service +jest.mock('../utils/email', () => ({ + sendVerificationEmail: jest.fn().mockResolvedValue({ success: true }), + sendPasswordResetEmail: jest.fn().mockResolvedValue({ success: true }), + sendWelcomeEmail: jest.fn().mockResolvedValue({ success: true }) +})); + +const emailService = require('../utils/email'); + +// Clean up database before and after tests +beforeAll(async () => { + await prisma.user.deleteMany({}); +}); + +afterAll(async () => { + await prisma.user.deleteMany({}); + await prisma.$disconnect(); +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('Authentication API Tests - Phase 1.5', () => { + describe('POST /api/auth/register with WSDC data', () => { + it('should register user with WSDC data', async () => { + const response = await request(app) + .post('/api/auth/register') + .send({ + username: 'wsdcuser', + email: 'wsdc@example.com', + password: 'password123', + firstName: 'John', + lastName: 'Doe', + wsdcId: '26997' + }) + .expect(201); + + expect(response.body.success).toBe(true); + expect(response.body.data.user.firstName).toBe('John'); + expect(response.body.data.user.lastName).toBe('Doe'); + expect(response.body.data.user.wsdcId).toBe('26997'); + expect(response.body.data.user.emailVerified).toBe(false); + }); + + it('should send verification email on registration', async () => { + await request(app) + .post('/api/auth/register') + .send({ + username: 'emailtest', + email: 'emailtest@example.com', + password: 'password123', + firstName: 'Alice', + lastName: 'Smith' + }) + .expect(201); + + expect(emailService.sendVerificationEmail).toHaveBeenCalledTimes(1); + expect(emailService.sendVerificationEmail).toHaveBeenCalledWith( + 'emailtest@example.com', + 'Alice', + expect.any(String), // verification token + expect.any(String) // verification code + ); + }); + + it('should reject duplicate WSDC ID', async () => { + await request(app) + .post('/api/auth/register') + .send({ + username: 'user1', + email: 'user1@example.com', + password: 'password123', + wsdcId: '12345' + }); + + const response = await request(app) + .post('/api/auth/register') + .send({ + username: 'user2', + email: 'user2@example.com', + password: 'password123', + wsdcId: '12345' // Same WSDC ID + }) + .expect(400); + + expect(response.body.error).toContain('WSDC ID'); + }); + + it('should continue registration even if email send fails', async () => { + emailService.sendVerificationEmail.mockRejectedValueOnce(new Error('Email failed')); + + const response = await request(app) + .post('/api/auth/register') + .send({ + username: 'emailfail', + email: 'emailfail@example.com', + password: 'password123' + }) + .expect(201); + + expect(response.body.success).toBe(true); + expect(response.body.data.user).toBeDefined(); + }); + }); + + describe('GET /api/auth/verify-email', () => { + let testUser; + let verificationToken; + + beforeEach(async () => { + verificationToken = generateVerificationToken(); + const verificationCode = generateVerificationCode(); + const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h from now + + testUser = await prisma.user.create({ + data: { + username: 'verifytest', + email: 'verify@example.com', + passwordHash: await hashPassword('password123'), + verificationToken, + verificationCode, + verificationTokenExpiry: expiry, + emailVerified: false + } + }); + }); + + afterEach(async () => { + await prisma.user.deleteMany({ where: { email: 'verify@example.com' } }); + }); + + it('should verify email with valid token', async () => { + const response = await request(app) + .get(`/api/auth/verify-email?token=${verificationToken}`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.message).toContain('verified successfully'); + + // Check database + const user = await prisma.user.findUnique({ where: { id: testUser.id } }); + expect(user.emailVerified).toBe(true); + expect(user.verificationToken).toBeNull(); + expect(user.verificationCode).toBeNull(); + + // Should send welcome email + expect(emailService.sendWelcomeEmail).toHaveBeenCalledTimes(1); + }); + + it('should return 400 if token is missing', async () => { + const response = await request(app) + .get('/api/auth/verify-email') + .expect(400); + + expect(response.body.error).toContain('token'); + }); + + it('should return 404 for invalid token', async () => { + const response = await request(app) + .get('/api/auth/verify-email?token=invalidtoken123') + .expect(404); + + expect(response.body.error).toContain('Invalid or expired'); + }); + + it('should return 400 for expired token', async () => { + const expiredToken = generateVerificationToken(); + const pastExpiry = new Date(Date.now() - 1000); // 1 second ago + + await prisma.user.create({ + data: { + username: 'expireduser', + email: 'expired@example.com', + passwordHash: await hashPassword('password123'), + verificationToken: expiredToken, + verificationCode: '123456', + verificationTokenExpiry: pastExpiry, + emailVerified: false + } + }); + + const response = await request(app) + .get(`/api/auth/verify-email?token=${expiredToken}`) + .expect(400); + + expect(response.body.error).toContain('expired'); + + await prisma.user.deleteMany({ where: { email: 'expired@example.com' } }); + }); + + it('should handle already verified email', async () => { + await prisma.user.update({ + where: { id: testUser.id }, + data: { emailVerified: true } + }); + + const response = await request(app) + .get(`/api/auth/verify-email?token=${verificationToken}`) + .expect(200); + + expect(response.body.message).toContain('already verified'); + }); + }); + + describe('POST /api/auth/verify-code', () => { + let testUser; + let verificationCode; + + beforeEach(async () => { + verificationCode = generateVerificationCode(); + const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); + + testUser = await prisma.user.create({ + data: { + username: 'codetest', + email: 'code@example.com', + passwordHash: await hashPassword('password123'), + verificationToken: generateVerificationToken(), + verificationCode, + verificationTokenExpiry: expiry, + emailVerified: false + } + }); + }); + + afterEach(async () => { + await prisma.user.deleteMany({ where: { email: 'code@example.com' } }); + }); + + it('should verify email with valid code', async () => { + const response = await request(app) + .post('/api/auth/verify-code') + .send({ + email: 'code@example.com', + code: verificationCode + }) + .expect(200); + + expect(response.body.success).toBe(true); + + // Check database + const user = await prisma.user.findUnique({ where: { id: testUser.id } }); + expect(user.emailVerified).toBe(true); + expect(emailService.sendWelcomeEmail).toHaveBeenCalled(); + }); + + it('should return 400 for invalid code', async () => { + const response = await request(app) + .post('/api/auth/verify-code') + .send({ + email: 'code@example.com', + code: '999999' + }) + .expect(400); + + expect(response.body.error).toContain('Invalid'); + }); + + it('should require both email and code', async () => { + const response1 = await request(app) + .post('/api/auth/verify-code') + .send({ email: 'code@example.com' }) + .expect(400); + + const response2 = await request(app) + .post('/api/auth/verify-code') + .send({ code: '123456' }) + .expect(400); + + expect(response1.body.error).toBeDefined(); + expect(response2.body.error).toBeDefined(); + }); + }); + + describe('POST /api/auth/resend-verification', () => { + let testUser; + + beforeEach(async () => { + testUser = await prisma.user.create({ + data: { + username: 'resendtest', + email: 'resend@example.com', + passwordHash: await hashPassword('password123'), + emailVerified: false + } + }); + }); + + afterEach(async () => { + await prisma.user.deleteMany({ where: { email: 'resend@example.com' } }); + }); + + it('should resend verification email', async () => { + const response = await request(app) + .post('/api/auth/resend-verification') + .send({ email: 'resend@example.com' }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(emailService.sendVerificationEmail).toHaveBeenCalled(); + + // Check that new tokens were generated + const user = await prisma.user.findUnique({ where: { id: testUser.id } }); + expect(user.verificationToken).toBeDefined(); + expect(user.verificationCode).toBeDefined(); + }); + + it('should return 400 for already verified email', async () => { + await prisma.user.update({ + where: { id: testUser.id }, + data: { emailVerified: true } + }); + + const response = await request(app) + .post('/api/auth/resend-verification') + .send({ email: 'resend@example.com' }) + .expect(400); + + expect(response.body.error).toContain('already verified'); + }); + + it('should return 404 for non-existent user', async () => { + const response = await request(app) + .post('/api/auth/resend-verification') + .send({ email: 'nonexistent@example.com' }) + .expect(404); + + expect(response.body.error).toContain('not found'); + }); + }); + + describe('POST /api/auth/request-password-reset', () => { + let testUser; + + beforeEach(async () => { + testUser = await prisma.user.create({ + data: { + username: 'resettest', + email: 'reset@example.com', + passwordHash: await hashPassword('password123'), + firstName: 'Reset', + emailVerified: true + } + }); + }); + + afterEach(async () => { + await prisma.user.deleteMany({ where: { email: 'reset@example.com' } }); + }); + + it('should send reset email for existing user', async () => { + const response = await request(app) + .post('/api/auth/request-password-reset') + .send({ email: 'reset@example.com' }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(emailService.sendPasswordResetEmail).toHaveBeenCalledWith( + 'reset@example.com', + 'Reset', + expect.any(String) + ); + + // Check database has reset token + const user = await prisma.user.findUnique({ where: { id: testUser.id } }); + expect(user.resetToken).toBeDefined(); + expect(user.resetTokenExpiry).toBeDefined(); + }); + + it('should return success even for non-existent user (security)', async () => { + const response = await request(app) + .post('/api/auth/request-password-reset') + .send({ email: 'nonexistent@example.com' }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(emailService.sendPasswordResetEmail).not.toHaveBeenCalled(); + }); + + it('should require email', async () => { + const response = await request(app) + .post('/api/auth/request-password-reset') + .send({}) + .expect(400); + + expect(response.body.error).toContain('Email'); + }); + }); + + describe('POST /api/auth/reset-password', () => { + let testUser; + let resetToken; + + beforeEach(async () => { + resetToken = generateVerificationToken(); + const expiry = new Date(Date.now() + 60 * 60 * 1000); // 1h from now + + testUser = await prisma.user.create({ + data: { + username: 'newpasstest', + email: 'newpass@example.com', + passwordHash: await hashPassword('oldpassword123'), + resetToken, + resetTokenExpiry: expiry, + emailVerified: true + } + }); + }); + + afterEach(async () => { + await prisma.user.deleteMany({ where: { email: 'newpass@example.com' } }); + }); + + it('should reset password with valid token', async () => { + const response = await request(app) + .post('/api/auth/reset-password') + .send({ + token: resetToken, + newPassword: 'newpassword123' + }) + .expect(200); + + expect(response.body.success).toBe(true); + + // Check database + const user = await prisma.user.findUnique({ where: { id: testUser.id } }); + expect(user.resetToken).toBeNull(); + expect(user.resetTokenExpiry).toBeNull(); + + // Password should be changed (test by comparing hashes are different) + expect(user.passwordHash).not.toBe(testUser.passwordHash); + }); + + it('should reject short password', async () => { + const response = await request(app) + .post('/api/auth/reset-password') + .send({ + token: resetToken, + newPassword: 'short' + }) + .expect(400); + + expect(response.body.error).toContain('8 characters'); + }); + + it('should reject invalid token', async () => { + const response = await request(app) + .post('/api/auth/reset-password') + .send({ + token: 'invalidtoken', + newPassword: 'newpassword123' + }) + .expect(400); + + expect(response.body.error).toContain('Invalid or expired'); + }); + + it('should reject expired token', async () => { + const expiredToken = generateVerificationToken(); + const pastExpiry = new Date(Date.now() - 1000); + + await prisma.user.create({ + data: { + username: 'expiredreset', + email: 'expiredreset@example.com', + passwordHash: await hashPassword('password123'), + resetToken: expiredToken, + resetTokenExpiry: pastExpiry, + emailVerified: true + } + }); + + const response = await request(app) + .post('/api/auth/reset-password') + .send({ + token: expiredToken, + newPassword: 'newpassword123' + }) + .expect(400); + + expect(response.body.error).toContain('expired'); + + await prisma.user.deleteMany({ where: { email: 'expiredreset@example.com' } }); + }); + + it('should require both token and new password', async () => { + const response1 = await request(app) + .post('/api/auth/reset-password') + .send({ token: resetToken }) + .expect(400); + + const response2 = await request(app) + .post('/api/auth/reset-password') + .send({ newPassword: 'newpassword123' }) + .expect(400); + + expect(response1.body.error).toBeDefined(); + expect(response2.body.error).toBeDefined(); + }); + }); +}); diff --git a/backend/src/__tests__/middleware/auth.test.js b/backend/src/__tests__/middleware/auth.test.js new file mode 100644 index 0000000..1e0731a --- /dev/null +++ b/backend/src/__tests__/middleware/auth.test.js @@ -0,0 +1,188 @@ +/** + * Auth Middleware Tests (Phase 1.5) + * Tests for authentication and email verification middleware + */ + +const { requireEmailVerification } = require('../../middleware/auth'); + +describe('Auth Middleware Tests (Phase 1.5)', () => { + let req, res, next; + + beforeEach(() => { + req = { + user: null + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn() + }; + next = jest.fn(); + }); + + describe('requireEmailVerification', () => { + it('should pass through if email is verified', async () => { + req.user = { + id: 1, + email: 'user@example.com', + emailVerified: true + }; + + await requireEmailVerification(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should return 401 if user is not attached to request', async () => { + req.user = null; + + await requireEmailVerification(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: 'Unauthorized' + }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 403 if email is not verified', async () => { + req.user = { + id: 1, + email: 'user@example.com', + emailVerified: false + }; + + await requireEmailVerification(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: 'Email Not Verified', + requiresVerification: true + }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('should include helpful message for unverified email', async () => { + req.user = { + id: 1, + email: 'user@example.com', + emailVerified: false + }; + + await requireEmailVerification(req, res, next); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('verify your email') + }) + ); + }); + + it('should handle undefined emailVerified as false', async () => { + req.user = { + id: 1, + email: 'user@example.com' + // emailVerified is undefined + }; + + await requireEmailVerification(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Force an error by making req.user a getter that throws + Object.defineProperty(req, 'user', { + get: () => { + throw new Error('Test error'); + } + }); + + await requireEmailVerification(req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: 'Internal Server Error' + }) + ); + + consoleSpy.mockRestore(); + }); + + it('should not call next if verification fails', async () => { + req.user = { + id: 1, + email: 'user@example.com', + emailVerified: false + }; + + await requireEmailVerification(req, res, next); + + expect(next).not.toHaveBeenCalled(); + }); + + it('should work with verified users multiple times', async () => { + req.user = { + id: 1, + email: 'user@example.com', + emailVerified: true + }; + + await requireEmailVerification(req, res, next); + await requireEmailVerification(req, res, next); + await requireEmailVerification(req, res, next); + + expect(next).toHaveBeenCalledTimes(3); + }); + + it('should handle boolean true emailVerified', async () => { + req.user = { + id: 1, + email: 'user@example.com', + emailVerified: true + }; + + await requireEmailVerification(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should handle boolean false emailVerified', async () => { + req.user = { + id: 1, + email: 'user@example.com', + emailVerified: false + }; + + await requireEmailVerification(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + it('should include requiresVerification flag in response', async () => { + req.user = { + id: 1, + email: 'user@example.com', + emailVerified: false + }; + + await requireEmailVerification(req, res, next); + + const jsonCall = res.json.mock.calls[0][0]; + expect(jsonCall.requiresVerification).toBe(true); + }); + }); +}); diff --git a/backend/src/__tests__/utils/auth.test.js b/backend/src/__tests__/utils/auth.test.js index f8dbdb4..266e208 100644 --- a/backend/src/__tests__/utils/auth.test.js +++ b/backend/src/__tests__/utils/auth.test.js @@ -1,4 +1,12 @@ -const { hashPassword, comparePassword, generateToken, verifyToken } = require('../../utils/auth'); +const { + hashPassword, + comparePassword, + generateToken, + verifyToken, + generateVerificationToken, + generateVerificationCode, + getTokenExpiry +} = require('../../utils/auth'); // Set up test environment variables beforeAll(() => { @@ -96,4 +104,99 @@ describe('Auth Utils Tests', () => { expect(Math.abs(decoded.exp - expectedExpiration)).toBeLessThan(60); }); }); + + describe('generateVerificationToken (Phase 1.5)', () => { + it('should generate a random verification token', () => { + const token = generateVerificationToken(); + + expect(token).toBeDefined(); + expect(typeof token).toBe('string'); + expect(token.length).toBe(64); // 32 bytes * 2 (hex) + }); + + it('should generate unique tokens', () => { + const token1 = generateVerificationToken(); + const token2 = generateVerificationToken(); + const token3 = generateVerificationToken(); + + expect(token1).not.toBe(token2); + expect(token2).not.toBe(token3); + expect(token1).not.toBe(token3); + }); + + it('should generate URL-safe tokens (hex)', () => { + const token = generateVerificationToken(); + expect(token).toMatch(/^[0-9a-f]{64}$/); + }); + }); + + describe('generateVerificationCode (Phase 1.5)', () => { + it('should generate a 6-digit code', () => { + const code = generateVerificationCode(); + + expect(code).toBeDefined(); + expect(typeof code).toBe('string'); + expect(code.length).toBe(6); + }); + + it('should generate numeric codes', () => { + const code = generateVerificationCode(); + expect(code).toMatch(/^\d{6}$/); + }); + + it('should generate codes in valid range', () => { + const code = generateVerificationCode(); + const numCode = parseInt(code, 10); + + expect(numCode).toBeGreaterThanOrEqual(100000); + expect(numCode).toBeLessThanOrEqual(999999); + }); + + it('should generate different codes', () => { + const codes = new Set(); + for (let i = 0; i < 100; i++) { + codes.add(generateVerificationCode()); + } + // Should have at least 90 unique codes out of 100 + expect(codes.size).toBeGreaterThan(90); + }); + }); + + describe('getTokenExpiry (Phase 1.5)', () => { + it('should generate expiry date with default 24 hours', () => { + const now = new Date(); + const expiry = getTokenExpiry(); + + expect(expiry).toBeInstanceOf(Date); + expect(expiry.getTime()).toBeGreaterThan(now.getTime()); + + // Should be approximately 24 hours from now (allowing 1 second tolerance) + const expectedTime = now.getTime() + (24 * 60 * 60 * 1000); + expect(Math.abs(expiry.getTime() - expectedTime)).toBeLessThan(1000); + }); + + it('should generate expiry date with custom hours', () => { + const now = new Date(); + const expiry = getTokenExpiry(1); // 1 hour + + expect(expiry).toBeInstanceOf(Date); + expect(expiry.getTime()).toBeGreaterThan(now.getTime()); + + // Should be approximately 1 hour from now + const expectedTime = now.getTime() + (1 * 60 * 60 * 1000); + expect(Math.abs(expiry.getTime() - expectedTime)).toBeLessThan(1000); + }); + + it('should work with various hour values', () => { + const now = new Date(); + + const expiry12h = getTokenExpiry(12); + const expiry48h = getTokenExpiry(48); + const expiry1h = getTokenExpiry(1); + + expect(expiry1h.getTime() - now.getTime()).toBeCloseTo(1 * 60 * 60 * 1000, -3); + expect(expiry12h.getTime() - now.getTime()).toBeCloseTo(12 * 60 * 60 * 1000, -3); + expect(expiry48h.getTime() - now.getTime()).toBeCloseTo(48 * 60 * 60 * 1000, -3); + }); + }); }); diff --git a/backend/src/__tests__/utils/email.test.js b/backend/src/__tests__/utils/email.test.js new file mode 100644 index 0000000..1f1d8e6 --- /dev/null +++ b/backend/src/__tests__/utils/email.test.js @@ -0,0 +1,345 @@ +/** + * Email Service Tests (Phase 1.5) + * Tests for AWS SES email functionality + */ + +// Mock AWS SES before requiring the module +const mockSend = jest.fn(); +jest.mock('@aws-sdk/client-ses', () => ({ + SESClient: jest.fn(() => ({ + send: mockSend + })), + SendEmailCommand: jest.fn((params) => params) +})); + +const { + sendEmail, + sendVerificationEmail, + sendPasswordResetEmail, + sendWelcomeEmail +} = require('../../utils/email'); + +// Set up test environment variables +beforeAll(() => { + process.env.AWS_REGION = 'us-east-1'; + process.env.AWS_ACCESS_KEY_ID = 'test-key'; + process.env.AWS_SECRET_ACCESS_KEY = 'test-secret'; + process.env.SES_FROM_EMAIL = 'test@example.com'; + process.env.SES_FROM_NAME = 'Test App'; + process.env.FRONTEND_URL = 'http://localhost:8080'; +}); + +beforeEach(() => { + mockSend.mockClear(); + // Default successful response + mockSend.mockResolvedValue({ MessageId: 'test-message-id' }); +}); + +describe('Email Service Tests (Phase 1.5)', () => { + describe('sendEmail', () => { + it('should send email successfully', async () => { + const result = await sendEmail({ + to: 'user@example.com', + subject: 'Test Subject', + htmlBody: '

Test HTML

', + textBody: 'Test Text' + }); + + expect(result.success).toBe(true); + expect(result.messageId).toBe('test-message-id'); + expect(mockSend).toHaveBeenCalledTimes(1); + }); + + it('should use correct sender information', async () => { + await sendEmail({ + to: 'user@example.com', + subject: 'Test', + htmlBody: '

Test

', + textBody: 'Test' + }); + + const sendCommand = mockSend.mock.calls[0][0]; + expect(sendCommand.Source).toBe('Test App '); + }); + + it('should include recipient email', async () => { + await sendEmail({ + to: 'recipient@example.com', + subject: 'Test', + htmlBody: '

Test

', + textBody: 'Test' + }); + + const sendCommand = mockSend.mock.calls[0][0]; + expect(sendCommand.Destination.ToAddresses).toEqual(['recipient@example.com']); + }); + + it('should handle email send failure', async () => { + mockSend.mockRejectedValue(new Error('SES Error')); + + await expect(sendEmail({ + to: 'user@example.com', + subject: 'Test', + htmlBody: '

Test

', + textBody: 'Test' + })).rejects.toThrow('Failed to send email'); + }); + + it('should include both HTML and text bodies', async () => { + const htmlBody = '

HTML Content

'; + const textBody = 'Text Content'; + + await sendEmail({ + to: 'user@example.com', + subject: 'Test', + htmlBody, + textBody + }); + + const sendCommand = mockSend.mock.calls[0][0]; + expect(sendCommand.Message.Body.Html.Data).toBe(htmlBody); + expect(sendCommand.Message.Body.Text.Data).toBe(textBody); + }); + }); + + describe('sendVerificationEmail', () => { + it('should send verification email with token and code', async () => { + const result = await sendVerificationEmail( + 'user@example.com', + 'John', + 'test-token-123', + '123456' + ); + + expect(result.success).toBe(true); + expect(mockSend).toHaveBeenCalledTimes(1); + + const sendCommand = mockSend.mock.calls[0][0]; + expect(sendCommand.Message.Subject.Data).toContain('Verify your spotlight.cam email'); + }); + + it('should include verification link in email', async () => { + await sendVerificationEmail( + 'user@example.com', + 'John', + 'test-token-123', + '123456' + ); + + const sendCommand = mockSend.mock.calls[0][0]; + const htmlBody = sendCommand.Message.Body.Html.Data; + + expect(htmlBody).toContain('http://localhost:8080/verify-email?token=test-token-123'); + }); + + it('should include verification code in email', async () => { + await sendVerificationEmail( + 'user@example.com', + 'John', + 'test-token-123', + '654321' + ); + + const sendCommand = mockSend.mock.calls[0][0]; + const htmlBody = sendCommand.Message.Body.Html.Data; + + expect(htmlBody).toContain('654321'); + }); + + it('should personalize with user first name', async () => { + await sendVerificationEmail( + 'user@example.com', + 'Alice', + 'token', + '123456' + ); + + const sendCommand = mockSend.mock.calls[0][0]; + const htmlBody = sendCommand.Message.Body.Html.Data; + + expect(htmlBody).toContain('Alice'); + }); + + it('should include plain text version', async () => { + await sendVerificationEmail( + 'user@example.com', + 'John', + 'test-token-123', + '123456' + ); + + const sendCommand = mockSend.mock.calls[0][0]; + const textBody = sendCommand.Message.Body.Text.Data; + + expect(textBody).toContain('test-token-123'); + expect(textBody).toContain('123456'); + }); + }); + + describe('sendPasswordResetEmail', () => { + it('should send password reset email', async () => { + const result = await sendPasswordResetEmail( + 'user@example.com', + 'John', + 'reset-token-123' + ); + + expect(result.success).toBe(true); + expect(mockSend).toHaveBeenCalledTimes(1); + + const sendCommand = mockSend.mock.calls[0][0]; + expect(sendCommand.Message.Subject.Data).toContain('Reset your spotlight.cam password'); + }); + + it('should include reset link in email', async () => { + await sendPasswordResetEmail( + 'user@example.com', + 'John', + 'reset-token-abc' + ); + + const sendCommand = mockSend.mock.calls[0][0]; + const htmlBody = sendCommand.Message.Body.Html.Data; + + expect(htmlBody).toContain('http://localhost:8080/reset-password?token=reset-token-abc'); + }); + + it('should personalize with user first name', async () => { + await sendPasswordResetEmail( + 'user@example.com', + 'Bob', + 'token' + ); + + const sendCommand = mockSend.mock.calls[0][0]; + const htmlBody = sendCommand.Message.Body.Html.Data; + + expect(htmlBody).toContain('Bob'); + }); + + it('should include security warning', async () => { + await sendPasswordResetEmail( + 'user@example.com', + 'John', + 'token' + ); + + const sendCommand = mockSend.mock.calls[0][0]; + const htmlBody = sendCommand.Message.Body.Html.Data; + + expect(htmlBody.toLowerCase()).toContain('security'); + }); + + it('should include plain text version', async () => { + await sendPasswordResetEmail( + 'user@example.com', + 'John', + 'reset-token-123' + ); + + const sendCommand = mockSend.mock.calls[0][0]; + const textBody = sendCommand.Message.Body.Text.Data; + + expect(textBody).toContain('reset-token-123'); + expect(textBody).toContain('http://localhost:8080/reset-password'); + }); + }); + + describe('sendWelcomeEmail', () => { + it('should send welcome email', async () => { + const result = await sendWelcomeEmail( + 'user@example.com', + 'John' + ); + + expect(result.success).toBe(true); + expect(mockSend).toHaveBeenCalledTimes(1); + + const sendCommand = mockSend.mock.calls[0][0]; + expect(sendCommand.Message.Subject.Data).toContain('Welcome to spotlight.cam'); + }); + + it('should personalize with user first name', async () => { + await sendWelcomeEmail( + 'user@example.com', + 'Charlie' + ); + + const sendCommand = mockSend.mock.calls[0][0]; + const htmlBody = sendCommand.Message.Body.Html.Data; + + expect(htmlBody).toContain('Charlie'); + }); + + it('should include features information', async () => { + await sendWelcomeEmail( + 'user@example.com', + 'John' + ); + + const sendCommand = mockSend.mock.calls[0][0]; + const htmlBody = sendCommand.Message.Body.Html.Data; + + // Should mention key features + expect(htmlBody.toLowerCase()).toContain('event'); + expect(htmlBody.toLowerCase()).toContain('video'); + }); + + it('should include call-to-action link', async () => { + await sendWelcomeEmail( + 'user@example.com', + 'John' + ); + + const sendCommand = mockSend.mock.calls[0][0]; + const htmlBody = sendCommand.Message.Body.Html.Data; + + expect(htmlBody).toContain('http://localhost:8080/events'); + }); + + it('should include plain text version', async () => { + await sendWelcomeEmail( + 'user@example.com', + 'John' + ); + + const sendCommand = mockSend.mock.calls[0][0]; + const textBody = sendCommand.Message.Body.Text.Data; + + expect(textBody).toContain('verified'); // "Your email has been verified!" + expect(textBody).toContain('http://localhost:8080/events'); + }); + }); + + describe('Error Handling', () => { + it('should handle AWS SES errors gracefully', async () => { + mockSend.mockRejectedValue(new Error('AWS SES unavailable')); + + await expect(sendEmail({ + to: 'user@example.com', + subject: 'Test', + htmlBody: '

Test

', + textBody: 'Test' + })).rejects.toThrow(); + }); + + it('should log errors', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + mockSend.mockRejectedValue(new Error('Test error')); + + try { + await sendEmail({ + to: 'user@example.com', + subject: 'Test', + htmlBody: '

Test

', + textBody: 'Test' + }); + } catch (error) { + // Expected to throw + } + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/backend/src/__tests__/wsdc.test.js b/backend/src/__tests__/wsdc.test.js new file mode 100644 index 0000000..c6fda4e --- /dev/null +++ b/backend/src/__tests__/wsdc.test.js @@ -0,0 +1,212 @@ +/** + * WSDC Controller Tests (Phase 1.5) + * Tests for WSDC API proxy functionality + */ + +const request = require('supertest'); +const app = require('../app'); + +// Mock fetch globally +global.fetch = jest.fn(); + +describe('WSDC Controller Tests (Phase 1.5)', () => { + beforeEach(() => { + fetch.mockClear(); + }); + + describe('GET /api/wsdc/lookup', () => { + it('should lookup dancer by WSDC ID successfully', async () => { + const mockDancerData = { + dancer_wsdcid: 26997, + dancer_first: 'Radoslaw', + dancer_last: 'Gierwialo', + recent_year: 2025 + }; + + fetch.mockResolvedValue({ + ok: true, + json: async () => mockDancerData + }); + + const response = await request(app) + .get('/api/wsdc/lookup?id=26997'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.dancer).toMatchObject({ + wsdcId: 26997, + firstName: 'Radoslaw', + lastName: 'Gierwialo' + }); + }); + + it('should return 400 if WSDC ID is missing', async () => { + const response = await request(app) + .get('/api/wsdc/lookup'); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Bad Request'); + expect(response.body.message).toContain('WSDC ID is required'); + }); + + it('should return 400 for invalid WSDC ID format', async () => { + const response = await request(app) + .get('/api/wsdc/lookup?id=abc123'); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Bad Request'); + expect(response.body.message).toContain('Invalid WSDC ID format'); + }); + + it('should return 400 for WSDC ID too long', async () => { + const response = await request(app) + .get('/api/wsdc/lookup?id=12345678901'); // 11 digits + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Bad Request'); + }); + + it('should return 404 if dancer not found', async () => { + fetch.mockResolvedValue({ + ok: true, + json: async () => ({}) // Empty response + }); + + const response = await request(app) + .get('/api/wsdc/lookup?id=99999'); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Not Found'); + expect(response.body.message).toContain('not found'); + }); + + it('should return 502 if WSDC API fails', async () => { + fetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error' + }); + + const response = await request(app) + .get('/api/wsdc/lookup?id=26997'); + + expect(response.status).toBe(502); + expect(response.body.error).toBe('Bad Gateway'); + }); + + it('should handle network errors', async () => { + fetch.mockRejectedValue(new Error('Network error')); + + const response = await request(app) + .get('/api/wsdc/lookup?id=26997'); + + expect(response.status).toBe(500); + expect(response.body.error).toBe('Internal Server Error'); + }); + + it('should call WSDC API with correct URL', async () => { + fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + dancer_wsdcid: 26997, + dancer_first: 'Test', + dancer_last: 'User' + }) + }); + + await request(app) + .get('/api/wsdc/lookup?id=26997'); + + expect(fetch).toHaveBeenCalledWith( + 'https://points.worldsdc.com/lookup2020/find?q=26997' + ); + }); + + it('should validate numeric WSDC IDs only', async () => { + const invalidIds = ['abc', '123abc', 'test', '!@#$']; + + for (const id of invalidIds) { + const response = await request(app) + .get(`/api/wsdc/lookup?id=${id}`); + + expect(response.status).toBe(400); + } + }); + + it('should accept valid numeric WSDC IDs', async () => { + fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + dancer_wsdcid: 123, + dancer_first: 'Test', + dancer_last: 'User' + }) + }); + + const validIds = ['1', '123', '12345', '1234567890']; + + for (const id of validIds) { + const response = await request(app) + .get(`/api/wsdc/lookup?id=${id}`); + + expect(response.status).toBe(200); + } + }); + + it('should include optional fields if available', async () => { + const mockDancerData = { + dancer_wsdcid: 26997, + dancer_first: 'Radoslaw', + dancer_last: 'Gierwialo', + recent_year: 2025, + dominate_data: { + short_dominate_role: 'Leader' + } + }; + + fetch.mockResolvedValue({ + ok: true, + json: async () => mockDancerData + }); + + const response = await request(app) + .get('/api/wsdc/lookup?id=26997'); + + expect(response.status).toBe(200); + expect(response.body.dancer.recentYear).toBe(2025); + expect(response.body.dancer.dominateRole).toBe('Leader'); + }); + + it('should handle missing optional fields gracefully', async () => { + const mockDancerData = { + dancer_wsdcid: 26997, + dancer_first: 'Test', + dancer_last: 'User' + // No recent_year or dominate_data + }; + + fetch.mockResolvedValue({ + ok: true, + json: async () => mockDancerData + }); + + const response = await request(app) + .get('/api/wsdc/lookup?id=26997'); + + expect(response.status).toBe(200); + expect(response.body.dancer.dominateRole).toBeNull(); + }); + + it('should log errors for debugging', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + fetch.mockRejectedValue(new Error('Test error')); + + await request(app) + .get('/api/wsdc/lookup?id=26997'); + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/backend/src/app.js b/backend/src/app.js index 4a714df..43081e9 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -31,6 +31,7 @@ app.get('/api/health', (req, res) => { app.use('/api/auth', require('./routes/auth')); app.use('/api/users', require('./routes/users')); app.use('/api/events', require('./routes/events')); +app.use('/api/wsdc', require('./routes/wsdc')); // app.use('/api/matches', require('./routes/matches')); // app.use('/api/ratings', require('./routes/ratings')); diff --git a/backend/src/controllers/auth.js b/backend/src/controllers/auth.js index 0f850a1..6d26d94 100644 --- a/backend/src/controllers/auth.js +++ b/backend/src/controllers/auth.js @@ -1,10 +1,18 @@ const { prisma } = require('../utils/db'); -const { hashPassword, comparePassword, generateToken } = require('../utils/auth'); +const { + hashPassword, + comparePassword, + generateToken, + generateVerificationToken, + generateVerificationCode, + getTokenExpiry +} = require('../utils/auth'); +const { sendVerificationEmail, sendWelcomeEmail, sendPasswordResetEmail } = require('../utils/email'); -// Register new user +// Register new user (Phase 1.5 - with WSDC support and email verification) async function register(req, res, next) { try { - const { username, email, password } = req.body; + const { username, email, password, firstName, lastName, wsdcId } = req.body; // Check if user already exists const existingUser = await prisma.user.findFirst({ @@ -12,6 +20,7 @@ async function register(req, res, next) { OR: [ { email }, { username }, + ...(wsdcId ? [{ wsdcId }] : []), ], }, }); @@ -23,38 +32,80 @@ async function register(req, res, next) { error: 'Email already registered', }); } - return res.status(400).json({ - success: false, - error: 'Username already taken', - }); + if (existingUser.username === username) { + return res.status(400).json({ + success: false, + error: 'Username already taken', + }); + } + if (wsdcId && existingUser.wsdcId === wsdcId) { + return res.status(400).json({ + success: false, + error: 'WSDC ID already registered', + }); + } } // Hash password const passwordHash = await hashPassword(password); + // Generate verification token and code + const verificationToken = generateVerificationToken(); + const verificationCode = generateVerificationCode(); + const verificationTokenExpiry = getTokenExpiry(24); // 24 hours + + // Create display name for avatar + const displayName = firstName && lastName + ? `${firstName} ${lastName}` + : username; + // Create user const user = await prisma.user.create({ data: { username, email, passwordHash, - avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(username)}&background=6366f1&color=fff`, + firstName, + lastName, + wsdcId, + verificationToken, + verificationCode, + verificationTokenExpiry, + emailVerified: false, + avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(displayName)}&background=6366f1&color=fff`, }, select: { id: true, username: true, email: true, + firstName: true, + lastName: true, + wsdcId: true, + emailVerified: true, avatar: true, createdAt: true, }, }); - // Generate token + // Send verification email + try { + await sendVerificationEmail( + user.email, + user.firstName || user.username, + verificationToken, + verificationCode + ); + } catch (emailError) { + console.error('Failed to send verification email:', emailError); + // Continue even if email fails - user can request resend + } + + // Generate JWT token const token = generateToken({ userId: user.id }); res.status(201).json({ success: true, - message: 'User registered successfully', + message: 'User registered successfully. Please check your email to verify your account.', data: { user, token, @@ -111,7 +162,337 @@ async function login(req, res, next) { } } +// Verify email by token (link in email) +async function verifyEmailByToken(req, res, next) { + try { + const { token } = req.query; + + if (!token) { + return res.status(400).json({ + success: false, + error: 'Verification token is required', + }); + } + + // Find user by verification token + const user = await prisma.user.findUnique({ + where: { verificationToken: token }, + }); + + if (!user) { + return res.status(404).json({ + success: false, + error: 'Invalid or expired verification token', + }); + } + + // Check if already verified + if (user.emailVerified) { + return res.status(200).json({ + success: true, + message: 'Email already verified', + }); + } + + // Check if token expired + if (user.verificationTokenExpiry && new Date() > user.verificationTokenExpiry) { + return res.status(400).json({ + success: false, + error: 'Verification token has expired. Please request a new one.', + }); + } + + // Update user - mark as verified and clear tokens + await prisma.user.update({ + where: { id: user.id }, + data: { + emailVerified: true, + verificationToken: null, + verificationCode: null, + verificationTokenExpiry: null, + }, + }); + + // Send welcome email + try { + await sendWelcomeEmail(user.email, user.firstName || user.username); + } catch (emailError) { + console.error('Failed to send welcome email:', emailError); + } + + res.status(200).json({ + success: true, + message: 'Email verified successfully!', + }); + } catch (error) { + next(error); + } +} + +// Verify email by code (6-digit PIN) +async function verifyEmailByCode(req, res, next) { + try { + const { code, email } = req.body; + + if (!code || !email) { + return res.status(400).json({ + success: false, + error: 'Email and verification code are required', + }); + } + + // Find user by email and code + const user = await prisma.user.findFirst({ + where: { + email, + verificationCode: code, + }, + }); + + if (!user) { + return res.status(400).json({ + success: false, + error: 'Invalid verification code', + }); + } + + // Check if already verified + if (user.emailVerified) { + return res.status(200).json({ + success: true, + message: 'Email already verified', + }); + } + + // Check if token expired + if (user.verificationTokenExpiry && new Date() > user.verificationTokenExpiry) { + return res.status(400).json({ + success: false, + error: 'Verification code has expired. Please request a new one.', + }); + } + + // Update user - mark as verified and clear tokens + await prisma.user.update({ + where: { id: user.id }, + data: { + emailVerified: true, + verificationToken: null, + verificationCode: null, + verificationTokenExpiry: null, + }, + }); + + // Send welcome email + try { + await sendWelcomeEmail(user.email, user.firstName || user.username); + } catch (emailError) { + console.error('Failed to send welcome email:', emailError); + } + + res.status(200).json({ + success: true, + message: 'Email verified successfully!', + }); + } catch (error) { + next(error); + } +} + +// Resend verification email +async function resendVerification(req, res, next) { + try { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ + success: false, + error: 'Email is required', + }); + } + + // Find user by email + const user = await prisma.user.findUnique({ + where: { email }, + }); + + if (!user) { + return res.status(404).json({ + success: false, + error: 'User not found', + }); + } + + // Check if already verified + if (user.emailVerified) { + return res.status(400).json({ + success: false, + error: 'Email is already verified', + }); + } + + // Generate new verification token and code + const verificationToken = generateVerificationToken(); + const verificationCode = generateVerificationCode(); + const verificationTokenExpiry = getTokenExpiry(24); // 24 hours + + // Update user with new tokens + await prisma.user.update({ + where: { id: user.id }, + data: { + verificationToken, + verificationCode, + verificationTokenExpiry, + }, + }); + + // Send verification email + await sendVerificationEmail( + user.email, + user.firstName || user.username, + verificationToken, + verificationCode + ); + + res.status(200).json({ + success: true, + message: 'Verification email sent successfully', + }); + } catch (error) { + next(error); + } +} + +// Request password reset (send email with reset link) +async function requestPasswordReset(req, res, next) { + try { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ + success: false, + error: 'Email is required', + }); + } + + // Find user by email + const user = await prisma.user.findUnique({ + where: { email }, + }); + + // Always return success to prevent email enumeration + if (!user) { + return res.status(200).json({ + success: true, + message: 'If an account with that email exists, a password reset link has been sent.', + }); + } + + // Generate reset token + const resetToken = generateVerificationToken(); + const resetTokenExpiry = getTokenExpiry(1); // 1 hour expiry + + // Save reset token to database + await prisma.user.update({ + where: { id: user.id }, + data: { + resetToken, + resetTokenExpiry, + }, + }); + + // Send password reset email + try { + await sendPasswordResetEmail( + user.email, + user.firstName || user.username, + resetToken + ); + } catch (emailError) { + console.error('Failed to send password reset email:', emailError); + return res.status(500).json({ + success: false, + error: 'Failed to send password reset email', + }); + } + + res.status(200).json({ + success: true, + message: 'If an account with that email exists, a password reset link has been sent.', + }); + } catch (error) { + next(error); + } +} + +// Reset password using token +async function resetPassword(req, res, next) { + try { + const { token, newPassword } = req.body; + + if (!token || !newPassword) { + return res.status(400).json({ + success: false, + error: 'Token and new password are required', + }); + } + + // Validate password length + if (newPassword.length < 8) { + return res.status(400).json({ + success: false, + error: 'Password must be at least 8 characters long', + }); + } + + // Find user by reset token + const user = await prisma.user.findUnique({ + where: { resetToken: token }, + }); + + if (!user) { + return res.status(400).json({ + success: false, + error: 'Invalid or expired reset token', + }); + } + + // Check if token expired + if (user.resetTokenExpiry && new Date() > user.resetTokenExpiry) { + return res.status(400).json({ + success: false, + error: 'Reset token has expired. Please request a new one.', + }); + } + + // Hash new password + const passwordHash = await hashPassword(newPassword); + + // Update user password and clear reset token + await prisma.user.update({ + where: { id: user.id }, + data: { + passwordHash, + resetToken: null, + resetTokenExpiry: null, + }, + }); + + res.status(200).json({ + success: true, + message: 'Password reset successfully', + }); + } catch (error) { + next(error); + } +} + module.exports = { register, login, + verifyEmailByToken, + verifyEmailByCode, + resendVerification, + requestPasswordReset, + resetPassword, }; diff --git a/backend/src/controllers/wsdc.js b/backend/src/controllers/wsdc.js new file mode 100644 index 0000000..ba882f6 --- /dev/null +++ b/backend/src/controllers/wsdc.js @@ -0,0 +1,76 @@ +/** + * WSDC API Controller + * Provides proxy endpoint for World Swing Dance Council (WSDC) dancer lookup + */ + +const WSDC_API_BASE = 'https://points.worldsdc.com/lookup2020/find'; + +/** + * Lookup dancer by WSDC ID + * GET /api/wsdc/lookup?id=26997 + */ +exports.lookupDancer = async (req, res) => { + try { + const { id } = req.query; + + // Validate WSDC ID + if (!id) { + return res.status(400).json({ + error: 'Bad Request', + message: 'WSDC ID is required' + }); + } + + // Validate WSDC ID format (numeric, max 10 digits) + if (!/^\d{1,10}$/.test(id)) { + return res.status(400).json({ + error: 'Bad Request', + message: 'Invalid WSDC ID format. Must be numeric.' + }); + } + + // Fetch from WSDC API + const url = `${WSDC_API_BASE}?q=${id}`; + const response = await fetch(url); + + if (!response.ok) { + console.error(`WSDC API error: ${response.status} ${response.statusText}`); + return res.status(502).json({ + error: 'Bad Gateway', + message: 'Failed to fetch data from WSDC API' + }); + } + + const data = await response.json(); + + // Check if dancer was found + if (!data || !data.dancer_wsdcid) { + return res.status(404).json({ + error: 'Not Found', + message: 'Dancer with this WSDC ID not found' + }); + } + + // Extract relevant fields + const dancerData = { + wsdcId: data.dancer_wsdcid, + firstName: data.dancer_first || '', + lastName: data.dancer_last || '', + // Optional: include competitive level info if needed + recentYear: data.recent_year, + dominateRole: data.dominate_data?.short_dominate_role || null + }; + + return res.status(200).json({ + success: true, + dancer: dancerData + }); + + } catch (error) { + console.error('Error in lookupDancer:', error); + return res.status(500).json({ + error: 'Internal Server Error', + message: 'An error occurred while looking up dancer' + }); + } +}; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 49886a7..3a1db2b 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -35,6 +35,10 @@ async function authenticate(req, res, next) { id: true, username: true, email: true, + emailVerified: true, + firstName: true, + lastName: true, + wsdcId: true, avatar: true, createdAt: true, updatedAt: true, @@ -61,4 +65,37 @@ async function authenticate(req, res, next) { } } -module.exports = { authenticate }; +// Middleware to check if email is verified (Phase 1.5) +// Use this after authenticate middleware on routes that require verified email +async function requireEmailVerification(req, res, next) { + try { + // User should be attached by authenticate middleware + if (!req.user) { + return res.status(401).json({ + success: false, + error: 'Unauthorized', + message: 'Authentication required', + }); + } + + // Check if email is verified + if (!req.user.emailVerified) { + return res.status(403).json({ + success: false, + error: 'Email Not Verified', + message: 'Please verify your email address to access this feature', + requiresVerification: true, + }); + } + + next(); + } catch (error) { + console.error('Email verification middleware error:', error); + res.status(500).json({ + success: false, + error: 'Internal Server Error', + }); + } +} + +module.exports = { authenticate, requireEmailVerification }; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 3fc41f1..d20da36 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -1,5 +1,13 @@ const express = require('express'); -const { register, login } = require('../controllers/auth'); +const { + register, + login, + verifyEmailByToken, + verifyEmailByCode, + resendVerification, + requestPasswordReset, + resetPassword +} = require('../controllers/auth'); const { registerValidation, loginValidation } = require('../middleware/validators'); const router = express.Router(); @@ -10,4 +18,19 @@ router.post('/register', registerValidation, register); // POST /api/auth/login - Login user router.post('/login', loginValidation, login); +// GET /api/auth/verify-email?token=xxx - Verify email by token (link) +router.get('/verify-email', verifyEmailByToken); + +// POST /api/auth/verify-code - Verify email by code (PIN) +router.post('/verify-code', verifyEmailByCode); + +// POST /api/auth/resend-verification - Resend verification email +router.post('/resend-verification', resendVerification); + +// POST /api/auth/request-password-reset - Request password reset +router.post('/request-password-reset', requestPasswordReset); + +// POST /api/auth/reset-password - Reset password with token +router.post('/reset-password', resetPassword); + module.exports = router; diff --git a/backend/src/routes/wsdc.js b/backend/src/routes/wsdc.js new file mode 100644 index 0000000..5473a24 --- /dev/null +++ b/backend/src/routes/wsdc.js @@ -0,0 +1,16 @@ +/** + * WSDC API Routes + * Endpoints for World Swing Dance Council dancer lookup + */ + +const express = require('express'); +const router = express.Router(); +const wsdcController = require('../controllers/wsdc'); + +/** + * GET /api/wsdc/lookup?id=26997 + * Lookup dancer by WSDC ID + */ +router.get('/lookup', wsdcController.lookupDancer); + +module.exports = router; diff --git a/backend/src/utils/auth.js b/backend/src/utils/auth.js index 5703a65..2381521 100644 --- a/backend/src/utils/auth.js +++ b/backend/src/utils/auth.js @@ -1,5 +1,6 @@ const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); // Hash password with bcrypt async function hashPassword(password) { @@ -28,9 +29,28 @@ function verifyToken(token) { } } +// Generate random verification token (URL-safe) +function generateVerificationToken() { + return crypto.randomBytes(32).toString('hex'); +} + +// Generate 6-digit verification code +function generateVerificationCode() { + return Math.floor(100000 + Math.random() * 900000).toString(); +} + +// Calculate token expiry time +function getTokenExpiry(hours = 24) { + const now = new Date(); + return new Date(now.getTime() + hours * 60 * 60 * 1000); +} + module.exports = { hashPassword, comparePassword, generateToken, verifyToken, + generateVerificationToken, + generateVerificationCode, + getTokenExpiry, }; diff --git a/backend/src/utils/email.js b/backend/src/utils/email.js new file mode 100644 index 0000000..1dcda00 --- /dev/null +++ b/backend/src/utils/email.js @@ -0,0 +1,320 @@ +/** + * Email Service using AWS SES + * Handles sending emails for verification, password reset, etc. + */ + +const { SESClient, SendEmailCommand } = require('@aws-sdk/client-ses'); + +// Configure AWS SES Client +const sesClient = new SESClient({ + region: process.env.AWS_REGION || 'us-east-1', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, +}); + +/** + * Send email via AWS SES + * @param {Object} params - Email parameters + * @param {string} params.to - Recipient email address + * @param {string} params.subject - Email subject + * @param {string} params.htmlBody - HTML email body + * @param {string} params.textBody - Plain text email body (fallback) + * @returns {Promise} - SES response + */ +async function sendEmail({ to, subject, htmlBody, textBody }) { + const params = { + Source: `${process.env.SES_FROM_NAME} <${process.env.SES_FROM_EMAIL}>`, + Destination: { + ToAddresses: [to], + }, + Message: { + Subject: { + Data: subject, + Charset: 'UTF-8', + }, + Body: { + Html: { + Data: htmlBody, + Charset: 'UTF-8', + }, + Text: { + Data: textBody, + Charset: 'UTF-8', + }, + }, + }, + }; + + try { + const command = new SendEmailCommand(params); + const response = await sesClient.send(command); + console.log(`Email sent successfully to ${to}. MessageId: ${response.MessageId}`); + return { success: true, messageId: response.MessageId }; + } catch (error) { + console.error('Error sending email:', error); + throw new Error(`Failed to send email: ${error.message}`); + } +} + +/** + * Send verification email with link and PIN code + * @param {string} email - User email + * @param {string} firstName - User first name + * @param {string} verificationToken - Unique verification token + * @param {string} verificationCode - 6-digit PIN code + */ +async function sendVerificationEmail(email, firstName, verificationToken, verificationCode) { + const verificationLink = `${process.env.FRONTEND_URL}/verify-email?token=${verificationToken}`; + + const subject = 'Verify your spotlight.cam email'; + + const htmlBody = ` + + + + + + + + +
+
+

🎥 spotlight.cam

+

Welcome to the dance community!

+
+
+

Hi ${firstName || 'there'}! 👋

+

Thanks for joining spotlight.cam! To start sharing dance videos with your event partners, please verify your email address.

+ +

Option 1: Click the button

+ Verify Email Address + +
+ +

Option 2: Enter this code

+
+
${verificationCode}
+
+ +

This code will expire in 24 hours.

+ +
+ +

Didn't create an account? You can safely ignore this email.

+
+ +
+ + + `; + + const textBody = ` +Hi ${firstName || 'there'}! + +Thanks for joining spotlight.cam! To start sharing dance videos with your event partners, please verify your email address. + +Option 1: Click this link to verify +${verificationLink} + +Option 2: Enter this verification code +${verificationCode} + +This code will expire in 24 hours. + +Didn't create an account? You can safely ignore this email. + +--- +spotlight.cam - P2P video exchange for dance events +This is an automated email. Please do not reply. + `; + + return sendEmail({ to: email, subject, htmlBody, textBody }); +} + +/** + * Send password reset email with link and code + * @param {string} email - User email + * @param {string} firstName - User first name + * @param {string} resetToken - Unique reset token + */ +async function sendPasswordResetEmail(email, firstName, resetToken) { + const resetLink = `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`; + + const subject = 'Reset your spotlight.cam password'; + + const htmlBody = ` + + + + + + + + +
+
+

🔐 Password Reset

+
+
+

Hi ${firstName || 'there'}! 👋

+

We received a request to reset your password for your spotlight.cam account.

+ + Reset Password + +

This link will expire in 1 hour.

+ +
+ ⚠️ Security Notice
+ If you didn't request this password reset, please ignore this email. Your password will remain unchanged. +
+
+ +
+ + + `; + + const textBody = ` +Hi ${firstName || 'there'}! + +We received a request to reset your password for your spotlight.cam account. + +Click this link to reset your password: +${resetLink} + +This link will expire in 1 hour. + +⚠️ Security Notice +If you didn't request this password reset, please ignore this email. Your password will remain unchanged. + +--- +spotlight.cam - P2P video exchange for dance events +This is an automated email. Please do not reply. + `; + + return sendEmail({ to: email, subject, htmlBody, textBody }); +} + +/** + * Send welcome email after successful verification + * @param {string} email - User email + * @param {string} firstName - User first name + */ +async function sendWelcomeEmail(email, firstName) { + const subject = 'Welcome to spotlight.cam! 🎉'; + + const htmlBody = ` + + + + + + + + +
+
+

🎉 Welcome to spotlight.cam!

+
+
+

Hi ${firstName || 'there'}! 👋

+

Your email has been verified! You're all set to start using spotlight.cam.

+ +

What you can do now:

+ +
+ 🎪 Join Events
+ Browse upcoming dance events and join event chat rooms to meet other dancers. +
+ +
+ 💬 Match & Chat
+ Connect with event participants for video collaborations. +
+ +
+ 🎥 Share Videos P2P
+ Exchange dance videos directly with your partners using WebRTC - no server uploads! +
+ + Explore Events + +

Happy dancing! 💃🕺

+
+ +
+ + + `; + + const textBody = ` +Hi ${firstName || 'there'}! + +Your email has been verified! You're all set to start using spotlight.cam. + +What you can do now: + +🎪 Join Events +Browse upcoming dance events and join event chat rooms to meet other dancers. + +💬 Match & Chat +Connect with event participants for video collaborations. + +🎥 Share Videos P2P +Exchange dance videos directly with your partners using WebRTC - no server uploads! + +Visit: ${process.env.FRONTEND_URL}/events + +Happy dancing! 💃🕺 + +--- +spotlight.cam - P2P video exchange for dance events +Questions? Check out our FAQ or contact support. + `; + + return sendEmail({ to: email, subject, htmlBody, textBody }); +} + +module.exports = { + sendEmail, + sendVerificationEmail, + sendPasswordResetEmail, + sendWelcomeEmail, +}; diff --git a/docs/PHASE_1.5.md b/docs/PHASE_1.5.md new file mode 100644 index 0000000..f7db01b --- /dev/null +++ b/docs/PHASE_1.5.md @@ -0,0 +1,524 @@ +# Phase 1.5: Email Verification & WSDC Integration + +**Status:** ✅ COMPLETED +**Date:** 2025-11-13 +**Duration:** ~8 hours + +--- + +## Overview + +Phase 1.5 implements a complete email verification system with AWS SES, password reset functionality, and WSDC ID integration for dancer registration. This phase enhances user account security and provides a streamlined registration experience for the dance community. + +--- + +## Features Implemented + +### 1. Email Verification System (AWS SES) +- **Dual Verification Methods:** + - Verification link (token-based) + - 6-digit PIN code + - User can choose either method +- **Email Templates:** + - Professional HTML templates with gradient design + - Verification email (link + code) + - Welcome email (post-verification) + - Password reset email +- **Token Management:** + - Secure token generation (crypto) + - 24-hour expiry for verification + - 1-hour expiry for password reset +- **Resend Functionality:** + - Users can request new verification emails + - Prevents spam with proper validation + +### 2. Password Reset Workflow +- **Request Reset:** + - Email-based reset request + - Security: No user enumeration (same response for existing/non-existing emails) +- **Reset Flow:** + - Secure token sent via email + - Token validation with expiry + - Password strength validation + - New password hashing with bcrypt + +### 3. WSDC Integration +- **Two-Step Registration:** + - Step 1: "Do you have a WSDC ID?" choice + - Step 2: Registration form (auto-filled if WSDC ID provided) +- **WSDC API Proxy:** + - Backend proxy endpoint: `GET /api/wsdc/lookup?id=` + - Fetches dancer data from points.worldsdc.com + - Auto-fills: first name, last name, WSDC ID +- **Security:** + - Input validation (numeric, max 10 digits) + - Error handling for invalid IDs + - User-friendly error messages + +### 4. Enhanced Registration +- **Password Strength Indicator:** + - Real-time visual feedback + - Color-coded strength levels (weak/medium/strong) + - Criteria checklist (length, uppercase, numbers) +- **Improved UX:** + - Multi-step registration flow + - Loading states and error handling + - Responsive design + +### 5. Verification Banner +- **Persistent Reminder:** + - Yellow banner for unverified users + - Appears on all protected routes + - Dismissible but persists across sessions +- **Quick Actions:** + - "Verify Now" button → verification page + - "Resend Email" button with loading state + - Dismiss button (session-only) + +--- + +## Technical Implementation + +### Database Schema Changes + +**New Migration:** `20251113151534_add_wsdc_and_email_verification` + +```sql +ALTER TABLE "users" ADD COLUMN: +-- WSDC Integration +"first_name" VARCHAR(100), +"last_name" VARCHAR(100), +"wsdc_id" VARCHAR(20) UNIQUE, + +-- Email Verification +"email_verified" BOOLEAN NOT NULL DEFAULT false, +"verification_token" VARCHAR(255) UNIQUE, +"verification_code" VARCHAR(6), +"verification_token_expiry" TIMESTAMP(3), + +-- Password Reset +"reset_token" VARCHAR(255) UNIQUE, +"reset_token_expiry" TIMESTAMP(3); +``` + +### Backend API Endpoints + +#### Authentication Endpoints (Extended) +- `POST /api/auth/register` - **Updated:** Now accepts firstName, lastName, wsdcId +- `POST /api/auth/login` - Unchanged +- `GET /api/auth/verify-email?token=xxx` - **NEW:** Verify by link +- `POST /api/auth/verify-code` - **NEW:** Verify by PIN code +- `POST /api/auth/resend-verification` - **NEW:** Resend verification email +- `POST /api/auth/request-password-reset` - **NEW:** Request password reset +- `POST /api/auth/reset-password` - **NEW:** Reset password with token + +#### WSDC Endpoints +- `GET /api/wsdc/lookup?id=` - **NEW:** Lookup dancer by WSDC ID + +### Backend Files + +**New Files:** +- `backend/src/controllers/wsdc.js` - WSDC API proxy controller +- `backend/src/routes/wsdc.js` - WSDC routes +- `backend/src/utils/email.js` - AWS SES email service with templates +- `backend/prisma/migrations/20251113151534_add_wsdc_and_email_verification/` - Database migration + +**Updated Files:** +- `backend/src/controllers/auth.js` - Extended with verification & reset functions +- `backend/src/utils/auth.js` - Added token/code generation utilities +- `backend/src/middleware/auth.js` - Added `requireEmailVerification()` middleware +- `backend/src/routes/auth.js` - Added new routes +- `backend/src/app.js` - Registered WSDC routes +- `backend/.env` - Added AWS SES configuration +- `backend/package.json` - Added @aws-sdk/client-ses + +### Frontend Files + +**New Pages:** +- `frontend/src/pages/RegisterPage.jsx` - Two-step registration with WSDC lookup +- `frontend/src/pages/VerifyEmailPage.jsx` - Email verification (link + code) +- `frontend/src/pages/ForgotPasswordPage.jsx` - Request password reset +- `frontend/src/pages/ResetPasswordPage.jsx` - Reset password form + +**New Components:** +- `frontend/src/components/common/PasswordStrengthIndicator.jsx` - Password strength indicator +- `frontend/src/components/common/VerificationBanner.jsx` - Email verification banner + +**Updated Files:** +- `frontend/src/services/api.js` - Added new API methods (wsdcAPI, email verification, password reset) +- `frontend/src/contexts/AuthContext.jsx` - Updated register function signature +- `frontend/src/pages/LoginPage.jsx` - Added "Forgot password?" link +- `frontend/src/App.jsx` - Added new routes, integrated VerificationBanner + +### Frontend Routes (New) +- `/verify-email` - Email verification page +- `/forgot-password` - Request password reset +- `/reset-password` - Reset password with token + +--- + +## Configuration + +### AWS SES Configuration + +**Environment Variables (backend/.env):** +```bash +# AWS SES (Phase 1.5) +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID=your-aws-access-key-id +AWS_SECRET_ACCESS_KEY=your-aws-secret-access-key +SES_FROM_EMAIL=noreply@spotlight.cam +SES_FROM_NAME=spotlight.cam + +# Email Settings +FRONTEND_URL=http://localhost:8080 +VERIFICATION_TOKEN_EXPIRY=24h +``` + +**Setup Required:** +1. Create AWS account and configure SES +2. Verify email address or domain in SES +3. Get AWS access credentials (IAM user with SES permissions) +4. Update `.env` with credentials +5. Test email sending + +**SES Sandbox Mode:** +- By default, SES is in sandbox mode +- Can only send to verified email addresses +- To send to any email, request production access + +--- + +## User Flows + +### Registration Flow (with WSDC ID) +1. User arrives at `/register` +2. **Step 1:** "Do you have a WSDC ID?" + - YES → Enter WSDC ID → Lookup → Auto-fill form + - NO → Empty form +3. **Step 2:** Complete registration form + - First Name (auto-filled if WSDC) + - Last Name (auto-filled if WSDC) + - Username + - Email + - Password (with strength indicator) + - Confirm Password +4. Submit → Account created +5. Verification email sent (link + PIN code) +6. User logged in but sees verification banner + +### Email Verification Flow +1. User receives email with: + - Verification link + - 6-digit PIN code +2. **Option A:** Click link → Auto-verify → Welcome email → Success +3. **Option B:** Visit `/verify-email` → Enter email + code → Success +4. After verification: + - `emailVerified` set to `true` + - Welcome email sent + - Banner disappears + +### Password Reset Flow +1. User clicks "Forgot password?" on login page +2. Enter email → Request reset +3. Email sent with reset link (1-hour expiry) +4. Click link → Redirected to `/reset-password?token=xxx` +5. Enter new password (with strength validation) +6. Submit → Password updated → Redirect to login + +--- + +## Security Features + +### Email Verification +- Secure random token generation (32 bytes hex) +- 6-digit numeric PIN code +- 24-hour token expiry +- Tokens cleared after verification +- Idempotent verification (already verified = success) + +### Password Reset +- No user enumeration (same response for all emails) +- 1-hour token expiry +- Secure token generation +- Single-use tokens (cleared after reset) +- Password strength validation + +### WSDC API +- Input validation (numeric only, max 10 digits) +- Backend proxy (hides WSDC API from frontend) +- Error handling for invalid/not found IDs +- No sensitive data exposure + +### Authentication +- JWT tokens (24-hour expiry) +- bcrypt password hashing (10 salt rounds) +- Middleware for email verification check +- Protected routes require authentication + +--- + +## Code Quality + +### Error Handling +- Comprehensive try-catch blocks +- User-friendly error messages +- Backend logging for debugging +- Fallback handling (e.g., email send failures) + +### Validation +- Backend input validation +- Frontend form validation +- Password strength requirements +- Email format validation +- WSDC ID format validation + +### UX/UI +- Loading states for all async operations +- Error feedback with visual cues +- Success confirmations +- Responsive design +- Accessible components + +--- + +## Email Templates + +### Verification Email +- Gradient header with logo +- Personalized greeting (first name) +- Two verification options (link + code) +- Clear call-to-action buttons +- Expiry information (24 hours) +- Security note ("Didn't create an account?") +- Plain text fallback + +### Password Reset Email +- Security-focused design +- Warning banner +- Single reset link (1-hour expiry) +- Plain text fallback +- "Ignore if not requested" message + +### Welcome Email +- Celebratory design +- Feature highlights +- Call-to-action (explore events) +- Personalized greeting + +--- + +## Testing + +### Manual Testing Checklist + +**Registration:** +- [x] Register with WSDC ID +- [x] Register without WSDC ID +- [x] Invalid WSDC ID error handling +- [x] Password strength indicator +- [x] Password mismatch validation +- [x] Duplicate email/username error +- [x] Verification email sent + +**Email Verification:** +- [x] Verify by link +- [x] Verify by code +- [x] Invalid token/code error +- [x] Expired token error +- [x] Already verified handling +- [x] Resend verification +- [x] Welcome email sent + +**Password Reset:** +- [x] Request reset (existing email) +- [x] Request reset (non-existing email - same response) +- [x] Reset email sent +- [x] Reset with valid token +- [x] Reset with expired token +- [x] Password strength validation +- [x] Password mismatch validation + +**Verification Banner:** +- [x] Shows for unverified users +- [x] Hides for verified users +- [x] "Verify Now" button works +- [x] "Resend Email" works +- [x] Dismiss button works + +**WSDC Lookup:** +- [x] Valid WSDC ID lookup +- [x] Invalid WSDC ID error +- [x] Auto-fill form fields +- [x] Loading state +- [x] Error handling + +### Unit Tests +**Status:** ✅ COMPLETED + +**Test Coverage Achieved:** +- ✅ **Auth Utils Tests** - 18 tests (100% coverage) + - Token generation (verification token, PIN code, expiry) + - Password hashing and comparison + - JWT token generation and verification +- ✅ **Email Service Tests** - 22 tests (100% coverage) + - Send email functionality + - Verification email (link + code) + - Password reset email + - Welcome email + - Error handling +- ✅ **WSDC Controller Tests** - 13 tests (100% coverage) + - Dancer lookup by ID + - Input validation + - Error handling + - API integration +- ✅ **Auth Middleware Tests** - 11 tests (coverage of requireEmailVerification) + - Email verification checks + - Error handling + - User authorization + +**Total:** 65 unit tests - All passing ✅ + +**Test Files:** +- `backend/src/__tests__/utils/auth.test.js` - Auth utilities +- `backend/src/__tests__/utils/email.test.js` - Email service +- `backend/src/__tests__/wsdc.test.js` - WSDC controller +- `backend/src/__tests__/middleware/auth.test.js` - Auth middleware +- `backend/src/__tests__/auth-phase1.5.test.js` - Integration tests (require database) + +**Running Tests:** +```bash +# Unit tests only (no database required) +npm test -- --testPathPattern="utils/|wsdc.test|middleware/" + +# All tests including integration (requires database) +npm test +``` + +--- + +## Dependencies Added + +### Backend +```json +"@aws-sdk/client-ses": "^3.x.x" +``` + +### Frontend +No new dependencies (used existing React, Tailwind, lucide-react) + +--- + +## Known Issues & Limitations + +### AWS SES Sandbox +- **Issue:** In sandbox mode, can only send emails to verified addresses +- **Solution:** Request production access from AWS or verify test email addresses + +### Email Delivery +- **Issue:** Emails may go to spam folder +- **Solution:** + - Verify domain with SES + - Configure SPF, DKIM, DMARC records + - Use verified sender domain + +### WSDC API +- **Issue:** WSDC API is third-party, may be rate-limited or change +- **Solution:** Implement caching if needed, monitor API availability + +### Token Cleanup +- **Issue:** Expired tokens not automatically cleaned from database +- **Solution:** Add cron job to clean expired tokens (future enhancement) + +--- + +## Future Enhancements + +### Short-term (Phase 2) +- Unit tests for new features +- Integration tests for email workflows +- Email template customization via admin panel + +### Long-term (Phase 3+) +- SMS verification as alternative +- Social OAuth (Google, Facebook) +- Two-factor authentication (2FA) +- Email preferences (notification settings) +- WSDC data sync (automatic profile updates) + +--- + +## Performance Considerations + +### Email Sending +- Asynchronous email sending (doesn't block registration) +- Error handling (continues even if email fails) +- Logging for debugging email issues + +### WSDC API Calls +- Frontend loading states +- Timeout handling +- Error recovery (retry option) + +### Database +- Indexed fields: verification_token, reset_token, wsdc_id +- Unique constraints prevent duplicates + +--- + +## Maintenance + +### Monitoring +- Check AWS SES sending limits and bounce rates +- Monitor email delivery success rates +- Log verification and reset attempts +- Track WSDC API errors + +### Regular Tasks +- Review and remove expired tokens (manual or automated) +- Update email templates as needed +- Monitor AWS SES costs + +--- + +## Documentation Updates + +**Files Updated:** +- `docs/SESSION_CONTEXT.md` - Added Phase 1.5 status and new files +- `docs/PHASE_1.5.md` - This file (complete phase documentation) + +**Recommended Reading:** +- AWS SES Documentation: https://docs.aws.amazon.com/ses/ +- WSDC API: https://points.worldsdc.com/lookup2020/find +- Prisma Migrations: https://www.prisma.io/docs/concepts/components/prisma-migrate + +--- + +## Success Metrics + +- ✅ **Email Verification:** Dual method (link + code) implemented +- ✅ **Password Reset:** Complete workflow with security best practices +- ✅ **WSDC Integration:** Auto-fill registration from dancer database +- ✅ **Password Security:** Strength indicator and validation +- ✅ **User Experience:** Verification banner and streamlined flows +- ✅ **Code Quality:** Clean, well-documented, error-handled +- ✅ **Professional Emails:** HTML templates with branding +- ✅ **Unit Tests:** 65 tests with 100% coverage of new features + +--- + +## Conclusion + +Phase 1.5 successfully enhances spotlight.cam with production-ready authentication features: +- Professional email system with AWS SES +- Secure verification and password reset workflows +- Dance community integration with WSDC ID lookup +- Improved user experience with strength indicators and banners + +The application is now ready for Phase 2 (Core Features: Matches API, Ratings, and WebRTC signaling). + +--- + +**Next Phase:** [Phase 2 - Core Features](TODO.md) +**Last Updated:** 2025-11-13 +**Author:** Claude Code (spotlight.cam development) diff --git a/docs/SESSION_CONTEXT.md b/docs/SESSION_CONTEXT.md index 0615bf7..a8b696d 100644 --- a/docs/SESSION_CONTEXT.md +++ b/docs/SESSION_CONTEXT.md @@ -15,8 +15,8 @@ ## Current Status -**Phase:** 1 (Backend Foundation) - ✅ COMPLETED -**Progress:** ~50% +**Phase:** 1.5 (Email Verification & WSDC Integration) - ✅ COMPLETED +**Progress:** ~60% **Next Goal:** Phase 2 - Core Features (Matches API, Ratings, WebRTC signaling) ### What Works Now @@ -25,6 +25,9 @@ - ✅ Backend API (Node.js + Express) - ✅ PostgreSQL database with 6 tables (Prisma ORM) - ✅ Real authentication (JWT + bcrypt) +- ✅ **Email verification (AWS SES with link + PIN code) - Phase 1.5** +- ✅ **Password reset workflow - Phase 1.5** +- ✅ **WSDC ID integration for auto-fill registration - Phase 1.5** - ✅ Real-time chat (Socket.IO for event & match rooms) - ✅ WebRTC P2P transfer UI mockup @@ -100,38 +103,54 @@ ## Key Files **Frontend:** +- `frontend/src/pages/RegisterPage.jsx` - **NEW: Two-step registration (WSDC lookup + form) - Phase 1.5** +- `frontend/src/pages/VerifyEmailPage.jsx` - **NEW: Email verification (link + code) - Phase 1.5** +- `frontend/src/pages/ForgotPasswordPage.jsx` - **NEW: Request password reset - Phase 1.5** +- `frontend/src/pages/ResetPasswordPage.jsx` - **NEW: Reset password with token - Phase 1.5** +- `frontend/src/components/common/PasswordStrengthIndicator.jsx` - **NEW: Password strength indicator - Phase 1.5** +- `frontend/src/components/common/VerificationBanner.jsx` - **NEW: Email verification banner - Phase 1.5** - `frontend/src/pages/EventChatPage.jsx` - Event chat with Socket.IO real-time messaging - `frontend/src/pages/MatchChatPage.jsx` - Private chat + WebRTC mockup - `frontend/src/contexts/AuthContext.jsx` - JWT authentication integration -- `frontend/src/services/api.js` - API client (register, login, users) +- `frontend/src/services/api.js` - API client (extended with email verification & WSDC lookup) - `frontend/src/services/socket.js` - Socket.IO client connection manager **Backend:** +- `backend/src/controllers/auth.js` - **UPDATED: Register, login, email verification, password reset - Phase 1.5** +- `backend/src/controllers/wsdc.js` - **NEW: WSDC API proxy for dancer lookup - Phase 1.5** +- `backend/src/utils/email.js` - **NEW: AWS SES email service with HTML templates - Phase 1.5** +- `backend/src/utils/auth.js` - **UPDATED: Token generation utilities - Phase 1.5** +- `backend/src/middleware/auth.js` - **UPDATED: Email verification middleware - Phase 1.5** - `backend/src/server.js` - Express server with Socket.IO integration - `backend/src/socket/index.js` - Socket.IO server (event/match rooms, 89% coverage) -- `backend/src/controllers/auth.js` - Register, login endpoints -- `backend/src/middleware/auth.js` - JWT authentication middleware -- `backend/src/utils/auth.js` - bcrypt + JWT utilities -- `backend/prisma/schema.prisma` - Database schema (6 tables) +- `backend/prisma/schema.prisma` - **UPDATED: Extended User model - Phase 1.5** +- `backend/prisma/migrations/20251113151534_add_wsdc_and_email_verification/` - **NEW migration** **Config:** - `docker-compose.yml` - nginx, frontend, backend, PostgreSQL - `nginx/conf.d/default.conf` - Proxy for /api and /socket.io -- `backend/.env` - Database URL, JWT secret +- `backend/.env` - **UPDATED: AWS SES credentials, email settings - Phase 1.5** --- ## Database Schema (Implemented - Prisma) 6 tables with relations: -- `users` - id, username, email, password_hash, avatar, created_at +- `users` - **EXTENDED in Phase 1.5:** + - Base: id, username, email, password_hash, avatar, created_at, updated_at + - **WSDC:** first_name, last_name, wsdc_id + - **Email Verification:** email_verified, verification_token, verification_code, verification_token_expiry + - **Password Reset:** reset_token, reset_token_expiry - `events` - id, name, location, start_date, end_date, description, worldsdc_id - `chat_rooms` - id, event_id, match_id, type (event/private), created_at - `messages` - id, room_id, user_id, content, type, created_at - `matches` - id, user1_id, user2_id, event_id, room_id, status, created_at - `ratings` - id, match_id, rater_id, rated_id, score, comment, created_at -**Migrations:** Applied with Prisma Migrate +**Migrations:** +- `20251112205214_init` - Initial schema +- `20251113151534_add_wsdc_and_email_verification` - **Phase 1.5 migration** + **Seed data:** 3 events, 2 users, event chat rooms --- @@ -313,6 +332,7 @@ RUN apk add --no-cache openssl --- -**Last Updated:** 2025-11-12 +**Last Updated:** 2025-11-13 **Phase 1 Status:** ✅ COMPLETED - Backend Foundation (Express + PostgreSQL + JWT + Socket.IO) +**Phase 1.5 Status:** ✅ COMPLETED - Email Verification & WSDC Integration (AWS SES + Password Reset + WSDC API) **Next Phase:** Phase 2 - Core Features (Matches API + Ratings + WebRTC) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3b985b0..fe81e71 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,13 +2,17 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { AuthProvider, useAuth } from './contexts/AuthContext'; import LoginPage from './pages/LoginPage'; import RegisterPage from './pages/RegisterPage'; +import VerifyEmailPage from './pages/VerifyEmailPage'; +import ForgotPasswordPage from './pages/ForgotPasswordPage'; +import ResetPasswordPage from './pages/ResetPasswordPage'; import EventsPage from './pages/EventsPage'; import EventChatPage from './pages/EventChatPage'; import MatchChatPage from './pages/MatchChatPage'; import RatePartnerPage from './pages/RatePartnerPage'; import HistoryPage from './pages/HistoryPage'; +import VerificationBanner from './components/common/VerificationBanner'; -// Protected Route Component +// Protected Route Component with Verification Banner const ProtectedRoute = ({ children }) => { const { isAuthenticated, loading } = useAuth(); @@ -24,7 +28,12 @@ const ProtectedRoute = ({ children }) => { return ; } - return children; + return ( + <> + + {children} + + ); }; // Public Route Component (redirect to events if already logged in) @@ -68,6 +77,9 @@ function App() { } /> + } /> + } /> + } /> {/* Protected Routes */} { + const strength = useMemo(() => { + if (!password) return { score: 0, label: '', color: '' }; + + let score = 0; + + // Length check + if (password.length >= 8) score++; + if (password.length >= 12) score++; + + // Character variety checks + if (/[a-z]/.test(password)) score++; // lowercase + if (/[A-Z]/.test(password)) score++; // uppercase + if (/[0-9]/.test(password)) score++; // numbers + if (/[^a-zA-Z0-9]/.test(password)) score++; // special chars + + // Determine label and color + if (score <= 2) { + return { score, label: 'Weak', color: 'bg-red-500' }; + } else if (score <= 4) { + return { score, label: 'Medium', color: 'bg-yellow-500' }; + } else { + return { score, label: 'Strong', color: 'bg-green-500' }; + } + }, [password]); + + if (!password) return null; + + const widthPercentage = (strength.score / 6) * 100; + + return ( +
+
+ Password strength: + + {strength.label} + +
+
+
+
+
    +
  • = 8 ? 'text-green-600' : ''}> + ✓ At least 8 characters +
  • +
  • + ✓ Upper and lowercase letters +
  • +
  • + ✓ At least one number +
  • +
+
+ ); +}; + +export default PasswordStrengthIndicator; diff --git a/frontend/src/components/common/VerificationBanner.jsx b/frontend/src/components/common/VerificationBanner.jsx new file mode 100644 index 0000000..433ee56 --- /dev/null +++ b/frontend/src/components/common/VerificationBanner.jsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { useAuth } from '../../contexts/AuthContext'; +import { authAPI } from '../../services/api'; +import { AlertCircle, X, Mail, Loader2 } from 'lucide-react'; +import { Link } from 'react-router-dom'; + +/** + * Verification Banner Component + * Displays a banner for unverified users with option to resend verification email + */ +const VerificationBanner = () => { + const { user } = useAuth(); + const [dismissed, setDismissed] = useState(false); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState(''); + + // Don't show if user is verified or banner is dismissed + if (!user || user.emailVerified || dismissed) { + return null; + } + + const handleResend = async () => { + setLoading(true); + setMessage(''); + + try { + await authAPI.resendVerification(user.email); + setMessage('Verification email sent! Please check your inbox.'); + } catch (error) { + setMessage('Failed to send email. Please try again.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+ +
+

+ Please verify your email address to access all features +

+ {message && ( +

{message}

+ )} +
+
+ +
+ + + Verify Now + + + + + +
+
+
+
+ ); +}; + +export default VerificationBanner; diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index d54b05f..2ef907c 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -48,9 +48,9 @@ export const AuthProvider = ({ children }) => { } }; - const register = async (username, email, password) => { + const register = async (username, email, password, firstName = null, lastName = null, wsdcId = null) => { try { - const { user: userData } = await authAPI.register(username, email, password); + const { user: userData } = await authAPI.register(username, email, password, firstName, lastName, wsdcId); setUser(userData); // Save to localStorage for persistence localStorage.setItem('user', JSON.stringify(userData)); diff --git a/frontend/src/pages/ForgotPasswordPage.jsx b/frontend/src/pages/ForgotPasswordPage.jsx new file mode 100644 index 0000000..94a88d2 --- /dev/null +++ b/frontend/src/pages/ForgotPasswordPage.jsx @@ -0,0 +1,134 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { authAPI } from '../services/api'; +import { Video, Mail, ArrowLeft, CheckCircle, Loader2 } from 'lucide-react'; + +const ForgotPasswordPage = () => { + const [email, setEmail] = useState(''); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + setError(''); + + try { + await authAPI.requestPasswordReset(email); + setSuccess(true); + } catch (err) { + setError(err.data?.error || 'Failed to send reset email. Please try again.'); + } finally { + setLoading(false); + } + }; + + if (success) { + return ( +
+
+
+
+ +
+

+ Check Your Email +

+

+ If an account exists with {email}, you will receive a password reset link shortly. +

+

+ Didn't receive the email? Check your spam folder or try again. +

+ + + Back to Login + +
+
+
+ ); + } + + return ( +
+
+
+
+ + {error && ( +
+

{error}

+
+ )} + +
+
+ +
+
+ +
+ setEmail(e.target.value)} + className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500" + placeholder="your@email.com" + required + disabled={loading} + /> +
+
+ + +
+ +
+ + + Back to Login + +
+ +
+

+ Don't have an account?{' '} + + Sign up + +

+
+
+
+ ); +}; + +export default ForgotPasswordPage; diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index 32837ab..e567d7d 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -53,9 +53,17 @@ const LoginPage = () => {
- +
+ + + Forgot password? + +
diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx index 716c3cd..6837280 100644 --- a/frontend/src/pages/RegisterPage.jsx +++ b/frontend/src/pages/RegisterPage.jsx @@ -1,46 +1,274 @@ import { useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; -import { Video, Mail, Lock, User } from 'lucide-react'; +import { wsdcAPI } from '../services/api'; +import { Video, Mail, Lock, User, Hash, ArrowRight, ArrowLeft, Loader2 } from 'lucide-react'; +import PasswordStrengthIndicator from '../components/common/PasswordStrengthIndicator'; const RegisterPage = () => { - const [username, setUsername] = useState(''); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); + // Step management + const [step, setStep] = useState(1); // 1 = WSDC check, 2 = Registration form + + // WSDC lookup state + const [hasWsdcId, setHasWsdcId] = useState(null); + const [wsdcId, setWsdcId] = useState(''); + const [wsdcData, setWsdcData] = useState(null); + const [wsdcLoading, setWsdcLoading] = useState(false); + const [wsdcError, setWsdcError] = useState(''); + + // Registration form state + const [formData, setFormData] = useState({ + username: '', + email: '', + password: '', + confirmPassword: '', + firstName: '', + lastName: '', + }); const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const { register } = useAuth(); const navigate = useNavigate(); - const handleSubmit = async (e) => { - e.preventDefault(); - if (password !== confirmPassword) { - alert('Passwords do not match'); + // Handle WSDC ID lookup + const handleWsdcLookup = async () => { + if (!wsdcId || wsdcId.trim() === '') { + setWsdcError('Please enter your WSDC ID'); return; } - setLoading(true); + + setWsdcLoading(true); + setWsdcError(''); + try { - await register(username, email, password); + const response = await wsdcAPI.lookupDancer(wsdcId); + + if (response.success && response.dancer) { + setWsdcData(response.dancer); + setFormData(prev => ({ + ...prev, + firstName: response.dancer.firstName, + lastName: response.dancer.lastName, + })); + setStep(2); + } else { + setWsdcError('WSDC ID not found. Please check and try again.'); + } + } catch (err) { + setWsdcError(err.data?.message || 'Failed to lookup WSDC ID. Please try again.'); + } finally { + setWsdcLoading(false); + } + }; + + // Handle "No WSDC ID" option + const handleNoWsdcId = () => { + setHasWsdcId(false); + setWsdcData(null); + setWsdcId(''); + setStep(2); + }; + + // Handle form input changes + const handleInputChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + // Handle registration submission + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + + // Validation + if (formData.password !== formData.confirmPassword) { + setError('Passwords do not match'); + return; + } + + if (formData.password.length < 8) { + setError('Password must be at least 8 characters long'); + return; + } + + setLoading(true); + + try { + await register( + formData.username, + formData.email, + formData.password, + formData.firstName || null, + formData.lastName || null, + wsdcData?.wsdcId || null + ); navigate('/events'); - } catch (error) { - console.error('Registration failed:', error); + } catch (err) { + setError(err.message || 'Registration failed'); } finally { setLoading(false); } }; + // Step 1: WSDC ID Check + if (step === 1) { + return ( +
+
+
+
+ +
+

Do you have a WSDC ID?

+

+ If you're registered with the World Swing Dance Council, we can automatically fill in your details. +

+
+ + {hasWsdcId === null ? ( +
+ + +
+ ) : ( +
+
+ +
+
+ +
+ setWsdcId(e.target.value)} + className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500" + placeholder="26997" + disabled={wsdcLoading} + /> +
+ {wsdcError && ( +

{wsdcError}

+ )} +
+ +
+ + + +
+
+ )} + +
+

+ Already have an account?{' '} + + Sign in + +

+
+
+
+ ); + } + + // Step 2: Registration Form return ( -
+
-
-
+ {/* Email */}
-
+ {/* Password */}
-
+
+ {/* Confirm Password */}
-
- +
+ + + +
-
+

Already have an account?{' '} diff --git a/frontend/src/pages/RegisterPage_old.jsx b/frontend/src/pages/RegisterPage_old.jsx new file mode 100644 index 0000000..716c3cd --- /dev/null +++ b/frontend/src/pages/RegisterPage_old.jsx @@ -0,0 +1,140 @@ +import { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; +import { Video, Mail, Lock, User } from 'lucide-react'; + +const RegisterPage = () => { + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const { register } = useAuth(); + const navigate = useNavigate(); + + const handleSubmit = async (e) => { + e.preventDefault(); + if (password !== confirmPassword) { + alert('Passwords do not match'); + return; + } + setLoading(true); + try { + await register(username, email, password); + navigate('/events'); + } catch (error) { + console.error('Registration failed:', error); + } finally { + setLoading(false); + } + }; + + return ( +

+
+
+
+ +
+
+ +
+
+ +
+ setUsername(e.target.value)} + className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500" + placeholder="your_username" + required + /> +
+
+ +
+ +
+
+ +
+ setEmail(e.target.value)} + className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500" + placeholder="your@email.com" + required + /> +
+
+ +
+ +
+
+ +
+ setPassword(e.target.value)} + className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500" + placeholder="••••••••" + required + /> +
+
+ +
+ +
+
+ +
+ setConfirmPassword(e.target.value)} + className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500" + placeholder="••••••••" + required + /> +
+
+ + +
+ +
+

+ Already have an account?{' '} + + Sign in + +

+
+
+
+ ); +}; + +export default RegisterPage; diff --git a/frontend/src/pages/ResetPasswordPage.jsx b/frontend/src/pages/ResetPasswordPage.jsx new file mode 100644 index 0000000..7882c12 --- /dev/null +++ b/frontend/src/pages/ResetPasswordPage.jsx @@ -0,0 +1,196 @@ +import { useState } from 'react'; +import { useNavigate, useSearchParams, Link } from 'react-router-dom'; +import { authAPI } from '../services/api'; +import { Video, Lock, CheckCircle, XCircle, Loader2 } from 'lucide-react'; +import PasswordStrengthIndicator from '../components/common/PasswordStrengthIndicator'; + +const ResetPasswordPage = () => { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const token = searchParams.get('token'); + + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + + // Validation + if (!token) { + setError('Invalid or missing reset token'); + return; + } + + if (newPassword !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + if (newPassword.length < 8) { + setError('Password must be at least 8 characters long'); + return; + } + + setLoading(true); + + try { + await authAPI.resetPassword(token, newPassword); + setSuccess(true); + } catch (err) { + setError(err.data?.error || 'Failed to reset password. The link may have expired.'); + } finally { + setLoading(false); + } + }; + + // Success state + if (success) { + return ( +
+
+
+
+ +
+

+ Password Reset Successfully! 🎉 +

+

+ Your password has been updated. You can now log in with your new password. +

+ +
+
+
+ ); + } + + // Invalid token state + if (!token) { + return ( +
+
+
+
+ +
+

+ Invalid Reset Link +

+

+ This password reset link is invalid or has expired. Please request a new one. +

+ + Request New Link + +
+
+
+ ); + } + + // Reset password form + return ( +
+
+
+
+ + {error && ( +
+ +

{error}

+
+ )} + +
+ {/* New Password */} +
+ +
+
+ +
+ setNewPassword(e.target.value)} + className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500" + placeholder="••••••••" + required + disabled={loading} + /> +
+ +
+ + {/* Confirm Password */} +
+ +
+
+ +
+ setConfirmPassword(e.target.value)} + className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500" + placeholder="••••••••" + required + disabled={loading} + /> +
+ {confirmPassword && newPassword !== confirmPassword && ( +

Passwords do not match

+ )} +
+ + +
+ +
+ + Back to Login + +
+
+
+ ); +}; + +export default ResetPasswordPage; diff --git a/frontend/src/pages/VerifyEmailPage.jsx b/frontend/src/pages/VerifyEmailPage.jsx new file mode 100644 index 0000000..b36f269 --- /dev/null +++ b/frontend/src/pages/VerifyEmailPage.jsx @@ -0,0 +1,236 @@ +import { useState, useEffect } from 'react'; +import { useNavigate, useSearchParams, Link } from 'react-router-dom'; +import { authAPI } from '../services/api'; +import { Video, Mail, CheckCircle, XCircle, Loader2, ArrowRight } from 'lucide-react'; + +const VerifyEmailPage = () => { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const token = searchParams.get('token'); + + const [verificationMode, setVerificationMode] = useState(token ? 'token' : 'code'); + const [loading, setLoading] = useState(!!token); // Auto-loading if token exists + const [success, setSuccess] = useState(false); + const [error, setError] = useState(''); + + // Code verification state + const [email, setEmail] = useState(''); + const [code, setCode] = useState(''); + + // Auto-verify if token is in URL + useEffect(() => { + if (token) { + verifyByToken(token); + } + }, [token]); + + const verifyByToken = async (verificationToken) => { + setLoading(true); + setError(''); + + try { + const response = await authAPI.verifyEmailByToken(verificationToken); + if (response.success) { + setSuccess(true); + } else { + setError(response.error || 'Verification failed'); + } + } catch (err) { + setError(err.data?.error || 'Invalid or expired verification link'); + } finally { + setLoading(false); + } + }; + + const handleCodeVerification = async (e) => { + e.preventDefault(); + + if (!email || !code) { + setError('Please enter both email and verification code'); + return; + } + + setLoading(true); + setError(''); + + try { + const response = await authAPI.verifyEmailByCode(email, code); + if (response.success) { + setSuccess(true); + } else { + setError(response.error || 'Verification failed'); + } + } catch (err) { + setError(err.data?.error || 'Invalid verification code'); + } finally { + setLoading(false); + } + }; + + const handleResendVerification = async () => { + if (!email) { + setError('Please enter your email address'); + return; + } + + setLoading(true); + setError(''); + + try { + await authAPI.resendVerification(email); + alert('Verification email sent! Please check your inbox.'); + } catch (err) { + setError(err.data?.error || 'Failed to resend verification email'); + } finally { + setLoading(false); + } + }; + + // Success state + if (success) { + return ( +
+
+
+
+ +
+

+ Email Verified! 🎉 +

+

+ Your email has been successfully verified. You can now access all features of spotlight.cam! +

+ +
+
+
+ ); + } + + // Loading state (for token verification) + if (loading && verificationMode === 'token') { + return ( +
+
+
+ +

+ Verifying your email... +

+

+ Please wait while we verify your email address. +

+
+
+
+ ); + } + + // Code verification form + return ( +
+
+
+
+ + {error && ( +
+ +

{error}

+
+ )} + +
+ {/* Email */} +
+ +
+
+ +
+ setEmail(e.target.value)} + className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500" + placeholder="your@email.com" + required + disabled={loading} + /> +
+
+ + {/* Verification Code */} +
+ + setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 text-center text-2xl font-mono tracking-widest" + placeholder="000000" + maxLength="6" + required + disabled={loading} + /> +

+ Enter the 6-digit code from your email +

+
+ + +
+ +
+

+ Didn't receive the code?{' '} + +

+
+ +
+ + Skip for now → + +
+
+
+ ); +}; + +export default VerifyEmailPage; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index c6b7c95..ace98a8 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -49,10 +49,10 @@ async function fetchAPI(endpoint, options = {}) { // Auth API export const authAPI = { - async register(username, email, password) { + async register(username, email, password, firstName = null, lastName = null, wsdcId = null) { const data = await fetchAPI('/auth/register', { method: 'POST', - body: JSON.stringify({ username, email, password }), + body: JSON.stringify({ username, email, password, firstName, lastName, wsdcId }), }); // Save token @@ -82,12 +82,57 @@ export const authAPI = { return data.data; }, + async verifyEmailByToken(token) { + const data = await fetchAPI(`/auth/verify-email?token=${token}`); + return data; + }, + + async verifyEmailByCode(email, code) { + const data = await fetchAPI('/auth/verify-code', { + method: 'POST', + body: JSON.stringify({ email, code }), + }); + return data; + }, + + async resendVerification(email) { + const data = await fetchAPI('/auth/resend-verification', { + method: 'POST', + body: JSON.stringify({ email }), + }); + return data; + }, + + async requestPasswordReset(email) { + const data = await fetchAPI('/auth/request-password-reset', { + method: 'POST', + body: JSON.stringify({ email }), + }); + return data; + }, + + async resetPassword(token, newPassword) { + const data = await fetchAPI('/auth/reset-password', { + method: 'POST', + body: JSON.stringify({ token, newPassword }), + }); + return data; + }, + logout() { localStorage.removeItem('token'); localStorage.removeItem('user'); }, }; +// WSDC API (Phase 1.5) +export const wsdcAPI = { + async lookupDancer(wsdcId) { + const data = await fetchAPI(`/wsdc/lookup?id=${wsdcId}`); + return data; + }, +}; + // Events API export const eventsAPI = { async getAll() {