Files
better-auth/demo/nextjs/app/dashboard/user-card.tsx
Bereket Engida c945d1ba95 feat: demo
2024-09-16 13:53:41 +03:00

531 lines
23 KiB
TypeScript

"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { client, signOut, user, useSession } from "@/lib/auth-client";
import { Session } from "@/lib/auth-types";
import { MobileIcon } from "@radix-ui/react-icons";
import { Edit, Fingerprint, Laptop, Loader2, LogOut, Plus, Trash, X } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { UAParser } from "ua-parser-js";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
export default function UserCard(props: {
session: Session | null;
activeSessions: Session["session"][]
}) {
const router = useRouter();
const { data, isPending, error } = useSession(props.session);
const [ua, setUa] = useState<UAParser.UAParserInstance>()
const session = data || props.session
useEffect(() => {
setUa(new UAParser(session?.session.userAgent))
}, [session?.session.userAgent])
return (
<Card>
<CardHeader>
<CardTitle>User</CardTitle>
</CardHeader>
<CardContent className="grid gap-8">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<Avatar className="hidden h-9 w-9 sm:flex ">
<AvatarImage src={session?.user.image || "#"} alt="Avatar" className="object-cover" />
<AvatarFallback>{session?.user.name.charAt(0)}</AvatarFallback>
</Avatar>
<div className="grid gap-1">
<p className="text-sm font-medium leading-none">
{session?.user.name}
</p>
<p className="text-sm">
{session?.user.email}
</p>
</div>
</div>
<EditUserDialog session={session} />
</div>
<div className="border-l-2 px-2 w-max gap-1 flex flex-col">
<p className="text-xs font-medium ">
Active Sessions
</p>
{
props.activeSessions.filter((session) => session.userAgent).map((session) => {
return (
<div className="flex items-center gap-2 text-sm text-black font-medium dark:text-white" key={session.id}>
{
new UAParser(session.userAgent).getDevice().type === "mobile" ? <MobileIcon /> : <Laptop size={16} />
}
{new UAParser(session.userAgent).getOS().name}, {new UAParser(session.userAgent).getBrowser().name}
</div>
)
})
}
</div>
<div className="border-y py-4 flex items-center justify-between gap-2">
<div className="flex flex-col gap-2">
<p className="text-sm">
Passkeys
</p>
<div className="flex gap-2">
<AddPasskey />
<ListPasskeys />
</div>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm">
2FA Authentication
</p>
{session?.user.twoFactorEnabled ? (
<Button
variant="secondary"
className="gap-2"
onClick={async () => {
const res = await client.twoFactor.disable();
if (res.error) {
toast.error(res.error.message);
}
}}
>
Disable 2FA
</Button>
) : (
<Button
variant="outline"
className="gap-2"
onClick={async () => {
const res = await client.twoFactor.enable();
if (res.error) {
toast.error(res.error.message);
}
}}
>
<p>Enable 2FA</p>
</Button>
)}
</div>
</div>
</CardContent>
<CardFooter className="gap-2 justify-between items-center">
<ChangePassword />
<Button className="gap-2 z-10" variant="secondary">
<LogOut size={16} />
<span
className="text-sm"
onClick={async () => {
await signOut({
options: {
body: {
callbackURL: "/",
}
}
})
}}
>
Sign Out
</span>
</Button>
</CardFooter>
</Card>
);
}
async function convertImageToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function ChangePassword() {
const [currentPassword, setCurrentPassword] = useState<string>("");
const [newPassword, setNewPassword] = useState<string>("");
const [confirmPassword, setConfirmPassword] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [open, setOpen] = useState<boolean>(false);
const [signOutDevices, setSignOutDevices] = useState<boolean>(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-2 z-10" variant="outline" size="sm">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M2.5 18.5v-1h19v1zm.535-5.973l-.762-.442l.965-1.693h-1.93v-.884h1.93l-.965-1.642l.762-.443L4 9.066l.966-1.643l.761.443l-.965 1.642h1.93v.884h-1.93l.965 1.693l-.762.442L4 10.835zm8 0l-.762-.442l.966-1.693H9.308v-.884h1.93l-.965-1.642l.762-.443L12 9.066l.966-1.643l.761.443l-.965 1.642h1.93v.884h-1.93l.965 1.693l-.762.442L12 10.835zm8 0l-.762-.442l.966-1.693h-1.931v-.884h1.93l-.965-1.642l.762-.443L20 9.066l.966-1.643l.761.443l-.965 1.642h1.93v.884h-1.93l.965 1.693l-.762.442L20 10.835z"></path></svg>
<span
className="text-sm text-muted-foreground"
onClick={async () => {
}}
>
Change Password
</span>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
Change Password
</DialogTitle>
<DialogDescription>
Change your password
</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
<Label htmlFor="current-password">Current Password</Label>
<PasswordInput id="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="new-password"
placeholder="Password" />
<Label htmlFor="new-password">New Password</Label>
<PasswordInput
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
placeholder="New Password" />
<Label htmlFor="password">Confirm Password</Label>
<PasswordInput
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
placeholder="Confirm Password" />
<div className="flex gap-2 items-center">
<Checkbox onCheckedChange={(checked) => checked ? setSignOutDevices(true) : setSignOutDevices(false)} />
<p className="text-sm">
Sign out from other devices
</p>
</div>
</div>
<DialogFooter>
<Button onClick={async () => {
if (newPassword !== confirmPassword) {
toast.error("Passwords do not match");
return;
}
if (newPassword.length < 8) {
toast.error("Password must be at least 8 characters");
return;
}
setLoading(true);
const res = await user.changePassword({
newPassword: newPassword,
currentPassword: currentPassword,
revokeOtherSessions: signOutDevices,
})
setLoading(false);
if (res.error) {
toast.error(res.error.message);
} else {
setOpen(false);
toast.success("Password changed successfully");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
}
}}>
{loading ? <Loader2 size={15} className="animate-spin" /> : "Change Password"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function EditUserDialog(props: { session: Session | null }) {
const [name, setName] = useState<string>();
const router = useRouter()
const [image, setImage] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setImage(file);
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const [open, setOpen] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" className="gap-2" variant="secondary">
<Edit size={13} />
Edit User
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
Edit User
</DialogTitle>
<DialogDescription>
Edit user information
</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
type="name"
placeholder={props.session?.user.name}
required
onChange={(e) => {
setName(e.target.value)
}}
/>
<div className="grid gap-2">
<Label htmlFor="image">Profile Image</Label>
<div className="flex items-end gap-4">
{imagePreview && (
<div className="relative w-16 h-16 rounded-sm overflow-hidden">
<Image
src={imagePreview}
alt="Profile preview"
layout="fill"
objectFit="cover"
/>
</div>
)}
<div className="flex items-center gap-2 w-full">
<Input
id="image"
type="file"
accept="image/*"
onChange={handleImageChange}
className="w-full text-muted-foreground"
/>
{imagePreview && <X className="cursor-pointer" onClick={() => {
setImage(null);
setImagePreview(null);
}} />}
</div>
</div>
</div>
</div>
<DialogFooter >
<Button disabled={isLoading} onClick={async () => {
setIsLoading(true);
await user.update({
image: image ? await convertImageToBase64(image) : undefined,
name: name ? name : undefined,
options: {
onSuccess: () => {
toast.success("User updated successfully");
},
onError: (error) => {
toast.error(error.error.message);
}
}
})
setName("")
router.refresh()
setImage(null)
setImagePreview(null)
setIsLoading(false);
setOpen(false);
}}>
{isLoading ? <Loader2 size={15} className="animate-spin" /> : "Update"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function AddPasskey() {
const [isOpen, setIsOpen] = useState(false);
const [passkeyName, setPasskeyName] = useState("");
const queryClient = useQueryClient()
const [isLoading, setIsLoading] = useState(false);
const handleAddPasskey = async () => {
if (!passkeyName) {
toast.error("Passkey name is required");
return;
}
setIsLoading(true);
const res = await client.passkey.addPasskey({
name: passkeyName,
});
if (res?.error) {
toast.error(res?.error.message);
} else {
setIsOpen(false);
toast.success("Passkey added successfully. You can now use it to login.");
}
setIsLoading(false);
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2">
<Plus size={15} />
Add New Passkey
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add New Passkey</DialogTitle>
<DialogDescription>
Create a new passkey to securely access your account without a
password.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
<Label htmlFor="passkey-name">Passkey Name</Label>
<Input id="passkey-name" value={passkeyName} onChange={(e) => setPasskeyName(e.target.value)} />
</div>
<DialogFooter>
<Button disabled={isLoading} type="submit" onClick={handleAddPasskey} className="w-full">
{isLoading ? <Loader2 size={15} className="animate-spin" /> : <>
<Fingerprint className="mr-2 h-4 w-4" />
Create Passkey
</>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ListPasskeys() {
const { data, error } = client.useListPasskeys()
const [isOpen, setIsOpen] = useState(false);
const [passkeyName, setPasskeyName] = useState("");
const handleAddPasskey = async () => {
if (!passkeyName) {
toast.error("Passkey name is required");
return;
}
setIsLoading(true);
const res = await client.passkey.addPasskey({
name: passkeyName,
});
setIsLoading(false);
if (res?.error) {
toast.error(res?.error.message);
} else {
toast.success("Passkey added successfully. You can now use it to login.");
}
};
const [isLoading, setIsLoading] = useState(false);
const [isDeletePasskey, setIsDeletePasskey] = useState<boolean>(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Fingerprint className="mr-2 h-4 w-4" />
Passkeys {
data?.length ? `[${data?.length}]` : ""
}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Passkeys</DialogTitle>
<DialogDescription>
List of passkeys
</DialogDescription>
</DialogHeader>
{data?.length ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((passkey) => (
<TableRow key={passkey.id} className="flex justify-between items-center">
<TableCell>{passkey.name || "My Passkey"}</TableCell>
<TableCell className="text-right">
<button onClick={async () => {
const res = await client.passkey.deletePasskey({
id: passkey.id,
options: {
onRequest: () => {
setIsDeletePasskey(true);
},
onSuccess: () => {
toast("Passkey deleted successfully"); setIsDeletePasskey(false);
},
onError: (error) => {
toast.error(error.error.message);
setIsDeletePasskey(false);
}
}
})
}} >
{isDeletePasskey ? <Loader2 size={15} className="animate-spin" /> : <Trash size={15} className="cursor-pointer text-red-600" />}
</button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="text-sm text-muted-foreground">
No passkeys found
</p>
)}
{
!data?.length && (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
<Label htmlFor="passkey-name" className="text-sm">
New Passkey
</Label>
<Input id="passkey-name" value={passkeyName} onChange={(e) => setPasskeyName(e.target.value)} placeholder="My Passkey" />
</div>
<Button type="submit" onClick={handleAddPasskey} className="w-full">
{isLoading ? <Loader2 size={15} className="animate-spin" /> : <>
<Fingerprint className="mr-2 h-4 w-4" />
Create Passkey
</>}
</Button>
</div>
)
}
<DialogFooter>
<Button onClick={() => setIsOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}