├── .env.example ├── .gitignore ├── README.md ├── actions ├── auth │ ├── email-verification │ │ └── index.ts │ ├── index.ts │ ├── login │ │ └── index.ts │ ├── password-reset │ │ └── index.ts │ ├── register │ │ └── index.ts │ ├── settings │ │ └── index.ts │ └── two-factor │ │ └── index.ts └── onboarding │ └── index.ts ├── app ├── (site) │ └── page.tsx ├── api │ ├── auth │ │ └── [...nextauth] │ │ │ └── route.ts │ └── protected-api │ │ └── route.ts ├── auth │ ├── change-password │ │ └── page.tsx │ ├── login │ │ └── page.tsx │ ├── register │ │ └── page.tsx │ ├── reset-password │ │ └── page.tsx │ ├── settings │ │ └── page.tsx │ └── verify-email │ │ └── page.tsx ├── favicon.ico ├── globals.css ├── layout.tsx └── onboarding │ └── page.tsx ├── assets ├── dois_fatores_page.jpg ├── forget_password_page.jpg ├── landing_page.jpg ├── light_landing_page.jpg ├── login_page.jpg ├── register_page.jpg ├── resend-api-key.jpg └── wave.gif ├── auth.config.ts ├── auth.ts ├── biome.json ├── components.json ├── components ├── auth │ ├── auth-card.tsx │ ├── auth-form-message.tsx │ ├── change-password-form.tsx │ ├── email-verification-form.tsx │ ├── login-badge.tsx │ ├── login-button.tsx │ ├── login-form.tsx │ ├── login-social-button.tsx │ ├── logout-button.tsx │ ├── register-form.tsx │ ├── reset-password-form.tsx │ ├── social-login.tsx │ ├── user-settings-form.tsx │ └── verification-email-template.tsx ├── icons │ └── index.tsx ├── onboarding │ ├── mail-address-form.tsx │ ├── multi-step-nav-buttons.tsx │ ├── multi-step-navbar.tsx │ ├── multi-step-wrapper.tsx │ ├── onboarding-form.tsx │ └── org-form.tsx ├── providers │ └── theme-provider.tsx ├── site │ └── navbar.tsx ├── theme-toggle.tsx └── ui │ ├── alert.tsx │ ├── avatar.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── checkbox.tsx │ ├── dropdown-menu.tsx │ ├── extension │ └── file-uploader.tsx │ ├── form.tsx │ ├── input-otp.tsx │ ├── input.tsx │ ├── label.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── switch.tsx │ ├── textarea.tsx │ ├── toast.tsx │ ├── toaster.tsx │ └── use-toast.ts ├── docker-compose.yml ├── hooks ├── multi-step-form │ └── useMultiStepForm.ts └── use-current-user.tsx ├── lib ├── auth │ ├── index.ts │ ├── invalid-credentials.ts │ └── user-not-found.ts ├── context │ └── onboarding │ │ └── store.ts ├── db.ts ├── mail │ └── index.ts └── utils.ts ├── middleware.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── prisma └── schema.prisma ├── public ├── next.svg └── vercel.svg ├── routes.ts ├── schemas ├── auth │ └── index.ts └── onboarding │ └── index.ts ├── services ├── auth │ ├── email-verification │ │ └── index.ts │ ├── index.ts │ ├── password-reset │ │ └── index.ts │ └── two-factor │ │ └── index.ts ├── index.ts └── onboarding │ └── org │ └── index.ts ├── tailwind.config.ts ├── tsconfig.json └── types ├── environment.d.ts └── next-auth.d.ts /.env.example: -------------------------------------------------------------------------------- 1 | # Environment variables declared in this file are automatically made available to Prisma. 2 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema 3 | 4 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. 5 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 6 | # When you are using the docker-compose.yml provided, you can use the commection string example below: 7 | DATABASE_URL="postgresql://developerdeck101:developerdeck101@127.0.0.1:5432/test" 8 | #Prisma 9 | #DATABASE_URL="postgresql://:@:/" 10 | 11 | #Authjs 12 | # AUTH_SECRET=kK97XcWvA/cVouO72uXK84yop6qBa9fZ6X4fU7Ewp28= 13 | AUTH_SECRET=314FUJnJeO1zGfxpxbmqqxQsBiCl/NwOyJ9AONpG03Y= 14 | AUTH_LOGIN_REDIRECT=/ 15 | #Use em AUTH_TRUST_HOST em ambiente de desenvolvimento 16 | #quando estiver usar modo produção para testar a 17 | #verificação de e-mail 18 | AUTH_TRUST_HOST=true 19 | 20 | # Login Providers 21 | 22 | # GOOGLE 23 | AUTH_GOOGLE_ID= 24 | AUTH_GOOGLE_SECRET= 25 | # GITHUB 26 | AUTH_GITHUB_ID= 27 | AUTH_GITHUB_SECRET= 28 | # Resend 29 | AUTH_RESEND_KEY= 30 | 31 | #resend 32 | NEXT_PUBLIC_URL=http://localhost:3000 33 | RESEND_EMAIL_FROM="DeveloperDeck101 " 34 | 35 | #Account Verification 36 | VERIFICATION_URL=/auth/verify-email 37 | VERIFICATION_SUBJECT=Verificação de E-mail 38 | OTP_SUBJECT=Seu codigo de verificação 39 | RESET_PASSWORD_URL=/auth/change-password 40 | RESET_PASSWORD_SUBJECT=Mudança de senha -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | # Pronto para Autenticar Seu Novo Projeto 5 | 6 | Este Starter Kit foi desenvolvido para poupar seu tempo, oferecendo tudo o que você precisa para começar a desenvolver seu projeto com segurança. 7 | 8 | ![GitHub last commit](https://img.shields.io/github/last-commit/devdeck101/authjs-prisma-template) ![GitHub forks](https://img.shields.io/github/forks/devdeck101/authjs-prisma-template) ![GitHub Repo stars](https://img.shields.io/github/stars/devdeck101/authjs-prisma-template) ![GitHub watchers](https://img.shields.io/github/watchers/devdeck101/authjs-prisma-template) 9 | 10 |
11 | drawing 12 | drawing 13 | drawing 14 | drawing 15 | drawing 16 | drawing 17 |
18 | 19 | 20 | ## drawing Detalhes Explicados no Meu Canal 21 | [![Youtube Badge](https://img.shields.io/badge/-@developerdeck101-cc181e?style=flat-square&logo=youtube&logoColor=white&link=https://www.youtube.com/developerdeck101)](https://www.youtube.com/developerdeck101) 22 | ![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCj75B_51OXb9qH15wiHs-Hw?style=social) 23 | ![YouTube Channel Views](https://img.shields.io/youtube/channel/views/UCj75B_51OXb9qH15wiHs-Hw) 24 | 25 | 26 | 27 | Este é um template de projeto de autenticação e autorização implementado em [Next.js](https://nextjs.org/) e [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 28 | Foi incluído a inicialização com [Shadcn-ui](https://ui.shadcn.com/), prisma [Prisma](https://www.prisma.io/), Authjs | Next-Auth [Authjs](https://authjs.dev/) utilizando banco de dados [PostgreSQL](https://www.postgresql.org/) 29 | 30 | ## Tecnologias e Bibliotecas 31 | 32 | ![Next.js](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white) 33 | ![Javascript](https://img.shields.io/badge/Javascript-F0DB4F?style=for-the-badge&labelColor=black&logo=javascript&logoColor=F0DB4F) 34 | ![Typescript](https://img.shields.io/badge/Typescript-007acc?style=for-the-badge&labelColor=black&logo=typescript&logoColor=007acc) 35 | ![React](https://img.shields.io/badge/-React-61DBFB?style=for-the-badge&labelColor=black&logo=react&logoColor=61DBFB) 36 | 37 | ![Static Badge](https://img.shields.io/badge/-PostgreSQL-PostgreSQL?logo=postgresql&logoColor=%23ffffff&labelColor=008bb9&color=848484) 38 | ![Static Badge](https://img.shields.io/badge/-RESEND-PostgreSQL?logo=resend&logoColor=%23ffffff&labelColor=000000&color=000000) 39 | ![Static Badge](https://img.shields.io/badge/-REACTEMAIL-REACTEMAIL?labelColor=000000&color=000000) 40 | ![Static Badge](https://img.shields.io/badge/-SHADCNUI-SHADCNUI?labelColor=000000&color=000000) 41 | ![Static Badge](https://img.shields.io/badge/-Prisma-Prisma?logo=prisma&logoColor=%23000000&labelColor=%230000&color=%23ffffff) 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | ## Getting Started 50 | 51 | Clone o repositório: 52 | 53 | ```bash 54 | git clone https://github.com/devdeck101/authjs-prisma-template.git 55 | ``` 56 | 57 | Entre na pasta do projeto e instale os pacotes: 58 | 59 | ```bash 60 | npm install 61 | ``` 62 | 63 | ## Banco de Dados 64 | 65 | O banco de dados utilizado é o PostgreSQL. Você precisará de uma instância dele para executar o projeto. Um arquivo docker-compose.yml está incluído para facilitar a execução de um container Docker. 66 | 67 | 68 | ### Container Docker - Docker Compose 69 | 70 | Na raiz do projeto, há um arquivo docker-compose.yml com a configuração para um banco de dados PostgreSQL. 71 | 72 | Caso não tenha o docker instalado, pode encontrá-lo aqui [Get Docker](https://docs.docker.com/get-docker/). 73 | 74 | Para inicializar o container: 75 | 76 | ```bash 77 | docker compose up -d 78 | ``` 79 | 80 | Para finalizar o serviço: 81 | 82 | ```bash 83 | docker compose down postgres 84 | ``` 85 | 86 | 87 | ## Configuração de Envio de E-Mail 88 | 89 | É necessário se cadastrar no [RESEND](https://resend.com/) e criar uma chave de API para envios de email, incluindo verificação de usuário, autenticação de dois fatores e mudança de senha. 90 | 91 | ### RESEND API KEY 92 | Após logar na sua conta, siga as instruções na imagem abaixo: 93 | 94 | ![image](assets/resend-api-key.jpg) 95 | 96 | ## Variáveis de Ambiente 97 | 98 | Renomeie o arquivo .env.example para .env. Depois, modifique as variáveis de ambiente conforme necessário: 99 | 100 | Váriável do banco de dados: 101 | ```bash 102 | # Exemplo utilizando o container Docker disponível 103 | DATABASE_URL="postgresql://developerdeck101:developerdeck101@127.0.0.1:5432/test" 104 | # Ou personalize com suas próprias configurações 105 | DATABASE_URL="postgresql://:@:/" 106 | ``` 107 | Variável de encriptação do token JWT: 108 | 109 | ```bash 110 | AUTH_SECRET=314FUJnJeO1zGfxpxbmqqxQsBiCl/NwOyJ9AONpG03Y= 111 | ``` 112 | 113 | Para gerar a chave AUTH_SECRET, utilize o comando: 114 | 115 | ```bash 116 | # Unix 117 | openssl rand -base64 32 118 | ``` 119 | 120 | ou 121 | 122 | ```bash 123 | # Windows 124 | npm exec auth secret 125 | ``` 126 | 127 | Caso deseje executar em modo produção npm run start, será necessário descomentar a variável: 128 | 129 | ```bash 130 | AUTH_TRUST_HOST=true 131 | ``` 132 | 133 | Para criar as tabelas do banco de dados, é possível executar os comandos do Prisma ou scripts do projeto. 134 | 135 | [![Static Badge](https://img.shields.io/badge/-discord-Discord?logo=Discord&labelColor=5e90ee&color=%23ffff)](http://discord.gg/GXQAVzn4Vn) 136 | 137 | 138 | ## Tabelas do Banco de Dados 139 | Para criar as tabelas do banco de dados, é possível executar os comandos do Prisma ou scripts do projeto. 140 | 141 | ### Comandos Prisma 142 | 143 | Execute o comando: 144 | 145 | ```bash 146 | npx prisma migrate dev 147 | ``` 148 | 149 | ou 150 | 151 | ```bash 152 | npx prisma db push 153 | ``` 154 | 155 | ### Scripts disponíveis 156 | 157 | ```bash 158 | # Cria as tabelas no banco de dados 159 | npm run push-db 160 | ``` 161 | 162 | ```bash 163 | # Limpa o banco de dados 164 | npm run clear-db 165 | ``` 166 | 167 | ```bash 168 | # Abre o Prisma Studio 169 | npm run studio 170 | ``` 171 | 172 | ## Para inicializar o projeto 173 | 174 | ### Modo Desenvolvimento 175 | ```bash 176 | # Executar o Projeto 177 | npm run dev 178 | ``` 179 | ### Modo Produção 180 | 181 | ```bash 182 | # Construir o projeto 183 | npm run build 184 | ``` 185 | 186 | ```bash 187 | # Executar o Projeto 188 | npm run start 189 | ``` 190 | 191 | Abrir [http://localhost:3000](http://localhost:3000) com seu navegador. 192 | 193 | # Não se esqueça 194 | 195 | ## Siga-me nas Redes Sociais drawing 196 | [![Youtube Badge](https://img.shields.io/badge/-@developerdeck101-darkred?style=flat-square&logo=youtube&logoColor=white&link=https://www.youtube.com/developerdeck101)](https://www.youtube.com/developerdeck101) 197 | [![Instagram Badge](https://img.shields.io/badge/-developerdeck101_-purple?style=flat-square&logo=instagram&logoColor=white&link=https://instagram.com/developerdeck101_/)](https://instagram.com/developerdeck101_) 198 | [![Linkedin Badge](https://img.shields.io/badge/-Bruno_Kilian-blue?style=flat-square&logo=Linkedin&logoColor=white&link=https://www.linkedin.com/in/brunokilian)](https://www.linkedin.com/in/brunokilian) 199 | [![Twitter Badge](https://img.shields.io/badge/-DeveloperDeck101-blue?style=flat-square&logo=Twitter&logoColor=white&link=http://twitter.com/devdeck101)](http://twitter.com/devdeck101) 200 | [![Discord Badge](https://img.shields.io/badge/-DeveloperDeck101-7289da?style=flat-square&logo=Discord&logoColor=white&link=http://discord.gg/GXQAVzn4Vn)](http://discord.gg/GXQAVzn4Vn) 201 | 202 | ## Apoie o Projeto e o Canal 203 | 204 | [![Youtube Badge](https://img.shields.io/badge/-Membros_do_Canal-darkred?style=flat-square&logo=youtube&logoColor=white&link=https://www.youtube.com/channel/UCj75B_51OXb9qH15wiHs-Hw/join)](https://www.youtube.com/channel/UCj75B_51OXb9qH15wiHs-Hw/join) 205 | [![Static Badge](https://img.shields.io/badge/LivePix-Apoie_o_Canal_e_Projeto-blue?logo=Livepix&logoColor=%23ffffff&labelColor=blue&color=%23ffffff)](https://livepix.gg/brkilian) 206 | [![Static Badge](https://img.shields.io/badge/LivePix_QR_CODE-Apoie_o_Canal_e_Projeto-blue?logo=Livepix&logoColor=%23ffffff&labelColor=blue&color=%23ffffff)](https://widget.livepix.gg/embed/80b6ae11-d611-464b-b3f0-2db50d84d6ee) 207 | 208 | 209 | 210 | -------------------------------------------------------------------------------- /actions/auth/email-verification/index.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import mail from "@/lib/mail"; 5 | import { findUserbyEmail } from "@/services"; 6 | import { findVerificationTokenbyToken } from "@/services/auth"; 7 | import type { User } from "@prisma/client"; 8 | /** 9 | * This method uses Resend to send an email to the user to verify 10 | * the ownership of the email by the user. 11 | * 12 | * @param {User} user - The user to send the verification email to. 13 | * @param {string} token - The verification token. 14 | * @returns {Promise<{ error?: string, success?: string }>} An object indicating the result of the operation. 15 | */ 16 | export const sendAccountVerificationEmail = async (user: User, token: string) => { 17 | const { RESEND_EMAIL_FROM, VERIFICATION_SUBJECT, NEXT_PUBLIC_URL, VERIFICATION_URL } = process.env; 18 | if (!RESEND_EMAIL_FROM || !VERIFICATION_SUBJECT || !NEXT_PUBLIC_URL || !VERIFICATION_URL) { 19 | return { 20 | error: "Configuração de ambiente insuficiente para envio de e-mail.", 21 | }; 22 | } 23 | 24 | const verificationUrl = `${NEXT_PUBLIC_URL}${VERIFICATION_URL}?token=${token}`; 25 | const { email } = user; 26 | try { 27 | const { data, error } = await mail.emails.send({ 28 | from: RESEND_EMAIL_FROM, 29 | to: email, 30 | subject: VERIFICATION_SUBJECT, 31 | html: `

