Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
d8a9b2e
docs(specs): add SDK wizard redesign + user attachments design
Ripwords Apr 27, 2026
6327f12
docs(plans): add SDK wizard redesign + user attachments implementatio…
Ripwords Apr 27, 2026
989d8e3
feat(sdk-utils): add canonical theme tokens shared by web and expo SDKs
Ripwords Apr 27, 2026
a6c8159
feat(sdk-utils): add Attachment shape and validateAttachments helper
Ripwords Apr 27, 2026
1a2e692
feat(ui): add themeToCssVars helper that emits flame/mist tokens as C…
Ripwords Apr 27, 2026
91a883b
feat(ui): inject flame/mist CSS vars into shadow root at mount
Ripwords Apr 27, 2026
ede8899
refactor(ui): switch styles to CSS custom properties from sdk-utils t…
Ripwords Apr 27, 2026
61404b5
feat(ui): add PrimaryButton, SecondaryButton, FieldLabel, StepIndicat…
Ripwords Apr 27, 2026
1077c55
feat(ui): add StepDetails (replaces step-describe in 3-step wizard)
Ripwords Apr 27, 2026
da15040
feat(ui): add StepReview with 'Included in this report' summary
Ripwords Apr 27, 2026
25c916f
feat(ui): replace 2-step wizard with annotate → details → review flow
Ripwords Apr 27, 2026
9fd59b1
feat(db): add report_attachments.filename and user-file kind
Ripwords Apr 27, 2026
01c0f9e
feat(env): add user-file intake size budgets
Ripwords Apr 27, 2026
f4bc566
feat(server): add sanitizeFilename + rollbackPuts helpers
Ripwords Apr 27, 2026
bceae0b
feat(intake): accept attachment[N] parts as user-file attachments
Ripwords Apr 27, 2026
b8070d6
feat(ui): add AttachmentList with hybrid thumbnail + chip rendering
Ripwords Apr 27, 2026
46af0bb
feat(sdk-web): add user attachments end-to-end
Ripwords Apr 27, 2026
dd715e5
feat(shared): add user-file kind and filename field to AttachmentDTO
Ripwords Apr 27, 2026
4242d52
feat(dashboard): include user-file filename in report detail response
Ripwords Apr 27, 2026
f046de2
feat(dashboard): add AttachmentsTab for user-file attachments
Ripwords Apr 27, 2026
125760a
feat(dashboard): expose user-file attachments tab in report drawer
Ripwords Apr 27, 2026
9ddc528
refactor(expo): re-export shared theme tokens from sdk-utils
Ripwords Apr 27, 2026
d664505
feat(expo): add pickFiles wrapper over expo-document-picker
Ripwords Apr 27, 2026
b2162d3
feat(expo): add AttachmentList for the mobile wizard
Ripwords Apr 27, 2026
9e64cea
feat(expo): add attachments to the wizard's Details step
Ripwords Apr 27, 2026
716d4b6
feat(expo): submit user-file attachments as attachment[N] multipart p…
Ripwords Apr 27, 2026
2485200
fix(dashboard): reject ?id= when authed via signed token
Ripwords Apr 27, 2026
4452729
feat(ui): side-by-side details layout + paste-to-attach screenshots
Ripwords Apr 28, 2026
53f97a3
feat(intake): virus-scan user attachments via ClamAV sidecar
Ripwords Apr 28, 2026
7971214
fix(docker): pin clamav sidecar to linux/amd64
Ripwords Apr 28, 2026
23b9349
feat(ui): inflight toast + clamav scan visibility
Ripwords Apr 28, 2026
368d63b
feat(dashboard): show clamav scan report on user-file attachments
Ripwords Apr 28, 2026
0cc0bf5
feat(expo): pick attachments from Photos / Files / Clipboard
Ripwords Apr 28, 2026
641faad
fix(expo): make clipboard paste actually work
Ripwords Apr 28, 2026
c0e0817
revert(expo): drop clipboard paste — Files + Photos picker is enough
Ripwords Apr 28, 2026
67f0dbf
fix(intake): scan user-files BEFORE inserting the report row
Ripwords Apr 28, 2026
2708f72
feat(extension): retheme popup + options to flame/mist tokens
Ripwords Apr 28, 2026
3690065
feat(deploy): add clamav sidecar to production compose.yaml
Ripwords Apr 28, 2026
50c4cec
chore: refresh bun.lock after clamav + expo peer-dep churn
Ripwords Apr 28, 2026
d829cc2
fix(ci): isolate SDK tests per-package to prevent globalThis pollution
Ripwords Apr 28, 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
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,20 @@ INTAKE_MAX_BYTES=5242880
INTAKE_REQUIRE_DWELL=true
INTAKE_MIN_DWELL_MS=1500

