feat(servers): Added features for adding host users and secure group management.
This commit is contained in:
1 parent
9486f68022
commit
e1d09fdf46
10 files changed
+2549
-546
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -46,6 +46,38 @@ export type ServerPendingConfirm =
|
||||
buildCache: boolean;
|
||||
};
|
||||
}
|
||||
| {
|
||||
kind: "system-user";
|
||||
username: string;
|
||||
groups: string[];
|
||||
privileged: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel: string;
|
||||
tone: "danger" | "warning" | "info";
|
||||
}
|
||||
| {
|
||||
kind: "system-group";
|
||||
groupName: string;
|
||||
privileged: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel: string;
|
||||
tone: "danger" | "warning" | "info";
|
||||
}
|
||||
| {
|
||||
kind: "system-user-update";
|
||||
username: string;
|
||||
groups: string[];
|
||||
shell: string;
|
||||
expectedGroups: string[];
|
||||
expectedShell: string;
|
||||
privileged: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel: string;
|
||||
tone: "danger" | "warning" | "info";
|
||||
}
|
||||
| {
|
||||
kind: "docker";
|
||||
action: "install" | "uninstall" | "reinstall";
|
||||
@@ -407,6 +439,7 @@ export function createUnavailableServerConfigSnapshot(
|
||||
users: [],
|
||||
rootUser: null,
|
||||
nonRootUsers: [],
|
||||
systemGroups: [],
|
||||
hasRootUser: false,
|
||||
sudoNonInteractive: false,
|
||||
docker: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { KeyRound } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { LockKeyhole, Pencil, ShieldCheck } from "lucide-react";
|
||||
import type { ServerSystemUser } from "@/lib/api";
|
||||
import type { UserBadgeTone } from "@/app/servers/components/server-config-utils";
|
||||
|
||||
@@ -99,7 +100,19 @@ export function ServiceStatusBadge({ state }: { state: string }) {
|
||||
return <UserBadge label={state} tone={tone} />;
|
||||
}
|
||||
|
||||
export function ServerUserCard({ user }: { user: ServerSystemUser }) {
|
||||
export function ServerUserCard({
|
||||
user,
|
||||
canEdit = false,
|
||||
isEditing = false,
|
||||
onEdit,
|
||||
children,
|
||||
}: {
|
||||
user: ServerSystemUser;
|
||||
canEdit?: boolean;
|
||||
isEditing?: boolean;
|
||||
onEdit?: () => void;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const accountLabel = user.isRoot ? "root" : "non-root";
|
||||
const accountTone = user.isRoot ? "danger" : "info";
|
||||
|
||||
@@ -143,19 +156,67 @@ export function ServerUserCard({ user }: { user: ServerSystemUser }) {
|
||||
UID {user.uid ?? "—"} • GID {user.gid ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
<KeyRound
|
||||
size={16}
|
||||
style={{ color: user.isRoot ? "#ef4444" : "#3b82f6" }}
|
||||
/>
|
||||
{user.isRoot ? (
|
||||
<span
|
||||
className="ui-badge"
|
||||
title="Root account is protected and cannot be edited here"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: "#ef4444",
|
||||
background: "rgba(239,68,68,0.08)",
|
||||
border: "1px solid rgba(239,68,68,0.2)",
|
||||
}}
|
||||
>
|
||||
<LockKeyhole size={13} /> Protected
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={onEdit}
|
||||
disabled={!canEdit}
|
||||
aria-expanded={isEditing}
|
||||
title={
|
||||
canEdit
|
||||
? user.isSshUser
|
||||
? "Edit access with SSH login protections"
|
||||
: "Edit user access"
|
||||
: "Operator or Super Admin role required"
|
||||
}
|
||||
style={{
|
||||
color: user.isSshUser ? "#f59e0b" : "#3b82f6",
|
||||
border: user.isSshUser
|
||||
? "1px solid rgba(245,158,11,0.2)"
|
||||
: "1px solid rgba(59,130,246,0.2)",
|
||||
background: user.isSshUser
|
||||
? "rgba(245,158,11,0.08)"
|
||||
: "rgba(59,130,246,0.08)",
|
||||
}}
|
||||
>
|
||||
{user.isSshUser ? (
|
||||
<ShieldCheck size={13} />
|
||||
) : (
|
||||
<Pencil size={13} />
|
||||
)}
|
||||
{user.isSshUser ? "Edit access" : "Edit"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
<ConfigInfoRow label="Home" value={user.home ?? "—"} />
|
||||
<ConfigInfoRow label="Shell" value={user.shell ?? "—"} />
|
||||
<ConfigInfoRow
|
||||
label="Groups"
|
||||
label="Primary group"
|
||||
value={user.primaryGroup ?? "—"}
|
||||
/>
|
||||
<ConfigInfoRow
|
||||
label="All groups"
|
||||
value={user.groups.length > 0 ? user.groups.join(", ") : "—"}
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import type { ServerConfigSnapshot } from "@/lib/api";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Group,
|
||||
Loader2,
|
||||
LockKeyhole,
|
||||
Plus,
|
||||
ShieldAlert,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import type { ServerConfigSnapshot, ServerSystemUser } from "@/lib/api";
|
||||
import {
|
||||
ServerUserCard,
|
||||
UserBadge,
|
||||
} from "@/app/servers/components/server-config/ServerConfigPrimitives";
|
||||
import SearchableSelect from "@/components/SearchableSelect";
|
||||
|
||||
interface ServerConfigUsersPanelProps {
|
||||
snapshot: ServerConfigSnapshot;
|
||||
snapshotLoadError?: string | null;
|
||||
canManageSystemAccounts: boolean;
|
||||
isActionRunning: (actionKey: string) => boolean;
|
||||
getSystemUserUpdateActionKey: (username: string) => string;
|
||||
onRequestCreateUserConfirm: (username: string, groups: string[]) => void;
|
||||
onRequestCreateGroupConfirm: (groupName: string) => void;
|
||||
onRequestUpdateUserConfirm: (
|
||||
user: ServerSystemUser,
|
||||
groups: string[],
|
||||
shell: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const accountNamePattern = /^[a-z_][a-z0-9_-]{0,31}$/;
|
||||
const sensitiveGroups = new Set(["docker", "root", "sudo", "wheel"]);
|
||||
|
||||
export default function ServerConfigUsersPanel({
|
||||
snapshot,
|
||||
snapshotLoadError,
|
||||
canManageSystemAccounts,
|
||||
isActionRunning,
|
||||
getSystemUserUpdateActionKey,
|
||||
onRequestCreateUserConfirm,
|
||||
onRequestCreateGroupConfirm,
|
||||
onRequestUpdateUserConfirm,
|
||||
}: ServerConfigUsersPanelProps) {
|
||||
const users = [
|
||||
...(snapshot.rootUser ? [snapshot.rootUser] : []),
|
||||
...snapshot.nonRootUsers,
|
||||
];
|
||||
const [previewMode, setPreviewMode] = useState<"user" | "group">("user");
|
||||
const [previewUsername, setPreviewUsername] = useState("deploy");
|
||||
const [previewGroupName, setPreviewGroupName] = useState("docker");
|
||||
const [selectedGroups, setSelectedGroups] = useState<string[]>(() =>
|
||||
snapshot.systemGroups.includes("docker") ? ["docker"] : [],
|
||||
);
|
||||
const [editingUsername, setEditingUsername] = useState<string | null>(null);
|
||||
const [editDraftGroups, setEditDraftGroups] = useState<string[]>([]);
|
||||
const [editDraftShell, setEditDraftShell] = useState("/bin/bash");
|
||||
const users = useMemo(
|
||||
() => [
|
||||
...(snapshot.rootUser ? [snapshot.rootUser] : []),
|
||||
...snapshot.nonRootUsers,
|
||||
],
|
||||
[snapshot.nonRootUsers, snapshot.rootUser],
|
||||
);
|
||||
const detectedGroups = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(
|
||||
users.flatMap((user) => user.groups).filter((group) => group.trim()),
|
||||
),
|
||||
).sort((left, right) => left.localeCompare(right)),
|
||||
[users],
|
||||
);
|
||||
const commonGroups = ["docker", "sudo", "wheel", "root", "www-data", "adm"];
|
||||
const groupOptions = Array.from(
|
||||
new Set([...commonGroups, ...detectedGroups, ...snapshot.systemGroups]),
|
||||
).filter(
|
||||
(group) =>
|
||||
snapshot.systemGroups.includes(group) && accountNamePattern.test(group),
|
||||
);
|
||||
const selectedSensitiveGroups = selectedGroups.filter((group) =>
|
||||
sensitiveGroups.has(group),
|
||||
);
|
||||
const privilegedSelection =
|
||||
previewMode === "user"
|
||||
? selectedSensitiveGroups.length > 0
|
||||
: sensitiveGroups.has(previewGroupName.trim());
|
||||
const quickGroupOptions = Array.from(
|
||||
new Set([...selectedGroups, ...groupOptions.slice(0, 8)]),
|
||||
);
|
||||
const normalizedUsername = previewUsername.trim();
|
||||
const normalizedGroupName = previewGroupName.trim();
|
||||
const usernameExists = users.some(
|
||||
(user) => user.username === normalizedUsername,
|
||||
);
|
||||
const groupExists = snapshot.systemGroups.includes(normalizedGroupName);
|
||||
const usernameError = !accountNamePattern.test(normalizedUsername)
|
||||
? "Use 1–32 lowercase letters, numbers, underscores, or hyphens; start with a letter or underscore."
|
||||
: normalizedUsername === "root"
|
||||
? "The root account cannot be created or replaced."
|
||||
: usernameExists
|
||||
? "This user already exists on the host."
|
||||
: null;
|
||||
const groupNameError = !accountNamePattern.test(normalizedGroupName)
|
||||
? "Use 1–32 lowercase letters, numbers, underscores, or hyphens; start with a letter or underscore."
|
||||
: groupExists
|
||||
? "This group already exists on the host."
|
||||
: null;
|
||||
const actionKey =
|
||||
previewMode === "user" ? "system-user:create" : "system-group:create";
|
||||
const actionRunning = isActionRunning(actionKey);
|
||||
const formInvalid =
|
||||
previewMode === "user" ? Boolean(usernameError) : Boolean(groupNameError);
|
||||
const previewCommand =
|
||||
previewMode === "user"
|
||||
? [
|
||||
`useradd -m -s /bin/bash${
|
||||
selectedGroups.length > 0
|
||||
? ` -G ${selectedGroups.join(",")}`
|
||||
: ""
|
||||
} ${previewUsername || "<username>"}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" && ")
|
||||
: `groupadd ${previewGroupName || "<group>"}`;
|
||||
|
||||
const toggleGroup = (group: string) => {
|
||||
setSelectedGroups((current) =>
|
||||
current.includes(group)
|
||||
? current.filter((item) => item !== group)
|
||||
: current.length < 16
|
||||
? [...current, group]
|
||||
: current,
|
||||
);
|
||||
};
|
||||
|
||||
const beginEdit = (user: ServerSystemUser) => {
|
||||
if (user.isRoot) return;
|
||||
if (editingUsername === user.username) {
|
||||
setEditingUsername(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingUsername(user.username);
|
||||
setEditDraftGroups(user.groups);
|
||||
setEditDraftShell(user.shell ?? "/bin/bash");
|
||||
};
|
||||
|
||||
const toggleEditGroup = (group: string, sourceUser: ServerSystemUser) => {
|
||||
setEditDraftGroups((current) => {
|
||||
if (current.includes(group)) {
|
||||
const isPrimaryGroup = group === sourceUser.primaryGroup;
|
||||
const isProtectedSshGroup =
|
||||
sourceUser.isSshUser && sourceUser.groups.includes(group);
|
||||
if (isPrimaryGroup || isProtectedSshGroup) {
|
||||
return current;
|
||||
}
|
||||
return current.filter((item) => item !== group);
|
||||
}
|
||||
return current.length < 16 ? [...current, group] : current;
|
||||
});
|
||||
};
|
||||
|
||||
const requestEditConfirmation = (user: ServerSystemUser) => {
|
||||
setEditingUsername(null);
|
||||
onRequestUpdateUserConfirm(
|
||||
user,
|
||||
editDraftGroups,
|
||||
user.isSshUser ? user.shell ?? editDraftShell : editDraftShell,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -51,6 +199,7 @@ export default function ServerConfigUsersPanel({
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<UserBadge
|
||||
@@ -64,6 +213,327 @@ export default function ServerConfigUsersPanel({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: 18,
|
||||
display: "grid",
|
||||
gap: 16,
|
||||
borderColor: "rgba(59,130,246,0.24)",
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(59,130,246,0.045), var(--bg-card) 68%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
|
||||
User & Group Management
|
||||
</strong>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 6,
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
maxWidth: 620,
|
||||
}}
|
||||
>
|
||||
Create passwordless host accounts, create groups, and assign
|
||||
supplementary group access without opening a terminal.
|
||||
</p>
|
||||
</div>
|
||||
<UserBadge label="Audited action" tone="success" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
|
||||
gap: 16,
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "grid", gap: 12, alignContent: "start" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 8,
|
||||
height: 40,
|
||||
alignSelf: "start",
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ key: "user", label: "Add User", icon: UserPlus },
|
||||
{ key: "group", label: "Add Group", icon: Group },
|
||||
].map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = previewMode === item.key;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => setPreviewMode(item.key as "user" | "group")}
|
||||
style={{
|
||||
height: 40,
|
||||
minHeight: 40,
|
||||
padding: "0 12px",
|
||||
justifyContent: "center",
|
||||
borderColor: active ? "var(--accent)" : "var(--border)",
|
||||
background: active
|
||||
? "rgba(59,130,246,0.12)"
|
||||
: "var(--bg-input)",
|
||||
color: active ? "var(--accent)" : "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{previewMode === "user" ? (
|
||||
<>
|
||||
<label style={{ display: "grid", gap: 6 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--text-muted)" }}>
|
||||
Username
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
value={previewUsername}
|
||||
onChange={(event) =>
|
||||
setPreviewUsername(event.target.value)
|
||||
}
|
||||
placeholder="deploy"
|
||||
autoComplete="off"
|
||||
aria-invalid={Boolean(usernameError)}
|
||||
/>
|
||||
{usernameError ? (
|
||||
<span style={{ color: "#ef4444", fontSize: 12 }}>
|
||||
{usernameError}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--text-muted)" }}>
|
||||
Initial groups ({selectedGroups.length}/16)
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{quickGroupOptions.map((group) => {
|
||||
const active = selectedGroups.includes(group);
|
||||
const sensitive = sensitiveGroups.has(group);
|
||||
return (
|
||||
<button
|
||||
key={group}
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => toggleGroup(group)}
|
||||
style={{
|
||||
padding: "5px 10px",
|
||||
fontSize: 12,
|
||||
borderColor: active
|
||||
? sensitive
|
||||
? "rgba(245,158,11,0.38)"
|
||||
: "rgba(59,130,246,0.38)"
|
||||
: "var(--border)",
|
||||
background: active
|
||||
? sensitive
|
||||
? "rgba(245,158,11,0.1)"
|
||||
: "rgba(59,130,246,0.1)"
|
||||
: "var(--bg-input)",
|
||||
color: active
|
||||
? sensitive
|
||||
? "#f59e0b"
|
||||
: "var(--accent)"
|
||||
: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{group}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{groupOptions.some(
|
||||
(group) => !selectedGroups.includes(group),
|
||||
) ? (
|
||||
<SearchableSelect
|
||||
value=""
|
||||
options={groupOptions
|
||||
.filter((group) => !selectedGroups.includes(group))
|
||||
.map((group) => ({
|
||||
value: group,
|
||||
label: group,
|
||||
description: sensitiveGroups.has(group)
|
||||
? "Privileged"
|
||||
: undefined,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
if (value) toggleGroup(value);
|
||||
}}
|
||||
placeholder="Select another existing group..."
|
||||
searchPlaceholder="Search group..."
|
||||
emptyText="No matching group found"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<label style={{ display: "grid", gap: 6 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--text-muted)" }}>
|
||||
Group name
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
value={previewGroupName}
|
||||
onChange={(event) => setPreviewGroupName(event.target.value)}
|
||||
placeholder="docker"
|
||||
autoComplete="off"
|
||||
aria-invalid={Boolean(groupNameError)}
|
||||
/>
|
||||
{groupNameError ? (
|
||||
<span style={{ color: "#ef4444", fontSize: 12 }}>
|
||||
{groupNameError}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 12,
|
||||
alignContent: "start",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
background: "var(--bg-input)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<strong
|
||||
style={{ color: "var(--text-primary)", fontSize: 13 }}
|
||||
>
|
||||
Planned host change
|
||||
</strong>
|
||||
{privilegedSelection ? (
|
||||
<UserBadge label="Needs confirm" tone="warning" />
|
||||
) : (
|
||||
<UserBadge label="Low risk" tone="success" />
|
||||
)}
|
||||
</div>
|
||||
<code
|
||||
style={{
|
||||
display: "block",
|
||||
marginTop: 12,
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: "normal",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{previewCommand}
|
||||
</code>
|
||||
{previewMode === "user" ? (
|
||||
<p
|
||||
style={{
|
||||
marginTop: 10,
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
No password is set. Configure an SSH public key separately
|
||||
before using this account for remote login.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{privilegedSelection ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
color: "#b45309",
|
||||
background: "rgba(245,158,11,0.09)",
|
||||
border: "1px solid rgba(245,158,11,0.22)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<ShieldAlert size={16} style={{ flex: "0 0 auto" }} />
|
||||
<span>
|
||||
{previewMode === "user"
|
||||
? `Membership in ${selectedSensitiveGroups.join(", ")} can grant administrative or root-equivalent access.`
|
||||
: `The ${normalizedGroupName} group is treated as privileged.`}{" "}
|
||||
An explicit confirmation and audit event are required.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={formInvalid || actionRunning || !canManageSystemAccounts}
|
||||
onClick={() => {
|
||||
if (previewMode === "user") {
|
||||
onRequestCreateUserConfirm(normalizedUsername, selectedGroups);
|
||||
} else {
|
||||
onRequestCreateGroupConfirm(normalizedGroupName);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
opacity:
|
||||
formInvalid || actionRunning || !canManageSystemAccounts
|
||||
? 0.65
|
||||
: 1,
|
||||
}}
|
||||
title={
|
||||
canManageSystemAccounts
|
||||
? undefined
|
||||
: "Operator or Super Admin role required"
|
||||
}
|
||||
>
|
||||
{actionRunning ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Plus size={14} />
|
||||
)}
|
||||
{previewMode === "user" ? "Create User" : "Create Group"}
|
||||
</button>
|
||||
{!canManageSystemAccounts ? (
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 12 }}>
|
||||
Operator or Super Admin role is required for host account
|
||||
changes.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{users.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
@@ -73,9 +543,244 @@ export default function ServerConfigUsersPanel({
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<ServerUserCard key={user.username} user={user} />
|
||||
))}
|
||||
{users.map((user) => {
|
||||
const sourceUser = user;
|
||||
const isEditing = editingUsername === user.username;
|
||||
const updateActionKey = getSystemUserUpdateActionKey(user.username);
|
||||
const updateRunning = isActionRunning(updateActionKey);
|
||||
const editGroupOptions = Array.from(
|
||||
new Set([...editDraftGroups, ...groupOptions.slice(0, 10)]),
|
||||
);
|
||||
const selectedPrivilegedEditGroups = editDraftGroups.filter(
|
||||
(group) =>
|
||||
sensitiveGroups.has(group) && !sourceUser.groups.includes(group),
|
||||
);
|
||||
const groupChanges =
|
||||
editDraftGroups.length !== sourceUser.groups.length ||
|
||||
editDraftGroups.some(
|
||||
(group) => !sourceUser.groups.includes(group),
|
||||
);
|
||||
const shellChanges =
|
||||
!sourceUser.isSshUser && editDraftShell !== sourceUser.shell;
|
||||
const hasChanges = groupChanges || shellChanges;
|
||||
|
||||
return (
|
||||
<ServerUserCard
|
||||
key={user.username}
|
||||
user={user}
|
||||
canEdit={canManageSystemAccounts && !updateRunning}
|
||||
isEditing={isEditing}
|
||||
onEdit={() => beginEdit(sourceUser)}
|
||||
>
|
||||
{isEditing ? (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 12,
|
||||
paddingTop: 14,
|
||||
borderTop: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<strong
|
||||
style={{ color: "var(--text-primary)", fontSize: 13 }}
|
||||
>
|
||||
{sourceUser.isSshUser
|
||||
? "Edit SSH user access"
|
||||
: "Edit user access"}
|
||||
</strong>
|
||||
<UserBadge label="Confirmation required" tone="warning" />
|
||||
</div>
|
||||
|
||||
{sourceUser.isSshUser ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
color: "#b45309",
|
||||
background: "rgba(245,158,11,0.08)",
|
||||
border: "1px solid rgba(245,158,11,0.2)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
The active SSH login keeps its current shell and groups.
|
||||
You can only add supplementary groups. Changes apply to
|
||||
new SSH sessions after confirmation.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label style={{ display: "grid", gap: 6 }}>
|
||||
<span
|
||||
style={{ fontSize: 12, color: "var(--text-muted)" }}
|
||||
>
|
||||
Login shell
|
||||
</span>
|
||||
<select
|
||||
className="input"
|
||||
value={editDraftShell}
|
||||
disabled={sourceUser.isSshUser}
|
||||
onChange={(event) =>
|
||||
setEditDraftShell(event.target.value)
|
||||
}
|
||||
>
|
||||
{Array.from(
|
||||
new Set([
|
||||
sourceUser.shell ?? "/bin/bash",
|
||||
"/bin/bash",
|
||||
"/bin/sh",
|
||||
"/usr/sbin/nologin",
|
||||
]),
|
||||
).map((shell) => (
|
||||
<option key={shell} value={shell}>
|
||||
{shell}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
<span
|
||||
style={{ fontSize: 12, color: "var(--text-muted)" }}
|
||||
>
|
||||
Group memberships ({editDraftGroups.length}/16)
|
||||
</span>
|
||||
<div
|
||||
style={{ display: "flex", gap: 7, flexWrap: "wrap" }}
|
||||
>
|
||||
{editGroupOptions.map((group) => {
|
||||
const selected = editDraftGroups.includes(group);
|
||||
const locked =
|
||||
group === sourceUser.primaryGroup ||
|
||||
(sourceUser.isSshUser &&
|
||||
sourceUser.groups.includes(group));
|
||||
const privileged = sensitiveGroups.has(group);
|
||||
return (
|
||||
<button
|
||||
key={group}
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() =>
|
||||
toggleEditGroup(group, sourceUser)
|
||||
}
|
||||
title={
|
||||
locked
|
||||
? group === sourceUser.primaryGroup
|
||||
? "The primary group cannot be removed"
|
||||
: "Current SSH login groups cannot be removed"
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
padding: "4px 9px",
|
||||
fontSize: 12,
|
||||
color: selected
|
||||
? privileged
|
||||
? "#f59e0b"
|
||||
: "#3b82f6"
|
||||
: "var(--text-secondary)",
|
||||
borderColor: selected
|
||||
? privileged
|
||||
? "rgba(245,158,11,0.35)"
|
||||
: "rgba(59,130,246,0.35)"
|
||||
: "var(--border)",
|
||||
background: selected
|
||||
? privileged
|
||||
? "rgba(245,158,11,0.08)"
|
||||
: "rgba(59,130,246,0.08)"
|
||||
: "var(--bg-input)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
}}
|
||||
>
|
||||
{locked ? <LockKeyhole size={11} /> : null}
|
||||
{group}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{groupOptions.some(
|
||||
(group) => !editDraftGroups.includes(group),
|
||||
) ? (
|
||||
<SearchableSelect
|
||||
value=""
|
||||
options={groupOptions
|
||||
.filter(
|
||||
(group) => !editDraftGroups.includes(group),
|
||||
)
|
||||
.map((group) => ({
|
||||
value: group,
|
||||
label: group,
|
||||
description: sensitiveGroups.has(group)
|
||||
? "Privileged"
|
||||
: undefined,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
if (value) {
|
||||
toggleEditGroup(value, sourceUser);
|
||||
}
|
||||
}}
|
||||
placeholder="Add another existing group..."
|
||||
searchPlaceholder="Search group..."
|
||||
emptyText="No matching group found"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{selectedPrivilegedEditGroups.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
color: "#b45309",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
New privileged access selected:{" "}
|
||||
{selectedPrivilegedEditGroups.join(", ")}. The live
|
||||
action requires explicit confirmation and is audited.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => setEditingUsername(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!hasChanges || updateRunning}
|
||||
onClick={() => requestEditConfirmation(sourceUser)}
|
||||
style={{ opacity: !hasChanges || updateRunning ? 0.65 : 1 }}
|
||||
>
|
||||
{updateRunning ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : null}
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</ServerUserCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
|
||||
Reference in new issue
Block a user