Compare commits

...

6 Commits

Author SHA1 Message Date
Daniel Alves Rösel
1e57ec7642 Merge pull request #12 from velocitatem/claude/webapp-robustness-diff-propagation-433ive
Add patch propagation and improve auth security
2026-06-19 19:07:02 +02:00
Claude
52eab5b6e4 feat: robust login with rate limiting + diff propagation across tree
Login hardening:
- API: in-memory rate limiter (10 attempts / IP / 15 min), timing-safe credential comparison, strict body validation
- Middleware: validate session token timestamp expiry (7-day window) not just HMAC signature
- Logout: also clears oidc_token_pub cookie
- Login page: try/catch around fetch to surface network errors, parse OIDC error query params, disable inputs during loading, wrap useSearchParams in Suspense

Diff propagation:
- DiffViewer: shows inherited ancestor patches in a collapsible section above own patches
- CVTree: annotates each branch with ↑N ancestor patch count badge; propagate (↓) button on hover for versions with own patches and child branches
- Dashboard: computeInheritedPatches/getDescendants helpers; Propagate ↓ action button in version header; handlePropagate sends current version's patches to all descendants via appendPatches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Stdyi4YmG1BUSurArZPe8w
2026-06-19 11:00:41 +00:00
Daniel Alves Rösel
f625dead76 Merge pull request #11 from velocitatem/claude/improve-docs-preview-94gyn
Add PDF preview and DOCX upload to version branches
2026-05-03 01:34:13 +04:00
Claude
97ee914b1b feat: live PDF preview, upload-to-branch diff, and copy markdown per branch
- Backend: authenticated GET preview endpoint generates PDF on-demand from any
  version without requiring a public asset publish
- Backend: POST upload endpoint on a version accepts a .docx, parses it to
  structured blocks, diffs against current blocks, and records patches
- Frontend: new Preview tab shows live PDF rendered from the authenticated
  endpoint (blob URL via fetch with auth header)
- Frontend: Upload DOCX (arrow-up) button in action bar sends the file to the
  branch, backend computes diff automatically
- Frontend: Copy Markdown button (clipboard icon) appears on branch hover in
  CVTree; copies block path/type/text as structured markdown to clipboard

https://claude.ai/code/session_01BTNfDfgFvcnehkve6r66nk
2026-05-02 21:31:49 +00:00
a21f14ea87 hotfix: internal server rror feedback 2026-04-18 12:42:10 +02:00
Daniel Alves Rösel
61430317f4 Merge pull request #10 from velocitatem/copilot/remove-cv-alves-world-references
Remove cv.alves.world references from README
2026-04-08 00:43:55 +04:00
11 changed files with 650 additions and 91 deletions

View File

