Compare commits

...

14 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
copilot-swe-agent[bot]
03187ab456 Remove cv.alves.world references from README
Agent-Logs-Url: https://github.com/velocitatem/cvfs/sessions/96afd90d-596d-4afd-a366-a95782f198f4

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>
2026-04-07 20:37:34 +00:00
Daniel Alves Rösel
9b5add1cdf Merge pull request #9 from velocitatem/copilot/remove-maximum-patchsize
Remove max patchsize limit from validate_patchset
2026-04-07 14:18:36 +04:00
copilot-swe-agent[bot]
3838bfe51a remove max patchsize limit from validate_patchset
Agent-Logs-Url: https://github.com/velocitatem/cvfs/sessions/e5483685-0807-4655-ba29-68311e6403db

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>
2026-04-07 10:08:56 +00:00
Daniel Alves Rösel
9c57dcaedb Skip dashboard auth middleware in demo mode 2026-04-05 16:36:49 +04:00
Claude
5680465e98 Skip login redirect in demo mode
When NEXT_PUBLIC_DEMO=true, bypass session/OIDC checks and allow direct dashboard access.

https://claude.ai/code/session_01PD5xMUxpkDEdfTAGZ2J4Yr
2026-04-05 12:20:17 +00:00
41352b05ec chore: upgrade Next.js to 15.5.14 for security 2026-04-05 13:44:23 +02:00
04882b2652 fix: remove unused dashboard demo timestamp constant 2026-04-05 13:41:46 +02:00
Daniel Alves Rösel
6ab7e35a6a Merge pull request #8 from velocitatem/claude/nlp-insights-demo-mode-iG1lv
feat: NLP patch insights + standalone demo mode
2026-04-05 15:27:36 +04:00
16 changed files with 670 additions and 118 deletions

View File

@@ -105,9 +105,9 @@ flowchart LR
| Variable | Description | Default |
|----------|-------------|---------|
| `API_BASE_URL` | Backend host consumed by the webapp build + runtime. | `http://cvfs-backend:8080` in Docker |
| `NEXT_PUBLIC_BASE_URL` | Public origin for web routes. | `https://cv.alves.world` |
| `NEXT_PUBLIC_BASE_URL` | Public origin for web routes. | |
| `AUTHENTIK_*` / `AUTH_OIDC_*` | OIDC issuer, audience, and client credentials. | required |
| `MINIO_ENDPOINT` / `MINIO_BUCKET` | Object storage endpoint + bucket for artifacts. | `https://storage.cv.alves.world` / `resume-branches` |
| `MINIO_ENDPOINT` / `MINIO_BUCKET` | Object storage endpoint + bucket for artifacts. | |
| `REDIS_URL` | Celery broker/result backend. | `redis://cvfs-redis:6379/0` |
| `DATABASE_URL` | SQLAlchemy DSN for Postgres. | `postgresql+asyncpg://postgres:postgres@cvfs-postgres:5432/resume_branches` |
| `ANTHROPIC_API_KEY` | Enables AI tailoring jobs. | unset |
@@ -148,7 +148,7 @@ Use `bun x nx affected -t lint,test,build` before PRs to run fine-grained checks
## Deployment notes
- **Docker images:** `docker/backend-fastapi.Dockerfile`, `docker/webapp.Dockerfile`, and `docker/worker.Dockerfile` bake env args for Dokploy. The web build now receives `API_BASE_URL` so rewrites point at the deployed backend.
- **Dokploy:** see `docs/resume-branches/dokploy.md` for the API payload that provisions `cvfs-backend`, `cvfs-webapp`, Redis, Postgres, and MinIO on `cv.alves.world` / `api.cv.alves.world`.
- **Dokploy:** see `docs/resume-branches/dokploy.md` for the API payload that provisions `cvfs-backend`, `cvfs-webapp`, Redis, Postgres, and MinIO.
- **Storage:** `make lift.minio` mirrors the production bucket layout. Set `MINIO_ROOT_USER/MINIO_ROOT_PASSWORD` for parity. Public artifacts should only be published via the API to guarantee immutability + audit logging.
## Limitations & next steps