Clique aqui para confirmar seu e-mail.

`, 32 | }); 33 | 34 | if (error) 35 | return { 36 | error, 37 | }; 38 | return { 39 | success: "E-mail enviado com sucesso", 40 | }; 41 | } catch (error) { 42 | return { error }; 43 | } 44 | }; 45 | 46 | /** 47 | * This method updates the user's record with the date the email was verified. 48 | * 49 | * @param {string} token - The verification token. 50 | * @returns {Promise<{ error?: string, success?: string }>} An object indicating the result of the operation. 51 | */ 52 | export const verifyToken = async (token: string) => { 53 | const existingToken = await findVerificationTokenbyToken(token); 54 | if (!existingToken) { 55 | return { 56 | error: "Código de verificação não encontrado", 57 | }; 58 | } 59 | 60 | const isTokenExpired = new Date(existingToken.expires) < new Date(); 61 | if (isTokenExpired) { 62 | return { 63 | error: "Código de verificação expirado", 64 | }; 65 | } 66 | 67 | const user = await findUserbyEmail(existingToken.email); 68 | if (!user) { 69 | return { 70 | error: "Usuário não encontrado", 71 | }; 72 | } 73 | 74 | try { 75 | await prisma.user.update({ 76 | where: { id: user.id }, 77 | data: { 78 | emailVerified: new Date(), 79 | }, 80 | }); 81 | 82 | await prisma.verificationToken.delete({ 83 | where: { 84 | id: existingToken.id, 85 | }, 86 | }); 87 | 88 | return { 89 | success: "E-mail verificado", 90 | }; 91 | } catch (err) { 92 | return { error: "Erro ao atualizar verificação de e-mail" }; 93 | } 94 | }; 95 | -------------------------------------------------------------------------------- /actions/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./email-verification"; 2 | export * from "./login"; 3 | export * from "./password-reset"; 4 | export * from "./register"; 5 | export * from "./two-factor"; 6 | -------------------------------------------------------------------------------- /actions/auth/login/index.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { signIn } from "@/auth"; 4 | import { CredentialsSchema, MagicLinkSignInSchema } from "@/schemas/auth"; 5 | import { findUserbyEmail } from "@/services"; 6 | import { 7 | createTwoFactorAuthToken, 8 | createVerificationToken, 9 | deleteTwoFactorAuthTokenById, 10 | findTwoFactorAuthTokenByEmail, 11 | } from "@/services/auth"; 12 | import { AuthError, CredentialsSignin } from "next-auth"; 13 | import type { z } from "zod"; 14 | import { sendAccountVerificationEmail } from "../email-verification"; 15 | import { sendTwoFactorAuthEmail } from "../two-factor"; 16 | 17 | /** 18 | * This method is responsible for executing the login flow. 19 | * @param {z.infer} credentials - The user credentials. 20 | * @returns {Promise<{ error?: string, success?: string, data?: { twoFactorAuthEnabled: boolean } }>} 21 | * An object containing error, success, or data about two-factor authentication status, 22 | * or throws an error if an unexpected error occurs. 23 | */ 24 | export const login = async (credentials: z.infer) => { 25 | const validCredentials = await CredentialsSchema.safeParse(credentials); 26 | if (!validCredentials.success) { 27 | return { 28 | error: "Dados inválidos", 29 | }; 30 | } 31 | 32 | try { 33 | const { email, password, code } = validCredentials.data; 34 | const user = await findUserbyEmail(email); 35 | if (!user) { 36 | return { 37 | error: "Usuário não encontrado", 38 | }; 39 | } 40 | //Verificação de E-mail 41 | if (!user.emailVerified) { 42 | const verificationToken = await createVerificationToken(user.email); 43 | await sendAccountVerificationEmail(user, verificationToken.token); 44 | return { 45 | success: "Verificação de E-mail enviada com sucesso", 46 | }; 47 | } 48 | 49 | //Two Factor Authentication 50 | if (user.isTwoFactorAuthEnabled) { 51 | if (code) { 52 | const twoFactorAuthToken = await findTwoFactorAuthTokenByEmail(email); 53 | 54 | if (!twoFactorAuthToken || twoFactorAuthToken.token !== code) { 55 | return { 56 | error: "Código Inválido", 57 | data: { 58 | twoFactorAuthEnabled: true, 59 | }, 60 | }; 61 | } 62 | 63 | const hasExpired = new Date(twoFactorAuthToken.expires) < new Date(); 64 | 65 | if (hasExpired) { 66 | return { 67 | error: "Código Expirado", 68 | data: { 69 | twoFactorAuthEnabled: true, 70 | }, 71 | }; 72 | } 73 | 74 | await deleteTwoFactorAuthTokenById(twoFactorAuthToken.id); 75 | } else { 76 | //generate code 77 | const twoFactorAuthToken = await createTwoFactorAuthToken(email); 78 | await sendTwoFactorAuthEmail(user, twoFactorAuthToken.token); 79 | return { 80 | data: { 81 | twoFactorAuthEnabled: true, 82 | }, 83 | }; 84 | } 85 | } 86 | 87 | const resp = await signIn("credentials", { 88 | email, 89 | password, 90 | redirectTo: process.env.AUTH_LOGIN_REDIRECT, 91 | }); 92 | } catch (err) { 93 | if (err instanceof AuthError) { 94 | if (err instanceof CredentialsSignin) { 95 | return { 96 | error: "Credenciais inválidas", 97 | }; 98 | } 99 | } 100 | 101 | throw err; // Rethrow all other errors 102 | } 103 | }; 104 | -------------------------------------------------------------------------------- /actions/auth/password-reset/index.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import mail from "@/lib/mail"; 4 | import { NewPasswordSchema, ResetPasswordSchema } from "@/schemas/auth"; 5 | import { findUserbyEmail } from "@/services"; 6 | import { 7 | createResetPasswordToken, 8 | deleteResetPasswordToken, 9 | findResetPasswordTokenByToken, 10 | updatePassword, 11 | } from "@/services/auth"; 12 | import bcryptjs from "bcryptjs"; 13 | import type { z } from "zod"; 14 | 15 | /** 16 | * This method initiates the reset password process 17 | * @param {z.infer} values - The values for resetting the password. 18 | * @returns {Promise<{error?: string, success?: string}>} The result of the reset password request. 19 | */ 20 | export const resetPassword = async (values: z.infer) => { 21 | const validatedEmail = ResetPasswordSchema.safeParse(values); 22 | if (!validatedEmail.success) { 23 | return { error: "E-mail inválido" }; 24 | } 25 | 26 | const { email } = validatedEmail.data; 27 | 28 | const existingUser = await findUserbyEmail(email); 29 | if (!existingUser) { 30 | return { error: "Usuário não encontrado" }; 31 | } 32 | 33 | const resetPasswordToken = await createResetPasswordToken(email); 34 | await sendResetPasswordEmail(resetPasswordToken.email, resetPasswordToken.token); 35 | 36 | return { success: "E-mail de mudança de senha enviado" }; 37 | }; 38 | 39 | /** 40 | * This method uses Resend to send an e-mail to change the user's password 41 | * @param {string} email - The user's email. 42 | * @param {string} token - The reset password token. 43 | * @returns {Promise<{error?: string, success?: string}>} The result of the email sending request. 44 | */ 45 | export const sendResetPasswordEmail = async (email: string, token: string) => { 46 | const { NEXT_PUBLIC_URL, RESEND_EMAIL_FROM, RESET_PASSWORD_SUBJECT, RESET_PASSWORD_URL } = process.env; 47 | 48 | if (!NEXT_PUBLIC_URL || !RESEND_EMAIL_FROM || !RESET_PASSWORD_SUBJECT || !RESET_PASSWORD_URL) { 49 | return { error: "Configuração de ambiente insuficiente para envio de e-mail." }; 50 | } 51 | 52 | const resetUrl = `${NEXT_PUBLIC_URL}${RESET_PASSWORD_URL}?token=${token}`; 53 | const { data, error } = await mail.emails.send({ 54 | from: RESEND_EMAIL_FROM, 55 | to: email, 56 | subject: RESET_PASSWORD_SUBJECT, 57 | html: `

