diff --git a/web/.env.example b/web/.env.example index cb37b0e..69002d2 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,3 +1,16 @@ NEXT_PUBLIC_API_BASE_URL=https://api.textbee.dev/api/v1 NEXT_PUBLIC_GOOGLE_CLIENT_ID= -NEXT_PUBLIC_TAWKTO_EMBED_URL= \ No newline at end of file +NEXT_PUBLIC_TAWKTO_EMBED_URL= + +NEXTAUTH_URL=http://localhost:3000 +AUTH_SECRET= # https://generate-secret.vercel.app/32 + +DATABASE_URL= + +MAIL_HOST= +MAIL_PORT= +MAIL_USER= +MAIL_PASS= +MAIL_FROM= + +ADMIN_EMAIL= \ No newline at end of file diff --git a/web/app/(app)/(auth)/(components)/login-form.tsx b/web/app/(app)/(auth)/(components)/login-form.tsx new file mode 100644 index 0000000..42197bb --- /dev/null +++ b/web/app/(app)/(auth)/(components)/login-form.tsx @@ -0,0 +1,119 @@ +'use client' + +import { signIn } from 'next-auth/react' +import { useRouter } from 'next/navigation' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import * as z from 'zod' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Routes } from '@/config/routes' + +const loginSchema = z.object({ + email: z.string().email({ message: 'Invalid email address' }), + password: z.string().min(1, { message: 'Password is required' }), +}) + +type LoginFormValues = z.infer + +export default function LoginForm() { + const router = useRouter() + + const form = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { + email: '', + password: '', + }, + }) + + const onSubmit = async (data: LoginFormValues) => { + try { + const result = await signIn('email-password-login', { + redirect: false, + email: data.email, + password: data.password, + }) + + if (result?.error) { + form.setError('root', { + type: 'manual', + message: 'Invalid email or password', + }) + } else { + router.push(Routes.dashboard) + } + } catch (error) { + console.error('login error:', error) + form.setError('root', { + type: 'manual', + message: 'An unexpected error occurred. Please try again.', + }) + } + } + + return ( +
+ + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + {form.formState.errors.root && ( +

+ {form.formState.errors.root.message} +

+ )} + + + + ) +} diff --git a/web/app/(app)/(auth)/(components)/login-with-google.tsx b/web/app/(app)/(auth)/(components)/login-with-google.tsx new file mode 100644 index 0000000..9db4a77 --- /dev/null +++ b/web/app/(app)/(auth)/(components)/login-with-google.tsx @@ -0,0 +1,47 @@ +'use client' + +import { Routes } from '@/config/routes' +import { toast } from '@/hooks/use-toast' +import { CredentialResponse, GoogleLogin } from '@react-oauth/google' +import { signIn } from 'next-auth/react' +import { useRouter } from 'next/navigation' + +export default function LoginWithGoogle() { + const router = useRouter() + + const onGoogleLoginSuccess = async ( + credentialResponse: CredentialResponse + ) => { + toast({ + title: 'Success', + description: 'You are logged in with Google', + variant: 'default', + }) + await signIn('google-id-token-login', { + redirect: false, + idToken: credentialResponse.credential, + }) + router.push(Routes.dashboard) + } + + const onGoogleLoginError = () => { + toast({ + title: 'Error', + description: 'Something went wrong', + variant: 'destructive', + }) + } + return ( + + ) +} diff --git a/web/app/(app)/(auth)/(components)/register-form.tsx b/web/app/(app)/(auth)/(components)/register-form.tsx new file mode 100644 index 0000000..7f7ca63 --- /dev/null +++ b/web/app/(app)/(auth)/(components)/register-form.tsx @@ -0,0 +1,179 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import * as z from 'zod' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { signIn } from 'next-auth/react' +import { Checkbox } from '@/components/ui/checkbox' +import { Routes } from '@/config/routes' + +const registerSchema = z.object({ + name: z + .string() + .min(2, { message: 'Name must be at least 2 characters long' }), + email: z.string().email({ message: 'Invalid email address' }), + password: z + .string() + .min(8, { message: 'Password must be at least 8 characters long' }), + phone: z.string().optional(), + marketingOptIn: z.boolean().optional().default(true), +}) + +type RegisterFormValues = z.infer + +export default function RegisterForm() { + const router = useRouter() + + const form = useForm({ + resolver: zodResolver(registerSchema), + defaultValues: { + name: '', + email: '', + password: '', + phone: '', + marketingOptIn: true, + }, + }) + + const onSubmit = async (data: RegisterFormValues) => { + form.clearErrors() + + try { + const result = await signIn('email-password-register', { + redirect: false, + email: data.email, + password: data.password, + name: data.name, + phone: data.phone, + marketingOptIn: data.marketingOptIn, + }) + + if (result?.error) { + console.log(result.error) + form.setError('root', { + type: 'manual', + message: 'Failed to create account', + }) + } else { + router.push(Routes.dashboard) + } + } catch (error) { + console.error('register error:', error) + form.setError('root', { + type: 'manual', + message: 'An unexpected error occurred. Please try again.', + }) + } + } + + return ( +
+ + ( + + Full Name + + + + + + )} + /> + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + ( + + Phone (optional) + + + + + + )} + /> + {form.formState.errors.root && ( +

+ {form.formState.errors.root.message} +

+ )} + + ( + +
+ + + + + I want to receive updates about new features and promotions + +
+ +
+ )} + /> + + + + ) +} diff --git a/web/app/(app)/(auth)/(components)/request-password-reset-form.tsx b/web/app/(app)/(auth)/(components)/request-password-reset-form.tsx new file mode 100644 index 0000000..32f1a50 --- /dev/null +++ b/web/app/(app)/(auth)/(components)/request-password-reset-form.tsx @@ -0,0 +1,134 @@ +'use client' + +import Link from 'next/link' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import * as z from 'zod' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card' +// import { Icons } from "@/components/ui/icons" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' + +import httpBrowserClient from '@/lib/httpBrowserClient' +import { ApiEndpoints } from '@/config/api' +import { Routes } from '@/config/routes' + +const requestPasswordResetSchema = z.object({ + email: z.string().email({ message: 'Invalid email address' }), +}) + +type RequestPasswordResetFormValues = z.infer + +export default function RequestPasswordResetForm() { + const form = useForm({ + resolver: zodResolver(requestPasswordResetSchema), + defaultValues: { + email: '', + }, + }) + + const onRequestPasswordReset = async ( + data: RequestPasswordResetFormValues + ) => { + form.clearErrors() + try { + await httpBrowserClient.post( + ApiEndpoints.auth.requestPasswordReset(), + data + ) + } catch (error) { + form.setError('email', { message: 'Invalid email address' }) + } + } + + return ( +
+ + + + Reset your password + + + Enter your email address and we'll send you a link to reset + your password + + + + {!form.formState.isSubmitted ? ( +
+ + ( + + Email + + + + + + )} + /> + + + + ) : ( + + {/* */} + Check your email + + If an account exists for {form.getValues().email}, you will + receive a password reset link shortly. + + + If you don't receive an email, please check your spam + folder or contact support. + + + )} +
+ + + Back to login + + +
+
+ ) +} diff --git a/web/app/(app)/(auth)/(components)/reset-password-form.tsx b/web/app/(app)/(auth)/(components)/reset-password-form.tsx new file mode 100644 index 0000000..cb45aeb --- /dev/null +++ b/web/app/(app)/(auth)/(components)/reset-password-form.tsx @@ -0,0 +1,199 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import * as z from 'zod' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card' +// import { Icons } from "@/components/ui/icons" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import httpBrowserClient from '@/lib/httpBrowserClient' +import { ApiEndpoints } from '@/config/api' +import { Routes } from '@/config/routes' + +const resetPasswordSchema = z + .object({ + email: z.string().email({ message: 'Invalid email address' }), + otp: z.string().min(4, { message: 'OTP is required' }), + newPassword: z + .string() + .min(8, { message: 'Password must be at least 8 characters long' }), + confirmPassword: z + .string() + .min(4, { message: 'Please confirm your password' }), + }) + .superRefine((data, ctx) => { + if (data.newPassword !== data.confirmPassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Passwords must match', + path: ['confirmPassword'], + }) + } + }) + +type ResetPasswordFormValues = z.infer + +export default function ResetPasswordForm({ + email, + otp, +}: { + email: string + otp: string +}) { + const form = useForm({ + resolver: zodResolver(resetPasswordSchema), + defaultValues: { + email: email, + otp: otp, + newPassword: '', + confirmPassword: '', + }, + }) + + const onResetPassword = async (data: ResetPasswordFormValues) => { + try { + await httpBrowserClient.post(ApiEndpoints.auth.resetPassword(), data) + } catch (error) { + console.error(error) + form.setError('root.serverError', { + message: 'Failed to reset password', + }) + } + } + + return ( +
+ + + + Reset your password + + + Enter your new password below + + + +
+ + ( + + Email + + + + + + )} + /> + ( + + OTP + + + + + + )} + /> + + ( + + New Password + + + + + + )} + /> + + ( + + Confirm Password + + + + + + )} + /> + + {form.formState.errors.root && ( +

+ {form.formState.errors.root.message} +

+ )} + + + + + {form.formState.isSubmitted && form.formState.isSubmitSuccessful && ( + + {/* */} + Password reset successful + + Your password has been reset successfully. You can now login + with your new password. + + + )} +
+ + + Back to login + + +
+
+ ) +} diff --git a/web/app/(app)/(auth)/layout.tsx b/web/app/(app)/(auth)/layout.tsx new file mode 100644 index 0000000..48eacd2 --- /dev/null +++ b/web/app/(app)/(auth)/layout.tsx @@ -0,0 +1,7 @@ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode +}) { + return <>{children} +} diff --git a/web/app/(app)/(auth)/login/page.tsx b/web/app/(app)/(auth)/login/page.tsx new file mode 100644 index 0000000..1fe8050 --- /dev/null +++ b/web/app/(app)/(auth)/login/page.tsx @@ -0,0 +1,66 @@ +'use client' + +import Link from 'next/link' + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card' + +import LoginWithGoogle from '../(components)/login-with-google' +import LoginForm from '../(components)/login-form' +import { Routes } from '@/config/routes' + +export default function LoginPage() { + return ( +
+ + + + Welcome back + + + Enter your credentials to access your account + + + + +
+
+ +
+
+ + Or + +
+
+
+ +
+
+ + + Forgot your password? + +

