diff --git a/app/(main)/admin/page.tsx b/app/(main)/admin/page.tsx new file mode 100644 index 0000000..c45c935 --- /dev/null +++ b/app/(main)/admin/page.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { ProtectedRoute } from "@/lib/auth/ProtectedRoute"; +import { useAuthStore } from "@/store/authStore"; +import { Card } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; + +function AdminContent() { + const { user } = useAuthStore(); + + return ( +
+
+ {/* Header */} +
+

+ Admin Dashboard +

+

+ Manage users, campaigns, and system settings. +

+
+ + {/* Admin Stats */} +
+ +

+ Total Users +

+

1,234

+

+12% this month

+
+ + +

+ Active Campaigns +

+

45

+

+8% this month

+
+ + +

+ Total Revenue +

+

$89,450

+

+23% this month

+
+ + +

+ Pending Approvals +

+

7

+

Need review

+
+
+ + {/* Admin Actions */} +
+ +

+ User Management +

+
+ + + +
+
+ + +

+ Campaign Management +

+
+ + + +
+
+
+ + {/* Recent Admin Activity */} + +

+ Recent Admin Activity +

+
+
+
+

+ Approved campaign "Clean Water Initiative" +

+

+ Admin: {user?.name} • 1 hour ago +

+
+ + Approved + +
+
+
+

+ Updated user role to moderator +

+

+ Admin: {user?.name} • 3 hours ago +

+
+ + Role Change + +
+
+
+

+ Suspended user account for policy violation +

+

+ Admin: {user?.name} • 5 hours ago +

+
+ + Suspended + +
+
+
+
+
+ ); +} + +export default function AdminPage() { + return ( + + + + ); +} diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx new file mode 100644 index 0000000..f4aefa0 --- /dev/null +++ b/app/(main)/dashboard/page.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { ProtectedRoute } from "@/lib/auth/ProtectedRoute"; +import { useAuthStore } from "@/store/authStore"; +import { Card } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; + +function DashboardContent() { + const { user, logout } = useAuthStore(); + + return ( +
+
+ {/* Header */} +
+

+ Welcome back, {user?.name || "User"}! +

+

+ Here's what's happening with your StellarAid account + today. +

+
+ + {/* User Info Card */} + +
+
+

+ Account Information +

+

Email: {user?.email}

+

User ID: {user?.id}

+ {(user as any)?.role && ( +

Role: {(user as any).role}

+ )} +
+ +
+
+ + {/* Dashboard Grid */} +
+ +

+ Total Donations +

+

$12,450

+

This month

+
+ + +

+ Active Campaigns +

+

8

+

Currently supporting

+
+ + +

+ Impact Score +

+

94

+

Based on contributions

+
+
+ + {/* Recent Activity */} + +

+ Recent Activity +

+
+
+
+

+ Donated to Clean Water Fund +

+

2 hours ago

+
+ $250 +
+
+
+

+ Supported Education Initiative +

+

1 day ago

+
+ $100 +
+
+
+

+ Joined Climate Action Campaign +

+

3 days ago

+
+ Joined +
+
+
+
+
+ ); +} + +export default function DashboardPage() { + return ( + + + + ); +} diff --git a/app/auth/README.md b/app/auth/README.md new file mode 100644 index 0000000..e3088c2 --- /dev/null +++ b/app/auth/README.md @@ -0,0 +1,189 @@ +# Authentication Flow Documentation + +## Password Reset Flow + +This document describes the complete password reset flow implemented in the StellarAid application. + +## Flow Overview + +1. **Forgot Password** - User requests password reset via email +2. **Email Sent** - Success message shown (even for non-existent emails) +3. **Reset Password** - User clicks link and sets new password +4. **Success** - User redirected to login with new password + +## Pages + +### 1. Forgot Password Page +**Route:** `/auth/forgot-password` + +**Features:** +- Email input with validation +- Secure submission (shows success for all emails) +- Clear instructions and next steps +- Option to try again or return to login + +**Security:** +- Shows success message even if email doesn't exist (prevents email enumeration) +- Rate limiting should be implemented on the backend +- Email validation before submission + +### 2. Reset Password Page +**Route:** `/auth/reset-password?token=` + +**Features:** +- Token validation from URL parameters +- New password and confirm password fields +- Real-time password requirements checking +- Visual feedback for all requirements +- Handles expired/invalid tokens gracefully + +**Password Requirements:** +- Minimum 8 characters +- At least one uppercase letter +- At least one lowercase letter +- At least one number +- At least one special character + +**Security:** +- Token validation before form display +- Password strength requirements +- Confirmation password matching +- Secure token handling + +## API Endpoints + +### Forgot Password +``` +POST /users/forgot-password +Content-Type: application/json + +{ + "email": "user@example.com" +} +``` + +**Response:** 200 OK (always returns success for security) + +### Reset Password +``` +POST /users/reset-password +Content-Type: application/json + +{ + "token": "reset_token_from_email", + "password": "new_secure_password" +} +``` + +**Response:** 200 OK on success + +**Error Responses:** +- 400: Token expired or invalid +- 400: Password doesn't meet requirements +- 500: Server error + +## User Experience + +### Success States + +#### Forgot Password Success +- Email icon with success message +- Clear next steps instructions +- Option to return to login or try again + +#### Reset Password Success +- Checkmark icon with success message +- Direct link to login page +- Confirmation that password was changed + +### Error States + +#### Invalid/Expired Token +- Warning icon with error message +- Options to request new reset link +- Option to return to login + +#### Form Validation Errors +- Real-time field validation +- Clear error messages +- Visual indicators for requirements + +## Security Considerations + +### Email Enumeration Protection +- Always show success message for forgot password +- Don't reveal if email exists in system + +### Token Security +- Tokens should have expiration (recommended: 1 hour) +- Tokens should be single-use +- Secure token generation on backend + +### Password Security +- Strong password requirements +- Password confirmation +- Secure transmission (HTTPS) + +### Rate Limiting +- Implement rate limiting on forgot password endpoint +- Prevent brute force attacks +- Limit password reset attempts + +## Integration Points + +### Navigation Links +- Login form → "Forgot password?" link +- Forgot password → "Return to sign in" link +- Reset password → "Request new reset link" for invalid tokens + +### Email Templates +Backend should send emails with: +- Reset link with token +- Expiration information +- Security warnings +- Clear call-to-action + +### Error Handling +- Graceful degradation for network errors +- User-friendly error messages +- Recovery options for all error states + +## Testing Scenarios + +### Happy Path +1. User enters valid email → receives reset email +2. User clicks valid link → sets valid password → success +3. User redirected to login → can sign in with new password + +### Edge Cases +1. Invalid email format → validation error +2. Non-existent email → success message (security) +3. Expired token → error message with option to request new +4. Invalid token → error message with option to request new +5. Weak password → validation error with requirements +6. Password mismatch → validation error +7. Network errors → retry options + +## Accessibility + +- Semantic HTML structure +- ARIA labels and roles +- Keyboard navigation support +- Screen reader friendly +- High contrast support +- Focus management + +## Mobile Responsiveness + +- Optimized for mobile devices +- Touch-friendly interface +- Readable text sizes +- Proper spacing for touch targets + +## Future Enhancements + +- Multi-factor authentication support +- Password strength meter +- Social login integration +- Passwordless login options +- Account recovery via security questions diff --git a/app/auth/forgot-password/ForgotPasswordForm.tsx b/app/auth/forgot-password/ForgotPasswordForm.tsx new file mode 100644 index 0000000..9383fe8 --- /dev/null +++ b/app/auth/forgot-password/ForgotPasswordForm.tsx @@ -0,0 +1,215 @@ +'use client'; + +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import Link from 'next/link'; +import { z } from 'zod'; + +import { authApi } from '@/lib/api/auth'; +import { Button } from '@/components/ui/Button'; +import { Card } from '@/components/ui/Card'; +import type { ApiError } from '@/types/api'; + +// Validation schema +const forgotPasswordSchema = z.object({ + email: z + .string() + .min(1, 'Email is required') + .email('Please enter a valid email address'), +}); + +type ForgotPasswordValues = z.infer; + +export default function ForgotPasswordForm() { + const [isSubmitted, setIsSubmitted] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + setError, + } = useForm({ + resolver: zodResolver(forgotPasswordSchema), + defaultValues: { + email: '', + }, + }); + + const onSubmit = async (data: ForgotPasswordValues) => { + setIsLoading(true); + try { + await authApi.forgotPassword({ + email: data.email, + }); + + // Show success message regardless of whether email exists + setIsSubmitted(true); + } catch (err) { + const apiError = err as ApiError; + + // Still show success message for security (don't reveal if email exists) + if (apiError.status === 404) { + setIsSubmitted(true); + } else { + // For other errors, show the error message + setError('root', { + type: 'manual', + message: apiError.message || 'Unable to process request. Please try again.', + }); + } + } finally { + setIsLoading(false); + } + }; + + if (isSubmitted) { + return ( + + {/* Success Icon */} +
+ + + +
+ + {/* Success Message */} +
+

