diff --git a/messages/en-US.json b/messages/en-US.json
index 8a81ce226..e057c686b 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -2160,10 +2160,10 @@
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
"sshSudo": "Allow sudo",
"sshSudoCommands": "Sudo Commands",
- "sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo. Absolute paths must be used.",
+ "sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, separated by commas, spaces, or new lines. Absolute paths must be used.",
"sshCreateHomeDir": "Create Home Directory",
"sshUnixGroups": "Unix Groups",
- "sshUnixGroupsDescription": "Comma separated Unix groups to add the user to on the target host.",
+ "sshUnixGroupsDescription": "Unix groups to add the user to on the target host, separated by commas, spaces, or new lines.",
"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/CreateRoleForm.tsx b/src/components/CreateRoleForm.tsx
index 080d5d293..678a9edb5 100644
--- a/src/components/CreateRoleForm.tsx
+++ b/src/components/CreateRoleForm.tsx
@@ -20,7 +20,12 @@ import type { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
-import { RoleForm, type RoleFormValues } from "./RoleForm";
+import {
+ parseSudoCommands,
+ parseUnixGroups,
+ RoleForm,
+ type RoleFormValues
+} from "./RoleForm";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type CreateRoleFormProps = {
@@ -53,16 +58,10 @@ export default function CreateRoleForm({
payload.sshSudoCommands =
values.sshSudoMode === "commands" &&
values.sshSudoCommands?.trim()
- ? values.sshSudoCommands
- .split(",")
- .map((s) => s.trim())
- .filter(Boolean)
+ ? parseSudoCommands(values.sshSudoCommands)
: [];
if (values.sshUnixGroups?.trim()) {
- payload.sshUnixGroups = values.sshUnixGroups
- .split(",")
- .map((s) => s.trim())
- .filter(Boolean);
+ payload.sshUnixGroups = parseUnixGroups(values.sshUnixGroups);
}
}
const res = await api
diff --git a/src/components/EditRoleForm.tsx b/src/components/EditRoleForm.tsx
index 8fa674bd1..bebf50288 100644
--- a/src/components/EditRoleForm.tsx
+++ b/src/components/EditRoleForm.tsx
@@ -20,7 +20,12 @@ import type { UpdateRoleBody, UpdateRoleResponse } from "@server/routers/role";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
-import { RoleForm, type RoleFormValues } from "./RoleForm";
+import {
+ parseSudoCommands,
+ parseUnixGroups,
+ RoleForm,
+ type RoleFormValues
+} from "./RoleForm";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type EditRoleFormProps = {
@@ -56,16 +61,10 @@ export default function EditRoleForm({
payload.sshSudoCommands =
values.sshSudoMode === "commands" &&
values.sshSudoCommands?.trim()
- ? values.sshSudoCommands
- .split(",")
- .map((s) => s.trim())
- .filter(Boolean)
+ ? parseSudoCommands(values.sshSudoCommands)
: [];
if (values.sshUnixGroups !== undefined) {
- payload.sshUnixGroups = values.sshUnixGroups
- .split(",")
- .map((s) => s.trim())
- .filter(Boolean);
+ payload.sshUnixGroups = parseUnixGroups(values.sshUnixGroups);
}
}
const res = await api
diff --git a/src/components/RoleForm.tsx b/src/components/RoleForm.tsx
index 9b8d7034b..e1890af45 100644
--- a/src/components/RoleForm.tsx
+++ b/src/components/RoleForm.tsx
@@ -10,6 +10,7 @@ import {
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
+import { Textarea } from "@app/components/ui/textarea";
import {
OptionSelect,
type OptionSelectOption
@@ -46,15 +47,34 @@ function toSshSudoMode(value: string | null | undefined): SshSudoMode {
return "none";
}
-function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean {
- if (!value?.trim()) return true;
+export function parseUnixGroups(value: string | undefined): string[] {
+ if (!value?.trim()) return [];
- const commands = value
- .split(",")
- .map((command) => command.trim())
+ return value
+ .split(/[,\s\n]+/)
+ .map((group) => group.trim())
.filter(Boolean);
+}
- return commands.every((command) => {
+export function parseSudoCommands(value: string | undefined): string[] {
+ if (!value?.trim()) return [];
+
+ const commands: string[] = [];
+ for (const segment of value.split(/[,\n]+/)) {
+ const trimmed = segment.trim();
+ if (!trimmed) continue;
+
+ for (const part of trimmed.split(/ (?=\/)/)) {
+ const command = part.trim();
+ if (command) commands.push(command);
+ }
+ }
+
+ return commands;
+}
+
+function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean {
+ return parseSudoCommands(value).every((command) => {
const executable = command.split(/\s+/)[0];
return executable.startsWith("/");
});
@@ -125,10 +145,10 @@ export function RoleForm({
(role as Role & { allowSsh?: boolean }).allowSsh ?? false,
sshSudoMode: toSshSudoMode(role.sshSudoMode),
sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join(
- ", "
+ "\n"
),
sshCreateHomeDir: role.sshCreateHomeDir ?? false,
- sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ")
+ sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join("\n")
}
: {
name: "",
@@ -156,10 +176,10 @@ export function RoleForm({
(role as Role & { allowSsh?: boolean }).allowSsh ?? false,
sshSudoMode: toSshSudoMode(role.sshSudoMode),
sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join(
- ", "
+ "\n"
),
sshCreateHomeDir: role.sshCreateHomeDir ?? false,
- sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ")
+ sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join("\n")
});
}
}, [variant, role, form]);
@@ -421,9 +441,10 @@ export function RoleForm({
{t("sshSudoCommands")}
-
@@ -446,9 +467,10 @@ export function RoleForm({
{t("sshUnixGroups")}
-