From 044cbeddbc98ea5bca570bed4801a9ceed494a50 Mon Sep 17 00:00:00 2001 From: Musa Khalid <112591148+Mkalbani@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:35:24 +0000 Subject: [PATCH 1/2] feat: Implement profile page with form for updating user details and Stellar wallet address --- frontend/app/(dashboard)/profile/page.tsx | 203 ++++++++++++++++++++++ frontend/package-lock.json | 39 ++--- 2 files changed, 222 insertions(+), 20 deletions(-) create mode 100644 frontend/app/(dashboard)/profile/page.tsx diff --git a/frontend/app/(dashboard)/profile/page.tsx b/frontend/app/(dashboard)/profile/page.tsx new file mode 100644 index 00000000..4c8e5208 --- /dev/null +++ b/frontend/app/(dashboard)/profile/page.tsx @@ -0,0 +1,203 @@ +'use client'; + +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { toast } from 'sonner'; + +import { updateProfile } from '@/lib/api/auth.api'; +import { useAuthStore } from '@/stores/auth.store'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +const stellarWalletRegex = /^G[A-Z2-7]{55}$/; + +const profileSchema = z.object({ + firstName: z.string().trim().min(1, 'First name is required'), + lastName: z.string().trim().min(1, 'Last name is required'), + walletAddress: z + .string() + .trim() + .optional() + .refine((value) => !value || stellarWalletRegex.test(value), { + message: 'Enter a valid Stellar wallet address', + }), +}); + +type ProfileFormValues = z.infer; + +function getErrorMessage(error: unknown): string { + if (typeof error === 'object' && error !== null && 'message' in error) { + const message = (error as { message?: string | string[] }).message; + if (Array.isArray(message)) { + return message[0] ?? 'Failed to update profile'; + } + if (typeof message === 'string' && message.length > 0) { + return message; + } + } + + return 'Failed to update profile'; +} + +function formatRole(role: string): string { + return role.charAt(0).toUpperCase() + role.slice(1); +} + +function formatMemberSince(isoDate: string): string { + const parsed = new Date(isoDate); + if (Number.isNaN(parsed.getTime())) { + return 'N/A'; + } + return parsed.toLocaleDateString(); +} + +export default function ProfilePage() { + const { user, isLoading, fetchCurrentUser, setUser } = useAuthStore(); + + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting, isDirty }, + } = useForm({ + resolver: zodResolver(profileSchema), + defaultValues: { + firstName: '', + lastName: '', + walletAddress: '', + }, + }); + + useEffect(() => { + if (!user) { + void fetchCurrentUser(); + } + }, [user, fetchCurrentUser]); + + useEffect(() => { + if (user) { + reset({ + firstName: user.firstName, + lastName: user.lastName, + walletAddress: user.walletAddress ?? '', + }); + } + }, [user, reset]); + + const onSubmit = async (values: ProfileFormValues) => { + try { + const updatedUser = await updateProfile({ + firstName: values.firstName, + lastName: values.lastName, + walletAddress: values.walletAddress || undefined, + }); + + setUser(updatedUser); + toast.success('Profile updated successfully'); + reset({ + firstName: updatedUser.firstName, + lastName: updatedUser.lastName, + walletAddress: updatedUser.walletAddress ?? '', + }); + } catch (error: unknown) { + toast.error(getErrorMessage(error)); + } + }; + + if (isLoading && !user) { + return ( +
+

Loading profile...

+
+ ); + } + + if (!user) { + return ( +
+

Unable to load profile.

+
+ ); + } + + return ( +
+

Profile

+ + + + Account Details + These fields are managed by your account and cannot be edited here. + + +
+

Email

+

{user.email}

+
+
+

Role

+

{formatRole(user.role)}

+
+
+

Email verification

+

{user.isEmailVerified ? 'Verified' : 'Not verified'}

+
+
+

Member since

+

{formatMemberSince(user.createdAt)}

+
+
+
+ + + + Edit Profile + Update your display details and optional Stellar wallet address. + + +
+
+
+ + + {errors.firstName ? ( +

{errors.firstName.message}

+ ) : null} +
+ +
+ + + {errors.lastName ? ( +

{errors.lastName.message}

+ ) : null} +
+
+ +
+ + + {errors.walletAddress ? ( +

{errors.walletAddress.message}

+ ) : null} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bd9ac8d1..5006a253 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -120,6 +120,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -702,6 +703,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -725,6 +727,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -3660,8 +3663,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3893,6 +3895,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3903,6 +3906,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3983,6 +3987,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -4502,6 +4507,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5007,6 +5013,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5723,8 +5730,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -6071,6 +6077,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6244,6 +6251,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8904,6 +8912,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -9396,7 +9405,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -9715,17 +9723,6 @@ } } }, - "node_modules/next-intl/node_modules/@swc/helpers": { - "version": "0.5.19", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz", - "integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -10331,7 +10328,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -10347,7 +10343,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -10425,6 +10420,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10434,6 +10430,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -10446,6 +10443,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -10462,8 +10460,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-remove-scroll": { "version": "2.7.2", @@ -11676,6 +11673,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11934,6 +11932,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" From 80b11b84519cdb5bb06ef40192b392f32a79edd4 Mon Sep 17 00:00:00 2001 From: Musa Khalid <112591148+Mkalbani@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:41:03 +0000 Subject: [PATCH 2/2] feat: Add reset password page with form validation and success/error handling --- frontend/app/(auth)/reset-password/page.tsx | 163 ++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 frontend/app/(auth)/reset-password/page.tsx diff --git a/frontend/app/(auth)/reset-password/page.tsx b/frontend/app/(auth)/reset-password/page.tsx new file mode 100644 index 00000000..58bc44d9 --- /dev/null +++ b/frontend/app/(auth)/reset-password/page.tsx @@ -0,0 +1,163 @@ +'use client'; + +import Link from 'next/link'; +import { Suspense, useMemo, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { toast } from 'sonner'; + +import { resetPassword } from '../../../lib/api/auth.api'; +import { Button } from '../../../components/ui/button'; +import { Input } from '../../../components/ui/input'; +import { Label } from '../../../components/ui/label'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '../../../components/ui/card'; + +const resetPasswordSchema = z + .object({ + newPassword: z.string().min(8, 'Password must be at least 8 characters'), + confirmPassword: z.string().min(8, 'Password must be at least 8 characters'), + }) + .refine((values) => values.newPassword === values.confirmPassword, { + message: 'Passwords must match', + path: ['confirmPassword'], + }); + +type ResetPasswordFormData = z.infer; + +function getErrorMessage(error: unknown): string { + if (typeof error === 'object' && error !== null && 'message' in error) { + const message = (error as { message?: string | string[] }).message; + if (Array.isArray(message) && message.length > 0) { + return message[0] ?? 'Failed to reset password'; + } + if (typeof message === 'string' && message.length > 0) { + return message; + } + } + + return 'Failed to reset password'; +} + +function ResetPasswordForm() { + const searchParams = useSearchParams(); + const token = useMemo(() => searchParams.get('token')?.trim() ?? '', [searchParams]); + const [isSuccess, setIsSuccess] = useState(false); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(resetPasswordSchema), + defaultValues: { + newPassword: '', + confirmPassword: '', + }, + }); + + if (!token) { + return ( + + + Invalid link + + This password reset link is missing or invalid. Request a new reset email to continue. + + + + + + + ); + } + + if (isSuccess) { + return ( + + + Password updated + + Your password has been reset successfully. You can now sign in with your new password. + + + + + + + ); + } + + const onSubmit = async (data: ResetPasswordFormData) => { + try { + await resetPassword(token, data.newPassword); + setIsSuccess(true); + } catch (error: unknown) { + toast.error(getErrorMessage(error)); + } + }; + + return ( + + + Reset password + Enter a new password for your account. + +
+ +
+ + + {errors.newPassword && ( +

{errors.newPassword.message}

+ )} +
+
+ + + {errors.confirmPassword && ( +

{errors.confirmPassword.message}

+ )} +
+
+ + + +
+
+ ); +} + +export default function ResetPasswordPage() { + return ( + + + + ); +}