From 1d32d315503db04d3072abaeb35eac14db4a2d32 Mon Sep 17 00:00:00 2001 From: gibbyb Date: Fri, 29 Aug 2025 16:41:24 -0500 Subject: [PATCH] Update login stuff --- README.md | 3 +- bun.lock | 65 ++++- convex/CustomPassword.ts | 228 ++++++++++++++++ convex/_generated/api.d.ts | 2 + convex/auth.ts | 2 +- package.json | 35 ++- src/app/signin/page.tsx | 373 ++++++++++++++++++++++----- src/components/ui/card.tsx | 92 +++++++ src/components/ui/form.tsx | 167 ++++++++++++ src/components/ui/index.tsx | 30 +++ src/components/ui/input.tsx | 21 ++ src/components/ui/label.tsx | 24 ++ src/components/ui/separator.tsx | 28 ++ src/components/ui/status-message.tsx | 55 ++++ src/components/ui/submit-button.tsx | 50 ++++ src/components/ui/tabs.tsx | 66 +++++ 16 files changed, 1155 insertions(+), 86 deletions(-) create mode 100644 convex/CustomPassword.ts create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/form.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/status-message.tsx create mode 100644 src/components/ui/submit-button.tsx create mode 100644 src/components/ui/tabs.tsx diff --git a/README.md b/README.md index 99fe5b4..9639768 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,9 @@ cp ./env.example ./.env cp ./host/convex/docker/env.example ./host/convex/docker/.env ``` -### Start self hosted convex by running +### Start self hosted convex +The basic gist is to run the commands below after you have filled out the environment variables you plan to use, but you should ultimately follow the [guide they provide](https://github.com/get-convex/convex-backend/tree/main/self-hosted) ```bash cd ./host/convex/docker sudo docker compose up -d diff --git a/bun.lock b/bun.lock index 2a95d77..29e6519 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,11 @@ "name": "example", "dependencies": { "@convex-dev/auth": "^0.0.81", + "@hookform/resolvers": "^5.2.1", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", "@sentry/nextjs": "^10.7.0", "@t3-oss/env-nextjs": "^0.13.8", "class-variance-authority": "^0.7.1", @@ -18,6 +22,7 @@ "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.62.0", "require-in-the-middle": "^7.5.2", "tailwind-merge": "^3.3.1", "typescript-eslint": "^8.41.0", @@ -291,6 +296,8 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.5", "", { "dependencies": { "@eslint/core": "^0.15.2", "levn": "^0.4.1" } }, "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w=="], + "@hookform/resolvers": ["@hookform/resolvers@5.2.1", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-u0+6X58gkjMcxur1wRWokA7XsiiBJ6aK17aPZxhkoYiK5J+HcTx0Vhu9ovXe6H+dVpO6cjrn2FkJTryXEMlryQ=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], @@ -519,10 +526,40 @@ "@prisma/instrumentation": ["@prisma/instrumentation@6.14.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-Po/Hry5bAeunRDq0yAQueKookW3glpP+qjjvvyOfm6dI2KG5/Y6Bgg3ahyWd7B0u2E+Wf9xRk2rtdda7ySgK1A=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], + + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + "@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@28.0.1", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA=="], "@rollup/pluginutils": ["@rollup/pluginutils@5.2.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw=="], @@ -571,17 +608,17 @@ "@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.12.0", "", {}, "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw=="], - "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.7.0", "", { "dependencies": { "@sentry/core": "10.7.0" } }, "sha512-M5L1XKVkhRhIV2nfUwNxBoqir4SVDcHdqRBq+k1EK6Z6DCVF9GMTiLg46+egBwgUDlAAGIQImdKgtJTH/47Z9g=="], + "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.8.0", "", { "dependencies": { "@sentry/core": "10.8.0" } }, "sha512-FaQX9eefc8sh3h3ZQy16U73KiH0xgDldXnrFiWK6OeWg8X4bJpnYbLqEi96LgHiQhjnnz+UQP1GDzH5oFuu5fA=="], - "@sentry-internal/feedback": ["@sentry-internal/feedback@10.7.0", "", { "dependencies": { "@sentry/core": "10.7.0" } }, "sha512-wTyoLjEKz6dwl9uyy5wfwmrlM59WmUM1DXMSyGr6j6ncDA9iPmoftU4O4+Kirkk8mRYoc0fHxjp21Z5BYtAehw=="], + "@sentry-internal/feedback": ["@sentry-internal/feedback@10.8.0", "", { "dependencies": { "@sentry/core": "10.8.0" } }, "sha512-n7SqgFQItq4QSPG7bCjcZcIwK6AatKnnmSDJ/i6e8jXNIyLwkEuY2NyvTXACxVdO/kafGD5VmrwnTo3Ekc1AMg=="], - "@sentry-internal/replay": ["@sentry-internal/replay@10.7.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.7.0", "@sentry/core": "10.7.0" } }, "sha512-lKK7NGSy20c0ArBQtGWP+ITMantNGOAeeILG2c2VaOxJUTe5EH3OcelHMNPEFTTXnYZt7RzMT03Tzmzto2LBdg=="], + "@sentry-internal/replay": ["@sentry-internal/replay@10.8.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.8.0", "@sentry/core": "10.8.0" } }, "sha512-9+qDEoEjv4VopLuOzK1zM4LcvcUsvB5N0iJ+FRCM3XzzOCbebJOniXTQbt5HflJc3XLnQNKFdKfTfgj8M/0RKQ=="], - "@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.7.0", "", { "dependencies": { "@sentry-internal/replay": "10.7.0", "@sentry/core": "10.7.0" } }, "sha512-68jJfqa8r9UPGO4+S2IthkhhohTItgHjVj7S7dH5g1YUHUS1N03dZMrDMc2jOAhWURi3EOqmCkSjd2xxogRUVQ=="], + "@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.8.0", "", { "dependencies": { "@sentry-internal/replay": "10.8.0", "@sentry/core": "10.8.0" } }, "sha512-jC4OOwiNgrlIPeXIPMLkaW53BSS1do+toYHoWzzO5AXGpN6jRhanoSj36FpVuH2N3kFnxKVfVxrwh8L+/3vFWg=="], "@sentry/babel-plugin-component-annotate": ["@sentry/babel-plugin-component-annotate@4.2.0", "", {}, "sha512-GFpS3REqaHuyX4LCNqlneAQZIKyHb5ePiI1802n0fhtYjk68I1DTQ3PnbzYi50od/vAsTQVCknaS5F6tidNqTQ=="], - "@sentry/browser": ["@sentry/browser@10.7.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.7.0", "@sentry-internal/feedback": "10.7.0", "@sentry-internal/replay": "10.7.0", "@sentry-internal/replay-canvas": "10.7.0", "@sentry/core": "10.7.0" } }, "sha512-KPnKOIKFqxCpRMydyH8dn+MrPai2BEc+easWf8p3IWgUCx70+tZs5AXc0yWRgcXT534284waMnky2isn6Jbkvg=="], + "@sentry/browser": ["@sentry/browser@10.8.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.8.0", "@sentry-internal/feedback": "10.8.0", "@sentry-internal/replay": "10.8.0", "@sentry-internal/replay-canvas": "10.8.0", "@sentry/core": "10.8.0" } }, "sha512-2J7HST8/ixCaboq17yFn/j/OEokXSXoCBMXRrFx4FKJggKWZ90e2Iau5mP/IPPhrW+W9zCptCgNMY0167wS4qA=="], "@sentry/bundler-plugin-core": ["@sentry/bundler-plugin-core@4.2.0", "", { "dependencies": { "@babel/core": "^7.18.5", "@sentry/babel-plugin-component-annotate": "4.2.0", "@sentry/cli": "^2.51.0", "dotenv": "^16.3.1", "find-up": "^5.0.0", "glob": "^9.3.2", "magic-string": "0.30.8", "unplugin": "1.0.1" } }, "sha512-EDG6ELSEN/Dzm4KUQOynoI2suEAdPdgwaBXVN4Ww705zdrYT79OGh51rkz74KGhovt7GukaPf0Z9LJwORXUbhg=="], @@ -603,22 +640,24 @@ "@sentry/cli-win32-x64": ["@sentry/cli-win32-x64@2.52.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hJT0C3FwHk1Mt9oFqcci88wbO1D+yAWUL8J29HEGM5ZAqlhdh7sAtPDIC3P2LceUJOjnXihow47Bkj62juatIQ=="], - "@sentry/core": ["@sentry/core@10.7.0", "", {}, "sha512-y1Ni71O6TqeSi2Ug78StkVLHnybHZVYhnbYtj2w4g89XnQcqo4GUeR8dQRQBJpCX98UrHw22OAE8BXtKb03yXw=="], + "@sentry/core": ["@sentry/core@10.8.0", "", {}, "sha512-scYzM/UOItu4PjEq6CpHLdArpXjIS0laHYxE4YjkIbYIH6VMcXGQbD/FSBClsnCr1wXRnlXfXBzj0hrQAFyw+Q=="], - "@sentry/nextjs": ["@sentry/nextjs@10.7.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@rollup/plugin-commonjs": "28.0.1", "@sentry-internal/browser-utils": "10.7.0", "@sentry/core": "10.7.0", "@sentry/node": "10.7.0", "@sentry/opentelemetry": "10.7.0", "@sentry/react": "10.7.0", "@sentry/vercel-edge": "10.7.0", "@sentry/webpack-plugin": "^4.1.1", "chalk": "3.0.0", "resolve": "1.22.8", "rollup": "^4.35.0", "stacktrace-parser": "^0.1.10" }, "peerDependencies": { "next": "^13.2.0 || ^14.0 || ^15.0.0-rc.0" } }, "sha512-zGATwmUYd5rgy0G6Zi29GglxFYnn38FQQPjS+gnRPXaVIbck6Dtrjh+cJ3QDsttgUvfuZKpEE03JSrAA7D5QOA=="], + "@sentry/nextjs": ["@sentry/nextjs@10.8.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@rollup/plugin-commonjs": "28.0.1", "@sentry-internal/browser-utils": "10.8.0", "@sentry/core": "10.8.0", "@sentry/node": "10.8.0", "@sentry/opentelemetry": "10.8.0", "@sentry/react": "10.8.0", "@sentry/vercel-edge": "10.8.0", "@sentry/webpack-plugin": "^4.1.1", "chalk": "3.0.0", "resolve": "1.22.8", "rollup": "^4.35.0", "stacktrace-parser": "^0.1.10" }, "peerDependencies": { "next": "^13.2.0 || ^14.0 || ^15.0.0-rc.0" } }, "sha512-lMTALU8Iye7HUAIIKWsW3sOsuH+38jTpyZKxthGuo7kMcrnLCzK7sVuzw0gb9fDv6h2//XRdBl7npgke8wxlog=="], - "@sentry/node": ["@sentry/node@10.7.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.203.0", "@opentelemetry/instrumentation-amqplib": "0.50.0", "@opentelemetry/instrumentation-connect": "0.47.0", "@opentelemetry/instrumentation-dataloader": "0.21.1", "@opentelemetry/instrumentation-express": "0.52.0", "@opentelemetry/instrumentation-fs": "0.23.0", "@opentelemetry/instrumentation-generic-pool": "0.47.0", "@opentelemetry/instrumentation-graphql": "0.51.0", "@opentelemetry/instrumentation-hapi": "0.50.0", "@opentelemetry/instrumentation-http": "0.203.0", "@opentelemetry/instrumentation-ioredis": "0.51.0", "@opentelemetry/instrumentation-kafkajs": "0.13.0", "@opentelemetry/instrumentation-knex": "0.48.0", "@opentelemetry/instrumentation-koa": "0.51.0", "@opentelemetry/instrumentation-lru-memoizer": "0.48.0", "@opentelemetry/instrumentation-mongodb": "0.56.0", "@opentelemetry/instrumentation-mongoose": "0.50.0", "@opentelemetry/instrumentation-mysql": "0.49.0", "@opentelemetry/instrumentation-mysql2": "0.50.0", "@opentelemetry/instrumentation-pg": "0.55.0", "@opentelemetry/instrumentation-redis": "0.51.0", "@opentelemetry/instrumentation-tedious": "0.22.0", "@opentelemetry/instrumentation-undici": "0.14.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@prisma/instrumentation": "6.14.0", "@sentry/core": "10.7.0", "@sentry/node-core": "10.7.0", "@sentry/opentelemetry": "10.7.0", "import-in-the-middle": "^1.14.2", "minimatch": "^9.0.0" } }, "sha512-VtUFyf8avWUqN5RRTTmcU8aGdyNUGHzz/f+3n86BR5gBL3lziKOajyc0VClfc80VLsih+PWQ/5FrIHl+S1S1YQ=="], + "@sentry/node": ["@sentry/node@10.8.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.203.0", "@opentelemetry/instrumentation-amqplib": "0.50.0", "@opentelemetry/instrumentation-connect": "0.47.0", "@opentelemetry/instrumentation-dataloader": "0.21.1", "@opentelemetry/instrumentation-express": "0.52.0", "@opentelemetry/instrumentation-fs": "0.23.0", "@opentelemetry/instrumentation-generic-pool": "0.47.0", "@opentelemetry/instrumentation-graphql": "0.51.0", "@opentelemetry/instrumentation-hapi": "0.50.0", "@opentelemetry/instrumentation-http": "0.203.0", "@opentelemetry/instrumentation-ioredis": "0.51.0", "@opentelemetry/instrumentation-kafkajs": "0.13.0", "@opentelemetry/instrumentation-knex": "0.48.0", "@opentelemetry/instrumentation-koa": "0.51.0", "@opentelemetry/instrumentation-lru-memoizer": "0.48.0", "@opentelemetry/instrumentation-mongodb": "0.56.0", "@opentelemetry/instrumentation-mongoose": "0.50.0", "@opentelemetry/instrumentation-mysql": "0.49.0", "@opentelemetry/instrumentation-mysql2": "0.50.0", "@opentelemetry/instrumentation-pg": "0.55.0", "@opentelemetry/instrumentation-redis": "0.51.0", "@opentelemetry/instrumentation-tedious": "0.22.0", "@opentelemetry/instrumentation-undici": "0.14.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@prisma/instrumentation": "6.14.0", "@sentry/core": "10.8.0", "@sentry/node-core": "10.8.0", "@sentry/opentelemetry": "10.8.0", "import-in-the-middle": "^1.14.2", "minimatch": "^9.0.0" } }, "sha512-1TtCjxzn4SxoGw+ulLK+jF/v9NaZfP0yCclQIqfvWNDjMf2F+SbZL1UnXx4L184FGlNpRQnJBDrBe88gxnMX0A=="], - "@sentry/node-core": ["@sentry/node-core@10.7.0", "", { "dependencies": { "@sentry/core": "10.7.0", "@sentry/opentelemetry": "10.7.0", "import-in-the-middle": "^1.14.2" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", "@opentelemetry/core": "^1.30.1 || ^2.0.0", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/resources": "^1.30.1 || ^2.0.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0" } }, "sha512-iafuG3Fp0pleuk1WaL4UW7wpT6C86pMEQBZ7ARZ7UHc9ujRi/dewKFi0Stu0SxJm6PZ706VZ8Igz9xpvQ0aEEg=="], + "@sentry/node-core": ["@sentry/node-core@10.8.0", "", { "dependencies": { "@sentry/core": "10.8.0", "@sentry/opentelemetry": "10.8.0", "import-in-the-middle": "^1.14.2" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", "@opentelemetry/core": "^1.30.1 || ^2.0.0", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/resources": "^1.30.1 || ^2.0.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0" } }, "sha512-KCFy5Otq6KTXge8hBKMgU13EDRFkO4gNwSyZGXub8a7KHYFtoUgpRkborR59SWxeJmC6aEYTyh0PyOoWZJbHUQ=="], - "@sentry/opentelemetry": ["@sentry/opentelemetry@10.7.0", "", { "dependencies": { "@sentry/core": "10.7.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", "@opentelemetry/core": "^1.30.1 || ^2.0.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0" } }, "sha512-8SrRZyERDfCYYett6dklGe+qWMDZSytKPIZpS0nDb0IqZGC02ZVIhRISbBTy4Gctowu/gMK9XaOXfBNN0pI1sg=="], + "@sentry/opentelemetry": ["@sentry/opentelemetry@10.8.0", "", { "dependencies": { "@sentry/core": "10.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", "@opentelemetry/core": "^1.30.1 || ^2.0.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0" } }, "sha512-62R/RPwTYVaiZ5lVcxcjHCAGwgCyfn8Q3kaQld8/LPm8FRizZeUJmmtrI80KaYCvPJhSB/Pvfma4X3w+aN5Q3A=="], - "@sentry/react": ["@sentry/react@10.7.0", "", { "dependencies": { "@sentry/browser": "10.7.0", "@sentry/core": "10.7.0", "hoist-non-react-statics": "^3.3.2" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-A7y6tmREluH6OxWdwhIytQhThsUyl23cIz6isxQXqml7AMgt381JygRnm47nNl+NQLm+Uo1EGwxyvjdKPzJ9rw=="], + "@sentry/react": ["@sentry/react@10.8.0", "", { "dependencies": { "@sentry/browser": "10.8.0", "@sentry/core": "10.8.0", "hoist-non-react-statics": "^3.3.2" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-w/dGLMCLJG2lp8gKVKX1jjeg2inXewKfPb73+PS1CDi9/ihvqZU2DAXxnaNsBA7YYtGwlWVJe1bLAqguwTEpqw=="], - "@sentry/vercel-edge": ["@sentry/vercel-edge@10.7.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/resources": "^2.0.0", "@sentry/core": "10.7.0" } }, "sha512-HSmpxuar6hXhGdLHa0C3ZOx1tDzUuvVs31RAoRfShychIQp2904OOw5edOa9jM4AnhG1Qk/ZeF7sYJZAzdFnGQ=="], + "@sentry/vercel-edge": ["@sentry/vercel-edge@10.8.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/resources": "^2.0.0", "@sentry/core": "10.8.0" } }, "sha512-H08L/2CnnVNI2t+uDZQueXXXvmDaohM5MJVKY7QHS5TLHHhjnwsPo1DWD3PgA7UDaPQU1DioDiomEV/b5qarHg=="], "@sentry/webpack-plugin": ["@sentry/webpack-plugin@4.2.0", "", { "dependencies": { "@sentry/bundler-plugin-core": "4.2.0", "unplugin": "1.0.1", "uuid": "^9.0.0" }, "peerDependencies": { "webpack": ">=4.40.0" } }, "sha512-2lPuvJhbiEOd/NAQv5EL8at9QVKchkEmWFDioDsOG6csFqbZ8hdWtTcbsXnhzH9j+CM1LmdeDNVjIF+SMoxCNg=="], + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], @@ -1427,6 +1466,8 @@ "react-dom": ["react-dom@19.1.1", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw=="], + "react-hook-form": ["react-hook-form@7.62.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA=="], + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "read-pkg": ["read-pkg@3.0.0", "", { "dependencies": { "load-json-file": "^4.0.0", "normalize-package-data": "^2.3.2", "path-type": "^3.0.0" } }, "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA=="], diff --git a/convex/CustomPassword.ts b/convex/CustomPassword.ts new file mode 100644 index 0000000..64308b5 --- /dev/null +++ b/convex/CustomPassword.ts @@ -0,0 +1,228 @@ +import { + ConvexCredentials, + type ConvexCredentialsUserConfig, +} from "@convex-dev/auth/providers/ConvexCredentials"; +import { + type EmailConfig, + type GenericActionCtxWithAuthConfig, + type GenericDoc, + createAccount, + invalidateSessions, + modifyAccountCredentials, + retrieveAccount, + signInViaProvider, +} from "@convex-dev/auth/server"; +import type { + DocumentByName, + GenericDataModel, + WithoutSystemFields, +} from "convex/server"; +import type { Value } from "convex/values"; +import { Scrypt } from "lucia"; + +export type PasswordConfig = { + id?: string; + /** + * Perform checks on provided params and customize the user + * information stored after sign up, including email normalization. + * + * Called for every flow ("signUp", "signIn", "reset", + * "reset-verification" and "email-verification"). + */ + profile?: ( + /** + * The values passed to the `signIn` function. + */ + params: Record, + /** + * Convex ActionCtx in case you want to read from or write to + * the database. + */ + ctx: GenericActionCtxWithAuthConfig, + ) => WithoutSystemFields> & { + email: string; + }; + /** + * Performs custom validation on password provided during sign up or reset. + * + * Otherwise the default validation is used (password is not empty and + * at least 8 characters in length). + * + * If the provided password is invalid, implementations must throw an Error. + * + * @param password the password supplied during "signUp" or + * "reset-verification" flows. + */ + validatePasswordRequirements?: (password: string) => void; + /** + * Provide hashing and verification functions if you want to control + * how passwords are hashed. + */ + crypto?: ConvexCredentialsUserConfig["crypto"]; + /** + * An Auth.js email provider used to require verification + * before password reset. + */ + reset?: EmailConfig | ((...args: any) => EmailConfig); + /** + * An Auth.js email provider used to require verification + * before sign up / sign in. + */ + verify?: EmailConfig | ((...args: any) => EmailConfig); +} + +/** + * Email and password authentication provider. + * + * Passwords are by default hashed using Scrypt from Lucia. + * You can customize the hashing via the `crypto` option. + * + * Email verification is not required unless you pass + * an email provider to the `verify` option. + */ +export function Password( + config: PasswordConfig = {}, +) { + const provider = config.id ?? "password"; + return ConvexCredentials({ + id: "password", + authorize: async (params, ctx) => { + const flow = params.flow as string; + const passwordToValidate = + flow === "signUp" + ? (params.password as string) + : flow === "reset-verification" + ? (params.newPassword as string) + : null; + if (passwordToValidate !== null) { + if (config.validatePasswordRequirements !== undefined) { + config.validatePasswordRequirements(passwordToValidate); + } else { + validateDefaultPasswordRequirements(passwordToValidate); + } + } + const profile = config.profile?.(params, ctx) ?? defaultProfile(params); + const { email } = profile; + const secret = params.password as string; + let account: GenericDoc; + let user: GenericDoc; + if (flow === "signUp") { + if (secret === undefined) { + throw new Error("Missing `password` param for `signUp` flow"); + } + const created = await createAccount(ctx, { + provider, + account: { id: email, secret }, + profile: profile as any, + shouldLinkViaEmail: config.verify !== undefined, + shouldLinkViaPhone: false, + }); + ({ account, user } = created); + } else if (flow === "signIn") { + if (secret === undefined) { + throw new Error("Missing `password` param for `signIn` flow"); + } + const retrieved = await retrieveAccount(ctx, { + provider, + account: { id: email, secret }, + }); + if (retrieved === null) { + throw new Error("Invalid credentials"); + } + ({ account, user } = retrieved); + // START: Optional, support password reset + } else if (flow === "reset") { + if (!config.reset) { + throw new Error(`Password reset is not enabled for ${provider}`); + } + const { account } = await retrieveAccount(ctx, { + provider, + account: { id: email }, + }); + return await signInViaProvider(ctx, config.reset, { + accountId: account._id, + params, + }); + } else if (flow === "reset-verification") { + if (!config.reset) { + throw new Error(`Password reset is not enabled for ${provider}`); + } + if (params.newPassword === undefined) { + throw new Error( + "Missing `newPassword` param for `reset-verification` flow", + ); + } + const result = await signInViaProvider(ctx, config.reset, { params }); + if (result === null) { + throw new Error("Invalid code"); + } + const { userId, sessionId } = result; + const secret = params.newPassword as string; + await modifyAccountCredentials(ctx, { + provider, + account: { id: email, secret }, + }); + await invalidateSessions(ctx, { userId, except: [sessionId] }); + return { userId, sessionId }; + // END + // START: Optional, email verification during sign in + } else if (flow === "email-verification") { + if (!config.verify) { + throw new Error(`Email verification is not enabled for ${provider}`); + } + const { account } = await retrieveAccount(ctx, { + provider, + account: { id: email }, + }); + return await signInViaProvider(ctx, config.verify, { + accountId: account._id, + params, + }); + // END + } else { + throw new Error( + "Missing `flow` param, it must be one of " + + '"signUp", "signIn", "reset", "reset-verification" or ' + + '"email-verification"!', + ); + } + // START: Optional, email verification during sign in + if (config.verify && !account.emailVerified) { + return await signInViaProvider(ctx, config.verify, { + accountId: account._id, + params, + }); + } + // END + return { userId: user._id }; + }, + crypto: { + async hashSecret(password: string) { + return await new Scrypt().hash(password); + }, + async verifySecret(password: string, hash: string) { + return await new Scrypt().verify(hash, password); + }, + }, + extraProviders: [config.reset, config.verify], + ...config, + }); +} + +function validateDefaultPasswordRequirements(password: string) { + if ( + password.length < 8 || + !/\d/.test(password) || + !/[a-z]/.test(password) || + !/[A-Z]/.test(password) + ) { + throw new Error("Invalid password."); + } +} + +function defaultProfile(params: Record) { + return { + email: params.email as string, + name: params.name as string, + }; +} diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 09899f7..6bc2bee 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -13,6 +13,7 @@ import type { FilterApi, FunctionReference, } from "convex/server"; +import type * as CustomPassword from "../CustomPassword.js"; import type * as auth from "../auth.js"; import type * as http from "../http.js"; import type * as myFunctions from "../myFunctions.js"; @@ -26,6 +27,7 @@ import type * as myFunctions from "../myFunctions.js"; * ``` */ declare const fullApi: ApiFromModules<{ + CustomPassword: typeof CustomPassword; auth: typeof auth; http: typeof http; myFunctions: typeof myFunctions; diff --git a/convex/auth.ts b/convex/auth.ts index d4572f9..8b623ac 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -1,5 +1,5 @@ -import { Password } from '@convex-dev/auth/providers/Password'; import { convexAuth } from '@convex-dev/auth/server'; +import { Password } from './CustomPassword'; export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ providers: [Password], diff --git a/package.json b/package.json index a88b6f7..1bf3921 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "type": "module", "scripts": { "dev": "npm-run-all --parallel dev:frontend dev:backend", + "dev:slow": "npm-run-all --parallel dev:frontend:slow dev:backend", "dev:frontend": "next dev --turbo", + "dev:frontend:slow": "next dev", "dev:backend": "convex dev", "predev": "convex dev --until-success && convex dev --once --run-sh \"node setup.mjs --once\" && convex dashboard", "build": "next build", @@ -17,37 +19,42 @@ }, "dependencies": { "@convex-dev/auth": "^0.0.81", + "@hookform/resolvers": "^5.2.1", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", - "@sentry/nextjs": "^10.7.0", + "@radix-ui/react-tabs": "^1.1.13", + "@sentry/nextjs": "^10.8.0", "@t3-oss/env-nextjs": "^0.13.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "convex": "^1.26.0", + "convex": "^1.26.2", "eslint-plugin-prettier": "^5.5.4", "lucide-react": "^0.542.0", "next": "15.2.3", "next-plausible": "^3.12.4", "next-themes": "^0.4.6", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-hook-form": "^7.62.0", "require-in-the-middle": "^7.5.2", "tailwind-merge": "^3.3.1", "typescript-eslint": "^8.41.0", "zod": "^4.1.5" }, "devDependencies": { - "@eslint/eslintrc": "^3", - "@tailwindcss/postcss": "^4", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "dotenv": "^16.4.7", - "eslint": "^9", + "@eslint/eslintrc": "^3.3.1", + "@tailwindcss/postcss": "^4.1.12", + "@types/node": "^20.19.11", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "dotenv": "^16.6.1", + "eslint": "^9.34.0", "eslint-config-next": "15.2.3", "npm-run-all": "^4.1.5", - "prettier": "^3.5.3", - "tailwindcss": "^4", + "prettier": "^3.6.2", + "tailwindcss": "^4.1.12", "tw-animate-css": "^1.3.7", - "typescript": "^5" + "typescript": "^5.9.2" } } diff --git a/src/app/signin/page.tsx b/src/app/signin/page.tsx index 08ddf20..ce41f19 100644 --- a/src/app/signin/page.tsx +++ b/src/app/signin/page.tsx @@ -1,71 +1,328 @@ 'use client'; - +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import Link from 'next/link'; import { useAuthActions } from '@convex-dev/auth/react'; import { useRouter } from 'next/navigation'; +import { ConvexError } from 'convex/values'; import { useState } from 'react'; +import { + Card, + CardContent, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Separator, + StatusMessage, + SubmitButton, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@/components/ui'; + +const signInFormSchema = z.object({ + email: z.email({ + message: 'Please enter a valid email address.', + }), + password: z.string() + .min(8, { + message: 'Password must be at least 8 characters.', + }) + .regex(/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,}$/, { + message: 'Password must contain at least one digit, ' + + 'one uppercase letter, one lowercase letter, ' + + 'and one special character.' + }) +}); + +const signUpFormSchema = z.object({ + name: z.string() + .min(2, { + message: 'Name must be at least 2 characters.', + }), + email: z.email({ + message: 'Please enter a valid email address.', + }), + password: z.string() + .min(8, { + message: 'Password must be at least 8 characters.', + }) + .regex(/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,}$/, { + message: 'Password must contain at least one digit, ' + + 'one uppercase letter, one lowercase letter, ' + + 'and one special character.' + }), + confirmPassword: z.string().min(8, { + message: 'Password must be at least 8 characters.', + }), +}) +.refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match!', + path: ['confirmPassword'], +}); export default function SignIn() { const { signIn } = useAuthActions(); const [flow, setFlow] = useState<'signIn' | 'signUp'>('signIn'); - const [error, setError] = useState(null); + const [statusMessage, setStatusMessage] = useState(''); + const [loading, setLoading] = useState(false); const router = useRouter(); + + const signInForm = useForm>({ + resolver: zodResolver(signInFormSchema), + defaultValues: { email: '', password: '' }, + }); + + const signUpForm = useForm>({ + resolver: zodResolver(signUpFormSchema), + defaultValues: { + name: '', + email: '', + password: '', + confirmPassword: '', + }, + }); + + const handleSignIn = async (values: z.infer) => { + try { + setLoading(true); + setStatusMessage(''); + const formData = new FormData(); + formData.append('email', values.email); + formData.append('password', values.password); + formData.append('flow', flow); + if (flow === 'signUp') { + formData.append('name', values.name); + if (values.confirmPassword !== values.password) + throw new ConvexError({message: 'Passwords do not match!'}); + } + await signIn('password', formData); + signInForm.reset(); + router.push('/'); + } catch (error) { + setStatusMessage(`Error signing in: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + setLoading(false); + } + }; + return ( -
-

Log in to see the numbers

-
{ - e.preventDefault(); - const formData = new FormData(e.target as HTMLFormElement); - formData.set('flow', flow); - void signIn('password', formData) - .catch((error) => { - setError(error.message); - }) - .then(() => { - router.push('/'); - }); - }} - > - - - -
- - {flow === 'signIn' - ? "Don't have an account?" - : 'Already have an account?'} - - setFlow(flow === 'signIn' ? 'signUp' : 'signIn')} - > - {flow === 'signIn' ? 'Sign up instead' : 'Sign in instead'} - -
- {error && ( -
-

- Error signing in: {error} -

-
- )} -
+ + + Sign In + + + Sign Up + + + + + +
+ + ( + + + Email + + + + +
+ +
+
+ )} + /> + ( + +
+ + Password + + Forgot Password? +
+ + + +
+ +
+
+ )} + /> + {statusMessage && ( + + )} + + Sign In + + + +
+
+
+ + + +
+ + ( + + + Name + + + + +
+ +
+
+ )} + /> + ( + + + Email + + + + +
+ +
+
+ )} + /> + ( + + + Password + + + + +
+ +
+
+ )} + /> + ( + + + Confirm Passsword + + + + +
+ +
+
+ )} + /> + {statusMessage && ( + + )} + + Sign Up + + + +
+
+
+ +
); -} +}; diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..d05bbc6 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..524b986 --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,167 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + FormProvider, + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState } = useFormContext() + const formState = useFormState({ name: fieldContext.name }) + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId() + + return ( + +
+ + ) +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps) { + const { error, formItemId } = useFormField() + + return ( +