Default user export to filtered scope; note Anonymous in all-users label (#1679)

## Summary
Two changes to the user data export dialog on the project Users page:

1. The export scope now defaults to **"Export only filtered/searched
users"** instead of "all users".
2. The all-users option label is now **"Export all users in the project
(includes Anonymous)"**.

To keep this scoped to the Users table (the shared export dialog is
reused by other tables), the dialog's default scope is made configurable
rather than changed globally:

- `DataGridExportOptions` gains `defaultScope?: DataGridExportScope`
(defaults to `"all"`).
- The dialog initializes `useState(exportOptions?.defaultScope ??
"all")`.
- `user-table.tsx` passes `defaultScope: "filtered"` and the updated
`allScopeLabel`.

Other tables (teams, transactions, emails) are unaffected — they keep
the `"all"` default.

Link to Devin session:
https://app.devin.ai/sessions/4996678b2b944090b6eef2f64f0a62a1

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Default the Users export dialog to filtered scope and clarify that the
"all users" option includes Anonymous; scope resets to the per-table
default only when the dialog reopens, and other tables keep "all".

- **New Features**
- Added `defaultScope` to export options and initialized scope from it;
Users table sets `defaultScope: "filtered"` and updates the all-users
label.
- Reset scope to `defaultScope` only on a closed→open transition to
avoid changing it while the dialog is open.

- **Bug Fixes**
- Stubbed `NODE_ENV` via `vi.stubEnv` in
`apps/backend/src/oauth/ssrf-protection.test.ts` to fix lint and prevent
env mutation.

<sup>Written for commit 3aa670b6d2.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1679?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: vedanta.gawande <vedanta.gawande@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-06-29 09:31:06 -07:00 committed by GitHub
parent 092c27dd0e
commit 4a0f2b1778
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 25 additions and 11 deletions

View File

@ -1,19 +1,14 @@
import { StatusError } from "@hexclave/shared/dist/utils/errors";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import dns from "node:dns";
import { assertSafeOAuthResolvedAddress, assertSafeOAuthUrlWithoutDns, isBlockedOAuthIpAddress, safeOAuthDnsLookup } from "./ssrf-protection";
async function withProductionNodeEnv<T>(callback: () => Promise<T>): Promise<T> {
const previousNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = "production";
vi.stubEnv("NODE_ENV", "production");
try {
return await callback();
} finally {
if (previousNodeEnv === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = previousNodeEnv;
}
vi.unstubAllEnvs();
}
}

View File

@ -426,7 +426,8 @@ function UserTableBody(props: {
fetchRows: fetchExportRows,
emptyExportTitle: "No users to export",
emptyExportDescription: "There are no users matching the current filters",
allScopeLabel: "Export all users in the project",
defaultScope: "filtered",
allScopeLabel: "Export all users in the project (includes Anonymous)",
filteredScopeLabel: (
<>
Export only filtered/searched users

View File

@ -1,7 +1,7 @@
"use client";
import { DownloadSimpleIcon } from "@phosphor-icons/react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DesignButton } from "../button";
import { DesignDialog } from "../dialog";
@ -55,7 +55,7 @@ export function DataGridExportDialog<TRow>({
[exportOptions?.fields, columns],
);
const [format, setFormat] = useState<DataGridExportFormat>("csv");
const [scope, setScope] = useState<DataGridExportScope>("all");
const [scope, setScope] = useState<DataGridExportScope>(exportOptions?.defaultScope ?? "all");
const [fields, setFields] = useState<readonly DataGridExportField<TRow>[]>(resolvedFields);
const [isExporting, setIsExporting] = useState(false);
const [progress, setProgress] = useState<ExportProgress>(idleExportProgress);
@ -67,6 +67,22 @@ export function DataGridExportDialog<TRow>({
}
}, [isExporting, resolvedFields]);
// Reset the scope to its default each time the dialog opens. The dialog stays
// mounted between opens, so without this the scope would retain whatever the
// user last picked instead of honoring `defaultScope` on every open. We track
// the previous `open` value with a ref so the reset only fires on a genuine
// closed->open transition -- not on every render that flips other state (e.g.
// `isExporting` going false after a failed/empty export would otherwise wipe
// the user's current selection while the dialog is still open).
const defaultScope = exportOptions?.defaultScope ?? "all";
const wasOpenRef = useRef(false);
useEffect(() => {
if (open && !wasOpenRef.current) {
setScope(defaultScope);
}
wasOpenRef.current = open;
}, [open, defaultScope]);
const entityName = exportOptions?.entityName ?? "row";
const entityNamePlural = exportOptions?.entityNamePlural ?? "rows";
const filenamePrefix = exportOptions?.filenamePrefix ?? exportFilename;

View File

@ -251,6 +251,8 @@ export type DataGridExportOptions<TRow> = {
allScopeLabel?: ReactNode;
filteredScopeLabel?: ReactNode;
progressSubjectLabel?: string;
/** Which export scope is selected by default when the dialog opens. Defaults to `"all"`. */
defaultScope?: DataGridExportScope;
};
// ─── Callbacks ───────────────────────────────────────────────────────