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

@@ -15,45 +15,106 @@ function buildTree(versions: Version[]): TreeNode | null {
return root;
}
function Node({ node, depth, selectedId, onSelect }: {
node: TreeNode; depth: number; selectedId: string | null; onSelect: (id: string) => void;
const DOT_COLORS = ['#0a0a0a', '#2563eb', '#7c3aed', '#059669', '#d97706', '#dc2626', '#0891b2'];
function Node({ node, depth, selectedId, onSelect, colorIndex = 0 }: {
node: TreeNode; depth: number; selectedId: string | null;
onSelect: (id: string) => void; colorIndex?: number;
}) {
const [open, setOpen] = useState(true);
const v = node.version;
const isRoot = !v.parent_version_id;
const isSelected = v.id === selectedId;
const hasChildren = node.children.length > 0;
const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length];
return (
<div>
<div style={{ position: 'relative' }}>
{/* horizontal connector from parent's vertical line */}
{depth > 0 && (
<div style={{
position: 'absolute', left: -1, top: 15,
width: 14, height: 1, background: 'var(--border-strong)', zIndex: 1,
}} />
)}
<div
onClick={() => onSelect(v.id)}
style={{
display: 'flex', alignItems: 'center', gap: 4,
paddingLeft: 12 + depth * 16, paddingRight: 8,
height: 30, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 6,
paddingLeft: depth > 0 ? 18 : 8, paddingRight: 8,
height: 30, cursor: 'pointer', borderRadius: 4, userSelect: 'none',
background: isSelected ? 'var(--selected-bg)' : 'transparent',
borderLeft: isSelected ? '2px solid var(--selected-border)' : '2px solid transparent',
transition: 'background 0.1s',
borderLeft: isSelected && depth === 0 ? '2px solid var(--selected-border)' : '2px solid transparent',
}}
onMouseEnter={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'var(--hover)'; }}
onMouseLeave={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
>
{/* expand toggle */}
<button
onClick={e => { e.stopPropagation(); setOpen(o => !o); }}
style={{ width: 14, height: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: hasChildren ? 1 : 0, cursor: 'pointer', background: 'none', border: 'none', padding: 0, color: 'var(--text-faint)', flexShrink: 0 }}
style={{
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',
}}
>
<svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor" style={{ transform: open ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.15s' }}>
<svg width="7" height="7" viewBox="0 0 8 8" fill="currentColor"
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform 0.12s' }}>
<path d="M2 1l4 3-4 3V1z" />
</svg>
</button>
<span style={{ flex: 1, fontSize: 13, fontWeight: !v.parent_version_id ? 600 : 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text)' }}>
{/* dot indicator */}
<span style={{
width: isRoot ? 8 : 7, height: isRoot ? 8 : 7,
borderRadius: '50%', flexShrink: 0,
background: isRoot || isSelected ? dotColor : 'transparent',
border: `2px solid ${dotColor}`,
transition: 'background 0.1s',
}} />
{/* label */}
<span style={{
flex: 1, fontSize: 13,
fontWeight: isRoot ? 600 : 400,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
color: isSelected ? 'var(--text)' : isRoot ? 'var(--text)' : 'var(--text-muted)',
}}>
{v.version_label || v.branch_name}
</span>
{/* patch count */}
{v.patches.length > 0 && (
<span style={{
fontSize: 10, color: 'var(--text-faint)',
background: 'var(--hover)', padding: '1px 4px',
borderRadius: 3, flexShrink: 0,
}}>
{v.patches.length}
</span>
)}
</div>
{open && node.children.map(child => (
<Node key={child.version.id} node={child} depth={depth + 1} selectedId={selectedId} onSelect={onSelect} />
))}
{/* children with vertical line */}
{open && node.children.length > 0 && (
<div style={{
marginLeft: depth > 0 ? 22 : 14,
borderLeft: `1px solid var(--border)`,
paddingLeft: 0,
}}>
{node.children.map((child, i) => (
<Node
key={child.version.id}
node={child}
depth={depth + 1}
selectedId={selectedId}
onSelect={onSelect}
colorIndex={depth === 0 ? i + 1 : colorIndex}
/>
))}
</div>
)}
</div>
);
}