Skip to content

Commit

Permalink
feat: reset password
Browse files Browse the repository at this point in the history
  • Loading branch information
rharkor committed Sep 14, 2023
1 parent 76f4e43 commit c1d8251
Show file tree
Hide file tree
Showing 42 changed files with 1,028 additions and 54 deletions.
9 changes: 8 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,11 @@ REDIS_USERNAME=
REDIS_PASSWORD=
REDIS_PORT=6379
REDIS_URL=redis://localhost:6379
REDIS_USE_TLS=false
REDIS_USE_TLS=false
SMTP_HOST=youtSmptHost
SMTP_PORT=465
SMTP_USERNAME=secret
SMTP_PASSWORD=secret
SMTP_FROM_NAME=FromName
SMTP_FROM_EMAIL=[email protected]
SUPPORT_EMAIL=[email protected]
2 changes: 1 addition & 1 deletion components.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
"components": "@/components",
"utils": "@/lib/utils"
}
}
}
17 changes: 17 additions & 0 deletions env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 0 additions & 4 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
54 changes: 54 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions prisma/migrations/20230914080302_add_reset_password/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ResetPassordToken" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
24 changes: 17 additions & 7 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -52,16 +52,26 @@ 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
expires DateTime
@@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())
}
111 changes: 111 additions & 0 deletions src/app/[lang]/(sys-auth)/forgot-password/form.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof formSchema>>

export default function ForgotPasswordForm({ dictionary }: { dictionary: TDictionary }) {
const router = useRouter()

const [latestEmailSentAt, setLatestEmailSentAt] = useState<number | null>(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<IForm>({
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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className={"relative !mt-5 grid w-[350px] space-y-2"}>
<div className="grid gap-1">
<Label className="sr-only" htmlFor="email">
{dictionary.email}
</Label>
<FormField
placeholder={dictionary.emailPlaceholder}
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
disabled={isDisabled}
form={form}
name="email"
/>
</div>
<Button type="submit" isLoading={isLoading} disabled={isDisabled}>
{dictionary.send}
</Button>
{latestEmailSentAt !== null && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="absolute -bottom-1 right-0 ml-auto flex w-max translate-y-full" type="button">
<AutoRefresh
callback={() => {
const retryInValue = retryIn()
const retryInFormatted =
retryInValue && retryInValue.getTime() > 0
? `${Math.floor(retryInValue.getTime() / 1000 / 60)}:${
Math.floor(retryInValue.getTime() / 1000) % 60
}`
: null
return (
<div className="ml-auto flex flex-row items-center text-sm text-gray-500">
<Clock className="mr-1 inline-block h-4 w-4" />
{retryInFormatted}
</div>
)
}}
interval={1000}
/>
</TooltipTrigger>
<TooltipContent>
<p>{dictionary.timeUntilYouCanRequestAnotherEmail}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</form>
</Form>
)
}
21 changes: 21 additions & 0 deletions src/app/[lang]/(sys-auth)/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="container flex flex-1 flex-col items-center justify-center space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">{dictionary.forgotPasswordTitle}</h1>
<p className="text-sm text-muted-foreground">{dictionary.forgotPasswordDescription}</p>
<ForgotPasswordForm dictionary={dictionary} />
</main>
)
}
Loading

0 comments on commit c1d8251

Please sign in to comment.