diff --git a/DTO/error_dto.go b/DTO/error_dto.go new file mode 100644 index 0000000..5ab68da --- /dev/null +++ b/DTO/error_dto.go @@ -0,0 +1,5 @@ +package dto + +type ErrorDTO struct { + Error string `json:"error"` +} diff --git a/DTO/generic_dto.go b/DTO/generic_dto.go new file mode 100644 index 0000000..a304f2a --- /dev/null +++ b/DTO/generic_dto.go @@ -0,0 +1,5 @@ +package dto + +type GenericDTO struct { + Message string `json:"message"` +} diff --git a/DTO/user_dto.go b/DTO/user_dto.go new file mode 100644 index 0000000..acf753a --- /dev/null +++ b/DTO/user_dto.go @@ -0,0 +1,8 @@ +package dto + +import "github.com/Fingertips18/go-auth/models" + +type UserDTO struct { + Message string `json:"message"` + User models.User `json:"user"` +} diff --git a/client/package-lock.json b/client/package-lock.json index 3b89572..6612d34 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -10,17 +10,17 @@ "license": "MIT", "dependencies": { "@tanstack/react-query": "^5.56.1", - "js-cookie": "^3.0.5", + "cookies": "^0.9.1", "lucide-react": "^0.440.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.2", "react-tooltip": "^5.28.0", - "sonner": "^1.5.0" + "sonner": "^1.5.0", + "zustand": "^4.5.5" }, "devDependencies": { "@eslint/js": "^9.9.0", - "@types/js-cookie": "^3.0.6", "@types/node": "^22.5.4", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -32,8 +32,8 @@ "globals": "^15.9.0", "postcss": "^8.4.45", "tailwindcss": "^3.4.11", - "typescript": "^5.5.3", - "typescript-eslint": "^8.0.1", + "typescript": "^5.6.2", + "typescript-eslint": "^8.5.0", "vite": "^5.4.1" } }, @@ -1259,13 +1259,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/js-cookie": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", - "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { "version": "22.5.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz", @@ -1280,14 +1273,14 @@ "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.5", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.5.tgz", "integrity": "sha512-WeqMfGJLGuLCqHGYRGHxnKrXcTitc6L/nBUWfWPcTarG3t9PsquqUMuVeXZeca+mglY4Vo5GZjCi0A3Or2lnxA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1901,6 +1894,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -1933,7 +1939,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -1961,6 +1967,15 @@ "dev": true, "license": "MIT" }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2671,15 +2686,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2720,6 +2726,18 @@ "dev": true, "license": "MIT" }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3840,6 +3858,15 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3939,6 +3966,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz", + "integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -4158,6 +4194,34 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.5.tgz", + "integrity": "sha512-+0PALYNJNgK6hldkgDq2vLrw5f6g/jCInz52n9RTpropGgeAf/ioFUCdtsjCqu4gNhW9D01rUQBROoRjdzyn2Q==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/client/package.json b/client/package.json index 4997937..5c29965 100644 --- a/client/package.json +++ b/client/package.json @@ -16,17 +16,17 @@ }, "dependencies": { "@tanstack/react-query": "^5.56.1", - "js-cookie": "^3.0.5", + "cookies": "^0.9.1", "lucide-react": "^0.440.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.2", "react-tooltip": "^5.28.0", - "sonner": "^1.5.0" + "sonner": "^1.5.0", + "zustand": "^4.5.5" }, "devDependencies": { "@eslint/js": "^9.9.0", - "@types/js-cookie": "^3.0.6", "@types/node": "^22.5.4", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -38,8 +38,8 @@ "globals": "^15.9.0", "postcss": "^8.4.45", "tailwindcss": "^3.4.11", - "typescript": "^5.5.3", - "typescript-eslint": "^8.0.1", + "typescript": "^5.6.2", + "typescript-eslint": "^8.5.0", "vite": "^5.4.1" } } diff --git a/client/src/App.tsx b/client/src/App.tsx index d0f97cd..5a15f5e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,20 +1,25 @@ import { Route, Routes } from "react-router-dom"; -import ProtectedGuard from "@/guards/protected-guard"; +import VerifyEmailPage from "@/pages/verify-email/page"; +import PrivateGuard from "@/guards/private-guard"; import { AppRoutes } from "@/constants/routes"; import SignUpPage from "@/pages/sign-up/page"; import SignInPage from "@/pages/sign-in/page"; +import AuthGuard from "@/guards/auth-guard"; import RootPage from "@/pages/root/page"; function App() { return (
- }> + }> } /> - } /> - } /> + }> + } /> + } /> + } /> +
); diff --git a/client/src/components/input.tsx b/client/src/components/input.tsx index be62024..9b55b8a 100644 --- a/client/src/components/input.tsx +++ b/client/src/components/input.tsx @@ -83,8 +83,8 @@ const Input = ({ disabled={disabled} required={required} maxLength={maxLength} - className={`w-full md:w-[400px] bg-background p-2.5 rounded-lg outline-none border ring-1 focus:ring-2 transition-all placeholder-foreground/50 - px-11 disabled:bg-opacity-25 disabled:border-primary/25 disabled:text-foreground/50 disabled:pointer-events-none + className={`w-full md:w-[400px] bg-background py-2.5 rounded-lg outline-none border ring-1 focus:ring-2 transition-all + placeholder-foreground/50 px-11 disabled:bg-opacity-25 disabled:border-primary/25 disabled:text-foreground/50 disabled:pointer-events-none ${ hasInput ? valid diff --git a/client/src/components/loading.tsx b/client/src/components/loading.tsx index 22ae20b..e27080f 100644 --- a/client/src/components/loading.tsx +++ b/client/src/components/loading.tsx @@ -2,7 +2,7 @@ import { Loader2 } from "lucide-react"; const Loading = () => { return ( -
+
{ + const { authorized } = useAuthStore(); + + if (authorized) { + return ; + } + + return ; +}; + +export default AuthGuard; diff --git a/client/src/guards/protected-guard.tsx b/client/src/guards/private-guard.tsx similarity index 56% rename from client/src/guards/protected-guard.tsx rename to client/src/guards/private-guard.tsx index 4a3e37d..8cd4410 100644 --- a/client/src/guards/protected-guard.tsx +++ b/client/src/guards/private-guard.tsx @@ -1,22 +1,31 @@ import { Navigate, Outlet } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; -import Cookies from "js-cookie"; +import { useEffect } from "react"; import { AuthService } from "@/lib/services/auth-service"; +import { useAuthStore } from "@/lib/stores/auth-store"; import { VERIFYTOKENKEY } from "@/constants/keys"; import { AppRoutes } from "@/constants/routes"; import { Loading } from "@/components/loading"; -const ProtectedGuard = () => { - const token = Cookies.get("token"); +const PrivateGuard = () => { + const { authorized, setAuthorized } = useAuthStore(); - const { isLoading, isError } = useQuery({ + const { isLoading, isError, isSuccess } = useQuery({ queryKey: [VERIFYTOKENKEY], queryFn: AuthService.verifyToken, - enabled: !!token, }); - if (!token) { + useEffect(() => { + if (isSuccess) { + setAuthorized(true); + } + if (isError) { + setAuthorized(false); + } + }, [isSuccess, isError, setAuthorized]); + + if (!authorized) { return ; } @@ -31,4 +40,4 @@ const ProtectedGuard = () => { return ; }; -export default ProtectedGuard; +export default PrivateGuard; diff --git a/client/src/lib/DTO/sign-in.dto.ts b/client/src/lib/DTO/sign-in-dto.ts similarity index 100% rename from client/src/lib/DTO/sign-in.dto.ts rename to client/src/lib/DTO/sign-in-dto.ts diff --git a/client/src/lib/DTO/sign-up.dto.ts b/client/src/lib/DTO/sign-up-dto.ts similarity index 100% rename from client/src/lib/DTO/sign-up.dto.ts rename to client/src/lib/DTO/sign-up-dto.ts diff --git a/client/src/lib/DTO/user-dto.ts b/client/src/lib/DTO/user-dto.ts new file mode 100644 index 0000000..cca01dd --- /dev/null +++ b/client/src/lib/DTO/user-dto.ts @@ -0,0 +1,9 @@ +export type UserDTO = { + id: string; + username: string; + email_address: string; + last_signed_in: Date; + is_verified: boolean; + created_at: Date; + updated_at: Date; +}; diff --git a/client/src/lib/classes/error-response-class.ts b/client/src/lib/classes/error-response-class.ts new file mode 100644 index 0000000..fcd65fb --- /dev/null +++ b/client/src/lib/classes/error-response-class.ts @@ -0,0 +1,15 @@ +type ErrorResponseType = { + status: number; + message: string; +}; + +export class ErrorResponse extends Error { + public status: number; + public message: string; + + constructor({ status, message }: ErrorResponseType) { + super(message); + this.status = status; + this.message = message; + } +} diff --git a/client/src/lib/classes/generic-response-class.ts b/client/src/lib/classes/generic-response-class.ts new file mode 100644 index 0000000..5aff4a3 --- /dev/null +++ b/client/src/lib/classes/generic-response-class.ts @@ -0,0 +1,11 @@ +type GenericResponseType = { + message: string; +}; + +export class GenericResponse { + public message: string; + + constructor({ message }: GenericResponseType) { + this.message = message; + } +} diff --git a/client/src/lib/classes/user-response-class.ts b/client/src/lib/classes/user-response-class.ts new file mode 100644 index 0000000..b1ca91a --- /dev/null +++ b/client/src/lib/classes/user-response-class.ts @@ -0,0 +1,16 @@ +import { UserDTO } from "@/lib/DTO/user-dto"; + +type UserResponseType = { + message: string; + user: UserDTO; +}; + +export class UserResponse { + public message: string; + public user: UserDTO; + + constructor({ message, user }: UserResponseType) { + this.message = message; + this.user = user; + } +} diff --git a/client/src/lib/services/auth-service.ts b/client/src/lib/services/auth-service.ts index c454281..dd5fed4 100644 --- a/client/src/lib/services/auth-service.ts +++ b/client/src/lib/services/auth-service.ts @@ -1,5 +1,8 @@ -import { SignUpDTO } from "@/lib/DTO/sign-up.dto"; -import { SignInDTO } from "@/lib/DTO/sign-in.dto"; +import { GenericResponse } from "@/lib/classes/generic-response-class"; +import { ErrorResponse } from "@/lib/classes/error-response-class"; +import { UserResponse } from "@/lib/classes/user-response-class"; +import { SignUpDTO } from "@/lib/DTO/sign-up-dto"; +import { SignInDTO } from "@/lib/DTO/sign-in-dto"; import { AppRoutes } from "@/constants/routes"; const baseURL = @@ -18,10 +21,16 @@ export const AuthService = { const data = await res.json(); if (!res.ok) { - throw new Error(data.error); + throw new ErrorResponse({ + status: res.status, + message: data.error, + }); } - return data; + return new UserResponse({ + message: data.message, + user: data.user, + }); }, signIn: async (signIn: SignInDTO) => { const res = await fetch(`${baseURL}${AppRoutes.SignIn}`, { @@ -36,10 +45,15 @@ export const AuthService = { const data = await res.json(); if (!res.ok) { - throw new Error(data.error); + throw new ErrorResponse({ + status: res.status, + message: data.error, + }); } - return data; + return new GenericResponse({ + message: data.message, + }); }, signOut: async () => { const res = await fetch(`${baseURL}${AppRoutes.SignOut}`, { @@ -50,10 +64,59 @@ export const AuthService = { const data = await res.json(); if (!res.ok) { - throw new Error(data.error); + throw new Error("Unable to sign out"); } - return data; + return new GenericResponse({ + message: data.message, + }); + }, + verifyEmail: async (token: string) => { + const res = await fetch(`${baseURL}${AppRoutes.VerifyEmail}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + token: token, + }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new ErrorResponse({ + status: res.status, + message: data.error, + }); + } + + return new GenericResponse({ + message: data.message, + }); + }, + resendVerify: async (email: string) => { + const res = await fetch(`${baseURL}${AppRoutes.ResendVerify}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: email, + }), + }); + + const data = await res.json(); + if (!res.ok) { + return new ErrorResponse({ + status: res.status, + message: data.error, + }); + } + + return new GenericResponse({ + message: data.message, + }); }, verifyToken: async () => { const res = await fetch(`${baseURL}${AppRoutes.VerifyToken}`, { @@ -64,9 +127,15 @@ export const AuthService = { const data = await res.json(); if (!res.ok) { - throw new Error(data.error); + throw new ErrorResponse({ + status: res.status, + message: data.error, + }); } - return data; + return new UserResponse({ + message: data.message, + user: data.user, + }); }, }; diff --git a/client/src/lib/stores/auth-store.ts b/client/src/lib/stores/auth-store.ts new file mode 100644 index 0000000..1f6860a --- /dev/null +++ b/client/src/lib/stores/auth-store.ts @@ -0,0 +1,24 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +interface AuthStoreState { + email: string; + setEmail: (email: string) => void; + authorized: boolean; + setAuthorized: (authorized: boolean) => void; +} + +export const useAuthStore = create( + persist( + (set) => ({ + email: "", + setEmail: (email: string) => set({ email }), + authorized: false, + setAuthorized: (authorized: boolean) => set({ authorized }), + }), + { + name: "email", + storage: createJSONStorage(() => sessionStorage), + } + ) +); diff --git a/client/src/pages/root/_components/header/sign-out-button.tsx b/client/src/pages/root/_components/header/sign-out-button.tsx index 5ae05c1..a2b8f14 100644 --- a/client/src/pages/root/_components/header/sign-out-button.tsx +++ b/client/src/pages/root/_components/header/sign-out-button.tsx @@ -1,24 +1,24 @@ import { useMutation } from "@tanstack/react-query"; -import { useNavigate } from "react-router-dom"; import { DoorOpen } from "lucide-react"; import { toast } from "sonner"; +import { GenericResponse } from "@/lib/classes/generic-response-class"; import { AuthService } from "@/lib/services/auth-service"; +import { useAuthStore } from "@/lib/stores/auth-store"; import IconButton from "@/components/icon-button"; -import { AppRoutes } from "@/constants/routes"; import { SIGNOUTKEY } from "@/constants/keys"; const SignOutButton = () => { - const navigate = useNavigate(); + const { setAuthorized } = useAuthStore(); const { mutate, isPending } = useMutation({ mutationKey: [SIGNOUTKEY], mutationFn: AuthService.signOut, - onSuccess: () => { - toast.success("Signed out successfully"); - navigate(AppRoutes.SignIn); + onSuccess: (res: GenericResponse) => { + toast.success(res.message); + setAuthorized(false); }, - onError: ({ message }) => toast.error(message || "Unable to sign out"), + onError: ({ message }) => toast.error(message), }); return ; diff --git a/client/src/pages/sign-in/_components/sign-in-form.tsx b/client/src/pages/sign-in/_components/sign-in-form.tsx index d6cf3ce..d31d9c8 100644 --- a/client/src/pages/sign-in/_components/sign-in-form.tsx +++ b/client/src/pages/sign-in/_components/sign-in-form.tsx @@ -3,9 +3,12 @@ import { useNavigate } from "react-router-dom"; import { FormEvent } from "react"; import { toast } from "sonner"; +import { GenericResponse } from "@/lib/classes/generic-response-class"; +import { ErrorResponse } from "@/lib/classes/error-response-class"; import { AuthService } from "@/lib/services/auth-service"; +import { useAuthStore } from "@/lib/stores/auth-store"; import { SIGNININPUTS } from "@/constants/collections"; -import { SignInDTO } from "@/lib/DTO/sign-in.dto"; +import { SignInDTO } from "@/lib/DTO/sign-in-dto"; import { Button } from "@/components/text-button"; import { AppRoutes } from "@/constants/routes"; import { SIGNINKEY } from "@/constants/keys"; @@ -13,15 +16,23 @@ import { Input } from "@/components/input"; const SignInForm = () => { const navigate = useNavigate(); + const { setEmail, setAuthorized } = useAuthStore(); const { mutate, isPending } = useMutation({ mutationKey: [SIGNINKEY], mutationFn: AuthService.signIn, - onSuccess: () => { - toast.success("Welcome! You have signed in"); - navigate(AppRoutes.Root); + onSuccess: (res: GenericResponse) => { + toast.success(res.message); + setAuthorized(true); + }, + onError: (error: ErrorResponse) => { + toast.error("Please verify to sign in"); + + if (error.status == 403) { + navigate(AppRoutes.VerifyEmail); + } + setAuthorized(false); }, - onError: ({ message }) => toast.error(message || "Unable to sign in"), }); const onSubmit = (e: FormEvent) => { @@ -31,6 +42,8 @@ const SignInForm = () => { const signInData = Object.fromEntries(formData.entries()) as SignInDTO; + setEmail(signInData.email); + mutate(signInData); }; diff --git a/client/src/pages/sign-in/page.tsx b/client/src/pages/sign-in/page.tsx index f62d12c..ea3e11c 100644 --- a/client/src/pages/sign-in/page.tsx +++ b/client/src/pages/sign-in/page.tsx @@ -6,7 +6,7 @@ import { SignInForm } from "./_components/sign-in-form"; const SignInPage = () => { return ( -
+
diff --git a/client/src/pages/sign-up/_components/sign-up-form.tsx b/client/src/pages/sign-up/_components/sign-up-form.tsx index e713ed4..d1357db 100644 --- a/client/src/pages/sign-up/_components/sign-up-form.tsx +++ b/client/src/pages/sign-up/_components/sign-up-form.tsx @@ -3,9 +3,10 @@ import { useNavigate } from "react-router-dom"; import { FormEvent, useState } from "react"; import { toast } from "sonner"; +import { ErrorResponse } from "@/lib/classes/error-response-class"; import { AuthService } from "@/lib/services/auth-service"; import { SIGNUPINPUTS } from "@/constants/collections"; -import { SignUpDTO } from "@/lib/DTO/sign-up.dto"; +import { SignUpDTO } from "@/lib/DTO/sign-up-dto"; import { Button } from "@/components/text-button"; import { AppRoutes } from "@/constants/routes"; import { SIGNUPKEY } from "@/constants/keys"; @@ -22,7 +23,7 @@ const SignUpForm = () => { toast.success("Registered successfully"); navigate(AppRoutes.SignIn); }, - onError: ({ message }) => toast.error(message || "Unable to register"), + onError: (error: ErrorResponse) => toast.error(error.message), }); const onSubmit = (e: FormEvent) => { diff --git a/client/src/pages/sign-up/page.tsx b/client/src/pages/sign-up/page.tsx index 38efb87..36dfa64 100644 --- a/client/src/pages/sign-up/page.tsx +++ b/client/src/pages/sign-up/page.tsx @@ -6,7 +6,7 @@ import { SignUpForm } from "./_components/sign-up-form"; const SignUpPage = () => { return ( -
+
diff --git a/client/src/pages/verify-email/_components/resend-code.tsx b/client/src/pages/verify-email/_components/resend-code.tsx new file mode 100644 index 0000000..1a00480 --- /dev/null +++ b/client/src/pages/verify-email/_components/resend-code.tsx @@ -0,0 +1,37 @@ +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { GenericResponse } from "@/lib/classes/generic-response-class"; +import { ErrorResponse } from "@/lib/classes/error-response-class"; +import { AuthService } from "@/lib/services/auth-service"; +import { useAuthStore } from "@/lib/stores/auth-store"; +import { RESENDVERIFYKEY } from "@/constants/keys"; + +const ResendCode = () => { + const { email } = useAuthStore(); + + const { mutate, isPending } = useMutation({ + mutationKey: [RESENDVERIFYKEY], + mutationFn: AuthService.resendVerify, + onSuccess: (res: GenericResponse) => toast.success(res.message), + onError: (error: ErrorResponse) => toast.error(error.message), + }); + + const onClick = () => mutate(email); + + return ( +
+

Didn't receive a code?

+ +
+ ); +}; + +export { ResendCode }; diff --git a/client/src/pages/verify-email/_components/single-input.tsx b/client/src/pages/verify-email/_components/single-input.tsx new file mode 100644 index 0000000..50b9649 --- /dev/null +++ b/client/src/pages/verify-email/_components/single-input.tsx @@ -0,0 +1,38 @@ +import { ChangeEventHandler, KeyboardEventHandler, LegacyRef } from "react"; + +interface SingleInputProps { + name: string; + iref: LegacyRef | undefined; + digit: string; + onChange: ChangeEventHandler | undefined; + onKeyDown: KeyboardEventHandler | undefined; + disabled?: boolean; +} + +const SingleInput = ({ + name, + iref, + digit, + onChange, + onKeyDown, + disabled, +}: SingleInputProps) => { + return ( + + ); +}; + +export { SingleInput }; diff --git a/client/src/pages/verify-email/_components/verify-email-form.tsx b/client/src/pages/verify-email/_components/verify-email-form.tsx new file mode 100644 index 0000000..64eab98 --- /dev/null +++ b/client/src/pages/verify-email/_components/verify-email-form.tsx @@ -0,0 +1,115 @@ +import { FormEvent, useEffect, useRef, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; +import { toast } from "sonner"; + +import { GenericResponse } from "@/lib/classes/generic-response-class"; +import { ErrorResponse } from "@/lib/classes/error-response-class"; +import { AuthService } from "@/lib/services/auth-service"; +import { Button } from "@/components/text-button"; +import { VERIFYEMAILKEY } from "@/constants/keys"; +import { AppRoutes } from "@/constants/routes"; + +import { SingleInput } from "./single-input"; + +const VerifyEmailForm = () => { + const [code, setCode] = useState(["", "", "", ""]); + const inputRef = useRef([]); + const formRef = useRef(null); + const navigate = useNavigate(); + + const { mutate, isPending } = useMutation({ + mutationKey: [VERIFYEMAILKEY], + mutationFn: AuthService.verifyEmail, + onSuccess: (res: GenericResponse) => { + toast.success(res.message); + navigate(AppRoutes.SignIn); + }, + onError: (error: ErrorResponse) => toast.error(error.message), + }); + + const onChange = (value: string, index: number) => { + if (index === 3 && value.length > 1) return; + + value = value.replace(/\D/g, ""); + + let newCode = [...code]; + + if (value.length > 1) { + const pastedCode = value.slice(0, 4).split(""); + newCode = pastedCode; + console.log(newCode); + setCode(newCode); + + const lastFilledIndex = newCode + .slice() + .reverse() + .findIndex((digit) => digit != ""); + const adjustedIndex = + lastFilledIndex >= 0 ? newCode.length - 1 - lastFilledIndex : -1; + const focusIndex = adjustedIndex < 3 ? adjustedIndex + 1 : 3; + inputRef.current[focusIndex]?.focus(); + } else { + newCode[index] = value; + setCode(newCode); + + if (value && index < 5) { + inputRef.current[index + 1]?.focus(); + } + } + }; + + const onKeyDown = ( + e: React.KeyboardEvent, + index: number + ) => { + if (e.key === "Backspace" && !code[index] && index > 0) { + inputRef.current[index - 1]?.focus(); + } + }; + + const onSubmit = (e: FormEvent) => { + e.preventDefault(); + + const verificationCode = code.join(""); + + mutate(verificationCode); + }; + + useEffect(() => { + if (code.every((digit) => digit !== "")) { + const event = new Event("submit", { bubbles: true }); + formRef.current?.dispatchEvent(event); + } + }, [code]); + + return ( +
+
+ {code.map((digit, i) => ( + { + if (el) { + return (inputRef.current[i] = el); + } + }} + digit={digit} + onChange={(e) => onChange(e.target.value, i)} + onKeyDown={(e) => onKeyDown(e, i)} + disabled={isPending} + /> + ))} +
+