eny.space Landingpage
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(app): integrate authentication and subscription management

+1213 -35
+12 -1
.env.local.example
··· 1 + NEXT_PUBLIC_APP_URL=http://localhost:3000 2 + 1 3 # Stripe keys 2 4 # https://dashboard.stripe.com/apikeys 3 5 NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_12345 4 6 STRIPE_SECRET_KEY=sk_12345 5 - STRIPE_PAYMENT_DESCRIPTION='Software development services' 7 + STRIPE_PAYMENT_DESCRIPTION='Something descriptive' 6 8 # https://stripe.com/docs/webhooks/signatures 7 9 STRIPE_WEBHOOK_SECRET=whsec_1234 10 + 11 + # Supabase keys 12 + NEXT_PUBLIC_SUPABASE_URL=https://randomhash.supabase.co 13 + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_random-hash 14 + NEXT_PUBLIC_SUPABASE_ANON_KEY=anonkey 15 + SUPABASE_SERVICE_ROLE_KEY=somekey 16 + 17 + # https://dashboard.stripe.com/ 18 + NEXT_PUBLIC_STRIPE_PRICE_ID=price_randomhash
+66 -17
README.md
··· 2 2 3 3 your data, your space, use it enywhere. 4 4 5 - A full-stack TypeScript application using Next.js for processing hosting service purchases. 5 + A full-stack TypeScript application using Next.js with Supabase Auth and Stripe subscriptions for access-controlled hosting services. 6 6 7 7 ## Features 8 8 9 - - **Checkout** - Custom amount hosting service purchases with hosted checkout 10 - - **Payment Elements** - Custom payment form with Payment Element 11 - - **Webhook handling** - Server-side webhook processing for payment events 9 + - **Authentication** - Email-based authentication with Supabase Auth 10 + - **Subscriptions** - Stripe subscription checkout and management 11 + - **Dashboard** - User dashboard showing subscription status 12 + - **Protected API** - Server endpoints only accessible to subscribed users 13 + - **Webhook handling** - Server-side webhook processing for subscription events 12 14 13 15 ## Tech Stack 14 16 15 17 - **Frontend**: Next.js, React, TypeScript 16 18 - **Backend**: Next.js Server Actions and Route Handlers 19 + - **Auth**: Supabase Auth 20 + - **Database**: Supabase PostgreSQL 21 + - **Payments**: Stripe Subscriptions 17 22 18 23 ## Getting Started 19 24 20 25 ### Prerequisites 21 26 22 27 - Node.js 18+ installed 23 - - A payment processor account 28 + - A Supabase account and project 29 + - A Stripe account 24 30 25 31 ### Installation 26 32 ··· 34 40 pnpm install 35 41 ``` 36 42 37 - 2. Set up environment variables: 43 + 2. Set up Supabase: 44 + 45 + - Create a new Supabase project at [supabase.com](https://supabase.com) 46 + - Run the migration file to create the subscriptions table: 47 + - Go to your Supabase project dashboard 48 + - Navigate to SQL Editor 49 + - Copy and run the contents of `supabase/migrations/001_subscriptions.sql` 50 + 51 + 3. Set up environment variables: 38 52 39 53 Create a `.env.local` file in the root directory: 40 54 41 55 ```bash 56 + # Supabase 57 + NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url 58 + NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key 59 + SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key 60 + 61 + # Stripe 42 62 NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=your_publishable_key 43 63 STRIPE_SECRET_KEY=your_secret_key 44 64 STRIPE_WEBHOOK_SECRET=your_webhook_secret 65 + NEXT_PUBLIC_STRIPE_PRICE_ID=your_stripe_price_id 66 + 67 + # App URL (for redirects) 68 + NEXT_PUBLIC_APP_URL=http://localhost:3000 45 69 ``` 46 70 47 - Get your API keys from your payment processor dashboard. 71 + Get your Supabase keys from your project settings → API. 72 + Get your Stripe keys from your Stripe dashboard. 73 + Create a subscription product and price in Stripe, then use the price ID for `NEXT_PUBLIC_STRIPE_PRICE_ID`. 48 74 49 75 3. Start the development server: 50 76 ··· 62 88 63 89 #### Local Development 64 90 65 - 1. Install the payment processor CLI and link your account. 91 + 1. Install the Stripe CLI and link your account: 92 + 93 + ```bash 94 + stripe login 95 + ``` 66 96 67 97 2. Start webhook forwarding to your local server: 68 98 69 99 ```bash 70 - # Example command - adjust based on your payment processor 71 - webhook listen --forward-to localhost:3000/api/webhooks 100 + stripe listen --forward-to localhost:3000/api/webhooks 72 101 ``` 73 102 74 - 3. Copy the webhook secret from the CLI output and add it to your `.env.local` file. 103 + 3. Copy the webhook signing secret from the CLI output and add it to your `.env.local` file as `STRIPE_WEBHOOK_SECRET`. 75 104 76 105 #### Production 77 106 78 107 1. Deploy your application and copy the webhook URL (e.g., `https://your-domain.com/api/webhooks`). 79 108 80 - 2. Create a webhook endpoint in your payment processor dashboard. 109 + 2. In your Stripe dashboard, go to Developers → Webhooks and add an endpoint: 110 + - URL: `https://your-domain.com/api/webhooks` 111 + - Events to listen to: 112 + - `checkout.session.completed` 113 + - `customer.subscription.created` 114 + - `customer.subscription.updated` 115 + - `customer.subscription.deleted` 116 + - `invoice.payment_succeeded` 117 + - `invoice.payment_failed` 81 118 82 - 3. Add the webhook signing secret to your production environment variables. 119 + 3. Copy the webhook signing secret and add it to your production environment variables as `STRIPE_WEBHOOK_SECRET`. 83 120 84 121 ## Testing 85 122 ··· 97 134 ## Project Structure 98 135 99 136 - `app/` - Next.js app directory with pages and components 100 - - `app/actions/` - Server actions for payment operations 101 - - `app/api/webhooks/` - Webhook handler route 102 - - `lib/` - Payment processor client configuration 103 - - `components/` - React components for payment forms 137 + - `dashboard/` - User dashboard with subscription status and protected actions 138 + - `login/` - Login page 139 + - `signup/` - Sign up page 140 + - `actions/` - Server actions for auth and subscriptions 141 + - `api/` - API routes (webhooks, protected server endpoints) 142 + - `lib/` - Client configurations (Stripe, Supabase) 143 + - `supabase/migrations/` - Database migrations 144 + - `components/` - React components 104 145 - `utils/` - Utility functions 146 + 147 + ## How It Works 148 + 149 + 1. **Authentication**: Users sign up/login with email via Supabase Auth 150 + 2. **Subscription**: Users can subscribe via Stripe Checkout 151 + 3. **Webhook Sync**: Stripe webhooks update subscription status in Supabase database 152 + 4. **Access Control**: Dashboard shows subscription status and protected API buttons 153 + 5. **Protected Routes**: `/api/server/[endpoint]` routes check for active subscription before allowing access 105 154 106 155 ## Multi-Remote Git Setup 107 156
+57
app/actions/auth.ts
··· 1 + "use server"; 2 + 3 + import { revalidatePath } from "next/cache"; 4 + import { redirect } from "next/navigation"; 5 + import { createClient } from "@/lib/supabase/server"; 6 + 7 + export async function signUp(formData: FormData) { 8 + const supabase = await createClient(); 9 + 10 + const data = { 11 + email: formData.get("email") as string, 12 + password: formData.get("password") as string, 13 + }; 14 + 15 + const { error } = await supabase.auth.signUp({ 16 + email: data.email, 17 + password: data.password, 18 + options: { 19 + emailRedirectTo: `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/auth/callback`, 20 + }, 21 + }); 22 + 23 + if (error) { 24 + return { error: error.message }; 25 + } 26 + 27 + revalidatePath("/", "layout"); 28 + redirect("/dashboard"); 29 + } 30 + 31 + export async function signIn(formData: FormData) { 32 + const supabase = await createClient(); 33 + 34 + const data = { 35 + email: formData.get("email") as string, 36 + password: formData.get("password") as string, 37 + }; 38 + 39 + const { error } = await supabase.auth.signInWithPassword({ 40 + email: data.email, 41 + password: data.password, 42 + }); 43 + 44 + if (error) { 45 + return { error: error.message }; 46 + } 47 + 48 + revalidatePath("/", "layout"); 49 + redirect("/dashboard"); 50 + } 51 + 52 + export async function signOut() { 53 + const supabase = await createClient(); 54 + await supabase.auth.signOut(); 55 + revalidatePath("/", "layout"); 56 + redirect("/"); 57 + }
+170
app/actions/subscription.ts
··· 1 + "use server"; 2 + 3 + import { createClient } from "@/lib/supabase/server"; 4 + import { createAdminClient } from "@/lib/supabase/admin"; 5 + import { stripe } from "@/lib/stripe"; 6 + import { headers } from "next/headers"; 7 + import type { Stripe } from "stripe"; 8 + 9 + export async function getSubscriptionStatus() { 10 + const supabase = await createClient(); 11 + const { 12 + data: { user }, 13 + } = await supabase.auth.getUser(); 14 + 15 + if (!user) { 16 + return { subscribed: false, subscription: null }; 17 + } 18 + 19 + const { data: subscription } = await supabase 20 + .from("subscriptions") 21 + .select("*") 22 + .eq("user_id", user.id) 23 + .in("status", ["active", "trialing"]) 24 + .order("created_at", { ascending: false }) 25 + .limit(1) 26 + .maybeSingle(); 27 + 28 + return { 29 + subscribed: !!subscription && (subscription.status === "active" || subscription.status === "trialing"), 30 + subscription, 31 + }; 32 + } 33 + 34 + export async function syncSubscriptionFromCheckoutSession(sessionId: string) { 35 + const supabase = await createClient(); 36 + const { 37 + data: { user }, 38 + } = await supabase.auth.getUser(); 39 + 40 + if (!user) { 41 + return { success: false, error: "Not authenticated" }; 42 + } 43 + 44 + try { 45 + // Retrieve the checkout session from Stripe 46 + const session = await stripe.checkout.sessions.retrieve(sessionId, { 47 + expand: ["subscription"], 48 + }); 49 + 50 + // SECURITY: Verify the session belongs to this user 51 + if (session.metadata?.user_id !== user.id) { 52 + console.error(`Session ${sessionId} does not belong to user ${user.id}`); 53 + return { success: false, error: "Session does not belong to this user" }; 54 + } 55 + 56 + // SECURITY: Verify payment was successful 57 + if (session.payment_status !== "paid") { 58 + return { success: false, error: "Payment not completed" }; 59 + } 60 + 61 + if (session.mode === "subscription" && session.subscription) { 62 + const subscription = typeof session.subscription === "string" 63 + ? await stripe.subscriptions.retrieve(session.subscription) 64 + : session.subscription; 65 + 66 + // SECURITY: Verify subscription customer matches session customer 67 + if (subscription.customer !== session.customer) { 68 + return { success: false, error: "Subscription customer mismatch" }; 69 + } 70 + 71 + // Use admin client to bypass RLS (this is a validated update from Stripe) 72 + // If admin client not available, we can't update (security: prevents user manipulation) 73 + let supabaseClient; 74 + try { 75 + supabaseClient = createAdminClient(); 76 + } catch (error) { 77 + console.error("Admin client required for subscription sync:", error); 78 + return { 79 + success: false, 80 + error: "Server configuration error. Please contact support." 81 + }; 82 + } 83 + 84 + const { error: dbError } = await supabaseClient.from("subscriptions").upsert({ 85 + user_id: user.id, 86 + stripe_customer_id: subscription.customer as string, 87 + stripe_subscription_id: subscription.id, 88 + stripe_price_id: subscription.items.data[0]?.price.id, 89 + status: subscription.status, 90 + current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), 91 + current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), 92 + cancel_at_period_end: subscription.cancel_at_period_end, 93 + }); 94 + 95 + if (dbError) { 96 + console.error("Database error:", dbError); 97 + return { success: false, error: "Failed to sync subscription" }; 98 + } 99 + 100 + return { success: true }; 101 + } 102 + 103 + return { success: false, error: "Not a subscription session" }; 104 + } catch (error) { 105 + console.error("Error syncing subscription:", error); 106 + return { success: false, error: error instanceof Error ? error.message : "Unknown error" }; 107 + } 108 + } 109 + 110 + export async function createSubscriptionCheckout(priceId: string) { 111 + const supabase = await createClient(); 112 + const { 113 + data: { user }, 114 + } = await supabase.auth.getUser(); 115 + 116 + if (!user) { 117 + throw new Error("User must be authenticated"); 118 + } 119 + 120 + // Get or create Stripe customer 121 + let customerId: string; 122 + const { data: existingSubscription } = await supabase 123 + .from("subscriptions") 124 + .select("stripe_customer_id") 125 + .eq("user_id", user.id) 126 + .maybeSingle(); 127 + 128 + if (existingSubscription?.stripe_customer_id) { 129 + customerId = existingSubscription.stripe_customer_id; 130 + } else { 131 + const customer = await stripe.customers.create({ 132 + email: user.email!, 133 + metadata: { 134 + supabase_user_id: user.id, 135 + }, 136 + }); 137 + customerId = customer.id; 138 + 139 + // Store customer ID in database (users can now insert their own records) 140 + await supabase.from("subscriptions").upsert({ 141 + user_id: user.id, 142 + stripe_customer_id: customerId, 143 + status: "incomplete", 144 + }); 145 + } 146 + 147 + const headersList = await headers(); 148 + const originHeader = headersList.get("origin"); 149 + const hostHeader = headersList.get("host"); 150 + const origin = originHeader || `https://${hostHeader}` || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; 151 + 152 + const checkoutSession = await stripe.checkout.sessions.create({ 153 + customer: customerId, 154 + mode: "subscription", 155 + payment_method_types: ["card"], 156 + line_items: [ 157 + { 158 + price: priceId, 159 + quantity: 1, 160 + }, 161 + ], 162 + success_url: `${origin}/dashboard?session_id={CHECKOUT_SESSION_ID}`, 163 + cancel_url: `${origin}/dashboard`, 164 + metadata: { 165 + user_id: user.id, 166 + }, 167 + }); 168 + 169 + return { url: checkoutSession.url }; 170 + }
+62
app/api/server/[endpoint]/route.ts
··· 1 + import { NextResponse } from "next/server"; 2 + import { createClient } from "@/lib/supabase/server"; 3 + import { getSubscriptionStatus } from "@/actions/subscription"; 4 + 5 + export async function POST( 6 + req: Request, 7 + { params }: { params: Promise<{ endpoint: string }> } 8 + ) { 9 + const supabase = await createClient(); 10 + const { 11 + data: { user }, 12 + } = await supabase.auth.getUser(); 13 + 14 + if (!user) { 15 + return NextResponse.json( 16 + { message: "Unauthorized" }, 17 + { status: 401 } 18 + ); 19 + } 20 + 21 + const { subscribed } = await getSubscriptionStatus(); 22 + 23 + if (!subscribed) { 24 + return NextResponse.json( 25 + { message: "Subscription required" }, 26 + { status: 403 } 27 + ); 28 + } 29 + 30 + const { endpoint } = await params; 31 + 32 + // Here you would make the actual call to your other server 33 + // For now, this is a placeholder that you can customize 34 + try { 35 + // Example: Make a call to your external server 36 + // const externalServerUrl = process.env.EXTERNAL_SERVER_URL; 37 + // const response = await fetch(`${externalServerUrl}/${endpoint}`, { 38 + // method: "POST", 39 + // headers: { 40 + // "Authorization": `Bearer ${userToken}`, 41 + // "Content-Type": "application/json", 42 + // }, 43 + // body: JSON.stringify({ userId: user.id }), 44 + // }); 45 + // const data = await response.json(); 46 + 47 + // Placeholder response 48 + return NextResponse.json({ 49 + success: true, 50 + endpoint, 51 + message: `Server call to ${endpoint} successful`, 52 + userId: user.id, 53 + timestamp: new Date().toISOString(), 54 + }); 55 + } catch (error) { 56 + console.error("Error making server call:", error); 57 + return NextResponse.json( 58 + { message: "Failed to make server call", error: error instanceof Error ? error.message : "Unknown error" }, 59 + { status: 500 } 60 + ); 61 + } 62 + }
+147 -15
app/api/webhooks/route.ts
··· 3 3 import { NextResponse } from "next/server"; 4 4 5 5 import { stripe } from "@/lib/stripe"; 6 + import { createAdminClient } from "@/lib/supabase/admin"; 6 7 7 8 export async function POST(req: Request) { 8 9 let event: Stripe.Event; ··· 27 28 // Successfully constructed event. 28 29 console.log("✅ Success:", event.id); 29 30 31 + const supabase = createAdminClient(); 32 + 30 33 const permittedEvents: string[] = [ 31 34 "checkout.session.completed", 32 - "payment_intent.succeeded", 33 - "payment_intent.payment_failed", 35 + "customer.subscription.created", 36 + "customer.subscription.updated", 37 + "customer.subscription.deleted", 38 + "invoice.payment_succeeded", 39 + "invoice.payment_failed", 34 40 ]; 35 41 36 42 if (permittedEvents.includes(event.type)) { 37 - let data; 38 - 39 43 try { 40 44 switch (event.type) { 41 - case "checkout.session.completed": 42 - data = event.data.object as Stripe.Checkout.Session; 43 - console.log(`💰 CheckoutSession status: ${data.payment_status}`); 45 + case "checkout.session.completed": { 46 + const session = event.data.object as Stripe.Checkout.Session; 47 + console.log(`💰 CheckoutSession completed: ${session.id}`); 48 + 49 + if (session.mode === "subscription" && session.subscription) { 50 + const subscription = await stripe.subscriptions.retrieve( 51 + session.subscription as string, 52 + { expand: ["items.data.price.product"] } 53 + ); 54 + 55 + const userId = session.metadata?.user_id; 56 + if (userId) { 57 + const { error } = await supabase.from("subscriptions").upsert({ 58 + user_id: userId, 59 + stripe_customer_id: subscription.customer as string, 60 + stripe_subscription_id: subscription.id, 61 + stripe_price_id: subscription.items.data[0]?.price.id, 62 + status: subscription.status, 63 + current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), 64 + current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), 65 + cancel_at_period_end: subscription.cancel_at_period_end, 66 + }); 67 + 68 + if (error) { 69 + console.error("Error upserting subscription:", error); 70 + } else { 71 + console.log(`✅ Subscription synced for user ${userId}`); 72 + } 73 + } else { 74 + console.warn("No user_id in checkout session metadata"); 75 + } 76 + } 44 77 break; 45 - case "payment_intent.payment_failed": 46 - data = event.data.object as Stripe.PaymentIntent; 47 - console.log(`❌ Payment failed: ${data.last_payment_error?.message}`); 78 + } 79 + 80 + case "customer.subscription.created": 81 + case "customer.subscription.updated": { 82 + const subscription = event.data.object as Stripe.Subscription; 83 + console.log(`📦 Subscription ${event.type}: ${subscription.id}`); 84 + 85 + // Find user by customer ID 86 + const { data: existing } = await supabase 87 + .from("subscriptions") 88 + .select("user_id") 89 + .eq("stripe_customer_id", subscription.customer as string) 90 + .single(); 91 + 92 + if (existing?.user_id) { 93 + const { error } = await supabase.from("subscriptions").upsert({ 94 + user_id: existing.user_id, 95 + stripe_customer_id: subscription.customer as string, 96 + stripe_subscription_id: subscription.id, 97 + stripe_price_id: subscription.items.data[0]?.price.id, 98 + status: subscription.status, 99 + current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), 100 + current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), 101 + cancel_at_period_end: subscription.cancel_at_period_end, 102 + }); 103 + 104 + if (error) { 105 + console.error("Error upserting subscription:", error); 106 + } 107 + } else { 108 + // Try to find user by customer metadata 109 + const customer = await stripe.customers.retrieve(subscription.customer as string); 110 + if (customer && !customer.deleted && customer.metadata?.supabase_user_id) { 111 + const { error } = await supabase.from("subscriptions").upsert({ 112 + user_id: customer.metadata.supabase_user_id, 113 + stripe_customer_id: subscription.customer as string, 114 + stripe_subscription_id: subscription.id, 115 + stripe_price_id: subscription.items.data[0]?.price.id, 116 + status: subscription.status, 117 + current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), 118 + current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), 119 + cancel_at_period_end: subscription.cancel_at_period_end, 120 + }); 121 + 122 + if (error) { 123 + console.error("Error upserting subscription:", error); 124 + } 125 + } 126 + } 48 127 break; 49 - case "payment_intent.succeeded": 50 - data = event.data.object as Stripe.PaymentIntent; 51 - console.log(`💰 PaymentIntent status: ${data.status}`); 128 + } 129 + 130 + case "customer.subscription.deleted": { 131 + const subscription = event.data.object as Stripe.Subscription; 132 + console.log(`🗑️ Subscription deleted: ${subscription.id}`); 133 + 134 + await supabase 135 + .from("subscriptions") 136 + .update({ status: "canceled" }) 137 + .eq("stripe_subscription_id", subscription.id); 138 + break; 139 + } 140 + 141 + case "invoice.payment_succeeded": { 142 + const invoice = event.data.object as Stripe.Invoice; 143 + console.log(`💳 Invoice payment succeeded: ${invoice.id}`); 144 + 145 + if (invoice.subscription) { 146 + const subscription = await stripe.subscriptions.retrieve( 147 + invoice.subscription as string 148 + ); 149 + 150 + const { data: existing } = await supabase 151 + .from("subscriptions") 152 + .select("user_id") 153 + .eq("stripe_subscription_id", subscription.id) 154 + .single(); 155 + 156 + if (existing?.user_id) { 157 + await supabase.from("subscriptions").upsert({ 158 + user_id: existing.user_id, 159 + stripe_customer_id: subscription.customer as string, 160 + stripe_subscription_id: subscription.id, 161 + stripe_price_id: subscription.items.data[0]?.price.id, 162 + status: subscription.status, 163 + current_period_start: new Date(subscription.current_period_start * 1000).toISOString(), 164 + current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), 165 + cancel_at_period_end: subscription.cancel_at_period_end, 166 + }); 167 + } 168 + } 52 169 break; 170 + } 171 + 172 + case "invoice.payment_failed": { 173 + const invoice = event.data.object as Stripe.Invoice; 174 + console.log(`❌ Invoice payment failed: ${invoice.id}`); 175 + 176 + if (invoice.subscription) { 177 + await supabase 178 + .from("subscriptions") 179 + .update({ status: "past_due" }) 180 + .eq("stripe_subscription_id", invoice.subscription as string); 181 + } 182 + break; 183 + } 184 + 53 185 default: 54 - throw new Error(`Unhandled event: ${event.type}`); 186 + console.log(`Unhandled event type: ${event.type}`); 55 187 } 56 188 } catch (error) { 57 - console.log(error); 189 + console.error("Webhook handler error:", error); 58 190 return NextResponse.json( 59 191 { message: "Webhook handler failed" }, 60 192 { status: 500 },
+15
app/auth/callback/route.ts
··· 1 + import { createClient } from "@/lib/supabase/server"; 2 + import { NextResponse } from "next/server"; 3 + 4 + export async function GET(request: Request) { 5 + const requestUrl = new URL(request.url); 6 + const code = requestUrl.searchParams.get("code"); 7 + const next = requestUrl.searchParams.get("next") || "/dashboard"; 8 + 9 + if (code) { 10 + const supabase = await createClient(); 11 + await supabase.auth.exchangeCodeForSession(code); 12 + } 13 + 14 + return NextResponse.redirect(new URL(next, request.url)); 15 + }
+136
app/dashboard/dashboard-client.tsx
··· 1 + "use client"; 2 + 3 + import { useState } from "react"; 4 + import { createSubscriptionCheckout } from "@/actions/subscription"; 5 + 6 + interface DashboardClientProps { 7 + subscribed: boolean; 8 + subscription: any; 9 + priceId: string; 10 + } 11 + 12 + export default function DashboardClient({ subscribed, subscription, priceId }: DashboardClientProps) { 13 + const [loading, setLoading] = useState(false); 14 + 15 + const handleSubscribe = async () => { 16 + if (!priceId) { 17 + alert("Stripe price ID not configured. Please set NEXT_PUBLIC_STRIPE_PRICE_ID in your environment variables."); 18 + return; 19 + } 20 + 21 + setLoading(true); 22 + try { 23 + const { url } = await createSubscriptionCheckout(priceId); 24 + if (url) { 25 + window.location.href = url; 26 + } 27 + } catch (error) { 28 + console.error("Error creating checkout:", error); 29 + alert("Failed to create checkout session. Please try again."); 30 + } finally { 31 + setLoading(false); 32 + } 33 + }; 34 + 35 + const handleServerCall = async (endpoint: string) => { 36 + try { 37 + const response = await fetch(`/api/server/${endpoint}`, { 38 + method: "POST", 39 + }); 40 + 41 + if (!response.ok) { 42 + const error = await response.json(); 43 + throw new Error(error.message || "Failed to make server call"); 44 + } 45 + 46 + const data = await response.json(); 47 + alert(`Success: ${JSON.stringify(data, null, 2)}`); 48 + } catch (error) { 49 + console.error("Error making server call:", error); 50 + alert(`Error: ${error instanceof Error ? error.message : "Unknown error"}`); 51 + } 52 + }; 53 + 54 + if (!subscribed) { 55 + return ( 56 + <div style={{ marginTop: "32px", padding: "24px", border: "1px solid #ccc", borderRadius: "8px" }}> 57 + <h2>Subscribe to Access</h2> 58 + <p>You need an active subscription to access the server features.</p> 59 + <button 60 + onClick={handleSubscribe} 61 + disabled={loading} 62 + style={{ 63 + marginTop: "16px", 64 + padding: "12px 24px", 65 + borderRadius: "6px", 66 + backgroundColor: "#000000", 67 + color: "#ffffff", 68 + border: "1px solid #ffffff", 69 + cursor: loading ? "not-allowed" : "pointer", 70 + fontWeight: 600, 71 + opacity: loading ? 0.6 : 1, 72 + }} 73 + > 74 + {loading ? "Loading..." : "Subscribe Now"} 75 + </button> 76 + </div> 77 + ); 78 + } 79 + 80 + return ( 81 + <div style={{ marginTop: "32px" }}> 82 + <div style={{ padding: "24px", border: "1px solid #4caf50", borderRadius: "8px", backgroundColor: "#f0f9f0", marginBottom: "24px" }}> 83 + <h2 style={{ color: "#4caf50", marginTop: 0 }}>✓ Active Subscription</h2> 84 + {subscription && ( 85 + <div> 86 + <p> 87 + <strong>Status:</strong> {subscription.status} 88 + </p> 89 + {subscription.current_period_end && ( 90 + <p> 91 + <strong>Renews:</strong> {new Date(subscription.current_period_end).toLocaleDateString()} 92 + </p> 93 + )} 94 + </div> 95 + )} 96 + </div> 97 + 98 + <div style={{ padding: "24px", border: "1px solid #ccc", borderRadius: "8px" }}> 99 + <h2>Server Actions</h2> 100 + <p>You have access to the following server endpoints:</p> 101 + 102 + <div style={{ display: "flex", flexDirection: "column", gap: "12px", marginTop: "16px" }}> 103 + <button 104 + onClick={() => handleServerCall("action1")} 105 + style={{ 106 + padding: "12px 24px", 107 + borderRadius: "6px", 108 + backgroundColor: "#000000", 109 + color: "#ffffff", 110 + border: "1px solid #ffffff", 111 + cursor: "pointer", 112 + fontWeight: 600, 113 + }} 114 + > 115 + Call Server Action 1 116 + </button> 117 + 118 + <button 119 + onClick={() => handleServerCall("action2")} 120 + style={{ 121 + padding: "12px 24px", 122 + borderRadius: "6px", 123 + backgroundColor: "#000000", 124 + color: "#ffffff", 125 + border: "1px solid #ffffff", 126 + cursor: "pointer", 127 + fontWeight: 600, 128 + }} 129 + > 130 + Call Server Action 2 131 + </button> 132 + </div> 133 + </div> 134 + </div> 135 + ); 136 + }
+41
app/dashboard/page.tsx
··· 1 + import { redirect } from "next/navigation"; 2 + import { createClient } from "@/lib/supabase/server"; 3 + import { getSubscriptionStatus, syncSubscriptionFromCheckoutSession } from "@/actions/subscription"; 4 + import DashboardClient from "./dashboard-client"; 5 + 6 + export default async function DashboardPage({ 7 + searchParams, 8 + }: { 9 + searchParams: Promise<{ session_id?: string }>; 10 + }) { 11 + const supabase = await createClient(); 12 + const { 13 + data: { user }, 14 + } = await supabase.auth.getUser(); 15 + 16 + if (!user) { 17 + redirect("/login"); 18 + } 19 + 20 + const params = await searchParams; 21 + 22 + // If we have a session_id, try to sync the subscription (fallback if webhook hasn't fired) 23 + if (params.session_id) { 24 + await syncSubscriptionFromCheckoutSession(params.session_id); 25 + } 26 + 27 + const { subscribed, subscription } = await getSubscriptionStatus(); 28 + 29 + return ( 30 + <main className="page-container"> 31 + <h1>Dashboard</h1> 32 + <p>Welcome, {user.email}!</p> 33 + 34 + <DashboardClient 35 + subscribed={subscribed} 36 + subscription={subscription} 37 + priceId={process.env.NEXT_PUBLIC_STRIPE_PRICE_ID || ""} 38 + /> 39 + </main> 40 + ); 41 + }
+40 -2
app/layout.tsx
··· 2 2 import { SpeedInsights } from "@vercel/speed-insights/next"; 3 3 import { Analytics } from "@vercel/analytics/next"; 4 4 import { Space_Grotesk } from "next/font/google"; 5 + import Link from "next/link"; 6 + import { createClient } from "@/lib/supabase/server"; 7 + import { signOut } from "@/actions/auth"; 5 8 6 9 import "./globals.css"; 7 10 ··· 25 28 }, 26 29 }; 27 30 28 - export default function RootLayout({ children }: LayoutProps) { 31 + export default async function RootLayout({ children }: LayoutProps) { 32 + const supabase = await createClient(); 33 + const { 34 + data: { user }, 35 + } = await supabase.auth.getUser(); 36 + 29 37 return ( 30 38 <html lang="en"> 31 39 <body className={fontSans.className}> 32 40 <div className="container"> 33 41 <header> 34 42 <div className="header-content"> 35 - <h1>eny.space</h1> 43 + <Link href="/" style={{ textDecoration: "none", color: "inherit" }}> 44 + <h1>eny.space</h1> 45 + </Link> 46 + <nav style={{ display: "flex", flexDirection: "column", gap: "12px", marginTop: "24px" }}> 47 + {user ? ( 48 + <> 49 + <Link href="/dashboard" style={{ color: "var(--h2-color)" }}>Dashboard</Link> 50 + <form action={signOut} style={{ margin: 0 }}> 51 + <button 52 + type="submit" 53 + style={{ 54 + background: "none", 55 + border: "none", 56 + cursor: "pointer", 57 + color: "var(--h2-color)", 58 + padding: 0, 59 + textAlign: "left", 60 + font: "inherit" 61 + }} 62 + > 63 + Sign Out 64 + </button> 65 + </form> 66 + </> 67 + ) : ( 68 + <> 69 + <Link href="/login" style={{ color: "var(--h2-color)" }}>Login</Link> 70 + <Link href="/signup" style={{ color: "var(--h2-color)" }}>Sign Up</Link> 71 + </> 72 + )} 73 + </nav> 36 74 </div> 37 75 </header> 38 76 {children}
+63
app/login/page.tsx
··· 1 + import { signIn } from "@/actions/auth"; 2 + import Link from "next/link"; 3 + 4 + export default function LoginPage() { 5 + return ( 6 + <main className="page-container" style={{ maxWidth: "400px", margin: "0 auto" }}> 7 + <h1>Login</h1> 8 + <form action={signIn} style={{ display: "flex", flexDirection: "column", gap: "16px" }}> 9 + <div> 10 + <label htmlFor="email" style={{ display: "block", marginBottom: "8px" }}> 11 + Email 12 + </label> 13 + <input 14 + type="email" 15 + id="email" 16 + name="email" 17 + required 18 + style={{ 19 + width: "100%", 20 + padding: "8px", 21 + borderRadius: "4px", 22 + border: "1px solid #ccc", 23 + }} 24 + /> 25 + </div> 26 + <div> 27 + <label htmlFor="password" style={{ display: "block", marginBottom: "8px" }}> 28 + Password 29 + </label> 30 + <input 31 + type="password" 32 + id="password" 33 + name="password" 34 + required 35 + style={{ 36 + width: "100%", 37 + padding: "8px", 38 + borderRadius: "4px", 39 + border: "1px solid #ccc", 40 + }} 41 + /> 42 + </div> 43 + <button 44 + type="submit" 45 + style={{ 46 + padding: "12px 24px", 47 + borderRadius: "6px", 48 + backgroundColor: "#000000", 49 + color: "#ffffff", 50 + border: "1px solid #ffffff", 51 + cursor: "pointer", 52 + fontWeight: 600, 53 + }} 54 + > 55 + Login 56 + </button> 57 + </form> 58 + <p style={{ marginTop: "16px", textAlign: "center" }}> 59 + Don&apos;t have an account? <Link href="/signup">Sign up</Link> 60 + </p> 61 + </main> 62 + ); 63 + }
+64
app/signup/page.tsx
··· 1 + import { signUp } from "@/actions/auth"; 2 + import Link from "next/link"; 3 + 4 + export default function SignUpPage() { 5 + return ( 6 + <main className="page-container" style={{ maxWidth: "400px", margin: "0 auto" }}> 7 + <h1>Sign Up</h1> 8 + <form action={signUp} style={{ display: "flex", flexDirection: "column", gap: "16px" }}> 9 + <div> 10 + <label htmlFor="email" style={{ display: "block", marginBottom: "8px" }}> 11 + Email 12 + </label> 13 + <input 14 + type="email" 15 + id="email" 16 + name="email" 17 + required 18 + style={{ 19 + width: "100%", 20 + padding: "8px", 21 + borderRadius: "4px", 22 + border: "1px solid #ccc", 23 + }} 24 + /> 25 + </div> 26 + <div> 27 + <label htmlFor="password" style={{ display: "block", marginBottom: "8px" }}> 28 + Password 29 + </label> 30 + <input 31 + type="password" 32 + id="password" 33 + name="password" 34 + required 35 + minLength={6} 36 + style={{ 37 + width: "100%", 38 + padding: "8px", 39 + borderRadius: "4px", 40 + border: "1px solid #ccc", 41 + }} 42 + /> 43 + </div> 44 + <button 45 + type="submit" 46 + style={{ 47 + padding: "12px 24px", 48 + borderRadius: "6px", 49 + backgroundColor: "#000000", 50 + color: "#ffffff", 51 + border: "1px solid #ffffff", 52 + cursor: "pointer", 53 + fontWeight: 600, 54 + }} 55 + > 56 + Sign Up 57 + </button> 58 + </form> 59 + <p style={{ marginTop: "16px", textAlign: "center" }}> 60 + Already have an account? <Link href="/login">Login</Link> 61 + </p> 62 + </main> 63 + ); 64 + }
+21
lib/supabase/admin.ts
··· 1 + import { createClient } from "@supabase/supabase-js"; 2 + 3 + // Admin client for server-side operations that bypass RLS 4 + // Use this ONLY in server-side code that needs to bypass RLS (like webhooks) 5 + export function createAdminClient() { 6 + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; 7 + const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; 8 + 9 + if (!supabaseUrl || !supabaseServiceKey) { 10 + throw new Error( 11 + "Missing Supabase admin credentials. Please set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in your environment variables." 12 + ); 13 + } 14 + 15 + return createClient(supabaseUrl, supabaseServiceKey, { 16 + auth: { 17 + autoRefreshToken: false, 18 + persistSession: false, 19 + }, 20 + }); 21 + }
+8
lib/supabase/client.ts
··· 1 + import { createBrowserClient } from "@supabase/ssr"; 2 + 3 + export function createClient() { 4 + return createBrowserClient( 5 + process.env.NEXT_PUBLIC_SUPABASE_URL!, 6 + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! 7 + ); 8 + }
+29
lib/supabase/server.ts
··· 1 + import { createServerClient } from "@supabase/ssr"; 2 + import { cookies } from "next/headers"; 3 + 4 + export async function createClient() { 5 + const cookieStore = await cookies(); 6 + 7 + return createServerClient( 8 + process.env.NEXT_PUBLIC_SUPABASE_URL!, 9 + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 10 + { 11 + cookies: { 12 + getAll() { 13 + return cookieStore.getAll(); 14 + }, 15 + setAll(cookiesToSet) { 16 + try { 17 + cookiesToSet.forEach(({ name, value, options }) => 18 + cookieStore.set(name, value, options) 19 + ); 20 + } catch { 21 + // The `setAll` method was called from a Server Component. 22 + // This can be ignored if you have middleware refreshing 23 + // user sessions. 24 + } 25 + }, 26 + }, 27 + } 28 + ); 29 + }
+152
package-lock.json
··· 8 8 "@radix-ui/react-slot": "^1.2.4", 9 9 "@stripe/react-stripe-js": "2.4.0", 10 10 "@stripe/stripe-js": "2.2.2", 11 + "@supabase/ssr": "^0.8.0", 12 + "@supabase/supabase-js": "^2.90.1", 11 13 "@tailwindcss/postcss": "^4.1.18", 12 14 "@vercel/analytics": "^1.6.1", 13 15 "@vercel/speed-insights": "^1.3.1", ··· 750 752 "integrity": "sha512-LvFZRZEBoMe6vXC6RoOAIbXWo/0JDdndq43ekL9M6affcM7PtF5KALmwt91BazW7q49sbSl0l7TunWhhSwEW4w==", 751 753 "license": "MIT" 752 754 }, 755 + "node_modules/@supabase/auth-js": { 756 + "version": "2.90.1", 757 + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.90.1.tgz", 758 + "integrity": "sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng==", 759 + "license": "MIT", 760 + "dependencies": { 761 + "tslib": "2.8.1" 762 + }, 763 + "engines": { 764 + "node": ">=20.0.0" 765 + } 766 + }, 767 + "node_modules/@supabase/functions-js": { 768 + "version": "2.90.1", 769 + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.90.1.tgz", 770 + "integrity": "sha512-x9mV9dF1Lam9qL3zlpP6mSM5C9iqMPtF5B/tU1Jj/F0ufX5mjDf9ghVBaErVxmrQJRL4+iMKWKY2GnODkpS8tw==", 771 + "license": "MIT", 772 + "dependencies": { 773 + "tslib": "2.8.1" 774 + }, 775 + "engines": { 776 + "node": ">=20.0.0" 777 + } 778 + }, 779 + "node_modules/@supabase/postgrest-js": { 780 + "version": "2.90.1", 781 + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.90.1.tgz", 782 + "integrity": "sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ==", 783 + "license": "MIT", 784 + "dependencies": { 785 + "tslib": "2.8.1" 786 + }, 787 + "engines": { 788 + "node": ">=20.0.0" 789 + } 790 + }, 791 + "node_modules/@supabase/realtime-js": { 792 + "version": "2.90.1", 793 + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.90.1.tgz", 794 + "integrity": "sha512-PWbnEMkcQRuor8jhObp4+Snufkq8C6fBp+MchVp2qBPY1NXk/c3Iv3YyiFYVzo0Dzuw4nAlT4+ahuPggy4r32w==", 795 + "license": "MIT", 796 + "dependencies": { 797 + "@types/phoenix": "^1.6.6", 798 + "@types/ws": "^8.18.1", 799 + "tslib": "2.8.1", 800 + "ws": "^8.18.2" 801 + }, 802 + "engines": { 803 + "node": ">=20.0.0" 804 + } 805 + }, 806 + "node_modules/@supabase/ssr": { 807 + "version": "0.8.0", 808 + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.8.0.tgz", 809 + "integrity": "sha512-/PKk8kNFSs8QvvJ2vOww1mF5/c5W8y42duYtXvkOSe+yZKRgTTZywYG2l41pjhNomqESZCpZtXuWmYjFRMV+dw==", 810 + "license": "MIT", 811 + "dependencies": { 812 + "cookie": "^1.0.2" 813 + }, 814 + "peerDependencies": { 815 + "@supabase/supabase-js": "^2.76.1" 816 + } 817 + }, 818 + "node_modules/@supabase/storage-js": { 819 + "version": "2.90.1", 820 + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.90.1.tgz", 821 + "integrity": "sha512-GHY+Ps/K/RBfRj7kwx+iVf2HIdqOS43rM2iDOIDpapyUnGA9CCBFzFV/XvfzznGykd//z2dkGZhlZZprsVFqGg==", 822 + "license": "MIT", 823 + "dependencies": { 824 + "iceberg-js": "^0.8.1", 825 + "tslib": "2.8.1" 826 + }, 827 + "engines": { 828 + "node": ">=20.0.0" 829 + } 830 + }, 831 + "node_modules/@supabase/supabase-js": { 832 + "version": "2.90.1", 833 + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.90.1.tgz", 834 + "integrity": "sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w==", 835 + "license": "MIT", 836 + "dependencies": { 837 + "@supabase/auth-js": "2.90.1", 838 + "@supabase/functions-js": "2.90.1", 839 + "@supabase/postgrest-js": "2.90.1", 840 + "@supabase/realtime-js": "2.90.1", 841 + "@supabase/storage-js": "2.90.1" 842 + }, 843 + "engines": { 844 + "node": ">=20.0.0" 845 + } 846 + }, 753 847 "node_modules/@swc/helpers": { 754 848 "version": "0.5.15", 755 849 "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", ··· 1021 1115 "integrity": "sha512-q0RkvNgMweWWIvSMDiXhflGUKMdIxBo2M2tYM/0kEGDueQByFzK4KZAgu5YHGFNxziTlppNpTIBcqHQAxlfHdA==", 1022 1116 "license": "MIT" 1023 1117 }, 1118 + "node_modules/@types/phoenix": { 1119 + "version": "1.6.7", 1120 + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", 1121 + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", 1122 + "license": "MIT" 1123 + }, 1024 1124 "node_modules/@types/prop-types": { 1025 1125 "version": "15.7.15", 1026 1126 "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", ··· 1047 1147 "devOptional": true, 1048 1148 "license": "MIT" 1049 1149 }, 1150 + "node_modules/@types/ws": { 1151 + "version": "8.18.1", 1152 + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", 1153 + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", 1154 + "license": "MIT", 1155 + "dependencies": { 1156 + "@types/node": "*" 1157 + } 1158 + }, 1050 1159 "node_modules/@vercel/analytics": { 1051 1160 "version": "1.6.1", 1052 1161 "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.6.1.tgz", ··· 1195 1304 "node": ">=6" 1196 1305 } 1197 1306 }, 1307 + "node_modules/cookie": { 1308 + "version": "1.1.1", 1309 + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", 1310 + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", 1311 + "license": "MIT", 1312 + "engines": { 1313 + "node": ">=18" 1314 + }, 1315 + "funding": { 1316 + "type": "opencollective", 1317 + "url": "https://opencollective.com/express" 1318 + } 1319 + }, 1198 1320 "node_modules/csstype": { 1199 1321 "version": "3.2.3", 1200 1322 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", ··· 1354 1476 }, 1355 1477 "engines": { 1356 1478 "node": ">= 0.4" 1479 + } 1480 + }, 1481 + "node_modules/iceberg-js": { 1482 + "version": "0.8.1", 1483 + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", 1484 + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", 1485 + "license": "MIT", 1486 + "engines": { 1487 + "node": ">=20.0.0" 1357 1488 } 1358 1489 }, 1359 1490 "node_modules/jiti": { ··· 2116 2247 }, 2117 2248 "engines": { 2118 2249 "node": ">=14.17" 2250 + } 2251 + }, 2252 + "node_modules/ws": { 2253 + "version": "8.19.0", 2254 + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", 2255 + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", 2256 + "license": "MIT", 2257 + "engines": { 2258 + "node": ">=10.0.0" 2259 + }, 2260 + "peerDependencies": { 2261 + "bufferutil": "^4.0.1", 2262 + "utf-8-validate": ">=5.0.2" 2263 + }, 2264 + "peerDependenciesMeta": { 2265 + "bufferutil": { 2266 + "optional": true 2267 + }, 2268 + "utf-8-validate": { 2269 + "optional": true 2270 + } 2119 2271 } 2120 2272 } 2121 2273 }
+2
package.json
··· 9 9 "@radix-ui/react-slot": "^1.2.4", 10 10 "@stripe/react-stripe-js": "2.4.0", 11 11 "@stripe/stripe-js": "2.2.2", 12 + "@supabase/ssr": "^0.8.0", 13 + "@supabase/supabase-js": "^2.90.1", 12 14 "@tailwindcss/postcss": "^4.1.18", 13 15 "@vercel/analytics": "^1.6.1", 14 16 "@vercel/speed-insights": "^1.3.1",
+1
supabase/.temp/cli-latest
··· 1 + v2.67.1
+1
supabase/.temp/gotrue-version
··· 1 + v2.185.0
+1
supabase/.temp/pooler-url
··· 1 + postgresql://postgres.espdzfmvhthcskxzrnyq@aws-1-eu-west-1.pooler.supabase.com:5432/postgres
+1
supabase/.temp/postgres-version
··· 1 + 17.6.1.063
+1
supabase/.temp/project-ref
··· 1 + espdzfmvhthcskxzrnyq
+1
supabase/.temp/rest-version
··· 1 + v14.1
+1
supabase/.temp/storage-migration
··· 1 + buckets-objects-grants-postgres
+1
supabase/.temp/storage-version
··· 1 + v1.33.0
+65
supabase/migrations/001_subscriptions.sql
··· 1 + -- Create subscriptions table to track user subscriptions 2 + CREATE TABLE IF NOT EXISTS subscriptions ( 3 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 4 + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, 5 + stripe_customer_id TEXT, 6 + stripe_subscription_id TEXT UNIQUE, 7 + stripe_price_id TEXT, 8 + status TEXT NOT NULL, 9 + current_period_start TIMESTAMPTZ, 10 + current_period_end TIMESTAMPTZ, 11 + cancel_at_period_end BOOLEAN DEFAULT false, 12 + created_at TIMESTAMPTZ DEFAULT NOW(), 13 + updated_at TIMESTAMPTZ DEFAULT NOW() 14 + ); 15 + 16 + -- Create index for faster lookups 17 + CREATE INDEX IF NOT EXISTS subscriptions_user_id_idx ON subscriptions(user_id); 18 + CREATE INDEX IF NOT EXISTS subscriptions_stripe_customer_id_idx ON subscriptions(stripe_customer_id); 19 + CREATE INDEX IF NOT EXISTS subscriptions_stripe_subscription_id_idx ON subscriptions(stripe_subscription_id); 20 + 21 + -- Enable Row Level Security 22 + ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY; 23 + 24 + -- Policy: Users can only see their own subscriptions 25 + DROP POLICY IF EXISTS "Users can view own subscriptions" ON subscriptions; 26 + CREATE POLICY "Users can view own subscriptions" 27 + ON subscriptions 28 + FOR SELECT 29 + USING (auth.uid() = user_id); 30 + 31 + -- Policy: Users can insert their own subscription records (for initial customer creation) 32 + -- But they can ONLY insert records with status='incomplete' to prevent fraud 33 + DROP POLICY IF EXISTS "Users can insert own subscriptions" ON subscriptions; 34 + CREATE POLICY "Users can insert own subscriptions" 35 + ON subscriptions 36 + FOR INSERT 37 + WITH CHECK ( 38 + auth.uid() = user_id 39 + AND status = 'incomplete' 40 + ); 41 + 42 + -- Users CANNOT update their own subscriptions directly 43 + -- All updates must come from webhooks (using service role) or validated server actions 44 + -- This prevents users from setting status='active' without paying 45 + DROP POLICY IF EXISTS "Users can update own subscriptions" ON subscriptions; 46 + 47 + -- Note: Service role (used by webhooks via admin client) bypasses RLS entirely 48 + -- The admin client uses SUPABASE_SERVICE_ROLE_KEY which automatically bypasses all RLS policies. 49 + -- No policy needed for service role since it bypasses RLS. 50 + 51 + -- Function to update updated_at timestamp 52 + CREATE OR REPLACE FUNCTION update_updated_at_column() 53 + RETURNS TRIGGER AS $$ 54 + BEGIN 55 + NEW.updated_at = NOW(); 56 + RETURN NEW; 57 + END; 58 + $$ language 'plpgsql'; 59 + 60 + -- Trigger to automatically update updated_at 61 + DROP TRIGGER IF EXISTS update_subscriptions_updated_at ON subscriptions; 62 + CREATE TRIGGER update_subscriptions_updated_at 63 + BEFORE UPDATE ON subscriptions 64 + FOR EACH ROW 65 + EXECUTE FUNCTION update_updated_at_column();
+20
supabase/migrations/002_add_user_insert_update_policies.sql
··· 1 + -- Add policies to allow users to insert and update their own subscriptions 2 + -- This is needed for the checkout flow to work without requiring service role key 3 + -- NOTE: This migration is now obsolete - we removed UPDATE policy for security 4 + -- Keeping it for migration history, but it will be skipped if policies already exist 5 + 6 + -- Policy: Users can insert their own subscription records (for initial customer creation) 7 + DO $$ 8 + BEGIN 9 + IF NOT EXISTS ( 10 + SELECT 1 FROM pg_policies 11 + WHERE schemaname = 'public' 12 + AND tablename = 'subscriptions' 13 + AND policyname = 'Users can insert own subscriptions' 14 + ) THEN 15 + CREATE POLICY "Users can insert own subscriptions" 16 + ON subscriptions 17 + FOR INSERT 18 + WITH CHECK (auth.uid() = user_id); 19 + END IF; 20 + END $$;
+16
supabase/migrations/003_remove_user_update_policy.sql
··· 1 + -- Remove the UPDATE policy that allows users to update their own subscriptions 2 + -- This is a security fix: users should NOT be able to modify subscription status 3 + -- All updates must come from webhooks (service role) or validated server actions 4 + 5 + -- This migration is safe to run multiple times 6 + DO $$ 7 + BEGIN 8 + IF EXISTS ( 9 + SELECT 1 FROM pg_policies 10 + WHERE schemaname = 'public' 11 + AND tablename = 'subscriptions' 12 + AND policyname = 'Users can update own subscriptions' 13 + ) THEN 14 + DROP POLICY "Users can update own subscriptions" ON subscriptions; 15 + END IF; 16 + END $$;
+19
supabase/migrations/004_add_subscription_validation.sql
··· 1 + -- Additional security: Add a check constraint to ensure data integrity 2 + -- Note: RLS policies already prevent users from updating, but this adds an extra layer 3 + 4 + -- Ensure status is one of the valid Stripe subscription statuses 5 + ALTER TABLE subscriptions 6 + DROP CONSTRAINT IF EXISTS valid_subscription_status; 7 + 8 + ALTER TABLE subscriptions 9 + ADD CONSTRAINT valid_subscription_status 10 + CHECK (status IN ( 11 + 'incomplete', 12 + 'incomplete_expired', 13 + 'trialing', 14 + 'active', 15 + 'past_due', 16 + 'canceled', 17 + 'unpaid', 18 + 'paused' 19 + ));