Clique aqui para modificar sua senha.

`, 58 | }); 59 | 60 | if (error) 61 | return { 62 | error, 63 | }; 64 | return { 65 | success: "E-mail enviado com sucesso", 66 | }; 67 | }; 68 | 69 | /** 70 | * This method updates the user's password 71 | * @param {z.infer} passwordData - The new password data. 72 | * @param {string | null} token - The reset password token. 73 | * @returns {Promise<{error?: string, success?: string}>} The result of the password change request. 74 | */ 75 | export const changePassword = async (passwordData: z.infer, token: string | null) => { 76 | if (!token) { 77 | return { error: "Token não encontrado" }; 78 | } 79 | 80 | const validatedPassword = NewPasswordSchema.safeParse(passwordData); 81 | 82 | if (!validatedPassword.success) { 83 | return { error: "Dados inválidos" }; 84 | } 85 | 86 | const { password } = validatedPassword.data; 87 | 88 | const existingToken = await findResetPasswordTokenByToken(token); 89 | if (!existingToken) { 90 | return { error: "Token inválido" }; 91 | } 92 | 93 | const hasExpired = new Date(existingToken.expires) < new Date(); 94 | if (hasExpired) { 95 | return { error: "Token Expirado" }; 96 | } 97 | 98 | const existingUser = await findUserbyEmail(existingToken.email); 99 | if (!existingUser) { 100 | return { error: "Usuário não encontrado" }; 101 | } 102 | 103 | const hashedPassword = await bcryptjs.hash(password, 10); 104 | 105 | await updatePassword(existingUser.id, hashedPassword); 106 | 107 | await deleteResetPasswordToken(existingToken.id); 108 | 109 | return { success: "Senha atualizada" }; 110 | }; 111 | -------------------------------------------------------------------------------- /actions/auth/register/index.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { RegisterSchema } from "@/schemas/auth"; 5 | import { createVerificationToken } from "@/services/auth"; 6 | import { UserRole } from "@prisma/client"; 7 | import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; 8 | import bcryptjs from "bcryptjs"; 9 | import type { z } from "zod"; 10 | import { sendAccountVerificationEmail } from "../email-verification"; 11 | 12 | /** 13 | * This method creates the user for Credentials provider 14 | * @param {z.infer} user - The new user data. 15 | * @returns {Promise<{error?: string, success?: string}>} The result of the password change request. 16 | */ 17 | export const register = async (user: z.infer) => { 18 | const valid = await RegisterSchema.safeParse(user); 19 | 20 | if (!valid.success) { 21 | return { 22 | error: "Dados inválidos", 23 | }; 24 | } 25 | 26 | try { 27 | const { name, email, password } = user; 28 | const hashedPassword = await bcryptjs.hash(password, 10); 29 | const createdUser = await prisma.user.create({ 30 | data: { 31 | name, 32 | email, 33 | password: hashedPassword, 34 | role: UserRole.DEFAULT, 35 | }, 36 | }); 37 | //Account verification flow with e-mail 38 | const verificationToken = await createVerificationToken(email); 39 | await sendAccountVerificationEmail(createdUser, verificationToken.token); 40 | return { 41 | success: "E-mail de verificação enviado", 42 | }; 43 | } catch (error) { 44 | if (error instanceof PrismaClientKnownRequestError) { 45 | if (error.code === "P2002") { 46 | return { 47 | error: "Já existe uma conta relacionada a este e-mail.", 48 | }; 49 | } 50 | } 51 | // return { error }; 52 | 53 | throw error; 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /actions/auth/settings/index.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { auth } from "@/auth"; 4 | import { useCurrentUser } from "@/hooks/use-current-user"; 5 | import { prisma } from "@/lib/db"; 6 | import { UserSettingsSchema } from "@/schemas/auth"; 7 | import { findUserbyEmail, findUserbyId } from "@/services"; 8 | import bcryptjs from "bcryptjs"; 9 | import type { z } from "zod"; 10 | 11 | import { update } from "@/auth"; 12 | 13 | /** 14 | * This method saves the user's new settings 15 | * @param {z.infer} user - The new user data. 16 | * @returns {Promise<{error?: string, success?: string}>} The result of the settings change request. 17 | */ 18 | export const changeSettings = async (settings: z.infer) => { 19 | const validData = UserSettingsSchema.safeParse(settings); 20 | if (!validData.success) { 21 | return { 22 | error: "Dados inválidos", 23 | }; 24 | } 25 | 26 | const session = await auth(); 27 | if (!session?.user || !session?.user.id) { 28 | return { 29 | error: "Conecte-se para atualizar seus dados", 30 | }; 31 | } 32 | 33 | const userData = await findUserbyId(session?.user.id); 34 | if (!userData) { 35 | return { 36 | error: "Usuário não encontrado", 37 | }; 38 | } 39 | 40 | //TODO: Add e-mail verification to enable two factor authentication 41 | const { password, newPassword } = validData.data; 42 | if (password && newPassword && userData?.password) { 43 | const validPassword = bcryptjs.compare(password, userData.password); 44 | if (!validPassword) { 45 | return { 46 | error: "Senha atual incorreta", 47 | }; 48 | } 49 | 50 | settings.newPassword = undefined; 51 | settings.password = await bcryptjs.hash(newPassword, 10); 52 | } 53 | settings.email = undefined; 54 | // settings.isTwoFactorEnabled = undefined; 55 | try { 56 | const updatedUser = await prisma.user.update({ 57 | data: { 58 | ...settings, 59 | }, 60 | where: { 61 | id: userData.id, 62 | }, 63 | }); 64 | 65 | await update({ 66 | user: { 67 | ...session.user, 68 | name: updatedUser.name, 69 | isTwoFactorEnabled: updatedUser.isTwoFactorAuthEnabled, 70 | //TODO: Add fields to chande roles and or e-mail for the user???? 71 | }, 72 | }); 73 | return { 74 | success: "Perfil atualizado", 75 | }; 76 | } catch (error) { 77 | return { 78 | error: "Algo deu errado", 79 | }; 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /actions/auth/two-factor/index.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import mail from "@/lib/mail"; 5 | import { findUserbyEmail } from "@/services"; 6 | import { findTwoFactorAuthTokeByToken } from "@/services/auth"; 7 | import type { User } from "@prisma/client"; 8 | 9 | /** 10 | * This method sends an e-mail to the user with the 6 digits code to login 11 | * when Two Factor Authentication is enabled 12 | * @param {User} user 13 | * @param {string} token 14 | * @returns 15 | */ 16 | /** 17 | * This method sends an e-mail to the user with the 6 digits code to login 18 | * when Two Factor Authentication is enabled 19 | * @param {User} user - The user to send the verification email to. 20 | * @param {string} token - The verification token. 21 | * @returns {Promise<{ error?: string, success?: string }>} An object indicating the result of the operation. 22 | */ 23 | export const sendTwoFactorAuthEmail = async (user: User, token: string) => { 24 | const { RESEND_EMAIL_FROM, OTP_SUBJECT } = process.env; 25 | 26 | if (!RESEND_EMAIL_FROM || !OTP_SUBJECT) { 27 | return { 28 | error: "Configuração de ambiente insuficiente para envio de e-mail.", 29 | }; 30 | } 31 | 32 | const { email } = user; 33 | try { 34 | const { error } = await mail.emails.send({ 35 | from: RESEND_EMAIL_FROM, 36 | to: email, 37 | subject: OTP_SUBJECT, 38 | html: `

Sue código OTP: ${token}

