This commit is contained in:
Théo LAGACHE
2026-02-23 12:04:48 +01:00
parent 5df6dd169a
commit 496be692e7
17 changed files with 4196 additions and 1346 deletions
@@ -3,7 +3,7 @@
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { ProfileSidebar } from "./profile-sidebar";
import { AuthProviderConfig } from "@/lib/auth/config";
import type { AuthProviderConfig } from "@/lib/auth/config";
import { User, Session, Account } from "@/db/schema/02_user";
import { ProfileGeneral } from "../../profile/profile-general";
import { ProfileSecurity } from "../../profile/profile-security";
@@ -45,6 +45,7 @@ export const ProfileModal = ({ user, sessions, currentSession, accounts, open, o
credentialAccount={accounts.find((acc) => acc.providerId === "credential")!}
isPasswordEnabled={providers.some((p) => p.id === "credential")}
isPasskeyEnabled={providers.some((p) => p.id === "passkey")}
providers={providers}
/>
</TabsContent>
@@ -1,253 +1,311 @@
"use client"
import {Backup, BackupWith, Restoration} from "@/db/schema/07_database";
import {Swiper, SwiperSlide} from "swiper/react";
"use client";
import { Backup, BackupWith, Restoration } from "@/db/schema/07_database";
import { Swiper, SwiperSlide } from "swiper/react";
//@ts-ignore
import "swiper/css";
import "swiper/css/pagination";
import {Pagination, Mousewheel} from "swiper/modules";
import {DatabaseActionKind, useBackupModal} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import { Pagination, Mousewheel } from "swiper/modules";
import {
BackupActionsSchema,
BackupActionsType
DatabaseActionKind,
useBackupModal,
} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
useZodForm,
} from "@/components/ui/form";
import {
BackupActionsSchema,
BackupActionsType,
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.schema";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {BackupStorageWith} from "@/db/schema/14_storage-backup";
import {TooltipProvider} from "@/components/ui/tooltip";
import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {Badge} from "@/components/ui/badge";
import {getStatusColor, getStatusIcon} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
import {useRouter} from "next/navigation";
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { BackupStorageWith } from "@/db/schema/14_storage-backup";
import { TooltipProvider } from "@/components/ui/tooltip";
import { getChannelIcon } from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import { Badge } from "@/components/ui/badge";
import {
createRestorationBackupAction, deleteBackupAction, deleteBackupStorageAction,
downloadBackupAction
getStatusColor,
getStatusIcon,
} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
import { useRouter } from "next/navigation";
import {
createRestorationBackupAction,
deleteBackupAction,
deleteBackupStorageAction,
downloadBackupAction,
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action";
import {toast} from "sonner";
import {SafeActionResult} from "next-safe-action";
import {ServerActionResult} from "@/types/action-type";
import {ZodString} from "zod";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
import {AlertCircleIcon} from "lucide-react";
import { toast } from "sonner";
import { SafeActionResult } from "next-safe-action";
import { ServerActionResult } from "@/types/action-type";
import { ZodString } from "zod";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertCircleIcon } from "lucide-react";
type BackupActionsFormProps = {
backup: BackupWith;
action: DatabaseActionKind;
}
backup: BackupWith;
action: DatabaseActionKind;
};
export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
export const BackupActionsForm = ({
backup,
action,
}: BackupActionsFormProps) => {
const filteredBackupStorages =
backup.storages?.filter((storage) => storage.deletedAt === null) ?? [];
const { closeModal } = useBackupModal();
const queryClient = useQueryClient();
const router = useRouter();
const filteredBackupStorages = backup.storages?.filter((storage) => storage.deletedAt === null) ?? []
const {closeModal} = useBackupModal();
const queryClient = useQueryClient();
const router = useRouter();
const form = useZodForm({
schema: BackupActionsSchema,
});
const form = useZodForm({
schema: BackupActionsSchema,
});
const mutation = useMutation({
mutationFn: async (values: BackupActionsType) => {
let result:
| SafeActionResult<
string,
ZodString,
readonly [],
{
_errors?: string[] | undefined;
},
readonly [],
ServerActionResult<string | Restoration | Backup>,
object
>
| undefined;
const mutation = useMutation({
mutationFn: async (values: BackupActionsType) => {
if (action === "download") {
result = await downloadBackupAction({
backupStorageId: values.backupStorageId,
});
} else if (action === "restore") {
result = await createRestorationBackupAction({
databaseId: backup.databaseId,
backupStorageId: values.backupStorageId,
backupId: backup.id,
});
} else if (action === "delete") {
result = await deleteBackupStorageAction({
databaseId: backup.databaseId,
backupStorageId: values.backupStorageId,
backupId: backup.id,
});
}
let result: SafeActionResult<string, ZodString, readonly [], {
_errors?: string[] | undefined;
}, readonly [], ServerActionResult<string | Restoration | Backup>, object> | undefined
const inner = result?.data;
if (action === "download") {
result = await downloadBackupAction({backupStorageId: values.backupStorageId})
} else if (action === "restore") {
result = await createRestorationBackupAction({
databaseId: backup.databaseId,
backupStorageId: values.backupStorageId,
backupId: backup.id
})
} else if (action === "delete") {
result = await deleteBackupStorageAction({
databaseId: backup.databaseId,
backupStorageId: values.backupStorageId,
backupId: backup.id,
})
}
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
queryClient.invalidateQueries({
queryKey: ["database-data", backup.databaseId],
});
router.refresh();
if (action === "download") {
const url = inner.value;
if (typeof url === "string") {
window.open(url, "_self");
}
closeModal();
} else if (action === "restore") {
closeModal();
} else if (action === "delete") {
closeModal();
} else {
closeModal();
}
} else {
if (action === "delete") {
toast.success("Backup deleted successfully.");
queryClient.invalidateQueries({
queryKey: ["database-data", backup.databaseId],
});
router.refresh();
closeModal();
} else {
toast.error(inner?.actionError?.message ?? "An error occurred.");
}
}
},
});
const inner = result?.data;
const mutationDeleteEntireBackup = useMutation({
mutationFn: async () => {
const result = await deleteBackupAction({
databaseId: backup.databaseId,
backupId: backup.id,
});
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
router.refresh();
if (action === "download") {
const url = inner.value
if (typeof url === "string") {
window.open(url, "_self");
}
closeModal()
} else if (action === "restore") {
closeModal()
} else if (action === "delete") {
closeModal()
} else {
closeModal()
}
} else {
if (action === "delete") {
toast.success("Backup deleted successfully.")
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
router.refresh();
closeModal()
} else {
toast.error(inner?.actionError?.message ?? "An error occurred.");
}
}
},
});
const inner = result?.data;
const mutationDeleteEntireBackup = useMutation({
mutationFn: async () => {
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
queryClient.invalidateQueries({
queryKey: ["database-data", backup.databaseId],
});
router.refresh();
closeModal();
} else {
toast.error(inner?.actionError?.message);
}
},
});
const result = await deleteBackupAction({
databaseId: backup.databaseId,
backupId: backup.id,
})
const inner = result?.data;
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
router.refresh();
closeModal()
} else {
toast.error(inner?.actionError?.message);
}
},
});
return (
<TooltipProvider>
<Form
form={form}
className="flex flex-col gap-4 mb-1"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
{filteredBackupStorages.length > 0 ?
<FormField
control={form.control}
name="backupStorageId"
render={({field}) => (
<FormItem>
<FormLabel>Choose a storage backup</FormLabel>
<FormControl>
<div style={{height: "250px"}}>
<Swiper
direction="vertical"
slidesPerView={3.5}
spaceBetween={10}
// pagination={{ clickable: true }}
mousewheel={{releaseOnEdges: true, forceToAxis: true}}
modules={[Pagination, Mousewheel]}
className="mySwiper"
style={{height: "100%"}}
>
{filteredBackupStorages.map((storage: BackupStorageWith) => (
<SwiperSlide key={storage.id}>
<button
disabled={action !== "delete" && storage.status.toLowerCase() !== "success"}
type="button"
onClick={() => field.onChange(storage.id)}
className={`w-full h-full flex items-start gap-3 p-4 rounded-lg border text-left transition-colors
${field.value === storage.id
return (
<TooltipProvider>
<Form
form={form}
className="flex flex-col gap-4 mb-1"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
{filteredBackupStorages.length > 0 ? (
<FormField
control={form.control}
name="backupStorageId"
render={({ field }) => (
<FormItem>
<FormLabel>Choose a storage backup</FormLabel>
<FormControl>
<div style={{ height: "250px" }}>
<Swiper
direction="vertical"
slidesPerView={3.5}
spaceBetween={10}
// pagination={{ clickable: true }}
mousewheel={{ releaseOnEdges: true, forceToAxis: true }}
modules={[Pagination, Mousewheel]}
className="mySwiper"
style={{ height: "100%" }}
>
{filteredBackupStorages.map(
(storage: BackupStorageWith) => (
<SwiperSlide key={storage.id}>
<button
disabled={
action !== "delete" &&
storage.status.toLowerCase() !== "success"
}
type="button"
onClick={() => field.onChange(storage.id)}
className={`w-full h-full flex items-start gap-3 p-4 rounded-lg border text-left transition-colors
${
field.value ===
storage.id
? "border-foreground bg-background"
: "border-border bg-background" + ((storage.status.toLowerCase() === "success" || action === "delete") ? " hover:border-muted-foreground" : "")}
: "border-border bg-background" +
(storage.status.toLowerCase() ===
"success" ||
action ===
"delete"
? " hover:border-muted-foreground"
: "")
}
${storage.status.toLowerCase() !== "success" && action !== "delete" ? "opacity-50 cursor-not-allowed" : ""}`}
>
<div
className={`mt-0.5 h-4 w-4 shrink-0 rounded-full border ${
field.value === storage.id ? "border-foreground" : "border-muted-foreground"
} flex items-center justify-center`}
>
{field.value === storage.id &&
<div className="h-2 w-2 rounded-full bg-foreground"/>}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0 flex flex-col gap-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0">
{getChannelIcon(storage.storageChannel?.provider || "")}
</div>
<h3 className="font-medium text-foreground truncate">
{storage.storageChannel?.name}
</h3>
<Badge variant="secondary"
className="text-xs font-mono shrink-0">
{storage.storageChannel?.provider}
</Badge>
</div>
<Badge variant="outline"
className={`gap-1.5 shrink-0 ${getStatusColor(storage.status)}`}>
{getStatusIcon(storage.status === "success")}
<span
className="capitalize">{storage.status.toUpperCase()}</span>
</Badge>
</div>
</div>
</div>
</div>
</button>
</SwiperSlide>
)) ?? <p>No storages available</p>}
</Swiper>
>
<div
className={`mt-0.5 h-4 w-4 shrink-0 rounded-full border ${
field.value === storage.id
? "border-foreground"
: "border-muted-foreground"
} flex items-center justify-center`}
>
{field.value === storage.id && (
<div className="h-2 w-2 rounded-full bg-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0 flex flex-col gap-1">
<div className="flex items-center justify-between gap-2 min-w-0 w-full">
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className="shrink-0">
{getChannelIcon(
storage.storageChannel?.provider ||
"",
)}
</div>
<h3 className="font-medium text-foreground truncate min-w-0">
{storage.storageChannel?.name}
</h3>
<Badge
variant="secondary"
className="text-xs font-mono shrink-0"
>
{storage.storageChannel?.provider}
</Badge>
</div>
<Badge
variant="outline"
className={`gap-1.5 shrink-0 ${getStatusColor(storage.status)}`}
>
{getStatusIcon(
storage.status === "success",
)}
<span className="capitalize">
{storage.status.toUpperCase()}
</span>
</Badge>
</div>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</div>
</div>
</div>
</button>
</SwiperSlide>
),
) ?? <p>No storages available</p>}
</Swiper>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
) : (
<Alert>
<AlertCircleIcon />
<AlertTitle>Backup does not have files</AlertTitle>
<AlertDescription>
<p>
You can safely delete the entire backup; no files seem to be
related. Maybe an error occurred.
</p>
</AlertDescription>
</Alert>
)}
:
<Alert>
<AlertCircleIcon/>
<AlertTitle>Backup does not have files</AlertTitle>
<AlertDescription>
<p>You can safely delete the entire backup; no files seem to be related. Maybe an error
occurred.</p>
</AlertDescription>
</Alert>
}
<div className="flex flex-row items-center gap-x-4 w-full">
{action === "delete" && (
<ButtonWithLoading
type="button"
variant="destructive"
onClick={() => mutationDeleteEntireBackup.mutateAsync()}
isPending={mutationDeleteEntireBackup.isPending}
disabled={mutationDeleteEntireBackup.isPending}
>
Delete entire backup
</ButtonWithLoading>
)}
<div className="flex flex-row items-center gap-x-4 w-full">
{action === "delete" && (
<ButtonWithLoading
type="button"
variant="destructive"
onClick={() => mutationDeleteEntireBackup.mutateAsync()}
isPending={mutationDeleteEntireBackup.isPending}
disabled={mutationDeleteEntireBackup.isPending}
>
Delete entire backup
</ButtonWithLoading>
)}
{filteredBackupStorages.length > 0 && (
<ButtonWithLoading
type="submit"
isPending={mutation.isPending}
disabled={mutation.isPending}
className="ml-auto"
>
Confirm
</ButtonWithLoading>
)}
</div>
</Form>
</TooltipProvider>
);
}
{filteredBackupStorages.length > 0 && (
<ButtonWithLoading
type="submit"
isPending={mutation.isPending}
disabled={mutation.isPending}
className="ml-auto"
>
Confirm
</ButtonWithLoading>
)}
</div>
</Form>
</TooltipProvider>
);
};
@@ -13,7 +13,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { SetPasswordProfileProviderModal } from "./modal/set-password-modal";
import { Icon } from "@iconify/react";
import Image from "next/image";
import { AuthProviderConfig } from "@/lib/auth/config";
import type { AuthProviderConfig } from "@/lib/auth/config";
import { Account } from "@/db/schema/02_user";
interface ProfileProviderProps {
@@ -122,16 +122,16 @@ export function ProfileProviders({ accounts, providers }: ProfileProviderProps)
variant="outline"
size="sm"
onClick={() => unlinkAccount(provider.id)}
disabled={!canUnlink || isLoading || provider.isManual}
className={!canUnlink ? "opacity-50 cursor-not-allowed" : ""}
disabled={!canUnlink || isLoading || provider.isManual || provider.allowUnlinking === false}
className={!canUnlink || provider.allowUnlinking === false ? "opacity-50 cursor-not-allowed" : ""}
>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Unlink"}
</Button>
</span>
</TooltipTrigger>
{!canUnlink && (
{(!canUnlink || provider.allowUnlinking === false) && (
<TooltipContent>
<p>You cannot unlink your last authentication provider.</p>
<p>{provider.allowUnlinking === false ? "Unlinking is disabled for this provider." : "You cannot unlink your last authentication provider."}</p>
</TooltipContent>
)}
</Tooltip>
@@ -141,7 +141,7 @@ export function ProfileProviders({ accounts, providers }: ProfileProviderProps)
{provider.id === "credential" ? (
<SetPasswordProfileProviderModal open={isPasswordDialogOpen} onOpenChange={setIsPasswordDialogOpen} />
) : (
<Button variant="default" size="sm" onClick={() => linkAccount(provider)} disabled={isLoading || provider.isManual}>
<Button variant="default" size="sm" onClick={() => linkAccount(provider)} disabled={isLoading || provider.isManual || provider.allowLinking === false}>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Link"}
</Button>
)}
@@ -4,10 +4,22 @@ import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { Globe, LogOut, Loader2, Fingerprint, Trash2, Plus } from "lucide-react";
import {
Globe,
LogOut,
Loader2,
Fingerprint,
Trash2,
Plus,
} from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { revokeAllSessionsAction, revokeSessionAction, getPasskeysAction, revokePasskeyAction } from "./actions/security.action";
import {
revokeAllSessionsAction,
revokeSessionAction,
getPasskeysAction,
revokePasskeyAction,
} from "./actions/security.action";
import { useRouter } from "next/navigation";
import { ResetPasswordProfileProviderModal } from "./modal/reset-password-modal";
import { SetPasswordProfileProviderModal } from "./modal/set-password-modal";
@@ -17,335 +29,459 @@ import { ViewBackupCodesModal } from "./modal/view-backup-codes-modal";
import { getDeviceDetails } from "@/utils/detection";
import { timeAgo } from "@/utils/date-formatting";
import { authClient } from "@/lib/auth/auth-client";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Account, Session, User } from "@/db/schema/02_user";
import { Icon } from "@iconify/react";
import Image from "next/image";
import type { AuthProviderConfig } from "@/lib/auth/config";
interface ProfileSecurityProps {
user: User;
sessions: Session[];
credentialAccount: Account;
currentSession: Session;
isPasswordEnabled?: boolean;
isPasskeyEnabled?: boolean;
user: User;
sessions: Session[];
credentialAccount: Account;
currentSession: Session;
isPasswordEnabled?: boolean;
isPasskeyEnabled?: boolean;
providers: AuthProviderConfig[];
}
export function ProfileSecurity({ user, sessions, credentialAccount, currentSession, isPasswordEnabled = false, isPasskeyEnabled = false }: ProfileSecurityProps) {
const router = useRouter();
export function ProfileSecurity({
user,
sessions,
credentialAccount,
currentSession,
isPasswordEnabled = false,
isPasskeyEnabled = false,
providers,
}: ProfileSecurityProps) {
const router = useRouter();
const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false);
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
const [isSetup2FADialogOpen, setIsSetup2FADialogOpen] = useState(false);
const [isDisable2FADialogOpen, setIsDisable2FADialogOpen] = useState(false);
const [isAddPasskeyOpen, setIsAddPasskeyOpen] = useState(false);
const [passkeyName, setPasskeyName] = useState("");
const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false);
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
const [isSetup2FADialogOpen, setIsSetup2FADialogOpen] = useState(false);
const [isDisable2FADialogOpen, setIsDisable2FADialogOpen] = useState(false);
const [isAddPasskeyOpen, setIsAddPasskeyOpen] = useState(false);
const [passkeyName, setPasskeyName] = useState("");
const { mutate: revokeSession, isPending: isRevoking } = useMutation({
mutationFn: async (token: string) => {
const result = await revokeSessionAction({ token });
const inner = result?.data;
if (inner?.success) {
toast.success("Session successfully revoked");
router.refresh();
} else {
toast.error("An error occurred while revoking session");
}
},
});
const { mutate: revokeSession, isPending: isRevoking } = useMutation({
mutationFn: async (token: string) => {
const result = await revokeSessionAction({ token });
const inner = result?.data;
if (inner?.success) {
toast.success("Session successfully revoked");
router.refresh();
} else {
toast.error("An error occurred while revoking session");
}
},
});
const { mutate: revokeOthers, isPending: isRevokingOthers } = useMutation({
mutationFn: async () => {
const result = await revokeAllSessionsAction();
const inner = result?.data;
if (inner?.success) {
toast.success("Revoking all sessions successfully done.");
router.refresh();
} else {
toast.error("An error occurred while revoking all sessions");
}
},
});
const { mutate: revokeOthers, isPending: isRevokingOthers } = useMutation({
mutationFn: async () => {
const result = await revokeAllSessionsAction();
const inner = result?.data;
if (inner?.success) {
toast.success("Revoking all sessions successfully done.");
router.refresh();
} else {
toast.error("An error occurred while revoking all sessions");
}
},
});
const {
data: passkeys,
isLoading: isLoadingPasskeys,
refetch: refetchPasskeys,
} = useQuery({
queryKey: ["passkeys"],
queryFn: async () => {
const result = await getPasskeysAction();
if (result?.data?.success) {
return result.data.value;
}
throw new Error("Failed to fetch passkeys");
},
});
const {
data: passkeys,
isLoading: isLoadingPasskeys,
refetch: refetchPasskeys,
} = useQuery({
queryKey: ["passkeys"],
queryFn: async () => {
const result = await getPasskeysAction();
if (result?.data?.success) {
return result.data.value;
}
throw new Error("Failed to fetch passkeys");
},
});
const { mutate: revokePasskey, isPending: isRevokingPasskey } = useMutation({
mutationFn: async (id: string) => {
const result = await revokePasskeyAction({ id });
if (!result?.data?.success) {
throw new Error("Failed to revoke passkey");
}
},
onSuccess: () => {
toast.success("Passkey revoked successfully");
refetchPasskeys();
},
onError: () => {
toast.error("Failed to revoke passkey");
},
});
const { mutate: revokePasskey, isPending: isRevokingPasskey } = useMutation({
mutationFn: async (id: string) => {
const result = await revokePasskeyAction({ id });
if (!result?.data?.success) {
throw new Error("Failed to revoke passkey");
}
},
onSuccess: () => {
toast.success("Passkey revoked successfully");
refetchPasskeys();
},
onError: () => {
toast.error("Failed to revoke passkey");
},
});
const { mutate: addPasskey, isPending: isAddingPasskey } = useMutation({
mutationFn: async () => {
const result = await authClient.passkey.addPasskey({
name: passkeyName || "My Passkey",
});
if (result?.error) {
throw result.error;
}
return result;
},
onSuccess: () => {
toast.success("Passkey added successfully");
setIsAddPasskeyOpen(false);
setPasskeyName("");
refetchPasskeys();
},
onError: (error: any) => {
toast.error(error.message || "Failed to add passkey");
},
});
const { mutate: addPasskey, isPending: isAddingPasskey } = useMutation({
mutationFn: async () => {
const result = await authClient.passkey.addPasskey({
name: passkeyName || "My Passkey",
});
if (result?.error) {
throw result.error;
}
return result;
},
onSuccess: () => {
toast.success("Passkey added successfully");
setIsAddPasskeyOpen(false);
setPasskeyName("");
refetchPasskeys();
},
onError: (error: any) => {
toast.error(error.message || "Failed to add passkey");
},
});
return (
<div className="space-y-8 animate-in fade-in-50 duration-300">
<div className="mb-6 space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">Security Settings</h2>
<p className="text-sm text-muted-foreground">Manage your password, two-factor authentication and sessions.</p>
return (
<div className="space-y-8 animate-in fade-in-50 duration-300">
<div className="mb-6 space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">
Security Settings
</h2>
<p className="text-sm text-muted-foreground">
Manage your password, two-factor authentication and sessions.
</p>
</div>
<div className="space-y-6">
<h3 className="text-lg font-medium">Authentication</h3>
<div className="border rounded-lg p-4 space-y-4">
{isPasswordEnabled && (
<>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="font-medium">Password</div>
<div className="text-sm text-muted-foreground">
{user.lastChangedPasswordAt
? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}`
: "Never changed"}
</div>
</div>
{credentialAccount ? (
<ResetPasswordProfileProviderModal
open={isPasswordDialogOpen}
onOpenChange={setIsPasswordDialogOpen}
/>
) : (
<SetPasswordProfileProviderModal
open={isPasswordDialogOpen}
onOpenChange={setIsPasswordDialogOpen}
/>
)}
</div>
<Separator />
</>
)}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="font-medium">Two-Factor Authentication</div>
{user.twoFactorEnabled && (
<Badge
variant="secondary"
className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0"
>
Active
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground">
Enhance the security of your account by requiring a second form
of verification during login.
</div>
</div>
<div className="space-y-6">
<h3 className="text-lg font-medium">Authentication</h3>
<div className="border rounded-lg p-4 space-y-4">
{isPasswordEnabled && (
<>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="font-medium">Password</div>
<div className="text-sm text-muted-foreground">
{user.lastChangedPasswordAt ? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}` : "Never changed"}
</div>
</div>
{credentialAccount ? (
<ResetPasswordProfileProviderModal open={isPasswordDialogOpen} onOpenChange={setIsPasswordDialogOpen} />
) : (
<SetPasswordProfileProviderModal open={isPasswordDialogOpen} onOpenChange={setIsPasswordDialogOpen} />
)}
</div>
<Separator />
</>
)}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="font-medium">Two-Factor Authentication</div>
{user.twoFactorEnabled && (
<Badge variant="secondary" className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0">
Active
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground">
Enhance the security of your account by requiring a second form of verification during login.
</div>
</div>
{user.twoFactorEnabled ? (
<div className="flex flex-col items-center gap-2">
<ViewBackupCodesModal open={isBackupCodesDialogOpen} onOpenChange={setIsBackupCodesDialogOpen} />
<Disable2FAProfileProviderModal open={isDisable2FADialogOpen} onOpenChange={setIsDisable2FADialogOpen} />
</div>
) : (
<Setup2FAProfileProviderModal disabled={!credentialAccount} open={isSetup2FADialogOpen} onOpenChange={setIsSetup2FADialogOpen} />
)}
</div>
</div>
</div>
{isPasskeyEnabled && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="text-lg font-medium">Passkeys</h3>
<div className="text-sm text-muted-foreground">Login securely with your fingerprint, face recognition, or hardware key.</div>
</div>
<Dialog open={isAddPasskeyOpen} onOpenChange={setIsAddPasskeyOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Passkey
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add New Passkey</DialogTitle>
<DialogDescription>Create a name for your passkey to identify it later.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Passkey Name</Label>
<Input
id="name"
placeholder="e.g. MacBook Pro, iPhone, YubiKey"
value={passkeyName}
onChange={(e) => setPasskeyName(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddPasskeyOpen(false)}>
Cancel
</Button>
<Button onClick={() => addPasskey()} disabled={isAddingPasskey}>
{isAddingPasskey && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Passkey
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="border rounded-lg divide-y">
{isLoadingPasskeys ? (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : passkeys && passkeys.length > 0 ? (
passkeys.map((pk: any) => <PasskeyRow key={pk.id} passkey={pk} onRevoke={(id) => revokePasskey(id)} isRevoking={isRevokingPasskey} />)
) : (
<div className="p-4 text-center text-muted-foreground">No passkeys found.</div>
)}
</div>
</div>
{user.twoFactorEnabled ? (
<div className="flex flex-col items-center gap-2">
<ViewBackupCodesModal
open={isBackupCodesDialogOpen}
onOpenChange={setIsBackupCodesDialogOpen}
/>
<Disable2FAProfileProviderModal
open={isDisable2FADialogOpen}
onOpenChange={setIsDisable2FADialogOpen}
/>
</div>
) : (
<Setup2FAProfileProviderModal
disabled={!credentialAccount}
open={isSetup2FADialogOpen}
onOpenChange={setIsSetup2FADialogOpen}
/>
)}
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Active Sessions</h3>
{sessions && sessions.length > 1 && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => revokeOthers()}
disabled={isRevokingOthers || (sessions?.length || 0) <= 1}
>
{isRevokingOthers && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Revoke All
</Button>
)}
</div>
<div className="border rounded-lg divide-y">
{sessions && sessions.length > 0 ? (
sessions?.map((session) => (
<SessionRow
key={session.id}
session={session}
onRevoke={(token) => revokeSession(token)}
isRevoking={isRevoking}
currentSession={currentSession}
/>
))
) : (
<div className="p-4 text-center text-muted-foreground">No active sessions found.</div>
)}
</div>
</div>
</div>
</div>
);
</div>
{isPasskeyEnabled && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="text-lg font-medium">Passkeys</h3>
<div className="text-sm text-muted-foreground">
Login securely with your fingerprint, face recognition, or
hardware key.
</div>
</div>
<Dialog open={isAddPasskeyOpen} onOpenChange={setIsAddPasskeyOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Passkey
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add New Passkey</DialogTitle>
<DialogDescription>
Create a name for your passkey to identify it later.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Passkey Name</Label>
<Input
id="name"
placeholder="e.g. MacBook Pro, iPhone, YubiKey"
value={passkeyName}
onChange={(e) => setPasskeyName(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsAddPasskeyOpen(false)}
>
Cancel
</Button>
<Button
onClick={() => addPasskey()}
disabled={isAddingPasskey}
>
{isAddingPasskey && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create Passkey
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="border rounded-lg divide-y">
{isLoadingPasskeys ? (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : passkeys && passkeys.length > 0 ? (
passkeys.map((pk: any) => (
<PasskeyRow
key={pk.id}
passkey={pk}
onRevoke={(id) => revokePasskey(id)}
isRevoking={isRevokingPasskey}
/>
))
) : (
<div className="p-4 text-center text-muted-foreground">
No passkeys found.
</div>
)}
</div>
</div>
)}
<div className="space-y-6 pb-10">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Active Sessions</h3>
{sessions && sessions.length > 1 && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => revokeOthers()}
disabled={isRevokingOthers || (sessions?.length || 0) <= 1}
>
{isRevokingOthers && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Revoke All
</Button>
)}
</div>
<div className="border rounded-lg divide-y">
{sessions && sessions.length > 0 ? (
sessions?.map((session) => (
<SessionRow
key={session.id}
session={session}
onRevoke={(token) => revokeSession(token)}
isRevoking={isRevoking}
currentSession={currentSession}
providers={providers}
/>
))
) : (
<div className="p-4 text-center text-muted-foreground">
No active sessions found.
</div>
)}
</div>
</div>
</div>
);
}
function SessionRow({
session,
onRevoke,
isRevoking,
currentSession,
session,
onRevoke,
isRevoking,
currentSession,
providers,
}: {
session: Session;
onRevoke: (token: string) => void;
isRevoking: boolean;
currentSession: Session;
session: Session;
onRevoke: (token: string) => void;
isRevoking: boolean;
currentSession: Session;
providers: AuthProviderConfig[];
}) {
const deviceInfo = getDeviceDetails(session.userAgent);
const deviceInfo = getDeviceDetails(session.userAgent);
const provider = providers.find((p) => p.id === (session as any).providerId);
return (
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground">
<deviceInfo.Icon className="w-5 h-5" />
</div>
<div className="space-y-0.5">
<div className="text-sm font-medium flex items-center gap-2">
{deviceInfo.os} <span className="text-muted-foreground font-normal"> {deviceInfo.browser}</span>
{session.id === currentSession.id && (
<Badge
variant="outline"
className="text-[10px] h-5 px-1.5 text-sky-600 bg-sky-50 border-sky-200 dark:bg-sky-900/20 dark:border-sky-800 dark:text-sky-400"
>
This device
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1">
<Globe className="w-3 h-3" /> {session.ipAddress}
<span className="ml-1">{session.id === currentSession.id ? "Active now" : `Last active ${timeAgo(new Date(session.createdAt))}`}</span>
</div>
</div>
</div>
{session.id !== currentSession.id && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onRevoke(session.token)}
disabled={isRevoking}
>
{isRevoking ? <Loader2 className="h-4 w-4 animate-spin" /> : <LogOut className="w-4 h-4" />}
<span className="sr-only">Revoke</span>
</Button>
return (
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground relative">
<deviceInfo.Icon className="w-5 h-5" />
{provider && (
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-background border flex items-center justify-center overflow-hidden">
{provider.icon.startsWith("/") || provider.icon.startsWith("http") ? (
<Image
src={provider.icon}
alt={provider.id}
width={12}
height={12}
className="w-3 h-3"
unoptimized={provider.icon.startsWith("http")}
/>
) : (
<Icon icon={provider.icon} className="w-3 h-3" />
)}
</div>
)}
</div>
<div className="space-y-0.5">
<div className="text-sm font-medium flex items-center gap-2">
{deviceInfo.os}{" "}
<span className="text-muted-foreground font-normal">
{deviceInfo.browser}
</span>
{provider && (
<span className="text-muted-foreground font-normal">
{provider.title || provider.name}
</span>
)}
{session.id === currentSession.id && (
<Badge
variant="outline"
className="text-[10px] h-5 px-1.5 text-sky-600 bg-sky-50 border-sky-200 dark:bg-sky-900/20 dark:border-sky-800 dark:text-sky-400"
>
This device
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1">
<Globe className="w-3 h-3" /> {session.ipAddress}
<span className="ml-1">
{session.id === currentSession.id
? "Active now"
: `Last active ${timeAgo(new Date(session.createdAt))}`}
</span>
</div>
</div>
);
</div>
{session.id !== currentSession.id && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onRevoke(session.token)}
disabled={isRevoking}
>
{isRevoking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LogOut className="w-4 h-4" />
)}
<span className="sr-only">Revoke</span>
</Button>
)}
</div>
);
}
function PasskeyRow({ passkey, onRevoke, isRevoking }: { passkey: any; onRevoke: (id: string) => void; isRevoking: boolean }) {
return (
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground">
<Fingerprint className="w-5 h-5" />
</div>
<div className="space-y-0.5">
<div className="font-medium text-sm">{passkey.name || "Unnamed Passkey"}</div>
<div className="text-xs text-muted-foreground">Created {timeAgo(new Date(passkey.createdAt))}</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onRevoke(passkey.id)}
disabled={isRevoking}
>
{isRevoking ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
<span className="sr-only">Revoke</span>
</Button>
function PasskeyRow({
passkey,
onRevoke,
isRevoking,
}: {
passkey: any;
onRevoke: (id: string) => void;
isRevoking: boolean;
}) {
return (
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground">
<Fingerprint className="w-5 h-5" />
</div>
);
<div className="space-y-0.5">
<div className="font-medium text-sm">
{passkey.name || "Unnamed Passkey"}
</div>
<div className="text-xs text-muted-foreground">
Created {timeAgo(new Date(passkey.createdAt))}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onRevoke(passkey.id)}
disabled={isRevoking}
>
{isRevoking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
<span className="sr-only">Revoke</span>
</Button>
</div>
);
}