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>
1030 lines
32 KiB
Markdown
1030 lines
32 KiB
Markdown
---
|
|
phase: 01-foundation-auth
|
|
plan: 03
|
|
type: execute
|
|
wave: 2
|
|
depends_on: ["01-01", "01-02"]
|
|
files_modified:
|
|
- src/lib/schemas/auth.ts
|
|
- src/app/actions/auth.ts
|
|
- src/app/(auth)/layout.tsx
|
|
- src/app/(auth)/register/page.tsx
|
|
- src/app/(auth)/login/page.tsx
|
|
- src/app/(auth)/verify-email/page.tsx
|
|
- src/app/(auth)/reset-password/page.tsx
|
|
- src/app/(auth)/update-password/page.tsx
|
|
- src/app/auth/callback/route.ts
|
|
- src/components/ui/button.tsx
|
|
- src/components/ui/input.tsx
|
|
- src/components/ui/card.tsx
|
|
- src/components/auth/register-form.tsx
|
|
- src/components/auth/login-form.tsx
|
|
autonomous: true
|
|
|
|
must_haves:
|
|
truths:
|
|
- "User can register with email and password"
|
|
- "User receives verification email after registration"
|
|
- "User cannot access app until email is verified"
|
|
- "User can log in with verified email/password"
|
|
- "User sees specific error messages (not generic)"
|
|
- "User can reset password via email link"
|
|
artifacts:
|
|
- path: "src/app/actions/auth.ts"
|
|
provides: "Server actions for auth operations"
|
|
exports: ["registerUser", "loginUser", "resetPassword", "updatePassword"]
|
|
- path: "src/app/(auth)/register/page.tsx"
|
|
provides: "Registration page"
|
|
min_lines: 20
|
|
- path: "src/app/(auth)/login/page.tsx"
|
|
provides: "Login page"
|
|
min_lines: 20
|
|
- path: "src/lib/schemas/auth.ts"
|
|
provides: "Zod validation schemas"
|
|
exports: ["registerSchema", "loginSchema"]
|
|
key_links:
|
|
- from: "src/components/auth/register-form.tsx"
|
|
to: "src/app/actions/auth.ts"
|
|
via: "Server Action call"
|
|
pattern: "registerUser"
|
|
- from: "src/app/auth/callback/route.ts"
|
|
to: "Supabase Auth"
|
|
via: "exchangeCodeForSession"
|
|
pattern: "exchangeCodeForSession"
|
|
---
|
|
|
|
<objective>
|
|
Implement complete email/password authentication flow with registration, login, email verification, and password reset.
|
|
|
|
Purpose: Enable users to create accounts and log in with email/password per AUTH-01 requirement. Email verification is mandatory per user decision.
|
|
|
|
Output: Working auth flow where users can register, verify email, log in, and reset password.
|
|
</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-CONTEXT.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 validation schemas and server actions</name>
|
|
<files>
|
|
src/lib/schemas/auth.ts
|
|
src/app/actions/auth.ts
|
|
</files>
|
|
<action>
|
|
Create validation schemas in src/lib/schemas/auth.ts:
|
|
|
|
```typescript
|
|
import { z } from 'zod'
|
|
|
|
export const registerSchema = z.object({
|
|
email: z.string()
|
|
.email('Email non valida'),
|
|
password: z.string()
|
|
.min(8, 'La password deve contenere almeno 8 caratteri')
|
|
.regex(/[0-9]/, 'La password deve contenere almeno un numero')
|
|
.regex(/[A-Z]/, 'La password deve contenere almeno una lettera maiuscola'),
|
|
confirmPassword: z.string()
|
|
}).refine((data) => data.password === data.confirmPassword, {
|
|
message: 'Le password non coincidono',
|
|
path: ['confirmPassword'],
|
|
})
|
|
|
|
export const loginSchema = z.object({
|
|
email: z.string().email('Email non valida'),
|
|
password: z.string().min(1, 'Password richiesta'),
|
|
})
|
|
|
|
export const resetPasswordSchema = z.object({
|
|
email: z.string().email('Email non valida'),
|
|
})
|
|
|
|
export const updatePasswordSchema = z.object({
|
|
password: z.string()
|
|
.min(8, 'La password deve contenere almeno 8 caratteri')
|
|
.regex(/[0-9]/, 'La password deve contenere almeno un numero')
|
|
.regex(/[A-Z]/, 'La password deve contenere almeno una lettera maiuscola'),
|
|
confirmPassword: z.string()
|
|
}).refine((data) => data.password === data.confirmPassword, {
|
|
message: 'Le password non coincidono',
|
|
path: ['confirmPassword'],
|
|
})
|
|
|
|
export type RegisterInput = z.infer<typeof registerSchema>
|
|
export type LoginInput = z.infer<typeof loginSchema>
|
|
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>
|
|
export type UpdatePasswordInput = z.infer<typeof updatePasswordSchema>
|
|
```
|
|
|
|
Create server actions in src/app/actions/auth.ts:
|
|
|
|
```typescript
|
|
'use server'
|
|
|
|
import { createClient } from '@/lib/supabase/server'
|
|
import { registerSchema, loginSchema, resetPasswordSchema, updatePasswordSchema } from '@/lib/schemas/auth'
|
|
import { redirect } from 'next/navigation'
|
|
import { revalidatePath } from 'next/cache'
|
|
|
|
export type ActionState = {
|
|
error?: string
|
|
fieldErrors?: Record<string, string[]>
|
|
success?: boolean
|
|
message?: string
|
|
}
|
|
|
|
export async function registerUser(
|
|
prevState: ActionState,
|
|
formData: FormData
|
|
): Promise<ActionState> {
|
|
const supabase = await createClient()
|
|
|
|
const parsed = registerSchema.safeParse({
|
|
email: formData.get('email'),
|
|
password: formData.get('password'),
|
|
confirmPassword: formData.get('confirmPassword'),
|
|
})
|
|
|
|
if (!parsed.success) {
|
|
return {
|
|
fieldErrors: parsed.error.flatten().fieldErrors,
|
|
}
|
|
}
|
|
|
|
const { data, error } = await supabase.auth.signUp({
|
|
email: parsed.data.email,
|
|
password: parsed.data.password,
|
|
options: {
|
|
emailRedirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback`,
|
|
}
|
|
})
|
|
|
|
if (error) {
|
|
// SPECIFIC error messages per user requirement
|
|
if (error.message.includes('already registered')) {
|
|
return { error: 'Questa email e gia registrata' }
|
|
}
|
|
if (error.message.includes('invalid')) {
|
|
return { error: 'Email non valida' }
|
|
}
|
|
return { error: error.message }
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Registrazione completata! Controlla la tua email per confermare l\'account.'
|
|
}
|
|
}
|
|
|
|
export async function loginUser(
|
|
prevState: ActionState,
|
|
formData: FormData
|
|
): Promise<ActionState> {
|
|
const supabase = await createClient()
|
|
|
|
const parsed = loginSchema.safeParse({
|
|
email: formData.get('email'),
|
|
password: formData.get('password'),
|
|
})
|
|
|
|
if (!parsed.success) {
|
|
return {
|
|
fieldErrors: parsed.error.flatten().fieldErrors,
|
|
}
|
|
}
|
|
|
|
const { data, error } = await supabase.auth.signInWithPassword({
|
|
email: parsed.data.email,
|
|
password: parsed.data.password,
|
|
})
|
|
|
|
if (error) {
|
|
// SPECIFIC error messages per user requirement
|
|
if (error.message.includes('Invalid login credentials')) {
|
|
return { error: 'Email o password errata' }
|
|
}
|
|
if (error.message.includes('Email not confirmed')) {
|
|
return { error: 'Devi confermare la tua email prima di accedere' }
|
|
}
|
|
return { error: error.message }
|
|
}
|
|
|
|
revalidatePath('/', 'layout')
|
|
redirect('/dashboard')
|
|
}
|
|
|
|
export async function resetPassword(
|
|
prevState: ActionState,
|
|
formData: FormData
|
|
): Promise<ActionState> {
|
|
const supabase = await createClient()
|
|
|
|
const parsed = resetPasswordSchema.safeParse({
|
|
email: formData.get('email'),
|
|
})
|
|
|
|
if (!parsed.success) {
|
|
return {
|
|
fieldErrors: parsed.error.flatten().fieldErrors,
|
|
}
|
|
}
|
|
|
|
const { error } = await supabase.auth.resetPasswordForEmail(
|
|
parsed.data.email,
|
|
{
|
|
redirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback?next=/update-password`,
|
|
}
|
|
)
|
|
|
|
if (error) {
|
|
return { error: error.message }
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Se l\'email esiste, riceverai un link per reimpostare la password.'
|
|
}
|
|
}
|
|
|
|
export async function updatePassword(
|
|
prevState: ActionState,
|
|
formData: FormData
|
|
): Promise<ActionState> {
|
|
const supabase = await createClient()
|
|
|
|
const parsed = updatePasswordSchema.safeParse({
|
|
password: formData.get('password'),
|
|
confirmPassword: formData.get('confirmPassword'),
|
|
})
|
|
|
|
if (!parsed.success) {
|
|
return {
|
|
fieldErrors: parsed.error.flatten().fieldErrors,
|
|
}
|
|
}
|
|
|
|
const { error } = await supabase.auth.updateUser({
|
|
password: parsed.data.password,
|
|
})
|
|
|
|
if (error) {
|
|
return { error: error.message }
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Password aggiornata con successo!'
|
|
}
|
|
}
|
|
|
|
export async function signOut() {
|
|
const supabase = await createClient()
|
|
await supabase.auth.signOut()
|
|
revalidatePath('/', 'layout')
|
|
redirect('/login')
|
|
}
|
|
```
|
|
|
|
Password requirements per CONTEXT.md decision:
|
|
- Minimum 8 characters
|
|
- At least 1 number
|
|
- At least 1 uppercase letter
|
|
|
|
Error messages are SPECIFIC per user requirement (not generic "invalid credentials").
|
|
</action>
|
|
<verify>
|
|
- Both files exist
|
|
- Zod schemas validate password requirements correctly
|
|
- Server actions export proper types
|
|
- Error messages are in Italian and specific
|
|
</verify>
|
|
<done>
|
|
Validation schemas and server actions for all auth operations created.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create auth callback route and UI components</name>
|
|
<files>
|
|
src/app/auth/callback/route.ts
|
|
src/components/ui/button.tsx
|
|
src/components/ui/input.tsx
|
|
src/components/ui/card.tsx
|
|
</files>
|
|
<action>
|
|
Create OAuth/email callback handler at src/app/auth/callback/route.ts:
|
|
|
|
```typescript
|
|
import { createClient } from '@/lib/supabase/server'
|
|
import { NextResponse } from 'next/server'
|
|
|
|
export async function GET(request: Request) {
|
|
const { searchParams, origin } = new URL(request.url)
|
|
const code = searchParams.get('code')
|
|
const next = searchParams.get('next') ?? '/dashboard'
|
|
|
|
if (code) {
|
|
const supabase = await createClient()
|
|
const { error } = await supabase.auth.exchangeCodeForSession(code)
|
|
|
|
if (!error) {
|
|
return NextResponse.redirect(`${origin}${next}`)
|
|
}
|
|
}
|
|
|
|
// Return the user to an error page with instructions
|
|
return NextResponse.redirect(`${origin}/login?error=auth_callback_error`)
|
|
}
|
|
```
|
|
|
|
Create basic UI components. Keep them minimal but functional:
|
|
|
|
src/components/ui/button.tsx:
|
|
```typescript
|
|
import { forwardRef, ButtonHTMLAttributes } from 'react'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
variant?: 'default' | 'outline' | 'ghost'
|
|
size?: 'default' | 'sm' | 'lg'
|
|
}
|
|
|
|
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
({ className, variant = 'default', size = 'default', ...props }, ref) => {
|
|
return (
|
|
<button
|
|
className={cn(
|
|
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
|
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
|
|
'disabled:pointer-events-none disabled:opacity-50',
|
|
{
|
|
'bg-blue-600 text-white hover:bg-blue-700': variant === 'default',
|
|
'border border-gray-300 bg-white hover:bg-gray-50': variant === 'outline',
|
|
'hover:bg-gray-100': variant === 'ghost',
|
|
},
|
|
{
|
|
'h-10 px-4 py-2': size === 'default',
|
|
'h-8 px-3 text-sm': size === 'sm',
|
|
'h-12 px-6 text-lg': size === 'lg',
|
|
},
|
|
className
|
|
)}
|
|
ref={ref}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
)
|
|
Button.displayName = 'Button'
|
|
|
|
export { Button }
|
|
```
|
|
|
|
src/components/ui/input.tsx:
|
|
```typescript
|
|
import { forwardRef, InputHTMLAttributes } from 'react'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
error?: boolean
|
|
}
|
|
|
|
const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
({ className, error, ...props }, ref) => {
|
|
return (
|
|
<input
|
|
className={cn(
|
|
'flex h-10 w-full rounded-md border bg-white px-3 py-2 text-sm',
|
|
'placeholder:text-gray-400',
|
|
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1',
|
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
error ? 'border-red-500' : 'border-gray-300',
|
|
className
|
|
)}
|
|
ref={ref}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
)
|
|
Input.displayName = 'Input'
|
|
|
|
export { Input }
|
|
```
|
|
|
|
src/components/ui/card.tsx:
|
|
```typescript
|
|
import { HTMLAttributes, forwardRef } from 'react'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
({ className, ...props }, ref) => (
|
|
<div
|
|
ref={ref}
|
|
className={cn(
|
|
'rounded-lg border border-gray-200 bg-white shadow-sm',
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
)
|
|
)
|
|
Card.displayName = 'Card'
|
|
|
|
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
({ className, ...props }, ref) => (
|
|
<div
|
|
ref={ref}
|
|
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
)
|
|
CardHeader.displayName = 'CardHeader'
|
|
|
|
const CardTitle = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLHeadingElement>>(
|
|
({ className, ...props }, ref) => (
|
|
<h3
|
|
ref={ref}
|
|
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
)
|
|
CardTitle.displayName = 'CardTitle'
|
|
|
|
const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
|
|
({ className, ...props }, ref) => (
|
|
<p
|
|
ref={ref}
|
|
className={cn('text-sm text-gray-500', className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
)
|
|
CardDescription.displayName = 'CardDescription'
|
|
|
|
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
({ className, ...props }, ref) => (
|
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
|
)
|
|
)
|
|
CardContent.displayName = 'CardContent'
|
|
|
|
const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
({ className, ...props }, ref) => (
|
|
<div
|
|
ref={ref}
|
|
className={cn('flex items-center p-6 pt-0', className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
)
|
|
CardFooter.displayName = 'CardFooter'
|
|
|
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
|
```
|
|
|
|
Create utility function src/lib/utils.ts:
|
|
```typescript
|
|
import { type ClassValue, clsx } from 'clsx'
|
|
import { twMerge } from 'tailwind-merge'
|
|
|
|
export function cn(...inputs: ClassValue[]) {
|
|
return twMerge(clsx(inputs))
|
|
}
|
|
```
|
|
|
|
Install clsx and tailwind-merge:
|
|
```bash
|
|
npm install clsx tailwind-merge
|
|
```
|
|
</action>
|
|
<verify>
|
|
- Callback route handles code exchange
|
|
- UI components render without errors
|
|
- cn utility works for class merging
|
|
</verify>
|
|
<done>
|
|
Auth callback and reusable UI components created.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Create auth pages (register, login, verify, reset)</name>
|
|
<files>
|
|
src/app/(auth)/layout.tsx
|
|
src/app/(auth)/register/page.tsx
|
|
src/app/(auth)/login/page.tsx
|
|
src/app/(auth)/verify-email/page.tsx
|
|
src/app/(auth)/reset-password/page.tsx
|
|
src/app/(auth)/update-password/page.tsx
|
|
src/components/auth/register-form.tsx
|
|
src/components/auth/login-form.tsx
|
|
</files>
|
|
<action>
|
|
Create auth layout at src/app/(auth)/layout.tsx:
|
|
```typescript
|
|
export default function AuthLayout({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode
|
|
}) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
|
<div className="max-w-md w-full space-y-8">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
Create register form component at src/components/auth/register-form.tsx:
|
|
```typescript
|
|
'use client'
|
|
|
|
import { useActionState } from 'react'
|
|
import { registerUser, ActionState } from '@/app/actions/auth'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import Link from 'next/link'
|
|
|
|
const initialState: ActionState = {}
|
|
|
|
export function RegisterForm() {
|
|
const [state, formAction, pending] = useActionState(registerUser, initialState)
|
|
|
|
if (state.success) {
|
|
return (
|
|
<div className="text-center">
|
|
<div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-md">
|
|
<p className="text-green-800">{state.message}</p>
|
|
</div>
|
|
<Link href="/login" className="text-blue-600 hover:underline">
|
|
Torna al login
|
|
</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<form action={formAction} className="space-y-4">
|
|
{state.error && (
|
|
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
|
|
<p className="text-red-800 text-sm">{state.error}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
|
Email
|
|
</label>
|
|
<Input
|
|
id="email"
|
|
name="email"
|
|
type="email"
|
|
autoComplete="email"
|
|
required
|
|
placeholder="tu@esempio.com"
|
|
error={!!state.fieldErrors?.email}
|
|
/>
|
|
{state.fieldErrors?.email && (
|
|
<p className="mt-1 text-sm text-red-600">{state.fieldErrors.email[0]}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
|
Password
|
|
</label>
|
|
<Input
|
|
id="password"
|
|
name="password"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
required
|
|
placeholder="Minimo 8 caratteri"
|
|
error={!!state.fieldErrors?.password}
|
|
/>
|
|
{state.fieldErrors?.password && (
|
|
<p className="mt-1 text-sm text-red-600">{state.fieldErrors.password[0]}</p>
|
|
)}
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
Almeno 8 caratteri, 1 numero, 1 maiuscola
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
|
Conferma Password
|
|
</label>
|
|
<Input
|
|
id="confirmPassword"
|
|
name="confirmPassword"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
required
|
|
placeholder="Ripeti la password"
|
|
error={!!state.fieldErrors?.confirmPassword}
|
|
/>
|
|
{state.fieldErrors?.confirmPassword && (
|
|
<p className="mt-1 text-sm text-red-600">{state.fieldErrors.confirmPassword[0]}</p>
|
|
)}
|
|
</div>
|
|
|
|
<Button type="submit" className="w-full" disabled={pending}>
|
|
{pending ? 'Registrazione...' : 'Registrati'}
|
|
</Button>
|
|
|
|
<p className="text-center text-sm text-gray-600">
|
|
Hai gia un account?{' '}
|
|
<Link href="/login" className="text-blue-600 hover:underline">
|
|
Accedi
|
|
</Link>
|
|
</p>
|
|
</form>
|
|
)
|
|
}
|
|
```
|
|
|
|
Create register page at src/app/(auth)/register/page.tsx:
|
|
```typescript
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { RegisterForm } from '@/components/auth/register-form'
|
|
|
|
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>
|
|
<RegisterForm />
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
```
|
|
|
|
Create login form at src/components/auth/login-form.tsx:
|
|
```typescript
|
|
'use client'
|
|
|
|
import { useActionState } from 'react'
|
|
import { loginUser, ActionState } from '@/app/actions/auth'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import Link from 'next/link'
|
|
|
|
const initialState: ActionState = {}
|
|
|
|
export function LoginForm() {
|
|
const [state, formAction, pending] = useActionState(loginUser, initialState)
|
|
|
|
return (
|
|
<form action={formAction} className="space-y-4">
|
|
{state.error && (
|
|
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
|
|
<p className="text-red-800 text-sm">{state.error}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
|
Email
|
|
</label>
|
|
<Input
|
|
id="email"
|
|
name="email"
|
|
type="email"
|
|
autoComplete="email"
|
|
required
|
|
placeholder="tu@esempio.com"
|
|
error={!!state.fieldErrors?.email}
|
|
/>
|
|
{state.fieldErrors?.email && (
|
|
<p className="mt-1 text-sm text-red-600">{state.fieldErrors.email[0]}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
|
Password
|
|
</label>
|
|
<Input
|
|
id="password"
|
|
name="password"
|
|
type="password"
|
|
autoComplete="current-password"
|
|
required
|
|
placeholder="La tua password"
|
|
error={!!state.fieldErrors?.password}
|
|
/>
|
|
{state.fieldErrors?.password && (
|
|
<p className="mt-1 text-sm text-red-600">{state.fieldErrors.password[0]}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end">
|
|
<Link href="/reset-password" className="text-sm text-blue-600 hover:underline">
|
|
Password dimenticata?
|
|
</Link>
|
|
</div>
|
|
|
|
<Button type="submit" className="w-full" disabled={pending}>
|
|
{pending ? 'Accesso...' : 'Accedi'}
|
|
</Button>
|
|
|
|
<p className="text-center text-sm text-gray-600">
|
|
Non hai un account?{' '}
|
|
<Link href="/register" className="text-blue-600 hover:underline">
|
|
Registrati
|
|
</Link>
|
|
</p>
|
|
</form>
|
|
)
|
|
}
|
|
```
|
|
|
|
Create login page at src/app/(auth)/login/page.tsx:
|
|
```typescript
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { LoginForm } from '@/components/auth/login-form'
|
|
|
|
export default function LoginPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ error?: string }>
|
|
}) {
|
|
return (
|
|
<Card>
|
|
<CardHeader className="text-center">
|
|
<CardTitle>Accedi a Leopost</CardTitle>
|
|
<CardDescription>
|
|
Inserisci le tue credenziali per continuare
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<LoginForm />
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
```
|
|
|
|
Create verify-email page at src/app/(auth)/verify-email/page.tsx:
|
|
```typescript
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import Link from 'next/link'
|
|
|
|
export default function VerifyEmailPage() {
|
|
return (
|
|
<Card>
|
|
<CardHeader className="text-center">
|
|
<CardTitle>Verifica la tua email</CardTitle>
|
|
<CardDescription>
|
|
Ti abbiamo inviato un link di conferma
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="text-center space-y-4">
|
|
<p className="text-gray-600">
|
|
Controlla la tua casella email e clicca sul link per attivare il tuo account.
|
|
</p>
|
|
<p className="text-sm text-gray-500">
|
|
Non hai ricevuto l'email? Controlla lo spam.
|
|
</p>
|
|
<Link href="/login" className="text-blue-600 hover:underline">
|
|
Torna al login
|
|
</Link>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
```
|
|
|
|
Create reset-password page at src/app/(auth)/reset-password/page.tsx:
|
|
```typescript
|
|
'use client'
|
|
|
|
import { useActionState } from 'react'
|
|
import { resetPassword, ActionState } from '@/app/actions/auth'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import Link from 'next/link'
|
|
|
|
const initialState: ActionState = {}
|
|
|
|
export default function ResetPasswordPage() {
|
|
const [state, formAction, pending] = useActionState(resetPassword, initialState)
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="text-center">
|
|
<CardTitle>Recupera password</CardTitle>
|
|
<CardDescription>
|
|
Inserisci la tua email per ricevere il link di reset
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{state.success ? (
|
|
<div className="text-center space-y-4">
|
|
<div className="p-4 bg-green-50 border border-green-200 rounded-md">
|
|
<p className="text-green-800">{state.message}</p>
|
|
</div>
|
|
<Link href="/login" className="text-blue-600 hover:underline">
|
|
Torna al login
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<form action={formAction} className="space-y-4">
|
|
{state.error && (
|
|
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
|
|
<p className="text-red-800 text-sm">{state.error}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
|
Email
|
|
</label>
|
|
<Input
|
|
id="email"
|
|
name="email"
|
|
type="email"
|
|
autoComplete="email"
|
|
required
|
|
placeholder="tu@esempio.com"
|
|
error={!!state.fieldErrors?.email}
|
|
/>
|
|
{state.fieldErrors?.email && (
|
|
<p className="mt-1 text-sm text-red-600">{state.fieldErrors.email[0]}</p>
|
|
)}
|
|
</div>
|
|
|
|
<Button type="submit" className="w-full" disabled={pending}>
|
|
{pending ? 'Invio...' : 'Invia link di reset'}
|
|
</Button>
|
|
|
|
<p className="text-center text-sm text-gray-600">
|
|
<Link href="/login" className="text-blue-600 hover:underline">
|
|
Torna al login
|
|
</Link>
|
|
</p>
|
|
</form>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
```
|
|
|
|
Create update-password page at src/app/(auth)/update-password/page.tsx:
|
|
```typescript
|
|
'use client'
|
|
|
|
import { useActionState } from 'react'
|
|
import { updatePassword, ActionState } from '@/app/actions/auth'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import Link from 'next/link'
|
|
|
|
const initialState: ActionState = {}
|
|
|
|
export default function UpdatePasswordPage() {
|
|
const [state, formAction, pending] = useActionState(updatePassword, initialState)
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="text-center">
|
|
<CardTitle>Nuova password</CardTitle>
|
|
<CardDescription>
|
|
Inserisci la tua nuova password
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{state.success ? (
|
|
<div className="text-center space-y-4">
|
|
<div className="p-4 bg-green-50 border border-green-200 rounded-md">
|
|
<p className="text-green-800">{state.message}</p>
|
|
</div>
|
|
<Link href="/login" className="text-blue-600 hover:underline">
|
|
Vai al login
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<form action={formAction} className="space-y-4">
|
|
{state.error && (
|
|
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
|
|
<p className="text-red-800 text-sm">{state.error}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
|
Nuova Password
|
|
</label>
|
|
<Input
|
|
id="password"
|
|
name="password"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
required
|
|
placeholder="Minimo 8 caratteri"
|
|
error={!!state.fieldErrors?.password}
|
|
/>
|
|
{state.fieldErrors?.password && (
|
|
<p className="mt-1 text-sm text-red-600">{state.fieldErrors.password[0]}</p>
|
|
)}
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
Almeno 8 caratteri, 1 numero, 1 maiuscola
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
|
Conferma Password
|
|
</label>
|
|
<Input
|
|
id="confirmPassword"
|
|
name="confirmPassword"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
required
|
|
placeholder="Ripeti la password"
|
|
error={!!state.fieldErrors?.confirmPassword}
|
|
/>
|
|
{state.fieldErrors?.confirmPassword && (
|
|
<p className="mt-1 text-sm text-red-600">{state.fieldErrors.confirmPassword[0]}</p>
|
|
)}
|
|
</div>
|
|
|
|
<Button type="submit" className="w-full" disabled={pending}>
|
|
{pending ? 'Aggiornamento...' : 'Aggiorna password'}
|
|
</Button>
|
|
</form>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
```
|
|
|
|
All text is in Italian as per project requirement.
|
|
Uses React 19 useActionState (not deprecated useFormState).
|
|
</action>
|
|
<verify>
|
|
- All auth pages render without errors
|
|
- Forms submit using server actions
|
|
- Error messages display correctly
|
|
- Success states show appropriate messages
|
|
- Navigation links work between pages
|
|
</verify>
|
|
<done>
|
|
Complete email/password auth flow with register, login, verify, and reset pages.
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
After all tasks complete:
|
|
1. `npm run dev` starts without errors
|
|
2. /register page allows form submission
|
|
3. /login page allows form submission
|
|
4. /reset-password page sends reset email
|
|
5. /update-password page updates password
|
|
6. Validation messages appear in Italian
|
|
7. Specific error messages show (not generic)
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- User can complete registration flow (form -> email)
|
|
- User can log in with email/password
|
|
- User sees Italian error messages
|
|
- Password validation enforces medium requirements
|
|
- Password reset sends email link (not code)
|
|
- All forms use Server Actions (not client API calls)
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/01-foundation-auth/01-03-SUMMARY.md`
|
|
</output>
|