From f006552ac13625b1270ab8828a8ad177502d2b5a Mon Sep 17 00:00:00 2001 From: tigerenwork Date: Tue, 3 Feb 2026 11:28:57 +0800 Subject: [PATCH] fix: resolve the first click get empty content issue --- docker-compose.yml | 2 + docs/DEPLOYMENT.md | 76 ++++++++-- src/app/api/auth/route.ts | 81 +++++++++++ src/app/login/page.tsx | 136 ++++++++++++++++++ .../releases/release-matrix-client.tsx | 7 + src/components/steps/code-block.tsx | 76 +++++----- src/components/steps/step-detail-panel.tsx | 28 +++- src/middleware.ts | 45 ++++++ 8 files changed, 398 insertions(+), 53 deletions(-) create mode 100644 src/app/api/auth/route.ts create mode 100644 src/app/login/page.tsx create mode 100644 src/middleware.ts 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 ( +
+ + +
+
+ +
+
+ Release Tracker + + Enter the passcode to access the application + +
+ +
+
+
+ setPasscode(e.target.value)} + className="pr-10" + disabled={isLoading} + autoComplete="off" + /> + +
+
+ + {error && ( +
+ {error} +
+ )} + + +
+
+
+
+ ); +} + +export default function LoginPage() { + return ( + + + +
Loading...
+
+
+ + }> + +
+ ); +} diff --git a/src/components/releases/release-matrix-client.tsx b/src/components/releases/release-matrix-client.tsx index 551bbfa..50adee8 100644 --- a/src/components/releases/release-matrix-client.tsx +++ b/src/components/releases/release-matrix-client.tsx @@ -30,6 +30,13 @@ export function ReleaseMatrixClient({ stepsByCluster, category, releaseId }: Rel const clusters = Object.values(stepsByCluster); const handleStepClick = (step: any, template: any = null) => { + console.log('[ReleaseMatrixClient] handleStepClick called:', { + stepId: step?.id, + stepName: step?.name, + stepContent: step?.content?.substring(0, 50), + templateId: template?.id, + templateContent: template?.content?.substring(0, 50), + }); setSelectedStep(step); setSelectedTemplate(template); setIsPanelOpen(true); diff --git a/src/components/steps/code-block.tsx b/src/components/steps/code-block.tsx index a13cb59..9506227 100644 --- a/src/components/steps/code-block.tsx +++ b/src/components/steps/code-block.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useState, useMemo } from 'react'; import { Copy, Check } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -11,15 +11,17 @@ interface CodeBlockProps { } export function CodeBlock({ code, type, showLineNumbers = true }: CodeBlockProps) { - const [highlighted, setHighlighted] = useState(''); + console.log('[CodeBlock] Render - code length:', code?.length, 'code preview:', code?.substring(0, 50), 'type:', type); + const [copied, setCopied] = useState(false); - useEffect(() => { - async function highlight() { + // Compute highlighted code synchronously + const highlighted = useMemo(() => { + if (!code) return ''; + try { // eslint-disable-next-line @typescript-eslint/no-require-imports const Prism = require('prismjs'); - // Load language components dynamically if (type === 'sql') { // eslint-disable-next-line @typescript-eslint/no-require-imports require('prismjs/components/prism-sql'); @@ -30,27 +32,31 @@ export function CodeBlock({ code, type, showLineNumbers = true }: CodeBlockProps const language = type === 'text' ? 'text' : type; const grammar = Prism.languages[language] || Prism.languages.text; - const highlightedCode = Prism.highlight(code, grammar, language); - setHighlighted(highlightedCode); + return Prism.highlight(code, grammar, language); + } catch (e) { + console.error('[CodeBlock] Prism error:', e); + return code.replace(//g, '>'); } - - highlight(); }, [code, type]); + console.log('[CodeBlock] highlighted length:', highlighted?.length); + const handleCopy = async () => { await navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 2000); }; - const lines = code.split('\n'); + // Split code into lines for line numbers + const lines = code ? code.split('\n') : []; + const highlightedLines = highlighted ? highlighted.split('\n') : []; return (
-
-        
+      
+
           {showLineNumbers ? (
-            
-              
-                {lines.map((line, i) => (
-                  
-                    
-                    
+            
+ {/* Line numbers column */} +
+ {lines.map((_, i) => ( + {i + 1} ))} -
-
- {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 ( - +
diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..0b56f35 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,45 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +// Environment configuration +const ENABLE_PASSCODE = process.env.ENABLE_PASSCODE === 'true'; +const AUTH_COOKIE_NAME = process.env.AUTH_COOKIE_NAME || 'release_tracker_auth'; + +// Paths that should be excluded from auth check +const PUBLIC_PATHS = ['/login', '/api/auth']; + +export function middleware(request: NextRequest) { + // If passcode protection is disabled, allow all requests + if (!ENABLE_PASSCODE) { + return NextResponse.next(); + } + + const { pathname } = request.nextUrl; + + // Check if the path is public (login page or auth API) + if (PUBLIC_PATHS.some(path => pathname.startsWith(path))) { + return NextResponse.next(); + } + + // Check for auth cookie + const authCookie = request.cookies.get(AUTH_COOKIE_NAME); + + if (!authCookie || authCookie.value !== 'authenticated') { + // Redirect to login page + const loginUrl = new URL('/login', request.url); + // Store the original URL to redirect back after login + loginUrl.searchParams.set('redirect', pathname); + return NextResponse.redirect(loginUrl); + } + + // User is authenticated, allow the request + return NextResponse.next(); +} + +// Configure which paths the middleware runs on +export const config = { + matcher: [ + // Match all paths except static files and api routes that are public + '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', + ], +};