From e459724568628e287aba28aaf3b997287242100f Mon Sep 17 00:00:00 2001 From: Alien501 Date: Thu, 3 Apr 2025 18:06:27 +0530 Subject: [PATCH 1/2] Fixed issue #342 -- Add hide/unhide button for passwords in change password --- .../containers/Users/ChangePasswordForm.tsx | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/frontend/src/containers/Users/ChangePasswordForm.tsx b/frontend/src/containers/Users/ChangePasswordForm.tsx index 75ed6f11..972bb1a3 100644 --- a/frontend/src/containers/Users/ChangePasswordForm.tsx +++ b/frontend/src/containers/Users/ChangePasswordForm.tsx @@ -22,9 +22,14 @@ import { enqueueSnackbar } from 'notistack'; import { useState, useEffect } from 'react'; import { ChangePasswordFormType } from './ChangePasswordForm.types'; import { CHANGE_PASSWORD_FORM_FIELDS } from './ChangePasswordForm.config'; +import { Eye, EyeIcon, EyeOff } from 'lucide-react'; const ChangePasswordForm = () => { const [accessToken, setAccessToken] = useState(null); + const [showPassword, setShowPassword] = useState({ + newPassword: false, + confirmPassword: false + }); useEffect(() => { const fetchAccessToken = async () => { @@ -61,7 +66,7 @@ const ChangePasswordForm = () => { }), }, ); - + if (res.statusCode === 200) { enqueueSnackbar('Password changed successfully', { variant: 'success' }); reset(); @@ -74,6 +79,10 @@ const ChangePasswordForm = () => { } }; + const togglePasswordVisibility = (field: 'newPassword' | 'confirmPassword') => { + setShowPassword((prev) => ({ ...prev, [field]: !prev[field] })); + } + return ( @@ -90,7 +99,7 @@ const ChangePasswordForm = () => { >
{CHANGE_PASSWORD_FORM_FIELDS.map((field) => ( -
+
{ {field.label} * - + {errors[field.name] && ( {errors[field.name]?.message} )} From 0029b21106f67dac9dba6db34bd1526783153183 Mon Sep 17 00:00:00 2001 From: Alien501 Date: Thu, 3 Apr 2025 22:15:37 +0530 Subject: [PATCH 2/2] Fixed #340 -- Implemented Forgot Password in Login Page --- .../src/app/(auth)/forgot-password/page.tsx | 1 + .../src/app/(auth)/reset-password/page.tsx | 1 + .../src/app/(core)/privacy-policy/page.tsx | 93 ++- frontend/src/components/Modal/modal.tsx | 42 +- frontend/src/constants/endpoints.ts | 1 + frontend/src/constants/schema.tsx | 82 ++- .../ForgotPassword/ForgotPassword.tsx | 158 +++++ .../ForgotPassword/ForgotPassword.types.ts | 3 + .../src/containers/ForgotPassword/index.ts | 2 + frontend/src/containers/Login/Login.tsx | 20 +- .../ResetPassword/ResetPassword.tsx | 276 ++++++++ .../ResetPassword/ResetPassword.types.ts | 4 + .../src/containers/ResetPassword/index.ts | 2 + frontend/src/containers/Signup/Signup.tsx | 127 ++-- .../Users/ChangePasswordForm.config.ts | 20 +- .../containers/Users/ChangePasswordForm.tsx | 305 +++++---- .../Users/ChangePasswordForm.types.ts | 18 +- frontend/src/containers/Users/User.tsx | 19 +- frontend/src/containers/Users/UserAvatar.tsx | 23 +- .../Users/UserProfileForm.config.ts | 108 +++- .../src/containers/Users/UserProfileForm.tsx | 604 ++++++++++-------- .../containers/Users/UserProfileForm.types.ts | 46 +- frontend/src/containers/index.ts | 2 + frontend/src/middleware.ts | 9 +- frontend/src/sections/Navbar/Navbar.tsx | 17 +- 25 files changed, 1327 insertions(+), 656 deletions(-) create mode 100644 frontend/src/app/(auth)/forgot-password/page.tsx create mode 100644 frontend/src/app/(auth)/reset-password/page.tsx create mode 100644 frontend/src/containers/ForgotPassword/ForgotPassword.tsx create mode 100644 frontend/src/containers/ForgotPassword/ForgotPassword.types.ts create mode 100644 frontend/src/containers/ForgotPassword/index.ts create mode 100644 frontend/src/containers/ResetPassword/ResetPassword.tsx create mode 100644 frontend/src/containers/ResetPassword/ResetPassword.types.ts create mode 100644 frontend/src/containers/ResetPassword/index.ts diff --git a/frontend/src/app/(auth)/forgot-password/page.tsx b/frontend/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 00000000..b80845ce --- /dev/null +++ b/frontend/src/app/(auth)/forgot-password/page.tsx @@ -0,0 +1 @@ +export { ForgotPasswordContainer as default } from '@containers'; diff --git a/frontend/src/app/(auth)/reset-password/page.tsx b/frontend/src/app/(auth)/reset-password/page.tsx new file mode 100644 index 00000000..8d8d5720 --- /dev/null +++ b/frontend/src/app/(auth)/reset-password/page.tsx @@ -0,0 +1 @@ +export { ResetPasswordContainer as default } from '@containers'; diff --git a/frontend/src/app/(core)/privacy-policy/page.tsx b/frontend/src/app/(core)/privacy-policy/page.tsx index 53c41150..4a98705d 100644 --- a/frontend/src/app/(core)/privacy-policy/page.tsx +++ b/frontend/src/app/(core)/privacy-policy/page.tsx @@ -1,49 +1,60 @@ -"use client"; +'use client'; import React from 'react'; + import Link from 'next/link'; const PrivacyPolicy = () => { return ( -
-
-

- Privacy Policy -

-

+

+
+

Privacy Policy

+

Last Updated: 2 March, 2025

-
+
-

+

1. Introduction

-

- Welcome to Django India! Your privacy is important to us. This Privacy Policy explains how we collect, use, and protect your information when you visit our website (www.djangoindia.org/home) and interact with our community. +

+ Welcome to Django India! Your privacy is important to us. This + Privacy Policy explains how we collect, use, and protect your + information when you visit our website ( + + www.djangoindia.org/home + + ) and interact with our community.

-

+

2. Information We Collect

-
    +
    • - Personal Information: When you subscribe to our newsletter, register for events, or contact us, we may collect your name, email address, and any other details you provide. + Personal Information: When you subscribe to our + newsletter, register for events, or contact us, we may collect + your name, email address, and any other details you provide.
    • - Cookies: We use cookies and similar tracking technologies to enhance user experience and improve our website. + Cookies: We use cookies and similar tracking + technologies to enhance user experience and improve our website.
-

+

3. How We Use Your Information

-
    +
    • Send updates, newsletters, and event invitations.
    • Improve our website and community engagement.
    • Analyze website traffic and trends.
    • @@ -52,51 +63,63 @@ const PrivacyPolicy = () => {
-

+

4. Data Sharing and Protection

-
    +
    • We do not sell, trade, or rent your personal information.
    • -
    • We may share data with service providers who help us manage our website and communications.
    • -
    • We take reasonable security measures to protect your data from unauthorized access, disclosure, or loss.
    • +
    • + We may share data with service providers who help us manage our + website and communications. +
    • +
    • + We take reasonable security measures to protect your data from + unauthorized access, disclosure, or loss. +
-

+

5. Third-Party Links

-

- Our website may contain links to external sites. We are not responsible for their privacy policies or content. +

+ Our website may contain links to external sites. We are not + responsible for their privacy policies or content.

-

+

6. Your Rights and Choices

-
    +
    • You can unsubscribe from our emails at any time.
    • -
    • You may request access, correction, or deletion of your personal data by contacting us.
    • +
    • + You may request access, correction, or deletion of your personal + data by contacting us. +
-

+

7. Changes to This Policy

-

- We may update this Privacy Policy from time to time. Any changes will be posted on this page with an updated effective date. +

+ We may update this Privacy Policy from time to time. Any changes + will be posted on this page with an updated effective date.

-

+

8. Contact Us

-

- If you have any questions about this Privacy Policy, please contact us at: {" "} - +

+ If you have any questions about this Privacy Policy, please contact + us at:{' '} + admin@djangoindia.org

@@ -106,4 +129,4 @@ const PrivacyPolicy = () => { ); }; -export default PrivacyPolicy; \ No newline at end of file +export default PrivacyPolicy; diff --git a/frontend/src/components/Modal/modal.tsx b/frontend/src/components/Modal/modal.tsx index 6af0b307..95d0f26c 100644 --- a/frontend/src/components/Modal/modal.tsx +++ b/frontend/src/components/Modal/modal.tsx @@ -64,36 +64,38 @@ const Modal: React.FC = ({ onClose }) => { {/* Full Name Field */}
- - + +
{errors.name && ( -

{errors.name.message}

+

{errors.name.message}

)}
{/* Email Field */}
- - + +
{errors.email && ( -

{errors.email.message}

+

+ {errors.email.message} +

)}
diff --git a/frontend/src/constants/endpoints.ts b/frontend/src/constants/endpoints.ts index 0f9c9fc8..91ac95bc 100644 --- a/frontend/src/constants/endpoints.ts +++ b/frontend/src/constants/endpoints.ts @@ -11,4 +11,5 @@ export const API_ENDPOINTS = { requestVerification: '/request-email-verify', profile: '/users/me', changePassword: '/users/me/set-password', + forgotPassword: '/forgot-password', }; diff --git a/frontend/src/constants/schema.tsx b/frontend/src/constants/schema.tsx index 8f8a50d3..9b1ac683 100644 --- a/frontend/src/constants/schema.tsx +++ b/frontend/src/constants/schema.tsx @@ -141,32 +141,61 @@ export const SIGNUP_FORM_SCHEMA = yup.object({ /[@$!%*?&#]/, 'Password must contain at least one special character (@, $, !, %, *, ?, &, or #).', ), - confirmPassword: yup - .string() - .when('newPassword', { - is: (newPassword: string) => newPassword && newPassword.length > 0, - then: (schema) => schema + confirmPassword: yup.string().when('newPassword', { + is: (newPassword: string) => newPassword && newPassword.length > 0, + then: (schema) => + schema .required('Confirm Password is required') .oneOf([yup.ref('newPassword')], 'Passwords must match'), - otherwise: (schema) => schema.optional().nullable() - }), + otherwise: (schema) => schema.optional().nullable(), + }), +}); + +export const FORGOT_PASSWORD_SCHEMA = yup.object({ + email: yup + .string() + .email('Please enter a valid email address') + .required('Email is required'), +}); + +export const RESET_PASSWORD_SCHEMA = yup.object({ + new_password: yup + .string() + .required('Password is required.') + .min(8, 'Password must be at least 8 characters long.') + .max(20, 'Password cannot exceed 20 characters.') + .matches(/[A-Z]/, 'Password must contain at least one uppercase letter.') + .matches(/[a-z]/, 'Password must contain at least one lowercase letter.') + .matches(/[0-9]/, 'Password must contain at least one number.') + .matches( + /[@$!%*?&#]/, + 'Password must contain at least one special character (@, $, !, %, *, ?, &, or #).', + ), + confirm_password: yup + .string() + .required('Confirm password is required') // Ensure this is required + .oneOf([yup.ref('new_password')], 'Passwords must match'), }); export const EDIT_PROFILE_FORM_SCHEMA = yup.object({ - username: yup.string().required("Username is required"), - email: yup.string().email("Invalid email").required("Email is required").meta({ disabled: true }), - first_name: yup.string().required("First Name is required"), - last_name: yup.string().required("Last Name is required"), + username: yup.string().required('Username is required'), + email: yup + .string() + .email('Invalid email') + .required('Email is required') + .meta({ disabled: true }), + first_name: yup.string().required('First Name is required'), + last_name: yup.string().required('Last Name is required'), gender: yup.string().optional().nullable(), bio: yup.string().optional().nullable(), about: yup.string().optional().nullable(), - website: yup.string().url("Invalid URL").optional().nullable(), - linkedin: yup.string().url("Invalid URL").optional().nullable(), - instagram: yup.string().url("Invalid URL").optional().nullable(), - github: yup.string().url("Invalid URL").optional().nullable(), - twitter: yup.string().url("Invalid URL").optional().nullable(), - mastodon: yup.string().url("Invalid URL").optional().nullable(), + website: yup.string().url('Invalid URL').optional().nullable(), + linkedin: yup.string().url('Invalid URL').optional().nullable(), + instagram: yup.string().url('Invalid URL').optional().nullable(), + github: yup.string().url('Invalid URL').optional().nullable(), + twitter: yup.string().url('Invalid URL').optional().nullable(), + mastodon: yup.string().url('Invalid URL').optional().nullable(), country: yup.string().optional().nullable(), organization: yup.string().optional().nullable(), @@ -174,13 +203,16 @@ export const EDIT_PROFILE_FORM_SCHEMA = yup.object({ }); export const CHANGE_PASSWORD_FORM_SCHEMA = yup.object({ - newPassword: yup.string().min(6, "Password must be at least 6 characters").required("Password is required"), - confirmPassword: yup.string() - .when('newPassword', { - is: (newPassword: string) => newPassword && newPassword.length > 0, - then: (schema) => schema + newPassword: yup + .string() + .min(6, 'Password must be at least 6 characters') + .required('Password is required'), + confirmPassword: yup.string().when('newPassword', { + is: (newPassword: string) => newPassword && newPassword.length > 0, + then: (schema) => + schema .required('Confirm Password is required') .oneOf([yup.ref('newPassword')], 'Passwords must match'), - otherwise: (schema) => schema.optional().nullable() - }), -}); \ No newline at end of file + otherwise: (schema) => schema.optional().nullable(), + }), +}); diff --git a/frontend/src/containers/ForgotPassword/ForgotPassword.tsx b/frontend/src/containers/ForgotPassword/ForgotPassword.tsx new file mode 100644 index 00000000..9f0d4fa8 --- /dev/null +++ b/frontend/src/containers/ForgotPassword/ForgotPassword.tsx @@ -0,0 +1,158 @@ +'use client'; + +import { yupResolver } from '@hookform/resolvers/yup'; +import { motion } from 'motion/react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { signIn } from 'next-auth/react'; +import { enqueueSnackbar } from 'notistack'; +import { useForm } from 'react-hook-form'; +import { FaGoogle, FaHome } from 'react-icons/fa'; + +import { Button, Input, Label } from '@/components'; +import { FORGOT_PASSWORD_SCHEMA } from '@/constants'; +import { fetchData } from '@/utils'; + +import type { ForgotPasswordType } from './ForgotPassword.types'; +// eslint-disable-next-line no-duplicate-imports +import type { SubmitHandler } from 'react-hook-form'; + +const Page = () => { + const searchParams = useSearchParams(); + const redirect = searchParams.get('redirect') || undefined; + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: yupResolver(FORGOT_PASSWORD_SCHEMA), + }); + + const onSubmit: SubmitHandler = async (data) => { + try { + const response = await fetchData('/forgot-password', { + method: 'POST', + body: JSON.stringify({ email: data.email }), + }); + + if (response.error) { + enqueueSnackbar(response.error.message || 'Failed to reset password', { + variant: 'error', + }); + } else { + enqueueSnackbar('Check your email to reset your password', { + variant: 'success', + }); + } + } catch (error) { + console.error('Unexpected error:', error); + enqueueSnackbar('Something went wrong. Please try again', { + variant: 'error', + }); + } + }; + + return ( +
+ + + +
+ + TajMahal Signup + +
+
+ +

Forgot

+

Password?

+ + Enter your email to continue + + +
+ + +

+ {errors.email?.message ?? ' '} +

+
+ + +
+
+ Don’t have account?{' '} + + Sign Up here! + +
+ Or + +
+
+
+
+ ); +}; + +export default Page; diff --git a/frontend/src/containers/ForgotPassword/ForgotPassword.types.ts b/frontend/src/containers/ForgotPassword/ForgotPassword.types.ts new file mode 100644 index 00000000..556cb1d7 --- /dev/null +++ b/frontend/src/containers/ForgotPassword/ForgotPassword.types.ts @@ -0,0 +1,3 @@ +export type ForgotPasswordType = { + email: string; +}; diff --git a/frontend/src/containers/ForgotPassword/index.ts b/frontend/src/containers/ForgotPassword/index.ts new file mode 100644 index 00000000..a4fdce7a --- /dev/null +++ b/frontend/src/containers/ForgotPassword/index.ts @@ -0,0 +1,2 @@ +export { default as ForgotPasswordContainer } from './ForgotPassword'; +export type * from './ForgotPassword.types'; diff --git a/frontend/src/containers/Login/Login.tsx b/frontend/src/containers/Login/Login.tsx index 73fc2fbb..6b1a9f51 100644 --- a/frontend/src/containers/Login/Login.tsx +++ b/frontend/src/containers/Login/Login.tsx @@ -118,12 +118,20 @@ const Page = () => {

- +
+ + + Forgot Password? + +
{ + const searchParams = useSearchParams(); + const router = useRouter(); + const redirect = searchParams.get("redirect") || "/"; + + // Retriving raw URL to handle potential HTML encoded parameters + const url = new URL(window.location.href); + + // Replace HTML-encoded ampersands (&) and other encoded characters + const fixedSearch = url.search + .replace(/&%3B/g, '&') // Fix &%3B (double-encoded &) + .replace(/&/g, '&'); // Fix & (HTML-encoded &) + + const params = new URLSearchParams(fixedSearch); + const uidb64 = params.get("uidb64") || url.searchParams.get("uidb64") || ""; + const token = params.get("token") || url.searchParams.get("token") || ""; + + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [isValidLink, setIsValidLink] = useState(true); + const [isValidating, setIsValidating] = useState(true); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: yupResolver(RESET_PASSWORD_SCHEMA), + }); + + useEffect(() => { + const checkAndValidateParams = () => { + setIsValidating(true); + + let validUidb64 = uidb64; + let validToken = token; + + const rawUrl = window.location.href; + if ((!validUidb64 || !validToken) && rawUrl.includes('uidb64=') && rawUrl.includes('token=')) { + try { + const uidMatch = rawUrl.match(/uidb64=([^&]+)/); + const tokenMatch = rawUrl.match(/token=([^&]+)/); + + if (uidMatch && uidMatch[1]) validUidb64 = uidMatch[1]; + if (tokenMatch && tokenMatch[1]) validToken = tokenMatch[1]; + } catch (error) { + console.error('Error parsing URL parameters:', error); + } + } + + if (!validUidb64 || !validToken) { + enqueueSnackbar('Invalid password reset link', { + variant: 'error', + }); + setIsValidLink(false); + router.push('/'); + } else { + setIsValidLink(true); + } + + setIsValidating(false); + }; + + checkAndValidateParams(); + }, [uidb64, token, router]); + + const onSubmit = async (data: ResetPasswordType) => { + try { + + if (!uidb64 || !token) { + enqueueSnackbar('Invalid password reset link', { + variant: 'error', + }); + return; + } + + const response = await fetchData( + `/reset-password/${uidb64}/${token}`, + { + method: 'POST', + body: JSON.stringify({ new_password: data.new_password }), + } + ); + + if (response.error) { + enqueueSnackbar(response.error.message || 'Failed to reset password', { + variant: 'error', + }); + } else { + enqueueSnackbar('Password has been reset successfully', { + variant: 'success', + }); + + router.push('/'); + } + } catch (error) { + console.error('Unexpected error:', error); + enqueueSnackbar('Something went wrong. Please try again', { + variant: 'error', + }); + } + }; + + const togglePassword = () => { + setShowPassword(!showPassword); + }; + + const toggleConfirmPassword = () => { + setShowConfirmPassword(!showConfirmPassword); + }; + + if (isValidating) { + return ( +
+
+

Validating reset link...

+
+
+ ); + } + + return ( +
+
+ + TajMahal Signup + +
+
+ +

Reset

+

Password?

+ + Enter your new password + +
+
+ +
+ + +
+

+ {errors.new_password?.message ?? ' '} +

+
+ +
+ +
+ + +
+

+ {errors.confirm_password?.message ?? ' '} +

+
+ + +
+
+
+ Don't have account?{' '} + + Sign Up here! + +
+ Or + +
+
+
+
+ ); +}; + +export default Page; \ No newline at end of file diff --git a/frontend/src/containers/ResetPassword/ResetPassword.types.ts b/frontend/src/containers/ResetPassword/ResetPassword.types.ts new file mode 100644 index 00000000..a67d04ab --- /dev/null +++ b/frontend/src/containers/ResetPassword/ResetPassword.types.ts @@ -0,0 +1,4 @@ +export type ResetPasswordType = { + new_password: string; + confirm_password: string; +}; diff --git a/frontend/src/containers/ResetPassword/index.ts b/frontend/src/containers/ResetPassword/index.ts new file mode 100644 index 00000000..b25856f7 --- /dev/null +++ b/frontend/src/containers/ResetPassword/index.ts @@ -0,0 +1,2 @@ +export { default as ResetPasswordContainer } from './ResetPassword'; +export type * from './ResetPassword.types'; diff --git a/frontend/src/containers/Signup/Signup.tsx b/frontend/src/containers/Signup/Signup.tsx index dbc3833b..8d88f3cf 100644 --- a/frontend/src/containers/Signup/Signup.tsx +++ b/frontend/src/containers/Signup/Signup.tsx @@ -10,7 +10,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { signIn } from 'next-auth/react'; import { enqueueSnackbar } from 'notistack'; import { type SubmitHandler, useForm } from 'react-hook-form'; -import { FaGoogle, FaHome , FaEye, FaEyeSlash } from 'react-icons/fa'; +import { FaEye, FaEyeSlash, FaGoogle, FaHome } from 'react-icons/fa'; import { Button, Input, Label } from '@/components'; import { API_ENDPOINTS, SIGNUP_FORM_SCHEMA } from '@/constants'; @@ -66,7 +66,8 @@ const SignupForm = () => { }; const togglePasswordVisibility = () => setShowPassword((prev) => !prev); - const toggleConfirmPasswordVisibility = () => setShowConfirmPassword((prev) => !prev); + const toggleConfirmPasswordVisibility = () => + setShowConfirmPassword((prev) => !prev); const onSubmit: SubmitHandler = async (data) => { const res = await fetchData<{ access_token: string }>( API_ENDPOINTS.signup, @@ -127,42 +128,42 @@ const SignupForm = () => { className='flex w-full flex-col' onSubmit={handleSubmit(onSubmit)} > -
+
- - -

- {errors.firstName?.message ?? ' '} -

+ + +

+ {errors.firstName?.message ?? ' '} +

- - -

- {errors.lastName?.message ?? ' '} -

+ + +

+ {errors.lastName?.message ?? ' '} +

@@ -191,21 +192,21 @@ const SignupForm = () => { New password
- - -
+

{errors.newPassword?.message ?? ' '}

@@ -218,21 +219,21 @@ const SignupForm = () => { Confirm Password
- - -
+

{errors.confirmPassword?.message ?? ' '}

diff --git a/frontend/src/containers/Users/ChangePasswordForm.config.ts b/frontend/src/containers/Users/ChangePasswordForm.config.ts index 7733387a..54bc1b0f 100644 --- a/frontend/src/containers/Users/ChangePasswordForm.config.ts +++ b/frontend/src/containers/Users/ChangePasswordForm.config.ts @@ -1,6 +1,16 @@ -import { FieldType } from "./ChangePasswordForm.types"; +import type { FieldType } from './ChangePasswordForm.types'; -export const CHANGE_PASSWORD_FORM_FIELDS: (FieldType)[] = [ - { name: 'newPassword', label: 'New Password', placeholder: 'Enter new password', type: 'password' }, - { name: 'confirmPassword', label: 'Confirm Password', placeholder: 'Confirm new password', type: 'password' }, -]; \ No newline at end of file +export const CHANGE_PASSWORD_FORM_FIELDS: FieldType[] = [ + { + name: 'newPassword', + label: 'New Password', + placeholder: 'Enter new password', + type: 'password', + }, + { + name: 'confirmPassword', + label: 'Confirm Password', + placeholder: 'Confirm new password', + type: 'password', + }, +]; diff --git a/frontend/src/containers/Users/ChangePasswordForm.tsx b/frontend/src/containers/Users/ChangePasswordForm.tsx index 972bb1a3..701448bf 100644 --- a/frontend/src/containers/Users/ChangePasswordForm.tsx +++ b/frontend/src/containers/Users/ChangePasswordForm.tsx @@ -1,152 +1,185 @@ 'use client'; +import { useEffect, useState } from 'react'; + +import { yupResolver } from '@hookform/resolvers/yup'; +import { Eye, EyeIcon, EyeOff } from 'lucide-react'; +import { enqueueSnackbar } from 'notistack'; import { type SubmitHandler, useForm } from 'react-hook-form'; -import { - Input, - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - Button -} from "@/components"; -import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/components/Card"; -import { yupResolver } from "@hookform/resolvers/yup"; -import { fetchData } from "@/utils"; -import { API_ENDPOINTS } from "@/constants"; -import { CHANGE_PASSWORD_FORM_SCHEMA } from "@/constants/schema"; +import { + Button, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from '@/components'; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/Card'; +import { API_ENDPOINTS } from '@/constants'; +import { CHANGE_PASSWORD_FORM_SCHEMA } from '@/constants/schema'; +import { fetchData } from '@/utils'; import { getAccessToken } from '@/utils/getAccesstoken'; -import { enqueueSnackbar } from 'notistack'; -import { useState, useEffect } from 'react'; -import { ChangePasswordFormType } from './ChangePasswordForm.types'; + import { CHANGE_PASSWORD_FORM_FIELDS } from './ChangePasswordForm.config'; -import { Eye, EyeIcon, EyeOff } from 'lucide-react'; -const ChangePasswordForm = () => { - const [accessToken, setAccessToken] = useState(null); - const [showPassword, setShowPassword] = useState({ - newPassword: false, - confirmPassword: false - }); +import type { ChangePasswordFormType } from './ChangePasswordForm.types'; - useEffect(() => { - const fetchAccessToken = async () => { - const token = await getAccessToken(); - setAccessToken(token ?? null); - }; - fetchAccessToken(); - }, []); +const ChangePasswordForm = () => { + const [accessToken, setAccessToken] = useState(null); + const [showPassword, setShowPassword] = useState({ + newPassword: false, + confirmPassword: false, + }); - const { - register, - control, - reset, - handleSubmit, - formState: { errors, isValid, dirtyFields, isSubmitting, ...restFormState }, - ...rest - } = useForm({ - resolver: yupResolver(CHANGE_PASSWORD_FORM_SCHEMA), - }); + useEffect(() => { + const fetchAccessToken = async () => { + const token = await getAccessToken(); + setAccessToken(token ?? null); + }; + fetchAccessToken(); + }, []); - const onSubmit: SubmitHandler = async (data) => { - try { - const res = await fetchData<{ message: string }>( - API_ENDPOINTS.changePassword, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${accessToken}`, - }, - body: JSON.stringify({ - new_password: data.newPassword, - confirm_password: data.confirmPassword - }), - }, - ); + const { + register, + control, + reset, + handleSubmit, + formState: { errors, isValid, dirtyFields, isSubmitting, ...restFormState }, + ...rest + } = useForm({ + resolver: yupResolver(CHANGE_PASSWORD_FORM_SCHEMA), + }); - if (res.statusCode === 200) { - enqueueSnackbar('Password changed successfully', { variant: 'success' }); - reset(); - } else { - enqueueSnackbar('Failed to change password', { variant: 'error' }); - } - } catch (error) { - console.error("Exception during password change:", error); - enqueueSnackbar('Something went wrong', { variant: 'error' }); - } - }; + const onSubmit: SubmitHandler = async (data) => { + try { + const res = await fetchData<{ message: string }>( + API_ENDPOINTS.changePassword, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + new_password: data.newPassword, + confirm_password: data.confirmPassword, + }), + }, + ); - const togglePasswordVisibility = (field: 'newPassword' | 'confirmPassword') => { - setShowPassword((prev) => ({ ...prev, [field]: !prev[field] })); + if (res.statusCode === 200) { + enqueueSnackbar('Password changed successfully', { + variant: 'success', + }); + reset(); + } else { + enqueueSnackbar('Failed to change password', { variant: 'error' }); + } + } catch (error) { + console.error('Exception during password change:', error); + enqueueSnackbar('Something went wrong', { variant: 'error' }); } + }; + + const togglePasswordVisibility = ( + field: 'newPassword' | 'confirmPassword', + ) => { + setShowPassword((prev) => ({ ...prev, [field]: !prev[field] })); + }; - return ( - - - Change Password - - -
- - {CHANGE_PASSWORD_FORM_FIELDS.map((field) => ( -
- ( - - - {field.label} * - - - - - - {errors[field.name] && ( - {errors[field.name]?.message} - )} - - )} - /> -
- ))} - - - -
- -
-
- ); + return ( + + + Change Password + + +
+ + {CHANGE_PASSWORD_FORM_FIELDS.map((field) => ( +
+ ( + + + {field.label} * + + + + + + {errors[field.name] && ( + {errors[field.name]?.message} + )} + + )} + /> +
+ ))} + + + +
+ +
+
+ ); }; -export default ChangePasswordForm; \ No newline at end of file +export default ChangePasswordForm; diff --git a/frontend/src/containers/Users/ChangePasswordForm.types.ts b/frontend/src/containers/Users/ChangePasswordForm.types.ts index 2bfd05e9..6474b36b 100644 --- a/frontend/src/containers/Users/ChangePasswordForm.types.ts +++ b/frontend/src/containers/Users/ChangePasswordForm.types.ts @@ -1,15 +1,15 @@ export type ChangePasswordFormType = { - newPassword: string; - confirmPassword?: string; -} + newPassword: string; + confirmPassword?: string; +}; type InputField = { - type:'password'; - options?: never; + type: 'password'; + options?: never; }; export type FieldType = { - label: string; - placeholder: string; - name: keyof ChangePasswordFormType; -} & InputField; \ No newline at end of file + label: string; + placeholder: string; + name: keyof ChangePasswordFormType; +} & InputField; diff --git a/frontend/src/containers/Users/User.tsx b/frontend/src/containers/Users/User.tsx index 8f1d6fa2..9f4edf4c 100644 --- a/frontend/src/containers/Users/User.tsx +++ b/frontend/src/containers/Users/User.tsx @@ -1,15 +1,15 @@ +import { Edit2 } from 'lucide-react'; import Image from 'next/image'; -import { Edit2 } from "lucide-react"; +import { Button } from '@/components'; import { fetchData } from '@/utils'; import { getAccessToken } from '@/utils/getAccesstoken'; +import ChangePasswordForm from './ChangePasswordForm'; import { UserAvatar } from './UserAvatar'; -import ProfileForm from "./UserProfileForm"; -import ChangePasswordForm from "./ChangePasswordForm"; +import ProfileForm from './UserProfileForm'; import type { PageProps } from '@/types/common'; -import { Button } from "@/components"; export type UserData = { id: number; @@ -59,12 +59,13 @@ const UserContainer = async ({ alt='Profile Cover' objectFit='cover' className='rounded-2xl' - /> - - - + /> + +
-
+

{userData?.first_name} {userData?.last_name}

diff --git a/frontend/src/containers/Users/UserAvatar.tsx b/frontend/src/containers/Users/UserAvatar.tsx index 29553045..ac6877c6 100644 --- a/frontend/src/containers/Users/UserAvatar.tsx +++ b/frontend/src/containers/Users/UserAvatar.tsx @@ -6,19 +6,24 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components'; export const UserAvatar = ({ avatarUrl }: { avatarUrl?: string | null }) => { const defaultAvatarUrl = 'https://github.com/shadcn.png'; - + return ( - + size-36 border-4 + border-[#F2ECE4] transition-all + duration-300 sm:size-40 + md:left-[10%] md:top-2/3 + md:size-48' + > - Default Avatar + Default Avatar ); diff --git a/frontend/src/containers/Users/UserProfileForm.config.ts b/frontend/src/containers/Users/UserProfileForm.config.ts index e5d2e0cb..5aaf4ad5 100644 --- a/frontend/src/containers/Users/UserProfileForm.config.ts +++ b/frontend/src/containers/Users/UserProfileForm.config.ts @@ -1,18 +1,38 @@ -import { FieldType } from "./UserProfileForm.types"; import { getAllCountries, getAllTimezones } from 'countries-and-timezones'; +import type { FieldType } from './UserProfileForm.types'; + export const USER_PROFILE_FORM_FIELDS: (FieldType | Array)[] = [ [ - { name: 'username', label: 'Username', placeholder: 'Username', type: 'text' }, + { + name: 'username', + label: 'Username', + placeholder: 'Username', + type: 'text', + }, { name: 'email', label: 'Email', placeholder: 'Email', type: 'text' }, ], [ - { name: 'first_name', label: 'First Name', placeholder: 'First Name', type: 'text' }, - { name: 'last_name', label: 'Last Name', placeholder: 'Last Name', type: 'text' }, - + { + name: 'first_name', + label: 'First Name', + placeholder: 'First Name', + type: 'text', + }, + { + name: 'last_name', + label: 'Last Name', + placeholder: 'Last Name', + type: 'text', + }, ], [ - { name: 'organization', label: 'Organization', placeholder: 'Organization', type: 'text' }, + { + name: 'organization', + label: 'Organization', + placeholder: 'Organization', + type: 'text', + }, { name: 'gender', label: 'Gender', @@ -25,40 +45,70 @@ export const USER_PROFILE_FORM_FIELDS: (FieldType | Array)[] = [ ], }, ], - { name: 'bio', label: 'Short Tagline', placeholder: 'Short Tagline', type: 'text' }, - { name: 'about', label: 'About Me', placeholder: 'About Me', type: 'textarea' }, + { + name: 'bio', + label: 'Short Tagline', + placeholder: 'Short Tagline', + type: 'text', + }, + { + name: 'about', + label: 'About Me', + placeholder: 'About Me', + type: 'textarea', + }, [ { name: 'website', label: 'Website', placeholder: 'Website', type: 'text' }, - { name: 'linkedin', label: 'LinkedIn', placeholder: 'LinkedIn', type: 'text' }, + { + name: 'linkedin', + label: 'LinkedIn', + placeholder: 'LinkedIn', + type: 'text', + }, ], [ - { name: 'instagram', label: 'Instagram', placeholder: 'Instagram', type: 'text' }, + { + name: 'instagram', + label: 'Instagram', + placeholder: 'Instagram', + type: 'text', + }, { name: 'github', label: 'GitHub', placeholder: 'GitHub', type: 'text' }, ], [ - { name: 'twitter', label: 'Twitter', placeholder: 'X (Twitter)', type: 'text' }, - { name: 'mastodon', label: 'Mastodon', placeholder: 'Mastodon', type: 'text' }, + { + name: 'twitter', + label: 'Twitter', + placeholder: 'X (Twitter)', + type: 'text', + }, + { + name: 'mastodon', + label: 'Mastodon', + placeholder: 'Mastodon', + type: 'text', + }, ], [ { - name: 'country', - label: 'Country', - placeholder: 'Select your country', - type: 'select', - options: Object.values(getAllCountries()).map(country => ({ - label: country.name, - value: country.id - })) + name: 'country', + label: 'Country', + placeholder: 'Select your country', + type: 'select', + options: Object.values(getAllCountries()).map((country) => ({ + label: country.name, + value: country.id, + })), }, { - name: 'user_timezone', - label: 'Timezone', - placeholder: 'Select your timezone', - type: 'select', - options: Object.values(getAllTimezones()).map(timezone => ({ - label: timezone.name, - value: timezone.name - })) + name: 'user_timezone', + label: 'Timezone', + placeholder: 'Select your timezone', + type: 'select', + options: Object.values(getAllTimezones()).map((timezone) => ({ + label: timezone.name, + value: timezone.name, + })), }, ], -]; \ No newline at end of file +]; diff --git a/frontend/src/containers/Users/UserProfileForm.tsx b/frontend/src/containers/Users/UserProfileForm.tsx index e19c1eab..e3d39cdf 100644 --- a/frontend/src/containers/Users/UserProfileForm.tsx +++ b/frontend/src/containers/Users/UserProfileForm.tsx @@ -1,304 +1,354 @@ 'use client'; +import { useEffect, useState } from 'react'; + +import { yupResolver } from '@hookform/resolvers/yup'; +import { enqueueSnackbar } from 'notistack'; import { type SubmitHandler, useForm } from 'react-hook-form'; -import { - Input, - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Textarea, - Button -} from "@/components"; -import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/components/Card"; -import { yupResolver } from "@hookform/resolvers/yup"; -import * as yup from "yup"; -import { fetchData } from "@/utils"; -import { API_ENDPOINTS, EDIT_PROFILE_FORM_SCHEMA } from "@/constants"; -import { USER_PROFILE_FORM_FIELDS } from "./UserProfileForm.config"; -import { UserData } from "./User"; +import { + Button, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Textarea, +} from '@/components'; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/Card'; +import { API_ENDPOINTS, EDIT_PROFILE_FORM_SCHEMA } from '@/constants'; +import { fetchData } from '@/utils'; import { getAccessToken } from '@/utils/getAccesstoken'; + +import { USER_PROFILE_FORM_FIELDS } from './UserProfileForm.config'; + +import type { UserData } from './User'; import type { ProfileForm } from './UserProfileForm.types'; -import { enqueueSnackbar } from 'notistack'; -import { useState, useEffect } from 'react'; +import type * as yup from 'yup'; const isFieldRequired = (schema: yup.AnyObjectSchema, fieldName: string) => { - try { - const fieldSchema = schema.describe().fields[fieldName]; - return (fieldSchema as any)?.tests?.some((test: any) => test.name === "required"); - } catch { - return false; - } + try { + const fieldSchema = schema.describe().fields[fieldName]; + return (fieldSchema as any)?.tests?.some( + (test: any) => test.name === 'required', + ); + } catch { + return false; + } }; const isFieldDisabled = (schema: yup.AnyObjectSchema, fieldName: string) => { - try { - return (schema.describe().fields[fieldName] as any)?.meta?.disabled === true; - } catch { - return false; - } + try { + return ( + (schema.describe().fields[fieldName] as any)?.meta?.disabled === true + ); + } catch { + return false; + } }; const ProfileForm = ({ userData }: { userData: UserData }) => { - const [accessToken, setAccessToken] = useState(null); + const [accessToken, setAccessToken] = useState(null); + + useEffect(() => { + const fetchAccessToken = async () => { + const token = await getAccessToken(); + setAccessToken(token ?? null); + }; + fetchAccessToken(); + }, []); - useEffect(() => { - const fetchAccessToken = async () => { - const token = await getAccessToken(); - setAccessToken(token ?? null); - }; - fetchAccessToken(); - }, []); + const { + register, + control, + reset, + handleSubmit, + formState: { errors, ...restFormState }, + ...rest + } = useForm({ + resolver: yupResolver(EDIT_PROFILE_FORM_SCHEMA) as any, + defaultValues: { + username: userData?.username || '', + email: userData?.email || '', + first_name: userData?.first_name || '', + last_name: userData?.last_name || '', + gender: userData?.gender || null, + bio: userData?.bio || '', + about: userData?.about || '', + website: userData?.website || null, + linkedin: userData?.linkedin || null, + instagram: userData?.instagram || null, + github: userData?.github || null, + twitter: userData?.twitter || null, + mastodon: userData?.mastodon || null, + organization: userData?.organization || null, + country: userData?.country || null, + user_timezone: userData?.user_timezone || '', + }, + mode: 'onChange', + }); - const { - register, - control, - reset, - handleSubmit, - formState: { errors, ...restFormState }, - ...rest - } = useForm({ - resolver: yupResolver(EDIT_PROFILE_FORM_SCHEMA) as any, - defaultValues: { - username: userData?.username || '', - email: userData?.email || '', - first_name: userData?.first_name || '', - last_name: userData?.last_name || '', - gender: userData?.gender || null, - bio: userData?.bio || '', - about: userData?.about || '', - website: userData?.website || null, - linkedin: userData?.linkedin || null, - instagram: userData?.instagram || null, - github: userData?.github || null, - twitter: userData?.twitter || null, - mastodon: userData?.mastodon || null, - organization: userData?.organization || null, - country: userData?.country || null, - user_timezone: userData?.user_timezone || '', + const onSubmit: SubmitHandler = async (data: any) => { + try { + const res = await fetchData<{ message: string }>(API_ENDPOINTS.profile, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, }, - mode: 'onChange' - }); + body: JSON.stringify(data), + }); - const onSubmit : SubmitHandler = async (data: any) => { - - try { - const res = await fetchData<{ message: string }>( - API_ENDPOINTS.profile, - { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${accessToken}`, - }, - body: JSON.stringify(data), - }, - ); - - if (res.statusCode === 200) { - console.log("res after updated",res) - enqueueSnackbar('Profile updated successfully', { variant: 'success' }); - } else { - // Handle nested error message object - const errorMessages = res.error?.message; - - if (typeof errorMessages === 'object') { - Object.entries(errorMessages).forEach(([field, errors]) => { - if (Array.isArray(errors)) { - errors.forEach(error => - enqueueSnackbar(`${field}: ${error}`, { variant: 'error' }) - ); - } - }); - } else { - enqueueSnackbar('An error occurred', { variant: 'error' }); - } + if (res.statusCode === 200) { + console.log('res after updated', res); + enqueueSnackbar('Profile updated successfully', { variant: 'success' }); + } else { + // Handle nested error message object + const errorMessages = res.error?.message; + + if (typeof errorMessages === 'object') { + Object.entries(errorMessages).forEach(([field, errors]) => { + if (Array.isArray(errors)) { + errors.forEach((error) => + enqueueSnackbar(`${field}: ${error}`, { variant: 'error' }), + ); } - } catch (error) { - console.error("Exception during API call:", error); - enqueueSnackbar('Something went wrong', { variant: 'error' }); + }); + } else { + enqueueSnackbar('An error occurred', { variant: 'error' }); } - }; + } + } catch (error) { + console.error('Exception during API call:', error); + enqueueSnackbar('Something went wrong', { variant: 'error' }); + } + }; - return ( - - - Profile Information - - -
+ + Profile Information + + + + + {USER_PROFILE_FORM_FIELDS.map((item, i) => + Array.isArray(item) ? ( +
- - {USER_PROFILE_FORM_FIELDS.map((item, i) => - Array.isArray(item) ? ( -
- {item.map(({ name, label, placeholder, type, options }) => { - const required = isFieldRequired(EDIT_PROFILE_FORM_SCHEMA, name); - const disabled = isFieldDisabled(EDIT_PROFILE_FORM_SCHEMA, name); + {item.map(({ name, label, placeholder, type, options }) => { + const required = isFieldRequired( + EDIT_PROFILE_FORM_SCHEMA, + name, + ); + const disabled = isFieldDisabled( + EDIT_PROFILE_FORM_SCHEMA, + name, + ); - return ( -
- { - return ( - - {type === "checkbox" ? ( -
- - - - - {label} - -
- ) : ( - <> - - {label} {required && *} - - {type === "select" ? ( - - ) : ( - - {type === "textarea" ? ( -