From 298fae916b17732684e59354a97df88d7e11c61d Mon Sep 17 00:00:00 2001 From: Taken Date: Fri, 8 Aug 2025 00:49:07 +0200 Subject: [PATCH] Added user profile update --- .../(admin)/dashboard/_components/passkey.tsx | 57 +++-- .../dashboard/_components/passkeys-list.tsx | 4 +- .../_components/sidebar-user-dropdown.tsx | 6 +- .../dashboard/_components/user-profile.tsx | 220 ++++++++++++++++++ src/app/(admin)/dashboard/user/page.tsx | 10 +- src/app/api/gravatar/[email]/route.ts | 39 ++++ src/app/globals.css | 2 +- src/app/sign-in/_components/signin-button.tsx | 6 +- src/components/user-dropdown.tsx | 6 +- src/lib/auth/auth-client.ts | 2 +- 10 files changed, 311 insertions(+), 41 deletions(-) create mode 100644 src/app/(admin)/dashboard/_components/user-profile.tsx create mode 100644 src/app/api/gravatar/[email]/route.ts diff --git a/src/app/(admin)/dashboard/_components/passkey.tsx b/src/app/(admin)/dashboard/_components/passkey.tsx index d98ca06..f0c6f6d 100644 --- a/src/app/(admin)/dashboard/_components/passkey.tsx +++ b/src/app/(admin)/dashboard/_components/passkey.tsx @@ -1,11 +1,11 @@ "use client" import { Button } from "@/components/ui/button" -import { Card, CardContent } from "@/components/ui/card" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" import { Input } from "@/components/ui/input" -import { passkey } from "@/lib/auth/auth-client" +import { authClient } from "@/lib/auth/auth-client" import { zodResolver } from "@hookform/resolvers/zod" import { KeyRoundIcon } from "lucide-react" import { useRouter } from "next/navigation" @@ -22,7 +22,7 @@ export function PasskeyAdd() { }) async function handleSubmit({ name }: { name: string }) { - const res = await passkey.addPasskey({ name }) + const res = await authClient.passkey.addPasskey({ name }) if (res && res.error) { toast.error(res.error.message) @@ -35,26 +35,33 @@ export function PasskeyAdd() { } return ( -
- - ( - - Name - - - - - - )} - /> - - - + + + Passkeys + + +
+ + ( + + Name + + + + + + )} + /> + + + +
+
) } @@ -64,12 +71,12 @@ export function PasskeyItem({ id, name, createdAt }: { id: string, name?: string async function handleDelete() { setIsLoading(true) - await passkey.deletePasskey({ id }) + await authClient.passkey.deletePasskey({ id }) router.refresh() } return ( - +

diff --git a/src/app/(admin)/dashboard/_components/passkeys-list.tsx b/src/app/(admin)/dashboard/_components/passkeys-list.tsx index 92d6d51..8c4b01a 100644 --- a/src/app/(admin)/dashboard/_components/passkeys-list.tsx +++ b/src/app/(admin)/dashboard/_components/passkeys-list.tsx @@ -8,9 +8,9 @@ export default async function PasskeysList() { }) return ( -

+
{passkeys.length === 0 ?

No passkeys registered.

: ( -
+
{passkeys.map(passkey => ( { - const res = await signOut({ + const res = await authClient.signOut({ fetchOptions: { onSuccess: () => { router.push("/") diff --git a/src/app/(admin)/dashboard/_components/user-profile.tsx b/src/app/(admin)/dashboard/_components/user-profile.tsx new file mode 100644 index 0000000..683c37d --- /dev/null +++ b/src/app/(admin)/dashboard/_components/user-profile.tsx @@ -0,0 +1,220 @@ +"use client" + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { authClient } from "@/lib/auth/auth-client" +import { zodResolver } from "@hookform/resolvers/zod" +import { Calendar, Image, User, User2 } from "lucide-react" +import { useRouter } from "next/navigation" +import { useState } from "react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" + +export default function UserProfile() { + const { data: session, isPending } = authClient.useSession() + + if (isPending) { + return ( + + +
+
+
+
+
+
+
+
+
+
+
+ ) + } + + if (!session?.user) { + return ( + + +

Not authenticated

+
+
+ ) + } + + const { user } = session + const initials = user.name + ? user.name.split(" ").map(n => n[0]).join("").toUpperCase() + : user.email?.[0]?.toUpperCase() || "U" + + return ( + + +
+ + + + {initials} + + +
+
+ Profile +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ ) +} + +const userUpdateSchema = z.object({ + name: z.string().min(1), + image: z.string().url().optional() +}) + +function UserUpdateForm({ name, image, email }: z.infer & { email: string }) { + const [editing, setEditing] = useState(false) + const router = useRouter() + const form = useForm>({ + resolver: zodResolver(userUpdateSchema), + defaultValues: { name, image }, + disabled: !editing + }) + + async function handleSubmit(data: z.infer) { + const res = await authClient.updateUser({ + name: data.name, + image: data.image + }) + + if (res && res.error) { + toast.error(res.error.message) + return + } + + toast.success("Profile updated successfully") + setEditing(false) + router.refresh() + } + + async function handleGravatar() { + const res = await fetch(`/api/gravatar/${email}`) + + if (!res.ok) { + toast.error("Failed to fetch Gravatar") + return + } + + const data = await res.json() + + if (data.error) { + toast.error(data.message) + return + } + + console.log(data) + + form.setValue("image", data.url) + } + + return ( + <> +
+ + ( + + + + Name + + + + + + + )} + /> + ( + + + {/* eslint-disable-next-line jsx-a11y/alt-text */} + + Image + + +
+ + +
+
+ +
+ )} + /> +
+ {editing === true ? + ( +
+ + +
+ ) : + ( + + )} +
+ + + + ) +} diff --git a/src/app/(admin)/dashboard/user/page.tsx b/src/app/(admin)/dashboard/user/page.tsx index 98f8cc2..d39de94 100644 --- a/src/app/(admin)/dashboard/user/page.tsx +++ b/src/app/(admin)/dashboard/user/page.tsx @@ -1,6 +1,7 @@ import { getSession } from "@/lib/auth/session" import { PasskeyAdd } from "../_components/passkey" import PasskeysList from "../_components/passkeys-list" +import UserProfile from "../_components/user-profile" export default async function UserPage() { const { session, redirect } = await getSession() @@ -11,12 +12,15 @@ export default async function UserPage() { return (
-
+

User Profile

Manage user settings

- - +
+ + + +
) } diff --git a/src/app/api/gravatar/[email]/route.ts b/src/app/api/gravatar/[email]/route.ts new file mode 100644 index 0000000..d73aa14 --- /dev/null +++ b/src/app/api/gravatar/[email]/route.ts @@ -0,0 +1,39 @@ +import { getSession } from "@/lib/auth/session" +import { getGravatar } from "@/lib/gravatar" +import { NextResponse } from "next/server" +import { z } from "zod" + +export async function GET(_: Request, { params }: { params: Promise<{ email: string }> }) { + const { session } = await getSession() + const { email: unsafeEmail } = await params + + if (!session) { + return NextResponse.json({ + error: true, + message: "Unauthorized" + }, { status: 401 }) + } + + const { error, data: email } = z.string().email().safeParse(unsafeEmail) + + if (error) { + return NextResponse.json({ + error: true, + message: "Invalid email" + }, { status: 400 }) + } + + const gravatar = await getGravatar(email) + + if (!gravatar) { + return NextResponse.json({ + error: true, + message: "Gravatar not found" + }, { status: 404 }) + } + + return NextResponse.json({ + error: false, + url: gravatar + }) +} diff --git a/src/app/globals.css b/src/app/globals.css index 1a6e594..8aee68a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -196,4 +196,4 @@ body { @apply bg-background text-foreground; } -} +} \ No newline at end of file diff --git a/src/app/sign-in/_components/signin-button.tsx b/src/app/sign-in/_components/signin-button.tsx index f0d16d4..06f4cc0 100644 --- a/src/app/sign-in/_components/signin-button.tsx +++ b/src/app/sign-in/_components/signin-button.tsx @@ -2,7 +2,7 @@ import { AuthentikIcon } from "@/components/svgs/authentik" import { Button } from "@/components/ui/button" -import { signIn } from "@/lib/auth/auth-client" +import { authClient } from "@/lib/auth/auth-client" import { KeyIcon, Loader2 } from "lucide-react" import { useRouter } from "next/navigation" import { useState } from "react" @@ -20,7 +20,7 @@ export function OAuthSignInButton({ const handleOAuthSignIn = async () => { setIsLoading(true) - const res = await signIn.oauth2({ + const res = await authClient.signIn.oauth2({ providerId: "authentik", callbackURL: "/dashboard" }) @@ -36,7 +36,7 @@ export function OAuthSignInButton({ const handlePasskeySignIn = async () => { setIsLoading(true) - const res = await signIn.passkey({ + const res = await authClient.signIn.passkey({ fetchOptions: { onSuccess: () => { router.push("/dashboard") diff --git a/src/components/user-dropdown.tsx b/src/components/user-dropdown.tsx index cbc10ac..3b6240d 100644 --- a/src/components/user-dropdown.tsx +++ b/src/components/user-dropdown.tsx @@ -10,7 +10,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" -import { signOut, useSession } from "@/lib/auth/auth-client" +import { authClient } from "@/lib/auth/auth-client" import { LogOut, Settings } from "lucide-react" import Link from "next/link" import { useRouter } from "next/navigation" @@ -21,11 +21,11 @@ interface UserDropdownProps { } export function UserDropdown({ className }: UserDropdownProps) { - const { data: session, isPending } = useSession() + const { data: session, isPending } = authClient.useSession() const router = useRouter() const handleSignOut = async () => { - const res = await signOut({ + const res = await authClient.signOut({ fetchOptions: { onSuccess: () => { router.push("/") diff --git a/src/lib/auth/auth-client.ts b/src/lib/auth/auth-client.ts index c2b891e..df89d31 100644 --- a/src/lib/auth/auth-client.ts +++ b/src/lib/auth/auth-client.ts @@ -1,6 +1,6 @@ import { genericOAuthClient, passkeyClient } from "better-auth/client/plugins" import { createAuthClient } from "better-auth/react" -export const { signIn, signOut, useSession, passkey } = createAuthClient({ +export const authClient = createAuthClient({ plugins: [genericOAuthClient(), passkeyClient()] })