diff --git a/frontend/app/(auth)/admin/error.tsx b/frontend/app/(auth)/admin/error.tsx new file mode 100644 index 0000000..9b72049 --- /dev/null +++ b/frontend/app/(auth)/admin/error.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { motion } from "framer-motion"; +import { AlertTriangle } from "lucide-react"; + +interface AdminErrorBoundaryProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function AdminErrorBoundary({ error, reset }: AdminErrorBoundaryProps) { + const router = useRouter(); + const showDevDetails = process.env.NODE_ENV === "development"; + + useEffect(() => { + console.error(error); + }, [error]); + + return ( +
+ + + + +
+

Something went wrong

+

+ We hit a snag while loading this admin page. You can try to continue below or head back to a safer place. +

+
+ +
+ + + +
+ + {showDevDetails && ( +
+ + Development details + +
+

+ Message: {error.message} +

+

+ Digest: {error.digest ?? "N/A"} +

+
+
+ )} +
+ ); +} diff --git a/frontend/app/(auth)/admin/layout.tsx b/frontend/app/(auth)/admin/layout.tsx new file mode 100644 index 0000000..951e06a --- /dev/null +++ b/frontend/app/(auth)/admin/layout.tsx @@ -0,0 +1,21 @@ +import { ReactNode } from "react"; + +import { AdminLayout } from "@/components/admin/AdminLayout"; +import { ToastProvider } from "@/providers/ToastProvider"; + +export const metadata = { + robots: { index: false, follow: false }, + title: { template: "%s | Admin | Savoria", default: "Admin | Savoria" }, +}; + +interface AdminRootLayoutProps { + children: ReactNode; +} + +export default function AdminRootLayout({ children }: AdminRootLayoutProps) { + return ( + + {children} + + ); +} diff --git a/frontend/app/(auth)/admin/page.tsx b/frontend/app/(auth)/admin/page.tsx new file mode 100644 index 0000000..1f81840 --- /dev/null +++ b/frontend/app/(auth)/admin/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +export default function AdminIndexPage() { + const router = useRouter(); + + useEffect(() => { + router.push("/admin/dashboard"); + }, [router]); + + return null; +} diff --git a/frontend/components/admin/AdminLayout.tsx b/frontend/components/admin/AdminLayout.tsx new file mode 100644 index 0000000..8ba727f --- /dev/null +++ b/frontend/components/admin/AdminLayout.tsx @@ -0,0 +1,2 @@ +export { AdminLayout } from "@/app/components/admin/AdminLayout"; +export { default } from "@/app/components/admin/AdminLayout"; diff --git a/frontend/components/admin/profile/ChangePasswordForm.tsx b/frontend/components/admin/profile/ChangePasswordForm.tsx new file mode 100644 index 0000000..4644202 --- /dev/null +++ b/frontend/components/admin/profile/ChangePasswordForm.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useState } from "react"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Eye, EyeOff, Info } from "lucide-react"; + +const passwordSchema = z + .object({ + currentPassword: z.string().min(1, "Current password is required"), + newPassword: z.string().min(6, "New password must be at least 6 characters"), + confirmPassword: z.string().min(1, "Please confirm your new password"), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: "Passwords don't match", + path: ["confirmPassword"], + }); + +export type PasswordFormData = z.infer; + +interface ChangePasswordFormProps { + onSubmit: (data: PasswordFormData) => Promise; + isSubmitting: boolean; +} + +export function ChangePasswordForm({ onSubmit, isSubmitting }: ChangePasswordFormProps) { + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(passwordSchema), + }); + + const [showCurrentPassword, setShowCurrentPassword] = useState(false); + const [showNewPassword, setShowNewPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + + const handleValidSubmit = handleSubmit(async (values) => { + await onSubmit(values); + reset(); + }); + + const renderToggleButton = (isVisible: boolean, toggle: () => void) => ( + + ); + + return ( +
+ {[ + { + key: "current", + name: "currentPassword" as const, + label: "Current password", + placeholder: "********", + type: showCurrentPassword ? "text" : "password", + isVisible: showCurrentPassword, + toggle: () => setShowCurrentPassword((current) => !current), + }, + { + key: "new", + name: "newPassword" as const, + label: "New password", + placeholder: "********", + type: showNewPassword ? "text" : "password", + isVisible: showNewPassword, + toggle: () => setShowNewPassword((current) => !current), + }, + { + key: "confirm", + name: "confirmPassword" as const, + label: "Confirm new password", + placeholder: "********", + type: showConfirmPassword ? "text" : "password", + isVisible: showConfirmPassword, + toggle: () => setShowConfirmPassword((current) => !current), + }, + ].map((field) => ( +
+ +
+ + {renderToggleButton(field.isVisible, field.toggle)} +
+ {field.name === "newPassword" && ( +

Must be at least 6 characters

+ )} + {errors[field.name] && ( +

{errors[field.name]?.message}

+ )} +
+ ))} + +
+
+ +

Security notice

+
+
    +
  • Use a strong, unique password
  • +
  • Include letters, numbers, and special characters
  • +
  • Don't reuse passwords from other accounts
  • +
+
+ +
+ +
+
+ ); +} + +export default ChangePasswordForm;