diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index d81b495..947d322 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -12,6 +12,7 @@ model User {
email String @unique
passwordHash String?
name String?
+ did String? @unique // EncryptID DID (passkey identity)
credits Int @default(0)
lastCreditAt DateTime @default(now())
emailVerified DateTime?
diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx
index 1c8051f..9ee9e62 100644
--- a/src/app/auth/signin/page.tsx
+++ b/src/app/auth/signin/page.tsx
@@ -8,9 +8,11 @@ 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 } from "lucide-react";
+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();
@@ -19,6 +21,7 @@ function SignInForm() {
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();
@@ -45,16 +48,129 @@ function SignInForm() {
}
}
+ 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 (
Sign in
- Enter your email and password to access your account
+ Sign in with a passkey or your email and password
-
+
+
+
+
+ Don't have an account?{" "}
+
+ Sign up
+
+
+
);
}
diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx
index ffbbc45..3e2eb27 100644
--- a/src/app/auth/signup/page.tsx
+++ b/src/app/auth/signup/page.tsx
@@ -8,9 +8,11 @@ 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 } from "lucide-react";
+import { Loader2, Fingerprint } from "lucide-react";
import { toast } from "sonner";
+const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || "https://encryptid.jeffemmett.com";
+
export default function SignUpPage() {
const router = useRouter();
@@ -19,6 +21,7 @@ export default function SignUpPage() {
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
+ const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -71,6 +74,101 @@ export default function SignUpPage() {
}
}
+ async function handlePasskeyRegister() {
+ setIsPasskeyLoading(true);
+
+ try {
+ const username = name || `user-${Date.now().toString(36)}`;
+
+ // Step 1: Get registration options from EncryptID server
+ const startRes = await fetch(`${ENCRYPTID_SERVER}/api/register/start`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username, displayName: name || username }),
+ });
+ const { options, userId } = await startRes.json();
+
+ // Step 2: Trigger WebAuthn registration ceremony in the browser
+ function fromBase64url(str: string): Uint8Array {
+ return Uint8Array.from(atob(str.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0));
+ }
+
+ const publicKeyOptions: PublicKeyCredentialCreationOptions = {
+ challenge: fromBase64url(options.challenge),
+ rp: options.rp,
+ user: {
+ id: fromBase64url(options.user.id),
+ name: options.user.name,
+ displayName: options.user.displayName,
+ },
+ pubKeyCredParams: options.pubKeyCredParams,
+ authenticatorSelection: options.authenticatorSelection,
+ timeout: options.timeout,
+ attestation: options.attestation as AttestationConveyancePreference,
+ };
+
+ const credential = await navigator.credentials.create({ publicKey: publicKeyOptions }) as PublicKeyCredential;
+ if (!credential) throw new Error("Passkey registration cancelled");
+
+ const response = credential.response as AuthenticatorAttestationResponse;
+
+ function toBase64url(buffer: ArrayBuffer): string {
+ return btoa(String.fromCharCode(...new Uint8Array(buffer)))
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/, "");
+ }
+
+ // Step 3: Complete registration with EncryptID server
+ const completeRes = await fetch(`${ENCRYPTID_SERVER}/api/register/complete`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ challenge: options.challenge,
+ userId,
+ username,
+ credential: {
+ credentialId: credential.id,
+ publicKey: toBase64url(response.getPublicKey?.() || response.attestationObject),
+ attestationObject: toBase64url(response.attestationObject),
+ clientDataJSON: toBase64url(response.clientDataJSON),
+ transports: (response as any).getTransports?.() || [],
+ },
+ }),
+ });
+
+ const regResult = await completeRes.json();
+ if (!regResult.success) {
+ throw new Error(regResult.error || "Registration failed");
+ }
+
+ // Step 4: Exchange EncryptID token for NextAuth session
+ const result = await signIn("encryptid", {
+ token: regResult.token,
+ redirect: false,
+ });
+
+ if (result?.error) {
+ toast.error("Passkey registered but session creation failed");
+ router.push("/auth/signin");
+ } else {
+ toast.success("Account created with passkey! Welcome to rVote.");
+ router.push("/");
+ router.refresh();
+ }
+ } catch (error: any) {
+ if (error.name === "NotAllowedError") {
+ toast.error("Passkey registration was cancelled");
+ } else {
+ toast.error(error.message || "Passkey registration failed");
+ }
+ } finally {
+ setIsPasskeyLoading(false);
+ }
+ }
+
+ const anyLoading = isLoading || isPasskeyLoading;
+
return (
@@ -80,19 +178,45 @@ export default function SignUpPage() {
Join rVote to start ranking and voting on proposals
-