Initial commit
This commit is contained in:
334
references/cloudflare-worker-drizzle.ts
Normal file
334
references/cloudflare-worker-drizzle.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Complete Cloudflare Worker with better-auth + Drizzle ORM
|
||||
*
|
||||
* This example demonstrates:
|
||||
* - D1 database with Drizzle ORM adapter
|
||||
* - Email/password authentication
|
||||
* - Google and GitHub OAuth
|
||||
* - Protected routes with session verification
|
||||
* - CORS configuration for SPA
|
||||
* - KV storage for sessions (strong consistency)
|
||||
* - Rate limiting with KV
|
||||
*
|
||||
* ⚠️ CRITICAL: better-auth requires Drizzle ORM or Kysely for D1
|
||||
* There is NO direct d1Adapter()!
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { drizzle, type DrizzleD1Database } from "drizzle-orm/d1";
|
||||
import { rateLimit } from "better-auth/plugins";
|
||||
import * as schema from "../db/schema"; // Your Drizzle schema
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Environment bindings
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
type Env = {
|
||||
DB: D1Database;
|
||||
SESSIONS_KV: KVNamespace;
|
||||
RATE_LIMIT_KV: KVNamespace;
|
||||
BETTER_AUTH_SECRET: string;
|
||||
BETTER_AUTH_URL: string;
|
||||
GOOGLE_CLIENT_ID: string;
|
||||
GOOGLE_CLIENT_SECRET: string;
|
||||
GITHUB_CLIENT_ID: string;
|
||||
GITHUB_CLIENT_SECRET: string;
|
||||
FRONTEND_URL: string;
|
||||
};
|
||||
|
||||
// Database type
|
||||
export type Database = DrizzleD1Database<typeof schema>;
|
||||
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CORS configuration for SPA
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.use("/api/*", async (c, next) => {
|
||||
const corsMiddleware = cors({
|
||||
origin: [c.env.FRONTEND_URL, "http://localhost:3000"],
|
||||
credentials: true,
|
||||
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowHeaders: ["Content-Type", "Authorization"],
|
||||
});
|
||||
return corsMiddleware(c, next);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Helper: Initialize Drizzle database
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function createDatabase(d1: D1Database): Database {
|
||||
return drizzle(d1, { schema });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Helper: Initialize auth (per-request to access env)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function createAuth(db: Database, env: Env) {
|
||||
return betterAuth({
|
||||
// Base URL for OAuth callbacks
|
||||
baseURL: env.BETTER_AUTH_URL,
|
||||
|
||||
// Secret for signing tokens
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
|
||||
// ⚠️ CRITICAL: Use Drizzle adapter with SQLite provider
|
||||
// There is NO direct d1Adapter()!
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "sqlite",
|
||||
}),
|
||||
|
||||
// Email/password authentication
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: true,
|
||||
sendVerificationEmail: async ({ user, url, token }) => {
|
||||
// TODO: Implement email sending
|
||||
// Use Resend, SendGrid, or Cloudflare Email Routing
|
||||
console.log(`Verification email for ${user.email}: ${url}`);
|
||||
console.log(`Verification code: ${token}`);
|
||||
},
|
||||
},
|
||||
|
||||
// Social providers
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||
scope: ["openid", "email", "profile"],
|
||||
},
|
||||
github: {
|
||||
clientId: env.GITHUB_CLIENT_ID,
|
||||
clientSecret: env.GITHUB_CLIENT_SECRET,
|
||||
scope: ["user:email", "read:user"],
|
||||
},
|
||||
},
|
||||
|
||||
// Session configuration
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
updateAge: 60 * 60 * 24, // Update every 24 hours
|
||||
|
||||
// Use KV for sessions (strong consistency vs D1 eventual consistency)
|
||||
storage: {
|
||||
get: async (sessionId) => {
|
||||
const session = await env.SESSIONS_KV.get(sessionId);
|
||||
return session ? JSON.parse(session) : null;
|
||||
},
|
||||
set: async (sessionId, session, ttl) => {
|
||||
await env.SESSIONS_KV.put(sessionId, JSON.stringify(session), {
|
||||
expirationTtl: ttl,
|
||||
});
|
||||
},
|
||||
delete: async (sessionId) => {
|
||||
await env.SESSIONS_KV.delete(sessionId);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Plugins
|
||||
plugins: [
|
||||
rateLimit({
|
||||
window: 60, // 60 seconds
|
||||
max: 10, // 10 requests per window
|
||||
storage: {
|
||||
get: async (key) => {
|
||||
return await env.RATE_LIMIT_KV.get(key);
|
||||
},
|
||||
set: async (key, value, ttl) => {
|
||||
await env.RATE_LIMIT_KV.put(key, value, {
|
||||
expirationTtl: ttl,
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Auth routes - handle all better-auth endpoints
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.all("/api/auth/*", async (c) => {
|
||||
const db = createDatabase(c.env.DB);
|
||||
const auth = createAuth(db, c.env);
|
||||
return auth.handler(c.req.raw);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Example: Protected API route
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.get("/api/protected", async (c) => {
|
||||
const db = createDatabase(c.env.DB);
|
||||
const auth = createAuth(db, c.env);
|
||||
|
||||
// Verify session
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
message: "Protected data",
|
||||
user: {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Example: User profile endpoint
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.get("/api/user/profile", async (c) => {
|
||||
const db = createDatabase(c.env.DB);
|
||||
const auth = createAuth(db, c.env);
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
// Fetch additional user data from D1
|
||||
const userProfile = await db.query.user.findFirst({
|
||||
where: (user, { eq }) => eq(user.id, session.user.id),
|
||||
});
|
||||
|
||||
return c.json(userProfile);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Example: Update user profile
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.patch("/api/user/profile", async (c) => {
|
||||
const db = createDatabase(c.env.DB);
|
||||
const auth = createAuth(db, c.env);
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const { name } = await c.req.json();
|
||||
|
||||
// Update user in D1 using Drizzle
|
||||
await db
|
||||
.update(schema.user)
|
||||
.set({ name, updatedAt: new Date() })
|
||||
.where(eq(schema.user.id, session.user.id));
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Example: Admin-only endpoint
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.get("/api/admin/users", async (c) => {
|
||||
const db = createDatabase(c.env.DB);
|
||||
const auth = createAuth(db, c.env);
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
// Check admin role (you'd store this in users table)
|
||||
const user = await db.query.user.findFirst({
|
||||
where: (user, { eq }) => eq(user.id, session.user.id),
|
||||
// Add role field to your schema if needed
|
||||
});
|
||||
|
||||
// if (user.role !== 'admin') {
|
||||
// return c.json({ error: 'Forbidden' }, 403)
|
||||
// }
|
||||
|
||||
// Fetch all users
|
||||
const users = await db.query.user.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json(users);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Health check
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.get("/health", (c) => {
|
||||
return c.json({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Export Worker
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
export default app;
|
||||
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
* SETUP CHECKLIST
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
*
|
||||
* 1. Create D1 database:
|
||||
* wrangler d1 create my-app-db
|
||||
*
|
||||
* 2. Create KV namespaces:
|
||||
* wrangler kv:namespace create SESSIONS_KV
|
||||
* wrangler kv:namespace create RATE_LIMIT_KV
|
||||
*
|
||||
* 3. Add to wrangler.toml:
|
||||
* [[d1_databases]]
|
||||
* binding = "DB"
|
||||
* database_name = "my-app-db"
|
||||
* database_id = "YOUR_ID"
|
||||
*
|
||||
* [[kv_namespaces]]
|
||||
* binding = "SESSIONS_KV"
|
||||
* id = "YOUR_ID"
|
||||
*
|
||||
* [[kv_namespaces]]
|
||||
* binding = "RATE_LIMIT_KV"
|
||||
* id = "YOUR_ID"
|
||||
*
|
||||
* [vars]
|
||||
* BETTER_AUTH_URL = "http://localhost:8787"
|
||||
* FRONTEND_URL = "http://localhost:3000"
|
||||
*
|
||||
* 4. Set secrets:
|
||||
* wrangler secret put BETTER_AUTH_SECRET
|
||||
* wrangler secret put GOOGLE_CLIENT_ID
|
||||
* wrangler secret put GOOGLE_CLIENT_SECRET
|
||||
* wrangler secret put GITHUB_CLIENT_ID
|
||||
* wrangler secret put GITHUB_CLIENT_SECRET
|
||||
*
|
||||
* 5. Generate and apply migrations:
|
||||
* npx drizzle-kit generate
|
||||
* wrangler d1 migrations apply my-app-db --local
|
||||
* wrangler d1 migrations apply my-app-db --remote
|
||||
*
|
||||
* 6. Deploy:
|
||||
* wrangler deploy
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
*/
|
||||
240
references/cloudflare-worker-kysely.ts
Normal file
240
references/cloudflare-worker-kysely.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Complete Cloudflare Worker with better-auth + Kysely
|
||||
*
|
||||
* This example demonstrates:
|
||||
* - D1 database with Kysely adapter
|
||||
* - Email/password authentication
|
||||
* - Google OAuth
|
||||
* - Protected routes with session verification
|
||||
* - CORS configuration for SPA
|
||||
* - CamelCasePlugin for schema conversion
|
||||
*
|
||||
* ⚠️ CRITICAL: better-auth requires Kysely (or Drizzle) for D1
|
||||
* There is NO direct d1Adapter()!
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { Kysely, CamelCasePlugin } from "kysely";
|
||||
import { D1Dialect } from "kysely-d1";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Environment bindings
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
type Env = {
|
||||
DB: D1Database;
|
||||
BETTER_AUTH_SECRET: string;
|
||||
BETTER_AUTH_URL: string;
|
||||
GOOGLE_CLIENT_ID: string;
|
||||
GOOGLE_CLIENT_SECRET: string;
|
||||
FRONTEND_URL: string;
|
||||
};
|
||||
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CORS configuration for SPA
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.use("/api/*", async (c, next) => {
|
||||
const corsMiddleware = cors({
|
||||
origin: [c.env.FRONTEND_URL, "http://localhost:3000"],
|
||||
credentials: true,
|
||||
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowHeaders: ["Content-Type", "Authorization"],
|
||||
});
|
||||
return corsMiddleware(c, next);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Helper: Initialize auth with Kysely
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function createAuth(env: Env) {
|
||||
return betterAuth({
|
||||
// Base URL for OAuth callbacks
|
||||
baseURL: env.BETTER_AUTH_URL,
|
||||
|
||||
// Secret for signing tokens
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
|
||||
// ⚠️ CRITICAL: Use Kysely with D1Dialect
|
||||
// There is NO direct d1Adapter()!
|
||||
database: {
|
||||
db: new Kysely({
|
||||
dialect: new D1Dialect({
|
||||
database: env.DB,
|
||||
}),
|
||||
plugins: [
|
||||
// CRITICAL: CamelCasePlugin converts between snake_case (DB) and camelCase (better-auth)
|
||||
// Without this, session reads will fail if your schema uses snake_case
|
||||
new CamelCasePlugin(),
|
||||
],
|
||||
}),
|
||||
type: "sqlite",
|
||||
},
|
||||
|
||||
// Email/password authentication
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: true,
|
||||
sendVerificationEmail: async ({ user, url, token }) => {
|
||||
// TODO: Implement email sending
|
||||
console.log(`Verification email for ${user.email}: ${url}`);
|
||||
console.log(`Verification code: ${token}`);
|
||||
},
|
||||
},
|
||||
|
||||
// Social providers
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||
scope: ["openid", "email", "profile"],
|
||||
},
|
||||
},
|
||||
|
||||
// Session configuration
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
updateAge: 60 * 60 * 24, // Update every 24 hours
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Auth routes - handle all better-auth endpoints
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.all("/api/auth/*", async (c) => {
|
||||
const auth = createAuth(c.env);
|
||||
return auth.handler(c.req.raw);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Example: Protected API route
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.get("/api/protected", async (c) => {
|
||||
const auth = createAuth(c.env);
|
||||
|
||||
// Verify session
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
message: "Protected data",
|
||||
user: {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Example: User profile endpoint
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.get("/api/user/profile", async (c) => {
|
||||
const auth = createAuth(c.env);
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
// Fetch user data using Kysely
|
||||
const db = new Kysely({
|
||||
dialect: new D1Dialect({ database: c.env.DB }),
|
||||
plugins: [new CamelCasePlugin()],
|
||||
});
|
||||
|
||||
const user = await db
|
||||
.selectFrom("user")
|
||||
.select(["id", "email", "name", "image", "createdAt"])
|
||||
.where("id", "=", session.user.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
return c.json(user);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Health check
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
app.get("/health", (c) => {
|
||||
return c.json({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Export Worker
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
export default app;
|
||||
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
* SETUP CHECKLIST
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
*
|
||||
* 1. Install dependencies:
|
||||
* npm install better-auth kysely kysely-d1 hono
|
||||
*
|
||||
* 2. Create D1 database:
|
||||
* wrangler d1 create my-app-db
|
||||
*
|
||||
* 3. Add to wrangler.toml:
|
||||
* [[d1_databases]]
|
||||
* binding = "DB"
|
||||
* database_name = "my-app-db"
|
||||
* database_id = "YOUR_ID"
|
||||
*
|
||||
* [vars]
|
||||
* BETTER_AUTH_URL = "http://localhost:8787"
|
||||
* FRONTEND_URL = "http://localhost:3000"
|
||||
*
|
||||
* 4. Set secrets:
|
||||
* wrangler secret put BETTER_AUTH_SECRET
|
||||
* wrangler secret put GOOGLE_CLIENT_ID
|
||||
* wrangler secret put GOOGLE_CLIENT_SECRET
|
||||
*
|
||||
* 5. Create database schema manually (Kysely doesn't auto-generate):
|
||||
* wrangler d1 execute my-app-db --local --command "
|
||||
* CREATE TABLE user (
|
||||
* id TEXT PRIMARY KEY,
|
||||
* name TEXT NOT NULL,
|
||||
* email TEXT NOT NULL UNIQUE,
|
||||
* email_verified INTEGER NOT NULL DEFAULT 0,
|
||||
* image TEXT,
|
||||
* created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
* updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
* );
|
||||
* CREATE TABLE session (...);
|
||||
* CREATE TABLE account (...);
|
||||
* CREATE TABLE verification (...);
|
||||
* "
|
||||
*
|
||||
* 6. Apply schema to remote:
|
||||
* wrangler d1 execute my-app-db --remote --file schema.sql
|
||||
*
|
||||
* 7. Deploy:
|
||||
* wrangler deploy
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
* WHY CamelCasePlugin?
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
*
|
||||
* If your database schema uses snake_case (email_verified),
|
||||
* but better-auth expects camelCase (emailVerified), the
|
||||
* CamelCasePlugin automatically converts between the two.
|
||||
*
|
||||
* Without it, session reads will fail with missing fields.
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
*/
|
||||
315
references/database-schema.ts
Normal file
315
references/database-schema.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Complete better-auth Database Schema for Drizzle ORM + D1
|
||||
*
|
||||
* This schema includes all tables required by better-auth core.
|
||||
* You can add your own application tables below.
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
* CRITICAL NOTES
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
*
|
||||
* 1. Column names use camelCase (emailVerified, createdAt)
|
||||
* - This matches better-auth expectations
|
||||
* - If you use snake_case, you MUST use CamelCasePlugin with Kysely
|
||||
*
|
||||
* 2. Timestamps use INTEGER with mode: "timestamp"
|
||||
* - D1 (SQLite) doesn't have native timestamp type
|
||||
* - Unix epoch timestamps (seconds since 1970)
|
||||
*
|
||||
* 3. Booleans use INTEGER with mode: "boolean"
|
||||
* - D1 (SQLite) doesn't have native boolean type
|
||||
* - 0 = false, 1 = true
|
||||
*
|
||||
* 4. Foreign keys use onDelete: "cascade"
|
||||
* - Automatically delete related records
|
||||
* - session deleted when user deleted
|
||||
* - account deleted when user deleted
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
import { integer, sqliteTable, text, index } from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// better-auth CORE TABLES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Users table - stores all user accounts
|
||||
*/
|
||||
export const user = sqliteTable(
|
||||
"user",
|
||||
{
|
||||
id: text().primaryKey(),
|
||||
name: text().notNull(),
|
||||
email: text().notNull().unique(),
|
||||
emailVerified: integer({ mode: "boolean" }).notNull().default(false),
|
||||
image: text(), // Profile picture URL
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => ({
|
||||
emailIdx: index("user_email_idx").on(table.email),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Sessions table - stores active user sessions
|
||||
*
|
||||
* NOTE: Consider using KV storage for sessions instead of D1
|
||||
* to avoid eventual consistency issues
|
||||
*/
|
||||
export const session = sqliteTable(
|
||||
"session",
|
||||
{
|
||||
id: text().primaryKey(),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
token: text().notNull().unique(),
|
||||
expiresAt: integer({ mode: "timestamp" }).notNull(),
|
||||
ipAddress: text(),
|
||||
userAgent: text(),
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index("session_user_id_idx").on(table.userId),
|
||||
tokenIdx: index("session_token_idx").on(table.token),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Accounts table - stores OAuth provider accounts and passwords
|
||||
*/
|
||||
export const account = sqliteTable(
|
||||
"account",
|
||||
{
|
||||
id: text().primaryKey(),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accountId: text().notNull(), // Provider's user ID
|
||||
providerId: text().notNull(), // "google", "github", etc.
|
||||
accessToken: text(),
|
||||
refreshToken: text(),
|
||||
accessTokenExpiresAt: integer({ mode: "timestamp" }),
|
||||
refreshTokenExpiresAt: integer({ mode: "timestamp" }),
|
||||
scope: text(), // OAuth scopes granted
|
||||
idToken: text(), // OpenID Connect ID token
|
||||
password: text(), // Hashed password for email/password auth
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index("account_user_id_idx").on(table.userId),
|
||||
providerIdx: index("account_provider_idx").on(
|
||||
table.providerId,
|
||||
table.accountId
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Verification tokens - for email verification, password reset, etc.
|
||||
*/
|
||||
export const verification = sqliteTable(
|
||||
"verification",
|
||||
{
|
||||
id: text().primaryKey(),
|
||||
identifier: text().notNull(), // Email or user ID
|
||||
value: text().notNull(), // Token value
|
||||
expiresAt: integer({ mode: "timestamp" }).notNull(),
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => ({
|
||||
identifierIdx: index("verification_identifier_idx").on(table.identifier),
|
||||
valueIdx: index("verification_value_idx").on(table.value),
|
||||
})
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// OPTIONAL: Additional tables for better-auth plugins
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Two-Factor Authentication table (if using 2FA plugin)
|
||||
*/
|
||||
export const twoFactor = sqliteTable(
|
||||
"two_factor",
|
||||
{
|
||||
id: text().primaryKey(),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
secret: text().notNull(), // TOTP secret
|
||||
backupCodes: text(), // JSON array of backup codes
|
||||
enabled: integer({ mode: "boolean" }).notNull().default(false),
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index("two_factor_user_id_idx").on(table.userId),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Organizations table (if using organization plugin)
|
||||
*/
|
||||
export const organization = sqliteTable("organization", {
|
||||
id: text().primaryKey(),
|
||||
name: text().notNull(),
|
||||
slug: text().notNull().unique(),
|
||||
logo: text(),
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
/**
|
||||
* Organization members table (if using organization plugin)
|
||||
*/
|
||||
export const organizationMember = sqliteTable(
|
||||
"organization_member",
|
||||
{
|
||||
id: text().primaryKey(),
|
||||
organizationId: text()
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
role: text().notNull(), // "owner", "admin", "member"
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => ({
|
||||
orgIdIdx: index("org_member_org_id_idx").on(table.organizationId),
|
||||
userIdIdx: index("org_member_user_id_idx").on(table.userId),
|
||||
})
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// YOUR APPLICATION TABLES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Example: User profile extension
|
||||
*/
|
||||
export const profile = sqliteTable("profile", {
|
||||
id: text().primaryKey(),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
bio: text(),
|
||||
website: text(),
|
||||
location: text(),
|
||||
phone: text(),
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
/**
|
||||
* Example: User preferences
|
||||
*/
|
||||
export const userPreferences = sqliteTable("user_preferences", {
|
||||
id: text().primaryKey(),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
theme: text().notNull().default("system"), // "light", "dark", "system"
|
||||
language: text().notNull().default("en"),
|
||||
emailNotifications: integer({ mode: "boolean" }).notNull().default(true),
|
||||
pushNotifications: integer({ mode: "boolean" }).notNull().default(false),
|
||||
createdAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Export all schemas for Drizzle
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
export const schema = {
|
||||
user,
|
||||
session,
|
||||
account,
|
||||
verification,
|
||||
twoFactor,
|
||||
organization,
|
||||
organizationMember,
|
||||
profile,
|
||||
userPreferences,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
* USAGE INSTRUCTIONS
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
*
|
||||
* 1. Save this file as: src/db/schema.ts
|
||||
*
|
||||
* 2. Create drizzle.config.ts:
|
||||
* import type { Config } from "drizzle-kit";
|
||||
*
|
||||
* export default {
|
||||
* out: "./drizzle",
|
||||
* schema: "./src/db/schema.ts",
|
||||
* dialect: "sqlite",
|
||||
* driver: "d1-http",
|
||||
* dbCredentials: {
|
||||
* databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
|
||||
* accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
|
||||
* token: process.env.CLOUDFLARE_TOKEN!,
|
||||
* },
|
||||
* } satisfies Config;
|
||||
*
|
||||
* 3. Generate migrations:
|
||||
* npx drizzle-kit generate
|
||||
*
|
||||
* 4. Apply migrations to D1:
|
||||
* wrangler d1 migrations apply my-app-db --local
|
||||
* wrangler d1 migrations apply my-app-db --remote
|
||||
*
|
||||
* 5. Use in your Worker:
|
||||
* import { drizzle } from "drizzle-orm/d1";
|
||||
* import * as schema from "./db/schema";
|
||||
*
|
||||
* const db = drizzle(env.DB, { schema });
|
||||
*
|
||||
* 6. Query example:
|
||||
* const users = await db.query.user.findMany({
|
||||
* where: (user, { eq }) => eq(user.emailVerified, true)
|
||||
* });
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════
|
||||
*/
|
||||
41
references/nextjs/README.md
Normal file
41
references/nextjs/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Next.js Examples
|
||||
|
||||
This directory contains better-auth examples for **Next.js with PostgreSQL**.
|
||||
|
||||
**Important**: These examples are NOT for Cloudflare D1. They use PostgreSQL via Hyperdrive or direct connection.
|
||||
|
||||
## Files
|
||||
|
||||
### `postgres-example.ts`
|
||||
Complete Next.js API route with better-auth using:
|
||||
- **PostgreSQL** (not D1)
|
||||
- Drizzle ORM with `postgres` driver
|
||||
- Organizations plugin
|
||||
- 2FA plugin
|
||||
- Email verification
|
||||
- Custom error handling
|
||||
|
||||
**Use this example when**:
|
||||
- Building Next.js application (not Cloudflare Workers)
|
||||
- Using PostgreSQL database
|
||||
- Need organizations and 2FA features
|
||||
|
||||
**Installation**:
|
||||
```bash
|
||||
npm install better-auth drizzle-orm postgres
|
||||
```
|
||||
|
||||
**Environment variables**:
|
||||
```env
|
||||
DATABASE_URL=postgresql://user:password@host:5432/database
|
||||
BETTER_AUTH_SECRET=your-secret
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
GOOGLE_CLIENT_ID=your-google-client-id
|
||||
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**For Cloudflare D1 examples**, see the parent `references/` directory:
|
||||
- `cloudflare-worker-drizzle.ts` - Complete Worker with Drizzle + D1
|
||||
- `cloudflare-worker-kysely.ts` - Complete Worker with Kysely + D1
|
||||
147
references/nextjs/postgres-example.ts
Normal file
147
references/nextjs/postgres-example.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Next.js API Route with better-auth
|
||||
*
|
||||
* This example demonstrates:
|
||||
* - PostgreSQL with Drizzle ORM
|
||||
* - Email/password + social auth
|
||||
* - Email verification
|
||||
* - Organizations plugin
|
||||
* - 2FA plugin
|
||||
* - Custom error handling
|
||||
*/
|
||||
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
import postgres from 'postgres'
|
||||
import { twoFactor, organization } from 'better-auth/plugins'
|
||||
import { sendEmail } from '@/lib/email' // Your email service
|
||||
|
||||
// Database connection
|
||||
const client = postgres(process.env.DATABASE_URL!)
|
||||
const db = drizzle(client)
|
||||
|
||||
// Initialize better-auth
|
||||
export const auth = betterAuth({
|
||||
database: db,
|
||||
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
|
||||
baseURL: process.env.NEXT_PUBLIC_APP_URL!,
|
||||
|
||||
// Email/password authentication
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: true,
|
||||
|
||||
// Custom email sending
|
||||
sendVerificationEmail: async ({ user, url, token }) => {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: 'Verify your email',
|
||||
html: `
|
||||
<h1>Verify your email</h1>
|
||||
<p>Click the link below to verify your email address:</p>
|
||||
<a href="${url}">Verify Email</a>
|
||||
<p>Or enter this code: <strong>${token}</strong></p>
|
||||
<p>This link expires in 24 hours.</p>
|
||||
`
|
||||
})
|
||||
},
|
||||
|
||||
// Password reset email
|
||||
sendResetPasswordEmail: async ({ user, url, token }) => {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: 'Reset your password',
|
||||
html: `
|
||||
<h1>Reset your password</h1>
|
||||
<p>Click the link below to reset your password:</p>
|
||||
<a href="${url}">Reset Password</a>
|
||||
<p>Or enter this code: <strong>${token}</strong></p>
|
||||
<p>This link expires in 1 hour.</p>
|
||||
`
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Social providers
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
scope: ['openid', 'email', 'profile']
|
||||
},
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
scope: ['user:email', 'read:user']
|
||||
},
|
||||
microsoft: {
|
||||
clientId: process.env.MICROSOFT_CLIENT_ID!,
|
||||
clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
|
||||
tenantId: process.env.MICROSOFT_TENANT_ID || 'common'
|
||||
}
|
||||
},
|
||||
|
||||
// Session configuration
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
updateAge: 60 * 60 * 24, // Update every 24 hours
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 60 * 5 // 5 minutes
|
||||
}
|
||||
},
|
||||
|
||||
// Advanced features via plugins
|
||||
plugins: [
|
||||
// Two-factor authentication
|
||||
twoFactor({
|
||||
methods: ['totp', 'sms'],
|
||||
issuer: 'MyApp',
|
||||
sendOTP: async ({ user, otp, method }) => {
|
||||
if (method === 'sms') {
|
||||
// Send SMS with OTP (use Twilio, etc.)
|
||||
console.log(`Send SMS to ${user.phone}: ${otp}`)
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Organizations and teams
|
||||
organization({
|
||||
roles: ['owner', 'admin', 'member'],
|
||||
permissions: {
|
||||
owner: ['*'], // All permissions
|
||||
admin: ['read', 'write', 'delete', 'invite'],
|
||||
member: ['read']
|
||||
},
|
||||
sendInvitationEmail: async ({ email, organizationName, inviteUrl }) => {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: `You've been invited to ${organizationName}`,
|
||||
html: `
|
||||
<h1>You've been invited!</h1>
|
||||
<p>Click the link below to join ${organizationName}:</p>
|
||||
<a href="${inviteUrl}">Accept Invitation</a>
|
||||
`
|
||||
})
|
||||
}
|
||||
})
|
||||
],
|
||||
|
||||
// Custom error handling
|
||||
onError: (error, req) => {
|
||||
console.error('Auth error:', error)
|
||||
// Log to your error tracking service (Sentry, etc.)
|
||||
},
|
||||
|
||||
// Success callbacks
|
||||
onSuccess: async (user, action) => {
|
||||
console.log(`User ${user.id} performed action: ${action}`)
|
||||
// Log auth events for security monitoring
|
||||
}
|
||||
})
|
||||
|
||||
// Type definitions for TypeScript
|
||||
export type Session = typeof auth.$Infer.Session
|
||||
export type User = typeof auth.$Infer.User
|
||||
470
references/react-client-hooks.tsx
Normal file
470
references/react-client-hooks.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* React Client Components with better-auth
|
||||
*
|
||||
* This example demonstrates:
|
||||
* - useSession hook
|
||||
* - Sign in/up forms
|
||||
* - Social sign-in buttons
|
||||
* - Protected route component
|
||||
* - User profile component
|
||||
* - Organization switcher
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { createAuthClient, useSession } from 'better-auth/client'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
// Initialize auth client
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Login Form Component
|
||||
// ============================================================================
|
||||
|
||||
export function LoginForm() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleEmailSignIn = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const { data, error } = await authClient.signIn.email({
|
||||
email,
|
||||
password
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect on success
|
||||
window.location.href = '/dashboard'
|
||||
} catch (err) {
|
||||
setError('An error occurred. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoogleSignIn = async () => {
|
||||
setLoading(true)
|
||||
await authClient.signIn.social({
|
||||
provider: 'google',
|
||||
callbackURL: '/dashboard'
|
||||
})
|
||||
}
|
||||
|
||||
const handleGitHubSignIn = async () => {
|
||||
setLoading(true)
|
||||
await authClient.signIn.social({
|
||||
provider: 'github',
|
||||
callbackURL: '/dashboard'
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow">
|
||||
<h2 className="text-2xl font-bold mb-6">Sign In</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleEmailSignIn} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={loading}
|
||||
className="py-2 px-4 border rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Google
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGitHubSignIn}
|
||||
disabled={loading}
|
||||
className="py-2 px-4 border rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
GitHub
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-gray-600">
|
||||
Don't have an account?{' '}
|
||||
<a href="/signup" className="text-blue-600 hover:underline">
|
||||
Sign up
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sign Up Form Component
|
||||
// ============================================================================
|
||||
|
||||
export function SignUpForm() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const handleSignUp = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const { data, error } = await authClient.signUp.email({
|
||||
email,
|
||||
password,
|
||||
name
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
return
|
||||
}
|
||||
|
||||
setSuccess(true)
|
||||
} catch (err) {
|
||||
setError('An error occurred. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow">
|
||||
<h2 className="text-2xl font-bold mb-4">Check your email</h2>
|
||||
<p className="text-gray-600">
|
||||
We've sent a verification link to <strong>{email}</strong>.
|
||||
Click the link to verify your account.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow">
|
||||
<h2 className="text-2xl font-bold mb-6">Sign Up</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSignUp} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
At least 8 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Sign Up'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<a href="/login" className="text-blue-600 hover:underline">
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// User Profile Component
|
||||
// ============================================================================
|
||||
|
||||
export function UserProfile() {
|
||||
const { data: session, isPending } = useSession()
|
||||
|
||||
if (isPending) {
|
||||
return <div className="p-4">Loading...</div>
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<p>Not authenticated</p>
|
||||
<a href="/login" className="text-blue-600 hover:underline">
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await authClient.signOut()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-white rounded-lg shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
{session.user.image && (
|
||||
<img
|
||||
src={session.user.image}
|
||||
alt={session.user.name || 'User'}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">{session.user.name}</h3>
|
||||
<p className="text-sm text-gray-600">{session.user.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="px-4 py-2 text-sm border rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Protected Route Component
|
||||
// ============================================================================
|
||||
|
||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { data: session, isPending } = useSession()
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto" />
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
// Redirect to login
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Organization Switcher Component (if using organizations plugin)
|
||||
// ============================================================================
|
||||
|
||||
export function OrganizationSwitcher() {
|
||||
const { data: session } = useSession()
|
||||
const [organizations, setOrganizations] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Fetch user's organizations
|
||||
useEffect(() => {
|
||||
async function fetchOrgs() {
|
||||
const orgs = await authClient.organization.listUserOrganizations()
|
||||
setOrganizations(orgs)
|
||||
setLoading(false)
|
||||
}
|
||||
fetchOrgs()
|
||||
}, [])
|
||||
|
||||
const switchOrganization = async (orgId: string) => {
|
||||
await authClient.organization.setActiveOrganization({ organizationId: orgId })
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
if (loading) return <div>Loading organizations...</div>
|
||||
|
||||
return (
|
||||
<select
|
||||
onChange={(e) => switchOrganization(e.target.value)}
|
||||
className="px-3 py-2 border rounded-md"
|
||||
>
|
||||
{organizations.map((org) => (
|
||||
<option key={org.id} value={org.id}>
|
||||
{org.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 2FA Setup Component (if using twoFactor plugin)
|
||||
// ============================================================================
|
||||
|
||||
export function TwoFactorSetup() {
|
||||
const [qrCode, setQrCode] = useState('')
|
||||
const [verifyCode, setVerifyCode] = useState('')
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
|
||||
const enable2FA = async () => {
|
||||
const { data } = await authClient.twoFactor.enable({ method: 'totp' })
|
||||
setQrCode(data.qrCode)
|
||||
}
|
||||
|
||||
const verify2FA = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const { error } = await authClient.twoFactor.verify({ code: verifyCode })
|
||||
if (!error) {
|
||||
setEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
return <div className="p-4 bg-green-100 rounded">2FA is enabled!</div>
|
||||
}
|
||||
|
||||
if (qrCode) {
|
||||
return (
|
||||
<div className="p-4 bg-white rounded-lg shadow">
|
||||
<h3 className="font-semibold mb-4">Scan QR Code</h3>
|
||||
<img src={qrCode} alt="2FA QR Code" className="mx-auto mb-4" />
|
||||
<form onSubmit={verify2FA} className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value)}
|
||||
placeholder="Enter 6-digit code"
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
maxLength={6}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md"
|
||||
>
|
||||
Verify & Enable
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={enable2FA}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md"
|
||||
>
|
||||
Enable 2FA
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user