diff --git a/docker-compose.yml b/docker-compose.yml index 1612fdf..4efeee4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: - NODE_ENV=production - DB_TYPE=sqlite - DATABASE_URL=file:/app/data/app.db + - ENABLE_PASSCODE=true + - PASSCODE=aabbcc volumes: # Mount the data directory for SQLite persistence - ./data:/app/data diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 4223f85..7185768 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -156,13 +156,17 @@ sudo docker compose up -d 3. **Set Environment Variables**: In the Vercel dashboard, go to Project Settings → Environment Variables, add: - | Variable | Value | - |----------|-------| - | `DB_TYPE` | `turso` | - | `TURSO_URL` | Your Turso database URL | - | `TURSO_TOKEN` | Your Turso auth token | + | Variable | Value | Required | + |----------|-------|----------| + | `DB_TYPE` | `turso` | Yes | + | `TURSO_URL` | Your Turso database URL | Yes | + | `TURSO_TOKEN` | Your Turso auth token | Yes | + | `ENABLE_PASSCODE` | `true` (recommended for public sites) | No (default: false) | + | `PASSCODE` | Your secret passcode | If ENABLE_PASSCODE=true | > **Note**: The build process will automatically run database migrations using the `prebuild` script. Make sure the environment variables are set before the first build. + > + > **Security**: For public deployments, set `ENABLE_PASSCODE=true` and `PASSCODE` to protect the application with a simple passcode. The cookie lasts 7 days. 4. **Deploy**: - Click "Deploy" @@ -252,6 +256,22 @@ sudo docker compose up -d **Issue**: Data doesn't persist between deployments **Solution**: This is expected with SQLite on Vercel. You must use Turso for persistence. +**Issue**: Passcode not working +**Solution**: + - Ensure both `ENABLE_PASSCODE=true` and `PASSCODE` are set + - The passcode is case-sensitive + - Clear browser cookies and try again + - Check browser console for API errors + +### Authentication Issues + +**Issue**: Cannot access site even with correct passcode +**Solution**: + - Check that cookies are enabled in your browser + - Try accessing in an incognito/private window + - Verify the `AUTH_COOKIE_NAME` hasn't changed between deployments + - The cookie is set to expire after 7 days; if you cleared cookies, you'll need to re-enter the passcode + ### Turso Issues **Issue**: Connection refused or timeout @@ -288,20 +308,46 @@ Note: Some SQLite-specific syntax may need adjustment for Turso compatibility. --- -## Security Considerations +## Authentication -### Docker Deployment +The application supports optional passcode-based authentication, controlled by environment variables. -- Container runs as root (uid=0) by design for simplicity -- SQLite database file is owned by root -- Ensure proper firewall rules on your server -- Consider using HTTPS reverse proxy (nginx/traefik) +### Configuration -### Vercel Deployment +| Variable | Description | Default | +|----------|-------------|---------| +| `ENABLE_PASSCODE` | Enable passcode protection | `false` | +| `PASSCODE` | The secret passcode | - | +| `AUTH_COOKIE_NAME` | Name of the auth cookie | `release_tracker_auth` | -- Turso provides encryption at rest and in transit -- Vercel provides HTTPS by default -- Keep `TURSO_TOKEN` secure and rotate periodically +### Use Cases + +**Private Docker Deployment** (no auth needed): +```bash +ENABLE_PASSCODE=false +``` + +**Public Vercel Deployment** (auth required): +```bash +ENABLE_PASSCODE=true +PASSCODE=your-secure-passcode-here +``` + +### How It Works + +1. When `ENABLE_PASSCODE=true`, all routes require authentication +2. Users are redirected to `/login` page +3. After entering the correct passcode, a 7-day cookie is set +4. Cookie is `httpOnly`, `secure` (in production), and `SameSite=strict` +5. No logout functionality (users must clear cookies or wait 7 days) + +### Security Considerations + +- Passcode is never sent to the client (verified server-side) +- Cookie cannot be accessed by JavaScript (`httpOnly`) +- Cookie is only sent over HTTPS in production (`secure`) +- Cookie expires after 7 days (`maxAge: 604800`) +- Single passcode for all users (simple but not suitable for multi-user scenarios) --- diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts new file mode 100644 index 0000000..4100931 --- /dev/null +++ b/src/app/api/auth/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// Environment configuration +const ENABLE_PASSCODE = process.env.ENABLE_PASSCODE === 'true'; +const PASSCODE = process.env.PASSCODE; +const AUTH_COOKIE_NAME = process.env.AUTH_COOKIE_NAME || 'release_tracker_auth'; + +// Cookie configuration +const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days in seconds + +export async function POST(request: NextRequest) { + // If passcode protection is disabled, return success + if (!ENABLE_PASSCODE) { + return NextResponse.json({ success: true }); + } + + // Check if passcode is configured + if (!PASSCODE) { + return NextResponse.json( + { error: 'Passcode not configured' }, + { status: 500 } + ); + } + + try { + const body = await request.json(); + const { passcode } = body; + + if (!passcode || typeof passcode !== 'string') { + return NextResponse.json( + { error: 'Passcode is required' }, + { status: 400 } + ); + } + + // Verify passcode + if (passcode !== PASSCODE) { + return NextResponse.json( + { error: 'Invalid passcode' }, + { status: 401 } + ); + } + + // Set authentication cookie + const response = NextResponse.json({ success: true }); + + response.cookies.set({ + name: AUTH_COOKIE_NAME, + value: 'authenticated', + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: COOKIE_MAX_AGE, + path: '/', + }); + + return response; + } catch { + return NextResponse.json( + { error: 'Invalid request' }, + { status: 400 } + ); + } +} + +// Optional: DELETE endpoint for logout (if needed in future) +export async function DELETE() { + const response = NextResponse.json({ success: true }); + + response.cookies.set({ + name: AUTH_COOKIE_NAME, + value: '', + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 0, + path: '/', + }); + + return response; +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..397c92e --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { Suspense, useState } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; +import { Lock, Eye, EyeOff, ArrowRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + +// Export dynamic to prevent static generation +export const dynamic = 'force-dynamic'; + +function LoginForm() { + const [passcode, setPasscode] = useState(''); + const [showPasscode, setShowPasscode] = useState(false); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const router = useRouter(); + const searchParams = useSearchParams(); + const redirectUrl = searchParams.get('redirect') || '/'; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setIsLoading(true); + + try { + const response = await fetch('/api/auth', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ passcode }), + }); + + if (response.ok) { + // Redirect to original URL or home + router.push(redirectUrl); + router.refresh(); + } else { + const data = await response.json(); + setError(data.error || 'Invalid passcode'); + } + } catch { + setError('An error occurred. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
-
+
+
{showLineNumbers ? (
-
-
- {lines.map((line, i) => (
-
-
- {i + 1}
-
-
-
+
+ {/* Line numbers column */}
+
+ {lines.map((_, i) => (
+ {i + 1}
))}
-
-
+
+ {/* Code column */}
+
+ {highlightedLines.map((line, i) => (
+
+ ))}
+
+
)}
-
-
+
+
);
}
diff --git a/src/components/steps/step-detail-panel.tsx b/src/components/steps/step-detail-panel.tsx
index adb7945..5ae898c 100644
--- a/src/components/steps/step-detail-panel.tsx
+++ b/src/components/steps/step-detail-panel.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
import { X, RotateCcw, Pencil, Trash2, FileText, AlertCircle, CheckCircle, Check, SkipForward, Copy } from 'lucide-react';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
@@ -64,13 +64,33 @@ export function StepDetailPanel({
onEditCustom,
onDeleteCustom,
}: StepDetailPanelProps) {
- const [notes, setNotes] = useState(step?.notes || '');
+ const [notes, setNotes] = useState('');
const [isEditing, setIsEditing] = useState(false);
- const [editContent, setEditContent] = useState(step?.content || '');
+ const [editContent, setEditContent] = useState('');
const [showOriginal, setShowOriginal] = useState(false);
const [skipReason, setSkipReason] = useState('');
const [isSkipping, setIsSkipping] = useState(false);
+ // Sync state when step changes
+ useEffect(() => {
+ console.log('[StepDetailPanel] useEffect triggered, step?.id:', step?.id);
+ if (step) {
+ console.log('[StepDetailPanel] Syncing state with step:', {
+ stepId: step.id,
+ stepName: step.name,
+ stepContent: step.content?.substring(0, 50),
+ });
+ setNotes(step.notes || '');
+ setEditContent(step.content || '');
+ setIsEditing(false);
+ setShowOriginal(false);
+ setSkipReason('');
+ setIsSkipping(false);
+ }
+ }, [step?.id]);
+
+ console.log('[StepDetailPanel] Render - step:', step?.id, 'step.content:', step?.content?.substring(0, 50), 'editContent:', editContent?.substring(0, 50));
+
if (!step) return null;
const isCustom = step.isCustom;
@@ -169,7 +189,7 @@ export function StepDetailPanel({
return (