mirror of
https://github.com/mfts/papermark.git
synced 2025-12-20 01:03:24 +08:00
feat: list passkeys
This commit is contained in:
@@ -48,3 +48,65 @@ export async function finishServerPasskeyRegistration({
|
||||
// select: { id: true },
|
||||
// });
|
||||
}
|
||||
|
||||
export async function listUserPasskeys({ session }: { session: Session }) {
|
||||
if (!session) throw new Error("Not logged in");
|
||||
|
||||
const sessionUser = session.user as CustomUser;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: sessionUser.email as string },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!user) throw new Error("User not found");
|
||||
|
||||
const tenantId = process.env.NEXT_PUBLIC_HANKO_TENANT_ID!;
|
||||
const apiKey = process.env.HANKO_API_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`https://passkeys.hanko.io/${tenantId}/credentials?user_id=${user.id}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
apiKey: apiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to list passkeys: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const passkeys = await response.json();
|
||||
return passkeys;
|
||||
}
|
||||
|
||||
export async function removeUserPasskey({
|
||||
credentialId,
|
||||
session,
|
||||
}: {
|
||||
credentialId: string;
|
||||
session: Session;
|
||||
}) {
|
||||
if (!session) throw new Error("Not logged in");
|
||||
|
||||
const tenantId = process.env.NEXT_PUBLIC_HANKO_TENANT_ID!;
|
||||
const apiKey = process.env.HANKO_API_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`https://passkeys.hanko.io/${tenantId}/credentials/${credentialId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
apiKey: apiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to remove passkey: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
31
lib/swr/use-passkeys.ts
Normal file
31
lib/swr/use-passkeys.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import useSWR from "swr";
|
||||
|
||||
import { fetcher } from "@/lib/utils";
|
||||
|
||||
interface PasskeyCredential {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
last_used_at: string;
|
||||
transports: string[];
|
||||
backup_eligible: boolean;
|
||||
backup_state: boolean;
|
||||
is_mfa: boolean;
|
||||
}
|
||||
|
||||
export function usePasskeys() {
|
||||
const { data, error, mutate, isValidating } = useSWR<{
|
||||
passkeys: PasskeyCredential[];
|
||||
}>("/api/account/passkeys", fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 30000,
|
||||
});
|
||||
|
||||
return {
|
||||
passkeys: data?.passkeys || [],
|
||||
loading: !data && !error,
|
||||
error,
|
||||
mutate,
|
||||
isValidating,
|
||||
};
|
||||
}
|
||||
@@ -1,18 +1,38 @@
|
||||
import { NextPage } from "next";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
type CredentialCreationOptionsJSON,
|
||||
create,
|
||||
} from "@github/webauthn-json";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { usePasskeys } from "@/lib/swr/use-passkeys";
|
||||
|
||||
import { AccountHeader } from "@/components/account/account-header";
|
||||
import AppLayout from "@/components/layouts/app";
|
||||
import Passkey from "@/components/shared/icons/passkey";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const ProfilePage: NextPage = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { passkeys, loading: isLoadingPasskeys, mutate } = usePasskeys();
|
||||
|
||||
async function registerPasskey() {
|
||||
setIsLoading(true);
|
||||
const createOptionsResponse = await fetch("/api/passkeys/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -34,9 +54,42 @@ const ProfilePage: NextPage = () => {
|
||||
|
||||
if (response.ok) {
|
||||
toast.success("Registered passkey successfully!");
|
||||
mutate(); // Refresh the list
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
// Now the user has registered their passkey and can use it to log in.
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function removePasskey(credentialId: string) {
|
||||
try {
|
||||
const response = await fetch("/api/account/passkeys", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ credentialId }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success("Passkey removed successfully!");
|
||||
mutate(); // Refresh the list
|
||||
} else {
|
||||
toast.error("Failed to remove passkey");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error removing passkey:", error);
|
||||
toast.error("Failed to remove passkey");
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
return new Date(dateString).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -44,6 +97,7 @@ const ProfilePage: NextPage = () => {
|
||||
<main className="relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10">
|
||||
<AccountHeader />
|
||||
<div className="space-y-6">
|
||||
{/* Register Passkey Section */}
|
||||
<div className="rounded-lg border border-muted p-10">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
@@ -56,12 +110,103 @@ const ProfilePage: NextPage = () => {
|
||||
<Button
|
||||
onClick={() => registerPasskey()}
|
||||
className="flex items-center justify-center space-x-2"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Passkey className="h-4 w-4" />
|
||||
<span>Register a new passkey</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Existing Passkeys Section */}
|
||||
<div className="rounded-lg border border-muted p-10">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-xl font-medium">Your passkeys</h2>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
Manage your registered passkeys. You can remove passkeys you
|
||||
no longer use.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoadingPasskeys ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading passkeys...
|
||||
</div>
|
||||
</div>
|
||||
) : passkeys.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No passkeys registered yet.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{passkeys.map((passkey) => (
|
||||
<div
|
||||
key={passkey.id}
|
||||
className="flex items-center justify-between rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Passkey className="h-5 w-5 text-muted-foreground" />
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">
|
||||
{passkey.name || "Unnamed Passkey"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Created: {formatDate(passkey.created_at)}
|
||||
{passkey.last_used_at && (
|
||||
<span className="ml-4">
|
||||
Last used: {formatDate(passkey.last_used_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{passkey.transports &&
|
||||
passkey.transports.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Transports: {passkey.transports.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove passkey</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to remove this passkey? This
|
||||
action cannot be undone. You will need to register
|
||||
a new passkey to continue using passwordless
|
||||
authentication.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => removePasskey(passkey.id)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</AppLayout>
|
||||
|
||||
43
pages/api/account/passkeys.ts
Normal file
43
pages/api/account/passkeys.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
|
||||
import { listUserPasskeys, removeUserPasskey } from "@/lib/api/auth/passkey";
|
||||
import { errorhandler } from "@/lib/errorHandler";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session) {
|
||||
return res.status(401).end("Unauthorized");
|
||||
}
|
||||
|
||||
try {
|
||||
if (req.method === "GET") {
|
||||
// List passkeys
|
||||
const passkeys = await listUserPasskeys({ session });
|
||||
res.status(200).json({ passkeys });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
// Remove passkey
|
||||
const { credentialId } = req.body as { credentialId: string };
|
||||
|
||||
if (!credentialId) {
|
||||
return res.status(400).json({ error: "Credential ID is required" });
|
||||
}
|
||||
|
||||
await removeUserPasskey({ credentialId, session });
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
} catch (error) {
|
||||
errorhandler(error, res);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user