From 429cdb7094abcbda5c4f2cb0aae5f63c75dd2756 Mon Sep 17 00:00:00 2001 From: brooktewabe Date: Tue, 14 Apr 2026 11:50:43 +0300 Subject: [PATCH] auth done --- next.config.ts | 2 - package-lock.json | 328 +++++++++++++++--------- package.json | 4 +- src/app/api/auth/[...nextauth]/route.ts | 6 + src/app/providers.tsx | 13 +- src/context/AuthContext.tsx | 309 ++++++++-------------- src/lib/api-client.ts | 45 ++++ src/lib/auth-options.ts | 213 +++++++++++++++ src/lib/env.ts | 11 + src/types/next-auth.d.ts | 26 ++ 10 files changed, 626 insertions(+), 331 deletions(-) create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/lib/api-client.ts create mode 100644 src/lib/auth-options.ts create mode 100644 src/lib/env.ts create mode 100644 src/types/next-auth.d.ts diff --git a/next.config.ts b/next.config.ts index 254ed69..637fb97 100644 --- a/next.config.ts +++ b/next.config.ts @@ -11,5 +11,3 @@ const nextConfig: NextConfig = { }; export default nextConfig; - -import('@opennextjs/cloudflare').then(m => m.initOpenNextCloudflareForDev()); diff --git a/package-lock.json b/package-lock.json index 01a6fb3..c9cf8ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "0.1.0", "dependencies": { "next": "16.2.1", + "next-auth": "^4.24.11", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "zustand": "^5.0.8" }, "devDependencies": { "@opennextjs/aws": "^3.9.16", @@ -102,9 +104,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -122,9 +121,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -142,9 +138,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -162,9 +155,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1635,6 +1625,14 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -1931,6 +1929,7 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2603,6 +2602,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2625,6 +2625,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2647,6 +2648,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2663,6 +2665,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2679,9 +2682,7 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2698,9 +2699,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2717,9 +2716,7 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2736,9 +2733,7 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2755,9 +2750,7 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2774,9 +2767,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2793,9 +2784,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2812,9 +2801,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2831,9 +2818,7 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2856,9 +2841,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2881,9 +2864,7 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2906,9 +2887,7 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2931,9 +2910,7 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2956,9 +2933,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2981,9 +2956,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3006,9 +2979,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3031,6 +3002,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -3050,6 +3022,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3069,6 +3042,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3088,6 +3062,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3239,9 +3214,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3258,9 +3230,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3277,9 +3246,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3296,9 +3262,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3632,6 +3595,14 @@ "wrangler": "^4.65.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -4627,9 +4598,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4647,9 +4615,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4667,9 +4632,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4687,9 +4649,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4841,7 +4800,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -5258,9 +5217,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5275,9 +5231,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5292,9 +5245,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5309,9 +5259,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5326,9 +5273,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5343,9 +5287,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5360,9 +5301,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5377,9 +5315,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6263,7 +6198,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -8717,6 +8652,14 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8997,9 +8940,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9021,9 +8961,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9045,9 +8982,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9069,9 +9003,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9472,6 +9403,45 @@ } } }, + "node_modules/next-auth": { + "version": "4.24.13", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.13.tgz", + "integrity": "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.3", + "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", + "nodemailer": "^7.0.7", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/next-auth/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -9581,6 +9551,11 @@ "node": ">=8" } }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -9591,6 +9566,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -9721,6 +9704,14 @@ "dev": true, "license": "MIT" }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -9760,6 +9751,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -10000,6 +10021,26 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.29.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", + "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -10010,6 +10051,11 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -11426,6 +11472,14 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -12312,6 +12366,34 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 5b82198..1806093 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,10 @@ }, "dependencies": { "next": "16.2.1", + "next-auth": "^4.24.11", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "zustand": "^5.0.8" }, "devDependencies": { "@opennextjs/aws": "^3.9.16", diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..62a9268 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import NextAuth from "next-auth"; +import { authOptions } from "@/lib/auth-options"; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 5375e61..dce7af1 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,16 +1,15 @@ "use client"; +import { SessionProvider } from "next-auth/react"; +import { StoreHydration } from "@/components/StoreHydration"; import { AuthProvider } from "@/context/AuthContext"; -import { BookingProvider } from "@/context/BookingContext"; -import { CurrencyProvider } from "@/context/CurrencyContext"; import type { ReactNode } from "react"; export function Providers({ children }: { children: ReactNode }) { return ( - - - {children} - - + + + {children} + ); } diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 3111fe0..1de7396 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -4,166 +4,111 @@ import { createContext, useCallback, useContext, - useEffect, useMemo, - useState, type ReactNode, } from "react"; -import { DEMO_BOOKING_REFS } from "@/lib/mocks/guestData"; +import { signIn, signOut, useSession } from "next-auth/react"; +import { getHotelPropertyId, getPublicApiUrl } from "@/lib/env"; +import { useGuestUiStore } from "@/stores/guest-ui-store"; +import { useOrdersStore } from "@/stores/orders-store"; +import type { OrderRecord } from "@/types/guest-order"; -const STORAGE_SESSION = "shitaye_session_v1"; -const STORAGE_ORDERS = "shitaye_orders_v1"; - -export type OrderCategory = "room-service" | "laundry" | "gym" | "spa"; - -export type OrderRecord = { - id: string; - category: OrderCategory; - title: string; - detail: string; - totalUsd: number; - placedAt: string; - status: "pending" | "confirmed" | "completed"; -}; +export type { OrderCategory, OrderRecord } from "@/types/guest-order"; export type MemberSession = { kind: "member"; + accessToken: string; email: string; displayName: string; + propertyId?: string; points: number; - tier: "Gold" | "Silver"; - /** How they signed in — for display only */ - authMethod: "otp" | "password" | "google" | "apple" | "facebook"; + authMethod: "otp" | "password" | "google"; + bookingCode?: string | null; + bookingId?: string | null; + role?: string; }; - -export type BookingRefSession = { - kind: "bookingRef"; - bookingRef: string; - guestName: string; - roomLabel: string; - checkOut: string; -}; - -export type GuestSession = MemberSession | BookingRefSession; +export type GuestSession = MemberSession; type AuthContextValue = { session: GuestSession | null; orders: OrderRecord[]; isHydrated: boolean; - /** Demo OTP is always 123456 */ + accessToken: string | null; requestOtp: (email: string) => Promise<{ ok: boolean; message: string }>; verifyOtp: (email: string, code: string) => Promise<{ ok: boolean; message: string }>; loginPassword: (email: string, password: string) => Promise<{ ok: boolean; message: string }>; - loginSocial: (provider: "google" | "apple" | "facebook") => void; - loginBookingRef: (ref: string) => { ok: boolean; message: string }; - logout: () => void; + loginGoogle: () => Promise; + loginBookingRef: (ref: string) => Promise<{ ok: boolean; message: string }>; + logout: () => Promise; addOrder: (o: Omit & { status?: OrderRecord["status"] }) => void; awardPoints: (points: number) => void; + setOrders: (orders: OrderRecord[] | ((prev: OrderRecord[]) => OrderRecord[])) => void; }; const AuthContext = createContext(null); -function loadOrders(): OrderRecord[] { - if (typeof window === "undefined") return []; - try { - const raw = localStorage.getItem(STORAGE_ORDERS); - if (!raw) return seedOrders(); - const parsed = JSON.parse(raw) as OrderRecord[]; - return Array.isArray(parsed) ? parsed : seedOrders(); - } catch { - return seedOrders(); - } -} - -function seedOrders(): OrderRecord[] { - return [ - { - id: "seed-rs-1", - category: "room-service", - title: "Room service · American breakfast ×2", - detail: "Delivered 07:15 · Room charge", - totalUsd: 36, - placedAt: new Date(Date.now() - 86400000 * 2).toISOString(), - status: "completed", - }, - { - id: "seed-l-1", - category: "laundry", - title: "Laundry · Express + 3 shirts", - detail: "Returned same evening", - totalUsd: 27, - placedAt: new Date(Date.now() - 86400000).toISOString(), - status: "completed", - }, - { - id: "seed-sp-1", - category: "spa", - title: "Spa · Signature Swedish 60 min", - detail: "Apr 4 · 15:00", - totalUsd: 85, - placedAt: new Date(Date.now() - 86400000 * 3).toISOString(), - status: "confirmed", - }, - ]; -} - -function loadSession(): GuestSession | null { - if (typeof window === "undefined") return null; - try { - const raw = localStorage.getItem(STORAGE_SESSION); - if (!raw) return null; - return JSON.parse(raw) as GuestSession; - } catch { - return null; - } -} - -function persistSession(s: GuestSession | null) { - if (typeof window === "undefined") return; - if (s) localStorage.setItem(STORAGE_SESSION, JSON.stringify(s)); - else localStorage.removeItem(STORAGE_SESSION); -} - -function persistOrders(orders: OrderRecord[]) { - if (typeof window === "undefined") return; - localStorage.setItem(STORAGE_ORDERS, JSON.stringify(orders)); -} - export function AuthProvider({ children }: { children: ReactNode }) { - const [session, setSession] = useState(null); - const [orders, setOrders] = useState([]); - const [isHydrated, setIsHydrated] = useState(false); + const { data, status } = useSession(); + const orders = useOrdersStore((s) => s.orders); + const localBonusPoints = useGuestUiStore((s) => s.localBonusPoints); - useEffect(() => { - setSession(loadSession()); - setOrders(loadOrders()); - setIsHydrated(true); - }, []); + const isHydrated = status !== "loading"; + + const guestSession = useMemo((): GuestSession | null => { + if (status !== "authenticated" || !data?.accessToken) return null; + const email = data.user?.email ?? ""; + const displayName = data.user?.name ?? email.split("@")[0] ?? "Guest"; + return { + kind: "member", + accessToken: data.accessToken, + email, + displayName: displayName.charAt(0).toUpperCase() + displayName.slice(1), + propertyId: data.propertyId ?? getHotelPropertyId(), + points: localBonusPoints, + authMethod: data.authMethod ?? "password", + bookingCode: data.bookingCode ?? null, + bookingId: data.bookingId ?? null, + role: data.role, + }; + }, [status, data, localBonusPoints]); const requestOtp = useCallback(async (email: string) => { if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return { ok: false, message: "Enter a valid email address." }; } - return { ok: true, message: "Demo code sent. Use OTP 123456 to continue." }; + const propertyId = getHotelPropertyId(); + if (!propertyId) { + return { ok: false, message: "Hotel is not configured (missing NEXT_PUBLIC_HOTEL_PROPERTY_ID)." }; + } + try { + const base = getPublicApiUrl(); + const res = await fetch(`${base}/auth/hotel-guest/send-otp`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ propertyId, email: email.trim().toLowerCase() }), + }); + const body = (await res.json().catch(() => ({}))) as { message?: string }; + if (!res.ok) { + return { + ok: false, + message: typeof body.message === "string" ? body.message : "Could not send code.", + }; + } + return { ok: true, message: "Check your email for the one-time code." }; + } catch { + return { ok: false, message: "Could not send code. Try again." }; + } }, []); const verifyOtp = useCallback(async (email: string, code: string) => { - const trimmed = code.replace(/\s/g, ""); - if (trimmed !== "123456") { - return { ok: false, message: "Invalid code. Demo OTP is 123456." }; + const r = await signIn("hotel-otp", { + email: email.trim().toLowerCase(), + otp: code.replace(/\s/g, ""), + redirect: false, + }); + if (r?.error) { + return { ok: false, message: "Invalid or expired code." }; } - const local = email.split("@")[0] ?? "Guest"; - const name = local.charAt(0).toUpperCase() + local.slice(1); - const next: MemberSession = { - kind: "member", - email: email.toLowerCase(), - displayName: name, - points: 2400, - tier: "Gold", - authMethod: "otp", - }; - setSession(next); - persistSession(next); return { ok: true, message: "Signed in." }; }, []); @@ -171,69 +116,47 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (!email || !password) { return { ok: false, message: "Email and password required." }; } - if (password !== "shitaye" && password !== "demo123") { - return { - ok: false, - message: "Incorrect password. Try demo password: shitaye", - }; + const r = await signIn("credentials", { + identifier: email.trim().toLowerCase(), + password, + redirect: false, + }); + if (r?.error) { + return { ok: false, message: "Invalid email or password." }; } - const local = email.split("@")[0] ?? "Guest"; - const name = local.charAt(0).toUpperCase() + local.slice(1); - const next: MemberSession = { - kind: "member", - email: email.toLowerCase(), - displayName: name, - points: 2400, - tier: "Gold", - authMethod: "password", - }; - setSession(next); - persistSession(next); return { ok: true, message: "Signed in." }; }, []); - const loginSocial = useCallback((provider: "google" | "apple" | "facebook") => { - const names: Record = { - google: "Google Guest", - apple: "Apple Guest", - facebook: "Facebook Guest", - }; - const next: MemberSession = { - kind: "member", - email: `guest.${provider}@shitaye.demo`, - displayName: names[provider], - points: 2100, - tier: "Silver", - authMethod: provider, - }; - setSession(next); - persistSession(next); + const loginGoogle = useCallback(async () => { + await signIn("google", { callbackUrl: "/", redirect: true }); }, []); - const loginBookingRef = useCallback((ref: string) => { - const key = ref.trim().toUpperCase(); - const row = DEMO_BOOKING_REFS[key]; - if (!row) { + const loginBookingRef = useCallback(async (ref: string) => { + const propertyId = getHotelPropertyId(); + if (!propertyId) { return { ok: false, - message: "Reference not found. Try SHITAYE-2026-DEMO or GUEST-1234.", + message: "Hotel is not configured (missing NEXT_PUBLIC_HOTEL_PROPERTY_ID).", }; } - const next: BookingRefSession = { - kind: "bookingRef", - bookingRef: key, - guestName: row.guestName, - roomLabel: row.room, - checkOut: row.checkOut, - }; - setSession(next); - persistSession(next); - return { ok: true, message: "Linked to your stay." }; + const r = await signIn("booking-code", { + bookingCode: ref.trim(), + propertyId, + redirect: false, + }); + if (r?.error) { + return { ok: false, message: "Invalid booking code or account not linked." }; + } + return { ok: true, message: "Signed in with your booking." }; }, []); - const logout = useCallback(() => { - setSession(null); - persistSession(null); + const logout = useCallback(async () => { + useGuestUiStore.getState().resetLocalBonus(); + await signOut({ callbackUrl: "/" }); + }, []); + + const setOrders = useCallback((next: OrderRecord[] | ((prev: OrderRecord[]) => OrderRecord[])) => { + useOrdersStore.getState().setOrders(next); }, []); const addOrder = useCallback( @@ -248,59 +171,49 @@ export function AuthProvider({ children }: { children: ReactNode }) { placedAt: new Date().toISOString(), status: o.status ?? "pending", }; - setOrders((prev) => { - const next = [rec, ...prev]; - persistOrders(next); - return next; - }); - if (session?.kind === "member") { + useOrdersStore.getState().pushOrder(rec); + if (guestSession?.kind === "member") { const bonus = Math.min(150, Math.round(o.totalUsd * 2)); - setSession((s) => { - if (!s || s.kind !== "member") return s; - const u = { ...s, points: s.points + bonus }; - persistSession(u); - return u; - }); + useGuestUiStore.getState().addLocalBonus(bonus); } }, - [session], + [guestSession], ); const awardPoints = useCallback((points: number) => { - setSession((s) => { - if (!s || s.kind !== "member") return s; - const u = { ...s, points: s.points + points }; - persistSession(u); - return u; - }); + useGuestUiStore.getState().addLocalBonus(points); }, []); const value = useMemo( () => ({ - session, + session: guestSession, orders, isHydrated, + accessToken: data?.accessToken ?? null, requestOtp, verifyOtp, loginPassword, - loginSocial, + loginGoogle, loginBookingRef, logout, addOrder, awardPoints, + setOrders, }), [ - session, + guestSession, orders, isHydrated, + data?.accessToken, requestOtp, verifyOtp, loginPassword, - loginSocial, + loginGoogle, loginBookingRef, logout, addOrder, awardPoints, + setOrders, ], ); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts new file mode 100644 index 0000000..5f17a9e --- /dev/null +++ b/src/lib/api-client.ts @@ -0,0 +1,45 @@ +import { getPublicApiUrl } from "@/lib/env"; + +export class ApiError extends Error { + constructor( + message: string, + public status: number, + public body?: unknown, + ) { + super(message); + this.name = "ApiError"; + } +} + +export async function apiFetch( + path: string, + init: RequestInit & { accessToken?: string } = {}, +): Promise { + const base = getPublicApiUrl(); + const url = path.startsWith("http") ? path : `${base}${path.startsWith("/") ? "" : "/"}${path}`; + const headers = new Headers(init.headers); + if (init.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + if (init.accessToken) { + headers.set("Authorization", `Bearer ${init.accessToken}`); + } + const res = await fetch(url, { ...init, headers }); + const text = await res.text(); + let data: unknown = null; + if (text) { + try { + data = JSON.parse(text) as unknown; + } catch { + data = text; + } + } + if (!res.ok) { + const msg = + typeof data === "object" && data !== null && "message" in data + ? String((data as { message: unknown }).message) + : res.statusText; + throw new ApiError(msg || `HTTP ${res.status}`, res.status, data); + } + return data as T; +} diff --git a/src/lib/auth-options.ts b/src/lib/auth-options.ts new file mode 100644 index 0000000..66dad51 --- /dev/null +++ b/src/lib/auth-options.ts @@ -0,0 +1,213 @@ +import type { NextAuthOptions } from "next-auth"; +import CredentialsProvider from "next-auth/providers/credentials"; +import GoogleProvider from "next-auth/providers/google"; +import { getPublicApiUrl, getHotelPropertyId } from "@/lib/env"; + +async function postJson(path: string, body: Record): Promise { + const api = getPublicApiUrl(); + const res = await fetch(`${api}${path.startsWith("/") ? path : `/${path}`}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = (await res.json().catch(() => ({}))) as T & { message?: string }; + if (!res.ok) { + throw new Error( + typeof data === "object" && data && "message" in data && typeof data.message === "string" + ? data.message + : `Auth failed (${res.status})`, + ); + } + return data as T; +} + +type LoginPayload = { + access_token: string; + user: { + id: string; + email: string | null; + name: string | null; + role: string; + propertyId?: string; + }; +}; + +type AuthMethod = "otp" | "password" | "google"; + +type BookingCodePayload = LoginPayload & { + booking?: { + id: string; + bookingCode: string | null; + checkIn: string; + checkOut: string; + status: string; + }; +}; + +const providers: NextAuthOptions["providers"] = [ + CredentialsProvider({ + id: "credentials", + name: "Email & password", + credentials: { + identifier: { label: "Email", type: "text" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + if (!credentials?.identifier || !credentials?.password) return null; + try { + const data = await postJson("/auth/login", { + identifier: credentials.identifier, + password: credentials.password, + }); + return { + id: data.user.id, + email: data.user.email ?? undefined, + name: data.user.name ?? undefined, + accessToken: data.access_token, + role: data.user.role, + propertyId: data.user.propertyId, + authMethod: "password" as AuthMethod, + }; + } catch { + return null; + } + }, + }), + CredentialsProvider({ + id: "hotel-otp", + name: "Hotel email OTP", + credentials: { + email: { label: "Email", type: "text" }, + otp: { label: "Code", type: "text" }, + }, + async authorize(credentials) { + if (!credentials?.email || !credentials?.otp) return null; + try { + const data = await postJson("/auth/hotel-user/login-email-otp", { + email: credentials.email, + otp: credentials.otp, + }); + return { + id: data.user.id, + email: data.user.email ?? undefined, + name: data.user.name ?? undefined, + accessToken: data.access_token, + role: data.user.role, + propertyId: data.user.propertyId, + authMethod: "otp" as AuthMethod, + }; + } catch { + return null; + } + }, + }), + CredentialsProvider({ + id: "booking-code", + name: "Booking code", + credentials: { + bookingCode: { label: "Booking code", type: "text" }, + propertyId: { label: "Property", type: "text" }, + }, + async authorize(credentials) { + const propertyId = credentials?.propertyId?.trim() || getHotelPropertyId(); + const bookingCode = credentials?.bookingCode?.trim(); + if (!propertyId || !bookingCode) return null; + try { + const data = await postJson("/auth/hotel-guest/login-booking-code", { + propertyId, + bookingCode, + }); + return { + id: data.user.id, + email: data.user.email ?? undefined, + name: data.user.name ?? undefined, + accessToken: data.access_token, + role: data.user.role, + propertyId: data.user.propertyId ?? propertyId, + bookingCode: data.booking?.bookingCode ?? bookingCode, + bookingId: data.booking?.id ?? null, + }; + } catch { + return null; + } + }, + }), +]; + +if (process.env.GOOGLE_CLIENT_ID?.trim() && process.env.GOOGLE_CLIENT_SECRET?.trim()) { + providers.push( + GoogleProvider({ + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + }), + ); +} + +export const authOptions: NextAuthOptions = { + providers, + callbacks: { + async jwt({ token, user, account, profile }) { + if (account?.provider === "google" && profile && "email" in profile && profile.email) { + try { + const data = await postJson("/auth/google", { + email: profile.email, + name: profile.name, + googleId: profile.sub, + role: "CUSTOMER", + }); + token.accessToken = data.access_token; + token.sub = data.user.id; + token.email = data.user.email ?? undefined; + token.name = data.user.name ?? undefined; + token.role = data.user.role; + token.propertyId = data.user.propertyId; + token.authMethod = "google"; + token.error = undefined; + } catch { + token.error = "GoogleSignInFailed"; + } + return token; + } + + if (user) { + const u = user as { + accessToken?: string; + role?: string; + propertyId?: string; + bookingCode?: string | null; + bookingId?: string | null; + authMethod?: AuthMethod; + }; + token.accessToken = u.accessToken; + token.role = u.role; + token.propertyId = u.propertyId; + token.bookingCode = u.bookingCode ?? undefined; + token.bookingId = u.bookingId ?? undefined; + token.authMethod = u.authMethod ?? token.authMethod ?? "password"; + } + return token; + }, + async session({ session, token }) { + if (session.user) { + session.user.email = (token.email as string) ?? session.user.email; + session.user.name = (token.name as string) ?? session.user.name; + } + session.accessToken = token.accessToken as string | undefined; + session.role = token.role as string | undefined; + session.propertyId = (token.propertyId as string | undefined) ?? getHotelPropertyId(); + session.bookingCode = token.bookingCode ?? null; + session.bookingId = token.bookingId ?? null; + session.authMethod = (token.authMethod as AuthMethod | undefined) ?? "password"; + session.error = token.error as string | undefined; + return session; + }, + }, + pages: { + signIn: "/login", + }, + session: { + strategy: "jwt", + maxAge: 60 * 60 * 24 * 7, + }, + secret: process.env.NEXTAUTH_SECRET, +}; diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..79fda7e --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,11 @@ +/** API origin including /api prefix, e.g. http://localhost:7777/api */ +export function getPublicApiUrl(): string { + const u = process.env.NEXT_PUBLIC_API_URL?.trim(); + if (u) return u.replace(/\/$/, ""); + return "http://localhost:7777/api"; +} + +export function getHotelPropertyId(): string | undefined { + const id = process.env.NEXT_PUBLIC_HOTEL_PROPERTY_ID?.trim(); + return id || undefined; +} diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 0000000..29b9a2d --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,26 @@ +import type { DefaultSession } from "next-auth"; + +declare module "next-auth" { + interface Session extends DefaultSession { + accessToken?: string; + role?: string; + propertyId?: string; + /** Set when signing in with booking code */ + bookingCode?: string | null; + bookingId?: string | null; + authMethod?: "otp" | "password" | "google"; + error?: string; + } +} + +declare module "next-auth/jwt" { + interface JWT { + accessToken?: string; + role?: string; + propertyId?: string; + bookingCode?: string | null; + bookingId?: string | null; + authMethod?: "otp" | "password" | "google"; + error?: string; + } +}