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