Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions frontend/app/(auth)/admin/error.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex min-h-screen w-full flex-col items-center justify-center gap-6 bg-gray-50 px-4 text-center">
<motion.div
initial={{ opacity: 0, scale: 0.8, y: -10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ type: "spring", stiffness: 140, damping: 18 }}
className="text-red-500"
>
<AlertTriangle size={72} />
</motion.div>

<div className="max-w-xl space-y-3">
<h1 className="text-3xl font-semibold text-gray-900">Something went wrong</h1>
<p className="text-sm text-gray-600">
We hit a snag while loading this admin page. You can try to continue below or head back to a safer place.
</p>
</div>

<div className="flex flex-wrap justify-center gap-3">
<button
type="button"
onClick={reset}
className="rounded-lg border border-transparent bg-blue-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-blue-700"
>
Try Again
</button>
<button
type="button"
onClick={() => router.push("/admin/dashboard")}
className="rounded-lg border border-blue-600 px-5 py-3 text-sm font-semibold text-blue-600 transition hover:bg-blue-50"
>
Go to Dashboard
</button>
<button
type="button"
onClick={() => router.push("/")}
className="rounded-lg border border-gray-300 px-5 py-3 text-sm font-semibold text-gray-700 transition hover:bg-gray-100"
>
Go to Main Site
</button>
</div>

{showDevDetails && (
<details className="w-full max-w-2xl rounded-lg border border-gray-200 bg-white p-4 text-left shadow-sm">
<summary className="cursor-pointer text-sm font-semibold text-gray-800">
Development details
</summary>
<div className="mt-3 space-y-1 text-xs text-gray-700">
<p>
<span className="font-semibold">Message:</span> {error.message}
</p>
<p>
<span className="font-semibold">Digest:</span> {error.digest ?? "N/A"}
</p>
</div>
</details>
)}
</div>
);
}
21 changes: 21 additions & 0 deletions frontend/app/(auth)/admin/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ToastProvider>
<AdminLayout>{children}</AdminLayout>
</ToastProvider>
);
}
14 changes: 14 additions & 0 deletions frontend/app/(auth)/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions frontend/components/admin/AdminLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { AdminLayout } from "@/app/components/admin/AdminLayout";
export { default } from "@/app/components/admin/AdminLayout";
150 changes: 150 additions & 0 deletions frontend/components/admin/profile/ChangePasswordForm.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof passwordSchema>;

interface ChangePasswordFormProps {
onSubmit: (data: PasswordFormData) => Promise<void>;
isSubmitting: boolean;
}

export function ChangePasswordForm({ onSubmit, isSubmitting }: ChangePasswordFormProps) {
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<PasswordFormData>({
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) => (
<button
type="button"
className="absolute top-1/2 right-3 -translate-y-1/2 text-sm text-gray-500"
onClick={toggle}
>
{isVisible ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
);

return (
<form onSubmit={handleValidSubmit} className="space-y-6">
{[
{
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) => (
<div key={field.key} className="space-y-1">
<label
htmlFor={field.name}
className="block text-sm font-medium text-gray-700"
>
{field.label}
</label>
<div className="relative">
<input
id={field.name}
type={field.type}
placeholder={field.placeholder}
autoComplete={
field.name === "currentPassword"
? "current-password"
: "new-password"
}
className="w-full rounded-lg border border-gray-300 bg-white px-4 py-3 pr-12 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
{...register(field.name)}
/>
{renderToggleButton(field.isVisible, field.toggle)}
</div>
{field.name === "newPassword" && (
<p className="text-xs text-gray-500">Must be at least 6 characters</p>
)}
{errors[field.name] && (
<p className="text-xs text-red-600">{errors[field.name]?.message}</p>
)}
</div>
))}

<div className="rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-800">
<div className="flex items-center gap-2 text-blue-800">
<Info size={16} />
<p className="font-semibold">Security notice</p>
</div>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>Use a strong, unique password</li>
<li>Include letters, numbers, and special characters</li>
<li>Don't reuse passwords from other accounts</li>
</ul>
</div>

<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="flex min-w-[200px] items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{isSubmitting ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>Updating...</span>
</>
) : (
"Update Password"
)}
</button>
</div>
</form>
);
}

export default ChangePasswordForm;
Loading