# User-supplied attachment caps (per file, total per report, count per report)
INTAKE_USER_FILE_MAX_BYTES=10485760
INTAKE_USER_FILES_TOTAL_MAX_BYTES=26214400
INTAKE_USER_FILES_MAX_COUNT=5

# Virus scan via the bundled clamav sidecar. Off by default — flip to true
# AFTER `docker compose up -d` and the clamav service has finished pulling
# its signature DB (~500MB, watch with `docker compose logs -f clamav`
# until you see "Database correctly reloaded"). When enabled, infected or
# unscannable uploads fail with HTTP 422/503 and nothing is persisted.
# CLAMAV_HOST and CLAMAV_PORT are hardcoded in compose.yaml — no need to
# set them here.
INTAKE_USER_FILE_SCAN_ENABLED=false

# Session replay — flip to false to disable the feature platform-wide
REPLAY_FEATURE_ENABLED=true
INTAKE_REPLAY_MAX_BYTES=1048576
Expand Down
159 changes: 159 additions & 0 deletions apps/dashboard/app/components/report-drawer/attachments-tab.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<!-- apps/dashboard/app/components/report-drawer/attachments-tab.vue -->
<script setup lang="ts">
import type { AttachmentDTO } from "@reprojs/shared"

const props = defineProps<{
attachments: AttachmentDTO[]
}>()

const userFiles = computed(() => props.attachments.filter((a) => a.kind === "user-file"))
const images = computed(() => userFiles.value.filter((a) => a.contentType.startsWith("image/")))
const others = computed(() => userFiles.value.filter((a) => !a.contentType.startsWith("image/")))

