From d294bf853439293de4e94f5fa9a98cccf080052d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 8 Jun 2026 15:30:03 -0700 Subject: [PATCH] support uploading csv or txt to sudo commands and groups --- .cursor/rules/Components.mdc | 5 + messages/en-US.json | 15 ++ src/components/RoleForm.tsx | 163 +++++++++++++++++++- src/components/TextFileImportDialog.tsx | 189 ++++++++++++++++++++++++ src/lib/roleFormTextImport.ts | 110 ++++++++++++++ 5 files changed, 479 insertions(+), 3 deletions(-) create mode 100644 .cursor/rules/Components.mdc create mode 100644 src/components/TextFileImportDialog.tsx create mode 100644 src/lib/roleFormTextImport.ts diff --git a/.cursor/rules/Components.mdc b/.cursor/rules/Components.mdc new file mode 100644 index 000000000..671eb9b10 --- /dev/null +++ b/.cursor/rules/Components.mdc @@ -0,0 +1,5 @@ +--- +alwaysApply: true +--- + +When creating UI for popup dialogs or modals, use the Credenza componennt. This component is mobile responsive and works on desktop and wraps the dialog component and sheet into one. diff --git a/messages/en-US.json b/messages/en-US.json index e057c686b..9793873ce 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2164,6 +2164,21 @@ "sshCreateHomeDir": "Create Home Directory", "sshUnixGroups": "Unix Groups", "sshUnixGroupsDescription": "Unix groups to add the user to on the target host, separated by commas, spaces, or new lines.", + "roleTextFieldPlaceholder": "Enter values, or drop a .txt or .csv file", + "roleTextImportTitle": "Import from File", + "roleTextImportDescription": "Importing {fileName} into {fieldLabel}.", + "roleTextImportSkipHeader": "Skip First Row (Header)", + "roleTextImportOverride": "Replace Existing", + "roleTextImportAppend": "Append to Existing", + "roleTextImportMode": "Import Mode", + "roleTextImportPreview": "Preview", + "roleTextImportItemCount": "{count, plural, =0 {No items to import} one {1 item to import} other {# items to import}}", + "roleTextImportTotalCount": "{existing} existing + {imported} imported = {total} total", + "roleTextImportConfirm": "Import", + "roleTextImportInvalidFile": "Unsupported file type", + "roleTextImportInvalidFileDescription": "Only .txt and .csv files are supported.", + "roleTextImportEmpty": "No items found in file", + "roleTextImportEmptyDescription": "The file does not contain any importable items.", "retryAttempts": "Retry Attempts", "expectedResponseCodes": "Expected Response Codes", "expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.", diff --git a/src/components/RoleForm.tsx b/src/components/RoleForm.tsx index e1890af45..ea45817ae 100644 --- a/src/components/RoleForm.tsx +++ b/src/components/RoleForm.tsx @@ -15,10 +15,20 @@ import { OptionSelect, type OptionSelectOption } from "@app/components/OptionSelect"; +import { TextFileImportDialog } from "@app/components/TextFileImportDialog"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { toast } from "@app/hooks/useToast"; +import { cn } from "@app/lib/cn"; +import { + getTextImportFileType, + isSupportedTextImportFile, + parseTextFileItems, + readFileAsText, + type TextImportFileType +} from "@app/lib/roleFormTextImport"; import { useTranslations } from "next-intl"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -98,6 +108,15 @@ type RoleFormProps = { formId?: string; }; +type RoleTextImportField = "sshSudoCommands" | "sshUnixGroups"; + +type PendingTextImport = { + field: RoleTextImportField; + fileName: string; + fileType: TextImportFileType; + rawContent: string; +}; + export function RoleForm({ variant, role, @@ -187,6 +206,10 @@ export function RoleForm({ const sshDisabled = !isPaidUser(tierMatrix.advancedPrivateResources); const sshSudoMode = form.watch("sshSudoMode"); const isAdminRole = variant === "edit" && role?.isAdmin === true; + const [pendingImport, setPendingImport] = + useState(null); + const [dragOverField, setDragOverField] = + useState(null); useEffect(() => { if (sshDisabled) { @@ -194,6 +217,78 @@ export function RoleForm({ } }, [sshDisabled, form]); + async function handleFileDrop( + file: File, + field: RoleTextImportField + ): Promise { + if (!isSupportedTextImportFile(file)) { + toast({ + variant: "destructive", + title: t("roleTextImportInvalidFile"), + description: t("roleTextImportInvalidFileDescription") + }); + return; + } + + const fileType = getTextImportFileType(file); + if (!fileType) return; + + const rawContent = await readFileAsText(file); + const parser = + field === "sshSudoCommands" ? parseSudoCommands : parseUnixGroups; + const items = parseTextFileItems({ + content: rawContent, + fileType, + skipHeader: false, + parser + }); + + if (items.length === 0) { + toast({ + variant: "destructive", + title: t("roleTextImportEmpty"), + description: t("roleTextImportEmptyDescription") + }); + return; + } + + setPendingImport({ + field, + fileName: file.name, + fileType, + rawContent + }); + } + + function getTextImportDropHandlers(field: RoleTextImportField) { + return { + onDragOver: (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (!sshDisabled) { + setDragOverField(field); + } + }, + onDragLeave: (event: React.DragEvent) => { + event.preventDefault(); + setDragOverField((current) => + current === field ? null : current + ); + }, + onDrop: (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setDragOverField(null); + if (sshDisabled) return; + + const file = event.dataTransfer.files[0]; + if (file) { + void handleFileDrop(file, field); + } + } + }; + } + return (