docs(01): create phase 1 plans - Foundation & Auth
Phase 01: Foundation & Auth - 6 plans in 4 execution waves - Wave 1: Project setup (01) + Database schema (02) [parallel] - Wave 2: Email/password auth (03) + Google OAuth (04) [parallel] - Wave 3: Middleware & route protection (05) - Wave 4: Subscription management UI (06) Requirements covered: AUTH-01, AUTH-02, AUTH-03 Ready for execution Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
266
.planning/phases/01-foundation-auth/01-01-PLAN.md
Normal file
266
.planning/phases/01-foundation-auth/01-01-PLAN.md
Normal file
@@ -0,0 +1,266 @@
|
||||
---
|
||||
phase: 01-foundation-auth
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- package.json
|
||||
- tsconfig.json
|
||||
- next.config.ts
|
||||
- tailwind.config.ts
|
||||
- postcss.config.mjs
|
||||
- .env.local
|
||||
- .env.example
|
||||
- src/lib/supabase/client.ts
|
||||
- src/lib/supabase/server.ts
|
||||
- src/app/layout.tsx
|
||||
- src/app/page.tsx
|
||||
- src/app/globals.css
|
||||
autonomous: true
|
||||
user_setup:
|
||||
- service: supabase
|
||||
why: "Database and authentication backend"
|
||||
env_vars:
|
||||
- name: NEXT_PUBLIC_SUPABASE_URL
|
||||
source: "Supabase Dashboard -> Project Settings -> API -> Project URL"
|
||||
- name: NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
source: "Supabase Dashboard -> Project Settings -> API -> anon public"
|
||||
- name: SUPABASE_SERVICE_ROLE_KEY
|
||||
source: "Supabase Dashboard -> Project Settings -> API -> service_role (secret)"
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Next.js dev server starts without errors"
|
||||
- "Supabase client connects to project"
|
||||
- "Environment variables are loaded correctly"
|
||||
artifacts:
|
||||
- path: "src/lib/supabase/client.ts"
|
||||
provides: "Browser Supabase client for Client Components"
|
||||
exports: ["createClient"]
|
||||
- path: "src/lib/supabase/server.ts"
|
||||
provides: "Server Supabase client for Server Components/Actions"
|
||||
exports: ["createClient"]
|
||||
- path: ".env.local"
|
||||
provides: "Environment configuration"
|
||||
contains: "NEXT_PUBLIC_SUPABASE_URL"
|
||||
key_links:
|
||||
- from: "src/lib/supabase/client.ts"
|
||||
to: ".env.local"
|
||||
via: "process.env.NEXT_PUBLIC_*"
|
||||
pattern: "process\\.env\\.NEXT_PUBLIC_SUPABASE"
|
||||
- from: "src/lib/supabase/server.ts"
|
||||
to: "next/headers cookies()"
|
||||
via: "cookie handling for SSR"
|
||||
pattern: "cookies\\(\\)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Initialize Next.js 14 project with Supabase client configuration using the official @supabase/ssr package for App Router compatibility.
|
||||
|
||||
Purpose: Establish the foundational project structure and Supabase connectivity that all subsequent auth work depends on.
|
||||
|
||||
Output: Working Next.js 14 app with properly configured Supabase clients (browser + server) ready for auth implementation.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@C:\Users\miche\.claude/get-shit-done/workflows/execute-plan.md
|
||||
@C:\Users\miche\.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/01-foundation-auth/01-RESEARCH.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Initialize Next.js 14 project with TypeScript and Tailwind</name>
|
||||
<files>
|
||||
package.json
|
||||
tsconfig.json
|
||||
next.config.ts
|
||||
tailwind.config.ts
|
||||
postcss.config.mjs
|
||||
src/app/layout.tsx
|
||||
src/app/page.tsx
|
||||
src/app/globals.css
|
||||
</files>
|
||||
<action>
|
||||
Create Next.js 14 project with App Router:
|
||||
|
||||
```bash
|
||||
npx create-next-app@latest . --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --use-npm
|
||||
```
|
||||
|
||||
If directory not empty, use --force or clean first.
|
||||
|
||||
After init, install Supabase packages:
|
||||
```bash
|
||||
npm install @supabase/supabase-js @supabase/ssr zod react-hook-form
|
||||
```
|
||||
|
||||
Update src/app/page.tsx to a simple placeholder:
|
||||
```tsx
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-24">
|
||||
<h1 className="text-4xl font-bold">Leopost</h1>
|
||||
<p className="mt-4 text-gray-600">Setup in corso...</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Ensure next.config.ts uses the new format (not .js):
|
||||
```typescript
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// config options here
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
Run `npm run dev` - server starts on localhost:3000 without errors.
|
||||
Visit http://localhost:3000 - shows Leopost placeholder.
|
||||
</verify>
|
||||
<done>
|
||||
Next.js 14 project initialized with TypeScript, Tailwind, App Router, and Supabase packages installed.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Configure environment variables</name>
|
||||
<files>
|
||||
.env.local
|
||||
.env.example
|
||||
.gitignore
|
||||
</files>
|
||||
<action>
|
||||
Create .env.example (committed to git, template for others):
|
||||
```
|
||||
# Supabase Configuration
|
||||
NEXT_PUBLIC_SUPABASE_URL=your-project-url
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
||||
|
||||
# App Configuration
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
Create .env.local with actual Supabase credentials:
|
||||
- Get URL from Supabase Dashboard -> Project Settings -> API -> Project URL
|
||||
- Get anon key from Supabase Dashboard -> Project Settings -> API -> anon public
|
||||
- Get service_role key from Supabase Dashboard -> Project Settings -> API -> service_role
|
||||
|
||||
NOTE: If Supabase project doesn't exist yet, create placeholder values and document that user must create project.
|
||||
|
||||
Verify .gitignore includes:
|
||||
```
|
||||
.env*.local
|
||||
```
|
||||
(This should already be present from create-next-app)
|
||||
</action>
|
||||
<verify>
|
||||
- .env.example exists and is NOT in .gitignore
|
||||
- .env.local exists and IS gitignored
|
||||
- Run `git status` - .env.local should NOT appear
|
||||
</verify>
|
||||
<done>
|
||||
Environment files configured with Supabase credentials (or placeholders if project not yet created).
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Create Supabase client utilities (dual client pattern)</name>
|
||||
<files>
|
||||
src/lib/supabase/client.ts
|
||||
src/lib/supabase/server.ts
|
||||
</files>
|
||||
<action>
|
||||
Create src/lib/supabase/client.ts for Client Components:
|
||||
```typescript
|
||||
import { createBrowserClient } from '@supabase/ssr'
|
||||
|
||||
export function createClient() {
|
||||
return createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Create src/lib/supabase/server.ts for Server Components, Route Handlers, Server Actions:
|
||||
```typescript
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
export async function createClient() {
|
||||
const cookieStore = await cookies()
|
||||
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
)
|
||||
} catch {
|
||||
// The `setAll` method was called from a Server Component.
|
||||
// This can be ignored if you have middleware refreshing user sessions.
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
IMPORTANT:
|
||||
- Use @supabase/ssr NOT @supabase/auth-helpers-nextjs (deprecated)
|
||||
- Server client uses async cookies() (Next.js 15 requirement)
|
||||
- The try/catch in setAll is required for Server Components
|
||||
</action>
|
||||
<verify>
|
||||
- Both files exist with correct exports
|
||||
- `npm run build` completes without TypeScript errors
|
||||
- No import errors in IDE
|
||||
</verify>
|
||||
<done>
|
||||
Dual Supabase client pattern implemented: client.ts for browser, server.ts for SSR/Server Actions.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
After all tasks complete:
|
||||
1. `npm run dev` starts without errors
|
||||
2. `npm run build` completes successfully
|
||||
3. Both Supabase client files exist and export createClient
|
||||
4. Environment variables are configured (even if placeholder)
|
||||
5. Project structure follows Next.js 14 App Router conventions
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Next.js 14 development server runs at localhost:3000
|
||||
- Supabase packages installed: @supabase/supabase-js, @supabase/ssr
|
||||
- Form packages installed: zod, react-hook-form
|
||||
- Dual client pattern implemented (client.ts + server.ts)
|
||||
- Environment configuration ready for Supabase connection
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-foundation-auth/01-01-SUMMARY.md`
|
||||
</output>
|
||||
450
.planning/phases/01-foundation-auth/01-02-PLAN.md
Normal file
450
.planning/phases/01-foundation-auth/01-02-PLAN.md
Normal file
@@ -0,0 +1,450 @@
|
||||
---
|
||||
phase: 01-foundation-auth
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- supabase/migrations/001_initial_auth_setup.sql
|
||||
- supabase/seed.sql
|
||||
- docs/DATABASE.md
|
||||
autonomous: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Plans table exists with Free, Creator, Pro entries"
|
||||
- "Profiles table creates automatically on user signup"
|
||||
- "RLS policies prevent cross-tenant data access"
|
||||
- "User cannot see other users' profiles"
|
||||
artifacts:
|
||||
- path: "supabase/migrations/001_initial_auth_setup.sql"
|
||||
provides: "Database schema and RLS policies"
|
||||
contains: "CREATE TABLE plans"
|
||||
- path: "docs/DATABASE.md"
|
||||
provides: "Schema documentation"
|
||||
contains: "plans"
|
||||
key_links:
|
||||
- from: "profiles table"
|
||||
to: "auth.users"
|
||||
via: "foreign key + trigger"
|
||||
pattern: "REFERENCES auth.users"
|
||||
- from: "profiles table"
|
||||
to: "plans table"
|
||||
via: "plan_id foreign key"
|
||||
pattern: "REFERENCES plans"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the database schema for multi-tenant authentication with subscription plans and Row Level Security policies.
|
||||
|
||||
Purpose: Establish secure data foundation with tenant isolation from day 1 - this is CRITICAL for security and cannot be retrofitted.
|
||||
|
||||
Output: SQL migration ready to execute in Supabase, with plans table, profiles table, RLS policies, and auto-profile trigger.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@C:\Users\miche\.claude/get-shit-done/workflows/execute-plan.md
|
||||
@C:\Users\miche\.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/01-foundation-auth/01-RESEARCH.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create database migration with plans and profiles</name>
|
||||
<files>
|
||||
supabase/migrations/001_initial_auth_setup.sql
|
||||
</files>
|
||||
<action>
|
||||
Create supabase/migrations/ directory if not exists.
|
||||
|
||||
Create migration file with complete auth schema:
|
||||
|
||||
```sql
|
||||
-- Migration: 001_initial_auth_setup.sql
|
||||
-- Purpose: Create plans, profiles tables with RLS for multi-tenant auth
|
||||
|
||||
-- ============================================
|
||||
-- PLANS TABLE
|
||||
-- ============================================
|
||||
|
||||
CREATE TABLE public.plans (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL UNIQUE CHECK (name IN ('free', 'creator', 'pro')),
|
||||
display_name TEXT NOT NULL,
|
||||
display_name_it TEXT NOT NULL, -- Italian display name
|
||||
price_monthly INTEGER NOT NULL CHECK (price_monthly >= 0), -- cents
|
||||
features JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Insert default plans
|
||||
INSERT INTO public.plans (name, display_name, display_name_it, price_monthly, features) VALUES
|
||||
('free', 'Free', 'Gratuito', 0, '{
|
||||
"posts_per_month": 10,
|
||||
"ai_models": ["gpt-4o-mini"],
|
||||
"social_accounts": 1,
|
||||
"image_generation": false,
|
||||
"automation": false
|
||||
}'),
|
||||
('creator', 'Creator', 'Creator', 1900, '{
|
||||
"posts_per_month": 50,
|
||||
"ai_models": ["gpt-4o-mini", "gpt-4o", "claude-3-5-sonnet"],
|
||||
"social_accounts": 3,
|
||||
"image_generation": true,
|
||||
"automation": "manual"
|
||||
}'),
|
||||
('pro', 'Pro', 'Pro', 4900, '{
|
||||
"posts_per_month": 200,
|
||||
"ai_models": ["gpt-4o-mini", "gpt-4o", "claude-3-5-sonnet", "claude-opus-4"],
|
||||
"social_accounts": 10,
|
||||
"image_generation": true,
|
||||
"automation": "full"
|
||||
}');
|
||||
|
||||
-- ============================================
|
||||
-- PROFILES TABLE
|
||||
-- ============================================
|
||||
|
||||
CREATE TABLE public.profiles (
|
||||
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
tenant_id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
plan_id UUID REFERENCES public.plans(id) NOT NULL DEFAULT (SELECT id FROM public.plans WHERE name = 'free'),
|
||||
email TEXT NOT NULL,
|
||||
full_name TEXT,
|
||||
avatar_url TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Performance indexes
|
||||
CREATE INDEX idx_profiles_tenant_id ON public.profiles(tenant_id);
|
||||
CREATE INDEX idx_profiles_plan_id ON public.profiles(plan_id);
|
||||
CREATE INDEX idx_profiles_email ON public.profiles(email);
|
||||
|
||||
-- ============================================
|
||||
-- ROW LEVEL SECURITY
|
||||
-- ============================================
|
||||
|
||||
-- Enable RLS on all tables (CRITICAL - never skip this)
|
||||
ALTER TABLE public.plans ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Plans: Everyone can read (public info)
|
||||
CREATE POLICY "Plans are viewable by everyone"
|
||||
ON public.plans FOR SELECT
|
||||
TO authenticated, anon
|
||||
USING (true);
|
||||
|
||||
-- Profiles: Users can only read their own profile
|
||||
-- IMPORTANT: Use (SELECT auth.uid()) for 99% performance improvement
|
||||
CREATE POLICY "Users can read own profile"
|
||||
ON public.profiles FOR SELECT
|
||||
TO authenticated
|
||||
USING ((SELECT auth.uid()) = id);
|
||||
|
||||
-- Profiles: Users can update their own profile
|
||||
CREATE POLICY "Users can update own profile"
|
||||
ON public.profiles FOR UPDATE
|
||||
TO authenticated
|
||||
USING ((SELECT auth.uid()) = id)
|
||||
WITH CHECK ((SELECT auth.uid()) = id);
|
||||
|
||||
-- Profiles: System can insert (via trigger)
|
||||
-- Note: INSERT policy needed because trigger runs as SECURITY DEFINER
|
||||
CREATE POLICY "System can insert profiles"
|
||||
ON public.profiles FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK ((SELECT auth.uid()) = id);
|
||||
|
||||
-- ============================================
|
||||
-- AUTO-CREATE PROFILE TRIGGER
|
||||
-- ============================================
|
||||
|
||||
-- Function to create profile on user signup
|
||||
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.profiles (id, email, tenant_id, full_name, avatar_url)
|
||||
VALUES (
|
||||
NEW.id,
|
||||
NEW.email,
|
||||
gen_random_uuid(), -- Each user gets unique tenant_id
|
||||
COALESCE(NEW.raw_user_meta_data->>'full_name', NEW.raw_user_meta_data->>'name'),
|
||||
NEW.raw_user_meta_data->>'avatar_url'
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Trigger on auth.users insert
|
||||
CREATE TRIGGER on_auth_user_created
|
||||
AFTER INSERT ON auth.users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.handle_new_user();
|
||||
|
||||
-- ============================================
|
||||
-- HELPER FUNCTIONS
|
||||
-- ============================================
|
||||
|
||||
-- Function to get current user's plan features (for API limit checking)
|
||||
CREATE OR REPLACE FUNCTION public.get_user_plan_features()
|
||||
RETURNS JSONB
|
||||
LANGUAGE SQL STABLE
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
SELECT p.features
|
||||
FROM public.plans p
|
||||
INNER JOIN public.profiles pr ON pr.plan_id = p.id
|
||||
WHERE pr.id = (SELECT auth.uid());
|
||||
$$;
|
||||
|
||||
-- Function to get current user's plan name
|
||||
CREATE OR REPLACE FUNCTION public.get_user_plan_name()
|
||||
RETURNS TEXT
|
||||
LANGUAGE SQL STABLE
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
SELECT p.name
|
||||
FROM public.plans p
|
||||
INNER JOIN public.profiles pr ON pr.plan_id = p.id
|
||||
WHERE pr.id = (SELECT auth.uid());
|
||||
$$;
|
||||
|
||||
-- Function to update profile's updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION public.update_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER profiles_updated_at
|
||||
BEFORE UPDATE ON public.profiles
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.update_updated_at();
|
||||
|
||||
-- ============================================
|
||||
-- GRANTS
|
||||
-- ============================================
|
||||
|
||||
-- Grant usage to authenticated users
|
||||
GRANT USAGE ON SCHEMA public TO authenticated;
|
||||
GRANT SELECT ON public.plans TO authenticated;
|
||||
GRANT SELECT, UPDATE ON public.profiles TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.get_user_plan_features() TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.get_user_plan_name() TO authenticated;
|
||||
```
|
||||
|
||||
CRITICAL NOTES from RESEARCH.md:
|
||||
- RLS MUST be enabled on EVERY table (CVE-2025-48757 exposed 170+ apps without this)
|
||||
- Use (SELECT auth.uid()) not bare auth.uid() for 99% performance improvement
|
||||
- Both SELECT and INSERT policies needed for profiles (PostgreSQL returns inserted rows)
|
||||
- SECURITY DEFINER on functions to bypass RLS when needed
|
||||
</action>
|
||||
<verify>
|
||||
- File exists at supabase/migrations/001_initial_auth_setup.sql
|
||||
- SQL syntax is valid (no obvious errors)
|
||||
- All three plans are inserted (free, creator, pro)
|
||||
- RLS is enabled on both tables
|
||||
- Trigger function exists for auto-profile creation
|
||||
</verify>
|
||||
<done>
|
||||
Complete database migration ready for Supabase execution.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create seed file for development</name>
|
||||
<files>
|
||||
supabase/seed.sql
|
||||
</files>
|
||||
<action>
|
||||
Create seed file for development/testing (optional data beyond migration):
|
||||
|
||||
```sql
|
||||
-- Seed file for development
|
||||
-- Note: Plans are already seeded in migration
|
||||
-- This file is for additional test data if needed
|
||||
|
||||
-- Verify plans exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM public.plans WHERE name = 'free') THEN
|
||||
RAISE EXCEPTION 'Plans not found - run migration first';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Log seed completion
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE 'Seed completed. Plans available: free, creator, pro';
|
||||
END $$;
|
||||
```
|
||||
|
||||
This seed file is minimal because:
|
||||
- Plans are created in migration (should always exist)
|
||||
- Profiles are created automatically via trigger
|
||||
- Test users should be created through the app flow
|
||||
</action>
|
||||
<verify>
|
||||
File exists at supabase/seed.sql
|
||||
</verify>
|
||||
<done>
|
||||
Seed file created for development verification.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Document database schema</name>
|
||||
<files>
|
||||
docs/DATABASE.md
|
||||
</files>
|
||||
<action>
|
||||
Create docs/ directory if not exists.
|
||||
|
||||
Create DATABASE.md documenting the schema:
|
||||
|
||||
```markdown
|
||||
# Database Schema - Leopost
|
||||
|
||||
## Overview
|
||||
|
||||
Leopost uses Supabase (PostgreSQL) with Row Level Security for multi-tenant data isolation.
|
||||
|
||||
## Tables
|
||||
|
||||
### plans
|
||||
|
||||
Subscription plan definitions.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | UUID | Primary key |
|
||||
| name | TEXT | Unique identifier: 'free', 'creator', 'pro' |
|
||||
| display_name | TEXT | English display name |
|
||||
| display_name_it | TEXT | Italian display name |
|
||||
| price_monthly | INTEGER | Price in cents (0, 1900, 4900) |
|
||||
| features | JSONB | Feature limits and flags |
|
||||
| created_at | TIMESTAMPTZ | Creation timestamp |
|
||||
|
||||
**Features JSONB structure:**
|
||||
```json
|
||||
{
|
||||
"posts_per_month": 10,
|
||||
"ai_models": ["gpt-4o-mini"],
|
||||
"social_accounts": 1,
|
||||
"image_generation": false,
|
||||
"automation": false
|
||||
}
|
||||
```
|
||||
|
||||
### profiles
|
||||
|
||||
User profiles with tenant isolation.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | UUID | Primary key, references auth.users |
|
||||
| tenant_id | UUID | Tenant isolation key (auto-generated) |
|
||||
| plan_id | UUID | References plans.id, defaults to 'free' |
|
||||
| email | TEXT | User email |
|
||||
| full_name | TEXT | Optional display name |
|
||||
| avatar_url | TEXT | Optional avatar URL |
|
||||
| created_at | TIMESTAMPTZ | Creation timestamp |
|
||||
| updated_at | TIMESTAMPTZ | Last update timestamp |
|
||||
|
||||
## Row Level Security
|
||||
|
||||
**CRITICAL**: RLS is enabled on all tables. Never bypass RLS in client code.
|
||||
|
||||
### plans
|
||||
- SELECT: Everyone (authenticated + anon) can read
|
||||
|
||||
### profiles
|
||||
- SELECT: Users can only read their own profile
|
||||
- UPDATE: Users can only update their own profile
|
||||
- INSERT: System creates via trigger on signup
|
||||
|
||||
## Helper Functions
|
||||
|
||||
### get_user_plan_features()
|
||||
Returns JSONB of current user's plan features. Use for limit checking.
|
||||
|
||||
```typescript
|
||||
const { data } = await supabase.rpc('get_user_plan_features')
|
||||
// Returns: { posts_per_month: 10, ai_models: [...], ... }
|
||||
```
|
||||
|
||||
### get_user_plan_name()
|
||||
Returns TEXT of current user's plan name ('free', 'creator', 'pro').
|
||||
|
||||
## Triggers
|
||||
|
||||
### on_auth_user_created
|
||||
Automatically creates a profile when a new user signs up via Supabase Auth.
|
||||
- Sets tenant_id to new UUID (multi-tenant isolation)
|
||||
- Sets plan_id to 'free' plan
|
||||
- Copies email, full_name, avatar_url from auth metadata
|
||||
|
||||
## Running Migrations
|
||||
|
||||
Option 1: Supabase Dashboard
|
||||
1. Go to SQL Editor
|
||||
2. Paste migration content
|
||||
3. Run
|
||||
|
||||
Option 2: Supabase CLI
|
||||
```bash
|
||||
supabase db push
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Never** use service_role key in client code
|
||||
- **Always** verify RLS is enabled after schema changes
|
||||
- Use Supabase Security Advisor in dashboard before production
|
||||
- tenant_id is in profiles table, not JWT (simpler approach for v1)
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
- docs/DATABASE.md exists
|
||||
- Documents both tables
|
||||
- Includes RLS policies
|
||||
- Includes helper functions
|
||||
</verify>
|
||||
<done>
|
||||
Database schema documented for team reference.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
After all tasks complete:
|
||||
1. Migration file exists and contains valid SQL
|
||||
2. Plans table has 3 entries (free, creator, pro)
|
||||
3. Profiles table has RLS policies
|
||||
4. Trigger creates profile on user signup
|
||||
5. Helper functions exist for plan checking
|
||||
6. Documentation is complete
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SQL migration ready to execute in Supabase
|
||||
- RLS enabled on ALL tables (security critical)
|
||||
- Auto-profile creation via trigger
|
||||
- Plan features stored as JSONB for flexibility
|
||||
- Helper functions for limit checking
|
||||
- Schema documented in docs/DATABASE.md
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-foundation-auth/01-02-SUMMARY.md`
|
||||
</output>
|
||||
1029
.planning/phases/01-foundation-auth/01-03-PLAN.md
Normal file
1029
.planning/phases/01-foundation-auth/01-03-PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
450
.planning/phases/01-foundation-auth/01-04-PLAN.md
Normal file
450
.planning/phases/01-foundation-auth/01-04-PLAN.md
Normal file
@@ -0,0 +1,450 @@
|
||||
---
|
||||
phase: 01-foundation-auth
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["01-01", "01-02"]
|
||||
files_modified:
|
||||
- src/components/auth/google-button.tsx
|
||||
- src/app/(auth)/login/page.tsx
|
||||
- src/app/(auth)/register/page.tsx
|
||||
- docs/GOOGLE_OAUTH_SETUP.md
|
||||
autonomous: true
|
||||
user_setup:
|
||||
- service: google-cloud
|
||||
why: "Google OAuth for social login"
|
||||
env_vars: []
|
||||
dashboard_config:
|
||||
- task: "Create OAuth 2.0 Client ID"
|
||||
location: "Google Cloud Console -> APIs & Services -> Credentials"
|
||||
- task: "Add authorized JavaScript origins"
|
||||
location: "OAuth Client -> Authorized JavaScript origins"
|
||||
value: "http://localhost:3000, https://your-domain.com"
|
||||
- task: "Add authorized redirect URI"
|
||||
location: "OAuth Client -> Authorized redirect URIs"
|
||||
value: "https://<project-ref>.supabase.co/auth/v1/callback"
|
||||
- service: supabase
|
||||
why: "Enable Google OAuth provider"
|
||||
env_vars: []
|
||||
dashboard_config:
|
||||
- task: "Enable Google provider"
|
||||
location: "Supabase Dashboard -> Authentication -> Providers -> Google"
|
||||
- task: "Paste Google Client ID and Secret"
|
||||
location: "Same settings page"
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can click 'Accedi con Google' button"
|
||||
- "User is redirected to Google consent screen"
|
||||
- "User returns to app authenticated after consent"
|
||||
- "User session persists after Google login"
|
||||
artifacts:
|
||||
- path: "src/components/auth/google-button.tsx"
|
||||
provides: "Google Sign-In button component"
|
||||
exports: ["GoogleSignInButton"]
|
||||
- path: "docs/GOOGLE_OAUTH_SETUP.md"
|
||||
provides: "Setup instructions for Google OAuth"
|
||||
contains: "Google Cloud Console"
|
||||
key_links:
|
||||
- from: "src/components/auth/google-button.tsx"
|
||||
to: "Supabase Auth"
|
||||
via: "signInWithOAuth"
|
||||
pattern: "signInWithOAuth.*google"
|
||||
- from: "Google OAuth"
|
||||
to: "src/app/auth/callback/route.ts"
|
||||
via: "redirect after consent"
|
||||
pattern: "auth/callback"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Implement Google OAuth login allowing users to sign in with their Google account.
|
||||
|
||||
Purpose: Enable social login per AUTH-02 requirement. Google OAuth provides frictionless registration/login.
|
||||
|
||||
Output: Working Google Sign-In button that authenticates users and creates their profile automatically.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@C:\Users\miche\.claude/get-shit-done/workflows/execute-plan.md
|
||||
@C:\Users\miche\.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/01-foundation-auth/01-RESEARCH.md
|
||||
@.planning/phases/01-foundation-auth/01-01-SUMMARY.md
|
||||
@.planning/phases/01-foundation-auth/01-02-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create Google Sign-In button component</name>
|
||||
<files>
|
||||
src/components/auth/google-button.tsx
|
||||
</files>
|
||||
<action>
|
||||
Create the Google Sign-In button at src/components/auth/google-button.tsx:
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useState } from 'react'
|
||||
|
||||
// Simple Google icon SVG
|
||||
function GoogleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GoogleSignInButton() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const supabase = createClient()
|
||||
|
||||
async function handleGoogleSignIn() {
|
||||
setLoading(true)
|
||||
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/auth/callback`,
|
||||
queryParams: {
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Google sign-in error:', error)
|
||||
setLoading(false)
|
||||
}
|
||||
// Note: No need to handle success - user is redirected to Google
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<GoogleIcon className="w-5 h-5" />
|
||||
{loading ? 'Reindirizzamento...' : 'Accedi con Google'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Key points:
|
||||
- Uses 'use client' since it needs browser APIs
|
||||
- Uses createClient from lib/supabase/client.ts (browser client)
|
||||
- redirectTo points to /auth/callback which handles the code exchange
|
||||
- access_type: 'offline' requests refresh token
|
||||
- prompt: 'consent' ensures user always sees consent screen (good for debugging)
|
||||
- Italian button text per project requirement
|
||||
</action>
|
||||
<verify>
|
||||
- Component file exists
|
||||
- No TypeScript errors
|
||||
- Button renders with Google icon
|
||||
</verify>
|
||||
<done>
|
||||
Google Sign-In button component created with proper OAuth configuration.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add Google button to login and register pages</name>
|
||||
<files>
|
||||
src/app/(auth)/login/page.tsx
|
||||
src/app/(auth)/register/page.tsx
|
||||
src/components/auth/login-form.tsx
|
||||
src/components/auth/register-form.tsx
|
||||
</files>
|
||||
<action>
|
||||
Update login page to include Google button. Modify src/app/(auth)/login/page.tsx:
|
||||
|
||||
```typescript
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { LoginForm } from '@/components/auth/login-form'
|
||||
import { GoogleSignInButton } from '@/components/auth/google-button'
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>Accedi a Leopost</CardTitle>
|
||||
<CardDescription>
|
||||
Inserisci le tue credenziali per continuare
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<GoogleSignInButton />
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white px-2 text-gray-500">oppure</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoginForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Update register page similarly. Modify src/app/(auth)/register/page.tsx:
|
||||
|
||||
```typescript
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { RegisterForm } from '@/components/auth/register-form'
|
||||
import { GoogleSignInButton } from '@/components/auth/google-button'
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>Crea il tuo account</CardTitle>
|
||||
<CardDescription>
|
||||
Inizia a usare Leopost gratuitamente
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<GoogleSignInButton />
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white px-2 text-gray-500">oppure</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RegisterForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The divider uses "oppure" (Italian for "or") to separate social login from email form.
|
||||
Google button appears FIRST (above the form) as it's the faster option.
|
||||
</action>
|
||||
<verify>
|
||||
- Login page shows Google button above email form
|
||||
- Register page shows Google button above email form
|
||||
- "oppure" divider separates the two methods
|
||||
- Pages render without errors
|
||||
</verify>
|
||||
<done>
|
||||
Google Sign-In button integrated into login and registration pages.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Document Google OAuth setup process</name>
|
||||
<files>
|
||||
docs/GOOGLE_OAUTH_SETUP.md
|
||||
</files>
|
||||
<action>
|
||||
Create comprehensive setup documentation at docs/GOOGLE_OAUTH_SETUP.md:
|
||||
|
||||
```markdown
|
||||
# Google OAuth Setup Guide
|
||||
|
||||
This guide explains how to configure Google OAuth for Leopost.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Google Cloud account
|
||||
- Supabase project created
|
||||
- Access to Supabase Dashboard
|
||||
|
||||
## Step 1: Google Cloud Console Setup
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
|
||||
2. Create a new project or select existing one
|
||||
|
||||
3. Navigate to **APIs & Services > Credentials**
|
||||
|
||||
4. Click **Create Credentials > OAuth client ID**
|
||||
|
||||
5. If prompted, configure the OAuth consent screen:
|
||||
- User Type: External
|
||||
- App name: Leopost
|
||||
- User support email: your email
|
||||
- Developer contact: your email
|
||||
- Scopes: email, profile, openid (default)
|
||||
|
||||
6. Create OAuth Client ID:
|
||||
- Application type: **Web application**
|
||||
- Name: Leopost Web Client
|
||||
|
||||
7. Add **Authorized JavaScript origins**:
|
||||
```
|
||||
http://localhost:3000
|
||||
https://your-production-domain.com
|
||||
```
|
||||
|
||||
8. Add **Authorized redirect URIs**:
|
||||
```
|
||||
https://<your-project-ref>.supabase.co/auth/v1/callback
|
||||
```
|
||||
|
||||
Find your project ref in Supabase Dashboard > Project Settings > General
|
||||
|
||||
9. Click **Create** and save the **Client ID** and **Client Secret**
|
||||
|
||||
## Step 2: Supabase Configuration
|
||||
|
||||
1. Go to [Supabase Dashboard](https://supabase.com/dashboard)
|
||||
|
||||
2. Select your project
|
||||
|
||||
3. Navigate to **Authentication > Providers**
|
||||
|
||||
4. Find **Google** and click to enable
|
||||
|
||||
5. Enter:
|
||||
- **Client ID**: From Google Cloud Console
|
||||
- **Client Secret**: From Google Cloud Console
|
||||
|
||||
6. Copy the **Callback URL** shown (should match what you entered in Google)
|
||||
|
||||
7. Click **Save**
|
||||
|
||||
## Step 3: Environment Variables
|
||||
|
||||
No additional environment variables needed for Google OAuth.
|
||||
The configuration is stored in Supabase Dashboard.
|
||||
|
||||
Your existing `.env.local` should have:
|
||||
```
|
||||
NEXT_PUBLIC_SUPABASE_URL=your-project-url
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||
```
|
||||
|
||||
## Step 4: Test the Integration
|
||||
|
||||
1. Start development server: `npm run dev`
|
||||
|
||||
2. Go to http://localhost:3000/login
|
||||
|
||||
3. Click "Accedi con Google"
|
||||
|
||||
4. You should be redirected to Google consent screen
|
||||
|
||||
5. After consent, you should return to the app authenticated
|
||||
|
||||
6. Check Supabase Dashboard > Authentication > Users to verify user was created
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "redirect_uri_mismatch" error
|
||||
|
||||
The redirect URI in Google Console doesn't match Supabase's callback URL.
|
||||
- Verify the exact URL in Supabase Auth Providers matches Google Console
|
||||
- Check for trailing slashes
|
||||
- Ensure using HTTPS for Supabase callback
|
||||
|
||||
### "Access blocked: This app's request is invalid"
|
||||
|
||||
OAuth consent screen not configured or not published.
|
||||
- Configure OAuth consent screen in Google Cloud Console
|
||||
- For testing, add your email as a test user
|
||||
- For production, submit for verification
|
||||
|
||||
### User created but profile missing
|
||||
|
||||
The database trigger might not have fired.
|
||||
- Check if `on_auth_user_created` trigger exists
|
||||
- Verify `handle_new_user` function has correct permissions
|
||||
- Check Supabase logs for errors
|
||||
|
||||
### Session not persisting
|
||||
|
||||
Middleware might not be refreshing sessions.
|
||||
- Ensure middleware.ts is in project root
|
||||
- Check middleware matcher includes auth routes
|
||||
- Verify cookies are being set correctly
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Never commit Client Secret to version control
|
||||
- For production, publish OAuth consent screen for verification
|
||||
- Use a separate OAuth client for production vs development
|
||||
- Regularly rotate Client Secrets
|
||||
|
||||
## Local Development vs Production
|
||||
|
||||
| Setting | Local | Production |
|
||||
|---------|-------|------------|
|
||||
| JavaScript origins | http://localhost:3000 | https://your-domain.com |
|
||||
| Redirect URI | Same Supabase callback | Same Supabase callback |
|
||||
| Consent screen | Testing mode | Published/Verified |
|
||||
| Test users | Your email added | Not needed |
|
||||
```
|
||||
|
||||
This documentation helps future developers (or the user) configure Google OAuth correctly.
|
||||
</action>
|
||||
<verify>
|
||||
- File exists at docs/GOOGLE_OAUTH_SETUP.md
|
||||
- Contains step-by-step instructions
|
||||
- Includes troubleshooting section
|
||||
- Covers both Google Cloud and Supabase configuration
|
||||
</verify>
|
||||
<done>
|
||||
Google OAuth setup documentation created.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
After all tasks complete:
|
||||
1. Google button appears on /login and /register pages
|
||||
2. Clicking button redirects to Google (or shows error if not configured)
|
||||
3. Documentation provides clear setup steps
|
||||
4. No TypeScript or build errors
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- GoogleSignInButton component exists and renders
|
||||
- Login/register pages show Google option prominently
|
||||
- Setup documentation is comprehensive
|
||||
- OAuth flow uses correct callback URL (/auth/callback)
|
||||
- Italian text used throughout UI
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-foundation-auth/01-04-SUMMARY.md`
|
||||
</output>
|
||||
554
.planning/phases/01-foundation-auth/01-05-PLAN.md
Normal file
554
.planning/phases/01-foundation-auth/01-05-PLAN.md
Normal file
@@ -0,0 +1,554 @@
|
||||
---
|
||||
phase: 01-foundation-auth
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["01-03", "01-04"]
|
||||
files_modified:
|
||||
- middleware.ts
|
||||
- src/lib/supabase/middleware.ts
|
||||
- src/app/(dashboard)/layout.tsx
|
||||
- src/app/(dashboard)/dashboard/page.tsx
|
||||
- src/components/layout/user-nav.tsx
|
||||
autonomous: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Unauthenticated users are redirected to /login when accessing /dashboard"
|
||||
- "Authenticated users stay logged in across page refreshes"
|
||||
- "User can log out and is redirected to login"
|
||||
- "Session refreshes automatically (no random logouts)"
|
||||
artifacts:
|
||||
- path: "middleware.ts"
|
||||
provides: "Route protection and session refresh"
|
||||
min_lines: 15
|
||||
- path: "src/lib/supabase/middleware.ts"
|
||||
provides: "Supabase session update helper"
|
||||
exports: ["updateSession"]
|
||||
- path: "src/app/(dashboard)/dashboard/page.tsx"
|
||||
provides: "Protected dashboard page"
|
||||
min_lines: 10
|
||||
key_links:
|
||||
- from: "middleware.ts"
|
||||
to: "src/lib/supabase/middleware.ts"
|
||||
via: "updateSession import"
|
||||
pattern: "updateSession"
|
||||
- from: "middleware.ts"
|
||||
to: "Next.js request handling"
|
||||
via: "matcher config"
|
||||
pattern: "matcher.*dashboard"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Implement middleware for session management and route protection, plus a basic protected dashboard.
|
||||
|
||||
Purpose: Ensure authenticated state persists across requests and protect private routes. This is MANDATORY per research - without middleware, sessions expire randomly.
|
||||
|
||||
Output: Working route protection where /dashboard requires authentication and sessions auto-refresh.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@C:\Users\miche\.claude/get-shit-done/workflows/execute-plan.md
|
||||
@C:\Users\miche\.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/01-foundation-auth/01-RESEARCH.md
|
||||
@.planning/phases/01-foundation-auth/01-03-SUMMARY.md
|
||||
@.planning/phases/01-foundation-auth/01-04-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create middleware helper and main middleware</name>
|
||||
<files>
|
||||
src/lib/supabase/middleware.ts
|
||||
middleware.ts
|
||||
</files>
|
||||
<action>
|
||||
Create the middleware helper at src/lib/supabase/middleware.ts:
|
||||
|
||||
```typescript
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
|
||||
export async function updateSession(request: NextRequest) {
|
||||
let supabaseResponse = NextResponse.next({
|
||||
request,
|
||||
})
|
||||
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value }) =>
|
||||
request.cookies.set(name, value)
|
||||
)
|
||||
supabaseResponse = NextResponse.next({
|
||||
request,
|
||||
})
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
supabaseResponse.cookies.set(name, value, options)
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// IMPORTANT: Do not remove this line
|
||||
// Refreshing the auth token is crucial for keeping the session alive
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
return { supabaseResponse, user }
|
||||
}
|
||||
```
|
||||
|
||||
Create the main middleware at middleware.ts (project root, NOT in src):
|
||||
|
||||
```typescript
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { updateSession } from '@/lib/supabase/middleware'
|
||||
|
||||
// Routes that require authentication
|
||||
const protectedRoutes = ['/dashboard', '/settings', '/subscription']
|
||||
|
||||
// Routes that should redirect to dashboard if already authenticated
|
||||
const authRoutes = ['/login', '/register']
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { supabaseResponse, user } = await updateSession(request)
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
// Check if trying to access protected route without auth
|
||||
const isProtectedRoute = protectedRoutes.some(route =>
|
||||
pathname.startsWith(route)
|
||||
)
|
||||
|
||||
if (isProtectedRoute && !user) {
|
||||
const redirectUrl = new URL('/login', request.url)
|
||||
// Save the original URL to redirect back after login
|
||||
redirectUrl.searchParams.set('redirectTo', pathname)
|
||||
return NextResponse.redirect(redirectUrl)
|
||||
}
|
||||
|
||||
// Check if trying to access auth routes while already authenticated
|
||||
const isAuthRoute = authRoutes.some(route =>
|
||||
pathname.startsWith(route)
|
||||
)
|
||||
|
||||
if (isAuthRoute && user) {
|
||||
return NextResponse.redirect(new URL('/dashboard', request.url))
|
||||
}
|
||||
|
||||
return supabaseResponse
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - public folder files
|
||||
*/
|
||||
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
CRITICAL from RESEARCH.md:
|
||||
- Middleware MUST call supabase.auth.getUser() to refresh session
|
||||
- Without this, sessions expire and users get randomly logged out
|
||||
- The matcher excludes static files for performance
|
||||
- Check path BEFORE redirecting to avoid infinite loops
|
||||
</action>
|
||||
<verify>
|
||||
- middleware.ts exists in project root (not src/)
|
||||
- src/lib/supabase/middleware.ts exists
|
||||
- No TypeScript errors
|
||||
- Matcher pattern is correct
|
||||
</verify>
|
||||
<done>
|
||||
Middleware configured for session refresh and route protection.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create protected dashboard layout and page</name>
|
||||
<files>
|
||||
src/app/(dashboard)/layout.tsx
|
||||
src/app/(dashboard)/dashboard/page.tsx
|
||||
src/components/layout/user-nav.tsx
|
||||
</files>
|
||||
<action>
|
||||
Create user navigation component at src/components/layout/user-nav.tsx:
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface UserNavProps {
|
||||
email: string
|
||||
planName?: string
|
||||
}
|
||||
|
||||
export function UserNav({ email, planName }: UserNavProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
const supabase = createClient()
|
||||
|
||||
async function handleSignOut() {
|
||||
setLoading(true)
|
||||
await supabase.auth.signOut()
|
||||
router.push('/login')
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-right">
|
||||
<p className="font-medium">{email}</p>
|
||||
{planName && (
|
||||
<p className="text-gray-500 text-xs capitalize">Piano {planName}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSignOut}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Uscita...' : 'Esci'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Create dashboard layout at src/app/(dashboard)/layout.tsx:
|
||||
|
||||
```typescript
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { UserNav } from '@/components/layout/user-nav'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Get user (should always exist due to middleware)
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
|
||||
if (authError || !user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Get profile with plan info
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select(`
|
||||
*,
|
||||
plans (
|
||||
name,
|
||||
display_name_it
|
||||
)
|
||||
`)
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center gap-8">
|
||||
<Link href="/dashboard" className="text-xl font-bold text-blue-600">
|
||||
Leopost
|
||||
</Link>
|
||||
<nav className="hidden md:flex items-center gap-4">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-gray-600 hover:text-gray-900 text-sm font-medium"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href="/subscription"
|
||||
className="text-gray-600 hover:text-gray-900 text-sm font-medium"
|
||||
>
|
||||
Piano
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
<UserNav
|
||||
email={user.email || ''}
|
||||
planName={profile?.plans?.display_name_it}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Create dashboard page at src/app/(dashboard)/dashboard/page.tsx:
|
||||
|
||||
```typescript
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Get profile with plan details
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select(`
|
||||
*,
|
||||
plans (
|
||||
name,
|
||||
display_name_it,
|
||||
features
|
||||
)
|
||||
`)
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
const features = profile?.plans?.features as {
|
||||
posts_per_month?: number
|
||||
ai_models?: string[]
|
||||
social_accounts?: number
|
||||
} | null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-500">Benvenuto in Leopost</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Il tuo piano</CardTitle>
|
||||
<CardDescription>
|
||||
{profile?.plans?.display_name_it || 'Gratuito'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-gray-600">
|
||||
<li>
|
||||
<span className="font-medium">{features?.posts_per_month || 10}</span> post/mese
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">{features?.social_accounts || 1}</span> account social
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">{features?.ai_models?.length || 1}</span> modelli AI
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Prossimi passi</CardTitle>
|
||||
<CardDescription>
|
||||
Completa la configurazione
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 rounded-full bg-green-100 text-green-600 flex items-center justify-center text-xs">
|
||||
✓
|
||||
</span>
|
||||
Account creato
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-gray-400">
|
||||
<span className="w-5 h-5 rounded-full bg-gray-100 flex items-center justify-center text-xs">
|
||||
2
|
||||
</span>
|
||||
Collega social (Phase 2)
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-gray-400">
|
||||
<span className="w-5 h-5 rounded-full bg-gray-100 flex items-center justify-center text-xs">
|
||||
3
|
||||
</span>
|
||||
Configura brand (Phase 3)
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Attivita</CardTitle>
|
||||
<CardDescription>
|
||||
Le tue statistiche
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-4 text-gray-400 text-sm">
|
||||
Nessuna attivita ancora.
|
||||
<br />
|
||||
Inizia collegando un account social!
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Key points:
|
||||
- Layout fetches user and profile data server-side
|
||||
- Dashboard shows plan info from database
|
||||
- "Prossimi passi" teases future phases
|
||||
- All text in Italian
|
||||
- Uses RLS-protected queries (profile data auto-filtered)
|
||||
</action>
|
||||
<verify>
|
||||
- Dashboard layout shows header with navigation
|
||||
- UserNav shows email and plan name
|
||||
- Logout button works
|
||||
- Dashboard page shows plan info
|
||||
- Page redirects to login if not authenticated
|
||||
</verify>
|
||||
<done>
|
||||
Protected dashboard with user navigation and plan info display.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Update home page to redirect appropriately</name>
|
||||
<files>
|
||||
src/app/page.tsx
|
||||
</files>
|
||||
<action>
|
||||
Update the home page to redirect based on auth state.
|
||||
|
||||
Modify src/app/page.tsx:
|
||||
|
||||
```typescript
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export default async function Home() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
// If logged in, redirect to dashboard
|
||||
if (user) {
|
||||
redirect('/dashboard')
|
||||
}
|
||||
|
||||
// Landing page for non-authenticated users
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col items-center justify-center p-8 bg-gradient-to-b from-blue-50 to-white">
|
||||
<div className="text-center max-w-2xl">
|
||||
<h1 className="text-5xl font-bold text-gray-900 mb-4">
|
||||
Leopost
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 mb-8">
|
||||
Il tuo social media manager potenziato dall'AI.
|
||||
<br />
|
||||
Minimo sforzo, massima resa.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Link href="/register">
|
||||
<Button size="lg">
|
||||
Inizia gratis
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/login">
|
||||
<Button variant="outline" size="lg">
|
||||
Accedi
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-sm text-gray-500">
|
||||
Nessuna carta richiesta. Piano gratuito disponibile.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
This creates a simple landing page that:
|
||||
- Redirects authenticated users to dashboard
|
||||
- Shows value proposition to visitors
|
||||
- Provides clear CTAs (register/login)
|
||||
- Italian copy reflecting the core value
|
||||
</action>
|
||||
<verify>
|
||||
- Visiting / when logged in redirects to /dashboard
|
||||
- Visiting / when logged out shows landing page
|
||||
- Register and Login buttons work
|
||||
</verify>
|
||||
<done>
|
||||
Home page with auth-aware redirect and landing content.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
After all tasks complete:
|
||||
1. Visit /dashboard when logged out -> redirects to /login
|
||||
2. Login successfully -> redirects to /dashboard
|
||||
3. Refresh /dashboard -> stays authenticated (session persists)
|
||||
4. Click "Esci" -> logs out and redirects to /login
|
||||
5. Visit / when logged in -> redirects to /dashboard
|
||||
6. Visit /login when logged in -> redirects to /dashboard
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Middleware refreshes session on every request
|
||||
- Protected routes redirect unauthenticated users
|
||||
- Auth routes redirect authenticated users
|
||||
- Logout works and clears session
|
||||
- No infinite redirect loops
|
||||
- Dashboard displays user's plan info
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-foundation-auth/01-05-SUMMARY.md`
|
||||
</output>
|
||||
639
.planning/phases/01-foundation-auth/01-06-PLAN.md
Normal file
639
.planning/phases/01-foundation-auth/01-06-PLAN.md
Normal file
@@ -0,0 +1,639 @@
|
||||
---
|
||||
phase: 01-foundation-auth
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["01-05"]
|
||||
files_modified:
|
||||
- src/app/(dashboard)/subscription/page.tsx
|
||||
- src/app/actions/subscription.ts
|
||||
- src/components/subscription/plan-card.tsx
|
||||
- src/lib/plans.ts
|
||||
autonomous: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can view all available plans (Free, Creator, Pro)"
|
||||
- "User can see their current plan highlighted"
|
||||
- "User can switch to a different plan"
|
||||
- "Plan change updates profile in database"
|
||||
- "Plan features are displayed clearly"
|
||||
artifacts:
|
||||
- path: "src/app/(dashboard)/subscription/page.tsx"
|
||||
provides: "Subscription management page"
|
||||
min_lines: 30
|
||||
- path: "src/app/actions/subscription.ts"
|
||||
provides: "Server action for plan switching"
|
||||
exports: ["switchPlan"]
|
||||
- path: "src/components/subscription/plan-card.tsx"
|
||||
provides: "Reusable plan display component"
|
||||
exports: ["PlanCard"]
|
||||
key_links:
|
||||
- from: "src/components/subscription/plan-card.tsx"
|
||||
to: "src/app/actions/subscription.ts"
|
||||
via: "switchPlan action"
|
||||
pattern: "switchPlan"
|
||||
- from: "src/app/actions/subscription.ts"
|
||||
to: "profiles table"
|
||||
via: "update plan_id"
|
||||
pattern: "update.*plan_id"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Implement subscription management allowing users to view plans and switch between them.
|
||||
|
||||
Purpose: Enable users to view and change their subscription plan per AUTH-03 requirement. Payment integration deferred to later phase.
|
||||
|
||||
Output: Working subscription page where users can view all plans and switch their plan.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@C:\Users\miche\.claude/get-shit-done/workflows/execute-plan.md
|
||||
@C:\Users\miche\.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/01-foundation-auth/01-RESEARCH.md
|
||||
@.planning/phases/01-foundation-auth/01-02-SUMMARY.md
|
||||
@.planning/phases/01-foundation-auth/01-05-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create plan utilities and types</name>
|
||||
<files>
|
||||
src/lib/plans.ts
|
||||
src/types/database.ts
|
||||
</files>
|
||||
<action>
|
||||
Create type definitions at src/types/database.ts:
|
||||
|
||||
```typescript
|
||||
export interface Plan {
|
||||
id: string
|
||||
name: 'free' | 'creator' | 'pro'
|
||||
display_name: string
|
||||
display_name_it: string
|
||||
price_monthly: number
|
||||
features: PlanFeatures
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface PlanFeatures {
|
||||
posts_per_month: number
|
||||
ai_models: string[]
|
||||
social_accounts: number
|
||||
image_generation: boolean
|
||||
automation: boolean | 'manual' | 'full'
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
id: string
|
||||
tenant_id: string
|
||||
plan_id: string
|
||||
email: string
|
||||
full_name: string | null
|
||||
avatar_url: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
plans?: Plan
|
||||
}
|
||||
```
|
||||
|
||||
Create plan utilities at src/lib/plans.ts:
|
||||
|
||||
```typescript
|
||||
import { PlanFeatures } from '@/types/database'
|
||||
|
||||
export const PLAN_DISPLAY_ORDER = ['free', 'creator', 'pro'] as const
|
||||
|
||||
// Feature display names in Italian
|
||||
export const FEATURE_LABELS: Record<keyof PlanFeatures, string> = {
|
||||
posts_per_month: 'Post al mese',
|
||||
ai_models: 'Modelli AI',
|
||||
social_accounts: 'Account social',
|
||||
image_generation: 'Generazione immagini',
|
||||
automation: 'Automazione',
|
||||
}
|
||||
|
||||
export function formatFeatureValue(
|
||||
key: keyof PlanFeatures,
|
||||
value: PlanFeatures[keyof PlanFeatures]
|
||||
): string {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Incluso' : 'Non incluso'
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.length.toString()
|
||||
}
|
||||
|
||||
if (key === 'automation') {
|
||||
if (value === 'manual') return 'Solo manuale'
|
||||
if (value === 'full') return 'Completa'
|
||||
return 'Non inclusa'
|
||||
}
|
||||
|
||||
return value.toString()
|
||||
}
|
||||
|
||||
export function formatPrice(cents: number): string {
|
||||
if (cents === 0) return 'Gratis'
|
||||
return `€${(cents / 100).toFixed(0)}/mese`
|
||||
}
|
||||
|
||||
export function getPlanBadgeColor(planName: string): string {
|
||||
switch (planName) {
|
||||
case 'pro':
|
||||
return 'bg-purple-100 text-purple-800 border-purple-200'
|
||||
case 'creator':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
These utilities:
|
||||
- Define TypeScript types for plans
|
||||
- Provide Italian labels for features
|
||||
- Format prices and feature values
|
||||
- Handle plan badge colors
|
||||
</action>
|
||||
<verify>
|
||||
- Both files exist
|
||||
- Types are correctly defined
|
||||
- Utility functions work
|
||||
- No TypeScript errors
|
||||
</verify>
|
||||
<done>
|
||||
Plan types and utility functions created.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create plan card component and switch action</name>
|
||||
<files>
|
||||
src/components/subscription/plan-card.tsx
|
||||
src/app/actions/subscription.ts
|
||||
</files>
|
||||
<action>
|
||||
Create plan card component at src/components/subscription/plan-card.tsx:
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { Plan, PlanFeatures } from '@/types/database'
|
||||
import { formatFeatureValue, formatPrice, FEATURE_LABELS, getPlanBadgeColor } from '@/lib/plans'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { switchPlan } from '@/app/actions/subscription'
|
||||
import { useTransition } from 'react'
|
||||
|
||||
interface PlanCardProps {
|
||||
plan: Plan
|
||||
isCurrentPlan: boolean
|
||||
onPlanChange?: () => void
|
||||
}
|
||||
|
||||
export function PlanCard({ plan, isCurrentPlan, onPlanChange }: PlanCardProps) {
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const features = plan.features as PlanFeatures
|
||||
|
||||
function handleSwitchPlan() {
|
||||
startTransition(async () => {
|
||||
const result = await switchPlan(plan.id)
|
||||
if (result.success && onPlanChange) {
|
||||
onPlanChange()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Highlight features to show
|
||||
const displayFeatures: (keyof PlanFeatures)[] = [
|
||||
'posts_per_month',
|
||||
'social_accounts',
|
||||
'ai_models',
|
||||
'image_generation',
|
||||
'automation',
|
||||
]
|
||||
|
||||
return (
|
||||
<Card className={`relative ${isCurrentPlan ? 'ring-2 ring-blue-500' : ''}`}>
|
||||
{isCurrentPlan && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<span className="bg-blue-500 text-white text-xs font-medium px-3 py-1 rounded-full">
|
||||
Piano attuale
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader className="text-center pt-8">
|
||||
<div className="mb-2">
|
||||
<span className={`inline-block px-3 py-1 text-xs font-medium rounded-full border ${getPlanBadgeColor(plan.name)}`}>
|
||||
{plan.display_name_it}
|
||||
</span>
|
||||
</div>
|
||||
<CardTitle className="text-3xl">
|
||||
{formatPrice(plan.price_monthly)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{plan.name === 'free' && 'Perfetto per iniziare'}
|
||||
{plan.name === 'creator' && 'Per creator seri'}
|
||||
{plan.name === 'pro' && 'Per professionisti'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<ul className="space-y-3">
|
||||
{displayFeatures.map((featureKey) => {
|
||||
const value = features[featureKey]
|
||||
const isIncluded = value !== false && value !== 'non incluso'
|
||||
|
||||
return (
|
||||
<li key={featureKey} className="flex items-center gap-2">
|
||||
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
|
||||
isIncluded ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'
|
||||
}`}>
|
||||
{isIncluded ? '✓' : '—'}
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium">
|
||||
{formatFeatureValue(featureKey, value)}
|
||||
</span>
|
||||
{' '}
|
||||
<span className="text-gray-500">
|
||||
{FEATURE_LABELS[featureKey].toLowerCase()}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter>
|
||||
{isCurrentPlan ? (
|
||||
<Button variant="outline" className="w-full" disabled>
|
||||
Piano attuale
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleSwitchPlan}
|
||||
disabled={isPending}
|
||||
variant={plan.name === 'pro' ? 'default' : 'outline'}
|
||||
>
|
||||
{isPending ? 'Cambio in corso...' : (
|
||||
plan.price_monthly === 0 ? 'Passa a Gratuito' : `Passa a ${plan.display_name_it}`
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Create subscription action at src/app/actions/subscription.ts:
|
||||
|
||||
```typescript
|
||||
'use server'
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export type SubscriptionActionState = {
|
||||
success?: boolean
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function switchPlan(planId: string): Promise<SubscriptionActionState> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Get current user
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
|
||||
if (authError || !user) {
|
||||
return { error: 'Devi essere autenticato per cambiare piano' }
|
||||
}
|
||||
|
||||
// Verify plan exists
|
||||
const { data: plan, error: planError } = await supabase
|
||||
.from('plans')
|
||||
.select('id, name, display_name_it')
|
||||
.eq('id', planId)
|
||||
.single()
|
||||
|
||||
if (planError || !plan) {
|
||||
return { error: 'Piano non trovato' }
|
||||
}
|
||||
|
||||
// Update user's plan
|
||||
const { error: updateError } = await supabase
|
||||
.from('profiles')
|
||||
.update({ plan_id: planId })
|
||||
.eq('id', user.id)
|
||||
|
||||
if (updateError) {
|
||||
console.error('Failed to update plan:', updateError)
|
||||
return { error: 'Errore durante il cambio piano. Riprova.' }
|
||||
}
|
||||
|
||||
// Revalidate pages that show plan info
|
||||
revalidatePath('/dashboard')
|
||||
revalidatePath('/subscription')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Piano cambiato a ${plan.display_name_it}`
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentPlan() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select(`
|
||||
plan_id,
|
||||
plans (
|
||||
id,
|
||||
name,
|
||||
display_name,
|
||||
display_name_it,
|
||||
price_monthly,
|
||||
features
|
||||
)
|
||||
`)
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
return profile?.plans || null
|
||||
}
|
||||
```
|
||||
|
||||
NOTE: This is a simplified plan switching for v1. In production:
|
||||
- Payment would be processed before switching to paid plans
|
||||
- Downgrade might be scheduled for end of billing period
|
||||
- Proration logic would be needed
|
||||
- These complexities are deferred per CONTEXT.md
|
||||
</action>
|
||||
<verify>
|
||||
- PlanCard component renders all plan features
|
||||
- switchPlan action updates database
|
||||
- Current plan is highlighted
|
||||
- Non-current plans have switch button
|
||||
</verify>
|
||||
<done>
|
||||
Plan card component and switch action created.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Create subscription page</name>
|
||||
<files>
|
||||
src/app/(dashboard)/subscription/page.tsx
|
||||
</files>
|
||||
<action>
|
||||
Create subscription management page at src/app/(dashboard)/subscription/page.tsx:
|
||||
|
||||
```typescript
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { PlanCard } from '@/components/subscription/plan-card'
|
||||
import { Plan } from '@/types/database'
|
||||
import { PLAN_DISPLAY_ORDER } from '@/lib/plans'
|
||||
|
||||
export default async function SubscriptionPage() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Get user's current plan
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('plan_id')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
// Get all plans
|
||||
const { data: plans, error: plansError } = await supabase
|
||||
.from('plans')
|
||||
.select('*')
|
||||
.order('price_monthly', { ascending: true })
|
||||
|
||||
if (plansError || !plans) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600">Errore nel caricamento dei piani</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Sort plans by our display order
|
||||
const sortedPlans = [...plans].sort((a, b) => {
|
||||
return PLAN_DISPLAY_ORDER.indexOf(a.name as typeof PLAN_DISPLAY_ORDER[number]) -
|
||||
PLAN_DISPLAY_ORDER.indexOf(b.name as typeof PLAN_DISPLAY_ORDER[number])
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Il tuo abbonamento</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Scegli il piano piu adatto alle tue esigenze
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info banner */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Nota:</strong> Il pagamento verra implementato nelle prossime versioni.
|
||||
Per ora puoi passare liberamente tra i piani per testare le funzionalita.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Plans grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{sortedPlans.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.id}
|
||||
plan={plan as Plan}
|
||||
isCurrentPlan={plan.id === profile?.plan_id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Feature comparison */}
|
||||
<div className="mt-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
Confronto funzionalita
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">
|
||||
Funzionalita
|
||||
</th>
|
||||
{sortedPlans.map((plan) => (
|
||||
<th key={plan.id} className="text-center py-3 px-4 font-medium text-gray-900">
|
||||
{plan.display_name_it}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<FeatureRow
|
||||
feature="Post al mese"
|
||||
plans={sortedPlans}
|
||||
getValue={(p) => (p.features as Plan['features']).posts_per_month.toString()}
|
||||
/>
|
||||
<FeatureRow
|
||||
feature="Account social"
|
||||
plans={sortedPlans}
|
||||
getValue={(p) => (p.features as Plan['features']).social_accounts.toString()}
|
||||
/>
|
||||
<FeatureRow
|
||||
feature="Modelli AI"
|
||||
plans={sortedPlans}
|
||||
getValue={(p) => (p.features as Plan['features']).ai_models.length.toString()}
|
||||
/>
|
||||
<FeatureRow
|
||||
feature="Generazione immagini"
|
||||
plans={sortedPlans}
|
||||
getValue={(p) => (p.features as Plan['features']).image_generation ? '✓' : '—'}
|
||||
/>
|
||||
<FeatureRow
|
||||
feature="Automazione"
|
||||
plans={sortedPlans}
|
||||
getValue={(p) => {
|
||||
const auto = (p.features as Plan['features']).automation
|
||||
if (auto === false) return '—'
|
||||
if (auto === 'manual') return 'Manuale'
|
||||
if (auto === 'full') return 'Completa'
|
||||
return '—'
|
||||
}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAQ */}
|
||||
<div className="mt-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
Domande frequenti
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<FaqItem
|
||||
question="Posso cambiare piano in qualsiasi momento?"
|
||||
answer="Si, puoi passare a un piano superiore o inferiore quando vuoi. Le modifiche sono immediate."
|
||||
/>
|
||||
<FaqItem
|
||||
question="Cosa succede se supero i limiti del mio piano?"
|
||||
answer="Riceverai un avviso quando ti avvicini al limite mensile. Non potrai creare nuovi post fino al rinnovo o all'upgrade."
|
||||
/>
|
||||
<FaqItem
|
||||
question="Come funziona il pagamento?"
|
||||
answer="Il sistema di pagamento verra implementato a breve. Per ora tutti i piani sono disponibili gratuitamente per i test."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureRow({
|
||||
feature,
|
||||
plans,
|
||||
getValue,
|
||||
}: {
|
||||
feature: string
|
||||
plans: Plan[]
|
||||
getValue: (plan: Plan) => string
|
||||
}) {
|
||||
return (
|
||||
<tr className="border-b">
|
||||
<td className="py-3 px-4 text-gray-600">{feature}</td>
|
||||
{plans.map((plan) => (
|
||||
<td key={plan.id} className="text-center py-3 px-4">
|
||||
{getValue(plan)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="font-medium text-gray-900 mb-2">{question}</h3>
|
||||
<p className="text-sm text-gray-600">{answer}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
This page:
|
||||
- Shows all three plans in cards
|
||||
- Highlights current plan
|
||||
- Allows switching between plans
|
||||
- Shows feature comparison table
|
||||
- Includes FAQ section
|
||||
- Notes that payment is deferred (transparency)
|
||||
- All text in Italian
|
||||
</action>
|
||||
<verify>
|
||||
- Page loads at /subscription
|
||||
- All three plans display
|
||||
- Current plan is highlighted
|
||||
- Can click to switch plans
|
||||
- Feature comparison table is accurate
|
||||
</verify>
|
||||
<done>
|
||||
Complete subscription management page with plan display and switching.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
After all tasks complete:
|
||||
1. Visit /subscription when logged in
|
||||
2. See all three plans (Free, Creator, Pro)
|
||||
3. Current plan is highlighted with "Piano attuale" badge
|
||||
4. Click switch button on different plan -> plan changes
|
||||
5. Dashboard reflects new plan after switch
|
||||
6. Feature comparison table shows correct values
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All three plans display with correct pricing
|
||||
- User can view their current plan
|
||||
- User can switch to a different plan
|
||||
- Plan switch updates database (verify in Supabase)
|
||||
- Feature limits are clearly displayed
|
||||
- All text is in Italian
|
||||
- Note about payment deferral is visible
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-foundation-auth/01-06-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user