feat(dashboard): complete CV branching dashboard with auth and full editing workflow

- Visual branch heritage tree with colored dots and connecting lines, depth-aware expand/collapse
- Dashboard 3-tab layout: Content (inline block editing + patch staging), Patches (diff view), Submissions (AI suggestions)
- Inline block editing: click to edit any CV block, stage edits, save as named branch with pre-filled patches
- Submissions tab: create applications, request AI tailoring suggestions, accept/reject per suggestion
- Simple hardcoded login (username/password via env vars LOGIN_USER/LOGIN_PASS, defaults admin/admin)
- Authentik OIDC integration: authorize redirect + callback exchange, configurable via NEXT_PUBLIC_AUTHENTIK_*
- Middleware protecting /dashboard with session cookie verification (HMAC-SHA256)
- Auth API routes: /api/auth/login, /api/auth/logout, /api/auth/callback, /api/auth/token
- Backend: GET/PATCH submission routes for listing submissions and accepting/rejecting AI suggestions
- API client: OIDC bearer token forwarding from client-readable cookie

https://claude.ai/code/session_01CdisLhbC2kVt2hxfJ7TNPf
This commit is contained in:
Claude
2026-04-03 13:45:51 +00:00
parent 9a8add0bcd
commit 01f34915f6
14 changed files with 1023 additions and 217 deletions

View File

@@ -9,14 +9,43 @@ from app.schemas import (
SubmissionCreateRequest,
SubmissionResponse,
SuggestionResponse,
SuggestionUpdateRequest,
)
from app.services.submissions import (
create_submission,
get_submission,
list_submissions,
request_ai_suggestions,
update_suggestion,
)
from app.services.submissions import create_submission, request_ai_suggestions
from dlib.auth import AuthenticatedUser
router = APIRouter(prefix="/submissions", tags=["submissions"])
@router.get("", response_model=list[SubmissionResponse])
async def list_submissions_endpoint(
version_id: str | None = None,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
items = await list_submissions(session, owner_id=user.sub, version_id=version_id)
return [SubmissionResponse.model_validate(s) for s in items]
@router.get("/{submission_id}", response_model=SubmissionResponse)
async def get_submission_endpoint(
submission_id: str,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
submission = await get_submission(session, owner_id=user.sub, submission_id=submission_id)
if not submission:
raise HTTPException(status_code=404, detail="Submission not found")
return SubmissionResponse.model_validate(submission)
@router.post("", response_model=SubmissionResponse)
async def create_submission_endpoint(
payload: SubmissionCreateRequest,
@@ -54,3 +83,23 @@ async def request_submissions_ai(
if suggestions is None:
raise HTTPException(status_code=404, detail="Submission not found")
return [SuggestionResponse.model_validate(item) for item in suggestions]
@router.patch("/{submission_id}/suggestions/{suggestion_id}", response_model=SuggestionResponse)
async def update_suggestion_endpoint(
submission_id: str,
suggestion_id: str,
payload: SuggestionUpdateRequest,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
suggestion = await update_suggestion(
session,
owner_id=user.sub,
submission_id=submission_id,
suggestion_id=suggestion_id,
accepted=payload.accepted,
)
if not suggestion:
raise HTTPException(status_code=404, detail="Suggestion not found")
return SuggestionResponse.model_validate(suggestion)

View File

@@ -10,6 +10,7 @@ from .cv import (
SubmissionCreateRequest,
SubmissionResponse,
SuggestionResponse,
SuggestionUpdateRequest,
VersionResponse,
)
@@ -23,6 +24,7 @@ __all__ = [
"SubmissionResponse",
"AiSuggestionRequest",
"SuggestionResponse",
"SuggestionUpdateRequest",
"PublishRequest",
"PublicAssetResponse",
"PublicAssetLookupResponse",

View File

@@ -123,3 +123,7 @@ class PublicAssetResponse(BaseModel):
class PublicAssetLookupResponse(BaseModel):
asset: PublicAssetResponse
class SuggestionUpdateRequest(BaseModel):
accepted: bool

View File

@@ -84,6 +84,55 @@ async def request_ai_suggestions(
return created
async def list_submissions(
session: AsyncSession,
*,
owner_id: str,
version_id: str | None = None,
) -> list[Submission]:
stmt = (
select(Submission)
.join(Submission.version)
.join(CvVersion.document)
.where(CvDocument.owner_id == owner_id)
.options(selectinload(Submission.suggestions))
)
if version_id:
stmt = stmt.where(Submission.version_id == version_id)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_submission(
session: AsyncSession, *, owner_id: str, submission_id: str
) -> Submission | None:
return await _get_submission_for_owner(session, owner_id, submission_id)
async def update_suggestion(
session: AsyncSession,
*,
owner_id: str,
submission_id: str,
suggestion_id: str,
accepted: bool,
) -> AiSuggestion | None:
submission = await _get_submission_for_owner(session, owner_id, submission_id)
if not submission:
return None
stmt = select(AiSuggestion).where(
AiSuggestion.id == suggestion_id, AiSuggestion.submission_id == submission_id
)
result = await session.execute(stmt)
suggestion = result.scalars().one_or_none()
if not suggestion:
return None
suggestion.accepted = accepted
await session.commit()
await session.refresh(suggestion)
return suggestion
async def _get_version_for_owner(
session: AsyncSession, owner_id: str, version_id: str
) -> CvVersion | None: