feat: one time credentials for hosts

This commit is contained in:
2026-05-14 11:53:41 +00:00
parent ca8b5dea7f
commit 20db4742a7
3 changed files with 399 additions and 59 deletions
+176 -15
View File
@@ -111,11 +111,14 @@ def init_db():
label VARCHAR(255) NOT NULL, label VARCHAR(255) NOT NULL,
hostname VARCHAR(512) NOT NULL, hostname VARCHAR(512) NOT NULL,
port INT NOT NULL DEFAULT 22, 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, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_host_identity FOREIGN KEY (identity_id) 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) CONSTRAINT fk_host_folder FOREIGN KEY (folder_id)
REFERENCES ssh_folders(id) ON DELETE SET NULL REFERENCES ssh_folders(id) ON DELETE SET NULL
); );
@@ -139,6 +142,7 @@ def init_db():
if s: if s:
cur.execute(s) cur.execute(s)
_ensure_jump_host_schema(cur) _ensure_jump_host_schema(cur)
_ensure_inline_identity_schema(cur)
def _ensure_jump_host_schema(cur) -> None: 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: def _like_escape(s: str) -> str:
return ( return (
s.replace("\\", "\\\\") s.replace("\\", "\\\\")
@@ -370,7 +425,21 @@ def _registry_count() -> int:
def _connect_paramiko(host_row: dict, sock=None) -> tuple[paramiko.SSHClient, paramiko.Channel]: 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) data = json.loads(payload)
client = paramiko.SSHClient() client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 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: if not username_ssh:
raise ValueError("identity payload missing ssh_username") raise ValueError("identity payload missing ssh_username")
if host_row["auth_type"] == "password": if auth_type == "password":
pwd = data.get("password") pwd = data.get("password")
if not pwd: if not pwd:
raise ValueError("missing password in identity") 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") raise ValueError("missing private_key in identity")
pkey = None pkey = None
key_pass = None key_pass = None
if host_row.get("encrypted_key_passphrase"): if encrypted_key_passphrase:
key_pass = decrypt_secret(host_row["encrypted_key_passphrase"]) or None key_pass = decrypt_secret(encrypted_key_passphrase) or None
last_err: Exception | None = None last_err: Exception | None = None
key_classes: list[type] = [ key_classes: list[type] = [
paramiko.RSAKey, paramiko.RSAKey,
@@ -446,9 +515,10 @@ def _load_host_connect_row(cur, host_id: int) -> dict[str, Any] | None:
cur.execute( cur.execute(
""" """
SELECT h.id, h.label, h.hostname, h.port, h.identity_id, h.jump_host_id, 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 i.auth_type, i.encrypted_blob, i.encrypted_key_passphrase
FROM ssh_hosts h 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 WHERE h.id = %s
""", """,
(host_id,), (host_id,),
@@ -720,11 +790,12 @@ def _host_select_sql(extra_where: str = "") -> str:
return f""" return f"""
SELECT h.id, h.folder_id, h.label, h.hostname, h.port, h.identity_id, h.jump_host_id, 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, 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, pf.label AS folder_label,
jh.label AS jump_host_label jh.label AS jump_host_label
FROM ssh_hosts h 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_folders pf ON pf.id = h.folder_id
LEFT JOIN ssh_hosts jh ON jh.id = h.jump_host_id LEFT JOIN ssh_hosts jh ON jh.id = h.jump_host_id
{extra_where} {extra_where}
@@ -917,11 +988,47 @@ def create_host():
label = (body.get("label") or "").strip() label = (body.get("label") or "").strip()
hostname = (body.get("hostname") or "").strip() hostname = (body.get("hostname") or "").strip()
port = int(body.get("port") or 22) port = int(body.get("port") or 22)
use_inline = body.get("use_inline_identity", False)
identity_id = body.get("identity_id") identity_id = body.get("identity_id")
jump_host_raw = body.get("jump_host_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 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") fid = body.get("folder_id")
folder_id = int(fid) if fid is not None and fid != "" else None folder_id = int(fid) if fid is not None and fid != "" else None
if folder_id is not 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,)) cur.execute("SELECT id FROM ssh_folders WHERE id = %s", (folder_id,))
if not cur.fetchone(): if not cur.fetchone():
return jsonify({"error": "folder not found"}), 400 return jsonify({"error": "folder not found"}), 400
with db_cursor() as (_, cur): with db_cursor() as (_, cur):
if jump_host_id is not None: if jump_host_id is not None:
cur.execute("SELECT id FROM ssh_hosts WHERE id = %s", (jump_host_id,)) cur.execute("SELECT id FROM ssh_hosts WHERE id = %s", (jump_host_id,))
if not cur.fetchone(): if not cur.fetchone():
return jsonify({"error": "jump host not found"}), 400 return jsonify({"error": "jump host not found"}), 400
if identity_id:
identity_id = int(identity_id)
cur.execute( cur.execute(
""" """
INSERT INTO ssh_hosts (folder_id, label, hostname, port, identity_id, jump_host_id) INSERT INTO ssh_hosts (folder_id, label, hostname, port, identity_id, jump_host_id,
VALUES (%s, %s, %s, %s, %s, %s) 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 hid = cur.lastrowid
return jsonify({"id": hid}), 201 return jsonify({"id": hid}), 201
@@ -951,6 +1065,53 @@ def update_host(hid: int):
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
fields = [] fields = []
args: list[Any] = [] 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: if "label" in body:
fields.append("label = %s") fields.append("label = %s")
args.append(str(body["label"]).strip()) args.append(str(body["label"]).strip())
@@ -960,7 +1121,7 @@ def update_host(hid: int):
if "port" in body: if "port" in body:
fields.append("port = %s") fields.append("port = %s")
args.append(int(body["port"])) 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") fields.append("identity_id = %s")
args.append(int(body["identity_id"])) args.append(int(body["identity_id"]))
if "jump_host_id" in body: if "jump_host_id" in body:
+221 -28
View File
@@ -69,7 +69,13 @@ const hostForm = ref({
label: "", label: "",
hostname: "", hostname: "",
port: 22, port: 22,
use_inline_identity: false,
identity_id: 0 as number, 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, jump_host_id: null as number | null,
}); });
const editHostForm = ref({ const editHostForm = ref({
@@ -77,7 +83,13 @@ const editHostForm = ref({
label: "", label: "",
hostname: "", hostname: "",
port: 22, port: 22,
use_inline_identity: false,
identity_id: 0, identity_id: 0,
auth_type: "password" as "password" | "publickey",
ssh_username: "",
password: "",
private_key: "",
key_passphrase: "",
folder_id: null as number | null, folder_id: null as number | null,
jump_host_id: null as number | null, jump_host_id: null as number | null,
}); });
@@ -283,20 +295,43 @@ async function submitEditIdentity() {
async function submitHost() { async function submitHost() {
const f = hostForm.value; const f = hostForm.value;
await api.createHost({ const body: Record<string, unknown> = {
label: f.label.trim(), label: f.label.trim(),
hostname: f.hostname.trim(), hostname: f.hostname.trim(),
port: Number(f.port) || 22, port: Number(f.port) || 22,
identity_id: f.identity_id,
folder_id: currentFolderId.value, folder_id: currentFolderId.value,
jump_host_id: f.jump_host_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.createHost(body);
showHostForm.value = false; showHostForm.value = false;
hostForm.value = { hostForm.value = {
label: "", label: "",
hostname: "", hostname: "",
port: 22, port: 22,
use_inline_identity: false,
identity_id: hostForm.value.identity_id, identity_id: hostForm.value.identity_id,
auth_type: "password",
ssh_username: "",
password: "",
private_key: "",
key_passphrase: "",
jump_host_id: null, jump_host_id: null,
}; };
await refreshData(); await refreshData();
@@ -312,12 +347,19 @@ async function submitFolder() {
} }
function openEditHost(h: HostRow) { function openEditHost(h: HostRow) {
const hasInlineIdentity = h.identity_id === null;
editHostForm.value = { editHostForm.value = {
id: h.id, id: h.id,
label: h.label, label: h.label,
hostname: h.hostname, hostname: h.hostname,
port: h.port, 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, folder_id: h.folder_id,
jump_host_id: h.jump_host_id, jump_host_id: h.jump_host_id,
}; };
@@ -326,14 +368,31 @@ function openEditHost(h: HostRow) {
async function submitEditHost() { async function submitEditHost() {
const f = editHostForm.value; const f = editHostForm.value;
await api.patchHost(f.id, { const body: Record<string, unknown> = {
label: f.label.trim(), label: f.label.trim(),
hostname: f.hostname.trim(), hostname: f.hostname.trim(),
port: Number(f.port) || 22, port: Number(f.port) || 22,
identity_id: f.identity_id,
folder_id: f.folder_id, folder_id: f.folder_id,
jump_host_id: f.jump_host_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; showEditHost.value = false;
allHosts.value = await api.listHosts(); allHosts.value = await api.listHosts();
await refreshBrowse(); await refreshBrowse();
@@ -927,16 +986,84 @@ async function deleteIdentityRow(id: number) {
max="65535" max="65535"
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm" 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>
<select <div class="mt-1 flex gap-2">
v-model.number="hostForm.identity_id" <label class="flex items-center gap-2 cursor-pointer">
required <input
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm" type="radio"
> :checked="!hostForm.use_inline_identity"
<option v-for="i in identities" :key="i.id" :value="i.id"> @change="hostForm.use_inline_identity = false"
{{ i.label }} ({{ i.auth_type }}) class="w-4 h-4"
</option> />
</select> <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
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
>
<option v-for="i in identities" :key="i.id" :value="i.id">
{{ 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> <label class="mt-3 block text-xs uppercase text-slate-500">Jump host (optional)</label>
<select <select
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm" 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 <button
type="submit" type="submit"
class="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-slate-950" 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 Save
</button> </button>
@@ -1057,16 +1184,82 @@ async function deleteIdentityRow(id: number) {
{{ folderOptionLabel(f.id) }} {{ folderOptionLabel(f.id) }}
</option> </option>
</select> </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>
<select <div class="mt-1 flex gap-2">
v-model.number="editHostForm.identity_id" <label class="flex items-center gap-2 cursor-pointer">
required <input
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm" type="radio"
> :checked="!editHostForm.use_inline_identity"
<option v-for="i in identities" :key="i.id" :value="i.id"> @change="editHostForm.use_inline_identity = false"
{{ i.label }} ({{ i.auth_type }}) class="w-4 h-4"
</option> />
</select> <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
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
>
<option v-for="i in identities" :key="i.id" :value="i.id">
{{ 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> <label class="mt-3 block text-xs uppercase text-slate-500">Jump host (optional)</label>
<select <select
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm" class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
+2 -16
View File
@@ -98,14 +98,7 @@ export const api = {
return d.items; return d.items;
}, },
async createHost(body: { async createHost(body: Record<string, unknown>): Promise<{ id: number }> {
label: string;
hostname: string;
port?: number;
identity_id: number;
folder_id?: number | null;
jump_host_id?: number | null;
}): Promise<{ id: number }> {
const res = await fetch("/api/hosts", { const res = await fetch("/api/hosts", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
@@ -117,14 +110,7 @@ export const api = {
async patchHost( async patchHost(
id: number, id: number,
body: Partial<{ body: Record<string, unknown>,
label: string;
hostname: string;
port: number;
identity_id: number;
folder_id: number | null;
jump_host_id: number | null;
}>,
): Promise<void> { ): Promise<void> {
const res = await fetch(`/api/hosts/${id}`, { const res = await fetch(`/api/hosts/${id}`, {
method: "PATCH", method: "PATCH",