+ Check Your Email +

+

+ We've sent password reset instructions to your email address. + Please check your inbox and follow the link to reset your password. +

+
+ + {/* Instructions */} +
+

Next Steps:

+
    +
  • • Check your email inbox
  • +
  • • Look for an email from StellarAid
  • +
  • • Click the reset link in the email
  • +
  • • Create a new password
  • +
+
+ + {/* Action Buttons */} +
+ + + + +

+ Didn't receive the email? Check your spam folder or{' '} + +

+
+
+ ); + } + + return ( + + {/* Brand header */} +
+
+ S +
+ StellarAid +
+ + {/* Heading */} +
+

Forgot Password?

+

+ Enter your email address and we'll send you a link to reset your password. +

+
+ + {/* Root-level error */} + {errors.root && ( +
+ {errors.root.message} +
+ )} + + {/* Form */} +
+ {/* Email */} +
+ + + {errors.email && ( +

+ {errors.email.message} +

+ )} +
+ + {/* Submit */} + +
+ + {/* Back to login */} +

+ Remember your password?{' '} + + Sign In + +

+
+ ); +} diff --git a/app/auth/forgot-password/page.tsx b/app/auth/forgot-password/page.tsx new file mode 100644 index 0000000..320b36d --- /dev/null +++ b/app/auth/forgot-password/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import ForgotPasswordForm from './ForgotPasswordForm'; + +export const metadata: Metadata = { + title: 'Forgot Password | StellarAid', + description: + 'Reset your StellarAid password securely via email verification.', +}; + +export default function ForgotPasswordPage() { + return ; +} diff --git a/app/auth/login/LoginForm.tsx b/app/auth/login/LoginForm.tsx index b368e0c..8daac05 100644 --- a/app/auth/login/LoginForm.tsx +++ b/app/auth/login/LoginForm.tsx @@ -1,42 +1,44 @@ -'use client'; +"use client"; -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import Link from 'next/link'; -import { Eye, EyeOff } from 'lucide-react'; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import Link from "next/link"; +import { Eye, EyeOff } from "lucide-react"; -import { loginSchema, LoginFormValues } from './schemas'; -import { authApi } from '@/lib/api/auth'; -import { useAuthStore } from '@/store/authStore'; -import { Button } from '@/components/ui/Button'; -import { Card } from '@/components/ui/Card'; -import type { ApiError } from '@/types/api'; +import { loginSchema, LoginFormValues } from "./schemas"; +import { authApi } from "@/lib/api/auth"; +import { useAuthStore } from "@/store/authStore"; +import { useRedirectToIntended } from "@/lib/auth/ProtectedRoute"; +import { Button } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import type { ApiError } from "@/types/api"; export default function LoginForm() { const router = useRouter(); const { login, isAuthenticated, setLoading, isLoading } = useAuthStore(); + const { redirect } = useRedirectToIntended(); const [showPassword, setShowPassword] = useState(false); // Redirect if already authenticated useEffect(() => { if (isAuthenticated) { - router.replace('/dashboard'); + redirect(); } - }, [isAuthenticated, router]); + }, [isAuthenticated, redirect]); const { register, handleSubmit, formState: { errors, isSubmitting }, setError, - // eslint-disable-next-line + // eslint-disable-next-line } = useForm({ resolver: zodResolver(loginSchema), defaultValues: { - email: '', - password: '', + email: "", + password: "", rememberMe: false, }, }); @@ -53,32 +55,33 @@ export default function LoginForm() { // "Remember me" — flag session-only mode when unchecked if (!data.rememberMe) { - sessionStorage.setItem('stellaraid-session-only', 'true'); + sessionStorage.setItem("stellaraid-session-only", "true"); } else { - sessionStorage.removeItem('stellaraid-session-only'); + sessionStorage.removeItem("stellaraid-session-only"); const expiry = Date.now() + 30 * 24 * 60 * 60 * 1000; - localStorage.setItem('stellaraid-session-expiry', String(expiry)); + localStorage.setItem("stellaraid-session-expiry", String(expiry)); } - router.replace('/dashboard'); + redirect(); } catch (err) { const apiError = err as ApiError; const status = apiError.status; if (status === 401 || status === 400) { - setError('password', { - type: 'manual', - message: apiError.message || 'Invalid email or password.', + setError("password", { + type: "manual", + message: apiError.message || "Invalid email or password.", }); } else if (status === 429) { - setError('root', { - type: 'manual', - message: 'Too many login attempts. Please wait a few minutes and try again.', + setError("root", { + type: "manual", + message: + "Too many login attempts. Please wait a few minutes and try again.", }); } else { - setError('root', { - type: 'manual', - message: apiError.message || 'Unable to connect. Please try again.', + setError("root", { + type: "manual", + message: apiError.message || "Unable to connect. Please try again.", }); } } finally { @@ -99,7 +102,9 @@ export default function LoginForm() { {/* Heading */}

Welcome Back

-

Sign in to your account to continue

+

+ Sign in to your account to continue +

{/* Root-level error (rate limiting) */} @@ -124,14 +129,14 @@ export default function LoginForm() { placeholder="you@example.com" autoComplete="email" className={[ - 'w-full px-4 py-2 border rounded-lg transition-all duration-200 text-sm', - 'focus:outline-none focus:ring-2 bg-white text-gray-900 placeholder-gray-400', + "w-full px-4 py-2 border rounded-lg transition-all duration-200 text-sm", + "focus:outline-none focus:ring-2 bg-white text-gray-900 placeholder-gray-400", errors.email - ? 'border-danger-500 focus:border-danger-500 focus:ring-danger-100' - : 'border-gray-300 focus:border-primary-500 focus:ring-primary-200', - ].join(' ')} + ? "border-danger-500 focus:border-danger-500 focus:ring-danger-100" + : "border-gray-300 focus:border-primary-500 focus:ring-primary-200", + ].join(" ")} aria-invalid={!!errors.email} - {...register('email')} + {...register("email")} /> {errors.email && (

@@ -143,7 +148,9 @@ export default function LoginForm() { {/* Password */}

- +
@@ -270,7 +280,7 @@ export default function LoginForm() { {/* Sign up link */}

- Don't have an account?{' '} + Don't have an account?{" "} data.password === data.confirmPassword, { + message: "Passwords don't match", + path: ["confirmPassword"], + }); + +type ResetPasswordValues = z.infer; + +export default function ResetPasswordForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [isLoading, setIsLoading] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + const [tokenError, setTokenError] = useState(null); + + const token = searchParams.get('token'); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + setError, + watch, + } = useForm({ + resolver: zodResolver(resetPasswordSchema), + defaultValues: { + password: '', + confirmPassword: '', + }, + }); + + const password = watch('password'); + + // Check password requirements + const passwordChecks = { + length: password.length >= passwordRequirements.minLength, + uppercase: /[A-Z]/.test(password), + lowercase: /[a-z]/.test(password), + number: /[0-9]/.test(password), + special: /[^A-Za-z0-9]/.test(password), + }; + + // Validate token on mount + useEffect(() => { + if (!token) { + setTokenError('Invalid reset link. Please request a new password reset.'); + } + }, [token]); + + const onSubmit = async (data: ResetPasswordValues) => { + if (!token) { + setError('root', { + type: 'manual', + message: 'Invalid reset link. Please request a new password reset.', + }); + return; + } + + setIsLoading(true); + try { + await authApi.resetPassword({ + token, + password: data.password, + }); + + setIsSuccess(true); + } catch (err) { + const apiError = err as ApiError; + + if (apiError.status === 400 && apiError.message?.includes('expired')) { + setTokenError('This reset link has expired. Please request a new password reset.'); + } else if (apiError.status === 400 && apiError.message?.includes('invalid')) { + setTokenError('This reset link is invalid. Please request a new password reset.'); + } else { + setError('root', { + type: 'manual', + message: apiError.message || 'Unable to reset password. Please try again.', + }); + } + } finally { + setIsLoading(false); + } + }; + + // Success state + if (isSuccess) { + return ( + + {/* Success Icon */} +

+ + + +
+ + {/* Success Message */} +
+

+ Password Reset Successful +

+

+ Your password has been successfully reset. You can now sign in with your new password. +

+
+ + {/* Action Button */} + + + + + ); + } + + // Token error state + if (tokenError) { + return ( + + {/* Error Icon */} +
+ + + +
+ + {/* Error Message */} +
+

