From 92f1c065f80860506d18b3726f94a55c2636e093 Mon Sep 17 00:00:00 2001 From: wass08 Date: Wed, 11 Feb 2026 17:09:08 +0900 Subject: [PATCH] fix magic link issue --- apps/editor/app/api/auth/[...all]/route.ts | 24 +-- apps/editor/lib/auth.ts | 44 +++++ packages/auth/src/server.ts | 189 ++++++++------------- packages/db/src/client.ts | 27 +-- packages/db/src/drizzle.ts | 33 +--- packages/db/src/server.ts | 33 +--- 6 files changed, 129 insertions(+), 221 deletions(-) create mode 100644 apps/editor/lib/auth.ts diff --git a/apps/editor/app/api/auth/[...all]/route.ts b/apps/editor/app/api/auth/[...all]/route.ts index 3eb68d83..a2cd0932 100644 --- a/apps/editor/app/api/auth/[...all]/route.ts +++ b/apps/editor/app/api/auth/[...all]/route.ts @@ -3,27 +3,9 @@ * Handles all /api/auth/* routes for authentication */ -import { auth } from '@pascal-app/auth/server' import { toNextJsHandler } from 'better-auth/next-js' -import type { NextRequest } from 'next/server' +import { auth } from '@/lib/auth' -// Lazy initialization of the handler -let handler: ReturnType | null = null +const { GET, POST } = toNextJsHandler(auth) -function getHandler() { - if (!handler) { - handler = toNextJsHandler(auth) - } - return handler -} - -// Export route handlers that initialize lazily -export async function GET(request: NextRequest) { - const handlers = getHandler() - return handlers.GET(request) -} - -export async function POST(request: NextRequest) { - const handlers = getHandler() - return handlers.POST(request) -} +export { GET, POST } diff --git a/apps/editor/lib/auth.ts b/apps/editor/lib/auth.ts new file mode 100644 index 00000000..3160d53b --- /dev/null +++ b/apps/editor/lib/auth.ts @@ -0,0 +1,44 @@ +import { createAuth } from '@pascal-app/auth/server' +import { db } from '@pascal-app/db' +import { Resend } from 'resend' + +const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null + +export const auth = createAuth({ + db, + appName: 'Pascal Editor', + baseURL: process.env.BETTER_AUTH_URL!, + secret: process.env.BETTER_AUTH_SECRET!, + sendMagicLink: async ({ email, url }) => { + if (!resend) { + console.log(`[DEV] Magic link for ${email}: ${url}`) + return + } + + try { + await resend.emails.send({ + from: 'Pascal ', + to: email, + subject: 'Sign in to Pascal Editor', + html: ` +
+

Sign in to Pascal Editor

+

Click the button below to sign in to your account:

+ + Sign In + +

This link will expire in 5 minutes.

+

If you didn't request this email, you can safely ignore it.

+
+ `, + }) + console.log(`✓ Magic link email sent to ${email}`) + } catch (error) { + console.error('Failed to send magic link email:', error) + throw error + } + }, +}) + +export type Session = typeof auth.$Infer.Session +export type User = typeof auth.$Infer.Session.user diff --git a/packages/auth/src/server.ts b/packages/auth/src/server.ts index 5071ea96..0929c1c0 100644 --- a/packages/auth/src/server.ts +++ b/packages/auth/src/server.ts @@ -1,125 +1,76 @@ -import { db, schema } from '@pascal-app/db' +import type { Database } from '@pascal-app/db' +import { schema } from '@pascal-app/db' +import type { BetterAuthOptions } from 'better-auth' import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { magicLink } from 'better-auth/plugins' -import { Resend } from 'resend' -let _auth: ReturnType | null = null - -function getAuth() { - if (!_auth) { - const betterAuthSecret = process.env.BETTER_AUTH_SECRET - const betterAuthUrl = process.env.BETTER_AUTH_URL - - if (!betterAuthSecret) { - throw new Error( - 'Missing BETTER_AUTH_SECRET environment variable. Please configure it in your deployment settings or .env.local file. Generate one with: openssl rand -base64 32', - ) - } - - if (!betterAuthUrl) { - throw new Error( - 'Missing BETTER_AUTH_URL environment variable. Please configure it in your deployment settings or .env.local file. Set it to your app URL (e.g., http://localhost:3000 or https://yourdomain.com)', - ) - } - - // Initialize Resend for email sending - const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null - - _auth = betterAuth({ - database: drizzleAdapter(db, { - provider: 'pg', - usePlural: true, - schema, - }), - advanced: { - database: { - generateId: false, // Use our prefixed nanoid IDs from schema - }, - }, - secret: betterAuthSecret, - baseURL: betterAuthUrl, - plugins: [ - magicLink({ - sendMagicLink: async ({ email, url }) => { - if (!resend) { - console.log(`[DEV] Magic link for ${email}: ${url}`) - return - } - - try { - await resend.emails.send({ - from: 'Pascal ', - to: email, - subject: 'Sign in to Pascal Editor', - html: ` -
-

Sign in to Pascal Editor

-

Click the button below to sign in to your account:

- - Sign In - -

This link will expire in 5 minutes.

-

If you didn't request this email, you can safely ignore it.

