feat: ✨ one time credentials for hosts
This commit is contained in:
@@ -111,11 +111,14 @@ def init_db():
|
||||
label VARCHAR(255) NOT NULL,
|
||||
hostname VARCHAR(512) NOT NULL,
|
||||
port INT NOT NULL DEFAULT 22,
|
||||
identity_id INT NOT NULL,
|
||||
identity_id INT,
|
||||
inline_identity_auth_type ENUM('password','publickey') NULL,
|
||||
inline_identity_encrypted_blob TEXT NULL,
|
||||
inline_identity_encrypted_key_passphrase TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_host_identity FOREIGN KEY (identity_id)
|
||||
REFERENCES ssh_identities(id) ON DELETE RESTRICT,
|
||||
REFERENCES ssh_identities(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_host_folder FOREIGN KEY (folder_id)
|
||||
REFERENCES ssh_folders(id) ON DELETE SET NULL
|
||||
);
|
||||
@@ -139,6 +142,7 @@ def init_db():
|
||||
if s:
|
||||
cur.execute(s)
|
||||
_ensure_jump_host_schema(cur)
|
||||
_ensure_inline_identity_schema(cur)
|
||||
|
||||
|
||||
def _ensure_jump_host_schema(cur) -> None:
|
||||
@@ -164,6 +168,57 @@ def _ensure_jump_host_schema(cur) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _ensure_inline_identity_schema(cur) -> None:
|
||||
"""Migrate existing databases to support inline (one-time) credentials."""
|
||||
# Check and add inline_identity_auth_type column
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ssh_hosts'
|
||||
AND COLUMN_NAME = 'inline_identity_auth_type'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
if cur.fetchone() is None:
|
||||
cur.execute(
|
||||
"ALTER TABLE ssh_hosts ADD COLUMN inline_identity_auth_type ENUM('password','publickey') NULL"
|
||||
)
|
||||
|
||||
# Check and add inline_identity_encrypted_blob column
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ssh_hosts'
|
||||
AND COLUMN_NAME = 'inline_identity_encrypted_blob'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
if cur.fetchone() is None:
|
||||
cur.execute(
|
||||
"ALTER TABLE ssh_hosts ADD COLUMN inline_identity_encrypted_blob TEXT NULL"
|
||||
)
|
||||
|
||||
# Check and add inline_identity_encrypted_key_passphrase column
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ssh_hosts'
|
||||
AND COLUMN_NAME = 'inline_identity_encrypted_key_passphrase'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
if cur.fetchone() is None:
|
||||
cur.execute(
|
||||
"ALTER TABLE ssh_hosts ADD COLUMN inline_identity_encrypted_key_passphrase TEXT NULL"
|
||||
)
|
||||
|
||||
|
||||
def _like_escape(s: str) -> str:
|
||||
return (
|
||||
s.replace("\\", "\\\\")
|
||||
@@ -370,7 +425,21 @@ def _registry_count() -> int:
|
||||
|
||||
|
||||
def _connect_paramiko(host_row: dict, sock=None) -> tuple[paramiko.SSHClient, paramiko.Channel]:
|
||||
payload = decrypt_secret(host_row["encrypted_blob"])
|
||||
# Prefer inline identity if available, otherwise use saved identity
|
||||
if host_row.get("inline_identity_encrypted_blob"):
|
||||
# Use inline credentials
|
||||
auth_type = host_row["inline_identity_auth_type"]
|
||||
encrypted_blob = host_row["inline_identity_encrypted_blob"]
|
||||
encrypted_key_passphrase = host_row.get("inline_identity_encrypted_key_passphrase")
|
||||
else:
|
||||
# Use saved identity
|
||||
if not host_row.get("encrypted_blob"):
|
||||
raise ValueError("host has no identity configured")
|
||||
auth_type = host_row["auth_type"]
|
||||
encrypted_blob = host_row["encrypted_blob"]
|
||||
encrypted_key_passphrase = host_row.get("encrypted_key_passphrase")
|
||||
|
||||
payload = decrypt_secret(encrypted_blob)
|
||||
data = json.loads(payload)
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
@@ -380,7 +449,7 @@ def _connect_paramiko(host_row: dict, sock=None) -> tuple[paramiko.SSHClient, pa
|
||||
if not username_ssh:
|
||||
raise ValueError("identity payload missing ssh_username")
|
||||
|
||||
if host_row["auth_type"] == "password":
|
||||
if auth_type == "password":
|
||||
pwd = data.get("password")
|
||||
if not pwd:
|
||||
raise ValueError("missing password in identity")
|
||||
@@ -400,8 +469,8 @@ def _connect_paramiko(host_row: dict, sock=None) -> tuple[paramiko.SSHClient, pa
|
||||
raise ValueError("missing private_key in identity")
|
||||
pkey = None
|
||||
key_pass = None
|
||||
if host_row.get("encrypted_key_passphrase"):
|
||||
key_pass = decrypt_secret(host_row["encrypted_key_passphrase"]) or None
|
||||
if encrypted_key_passphrase:
|
||||
key_pass = decrypt_secret(encrypted_key_passphrase) or None
|
||||
last_err: Exception | None = None
|
||||
key_classes: list[type] = [
|
||||
paramiko.RSAKey,
|
||||
@@ -446,9 +515,10 @@ def _load_host_connect_row(cur, host_id: int) -> dict[str, Any] | None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT h.id, h.label, h.hostname, h.port, h.identity_id, h.jump_host_id,
|
||||
h.inline_identity_auth_type, h.inline_identity_encrypted_blob, h.inline_identity_encrypted_key_passphrase,
|
||||
i.auth_type, i.encrypted_blob, i.encrypted_key_passphrase
|
||||
FROM ssh_hosts h
|
||||
JOIN ssh_identities i ON i.id = h.identity_id
|
||||
LEFT JOIN ssh_identities i ON i.id = h.identity_id
|
||||
WHERE h.id = %s
|
||||
""",
|
||||
(host_id,),
|
||||
@@ -720,11 +790,12 @@ def _host_select_sql(extra_where: str = "") -> str:
|
||||
return f"""
|
||||
SELECT h.id, h.folder_id, h.label, h.hostname, h.port, h.identity_id, h.jump_host_id,
|
||||
h.created_at, h.updated_at,
|
||||
i.label AS identity_label, i.auth_type AS identity_auth_type,
|
||||
COALESCE(i.label, 'One-time') AS identity_label,
|
||||
COALESCE(i.auth_type, h.inline_identity_auth_type) AS identity_auth_type,
|
||||
pf.label AS folder_label,
|
||||
jh.label AS jump_host_label
|
||||
FROM ssh_hosts h
|
||||
JOIN ssh_identities i ON i.id = h.identity_id
|
||||
LEFT JOIN ssh_identities i ON i.id = h.identity_id
|
||||
LEFT JOIN ssh_folders pf ON pf.id = h.folder_id
|
||||
LEFT JOIN ssh_hosts jh ON jh.id = h.jump_host_id
|
||||
{extra_where}
|
||||
@@ -917,11 +988,47 @@ def create_host():
|
||||
label = (body.get("label") or "").strip()
|
||||
hostname = (body.get("hostname") or "").strip()
|
||||
port = int(body.get("port") or 22)
|
||||
use_inline = body.get("use_inline_identity", False)
|
||||
identity_id = body.get("identity_id")
|
||||
jump_host_raw = body.get("jump_host_id")
|
||||
jump_host_id = int(jump_host_raw) if jump_host_raw is not None and jump_host_raw != "" else None
|
||||
if not label or not hostname or not identity_id:
|
||||
return jsonify({"error": "label, hostname, identity_id required"}), 400
|
||||
|
||||
if not label or not hostname:
|
||||
return jsonify({"error": "label, hostname required"}), 400
|
||||
|
||||
# Validate identity or inline credentials
|
||||
inline_auth_type = None
|
||||
inline_blob = None
|
||||
inline_key_pass = None
|
||||
|
||||
if use_inline:
|
||||
# Use inline credentials
|
||||
auth_type = body.get("auth_type")
|
||||
ssh_username = (body.get("ssh_username") or "").strip()
|
||||
if not auth_type or auth_type not in ("password", "publickey") or not ssh_username:
|
||||
return jsonify({"error": "auth_type and ssh_username required for inline identity"}), 400
|
||||
|
||||
if auth_type == "password":
|
||||
password = body.get("password")
|
||||
if not password:
|
||||
return jsonify({"error": "password required"}), 400
|
||||
payload = json.dumps({"ssh_username": ssh_username, "password": password})
|
||||
else:
|
||||
private_key = body.get("private_key")
|
||||
if not private_key or not isinstance(private_key, str):
|
||||
return jsonify({"error": "private_key required"}), 400
|
||||
key_pass_plain = body.get("key_passphrase")
|
||||
payload = json.dumps({"ssh_username": ssh_username, "private_key": private_key})
|
||||
inline_key_pass = encrypt_secret(key_pass_plain) if key_pass_plain else None
|
||||
|
||||
inline_auth_type = auth_type
|
||||
inline_blob = encrypt_secret(payload)
|
||||
identity_id = None
|
||||
else:
|
||||
# Use saved identity
|
||||
if not identity_id:
|
||||
return jsonify({"error": "identity_id required when not using inline identity"}), 400
|
||||
|
||||
fid = body.get("folder_id")
|
||||
folder_id = int(fid) if fid is not None and fid != "" else None
|
||||
if folder_id is not None:
|
||||
@@ -929,17 +1036,24 @@ def create_host():
|
||||
cur.execute("SELECT id FROM ssh_folders WHERE id = %s", (folder_id,))
|
||||
if not cur.fetchone():
|
||||
return jsonify({"error": "folder not found"}), 400
|
||||
|
||||
with db_cursor() as (_, cur):
|
||||
if jump_host_id is not None:
|
||||
cur.execute("SELECT id FROM ssh_hosts WHERE id = %s", (jump_host_id,))
|
||||
if not cur.fetchone():
|
||||
return jsonify({"error": "jump host not found"}), 400
|
||||
|
||||
if identity_id:
|
||||
identity_id = int(identity_id)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO ssh_hosts (folder_id, label, hostname, port, identity_id, jump_host_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
INSERT INTO ssh_hosts (folder_id, label, hostname, port, identity_id, jump_host_id,
|
||||
inline_identity_auth_type, inline_identity_encrypted_blob, inline_identity_encrypted_key_passphrase)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(folder_id, label, hostname, port, int(identity_id), jump_host_id),
|
||||
(folder_id, label, hostname, port, identity_id, jump_host_id,
|
||||
inline_auth_type, inline_blob, inline_key_pass),
|
||||
)
|
||||
hid = cur.lastrowid
|
||||
return jsonify({"id": hid}), 201
|
||||
@@ -951,6 +1065,53 @@ def update_host(hid: int):
|
||||
body = request.get_json(silent=True) or {}
|
||||
fields = []
|
||||
args: list[Any] = []
|
||||
|
||||
# Handle inline identity switching
|
||||
if "use_inline_identity" in body:
|
||||
use_inline = body.get("use_inline_identity", False)
|
||||
if use_inline:
|
||||
# Switch to inline credentials
|
||||
auth_type = body.get("auth_type")
|
||||
ssh_username = (body.get("ssh_username") or "").strip()
|
||||
if not auth_type or auth_type not in ("password", "publickey") or not ssh_username:
|
||||
return jsonify({"error": "auth_type and ssh_username required for inline identity"}), 400
|
||||
|
||||
if auth_type == "password":
|
||||
password = body.get("password")
|
||||
if not password:
|
||||
return jsonify({"error": "password required"}), 400
|
||||
payload = json.dumps({"ssh_username": ssh_username, "password": password})
|
||||
else:
|
||||
private_key = body.get("private_key")
|
||||
if not private_key or not isinstance(private_key, str):
|
||||
return jsonify({"error": "private_key required"}), 400
|
||||
key_pass_plain = body.get("key_passphrase")
|
||||
payload = json.dumps({"ssh_username": ssh_username, "private_key": private_key})
|
||||
inline_key_pass = encrypt_secret(key_pass_plain) if key_pass_plain else None
|
||||
|
||||
# Clear identity_id and set inline fields
|
||||
fields.append("identity_id = %s")
|
||||
args.append(None)
|
||||
fields.append("inline_identity_auth_type = %s")
|
||||
args.append(auth_type)
|
||||
fields.append("inline_identity_encrypted_blob = %s")
|
||||
args.append(encrypt_secret(payload))
|
||||
fields.append("inline_identity_encrypted_key_passphrase = %s")
|
||||
if auth_type == "password":
|
||||
args.append(None)
|
||||
else:
|
||||
args.append(inline_key_pass)
|
||||
else:
|
||||
# Switch to saved identity - clear inline fields
|
||||
fields.append("identity_id = %s")
|
||||
args.append(body.get("identity_id"))
|
||||
fields.append("inline_identity_auth_type = %s")
|
||||
args.append(None)
|
||||
fields.append("inline_identity_encrypted_blob = %s")
|
||||
args.append(None)
|
||||
fields.append("inline_identity_encrypted_key_passphrase = %s")
|
||||
args.append(None)
|
||||
|
||||
if "label" in body:
|
||||
fields.append("label = %s")
|
||||
args.append(str(body["label"]).strip())
|
||||
@@ -960,7 +1121,7 @@ def update_host(hid: int):
|
||||
if "port" in body:
|
||||
fields.append("port = %s")
|
||||
args.append(int(body["port"]))
|
||||
if "identity_id" in body:
|
||||
if "identity_id" in body and "use_inline_identity" not in body:
|
||||
fields.append("identity_id = %s")
|
||||
args.append(int(body["identity_id"]))
|
||||
if "jump_host_id" in body:
|
||||
|
||||
+203
-10
@@ -69,7 +69,13 @@ const hostForm = ref({
|
||||
label: "",
|
||||
hostname: "",
|
||||
port: 22,
|
||||
use_inline_identity: false,
|
||||
identity_id: 0 as number,
|
||||
auth_type: "password" as "password" | "publickey",
|
||||
ssh_username: "",
|
||||
password: "",
|
||||
private_key: "",
|
||||
key_passphrase: "",
|
||||
jump_host_id: null as number | null,
|
||||
});
|
||||
const editHostForm = ref({
|
||||
@@ -77,7 +83,13 @@ const editHostForm = ref({
|
||||
label: "",
|
||||
hostname: "",
|
||||
port: 22,
|
||||
use_inline_identity: false,
|
||||
identity_id: 0,
|
||||
auth_type: "password" as "password" | "publickey",
|
||||
ssh_username: "",
|
||||
password: "",
|
||||
private_key: "",
|
||||
key_passphrase: "",
|
||||
folder_id: null as number | null,
|
||||
jump_host_id: null as number | null,
|
||||
});
|
||||
@@ -283,20 +295,43 @@ async function submitEditIdentity() {
|
||||
|
||||
async function submitHost() {
|
||||
const f = hostForm.value;
|
||||
await api.createHost({
|
||||
const body: Record<string, unknown> = {
|
||||
label: f.label.trim(),
|
||||
hostname: f.hostname.trim(),
|
||||
port: Number(f.port) || 22,
|
||||
identity_id: f.identity_id,
|
||||
folder_id: currentFolderId.value,
|
||||
jump_host_id: f.jump_host_id,
|
||||
});
|
||||
};
|
||||
|
||||
if (f.use_inline_identity) {
|
||||
body.use_inline_identity = true;
|
||||
body.auth_type = f.auth_type;
|
||||
body.ssh_username = f.ssh_username.trim();
|
||||
if (f.auth_type === "password") {
|
||||
body.password = f.password;
|
||||
} else {
|
||||
body.private_key = f.private_key;
|
||||
if (f.key_passphrase) {
|
||||
body.key_passphrase = f.key_passphrase;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
body.identity_id = f.identity_id;
|
||||
}
|
||||
|
||||
await api.createHost(body);
|
||||
showHostForm.value = false;
|
||||
hostForm.value = {
|
||||
label: "",
|
||||
hostname: "",
|
||||
port: 22,
|
||||
use_inline_identity: false,
|
||||
identity_id: hostForm.value.identity_id,
|
||||
auth_type: "password",
|
||||
ssh_username: "",
|
||||
password: "",
|
||||
private_key: "",
|
||||
key_passphrase: "",
|
||||
jump_host_id: null,
|
||||
};
|
||||
await refreshData();
|
||||
@@ -312,12 +347,19 @@ async function submitFolder() {
|
||||
}
|
||||
|
||||
function openEditHost(h: HostRow) {
|
||||
const hasInlineIdentity = h.identity_id === null;
|
||||
editHostForm.value = {
|
||||
id: h.id,
|
||||
label: h.label,
|
||||
hostname: h.hostname,
|
||||
port: h.port,
|
||||
identity_id: h.identity_id,
|
||||
use_inline_identity: hasInlineIdentity,
|
||||
identity_id: h.identity_id || 0,
|
||||
auth_type: (h.identity_auth_type as "password" | "publickey") || "password",
|
||||
ssh_username: "",
|
||||
password: "",
|
||||
private_key: "",
|
||||
key_passphrase: "",
|
||||
folder_id: h.folder_id,
|
||||
jump_host_id: h.jump_host_id,
|
||||
};
|
||||
@@ -326,14 +368,31 @@ function openEditHost(h: HostRow) {
|
||||
|
||||
async function submitEditHost() {
|
||||
const f = editHostForm.value;
|
||||
await api.patchHost(f.id, {
|
||||
const body: Record<string, unknown> = {
|
||||
label: f.label.trim(),
|
||||
hostname: f.hostname.trim(),
|
||||
port: Number(f.port) || 22,
|
||||
identity_id: f.identity_id,
|
||||
folder_id: f.folder_id,
|
||||
jump_host_id: f.jump_host_id,
|
||||
});
|
||||
};
|
||||
|
||||
if (f.use_inline_identity) {
|
||||
body.use_inline_identity = true;
|
||||
body.auth_type = f.auth_type;
|
||||
body.ssh_username = f.ssh_username.trim();
|
||||
if (f.auth_type === "password") {
|
||||
body.password = f.password;
|
||||
} else {
|
||||
body.private_key = f.private_key;
|
||||
if (f.key_passphrase) {
|
||||
body.key_passphrase = f.key_passphrase;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
body.identity_id = f.identity_id;
|
||||
}
|
||||
|
||||
await api.patchHost(f.id, body);
|
||||
showEditHost.value = false;
|
||||
allHosts.value = await api.listHosts();
|
||||
await refreshBrowse();
|
||||
@@ -927,7 +986,29 @@ async function deleteIdentityRow(id: number) {
|
||||
max="65535"
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||
/>
|
||||
<label class="mt-3 block text-xs uppercase text-slate-500">Identity</label>
|
||||
<label class="mt-3 block text-xs uppercase text-slate-500">Credentials</label>
|
||||
<div class="mt-1 flex gap-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
:checked="!hostForm.use_inline_identity"
|
||||
@change="hostForm.use_inline_identity = false"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<span class="text-sm">Saved identity</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
:checked="hostForm.use_inline_identity"
|
||||
@change="hostForm.use_inline_identity = true"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<span class="text-sm">One-time</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="!hostForm.use_inline_identity" class="mt-3">
|
||||
<label class="block text-xs uppercase text-slate-500">Identity</label>
|
||||
<select
|
||||
v-model.number="hostForm.identity_id"
|
||||
required
|
||||
@@ -937,6 +1018,52 @@ async function deleteIdentityRow(id: number) {
|
||||
{{ i.label }} ({{ i.auth_type }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-else class="mt-3 space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs uppercase text-slate-500">Auth type</label>
|
||||
<select
|
||||
v-model="hostForm.auth_type"
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="password">Password</option>
|
||||
<option value="publickey">Public key</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs uppercase text-slate-500">SSH username</label>
|
||||
<input
|
||||
v-model="hostForm.ssh_username"
|
||||
required
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="hostForm.auth_type === 'password'">
|
||||
<label class="block text-xs uppercase text-slate-500">Password</label>
|
||||
<input
|
||||
v-model="hostForm.password"
|
||||
type="password"
|
||||
required
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<label class="block text-xs uppercase text-slate-500">Private key</label>
|
||||
<textarea
|
||||
v-model="hostForm.private_key"
|
||||
required
|
||||
rows="4"
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm font-mono text-xs"
|
||||
placeholder="-----BEGIN PRIVATE KEY-----"
|
||||
/>
|
||||
<label class="mt-2 block text-xs uppercase text-slate-500">Key passphrase (optional)</label>
|
||||
<input
|
||||
v-model="hostForm.key_passphrase"
|
||||
type="password"
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label class="mt-3 block text-xs uppercase text-slate-500">Jump host (optional)</label>
|
||||
<select
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||
@@ -964,7 +1091,7 @@ async function deleteIdentityRow(id: number) {
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-slate-950"
|
||||
:disabled="!identities.length"
|
||||
:disabled="!hostForm.use_inline_identity && !identities.length"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
@@ -1057,7 +1184,29 @@ async function deleteIdentityRow(id: number) {
|
||||
{{ folderOptionLabel(f.id) }}
|
||||
</option>
|
||||
</select>
|
||||
<label class="mt-3 block text-xs uppercase text-slate-500">Identity</label>
|
||||
<label class="mt-3 block text-xs uppercase text-slate-500">Credentials</label>
|
||||
<div class="mt-1 flex gap-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
:checked="!editHostForm.use_inline_identity"
|
||||
@change="editHostForm.use_inline_identity = false"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<span class="text-sm">Saved identity</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
:checked="editHostForm.use_inline_identity"
|
||||
@change="editHostForm.use_inline_identity = true"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<span class="text-sm">One-time</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="!editHostForm.use_inline_identity" class="mt-3">
|
||||
<label class="block text-xs uppercase text-slate-500">Identity</label>
|
||||
<select
|
||||
v-model.number="editHostForm.identity_id"
|
||||
required
|
||||
@@ -1067,6 +1216,50 @@ async function deleteIdentityRow(id: number) {
|
||||
{{ i.label }} ({{ i.auth_type }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-else class="mt-3 space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs uppercase text-slate-500">Auth type</label>
|
||||
<select
|
||||
v-model="editHostForm.auth_type"
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="password">Password</option>
|
||||
<option value="publickey">Public key</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs uppercase text-slate-500">SSH username</label>
|
||||
<input
|
||||
v-model="editHostForm.ssh_username"
|
||||
required
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="editHostForm.auth_type === 'password'">
|
||||
<label class="block text-xs uppercase text-slate-500">Password</label>
|
||||
<input
|
||||
v-model="editHostForm.password"
|
||||
type="password"
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<label class="block text-xs uppercase text-slate-500">Private key</label>
|
||||
<textarea
|
||||
v-model="editHostForm.private_key"
|
||||
rows="4"
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm font-mono text-xs"
|
||||
placeholder="-----BEGIN PRIVATE KEY-----"
|
||||
/>
|
||||
<label class="mt-2 block text-xs uppercase text-slate-500">Key passphrase (optional)</label>
|
||||
<input
|
||||
v-model="editHostForm.key_passphrase"
|
||||
type="password"
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label class="mt-3 block text-xs uppercase text-slate-500">Jump host (optional)</label>
|
||||
<select
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||
|
||||
+2
-16
@@ -98,14 +98,7 @@ export const api = {
|
||||
return d.items;
|
||||
},
|
||||
|
||||
async createHost(body: {
|
||||
label: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
identity_id: number;
|
||||
folder_id?: number | null;
|
||||
jump_host_id?: number | null;
|
||||
}): Promise<{ id: number }> {
|
||||
async createHost(body: Record<string, unknown>): Promise<{ id: number }> {
|
||||
const res = await fetch("/api/hosts", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
@@ -117,14 +110,7 @@ export const api = {
|
||||
|
||||
async patchHost(
|
||||
id: number,
|
||||
body: Partial<{
|
||||
label: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
identity_id: number;
|
||||
folder_id: number | null;
|
||||
jump_host_id: number | null;
|
||||
}>,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`/api/hosts/${id}`, {
|
||||
method: "PATCH",
|
||||
|
||||
Reference in New Issue
Block a user