From c1d8251649586d85dc61a6fcd8d807a7585bcb59 Mon Sep 17 00:00:00 2001 From: rharkor Date: Thu, 14 Sep 2023 12:43:44 +0200 Subject: [PATCH] feat: reset password --- .env.example | 9 +- components.json | 2 +- env.mjs | 17 ++ next.config.mjs | 4 - package-lock.json | 54 +++++ package.json | 3 + .../migration.sql | 15 ++ .../migration.sql | 2 + prisma/schema.prisma | 24 +- .../(sys-auth)/forgot-password/form.tsx | 111 +++++++++ .../(sys-auth)/forgot-password/page.tsx | 21 ++ .../reset-password/[token]/form.tsx | 83 +++++++ .../reset-password/[token]/page.tsx | 27 +++ src/components/auth/delete-account-button.tsx | 2 +- src/components/auth/login-user-auth-form.tsx | 6 +- .../auth/register-user-auth-form.tsx | 2 +- src/components/auth/require-auth.tsx | 2 +- src/components/auto-refresh.tsx | 18 ++ .../profile/sessions/sessions-table.tsx | 2 +- src/components/profile/update-account.tsx | 2 +- src/components/ui/form-field.tsx | 4 +- src/components/ui/tooltip.tsx | 30 +++ src/contexts/account.tsx | 2 +- src/contexts/active-sessions.tsx | 2 +- src/langs/en.json | 26 +- src/langs/fr.json | 28 ++- src/lib/api/auth/mutations.ts | 2 +- src/lib/api/me/mutation.ts | 2 +- src/lib/api/me/password/mutation.ts | 111 +++++++++ src/lib/api/me/queries.ts | 2 +- src/lib/api/me/sessions/mutation.ts | 2 +- src/lib/api/me/sessions/queries.ts | 2 +- src/lib/mailer.ts | 30 +++ src/lib/schemas/user.ts | 29 ++- src/lib/server/routers/me.ts | 15 +- src/lib/templates/mail/reset-password.ts | 227 ++++++++++++++++++ src/lib/templates/mail/verify-email.ts | 88 +++++++ src/lib/{ => utils}/client-utils.ts | 8 +- src/lib/{utils.ts => utils/index.ts} | 29 ++- src/lib/{ => utils}/server-utils.ts | 16 +- src/middleware.ts | 16 +- src/types/constants.ts | 5 + 42 files changed, 1028 insertions(+), 54 deletions(-) create mode 100644 prisma/migrations/20230914080302_add_reset_password/migration.sql create mode 100644 prisma/migrations/20230914084608_created_ad_on_reset_password/migration.sql create mode 100644 src/app/[lang]/(sys-auth)/forgot-password/form.tsx create mode 100644 src/app/[lang]/(sys-auth)/forgot-password/page.tsx create mode 100644 src/app/[lang]/(sys-auth)/reset-password/[token]/form.tsx create mode 100644 src/app/[lang]/(sys-auth)/reset-password/[token]/page.tsx create mode 100644 src/components/auto-refresh.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/lib/api/me/password/mutation.ts create mode 100644 src/lib/mailer.ts create mode 100644 src/lib/templates/mail/reset-password.ts create mode 100644 src/lib/templates/mail/verify-email.ts rename src/lib/{ => utils}/client-utils.ts (85%) rename src/lib/{utils.ts => utils/index.ts} (79%) rename src/lib/{ => utils}/server-utils.ts (68%) diff --git a/.env.example b/.env.example index 77d6c599..fa5daf09 100644 --- a/.env.example +++ b/.env.example @@ -19,4 +19,11 @@ REDIS_USERNAME= REDIS_PASSWORD= REDIS_PORT=6379 REDIS_URL=redis://localhost:6379 -REDIS_USE_TLS=false \ No newline at end of file +REDIS_USE_TLS=false +SMTP_HOST=youtSmptHost +SMTP_PORT=465 +SMTP_USERNAME=secret +SMTP_PASSWORD=secret +SMTP_FROM_NAME=FromName +SMTP_FROM_EMAIL=email@example.com +SUPPORT_EMAIL=support@example.com \ No newline at end of file diff --git a/components.json b/components.json index 48c34e4c..fd5076fb 100644 --- a/components.json +++ b/components.json @@ -13,4 +13,4 @@ "components": "@/components", "utils": "@/lib/utils" } -} \ No newline at end of file +} diff --git a/env.mjs b/env.mjs index b24914ec..df8c8950 100644 --- a/env.mjs +++ b/env.mjs @@ -32,6 +32,16 @@ export const env = createEnv({ ENV: z.enum(["development", "recette", "production"]).optional(), BASE_URL: z.string().url(), VERCEL_URL: z.string().optional(), + SMTP_HOST: z.string().nonempty(), + SMTP_PORT: z + .string() + .nonempty() + .transform((value) => parseInt(value)), + SMTP_USERNAME: z.string().nonempty(), + SMTP_PASSWORD: z.string().nonempty(), + SMTP_FROM_NAME: z.string().nonempty(), + SMTP_FROM_EMAIL: z.string().nonempty(), + SUPPORT_EMAIL: z.string().optional(), }, client: { NEXT_PUBLIC_IS_DEMO: z @@ -66,6 +76,13 @@ export const env = createEnv({ ENV: process.env.ENV, BASE_URL: process.env.BASE_URL, VERCEL_URL: process.env.VERCEL_URL, + SMTP_HOST: process.env.SMTP_HOST, + SMTP_PORT: process.env.SMTP_PORT, + SMTP_USERNAME: process.env.SMTP_USERNAME, + SMTP_PASSWORD: process.env.SMTP_PASSWORD, + SMTP_FROM_NAME: process.env.SMTP_FROM_NAME, + SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL, + SUPPORT_EMAIL: process.env.SUPPORT_EMAIL, }, onValidationError: (error) => { console.error(error) diff --git a/next.config.mjs b/next.config.mjs index 31aef952..70768689 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -14,10 +14,6 @@ const config = withPlugins([[withBundleAnalyzer({ enabled: env.ANALYZE })]], { { source: "/health", destination: "/api/health" }, { source: "/ping", destination: "/api/health" }, { source: "/api/ping", destination: "/api/health" }, - { source: "/login", destination: "/sign-in" }, - { source: "/signin", destination: "/sign-in" }, - { source: "/register", destination: "/sign-up" }, - { source: "/signup", destination: "/sign-up" }, ] }, compiler: { diff --git a/package-lock.json b/package-lock.json index 56042de5..bc60a175 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@radix-ui/react-select": "^1.2.2", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-toast": "^1.1.4", + "@radix-ui/react-tooltip": "^1.0.6", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^10.0.1", "@semantic-release/git": "^10.0.1", @@ -49,6 +50,7 @@ "next-auth": "^4.23.1", "next-compose-plugins": "^2.2.1", "next-themes": "^0.2.1", + "nodemailer": "^6.9.5", "prisma": "^5.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -73,6 +75,7 @@ "@types/lodash.isequal": "^4.5.6", "@types/negotiator": "^0.6.1", "@types/node": "^20.4.9", + "@types/nodemailer": "^6.4.10", "@types/react": "^18.2.19", "@types/react-dom": "^18.2.7", "@types/request-ip": "^0.0.38", @@ -4786,6 +4789,40 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.6.tgz", + "integrity": "sha512-DmNFOiwEc2UDigsYj6clJENma58OelxD24O4IODoZ+3sQc3Zb+L8w1EP+y9laTuKCLAysPw4fD6/v0j4KNV8rg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-popper": "1.1.2", + "@radix-ui/react-portal": "1.0.3", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-visually-hidden": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", @@ -6113,6 +6150,15 @@ "integrity": "sha512-najjVq5KN2vsH2U/xyh2opaSEz6cZMR2SetLIlxlj08nOcmPOemJmUK2o4kUzfLqfrWE0PIrNeE16XhYDd3nqg==", "devOptional": true }, + "node_modules/@types/nodemailer": { + "version": "6.4.10", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.10.tgz", + "integrity": "sha512-oPW/IdhkU3FyZc1dzeqmS+MBjrjZNiiINnrEOrWALzccJlP5xTlbkNr2YnTnnyj9Eqm5ofjRoASEbrCYpA7BrA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz", @@ -15457,6 +15503,14 @@ "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", "dev": true }, + "node_modules/nodemailer": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.5.tgz", + "integrity": "sha512-/dmdWo62XjumuLc5+AYQZeiRj+PRR8y8qKtFCOyuOl1k/hckZd8durUUHs/ucKx6/8kN+wFxqKJlQ/LK/qR5FA==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-package-data": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", diff --git a/package.json b/package.json index 9ff3c676..867c6538 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@radix-ui/react-select": "^1.2.2", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-toast": "^1.1.4", + "@radix-ui/react-tooltip": "^1.0.6", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^10.0.1", "@semantic-release/git": "^10.0.1", @@ -62,6 +63,7 @@ "next-auth": "^4.23.1", "next-compose-plugins": "^2.2.1", "next-themes": "^0.2.1", + "nodemailer": "^6.9.5", "prisma": "^5.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -86,6 +88,7 @@ "@types/lodash.isequal": "^4.5.6", "@types/negotiator": "^0.6.1", "@types/node": "^20.4.9", + "@types/nodemailer": "^6.4.10", "@types/react": "^18.2.19", "@types/react-dom": "^18.2.7", "@types/request-ip": "^0.0.38", diff --git a/prisma/migrations/20230914080302_add_reset_password/migration.sql b/prisma/migrations/20230914080302_add_reset_password/migration.sql new file mode 100644 index 00000000..5009f35d --- /dev/null +++ b/prisma/migrations/20230914080302_add_reset_password/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "ResetPassordToken" ( + "identifier" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ResetPassordToken_identifier_key" ON "ResetPassordToken"("identifier"); + +-- CreateIndex +CREATE UNIQUE INDEX "ResetPassordToken_token_key" ON "ResetPassordToken"("token"); + +-- AddForeignKey +ALTER TABLE "ResetPassordToken" ADD CONSTRAINT "ResetPassordToken_identifier_fkey" FOREIGN KEY ("identifier") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20230914084608_created_ad_on_reset_password/migration.sql b/prisma/migrations/20230914084608_created_ad_on_reset_password/migration.sql new file mode 100644 index 00000000..4dfc0796 --- /dev/null +++ b/prisma/migrations/20230914084608_created_ad_on_reset_password/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ResetPassordToken" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d5287ef0..a99c25c2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -6,9 +6,9 @@ generator client { } datasource db { - provider = "postgresql" - url = env("DATABASE_PRISMA_URL") // uses connection pooling - directUrl = env("DATABASE_URL_NON_POOLING") // uses a direct connection + provider = "postgresql" + url = env("DATABASE_PRISMA_URL") // uses connection pooling + directUrl = env("DATABASE_URL_NON_POOLING") // uses a direct connection } model Account { @@ -52,12 +52,14 @@ model User { sessions Session[] // Custom fields - username String? @unique - role String @default("user") - password String? - hasPassword Boolean @default(false) + username String? @unique + role String @default("user") + password String? + hasPassword Boolean @default(false) + resetPasswordToken ResetPassordToken? } +//? For one time login links model VerificationToken { identifier String token String @unique @@ -65,3 +67,11 @@ model VerificationToken { @@unique([identifier, token]) } + +model ResetPassordToken { + identifier String @unique + token String @unique + expires DateTime + user User @relation(fields: [identifier], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) +} diff --git a/src/app/[lang]/(sys-auth)/forgot-password/form.tsx b/src/app/[lang]/(sys-auth)/forgot-password/form.tsx new file mode 100644 index 00000000..376f839e --- /dev/null +++ b/src/app/[lang]/(sys-auth)/forgot-password/form.tsx @@ -0,0 +1,111 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import { Clock } from "lucide-react" +import { useRouter } from "next/navigation" +import { useState } from "react" +import { useForm } from "react-hook-form" +import { z } from "zod" +import AutoRefresh from "@/components/auto-refresh" +import { Button } from "@/components/ui/button" +import { Form } from "@/components/ui/form" +import FormField from "@/components/ui/form-field" +import { Label } from "@/components/ui/label" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { toast } from "@/components/ui/use-toast" +import { TDictionary } from "@/lib/langs" +import { forgotPasswordSchema } from "@/lib/schemas/user" +import { trpc } from "@/lib/trpc/client" +import { handleMutationError } from "@/lib/utils/client-utils" +import { resendResetPasswordExpiration } from "@/types/constants" + +const formSchema = forgotPasswordSchema +type IForm = z.infer> + +export default function ForgotPasswordForm({ dictionary }: { dictionary: TDictionary }) { + const router = useRouter() + + const [latestEmailSentAt, setLatestEmailSentAt] = useState(null) + + const forgotPasswordMutation = trpc.me.forgotPassword.useMutation({ + onError: (error) => handleMutationError(error, dictionary, router), + onSuccess: () => { + setLatestEmailSentAt(Date.now()) + toast({ + title: dictionary.forgotPasswordSuccessTitle, + description: dictionary.forgotPasswordSuccessDescription, + }) + }, + }) + + const form = useForm({ + resolver: zodResolver(formSchema(dictionary)), + defaultValues: { + email: "", + }, + }) + + async function onSubmit(data: IForm) { + forgotPasswordMutation.mutate(data) + } + + const isLoading = forgotPasswordMutation.isLoading + const retryIn = () => + latestEmailSentAt ? new Date(resendResetPasswordExpiration - (Date.now() - latestEmailSentAt)) : null + const retryInValue = retryIn() + const isDisabled = isLoading || (latestEmailSentAt && retryInValue ? retryInValue.getTime() > 0 : false) + + return ( +
+ +
+ + +
+ + {latestEmailSentAt !== null && ( + + + + { + const retryInValue = retryIn() + const retryInFormatted = + retryInValue && retryInValue.getTime() > 0 + ? `${Math.floor(retryInValue.getTime() / 1000 / 60)}:${ + Math.floor(retryInValue.getTime() / 1000) % 60 + }` + : null + return ( +
+ + {retryInFormatted} +
+ ) + }} + interval={1000} + /> +
+ +

{dictionary.timeUntilYouCanRequestAnotherEmail}

+
+
+
+ )} +
+ + ) +} diff --git a/src/app/[lang]/(sys-auth)/forgot-password/page.tsx b/src/app/[lang]/(sys-auth)/forgot-password/page.tsx new file mode 100644 index 00000000..9344cea2 --- /dev/null +++ b/src/app/[lang]/(sys-auth)/forgot-password/page.tsx @@ -0,0 +1,21 @@ +import { getDictionary } from "@/lib/langs" +import { Locale } from "i18n-config" +import ForgotPasswordForm from "./form" + +export default async function ForgotPassword({ + params: { lang }, +}: { + params: { + lang: Locale + } +}) { + const dictionary = await getDictionary(lang) + + return ( +
+

{dictionary.forgotPasswordTitle}

+

{dictionary.forgotPasswordDescription}

+ +
+ ) +} diff --git a/src/app/[lang]/(sys-auth)/reset-password/[token]/form.tsx b/src/app/[lang]/(sys-auth)/reset-password/[token]/form.tsx new file mode 100644 index 00000000..859b53d8 --- /dev/null +++ b/src/app/[lang]/(sys-auth)/reset-password/[token]/form.tsx @@ -0,0 +1,83 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import { useRouter } from "next/navigation" +import { useForm } from "react-hook-form" +import { z } from "zod" +import { Button } from "@/components/ui/button" +import { Form } from "@/components/ui/form" +import FormField from "@/components/ui/form-field" +import { Label } from "@/components/ui/label" +import { toast } from "@/components/ui/use-toast" +import { authRoutes } from "@/lib/auth/constants" +import { TDictionary } from "@/lib/langs" +import { resetPasswordSchema } from "@/lib/schemas/user" +import { trpc } from "@/lib/trpc/client" +import { handleMutationError } from "@/lib/utils/client-utils" + +const formSchema = resetPasswordSchema +type IForm = z.infer> + +export default function ResetPasswordForm({ dictionary, token }: { dictionary: TDictionary; token: string }) { + const router = useRouter() + + const resetPasswordMutation = trpc.me.resetPassword.useMutation({ + onError: (error) => handleMutationError(error, dictionary, router), + onSuccess: () => { + toast({ + title: dictionary.resetPasswordSuccessTitle, + description: dictionary.resetPasswordSuccessDescription, + }) + router.push(authRoutes.signIn[0]) + }, + }) + + const form = useForm({ + resolver: zodResolver(formSchema(dictionary)), + defaultValues: { + token, + password: "", + passwordConfirmation: "", + }, + }) + + async function onSubmit(data: IForm) { + resetPasswordMutation.mutate(data) + } + + const isLoading = resetPasswordMutation.isLoading + + return ( +
+ +
+ + +
+
+ + +
+ +
+ + ) +} diff --git a/src/app/[lang]/(sys-auth)/reset-password/[token]/page.tsx b/src/app/[lang]/(sys-auth)/reset-password/[token]/page.tsx new file mode 100644 index 00000000..89016519 --- /dev/null +++ b/src/app/[lang]/(sys-auth)/reset-password/[token]/page.tsx @@ -0,0 +1,27 @@ +import Link from "next/link" +import { authRoutes } from "@/lib/auth/constants" +import { getDictionary } from "@/lib/langs" +import { Locale } from "i18n-config" +import ResetPasswordForm from "./form" + +export default async function ForgotPassword({ + params: { lang, token }, +}: { + params: { + lang: Locale + token: string + } +}) { + const dictionary = await getDictionary(lang) + + return ( +
+

{dictionary.resetPasswordTitle}

+

{dictionary.resetPasswordDescription}

+ + + {dictionary.goToSignInPage} + +
+ ) +} diff --git a/src/components/auth/delete-account-button.tsx b/src/components/auth/delete-account-button.tsx index ad93320b..c7204d19 100644 --- a/src/components/auth/delete-account-button.tsx +++ b/src/components/auth/delete-account-button.tsx @@ -13,9 +13,9 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog" import { authRoutes } from "@/lib/auth/constants" -import { handleMutationError } from "@/lib/client-utils" import { TDictionary } from "@/lib/langs" import { trpc } from "@/lib/trpc/client" +import { handleMutationError } from "@/lib/utils/client-utils" import { Button } from "../ui/button" import { toast } from "../ui/use-toast" diff --git a/src/components/auth/login-user-auth-form.tsx b/src/components/auth/login-user-auth-form.tsx index 5e712e2a..afe598eb 100644 --- a/src/components/auth/login-user-auth-form.tsx +++ b/src/components/auth/login-user-auth-form.tsx @@ -2,6 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { BadgeInfo } from "lucide-react" +import Link from "next/link" import { useRouter } from "next/navigation" import * as React from "react" import { useForm } from "react-hook-form" @@ -71,7 +72,7 @@ export function LoginUserAuthForm({ dictionary, searchParams, ...props }: UserAu return (
- + {env.NEXT_PUBLIC_IS_DEMO && (
@@ -133,6 +134,9 @@ export function LoginUserAuthForm({ dictionary, searchParams, ...props }: UserAu name="password" />
+ + {dictionary.forgotPassword} + diff --git a/src/components/auth/register-user-auth-form.tsx b/src/components/auth/register-user-auth-form.tsx index 1df00a22..5654d511 100644 --- a/src/components/auth/register-user-auth-form.tsx +++ b/src/components/auth/register-user-auth-form.tsx @@ -8,12 +8,12 @@ import { useForm } from "react-hook-form" import * as z from "zod" import { authRoutes } from "@/lib/auth/constants" import { handleSignError, handleSignIn } from "@/lib/auth/handle-sign" -import { handleMutationError } from "@/lib/client-utils" import { TDictionary } from "@/lib/langs" import { logger } from "@/lib/logger" import { signUpSchema } from "@/lib/schemas/auth" import { trpc } from "@/lib/trpc/client" import { cn } from "@/lib/utils" +import { handleMutationError } from "@/lib/utils/client-utils" import { Button, buttonVariants } from "../ui/button" import { Form } from "../ui/form" import FormField from "../ui/form-field" diff --git a/src/components/auth/require-auth.tsx b/src/components/auth/require-auth.tsx index b02923aa..dc3bcba2 100644 --- a/src/components/auth/require-auth.tsx +++ b/src/components/auth/require-auth.tsx @@ -4,7 +4,7 @@ import { getServerSession } from "next-auth" import { nextAuthOptions } from "@/lib/auth" import { authRoutes } from "@/lib/auth/constants" import { logger } from "@/lib/logger" -import { validateSession } from "@/lib/server-utils" +import { validateSession } from "@/lib/utils/server-utils" export default async function requireAuth(callbackUrl?: string) { const session = await getServerSession(nextAuthOptions) diff --git a/src/components/auto-refresh.tsx b/src/components/auto-refresh.tsx new file mode 100644 index 00000000..6cdde1d8 --- /dev/null +++ b/src/components/auto-refresh.tsx @@ -0,0 +1,18 @@ +"use client" + +import React, { useEffect, useState } from "react" + +export default function AutoRefresh({ callback, interval }: { callback: () => React.ReactNode; interval: number }) { + const [result, setResult] = useState(callback()) + useEffect(() => { + const intervalId = setInterval(() => { + setResult(callback()) + }, interval) + + return () => { + clearInterval(intervalId) + } + }, [callback, interval]) + + return <>{result} +} diff --git a/src/components/profile/sessions/sessions-table.tsx b/src/components/profile/sessions/sessions-table.tsx index d1371617..4899eea1 100644 --- a/src/components/profile/sessions/sessions-table.tsx +++ b/src/components/profile/sessions/sessions-table.tsx @@ -15,10 +15,10 @@ import { } from "@/components/ui/alert-dialog" import Pagination from "@/components/ui/pagination" import { useActiveSessions } from "@/contexts/active-sessions" -import { handleMutationError } from "@/lib/client-utils" import { IMeta } from "@/lib/json-api" import { TDictionary } from "@/lib/langs" import { trpc } from "@/lib/trpc/client" +import { handleMutationError } from "@/lib/utils/client-utils" import SessionRow from "./session-row" const itemsPerPageInitial = 5 diff --git a/src/components/profile/update-account.tsx b/src/components/profile/update-account.tsx index 6a06241a..2220e07e 100644 --- a/src/components/profile/update-account.tsx +++ b/src/components/profile/update-account.tsx @@ -7,11 +7,11 @@ import { useCallback, useEffect, useState } from "react" import { useForm } from "react-hook-form" import * as z from "zod" import { useAccount } from "@/contexts/account" -import { handleMutationError } from "@/lib/client-utils" import { TDictionary } from "@/lib/langs" import { logger } from "@/lib/logger" import { updateUserSchema } from "@/lib/schemas/user" import { trpc } from "@/lib/trpc/client" +import { handleMutationError } from "@/lib/utils/client-utils" import NeedSavePopup from "../need-save-popup" import { Form } from "../ui/form" import FormField from "../ui/form-field" diff --git a/src/components/ui/form-field.tsx b/src/components/ui/form-field.tsx index 768b67fc..4e826be5 100644 --- a/src/components/ui/form-field.tsx +++ b/src/components/ui/form-field.tsx @@ -45,9 +45,9 @@ function getInner< props: InputWithOmittedProps ) { if (type === "password-eye-slash") { - return + return } else { - return + return } } diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 00000000..9af73408 --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as TooltipPrimitive from "@radix-ui/react-tooltip" +import * as React from "react" + +import { cn } from "@/lib/utils" + +const TooltipProvider = TooltipPrimitive.Provider + +const Tooltip = TooltipPrimitive.Root + +const TooltipTrigger = TooltipPrimitive.Trigger + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/src/contexts/account.tsx b/src/contexts/account.tsx index 74a6d015..8ad18b4d 100644 --- a/src/contexts/account.tsx +++ b/src/contexts/account.tsx @@ -1,9 +1,9 @@ import { useRouter } from "next/navigation" import { z } from "zod" -import { handleQueryError } from "@/lib/client-utils" import { TDictionary } from "@/lib/langs" import { getAccountResponseSchema } from "@/lib/schemas/user" import { trpc } from "@/lib/trpc/client" +import { handleQueryError } from "@/lib/utils/client-utils" export function useAccount( dictionary: TDictionary, diff --git a/src/contexts/active-sessions.tsx b/src/contexts/active-sessions.tsx index 6d621087..134bc3cf 100644 --- a/src/contexts/active-sessions.tsx +++ b/src/contexts/active-sessions.tsx @@ -1,9 +1,9 @@ import { useRouter } from "next/navigation" import { z } from "zod" -import { handleQueryError } from "@/lib/client-utils" import { TDictionary } from "@/lib/langs" import { getActiveSessionsResponseSchema } from "@/lib/schemas/user" import { trpc } from "@/lib/trpc/client" +import { handleQueryError } from "@/lib/utils/client-utils" export function useActiveSessions( dictionary: TDictionary, diff --git a/src/langs/en.json b/src/langs/en.json index 796cbafc..e2425341 100644 --- a/src/langs/en.json +++ b/src/langs/en.json @@ -97,14 +97,22 @@ "wrongProvider": "You already have an account with a different provider. Please sign in with it.", "invalidCredentials": "Invalid credentials. Please try again.", "unauthorized": "You have been logged out. Please sign in again.", - "userNotFound": "User not found.", + "userNotFound": "This user does not exist.", "cannotDeleteAdmin": "You cannot delete the admin account.", - "accountAlreadyExists": "An account with this email already exists." + "accountAlreadyExists": "An account with this email already exists.", + "userDoesNotHaveAPassword": "This user does not have a password.", + "pleaseTryAgainInFewMinutes": "Please try again in a few minutes.", + "emailAlreadySentPleaseTryAgainInFewMinutes": "An email has already been sent. Please try again in a few minutes.", + "tokenNotFound": "Token invalid or already used.", + "tokenExpired": "Token has expired.", + "passwordsDoNotMatch": "Passwords do not match.", + "cannotResetAdminPasswordInDemoMode": "You cannot reset the admin password in demo mode." }, "email": "Email", "emailPlaceholder": "name@example.com", "password": "Password", "passwordPlaceholder": "Password", + "passwordConfirmation": "Confirm Password", "edit": "Edit", "username": "Username", "usernamePlaceholder": "Username", @@ -141,5 +149,17 @@ "deleteAccountConfirmationDescription": "Are you sure you want to delete your account? This action is irreversible.", "deleteAccountConfirm": "Delete account", "deleteAccountSuccessTitle": "Account deleted", - "deleteAccountSuccessDescription": "Your account has been deleted." + "deleteAccountSuccessDescription": "Your account has been deleted.", + "forgotPassword": "Forgot password?", + "forgotPasswordTitle": "Forgot password", + "forgotPasswordDescription": "Enter your email below to reset your password.", + "forgotPasswordSuccessTitle": "Email sent", + "forgotPasswordSuccessDescription": "An email has been sent to you. Please follow the instructions in the email to reset your password.", + "timeUntilYouCanRequestAnotherEmail": "Time until you can request another email", + "resetPasswordTitle": "Reset password", + "resetPasswordDescription": "Enter your new password below.", + "resetPasswordSuccessTitle": "Password reset", + "resetPasswordSuccessDescription": "Your password has been reset.", + "send": "Send", + "goToSignInPage": "Go to sign in page" } diff --git a/src/langs/fr.json b/src/langs/fr.json index 661395bc..f24e233a 100644 --- a/src/langs/fr.json +++ b/src/langs/fr.json @@ -50,7 +50,7 @@ "toSignIn": "Se connecter", "signOut": "Déconnexion", "deleteAccount": "Supprimer le compte", - "notFound": "Page non trouvée", + "notFound": "Page introuvable", "login": "Connexion", "cancel": "Annuler", "continue": "Confirmer", @@ -97,14 +97,22 @@ "wrongProvider": "Vous avez déjà un compte avec un autre provider. Veuillez vous connecter avec celui-ci.", "invalidCredentials": "Les informations d'identification sont invalides.", "unauthorized": "Vous avez été déconnecté. Veuillez vous reconnecter.", - "userNotFound": "Utilisateur introuvable.", + "userNotFound": "Cet utilisateur n'existe pas.", "cannotDeleteAdmin": "Vous ne pouvez pas supprimer le compte administrateur.", - "accountAlreadyExists": "Un compte existe déjà avec cet email." + "accountAlreadyExists": "Un compte existe déjà avec cet email.", + "userDoesNotHaveAPassword": "L'utilisateur n'a pas de mot de passe.", + "pleaseTryAgainInFewMinutes": "Veuillez ressayer dans quelques minutes.", + "emailAlreadySentPleaseTryAgainInFewMinutes": "Un email a déjà été envoyé. Veuillez ressayer dans quelques minutes.", + "tokenNotFound": "Token invalide ou déjà utilisé.", + "tokenExpired": "Token a expiré.", + "passwordsDoNotMatch": "Les mots de passe ne correspondent pas.", + "cannotResetAdminPasswordInDemoMode": "Vous ne pouvez pas réinitialiser le mot de passe de l'administrateur en mode démo." }, "email": "Email", "emailPlaceholder": "nom@exemple.com", "password": "Mot de passe", "passwordPlaceholder": "Mot de passe", + "passwordConfirmation": "Confirmer le mot de passe", "edit": "Modifier", "username": "Nom d'utilisateur", "usernamePlaceholder": "Nom d'utilisateur", @@ -141,5 +149,17 @@ "deleteAccountConfirmationDescription": "Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est irréversible.", "deleteAccountConfirm": "Confirmer la suppression", "deleteAccountSuccessTitle": "Compte supprimé", - "deleteAccountSuccessDescription": "Votre compte a été supprimé avec succès." + "deleteAccountSuccessDescription": "Votre compte a été supprimé avec succès.", + "forgotPassword": "Mot de passe oublié ?", + "forgotPasswordTitle": "Mot de passe oublié", + "forgotPasswordDescription": "Entrez votre adresse email pour réinitialiser votre mot de passe.", + "forgotPasswordSuccessTitle": "Email envoyé", + "forgotPasswordSuccessDescription": "Un email a été envoyé à l'adresse indiquée. Veuillez suivre les instructions pour réinitialiser votre mot de passe.", + "timeUntilYouCanRequestAnotherEmail": "Temps restant avant de pouvoir demander un autre email", + "resetPasswordTitle": "Réinitialiser le mot de passe", + "resetPasswordDescription": "Entrez votre nouveau mot de passe ci-dessous.", + "resetPasswordSuccessTitle": "Mot de passe réinitialisé", + "resetPasswordSuccessDescription": "Votre mot de passe a été réinitialisé avec succès.", + "send": "Envoyer", + "goToSignInPage": "Aller à la page de connexion" } diff --git a/src/lib/api/auth/mutations.ts b/src/lib/api/auth/mutations.ts index 23323bf2..27add365 100644 --- a/src/lib/api/auth/mutations.ts +++ b/src/lib/api/auth/mutations.ts @@ -2,8 +2,8 @@ import { Prisma } from "@prisma/client" import { hash } from "@/lib/bcrypt" import { prisma } from "@/lib/prisma" import { signUpSchema } from "@/lib/schemas/auth" -import { handleApiError } from "@/lib/server-utils" import { ApiError, throwableErrorsMessages } from "@/lib/utils" +import { handleApiError } from "@/lib/utils/server-utils" import { apiInputFromSchema } from "@/types" export const register = async ({ input }: apiInputFromSchema) => { diff --git a/src/lib/api/me/mutation.ts b/src/lib/api/me/mutation.ts index b98844f5..fcfa0135 100644 --- a/src/lib/api/me/mutation.ts +++ b/src/lib/api/me/mutation.ts @@ -1,8 +1,8 @@ import { Prisma } from "@prisma/client" import { prisma } from "@/lib/prisma" import { updateUserSchema } from "@/lib/schemas/user" -import { ensureLoggedIn, handleApiError } from "@/lib/server-utils" import { ApiError } from "@/lib/utils" +import { ensureLoggedIn, handleApiError } from "@/lib/utils/server-utils" import { apiInputFromSchema } from "@/types" import { rolesAsObject } from "@/types/constants" diff --git a/src/lib/api/me/password/mutation.ts b/src/lib/api/me/password/mutation.ts new file mode 100644 index 00000000..c080a2df --- /dev/null +++ b/src/lib/api/me/password/mutation.ts @@ -0,0 +1,111 @@ +import { randomUUID } from "crypto" +import { hash } from "@/lib/bcrypt" +import { logger } from "@/lib/logger" +import { sendMail } from "@/lib/mailer" +import { prisma } from "@/lib/prisma" +import { forgotPasswordSchema, resetPasswordSchema } from "@/lib/schemas/user" +import { html, plainText, subject } from "@/lib/templates/mail/reset-password" +import { ApiError, throwableErrorsMessages } from "@/lib/utils" +import { handleApiError } from "@/lib/utils/server-utils" +import { apiInputFromSchema } from "@/types" +import { resendResetPasswordExpiration, resetPasswordExpiration, rolesAsObject } from "@/types/constants" +import { env } from "env.mjs" + +export const forgotPassword = async ({ input }: apiInputFromSchema) => { + const { email } = forgotPasswordSchema().parse(input) + try { + //? Check if user exists + const user = await prisma.user.findUnique({ + where: { + email, + }, + }) + // if (!user) return ApiError(throwableErrorsMessages.userNotFound, "NOT_FOUND") + if (!user) { + logger.debug("User not found") + return { email } + } + if (!user.hasPassword) return ApiError(throwableErrorsMessages.userDoesNotHaveAPassword, "BAD_REQUEST") + + const resetPassordToken = await prisma.resetPassordToken.findFirst({ + where: { + identifier: user.id, + }, + }) + + //? Too recent + if (resetPassordToken && resetPassordToken.createdAt > new Date(Date.now() - resendResetPasswordExpiration)) { + return ApiError(throwableErrorsMessages.emailAlreadySentPleaseTryAgainInFewMinutes, "BAD_REQUEST") + } + + if (resetPassordToken) { + await prisma.resetPassordToken.delete({ + where: { + identifier: resetPassordToken.identifier, + }, + }) + } + + const resetPasswordToken = randomUUID() + await prisma.resetPassordToken.create({ + data: { + token: resetPasswordToken, + expires: new Date(Date.now() + resetPasswordExpiration), + user: { + connect: { + id: user.id, + }, + }, + }, + }) + + await sendMail({ + from: `"${env.SMTP_FROM_NAME}" <${env.SMTP_FROM_EMAIL}>`, + to: email, + subject: subject, + text: plainText(user.username ?? email, `${env.BASE_URL}/reset-password/${resetPasswordToken}`), + html: html(user.username ?? email, `${env.BASE_URL}/reset-password/${resetPasswordToken}`), + }) + + return { email } + } catch (error: unknown) { + return handleApiError(error) + } +} + +export const resetPassword = async ({ input }: apiInputFromSchema) => { + const { token, password } = resetPasswordSchema().parse(input) + try { + const resetPassordToken = await prisma.resetPassordToken.findUnique({ + where: { + token, + }, + include: { + user: true, + }, + }) + if (!resetPassordToken) return ApiError(throwableErrorsMessages.tokenNotFound, "NOT_FOUND") + await prisma.resetPassordToken.delete({ + where: { + identifier: resetPassordToken.identifier, + }, + }) + if (resetPassordToken.expires < new Date()) return ApiError(throwableErrorsMessages.tokenExpired, "BAD_REQUEST") + + if (resetPassordToken.user.role === rolesAsObject.admin && env.NEXT_PUBLIC_IS_DEMO === true) + return ApiError(throwableErrorsMessages.cannotResetAdminPasswordInDemoMode, "BAD_REQUEST") + + await prisma.user.update({ + where: { + id: resetPassordToken.user.id, + }, + data: { + password: await hash(password, 12), + }, + }) + + return { user: resetPassordToken.user } + } catch (error: unknown) { + return handleApiError(error) + } +} diff --git a/src/lib/api/me/queries.ts b/src/lib/api/me/queries.ts index 215ed454..83dfd230 100644 --- a/src/lib/api/me/queries.ts +++ b/src/lib/api/me/queries.ts @@ -1,6 +1,6 @@ import { prisma } from "@/lib/prisma" -import { ensureLoggedIn, handleApiError } from "@/lib/server-utils" import { ApiError, throwableErrorsMessages } from "@/lib/utils" +import { ensureLoggedIn, handleApiError } from "@/lib/utils/server-utils" import { apiInputFromSchema } from "@/types" export const getAccount = async ({ ctx: { session } }: apiInputFromSchema) => { diff --git a/src/lib/api/me/sessions/mutation.ts b/src/lib/api/me/sessions/mutation.ts index 71cd5d1d..0e3c3880 100644 --- a/src/lib/api/me/sessions/mutation.ts +++ b/src/lib/api/me/sessions/mutation.ts @@ -1,6 +1,6 @@ import { prisma } from "@/lib/prisma" import { deleteSessionSchema } from "@/lib/schemas/user" -import { ensureLoggedIn, handleApiError } from "@/lib/server-utils" +import { ensureLoggedIn, handleApiError } from "@/lib/utils/server-utils" import { apiInputFromSchema } from "@/types" export const deleteSession = async ({ input, ctx: { session } }: apiInputFromSchema) => { diff --git a/src/lib/api/me/sessions/queries.ts b/src/lib/api/me/sessions/queries.ts index d0ee6bfc..ae3475cc 100644 --- a/src/lib/api/me/sessions/queries.ts +++ b/src/lib/api/me/sessions/queries.ts @@ -2,7 +2,7 @@ import { z } from "zod" import { getJsonApiSkip, getJsonApiSort, getJsonApiTake, parseJsonApiQuery } from "@/lib/json-api" import { prisma } from "@/lib/prisma" import { getActiveSessionsResponseSchema, getActiveSessionsSchema } from "@/lib/schemas/user" -import { ensureLoggedIn, handleApiError } from "@/lib/server-utils" +import { ensureLoggedIn, handleApiError } from "@/lib/utils/server-utils" import { apiInputFromSchema } from "@/types" export const getActiveSessions = async ({ diff --git a/src/lib/mailer.ts b/src/lib/mailer.ts new file mode 100644 index 00000000..f984c8ea --- /dev/null +++ b/src/lib/mailer.ts @@ -0,0 +1,30 @@ +import "server-only" +import { createTransport } from "nodemailer" +import { env } from "env.mjs" +import { logger } from "./logger" + +export const configOptions = { + port: env.SMTP_PORT, + host: env.SMTP_HOST, + username: env.SMTP_USERNAME, + password: env.SMTP_PASSWORD, +} + +const transporter = createTransport({ + port: configOptions.port, + host: configOptions.host, + auth: { + user: configOptions.username, + pass: configOptions.password, + }, +}) + +export const sendMail = async (...params: Parameters) => { + try { + const res = await transporter.sendMail(...params) + logger.info(`Email sent to ${res.envelope.to}`) + } catch (error) { + logger.error(`Error sending message: ${error}`) + throw error + } +} diff --git a/src/lib/schemas/user.ts b/src/lib/schemas/user.ts index 1d1917a2..dba6d1d5 100644 --- a/src/lib/schemas/user.ts +++ b/src/lib/schemas/user.ts @@ -1,5 +1,5 @@ import { z } from "zod" -import { usernameSchema } from "./auth" +import { emailSchema, usernameSchema } from "./auth" import { jsonApiQuerySchema, jsonApiResponseSchema } from "../json-api" import { TDictionary } from "../langs" @@ -70,3 +70,30 @@ export const deleteAccountResponseSchema = () => id: z.string(), }), }) + +export const forgotPasswordSchema = (dictionary?: TDictionary) => + z.object({ + email: emailSchema(dictionary), + }) + +export const forgotPasswordResponseSchema = () => + z.object({ + email: z.string(), + }) + +export const resetPasswordSchema = (dictionary?: TDictionary) => + z + .object({ + token: z.string(), + password: z.string(), + passwordConfirmation: z.string(), + }) + .refine((data) => data.password === data.passwordConfirmation, { + message: dictionary?.errors.passwordsDoNotMatch ?? "Passwords do not match", + path: ["passwordConfirmation"], + }) + +export const resetPasswordResponseSchema = () => + z.object({ + user: userSchema(), + }) diff --git a/src/lib/server/routers/me.ts b/src/lib/server/routers/me.ts index 3c9ab805..138e1536 100644 --- a/src/lib/server/routers/me.ts +++ b/src/lib/server/routers/me.ts @@ -1,4 +1,5 @@ import { deleteAccount, updateUser } from "@/lib/api/me/mutation" +import { forgotPassword, resetPassword } from "@/lib/api/me/password/mutation" import { getAccount } from "@/lib/api/me/queries" import { deleteSession } from "@/lib/api/me/sessions/mutation" import { getActiveSessions } from "@/lib/api/me/sessions/queries" @@ -6,13 +7,17 @@ import { deleteAccountResponseSchema, deleteSessionResponseSchema, deleteSessionSchema, + forgotPasswordResponseSchema, + forgotPasswordSchema, getAccountResponseSchema, getActiveSessionsResponseSchema, getActiveSessionsSchema, + resetPasswordResponseSchema, + resetPasswordSchema, updateUserResponseSchema, updateUserSchema, } from "@/lib/schemas/user" -import { authenticatedProcedure, router } from "../trpc" +import { authenticatedProcedure, publicProcedure, router } from "../trpc" export const meRouter = router({ updateUser: authenticatedProcedure.input(updateUserSchema()).output(updateUserResponseSchema()).mutation(updateUser), @@ -26,4 +31,12 @@ export const meRouter = router({ .mutation(deleteSession), getAccount: authenticatedProcedure.output(getAccountResponseSchema()).query(getAccount), deleteAccount: authenticatedProcedure.output(deleteAccountResponseSchema()).mutation(deleteAccount), + forgotPassword: publicProcedure + .input(forgotPasswordSchema()) + .output(forgotPasswordResponseSchema()) + .mutation(forgotPassword), + resetPassword: publicProcedure + .input(resetPasswordSchema()) + .output(resetPasswordResponseSchema()) + .mutation(resetPassword), }) diff --git a/src/lib/templates/mail/reset-password.ts b/src/lib/templates/mail/reset-password.ts new file mode 100644 index 00000000..3bf12fe8 --- /dev/null +++ b/src/lib/templates/mail/reset-password.ts @@ -0,0 +1,227 @@ +import { env } from "env.mjs" + +export const subject = "Reset your password" + +export const plainText = (username: string, resetLink: string) => `Password Reset + +Hello ${username}, + +We received a request to reset your password. You can reset your password by clicking the following link: + +${resetLink} + +If you did not request this password reset, you can safely ignore this email. + +This email was sent to you as part of our account services.${ + env.SUPPORT_EMAIL ? ` If you have any questions, please contact us at ${env.SUPPORT_EMAIL}.` : "" +} +` + +export const html = (username: string, resetLink: string) => ` + + + + + + + + + + + + + + + + + + + + +` diff --git a/src/lib/templates/mail/verify-email.ts b/src/lib/templates/mail/verify-email.ts new file mode 100644 index 00000000..0e4021a0 --- /dev/null +++ b/src/lib/templates/mail/verify-email.ts @@ -0,0 +1,88 @@ +export const subject = "Verify your email address" + +export const plainText = (verificationLink: string) => `Email Verification + +Hello, + +Thank you for signing up with us. Please verify your email address by clicking the following link: + +${verificationLink} + +If you did not sign up for an account, you can safely ignore this email. + +This email was sent to you as part of our account services. If you have any questions, please contact us at support@example.com. +` + +export const html = (verificationLink: string) => ` + + + + + + Email Verification + + + + +
+
+ +

Email Verification

+
+
+

Hello,

+

Thank you for signing up with us. Please verify your email address by clicking the button below:

+ Verify Email Address +

If you did not sign up for an account, you can safely ignore this email.

+
+ +
+ + +` diff --git a/src/lib/client-utils.ts b/src/lib/utils/client-utils.ts similarity index 85% rename from src/lib/client-utils.ts rename to src/lib/utils/client-utils.ts index 33b09212..1f06c9a0 100644 --- a/src/lib/client-utils.ts +++ b/src/lib/utils/client-utils.ts @@ -2,10 +2,10 @@ import { TRPCClientErrorLike } from "@trpc/client" import "client-only" import { AppRouterInstance } from "next/dist/shared/lib/app-router-context" import { toast } from "@/components/ui/use-toast" -import { TDictionary } from "./langs" -import { logger } from "./logger" -import { AppRouter } from "./server/routers/_app" -import { handleApiError } from "./utils" +import { TDictionary } from "../langs" +import { logger } from "../logger" +import { AppRouter } from "../server/routers/_app" +import { handleApiError } from "." export const handleQueryError = >( error: T, diff --git a/src/lib/utils.ts b/src/lib/utils/index.ts similarity index 79% rename from src/lib/utils.ts rename to src/lib/utils/index.ts index 4b82a383..074bd33e 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils/index.ts @@ -4,16 +4,17 @@ import { TRPC_ERROR_CODE_KEY } from "@trpc/server/rpc" import { type ClassValue, clsx } from "clsx" import { AppRouterInstance } from "next/dist/shared/lib/app-router-context" import { twMerge } from "tailwind-merge" -import { authRoutes } from "./auth/constants" -import { TDictionary } from "./langs" -import { logger } from "./logger" -import { AppRouter } from "./server/routers/_app" +import { ValueOf } from "@/types" +import { authRoutes } from "../auth/constants" +import { TDictionary } from "../langs" +import { logger } from "../logger" +import { AppRouter } from "../server/routers/_app" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } -export function ApiError(message: string, code?: TRPC_ERROR_CODE_KEY): never { +export function ApiError(message: ValueOf, code?: TRPC_ERROR_CODE_KEY): never { throw new TRPCError({ code: code ?? "BAD_REQUEST", message: message, @@ -118,6 +119,12 @@ export const throwableErrorsMessages = { youAreNotLoggedIn: "You are not logged in", unknownError: "Unknown error", userNotFound: "User not found", + userDoesNotHaveAPassword: "User does not have a password", + pleaseTryAgainInFewMinutes: "Please try again in a few minutes", + emailAlreadySentPleaseTryAgainInFewMinutes: "Email already sent, please try again in a few minutes", + tokenNotFound: "Token not found", + tokenExpired: "Token expired", + cannotResetAdminPasswordInDemoMode: "You cannot reset the admin password in demo mode", } as const //? Verify no duplicate values @@ -141,6 +148,18 @@ export const translateError = (error: string, dictionary: TDictionary): string = return dictionary.errors.unknownError case throwableErrorsMessages.userNotFound: return dictionary.errors.userNotFound + case throwableErrorsMessages.userDoesNotHaveAPassword: + return dictionary.errors.userDoesNotHaveAPassword + case throwableErrorsMessages.pleaseTryAgainInFewMinutes: + return dictionary.errors.pleaseTryAgainInFewMinutes + case throwableErrorsMessages.emailAlreadySentPleaseTryAgainInFewMinutes: + return dictionary.errors.emailAlreadySentPleaseTryAgainInFewMinutes + case throwableErrorsMessages.tokenNotFound: + return dictionary.errors.tokenNotFound + case throwableErrorsMessages.tokenExpired: + return dictionary.errors.tokenExpired + case throwableErrorsMessages.cannotResetAdminPasswordInDemoMode: + return dictionary.errors.cannotResetAdminPasswordInDemoMode default: logger.error("Unknown translation for:", error) return error diff --git a/src/lib/server-utils.ts b/src/lib/utils/server-utils.ts similarity index 68% rename from src/lib/server-utils.ts rename to src/lib/utils/server-utils.ts index a2e677a8..2192bc90 100644 --- a/src/lib/server-utils.ts +++ b/src/lib/utils/server-utils.ts @@ -2,8 +2,9 @@ import "server-only" import { TRPCError } from "@trpc/server" import { Session } from "next-auth" import { z } from "zod" -import { logger } from "./logger" -import { ApiError, throwableErrorsMessages } from "./utils" +import { ValueOf } from "@/types" +import { logger } from "../logger" +import { ApiError, throwableErrorsMessages } from "." export const validateSession = async (session: Session): Promise => { const getResult = async () => { @@ -21,17 +22,18 @@ export const parseRequestBody = async ( ): Promise<{ data?: never; error: ReturnType } | { data: T; error?: never }> => { const reqData = (await req.json()) as T const bodyParsedResult = schema.safeParse(reqData) - if (!bodyParsedResult.success) return { error: ApiError(bodyParsedResult.error.message) } + if (!bodyParsedResult.success) + return { error: ApiError(bodyParsedResult.error.message as ValueOf) } return { data: bodyParsedResult.data } } export const handleApiError = (error: unknown) => { - logger.error(error) if (error instanceof TRPCError) { - return ApiError(error.message, error.code) - } else if (error instanceof Error) { - return ApiError(error.message, "INTERNAL_SERVER_ERROR") + return ApiError(error.message as ValueOf, error.code) } else { + logger.error(error) + if (error instanceof Error) + return ApiError(error.message as ValueOf, "INTERNAL_SERVER_ERROR") return ApiError(throwableErrorsMessages.unknownError, "INTERNAL_SERVER_ERROR") } } diff --git a/src/middleware.ts b/src/middleware.ts index 06dd8b8d..f2991e49 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -6,6 +6,18 @@ import type { NextRequest } from "next/server" import { logger } from "./lib/logger" import { i18n } from "../i18n-config" +const blackListedPaths = [ + "healthz", + "api/healthz", + "health", + "ping", + "api/ping", + "login", + "signin", + "register", + "signup", +] + function getLocale(request: NextRequest): string | undefined { // Negotiator expects plain object so we need to transform headers const negotiatorHeaders: Record = {} @@ -39,8 +51,10 @@ export function middleware(request: NextRequest) { (locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}` ) + const pathnameIsNotBlacklisted = !blackListedPaths.some((path) => pathname.startsWith(`/${path}`)) + // Redirect if there is no locale - if (pathnameIsMissingLocale) { + if (pathnameIsMissingLocale && pathnameIsNotBlacklisted) { const locale = getLocale(request) // e.g. incoming request is /products diff --git a/src/types/constants.ts b/src/types/constants.ts index c7f4777c..91b2ee60 100644 --- a/src/types/constants.ts +++ b/src/types/constants.ts @@ -2,3 +2,8 @@ export const rolesAsObject = { admin: "admin", user: "user", } as const + +export const resetPasswordExpiration = 1000 * 60 * 60 * 24 // 24 hours +export const resendResetPasswordExpiration = 1000 * 60 * 5 // 5 minutes +export const emailVerificationExpiration = 1000 * 60 * 60 * 24 * 3 // 3 days +export const resendEmailVerificationExpiration = 1000 * 60 * 5 // 5 minutes