894 lines
29 KiB
TypeScript
894 lines
29 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import {
|
|
AlertTriangle,
|
|
CheckCircle2,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Eye,
|
|
EyeOff,
|
|
KeyRound,
|
|
Loader2,
|
|
RotateCcw,
|
|
ServerCog,
|
|
ShieldCheck,
|
|
} from "lucide-react";
|
|
import type {
|
|
Server as ServerType,
|
|
ServerConfigSnapshot,
|
|
ServerSshAccessUpdateBody,
|
|
} from "@/lib/api";
|
|
import { UserBadge } from "@/app/servers/components/server-config/ServerConfigPrimitives";
|
|
|
|
interface ServerConfigSshAccessPanelProps {
|
|
server: ServerType;
|
|
snapshot: ServerConfigSnapshot;
|
|
snapshotLoadError?: string | null;
|
|
canManageSystemAccounts: boolean;
|
|
actionRunning: boolean;
|
|
onRequestApplyConfirm: (options: ServerSshAccessUpdateBody) => void;
|
|
onRequestPasswordConfirm: (options: {
|
|
username: string;
|
|
action: "set";
|
|
password: string;
|
|
requireChange: boolean;
|
|
noticeTab?: "ssh-access";
|
|
}) => void;
|
|
}
|
|
|
|
function PolicySwitch({
|
|
checked,
|
|
disabled = false,
|
|
label,
|
|
onChange,
|
|
}: {
|
|
checked: boolean;
|
|
disabled?: boolean;
|
|
label: string;
|
|
onChange: (checked: boolean) => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={checked}
|
|
aria-label={label}
|
|
disabled={disabled}
|
|
onClick={() => onChange(!checked)}
|
|
style={{
|
|
width: 40,
|
|
height: 22,
|
|
flex: "0 0 auto",
|
|
padding: 2,
|
|
borderRadius: 999,
|
|
border: checked
|
|
? "1px solid rgba(16,185,129,0.5)"
|
|
: "1px solid var(--border)",
|
|
background: checked ? "rgba(16,185,129,0.2)" : "var(--bg-input)",
|
|
cursor: disabled ? "not-allowed" : "pointer",
|
|
opacity: disabled ? 0.6 : 1,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
display: "block",
|
|
width: 16,
|
|
height: 16,
|
|
borderRadius: "50%",
|
|
background: checked ? "#10b981" : "var(--text-muted)",
|
|
transform: checked ? "translateX(17px)" : "translateX(0)",
|
|
transition: "transform 150ms ease",
|
|
}}
|
|
/>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function SettingRow({
|
|
title,
|
|
description,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
description: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
gap: 14,
|
|
padding: "12px 0",
|
|
borderBottom: "1px solid var(--border)",
|
|
}}
|
|
>
|
|
<div style={{ minWidth: 0 }}>
|
|
<strong
|
|
style={{ display: "block", color: "var(--text-primary)", fontSize: 13 }}
|
|
>
|
|
{title}
|
|
</strong>
|
|
<span
|
|
style={{
|
|
display: "block",
|
|
marginTop: 4,
|
|
color: "var(--text-muted)",
|
|
fontSize: 11,
|
|
lineHeight: 1.45,
|
|
}}
|
|
>
|
|
{description}
|
|
</span>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type LoginPreset = "key-only" | "key-password" | "temporary";
|
|
|
|
export default function ServerConfigSshAccessPanel({
|
|
server,
|
|
snapshot,
|
|
snapshotLoadError,
|
|
canManageSystemAccounts,
|
|
actionRunning,
|
|
onRequestApplyConfirm,
|
|
onRequestPasswordConfirm,
|
|
}: ServerConfigSshAccessPanelProps) {
|
|
const current = snapshot.sshAccess;
|
|
const initialPubkey = current.pubkeyAuthentication ?? true;
|
|
const initialPassword =
|
|
current.passwordAuthentication ?? server.authType === "PASSWORD";
|
|
const initialRoot =
|
|
current.permitRootLogin === "prohibit-password"
|
|
? "prohibit-password"
|
|
: server.username === "root" && server.authType === "SSH_KEY"
|
|
? "prohibit-password"
|
|
: "no";
|
|
const [publicKeyAuthentication, setPublicKeyAuthentication] =
|
|
useState(initialPubkey);
|
|
const [passwordAuthentication, setPasswordAuthentication] =
|
|
useState(initialPassword);
|
|
const [permitRootLogin, setPermitRootLogin] = useState<
|
|
"no" | "prohibit-password"
|
|
>(initialRoot);
|
|
const [selectedPreset, setSelectedPreset] = useState<LoginPreset>(() =>
|
|
current.temporaryRollbackScheduled
|
|
? "temporary"
|
|
: initialPassword
|
|
? "key-password"
|
|
: "key-only",
|
|
);
|
|
const [temporaryDuration, setTemporaryDuration] = useState<
|
|
15 | 30 | 60 | 240
|
|
>(15);
|
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
|
const [passwordUsername, setPasswordUsername] = useState(
|
|
() =>
|
|
snapshot.nonRootUsers.find((user) => !user.isSshUser)?.username ??
|
|
snapshot.nonRootUsers[0]?.username ??
|
|
"",
|
|
);
|
|
const [temporaryPassword, setTemporaryPassword] = useState("");
|
|
const [confirmTemporaryPassword, setConfirmTemporaryPassword] = useState("");
|
|
const [showTemporaryPassword, setShowTemporaryPassword] = useState(false);
|
|
const [forcePasswordChange, setForcePasswordChange] = useState(true);
|
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
|
|
|
const activeMethodLocked =
|
|
(server.authType === "PASSWORD" && !passwordAuthentication) ||
|
|
(server.authType === "SSH_KEY" && !publicKeyAuthentication);
|
|
const rootConnectionBlocked =
|
|
server.username === "root" &&
|
|
(permitRootLogin === "no" || server.authType === "PASSWORD");
|
|
const noAuthenticationMethod =
|
|
!publicKeyAuthentication && !passwordAuthentication;
|
|
const temporaryUnsupported = server.authType !== "SSH_KEY";
|
|
const temporaryPresetBlocked =
|
|
temporaryUnsupported ||
|
|
(current.passwordAuthentication === true &&
|
|
!current.temporaryRollbackScheduled);
|
|
const temporaryAlreadyEnabled =
|
|
selectedPreset === "temporary" && current.passwordAuthentication === true;
|
|
const temporaryMinutes =
|
|
selectedPreset === "temporary" && !temporaryAlreadyEnabled
|
|
? temporaryDuration
|
|
: null;
|
|
const hasChanges =
|
|
publicKeyAuthentication !== current.pubkeyAuthentication ||
|
|
passwordAuthentication !== current.passwordAuthentication ||
|
|
permitRootLogin !== current.permitRootLogin ||
|
|
temporaryMinutes != null ||
|
|
(current.temporaryRollbackScheduled && selectedPreset !== "temporary");
|
|
const formBlocked =
|
|
!current.available ||
|
|
!current.revision ||
|
|
activeMethodLocked ||
|
|
rootConnectionBlocked ||
|
|
noAuthenticationMethod ||
|
|
(selectedPreset === "temporary" && temporaryPresetBlocked);
|
|
const configPreview = useMemo(
|
|
() =>
|
|
[
|
|
`PubkeyAuthentication ${publicKeyAuthentication ? "yes" : "no"}`,
|
|
`PasswordAuthentication ${passwordAuthentication ? "yes" : "no"}`,
|
|
"KbdInteractiveAuthentication no",
|
|
`PermitRootLogin ${permitRootLogin}`,
|
|
"PermitEmptyPasswords no",
|
|
].join("\n"),
|
|
[passwordAuthentication, permitRootLogin, publicKeyAuthentication],
|
|
);
|
|
|
|
const selectPreset = (preset: LoginPreset) => {
|
|
if (preset === "temporary" && temporaryPresetBlocked) return;
|
|
setSelectedPreset(preset);
|
|
setPublicKeyAuthentication(true);
|
|
setPasswordAuthentication(preset !== "key-only");
|
|
};
|
|
|
|
const resetForm = () => {
|
|
setPublicKeyAuthentication(initialPubkey);
|
|
setPasswordAuthentication(initialPassword);
|
|
setPermitRootLogin(initialRoot);
|
|
setSelectedPreset(
|
|
current.temporaryRollbackScheduled
|
|
? "temporary"
|
|
: initialPassword
|
|
? "key-password"
|
|
: "key-only",
|
|
);
|
|
setTemporaryDuration(15);
|
|
};
|
|
|
|
const reviewTemporaryPassword = () => {
|
|
if (!passwordUsername) {
|
|
setPasswordError("Select a non-root user first.");
|
|
return;
|
|
}
|
|
if (temporaryPassword.length < 12 || temporaryPassword.length > 128) {
|
|
setPasswordError("Use a password between 12 and 128 characters.");
|
|
return;
|
|
}
|
|
if (
|
|
temporaryPassword.toLowerCase().includes(passwordUsername.toLowerCase())
|
|
) {
|
|
setPasswordError("The password must not contain the username.");
|
|
return;
|
|
}
|
|
if (temporaryPassword !== confirmTemporaryPassword) {
|
|
setPasswordError("Password confirmation does not match.");
|
|
return;
|
|
}
|
|
onRequestPasswordConfirm({
|
|
username: passwordUsername,
|
|
action: "set",
|
|
password: temporaryPassword,
|
|
requireChange: forcePasswordChange,
|
|
noticeTab: "ssh-access",
|
|
});
|
|
setTemporaryPassword("");
|
|
setConfirmTemporaryPassword("");
|
|
setShowTemporaryPassword(false);
|
|
setPasswordError(null);
|
|
};
|
|
|
|
return (
|
|
<div style={{ display: "grid", gap: 16 }}>
|
|
<div
|
|
className="card"
|
|
style={{
|
|
padding: 18,
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "flex-start",
|
|
gap: 14,
|
|
flexWrap: "wrap",
|
|
borderColor: "rgba(59,130,246,0.24)",
|
|
background:
|
|
"linear-gradient(135deg, rgba(59,130,246,0.08), rgba(16,185,129,0.04))",
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", alignItems: "flex-start", gap: 10 }}>
|
|
<ShieldCheck size={18} color="#3b82f6" style={{ marginTop: 1 }} />
|
|
<div>
|
|
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
|
|
SSH Access
|
|
</strong>
|
|
<p
|
|
style={{
|
|
maxWidth: 650,
|
|
marginTop: 6,
|
|
color: "var(--text-muted)",
|
|
fontSize: 13,
|
|
lineHeight: 1.55,
|
|
}}
|
|
>
|
|
Manage host-wide SSH authentication without changing the SSH
|
|
port. Every apply is validated, reloaded, reconnected, and audited.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<UserBadge
|
|
label={current.managed ? "Managed by Doktainer" : "Host policy"}
|
|
tone={current.available ? "success" : "warning"}
|
|
/>
|
|
</div>
|
|
|
|
{snapshotLoadError || !current.available ? (
|
|
<div
|
|
className="card"
|
|
style={{
|
|
padding: 12,
|
|
color: "#b45309",
|
|
background: "rgba(245,158,11,0.08)",
|
|
borderColor: "rgba(245,158,11,0.22)",
|
|
fontSize: 12,
|
|
lineHeight: 1.5,
|
|
}}
|
|
>
|
|
{current.error || snapshotLoadError ||
|
|
"The effective SSH policy is unavailable. No change can be applied."}
|
|
</div>
|
|
) : null}
|
|
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns:
|
|
"repeat(auto-fit, minmax(min(210px, 100%), 1fr))",
|
|
gap: 12,
|
|
}}
|
|
>
|
|
{[
|
|
{
|
|
label: "Connected user",
|
|
value: snapshot.currentUser ?? server.username,
|
|
detail:
|
|
server.authType === "SSH_KEY"
|
|
? "Doktainer uses an SSH key"
|
|
: "Doktainer uses a password",
|
|
},
|
|
{
|
|
label: "Connection port",
|
|
value: String(server.sshPort),
|
|
detail: "Port changes are excluded",
|
|
},
|
|
{
|
|
label: "Effective login",
|
|
value: current.passwordAuthentication ? "Key + password" : "Key only",
|
|
detail: "Read with sshd -T",
|
|
},
|
|
{
|
|
label: "Root login",
|
|
value:
|
|
current.permitRootLogin === "no"
|
|
? "Blocked"
|
|
: current.permitRootLogin === "prohibit-password"
|
|
? "Keys only"
|
|
: "Allowed",
|
|
detail: "Empty passwords are always blocked",
|
|
},
|
|
].map((item) => (
|
|
<div
|
|
key={item.label}
|
|
className="card"
|
|
style={{ padding: 14, display: "grid", gap: 5 }}
|
|
>
|
|
<span style={{ color: "var(--text-muted)", fontSize: 11 }}>
|
|
{item.label}
|
|
</span>
|
|
<strong style={{ color: "var(--text-primary)", fontSize: 13 }}>
|
|
{item.value}
|
|
</strong>
|
|
<span style={{ color: "var(--text-muted)", fontSize: 10 }}>
|
|
{item.detail}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{current.temporaryRollbackScheduled ? (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "flex-start",
|
|
gap: 9,
|
|
padding: 12,
|
|
borderRadius: 9,
|
|
color: "#b45309",
|
|
background: "rgba(245,158,11,0.08)",
|
|
border: "1px solid rgba(245,158,11,0.22)",
|
|
fontSize: 12,
|
|
lineHeight: 1.5,
|
|
}}
|
|
>
|
|
<AlertTriangle size={15} style={{ flex: "0 0 auto" }} />
|
|
A host-side timer is scheduled to restore the previous SSH policy.
|
|
Applying a permanent policy will cancel that timer.
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="card" style={{ padding: 18, display: "grid", gap: 14 }}>
|
|
<div>
|
|
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
|
|
How should users sign in?
|
|
</strong>
|
|
<p style={{ marginTop: 5, color: "var(--text-muted)", fontSize: 12 }}>
|
|
Choose a preset first. Advanced settings remain available below.
|
|
</p>
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns:
|
|
"repeat(auto-fit, minmax(min(210px, 100%), 1fr))",
|
|
gap: 10,
|
|
}}
|
|
>
|
|
{[
|
|
{
|
|
value: "key-only" as const,
|
|
title: "Key only",
|
|
badge: "Recommended",
|
|
description: "Production policy using authorized public keys.",
|
|
disabled: server.authType === "PASSWORD",
|
|
},
|
|
{
|
|
value: "key-password" as const,
|
|
title: "Key and password",
|
|
badge: "Easy setup",
|
|
description: "Allow either a public key or account password.",
|
|
disabled: false,
|
|
},
|
|
{
|
|
value: "temporary" as const,
|
|
title: "Temporary password",
|
|
badge: `${temporaryDuration} min`,
|
|
description: "Enable password login with host-side rollback.",
|
|
disabled: temporaryPresetBlocked,
|
|
},
|
|
].map((preset) => {
|
|
const selected = selectedPreset === preset.value;
|
|
return (
|
|
<button
|
|
key={preset.value}
|
|
type="button"
|
|
className="btn"
|
|
disabled={preset.disabled}
|
|
onClick={() => selectPreset(preset.value)}
|
|
title={
|
|
preset.disabled
|
|
? "Change Doktainer to an SSH key connection before selecting this policy"
|
|
: undefined
|
|
}
|
|
style={{
|
|
minHeight: 96,
|
|
height: "auto",
|
|
padding: 12,
|
|
display: "grid",
|
|
justifyItems: "start",
|
|
alignContent: "center",
|
|
gap: 6,
|
|
textAlign: "left",
|
|
opacity: preset.disabled ? 0.6 : 1,
|
|
borderColor: selected
|
|
? "rgba(59,130,246,0.5)"
|
|
: "var(--border)",
|
|
background: selected
|
|
? "rgba(59,130,246,0.1)"
|
|
: "var(--bg-input)",
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
width: "100%",
|
|
}}
|
|
>
|
|
<strong
|
|
style={{
|
|
color: selected ? "#3b82f6" : "var(--text-primary)",
|
|
fontSize: 13,
|
|
}}
|
|
>
|
|
{preset.title}
|
|
</strong>
|
|
<UserBadge
|
|
label={preset.badge}
|
|
tone={
|
|
preset.value === "key-only"
|
|
? "success"
|
|
: preset.value === "temporary"
|
|
? "warning"
|
|
: "info"
|
|
}
|
|
/>
|
|
</span>
|
|
<span
|
|
style={{ color: "var(--text-muted)", fontSize: 10, lineHeight: 1.45 }}
|
|
>
|
|
{preset.description}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
{selectedPreset === "temporary" && !temporaryAlreadyEnabled ? (
|
|
<label
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "minmax(0, 1fr) minmax(130px, 180px)",
|
|
alignItems: "center",
|
|
gap: 12,
|
|
padding: 10,
|
|
borderRadius: 8,
|
|
background: "rgba(245,158,11,0.07)",
|
|
border: "1px solid rgba(245,158,11,0.2)",
|
|
}}
|
|
>
|
|
<span style={{ color: "var(--text-secondary)", fontSize: 11 }}>
|
|
Restore the previous policy after
|
|
</span>
|
|
<select
|
|
className="input"
|
|
value={temporaryDuration}
|
|
onChange={(event) =>
|
|
setTemporaryDuration(
|
|
Number(event.target.value) as 15 | 30 | 60 | 240,
|
|
)
|
|
}
|
|
>
|
|
<option value={15}>15 minutes</option>
|
|
<option value={30}>30 minutes</option>
|
|
<option value={60}>1 hour</option>
|
|
<option value={240}>4 hours</option>
|
|
</select>
|
|
</label>
|
|
) : null}
|
|
</div>
|
|
|
|
{selectedPreset === "temporary" ? (
|
|
<div
|
|
className="card"
|
|
style={{
|
|
padding: 18,
|
|
display: "grid",
|
|
gap: 12,
|
|
borderColor: "rgba(245,158,11,0.28)",
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
<KeyRound size={15} color="#f59e0b" />
|
|
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
|
|
Optional account password
|
|
</strong>
|
|
</div>
|
|
<p style={{ color: "var(--text-muted)", fontSize: 11, lineHeight: 1.5 }}>
|
|
Enabling password authentication does not create an account password.
|
|
Set one here only when the selected user does not already have it.
|
|
</p>
|
|
{snapshot.nonRootUsers.length > 0 ? (
|
|
<>
|
|
<select
|
|
className="input"
|
|
value={passwordUsername}
|
|
onChange={(event) => {
|
|
setPasswordUsername(event.target.value);
|
|
setPasswordError(null);
|
|
}}
|
|
>
|
|
{snapshot.nonRootUsers.map((user) => (
|
|
<option key={user.username} value={user.username}>
|
|
{user.username}
|
|
{user.passwordStatus === "set" ? " (password configured)" : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<div style={{ position: "relative" }}>
|
|
<input
|
|
className="input"
|
|
type={showTemporaryPassword ? "text" : "password"}
|
|
value={temporaryPassword}
|
|
maxLength={128}
|
|
autoComplete="new-password"
|
|
onChange={(event) => {
|
|
setTemporaryPassword(event.target.value);
|
|
setPasswordError(null);
|
|
}}
|
|
placeholder="Password (minimum 12 characters)"
|
|
style={{ width: "100%", paddingRight: 40 }}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost btn-sm"
|
|
onClick={() => setShowTemporaryPassword((value) => !value)}
|
|
aria-label={showTemporaryPassword ? "Hide password" : "Show password"}
|
|
style={{
|
|
position: "absolute",
|
|
top: "50%",
|
|
right: 5,
|
|
width: 28,
|
|
minWidth: 28,
|
|
height: 28,
|
|
padding: 0,
|
|
transform: "translateY(-50%)",
|
|
}}
|
|
>
|
|
{showTemporaryPassword ? <EyeOff size={13} /> : <Eye size={13} />}
|
|
</button>
|
|
</div>
|
|
<input
|
|
className="input"
|
|
type={showTemporaryPassword ? "text" : "password"}
|
|
value={confirmTemporaryPassword}
|
|
maxLength={128}
|
|
autoComplete="new-password"
|
|
onChange={(event) => {
|
|
setConfirmTemporaryPassword(event.target.value);
|
|
setPasswordError(null);
|
|
}}
|
|
placeholder="Confirm password"
|
|
/>
|
|
<label
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
color: "var(--text-secondary)",
|
|
fontSize: 11,
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={forcePasswordChange}
|
|
onChange={(event) => setForcePasswordChange(event.target.checked)}
|
|
/>
|
|
Require password change at next login
|
|
</label>
|
|
{passwordError ? (
|
|
<p role="alert" style={{ color: "#ef4444", fontSize: 11 }}>
|
|
{passwordError}
|
|
</p>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm"
|
|
disabled={!temporaryPassword || !confirmTemporaryPassword}
|
|
onClick={reviewTemporaryPassword}
|
|
style={{ justifyContent: "center" }}
|
|
>
|
|
<KeyRound size={13} /> Review Password Change
|
|
</button>
|
|
</>
|
|
) : (
|
|
<p style={{ color: "var(--text-muted)", fontSize: 11 }}>
|
|
Create a non-root user from the Users tab first.
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
onClick={() => setAdvancedOpen((value) => !value)}
|
|
aria-expanded={advancedOpen}
|
|
style={{ width: "100%", justifyContent: "space-between", padding: "10px 14px" }}
|
|
>
|
|
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
<ServerCog size={14} /> Advanced SSH configuration
|
|
</span>
|
|
{advancedOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
|
</button>
|
|
|
|
{advancedOpen ? (
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns:
|
|
"repeat(auto-fit, minmax(min(320px, 100%), 1fr))",
|
|
gap: 16,
|
|
alignItems: "start",
|
|
}}
|
|
>
|
|
<div className="card" style={{ padding: 18, display: "grid", gap: 2 }}>
|
|
<SettingRow
|
|
title="Public key authentication"
|
|
description="Required while Doktainer connects with an SSH key."
|
|
>
|
|
<PolicySwitch
|
|
checked={publicKeyAuthentication}
|
|
disabled={server.authType === "SSH_KEY"}
|
|
label="Public key authentication"
|
|
onChange={setPublicKeyAuthentication}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
title="Password authentication"
|
|
description="Required while Doktainer connects with a password."
|
|
>
|
|
<PolicySwitch
|
|
checked={passwordAuthentication}
|
|
disabled={server.authType === "PASSWORD"}
|
|
label="Password authentication"
|
|
onChange={setPasswordAuthentication}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
title="Permit root login"
|
|
description="Direct root password login is never offered."
|
|
>
|
|
<select
|
|
className="input"
|
|
value={permitRootLogin}
|
|
onChange={(event) =>
|
|
setPermitRootLogin(
|
|
event.target.value as "no" | "prohibit-password",
|
|
)
|
|
}
|
|
style={{ width: 150, minWidth: 150, fontSize: 12 }}
|
|
>
|
|
<option value="no">No</option>
|
|
<option value="prohibit-password">Keys only</option>
|
|
</select>
|
|
</SettingRow>
|
|
<SettingRow
|
|
title="Permit empty passwords"
|
|
description="Hard-disabled and unavailable for editing."
|
|
>
|
|
<PolicySwitch
|
|
checked={false}
|
|
disabled
|
|
label="Permit empty passwords"
|
|
onChange={() => undefined}
|
|
/>
|
|
</SettingRow>
|
|
</div>
|
|
<div className="card" style={{ padding: 18, display: "grid", gap: 12 }}>
|
|
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
|
|
Managed drop-in
|
|
</strong>
|
|
<code
|
|
style={{
|
|
display: "block",
|
|
padding: 12,
|
|
borderRadius: 8,
|
|
color: "var(--text-primary)",
|
|
background: "var(--bg-input)",
|
|
border: "1px solid var(--border)",
|
|
fontSize: 11,
|
|
lineHeight: 1.7,
|
|
whiteSpace: "pre-wrap",
|
|
}}
|
|
>
|
|
{configPreview}
|
|
</code>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{(activeMethodLocked || rootConnectionBlocked || noAuthenticationMethod) ? (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "flex-start",
|
|
gap: 9,
|
|
padding: 12,
|
|
borderRadius: 9,
|
|
color: "#b45309",
|
|
background: "rgba(245,158,11,0.08)",
|
|
border: "1px solid rgba(245,158,11,0.22)",
|
|
fontSize: 12,
|
|
lineHeight: 1.5,
|
|
}}
|
|
>
|
|
<AlertTriangle size={15} style={{ flex: "0 0 auto" }} />
|
|
{rootConnectionBlocked
|
|
? "The saved Doktainer connection still uses root. Move it to a non-root account before disabling root login."
|
|
: activeMethodLocked
|
|
? "The authentication method used by Doktainer must remain enabled."
|
|
: "At least one authentication method must remain enabled."}
|
|
</div>
|
|
) : null}
|
|
|
|
<div
|
|
className="card"
|
|
style={{
|
|
padding: 18,
|
|
display: "grid",
|
|
gridTemplateColumns:
|
|
"repeat(auto-fit, minmax(min(280px, 100%), 1fr))",
|
|
gap: 16,
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<div style={{ display: "grid", gap: 8 }}>
|
|
{[
|
|
"Write /etc/ssh/sshd_config.d/00-doktainer.conf atomically",
|
|
"Validate combined configuration with sshd -t and sshd -T",
|
|
"Reload SSH without restarting the daemon",
|
|
"Verify a fresh connection and rollback on failure",
|
|
].map((step) => (
|
|
<span
|
|
key={step}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 7,
|
|
color: "var(--text-secondary)",
|
|
fontSize: 11,
|
|
}}
|
|
>
|
|
<CheckCircle2 size={13} color="#10b981" /> {step}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<div style={{ display: "grid", gap: 9 }}>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
disabled={
|
|
formBlocked ||
|
|
!hasChanges ||
|
|
actionRunning ||
|
|
!canManageSystemAccounts
|
|
}
|
|
onClick={() => {
|
|
if (!current.revision) return;
|
|
onRequestApplyConfirm({
|
|
expectedRevision: current.revision,
|
|
pubkeyAuthentication: publicKeyAuthentication,
|
|
passwordAuthentication,
|
|
permitRootLogin,
|
|
permitEmptyPasswords: false,
|
|
temporaryMinutes,
|
|
});
|
|
}}
|
|
style={{ justifyContent: "center" }}
|
|
>
|
|
{actionRunning ? (
|
|
<Loader2 size={14} className="animate-spin" />
|
|
) : (
|
|
<ShieldCheck size={14} />
|
|
)}
|
|
Review & Apply SSH Policy
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
disabled={actionRunning}
|
|
onClick={resetForm}
|
|
style={{ justifyContent: "center" }}
|
|
>
|
|
<RotateCcw size={14} /> Reset to Effective Policy
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="card"
|
|
style={{
|
|
padding: 14,
|
|
display: "flex",
|
|
alignItems: "flex-start",
|
|
gap: 9,
|
|
color: "var(--text-secondary)",
|
|
fontSize: 11,
|
|
lineHeight: 1.55,
|
|
}}
|
|
>
|
|
<KeyRound size={15} color="#3b82f6" style={{ flex: "0 0 auto" }} />
|
|
Public keys remain managed per account from Users → Edit → Login Access.
|
|
This tab controls the host-wide authentication policy only.
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|