support uploading csv or txt to sudo commands and groups

This commit is contained in:
miloschwartz
2026-06-08 15:30:03 -07:00
parent 3c8fea382f
commit d294bf8534
5 changed files with 479 additions and 3 deletions

View File

@@ -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.

View File

@@ -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.",

View File

@@ -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<PendingTextImport | null>(null);
const [dragOverField, setDragOverField] =
useState<RoleTextImportField | null>(null);
useEffect(() => {
if (sshDisabled) {
@@ -194,6 +217,78 @@ export function RoleForm({
}
}, [sshDisabled, form]);
async function handleFileDrop(
file: File,
field: RoleTextImportField
): Promise<void> {
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<HTMLTextAreaElement>) => {
event.preventDefault();
event.stopPropagation();
if (!sshDisabled) {
setDragOverField(field);
}
},
onDragLeave: (event: React.DragEvent<HTMLTextAreaElement>) => {
event.preventDefault();
setDragOverField((current) =>
current === field ? null : current
);
},
onDrop: (event: React.DragEvent<HTMLTextAreaElement>) => {
event.preventDefault();
event.stopPropagation();
setDragOverField(null);
if (sshDisabled) return;
const file = event.dataTransfer.files[0];
if (file) {
void handleFileDrop(file, field);
}
}
};
}
return (
<Form {...form}>
<form
@@ -443,8 +538,23 @@ export function RoleForm({
<FormControl>
<Textarea
{...field}
{...getTextImportDropHandlers(
"sshSudoCommands"
)}
placeholder={
sshDisabled
? undefined
: t(
"roleTextFieldPlaceholder"
)
}
disabled={sshDisabled}
className="h-20 min-h-20"
className={cn(
"h-20 min-h-20",
dragOverField ===
"sshSudoCommands" &&
"border-primary"
)}
/>
</FormControl>
<FormDescription>
@@ -469,8 +579,23 @@ export function RoleForm({
<FormControl>
<Textarea
{...field}
{...getTextImportDropHandlers(
"sshUnixGroups"
)}
placeholder={
sshDisabled
? undefined
: t(
"roleTextFieldPlaceholder"
)
}
disabled={sshDisabled}
className="h-20 min-h-20"
className={cn(
"h-20 min-h-20",
dragOverField ===
"sshUnixGroups" &&
"border-primary"
)}
/>
</FormControl>
<FormDescription>
@@ -521,6 +646,38 @@ export function RoleForm({
</HorizontalTabs>
)}
</form>
{pendingImport && (
<TextFileImportDialog
key={`${pendingImport.field}-${pendingImport.fileName}`}
open={true}
onOpenChange={(open) => {
if (!open) {
setPendingImport(null);
}
}}
fileName={pendingImport.fileName}
fileType={pendingImport.fileType}
rawContent={pendingImport.rawContent}
currentValue={form.watch(pendingImport.field) ?? ""}
fieldLabel={
pendingImport.field === "sshSudoCommands"
? t("sshSudoCommands")
: t("sshUnixGroups")
}
parser={
pendingImport.field === "sshSudoCommands"
? parseSudoCommands
: parseUnixGroups
}
onConfirm={(value) => {
form.setValue(pendingImport.field, value, {
shouldDirty: true,
shouldValidate: true
});
setPendingImport(null);
}}
/>
)}
</Form>
);
}

View File

@@ -0,0 +1,189 @@
"use client";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import {
OptionSelect,
type OptionSelectOption
} from "@app/components/OptionSelect";
import { Button } from "@app/components/ui/button";
import { CheckboxWithLabel } from "@app/components/ui/checkbox";
import { Textarea } from "@app/components/ui/textarea";
import {
applyTextImport,
parsePreviewLines,
parseTextFileItems,
type TextImportFileType,
type TextImportMode
} from "@app/lib/roleFormTextImport";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useState } from "react";
type TextFileImportDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
fileName: string;
fileType: TextImportFileType;
rawContent: string;
currentValue: string;
fieldLabel: string;
parser: (value: string | undefined) => string[];
onConfirm: (value: string) => void;
};
export function TextFileImportDialog({
open,
onOpenChange,
fileName,
fileType,
rawContent,
currentValue,
fieldLabel,
parser,
onConfirm
}: TextFileImportDialogProps) {
const t = useTranslations();
const [editablePreview, setEditablePreview] = useState("");
const [skipHeader, setSkipHeader] = useState(false);
const [mode, setMode] = useState<TextImportMode>("override");
const parsedFromFile = useMemo(
() =>
parseTextFileItems({
content: rawContent,
fileType,
skipHeader,
parser
}),
[rawContent, fileType, skipHeader, parser]
);
useEffect(() => {
setEditablePreview(parsedFromFile.join("\n"));
}, [parsedFromFile]);
const importedItems = useMemo(
() => parsePreviewLines(editablePreview),
[editablePreview]
);
const existingCount = useMemo(
() => parser(currentValue).length,
[currentValue, parser]
);
const totalCount =
mode === "append"
? existingCount + importedItems.length
: importedItems.length;
const modeOptions: OptionSelectOption<TextImportMode>[] = [
{
value: "override",
label: t("roleTextImportOverride")
},
{
value: "append",
label: t("roleTextImportAppend")
}
];
function handleConfirm() {
onConfirm(
applyTextImport({
currentValue,
imported: importedItems,
mode,
parser
})
);
onOpenChange(false);
}
return (
<Credenza open={open} onOpenChange={onOpenChange}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("roleTextImportTitle")}</CredenzaTitle>
<CredenzaDescription>
{t("roleTextImportDescription", {
fileName,
fieldLabel
})}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{fileType === "csv" && (
<CheckboxWithLabel
checked={skipHeader}
onCheckedChange={(checked) => {
if (checked !== "indeterminate") {
setSkipHeader(checked);
}
}}
label={t("roleTextImportSkipHeader")}
/>
)}
<div className="space-y-2">
<p className="text-sm font-medium">
{t("roleTextImportPreview")}
</p>
<Textarea
value={editablePreview}
onChange={(event) =>
setEditablePreview(event.target.value)
}
placeholder={t("roleTextImportEmpty")}
className="min-h-32 text-sm"
/>
</div>
<OptionSelect<TextImportMode>
label={t("roleTextImportMode")}
options={modeOptions}
value={mode}
onChange={setMode}
cols={2}
/>
<p className="text-sm text-muted-foreground">
{mode === "append"
? t("roleTextImportTotalCount", {
existing: existingCount,
imported: importedItems.length,
total: totalCount
})
: t("roleTextImportItemCount", {
count: importedItems.length
})}
</p>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button type="button" variant="outline">
{t("close")}
</Button>
</CredenzaClose>
<Button
type="button"
onClick={handleConfirm}
disabled={importedItems.length === 0}
>
{t("roleTextImportConfirm")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -0,0 +1,110 @@
export type TextImportFileType = "txt" | "csv";
export type TextImportMode = "override" | "append";
export function getTextImportFileType(file: File): TextImportFileType | null {
const extension = file.name.split(".").pop()?.toLowerCase();
if (extension === "txt" || extension === "csv") {
return extension;
}
return null;
}
export function isSupportedTextImportFile(file: File): boolean {
return getTextImportFileType(file) !== null;
}
export function readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
const result = event.target?.result;
if (typeof result === "string") {
resolve(result);
return;
}
reject(new Error("Failed to read file"));
};
reader.onerror = () => reject(reader.error);
reader.readAsText(file);
});
}
function parseCsvFirstCell(row: string): string {
const trimmed = row.trim();
if (!trimmed) return "";
if (trimmed.startsWith('"')) {
let index = 1;
let cell = "";
while (index < trimmed.length) {
if (trimmed[index] === '"') {
if (trimmed[index + 1] === '"') {
cell += '"';
index += 2;
continue;
}
break;
}
cell += trimmed[index];
index += 1;
}
return cell.trim();
}
const commaIndex = trimmed.indexOf(",");
return (commaIndex === -1 ? trimmed : trimmed.slice(0, commaIndex)).trim();
}
export function parseCsvFirstColumn(
content: string,
skipHeader: boolean
): string[] {
const rows = content.split(/\r?\n/).filter((line) => line.trim());
const dataRows = skipHeader ? rows.slice(1) : rows;
return dataRows.map(parseCsvFirstCell).filter(Boolean);
}
export function parseTextFileItems({
content,
fileType,
skipHeader,
parser
}: {
content: string;
fileType: TextImportFileType;
skipHeader: boolean;
parser: (value: string | undefined) => string[];
}): string[] {
if (fileType === "csv") {
return parseCsvFirstColumn(content, skipHeader);
}
return parser(content);
}
export function parsePreviewLines(value: string): string[] {
return value
.split(/\n/)
.map((line) => line.trim())
.filter(Boolean);
}
export function applyTextImport({
currentValue,
imported,
mode,
parser
}: {
currentValue: string;
imported: string[];
mode: TextImportMode;
parser: (value: string | undefined) => string[];
}): string {
if (mode === "override") {
return imported.join("\n");
}
const existing = parser(currentValue);
return [...existing, ...imported].join("\n");
}