function formatBytes(n: number): string {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`
return `${(n / (1024 * 1024)).toFixed(1)} MB`
}

function truncate(name: string, max = 40): string {
if (name.length <= max) return name
const head = name.slice(0, Math.floor(max / 2) - 1)
const tail = name.slice(-Math.floor(max / 2))
return `${head}…${tail}`
}

function relativeTime(iso: string | null): string {
if (!iso) return ""
const seconds = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000))
if (seconds < 60) return `${seconds}s ago`
const minutes = Math.round(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.round(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.round(hours / 24)
return `${days}d ago`
}

interface ScanBadge {
status: "clean" | "unknown"
label: string
detail: string
tone: "success" | "muted"
}

function scanBadge(file: AttachmentDTO): ScanBadge {
if (file.scanStatus === "clean") {
const engine = file.scanEngine ?? "AV"
const duration = file.scanDurationMs != null ? `${file.scanDurationMs}ms` : ""
const when = relativeTime(file.scannedAt)
const detailParts = [engine, duration, when].filter((p) => p.length > 0)
return {
status: "clean",
label: "Scanned clean",
detail: detailParts.join(" · "),
tone: "success",
}
}
return {
status: "unknown",
label: "Not scanned",
detail: "no virus scan recorded for this file",
tone: "muted",
}
}
</script>

<template>
<div class="space-y-6 p-4">
<p v-if="userFiles.length === 0" class="text-sm text-muted italic">
No additional attachments on this report.
</p>

<section v-if="images.length > 0" class="space-y-3">
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted">
Images ({{ images.length }})
</h3>
<div class="grid grid-cols-3 gap-3">
<div v-for="img in images" :key="img.url" class="space-y-1.5">
<a
:href="img.url"
target="_blank"
rel="noopener"
class="block aspect-square overflow-hidden rounded-md border border-default bg-elevated/40"
:title="img.filename ?? ''"
>
<img
:src="img.url"
:alt="img.filename ?? 'attachment'"
class="h-full w-full object-cover"
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
</a>
<div
class="flex items-center gap-1 text-[11px]"
:class="scanBadge(img).tone === 'success' ? 'text-success' : 'text-muted'"
:title="scanBadge(img).detail"
>
<UIcon
:name="
scanBadge(img).status === 'clean'
? 'i-heroicons-shield-check'
: 'i-heroicons-shield-exclamation'
"
class="size-3 shrink-0"
/>
<span class="truncate">{{ scanBadge(img).label }}</span>
</div>
</div>
</div>
</section>

<section v-if="others.length > 0" class="space-y-3">
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted">
Files ({{ others.length }})
</h3>
<ul class="divide-y divide-default rounded-md border border-default">
<li v-for="file in others" :key="file.url" class="flex items-start gap-3 px-3 py-2.5">
<UIcon name="i-heroicons-document" class="mt-0.5 size-4 shrink-0 text-muted" />
<div class="min-w-0 flex-1">
<div class="truncate text-sm text-default" :title="file.filename ?? ''">
{{ truncate(file.filename ?? "(unnamed)") }}
</div>
<div class="text-xs tabular-nums text-muted">
{{ file.contentType }} · {{ formatBytes(file.sizeBytes) }}
</div>
<div
class="mt-1 flex items-center gap-1 text-[11px]"
:class="scanBadge(file).tone === 'success' ? 'text-success' : 'text-muted'"
:title="scanBadge(file).detail"
>
<UIcon
:name="
scanBadge(file).status === 'clean'
? 'i-heroicons-shield-check'
: 'i-heroicons-shield-exclamation'
"
class="size-3 shrink-0"
/>
<span class="truncate">
<template v-if="scanBadge(file).status === 'clean'">
{{ scanBadge(file).label }}
<span class="text-muted">· {{ scanBadge(file).detail }}</span>
</template>
<template v-else>{{ scanBadge(file).label }}</template>
</span>
</div>
</div>
<a
:href="file.url"
:download="file.filename ?? ''"
class="text-xs font-medium text-primary-600 dark:text-primary-400 hover:underline"
>
Download
</a>
</li>
</ul>
</section>
</div>
</template>
24 changes: 21 additions & 3 deletions apps/dashboard/app/components/report-drawer/overview-tab.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
<!-- apps/dashboard/app/components/report-drawer/overview-tab.vue -->
<script setup lang="ts">
import type { ReportSummaryDTO } from "@reprojs/shared"
import type { ReportDetailDTO } from "@reprojs/shared"
import { safeHref } from "~/composables/use-safe-href"
import { parseBrowser, parseOs } from "~/composables/use-user-agent"
import { useMarkdown } from "~/composables/use-markdown"

const props = defineProps<{ projectId: string; report: ReportSummaryDTO }>()
const emit = defineEmits<{ "select-tab": [tab: "console" | "network" | "replay"] }>()
const props = defineProps<{ projectId: string; report: ReportDetailDTO }>()
const emit = defineEmits<{
"select-tab": [tab: "console" | "network" | "replay" | "attachments"]
}>()

const userFileCount = computed(
() => (props.report.attachments ?? []).filter((a) => a.kind === "user-file").length,
)

const ctx = computed(() => props.report.context)
const sys = computed(() => ctx.value?.systemInfo)
Expand Down Expand Up @@ -39,6 +45,18 @@ const descriptionHtml = computed(() =>
</div>
</UCard>

<!-- User-uploaded file chip. Only visible when the reporter attached extra
files. Clicking navigates to the Attachments tab. -->
<button
v-if="userFileCount > 0"
type="button"
class="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary hover:bg-primary/20 transition-colors"
@click="emit('select-tab', 'attachments')"
>
<UIcon name="i-heroicons-paper-clip" class="size-3.5" />
{{ userFileCount }} additional {{ userFileCount === 1 ? "file" : "files" }}
</button>

<!-- Reporter-authored description. Sits above the metadata so the user
sees the "what" before the "where/when". Hidden entirely when the
SDK caller submitted an empty description (common for widget-only
Expand Down
16 changes: 14 additions & 2 deletions apps/dashboard/app/pages/projects/[id]/reports/[reportId].vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
browser back button.
-->
<script setup lang="ts">
import type { LogsAttachment, ReportSummaryDTO } from "@reprojs/shared"
import type { LogsAttachment, ReportDetailDTO } from "@reprojs/shared"
import AppErrorState from "~/components/common/app-error-state.vue"
import AppLoadingSkeleton from "~/components/common/app-loading-skeleton.vue"
import ActivityTab from "~/components/report-drawer/activity-tab.vue"
import AttachmentsTab from "~/components/report-drawer/attachments-tab.vue"
import CommentsTab from "~/components/report-drawer/comments-tab.vue"
import ConsoleTab from "~/components/report-drawer/console-tab.vue"
import CookiesTab from "~/components/report-drawer/cookies-tab.vue"
Expand All @@ -33,7 +34,7 @@ const {
pending,
refresh,
error,
} = useApi<ReportSummaryDTO>(() => `/api/projects/${projectId.value}/reports/${reportId.value}`, {
} = useApi<ReportDetailDTO>(() => `/api/projects/${projectId.value}/reports/${reportId.value}`, {
key: computed(() => `report-${projectId.value}-${reportId.value}`),
watch: [projectId, reportId],
})
Expand All @@ -57,6 +58,7 @@ type TabId =
| "activity"
| "comments"
| "cookies"
| "attachments"
| "system"
| "raw"
const activeTab = ref<TabId>("overview")
Expand Down Expand Up @@ -84,6 +86,9 @@ const consoleHasData = computed(
)
const networkHasData = computed(() => logs.value !== null && logs.value.network.length > 0)
const cookiesHasData = computed(() => (report.value?.context?.cookies?.length ?? 0) > 0)
const userFileCount = computed(
() => (report.value?.attachments ?? []).filter((a) => a.kind === "user-file").length,
)

const tabs = computed(() => {
const base: { id: string; label: string; hasData?: boolean }[] = [
Expand All @@ -99,6 +104,9 @@ const tabs = computed(() => {
if (report.value?.source !== "expo") {
base.push({ id: "cookies", label: "Cookies", hasData: cookiesHasData.value })
}
if (userFileCount.value > 0) {
base.push({ id: "attachments", label: "Attachments", hasData: true })
}
base.push({ id: "system", label: "System" })
base.push({ id: "raw", label: "Raw" })
return base
Expand Down Expand Up @@ -256,6 +264,10 @@ onUnmounted(() => window.removeEventListener("keydown", onKey))
:report-id="report.id"
/>
<CookiesTab v-else-if="activeTab === 'cookies'" :project-id="projectId" :report="report" />
<AttachmentsTab
v-else-if="activeTab === 'attachments'"
:attachments="report.attachments ?? []"
/>
<div v-else-if="activeTab === 'system'" class="p-5">
<UCard :ui="{ body: 'p-4' }">
<pre class="text-sm font-mono whitespace-pre-wrap break-all">{{
Expand Down
33 changes: 33 additions & 0 deletions apps/dashboard/docker/docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,38 @@ services:
timeout: 5s
retries: 10

# Optional virus scanner for user-supplied attachments. The dashboard only
# talks to it when INTAKE_USER_FILE_SCAN_ENABLED=true (off by default), so
# operators who don't want this can leave it stopped. First boot pulls the
# signature DB (~500 MB); freshclam updates it daily afterwards.
#
# NOTE: this dev compose exposes 3310 on 127.0.0.1 ON PURPOSE — `bun run
# dev` runs the dashboard on the host, not inside docker, so the host
# needs a path to clamd on the bridge network. The production compose.yaml
# at the repo root does NOT expose this port because the dashboard there
# runs in a container alongside clamav and reaches it as `clamav:3310`
# over the internal compose network.
#
# platform: linux/amd64 is pinned because the official clamav/clamav:stable
# tag's arm64 manifest is intermittently absent (Docker Hub rebuilds drop
# arm64 occasionally). amd64 + Rosetta works reliably on Apple Silicon and
# the throughput cost on a sidecar that only runs on intake is negligible.
# Native arm64 hosts can drop the platform line.
clamav:
image: clamav/clamav:stable
platform: linux/amd64
ports:
- "127.0.0.1:3310:3310"
volumes:
- repro_clamav_db:/var/lib/clamav
healthcheck:
test: ["CMD", "clamdscan", "--ping", "1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 120s
restart: unless-stopped

volumes:
repro_data:
repro_clamav_db:
2 changes: 2 additions & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@tailwindcss/vite": "^4.2.1",
"@vueuse/nuxt": "14.2.1",
"better-auth": "^1.5.6",
"clamscan": "^2.4.0",
"dompurify": "^3.4.1",
"drizzle-orm": "^0.45.2",
"h3": "^1.15.11",
Expand All @@ -46,6 +47,7 @@
"@iconify-json/simple-icons": "^1.2.79",
"@nuxt/test-utils": "^4.0.2",
"@types/bun": "^1.3.12",
"@types/clamscan": "^2.4.1",
"@types/dompurify": "^3.2.0",
"@types/node": "^25.6.0",
"@types/nodemailer": "^8.0.0",
Expand Down
Loading
Loading