+ Reset Link Invalid +

+

+ {tokenError} +

+
+ + {/* Action Buttons */} +
+ + + + + + + +
+
+ ); + } + + return ( + + {/* Brand header */} +
+
+ S +
+ StellarAid +
+ + {/* Heading */} +
+

Reset Password

+

+ Create a new password for your StellarAid account. +

+
+ + {/* Root-level error */} + {errors.root && ( +
+ {errors.root.message} +
+ )} + + {/* Form */} +
+ {/* New Password */} +
+ + + {errors.password && ( +

+ {errors.password.message} +

+ )} + + {/* Password Requirements */} + {password && ( +
+

+ Password must contain: +

+
+
+
+ {passwordChecks.length ? ( + + + + ) : ( + + + + )} +
+ + At least {passwordRequirements.minLength} characters + +
+
+
+ {passwordChecks.uppercase ? ( + + + + ) : ( + + + + )} +
+ + One uppercase letter + +
+
+
+ {passwordChecks.lowercase ? ( + + + + ) : ( + + + + )} +
+ + One lowercase letter + +
+
+
+ {passwordChecks.number ? ( + + + + ) : ( + + + + )} +
+ + One number + +
+
+
+ {passwordChecks.special ? ( + + + + ) : ( + + + + )} +
+ + One special character + +
+
+
+ )} +
+ + {/* Confirm Password */} +
+ + + {errors.confirmPassword && ( +

+ {errors.confirmPassword.message} +

+ )} +
+ + {/* Submit */} + +
+ + {/* Back to login */} +

+ Remember your password?{' '} + + Sign In + +

+
+ ); +} diff --git a/app/auth/reset-password/page.tsx b/app/auth/reset-password/page.tsx new file mode 100644 index 0000000..0beb33c --- /dev/null +++ b/app/auth/reset-password/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import ResetPasswordForm from './ResetPasswordForm'; + +export const metadata: Metadata = { + title: 'Reset Password | StellarAid', + description: + 'Create a new password for your StellarAid account.', +}; + +export default function ResetPasswordPage() { + return ; +} diff --git a/app/auth/signup/SignupForm.tsx b/app/auth/signup/SignupForm.tsx index 48d74a8..13fce31 100644 --- a/app/auth/signup/SignupForm.tsx +++ b/app/auth/signup/SignupForm.tsx @@ -1,22 +1,22 @@ "use client"; import { useCallback } from "react"; -import Link from 'next/link'; +import Link from "next/link"; import { Button, Input, useToast } from "@/components/ui"; -import PasswordInput from '@/components/passwordInput'; -import { FcGoogle } from "react-icons/fc"; -import { SiStellar } from "react-icons/si"; +import PasswordInput from "@/components/passwordInput"; +// import { FcGoogle } from "react-icons/fc"; +// import { SiStellar } from "react-icons/si"; // form & validation -import { useForm, Controller } from 'react-hook-form'; -import { yupResolver } from '@hookform/resolvers/yup'; -import { useRouter } from 'next/navigation'; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; import { registerSchema } from "@/features/authValidation"; import { RadioOption } from "@/components/ui/radioOption"; interface RegisterFormData { email: string; - role: 'donor' | 'creator'; + role: "donor" | "creator"; password: string; confirmPassword: string; } @@ -31,13 +31,13 @@ const Signup = () => { formState: { errors, isValid, isSubmitting }, watch, } = useForm({ - resolver: yupResolver(registerSchema), - mode: 'onChange', + resolver: zodResolver(registerSchema), + mode: "onChange", defaultValues: { - email: '', - role: 'donor', - password: '', - confirmPassword: '', + email: "", + role: "donor", + password: "", + confirmPassword: "", }, }); @@ -46,243 +46,288 @@ const Signup = () => { try { // TODO: Replace with actual API call when backend is ready // await dispatch(registerUser(data)).unwrap(); - + // Simulate API call - await new Promise(resolve => setTimeout(resolve, 1500)); - - toast.success('Registration successful! Please check your email to verify your account.'); - router.push('/auth/verify-email'); + await new Promise((resolve) => setTimeout(resolve, 1500)); + + toast.success( + "Registration successful! Please check your email to verify your account.", + ); + router.push("/auth/verify-email"); } catch (err: any) { - toast.error(err?.message || 'Registration failed. Please try again.'); + toast.error(err?.message || "Registration failed. Please try again."); } }, - [toast, router] + [toast, router], ); return ( -
-
+ - - {/* Logo */} -
-
+ {/* Logo */} +
+
- S -
- - StellarAid + }} + > + + S
+ + StellarAid + +
- {/* Heading */} -
-

