From 2889deaf120652117eebede72cb7d7241195e625 Mon Sep 17 00:00:00 2001 From: "manuel.penaa" <143857167+ManuP6789@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:48:17 -0500 Subject: [PATCH 1/4] 30 frontend backend final log in flow (#39) * connected siginIn frontend * login flow works need to add error handling * add error logic * add Enter onpress --------- Co-authored-by: wkim10 --- src/app/api/auth/[...nextauth]/options.ts | 10 +++- src/app/public/{login => signIn}/page.tsx | 0 src/components/Login.tsx | 61 ++++++++++++++++++++--- 3 files changed, 61 insertions(+), 10 deletions(-) rename src/app/public/{login => signIn}/page.tsx (100%) diff --git a/src/app/api/auth/[...nextauth]/options.ts b/src/app/api/auth/[...nextauth]/options.ts index 136e0dc..4807815 100644 --- a/src/app/api/auth/[...nextauth]/options.ts +++ b/src/app/api/auth/[...nextauth]/options.ts @@ -30,14 +30,20 @@ export const options: NextAuthOptions = { // Docs: https://next-auth.js.org/configuration/providers/credentials const user: User | null = await getUserByEmailServer(email); - + // Check if user exists and if password matches + if (!user) { + throw new Error("Invalid user"); + } if (user && (await compare(password, user.password))) { return user; // Authentication successful } else { - return null; // Authentication failed + throw new Error("Invalid password"); } }, }), ], + pages: { + signIn: "/public/signIn", + }, }; diff --git a/src/app/public/login/page.tsx b/src/app/public/signIn/page.tsx similarity index 100% rename from src/app/public/login/page.tsx rename to src/app/public/signIn/page.tsx diff --git a/src/components/Login.tsx b/src/components/Login.tsx index bb8003d..cfd8d95 100644 --- a/src/components/Login.tsx +++ b/src/components/Login.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import TextField from "@mui/material/TextField"; import Checkbox from "@mui/material/Checkbox"; import FormControlLabel from "@mui/material/FormControlLabel"; @@ -13,11 +13,46 @@ import VisibilityIcon from "@mui/icons-material/Visibility"; import Image from "next/image"; import logo1 from "../../public/logo1.png"; +import { signIn } from "next-auth/react"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; + export default function LoginForm() { const [showPassword, setShowPassword] = useState(false); const [password, setPassword] = useState(""); const [email, setEmail] = useState(""); - const [displayError, setDisplayError] = useState(false); + const [emailDisplayError, setEmailDisplayError] = useState(false); + const [passwordDisplayError, setPasswordDisplayError] = useState(false); + const router = useRouter(); + const { status } = useSession(); + + useEffect(() => { + if (status === "authenticated") { + router.push("/"); // Change to your desired redirect path + } + }, [status, router]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const res = await signIn("credentials", { + email, + password, + redirect: false, + }); + + if (res?.error) { + if (res?.error == "Invalid password") { + setPasswordDisplayError(true); + setTimeout(() => setPasswordDisplayError(false), 3000); + } else if (res?.error == "Invalid user") { + setEmailDisplayError(true); + setTimeout(() => setEmailDisplayError(false), 3000); + } + } else { + window.location.href = "/"; + } + }; const CustomInputProps = { endAdornment: ( @@ -70,8 +105,13 @@ export default function LoginForm() { onChange={(e) => { setEmail(e.target.value); }} - error={displayError} - helperText={displayError && "Couldn't find your account"} + onKeyUp={(e) => { + if (e.key === "Enter" && email !== "" && password !== "") { + handleSubmit(e); + } + }} + error={emailDisplayError} + helperText={emailDisplayError && "Couldn't find your account"} /> { + if (e.key === "Enter" && email !== "" && password !== "") { + handleSubmit(e); + } + }} + error={passwordDisplayError} + helperText={passwordDisplayError && "Wrong Password"} />
{ + onClick={(e) => { if (email !== "" && password !== "") { - setDisplayError(true); + handleSubmit(e); } }} > From 4411c676b02e65edac4d2a435c5f3125005ae41e Mon Sep 17 00:00:00 2001 From: Johnny Tan <115383099+johnny-t06@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:49:05 -0500 Subject: [PATCH 2/4] Implement forgot password flow (#40) * Implement forgot password flow * add Enter key continue * Handle submit with Enter --- package-lock.json | 21 +++ package.json | 2 + prisma/schema.prisma | 9 ++ src/app/api/password/route.client.ts | 23 ++++ src/app/api/password/route.ts | 76 +++++++++++ src/app/api/verify-code/route.client.ts | 11 ++ src/app/api/verify-code/route.ts | 54 ++++++++ src/components/ForgotPassword.tsx | 170 +++++++++++++----------- src/lib/nodemail.ts | 25 ++++ 9 files changed, 316 insertions(+), 75 deletions(-) create mode 100644 src/app/api/password/route.client.ts create mode 100644 src/app/api/password/route.ts create mode 100644 src/app/api/verify-code/route.client.ts create mode 100644 src/app/api/verify-code/route.ts create mode 100644 src/lib/nodemail.ts diff --git a/package-lock.json b/package-lock.json index 33d6830..bb87172 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "iconify": "^1.4.0", "next": "14.2.14", "next-auth": "^4.24.10", + "nodemailer": "^6.9.16", "prisma": "^5.20.0", "react": "^18", "react-dom": "^18" @@ -27,6 +28,7 @@ "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/node": "^20", + "@types/nodemailer": "^6.4.17", "@types/react": "^18", "@types/react-dom": "^18", "eslint": "^8", @@ -1227,6 +1229,16 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.17", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz", + "integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -4856,6 +4868,15 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/nodemailer": { + "version": "6.9.16", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.16.tgz", + "integrity": "sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", diff --git a/package.json b/package.json index e112cd4..6f0c1bf 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "iconify": "^1.4.0", "next": "14.2.14", "next-auth": "^4.24.10", + "nodemailer": "^6.9.16", "prisma": "^5.20.0", "react": "^18", "react-dom": "^18" @@ -28,6 +29,7 @@ "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/node": "^20", + "@types/nodemailer": "^6.4.17", "@types/react": "^18", "@types/react-dom": "^18", "eslint": "^8", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e618096..243f05c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,6 +26,7 @@ model User { email String @unique password String @default("") volunteerDetails VolunteerDetails? + code Code? eventIds String[] @db.ObjectId events Event[] @relation(fields: [eventIds], references: [id]) } @@ -46,6 +47,14 @@ model VolunteerDetails { userId String @unique @db.ObjectId } +model Code { + id String @id @default(auto()) @map("_id") @db.ObjectId + codeString String @default("") + expire DateTime @default(now()) + user User @relation(fields: [userId], references: [id]) + userId String @unique @db.ObjectId +} + model Event { id String @id @default(auto()) @map("_id") @db.ObjectId userIds String[] @db.ObjectId diff --git a/src/app/api/password/route.client.ts b/src/app/api/password/route.client.ts new file mode 100644 index 0000000..4940d1b --- /dev/null +++ b/src/app/api/password/route.client.ts @@ -0,0 +1,23 @@ +export const sendForgotCode = async (email: string) => { + const response = await fetch("/api/password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + + const json = await response.json(); + + return json; +}; + +export const updatePassword = async (email: string, password: string) => { + const response = await fetch("/api/password", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + const json = await response.json(); + + return json; +}; diff --git a/src/app/api/password/route.ts b/src/app/api/password/route.ts new file mode 100644 index 0000000..468332a --- /dev/null +++ b/src/app/api/password/route.ts @@ -0,0 +1,76 @@ +import { PrismaClient } from "@prisma/client"; +import { NextRequest, NextResponse } from "next/server"; +import { sendMail } from "../../../lib/nodemail"; +import bcrypt from "bcryptjs"; + +const prisma = new PrismaClient(); + +export const POST = async (request: NextRequest) => { + try { + const { email } = await request.json(); + const user = await prisma.user.findUnique({ + where: { + email, + }, + }); + if (!user) { + return NextResponse.json({ + code: "ERROR", + message: "Email not found", + }); + } + + const codeString = Math.floor(Math.random() * 10000) + .toString() + .padStart(4, "6"); + const expire = new Date(Date.now() + 1000 * 60); + + await prisma.code.update({ + where: { userId: user.id }, + data: { codeString, expire }, + }); + + await sendMail(email, codeString); + + return NextResponse.json({ + code: "SUCCESS", + message: "Forgot password email sent", + }); + } catch (error) { + console.error("Error:", error); + return NextResponse.json({ + code: "ERROR", + message: error, + }); + } +}; + +export const PUT = async (request: NextRequest) => { + try { + const { email, password } = await request.json(); + + // Hash the user's new password + const saltRounds = 10; + const hashedPassword = await bcrypt.hash(password, saltRounds); + + await prisma.user.update({ + where: { + email, + }, + data: { + password: hashedPassword, + }, + }); + + return NextResponse.json({ + code: "SUCCESS", + message: "Password successfully updated", + }); + } catch (error) { + console.error("Error:", error); + return NextResponse.json({ + code: "ERROR", + message: error, + }); + } +}; diff --git a/src/app/api/verify-code/route.client.ts b/src/app/api/verify-code/route.client.ts new file mode 100644 index 0000000..8c92fe2 --- /dev/null +++ b/src/app/api/verify-code/route.client.ts @@ -0,0 +1,11 @@ +export const verifyCode = async (email: string, code: string) => { + const response = await fetch("/api/verify-code", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, code }), + }); + + const json = await response.json(); + + return json; +}; diff --git a/src/app/api/verify-code/route.ts b/src/app/api/verify-code/route.ts new file mode 100644 index 0000000..0ef7ce3 --- /dev/null +++ b/src/app/api/verify-code/route.ts @@ -0,0 +1,54 @@ +import { PrismaClient } from "@prisma/client"; +import { NextRequest, NextResponse } from "next/server"; + +const prisma = new PrismaClient(); + +export const POST = async (request: NextRequest) => { + try { + const { email, code } = await request.json(); + const user = await prisma.user.findUnique({ + where: { + email, + }, + }); + + if (!user) { + return NextResponse.json({ + code: "ERROR", + message: "Email not found", + }); + } + + const codeDetails = await prisma.code.findUnique({ + where: { userId: user.id }, + }); + + if (!codeDetails) { + return NextResponse.json({ + code: "ERROR", + message: "Code Details not found", + }); + } + + if ( + codeDetails.codeString !== code || + codeDetails.expire < new Date(Date.now()) + ) { + return NextResponse.json({ + code: "ERROR", + message: "Code is not valid or code is expired", + }); + } + + return NextResponse.json({ + code: "SUCCESS", + message: "Code is valid", + }); + } catch (error) { + console.error("Error:", error); + return NextResponse.json({ + code: "ERROR", + message: error, + }); + } +}; diff --git a/src/components/ForgotPassword.tsx b/src/components/ForgotPassword.tsx index a712795..17be861 100644 --- a/src/components/ForgotPassword.tsx +++ b/src/components/ForgotPassword.tsx @@ -10,6 +10,8 @@ import InputAdornment from "@mui/material/InputAdornment"; import IconButton from "@mui/material/IconButton"; import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; import VisibilityIcon from "@mui/icons-material/Visibility"; +import { sendForgotCode, updatePassword } from "@api/password/route.client"; +import { verifyCode } from "@api/verify-code/route.client"; export default function ForgotPasswordForm() { const router = useRouter(); @@ -21,51 +23,57 @@ export default function ForgotPasswordForm() { const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [code, setCode] = useState(["", "", "", ""]); - const [codeError, setCodeError] = useState(false); - const [codeBackendError, setCodeBackendError] = useState(false); const inputs = useRef<(HTMLInputElement | null)[]>([]); const [error, setError] = useState(""); - const [passwordError, setPasswordError] = useState(false); - const [counter, setCounter] = React.useState(30); + const [counter, setCounter] = React.useState(60); - const handleEmailSubmit = () => { + const handleEmailSubmit = async () => { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - const emailFound = true; // Placeholder, replace with backend logic if (!emailRegex.test(email.trim())) { setError("Please enter a valid email address."); - } else if (!emailFound) { + return; + } + + const codeResponse = await sendForgotCode(email); + + if (codeResponse.message === "Email not found") { setError("Couldn't find your account"); + } else if (codeResponse.code === "ERROR") { + setError(codeResponse.message); } else { setError(""); setStep(2); - setCounter(30); + setCounter(60); } }; - const handlePasswordSubmit = () => { + const handlePasswordSubmit = async () => { if ( confirmPassword !== newPassword || newPassword.trim() === "" || confirmPassword.trim() === "" ) { - setPasswordError(true); + setError("Passwords do not match or empty password"); } else { - setPasswordError(false); + const updatedMessage = await updatePassword(email, newPassword); + if (updatedMessage.code === "ERROR") { + setError(updatedMessage.message); + return; + } + setError(""); setStep(4); } }; - const handleCodeSubmit = () => { - const isCodeCorrect = true; // Placeholder, replace with backend logic + const handleCodeSubmit = async () => { + const verifyReponse = await verifyCode(email, code.join("")); + if (code.some((digit) => digit === "")) { - setCodeError(true); - setCodeBackendError(false); - } else if (!isCodeCorrect) { - setCodeBackendError(true); - setCodeError(false); + setError("Please enter all 4 digits"); + } else if (verifyReponse.code === "ERROR") { + setError("Invalid code or expired code"); } else { - setCodeError(false); - setCodeBackendError(false); + setError(""); setStep(3); } }; @@ -77,9 +85,8 @@ export default function ForgotPasswordForm() { newCode[index] = value; setCode(newCode); - if (codeError || codeBackendError) { - setCodeError(false); - setCodeBackendError(false); + if (error) { + setError(""); } if (value && index < 3 && inputs.current[index + 1]) { @@ -87,6 +94,20 @@ export default function ForgotPasswordForm() { } }; + const handleCodeResend = async () => { + setCounter(60); + setError(""); + setCode(["", "", "", ""]); + + const codeResponse = await sendForgotCode(email); + + if (codeResponse.message === "Email not found") { + setError("Couldn't find your account"); + } else if (codeResponse.code === "ERROR") { + setError(codeResponse.message); + } + }; + const handleKeyDown = (e: React.KeyboardEvent, index: number) => { if ( e.key === "Backspace" && @@ -96,9 +117,6 @@ export default function ForgotPasswordForm() { ) { (inputs.current[index - 1] as HTMLInputElement)?.focus(); } - if (e.key === "Enter") { - handleCodeSubmit(); - } }; const handleKeyDownSpace = (e: React.KeyboardEvent) => { @@ -131,36 +149,22 @@ export default function ForgotPasswordForm() { return { endAdornment: ( - {inputType === "newPassword" && newPassword !== "" && ( - - {showNewPassword ? ( - - ) : ( - - )} - - )} - {inputType === "confirmPassword" && confirmPassword !== "" && ( - - {showConfirmPassword ? ( - - ) : ( - - )} - - )} + + {newPassword !== "" && + (inputType === "newPassword" + ? showNewPassword + : showConfirmPassword) ? ( + + ) : ( + + )} + ), }; @@ -195,7 +199,7 @@ export default function ForgotPasswordForm() { className="" />
-
+
{step <= 2 ? "Forgot password" @@ -229,7 +233,7 @@ export default function ForgotPasswordForm() { handleEmailSubmit(); } }} - error={Boolean(error)} + error={error !== ""} helperText={error} />
@@ -251,7 +255,7 @@ export default function ForgotPasswordForm() {
{" "}
-
+
{code.map((value, index) => ( handleCodeChange(e.target.value.slice(-1), index) } + onKeyUp={(e) => { + if (e.key === "Enter") { + handleCodeSubmit(); + } + }} onKeyDown={(e) => handleKeyDown(e, index)} inputRef={(el) => (inputs.current[index] = el)} - error={codeError} + error={error !== ""} className={`w-[50px] h-[56px] text-center text-lg ${ - codeError ? "border-rose-600" : "border-gray-300" + error !== "" ? "border-rose-600" : "border-gray-300" } rounded-lg`} - inputProps={{ maxLength: 1, className: "text-center" }} + slotProps={{ + htmlInput: { + maxLength: 1, + style: { textAlign: "center" }, + }, + }} /> ))}
-
+ {error !== "" && ( +
{error}
+ )} +
{String(Math.floor(counter / 60)).padStart(2, "0")}: {String(counter % 60).padStart(2, "0")}
@@ -306,18 +323,18 @@ export default function ForgotPasswordForm() { slotProps={{ input: CustomInputProps("newPassword"), }} + onKeyUp={(e) => { + if (e.key === "Enter") { + handlePasswordSubmit(); + } + }} onChange={(e) => { setNewPassword(e.target.value); if (!showNewPassword) { e.preventDefault(); } }} - onKeyUp={(e) => { - if (e.key === "Enter") { - handlePasswordSubmit(); - } - }} - error={passwordError} + error={error !== ""} /> { - setConfirmPassword(e.target.value); - }} onKeyUp={(e) => { if (e.key === "Enter") { handlePasswordSubmit(); } }} - error={passwordError} - helperText={passwordError && "Passwords do not match"} + onChange={(e) => { + setConfirmPassword(e.target.value); + }} + error={error !== ""} + helperText={error} />
diff --git a/src/lib/nodemail.ts b/src/lib/nodemail.ts new file mode 100644 index 0000000..567f1ff --- /dev/null +++ b/src/lib/nodemail.ts @@ -0,0 +1,25 @@ +import nodemailer from "nodemailer"; + +export const sendMail = async (email: string, code: string) => { + const transporter = nodemailer.createTransport({ + service: "gmail", + auth: { + user: process.env.NODEMAILER_EMAIL, + type: "OAuth2", + clientId: process.env.OAUTH_CLIENTID, + clientSecret: process.env.OAUTH_CLIENTSECRET, + refreshToken: process.env.OAUTH_REFRESHTOKEN, + accessToken: process.env.OAUTH_ACCESSTOKEN, + }, + }); + + const message = { + from: process.env.NODEMAILER_EMAIL, + to: email, + subject: "Your Password Reset Code", + text: `Your password reset code ${code}. This will expire in 60 seconds`, + html: `

Your password reset code is: ${code}

`, + }; + + await transporter.sendMail(message); +}; From 06559643c8a7f11af119bf2b087b496415539806 Mon Sep 17 00:00:00 2001 From: Won Kim <103711389+wkim10@users.noreply.github.com> Date: Tue, 10 Dec 2024 20:45:45 -1000 Subject: [PATCH 3/4] add code to post request (#43) Co-authored-by: wkim10 --- src/app/api/user/route.ts | 8 ++++++++ src/components/ForgotPassword.tsx | 2 +- src/components/Login.tsx | 4 ++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index 81e664f..ce551fe 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -33,6 +33,14 @@ export const POST = async (request: NextRequest) => { }, }); + await prisma.code.create({ + data: { + codeString: "", + expire: new Date(), + userId: savedUser.id, + }, + }); + return NextResponse.json({ code: "SUCCESS", message: `User created with email: ${savedUser.email}`, diff --git a/src/components/ForgotPassword.tsx b/src/components/ForgotPassword.tsx index 17be861..ec780c5 100644 --- a/src/components/ForgotPassword.tsx +++ b/src/components/ForgotPassword.tsx @@ -384,7 +384,7 @@ export default function ForgotPasswordForm() { diff --git a/src/components/Login.tsx b/src/components/Login.tsx index cfd8d95..8de1340 100644 --- a/src/components/Login.tsx +++ b/src/components/Login.tsx @@ -89,7 +89,7 @@ export default function LoginForm() { fontFamily: "Kepler Std", }} > - Welcome back! + Welcome!
Please enter your details @@ -144,7 +144,7 @@ export default function LoginForm() { />
From f0006d7151ad43bbac98e593d97a4fb09df2613a Mon Sep 17 00:00:00 2001 From: Won Kim <103711389+wkim10@users.noreply.github.com> Date: Tue, 10 Dec 2024 20:54:00 -1000 Subject: [PATCH 4/4] add logout flow (#44) * add logout flow * Lowercase signin --------- Co-authored-by: wkim10 Co-authored-by: Johnny Tan --- src/components/SideNavBar.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/SideNavBar.tsx b/src/components/SideNavBar.tsx index a3d27bb..3996208 100644 --- a/src/components/SideNavBar.tsx +++ b/src/components/SideNavBar.tsx @@ -5,6 +5,7 @@ import Image from "next/image"; import Logo from "../app/icons/br-logo.png"; import Divider from "../app/icons/divider"; import { usePathname } from "next/navigation"; +import { signOut } from "next-auth/react"; interface SideNavBarProps { role: string; @@ -28,14 +29,14 @@ const SideNavBar = ({ role }: SideNavBarProps) => { href: "/private/communication", }, { name: "Profile", icon: "charm:person", href: "/private/profile" }, - { name: "Logout", icon: "tabler:logout", href: "/private/logout" }, // @TODO + { name: "Logout", icon: "tabler:logout", href: "" }, ]; const volunteerTabs = [ { name: "Home", icon: "tabler:home", href: "/private" }, { name: "Events", icon: "uil:calender", href: "/private/events" }, { name: "Profile", icon: "charm:person", href: "/private/profile" }, - { name: "Logout", icon: "tabler:logout", href: "/private/logout" }, // @TODO + { name: "Logout", icon: "tabler:logout", href: "" }, ]; const tabs = role === "admin" ? adminTabs : volunteerTabs; @@ -62,7 +63,11 @@ const SideNavBar = ({ role }: SideNavBarProps) => { className={`nav-button flex gap-3 items-center h-11 w-full text-[18px] font-medium focus:text-darkrose focus:bg-rose rounded-md pt-px pb-px px-3 ${ pathname === tab.href ? "text-darkrose bg-rose" : "" }`} - onClick={() => router.replace(tab.href)} + onClick={() => + tab.name === "Logout" + ? signOut({ callbackUrl: "/public/signin" }) + : router.replace(tab.href) + } > {tab.name}