224 lines
7.5 KiB
TypeScript
224 lines
7.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState, Suspense } from "react";
|
|
import { signIn } from "next-auth/react";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Loader2, Fingerprint } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || "https://encryptid.jeffemmett.com";
|
|
|
|
function SignInForm() {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const callbackUrl = searchParams.get("callbackUrl") || "/";
|
|
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
const result = await signIn("credentials", {
|
|
email,
|
|
password,
|
|
redirect: false,
|
|
});
|
|
|
|
if (result?.error) {
|
|
toast.error("Invalid email or password");
|
|
} else {
|
|
toast.success("Signed in successfully!");
|
|
router.push(callbackUrl);
|
|
router.refresh();
|
|
}
|
|
} catch (error) {
|
|
toast.error("An error occurred. Please try again.");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handlePasskeySignIn() {
|
|
setIsPasskeyLoading(true);
|
|
|
|
try {
|
|
// Step 1: Get authentication options from EncryptID server
|
|
const startRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/start`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({}),
|
|
});
|
|
const { options } = await startRes.json();
|
|
|
|
// Step 2: Trigger WebAuthn ceremony in the browser
|
|
const challenge = Uint8Array.from(atob(options.challenge.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0));
|
|
|
|
const publicKeyOptions: PublicKeyCredentialRequestOptions = {
|
|
challenge,
|
|
rpId: options.rpId,
|
|
userVerification: options.userVerification as UserVerificationRequirement,
|
|
timeout: options.timeout,
|
|
allowCredentials: options.allowCredentials?.map((c: { type: string; id: string; transports?: string[] }) => ({
|
|
type: c.type as PublicKeyCredentialType,
|
|
id: Uint8Array.from(atob(c.id.replace(/-/g, "+").replace(/_/g, "/")), ch => ch.charCodeAt(0)),
|
|
transports: c.transports as AuthenticatorTransport[],
|
|
})),
|
|
};
|
|
|
|
const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions }) as PublicKeyCredential;
|
|
if (!assertion) throw new Error("Passkey authentication cancelled");
|
|
|
|
const response = assertion.response as AuthenticatorAssertionResponse;
|
|
|
|
// Helper to convert ArrayBuffer to base64url
|
|
function toBase64url(buffer: ArrayBuffer): string {
|
|
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_")
|
|
.replace(/=+$/, "");
|
|
}
|
|
|
|
// Step 3: Complete authentication with EncryptID server
|
|
const completeRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/complete`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
challenge: options.challenge,
|
|
credential: {
|
|
credentialId: assertion.id,
|
|
authenticatorData: toBase64url(response.authenticatorData),
|
|
clientDataJSON: toBase64url(response.clientDataJSON),
|
|
signature: toBase64url(response.signature),
|
|
userHandle: response.userHandle ? toBase64url(response.userHandle) : null,
|
|
},
|
|
}),
|
|
});
|
|
|
|
const authResult = await completeRes.json();
|
|
if (!authResult.success) {
|
|
throw new Error(authResult.error || "Authentication failed");
|
|
}
|
|
|
|
// Step 4: Exchange EncryptID token for NextAuth session
|
|
const result = await signIn("encryptid", {
|
|
token: authResult.token,
|
|
redirect: false,
|
|
});
|
|
|
|
if (result?.error) {
|
|
toast.error("Passkey verified but session creation failed");
|
|
} else {
|
|
toast.success("Signed in with passkey!");
|
|
router.push(callbackUrl);
|
|
router.refresh();
|
|
}
|
|
} catch (error: any) {
|
|
if (error.name === "NotAllowedError") {
|
|
toast.error("Passkey authentication was cancelled");
|
|
} else {
|
|
toast.error(error.message || "Passkey authentication failed");
|
|
}
|
|
} finally {
|
|
setIsPasskeyLoading(false);
|
|
}
|
|
}
|
|
|
|
const anyLoading = isLoading || isPasskeyLoading;
|
|
|
|
return (
|
|
<Card className="w-full max-w-md">
|
|
<CardHeader className="space-y-1">
|
|
<CardTitle className="text-2xl font-bold">Sign in</CardTitle>
|
|
<CardDescription>
|
|
Sign in with a passkey or your email and password
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* Passkey sign-in button */}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full gap-2"
|
|
onClick={handlePasskeySignIn}
|
|
disabled={anyLoading}
|
|
>
|
|
{isPasskeyLoading ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Fingerprint className="h-4 w-4" />
|
|
)}
|
|
Sign in with Passkey
|
|
</Button>
|
|
|
|
<div className="relative">
|
|
<div className="absolute inset-0 flex items-center">
|
|
<span className="w-full border-t" />
|
|
</div>
|
|
<div className="relative flex justify-center text-xs uppercase">
|
|
<span className="bg-background px-2 text-muted-foreground">Or continue with email</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Email/password form */}
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">Email</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
placeholder="you@example.com"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
required
|
|
disabled={anyLoading}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="password">Password</Label>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
required
|
|
disabled={anyLoading}
|
|
/>
|
|
</div>
|
|
<Button type="submit" className="w-full" disabled={anyLoading}>
|
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Sign in
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
<CardFooter>
|
|
<p className="text-sm text-muted-foreground text-center w-full">
|
|
Don't have an account?{" "}
|
|
<Link href="/auth/signup" className="text-primary hover:underline">
|
|
Sign up
|
|
</Link>
|
|
</p>
|
|
</CardFooter>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export default function SignInPage() {
|
|
return (
|
|
<div className="flex min-h-[60vh] items-center justify-center">
|
|
<Suspense fallback={<div className="animate-pulse">Loading...</div>}>
|
|
<SignInForm />
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|