+

- Create Account -

-

- Join our transparent giving community -

-
- - {/* Email */} - ( - - )} - /> + }} + > + Create Account + +

+ Join our transparent giving community +

+
- {/* Role selection */} - ( -
- - I want to - - field.onChange("donor")} - /> - field.onChange("creator")} - /> -
- )} - /> - {errors.role && ( -

{errors.role.message}

+ {/* Email */} + ( + )} + /> - {/* Password */} - ( - ( +
+ + I want to + + field.onChange("donor")} /> - )} - /> - {errors.password && ( -

{errors.password.message}

+ field.onChange("creator")} + /> +
)} + /> + {errors.role && ( +

{errors.role.message}

+ )} - {/* Confirm Password */} - ( - - )} - /> - {errors.confirmPassword && ( -

{errors.confirmPassword.message}

+ {/* Password */} + ( + )} + /> + {errors.password && ( +

{errors.password.message}

+ )} - {/* Primary CTA */} - + {/* Confirm Password */} + ( + + )} + /> + {errors.confirmPassword && ( +

+ {errors.confirmPassword.message} +

+ )} - {/* Divider */} -
-
- or -
-
+ {/* Primary CTA */} + - {/* Social / Wallet */} -
- + {/* Divider */} +
+
+ or +
+
- -
+ + + + + + Continue with Google + - {/* Sign-in link */} -

+ Connect Stellar Wallet + +

+ + {/* Sign-in link */} +

- Already have an account?{" "} - e.currentTarget.style.color = "#1e3a8a"} - onMouseLeave={(e) => e.currentTarget.style.color = "#1d4ed8"} - > - Sign in - -

- -
+ }} + > + Already have an account?{" "} + (e.currentTarget.style.color = "#1e3a8a")} + onMouseLeave={(e) => (e.currentTarget.style.color = "#1d4ed8")} + > + Sign in + +

