From 7628338e705378f18b4042028b27f96458e1641d Mon Sep 17 00:00:00 2001 From: Foysal Ahamed Date: Fri, 6 Dec 2024 23:36:34 +0000 Subject: [PATCH] :sparkles: Add column selector and admin field protection --- components/common/Dropdown.tsx | 4 +- components/shell/CommandPalette/Root.tsx | 4 +- components/workspace/ExportPanel.tsx | 129 +++++++++++++++ components/workspace/FilterSelector.tsx | 2 +- components/workspace/List.tsx | 18 +-- components/workspace/Panel.tsx | 180 +++++++++++---------- components/workspace/hooks.tsx | 192 +++++++++++++++-------- lib/csv.ts | 12 +- lib/util.ts | 5 +- 9 files changed, 376 insertions(+), 170 deletions(-) create mode 100644 components/workspace/ExportPanel.tsx diff --git a/components/common/Dropdown.tsx b/components/common/Dropdown.tsx index 07c4562b..a9c943d4 100644 --- a/components/common/Dropdown.tsx +++ b/components/common/Dropdown.tsx @@ -43,11 +43,11 @@ export const Dropdown = ({ leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - {/* z-30 value is important because we want all dropdowns to draw over other elements in the page and besides mobile menu, z-30 is the highest z-index we use in this codebase */} + {/* z-50 value is important because we want all dropdowns to draw over other elements in the page and besides mobile menu, z-40 is the highest z-index we use in this codebase */} {items.map((item) => ( diff --git a/components/shell/CommandPalette/Root.tsx b/components/shell/CommandPalette/Root.tsx index 8b194e45..c7d0898c 100644 --- a/components/shell/CommandPalette/Root.tsx +++ b/components/shell/CommandPalette/Root.tsx @@ -52,8 +52,8 @@ export const CommandPaletteRoot = ({ return ( - {/* z-40 value is important because we want the cmd palette to be able above all panels and currently, the highest z-index we use is z-40 */} - + {/* z-50 value is important because we want the cmd palette to be able above all panels and currently, the highest z-index we use is z-50 */} + { + const { + mutateAsync, + isLoading, + headers, + filename, + setFilename, + selectedColumns, + setSelectedColumns, + } = useWorkspaceExport() + + const canDownload = selectedColumns.length > 0 && !isLoading && !!filename + + return ( + + {({ open, close }) => ( + <> + + + + {isLoading ? 'Exporting...' : 'Export'} + + + + + {/* Use the `Transition` component. */} + + +
+
+ + setFilename(e.target.value)} + /> + + + + + {headers.map((fieldName) => { + return ( + + e.target.checked + ? setSelectedColumns([ + ...selectedColumns, + fieldName, + ]) + : setSelectedColumns( + selectedColumns.filter( + (col) => col !== fieldName, + ), + ) + } + /> + ) + })} +
+

+ Note:{' '} + + The exported file will only contain the selected columns. +
+ When exporting from records (posts, profiles etc.) the + exported file will only contain the author account details + of the records. +
+

+
+ { + mutateAsync(listData).then(() => close()) + }} + > + + {isLoading ? 'Downloading...' : 'Download'} + + +
+
+
+
+ + )} +
+ ) +} diff --git a/components/workspace/FilterSelector.tsx b/components/workspace/FilterSelector.tsx index 44526794..faa3065a 100644 --- a/components/workspace/FilterSelector.tsx +++ b/components/workspace/FilterSelector.tsx @@ -134,7 +134,7 @@ export const WorkspaceFilterSelector = ({ } return ( - + {({ open }) => ( <> diff --git a/components/workspace/List.tsx b/components/workspace/List.tsx index a3c45443..20d1bfeb 100644 --- a/components/workspace/List.tsx +++ b/components/workspace/List.tsx @@ -18,7 +18,6 @@ import { import { SubjectOverview } from '@/reports/SubjectOverview' import { ReviewStateIcon } from '@/subject/ReviewStateMarker' import { PreviewCard } from '@/common/PreviewCard' -import { useWorkspaceExportMutation } from './hooks' import { WorkspaceListData, WorkspaceListItemData, @@ -26,9 +25,11 @@ import { import { ToolsOzoneModerationDefs } from '@atproto/api' import { SubjectTag } from 'components/tags/SubjectTag' import { ModerationLabel } from '@/common/labels' +import { WorkspaceExportPanel } from './ExportPanel' interface WorkspaceListProps { list: string[] + canExport: boolean listData: WorkspaceListData onRemoveItem: (item: string) => void } @@ -50,9 +51,11 @@ const getLangTagFromRecordValue = ( const WorkspaceList: React.FC = ({ list, listData, + canExport, onRemoveItem, }) => { const groupedItems = groupSubjects(list) + return (
@@ -64,7 +67,7 @@ const WorkspaceList: React.FC = ({ items={items} listData={listData} onRemoveItem={onRemoveItem} - canExport={key === 'dids'} + canExport={canExport} title={ GroupTitles[key] || `${key.charAt(0).toUpperCase()}${key.slice(1)}` @@ -92,7 +95,6 @@ const ListGroup = ({ const checkboxesRef = useRef<(HTMLInputElement | null)[]>([]) const [detailShown, setDetailShown] = useState([]) const areAllDetailShown = items.every((item) => detailShown.includes(item)) - const exportMutation = useWorkspaceExportMutation() // This ensures that when shift+clicking checkboxes, all checkboxes between the last interacted item are toggled const handleChange = ( @@ -121,15 +123,7 @@ const ListGroup = ({
{canExport && ( - exportMutation.mutateAsync(items)} - > - - {exportMutation.isLoading ? 'Exporting...' : 'Export'} - - + )} (v: V): v is NonNullable { - return v != null -} +import { isNonNullable } from '@/lib/util' export function WorkspacePanel(props: PropsOf) { const { onClose, ...others } = props @@ -71,100 +69,103 @@ export function WorkspacePanel(props: PropsOf) { }>({ isSubmitting: false, error: '' }) const labelerAgent = useLabelerAgent() - const supportsCorrelation = useServerConfig().pds != null - const handleFindCorrelation = supportsCorrelation - ? async () => { - const selectedItems = new FormData(formRef.current!) - .getAll('workspaceItem') - .filter((item): item is string => typeof item === 'string') - - // For every selected item, find out which DID it corresponds - const dids = selectedItems - .map((item) => { - if (item.startsWith('did:')) return item - - const status = workspaceListStatuses?.[item] - - if (ToolsOzoneModerationDefs.isRepoViewDetail(status)) { - return status.did - } - - if (ToolsOzoneModerationDefs.isRecordViewDetail(status)) { - return status.repo.did - } + const { pds, role } = useServerConfig() + const handleFindCorrelation = + pds != null + ? async () => { + const selectedItems = new FormData(formRef.current!) + .getAll('workspaceItem') + .filter((item): item is string => typeof item === 'string') + + // For every selected item, find out which DID it corresponds + const dids = selectedItems + .map((item) => { + if (item.startsWith('did:')) return item + + const status = workspaceListStatuses?.[item] + + if (ToolsOzoneModerationDefs.isRepoViewDetail(status)) { + return status.did + } - if (ToolsOzoneModerationDefs.isSubjectStatusView(status)) { - const { subject } = status - if (ComAtprotoAdminDefs.isRepoRef(subject)) { - return subject.did + if (ToolsOzoneModerationDefs.isRecordViewDetail(status)) { + return status.repo.did } - if (ComAtprotoRepoStrongRef.isMain(subject)) { - return new AtUri(subject.uri).host + if (ToolsOzoneModerationDefs.isSubjectStatusView(status)) { + const { subject } = status + if (ComAtprotoAdminDefs.isRepoRef(subject)) { + return subject.did + } + + if (ComAtprotoRepoStrongRef.isMain(subject)) { + return new AtUri(subject.uri).host + } } - } - // Should never happen (future proofing against new item types in workspace) - return undefined - }) - .filter(isNonNullable) + // Should never happen (future proofing against new item types in workspace) + return undefined + }) + .filter(isNonNullable) - if (dids.length <= 1) { - toast.warning('Please select at least two accounts to correlate.') - return - } + if (dids.length <= 1) { + toast.warning('Please select at least two accounts to correlate.') + return + } - if (dids.length !== selectedItems.length) { - toast.info('Only accounts can be correlated (ignoring non-accounts).') - } + if (dids.length !== selectedItems.length) { + toast.info( + 'Only accounts can be correlated (ignoring non-accounts).', + ) + } - const res = await labelerAgent.tools.ozone.signature.findCorrelation({ - dids, - }) - - const { details } = res.data - - if (!details.length) { - toast.info('No correlation found between the selected accounts.') - } else { - toast.success( -
- The following correlation were found between the selected - accounts: -
- {details.map(({ property, value }) => ( - - - - {property} - - - ))} - {details.length > 1 && ( - <> -
+ const res = await labelerAgent.tools.ozone.signature.findCorrelation({ + dids, + }) + + const { details } = res.data + + if (!details.length) { + toast.info('No correlation found between the selected accounts.') + } else { + toast.success( +
+ The following correlation were found between the selected + accounts: +
+ {details.map(({ property, value }) => ( s.value)), - )}`} - className="text-blue-500 underline" + key={property} + href={`/repositories?term=sig:${encodeURIComponent(value)}`} > - Click here to show all accounts with the same details. + + + {property} + - - )} -
, - { - autoClose: 10_000, - }, - ) + ))} + {details.length > 1 && ( + <> +
+ s.value)), + )}`} + className="text-blue-500 underline" + > + Click here to show all accounts with the same details. + + + )} +
, + { + autoClose: 10_000, + }, + ) + } } - } - : undefined + : undefined // on form submit const onFormSubmit = async ( @@ -337,6 +338,13 @@ export function WorkspacePanel(props: PropsOf) { `}
{ return mutation } -export const useWorkspaceExportMutation = () => { - const labelerAgent = useLabelerAgent() - return useMutation({ - mutationFn: async (items: string[]) => { - // Items are exported in groups so we can expect all items in the group to be of same type - // For now, only support exporting accounts - if (!items[0].startsWith('did:')) { - toast.error(`Exporting is only enabled for accounts.`) - return [] - } +export const WORKSPACE_EXPORT_FIELDS = [ + 'did', + 'handle', + 'email', + 'ip', + 'name', + 'labels', + 'tags', + 'bskyUrl', +] +export const ADMIN_ONLY_WORKSPACE_EXPORT_FIELDS = ['email', 'ip'] +const filterExportFields = (fields: string[], isAdmin: boolean) => { + return isAdmin + ? fields + : fields.filter( + (field) => !ADMIN_ONLY_WORKSPACE_EXPORT_FIELDS.includes(field), + ) +} - const data: Record< - string, - ToolsOzoneModerationDefs.RepoViewDetail | null - > = {} - - for (const itemChunk of chunkArray(items, 50)) { - await Promise.all( - itemChunk.map(async (did) => { - try { - const { data: repo } = - await labelerAgent.tools.ozone.moderation.getRepo({ - did, - }) - - data[did] = repo - } catch (error) { - // For now we're just swallowing errors and exporting what I can - console.error(error) - data[did] = null - } - }), - ) +const getExportFieldsFromWorkspaceListItem = (item: WorkspaceListItemData) => { + const isRecord = ToolsOzoneModerationDefs.isRecordViewDetail(item) + + if (ToolsOzoneModerationDefs.isRepoViewDetail(item) || isRecord) { + const repo = isRecord ? item.repo : item + const profile = repo.relatedRecords.find(AppBskyActorProfile.isRecord) + const baseFields = { + did: repo.did, + handle: repo.handle, + email: repo.email, + ip: 'Unknown', + labels: 'Unknown', + name: profile?.displayName, + tags: repo.moderation.subjectStatus?.tags?.join('|'), + bskyUrl: buildBlueSkyAppUrl({ did: repo.did }), + } + + // For record entries, the repo does not include labels + if (!isRecord) { + return { + ...baseFields, + ip: item.ip as string, + labels: item.labels?.map(({ val }) => val).join('|'), } + } + } + + if (ToolsOzoneModerationDefs.isSubjectStatusView(item)) { + const did = ComAtprotoRepoStrongRef.isMain(item.subject) + ? new AtUri(item.subject.uri).host + : ComAtprotoAdminDefs.isRepoRef(item.subject) + ? item.subject.did + : '' + return { + did, + handle: item.subjectRepoHandle, + relatedRecords: [] as {}[], + email: 'Unknown', + ip: 'Unknown', + labels: 'None', + name: 'Unknown', + tags: item.tags?.join('|'), + bskyUrl: buildBlueSkyAppUrl({ did }), + } + } + return null +} +export const useWorkspaceExport = () => { + const { role } = useServerConfig() + const isAdmin = role === ToolsOzoneTeamDefs.ROLEADMIN + const headers = isAdmin + ? WORKSPACE_EXPORT_FIELDS + : WORKSPACE_EXPORT_FIELDS.filter( + (field) => !ADMIN_ONLY_WORKSPACE_EXPORT_FIELDS.includes(field), + ) + + const [selectedColumns, setSelectedColumns] = useState(headers) + const [filename, setFilename] = useState(`workspace-export`) + + const mutation = useMutation({ + mutationFn: async (items: WorkspaceListData) => { + const exportHeaders = filterExportFields(selectedColumns, isAdmin) downloadCSV( createCSV({ - headers: [ - 'did', - 'handle', - 'email', - 'ip', - 'name', - 'labels', - 'profile', - ], - lines: Object.values(data).map((repo) => { - if (!repo) return '' - const profile = AppBskyActorProfile.isRecord(repo.relatedRecords[0]) - ? (repo.relatedRecords[0] as AppBskyActorProfile.Record) - : null - - const line: string[] = [ - repo.did, - repo.handle, - repo.email || 'Unknown', - `${repo.ip || 'Unknown'}`, - `${profile?.displayName || 'Unknown'}`, - repo.labels?.map(({ val }) => val).join(', ') || 'None', - buildBlueSkyAppUrl({ did: repo.did }), - ] - return line.join(',') - }), + filename, + headers: exportHeaders, + lines: Object.values(items) + .map((item) => { + if (!item) return '' + + const exportFields = getExportFieldsFromWorkspaceListItem(item) + if (!exportFields) return '' + + const line: string[] = [ + exportFields.did, + exportFields.handle, + exportHeaders.includes('email') ? exportFields.email : '', + exportHeaders.includes('ip') ? exportFields.ip : '', + exportFields.name, + exportFields.labels, + exportFields.tags, + exportFields.bskyUrl, + ].filter(isNonNullable) + return line.map(escapeCSVValue).join(',') + }) + .filter(Boolean), }), ) - - return data }, }) + + return { + headers, + selectedColumns, + setSelectedColumns, + filename, + setFilename, + ...mutation, + } } const getList = (): string[] => { diff --git a/lib/csv.ts b/lib/csv.ts index 9b54b067..dd2e691c 100644 --- a/lib/csv.ts +++ b/lib/csv.ts @@ -3,7 +3,17 @@ export type CsvContent = { headerRow: string body: string } - +export const escapeCSVValue = (value: string) => { + if ( + value.includes(',') || + value.includes('"') || + value.includes('\r') || + value.includes('\n') + ) { + return `"${value.replaceAll('"', '""')}"` + } + return value +} export function createCSV({ headers, lines, diff --git a/lib/util.ts b/lib/util.ts index d5a5d841..bd94d01d 100644 --- a/lib/util.ts +++ b/lib/util.ts @@ -180,4 +180,7 @@ export function simpleHash(str: string) { hash = ((hash << 5) - hash + chr) | 0 } return hash -} \ No newline at end of file +} +export function isNonNullable(v: V): v is NonNullable { + return v != null +}