Skip to content

Commit a4f8f76

Browse files
committed
feat(auth): require email verification
1 parent aff93a3 commit a4f8f76

17 files changed

Lines changed: 723 additions & 280 deletions

apps/web/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,15 @@
5252
"zustand": "^5.0.12"
5353
},
5454
"devDependencies": {
55-
"@cloudflare/vite-plugin": "^1.32.0",
56-
"@cloudflare/workers-types": "^4.20260413.1",
55+
"@cloudflare/vite-plugin": "^1.35.0",
56+
"@cloudflare/workers-types": "^4.20260505.1",
5757
"@tailwindcss/vite": "^4.2.2",
5858
"@types/react": "^19.2.14",
5959
"@types/react-dom": "^19.2.3",
6060
"@vitejs/plugin-react": "^4.7.0",
6161
"tailwindcss": "^4.2.2",
6262
"typescript": "^5.9.3",
6363
"vite": "^7.3.2",
64-
"wrangler": "^4.82.0"
64+
"wrangler": "^4.87.0"
6565
}
6666
}

apps/web/server/betterAuth.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { betterAuth } from "better-auth";
44
import { admin, bearer } from "better-auth/plugins";
55
import { Kysely } from "kysely";
66
import { D1Dialect } from "kysely-d1";
7+
import { sendVerificationEmail } from "./emailService";
78
import type { Env } from "./types";
89

910
export function createAuth(env: Env) {
@@ -14,13 +15,30 @@ export function createAuth(env: Env) {
1415
},
1516
basePath: "/api/auth",
1617
baseURL: {
17-
allowedHosts: env.ALLOWED_HOSTS.split(","),
18+
allowedHosts: authAllowedHosts(env),
1819
fallback: `https://${env.ALLOWED_HOSTS.split(",")[0]}`,
1920
protocol: "auto",
2021
},
2122
secret: env.AUTH_SECRET,
2223
emailAndPassword: {
2324
enabled: true,
25+
requireEmailVerification: true,
26+
customSyntheticUser: ({ coreFields, additionalFields, id }) => ({
27+
...coreFields,
28+
role: "user",
29+
banned: false,
30+
banReason: null,
31+
banExpires: null,
32+
...additionalFields,
33+
id,
34+
}),
35+
},
36+
emailVerification: {
37+
autoSignInAfterVerification: true,
38+
sendOnSignIn: true,
39+
sendVerificationEmail: async ({ user, url }, request) => {
40+
await sendVerificationEmail(env, user.email, verificationPageUrl(env, url, request));
41+
},
2442
},
2543
socialProviders: {
2644
github: {
@@ -68,3 +86,22 @@ export function createAuth(env: Env) {
6886
}
6987

7088
export type Auth = ReturnType<typeof createAuth>;
89+
90+
function authAllowedHosts(env: Env): string[] {
91+
const hosts = env.ALLOWED_HOSTS.split(",");
92+
if (!hosts.some((host) => host.startsWith("localhost") || host.startsWith("127.0.0.1"))) return hosts;
93+
return [...hosts, "localhost:*", "127.0.0.1:*"];
94+
}
95+
96+
function verificationUrlForRequest(env: Env, url: string, request?: Request): string {
97+
if (!request) return new URL(url, `https://${env.ALLOWED_HOSTS.split(",")[0]}`).toString();
98+
const origin = new URL(request.url).origin;
99+
return new URL(url, origin).toString();
100+
}
101+
102+
function verificationPageUrl(env: Env, url: string, request?: Request): string {
103+
const resolved = new URL(verificationUrlForRequest(env, url, request));
104+
const page = new URL("/auth/verify", resolved.origin);
105+
page.searchParams.set("token", resolved.searchParams.get("token") || "");
106+
return page.toString();
107+
}

apps/web/server/emailService.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { Env } from "./types";
2+
3+
const VERIFY_FROM = { email: "noreply@agent-kanban.dev", name: "Agent Kanban" };
4+
5+
export async function sendVerificationEmail(env: Env, to: string, url: string): Promise<void> {
6+
if (isLocalDev(env)) {
7+
console.info(`[email-verification] ${to}: ${localVerificationUrl(url)}`);
8+
return;
9+
}
10+
11+
await env.EMAIL.send({
12+
to,
13+
from: VERIFY_FROM,
14+
subject: "Verify your Agent Kanban email",
15+
html: verificationHtml(url),
16+
text: `Verify your Agent Kanban email: ${url}\n\nThis link expires in 1 hour.`,
17+
});
18+
}
19+
20+
function verificationHtml(url: string): string {
21+
const escapedUrl = escapeHtml(url);
22+
return `
23+
<p>Verify your Agent Kanban email address.</p>
24+
<p><a href="${escapedUrl}">Verify email</a></p>
25+
<p>This link expires in 1 hour.</p>
26+
`;
27+
}
28+
29+
function escapeHtml(value: string): string {
30+
return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
31+
}
32+
33+
function isLocalDev(env: Env): boolean {
34+
return env.ALLOWED_HOSTS.split(",").some((host) => host.startsWith("localhost") || host.startsWith("127.0.0.1"));
35+
}
36+
37+
function localVerificationUrl(url: string): string {
38+
return url.replace(/^https:\/\/(localhost|127\.0\.0\.1)(:\d+)?/, "http://$1$2");
39+
}

apps/web/server/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Session, User } from "better-auth";
33
export interface Env {
44
DB: D1Database;
55
AE: AnalyticsEngineDataset;
6+
EMAIL: SendEmail;
67
TUNNEL_RELAY: DurableObjectNamespace;
78
ASSETS: Fetcher;
89
AUTH_SECRET: string;

apps/web/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { AgentNewPage } from "./routes/AgentNewPage";
77
import { AgentsPage } from "./routes/AgentsPage";
88
import { AuthCallbackPage } from "./routes/AuthCallbackPage";
99
import { AuthPage } from "./routes/AuthPage";
10+
import { AuthVerifyPage } from "./routes/AuthVerifyPage";
1011
import { AdminDashboardPage } from "./routes/admin/AdminDashboardPage";
1112
import { AdminLayout } from "./routes/admin/AdminLayout";
1213
import { AdminMachinesPage } from "./routes/admin/AdminMachinesPage";
@@ -55,6 +56,7 @@ export function App() {
5556
<Routes>
5657
<Route path="/auth" element={<AuthPage />} />
5758
<Route path="/auth/callback" element={<AuthCallbackPage />} />
59+
<Route path="/auth/verify" element={<AuthVerifyPage />} />
5860
<Route path="/landing" element={<LandingPage />} />
5961
<Route path="/mock/chat" element={<MockChatPage />} />
6062
<Route path="/share/:slug" element={<SharePage />} />

apps/web/src/lib/auth-client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,4 @@ export const authClient = createAuthClient({
5050
},
5151
});
5252

53-
export const { useSession, signIn, signUp, signOut } = authClient;
53+
export const { useSession, signIn, signUp, signOut, sendVerificationEmail } = authClient;

0 commit comments

Comments
 (0)