fix: resolve the first click get empty content issue
This commit is contained in:
parent
46d9bd9596
commit
f006552ac1
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-100 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<Lock className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">Release Tracker</CardTitle>
|
||||
<CardDescription>
|
||||
Enter the passcode to access the application
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="passcode"
|
||||
type={showPasscode ? 'text' : 'password'}
|
||||
placeholder="Enter passcode"
|
||||
value={passcode}
|
||||
onChange={(e) => setPasscode(e.target.value)}
|
||||
className="pr-10"
|
||||
disabled={isLoading}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPasscode(!showPasscode)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPasscode ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 bg-red-50 p-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading || !passcode.trim()}
|
||||
>
|
||||
{isLoading ? (
|
||||
'Verifying...'
|
||||
) : (
|
||||
<>
|
||||
Access Application
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-100">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="py-12">
|
||||
<div className="text-center text-slate-500">Loading...</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<string>('');
|
||||
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, '<').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 (
|
||||
<div className="relative group">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
|
|
@ -59,33 +65,35 @@ export function CodeBlock({ code, type, showLineNumbers = true }: CodeBlockProps
|
|||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<pre className="rounded-lg bg-slate-900 p-4 overflow-x-auto text-sm">
|
||||
<code className="language-{type}">
|
||||
<div className="rounded-lg bg-slate-900 p-4 overflow-x-auto text-sm">
|
||||
<pre className={`language-${type} m-0`}>
|
||||
{showLineNumbers ? (
|
||||
<table className="border-collapse">
|
||||
<tbody>
|
||||
{lines.map((line, i) => (
|
||||
<tr key={i}>
|
||||
<td className="text-slate-500 text-right pr-4 select-none w-12">
|
||||
{i + 1}
|
||||
</td>
|
||||
<td
|
||||
className="text-slate-100"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlighted
|
||||
? highlighted.split('\n')[i] || ''
|
||||
: line
|
||||
}}
|
||||
/>
|
||||
</tr>
|
||||
<div className="flex">
|
||||
{/* Line numbers column */}
|
||||
<div className="flex flex-col text-slate-500 text-right pr-4 select-none min-w-[3rem]">
|
||||
{lines.map((_, i) => (
|
||||
<span key={i}>{i + 1}</span>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Code column */}
|
||||
<div className="flex flex-col">
|
||||
{highlightedLines.map((line, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="text-slate-100 whitespace-pre"
|
||||
dangerouslySetInnerHTML={{ __html: line || ' ' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span dangerouslySetInnerHTML={{ __html: highlighted || code }} />
|
||||
<code
|
||||
className="text-slate-100 whitespace-pre"
|
||||
dangerouslySetInnerHTML={{ __html: highlighted || code || '' }}
|
||||
/>
|
||||
)}
|
||||
</code>
|
||||
</pre>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Sheet open={isOpen} onOpenChange={onClose}>
|
||||
<SheetContent className="w-[600px] sm:max-w-[600px]">
|
||||
<SheetContent key={step?.id} className="w-[600px] sm:max-w-[600px]">
|
||||
<SheetHeader className="px-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -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)$).*)',
|
||||
],
|
||||
};
|
||||
Loading…
Reference in New Issue