-
- `, - }) - console.log(`✓ Magic link email sent to ${email}`) - } catch (error) { - console.error('Failed to send magic link email:', error) - throw error - } - }, - }), - ], - session: { - expiresIn: 60 * 60 * 24 * 7, // 7 days - updateAge: 60 * 60 * 24, // 1 day - cookieCache: { - enabled: true, - maxAge: 5 * 60, // 5 minutes - }, - additionalFields: { - activePropertyId: { - type: 'string', - }, - }, - }, - }) - } - return _auth -} - -/** - * Better Auth server instance - * Configured with PostgreSQL database (Supabase) and magic link authentication - * - * Initialized lazily to avoid requiring env vars at build time - */ -export const auth = new Proxy({} as ReturnType, { - get(_target, prop) { - return Reflect.get(getAuth(), prop) - }, -}) - -/** - * Type helpers for better-auth session - * These are generic types that should match Better Auth's session structure - */ -export type Session = { - id: string - userId: string - activePropertyId?: string | null - expiresAt: Date - createdAt: Date - updatedAt: Date -} - -export type User = { - id: string +export interface SendMagicLinkParams { email: string - emailVerified: boolean - name: string - createdAt: Date - updatedAt: Date + url: string + token: string } + +export interface AuthConfig { + db: Database + appName: string + baseURL: string + secret: string + /** Callback to send magic link emails */ + sendMagicLink?: (params: SendMagicLinkParams) => Promise + /** Additional plugins to add (e.g., nextCookies for web) */ + additionalPlugins?: BetterAuthOptions['plugins'] +} + +/** + * Creates a Better Auth instance with full configuration including: + * - Magic link authentication + * - Custom session with activePropertyId + * - Session cookie caching + */ +export function createAuth(config: AuthConfig): ReturnType { + return betterAuth({ + appName: config.appName, + baseURL: config.baseURL, + secret: config.secret, + basePath: '/api/auth', + database: drizzleAdapter(config.db, { + provider: 'pg', + usePlural: true, + schema, + }), + advanced: { + database: { + generateId: false, // Use our prefixed nanoid IDs from schema + }, + }, + session: { + // Session caching to reduce database queries + cookieCache: { + enabled: true, + maxAge: 5 * 60, // Cache duration in seconds (5 minutes) + }, + additionalFields: { + // Additional fields for the session table + activePropertyId: { + type: 'string', + }, + }, + }, + plugins: [ + ...(config.additionalPlugins ?? []), + // Magic link authentication + ...(config.sendMagicLink + ? [ + magicLink({ + sendMagicLink: config.sendMagicLink, + expiresIn: 300, // 5 minutes + disableSignUp: false, // Allow new users to sign up via magic link + }), + ] + : []), + ], + }) +} + +export type Auth = ReturnType diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 5771823d..1052bea4 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -1,32 +1,11 @@ import { createClient } from '@supabase/supabase-js' import type { Database } from './types' -let _supabase: ReturnType> | null = null - -function getSupabase() { - if (!_supabase) { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL - const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY - - if (!supabaseUrl || !supabaseAnonKey) { - throw new Error( - 'Missing required environment variables: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY. Please configure them in your deployment settings or .env.local file.' - ) - } - - _supabase = createClient(supabaseUrl, supabaseAnonKey) - } - return _supabase -} +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL! +const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! /** * Supabase client for client-side use with anon key * Uses Row Level Security (RLS) policies - * - * Initialized lazily to avoid requiring env vars at build time */ -export const supabase = new Proxy({} as ReturnType>, { - get(_target, prop) { - return Reflect.get(getSupabase(), prop) - }, -}) +export const supabase = createClient(supabaseUrl, supabaseAnonKey) diff --git a/packages/db/src/drizzle.ts b/packages/db/src/drizzle.ts index b990905b..f86e112e 100644 --- a/packages/db/src/drizzle.ts +++ b/packages/db/src/drizzle.ts @@ -1,38 +1,11 @@ import { drizzle } from 'drizzle-orm/postgres-js' -import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import * as schema from './schema' -let _db: PostgresJsDatabase | null = null -let _client: ReturnType | null = null +const connectionString = process.env.POSTGRES_URL! -function getDb() { - if (!_db) { - const postgresUrl = process.env.POSTGRES_URL +const client = postgres(connectionString, { prepare: false }) - if (!postgresUrl) { - throw new Error( - 'Missing POSTGRES_URL environment variable. Please configure it in your deployment settings or .env.local file.', - ) - } - - // Create postgres connection - _client = postgres(postgresUrl) - - // Create drizzle instance - _db = drizzle(_client, { schema }) - } - return _db -} - -/** - * Drizzle database instance - * Initialized lazily to avoid requiring env vars at build time - */ -export const db = new Proxy({} as PostgresJsDatabase, { - get(_target, prop) { - return Reflect.get(getDb(), prop) - }, -}) +export const db = drizzle({ client, schema }) export type Database = typeof db diff --git a/packages/db/src/server.ts b/packages/db/src/server.ts index 0c03c256..f42ccbc0 100644 --- a/packages/db/src/server.ts +++ b/packages/db/src/server.ts @@ -1,38 +1,17 @@ import { createClient } from '@supabase/supabase-js' import type { Database } from './types' -let _supabaseAdmin: ReturnType> | null = null - -function getSupabaseAdmin() { - if (!_supabaseAdmin) { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL - const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY - - if (!supabaseUrl || !supabaseServiceRoleKey) { - throw new Error( - 'Missing required environment variables: NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY. Please configure them in your deployment settings or .env.local file.' - ) - } - - _supabaseAdmin = createClient(supabaseUrl, supabaseServiceRoleKey, { - auth: { - persistSession: false, - autoRefreshToken: false, - }, - }) - } - return _supabaseAdmin -} +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL! +const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY! /** * Supabase client for server-side use with service role key * Bypasses Row Level Security (RLS) - use with caution * Always filter by user_id to enforce permissions - * - * Initialized lazily to avoid requiring env vars at build time */ -export const supabaseAdmin = new Proxy({} as ReturnType>, { - get(_target, prop) { - return Reflect.get(getSupabaseAdmin(), prop) +export const supabaseAdmin = createClient(supabaseUrl, supabaseServiceRoleKey, { + auth: { + persistSession: false, + autoRefreshToken: false, }, })