auth done

This commit is contained in:
brooktewabe 2026-04-14 11:50:43 +03:00
parent aba29922c7
commit 429cdb7094
10 changed files with 626 additions and 331 deletions

View File

@ -11,5 +11,3 @@ const nextConfig: NextConfig = {
}; };
export default nextConfig; export default nextConfig;
import('@opennextjs/cloudflare').then(m => m.initOpenNextCloudflareForDev());

328
package-lock.json generated
View File

@ -9,8 +9,10 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"next": "16.2.1", "next": "16.2.1",
"next-auth": "^4.24.11",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@opennextjs/aws": "^3.9.16", "@opennextjs/aws": "^3.9.16",
@ -102,9 +104,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -122,9 +121,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -142,9 +138,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -162,9 +155,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1635,6 +1625,14 @@
"node": ">=6.0.0" "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": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@ -1931,6 +1929,7 @@
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@ -2603,6 +2602,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2625,6 +2625,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2647,6 +2648,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2663,6 +2665,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2679,9 +2682,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [ "dev": true,
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2698,9 +2699,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [ "dev": true,
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2717,9 +2716,7 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [ "dev": true,
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2736,9 +2733,7 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [ "dev": true,
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2755,9 +2750,7 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [ "dev": true,
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2774,9 +2767,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [ "dev": true,
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2793,9 +2784,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [ "dev": true,
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2812,9 +2801,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [ "dev": true,
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2831,9 +2818,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [ "dev": true,
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2856,9 +2841,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [ "dev": true,
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2881,9 +2864,7 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [ "dev": true,
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2906,9 +2887,7 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [ "dev": true,
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2931,9 +2910,7 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [ "dev": true,
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2956,9 +2933,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [ "dev": true,
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2981,9 +2956,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [ "dev": true,
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3006,9 +2979,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [ "dev": true,
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3031,6 +3002,7 @@
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@ -3050,6 +3022,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later", "license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -3069,6 +3042,7 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later", "license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -3088,6 +3062,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later", "license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -3239,9 +3214,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3258,9 +3230,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3277,9 +3246,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3296,9 +3262,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3632,6 +3595,14 @@
"wrangler": "^4.65.0" "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": { "node_modules/@poppinss/colors": {
"version": "4.1.6", "version": "4.1.6",
"resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
@ -4627,9 +4598,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -4647,9 +4615,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -4667,9 +4632,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -4687,9 +4649,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -4841,7 +4800,7 @@
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@ -5258,9 +5217,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5275,9 +5231,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5292,9 +5245,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5309,9 +5259,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5326,9 +5273,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5343,9 +5287,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5360,9 +5301,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5377,9 +5315,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -6263,7 +6198,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
@ -8717,6 +8652,14 @@
"jiti": "lib/jiti-cli.mjs" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -8997,9 +8940,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -9021,9 +8961,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -9045,9 +8982,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -9069,9 +9003,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "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": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@ -9581,6 +9551,11 @@
"node": ">=8" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -9591,6 +9566,14 @@
"node": ">=0.10.0" "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": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -9721,6 +9704,14 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -9760,6 +9751,36 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -10000,6 +10021,26 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -10010,6 +10051,11 @@
"node": ">= 0.8.0" "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": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@ -11426,6 +11472,14 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@ -12312,6 +12366,34 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.0 || ^4.0.0" "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
}
}
} }
} }
} }

View File

@ -15,8 +15,10 @@
}, },
"dependencies": { "dependencies": {
"next": "16.2.1", "next": "16.2.1",
"next-auth": "^4.24.11",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@opennextjs/aws": "^3.9.16", "@opennextjs/aws": "^3.9.16",

View File

@ -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 };

View File

@ -1,16 +1,15 @@
"use client"; "use client";
import { SessionProvider } from "next-auth/react";
import { StoreHydration } from "@/components/StoreHydration";
import { AuthProvider } from "@/context/AuthContext"; import { AuthProvider } from "@/context/AuthContext";
import { BookingProvider } from "@/context/BookingContext";
import { CurrencyProvider } from "@/context/CurrencyContext";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
export function Providers({ children }: { children: ReactNode }) { export function Providers({ children }: { children: ReactNode }) {
return ( return (
<CurrencyProvider> <SessionProvider refetchOnWindowFocus>
<AuthProvider> <StoreHydration />
<BookingProvider>{children}</BookingProvider> <AuthProvider>{children}</AuthProvider>
</AuthProvider> </SessionProvider>
</CurrencyProvider>
); );
} }

View File

@ -4,166 +4,111 @@ import {
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
useEffect,
useMemo, useMemo,
useState,
type ReactNode, type ReactNode,
} from "react"; } 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"; export type { OrderCategory, OrderRecord } from "@/types/guest-order";
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 MemberSession = { export type MemberSession = {
kind: "member"; kind: "member";
accessToken: string;
email: string; email: string;
displayName: string; displayName: string;
propertyId?: string;
points: number; points: number;
tier: "Gold" | "Silver"; authMethod: "otp" | "password" | "google";
/** How they signed in — for display only */ bookingCode?: string | null;
authMethod: "otp" | "password" | "google" | "apple" | "facebook"; bookingId?: string | null;
role?: string;
}; };
export type GuestSession = MemberSession;
export type BookingRefSession = {
kind: "bookingRef";
bookingRef: string;
guestName: string;
roomLabel: string;
checkOut: string;
};
export type GuestSession = MemberSession | BookingRefSession;
type AuthContextValue = { type AuthContextValue = {
session: GuestSession | null; session: GuestSession | null;
orders: OrderRecord[]; orders: OrderRecord[];
isHydrated: boolean; isHydrated: boolean;
/** Demo OTP is always 123456 */ accessToken: string | null;
requestOtp: (email: string) => Promise<{ ok: boolean; message: string }>; requestOtp: (email: string) => Promise<{ ok: boolean; message: string }>;
verifyOtp: (email: string, code: 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 }>; loginPassword: (email: string, password: string) => Promise<{ ok: boolean; message: string }>;
loginSocial: (provider: "google" | "apple" | "facebook") => void; loginGoogle: () => Promise<void>;
loginBookingRef: (ref: string) => { ok: boolean; message: string }; loginBookingRef: (ref: string) => Promise<{ ok: boolean; message: string }>;
logout: () => void; logout: () => Promise<void>;
addOrder: (o: Omit<OrderRecord, "id" | "placedAt" | "status"> & { status?: OrderRecord["status"] }) => void; addOrder: (o: Omit<OrderRecord, "id" | "placedAt" | "status"> & { status?: OrderRecord["status"] }) => void;
awardPoints: (points: number) => void; awardPoints: (points: number) => void;
setOrders: (orders: OrderRecord[] | ((prev: OrderRecord[]) => OrderRecord[])) => void;
}; };
const AuthContext = createContext<AuthContextValue | null>(null); const AuthContext = createContext<AuthContextValue | null>(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 }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<GuestSession | null>(null); const { data, status } = useSession();
const [orders, setOrders] = useState<OrderRecord[]>([]); const orders = useOrdersStore((s) => s.orders);
const [isHydrated, setIsHydrated] = useState(false); const localBonusPoints = useGuestUiStore((s) => s.localBonusPoints);
useEffect(() => { const isHydrated = status !== "loading";
setSession(loadSession());
setOrders(loadOrders()); const guestSession = useMemo((): GuestSession | null => {
setIsHydrated(true); 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) => { const requestOtp = useCallback(async (email: string) => {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return { ok: false, message: "Enter a valid email address." }; 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 verifyOtp = useCallback(async (email: string, code: string) => {
const trimmed = code.replace(/\s/g, ""); const r = await signIn("hotel-otp", {
if (trimmed !== "123456") { email: email.trim().toLowerCase(),
return { ok: false, message: "Invalid code. Demo OTP is 123456." }; 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." }; return { ok: true, message: "Signed in." };
}, []); }, []);
@ -171,69 +116,47 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (!email || !password) { if (!email || !password) {
return { ok: false, message: "Email and password required." }; return { ok: false, message: "Email and password required." };
} }
if (password !== "shitaye" && password !== "demo123") { const r = await signIn("credentials", {
return { identifier: email.trim().toLowerCase(),
ok: false, password,
message: "Incorrect password. Try demo password: shitaye", 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." }; return { ok: true, message: "Signed in." };
}, []); }, []);
const loginSocial = useCallback((provider: "google" | "apple" | "facebook") => { const loginGoogle = useCallback(async () => {
const names: Record<typeof provider, string> = { await signIn("google", { callbackUrl: "/", redirect: true });
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 loginBookingRef = useCallback((ref: string) => { const loginBookingRef = useCallback(async (ref: string) => {
const key = ref.trim().toUpperCase(); const propertyId = getHotelPropertyId();
const row = DEMO_BOOKING_REFS[key]; if (!propertyId) {
if (!row) {
return { return {
ok: false, 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 = { const r = await signIn("booking-code", {
kind: "bookingRef", bookingCode: ref.trim(),
bookingRef: key, propertyId,
guestName: row.guestName, redirect: false,
roomLabel: row.room, });
checkOut: row.checkOut, if (r?.error) {
}; return { ok: false, message: "Invalid booking code or account not linked." };
setSession(next); }
persistSession(next); return { ok: true, message: "Signed in with your booking." };
return { ok: true, message: "Linked to your stay." };
}, []); }, []);
const logout = useCallback(() => { const logout = useCallback(async () => {
setSession(null); useGuestUiStore.getState().resetLocalBonus();
persistSession(null); await signOut({ callbackUrl: "/" });
}, []);
const setOrders = useCallback((next: OrderRecord[] | ((prev: OrderRecord[]) => OrderRecord[])) => {
useOrdersStore.getState().setOrders(next);
}, []); }, []);
const addOrder = useCallback( const addOrder = useCallback(
@ -248,59 +171,49 @@ export function AuthProvider({ children }: { children: ReactNode }) {
placedAt: new Date().toISOString(), placedAt: new Date().toISOString(),
status: o.status ?? "pending", status: o.status ?? "pending",
}; };
setOrders((prev) => { useOrdersStore.getState().pushOrder(rec);
const next = [rec, ...prev]; if (guestSession?.kind === "member") {
persistOrders(next);
return next;
});
if (session?.kind === "member") {
const bonus = Math.min(150, Math.round(o.totalUsd * 2)); const bonus = Math.min(150, Math.round(o.totalUsd * 2));
setSession((s) => { useGuestUiStore.getState().addLocalBonus(bonus);
if (!s || s.kind !== "member") return s;
const u = { ...s, points: s.points + bonus };
persistSession(u);
return u;
});
} }
}, },
[session], [guestSession],
); );
const awardPoints = useCallback((points: number) => { const awardPoints = useCallback((points: number) => {
setSession((s) => { useGuestUiStore.getState().addLocalBonus(points);
if (!s || s.kind !== "member") return s;
const u = { ...s, points: s.points + points };
persistSession(u);
return u;
});
}, []); }, []);
const value = useMemo<AuthContextValue>( const value = useMemo<AuthContextValue>(
() => ({ () => ({
session, session: guestSession,
orders, orders,
isHydrated, isHydrated,
accessToken: data?.accessToken ?? null,
requestOtp, requestOtp,
verifyOtp, verifyOtp,
loginPassword, loginPassword,
loginSocial, loginGoogle,
loginBookingRef, loginBookingRef,
logout, logout,
addOrder, addOrder,
awardPoints, awardPoints,
setOrders,
}), }),
[ [
session, guestSession,
orders, orders,
isHydrated, isHydrated,
data?.accessToken,
requestOtp, requestOtp,
verifyOtp, verifyOtp,
loginPassword, loginPassword,
loginSocial, loginGoogle,
loginBookingRef, loginBookingRef,
logout, logout,
addOrder, addOrder,
awardPoints, awardPoints,
setOrders,
], ],
); );

45
src/lib/api-client.ts Normal file
View File

@ -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<T = unknown>(
path: string,
init: RequestInit & { accessToken?: string } = {},
): Promise<T> {
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;
}

213
src/lib/auth-options.ts Normal file
View File

@ -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<T>(path: string, body: Record<string, unknown>): Promise<T> {
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<LoginPayload>("/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<LoginPayload>("/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<BookingCodePayload>("/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<LoginPayload>("/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,
};

11
src/lib/env.ts Normal file
View File

@ -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;
}

26
src/types/next-auth.d.ts vendored Normal file
View File

@ -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;
}
}