View File

@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
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 (
create_document,
delete_document,
@@ -14,8 +14,9 @@ from app.services.documents import (
list_documents,
)
from app.services.storage import storage_client
from app.services.versions import upload_docx_to_version
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"])
@@ -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)
async def upload_document(
title: str = Form(...),

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from fastapi import UploadFile
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -8,11 +9,34 @@ from dlib.cv import (
StructuredBlock,
StructuredDocument,
PatchPayload,
PatchOperation,
apply_patchset,
validate_patchset,
parse_docx_bytes,
)
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(
@@ -78,7 +102,10 @@ async def create_branch(
stmt_refresh = (
select(CvVersion)
.where(CvVersion.id == new_version.id)
.options(selectinload(CvVersion.patches))
.options(
selectinload(CvVersion.patches),
selectinload(CvVersion.public_assets),
)
)
result = await session.execute(stmt_refresh)
return result.scalars().one()
@@ -95,7 +122,10 @@ async def append_patches_to_version(
select(CvVersion)
.join(CvVersion.document)
.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)
version = result.scalars().one_or_none()
@@ -138,12 +168,64 @@ async def append_patches_to_version(
stmt_refresh = (
select(CvVersion)
.where(CvVersion.id == version_id)
.options(selectinload(CvVersion.patches))
.options(
selectinload(CvVersion.patches),
selectinload(CvVersion.public_assets),
)
)
result = await session.execute(stmt_refresh)
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(
session: AsyncSession, owner_id: str, version_id: str
) -> bool | str:

View File

@@ -7,7 +7,7 @@
"dependencies": {
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.57.4",
"next": "15.5.2",
"next": "15.5.14",
"react": "19.1.0",
"react-dom": "19.1.0",
},
@@ -18,7 +18,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.2",
"eslint-config-next": "15.5.14",
"tailwindcss": "^4",
"typescript": "^5",
},
@@ -117,25 +117,25 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@next/env": ["@next/env@15.5.2", "", {}, "sha512-Qe06ew4zt12LeO6N7j8/nULSOe3fMXE4dM6xgpBQNvdzyK1sv5y4oAP3bq4LamrvGCZtmRYnW8URFCeX5nFgGg=="],
"@next/env": ["@next/env@15.5.14", "", {}, "sha512-aXeirLYuASxEgi4X4WhfXsShCFxWDfNn/8ZeC5YXAS2BB4A8FJi1kwwGL6nvMVboE7fZCzmJPNdMvVHc8JpaiA=="],
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.5.2", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-lkLrRVxcftuOsJNhWatf1P2hNVfh98k/omQHrCEPPriUypR6RcS13IvLdIrEvkm9AH2Nu2YpR5vLqBuy6twH3Q=="],
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.5.14", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-ogBjgsFrPPz19abP3VwcYSahbkUOMMvJjxCOYWYndw+PydeMuLuB4XrvNkNutFrTjC9St2KFULRdKID8Sd/CMQ=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8bGt577BXGSd4iqFygmzIfTYizHb0LGWqH+qgIF/2EDxS5JsSdERJKA8WgwDyNBZgTIIA4D8qUtoQHmxIIquoQ=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y9K6SPzobnZvrRDPO2s0grgzC+Egf0CqfbdvYmQVaztV890zicw8Z8+4Vqw8oPck8r1TjUHxVh8299Cg4TrxXg=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-2DjnmR6JHK4X+dgTXt5/sOCu/7yPtqpYt8s8hLkHFK3MGkka2snTv3yRMdHvuRtJVkPwCGsvBSwmoQCHatauFQ=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-aNnkSMjSFRTOmkd7qoNI2/rETQm/vKD6c/Ac9BZGa9CtoOzy3c2njgz7LvebQJ8iPxdeTuGnAjagyis8a9ifBw=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3j7SWDBS2Wov/L9q0mFJtEvQ5miIqfO4l7d2m9Mo06ddsgUK8gWfHGgbjdFlCp2Ek7MmMQZSxpGFqcC8zGh2AA=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-tjlpia+yStPRS//6sdmlVwuO1Rioern4u2onafa5n+h2hCS9MAvMXqpVbSrjgiEOoCs0nJy7oPOmWgtRRNSM5Q=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-s6N8k8dF9YGc5T01UPQ08yxsK6fUow5gG1/axWc1HVVBYQBgOjca4oUZF7s4p+kwhkB1bDSGR8QznWrFZ/Rt5g=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-8B8cngBaLadl5lbDRdxGCP1Lef8ipD6KlxS3v0ElDAGil6lafrAM3B258p1KJOglInCVFUjk751IXMr2ixeQOQ=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-o1RV/KOODQh6dM6ZRJGZbc+MOAHww33Vbs5JC9Mp1gDk8cpEO+cYC/l7rweiEalkSm5/1WGa4zY7xrNwObN4+Q=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-bAS6tIAg8u4Gn3Nz7fCPpSoKAexEt2d5vn1mzokcqdqyov6ZJ6gu6GdF9l8ORFrBuRHgv3go/RfzYz5BkZ6YSQ=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-/VUnh7w8RElYZ0IV83nUcP/J4KJ6LLYliiBIri3p3aW2giF+PAVgZb6mk8jbQSB3WlTai8gEmCAr7kptFa1H6g=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-mMxv/FcrT7Gfaq4tsR22l17oKWXZmH/lVqcvjX0kfp5I0lKodHYLICKPoX1KRnnE+ci6oIUdriUhuA3rBCDiSw=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-sMPyTvRcNKXseNQ/7qRfVRLa0VhR0esmQ29DD6pqvG71+JdVnESJaHPA8t7bc67KD5spP3+DOCNLhqlEI2ZgQg=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-OTmiBlYThppnvnsqx0rBqjDRemlmIeZ8/o4zI7veaXoeO1PVHoyj2lfTfXTiiGjCyRDhA10y4h6ZvZvBiynr2g=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.2", "", { "os": "win32", "cpu": "x64" }, "sha512-W5VvyZHnxG/2ukhZF/9Ikdra5fdNftxI6ybeVKYvBPDtyx7x4jPPSNduUkfH5fo3zG0JQ0bPxgy41af2JX5D4Q=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.14", "", { "os": "win32", "cpu": "x64" }, "sha512-+W7eFf3RS7m4G6tppVTOSyP9Y6FsJXfOuKzav1qKniiFm3KFByQfPEcouHdjlZmysl4zJGuGLQ/M9XyVeyeNEg=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -395,7 +395,7 @@
"eslint": ["eslint@9.35.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.1", "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.35.0", "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "bin": "bin/eslint.js" }, "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg=="],
"eslint-config-next": ["eslint-config-next@15.5.2", "", { "dependencies": { "@next/eslint-plugin-next": "15.5.2", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^5.0.0" }, "peerDependencies": { "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", "typescript": ">=3.3.1" } }, "sha512-3hPZghsLupMxxZ2ggjIIrat/bPniM2yRpsVPVM40rp8ZMzKWOJp2CGWn7+EzoV2ddkUr5fxNfHpF+wU1hGt/3g=="],
"eslint-config-next": ["eslint-config-next@15.5.14", "", { "dependencies": { "@next/eslint-plugin-next": "15.5.14", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^5.0.0" }, "peerDependencies": { "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-lmJ5F8ZgOYogq0qtH4L5SpxuASY2SPdOzqUprN2/56+P3GPsIpXaUWIJC66kYIH+yZdsM4nkHE5MIBP6s1NiBw=="],
"eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="],
@@ -635,7 +635,7 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"next": ["next@15.5.2", "", { "dependencies": { "@next/env": "15.5.2", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.2", "@next/swc-darwin-x64": "15.5.2", "@next/swc-linux-arm64-gnu": "15.5.2", "@next/swc-linux-arm64-musl": "15.5.2", "@next/swc-linux-x64-gnu": "15.5.2", "@next/swc-linux-x64-musl": "15.5.2", "@next/swc-win32-arm64-msvc": "15.5.2", "@next/swc-win32-x64-msvc": "15.5.2", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": "dist/bin/next" }, "sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q=="],
"next": ["next@15.5.14", "", { "dependencies": { "@next/env": "15.5.14", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.14", "@next/swc-darwin-x64": "15.5.14", "@next/swc-linux-arm64-gnu": "15.5.14", "@next/swc-linux-arm64-musl": "15.5.14", "@next/swc-linux-x64-gnu": "15.5.14", "@next/swc-linux-x64-musl": "15.5.14", "@next/swc-win32-arm64-msvc": "15.5.14", "@next/swc-win32-x64-msvc": "15.5.14", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -843,8 +843,6 @@
"eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="],
"eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"is-bun-module/semver": ["semver@7.7.2", "", { "bin": "bin/semver.js" }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],

View File

@@ -13,7 +13,7 @@
"dependencies": {
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.57.4",
"next": "15.5.2",
"next": "15.5.14",
"react": "19.1.0",
"react-dom": "19.1.0"
},
@@ -24,7 +24,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.2",
"eslint-config-next": "15.5.14",
"tailwindcss": "^4",
"typescript": "^5"
}

View File

@@ -1,20 +1,59 @@
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 LOGIN_USER = process.env.LOGIN_USER ?? '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) {
return createHmac('sha256', SECRET).update(value).digest('hex');
}
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => ({}));
const { username, password } = body as Record<string, string>;
if (!username || !password || username !== LOGIN_USER || password !== LOGIN_PASS) {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? req.headers.get('x-real-ip') ?? 'unknown';
if (!checkRate(ip)) {
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 });
}
const payload = `${username}:${Date.now()}`;
const token = `${payload}.${sign(payload)}`;
const res = NextResponse.json({ ok: true });

View File

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

View File

@@ -1,6 +1,5 @@
import type { Document, Submission, InsightsResult } from '@/libs/api';
const NOW = new Date().toISOString();
const D = (daysAgo: number) => new Date(Date.now() - daysAgo * 86_400_000).toISOString();
const ROOT_VERSION_ID = 'demo-v1';

View File

@@ -1,9 +1,10 @@
'use client';
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 InsightsPanel from '@/components/cv/InsightsPanel';
import PDFPreview from '@/components/cv/PDFPreview';
import Link from 'next/link';
import {
appendPatches,
@@ -12,6 +13,7 @@ import {
fetchDocuments, fetchInsights, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
InsightsResult,
IS_DEMO,
Patch,
publishVersion, PublicAsset, PublicAssetAnalytics,
requestAiSuggestions,
Submission,
@@ -21,12 +23,32 @@ import {
updateSubmissionStatus,
updateSuggestion,
uploadDocument,
uploadDocxToBranch,
Version,
} from '@/libs/api';
import {
DEMO_DOCUMENTS, DEMO_DOC_ID, DEMO_INSIGHTS, DEMO_SUBMISSIONS,
} 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 ───────────────────────────────────────────────────────────────────
function fmt(iso: string) {
@@ -74,8 +96,14 @@ function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: D
const submit = async () => {
if (!title.trim() || !file) { setError('Title and file required.'); return; }
setLoading(true); setError('');
try { onDone(await uploadDocument(title.trim(), desc.trim() || null, file)); }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Upload failed'); setLoading(false); }
try {
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 (
@@ -123,8 +151,14 @@ function BranchModal({
const submit = async () => {
if (!name.trim()) { setError('Branch name required.'); return; }
setLoading(true); setError('');
try { onDone(await createBranch(version.id, name.trim(), label.trim() || null, patches as Record<string, unknown>[])); }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
try {
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 (
@@ -170,8 +204,14 @@ function SubmissionModal({ version, onClose, onDone }: { version: Version; onClo
const submit = async () => {
if (!company.trim() || !role.trim()) { setError('Company and role required.'); return; }
setLoading(true); setError('');
try { onDone(await createSubmission(version.id, company.trim(), role.trim(), url.trim() || null, jd.trim() || null)); }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
try {
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 (
@@ -211,8 +251,12 @@ function PublishModal({ version, onClose, onDone }: { version: Version; onClose:
setLoading(true); setError('');
try {
const asset = await publishVersion(version.id, null, slug.trim() || null);
onDone(asset);
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
await Promise.resolve(onDone(asset));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed');
} finally {
setLoading(false);
}
};
return (
@@ -554,7 +598,7 @@ function SubmissionsTab({
// ── main dashboard ────────────────────────────────────────────────────────────
type Modal = 'upload' | 'branch' | 'submission' | 'publish' | null;
type Tab = 'content' | 'patches' | 'submissions' | 'insights';
type Tab = 'content' | 'patches' | 'submissions' | 'insights' | 'preview';
export default function Dashboard() {
const [docs, setDocs] = useState<Document[]>([]);
@@ -575,6 +619,11 @@ export default function Dashboard() {
const [applyLoading, setApplyLoading] = useState(false);
const [applyError, setApplyError] = useState('');
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(() => {
if (IS_DEMO) {
@@ -606,6 +655,7 @@ export default function Dashboard() {
setApplyLoading(false);
setPublishedAnalytics({});
setRecentlyPublishedSlug(null);
setPropagateError('');
}, [selectedVersionId]);
useEffect(() => {
@@ -695,6 +745,40 @@ export default function Dashboard() {
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 () => {
if (!selectedVersionId || !stagedPatches.length) return;
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) => {
setSelectedVersionId(id);
setActiveTab('content');
@@ -877,6 +986,8 @@ export default function Dashboard() {
selectedVersionId={selectedVersionId}
onSelect={selectVersion}
onDeleteVersion={handleDeleteVersion}
onCopyMarkdown={handleCopyBranchMarkdown}
onPropagate={handlePropagate}
/>
</div>
)}
@@ -929,6 +1040,8 @@ export default function Dashboard() {
versions={selectedDoc.versions}
selectedVersionId={selectedVersionId}
onSelect={selectVersion}
onCopyMarkdown={handleCopyBranchMarkdown}
onPropagate={handlePropagate}
/>
</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('submission'); }}>Submit</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 && (
<a href="/demo-cv.docx" download="alex-rivera-cv.docx" className="btn btn-ghost">
DOCX
@@ -989,6 +1113,29 @@ export default function Dashboard() {
DOCX
</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>
@@ -1085,9 +1232,31 @@ export default function Dashboard() {
</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 */}
<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
key={t}
onClick={() => setActiveTab(t)}
@@ -1106,7 +1275,7 @@ export default function Dashboard() {
</div>
{/* 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' && (
<ContentTab
blocks={selectedVersion.structured_blocks ?? []}
@@ -1114,9 +1283,17 @@ export default function Dashboard() {
onEdit={stageEdit}
/>
)}
{activeTab === 'patches' && (
<DiffViewer patches={selectedVersion.patches} />
)}
{activeTab === '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' && (
<SubmissionsTab
submissions={IS_DEMO
@@ -1134,6 +1311,11 @@ export default function Dashboard() {
{activeTab === '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>
</>
)}

View File

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

View File

@@ -1,34 +1,60 @@
'use client';
import { useState } from 'react';
import { MouseEvent, useState } from 'react';
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 {
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;
for (const node of map.values()) {
const pid = node.version.parent_version_id;
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;
}
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'];
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;
onSelect: (id: string) => void;
onDelete?: (id: string) => void;
onCopyMarkdown?: (id: string) => void;
onPropagate?: (id: string) => void;
colorIndex?: number;
}) {
const [open, setOpen] = useState(true);
const [hovered, setHovered] = useState(false);
const [copied, setCopied] = useState(false);
const v = node.version;
const isRoot = !v.parent_version_id;
const isSelected = v.id === selectedId;
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 (
<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',
justifyContent: 'center', cursor: 'pointer', background: 'none',
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"
@@ -83,6 +109,7 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
{v.version_label || v.branch_name}
</span>
{/* own patch count */}
{v.patches.length > 0 && (
<span style={{
fontSize: 10, color: 'var(--text-faint)',
@@ -93,6 +120,53 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
</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 && (
<button
onClick={e => { e.stopPropagation(); onDelete(v.id); }}
@@ -110,7 +184,7 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
)}
</div>
{open && node.children.length > 0 && (
{open && hasChildren && (
<div style={{
marginLeft: depth > 0 ? 22 : 14,
borderLeft: `1px solid var(--border)`,
@@ -124,6 +198,8 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
selectedId={selectedId}
onSelect={onSelect}
onDelete={onDelete}
onCopyMarkdown={onCopyMarkdown}
onPropagate={onPropagate}
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;
onSelect: (id: string) => void;
onDeleteVersion?: (id: string) => void;
onCopyMarkdown?: (id: string) => void;
onPropagate?: (id: string) => void;
}) {
const tree = buildTree(versions);
if (!tree) return <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>No versions</div>;
return (
<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>
);
}

View File

@@ -1,13 +1,46 @@
'use client';
import { useState } from 'react';
import { Patch } from '@/libs/api';
const OP_SYMBOL: Record<string, string> = {
replace_text: '±', remove_block: '', reorder_section: '↕', boost_keyword: '+',
};
export default function DiffViewer({ patches }: { patches: Patch[] }) {
if (!patches.length) {
function PatchRow({ p, dim }: { p: Patch; dim?: boolean }) {
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 (
<div style={{ padding: '20px 0', color: 'var(--text-faint)', fontSize: 13 }}>
No patches identical to parent.
@@ -16,27 +49,37 @@ export default function DiffViewer({ patches }: { patches: Patch[] }) {
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{patches.map(p => (
<div key={p.id} style={{ borderLeft: '2px solid var(--border-strong)', paddingLeft: 12 }}>
<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>
{hasInherited && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<button
className="btn btn-ghost"
style={{ fontSize: 11, padding: '2px 8px' }}
onClick={() => setShowInherited(v => !v)}
>
{showInherited ? '▾' : '▸'} Inherited from ancestors ({inheritedPatches!.length})
</button>
{ancestorLabel && (
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>via {ancestorLabel}</span>
)}
</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>
);
}

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 =>
`${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(
parentVersionId: string,
branchName: string,

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
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> {
const lastDot = token.lastIndexOf('.');
@@ -13,12 +14,17 @@ async function verifySession(token: string): Promise<boolean> {
{ name: 'HMAC', hash: 'SHA-256' }, false, ['verify'],
);
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; }
}
export async function middleware(req: NextRequest) {
if (!req.nextUrl.pathname.startsWith('/dashboard')) return NextResponse.next();
if (process.env.NEXT_PUBLIC_DEMO === 'true') return NextResponse.next();
const session = req.cookies.get('session')?.value;
const oidc = req.cookies.get('oidc_token')?.value;
if (oidc) return NextResponse.next();

View File

@@ -13,14 +13,9 @@ def validate_patchset(
document: StructuredDocument,
patches: Iterable[PatchPayload],
*,
max_changes: int = 12,
max_growth_ratio: float = 1.45,
) -> None:
patch_list = list(patches)
if len(patch_list) > max_changes:
raise PatchValidationError(
f"Patchset exceeds max changes ({len(patch_list)} > {max_changes})"
)
block_map = {block.path: block for block in document.blocks}
for patch in patch_list:
block = block_map.get(patch.target_path)