+ Don't have an account?{' '} + + Sign up + +

+
+
+
+ ) +} diff --git a/web/app/(app)/(auth)/logout/page.tsx b/web/app/(app)/(auth)/logout/page.tsx new file mode 100644 index 0000000..f9029d9 --- /dev/null +++ b/web/app/(app)/(auth)/logout/page.tsx @@ -0,0 +1,16 @@ +'use client' + +import { signOut } from 'next-auth/react' +import { useEffect } from 'react' + +export default function Logout() { + useEffect(() => { + signOut() + }, []) + + return ( +
+ Logging out... +
+ ) +} diff --git a/web/app/(app)/(auth)/register/page.tsx b/web/app/(app)/(auth)/register/page.tsx new file mode 100644 index 0000000..1f4ac59 --- /dev/null +++ b/web/app/(app)/(auth)/register/page.tsx @@ -0,0 +1,49 @@ +'use client' + +import Link from 'next/link' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card' + +import LoginWithGoogle from '../(components)/login-with-google' +import RegisterForm from '../(components)/register-form' +import { Routes } from '@/config/routes' + +export default function RegisterPage() { + return ( +
+ + + + Create an account + + + Enter your details to get started + + + + +
+ +
+
+ +

+ Already have an account?{' '} + + Sign in + +

