Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6141696
fix(frontend): make typecheck actually type-check files
umzcio Jul 25, 2026
ecb2011
fix(frontend): make coverage honest, monotonic, and single-sourced
umzcio Jul 25, 2026
b4f007c
fix(frontend): correct coverage thresholds to the honest measured bas…
umzcio Jul 27, 2026
5a17b73
refactor(admin): extract shared admin primitives and formatters
umzcio Jul 24, 2026
0b6392f
refactor(admin): extract AuditTab into its own file
umzcio Jul 24, 2026
7df6a38
refactor(admin): extract CertificationsTab into its own file
umzcio Jul 24, 2026
f0036e8
refactor(admin): extract UsageTab into its own file
umzcio Jul 24, 2026
24b5ee1
refactor(admin): extract WorkflowsTab into its own file
umzcio Jul 24, 2026
73f7015
refactor(admin): extract QualityTab into its own file
umzcio Jul 24, 2026
1f19276
refactor(admin): extract UsersTab into its own file
umzcio Jul 24, 2026
cc73e98
refactor(admin): extract TeamsTab into its own file
umzcio Jul 24, 2026
25a5bc9
refactor(admin): extract EmailAnalyticsTab into its own file
umzcio Jul 24, 2026
f60d739
refactor(admin): extract OrganizationsTab into its own file
umzcio Jul 24, 2026
1a1cc27
refactor(admin): extract DemoTab into its own file
umzcio Jul 24, 2026
70e143a
refactor(admin): extract ConfigTab into its own file
umzcio Jul 24, 2026
462ff09
refactor(admin): drop unused React default import
umzcio Jul 24, 2026
8d9f42a
fix(a11y): compute brand-button text color instead of hardcoding blac…
umzcio Jul 25, 2026
60380aa
fix(admin/teams): prevent flex-shrink squeeze on Create/Assign buttons
umzcio Jul 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ uvicorn app.main:app --host 0.0.0.0 --port 8001 --workers 4
make backend-ci # Unit tests (50% coverage gate) + tier-1 integration tests
make backend-static # Ruff lint + Bandit security scan
make backend-backlog # Mypy typecheck + pip-audit (non-blocking)
make frontend-ci # Typecheck, lint, audit, Vitest (35% line coverage gate), build
make frontend-ci # Typecheck, lint, audit, Vitest (coverage gate over whole src/ tree, currently ~6% lines — see frontend/vitest.config.ts), build
make ci # backend-ci + frontend-ci
make release-check # ci + backend-static + Docker builds

Expand Down
8 changes: 5 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,12 @@ frontend-typecheck:
frontend-lint:
cd $(FRONTEND_DIR) && npm run lint

# Coverage gate set just below current measured (~44%). Bump as more
# components/hooks/api modules get tests.
# Coverage denominator is the whole frontend/src tree (see vitest.config.ts),
# not just what a test happens to import, so this number is honest and
# monotonic. Thresholds live in vitest.config.ts (single source) — bump them
# there as more components/hooks/api modules get tests.
frontend-test:
cd $(FRONTEND_DIR) && npm run test:coverage -- --coverage.thresholds.lines=40
cd $(FRONTEND_DIR) && npm run test:coverage

