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 (