Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/example

# Cloudflare Hyperdrive for local development
# https://developers.cloudflare.com/hyperdrive/
WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE_CACHED=postgres://postgres:postgres@localhost:5432/example
WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE_DIRECT=postgres://postgres:postgres@localhost:5432/example
CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE_CACHED=postgres://postgres:postgres@localhost:5432/example
CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE_DIRECT=postgres://postgres:postgres@localhost:5432/example

# Better Auth
# bunx @better-auth/cli@latest secret
Expand Down
2 changes: 1 addition & 1 deletion .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Looking for a place to start? Check out issues labeled with:

### Prerequisites

- [Bun](https://bun.sh) >= 1.2.0
- [Bun](https://bun.sh) >= 1.3.0
- [Node.js](https://nodejs.org) >= 20 (for some tooling compatibility)
- [Git](https://git-scm.com)

Expand Down
10 changes: 9 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.quickSuggestions": {
"strings": "on"
},
"editor.tabSize": 2,
"[terraform]": {
"editor.defaultFormatter": "hashicorp.terraform",
Expand All @@ -17,7 +20,8 @@
"vitest.commandLine": "bun run vitest",
"files.associations": {
".env.*.local": "properties",
".env.*": "properties"
".env.*": "properties",
"*.css": "tailwindcss"
},
"files.exclude": {
"**/.cache": true,
Expand All @@ -44,6 +48,10 @@
"**/bun.lock": true,
"**/routeTree.gen.ts": true
},
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"],
["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"]
],
"terminal.integrated.env.linux": {
"CACHE_DIR": "${workspaceFolder}/.cache"
},
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Full-stack React application template optimized for Cloudflare Workers deploymen

## Tech Stack

- **Runtime:** Bun (>=1.2.0), TypeScript 5.8
- **Runtime:** Bun (>=1.3.0), TypeScript 5.8
- **Frontend:** React 19, TanStack Router, Jotai, shadcn/ui, Tailwind CSS v4, Better Auth
- **Backend:** Hono framework, tRPC
- **Database:** Neon PostgreSQL, Drizzle ORM
Expand Down
2 changes: 1 addition & 1 deletion apps/api/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { schema as Db } from "@repo/db";
import { betterAuth } from "better-auth";
import type { DB } from "better-auth/adapters/drizzle";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { passkey } from "@better-auth/passkey";
import { anonymous, organization } from "better-auth/plugins";
import { emailOTP } from "better-auth/plugins/email-otp";
import { passkey } from "better-auth/plugins/passkey";
import { sendOTP, sendPasswordReset, sendVerificationEmail } from "./email";
import type { Env } from "./env";

Expand Down
10 changes: 5 additions & 5 deletions apps/api/lib/context.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* SPDX-FileCopyrightText: 2014-present Kriasoft */
/* SPDX-License-Identifier: MIT */

import type { DbSchema } from "@repo/db";
import type { DatabaseSchema } from "@repo/db";
import type { CreateHTTPContextOptions } from "@trpc/server/adapters/standalone";
import type { Session, User } from "better-auth/types";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
Expand Down Expand Up @@ -39,10 +39,10 @@ export type TRPCContext = {
info: CreateHTTPContextOptions["info"];

/** Drizzle ORM database instance (PostgreSQL via Hyperdrive cached connection) */
db: PostgresJsDatabase<DbSchema>;
db: PostgresJsDatabase<DatabaseSchema>;

/** Drizzle ORM database instance (PostgreSQL via Hyperdrive direct connection) */
dbDirect: PostgresJsDatabase<DbSchema>;
dbDirect: PostgresJsDatabase<DatabaseSchema>;

/** Authenticated user session (null if not authenticated) */
session: Session | null;
Expand Down Expand Up @@ -78,8 +78,8 @@ export type TRPCContext = {
export type AppContext = {
Bindings: Env;
Variables: {
db: PostgresJsDatabase<DbSchema>;
dbDirect: PostgresJsDatabase<DbSchema>;
db: PostgresJsDatabase<DatabaseSchema>;
dbDirect: PostgresJsDatabase<DatabaseSchema>;
auth: Auth;
resend?: Resend;
session: Session | null;
Expand Down
31 changes: 16 additions & 15 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,30 +18,31 @@
"logs": "wrangler tail --env-file ../../.env --env-file ../../.env.local"
},
"dependencies": {
"@ai-sdk/openai": "^2.0.27",
"@ai-sdk/openai": "^2.0.77",
"@better-auth/passkey": "^1.4.5",
"@repo/core": "workspace:*",
"@repo/db": "workspace:*",
"@repo/email": "workspace:*",
"@trpc/server": "^11.5.1",
"ai": "^5.0.39",
"better-auth": "^1.3.9",
"@trpc/server": "^11.7.2",
"ai": "^5.0.107",
"better-auth": "^1.4.5",
"dataloader": "^2.2.3",
"drizzle-orm": "^0.44.5",
"drizzle-orm": "^0.45.0",
"postgres": "^3.4.7",
"resend": "^6.0.3"
"resend": "^6.5.2"
},
"peerDependencies": {
"hono": "^4.9.6",
"zod": "^4.1.5"
"hono": "^4.10.7",
"zod": "^4.1.13"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250910.0",
"@cloudflare/workers-types": "^4.20251205.0",
"@repo/typescript-config": "workspace:*",
"@types/bun": "^1.2.21",
"hono": "^4.9.6",
"typescript": "~5.9.2",
"vitest": "~3.2.4",
"wrangler": "^4.35.0",
"zod": "^4.1.5"
"@types/bun": "^1.3.3",
"hono": "^4.10.7",
"typescript": "~5.9.3",
"vitest": "~4.0.15",
"wrangler": "^4.53.0",
"zod": "^4.1.13"
}
}
182 changes: 44 additions & 138 deletions apps/app/components/auth/passkey-login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
/* SPDX-License-Identifier: MIT */

import { auth } from "@/lib/auth";
import { Button, Input } from "@repo/ui";
import { authConfig } from "@/lib/auth-config";
import { Button } from "@repo/ui";
import { KeyRound } from "lucide-react";
import { useEffect, useState } from "react";

Expand All @@ -12,183 +13,88 @@ interface PasskeyLoginProps {
isDisabled?: boolean;
}

/**
* Passkey sign-in component using WebAuthn.
*
* WebAuthn handles credential discovery - no email input needed.
* The browser prompts the user to select from their available passkeys.
*/
export function PasskeyLogin({
onSuccess,
onError,
isDisabled,
}: PasskeyLoginProps) {
const [email, setEmail] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [showEmailInput, setShowEmailInput] = useState(false);

// Set up conditional UI for passkey autofill when component mounts
// Set up conditional UI for passkey autofill (gated by config)
useEffect(() => {
let abortController: AbortController | null = null;
if (!authConfig.passkey.enableConditionalUI) return;

const setupConditionalUI = async () => {
// Check if browser supports conditional UI
if (!window.PublicKeyCredential?.isConditionalMediationAvailable) {
return;
}

const isAvailable =
await window.PublicKeyCredential.isConditionalMediationAvailable();
if (!isAvailable) {
return;
}
try {
if (!window.PublicKeyCredential?.isConditionalMediationAvailable)
return;

// Create abort controller for cleanup
abortController = new AbortController();
const isAvailable =
await window.PublicKeyCredential.isConditionalMediationAvailable();
if (!isAvailable) return;

// This enables autofill for passkeys on input fields with autocomplete="webauthn"
// It runs silently in the background and doesn't show any UI unless user interacts with an input
try {
await auth.signIn.passkey({
autoFill: true,
});
} catch (err) {
// Only log if not aborted
if (err instanceof Error && !err.message.includes("abort")) {
console.debug("Passkey autofill setup failed:", err);
// Enable autofill for passkeys on input fields with autocomplete="webauthn"
const result = await auth.signIn.passkey({ autoFill: true });
if (result.data) {
onSuccess();
}
} catch {
// Silently ignore errors from conditional UI (user hasn't explicitly requested auth)
}
};

setupConditionalUI();

// Cleanup function to abort passkey operation
return () => {
if (abortController) {
abortController.abort();
}
};
}, []);
}, [onSuccess]);

const handlePasskeyLogin = async () => {
try {
// If no email provided yet, show the email input
if (!email && !showEmailInput) {
setShowEmailInput(true);
onError(""); // Clear any previous errors
return;
}

setIsLoading(true);
onError(""); // Clear any previous errors

// Check if browser supports WebAuthn
if (!window.PublicKeyCredential) {
throw new Error("Your browser doesn't support passkeys");
}
// Check WebAuthn support before attempting
if (!window.PublicKeyCredential) {
onError(authConfig.errors.passkeyNotSupported);
return;
}

// Email is required for passkey sign-in
if (!email) {
onError("Email is required for passkey sign-in");
return;
}
setIsLoading(true);
onError("");

// Attempt passkey sign-in with the provided email
const result = await auth.signIn.passkey({ email });
try {
// Better Auth passkey client returns errors via result.error for HTTP/WebAuthn errors,
// but network failures (offline, DNS) can still reject
const result = await auth.signIn.passkey();

if (result.data) {
onSuccess();
} else if (result.error) {
// Handle specific error cases based on error message
const errorMessage = result.error.message || "";
if (
errorMessage.includes("not found") ||
errorMessage.includes("No passkey")
) {
if (!showEmailInput) {
// Show email input for passkey
setShowEmailInput(true);
onError("Please enter your email to sign in with passkey.");
} else {
onError(
"No passkey found for this email. Please sign in with Google first, then register a passkey from your account settings.",
);
}
} else if (
errorMessage.includes("cancelled") ||
errorMessage.includes("aborted")
) {
// AUTH_CANCELLED: user dismissed prompt, timed out, or WebAuthn not supported
// Server errors (e.g., no passkey found) have different codes
const errorCode =
"code" in result.error ? result.error.code : undefined;
if (errorCode === "AUTH_CANCELLED") {
onError("Passkey authentication was cancelled.");
} else {
onError(errorMessage || "Failed to sign in with passkey");
}
}
} catch (err) {
console.error("Passkey login error:", err);
// Provide helpful error messages
if (err instanceof Error) {
if (err.message.includes("NotAllowedError")) {
onError(
"Passkey operation was cancelled or timed out. Please try again.",
);
} else if (err.message.includes("NotSupportedError")) {
onError("Passkeys are not supported on this device or browser.");
} else {
onError(err.message);
onError(result.error.message || authConfig.errors.genericError);
}
} else {
onError("An unexpected error occurred during passkey authentication.");
}
} catch {
// Network-level failures (offline, DNS, connection refused)
onError(authConfig.errors.networkError);
} finally {
setIsLoading(false);
}
};

const handleReset = () => {
setShowEmailInput(false);
setEmail("");
onError("");
};

const disabled = isDisabled || isLoading;

if (showEmailInput) {
return (
<div className="grid gap-3">
<Input
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={disabled}
autoComplete="email webauthn"
required
autoFocus
/>
<Button
type="button"
variant="default"
className="w-full"
onClick={handlePasskeyLogin}
disabled={disabled || !email}
>
<KeyRound className="mr-2 h-4 w-4" />
Continue with passkey
</Button>
<Button
type="button"
variant="ghost"
className="w-full text-sm"
onClick={handleReset}
disabled={disabled}
>
Try a different method
</Button>
</div>
);
}

return (
<Button
type="button"
variant="default"
className="w-full"
onClick={handlePasskeyLogin}
disabled={disabled}
disabled={isDisabled || isLoading}
>
<KeyRound className="mr-2 h-4 w-4" />
Sign in with passkey
Expand Down
Loading
Loading