Skip to content

feat: upgrade issue report form with file attachments and metadata#240

Open
beran-t wants to merge 18 commits intomainfrom
upgrade-issue-form
Open

feat: upgrade issue report form with file attachments and metadata#240
beran-t wants to merge 18 commits intomainfrom
upgrade-issue-form

Conversation

@beran-t
Copy link
Contributor

@beran-t beran-t commented Feb 14, 2026

Summary

  • Popover → Dialog: Converted the issue report UI from a narrow Popover to a full Dialog for better space, using React Hook Form + zod resolver to align with codebase conventions
  • File attachments: Added drag-and-drop file upload support (images, PDFs, text; max 5 files, 10MB each) via a new server action that uploads to a dedicated issue-attachments Supabase Storage bucket
  • Enriched Plain thread: Extended the tRPC payload and Plain thread formatting to include teamId, teamName, customerEmail, customerTier, and attachment links with file sizes
  • Multi-bucket storage: Parameterized the storage utility functions with an optional bucketName parameter (backward compatible)

New files

  • src/features/dashboard/navbar/report-issue-dialog.tsx — Dialog-based form with React Hook Form
  • src/features/dashboard/navbar/file-drop-zone.tsx — Native HTML5 drag-and-drop component
  • src/server/support/support-actions.ts — Server action for file upload with MIME + magic bytes validation

Modified files

  • src/server/api/routers/support.ts — Expanded schema and richer Plain thread formatting
  • src/lib/clients/storage.ts — Added optional bucketName param to all functions
  • src/configs/storage.ts — Added ISSUE_ATTACHMENTS_BUCKET_NAME
  • src/features/dashboard/sidebar/footer.tsx — Updated import

Deleted files

  • src/features/dashboard/navbar/report-issue-popover.tsx — Replaced by dialog

Infrastructure prerequisite

A new Supabase Storage bucket named issue-attachments must be created before file uploads will work.

Test plan

  • Verify existing profile picture upload still works (backward compat of storage parameterization)
  • Open the Report Issue dialog from the sidebar footer
  • Submit an issue with description only — verify Plain thread contains email, team, tier metadata
  • Submit an issue with sandbox ID + description + 1-2 file attachments — verify attachment links appear in Plain thread
  • Test drag-and-drop and click-to-upload for the file drop zone
  • Attempt to upload disallowed file types (e.g., .exe) and files over 10MB — verify error toasts
  • Attempt to add more than 5 files — verify the drop zone disables
  • Verify PostHog events include team_id, tier, attachment_count

…tadata

Convert the issue report from a Popover to a Dialog with React Hook Form,
add drag-and-drop file attachment support via Supabase Storage, and enrich
the Plain thread payload with teamId, teamName, customerEmail, customerTier,
and attachment links.
@vercel
Copy link

vercel bot commented Feb 14, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
web Ready Ready Preview, Comment Feb 25, 2026 4:03pm
web-juliett Ready Ready Preview, Comment Feb 25, 2026 4:03pm

