From 8dccad12958dde2594d535e268ebe81c52a88bdf Mon Sep 17 00:00:00 2001 From: Nirbhay Date: Tue, 3 Feb 2026 17:31:46 +0530 Subject: [PATCH] feat(auth): Setup better-auth client with signup, login and OAuth --- .gitignore | 4 +- package.json | 6 +- pnpm-lock.yaml | 230 ++++++++++- .../(auth)/_components/AuthSubmitButton.tsx | 2 +- .../_components/GoogleAndGithubProviders.tsx | 195 +++++++++- src/app/(auth)/_components/SigninForm.tsx | 117 +++--- src/app/(auth)/_components/SignupForm.tsx | 363 +++++++++--------- src/app/(auth)/signup/page.tsx | 1 - src/constants/auth.ts | 5 + src/constants/form-helpers.ts | 6 + src/lib/auth-client.ts | 26 ++ .../validation/auth/email-signin.schema.ts | 11 + .../validation/auth/email-signup.schema.ts | 40 ++ 13 files changed, 725 insertions(+), 281 deletions(-) create mode 100644 src/constants/auth.ts create mode 100644 src/constants/form-helpers.ts create mode 100644 src/lib/auth-client.ts create mode 100644 src/lib/validation/auth/email-signin.schema.ts create mode 100644 src/lib/validation/auth/email-signup.schema.ts diff --git a/.gitignore b/.gitignore index 7eb677b..52df7de 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,9 @@ yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) -.env* +.env +.env.local +.env.development # vercel .vercel diff --git a/package.json b/package.json index 9f73188..8a0fa92 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,10 @@ "prepare": "husky" }, "dependencies": { + "@hookform/resolvers": "^5.2.2", "@radix-ui/react-tooltip": "^1.2.8", "@reduxjs/toolkit": "^2.11.0", + "better-auth": "^1.4.18", "clsx": "^2.1.1", "lucide-react": "^0.555.0", "motion": "^12.23.24", @@ -24,8 +26,10 @@ "next-themes": "^0.4.6", "react": "19.2.3", "react-dom": "19.2.3", + "react-hook-form": "^7.71.1", "react-redux": "^9.2.0", - "tailwind-merge": "^3.4.0" + "tailwind-merge": "^3.4.0", + "zod": "^4.3.6" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ac1e63..42cdc7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,18 @@ importers: .: dependencies: + '@hookform/resolvers': + specifier: ^5.2.2 + version: 5.2.2(react-hook-form@7.71.1(react@19.2.3)) '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@reduxjs/toolkit': specifier: ^2.11.0 version: 2.11.2(react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1))(react@19.2.3) + better-auth: + specifier: ^1.4.18 + version: 1.4.18(next@16.0.10(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -35,12 +41,18 @@ importers: react-dom: specifier: 19.2.3 version: 19.2.3(react@19.2.3) + react-hook-form: + specifier: ^7.71.1 + version: 7.71.1(react@19.2.3) react-redux: specifier: ^9.2.0 version: 9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@tailwindcss/postcss': specifier: ^4 @@ -161,6 +173,27 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@better-auth/core@1.4.18': + resolution: {integrity: sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg==} + peerDependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + better-call: 1.1.8 + jose: ^6.1.0 + kysely: ^0.28.5 + nanostores: ^1.0.1 + + '@better-auth/telemetry@1.4.18': + resolution: {integrity: sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ==} + peerDependencies: + '@better-auth/core': 1.4.18 + + '@better-auth/utils@0.3.0': + resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} + + '@better-fetch/fetch@1.1.21': + resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@emnapi/core@1.7.1': resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} @@ -223,6 +256,11 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@hookform/resolvers@5.2.2': + resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} + peerDependencies: + react-hook-form: ^7.55.0 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -469,6 +507,14 @@ packages: cpu: [x64] os: [win32] + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1094,6 +1140,76 @@ packages: resolution: {integrity: sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg==} hasBin: true + better-auth@1.4.18: + resolution: {integrity: sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: '>=0.31.4' + drizzle-orm: '>=0.41.0' + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.1.8: + resolution: {integrity: sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1217,6 +1333,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1724,6 +1843,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1761,6 +1883,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kysely@0.28.11: + resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==} + engines: {node: '>=20.0.0'} + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -1939,6 +2065,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanostores@1.1.0: + resolution: {integrity: sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==} + engines: {node: ^20.0.0 || >=22.0.0} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -2151,6 +2281,12 @@ packages: peerDependencies: react: ^19.2.3 + react-hook-form@7.71.1: + resolution: {integrity: sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -2216,6 +2352,9 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rou3@0.7.12: + resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2243,6 +2382,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -2503,8 +2645,8 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@4.2.0: - resolution: {integrity: sha512-Bd5fw9wlIhtqCCxotZgdTOMwGm1a0u75wARVEY9HMs1X17trvA/lMi4+MGK5EUfYkXVTbX8UDiDKW4OgzHVUZw==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} snapshots: @@ -2610,6 +2752,27 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)': + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + '@standard-schema/spec': 1.0.0 + better-call: 1.1.8(zod@4.3.6) + jose: 6.1.3 + kysely: 0.28.11 + nanostores: 1.1.0 + zod: 4.3.6 + + '@better-auth/telemetry@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))': + dependencies: + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + + '@better-auth/utils@0.3.0': {} + + '@better-fetch/fetch@1.1.21': {} + '@emnapi/core@1.7.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -2689,6 +2852,11 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@hookform/resolvers@5.2.2(react-hook-form@7.71.1(react@19.2.3))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.71.1(react@19.2.3) + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -2853,6 +3021,10 @@ snapshots: '@next/swc-win32-x64-msvc@16.0.10': optional: true + '@noble/ciphers@2.1.1': {} + + '@noble/hashes@2.0.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3425,6 +3597,34 @@ snapshots: baseline-browser-mapping@2.9.7: {} + better-auth@1.4.18(next@16.0.10(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) + '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + '@noble/ciphers': 2.1.1 + '@noble/hashes': 2.0.1 + better-call: 1.1.8(zod@4.3.6) + defu: 6.1.4 + jose: 6.1.3 + kysely: 0.28.11 + nanostores: 1.1.0 + zod: 4.3.6 + optionalDependencies: + next: 16.0.10(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + better-call@1.1.8(zod@4.3.6): + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + rou3: 0.7.12 + set-cookie-parser: 2.7.2 + optionalDependencies: + zod: 4.3.6 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -3549,6 +3749,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + defu@6.1.4: {} + detect-libc@2.1.2: {} doctrine@2.1.0: @@ -3791,8 +3993,8 @@ snapshots: '@babel/parser': 7.28.5 eslint: 9.39.2(jiti@2.6.1) hermes-parser: 0.25.1 - zod: 4.2.0 - zod-validation-error: 4.0.2(zod@4.2.0) + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color @@ -4195,6 +4397,8 @@ snapshots: jiti@2.6.1: {} + jose@6.1.3: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -4226,6 +4430,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kysely@0.28.11: {} + language-subtag-registry@0.3.23: {} language-tags@1.0.9: @@ -4376,6 +4582,8 @@ snapshots: nanoid@3.3.11: {} + nanostores@1.1.0: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -4535,6 +4743,10 @@ snapshots: react: 19.2.3 scheduler: 0.27.0 + react-hook-form@7.71.1(react@19.2.3): + dependencies: + react: 19.2.3 + react-is@16.13.1: {} react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1): @@ -4601,6 +4813,8 @@ snapshots: rfdc@1.4.1: {} + rou3@0.7.12: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -4630,6 +4844,8 @@ snapshots: semver@7.7.3: {} + set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -5002,8 +5218,8 @@ snapshots: yocto-queue@0.1.0: {} - zod-validation-error@4.0.2(zod@4.2.0): + zod-validation-error@4.0.2(zod@4.3.6): dependencies: - zod: 4.2.0 + zod: 4.3.6 - zod@4.2.0: {} + zod@4.3.6: {} diff --git a/src/app/(auth)/_components/AuthSubmitButton.tsx b/src/app/(auth)/_components/AuthSubmitButton.tsx index ed83c2f..5ac9fd5 100644 --- a/src/app/(auth)/_components/AuthSubmitButton.tsx +++ b/src/app/(auth)/_components/AuthSubmitButton.tsx @@ -42,7 +42,7 @@ const AuthSubmitButton = ({ aria-busy={loading} whileTap={!isDisabled ? { scale: 0.97 } : undefined} className={cn( - 'relative mt-2 flex h-14 w-full items-center justify-center gap-3 overflow-hidden rounded-xl', + 'relative mt-2 flex h-14 w-full cursor-pointer items-center justify-center gap-3 overflow-hidden rounded-xl', 'bg-primary font-poppins font-semibold text-white', 'transition-colors duration-200', isDisabled ? 'cursor-not-allowed opacity-80' : 'hover:bg-primary/90', diff --git a/src/app/(auth)/_components/GoogleAndGithubProviders.tsx b/src/app/(auth)/_components/GoogleAndGithubProviders.tsx index b0a8b85..e1c58d6 100644 --- a/src/app/(auth)/_components/GoogleAndGithubProviders.tsx +++ b/src/app/(auth)/_components/GoogleAndGithubProviders.tsx @@ -1,55 +1,208 @@ +import { ClientFetchOption, SocialProvider } from 'better-auth'; +import { AnimatePresence } from 'motion/react'; +import * as motion from 'motion/react-client'; +import { useRouter } from 'next/navigation'; +import React, { useEffect, useState } from 'react'; + import GithubIcon from '@/components/ui/icons/GithubIcon'; import GoogleIcon from '@/components/ui/icons/GoogleIcon'; +import { authClient } from '@/lib/auth-client'; import { cn } from '@/lib/cn'; +import AuthErrorMessage from './AuthErrorMessage'; + +type AllowedProviders = Extract; +type SocialSignInPayload = + | { + provider: AllowedProviders; + } + | { + provider: AllowedProviders; + additionalData: { + role: UserType; + termsAccepted: boolean; + }; + }; + type GoogleAndGithubProvidersProps = | { providerFor: 'signin'; className?: string; + requestTermsAcceptance?: () => void; + termsAccepted?: boolean; } | { providerFor: 'signup'; userType: UserType; className?: string; + requestTermsAcceptance: () => void; + termsAccepted: boolean; }; +type ProviderButtonProps = { + children: React.ReactNode; + label: string; + onClick?: () => void; + isLoading?: boolean; + isDisabled?: boolean; + isSoftDisabled?: boolean; +} & Omit, 'onClick' | 'disabled'>; + const ProviderButton = ({ children: icon, label, -}: { - children: React.ReactNode; - label: string; -}) => { + onClick, + isLoading, + isDisabled, + isSoftDisabled, + ...props +}: ProviderButtonProps) => { return ( ); }; const GoogleAndGithubProviders = (props: GoogleAndGithubProvidersProps) => { const { className, providerFor } = props; + const [loadingProvider, setLoadingProvider] = useState(null); + const [providerError, setProviderError] = useState(null); + const router = useRouter(); + + useEffect(() => { + if (!providerError) return; - if (providerFor === 'signup') { - const { userType } = props; - // You can use userType for any specific logic if needed - } + const clearError = () => { + setProviderError(null); + }; + + document.addEventListener('focusin', clearError); + document.addEventListener('pointerdown', clearError); + document.addEventListener('keydown', clearError); + + return () => { + document.removeEventListener('focusin', clearError); + document.removeEventListener('pointerdown', clearError); + document.removeEventListener('keydown', clearError); + }; + }, [providerError]); + + const handleSocialAuth = async (provider: AllowedProviders) => { + if (!props.termsAccepted && providerFor === 'signup') { + props.requestTermsAcceptance?.(); + return; + } + + // Build payload (signup includes additional data) + const payload: SocialSignInPayload = + providerFor === 'signup' && 'userType' in props + ? { + provider, + additionalData: { + role: props.userType, + termsAccepted: props.termsAccepted, + }, + } + : { provider }; + + const hooks: ClientFetchOption = { + onRequest: () => { + setProviderError(null); + setLoadingProvider(provider); + }, + onError: (error: unknown) => { + setLoadingProvider(null); + const message = error instanceof Error ? error.message : null; + setProviderError(message || 'An unexpected error occurred. Please try again.'); + }, + onSuccess: () => { + router.push('/dashboard'); + }, + } as const; + + try { + await authClient.signIn.social(payload, hooks); + } catch (error: unknown) { + setLoadingProvider(null); + setProviderError( + error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.', + ); + } + }; + + const getTitleForProvider = (provider: AllowedProviders) => { + if (!props.termsAccepted && providerFor === 'signup') { + return 'Please accept terms to continue'; + } + return `Continue with ${provider.charAt(0).toUpperCase() + provider.slice(1)}`; + }; + + const isSoftDisabled = !props.termsAccepted && providerFor === 'signup'; return ( -
- - - - - - -
+ <> +
+ handleSocialAuth('google')} + isLoading={loadingProvider === 'google'} + isSoftDisabled={isSoftDisabled} + isDisabled={loadingProvider !== null} + > + + + handleSocialAuth('github')} + isLoading={loadingProvider === 'github'} + isSoftDisabled={isSoftDisabled} + isDisabled={loadingProvider !== null} + > + + +
+ + + {providerError && ( + + + + )} + + ); }; diff --git a/src/app/(auth)/_components/SigninForm.tsx b/src/app/(auth)/_components/SigninForm.tsx index 686fc36..749e848 100644 --- a/src/app/(auth)/_components/SigninForm.tsx +++ b/src/app/(auth)/_components/SigninForm.tsx @@ -1,104 +1,101 @@ 'use client'; -import { LockIcon, UserIcon } from 'lucide-react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { LockIcon, MailIcon } from 'lucide-react'; import { AnimatePresence } from 'motion/react'; -import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useForm } from 'react-hook-form'; import FormField from '@/components/ui/forms/FormField'; import TextLink from '@/components/ui/typography/TextLink'; +import { authClient } from '@/lib/auth-client'; +import { EmailSigninFormData, EmailSigninSchema } from '@/lib/validation/auth/email-signin.schema'; import AuthDivider from './AuthDivider'; import AuthErrorMessage from './AuthErrorMessage'; import AuthSubmitButton from './AuthSubmitButton'; import GoogleAndGithubProviders from './GoogleAndGithubProviders'; -type SigninField = 'email' | 'password'; -interface SigninFormData { - email: string; - password: string; -} - const SigninForm = () => { - const [formData, setFormData] = useState({ - email: '', - password: '', - }); - const [error, setError] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); - - const signin = async (data: SigninFormData) => { - // Simulate an API call - return new Promise((resolve, reject) => { - setTimeout(() => { - reject(new Error('Invalid email or password')); - }, 800); - }); - }; - - const handleOnChange = (value: string, field: SigninField) => { - setError(null); - setFormData((prev) => ({ - ...prev, - [field]: value, - })); - }; - - const handleOnSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - console.log('Signin Form Data:', formData); - // Handle form submission logic here + const router = useRouter(); - if (isSubmitting) return; + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + setError, + clearErrors, + } = useForm({ + resolver: zodResolver(EmailSigninSchema), + mode: 'onSubmit', + }); + const signin = async (data: EmailSigninFormData) => { try { - setIsSubmitting(true); - const data = { email: formData.email, password: formData.password }; - await signin(data); - } catch (err) { - setError(err instanceof Error ? err.message : 'Something went wrong'); - } finally { - setIsSubmitting(false); + await authClient.signIn.email(data, { + onError: (error) => { + setError('root', { + type: 'server', + message: error.error.message || 'An unexpected error occurred. Please try again.', + }); + }, + onSuccess: () => { + router.push('/dashboard'); + }, + }); + } catch (error) { + setError('root', { + type: 'server', + message: + error instanceof Error + ? error.message + : 'An unexpected error occurred. Please try again.', + }); } }; return ( -
- + + + +
- {error && } + {errors.root?.message && }
+
handleOnChange(e.target.value, 'email')} + disabled={isSubmitting} + aria-disabled={isSubmitting} + error={errors.email?.message} + {...register('email', { onChange: () => clearErrors('root') })} /> + handleOnChange(e.target.value, 'password')} + disabled={isSubmitting} + aria-disabled={isSubmitting} + error={errors.password?.message} + {...register('password', { onChange: () => clearErrors('root') })} />
+
Forgot your password?
+ Sign In diff --git a/src/app/(auth)/_components/SignupForm.tsx b/src/app/(auth)/_components/SignupForm.tsx index cccae7a..0428179 100644 --- a/src/app/(auth)/_components/SignupForm.tsx +++ b/src/app/(auth)/_components/SignupForm.tsx @@ -1,238 +1,207 @@ 'use client'; -import { Building2Icon, CheckIcon, LockIcon, MailIcon, UserIcon } from 'lucide-react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { CheckIcon, LockIcon, MailIcon, UserIcon } from 'lucide-react'; import { AnimatePresence } from 'motion/react'; import * as motion from 'motion/react-client'; -import React, { useState } from 'react'; +import React from 'react'; +import { Controller, useForm, useWatch } from 'react-hook-form'; import FormField from '@/components/ui/forms/FormField'; import TextLink from '@/components/ui/typography/TextLink'; import UserTypeToggle from '@/components/ui/UserTypeToggle'; +import { SIGNUP_FORM_HELPER } from '@/constants/form-helpers'; +import { authClient } from '@/lib/auth-client'; +import { EmailSignupFormData, EmailSignupSchema } from '@/lib/validation/auth/email-signup.schema'; import AuthDivider from './AuthDivider'; import AuthErrorMessage from './AuthErrorMessage'; import AuthSubmitButton from './AuthSubmitButton'; import GoogleAndGithubProviders from './GoogleAndGithubProviders'; -type CommonFormFields = 'fullname' | 'email' | 'password'; - -interface BaseSignupFormData { - fullname: string; - email: string; - password: string; - termsAccepted: boolean; -} - -type SignupFormData = - | (BaseSignupFormData & { accountType: 'recruiter'; companyName: string }) - | (BaseSignupFormData & { accountType: 'candidate' }); - -const AnimatedField = React.memo(function AnimatedField({ - children, -}: { - children: React.ReactNode; -}) { - return ( - - {children} - - ); -}); - const SignupForm = () => { const initialUserType: UserType = 'recruiter'; - const [activeUserType, setActiveUserType] = useState(initialUserType); - const [formData, setFormData] = useState({ - accountType: initialUserType, - fullname: '', - email: '', - companyName: '', - password: '', - termsAccepted: false, + const [termsErrorSignal, triggerTermsError] = React.useReducer((x) => x + 1, 0); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + control, + setError, + clearErrors, + setFocus, + trigger, + } = useForm({ + resolver: zodResolver(EmailSignupSchema), + mode: 'onChange', + defaultValues: { + role: initialUserType, + termsAccepted: false, + }, }); - const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(null); - - const signup = async (data: SignupFormData) => { - return new Promise((resolve, reject) => { - setTimeout(() => { - reject(new Error('Signup failed')); - }, 800); - }); - }; - const updateCommonField = (field: CommonFormFields, value: string) => { - setError(null); - setFormData((prev) => ({ - ...prev, - [field]: value, - })); - }; - - const updateCompanyName = (value: string) => { - setError(null); - setFormData((prev) => { - if (prev.accountType === 'recruiter') { - return { - ...prev, - companyName: value, - }; - } else { - return prev; - } - }); - }; - - const updateTermsAccepted = (accepted: boolean) => { - setFormData((prev) => ({ - ...prev, - termsAccepted: accepted, - })); - }; - - const onUserTypeChange = (newUserType: UserType) => { - setActiveUserType(newUserType); - setFormData((prev) => { - if (newUserType === 'recruiter') { - return { - ...prev, - accountType: 'recruiter', - companyName: prev.accountType === 'recruiter' ? prev.companyName : '', - }; - } else { - return { - ...prev, - accountType: 'candidate', - companyName: undefined, - }; - } - }); - }; - - const handleFormSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - console.log('Form submitted:', formData); - // Handle form submission logic here - - if (isSubmitting) return; + const watchedAccountType = useWatch({ control, name: 'role' }); + const watchTermsAccepted = useWatch({ control, name: 'termsAccepted' }); + const watchConfirmPassword = useWatch({ control, name: 'confirmPassword' }); + const signup = async (data: EmailSignupFormData) => { try { - setIsSubmitting(true); - await signup(formData); - } catch (err) { - setError(err instanceof Error ? err.message : 'Something went wrong'); - } finally { - setIsSubmitting(false); + await authClient.signUp.email(data, { + onError: (error) => { + setError('root', { + type: 'server', + message: error.error.message || 'An unexpected error occurred. Please try again.', + }); + }, + }); + } catch (error) { + setError('root', { + type: 'server', + message: + error instanceof Error + ? error.message + : 'An unexpected error occurred. Please try again.', + }); } }; return ( - - + ( + { + field.onChange(value); + clearErrors(); + }} + variant="standard" + /> + )} /> - + { + // Clear all previous errors + clearErrors(); + + // Set termsAccepted error + setError('termsAccepted', { + message: 'Please accept the terms and privacy policy to continue.', + }); + + // Guide focus to the checkbox + setFocus('termsAccepted', { shouldSelect: true }); + triggerTermsError(); + + document + .getElementById('termsAccepted') + ?.closest('label') + ?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }} + />
- {error && } + {errors.root?.message && }
- - - updateCommonField('fullname', event.currentTarget.value)} - /> - - - - updateCommonField('email', event.currentTarget.value)} - /> - - - {formData.accountType === 'recruiter' && ( - - updateCompanyName(event.currentTarget.value)} - /> - - )} +
+ {/* Full name */} + clearErrors('root'), + })} + /> - - updateCommonField('password', event.currentTarget.value)} - /> - - + {/* Email */} + clearErrors('root'), + })} + /> + + {/* Password */} + { + clearErrors('root'); + if (watchConfirmPassword) { + trigger('confirmPassword'); + } + }, + })} + /> + + {/* Confirm Password */} + clearErrors('root'), + })} + /> +
+ + {errors.termsAccepted && ( + + {errors.termsAccepted.message} + + )} + + Sign Up diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index 778a10c..07880b8 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -1,6 +1,5 @@ import { Metadata } from 'next'; -import Logo from '@/components/common/Logo'; import TextLink from '@/components/ui/typography/TextLink'; import AuthCard from '../_components/AuthCard'; diff --git a/src/constants/auth.ts b/src/constants/auth.ts new file mode 100644 index 0000000..76faaf8 --- /dev/null +++ b/src/constants/auth.ts @@ -0,0 +1,5 @@ +export const USER_ROLES: UserType[] = ['candidate', 'recruiter'] as const; +export const USER_ROLE = { + CANDIDATE: 'candidate', + RECRUITER: 'recruiter', +} as const; diff --git a/src/constants/form-helpers.ts b/src/constants/form-helpers.ts new file mode 100644 index 0000000..3deb76a --- /dev/null +++ b/src/constants/form-helpers.ts @@ -0,0 +1,6 @@ +export const SIGNUP_FORM_HELPER = { + name: 'Enter your full legal name as it appears on official documents.', + email: 'We’ll use this email for login and important updates.', + password: 'Use at least 8 characters. Avoid common words.', + confirmPassword: 'Re-enter your password to confirm it matches.', +}; diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts new file mode 100644 index 0000000..d48ecfd --- /dev/null +++ b/src/lib/auth-client.ts @@ -0,0 +1,26 @@ +import { inferAdditionalFields } from 'better-auth/client/plugins'; +import { createAuthClient } from 'better-auth/react'; + +const BASE_URL = process.env.NEXT_PUBLIC_AUTH_BASE_URL; + +if (!BASE_URL) { + throw new Error('NEXT_PUBLIC_AUTH_BASE_URL is not defined in environment variables'); +} + +export const authClient = createAuthClient({ + baseURL: BASE_URL, // The base URL of auth server - (backend url) + plugins: [ + inferAdditionalFields({ + user: { + role: { + type: 'string', + required: true, + }, + termsAccepted: { + type: 'boolean', + required: true, + }, + }, + }), + ], +}); diff --git a/src/lib/validation/auth/email-signin.schema.ts b/src/lib/validation/auth/email-signin.schema.ts new file mode 100644 index 0000000..5745eda --- /dev/null +++ b/src/lib/validation/auth/email-signin.schema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const EmailSigninSchema = z.object({ + email: z.string().trim().min(1, 'Email is required').pipe(z.email('Invalid email address')), + password: z + .string() + .min(1, 'Password is required') + .max(100, 'Password must be at most 100 characters long'), +}); + +export type EmailSigninFormData = z.infer; diff --git a/src/lib/validation/auth/email-signup.schema.ts b/src/lib/validation/auth/email-signup.schema.ts new file mode 100644 index 0000000..bec960f --- /dev/null +++ b/src/lib/validation/auth/email-signup.schema.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; + +import { USER_ROLES } from '@/constants/auth'; + +export const EmailSignupSchema = z + .object({ + name: z + .string() + .min(1, 'Full name is required') + .min(2, 'Full name must be at least 2 characters long') + .max(100, 'Full name must be at most 100 characters long') + .trim(), + + email: z + .string() + .trim() + .toLowerCase() + .min(1, 'Email is required') + .pipe(z.email('Invalid email address')), + + password: z + .string() + .min(1, 'Password is required') + .min(8, 'Password must be at least 8 characters long') + .max(100, 'Password must be at most 100 characters long'), + + confirmPassword: z.string().min(1, 'Please confirm your password'), + + termsAccepted: z.boolean().refine((val) => val === true, { + message: 'You must accept the terms and conditions', + }), + + role: z.enum(USER_ROLES, 'Invalid account type'), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords don't match", + path: ['confirmPassword'], + }); + +export type EmailSignupFormData = z.infer;