diff --git a/package-lock.json b/package-lock.json index 07c7edd5..3c30c347 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "classnames": "^2.3.2", "daisyui": "^2.51.5", "next": "^13.2.1", - "next-auth": "^4.19.0", + "next-auth": "^4.21.1", "next-themes": "^0.2.1", "prisma": "^4.12.0", "react": "18.2.0", @@ -3475,9 +3475,9 @@ } }, "node_modules/next-auth": { - "version": "4.20.1", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.20.1.tgz", - "integrity": "sha512-ZcTUN4qzzZ/zJYgOW0hMXccpheWtAol8QOMdMts+LYRcsPGsqf2hEityyaKyECQVw1cWInb9dF3wYwI5GZdEmQ==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.21.1.tgz", + "integrity": "sha512-NYkU4jAPSVxWhCblE8dDFAnKM7kOoO/QEobQ0RoEVP9Wox99A3PKHwOAsWhSg8ahJG/iKIWk2Bo1xHvsS4R39Q==", "dependencies": { "@babel/runtime": "^7.20.13", "@panva/hkdf": "^1.0.2", @@ -7485,9 +7485,9 @@ } }, "next-auth": { - "version": "4.20.1", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.20.1.tgz", - "integrity": "sha512-ZcTUN4qzzZ/zJYgOW0hMXccpheWtAol8QOMdMts+LYRcsPGsqf2hEityyaKyECQVw1cWInb9dF3wYwI5GZdEmQ==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.21.1.tgz", + "integrity": "sha512-NYkU4jAPSVxWhCblE8dDFAnKM7kOoO/QEobQ0RoEVP9Wox99A3PKHwOAsWhSg8ahJG/iKIWk2Bo1xHvsS4R39Q==", "requires": { "@babel/runtime": "^7.20.13", "@panva/hkdf": "^1.0.2", diff --git a/package.json b/package.json index 172401d8..1b583cbd 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "classnames": "^2.3.2", "daisyui": "^2.51.5", "next": "^13.2.1", - "next-auth": "^4.19.0", + "next-auth": "^4.21.1", "next-themes": "^0.2.1", "prisma": "^4.12.0", "react": "18.2.0", diff --git a/src/components/elements/input.tsx b/src/components/elements/input.tsx new file mode 100644 index 00000000..07bc15fc --- /dev/null +++ b/src/components/elements/input.tsx @@ -0,0 +1,42 @@ +import { useEffect, useRef } from "react"; + +interface PasswordInputProps { + placeholder: string; + value: string; + name: string; + type: string; + onChange: (event: React.ChangeEvent) => void; + focus?: boolean; +} + +const Input = ({ + placeholder, + value, + name, + onChange, + type, + focus = false, + ...rest +}: PasswordInputProps) => { + const inputRef = useRef(null); + + useEffect(() => { + if (focus && inputRef.current) { + inputRef.current.focus(); + } + }, [focus]); + return ( + + ); +}; + +export default Input; diff --git a/src/components/elements/inputField.tsx b/src/components/elements/inputField.tsx new file mode 100644 index 00000000..da799802 --- /dev/null +++ b/src/components/elements/inputField.tsx @@ -0,0 +1,125 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +import { useState } from "react"; +import Input from "~/components/elements/input"; +import EditIcon from "~/icons/edit"; + +interface FieldConfig { + name: string; + initialValue?: string; + type: string; + placeholder: string; + displayValue?: string; +} + +interface FormProps { + label: string; + isLoading?: boolean; + placeholder?: string; + fields: FieldConfig[]; + submitHandler: (formValues: { + [key: string]: string; + }) => Promise | string | void; + badge?: { + text: string; + color: string; + }; +} + +const InputField = ({ + label, + placeholder, + fields, + submitHandler, + badge, + isLoading, +}: FormProps) => { + const [showInputs, setShowInputs] = useState(false); + const [formValues, setFormValues] = useState( + fields.reduce((acc, field) => { + acc[field.name] = field.initialValue || ""; + return acc; + }, {}) + ); + + const handleEditClick = () => setShowInputs(!showInputs); + + const handleChange = (e: React.ChangeEvent) => { + const targetName = e.target.name; + const targetValue = e.target.value; + setFormValues({ ...formValues, [targetName]: targetValue }); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const response = await submitHandler(formValues); + if (response) { + setShowInputs(false); + } + }; + const renderInputs = () => ( +
{ + void handleSubmit(event); + }} + className="my-3 space-y-3" + > + {fields.map((field, i) => ( + + ))} +
+ + +
+
+ ); + const renderLoading = () => ( +
+ +
+ ); + return ( + <> +
+ {label} + +
+ {showInputs ? ( + isLoading ? ( + renderLoading() + ) : ( + renderInputs() + ) + ) : ( +
+ {placeholder ?? fields[0].placeholder} + {badge && ( +
{badge.text}
+ )} +
+ )} + + ); +}; + +export default InputField; diff --git a/src/components/modules/sidebar.tsx b/src/components/modules/sidebar.tsx index a1231610..0fe2e35d 100644 --- a/src/components/modules/sidebar.tsx +++ b/src/components/modules/sidebar.tsx @@ -106,10 +106,15 @@ const Sidebar = (): JSX.Element => { Account - {/*
  • - + { Profile - -
  • */} + + {/*
  • void; +} + +const EditIcon = ({ className, onClick, ...rest }: IeditIcon) => { + return ( + + + + + + ); +}; + +export default EditIcon; diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx new file mode 100644 index 00000000..d16cfcbd --- /dev/null +++ b/src/pages/profile/index.tsx @@ -0,0 +1,145 @@ +import { useSession } from "next-auth/react"; +import { type ReactElement } from "react"; +import { LayoutAuthenticated } from "~/components/layouts/layout"; +import EditableField from "~/components/elements/inputField"; +import { toast } from "react-hot-toast"; +import { api } from "~/utils/api"; + +const Profile = () => { + const { data: session, update: sessionUpdate } = useSession(); + const { mutate: userUpdate, error: userError } = + api.auth.update.useMutation(); + + if (userError) { + toast.error(userError.message); + } + + return ( +
    +
    +
    +

    Profile

    +
    +
    +
    +
    + + await sessionUpdate({ update: { ...params } }) + } + /> +
    +
    + + await sessionUpdate({ update: { ...params } }) + } + /> +
    +
    + { + return new Promise((resolve, reject) => { + userUpdate( + { ...params }, + { + onSuccess: () => { + resolve(true); + }, + onError: () => { + reject(false); + }, + } + ); + }); + }} + /> +
    + {/* */} + +
    +
    Role
    +
    {session?.user.role}
    +
    +
    +
    +
    +
    +
    +
    +

    Activity

    +
    +
    +

    No activity to display.

    +
    +
    +
    +
    + ); +}; + +Profile.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; +// interface Props { +// me?: string; +// } +// export const getServerSideProps: GetServerSideProps = async ( +// context: GetServerSidePropsContext +// ): Promise> => { +// const session = await getSession(context); +// const me = await prisma.user.findUnique({ +// where: { +// id: session?.user.id, +// }, +// }); + +// return { +// props: { me: JSON.stringify(me) }, +// }; +// }; + +export default Profile; diff --git a/src/server/api/routers/authRouter.ts b/src/server/api/routers/authRouter.ts index 5b3bf073..b9c83982 100644 --- a/src/server/api/routers/authRouter.ts +++ b/src/server/api/routers/authRouter.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import bcrypt from "bcryptjs"; import { createTRPCRouter, + protectedProcedure, publicProcedure, // protectedProcedure, } from "~/server/api/trpc"; @@ -18,16 +19,17 @@ const mediumPassword = new RegExp( ); // create a zod password schema -const passwordSchema = z - .string() - .nonempty() - .max(40) - .refine((val) => { - if (!mediumPassword.test(val)) { - throw new Error(`Password does not meet the requirements!`); - } - return true; - }); +const passwordSchema = (errorMessage: string) => + z + .string() + .nonempty() + .max(40) + .refine((val) => { + if (!mediumPassword.test(val)) { + throw new Error(errorMessage); + } + return true; + }); export const authRouter = createTRPCRouter({ register: publicProcedure @@ -38,7 +40,7 @@ export const authRouter = createTRPCRouter({ .nonempty() .email() .transform((val) => val.trim()), - password: passwordSchema, + password: passwordSchema("password does not meet the requirements!"), name: z.string().nonempty().max(40), }) ) @@ -106,7 +108,7 @@ export const authRouter = createTRPCRouter({ data: { name, email, - lastLogin: new Date(), + lastLogin: new Date().toISOString(), role: userCount === 0 ? "ADMIN" : "USER", hash, }, @@ -142,4 +144,106 @@ export const authRouter = createTRPCRouter({ // }); // }); }), + me: protectedProcedure.query(async ({ ctx }) => { + await ctx.prisma.user.findFirst({ + where: { + id: ctx.session.user.id, + }, + }); + }), + update: protectedProcedure + .input( + z.object({ + email: z + .string() + .email() + .transform((val) => val.trim()) + .optional(), + password: passwordSchema( + "Current password does not meet the requirements!" + ) + .transform((val) => val.trim()) + .optional(), + newPassword: passwordSchema( + "New Password does not meet the requirements!" + ) + .transform((val) => val.trim()) + .optional(), + repeatNewPassword: passwordSchema( + "Repeat NewPassword does not meet the requirements!" + ) + .transform((val) => val.trim()) + .optional(), + name: z.string().nonempty().max(40).optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const user = await ctx.prisma.user.findFirst({ + where: { + id: ctx.session.user.id, + }, + }); + + // validate + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `User not found!`, + }); + } + + if (input.newPassword || input.repeatNewPassword || input.password) { + // make sure all fields are filled + if (!input.newPassword || !input.repeatNewPassword || !input.password) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Please fill all fields!`, + // optional: pass the original error to retain stack trace + + // cause: theError, + }); + } + + if (!mediumPassword.test(input.newPassword)) + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Password does not meet the requirements!`, + // optional: pass the original error to retain stack trace + // cause: theError, + }); + + // check if old password is correct + if (!bcrypt.compareSync(input.password, user.hash)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Old password is incorrect!`, + // optional: pass the original error to retain stack trace + // cause: theError, + }); + } + // make sure both new passwords are the same + if (input.newPassword !== input.repeatNewPassword) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Passwords do not match!`, + // optional: pass the original error to retain stack trace + // cause: theError, + }); + } + } + + // update user with new values + await ctx.prisma.user.update({ + where: { + id: user.id, + }, + data: { + email: input.email || user.email, + name: input.name || user.name, + hash: input.newPassword + ? bcrypt.hashSync(input.newPassword, 10) + : user.hash, + }, + }); + }), }); diff --git a/src/server/auth.ts b/src/server/auth.ts index 9d3305a3..14c0258c 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { type GetServerSidePropsContext } from "next"; import { getServerSession, @@ -6,9 +8,11 @@ import { } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; -// import { env } from "~/env.mjs"; +import bcrypt from "bcryptjs"; import { prisma } from "~/server/db"; import { compare } from "bcryptjs"; +import { type User as IUser } from "@prisma/client"; +// import { type User } from ".prisma/client"; /** * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` @@ -18,14 +22,11 @@ import { compare } from "bcryptjs"; */ declare module "next-auth" { interface Session extends DefaultSession { - user: { - id: number; - email: string; - role: string; - accessToken: string; - // ...other properties - // role: UserRole; - } & DefaultSession["user"]; + user: IUser; + update: { + name?: string; + email?: string; + }; } interface User { @@ -83,10 +84,8 @@ export const authOptions: NextAuthOptions = { } return { - id: user.id, - email: user.email, - name: user.name, - role: user.role, + ...user, + hash: null, }; }, }), @@ -106,25 +105,63 @@ export const authOptions: NextAuthOptions = { maxAge: 30 * 24 * 60 * 60, // 30 Days }, callbacks: { - jwt({ token, user, account }) { + async jwt({ token, user, trigger, account, session }) { + if (trigger === "update") { + if (session.update) { + const user = await prisma.user.findFirst({ + where: { + id: token.id, + }, + }); + // session update => https://github.com/nextauthjs/next-auth/discussions/3941 + // verify that name has at least one character + if (typeof session.update.name === "string") { + // TODO throwing error will logout user. + // if (session.update.name.length < 1) { + // throw new Error("Name must be at least one character long."); + // } + token.name = session.update.name; + } + + // verify that email is valid + if (typeof session.update.email === "string") { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + // if (!session.update.email.includes("@")) { + // throw new Error("Email must be a valid email address."); + // } + token.email = session.update.email; + } + + // update user with new values + await prisma.user.update({ + where: { + id: token.id as number, + }, + data: { + email: session.update.email || user.email, + name: session.update.name || user.name, + }, + }); + } + return token; + } + // Persist the OAuth access_token to the token right after signin if (account) { token.accessToken = account.accessToken; } if (user) { - token.id = user.id; - token.name = user.name; - token.role = user.role; + // token.id = user.id; + // token.name = user.name; + // token.role = user.role; + token = { ...user }; } return token; }, session: ({ session, token }) => { if (!token.id) return null; - // session.user = user; - // session.accessToken = token.accessToken as string; - session.user.id = token.id as number; - session.user.name = token.name; - session.user.role = token.role as string; + + session.user = { ...token } as IUser; return session; }, redirect({ url, baseUrl }) {