Compare commits
2 Commits
57737f5a03
...
1d32d31550
Author | SHA1 | Date | |
---|---|---|---|
1d32d31550 | |||
6e892a8614 |
113
README.md
113
README.md
@@ -1,46 +1,95 @@
|
||||
# Welcome to your Convex + Next.js + Convex Auth app
|
||||
<h1 align="center">
|
||||
<br>
|
||||
<img
|
||||
src="https://git.gbrown.org/gib/techtracker/raw/branch/main/public/favicon.png"
|
||||
alt="Next Template"
|
||||
width="100"
|
||||
>
|
||||
<br>
|
||||
<b>Tech Tracker</b>
|
||||
<br>
|
||||
</h1>
|
||||
|
||||
This is a [Convex](https://convex.dev/) project created with [`npm create convex`](https://www.npmjs.com/package/create-convex).
|
||||
<details>
|
||||
<summary>
|
||||
<h2>How to run:</h2>
|
||||
</summary>
|
||||
|
||||
After the initial setup (<2 minutes) you'll have a working full-stack app using:
|
||||
### Clone the Repository & Install Dependencies
|
||||
|
||||
- Convex as your backend (database, server logic)
|
||||
- [React](https://react.dev/) as your frontend (web page interactivity)
|
||||
- [Next.js](https://nextjs.org/) for optimized web hosting and page routing
|
||||
- [Tailwind](https://tailwindcss.com/) for building great looking accessible UI
|
||||
- [Convex Auth](https://labs.convex.dev/auth) for authentication
|
||||
|
||||
## Get started
|
||||
|
||||
If you just cloned this codebase and didn't use `npm create convex`, run:
|
||||
|
||||
```
|
||||
npm install
|
||||
npm run dev
|
||||
```bash
|
||||
git clone https://git.gbrown.org/gib/techtracker.git
|
||||
```
|
||||
|
||||
If you're reading this README on GitHub and want to use this template, run:
|
||||
|
||||
```
|
||||
npm create convex@latest -- -t nextjs-convexauth
|
||||
```bash
|
||||
cd techtracker
|
||||
```
|
||||
|
||||
## Learn more
|
||||
I would recommend using [bun](https://bun.sh/) to install dependencies.
|
||||
|
||||
To learn more about developing your project with Convex, check out:
|
||||
```bash
|
||||
bun i
|
||||
```
|
||||
|
||||
- The [Tour of Convex](https://docs.convex.dev/get-started) for a thorough introduction to Convex principles.
|
||||
- The rest of [Convex docs](https://docs.convex.dev/) to learn about all Convex features.
|
||||
- [Stack](https://stack.convex.dev/) for in-depth articles on advanced topics.
|
||||
- [Convex Auth docs](https://labs.convex.dev/auth) for documentation on the Convex Auth library.
|
||||
You will also need docker installed on whatever host you plan to run the Supabase instance from, whether locally, or on a home server or a VPS or whatever. Or you can just use the Supabase SaaS if you want to have a much easier time, probably. I wouldn't know!
|
||||
|
||||
## Configuring other authentication methods
|
||||
### Add your environment variables
|
||||
|
||||
To configure different authentication methods, see [Configuration](https://labs.convex.dev/auth/config) in the Convex Auth docs.
|
||||
Copy the example environment variable files and paste them in the same directory named `.env`.
|
||||
|
||||
## Join the community
|
||||
```bash
|
||||
cp ./env.example ./.env
|
||||
```
|
||||
|
||||
Join thousands of developers building full-stack apps with Convex:
|
||||
```bash
|
||||
cp ./host/convex/docker/env.example ./host/convex/docker/.env
|
||||
```
|
||||
|
||||
- Join the [Convex Discord community](https://convex.dev/community) to get help in real-time.
|
||||
- Follow [Convex on GitHub](https://github.com/get-convex/), star and contribute to the open-source implementation of Convex.
|
||||
### 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
|
||||
sudo docker compose exec convex-backend ./generate_admin_key.sh
|
||||
```
|
||||
|
||||
### Start your development environment.
|
||||
|
||||
Run
|
||||
|
||||
```bash
|
||||
bun dev
|
||||
```
|
||||
|
||||
to start your development environment with turbopack
|
||||
|
||||
You can also run
|
||||
|
||||
```bash
|
||||
bun dev:slow
|
||||
```
|
||||
|
||||
to start your development environment with webpack
|
||||
|
||||
### Start your Production Environment.
|
||||
|
||||
There are Dockerfiles & docker compose files that can be found in the `./scripts/docker` folder for the Next.js website. There is also a script called `reload_container` located in the `./scripts/` folder which was created to quickly update the container, but this will give you a better idea of what you need to do. First, build the image with
|
||||
|
||||
```bash
|
||||
sudo docker compose -f ./host/next/docker/compose.yml build
|
||||
```
|
||||
|
||||
then you can run the container with
|
||||
|
||||
```bash
|
||||
sudo docker compose -f ./host/next/docker/compose up -d
|
||||
```
|
||||
|
||||
Now, you may end up with some build errors. The `reload_containers` script swaps out the next config before it runs the docker build to skip any build errors, so you may want to do this as well, though you are welcome to fix the build errors as well, of course!
|
||||
|
||||
### Fin
|
||||
|
||||
I am sure I am missing a lot of stuff so feel free to open an issue if you have any questions or if you feel that I should add something here!
|
||||
|
||||
</details>
|
||||
|
65
bun.lock
65
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=="],
|
||||
|
228
convex/CustomPassword.ts
Normal file
228
convex/CustomPassword.ts
Normal file
@@ -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<DataModel extends GenericDataModel> = {
|
||||
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<string, Value | undefined>,
|
||||
/**
|
||||
* Convex ActionCtx in case you want to read from or write to
|
||||
* the database.
|
||||
*/
|
||||
ctx: GenericActionCtxWithAuthConfig<DataModel>,
|
||||
) => WithoutSystemFields<DocumentByName<DataModel, "users">> & {
|
||||
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<DataModel extends GenericDataModel>(
|
||||
config: PasswordConfig<DataModel> = {},
|
||||
) {
|
||||
const provider = config.id ?? "password";
|
||||
return ConvexCredentials<DataModel>({
|
||||
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<DataModel, "authAccounts">;
|
||||
let user: GenericDoc<DataModel, "users">;
|
||||
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<string, unknown>) {
|
||||
return {
|
||||
email: params.email as string,
|
||||
name: params.name as string,
|
||||
};
|
||||
}
|
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -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;
|
||||
|
@@ -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],
|
||||
|
18
env.example
Normal file
18
env.example
Normal file
@@ -0,0 +1,18 @@
|
||||
### Server Variables ###
|
||||
# Convex
|
||||
CONVEX_SELF_HOSTED_URL=
|
||||
CONVEX_SELF_HOSTED_ADMIN_KEY=
|
||||
NEXT_PUBLIC_CONVEX_URL=
|
||||
SETUP_SCRIPT_RAN=
|
||||
# Sentry
|
||||
SENTRY_AUTH_TOKEN=
|
||||
|
||||
### Client Variables ###
|
||||
# Next # Default Values:
|
||||
NEXT_PUBLIC_SITE_URL='http://localhost:3000'
|
||||
# Sentry # Default Values
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
NEXT_PUBLIC_SENTRY_URL=
|
||||
NEXT_PUBLIC_SENTRY_ORG=
|
||||
NEXT_PUBLIC_SENTRY_PROJECT_NAME=
|
||||
|
17
host/convex/docker/env.example
Normal file
17
host/convex/docker/env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
BACKEND_CONTAINER_NAME=
|
||||
DASHBOARD_CONTAINER_NAME=
|
||||
INSTANCE_NAME=
|
||||
CONVEX_CLOUD_ORIGIN=
|
||||
CONVEX_SITE_ORIGIN=
|
||||
DISABLE_BEACON=
|
||||
REDACT_LOGS_TO_CLIENT=
|
||||
DO_NOT_REQUIRE_SSL=
|
||||
NEXT_PUBLIC_DEPLOYMENT_URL=
|
||||
#POSTGRES_URL=
|
||||
#DATABASE_URL=
|
||||
#INSTANCE_SECRET=
|
||||
#CONVEX_RELEASE_VERSION_DEV=
|
||||
#ACTIONS_USER_TIMEOUT_SECS=
|
||||
#MYSQL_URL=
|
||||
#RUST_LOG=
|
||||
#RUST_BACKTRACE=
|
35
package.json
35
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"
|
||||
}
|
||||
}
|
||||
|
@@ -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<string | null>(null);
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className='flex flex-col gap-8 w-96 mx-auto h-screen justify-center items-center'>
|
||||
<p>Log in to see the numbers</p>
|
||||
<form
|
||||
className='flex flex-col gap-2'
|
||||
onSubmit={(e) => {
|
||||
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('/');
|
||||
|
||||
const signInForm = useForm<z.infer<typeof signInFormSchema>>({
|
||||
resolver: zodResolver(signInFormSchema),
|
||||
defaultValues: { email: '', password: '' },
|
||||
});
|
||||
}}
|
||||
|
||||
const signUpForm = useForm<z.infer<typeof signUpFormSchema>>({
|
||||
resolver: zodResolver(signUpFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => {
|
||||
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 (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
|
||||
<Tabs
|
||||
defaultValue={flow}
|
||||
onValueChange={(value) => setFlow(value as 'signIn' | 'signUp')}
|
||||
className='items-center'
|
||||
>
|
||||
<input
|
||||
className='bg-background text-foreground rounded-md p-2 border-2 border-slate-200 dark:border-slate-800'
|
||||
type='email'
|
||||
<TabsList className='py-6'>
|
||||
<TabsTrigger
|
||||
value='signIn'
|
||||
className='p-6 text-2xl font-bold cursor-pointer'
|
||||
>
|
||||
Sign In
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='signUp'
|
||||
className='p-6 text-2xl font-bold cursor-pointer'
|
||||
>
|
||||
Sign Up
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='signIn'>
|
||||
<Card className='min-w-xs sm:min-w-sm bg-card/50'>
|
||||
<CardContent>
|
||||
<Form {...signInForm}>
|
||||
<form
|
||||
onSubmit={signInForm.handleSubmit(handleSignIn)}
|
||||
className='flex flex-col space-y-8'
|
||||
>
|
||||
<FormField
|
||||
control={signInForm.control}
|
||||
name='email'
|
||||
placeholder='Email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>
|
||||
Email
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='email'
|
||||
placeholder='you@example.com'
|
||||
{...field}
|
||||
/>
|
||||
<input
|
||||
className='bg-background text-foreground rounded-md p-2 border-2 border-slate-200 dark:border-slate-800'
|
||||
type='password'
|
||||
name='password'
|
||||
placeholder='Password'
|
||||
/>
|
||||
<button
|
||||
className='bg-foreground text-background rounded-md'
|
||||
type='submit'
|
||||
>
|
||||
{flow === 'signIn' ? 'Sign in' : 'Sign up'}
|
||||
</button>
|
||||
<div className='flex flex-row gap-2'>
|
||||
<span>
|
||||
{flow === 'signIn'
|
||||
? "Don't have an account?"
|
||||
: 'Already have an account?'}
|
||||
</span>
|
||||
<span
|
||||
className='text-foreground underline hover:no-underline cursor-pointer'
|
||||
onClick={() => setFlow(flow === 'signIn' ? 'signUp' : 'signIn')}
|
||||
>
|
||||
{flow === 'signIn' ? 'Sign up instead' : 'Sign in instead'}
|
||||
</span>
|
||||
</div>
|
||||
{error && (
|
||||
<div className='bg-red-500/20 border-2 border-red-500/50 rounded-md p-2'>
|
||||
<p className='text-foreground font-mono text-xs'>
|
||||
Error signing in: {error}
|
||||
</p>
|
||||
</FormControl>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={signInForm.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className='flex justify-between'>
|
||||
<FormLabel className='text-xl'>
|
||||
Password
|
||||
</FormLabel>
|
||||
<Link href='/forgot-password'>Forgot Password?</Link>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Your password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{statusMessage && (
|
||||
<StatusMessage
|
||||
message={
|
||||
statusMessage.toLowerCase().includes('error')
|
||||
? { error: statusMessage }
|
||||
: { success: statusMessage }
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Signing in...'
|
||||
className='text-lg font-semibold w-2/3 mx-auto'
|
||||
>
|
||||
Sign In
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value='signUp'>
|
||||
<Card className='min-w-xs sm:min-w-sm bg-card/50'>
|
||||
<CardContent>
|
||||
<Form {...signUpForm}>
|
||||
<form
|
||||
onSubmit={signUpForm.handleSubmit(handleSignIn)}
|
||||
className='flex flex-col space-y-8'
|
||||
>
|
||||
<FormField
|
||||
control={signUpForm.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>
|
||||
Name
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='Full Name'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={signUpForm.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>
|
||||
Email
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='email'
|
||||
placeholder='you@example.com'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={signUpForm.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>
|
||||
Password
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Your password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={signUpForm.control}
|
||||
name='confirmPassword'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>
|
||||
Confirm Passsword
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Confirm your password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{statusMessage && (
|
||||
<StatusMessage
|
||||
message={
|
||||
statusMessage.toLowerCase().includes('error')
|
||||
? { error: statusMessage }
|
||||
: { success: statusMessage }
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Signing Up...'
|
||||
className='text-lg font-semibold w-2/3 mx-auto'
|
||||
>
|
||||
Sign Up
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
167
src/components/ui/form.tsx
Normal file
167
src/components/ui/form.tsx
Normal file
@@ -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<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
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 <FormField>")
|
||||
}
|
||||
|
||||
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<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
@@ -1 +1,31 @@
|
||||
export { Button, buttonVariants } from './button';
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from './card';
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
} from './form';
|
||||
export { Input } from './input';
|
||||
export { Label } from './label';
|
||||
export { Separator } from './separator';
|
||||
export { StatusMessage } from './status-message';
|
||||
export { SubmitButton } from './submit-button';
|
||||
export {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent
|
||||
} from './tabs';
|
||||
|
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
28
src/components/ui/separator.tsx
Normal file
28
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
55
src/components/ui/status-message.tsx
Normal file
55
src/components/ui/status-message.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { type ComponentProps } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Message = { success: string } | { error: string } | { message: string };
|
||||
|
||||
type StatusMessageProps = {
|
||||
message: Message;
|
||||
containerProps?: ComponentProps<'div'>;
|
||||
textProps?: ComponentProps<'div'>;
|
||||
};
|
||||
|
||||
export const StatusMessage = ({
|
||||
message,
|
||||
containerProps,
|
||||
textProps,
|
||||
}: StatusMessageProps) => {
|
||||
return (
|
||||
<div className='flex flex-col items-center w-full'>
|
||||
{'success' in message && (
|
||||
<div {...containerProps}
|
||||
className={cn(
|
||||
'flex flex-col items-center w-11/12 rounded-md p-2',
|
||||
'dark:bg-green-500/20 bg-green-700/20 border-2',
|
||||
'dark:border-green-500/50 border-green-700/50',
|
||||
containerProps?.className,
|
||||
)}
|
||||
>
|
||||
<p {...textProps}>{message.success}</p>
|
||||
</div>
|
||||
)}
|
||||
{'error' in message && (
|
||||
<div {...containerProps}
|
||||
className={cn(
|
||||
'flex flex-col items-center w-11/12 rounded-md p-2',
|
||||
'bg-destructive/20 border-2 border-destructive/80',
|
||||
containerProps?.className,
|
||||
)}
|
||||
>
|
||||
<p {...textProps}>{message.error}</p>
|
||||
</div>
|
||||
)}
|
||||
{'message' in message && (
|
||||
<div {...containerProps}
|
||||
className={cn(
|
||||
'flex flex-col items-center w-11/12 rounded-md p-2',
|
||||
'bg-accent/20 border-2 border-primary/80',
|
||||
containerProps?.className,
|
||||
)}
|
||||
>
|
||||
<p {...textProps}>{message.message}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
50
src/components/ui/submit-button.tsx
Normal file
50
src/components/ui/submit-button.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
import { Button } from '@/components/ui';
|
||||
import { type ComponentProps } from 'react';
|
||||
import { useFormStatus } from 'react-dom';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type SubmitButtonProps = Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
'type' | 'aria-disabled'
|
||||
> & {
|
||||
pendingText?: string;
|
||||
pendingTextProps?: ComponentProps<'p'>;
|
||||
loaderProps?: ComponentProps<typeof Loader2>;
|
||||
};
|
||||
|
||||
export const SubmitButton = ({
|
||||
children,
|
||||
className,
|
||||
pendingText = 'Submitting...',
|
||||
pendingTextProps,
|
||||
loaderProps,
|
||||
...props
|
||||
}: SubmitButtonProps) => {
|
||||
const { pending } = useFormStatus();
|
||||
return (
|
||||
<Button
|
||||
type='submit'
|
||||
aria-disabled={pending}
|
||||
{...props}
|
||||
className={cn('cursor-pointer', className)}
|
||||
>
|
||||
{pending || props.disabled ? (
|
||||
<>
|
||||
<Loader2
|
||||
{...loaderProps}
|
||||
className={cn('mr-2 h-4 w-4 animate-spin', loaderProps?.className)}
|
||||
/>
|
||||
<p {...pendingTextProps}
|
||||
className={cn('text-sm font-medium', pendingTextProps?.className)}
|
||||
>
|
||||
{pendingText}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
66
src/components/ui/tabs.tsx
Normal file
66
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
Reference in New Issue
Block a user