Added user profile update

This commit is contained in:
2025-08-08 00:49:07 +02:00
parent 0a4f5a7d36
commit 298fae916b
10 changed files with 311 additions and 41 deletions

View File

@@ -1,11 +1,11 @@
"use client" "use client"
import { Button } from "@/components/ui/button" 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 { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input" 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 { zodResolver } from "@hookform/resolvers/zod"
import { KeyRoundIcon } from "lucide-react" import { KeyRoundIcon } from "lucide-react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
@@ -22,7 +22,7 @@ export function PasskeyAdd() {
}) })
async function handleSubmit({ name }: { name: string }) { async function handleSubmit({ name }: { name: string }) {
const res = await passkey.addPasskey({ name }) const res = await authClient.passkey.addPasskey({ name })
if (res && res.error) { if (res && res.error) {
toast.error(res.error.message) toast.error(res.error.message)
@@ -35,6 +35,11 @@ export function PasskeyAdd() {
} }
return ( return (
<Card className="max-w-lg">
<CardHeader>
<CardTitle>Passkeys</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 mb-5 max-w-48"> <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 mb-5 max-w-48">
<FormField <FormField
@@ -55,6 +60,8 @@ export function PasskeyAdd() {
</Button> </Button>
</form> </form>
</Form> </Form>
</CardContent>
</Card>
) )
} }
@@ -64,12 +71,12 @@ export function PasskeyItem({ id, name, createdAt }: { id: string, name?: string
async function handleDelete() { async function handleDelete() {
setIsLoading(true) setIsLoading(true)
await passkey.deletePasskey({ id }) await authClient.passkey.deletePasskey({ id })
router.refresh() router.refresh()
} }
return ( return (
<Card className="mb-2"> <Card>
<CardContent> <CardContent>
<p className="font-medium flex gap-2"> <p className="font-medium flex gap-2">
<span> <span>

View File

@@ -8,9 +8,9 @@ export default async function PasskeysList() {
}) })
return ( return (
<div className="space-y-4"> <div className="space-y-4 max-w-lg">
{passkeys.length === 0 ? <p className="text-sm text-muted-foreground">No passkeys registered.</p> : ( {passkeys.length === 0 ? <p className="text-sm text-muted-foreground">No passkeys registered.</p> : (
<div className="max-w-md"> <div>
{passkeys.map(passkey => ( {passkeys.map(passkey => (
<PasskeyItem <PasskeyItem
key={passkey.id} key={passkey.id}

View File

@@ -10,19 +10,19 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar" import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar"
import { signOut, useSession } from "@/lib/auth/auth-client" import { authClient } from "@/lib/auth/auth-client"
import { LogOut, User } from "lucide-react" import { LogOut, User } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" import { toast } from "sonner"
export function SidebarUserDropdown() { export function SidebarUserDropdown() {
const { data: session, isPending } = useSession() const { data: session, isPending } = authClient.useSession()
const { state } = useSidebar() const { state } = useSidebar()
const router = useRouter() const router = useRouter()
const handleSignOut = async () => { const handleSignOut = async () => {
const res = await signOut({ const res = await authClient.signOut({
fetchOptions: { fetchOptions: {
onSuccess: () => { onSuccess: () => {
router.push("/") router.push("/")

View File

@@ -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 (
<Card className="max-w-lg">
<CardContent className="p-6">
<div className="animate-pulse">
<div className="flex items-center space-x-4">
<div className="w-16 h-16 bg-gray-200 rounded-full"></div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded w-32"></div>
<div className="h-3 bg-gray-200 rounded w-24"></div>
</div>
</div>
</div>
</CardContent>
</Card>
)
}
if (!session?.user) {
return (
<Card className="max-w-lg">
<CardContent className="p-6">
<p className="text-center text-gray-500">Not authenticated</p>
</CardContent>
</Card>
)
}
const { user } = session
const initials = user.name
? user.name.split(" ").map(n => n[0]).join("").toUpperCase()
: user.email?.[0]?.toUpperCase() || "U"
return (
<Card className="max-w-lg">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<Avatar className="w-20 h-20">
<AvatarImage src={user.image || undefined} alt={user.name || "User"} />
<AvatarFallback className="text-lg">
{initials}
</AvatarFallback>
</Avatar>
</div>
<div className="flex items-center justify-between">
<CardTitle className="text-xl">Profile</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-medium flex items-center gap-2">
<User className="w-4 h-4" />
User ID
</Label>
<Input
value={user.id}
disabled
className="bg-muted text-muted-foreground"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium flex items-center gap-2">
<Calendar className="w-4 h-4" />
Joined
</Label>
<Input
value={new Date(user.createdAt).toLocaleDateString()}
disabled
className="bg-muted text-muted-foreground"
/>
</div>
<UserUpdateForm name={user.name} image={user.image ? user.image : undefined} email={user.email} />
</CardContent>
</Card>
)
}
const userUpdateSchema = z.object({
name: z.string().min(1),
image: z.string().url().optional()
})
function UserUpdateForm({ name, image, email }: z.infer<typeof userUpdateSchema> & { email: string }) {
const [editing, setEditing] = useState(false)
const router = useRouter()
const form = useForm<z.infer<typeof userUpdateSchema>>({
resolver: zodResolver(userUpdateSchema),
defaultValues: { name, image },
disabled: !editing
})
async function handleSubmit(data: z.infer<typeof userUpdateSchema>) {
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 (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium flex items-center gap-2">
<User2 className="w-4 h-4" />
Name
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="image"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium flex items-center gap-2">
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<Image className="w-4 h-4" />
Image
</FormLabel>
<FormControl>
<div className="flex gap-2">
<Input {...field} />
<Button type="button" variant="outline" hidden={!editing} onClick={handleGravatar}>
Gravatar
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
{editing === true ?
(
<div className="flex gap-2">
<Button
onClick={() => {
setEditing(false)
form.reset()
}}
variant="outline"
type="button"
>
Cancel
</Button>
<Button type="submit">
Save
</Button>
</div>
) :
(
<Button onClick={() => setEditing(true)}>
Edit
</Button>
)}
</div>
</form>
</Form>
</>
)
}

View File

@@ -1,6 +1,7 @@
import { getSession } from "@/lib/auth/session" import { getSession } from "@/lib/auth/session"
import { PasskeyAdd } from "../_components/passkey" import { PasskeyAdd } from "../_components/passkey"
import PasskeysList from "../_components/passkeys-list" import PasskeysList from "../_components/passkeys-list"
import UserProfile from "../_components/user-profile"
export default async function UserPage() { export default async function UserPage() {
const { session, redirect } = await getSession() const { session, redirect } = await getSession()
@@ -11,12 +12,15 @@ export default async function UserPage() {
return ( return (
<div className="p-6"> <div className="p-6">
<div className="mb-6"> <div className="pb-6">
<h1 className="block mb-2 text-2xl font-bold text-foreground">User Profile</h1> <h1 className="block mb-2 text-2xl font-bold text-foreground">User Profile</h1>
<h1 className="block text-muted-foreground">Manage user settings</h1> <h1 className="block text-muted-foreground">Manage user settings</h1>
</div> </div>
<div className="flex flex-col gap-6">
<UserProfile />
<PasskeyAdd /> <PasskeyAdd />
<PasskeysList /> <PasskeysList />
</div> </div>
</div>
) )
} }

View File

@@ -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
})
}

View File

@@ -2,7 +2,7 @@
import { AuthentikIcon } from "@/components/svgs/authentik" import { AuthentikIcon } from "@/components/svgs/authentik"
import { Button } from "@/components/ui/button" 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 { KeyIcon, Loader2 } from "lucide-react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useState } from "react" import { useState } from "react"
@@ -20,7 +20,7 @@ export function OAuthSignInButton({
const handleOAuthSignIn = async () => { const handleOAuthSignIn = async () => {
setIsLoading(true) setIsLoading(true)
const res = await signIn.oauth2({ const res = await authClient.signIn.oauth2({
providerId: "authentik", providerId: "authentik",
callbackURL: "/dashboard" callbackURL: "/dashboard"
}) })
@@ -36,7 +36,7 @@ export function OAuthSignInButton({
const handlePasskeySignIn = async () => { const handlePasskeySignIn = async () => {
setIsLoading(true) setIsLoading(true)
const res = await signIn.passkey({ const res = await authClient.signIn.passkey({
fetchOptions: { fetchOptions: {
onSuccess: () => { onSuccess: () => {
router.push("/dashboard") router.push("/dashboard")

View File

@@ -10,7 +10,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger
} from "@/components/ui/dropdown-menu" } 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 { LogOut, Settings } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
@@ -21,11 +21,11 @@ interface UserDropdownProps {
} }
export function UserDropdown({ className }: UserDropdownProps) { export function UserDropdown({ className }: UserDropdownProps) {
const { data: session, isPending } = useSession() const { data: session, isPending } = authClient.useSession()
const router = useRouter() const router = useRouter()
const handleSignOut = async () => { const handleSignOut = async () => {
const res = await signOut({ const res = await authClient.signOut({
fetchOptions: { fetchOptions: {
onSuccess: () => { onSuccess: () => {
router.push("/") router.push("/")

View File

@@ -1,6 +1,6 @@
import { genericOAuthClient, passkeyClient } from "better-auth/client/plugins" import { genericOAuthClient, passkeyClient } from "better-auth/client/plugins"
import { createAuthClient } from "better-auth/react" import { createAuthClient } from "better-auth/react"
export const { signIn, signOut, useSession, passkey } = createAuthClient({ export const authClient = createAuthClient({
plugins: [genericOAuthClient(), passkeyClient()] plugins: [genericOAuthClient(), passkeyClient()]
}) })