+ +
); }; diff --git a/app/unauthorized/page.tsx b/app/unauthorized/page.tsx new file mode 100644 index 0000000..16f9569 --- /dev/null +++ b/app/unauthorized/page.tsx @@ -0,0 +1,64 @@ +"use client"; + +import Link from "next/link"; +import { Card } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; + +export default function UnauthorizedPage() { + return ( +
+ + {/* Error Icon */} +
+ + + +
+ + {/* Title */} +

Access Denied

+ + {/* Description */} +

+ You don't have permission to access this page. Please contact + your administrator if you believe this is an error. +

+ + {/* Action Buttons */} +
+ + + + + + + +
+ + {/* Help Text */} +

+ If you need assistance, please contact our support team. +

+
+
+ ); +} diff --git a/components/passwordInput.tsx b/components/passwordInput.tsx index 49d09de..edf77a3 100644 --- a/components/passwordInput.tsx +++ b/components/passwordInput.tsx @@ -1,5 +1,5 @@ import { useState, forwardRef, InputHTMLAttributes } from "react"; -import { FiEye, FiEyeOff } from "react-icons/fi"; +// import { FiEye, FiEyeOff } from "react-icons/fi"; import PasswordStrengthBar from "./passwordStrengthBar"; interface PasswordInputProps extends InputHTMLAttributes { @@ -66,7 +66,7 @@ const PasswordInput = forwardRef(function color: "#6b7280", }} > - {visible ? : } + {/* {visible ? : } */}
{showStrength && } diff --git a/components/ui/Toast.tsx b/components/ui/Toast.tsx index 3909995..cb88c41 100644 --- a/components/ui/Toast.tsx +++ b/components/ui/Toast.tsx @@ -1,17 +1,26 @@ "use client"; -import React, { useState, useEffect, useCallback, createContext, useContext, HTMLAttributes, ForwardRefRenderFunction, forwardRef } from "react"; +import React, { + useState, + useEffect, + useCallback, + createContext, + useContext, + HTMLAttributes, + ForwardRefRenderFunction, + forwardRef, +} from "react"; /** Toast types */ export type ToastType = "success" | "error" | "warning" | "info"; /** Toast positions */ -export type ToastPosition = - | "top-left" - | "top-center" - | "top-right" - | "bottom-left" - | "bottom-center" +export type ToastPosition = + | "top-left" + | "top-center" + | "top-right" + | "bottom-left" + | "bottom-center" | "bottom-right"; /** Toast duration options */ @@ -55,23 +64,63 @@ const durationMap: Record = { /** Toast icon components */ const icons: Record = { success: ( - - + + ), error: ( - - + + ), warning: ( - - + + ), info: ( - - + + ), }; @@ -128,6 +177,10 @@ export const ToastProvider: React.FC = ({ setToasts((prev) => prev.filter((toast) => toast.id !== id)); }, []); + // const removeToast = useCallback((id: string) => { + // setToasts((prev) => prev.filter((toast) => toast.id !== id)); + // }, []); + const addToast = useCallback( (toast: Omit): string => { const id = generateId(); @@ -144,7 +197,7 @@ export const ToastProvider: React.FC = ({ return id; }, - [maxToasts, removeToast] + [maxToasts, removeToast], ); const clearAll = useCallback(() => { @@ -155,7 +208,7 @@ export const ToastProvider: React.FC = ({ (type: ToastType) => (message: string, title?: string) => { return addToast({ type, message, title }); }, - [addToast] + [addToast], ); const value: ToastContextType = { @@ -184,7 +237,10 @@ interface ToastContainerProps { } /** Toast container component */ -const ToastContainer: React.FC = ({ position, toasts }) => { +const ToastContainer: React.FC = ({ + position, + toasts, +}) => { const positionClasses: Record = { "top-left": "top-4 left-4", "top-center": "top-4 left-1/2 -translate-x-1/2", @@ -210,12 +266,30 @@ const ToastContainer: React.FC = ({ position, toasts }) => /** Individual toast item */ const ToastItem = forwardRef(function ToastItem( - { id, type = "info", title, message, duration = "medium", onClose, isClosable = true, action, className = "", ...props }, - ref + { + id, + type = "info", + title, + message, + duration = "medium", + onClose, + isClosable = true, + action, + className = "", + ...props + }, + ref, ) { const [isVisible, setIsVisible] = useState(false); const [isLeaving, setIsLeaving] = useState(false); + // const handleClose = useCallback(() => { + // setIsLeaving(true); + // setTimeout(() => { + // onClose(id); + // }, 300); + // }, [id, onClose]); + // Handle animation on mount useEffect(() => { requestAnimationFrame(() => { @@ -271,7 +345,9 @@ const ToastItem = forwardRef(function ToastItem( {title}

)} -

+

{message}

