mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-10 09:33:15 +00:00
support uploading csv or txt to sudo commands and groups
This commit is contained in:
5
.cursor/rules/Components.mdc
Normal file
5
.cursor/rules/Components.mdc
Normal 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.
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
189
src/components/TextFileImportDialog.tsx
Normal file
189
src/components/TextFileImportDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
src/lib/roleFormTextImport.ts
Normal file
110
src/lib/roleFormTextImport.ts
Normal 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");
|
||||
}
|
||||
Reference in New Issue
Block a user