feat(users): Convert the user and group management UI/UX from a native view to a modal
Build And Publish Doktainer Image / docker (push) Canceled after 0s
Build And Publish Doktainer Image / docker (push) Canceled after 0s
This commit is contained in:
1 parent
14411e0cf8
commit
bbfe4c6680
2 files changed
+97
-278
No files matched your search
@@ -924,27 +924,6 @@ export default function ServerConfigModal({
|
||||
});
|
||||
};
|
||||
|
||||
const requestSystemGroupDeleteConfirm = (options: {
|
||||
groupName: string;
|
||||
gid: number;
|
||||
members: string[];
|
||||
primaryUsers: string[];
|
||||
confirmation: string;
|
||||
}) => {
|
||||
setPendingConfirm({
|
||||
kind: "system-group-delete",
|
||||
groupName: options.groupName,
|
||||
expectedGid: options.gid,
|
||||
expectedMembers: options.members,
|
||||
expectedPrimaryUsers: options.primaryUsers,
|
||||
confirmation: options.confirmation,
|
||||
title: `Delete system group ${options.groupName}`,
|
||||
description: `Permanently delete the empty group ${options.groupName} (GID ${options.gid}). System, privileged, changed, or in-use groups will be rejected by the host.`,
|
||||
confirmLabel: "Delete Group",
|
||||
tone: "danger",
|
||||
});
|
||||
};
|
||||
|
||||
const requestSshAccessUpdateConfirm = (
|
||||
options: ServerSshAccessUpdateBody,
|
||||
) => {
|
||||
@@ -1008,7 +987,6 @@ export default function ServerConfigModal({
|
||||
requestSystemUserSshKeyRevokeConfirm
|
||||
}
|
||||
onRequestDeleteUserConfirm={requestSystemUserDeleteConfirm}
|
||||
onRequestDeleteGroupConfirm={requestSystemGroupDeleteConfirm}
|
||||
/>
|
||||
);
|
||||
case "ssh-access":
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
ServerConfigSnapshot,
|
||||
@@ -63,28 +65,10 @@ interface ServerConfigUsersPanelProps {
|
||||
confirmation: string;
|
||||
removeHome: boolean;
|
||||
}) => void;
|
||||
onRequestDeleteGroupConfirm: (options: {
|
||||
groupName: string;
|
||||
gid: number;
|
||||
members: string[];
|
||||
primaryUsers: string[];
|
||||
confirmation: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const accountNamePattern = /^[a-z_][a-z0-9_-]{0,31}$/;
|
||||
const sensitiveGroups = new Set(["docker", "root", "sudo", "wheel"]);
|
||||
const protectedGroupNames = new Set([
|
||||
"root",
|
||||
"sudo",
|
||||
"wheel",
|
||||
"docker",
|
||||
"adm",
|
||||
"www-data",
|
||||
"systemd-journal",
|
||||
"ssh",
|
||||
"sshd",
|
||||
]);
|
||||
const publicKeyPattern = /^(?:ssh-(?:ed25519|rsa)|ecdsa-sha2-nistp(?:256|384|521)|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com)\s+[A-Za-z0-9+/]+={0,3}(?:\s+.*)?$/;
|
||||
|
||||
export default function ServerConfigUsersPanel({
|
||||
@@ -101,9 +85,9 @@ export default function ServerConfigUsersPanel({
|
||||
onRequestSshKeyAddConfirm,
|
||||
onRequestSshKeyRevokeConfirm,
|
||||
onRequestDeleteUserConfirm,
|
||||
onRequestDeleteGroupConfirm,
|
||||
}: ServerConfigUsersPanelProps) {
|
||||
const [previewMode, setPreviewMode] = useState<"user" | "group">("user");
|
||||
const [managementModalOpen, setManagementModalOpen] = useState(false);
|
||||
const [previewUsername, setPreviewUsername] = useState("deploy");
|
||||
const [previewGroupName, setPreviewGroupName] = useState("docker");
|
||||
const [userPurpose, setUserPurpose] = useState<"ssh" | "local">("ssh");
|
||||
@@ -129,12 +113,6 @@ export default function ServerConfigUsersPanel({
|
||||
);
|
||||
const [deleteUserConfirmation, setDeleteUserConfirmation] = useState("");
|
||||
const [deleteUserHome, setDeleteUserHome] = useState(false);
|
||||
const [managedGroup, setManagedGroup] = useState(
|
||||
snapshot.systemGroups.includes("docker")
|
||||
? "docker"
|
||||
: snapshot.systemGroups[0] ?? "",
|
||||
);
|
||||
const [deleteGroupConfirmation, setDeleteGroupConfirmation] = useState("");
|
||||
const [passwordEditorOpen, setPasswordEditorOpen] = useState(false);
|
||||
const [editPassword, setEditPassword] = useState("");
|
||||
const [editPasswordConfirm, setEditPasswordConfirm] = useState("");
|
||||
@@ -145,6 +123,19 @@ export default function ServerConfigUsersPanel({
|
||||
const [sshKeyLabel, setSshKeyLabel] = useState("Personal laptop");
|
||||
const [sshPublicKey, setSshPublicKey] = useState("");
|
||||
const [sshKeyError, setSshKeyError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!managementModalOpen) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setManagementModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [managementModalOpen]);
|
||||
const users = useMemo(
|
||||
() => [
|
||||
...(snapshot.rootUser ? [snapshot.rootUser] : []),
|
||||
@@ -219,24 +210,6 @@ export default function ServerConfigUsersPanel({
|
||||
: loginMethod === "password"
|
||||
? `Create ${normalizedUsername || "<username>"} and prepare password-based SSH login.`
|
||||
: `Create ${normalizedUsername || "<username>"} and install an SSH public key.`;
|
||||
const managedGroupDetails = snapshot.systemGroupDetails.find(
|
||||
(group) => group.name === managedGroup,
|
||||
);
|
||||
const managedGroupUsers = managedGroupDetails?.members ?? [];
|
||||
const managedGroupPrimaryUsers = managedGroupDetails?.primaryUsers ?? [];
|
||||
const managedGroupBlockReason = !managedGroup
|
||||
? "Select a group to inspect."
|
||||
: !managedGroupDetails || snapshot.gidMin == null
|
||||
? "Group metadata or the host GID_MIN policy is unavailable. Refresh Server Config."
|
||||
: protectedGroupNames.has(managedGroup)
|
||||
? `${managedGroup} is protected because it is a system or privileged group.`
|
||||
: managedGroupDetails.gid < snapshot.gidMin
|
||||
? `GID ${managedGroupDetails.gid} is below the host GID_MIN (${snapshot.gidMin}).`
|
||||
: managedGroupPrimaryUsers.length > 0
|
||||
? `This is the primary group for ${managedGroupPrimaryUsers.join(", ")}.`
|
||||
: managedGroupUsers.length > 0
|
||||
? `Remove its members first: ${managedGroupUsers.join(", ")}.`
|
||||
: null;
|
||||
|
||||
const toggleGroup = (group: string) => {
|
||||
setSelectedGroups((current) =>
|
||||
@@ -292,6 +265,7 @@ export default function ServerConfigUsersPanel({
|
||||
remoteLogin,
|
||||
credential,
|
||||
});
|
||||
setManagementModalOpen(false);
|
||||
setGuidedSetupError(null);
|
||||
setSetupPassword("");
|
||||
setSetupPasswordConfirm("");
|
||||
@@ -408,57 +382,78 @@ export default function ServerConfigUsersPanel({
|
||||
style={{ display: "grid", gap: 16 }}
|
||||
hidden={snapshotLoadError != null}
|
||||
>
|
||||
<div className="card" style={{ padding: 18 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
|
||||
User Inventory
|
||||
</strong>
|
||||
<p
|
||||
style={{ marginTop: 6, color: "var(--text-muted)", fontSize: 13 }}
|
||||
>
|
||||
Showing root and non-root accounts together with their detected
|
||||
groups.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<UserBadge
|
||||
label={`${snapshot.hasRootUser ? 1 : 0} root`}
|
||||
tone="danger"
|
||||
/>
|
||||
<UserBadge
|
||||
label={`${snapshot.nonRootUsers.length} non-root`}
|
||||
tone="info"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: 18,
|
||||
display: "grid",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
flexWrap: "wrap",
|
||||
borderColor: "rgba(59,130,246,0.24)",
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(59,130,246,0.045), var(--bg-card) 68%)",
|
||||
"linear-gradient(180deg, rgba(59,130,246,0.045), transparent 68%), var(--bg-card)",
|
||||
}}
|
||||
>
|
||||
<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 host accounts, create groups, and assign supplementary
|
||||
group access without opening a terminal.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => setManagementModalOpen(true)}
|
||||
style={{ minHeight: 44, justifyContent: "center" }}
|
||||
>
|
||||
<UserPlus size={14} /> Manage Users & Groups
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{managementModalOpen && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div className="modal-overlay" style={{ zIndex: 1003 }}>
|
||||
<div className="modal-shell" style={{ maxWidth: 920 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close"
|
||||
onClick={() => setManagementModalOpen(false)}
|
||||
aria-label="Close user and group management modal"
|
||||
autoFocus
|
||||
>
|
||||
<X size={22} />
|
||||
</button>
|
||||
<div
|
||||
className="modal animate-slide-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="user-group-management-title"
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
maxWidth: 920,
|
||||
maxHeight: "90vh",
|
||||
padding: 24,
|
||||
display: "grid",
|
||||
gap: 16,
|
||||
overflowY: "auto",
|
||||
borderColor: "rgba(59,130,246,0.24)",
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(59,130,246,0.045), transparent 68%), var(--bg-card)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -469,7 +464,10 @@ export default function ServerConfigUsersPanel({
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
|
||||
<strong
|
||||
id="user-group-management-title"
|
||||
style={{ color: "var(--text-primary)", fontSize: 14 }}
|
||||
>
|
||||
User & Group Management
|
||||
</strong>
|
||||
<p
|
||||
@@ -480,8 +478,8 @@ export default function ServerConfigUsersPanel({
|
||||
maxWidth: 620,
|
||||
}}
|
||||
>
|
||||
Create passwordless host accounts, create groups, and assign
|
||||
supplementary group access without opening a terminal.
|
||||
Create host accounts, create groups, and assign supplementary
|
||||
group access without opening a terminal.
|
||||
</p>
|
||||
</div>
|
||||
<UserBadge label="Audited action" tone="success" />
|
||||
@@ -490,7 +488,8 @@ export default function ServerConfigUsersPanel({
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
|
||||
gridTemplateColumns:
|
||||
"repeat(auto-fit, minmax(min(260px, 100%), 1fr))",
|
||||
gap: 16,
|
||||
alignItems: "start",
|
||||
}}
|
||||
@@ -501,7 +500,7 @@ export default function ServerConfigUsersPanel({
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 8,
|
||||
height: 40,
|
||||
minHeight: 44,
|
||||
alignSelf: "start",
|
||||
}}
|
||||
>
|
||||
@@ -518,8 +517,7 @@ export default function ServerConfigUsersPanel({
|
||||
className="btn"
|
||||
onClick={() => setPreviewMode(item.key as "user" | "group")}
|
||||
style={{
|
||||
height: 40,
|
||||
minHeight: 40,
|
||||
minHeight: 44,
|
||||
padding: "0 12px",
|
||||
justifyContent: "center",
|
||||
borderColor: active ? "var(--accent)" : "var(--border)",
|
||||
@@ -1035,6 +1033,7 @@ export default function ServerConfigUsersPanel({
|
||||
if (previewMode === "user") {
|
||||
stageGuidedUserSetup();
|
||||
} else {
|
||||
setManagementModalOpen(false);
|
||||
onRequestCreateGroupConfirm(normalizedGroupName);
|
||||
}
|
||||
}}
|
||||
@@ -1068,170 +1067,12 @@ export default function ServerConfigUsersPanel({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: 18,
|
||||
display: "grid",
|
||||
gap: 14,
|
||||
borderColor: "rgba(239,68,68,0.18)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
|
||||
Existing Groups
|
||||
</strong>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 5,
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Inspect group usage before opening the protected delete flow.
|
||||
</p>
|
||||
</div>
|
||||
<UserBadge label="Audited action" tone="success" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
|
||||
gap: 14,
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "grid", gap: 10 }}>
|
||||
<SearchableSelect
|
||||
value={managedGroup}
|
||||
options={snapshot.systemGroupDetails.map((group) => ({
|
||||
value: group.name,
|
||||
label: group.name,
|
||||
description: protectedGroupNames.has(group.name) ||
|
||||
(snapshot.gidMin != null && group.gid < snapshot.gidMin)
|
||||
? "Protected group"
|
||||
: group.members.length > 0 || group.primaryUsers.length > 0
|
||||
? "In use"
|
||||
: `GID ${group.gid} · eligible for review`,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setManagedGroup(value);
|
||||
setDeleteGroupConfirmation("");
|
||||
}}
|
||||
placeholder="Select an existing group..."
|
||||
searchPlaceholder="Search group..."
|
||||
emptyText="No matching group found"
|
||||
/>
|
||||
{managedGroup ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 7,
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<UserBadge
|
||||
label={managedGroupBlockReason ? "Deletion locked" : "Candidate"}
|
||||
tone={managedGroupBlockReason ? "warning" : "success"}
|
||||
/>
|
||||
<UserBadge
|
||||
label={`${managedGroupUsers.length} detected member${managedGroupUsers.length === 1 ? "" : "s"}`}
|
||||
tone="neutral"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 10,
|
||||
padding: 12,
|
||||
borderRadius: 9,
|
||||
border: managedGroupBlockReason
|
||||
? "1px solid rgba(245,158,11,0.24)"
|
||||
: "1px solid rgba(239,68,68,0.24)",
|
||||
background: managedGroupBlockReason
|
||||
? "rgba(245,158,11,0.055)"
|
||||
: "rgba(239,68,68,0.035)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{managedGroupBlockReason ? (
|
||||
<LockKeyhole size={15} color="#f59e0b" />
|
||||
) : (
|
||||
<Trash2 size={15} color="#ef4444" />
|
||||
)}
|
||||
<strong style={{ color: "var(--text-primary)", fontSize: 13 }}>
|
||||
Delete group
|
||||
</strong>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
color: managedGroupBlockReason ? "#b45309" : "var(--text-muted)",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{managedGroupBlockReason ??
|
||||
"No members were detected. The live feature will re-check GID_MIN, primary-group usage, and membership on the host immediately before deletion."}
|
||||
</p>
|
||||
{!managedGroupBlockReason && managedGroup ? (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
value={deleteGroupConfirmation}
|
||||
onChange={(event) =>
|
||||
setDeleteGroupConfirmation(event.target.value)
|
||||
}
|
||||
placeholder={`Type ${managedGroup} to confirm`}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
disabled={
|
||||
deleteGroupConfirmation !== managedGroup ||
|
||||
!canManageSystemAccounts ||
|
||||
!managedGroupDetails ||
|
||||
isActionRunning(`system-group:delete:${managedGroup}`)
|
||||
}
|
||||
onClick={() => {
|
||||
if (!managedGroupDetails) return;
|
||||
onRequestDeleteGroupConfirm({
|
||||
groupName: managedGroupDetails.name,
|
||||
gid: managedGroupDetails.gid,
|
||||
members: managedGroupDetails.members,
|
||||
primaryUsers: managedGroupDetails.primaryUsers,
|
||||
confirmation: deleteGroupConfirmation,
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
color: "#ef4444",
|
||||
borderColor: "rgba(239,68,68,0.32)",
|
||||
}}
|
||||
>
|
||||
<Trash2 size={13} /> Review Group Deletion
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{users.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
|
||||
Reference in new issue
Block a user