`, 39 | }); 40 | 41 | if (error) 42 | return { 43 | error, 44 | }; 45 | return { 46 | success: "E-mail enviado com sucesso", 47 | }; 48 | } catch (error) { 49 | return { error }; 50 | } 51 | }; 52 | 53 | /** 54 | * This method updates the user's record with the date and time the 55 | * Two Factor Authentication was verified 56 | * @param token 57 | * @returns 58 | */ 59 | export const verifyTwoFactorToken = async (token: string) => { 60 | const existingToken = await findTwoFactorAuthTokeByToken(token); 61 | if (!existingToken) { 62 | return { 63 | error: "Código de verificação não encontrado", 64 | }; 65 | } 66 | 67 | const isTokenExpired = new Date(existingToken.expires) < new Date(); 68 | if (isTokenExpired) { 69 | return { 70 | error: "Código de verificação expirado", 71 | }; 72 | } 73 | 74 | const user = await findUserbyEmail(existingToken.email); 75 | if (!user) { 76 | return { 77 | error: "Usuário não encontrado", 78 | }; 79 | } 80 | 81 | try { 82 | await prisma.user.update({ 83 | where: { id: user.id }, 84 | data: { 85 | twoFactorAuthVerified: new Date(), 86 | }, 87 | }); 88 | 89 | await prisma.twoFactorToken.delete({ 90 | where: { 91 | id: existingToken.id, 92 | }, 93 | }); 94 | 95 | return { 96 | success: "Autênticação de dois fatores verificada", 97 | }; 98 | } catch (err) { 99 | return { error: "Erro ao verificar o código de autenticação de 2 fatores" }; 100 | } 101 | }; 102 | -------------------------------------------------------------------------------- /actions/onboarding/index.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import { auth, update } from "@/auth"; 3 | import { prisma } from "@/lib/db"; 4 | import { OrgSchema } from "@/schemas/onboarding"; 5 | import type { z } from "zod"; 6 | 7 | /** 8 | * This method creates the org for the user 9 | * @param {z.infer} org - The new org data. 10 | * @returns {Promise<{error?: string, success?: string}>} The result of the password change request. 11 | */ 12 | export const createOrg = async (org: z.infer) => { 13 | const session = await auth(); 14 | const valid = await OrgSchema.safeParse(org); 15 | 16 | if (!valid.success) { 17 | return { 18 | error: "Dados inválidos", 19 | }; 20 | } 21 | 22 | if (!session || !session.user || !session.user.id) { 23 | return { 24 | error: "Usuário deve estar autenticado para executar esta operação", 25 | }; 26 | } 27 | try { 28 | const { name, image } = valid.data; 29 | //TODO: Define the place to save the image 30 | const org = await prisma.org.create({ 31 | data: { 32 | name, 33 | owner: session?.user.id, 34 | }, 35 | }); 36 | await update({ 37 | user: { 38 | ...session.user, 39 | orgId: org.id, 40 | }, 41 | }); 42 | return { 43 | success: "Org criada com sucesso", 44 | }; 45 | } catch (error) { 46 | return { 47 | error: "Não foi possível criar a org", 48 | }; 49 | // if (error instanceof PrismaClientKnownRequestError) { 50 | // if (error.code === "P2002") { 51 | // return { 52 | // error: "Já existe uma conta relacionada a este e-mail.", 53 | // }; 54 | // } 55 | // } 56 | // return { error }; 57 | // throw error; 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /app/(site)/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@/auth"; 2 | import Navbar from "@/components/site/navbar"; 3 | import { HandCoins, Twitch, Youtube } from "lucide-react"; 4 | 5 | export default async function Home() { 6 | return ( 7 |
8 |
9 | 10 |
11 |
12 |
13 |
14 |
15 | 21 | 22 | Novidades 23 | {" "} 24 | Auth Starter-Kit 25 | 33 | 38 | 39 | 40 |

41 | 42 | Pronto para autenticar seu novo projeto 43 | 44 |

45 |

46 | Este Starter-Kit foi desenvolvido para poupar seu tempo. Aqui você encontra o que precisa para começar a 47 | desenvolver seu projeto com segurança. 48 |

49 | 91 | 165 |
166 |
167 |
168 |
169 |
170 | ); 171 | } 172 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | export { GET, POST } from "@/auth"; 2 | -------------------------------------------------------------------------------- /app/api/protected-api/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/auth"; 2 | import { NextResponse } from "next/server"; 3 | 4 | export const GET = auth((req) => { 5 | if (req.auth) 6 | return NextResponse.json({ 7 | message: "Usuário Autenticado", 8 | }); 9 | return NextResponse.json( 10 | { 11 | message: "Não Autenticado", 12 | }, 13 | { 14 | status: 401, 15 | }, 16 | ); 17 | }); 18 | -------------------------------------------------------------------------------- /app/auth/change-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { ChangePasswordForm } from "@/components/auth/change-password-form"; 2 | import React, { Suspense } from "react"; 3 | 4 | const ChangePassword = () => { 5 | return ( 6 |
7 | 8 | 9 | 10 |
11 | ); 12 | }; 13 | 14 | export default ChangePassword; 15 | -------------------------------------------------------------------------------- /app/auth/login/page.tsx: -------------------------------------------------------------------------------- 1 | import LoginForm from "@/components/auth/login-form"; 2 | 3 | const Login = async () => { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | }; 10 | 11 | export default Login; 12 | -------------------------------------------------------------------------------- /app/auth/register/page.tsx: -------------------------------------------------------------------------------- 1 | import RegisterForm from "@/components/auth/register-form"; 2 | 3 | const Login = async () => { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | }; 10 | 11 | export default Login; 12 | -------------------------------------------------------------------------------- /app/auth/reset-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { ResetPasswordForm } from "@/components/auth/reset-password-form"; 2 | 3 | const page = () => { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | }; 10 | 11 | export default page; 12 | -------------------------------------------------------------------------------- /app/auth/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { CircleUser, Menu, Package2, Search } from "lucide-react"; 2 | import Link from "next/link"; 3 | 4 | import { auth } from "@/auth"; 5 | import UserSettingsForm from "@/components/auth/user-settings-form"; 6 | import { Button } from "@/components/ui/button"; 7 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; 8 | import { Checkbox } from "@/components/ui/checkbox"; 9 | import { 10 | DropdownMenu, 11 | DropdownMenuContent, 12 | DropdownMenuItem, 13 | DropdownMenuLabel, 14 | DropdownMenuSeparator, 15 | DropdownMenuTrigger, 16 | } from "@/components/ui/dropdown-menu"; 17 | import { Input } from "@/components/ui/input"; 18 | import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; 19 | 20 | export default async function Settings() { 21 | const session = await auth(); 22 | return ( 23 |
24 |
25 |
26 |

Settings

27 |
28 |
29 | 36 |
37 | 38 |
39 |
40 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /app/auth/verify-email/page.tsx: -------------------------------------------------------------------------------- 1 | import EmailVerificationForm from "@/components/auth/email-verification-form"; 2 | import { Suspense } from "react"; 3 | 4 | const VerifyEmail = () => { 5 | return ( 6 |
7 | 8 | 9 | 10 |
11 | ); 12 | }; 13 | 14 | export default VerifyEmail; 15 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devdeck101/hp-saas/d465b1bf7b158e2978b97d7a6ac98e6f6fbcdfee/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body, 7 | :root { 8 | height: 100%; 9 | } 10 | 11 | @layer base { 12 | :root { 13 | --background: 0 0% 100%; 14 | --foreground: 240 10% 3.9%; 15 | --card: 0 0% 100%; 16 | --card-foreground: 240 10% 3.9%; 17 | --popover: 0 0% 100%; 18 | --popover-foreground: 240 10% 3.9%; 19 | --primary: 142.1 76.2% 36.3%; 20 | --primary-foreground: 355.7 100% 97.3%; 21 | --secondary: 240 4.8% 95.9%; 22 | --secondary-foreground: 240 5.9% 10%; 23 | --muted: 240 4.8% 95.9%; 24 | --muted-foreground: 240 3.8% 46.1%; 25 | --accent: 240 4.8% 95.9%; 26 | --accent-foreground: 240 5.9% 10%; 27 | --destructive: 346.8 77.2% 49.8%; 28 | --destructive-foreground: 0 0% 98%; 29 | --border: 240 5.9% 90%; 30 | --input: 240 5.9% 90%; 31 | --ring: 142.1 76.2% 36.3%; 32 | --radius: 0.3rem; 33 | } 34 | 35 | .dark { 36 | --background: 20 14.3% 4.1%; 37 | --foreground: 0 0% 95%; 38 | --card: 24 9.8% 10%; 39 | --card-foreground: 0 0% 95%; 40 | --popover: 0 0% 9%; 41 | --popover-foreground: 0 0% 95%; 42 | --primary: 142.1 70.6% 45.3%; 43 | --primary-foreground: 144.9 80.4% 10%; 44 | --secondary: 240 3.7% 15.9%; 45 | --secondary-foreground: 0 0% 98%; 46 | --muted: 0 0% 15%; 47 | --muted-foreground: 240 5% 64.9%; 48 | --accent: 12 6.5% 15.1%; 49 | --accent-foreground: 0 0% 98%; 50 | --destructive: 346.8 77.2% 49.8%; 51 | --destructive-foreground: 0 85.7% 97.3%; 52 | --border: 240 3.7% 15.9%; 53 | --input: 240 3.7% 15.9%; 54 | --ring: 142.4 71.8% 29.2%; 55 | } 56 | } 57 | 58 | @layer base { 59 | * { 60 | @apply border-border; 61 | } 62 | body { 63 | @apply bg-background text-foreground; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | import { auth } from "@/auth"; 5 | import { ThemeProvider } from "@/components/providers/theme-provider"; 6 | import { cn } from "@/lib/utils"; 7 | import { SessionProvider } from "next-auth/react"; 8 | 9 | const inter = Inter({ subsets: ["latin"] }); 10 | 11 | export const metadata: Metadata = { 12 | title: "Create Next App", 13 | description: "Generated by create next app", 14 | }; 15 | 16 | export default async function RootLayout({ 17 | children, 18 | }: Readonly<{ 19 | children: React.ReactNode; 20 | }>) { 21 | const session = await auth(); 22 | return ( 23 | 24 | 25 | 26 | 27 | {children} 28 | 29 | 30 | 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /app/onboarding/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from '@/auth' 2 | import OnboardingForm from '@/components/onboarding/onboarding-form' 3 | 4 | const Onboarding = async () => { 5 | 6 | return ( 7 |
8 | 9 |
10 | ) 11 | } 12 | 13 | export default Onboarding -------------------------------------------------------------------------------- /assets/dois_fatores_page.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devdeck101/hp-saas/d465b1bf7b158e2978b97d7a6ac98e6f6fbcdfee/assets/dois_fatores_page.jpg -------------------------------------------------------------------------------- /assets/forget_password_page.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devdeck101/hp-saas/d465b1bf7b158e2978b97d7a6ac98e6f6fbcdfee/assets/forget_password_page.jpg -------------------------------------------------------------------------------- /assets/landing_page.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devdeck101/hp-saas/d465b1bf7b158e2978b97d7a6ac98e6f6fbcdfee/assets/landing_page.jpg -------------------------------------------------------------------------------- /assets/light_landing_page.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devdeck101/hp-saas/d465b1bf7b158e2978b97d7a6ac98e6f6fbcdfee/assets/light_landing_page.jpg -------------------------------------------------------------------------------- /assets/login_page.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devdeck101/hp-saas/d465b1bf7b158e2978b97d7a6ac98e6f6fbcdfee/assets/login_page.jpg -------------------------------------------------------------------------------- /assets/register_page.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devdeck101/hp-saas/d465b1bf7b158e2978b97d7a6ac98e6f6fbcdfee/assets/register_page.jpg -------------------------------------------------------------------------------- /assets/resend-api-key.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devdeck101/hp-saas/d465b1bf7b158e2978b97d7a6ac98e6f6fbcdfee/assets/resend-api-key.jpg -------------------------------------------------------------------------------- /assets/wave.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devdeck101/hp-saas/d465b1bf7b158e2978b97d7a6ac98e6f6fbcdfee/assets/wave.gif -------------------------------------------------------------------------------- /auth.config.ts: -------------------------------------------------------------------------------- 1 | import bcryptjs from "bcryptjs"; 2 | import type { NextAuthConfig } from "next-auth"; 3 | import Credentials from "next-auth/providers/credentials"; 4 | import Github from "next-auth/providers/github"; 5 | import Google from "next-auth/providers/google"; 6 | import { InvalidCredentials, UserNotFound } from "./lib/auth"; 7 | import { CredentialsSchema } from "./schemas/auth"; 8 | import { findUserbyEmail } from "./services"; 9 | 10 | export default { 11 | providers: [ 12 | Credentials({ 13 | async authorize(credentials) { 14 | const validdCredentials = CredentialsSchema.safeParse(credentials); 15 | if (validdCredentials.success) { 16 | const { email, password } = validdCredentials.data; 17 | const user = await findUserbyEmail(email); 18 | if (!user || !user.password) { 19 | throw new UserNotFound(); 20 | } 21 | const validPassword = await bcryptjs.compare(password, user.password); 22 | if (validPassword) return user; 23 | } 24 | //TODO: Would it be better to throw new InvalidCredential? 25 | return null; 26 | }, 27 | }), 28 | Google, 29 | Github, 30 | ], 31 | } satisfies NextAuthConfig; 32 | -------------------------------------------------------------------------------- /auth.ts: -------------------------------------------------------------------------------- 1 | import { PrismaAdapter } from "@auth/prisma-adapter"; 2 | import { UserRole } from "@prisma/client"; 3 | import NextAuth from "next-auth"; 4 | import authConfig from "./auth.config"; 5 | import { prisma } from "./lib/db"; 6 | import { findUserbyEmail } from "./services"; 7 | import { isTwoFactorAutenticationEnabled } from "./services/auth"; 8 | import { findOrgByOwnerId } from "./services/onboarding/org"; 9 | export const { 10 | handlers: { GET, POST }, 11 | auth, 12 | signIn, 13 | signOut, 14 | unstable_update: update, 15 | } = NextAuth({ 16 | adapter: PrismaAdapter(prisma), 17 | session: { 18 | strategy: "jwt", 19 | }, 20 | pages: { 21 | signIn: "/auth/login", 22 | }, 23 | callbacks: { 24 | async signIn({ user, email, account, profile }) { 25 | if (account && (account.provider === "google" || account.provider === "github")) { 26 | return true; 27 | } 28 | if (user.email) { 29 | const registeredUser = await findUserbyEmail(user?.email); 30 | if (!registeredUser?.emailVerified) return false; 31 | } 32 | return true; 33 | }, 34 | async jwt({ token, user, trigger, session }) { 35 | if (trigger && trigger === "update" && session) { 36 | token.orgId = session.user.orgId; 37 | return token; 38 | } 39 | if (user) { 40 | // User is available during sign-in 41 | if (user.id) { 42 | const isTwoFactorEnabled = await isTwoFactorAutenticationEnabled(user?.id || ""); 43 | token.isTwoFactorEnabled = isTwoFactorEnabled; 44 | const org = await findOrgByOwnerId(user.id); 45 | token.orgId = org?.id || ""; 46 | if (org?.id) { 47 | token.role = UserRole.ADMIN; 48 | } 49 | } 50 | } 51 | return token; 52 | }, 53 | async session({ session, token }) { 54 | // `session.user.role` is now a valid property, and will be type-checked 55 | // in places like `useSession().data.user` or `auth().user` 56 | if (session.user && token.sub) { 57 | session.user.id = token.sub; 58 | session.user.isTwoFactorEnabled = token.isTwoFactorEnabled as boolean; 59 | session.user.orgId = token.orgId; 60 | } 61 | return { 62 | ...session, 63 | user: { 64 | ...session.user, 65 | role: token.role, 66 | }, 67 | }; 68 | }, 69 | }, 70 | ...authConfig, 71 | }); 72 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.7.3/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "formatter": { 7 | "indentStyle": "tab", 8 | "formatWithErrors": false, 9 | "attributePosition": "auto", 10 | "indentWidth": 2, 11 | "enabled": true, 12 | "lineWidth": 120 13 | }, 14 | "linter": { 15 | "enabled": true, 16 | "rules": { 17 | "recommended": true 18 | } 19 | }, 20 | "javascript": { 21 | "formatter": { 22 | "semicolons": "always", 23 | "arrowParentheses": "always", 24 | "bracketSameLine": false, 25 | "bracketSpacing": true, 26 | "jsxQuoteStyle": "double", 27 | "quoteProperties": "asNeeded", 28 | "trailingComma": "all" 29 | } 30 | }, 31 | "json": { 32 | "formatter": { 33 | "trailingCommas": "none" 34 | } 35 | }, 36 | "files": { 37 | "ignore": [".next/**/*", "components/ui/**/*"] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /components/auth/auth-card.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; 2 | 3 | interface AuthCardProps { 4 | title?: string; 5 | description?: string; 6 | children: React.ReactNode; 7 | } 8 | 9 | const AuthCard = ({ title, description, children }: AuthCardProps) => { 10 | return ( 11 | 12 | 13 | {title && {title}} 14 | {description && {description}} 15 | 16 | {children} 17 | 18 | ); 19 | }; 20 | 21 | export default AuthCard; 22 | -------------------------------------------------------------------------------- /components/auth/auth-form-message.tsx: -------------------------------------------------------------------------------- 1 | import { AlertCircle, CheckCircle } from "lucide-react"; 2 | 3 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 4 | 5 | interface AuthFormMessageProps { 6 | title?: string; 7 | message: string; 8 | type: "success" | "error"; 9 | } 10 | const AuthFormMessage = ({ message, type, title }: AuthFormMessageProps) => { 11 | return ( 12 | 13 | {type === "success" ? : } 14 | {title && {title}} 15 | {message} 16 | 17 | ); 18 | }; 19 | 20 | export default AuthFormMessage; 21 | -------------------------------------------------------------------------------- /components/auth/change-password-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { zodResolver } from "@hookform/resolvers/zod"; 4 | import { useState, useTransition } from "react"; 5 | import { useForm } from "react-hook-form"; 6 | import type * as z from "zod"; 7 | 8 | import { changePassword, resetPassword } from "@/actions/auth"; 9 | import { Button } from "@/components/ui/button"; 10 | import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; 11 | import { Input } from "@/components/ui/input"; 12 | import { NewPasswordSchema } from "@/schemas/auth"; 13 | import { LoaderIcon } from "lucide-react"; 14 | import Link from "next/link"; 15 | import { useSearchParams } from "next/navigation"; 16 | import AuthCard from "./auth-card"; 17 | import AuthFormMessage from "./auth-form-message"; 18 | 19 | export const ChangePasswordForm = () => { 20 | const [error, setError] = useState(""); 21 | const [success, setSuccess] = useState(""); 22 | const [isPending, startTransition] = useTransition(); 23 | const searchParam = useSearchParams(); 24 | const token = searchParam.get("token"); 25 | 26 | const form = useForm>({ 27 | resolver: zodResolver(NewPasswordSchema), 28 | defaultValues: { 29 | password: "", 30 | }, 31 | }); 32 | 33 | const onSubmit = (values: z.infer) => { 34 | setError(""); 35 | setSuccess(""); 36 | 37 | startTransition(async () => { 38 | try { 39 | const { success, error } = await changePassword(values, token); 40 | if (error) setError(error); 41 | setSuccess(success || ""); 42 | form.reset(); 43 | } catch (err) { 44 | setSuccess(""); 45 | setError("Algo deu errado."); 46 | form.reset(); 47 | } 48 | }); 49 | }; 50 | 51 | return ( 52 | 53 |
54 | 55 |
56 | ( 60 | 61 | Nova senha 62 | 63 | 64 | 65 | 66 | 67 | )} 68 | /> 69 |
70 | {error && } 71 | {success && } 72 | 73 | 77 | 78 | 79 |
80 | Gostaria de conectar-se?{" "} 81 | 82 | Conectar agora 83 | 84 |
85 |
86 | ); 87 | }; 88 | -------------------------------------------------------------------------------- /components/auth/email-verification-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { verifyToken } from "@/actions/auth"; 3 | import { useSearchParams } from "next/navigation"; 4 | import React, { Suspense, useCallback, useEffect, useState } from "react"; 5 | import AuthCard from "./auth-card"; 6 | import AuthFormMessage from "./auth-form-message"; 7 | 8 | const EmailVerificationForm = () => { 9 | const [error, setError] = useState(undefined); 10 | const [success, setSuccess] = useState(undefined); 11 | const searchParam = useSearchParams(); 12 | const token = searchParam.get("token"); 13 | 14 | const automaticSubmission = useCallback(() => { 15 | if (error || success) return; 16 | 17 | if (!token) { 18 | setError("Token inválido"); 19 | return; 20 | } 21 | 22 | verifyToken(token) 23 | .then((data) => { 24 | setSuccess(data.success); 25 | setError(data.error); 26 | }) 27 | .catch(() => { 28 | setError("Algo deu errado"); 29 | }); 30 | }, [token, success, error]); 31 | 32 | useEffect(() => { 33 | automaticSubmission(); 34 | }, [automaticSubmission]); 35 | return ( 36 |
37 | 38 | {success && } 39 | {error && } 40 | 41 |
42 | ); 43 | }; 44 | 45 | export default EmailVerificationForm; 46 | -------------------------------------------------------------------------------- /components/auth/login-badge.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | DropdownMenu, 6 | DropdownMenuContent, 7 | DropdownMenuItem, 8 | DropdownMenuLabel, 9 | DropdownMenuSeparator, 10 | DropdownMenuTrigger, 11 | } from "@/components/ui/dropdown-menu"; 12 | import { CircleUser, LogOut } from "lucide-react"; 13 | import type { User } from "next-auth"; 14 | import Link from "next/link"; 15 | import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; 16 | import LoginButton from "./login-button"; 17 | import LogoutButton from "./logout-button"; 18 | import { LineMdCogLoop } from "../icons"; 19 | 20 | 21 | type Props = { 22 | user?: User; 23 | }; 24 | 25 | const LoginBadge = ({ user }: Props) => { 26 | return ( 27 | <> 28 | {user && ( 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | Minha Conta 40 | 41 | 42 | 43 | Perfil 44 | 45 | 46 | 47 | 48 | 49 | 52 | 53 | 54 | 55 | 56 | )} 57 | { 58 | !user && ( 59 | 60 | 61 | 62 | ) 63 | } 64 | 65 | ); 66 | }; 67 | 68 | export default LoginBadge; 69 | -------------------------------------------------------------------------------- /components/auth/login-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { signIn } from "next-auth/react"; 4 | import type { ReactNode } from "react"; 5 | 6 | type Props = { 7 | children?: ReactNode; 8 | }; 9 | 10 | const LoginButton = ({ children }: Props) => { 11 | return ( 12 | // biome-ignore lint: reason 13 |
{ 15 | signIn(); 16 | }} 17 | > 18 | {children} 19 |
20 | ); 21 | }; 22 | 23 | export default LoginButton; 24 | -------------------------------------------------------------------------------- /components/auth/login-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { useState, useTransition } from "react"; 5 | 6 | import { Button } from "@/components/ui/button"; 7 | import { Input } from "@/components/ui/input"; 8 | import AuthCard from "./auth-card"; 9 | 10 | import { zodResolver } from "@hookform/resolvers/zod"; 11 | import { useForm } from "react-hook-form"; 12 | import type { z } from "zod"; 13 | 14 | import { login } from "@/actions/auth"; 15 | import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; 16 | import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@/components/ui/input-otp"; 17 | import { CredentialsSchema } from "@/schemas/auth"; 18 | import { LoaderIcon } from "lucide-react"; 19 | import { useSearchParams } from "next/navigation"; 20 | import { Separator } from "../ui/separator"; 21 | import AuthFormMessage from "./auth-form-message"; 22 | import SocialLogin from "./social-login"; 23 | 24 | export default function LoginForm() { 25 | const [isPending, startTransition] = useTransition(); 26 | const [error, setError] = useState(""); 27 | const [success, setSuccess] = useState(""); 28 | const [showOTPForm, setShowOTP] = useState(false); 29 | const searchParams = useSearchParams(); 30 | const callbackError = 31 | searchParams.get("error") === "OAuthAccountNotLinked" ? "E-mail em uso com provedor diferente" : undefined; 32 | const form = useForm>({ 33 | resolver: zodResolver(CredentialsSchema), 34 | defaultValues: { 35 | email: "", 36 | password: "", 37 | }, 38 | }); 39 | 40 | const onSubmit = async (values: z.infer) => { 41 | startTransition(async () => { 42 | try { 43 | const resp = await login(values); 44 | 45 | if (!resp) { 46 | setError("Resposta inválida do servidor"); 47 | setSuccess(""); 48 | form.reset(); 49 | return; 50 | } 51 | 52 | const { error, success, data } = resp; 53 | 54 | if (data?.twoFactorAuthEnabled) { 55 | setShowOTP(true); 56 | if (resp.error) { 57 | setError(resp.error); 58 | setSuccess(""); 59 | return; 60 | } 61 | return; 62 | } 63 | 64 | if (error) { 65 | setError(resp.error); 66 | setSuccess(""); 67 | form.reset(); 68 | return; 69 | } 70 | if (success) { 71 | setSuccess(resp.success); 72 | setError(""); 73 | return; 74 | } 75 | 76 | form.reset(); 77 | } catch (err) { 78 | setError("Algo deu errado"); 79 | setSuccess(""); 80 | form.reset(); 81 | } 82 | }); 83 | }; 84 | 85 | return ( 86 | 87 |
88 |
89 | 90 | {!showOTPForm && ( 91 |
92 | ( 96 | 97 | E-mail 98 | 99 | 106 | 107 | Seu e-mail. 108 | 109 | 110 | )} 111 | /> 112 | ( 116 | 117 | Senha 118 | 119 |
120 | 121 |
122 | 126 | Esqueceu a senha? 127 | 128 |
129 |
130 |
131 | Seu e-mail. 132 | 133 |
134 | )} 135 | /> 136 | {callbackError && } 137 | {error && } 138 | {success && } 139 | 143 |
144 | )} 145 | {showOTPForm && ( 146 |
147 | ( 151 | 152 | Código 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | Favor entrar com o códio enviado por e-mail 168 | 169 | 170 | )} 171 | /> 172 | {error && } 173 | 177 |
178 | )} 179 |
180 | 181 | 182 | 183 | 184 | 185 | {!showOTPForm && ( 186 |
187 | Não tem uma conta?{" "} 188 | 189 | Cadastre-se 190 | 191 |
192 | )} 193 | {showOTPForm && ( 194 |
195 | Conectar agora?{" "} 196 | 197 | Conectar 198 | 199 |
200 | )} 201 |
202 |
203 | ); 204 | } 205 | -------------------------------------------------------------------------------- /components/auth/login-social-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { signIn } from "next-auth/react"; 5 | import type { ReactNode } from "react"; 6 | 7 | type Props = { 8 | provider: "google" | "github"; 9 | callbackUrl?: string; 10 | children?: ReactNode; 11 | }; 12 | 13 | const LoginSocialButton = ({ children, provider, callbackUrl }: Props) => { 14 | return ( 15 | // biome-ignore lint: TODO: Need to implement key stroke shortcuts 16 | 25 | ); 26 | }; 27 | 28 | export default LoginSocialButton; 29 | -------------------------------------------------------------------------------- /components/auth/logout-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { signOut } from "next-auth/react"; 4 | import type { ReactNode } from "react"; 5 | 6 | type Props = { 7 | children?: ReactNode; 8 | }; 9 | 10 | const LogoutButton = ({ children }: Props) => { 11 | return ( 12 | // biome-ignore lint: reason 13 |
{ 15 | await signOut(); 16 | }} 17 | > 18 | {children} 19 |
20 | ); 21 | }; 22 | 23 | export default LogoutButton; 24 | -------------------------------------------------------------------------------- /components/auth/register-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { Input } from "@/components/ui/input"; 5 | import { zodResolver } from "@hookform/resolvers/zod"; 6 | import { LoaderIcon } from "lucide-react"; 7 | import Link from "next/link"; 8 | import { useRouter } from "next/navigation"; 9 | import { useState, useTransition } from "react"; 10 | import { useForm } from "react-hook-form"; 11 | import type { z } from "zod"; 12 | 13 | import { register } from "@/actions/auth"; 14 | import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; 15 | import { RegisterSchema } from "@/schemas/auth"; 16 | import AuthCard from "./auth-card"; 17 | import AuthFormMessage from "./auth-form-message"; 18 | 19 | export default function RegisterForm() { 20 | const router = useRouter(); 21 | const [isPending, startTransition] = useTransition(); 22 | const [error, setError] = useState(""); 23 | const [success, setSuccess] = useState(""); 24 | const form = useForm>({ 25 | resolver: zodResolver(RegisterSchema), 26 | defaultValues: { 27 | name: "", 28 | email: "", 29 | password: "", 30 | }, 31 | }); 32 | 33 | const onSubmit = async (values: z.infer) => { 34 | startTransition(async () => { 35 | try { 36 | const { success, error } = await register(values); 37 | if (error) setError(error); 38 | setSuccess(success || ""); 39 | form.reset(); 40 | } catch (error) { 41 | setSuccess(""); 42 | setError("Algo deu errado."); 43 | form.reset(); 44 | } 45 | }); 46 | }; 47 | 48 | return ( 49 | 50 |
51 |
52 | 53 |
54 | ( 58 | 59 | Name 60 | 61 | 69 | 70 | Seu nome. 71 | 72 | 73 | )} 74 | /> 75 | ( 79 | 80 | E-mail 81 | 82 | 83 | 84 | Seu e-mail. 85 | 86 | 87 | )} 88 | /> 89 | ( 93 | 94 | Senha 95 | 96 | 97 | 98 | Seu e-mail. 99 | 100 | 101 | )} 102 | /> 103 | {error && } 104 | {success && } 105 | 109 |
110 |
111 | 112 | 113 |
114 | Já tem uma conta?{" "} 115 | 116 | Efetue Login 117 | 118 |
119 |
120 |
121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /components/auth/reset-password-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { zodResolver } from "@hookform/resolvers/zod"; 4 | import { useState, useTransition } from "react"; 5 | import { useForm } from "react-hook-form"; 6 | import type * as z from "zod"; 7 | 8 | import { resetPassword } from "@/actions/auth"; 9 | import { Button } from "@/components/ui/button"; 10 | import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; 11 | import { Input } from "@/components/ui/input"; 12 | import { ResetPasswordSchema } from "@/schemas/auth"; 13 | import { LoaderIcon } from "lucide-react"; 14 | import Link from "next/link"; 15 | import AuthCard from "./auth-card"; 16 | import AuthFormMessage from "./auth-form-message"; 17 | 18 | export const ResetPasswordForm = () => { 19 | const [error, setError] = useState(""); 20 | const [success, setSuccess] = useState(""); 21 | const [isPending, startTransition] = useTransition(); 22 | 23 | const form = useForm>({ 24 | resolver: zodResolver(ResetPasswordSchema), 25 | defaultValues: { 26 | email: "", 27 | }, 28 | }); 29 | 30 | const onSubmit = (values: z.infer) => { 31 | setError(""); 32 | setSuccess(""); 33 | 34 | startTransition(async () => { 35 | try { 36 | const { success, error } = await resetPassword(values); 37 | if (error) setError(error); 38 | setSuccess(success || ""); 39 | form.reset(); 40 | } catch (err) { 41 | setSuccess(""); 42 | setError("Algo deu errado."); 43 | form.reset(); 44 | } 45 | }); 46 | }; 47 | 48 | return ( 49 | 50 |
51 | 52 |
53 | ( 57 | 58 | Email 59 | 60 | 61 | 62 | 63 | 64 | )} 65 | /> 66 |
67 | {error && } 68 | {success && } 69 | 70 | 74 | 75 | 76 |
77 | Gostaria de conectar-se?{" "} 78 | 79 | Conectar agora 80 | 81 |
82 |
83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /components/auth/social-login.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import React from "react"; 3 | import LoginSocialButton from "./login-social-button"; 4 | 5 | const SocialLogin = () => { 6 | const callbackUrl = `${process.env.NEXT_PUBLIC_URL}`; 7 | return ( 8 |
9 | 10 | 11 | Google 12 | 16 | 20 | 24 | 28 | 32 | 33 | 34 | 35 | 36 | Github 37 | 41 | 42 | 43 |
44 | ); 45 | }; 46 | 47 | export default SocialLogin; 48 | -------------------------------------------------------------------------------- /components/auth/user-settings-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { changeSettings } from "@/actions/auth/settings"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; 6 | import { Input } from "@/components/ui/input"; 7 | import { UserSettingsSchema } from "@/schemas/auth"; 8 | import { zodResolver } from "@hookform/resolvers/zod"; 9 | import { LoaderIcon, ShieldAlert } from "lucide-react"; 10 | import type { User } from "next-auth"; 11 | import { useSession } from "next-auth/react"; 12 | import Link from "next/link"; 13 | import { useState, useTransition } from "react"; 14 | import { useForm } from "react-hook-form"; 15 | import type { z } from "zod"; 16 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "../ui/card"; 17 | import { Separator } from "../ui/separator"; 18 | import { Switch } from "../ui/switch"; 19 | import AuthFormMessage from "./auth-form-message"; 20 | 21 | interface Props { 22 | user?: User; 23 | } 24 | export default function UserSettingsForm({ user }: Props) { 25 | const { update } = useSession(); 26 | const [isPending, startTransition] = useTransition(); 27 | const [error, setError] = useState(""); 28 | const [success, setSuccess] = useState(""); 29 | const form = useForm>({ 30 | resolver: zodResolver(UserSettingsSchema), 31 | defaultValues: { 32 | name: user?.name || undefined, 33 | email: user?.email || undefined, 34 | password: undefined, 35 | newPassword: undefined, 36 | //@ts-ignore 37 | isTwoFactorAuthEnabled: !!user?.isTwoFactorEnabled, 38 | }, 39 | }); 40 | 41 | const onSubmit = async (values: z.infer) => { 42 | startTransition(async () => { 43 | try { 44 | const resp = await changeSettings(values); 45 | const { success, error } = resp; 46 | if (!resp) { 47 | setError("Resposta inválida do servidor"); 48 | setSuccess(""); 49 | form.reset(); 50 | return; 51 | } 52 | 53 | if (error) { 54 | setError(error); 55 | setSuccess(""); 56 | return; 57 | } 58 | if (success) { 59 | setSuccess(success); 60 | setError(""); 61 | update(); 62 | return; 63 | } 64 | } catch (error) { 65 | setSuccess(""); 66 | setError("Algo deu errado."); 67 | form.reset(); 68 | } 69 | }); 70 | }; 71 | 72 | return ( 73 | 74 | 75 | Dados do Usuário 76 | Suas informações 77 | 78 | 79 |
80 |
81 | 82 |
83 | ( 87 | 88 | Name 89 | 90 | 97 | 98 | Seu nome. 99 | 100 | 101 | )} 102 | /> 103 | ( 107 | 108 | E-mail 109 | 110 | 111 | 112 | Seu e-mail. 113 | 114 | 115 | )} 116 | /> 117 | ( 121 | 122 | Senha 123 | 124 | 125 | 126 | Seu e-mail. 127 | 128 | 129 | )} 130 | /> 131 | ( 135 | 136 | Nova senha 137 | 138 | 139 | 140 | Seu e-mail. 141 | 142 | 143 | )} 144 | /> 145 | 146 | ( 150 | 151 | 152 | 153 |

Autenticação de 2 Fatores

154 |

Deixe sua conta mais segura

155 |
156 | 157 | 158 | 159 |
160 | )} 161 | /> 162 | 163 | {error && } 164 | {success && } 165 | 166 |
167 | 171 |
172 |
173 |
174 | 175 | 176 |
177 | 178 | Página Inicial 179 | 180 |
181 |
182 |
183 |
184 | ); 185 | } 186 | -------------------------------------------------------------------------------- /components/auth/verification-email-template.tsx: -------------------------------------------------------------------------------- 1 | interface VerificationEmailTemplateProps { 2 | name: string; 3 | token: string; 4 | } 5 | 6 | export const VerificationEmailTemplate: React.FC> = ({ name, token }) => { 7 | const verificationUrl = `${process.env.NEXT_PUBLIC_URL}${process.env.RESEND_VERIFICATION_URL}?token=${token}`; 8 | return ( 9 |
10 |

Seja bem vindo ${name},

11 |

12 | Para verificar sua conta, favor clicar{" "} 13 | 14 | aqui 15 | 16 |

17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /components/icons/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { SVGProps } from 'react'; 3 | 4 | export function FileUploadIcon(props: SVGProps) { 5 | return ( 6 | 18 | File Upload 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | export function LineMdCogLoop(props: SVGProps) { 27 | return ( 28 | LineMdCogLoop 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | ); 53 | } -------------------------------------------------------------------------------- /components/onboarding/mail-address-form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card" 3 | import { Label } from "@/components/ui/label" 4 | import { FileUploadIcon } from "../icons" 5 | 6 | import type { z } from "zod"; 7 | import { Button } from "@/components/ui/button" 8 | import { 9 | Form, 10 | FormControl, 11 | FormDescription, 12 | FormField, 13 | FormItem, 14 | FormLabel, 15 | FormMessage, 16 | } from "@/components/ui/form" 17 | import { Input } from "@/components/ui/input" 18 | 19 | 20 | 21 | import { FileInput, FileUploader, FileUploaderContent, FileUploaderItem } from "../ui/extension/file-uploader" 22 | import { useState } from "react"; 23 | import { useFormContext } from "react-hook-form"; 24 | 25 | 26 | export default function OrgForm() { 27 | const form = useFormContext() 28 | // const [error, setError] = useState("") 29 | // const [success, setSuccess] = useState("") 30 | const [files, setFiles] = useState(null); 31 | 32 | const dropZoneConfig = { 33 | maxFiles: 1, 34 | maxSize: 1024 * 1024 * 4, 35 | multiple: true, 36 | }; 37 | 38 | 39 | 40 | 41 | 42 | return ( 43 | 44 | 45 | 46 |
47 | ( 51 | 52 | Name 53 | 54 | 55 | 56 | 57 | Nomeie a sua Organização 58 | 59 | 60 | 61 | )} 62 | /> 63 |
64 |
65 | 66 |
67 | 68 |
69 | 75 | 76 |
77 | 78 | Arraste a imagem aqui ou clique para selecionar 79 |
80 |
81 | 82 | {files && 83 | files.length > 0 && 84 | files.map((file, i) => ( 85 | 86 | {file.name} URL.revokeObjectURL(URL.createObjectURL(file))} 90 | className="size-20 p-0 object-contain" /> 91 | 92 | 93 | ))} 94 | 95 | 96 |
97 |
98 |
99 | {/* {error && (
{error}
)} 100 | {success && (
{success}
)} */} 101 | {/*
102 | 105 |
*/} 106 |
107 | 108 | 109 |
110 | 111 | ) 112 | } 113 | 114 | -------------------------------------------------------------------------------- /components/onboarding/multi-step-nav-buttons.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { Button } from "@/components/ui/button"; 5 | 6 | 7 | 8 | 9 | 10 | interface MultiStepNavButtonsProps extends React.HTMLAttributes { 11 | currentStep?: number 12 | previousLabel: string; 13 | nextLabel: string; 14 | isFirstStep: boolean; 15 | isLastStep: boolean; 16 | previousStep: () => void; 17 | nextStep: () => void; 18 | debug?: boolean; 19 | } 20 | 21 | const MultiStepNavButtons = ({ className, ...props }: MultiStepNavButtonsProps) => { 22 | const { currentStep, previousLabel, nextLabel, isFirstStep, isLastStep, previousStep, nextStep, debug } = props 23 | return ( 24 |
25 | {debug && (
{`Current Step: ${currentStep}`}
)} 26 | 32 | 38 | 39 |
40 | ) 41 | } 42 | 43 | export default MultiStepNavButtons -------------------------------------------------------------------------------- /components/onboarding/multi-step-navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { buttonVariants } from "../ui/button"; 5 | import { usePathname } from "next/navigation"; 6 | 7 | interface MultiStepNavBarProps extends React.HTMLAttributes { 8 | items: { 9 | title: string 10 | }[] 11 | } 12 | 13 | const MultiStepNavbar = ({ className, items, ...props }: MultiStepNavBarProps) => { 14 | const pathname = usePathname() 15 | return ( 16 |
    23 | 24 | {items.map((item) => ( 25 |
  • 34 | {item.title} 35 |
  • 36 | 37 | ))} 38 | 39 |
40 | ) 41 | } 42 | 43 | export default MultiStepNavbar -------------------------------------------------------------------------------- /components/onboarding/multi-step-wrapper.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { motion } from "framer-motion" 3 | 4 | 5 | 6 | 7 | 8 | type Props = { 9 | title: string; 10 | description: string; 11 | children: React.ReactNode; 12 | } 13 | 14 | const container = { 15 | hidden: { 16 | opacity: 0, 17 | x: -80 18 | }, 19 | visible: { 20 | opacity: 1, 21 | x: 0, 22 | transition: { 23 | delay: 0.5, 24 | ease: "linear", 25 | type: "spring", stiffness: 100 26 | } 27 | }, 28 | exit: { 29 | opacity: 0, 30 | x: 80, 31 | transition: { 32 | ease: "easeOut" 33 | } 34 | } 35 | 36 | } 37 | 38 | const MultiStepWrapper = ({ title, description, children }: Props) => { 39 | 40 | return ( 41 | 48 |
49 |

{title}

50 |

{description}

51 |
52 |
53 | {children} 54 |
55 |
56 | ) 57 | } 58 | 59 | export default MultiStepWrapper -------------------------------------------------------------------------------- /components/onboarding/onboarding-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { startTransition, useContext, useState } from 'react' 3 | import MultiStepWrapper from './multi-step-wrapper' 4 | 5 | 6 | import { useMultiStepForm } from '@/hooks/multi-step-form/useMultiStepForm' 7 | 8 | import OrgForm from './org-form'; 9 | import { OnboardingFormContext, OnboardingFormProvider } from '@/lib/context/onboarding/store'; 10 | import { useForm, FormProvider } from "react-hook-form" 11 | import { zodResolver } from "@hookform/resolvers/zod" 12 | import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card" 13 | import { 14 | Form, 15 | 16 | } from "@/components/ui/form" 17 | import { OnboardingSchema } from '@/schemas/onboarding'; 18 | import type { z } from 'zod'; 19 | import { createOrg } from '@/actions/onboarding'; 20 | import MultiStepNavbar from './multi-step-navbar'; 21 | import MultiStepNavButtons from './multi-step-nav-buttons'; 22 | 23 | 24 | const initialData = { 25 | name: "", 26 | image: "", 27 | owner: "" 28 | } 29 | const formSteps = [{ title: "Step 1" }, { title: "Step 2" }, { title: "Step 3" }] 30 | const OnboardingForm = () => { 31 | const [error, setError] = useState("") 32 | const [success, setSuccess] = useState("") 33 | const { name, image, owner } = useContext(OnboardingFormContext) 34 | const [currentStep, 35 | steps, 36 | goToStep, 37 | nextStep, 38 | previousStep, 39 | isFirstStep, 40 | isLastStep] = useMultiStepForm(formSteps.length) 41 | 42 | const form = useForm>({ 43 | resolver: zodResolver(OnboardingSchema), 44 | defaultValues: { 45 | name: name || "", 46 | image: image || "" 47 | } 48 | }) 49 | 50 | const onSubmit = async (values: z.infer) => { 51 | startTransition(async () => { 52 | try { 53 | const { success, error } = await createOrg(values); 54 | if (error) setError(error); 55 | setSuccess(success || ""); 56 | form.reset(); 57 | } catch (error) { 58 | setSuccess(""); 59 | setError("Algo deu errado."); 60 | form.reset(); 61 | } 62 | }); 63 | } 64 | return ( 65 | 66 | 67 | 68 | 69 | 70 | 71 | Dados da Org 72 | Preencha as informações referentes a sua organização. 73 | 74 |
75 | 76 | 77 | 78 | 79 | 80 | 81 | 91 | 92 | 93 |
94 |
95 |
96 |
97 | ) 98 | } 99 | 100 | export default OnboardingForm; -------------------------------------------------------------------------------- /components/onboarding/org-form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card" 3 | import { Label } from "@/components/ui/label" 4 | import { FileUploadIcon } from "../icons" 5 | 6 | import type { z } from "zod"; 7 | import { Button } from "@/components/ui/button" 8 | import { 9 | Form, 10 | FormControl, 11 | FormDescription, 12 | FormField, 13 | FormItem, 14 | FormLabel, 15 | FormMessage, 16 | } from "@/components/ui/form" 17 | import { Input } from "@/components/ui/input" 18 | 19 | 20 | 21 | import { FileInput, FileUploader, FileUploaderContent, FileUploaderItem } from "../ui/extension/file-uploader" 22 | import { useState } from "react"; 23 | import { useFormContext } from "react-hook-form"; 24 | 25 | 26 | export default function OrgForm() { 27 | const form = useFormContext() 28 | // const [error, setError] = useState("") 29 | // const [success, setSuccess] = useState("") 30 | const [files, setFiles] = useState(null); 31 | 32 | const dropZoneConfig = { 33 | maxFiles: 1, 34 | maxSize: 1024 * 1024 * 4, 35 | multiple: true, 36 | }; 37 | 38 | 39 | 40 | 41 | 42 | return ( 43 | 44 | 45 | 46 |
47 | ( 51 | 52 | Name 53 | 54 | 55 | 56 | 57 | Nomeie a sua Organização 58 | 59 | 60 | 61 | )} 62 | /> 63 |
64 |
65 | 66 |
67 | 68 |
69 | 75 | 76 |
77 | 78 | Arraste a imagem aqui ou clique para selecionar 79 |
80 |
81 | 82 | {files && 83 | files.length > 0 && 84 | files.map((file, i) => ( 85 | 86 | {file.name} URL.revokeObjectURL(URL.createObjectURL(file))} 90 | className="size-20 p-0 object-contain" /> 91 | 92 | 93 | ))} 94 | 95 | 96 |
97 |
98 |
99 | {/* {error && (
{error}
)} 100 | {success && (
{success}
)} */} 101 | {/*
102 | 105 |
*/} 106 |
107 | 108 | 109 |
110 | 111 | ) 112 | } 113 | 114 | -------------------------------------------------------------------------------- /components/providers/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 4 | import type { ThemeProviderProps } from "next-themes/dist/types"; 5 | import * as React from "react"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /components/site/navbar.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@/auth"; 2 | import LoginBadge from "@/components/auth/login-badge"; 3 | import { Input } from "@/components/ui/input"; 4 | import { Fingerprint, Search } from "lucide-react"; 5 | import Link from "next/link"; 6 | import { ThemeToggle } from "../theme-toggle"; 7 | 8 | const Navbar = async () => { 9 | const session = await auth(); 10 | return ( 11 | <> 12 | 33 |
34 |
35 |
36 | 37 | 42 |
43 |
44 | 45 | 46 |
47 | 48 | ); 49 | }; 50 | 51 | export default Navbar; 52 | -------------------------------------------------------------------------------- /components/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Moon, Sun } from "lucide-react"; 4 | import { useTheme } from "next-themes"; 5 | import * as React from "react"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu"; 14 | 15 | export function ThemeToggle() { 16 | const { setTheme } = useTheme(); 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme("light")}>Light 29 | setTheme("dark")}>Dark 30 | setTheme("system")}>System 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 13 | error: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | success: "border-emerald-500 text-emerald-700 dark:border-emerald-100 [&>svg]:text-emerald-600", 15 | }, 16 | }, 17 | defaultVariants: { 18 | variant: "default", 19 | }, 20 | }, 21 | ) 22 | 23 | const Alert = React.forwardRef< 24 | HTMLDivElement, 25 | React.HTMLAttributes & VariantProps 26 | >(({ className, variant, ...props }, ref) => ( 27 |
28 | )) 29 | Alert.displayName = "Alert" 30 | 31 | const AlertTitle = React.forwardRef>( 32 | ({ className, ...props }, ref) => ( 33 |
34 | ), 35 | ) 36 | AlertTitle.displayName = "AlertTitle" 37 | 38 | const AlertDescription = React.forwardRef>( 39 | ({ className, ...props }, ref) => ( 40 |
41 | ), 42 | ) 43 | AlertDescription.displayName = "AlertDescription" 44 | 45 | export { Alert, AlertTitle, AlertDescription } 46 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 17 | )) 18 | Avatar.displayName = AvatarPrimitive.Root.displayName 19 | 20 | const AvatarImage = React.forwardRef< 21 | React.ElementRef, 22 | React.ComponentPropsWithoutRef 23 | >(({ className, ...props }, ref) => ( 24 | 25 | )) 26 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 27 | 28 | const AvatarFallback = React.forwardRef< 29 | React.ElementRef, 30 | React.ComponentPropsWithoutRef 31 | >(({ className, ...props }, ref) => ( 32 | 37 | )) 38 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 39 | 40 | export { Avatar, AvatarImage, AvatarFallback } 41 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 12 | secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 13 | destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 14 | outline: "text-foreground", 15 | }, 16 | }, 17 | defaultVariants: { 18 | variant: "default", 19 | }, 20 | }, 21 | ) 22 | 23 | export interface BadgeProps extends React.HTMLAttributes, VariantProps {} 24 | 25 | function Badge({ className, variant, ...props }: BadgeProps) { 26 | return
27 | } 28 | 29 | export { Badge, badgeVariants } 30 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", 14 | outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 15 | secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", 16 | ghost: "hover:bg-accent hover:text-accent-foreground", 17 | link: "text-primary underline-offset-4 hover:underline", 18 | }, 19 | size: { 20 | default: "h-10 px-4 py-2", 21 | sm: "h-9 rounded-md px-3", 22 | lg: "h-11 rounded-md px-8", 23 | icon: "h-10 w-10", 24 | }, 25 | }, 26 | defaultVariants: { 27 | variant: "default", 28 | size: "default", 29 | }, 30 | }, 31 | ) 32 | 33 | export interface ButtonProps 34 | extends React.ButtonHTMLAttributes, 35 | VariantProps { 36 | asChild?: boolean 37 | } 38 | 39 | const Button = React.forwardRef( 40 | ({ className, variant, size, asChild = false, ...props }, ref) => { 41 | const Comp = asChild ? Slot : "button" 42 | return 43 | }, 44 | ) 45 | Button.displayName = "Button" 46 | 47 | export { Button, buttonVariants } 48 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef>(({ className, ...props }, ref) => ( 6 |
7 | )) 8 | Card.displayName = "Card" 9 | 10 | const CardHeader = React.forwardRef>( 11 | ({ className, ...props }, ref) => ( 12 |
13 | ), 14 | ) 15 | CardHeader.displayName = "CardHeader" 16 | 17 | const CardTitle = React.forwardRef>( 18 | ({ className, ...props }, ref) => ( 19 |

20 | ), 21 | ) 22 | CardTitle.displayName = "CardTitle" 23 | 24 | const CardDescription = React.forwardRef>( 25 | ({ className, ...props }, ref) => ( 26 |

27 | ), 28 | ) 29 | CardDescription.displayName = "CardDescription" 30 | 31 | const CardContent = React.forwardRef>( 32 | ({ className, ...props }, ref) =>

, 33 | ) 34 | CardContent.displayName = "CardContent" 35 | 36 | const CardFooter = React.forwardRef>( 37 | ({ className, ...props }, ref) => ( 38 |
39 | ), 40 | ) 41 | CardFooter.displayName = "CardFooter" 42 | 43 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 44 | -------------------------------------------------------------------------------- /components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 5 | import { Check } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 22 | 23 | 24 | 25 | )) 26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 27 | 28 | export { Checkbox } 29 | -------------------------------------------------------------------------------- /components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )) 40 | DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName 41 | 42 | const DropdownMenuSubContent = React.forwardRef< 43 | React.ElementRef, 44 | React.ComponentPropsWithoutRef 45 | >(({ className, ...props }, ref) => ( 46 | 54 | )) 55 | DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName 56 | 57 | const DropdownMenuContent = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, sideOffset = 4, ...props }, ref) => ( 61 | 62 | 71 | 72 | )) 73 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 74 | 75 | const DropdownMenuItem = React.forwardRef< 76 | React.ElementRef, 77 | React.ComponentPropsWithoutRef & { 78 | inset?: boolean 79 | } 80 | >(({ className, inset, ...props }, ref) => ( 81 | 90 | )) 91 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 92 | 93 | const DropdownMenuCheckboxItem = React.forwardRef< 94 | React.ElementRef, 95 | React.ComponentPropsWithoutRef 96 | >(({ className, children, checked, ...props }, ref) => ( 97 | 106 | 107 | 108 | 109 | 110 | 111 | {children} 112 | 113 | )) 114 | DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName 115 | 116 | const DropdownMenuRadioItem = React.forwardRef< 117 | React.ElementRef, 118 | React.ComponentPropsWithoutRef 119 | >(({ className, children, ...props }, ref) => ( 120 | 128 | 129 | 130 | 131 | 132 | 133 | {children} 134 | 135 | )) 136 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 137 | 138 | const DropdownMenuLabel = React.forwardRef< 139 | React.ElementRef, 140 | React.ComponentPropsWithoutRef & { 141 | inset?: boolean 142 | } 143 | >(({ className, inset, ...props }, ref) => ( 144 | 149 | )) 150 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 151 | 152 | const DropdownMenuSeparator = React.forwardRef< 153 | React.ElementRef, 154 | React.ComponentPropsWithoutRef 155 | >(({ className, ...props }, ref) => ( 156 | 157 | )) 158 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 159 | 160 | const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { 161 | return 162 | } 163 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 164 | 165 | export { 166 | DropdownMenu, 167 | DropdownMenuTrigger, 168 | DropdownMenuContent, 169 | DropdownMenuItem, 170 | DropdownMenuCheckboxItem, 171 | DropdownMenuRadioItem, 172 | DropdownMenuLabel, 173 | DropdownMenuSeparator, 174 | DropdownMenuShortcut, 175 | DropdownMenuGroup, 176 | DropdownMenuPortal, 177 | DropdownMenuSub, 178 | DropdownMenuSubContent, 179 | DropdownMenuSubTrigger, 180 | DropdownMenuRadioGroup, 181 | } 182 | -------------------------------------------------------------------------------- /components/ui/extension/file-uploader.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Input } from "@/components/ui/input"; 4 | import { cn } from "@/lib/utils"; 5 | import { 6 | Dispatch, 7 | SetStateAction, 8 | createContext, 9 | forwardRef, 10 | useCallback, 11 | useContext, 12 | useEffect, 13 | useRef, 14 | useState, 15 | } from "react"; 16 | import { 17 | useDropzone, 18 | DropzoneState, 19 | FileRejection, 20 | DropzoneOptions, 21 | } from "react-dropzone"; 22 | import { toast } from "sonner"; 23 | import { Trash2 as RemoveIcon } from "lucide-react"; 24 | import { buttonVariants } from "@/components/ui/button"; 25 | 26 | type DirectionOptions = "rtl" | "ltr" | undefined; 27 | 28 | type FileUploaderContextType = { 29 | dropzoneState: DropzoneState; 30 | isLOF: boolean; 31 | isFileTooBig: boolean; 32 | removeFileFromSet: (index: number) => void; 33 | activeIndex: number; 34 | setActiveIndex: Dispatch>; 35 | orientation: "horizontal" | "vertical"; 36 | direction: DirectionOptions; 37 | }; 38 | 39 | const FileUploaderContext = createContext(null); 40 | 41 | export const useFileUpload = () => { 42 | const context = useContext(FileUploaderContext); 43 | if (!context) { 44 | throw new Error("useFileUpload must be used within a FileUploaderProvider"); 45 | } 46 | return context; 47 | }; 48 | 49 | type FileUploaderProps = { 50 | value: File[] | null; 51 | reSelect?: boolean; 52 | onValueChange: (value: File[] | null) => void; 53 | dropzoneOptions: DropzoneOptions; 54 | orientation?: "horizontal" | "vertical"; 55 | }; 56 | 57 | export const FileUploader = forwardRef< 58 | HTMLDivElement, 59 | FileUploaderProps & React.HTMLAttributes 60 | >( 61 | ( 62 | { 63 | className, 64 | dropzoneOptions, 65 | value, 66 | onValueChange, 67 | reSelect, 68 | orientation = "vertical", 69 | children, 70 | dir, 71 | ...props 72 | }, 73 | ref 74 | ) => { 75 | const [isFileTooBig, setIsFileTooBig] = useState(false); 76 | const [isLOF, setIsLOF] = useState(false); 77 | const [activeIndex, setActiveIndex] = useState(-1); 78 | const { 79 | accept = { 80 | "image/*": [".jpg", ".jpeg", ".png", ".gif"], 81 | }, 82 | maxFiles = 1, 83 | maxSize = 4 * 1024 * 1024, 84 | multiple = true, 85 | } = dropzoneOptions; 86 | 87 | const reSelectAll = maxFiles === 1 ? true : reSelect; 88 | const direction: DirectionOptions = dir === "rtl" ? "rtl" : "ltr"; 89 | 90 | const removeFileFromSet = useCallback( 91 | (i: number) => { 92 | if (!value) return; 93 | const newFiles = value.filter((_, index) => index !== i); 94 | onValueChange(newFiles); 95 | }, 96 | [value, onValueChange] 97 | ); 98 | 99 | const handleKeyDown = useCallback( 100 | (e: React.KeyboardEvent) => { 101 | e.preventDefault(); 102 | e.stopPropagation(); 103 | 104 | if (!value) return; 105 | 106 | const moveNext = () => { 107 | const nextIndex = activeIndex + 1; 108 | setActiveIndex(nextIndex > value.length - 1 ? 0 : nextIndex); 109 | }; 110 | 111 | const movePrev = () => { 112 | const nextIndex = activeIndex - 1; 113 | setActiveIndex(nextIndex < 0 ? value.length - 1 : nextIndex); 114 | }; 115 | 116 | const prevKey = 117 | orientation === "horizontal" 118 | ? direction === "ltr" 119 | ? "ArrowLeft" 120 | : "ArrowRight" 121 | : "ArrowUp"; 122 | 123 | const nextKey = 124 | orientation === "horizontal" 125 | ? direction === "ltr" 126 | ? "ArrowRight" 127 | : "ArrowLeft" 128 | : "ArrowDown"; 129 | 130 | if (e.key === nextKey) { 131 | moveNext(); 132 | } else if (e.key === prevKey) { 133 | movePrev(); 134 | } else if (e.key === "Enter" || e.key === "Space") { 135 | if (activeIndex === -1) { 136 | dropzoneState.inputRef.current?.click(); 137 | } 138 | } else if (e.key === "Delete" || e.key === "Backspace") { 139 | if (activeIndex !== -1) { 140 | removeFileFromSet(activeIndex); 141 | if (value.length - 1 === 0) { 142 | setActiveIndex(-1); 143 | return; 144 | } 145 | movePrev(); 146 | } 147 | } else if (e.key === "Escape") { 148 | setActiveIndex(-1); 149 | } 150 | }, 151 | [value, activeIndex, removeFileFromSet] 152 | ); 153 | 154 | const onDrop = useCallback( 155 | (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { 156 | const files = acceptedFiles; 157 | 158 | if (!files) { 159 | toast.error("file error , probably too big"); 160 | return; 161 | } 162 | 163 | const newValues: File[] = value ? [...value] : []; 164 | 165 | if (reSelectAll) { 166 | newValues.splice(0, newValues.length); 167 | } 168 | 169 | files.forEach((file) => { 170 | if (newValues.length < maxFiles) { 171 | newValues.push(file); 172 | } 173 | }); 174 | 175 | onValueChange(newValues); 176 | 177 | if (rejectedFiles.length > 0) { 178 | for (let i = 0; i < rejectedFiles.length; i++) { 179 | if (rejectedFiles[i].errors[0]?.code === "file-too-large") { 180 | toast.error( 181 | `File is too large. Max size is ${maxSize / 1024 / 1024}MB` 182 | ); 183 | break; 184 | } 185 | if (rejectedFiles[i].errors[0]?.message) { 186 | toast.error(rejectedFiles[i].errors[0].message); 187 | break; 188 | } 189 | } 190 | } 191 | }, 192 | [reSelectAll, value] 193 | ); 194 | 195 | useEffect(() => { 196 | if (!value) return; 197 | if (value.length === maxFiles) { 198 | setIsLOF(true); 199 | return; 200 | } 201 | setIsLOF(false); 202 | }, [value, maxFiles]); 203 | 204 | const opts = dropzoneOptions 205 | ? dropzoneOptions 206 | : { accept, maxFiles, maxSize, multiple }; 207 | 208 | const dropzoneState = useDropzone({ 209 | ...opts, 210 | onDrop, 211 | onDropRejected: () => setIsFileTooBig(true), 212 | onDropAccepted: () => setIsFileTooBig(false), 213 | }); 214 | 215 | return ( 216 | 228 |
0, 237 | } 238 | )} 239 | dir={dir} 240 | {...props} 241 | > 242 | {children} 243 |
244 |
245 | ); 246 | } 247 | ); 248 | 249 | FileUploader.displayName = "FileUploader"; 250 | 251 | export const FileUploaderContent = forwardRef< 252 | HTMLDivElement, 253 | React.HTMLAttributes 254 | >(({ children, className, ...props }, ref) => { 255 | const { orientation } = useFileUpload(); 256 | const containerRef = useRef(null); 257 | 258 | return ( 259 |
264 |
273 | {children} 274 |
275 |
276 | ); 277 | }); 278 | 279 | FileUploaderContent.displayName = "FileUploaderContent"; 280 | 281 | export const FileUploaderItem = forwardRef< 282 | HTMLDivElement, 283 | { index: number } & React.HTMLAttributes 284 | >(({ className, index, children, ...props }, ref) => { 285 | const { removeFileFromSet, activeIndex, direction } = useFileUpload(); 286 | const isSelected = index === activeIndex; 287 | return ( 288 |
298 |
299 | {children} 300 |
301 | 312 |
313 | ); 314 | }); 315 | 316 | FileUploaderItem.displayName = "FileUploaderItem"; 317 | 318 | export const FileInput = forwardRef< 319 | HTMLDivElement, 320 | React.HTMLAttributes 321 | >(({ className, children, ...props }, ref) => { 322 | const { dropzoneState, isFileTooBig, isLOF } = useFileUpload(); 323 | const rootProps = isLOF ? {} : dropzoneState.getRootProps(); 324 | return ( 325 |
331 |
344 | {children} 345 |
346 | 352 |
353 | ); 354 | }); 355 | 356 | FileInput.displayName = "FileInput"; 357 | -------------------------------------------------------------------------------- /components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { Slot } from "@radix-ui/react-slot" 4 | import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from "react-hook-form" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { Label } from "@/components/ui/label" 8 | 9 | const Form = FormProvider 10 | 11 | type FormFieldContextValue< 12 | TFieldValues extends FieldValues = FieldValues, 13 | TName extends FieldPath = FieldPath, 14 | > = { 15 | name: TName 16 | } 17 | 18 | const FormFieldContext = React.createContext({} as FormFieldContextValue) 19 | 20 | const FormField = < 21 | TFieldValues extends FieldValues = FieldValues, 22 | TName extends FieldPath = FieldPath, 23 | >({ 24 | ...props 25 | }: ControllerProps) => { 26 | return ( 27 | 28 | 29 | 30 | ) 31 | } 32 | 33 | const useFormField = () => { 34 | const fieldContext = React.useContext(FormFieldContext) 35 | const itemContext = React.useContext(FormItemContext) 36 | const { getFieldState, formState } = useFormContext() 37 | 38 | const fieldState = getFieldState(fieldContext.name, formState) 39 | 40 | if (!fieldContext) { 41 | throw new Error("useFormField should be used within ") 42 | } 43 | 44 | const { id } = itemContext 45 | 46 | return { 47 | id, 48 | name: fieldContext.name, 49 | formItemId: `${id}-form-item`, 50 | formDescriptionId: `${id}-form-item-description`, 51 | formMessageId: `${id}-form-item-message`, 52 | ...fieldState, 53 | } 54 | } 55 | 56 | type FormItemContextValue = { 57 | id: string 58 | } 59 | 60 | const FormItemContext = React.createContext({} as FormItemContextValue) 61 | 62 | const FormItem = React.forwardRef>( 63 | ({ className, ...props }, ref) => { 64 | const id = React.useId() 65 | 66 | return ( 67 | 68 |
69 | 70 | ) 71 | }, 72 | ) 73 | FormItem.displayName = "FormItem" 74 | 75 | const FormLabel = React.forwardRef< 76 | React.ElementRef, 77 | React.ComponentPropsWithoutRef 78 | >(({ className, ...props }, ref) => { 79 | const { error, formItemId } = useFormField() 80 | 81 | return