@@ -298,8 +374,18 @@ const ToastItem = forwardRef(function ToastItem( focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 rounded" aria-label="Dismiss notification" > - - + + )} @@ -311,8 +397,19 @@ const ToastItem = forwardRef(function ToastItem( /** Standalone Toast component for manual control */ export const Toast = forwardRef(function Toast( - { id, type = "info", title, message, duration = "medium", onClose, isClosable = true, action, className = "", ...props }, - ref + { + id, + type = "info", + title, + message, + duration = "medium", + onClose, + isClosable = true, + action, + className = "", + ...props + }, + ref, ) { return ( ( - 'auth/register', - async (userData: RegisterData, { rejectWithValue }) => { - try { - // const response = await apiClient.post('/auth/register', userData); - toastSuccess('Registration successful'); - // return response.data; - return { status: 'success', message: 'Registration successful' }; - } catch (err: any) { - toastError(err); - return rejectWithValue(err.response?.data || err.message); - } - } -); - -export const loginUser = createAsyncThunk( - 'auth/login', - async (credentials: LoginCredentials, { rejectWithValue }) => { - try { - const response = await apiClient.post('/auth/login', credentials); - toastSuccess('Logged in successfully'); - return response.data; - } catch (err: any) { - toastError(err); - return rejectWithValue(err.response?.data || err.message); - } - } -); - -export const logoutUser = createAsyncThunk( - 'auth/logout', - async (_: void, { rejectWithValue }) => { - try { - await apiClient.post('/auth/logout'); - toastSuccess('Logged out successfully'); - return true; - } catch (err: any) { - toastError(err); - return rejectWithValue(err.response?.data || err.message); - } - } -); - -export const verifyEmail = createAsyncThunk( - 'auth/verifyEmail', - async (token: string, { rejectWithValue }) => { - try { - const response = await apiClient.post('/auth/verify-email', { token }); - toastSuccess('Email verified'); - return response.data; - } catch (err: any) { - toastError(err); - return rejectWithValue(err.response?.data || err.message); - } - } -); - -export const forgotPassword = createAsyncThunk( - 'auth/forgotPassword', - async (email: string) => { - try { - const response = await apiClient.post('/auth/forgot-password', { email }); - toastSuccess('If this email is registered, a reset link has been sent'); - return response.data; - } catch (err: any) { - // Log for debugging but don't show error toast to user - console.error('Forgot password background error:', err); - - // For security (avoiding user enumeration), always return success state to the UI - // unless it's a critical application error we want the user to see. - // In this specific task, "regardless of whether the email exists" implies a uniform success UI. - toastSuccess('If this email is registered, a reset link has been sent'); - return { status: 'success', message: 'Email processed' }; - } - } -); - -export const resetPassword = createAsyncThunk( - 'auth/resetPassword', - async ({ token, password }: ResetPasswordPayload, { rejectWithValue }) => { - try { - const response = await apiClient.post('/auth/reset-password', { token, password }); - toastSuccess('Password reset successfully'); - return response.data; - } catch (err: any) { - toastError(err); - return rejectWithValue(err.response?.data || err.message); - } - } -); diff --git a/features/authValidation.ts b/features/authValidation.ts index 0dcc162..63db067 100644 --- a/features/authValidation.ts +++ b/features/authValidation.ts @@ -1,43 +1,46 @@ -import * as yup from 'yup'; +import { z } from "zod"; // shared rules -const emailRule = yup +const emailRule = z .string() - .required('Email is required') - .email('Must be a valid email'); + .min(1, "Email is required") + .email("Must be a valid email"); -const passwordRule = yup +const passwordRule = z .string() - .required('Password is required') - .min(8, 'Password must be at least 8 characters'); + .min(1, "Password is required") + .min(8, "Password must be at least 8 characters"); -export const registerSchema = yup.object().shape({ - email: emailRule, - role: yup - .string() - .oneOf(['donor', 'creator'], 'Please select a valid role') - .required('Role is required'), - password: passwordRule, - confirmPassword: yup - .string() - .required('Please confirm your password') - .oneOf([yup.ref('password')], 'Passwords must match'), -}); +export const registerSchema = z + .object({ + email: emailRule, + role: z.enum(["donor", "creator"], { + message: "Please select a valid role", + }), + password: passwordRule, + confirmPassword: z.string().min(1, "Please confirm your password"), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords must match", + path: ["confirmPassword"], + }); -export const loginSchema = yup.object().shape({ +export const loginSchema = z.object({ email: emailRule, - password: yup.string().required('Password is required'), + password: z.string().min(1, "Password is required"), }); -export const forgotPasswordSchema = yup.object().shape({ +export const forgotPasswordSchema = z.object({ email: emailRule, }); -export const resetPasswordSchema = yup.object().shape({ - password: passwordRule, - confirmPassword: yup - .string() - .required('Please confirm your password') - .oneOf([yup.ref('password')], 'Passwords must match'), - token: yup.string().required('Token is required'), -}); +export const resetPasswordSchema = z + .object({ + password: passwordRule, + confirmPassword: z.string().min(1, "Please confirm your password"), + token: z.string().min(1, "Token is required"), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords must match", + path: ["confirmPassword"], + }); diff --git a/lib/api/auth.ts b/lib/api/auth.ts index b42a484..72279a5 100644 --- a/lib/api/auth.ts +++ b/lib/api/auth.ts @@ -1,23 +1,52 @@ -import { apiClient } from './interceptors'; -import { LoginResponse, ApiResponse, RegisterRequest } from '@/types/api'; +import { apiClient } from "./interceptors"; +import { LoginResponse, ApiResponse, RegisterRequest } from "@/types/api"; export const authApi = { - login: async (credentials: any): Promise> => { - const response = await apiClient.post>('/auth/login', credentials); - return response.data; - }, + login: async (credentials: any): Promise> => { + const response = await apiClient.post>( + "/auth/login", + credentials, + ); + return response.data; + }, - register: async (data: RegisterRequest): Promise> => { - const response = await apiClient.post>('/auth/register', data); - return response.data; - }, + register: async ( + data: RegisterRequest, + ): Promise> => { + const response = await apiClient.post>( + "/auth/register", + data, + ); + return response.data; + }, - logout: async (): Promise => { - await apiClient.post('/auth/logout'); - }, + logout: async (): Promise => { + await apiClient.post("/auth/logout"); + }, - getCurrentUser: async (): Promise> => { - const response = await apiClient.get>('/auth/me'); - return response.data; - }, + getCurrentUser: async (): Promise> => { + const response = await apiClient.get>("/auth/me"); + return response.data; + }, + + forgotPassword: async (data: { + email: string; + }): Promise> => { + const response = await apiClient.post>( + "/users/forgot-password", + data, + ); + return response.data; + }, + + resetPassword: async (data: { + token: string; + password: string; + }): Promise> => { + const response = await apiClient.post>( + "/users/reset-password", + data, + ); + return response.data; + }, }; diff --git a/lib/auth/ProtectedRoute.tsx b/lib/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..11f2b43 --- /dev/null +++ b/lib/auth/ProtectedRoute.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, usePathname } from "next/navigation"; +import { useAuthStore } from "@/store/authStore"; +import type { User } from "@/types"; + +// Role type for access control +export type UserRole = "admin" | "user" | "moderator"; + +// Protected route props +interface ProtectedRouteProps { + children: React.ReactNode; + requiredRole?: UserRole; + allowedRoles?: UserRole[]; + fallbackPath?: string; + loadingComponent?: React.ReactNode; +} + +// JWT token validation utility +const isTokenValid = (token: string): boolean => { + try { + // Decode JWT payload (without verification for simplicity) + const payload = JSON.parse(atob(token.split(".")[1])); + const currentTime = Date.now() / 1000; + + // Check if token is expired + return payload.exp > currentTime; + } catch (error) { + console.error("Invalid token format:", error); + return false; + } +}; + +// Check if user has required role +const hasRequiredRole = ( + user: User | null, + requiredRole?: UserRole, + allowedRoles?: UserRole[], +): boolean => { + if (!user) return false; + if (!requiredRole && !allowedRoles) return true; + + const userRole = (user as any).role as UserRole; + + if (allowedRoles) { + return allowedRoles.includes(userRole); + } + + if (requiredRole) { + return userRole === requiredRole; + } + + return false; +}; + +// Loading spinner component +const DefaultLoadingSpinner = () => ( +
+
+
+); + +// Main ProtectedRoute HOC component +export function ProtectedRoute({ + children, + requiredRole, + allowedRoles, + fallbackPath = "/auth/login", + loadingComponent, +}: ProtectedRouteProps) { + const router = useRouter(); + const pathname = usePathname(); + const { user, token, isAuthenticated, isLoading } = useAuthStore(); + const [isCheckingAuth, setIsCheckingAuth] = useState(true); + const [isAuthorized, setIsAuthorized] = useState(false); + + useEffect(() => { + const checkAuthentication = async () => { + setIsCheckingAuth(true); + + // If auth store is still loading, wait + if (isLoading) { + return; + } + + // Check if user is authenticated and token is valid + const isTokenValidValue = token ? isTokenValid(token) : false; + const isUserAuthenticated = isAuthenticated && isTokenValidValue; + + if (!isUserAuthenticated) { + // Store intended destination and redirect to login + if ( + typeof window !== "undefined" && + pathname !== fallbackPath && + pathname !== "/auth/login" + ) { + sessionStorage.setItem("intended-destination", pathname); + } + router.replace(fallbackPath); + setIsAuthorized(false); + setIsCheckingAuth(false); + return; + } + + // Check role-based access + const hasRoleAccess = hasRequiredRole(user, requiredRole, allowedRoles); + + if (!hasRoleAccess) { + // Redirect to unauthorized page or dashboard + router.replace("/unauthorized"); + setIsAuthorized(false); + setIsCheckingAuth(false); + return; + } + + // User is authenticated and authorized + setIsAuthorized(true); + setIsCheckingAuth(false); + + // Clear intended destination if we're on the intended page + if (typeof window !== "undefined") { + const intendedDest = sessionStorage.getItem("intended-destination"); + if (pathname === intendedDest) { + sessionStorage.removeItem("intended-destination"); + } + } + }; + + checkAuthentication(); + }, [ + isAuthenticated, + token, + user, + isLoading, + requiredRole, + allowedRoles, + router, + pathname, + fallbackPath, + ]); + + // Show loading state during auth check + if (isCheckingAuth || isLoading) { + return loadingComponent || ; + } + + // Only render children if authorized + if (!isAuthorized) { + return null; + } + + return <>{children}; +} + +// Higher-order component wrapper +export function withAuth

( + WrappedComponent: React.ComponentType

, + options?: Omit, +) { + return function AuthenticatedComponent(props: P) { + return ( + + + + ); + }; +} + +// Get intended destination from session storage +const getIntendedDestination = (): string => { + if (typeof window === "undefined") return "/dashboard"; + return sessionStorage.getItem("intended-destination") || "/dashboard"; +}; + +// Hook for getting intended destination +export function useIntendedDestination(): string { + return getIntendedDestination(); +} + +// Hook for redirecting to intended destination +export function useRedirectToIntended() { + const router = useRouter(); + const intendedDestination = useIntendedDestination(); + + const redirect = () => { + if (typeof window !== "undefined") { + sessionStorage.removeItem("intended-destination"); + } + router.replace(intendedDestination); + }; + + return { redirect, intendedDestination }; +} + +export default ProtectedRoute; diff --git a/lib/errorTracking.ts b/lib/errorTracking.ts index 615d292..1864299 100644 --- a/lib/errorTracking.ts +++ b/lib/errorTracking.ts @@ -32,7 +32,9 @@ let config: ErrorTrackingConfig = { }; // Initialize error tracking (call this in app initialization) -export function initErrorTracking(customConfig?: Partial): void { +export function initErrorTracking( + customConfig?: Partial, +): void { if (customConfig) { config = { ...config, ...customConfig }; } @@ -40,7 +42,7 @@ export function initErrorTracking(customConfig?: Partial): // Setup global error handlers if enabled if (config.enabled) { setupGlobalErrorHandlers(); - + // Initialize Sentry if available initSentry(); } @@ -58,10 +60,10 @@ function setupGlobalErrorHandlers(): void { source?: string, lineno?: number, colno?: number, - error?: Error + error?: Error, ) => { const errorObj = error || new Error(String(message)); - + captureException(errorObj, { type: "uncaught-error", source, @@ -75,9 +77,10 @@ function setupGlobalErrorHandlers(): void { // Handle unhandled promise rejections window.onunhandledrejection = (event: PromiseRejectionEvent) => { - const error = event.reason instanceof Error - ? event.reason - : new Error(String(event.reason)); + const error = + event.reason instanceof Error + ? event.reason + : new Error(String(event.reason)); captureException(error, { type: "unhandled-promise-rejection", @@ -94,7 +97,9 @@ async function initSentry(): Promise { const sentryDsn = process.env.NEXT_PUBLIC_SENTRY_DSN; if (!sentryDsn) { - console.log("[ErrorTracking] Sentry DSN not configured, skipping Sentry init"); + console.log( + "[ErrorTracking] Sentry DSN not configured, skipping Sentry init", + ); return; } @@ -106,38 +111,49 @@ async function initSentry(): Promise { // eslint-disable-next-line sentryModule = require("@sentry/nextjs"); } catch { - console.log("[ErrorTracking] @sentry/nextjs not installed, skipping Sentry initialization"); + console.log( + "[ErrorTracking] @sentry/nextjs not installed, skipping Sentry initialization", + ); return; } - - if (!sentryModule || typeof sentryModule !== 'object') { - console.log("[ErrorTracking] Sentry module invalid, skipping initialization"); + + if (!sentryModule || typeof sentryModule !== "object") { + console.log( + "[ErrorTracking] Sentry module invalid, skipping initialization", + ); return; } - + const Sentry = sentryModule as { init?: (config: Record) => Promise; browserTracingIntegration?: () => unknown; - replayIntegration?: (options: { maskAllText: boolean; blockAllMedia: boolean }) => unknown; + replayIntegration?: (options: { + maskAllText: boolean; + blockAllMedia: boolean; + }) => unknown; }; - + if (!Sentry.init) { - console.log("[ErrorTracking] Sentry.init not found, skipping initialization"); + console.log( + "[ErrorTracking] Sentry.init not found, skipping initialization", + ); return; } - + const integrations: unknown[] = []; - + if (Sentry.browserTracingIntegration) { integrations.push(Sentry.browserTracingIntegration()); } if (Sentry.replayIntegration) { - integrations.push(Sentry.replayIntegration({ - maskAllText: true, - blockAllMedia: true, - })); + integrations.push( + Sentry.replayIntegration({ + maskAllText: true, + blockAllMedia: true, + }), + ); } - + await Sentry.init({ dsn: sentryDsn, environment: config.environment, @@ -151,17 +167,16 @@ async function initSentry(): Promise { console.log("[ErrorTracking] Sentry initialized successfully"); } catch (error) { - console.log("[ErrorTracking] Sentry not available, skipping initialization"); + console.log( + "[ErrorTracking] Sentry not available, skipping initialization", + ); } } /** * Capture and report an exception */ -export function captureException( - error: Error, - context?: ErrorContext -): void { +export function captureException(error: Error, context?: ErrorContext): void { if (!config.enabled) { console.error("[ErrorTracking] Error (tracking disabled):", error, context); return; @@ -190,10 +205,13 @@ export function captureException( export function captureMessage( message: string, level: "info" | "warning" | "error" = "info", - context?: ErrorContext + context?: ErrorContext, ): void { if (!config.enabled) { - console.log(`[ErrorTracking] Message (tracking disabled): ${message}`, context); + console.log( + `[ErrorTracking] Message (tracking disabled): ${message}`, + context, + ); return; } @@ -231,7 +249,9 @@ export function addBreadcrumb(breadcrumb: Breadcrumb): void { /** * Set user context for error tracking */ -export function setUserContext(user: { id: string; email?: string; username?: string } | null): void { +export function setUserContext( + user: { id: string; email?: string; username?: string } | null, +): void { if (!config.enabled) return; if (typeof window !== "undefined") { @@ -263,7 +283,9 @@ export function setExtraContext(context: ErrorContext): void { /** * Create a retry wrapper for async functions with exponential backoff */ -export function createRetryableFunction Promise>( +export function createRetryableFunction< + T extends (...args: unknown[]) => Promise, +>( fn: T, options: { maxRetries?: number; @@ -271,7 +293,7 @@ export function createRetryableFunction Promis maxDelay?: number; backoffMultiplier?: number; onRetry?: (attempt: number, error: Error) => void; - } = {} + } = {}, ): T { const { maxRetries = 3, @@ -287,7 +309,7 @@ export function createRetryableFunction Promis for (let attempt = 0; attempt <= maxRetries; attempt++) { try { - return await fn(...args) as ReturnType; + return (await fn(...args)) as ReturnType; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); @@ -308,7 +330,7 @@ export function createRetryableFunction Promis // Wait with exponential backoff await new Promise((resolve) => setTimeout(resolve, delay)); - + // Increase delay for next attempt delay = Math.min(delay * backoffMultiplier, maxDelay); } @@ -345,17 +367,26 @@ declare global { interface Window { Sentry?: { captureException: (error: Error, context?: ErrorContext) => void; - captureMessage: (message: string, level?: string, context?: ErrorContext) => void; + captureMessage: ( + message: string, + level?: string, + context?: ErrorContext, + ) => void; addBreadcrumb: (breadcrumb: Breadcrumb) => void; - setUser: (user: { id: string; email?: string; username?: string } | null) => void; + setUser: ( + user: { id: string; email?: string; username?: string } | null, + ) => void; setExtra: (key: string, value: unknown) => void; browserTracingIntegration?: () => unknown; - replayIntegration?: (options: { maskAllText: boolean; blockAllMedia: boolean }) => unknown; + replayIntegration?: (options: { + maskAllText: boolean; + blockAllMedia: boolean; + }) => unknown; }; } } -const errorTrackingModule = { +const errorTracking = { initErrorTracking, captureException, captureMessage, @@ -366,4 +397,4 @@ const errorTrackingModule = { isRetryableError, }; -export default errorTrackingModule; +export default errorTracking; diff --git a/types/index.ts b/types/index.ts index b7138ff..0417c93 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1,9 +1,12 @@ // User Types +export type UserRole = "admin" | "user" | "moderator"; + export interface User { id: string; email: string; name: string; avatar?: string; + role?: UserRole; } // Auth Store Types @@ -43,11 +46,11 @@ export interface WalletActions { export type WalletStore = WalletState & WalletActions; // UI Store Types -export type ModalType = 'login' | 'wallet' | 'settings' | null; +export type ModalType = "login" | "wallet" | "settings" | null; export interface Notification { id: string; - type: 'success' | 'error' | 'warning' | 'info'; + type: "success" | "error" | "warning" | "info"; message: string; duration?: number; } @@ -56,24 +59,24 @@ export interface UIState { activeModal: ModalType; notifications: Notification[]; isSidebarOpen: boolean; - theme: 'light' | 'dark'; + theme: "light" | "dark"; isGlobalLoading: boolean; } export interface UIActions { openModal: (modal: ModalType) => void; closeModal: () => void; - addNotification: (notification: Omit) => void; + addNotification: (notification: Omit) => void; removeNotification: (id: string) => void; toggleSidebar: () => void; - setTheme: (theme: 'light' | 'dark') => void; + setTheme: (theme: "light" | "dark") => void; setGlobalLoading: (isLoading: boolean) => void; } export type UIStore = UIState & UIActions; // Stellar Types -export type StellarNetworkType = 'testnet' | 'public' | 'futurenet'; +export type StellarNetworkType = "testnet" | "public" | "futurenet"; export interface StellarAccount { address: string; @@ -102,7 +105,7 @@ export interface StellarTransaction { asset: string; createdAt: string; memo?: string; - status: 'pending' | 'completed' | 'failed'; + status: "pending" | "completed" | "failed"; } export interface ConnectionStatus {