+
+
+
+ ) +} diff --git a/web/app/(app)/(auth)/reset-password/page.tsx b/web/app/(app)/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..8fdc8c4 --- /dev/null +++ b/web/app/(app)/(auth)/reset-password/page.tsx @@ -0,0 +1,18 @@ +'use client' + +import { useSearchParams } from 'next/navigation' +import ResetPasswordForm from '../(components)/reset-password-form' + +import RequestPasswordResetForm from '../(components)/request-password-reset-form' + +export default function ResetPasswordPage() { + const searchParams = useSearchParams() + const otp = searchParams.get('otp') + const email = searchParams.get('email') + + if (otp && email) { + return + } + + return +} diff --git a/web/app/(app)/dashboard/(components)/account-settings.tsx b/web/app/(app)/dashboard/(components)/account-settings.tsx new file mode 100644 index 0000000..4c832bb --- /dev/null +++ b/web/app/(app)/dashboard/(components)/account-settings.tsx @@ -0,0 +1,509 @@ +'use client' + +import { useState } from 'react' +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Badge } from '@/components/ui/badge' +import { + AlertTriangle, + Mail, + Shield, + UserCircle, + Loader2, + Check, +} from 'lucide-react' +import { useForm } from 'react-hook-form' +import { z } from 'zod' +import { zodResolver } from '@hookform/resolvers/zod' +import { useToast } from '@/hooks/use-toast' +import httpBrowserClient from '@/lib/httpBrowserClient' +import { ApiEndpoints } from '@/config/api' +import { useMutation, useQuery } from '@tanstack/react-query' +import { Spinner } from '@/components/ui/spinner' +import Link from 'next/link' +import { Textarea } from '@/components/ui/textarea' +import axios from 'axios' +import { useSession } from 'next-auth/react' +import { Routes } from '@/config/routes' + +const updateProfileSchema = z.object({ + name: z.string().min(1, 'Name is required'), + email: z.string().email().optional(), + phone: z + .string() + .regex(/^\+?\d{0,14}$/, 'Invalid phone number') + .optional(), +}) + +type UpdateProfileFormData = z.infer + +const changePasswordSchema = z + .object({ + oldPassword: z.string().min(1, 'Old password is required'), + newPassword: z + .string() + .min(8, { message: 'Password must be at least 8 characters long' }), + confirmPassword: z + .string() + .min(4, { message: 'Please confirm your password' }), + }) + .superRefine((data, ctx) => { + if (data.newPassword !== data.confirmPassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Passwords must match', + path: ['confirmPassword'], + }) + } + }) + +type ChangePasswordFormData = z.infer + +export default function AccountSettings() { + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) + const [deleteConfirmEmail, setDeleteConfirmEmail] = useState('') + const { update: updateSession } = useSession() + + const { toast } = useToast() + + const { + data: currentUser, + isLoading: isLoadingUser, + refetch: refetchCurrentUser, + } = useQuery({ + queryKey: ['currentUser'], + queryFn: () => + httpBrowserClient + .get(ApiEndpoints.auth.whoAmI()) + .then((res) => res.data?.data), + }) + + const updateProfileForm = useForm({ + resolver: zodResolver(updateProfileSchema), + defaultValues: { + name: currentUser?.name, + email: currentUser?.email, + phone: currentUser?.phone, + }, + }) + + const changePasswordForm = useForm({ + resolver: zodResolver(changePasswordSchema), + }) + + const handleDeleteAccount = () => { + if (deleteConfirmEmail !== currentUser?.email) { + toast({ + title: 'Please enter your correct email address', + }) + return + } + requestAccountDeletion() + } + + const handleVerifyEmail = () => { + // TODO: Implement email verification + } + + const { + mutate: updateProfile, + isPending: isUpdatingProfile, + error: updateProfileError, + isSuccess: isUpdateProfileSuccess, + } = useMutation({ + mutationFn: (data: UpdateProfileFormData) => + httpBrowserClient.patch(ApiEndpoints.auth.updateProfile(), data), + onSuccess: () => { + refetchCurrentUser() + toast({ + title: 'Profile updated successfully!', + }) + updateSession({ + name: updateProfileForm.getValues().name, + phone: updateProfileForm.getValues().phone, + }) + }, + onError: () => { + toast({ + title: 'Failed to update profile', + }) + }, + }) + + const { + mutate: changePassword, + isPending: isChangingPassword, + error: changePasswordError, + isSuccess: isChangePasswordSuccess, + } = useMutation({ + mutationFn: (data: ChangePasswordFormData) => + httpBrowserClient.post(ApiEndpoints.auth.changePassword(), data), + onSuccess: () => { + toast({ + title: 'Password changed successfully!', + }) + changePasswordForm.reset() + }, + onError: (error) => { + const errorMessage = (error as any).response?.data?.error + changePasswordForm.setError('root.serverError', { + message: errorMessage || 'Failed to change password', + }) + toast({ + title: 'Failed to change password', + }) + }, + }) + + const [deleteReason, setDeleteReason] = useState('') + + const { + mutate: requestAccountDeletion, + isPending: isRequestingAccountDeletion, + error: requestAccountDeletionError, + isSuccess: isRequestAccountDeletionSuccess, + } = useMutation({ + mutationFn: () => + axios.post('/api/request-account-deletion', { + message: deleteReason, + }), + onSuccess: () => { + toast({ + title: 'Account deletion request submitted', + }) + }, + onError: () => { + toast({ + title: 'Failed to submit account deletion request', + }) + }, + }) + + if (isLoadingUser) + return ( +
+ +
+ ) + + return ( +
+ + +
+ + Profile Information +
+ Update your profile information +
+ +
+ updateProfile(data) + )} + className='space-y-4' + > +
+ + + {updateProfileForm.formState.errors.name && ( +

+ {updateProfileForm.formState.errors.name.message} +

+ )} +
+ +
+ +
+ + {!currentUser?.emailVerifiedAt ? ( + + ) : ( + + )} +
+ {updateProfileForm.formState.errors.email && ( +

+ {updateProfileForm.formState.errors.email.message} +

+ )} +
+ +
+ + + {updateProfileForm.formState.errors.phone && ( +

+ {updateProfileForm.formState.errors.phone.message} +

+ )} +
+ + {isUpdateProfileSuccess && ( +

+ Profile updated successfully! +

+ )} + + +
+
+
+ + + +
+ Change Password +
+ + If you signed in with google, your can reset your password{' '} + + here + + . + +
+ +
+ changePassword(data) + )} + className='space-y-4' + > +
+ + + {changePasswordForm.formState.errors.oldPassword && ( +

+ {changePasswordForm.formState.errors.oldPassword.message} +

+ )} +
+ +
+ + + {changePasswordForm.formState.errors.newPassword && ( +

+ {changePasswordForm.formState.errors.newPassword.message} +

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

+ {changePasswordForm.formState.errors.confirmPassword.message} +

+ )} +
+ + {changePasswordForm.formState.errors.root?.serverError && ( +

+ {changePasswordForm.formState.errors.root.serverError.message} +

+ )} + + {isChangePasswordSuccess && ( +

+ Password changed successfully! +

+ )} + + +
+
+
+ + + +
+ + Danger Zone +
+ + Permanently delete your account and all associated data + +
+ + + + + + + + Delete Account + + +

+ Are you sure you want to delete your account? This action: +

+
    +
  • Cannot be undone
  • +
  • Will permanently delete all your data
  • +
  • Will cancel all active subscriptions
  • +
  • Will remove access to all services
  • +
+ + {/* enter reason for deletion text area */} + +