@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db from app.api.deps import get_current_user, get_db
from app.core.config import get_settings from app.core.config import get_settings
from app.schemas import DocumentListResponse, DocumentResponse from app.schemas import DocumentListResponse, DocumentResponse, VersionResponse
from app.services.documents import ( from app.services.documents import (
create_document, create_document,
delete_document, delete_document,
@@ -14,8 +14,9 @@ from app.services.documents import (
list_documents, list_documents,
) )
from app.services.storage import storage_client from app.services.storage import storage_client
from app.services.versions import upload_docx_to_version
from dlib.auth import AuthenticatedUser from dlib.auth import AuthenticatedUser
from dlib.cv import generate_patched_docx from dlib.cv import docx_bytes_to_pdf, generate_patched_docx
router = APIRouter(prefix="/documents", tags=["documents"]) router = APIRouter(prefix="/documents", tags=["documents"])
@@ -66,6 +67,50 @@ async def download_version_docx(
) )
@router.get("/{document_id}/versions/{version_id}/preview")
async def preview_version_pdf(
document_id: str,
version_id: str,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
document = await get_document(session, owner_id=user.sub, document_id=document_id)
if not document:
raise HTTPException(status_code=404, detail="Document not found")
version = next((v for v in document.versions if v.id == version_id), None)
if not version or not version.artifact_docx_key:
raise HTTPException(status_code=404, detail="Version artifact not found")
original = storage_client.download_bytes(key=version.artifact_docx_key)
patched = generate_patched_docx(original, version.structured_blocks or [])
pdf = docx_bytes_to_pdf(patched)
slug = f"{document.title.replace(' ', '-')}-{version.branch_name}"
return Response(
content=pdf,
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{slug}.pdf"'},
)
@router.post("/{document_id}/versions/{version_id}/upload", response_model=VersionResponse)
async def upload_docx_to_branch(
document_id: str,
version_id: str,
file: UploadFile = File(...),
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
document = await get_document(session, owner_id=user.sub, document_id=document_id)
if not document:
raise HTTPException(status_code=404, detail="Document not found")
version = next((v for v in document.versions if v.id == version_id), None)
if not version:
raise HTTPException(status_code=404, detail="Version not found")
updated = await upload_docx_to_version(session, owner_id=user.sub, version_id=version_id, upload=file)
if not updated:
raise HTTPException(status_code=404, detail="Version not found")
return VersionResponse.model_validate(updated)
@router.post("", response_model=DocumentResponse) @router.post("", response_model=DocumentResponse)
async def upload_document( async def upload_document(
title: str = Form(...), title: str = Form(...),

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from fastapi import UploadFile
from sqlalchemy import delete, select from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -8,11 +9,34 @@ from dlib.cv import (
StructuredBlock, StructuredBlock,
StructuredDocument, StructuredDocument,
PatchPayload, PatchPayload,
PatchOperation,
apply_patchset, apply_patchset,
validate_patchset, validate_patchset,
parse_docx_bytes,
) )
from app.models import CvDocument, CvPatch, CvVersion, PublicAsset from app.models import CvDocument, CvPatch, CvVersion, PublicAsset
from app.services.storage import persist_upload
def _diff_blocks(old: list[dict], new: list[dict]) -> list[PatchPayload]:
old_map = {b["path"]: b for b in old}
new_map = {b["path"]: b for b in new}
patches: list[PatchPayload] = []
for path, nb in new_map.items():
ob = old_map.get(path)
if ob and ob["text"] != nb["text"]:
patches.append(PatchPayload(
target_path=path, operation=PatchOperation.REPLACE_TEXT,
old_value=ob["text"], new_value=nb["text"],
))
for path, ob in old_map.items():
if path not in new_map and ob.get("block_type") != "heading":
patches.append(PatchPayload(
target_path=path, operation=PatchOperation.REMOVE_BLOCK,
old_value=ob["text"],
))
return patches
async def create_branch( async def create_branch(
@@ -78,7 +102,10 @@ async def create_branch(
stmt_refresh = ( stmt_refresh = (
select(CvVersion) select(CvVersion)
.where(CvVersion.id == new_version.id) .where(CvVersion.id == new_version.id)
.options(selectinload(CvVersion.patches)) .options(
selectinload(CvVersion.patches),
selectinload(CvVersion.public_assets),
)
) )
result = await session.execute(stmt_refresh) result = await session.execute(stmt_refresh)
return result.scalars().one() return result.scalars().one()
@@ -95,7 +122,10 @@ async def append_patches_to_version(
select(CvVersion) select(CvVersion)
.join(CvVersion.document) .join(CvVersion.document)
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id) .where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
.options(selectinload(CvVersion.patches)) .options(
selectinload(CvVersion.patches),
selectinload(CvVersion.public_assets),
)
) )
result = await session.execute(stmt) result = await session.execute(stmt)
version = result.scalars().one_or_none() version = result.scalars().one_or_none()
@@ -138,12 +168,64 @@ async def append_patches_to_version(
stmt_refresh = ( stmt_refresh = (
select(CvVersion) select(CvVersion)
.where(CvVersion.id == version_id) .where(CvVersion.id == version_id)
.options(selectinload(CvVersion.patches)) .options(
selectinload(CvVersion.patches),
selectinload(CvVersion.public_assets),
)
) )
result = await session.execute(stmt_refresh) result = await session.execute(stmt_refresh)
return result.scalars().one() return result.scalars().one()
async def upload_docx_to_version(
session: AsyncSession,
*,
owner_id: str,
version_id: str,
upload: UploadFile,
) -> CvVersion | None:
stmt = (
select(CvVersion)
.join(CvVersion.document)
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
.options(selectinload(CvVersion.patches), selectinload(CvVersion.public_assets))
)
version = (await session.execute(stmt)).scalars().one_or_none()
if not version:
return None
artifact_key, file_bytes = await persist_upload(upload, owner_id)
new_blocks_parsed = parse_docx_bytes(file_bytes)
new_blocks = [b.model_dump() for b in new_blocks_parsed.blocks]
diff_patches = _diff_blocks(version.structured_blocks or [], new_blocks)
version.artifact_docx_key = artifact_key
version.structured_blocks = new_blocks
metadata = version.metadata_json or {}
metadata["patch_count"] = int(metadata.get("patch_count") or 0) + len(diff_patches)
version.metadata_json = metadata
for patch in diff_patches:
session.add(CvPatch(
version_id=version.id,
target_path=patch.target_path,
operation=patch.operation.value,
old_value=patch.old_value,
new_value=patch.new_value,
metadata_json=patch.metadata,
))
await session.commit()
stmt_refresh = (
select(CvVersion)
.where(CvVersion.id == version_id)
.options(selectinload(CvVersion.patches), selectinload(CvVersion.public_assets))
)
return (await session.execute(stmt_refresh)).scalars().one()
async def delete_version( async def delete_version(
session: AsyncSession, owner_id: str, version_id: str session: AsyncSession, owner_id: str, version_id: str
) -> bool | str: ) -> bool | str:

View File

@@ -1,20 +1,59 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { createHmac } from 'crypto'; import { createHmac, timingSafeEqual } from 'crypto';
const SECRET = process.env.SESSION_SECRET ?? 'dev-secret-change-in-production'; const SECRET = process.env.SESSION_SECRET ?? 'dev-secret-change-in-production';
const LOGIN_USER = process.env.LOGIN_USER ?? 'admin'; const LOGIN_USER = process.env.LOGIN_USER ?? 'admin';
const LOGIN_PASS = process.env.LOGIN_PASS ?? 'admin'; const LOGIN_PASS = process.env.LOGIN_PASS ?? 'admin';
// in-memory rate limiter: max 10 attempts per IP per 15 min
const attempts = new Map<string, { n: number; resetAt: number }>();
function checkRate(ip: string): boolean {
const now = Date.now();
const e = attempts.get(ip);
if (!e || now > e.resetAt) { attempts.set(ip, { n: 1, resetAt: now + 15 * 60_000 }); return true; }
if (e.n >= 10) return false;
e.n++;
return true;
}
function safeEq(a: string, b: string): boolean {
try {
const ab = Buffer.from(a), bb = Buffer.from(b);
if (ab.length !== bb.length) {
// still run comparison on equal-length buffers to avoid timing leak
timingSafeEqual(ab, ab);
return false;
}
return timingSafeEqual(ab, bb);
} catch { return false; }
}
function sign(value: string) { function sign(value: string) {
return createHmac('sha256', SECRET).update(value).digest('hex'); return createHmac('sha256', SECRET).update(value).digest('hex');
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const body = await req.json().catch(() => ({})); const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? req.headers.get('x-real-ip') ?? 'unknown';
const { username, password } = body as Record<string, string>; if (!checkRate(ip)) {
if (!username || !password || username !== LOGIN_USER || password !== LOGIN_PASS) { return NextResponse.json({ error: 'Too many attempts. Try again later.' }, { status: 429 });
}
const body = await req.json().catch(() => null);
if (!body || typeof body !== 'object') {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
const { username, password } = body as Record<string, unknown>;
if (typeof username !== 'string' || typeof password !== 'string' || !username || !password) {
return NextResponse.json({ error: 'Username and password required' }, { status: 400 });
}
const validUser = safeEq(username, LOGIN_USER);
const validPass = safeEq(password, LOGIN_PASS);
if (!validUser || !validPass) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }); return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
} }
const payload = `${username}:${Date.now()}`; const payload = `${username}:${Date.now()}`;
const token = `${payload}.${sign(payload)}`; const token = `${payload}.${sign(payload)}`;
const res = NextResponse.json({ ok: true }); const res = NextResponse.json({ ok: true });

View File

@@ -4,5 +4,6 @@ export async function POST() {
const res = NextResponse.json({ ok: true }); const res = NextResponse.json({ ok: true });
res.cookies.delete('session'); res.cookies.delete('session');
res.cookies.delete('oidc_token'); res.cookies.delete('oidc_token');
res.cookies.delete('oidc_token_pub');
return res; return res;
} }

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import CVTree from '@/components/cv/CVTree'; import CVTree, { versionToMarkdown } from '@/components/cv/CVTree';
import DiffViewer from '@/components/cv/DiffViewer'; import DiffViewer from '@/components/cv/DiffViewer';
import InsightsPanel from '@/components/cv/InsightsPanel'; import InsightsPanel from '@/components/cv/InsightsPanel';
import PDFPreview from '@/components/cv/PDFPreview';
import Link from 'next/link'; import Link from 'next/link';
import { import {
appendPatches, appendPatches,
@@ -12,6 +13,7 @@ import {
fetchDocuments, fetchInsights, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl, fetchDocuments, fetchInsights, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
InsightsResult, InsightsResult,
IS_DEMO, IS_DEMO,
Patch,
publishVersion, PublicAsset, PublicAssetAnalytics, publishVersion, PublicAsset, PublicAssetAnalytics,
requestAiSuggestions, requestAiSuggestions,
Submission, Submission,
@@ -21,12 +23,32 @@ import {
updateSubmissionStatus, updateSubmissionStatus,
updateSuggestion, updateSuggestion,
uploadDocument, uploadDocument,
uploadDocxToBranch,
Version, Version,
} from '@/libs/api'; } from '@/libs/api';
import { import {
DEMO_DOCUMENTS, DEMO_DOC_ID, DEMO_INSIGHTS, DEMO_SUBMISSIONS, DEMO_DOCUMENTS, DEMO_DOC_ID, DEMO_INSIGHTS, DEMO_SUBMISSIONS,
} from './demo-data'; } from './demo-data';
// ── diff propagation helpers ──────────────────────────────────────────────────
function getAncestorChain(versions: Version[], versionId: string): Version[] {
const map = new Map(versions.map(v => [v.id, v]));
const chain: Version[] = [];
let cur = map.get(versionId);
while (cur) { chain.unshift(cur); cur = cur.parent_version_id ? map.get(cur.parent_version_id) : undefined; }
return chain;
}
function computeInheritedPatches(versions: Version[], versionId: string): Patch[] {
return getAncestorChain(versions, versionId).slice(0, -1).flatMap(v => v.patches);
}
function getDescendants(versions: Version[], versionId: string): Version[] {
const children = versions.filter(v => v.parent_version_id === versionId);
return children.flatMap(c => [c, ...getDescendants(versions, c.id)]);
}
// ── helpers ─────────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────────
function fmt(iso: string) { function fmt(iso: string) {
@@ -74,8 +96,14 @@ function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: D
const submit = async () => { const submit = async () => {
if (!title.trim() || !file) { setError('Title and file required.'); return; } if (!title.trim() || !file) { setError('Title and file required.'); return; }
setLoading(true); setError(''); setLoading(true); setError('');
try { onDone(await uploadDocument(title.trim(), desc.trim() || null, file)); } try {
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Upload failed'); setLoading(false); } const doc = await uploadDocument(title.trim(), desc.trim() || null, file);
await Promise.resolve(onDone(doc));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Upload failed');
} finally {
setLoading(false);
}
}; };
return ( return (
@@ -123,8 +151,14 @@ function BranchModal({
const submit = async () => { const submit = async () => {
if (!name.trim()) { setError('Branch name required.'); return; } if (!name.trim()) { setError('Branch name required.'); return; }
setLoading(true); setError(''); setLoading(true); setError('');
try { onDone(await createBranch(version.id, name.trim(), label.trim() || null, patches as Record<string, unknown>[])); } try {
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); } const v = await createBranch(version.id, name.trim(), label.trim() || null, patches as Record<string, unknown>[]);
await Promise.resolve(onDone(v));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed');
} finally {
setLoading(false);
}
}; };
return ( return (
@@ -170,8 +204,14 @@ function SubmissionModal({ version, onClose, onDone }: { version: Version; onClo
const submit = async () => { const submit = async () => {
if (!company.trim() || !role.trim()) { setError('Company and role required.'); return; } if (!company.trim() || !role.trim()) { setError('Company and role required.'); return; }
setLoading(true); setError(''); setLoading(true); setError('');
try { onDone(await createSubmission(version.id, company.trim(), role.trim(), url.trim() || null, jd.trim() || null)); } try {
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); } const s = await createSubmission(version.id, company.trim(), role.trim(), url.trim() || null, jd.trim() || null);
await Promise.resolve(onDone(s));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed');
} finally {
setLoading(false);
}
}; };
return ( return (
@@ -211,8 +251,12 @@ function PublishModal({ version, onClose, onDone }: { version: Version; onClose:
setLoading(true); setError(''); setLoading(true); setError('');
try { try {
const asset = await publishVersion(version.id, null, slug.trim() || null); const asset = await publishVersion(version.id, null, slug.trim() || null);
onDone(asset); await Promise.resolve(onDone(asset));
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); } } catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed');
} finally {
setLoading(false);
}
}; };
return ( return (
@@ -554,7 +598,7 @@ function SubmissionsTab({
// ── main dashboard ──────────────────────────────────────────────────────────── // ── main dashboard ────────────────────────────────────────────────────────────
type Modal = 'upload' | 'branch' | 'submission' | 'publish' | null; type Modal = 'upload' | 'branch' | 'submission' | 'publish' | null;
type Tab = 'content' | 'patches' | 'submissions' | 'insights'; type Tab = 'content' | 'patches' | 'submissions' | 'insights' | 'preview';
export default function Dashboard() { export default function Dashboard() {
const [docs, setDocs] = useState<Document[]>([]); const [docs, setDocs] = useState<Document[]>([]);
@@ -575,6 +619,11 @@ export default function Dashboard() {
const [applyLoading, setApplyLoading] = useState(false); const [applyLoading, setApplyLoading] = useState(false);
const [applyError, setApplyError] = useState(''); const [applyError, setApplyError] = useState('');
const [insights, setInsights] = useState<InsightsResult | null>(null); const [insights, setInsights] = useState<InsightsResult | null>(null);
const [uploadBranchLoading, setUploadBranchLoading] = useState(false);
const [uploadBranchError, setUploadBranchError] = useState('');
const [propagateLoading, setPropagateLoading] = useState(false);
const [propagateError, setPropagateError] = useState('');
const uploadBranchRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
if (IS_DEMO) { if (IS_DEMO) {
@@ -606,6 +655,7 @@ export default function Dashboard() {
setApplyLoading(false); setApplyLoading(false);
setPublishedAnalytics({}); setPublishedAnalytics({});
setRecentlyPublishedSlug(null); setRecentlyPublishedSlug(null);
setPropagateError('');
}, [selectedVersionId]); }, [selectedVersionId]);
useEffect(() => { useEffect(() => {
@@ -695,6 +745,40 @@ export default function Dashboard() {
const discardEdits = () => setPendingEdits(new Map()); const discardEdits = () => setPendingEdits(new Map());
const handleUploadToBranch = async (file: File) => {
if (!selectedDocId || !selectedVersionId) return;
setUploadBranchLoading(true);
setUploadBranchError('');
try {
await uploadDocxToBranch(selectedDocId, selectedVersionId, file);
await refreshDocs();
} catch (e: unknown) {
setUploadBranchError(e instanceof Error ? e.message : 'Upload failed');
} finally {
setUploadBranchLoading(false);
}
};
const handleCopyMarkdown = (versionId: string) => {
if (!selectedDoc) return;
const versionMap = new Map(selectedDoc.versions.map(v => [v.id, v]));
const sections = selectedDoc.versions
.filter(v => v.id === versionId || selectedDoc.versions.some(x => x.id === versionId))
.map(v => versionToMarkdown(v, v.parent_version_id ? (versionMap.get(v.parent_version_id ?? '')?.branch_name) : undefined));
const md = `# ${selectedDoc.title}\n\n${sections.join('\n\n---\n\n')}`;
navigator.clipboard.writeText(md).catch(() => {});
};
const handleCopyBranchMarkdown = (versionId: string) => {
if (!selectedDoc) return;
const version = selectedDoc.versions.find(v => v.id === versionId);
if (!version) return;
const versionMap = new Map(selectedDoc.versions.map(v => [v.id, v]));
const parentName = version.parent_version_id ? versionMap.get(version.parent_version_id)?.branch_name : undefined;
const md = `# ${selectedDoc.title}\n\n${versionToMarkdown(version, parentName)}`;
navigator.clipboard.writeText(md).catch(() => {});
};
const applyStagedEdits = async () => { const applyStagedEdits = async () => {
if (!selectedVersionId || !stagedPatches.length) return; if (!selectedVersionId || !stagedPatches.length) return;
setApplyLoading(true); setApplyLoading(true);
@@ -746,6 +830,31 @@ export default function Dashboard() {
} }
}; };
const handlePropagate = async (versionId: string) => {
if (IS_DEMO || !selectedDoc) return;
const version = selectedDoc.versions.find(v => v.id === versionId);
if (!version || !version.patches.length) return;
const descendants = getDescendants(selectedDoc.versions, versionId);
if (!descendants.length) return;
const msg = `Propagate ${version.patches.length} patch${version.patches.length !== 1 ? 'es' : ''} from "${version.branch_name}" to ${descendants.length} downstream branch${descendants.length !== 1 ? 'es' : ''}?`;
if (!confirm(msg)) return;
setPropagateLoading(true);
setPropagateError('');
try {
await Promise.all(descendants.map(d =>
appendPatches(d.id, version.patches.map(p => ({
target_path: p.target_path, operation: p.operation,
old_value: p.old_value ?? undefined, new_value: p.new_value ?? undefined,
})) as Record<string, unknown>[])
));
await refreshDocs();
} catch (e: unknown) {
setPropagateError(e instanceof Error ? e.message : 'Propagation failed');
} finally {
setPropagateLoading(false);
}
};
const selectVersion = (id: string) => { const selectVersion = (id: string) => {
setSelectedVersionId(id); setSelectedVersionId(id);
setActiveTab('content'); setActiveTab('content');
@@ -877,6 +986,8 @@ export default function Dashboard() {
selectedVersionId={selectedVersionId} selectedVersionId={selectedVersionId}
onSelect={selectVersion} onSelect={selectVersion}
onDeleteVersion={handleDeleteVersion} onDeleteVersion={handleDeleteVersion}
onCopyMarkdown={handleCopyBranchMarkdown}
onPropagate={handlePropagate}
/> />
</div> </div>
)} )}
@@ -929,6 +1040,8 @@ export default function Dashboard() {
versions={selectedDoc.versions} versions={selectedDoc.versions}
selectedVersionId={selectedVersionId} selectedVersionId={selectedVersionId}
onSelect={selectVersion} onSelect={selectVersion}
onCopyMarkdown={handleCopyBranchMarkdown}
onPropagate={handlePropagate}
/> />
</div> </div>
@@ -979,6 +1092,17 @@ export default function Dashboard() {
{!IS_DEMO && <button className="btn btn-ghost" onClick={() => setModal('branch')}>Branch</button>} {!IS_DEMO && <button className="btn btn-ghost" onClick={() => setModal('branch')}>Branch</button>}
{!IS_DEMO && <button className="btn btn-ghost" onClick={() => { setModal('submission'); }}>Submit</button>} {!IS_DEMO && <button className="btn btn-ghost" onClick={() => { setModal('submission'); }}>Submit</button>}
{!IS_DEMO && <button className="btn btn-ghost" onClick={() => setModal('publish')}>Publish</button>} {!IS_DEMO && <button className="btn btn-ghost" onClick={() => setModal('publish')}>Publish</button>}
{!IS_DEMO && selectedVersion.patches.length > 0 && selectedDoc && getDescendants(selectedDoc.versions, selectedVersion.id).length > 0 && (
<button
className="btn btn-ghost"
style={{ color: '#7c3aed', borderColor: '#c4b5fd' }}
onClick={() => handlePropagate(selectedVersion.id)}
disabled={propagateLoading}
title="Apply this version's patches to all descendant branches"
>
{propagateLoading ? 'Propagating…' : 'Propagate ↓'}
</button>
)}
{IS_DEMO && ( {IS_DEMO && (
<a href="/demo-cv.docx" download="alex-rivera-cv.docx" className="btn btn-ghost"> <a href="/demo-cv.docx" download="alex-rivera-cv.docx" className="btn btn-ghost">
DOCX DOCX
@@ -989,6 +1113,29 @@ export default function Dashboard() {
DOCX DOCX
</a> </a>
)} )}
{!IS_DEMO && (
<>
<button
className="btn btn-ghost"
onClick={() => uploadBranchRef.current?.click()}
disabled={uploadBranchLoading}
title="Upload a new DOCX to this branch — diff is computed automatically"
>
{uploadBranchLoading ? 'Uploading…' : '↑ DOCX'}
</button>
<input
ref={uploadBranchRef}
type="file"
accept=".docx"
style={{ display: 'none' }}
onChange={e => {
const f = e.target.files?.[0];
if (f) handleUploadToBranch(f);
e.target.value = '';
}}
/>
</>
)}
</div> </div>
</div> </div>
@@ -1085,9 +1232,31 @@ export default function Dashboard() {
</div> </div>
)} )}
{uploadBranchError && (
<div style={{
padding: '6px 12px', background: '#fef2f2', border: '1px solid #fca5a5',
borderRadius: 5, marginBottom: 12, fontSize: 12, color: '#b91c1c',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}>
<span>{uploadBranchError}</span>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: '1px 6px' }} onClick={() => setUploadBranchError('')}>×</button>
</div>
)}
{propagateError && (
<div style={{
padding: '6px 12px', background: '#fef2f2', border: '1px solid #fca5a5',
borderRadius: 5, marginBottom: 12, fontSize: 12, color: '#b91c1c',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}>
<span>{propagateError}</span>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: '1px 6px' }} onClick={() => setPropagateError('')}>×</button>
</div>
)}
{/* tabs */} {/* tabs */}
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border)', overflowX: 'auto' }}> <div style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border)', overflowX: 'auto' }}>
{(['content', 'patches', 'submissions', 'insights'] as Tab[]).map(t => ( {(['content', 'patches', 'submissions', 'insights', 'preview'] as Tab[]).map(t => (
<button <button
key={t} key={t}
onClick={() => setActiveTab(t)} onClick={() => setActiveTab(t)}
@@ -1106,7 +1275,7 @@ export default function Dashboard() {
</div> </div>
{/* tab content */} {/* tab content */}
<div style={{ padding: '16px 20px', flex: 1, overflow: 'auto' }}> <div style={{ padding: activeTab === 'preview' ? 0 : '16px 20px', flex: 1, overflow: activeTab === 'preview' ? 'hidden' : 'auto' }}>
{activeTab === 'content' && ( {activeTab === 'content' && (
<ContentTab <ContentTab
blocks={selectedVersion.structured_blocks ?? []} blocks={selectedVersion.structured_blocks ?? []}
@@ -1114,9 +1283,17 @@ export default function Dashboard() {
onEdit={stageEdit} onEdit={stageEdit}
/> />
)} )}
{activeTab === 'patches' && ( {activeTab === 'patches' && (() => {
<DiffViewer patches={selectedVersion.patches} /> const inherited = selectedDoc ? computeInheritedPatches(selectedDoc.versions, selectedVersion.id) : [];
)} const parentBranch = selectedDoc?.versions.find(v => v.id === selectedVersion.parent_version_id)?.branch_name;
return (
<DiffViewer
patches={selectedVersion.patches}
inheritedPatches={inherited}
ancestorLabel={parentBranch}
/>
);
})()}
{activeTab === 'submissions' && ( {activeTab === 'submissions' && (
<SubmissionsTab <SubmissionsTab
submissions={IS_DEMO submissions={IS_DEMO
@@ -1134,6 +1311,11 @@ export default function Dashboard() {
{activeTab === 'insights' && ( {activeTab === 'insights' && (
<InsightsPanel data={insights} /> <InsightsPanel data={insights} />
)} )}
{activeTab === 'preview' && selectedDoc && (
IS_DEMO
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-faint)', fontSize: 13 }}>Preview not available in demo mode.</div>
: <PDFPreview documentId={selectedDoc.id} versionId={selectedVersion.id} />
)}
</div> </div>
</> </>
)} )}

View File

@@ -1,16 +1,17 @@
'use client'; 'use client';
import { useState } from 'react'; import { Suspense, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
const OIDC_ERROR_LABELS: Record<string, string> = {
no_code: 'No authorization code received from provider.',
oidc_not_configured: 'OIDC provider not configured.',
token_exchange: 'Failed to exchange token with provider.',
};
function authentikBase(url?: string | null) { function authentikBase(url?: string | null) {
if (!url) return null; if (!url) return null;
try { try { return new URL(url).origin.replace(/\/$/, ''); } catch { return null; }
const parsed = new URL(url);
return parsed.origin.replace(/\/$/, '');
} catch {
return null;
}
} }
function authentikUrl() { function authentikUrl() {
@@ -18,37 +19,45 @@ function authentikUrl() {
const clientId = process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_ID; const clientId = process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_ID;
const base = process.env.NEXT_PUBLIC_BASE_URL ?? (typeof window !== 'undefined' ? window.location.origin : ''); const base = process.env.NEXT_PUBLIC_BASE_URL ?? (typeof window !== 'undefined' ? window.location.origin : '');
if (!baseHost || !clientId) return null; if (!baseHost || !clientId) return null;
const params = new URLSearchParams({ return `${baseHost}/application/o/authorize/?${new URLSearchParams({
response_type: 'code', response_type: 'code', client_id: clientId,
client_id: clientId, redirect_uri: `${base}/api/auth/callback`, scope: 'openid email profile',
redirect_uri: `${base}/api/auth/callback`, })}`;
scope: 'openid email profile',
});
return `${baseHost}/application/o/authorize/?${params}`;
} }
export default function LoginPage() { function LoginForm() {
const router = useRouter(); const router = useRouter();
const params = useSearchParams();
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const oidcUrl = typeof window !== 'undefined' ? authentikUrl() : null; const oidcUrl = typeof window !== 'undefined' ? authentikUrl() : null;
useEffect(() => {
const e = params.get('error');
if (e) setError(OIDC_ERROR_LABELS[e] ?? `Auth error: ${e}`);
}, [params]);
const submit = async (e: React.FormEvent) => { const submit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!username || !password) return; if (!username || !password || loading) return;
setLoading(true); setError(''); setLoading(true); setError('');
const res = await fetch('/api/auth/login', { try {
method: 'POST', const res = await fetch('/api/auth/login', {
headers: { 'content-type': 'application/json' }, method: 'POST',
body: JSON.stringify({ username, password }), headers: { 'content-type': 'application/json' },
}); body: JSON.stringify({ username, password }),
if (res.ok) { });
router.push('/dashboard'); if (res.ok) {
} else { router.push('/dashboard');
const j = await res.json().catch(() => ({})); } else {
setError(j.error ?? 'Login failed'); const j = await res.json().catch(() => ({}));
setError(j.error ?? `Login failed (${res.status})`);
setLoading(false);
}
} catch {
setError('Network error — check your connection and try again.');
setLoading(false); setLoading(false);
} }
}; };
@@ -59,17 +68,11 @@ export default function LoginPage() {
justifyContent: 'center', background: 'var(--bg)', justifyContent: 'center', background: 'var(--bg)',
}}> }}>
<div style={{ width: '100%', maxWidth: 360, padding: '0 20px' }}> <div style={{ width: '100%', maxWidth: 360, padding: '0 20px' }}>
{/* brand */}
<div style={{ textAlign: 'center', marginBottom: 32 }}> <div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 6 }}> <div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 6 }}>cvfs</div>
cvfs <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Sign in to your account</div>
</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
Sign in to your account
</div>
</div> </div>
{/* form card */}
<div style={{ <div style={{
background: 'var(--surface)', border: '1px solid var(--border)', background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, padding: '24px 24px 20px', borderRadius: 8, padding: '24px 24px 20px',
@@ -82,7 +85,7 @@ export default function LoginPage() {
<input <input
type="text" autoComplete="username" autoFocus type="text" autoComplete="username" autoFocus
value={username} onChange={e => setUsername(e.target.value)} value={username} onChange={e => setUsername(e.target.value)}
placeholder="admin" placeholder="admin" disabled={loading}
/> />
</div> </div>
<div> <div>
@@ -92,7 +95,7 @@ export default function LoginPage() {
<input <input
type="password" autoComplete="current-password" type="password" autoComplete="current-password"
value={password} onChange={e => setPassword(e.target.value)} value={password} onChange={e => setPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••" disabled={loading}
/> />
</div> </div>
@@ -142,3 +145,11 @@ export default function LoginPage() {
</div> </div>
); );
} }
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginForm />
</Suspense>
);
}

View File

@@ -1,34 +1,60 @@
'use client'; 'use client';
import { useState } from 'react'; import { MouseEvent, useState } from 'react';
import { Version } from '@/libs/api'; import { Version } from '@/libs/api';
type TreeNode = { version: Version; children: TreeNode[] }; type TreeNode = { version: Version; children: TreeNode[]; ancestorPatchCount: number };
function buildTree(versions: Version[]): TreeNode | null { function buildTree(versions: Version[]): TreeNode | null {
const map = new Map(versions.map(v => [v.id, { version: v, children: [] as TreeNode[] }])); const map = new Map(versions.map(v => [v.id, { version: v, children: [] as TreeNode[], ancestorPatchCount: 0 }]));
let root: TreeNode | null = null; let root: TreeNode | null = null;
for (const node of map.values()) { for (const node of map.values()) {
const pid = node.version.parent_version_id; const pid = node.version.parent_version_id;
if (!pid) { root = node; } else { map.get(pid)?.children.push(node); } if (!pid) { root = node; } else { map.get(pid)?.children.push(node); }
} }
// annotate each node with total ancestor patch count
function annotate(node: TreeNode, inherited: number) {
node.ancestorPatchCount = inherited;
for (const child of node.children) annotate(child, inherited + node.version.patches.length);
}
if (root) annotate(root, 0);
return root; return root;
} }
export function versionToMarkdown(version: Version, parentName?: string): string {
const header = `## ${version.version_label || version.branch_name}${parentName ? ` (from ${parentName})` : ''}`;
const blocks = (version.structured_blocks ?? []).map(b =>
`[${b.path}] ${b.block_type}: ${b.text}`
).join('\n');
return `${header}\n\n${blocks || '(no blocks)'}`;
}
const DOT_COLORS = ['#0a0a0a', '#2563eb', '#7c3aed', '#059669', '#d97706', '#dc2626', '#0891b2']; const DOT_COLORS = ['#0a0a0a', '#2563eb', '#7c3aed', '#059669', '#d97706', '#dc2626', '#0891b2'];
function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: { function Node({ node, depth, selectedId, onSelect, onDelete, onCopyMarkdown, onPropagate, colorIndex = 0 }: {
node: TreeNode; depth: number; selectedId: string | null; node: TreeNode; depth: number; selectedId: string | null;
onSelect: (id: string) => void; onSelect: (id: string) => void;
onDelete?: (id: string) => void; onDelete?: (id: string) => void;
onCopyMarkdown?: (id: string) => void;
onPropagate?: (id: string) => void;
colorIndex?: number; colorIndex?: number;
}) { }) {
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const [copied, setCopied] = useState(false);
const v = node.version; const v = node.version;
const isRoot = !v.parent_version_id; const isRoot = !v.parent_version_id;
const isSelected = v.id === selectedId; const isSelected = v.id === selectedId;
const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length]; const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length];
const hasChildren = node.children.length > 0;
const canPropagate = hasChildren && v.patches.length > 0;
const handleCopy = (e: MouseEvent) => {
e.stopPropagation();
onCopyMarkdown?.(v.id);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return ( return (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
@@ -57,7 +83,7 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
width: 12, height: 12, display: 'flex', alignItems: 'center', width: 12, height: 12, display: 'flex', alignItems: 'center',
justifyContent: 'center', cursor: 'pointer', background: 'none', justifyContent: 'center', cursor: 'pointer', background: 'none',
border: 'none', padding: 0, color: 'var(--text-faint)', flexShrink: 0, border: 'none', padding: 0, color: 'var(--text-faint)', flexShrink: 0,
opacity: node.children.length > 0 ? 1 : 0, pointerEvents: node.children.length > 0 ? 'auto' : 'none', opacity: hasChildren ? 1 : 0, pointerEvents: hasChildren ? 'auto' : 'none',
}} }}
> >
<svg width="7" height="7" viewBox="0 0 8 8" fill="currentColor" <svg width="7" height="7" viewBox="0 0 8 8" fill="currentColor"
@@ -83,6 +109,7 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
{v.version_label || v.branch_name} {v.version_label || v.branch_name}
</span> </span>
{/* own patch count */}
{v.patches.length > 0 && ( {v.patches.length > 0 && (
<span style={{ <span style={{
fontSize: 10, color: 'var(--text-faint)', fontSize: 10, color: 'var(--text-faint)',
@@ -93,6 +120,53 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
</span> </span>
)} )}
{/* ancestor patch indicator on non-root branches */}
{!isRoot && node.ancestorPatchCount > 0 && (
<span
title={`${node.ancestorPatchCount} patch${node.ancestorPatchCount !== 1 ? 'es' : ''} inherited from ancestors`}
style={{
fontSize: 10, color: '#2563eb',
background: '#eff6ff', padding: '1px 4px',
borderRadius: 3, flexShrink: 0,
}}
>
{node.ancestorPatchCount}
</span>
)}
{hovered && onCopyMarkdown && (
<button
onClick={handleCopy}
title="Copy Markdown"
aria-label="Copy Markdown"
style={{
width: 18, height: 18, display: 'flex', alignItems: 'center',
justifyContent: 'center', cursor: 'pointer', background: 'none',
border: 'none', padding: 0, color: copied ? '#059669' : 'var(--text-faint)',
flexShrink: 0, borderRadius: 3, fontSize: 11, lineHeight: 1,
}}
>
{copied ? '✓' : '⎘'}
</button>
)}
{/* propagate button: version has own patches and has children */}
{hovered && canPropagate && onPropagate && (
<button
onClick={e => { e.stopPropagation(); onPropagate(v.id); }}
title={`Propagate ${v.patches.length} patch${v.patches.length !== 1 ? 'es' : ''} to children`}
aria-label="Propagate patches to children"
style={{
width: 18, height: 18, display: 'flex', alignItems: 'center',
justifyContent: 'center', cursor: 'pointer', background: 'none',
border: 'none', padding: 0, color: '#7c3aed',
flexShrink: 0, borderRadius: 3, fontSize: 12, lineHeight: 1,
}}
>
</button>
)}
{!isRoot && onDelete && hovered && ( {!isRoot && onDelete && hovered && (
<button <button
onClick={e => { e.stopPropagation(); onDelete(v.id); }} onClick={e => { e.stopPropagation(); onDelete(v.id); }}
@@ -110,7 +184,7 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
)} )}
</div> </div>
{open && node.children.length > 0 && ( {open && hasChildren && (
<div style={{ <div style={{
marginLeft: depth > 0 ? 22 : 14, marginLeft: depth > 0 ? 22 : 14,
borderLeft: `1px solid var(--border)`, borderLeft: `1px solid var(--border)`,
@@ -124,6 +198,8 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
selectedId={selectedId} selectedId={selectedId}
onSelect={onSelect} onSelect={onSelect}
onDelete={onDelete} onDelete={onDelete}
onCopyMarkdown={onCopyMarkdown}
onPropagate={onPropagate}
colorIndex={depth === 0 ? i + 1 : colorIndex} colorIndex={depth === 0 ? i + 1 : colorIndex}
/> />
))} ))}
@@ -133,16 +209,18 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
); );
} }
export default function CVTree({ versions, selectedVersionId, onSelect, onDeleteVersion }: { export default function CVTree({ versions, selectedVersionId, onSelect, onDeleteVersion, onCopyMarkdown, onPropagate }: {
versions: Version[]; selectedVersionId: string | null; versions: Version[]; selectedVersionId: string | null;
onSelect: (id: string) => void; onSelect: (id: string) => void;
onDeleteVersion?: (id: string) => void; onDeleteVersion?: (id: string) => void;
onCopyMarkdown?: (id: string) => void;
onPropagate?: (id: string) => void;
}) { }) {
const tree = buildTree(versions); const tree = buildTree(versions);
if (!tree) return <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>No versions</div>; if (!tree) return <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>No versions</div>;
return ( return (
<div style={{ paddingBottom: 8 }}> <div style={{ paddingBottom: 8 }}>
<Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} onDelete={onDeleteVersion} /> <Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} onDelete={onDeleteVersion} onCopyMarkdown={onCopyMarkdown} onPropagate={onPropagate} />
</div> </div>
); );
} }

View File

@@ -1,13 +1,46 @@
'use client'; 'use client';
import { useState } from 'react';
import { Patch } from '@/libs/api'; import { Patch } from '@/libs/api';
const OP_SYMBOL: Record<string, string> = { const OP_SYMBOL: Record<string, string> = {
replace_text: '±', remove_block: '', reorder_section: '↕', boost_keyword: '+', replace_text: '±', remove_block: '', reorder_section: '↕', boost_keyword: '+',
}; };
export default function DiffViewer({ patches }: { patches: Patch[] }) { function PatchRow({ p, dim }: { p: Patch; dim?: boolean }) {
if (!patches.length) { return (
<div style={{ borderLeft: '2px solid var(--border-strong)', paddingLeft: 12, opacity: dim ? 0.6 : 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-muted)' }}>
{OP_SYMBOL[p.operation] ?? '·'} {p.target_path}
</span>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{p.operation}</span>
</div>
{p.old_value && (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.5, padding: '3px 6px', background: '#fef2f2', borderRadius: 3, marginBottom: 3, color: '#991b1b', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{p.old_value}
</div>
)}
{p.new_value && (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.5, padding: '3px 6px', background: '#f0fdf4', borderRadius: 3, color: '#166534', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
+ {p.new_value}
</div>
)}
</div>
);
}
export default function DiffViewer({
patches, inheritedPatches, ancestorLabel,
}: {
patches: Patch[];
inheritedPatches?: Patch[];
ancestorLabel?: string;
}) {
const [showInherited, setShowInherited] = useState(false);
const hasInherited = (inheritedPatches?.length ?? 0) > 0;
if (!patches.length && !hasInherited) {
return ( return (
<div style={{ padding: '20px 0', color: 'var(--text-faint)', fontSize: 13 }}> <div style={{ padding: '20px 0', color: 'var(--text-faint)', fontSize: 13 }}>
No patches identical to parent. No patches identical to parent.
@@ -16,27 +49,37 @@ export default function DiffViewer({ patches }: { patches: Patch[] }) {
} }
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div>
{patches.map(p => ( {hasInherited && (
<div key={p.id} style={{ borderLeft: '2px solid var(--border-strong)', paddingLeft: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}> <button
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-muted)' }}> className="btn btn-ghost"
{OP_SYMBOL[p.operation] ?? '·'} {p.target_path} style={{ fontSize: 11, padding: '2px 8px' }}
</span> onClick={() => setShowInherited(v => !v)}
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{p.operation}</span> >
</div> {showInherited ? '▾' : '▸'} Inherited from ancestors ({inheritedPatches!.length})
{p.old_value && ( </button>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.5, padding: '3px 6px', background: '#fef2f2', borderRadius: 3, marginBottom: 3, color: '#991b1b', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}> {ancestorLabel && (
{p.old_value} <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>via {ancestorLabel}</span>
</div>
)}
{p.new_value && (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.5, padding: '3px 6px', background: '#f0fdf4', borderRadius: 3, color: '#166534', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
+ {p.new_value}
</div>
)} )}
</div> </div>
))} )}
{showInherited && hasInherited && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 18, paddingBottom: 14, borderBottom: '1px dashed var(--border)' }}>
{inheritedPatches!.map(p => <PatchRow key={p.id} p={p} dim />)}
</div>
)}
{patches.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{patches.map(p => <PatchRow key={p.id} p={p} />)}
</div>
) : (
<div style={{ padding: '8px 0', color: 'var(--text-faint)', fontSize: 13 }}>
No direct patches on this branch.
</div>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,64 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { previewVersionPdfUrl } from '@/libs/api';
function getToken(): string | null {
if (typeof document === 'undefined') return null;
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('oidc_token_pub='))?.split('=').slice(1).join('=') ?? null;
}
export default function PDFPreview({ documentId, versionId }: { documentId: string; versionId: string }) {
const [src, setSrc] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const prevUrl = useRef<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const token = getToken();
fetch(previewVersionPdfUrl(documentId, versionId), {
headers: token ? { authorization: `Bearer ${decodeURIComponent(token)}` } : {},
})
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.blob();
})
.then(blob => {
if (cancelled) return;
if (prevUrl.current) URL.revokeObjectURL(prevUrl.current);
const url = URL.createObjectURL(blob);
prevUrl.current = url;
setSrc(url);
})
.catch(e => { if (!cancelled) setError(e.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [documentId, versionId]);
useEffect(() => () => { if (prevUrl.current) URL.revokeObjectURL(prevUrl.current); }, []);
if (loading) return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-faint)', fontSize: 13 }}>
Rendering PDF
</div>
);
if (error) return (
<div style={{ padding: 16, fontSize: 12, color: '#dc2626' }}>
Preview unavailable: {error}
</div>
);
if (!src) return null;
return (
<iframe
src={src}
style={{ width: '100%', height: '100%', border: 'none', borderRadius: 4 }}
title="CV Preview"
/>
);
}

View File

@@ -144,6 +144,15 @@ export async function uploadDocument(title: string, description: string | null,
export const downloadVersionUrl = (documentId: string, versionId: string): string => export const downloadVersionUrl = (documentId: string, versionId: string): string =>
`${API}/api/v1/documents/${documentId}/versions/${versionId}/download`; `${API}/api/v1/documents/${documentId}/versions/${versionId}/download`;
export const previewVersionPdfUrl = (documentId: string, versionId: string): string =>
`${API}/api/v1/documents/${documentId}/versions/${versionId}/preview`;
export async function uploadDocxToBranch(documentId: string, versionId: string, file: File): Promise<Version> {
const form = new FormData();
form.append('file', file);
return req<Version>(`/api/v1/documents/${documentId}/versions/${versionId}/upload`, { method: 'POST', body: form });
}
export async function createBranch( export async function createBranch(
parentVersionId: string, parentVersionId: string,
branchName: string, branchName: string,

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
const SECRET = process.env.SESSION_SECRET ?? 'dev-secret-change-in-production'; const SECRET = process.env.SESSION_SECRET ?? 'dev-secret-change-in-production';
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days in ms
async function verifySession(token: string): Promise<boolean> { async function verifySession(token: string): Promise<boolean> {
const lastDot = token.lastIndexOf('.'); const lastDot = token.lastIndexOf('.');
@@ -13,7 +14,11 @@ async function verifySession(token: string): Promise<boolean> {
{ name: 'HMAC', hash: 'SHA-256' }, false, ['verify'], { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'],
); );
const sigBytes = new Uint8Array((sigHex.match(/.{1,2}/g) ?? []).map(b => parseInt(b, 16))); const sigBytes = new Uint8Array((sigHex.match(/.{1,2}/g) ?? []).map(b => parseInt(b, 16)));
return await globalThis.crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(payload)); const valid = await globalThis.crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(payload));
if (!valid) return false;
// check expiry from embedded timestamp (payload = "username:timestamp")
const ts = parseInt(payload.split(':').pop() ?? '0', 10);
return !isNaN(ts) && Date.now() - ts < SESSION_MAX_AGE;
} catch { return false; } } catch { return false; }
} }