frontend-build:
cd $(FRONTEND_DIR) && npm run build
Expand Down
4 changes: 2 additions & 2 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc --noEmit",
"typecheck": "tsc -b",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:coverage": "vitest run --coverage --coverage.reporter=text",
"test:e2e": "playwright test",
"ci": "npm run typecheck && npm run lint && npm run test:coverage -- --coverage.thresholds.lines=40 && npm run build"
"ci": "npm run typecheck && npm run lint && npm run test:coverage && npm run build"
},
"dependencies": {
"@sentry/react": "^10.63.0",
Expand Down
109 changes: 109 additions & 0 deletions frontend/src/components/admin/AuditTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { useCallback, useEffect, useState } from 'react'
import { ChevronLeft, ChevronRight, Download } from 'lucide-react'

import * as auditApi from '../../api/audit'
import type { AuditLogEntry } from '../../api/audit'

export function AuditTab() {
const [entries, setEntries] = useState<AuditLogEntry[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(0)
const [loading, setLoading] = useState(true)
const [actionFilter, setActionFilter] = useState('')
const [resourceTypeFilter, setResourceTypeFilter] = useState('')
const limit = 25

const load = useCallback(async () => {
setLoading(true)
try {
const data = await auditApi.queryAuditLog({ action: actionFilter || undefined, resource_type: resourceTypeFilter || undefined, skip: page * limit, limit })
setEntries(data.entries)
setTotal(data.total)
} catch { /* ignore */ } finally { setLoading(false) }
}, [page, actionFilter, resourceTypeFilter])

useEffect(() => { load() }, [load])

const ACTION_COLORS: Record<string, string> = {
'document.create': '#dcfce7', 'document.delete': '#fee2e2',
'extraction.run': '#dbeafe', 'workflow.run': '#f3e8ff',
'workflow.approve': '#dcfce7', 'workflow.reject': '#fee2e2',
'user.login': '#f3f4f6', 'config.update': '#ffedd5',
}
const totalPages = Math.ceil(total / limit)

return (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<h2 style={{ fontSize: 18, fontWeight: 700, margin: 0 }}>Audit Log <span style={{ fontSize: 14, fontWeight: 400, color: '#9ca3af' }}>({total} entries)</span></h2>
<a href={auditApi.exportAuditLog({ action: actionFilter, resource_type: resourceTypeFilter })}
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', border: '1px solid #d1d5db', borderRadius: 6, fontSize: 13, color: '#374151', textDecoration: 'none' }}>
<Download size={14} /> Export CSV
</a>
</div>
<div style={{ display: 'flex', gap: 10, marginBottom: 16 }}>
<input type="text" value={actionFilter} onChange={e => { setActionFilter(e.target.value); setPage(0) }}
placeholder="Filter by action…" style={{ padding: '6px 10px', border: '1px solid #d1d5db', borderRadius: 6, fontSize: 13, fontFamily: 'inherit' }} />
<select value={resourceTypeFilter} onChange={e => { setResourceTypeFilter(e.target.value); setPage(0) }}
style={{ padding: '6px 10px', border: '1px solid #d1d5db', borderRadius: 6, fontSize: 13, fontFamily: 'inherit' }}>
<option value="">All resources</option>
{['document','workflow','extraction','user','team','config','organization','approval'].map(r => (
<option key={r} value={r}>{r.charAt(0).toUpperCase() + r.slice(1)}</option>
))}
</select>
</div>
<div style={{ border: '1px solid #e5e7eb', borderRadius: 8, overflow: 'hidden', backgroundColor: '#fff' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ backgroundColor: '#f9fafb', borderBottom: '1px solid #e5e7eb' }}>
{['Time','Action','Actor','Resource','Details'].map(h => (
<th key={h} style={{ padding: '10px 14px', textAlign: 'left', fontSize: 11, fontWeight: 600, color: '#6b7280', textTransform: 'uppercase' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={5} style={{ padding: '32px', textAlign: 'center', color: '#9ca3af' }}>Loading…</td></tr>
) : entries.length === 0 ? (
<tr><td colSpan={5} style={{ padding: '32px', textAlign: 'center', color: '#9ca3af' }}>No entries found</td></tr>
) : entries.map(entry => (
<tr key={entry.uuid} style={{ borderBottom: '1px solid #f3f4f6' }}>
<td style={{ padding: '10px 14px', whiteSpace: 'nowrap', color: '#6b7280' }}>
{entry.timestamp ? new Date(entry.timestamp).toLocaleDateString() + ' ' + new Date(entry.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '-'}
</td>
<td style={{ padding: '10px 14px' }}>
<span style={{ display: 'inline-block', padding: '2px 8px', borderRadius: 12, fontSize: 11, fontWeight: 500, backgroundColor: ACTION_COLORS[entry.action] ?? '#f3f4f6', color: '#374151' }}>
{entry.action}
</span>
</td>
<td style={{ padding: '10px 14px', color: '#374151' }}>{entry.actor_user_id}</td>
<td style={{ padding: '10px 14px', color: '#374151' }}>
{entry.resource_name || entry.resource_id || '-'}
<span style={{ marginLeft: 6, fontSize: 11, color: '#9ca3af' }}>{entry.resource_type}</span>
</td>
<td style={{ padding: '10px 14px', color: '#6b7280', fontSize: 12, maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{Object.keys(entry.detail).length > 0 ? JSON.stringify(entry.detail).slice(0, 80) : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 12 }}>
<span style={{ fontSize: 13, color: '#6b7280' }}>Page {page + 1} of {totalPages}</span>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => setPage(Math.max(0, page - 1))} disabled={page === 0}
style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '5px 12px', border: '1px solid #d1d5db', borderRadius: 6, fontSize: 13, cursor: page === 0 ? 'not-allowed' : 'pointer', opacity: page === 0 ? 0.5 : 1, backgroundColor: '#fff', fontFamily: 'inherit' }}>
<ChevronLeft size={14} /> Previous
</button>
<button onClick={() => setPage(Math.min(totalPages - 1, page + 1))} disabled={page >= totalPages - 1}
style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '5px 12px', border: '1px solid #d1d5db', borderRadius: 6, fontSize: 13, cursor: page >= totalPages - 1 ? 'not-allowed' : 'pointer', opacity: page >= totalPages - 1 ? 0.5 : 1, backgroundColor: '#fff', fontFamily: 'inherit' }}>
Next <ChevronRight size={14} />
</button>
</div>
</div>
)}
</div>
)
}
218 changes: 218 additions & 0 deletions frontend/src/components/admin/CertificationsTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Award, Lock, RefreshCw, Unlock } from 'lucide-react'
import {
getCertificationProgressList, setCertificationUnlock,
type CertificationProgressItem,
} from '../../api/admin'
import { formatNumber } from './shared/format'
import { ExportButton, SearchInput, UserAvatar } from './shared/primitives'

function parseUtcDate(d: string): Date {
// Backend stores UTC but may omit timezone suffix; ensure JS treats it as UTC
if (!d.endsWith('Z') && !d.includes('+') && !d.includes('-', 10)) return new Date(d + 'Z')
return new Date(d)
}

function formatDate(d: string | null): string {
if (!d) return '-'
return parseUtcDate(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}

function downloadCSV(filename: string, headers: string[], rows: (string | number | null)[][]) {
const escape = (v: string | number | null) => {
if (v === null || v === undefined) return ''
const s = String(v)
return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s
}
const csv = [headers.join(','), ...rows.map(r => r.map(escape).join(','))].join('\n')
const blob = new Blob([csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}

export function CertificationsTab() {
const [items, setItems] = useState<CertificationProgressItem[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [busyUser, setBusyUser] = useState<string | null>(null)

const refresh = useCallback(async () => {
setLoading(true)
try {
const data = await getCertificationProgressList()
setItems(data)
} finally {
setLoading(false)
}
}, [])

useEffect(() => { refresh() }, [refresh])

const filtered = useMemo(() => {
if (!search.trim()) return items
const q = search.toLowerCase()
return items.filter(p =>
(p.name || '').toLowerCase().includes(q) ||
(p.email || '').toLowerCase().includes(q) ||
(p.user_id || '').toLowerCase().includes(q)
)
}, [items, search])

const toggleUnlock = async (item: CertificationProgressItem) => {
setBusyUser(item.user_id)
try {
await setCertificationUnlock(item.user_id, !item.unlocked)
setItems(prev => prev.map(p =>
p.user_id === item.user_id ? { ...p, unlocked: !item.unlocked } : p
))
} finally {
setBusyUser(null)
}
}

const handleExport = () => {
downloadCSV('certifications.csv',
['User', 'Email', 'Level', 'Total XP', 'Modules Completed', 'Modules Total', 'Certified', 'Certified At', 'Streak', 'Last Activity', 'Unlocked'],
filtered.map(p => [
p.name || p.user_id, p.email,
p.level, p.total_xp,
p.modules_completed, p.modules_total,
p.certified ? 'yes' : 'no',
p.certified_at,
p.streak_days,
p.last_activity_date,
p.unlocked ? 'yes' : 'no',
])
)
}

if (loading) return <div style={{ padding: 40, textAlign: 'center', color: '#6b7280' }}>Loading certification progress...</div>

return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, margin: 0 }}>Certifications</h2>
<p style={{ fontSize: 13, color: '#6b7280', marginTop: 4 }}>
Users who have started the Vandal Workflow Architect certification and where they are in the program.
</p>
</div>

<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<SearchInput value={search} onChange={setSearch} placeholder="Search users..." />
<div style={{ flex: 1 }} />
<button
onClick={refresh}
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', border: '1px solid #d1d5db', borderRadius: 6, background: '#fff', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}
>
<RefreshCw size={14} /> Refresh
</button>
<ExportButton onClick={handleExport} />
</div>

<div style={{ background: '#fff', border: '1px solid #e5e7eb', borderRadius: 'var(--ui-radius, 12px)', overflow: 'hidden' }}>
<div style={{ padding: '16px 20px', borderBottom: '1px solid #e5e7eb', fontSize: 15, fontWeight: 600 }}>
Certification Progress ({filtered.length})
</div>
{filtered.length === 0 ? (
<div style={{ padding: 40, textAlign: 'center', color: '#6b7280' }}>No users have started the certification yet.</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f9fafb', borderBottom: '1px solid #e5e7eb' }}>
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: 11, fontWeight: 600, color: '#6b7280', textTransform: 'uppercase' }}>User</th>
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: 11, fontWeight: 600, color: '#6b7280', textTransform: 'uppercase' }}>Level</th>
<th style={{ padding: '10px 16px', textAlign: 'right', fontSize: 11, fontWeight: 600, color: '#6b7280', textTransform: 'uppercase' }}>XP</th>
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: 11, fontWeight: 600, color: '#6b7280', textTransform: 'uppercase' }}>Modules</th>
<th style={{ padding: '10px 16px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: '#6b7280', textTransform: 'uppercase' }}>Certified</th>
<th style={{ padding: '10px 16px', textAlign: 'right', fontSize: 11, fontWeight: 600, color: '#6b7280', textTransform: 'uppercase' }}>Last Active</th>
<th style={{ padding: '10px 16px', textAlign: 'right', fontSize: 11, fontWeight: 600, color: '#6b7280', textTransform: 'uppercase' }}>Debug Unlock</th>
</tr>
</thead>
<tbody>
{filtered.map(p => {
const pct = p.modules_total > 0 ? (p.modules_completed / p.modules_total) * 100 : 0
return (
<tr key={p.user_id} style={{ borderBottom: '1px solid #f3f4f6' }}>
<td style={{ padding: '12px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<UserAvatar name={p.name || p.email} />
<div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{p.name || 'Unknown'}</div>
<div style={{ fontSize: 12, color: '#6b7280' }}>{p.email || p.user_id}</div>
</div>
</div>
</td>
<td style={{ padding: '12px 16px' }}>
<span style={{
display: 'inline-block', padding: '2px 10px', borderRadius: 9999,
fontSize: 11, fontWeight: 700, backgroundColor: '#eef2ff', color: '#4338ca',
textTransform: 'uppercase', letterSpacing: 0.5,
}}>
{p.level}
</span>
</td>
<td style={{ padding: '12px 16px', textAlign: 'right', fontSize: 14, fontFamily: 'ui-monospace, monospace' }}>{formatNumber(p.total_xp)}</td>
<td style={{ padding: '12px 16px', minWidth: 200 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ flex: 1, height: 6, backgroundColor: '#f3f4f6', borderRadius: 3, overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', backgroundColor: 'var(--highlight-color, #eab308)', borderRadius: 3 }} />
</div>
<span style={{ fontSize: 12, color: '#6b7280', fontFamily: 'ui-monospace, monospace', minWidth: 48, textAlign: 'right' }}>
{p.modules_completed}/{p.modules_total}
</span>
</div>
</td>
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
{p.certified ? (
<span title={p.certified_at ? `Certified ${formatDate(p.certified_at)}` : 'Certified'} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: '#16a34a', fontSize: 13, fontWeight: 600 }}>
<Award size={14} /> Yes
</span>
) : (
<span style={{ color: '#9ca3af', fontSize: 13 }}>—</span>
)}
</td>
<td style={{ padding: '12px 16px', textAlign: 'right', fontSize: 13, color: '#6b7280' }}>
{p.last_activity_date || formatDate(p.updated_at)}
</td>
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
<button
onClick={() => toggleUnlock(p)}
disabled={busyUser === p.user_id}
title={p.unlocked
? 'Re-lock prerequisites for this user'
: 'Unlock all units so this user can select any module without prerequisites'}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '5px 10px', border: '1px solid',
borderColor: p.unlocked ? '#16a34a' : '#d1d5db',
borderRadius: 6, fontSize: 12, fontWeight: 500,
background: p.unlocked ? '#dcfce7' : '#fff',
color: p.unlocked ? '#166534' : '#374151',
cursor: busyUser === p.user_id ? 'wait' : 'pointer',
opacity: busyUser === p.user_id ? 0.6 : 1,
fontFamily: 'inherit',
}}
>
{p.unlocked ? <><Unlock size={12} /> Unlocked</> : <><Lock size={12} /> Unlock</>}
</button>
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>

<div style={{ fontSize: 12, color: '#6b7280', padding: '8px 4px' }}>
<strong>Note:</strong> The unlock toggle is a debugging aid. It lets a user select any unit
in the certification program without completing the prerequisites — it does not mark
modules as completed or grant XP.
</div>
</div>
)
}
Loading
Loading