Request Review

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c5a5492ff5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines 85 to 89
setAttachments((prev) => [
...prev,
{
url: data.url,
fileName: data.fileName,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Ignore late upload callbacks after dialog reset

Closing the dialog while files are still uploading resets local state, but in-flight upload callbacks still append attachments unconditionally; this repopulates a canceled form with stale files and can also drive uploadingCount below zero when callbacks decrement after reset. Users can then reopen the modal and accidentally submit attachments from a previous canceled attempt, so upload results need to be ignored/canceled once the form session is reset.

Useful? React with 👍 / 👎.

Comment on lines 47 to 49
const detectedMime = fileType?.mime ?? file.type

if (!ALLOWED_MIME_TYPES.includes(detectedMime)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject unknown file signatures in attachment validation

The MIME check falls back to the client-provided file.type whenever fileTypeFromBuffer cannot identify magic bytes, which lets a crafted upload bypass server-side type enforcement by labeling unsupported binary content as text/plain. This defeats the stated magic-byte validation and allows disallowed file formats into storage unless unknown signatures are rejected (or text files are validated independently).

Useful? React with 👍 / 👎.

Update icon (Bug → LifeBuoy), dialog copy, form labels, toast messages,
and PostHog event names to reflect general support rather than bug reporting.
Replace the two-step Supabase upload with Plain's native attachment system.
Files are held in browser state and uploaded to Plain on submit via a single
contactSupportAction server action. Remove the tRPC support router, revert
storage parameterization, and drop the issue-attachments bucket dependency.
Move the customer metadata header (email, tier, team ID, Orbit link)
from the thread body to an internal Plain note so it doesn't get
quoted in email replies. Map tier slugs to readable names (base_v1
→ Hobby, pro_v1 → Pro).
…eThread fields

Switch from deprecated createThread components/attachmentIds to the
recommended flow: createThread (bare) → sendCustomerChat (message +
attachments). Use AttachmentType.Chat instead of CustomTimelineEntry.
…or handling

- Use description field on createThread instead of deprecated components
- Switch from Error to ActionError so error messages surface to the user
  instead of the generic "Unexpected Error" message
sendCustomerChat requires the thread to use the CHAT channel.
Also include Plain error message in ActionError for easier debugging.
The API key lacks chat:create permission needed for sendCustomerChat.
Revert to using createThread with components and attachmentIds which
works with existing thread:create permission.
… logging

- Put the ######## header block back into the thread body text
- Add detailed logging for each stage of attachment upload (start,
  URL creation, upload, success/failure with response body)
- Remove separate internal note in favor of inline header
Copy link
Contributor Author

@beran-t beran-t left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review completed. 1 issue found.

accountOwnerEmail: zfd.text(z.string().email()),
customerTier: zfd.text(z.string().min(1)),
files: zfd.repeatableOfType(zfd.file()).optional(),
})
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: Server action trusts client-supplied metadata without verification

The contactSupportAction accepts teamId, teamName, customerEmail, accountOwnerEmail, and customerTier directly from client-submitted FormData without any server-side verification. An authenticated user can spoof all of these values by manipulating the request.

Impact:

  • Tier spoofing: A Hobby user can submit customerTier: "pro_v1" to appear as Pro in the support thread, potentially getting prioritized support
  • Team impersonation: A user can submit another team's ID, causing the Orbit link in the thread to point to the wrong team. A support agent acting on this could make changes to the wrong team
  • Email spoofing: The customerEmail displayed to agents can differ from ctx.user.email (which is verified from the auth session). An attacker could impersonate another user

Notably, ctx.user.email from the auth context is used for the Plain customer upsert (line 153), but the thread text shown to support agents uses the untrusted client-supplied customerEmail instead.

Every other team-scoped action in this codebase uses the withTeamIdResolution middleware which calls checkUserTeamAuthCached() to verify team membership. This action is the only one that skips it.

Suggested fix: Use .use(withTeamIdResolution), fetch team data (name, email, tier) server-side from the verified team ID, and use ctx.user.email for the customer email field.

The action previously trusted client-supplied teamId, teamName,
customerEmail, accountOwnerEmail, and customerTier without verification.
An authenticated user could spoof these to impersonate another team.

Now uses withTeamIdResolution middleware to verify team membership
(matching every other team-scoped action) and fetches team name,
email, and tier from the database server-side. Only description,
teamIdOrSlug, and files are accepted from the client.
Copy link
Contributor Author

@beran-t beran-t left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

Overall this is a well-structured PR. Authentication, authorization (via authActionClient + withTeamIdResolution), and error handling are solid. Team metadata is correctly fetched server-side to prevent spoofing. One issue found below.

</p>
{remaining > 0 && !isUploading && (
<p className="text-xs text-fg-tertiary">
Up to {remaining} more file{remaining !== 1 ? 's' : ''} (max 10MB each)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mismatched file size limit: The UI tells users "max 10MB each" but no 10MB validation exists anywhere — neither client-side nor server-side. The server enforces 50MB (support-actions.ts:11), not 10MB. Additionally, files exceeding 50MB are silently filtered out server-side (support-actions.ts:225) with no error feedback to the user.

Either add a client-side size check matching the displayed limit, or update the displayed text to match the actual server limit.

- Set server MAX_FILE_SIZE to 10MB to match the UI's "max 10MB each"
- Add client-side file size validation with toast error for oversized files
- Move Contact Support button from header back to sidebar footer,
  next to the Feedback button
Copy link
Contributor Author

@beran-t beran-t left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review by automated code review agent.

formData.append('files', file)
}

submitSupport(formData)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: FormData passed to withTeamIdResolution middleware causes every submission to fail

The onSubmit handler constructs a FormData and passes it directly to submitSupport(formData). However, the withTeamIdResolution middleware checks for teamIdOrSlug using the in operator on raw clientInput:

if (\!clientInput || typeof clientInput \!== 'object' || \!('teamIdOrSlug' in clientInput)) {
  // throws error
}

The in operator does not work on FormData objects — FormData stores entries internally, not as own properties. So 'teamIdOrSlug' in formData always returns false, and the middleware throws "teamIdOrSlug is required when using withTeamIdResolution middleware" on every submission.

The existing uploadTeamProfilePictureAction (which also uses zfd.formData + withTeamIdResolution) avoids this by passing a plain object instead of FormData.

Suggested fix — pass a plain object:

const onSubmit = (values: SupportFormValues) => {
  submitSupport({
    description: values.description.trim(),
    teamIdOrSlug: team.id,
    files,
  })
}

- Pass plain object to submitSupport() so withTeamIdResolution
  middleware can find teamIdOrSlug via the `in` operator (FormData
  stores entries internally, not as object properties)
- Swap sidebar button order: Feedback left, Contact Support right
- Add basis-1/2 for even 50/50 split
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant