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:
Michele
2026-01-31 03:12:38 +01:00
parent 6a969bccc8
commit bd3e1074a8
6 changed files with 3388 additions and 0 deletions

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>