feat: SSH key management on dev pod page — list, add, remove keys
Some checks failed
Build & Release / build (push) Has been cancelled

This commit is contained in:
Ryan Moon
2026-04-05 09:36:56 -05:00
parent daa6035f0e
commit 3f79939bd3
2 changed files with 95 additions and 1 deletions

View File

@@ -1067,8 +1067,19 @@
<span class="stat-value" style="font-family:monospace;font-size:0.8rem">dev</span>
</div>
</div>
<div class="stat-card stat-card-full" id="devpod-keys-card">
<div class="card-title">SSH Keys</div>
<div id="devpod-keys-list" style="color:#8b949e;font-size:0.845rem">Loading…</div>
<div style="display:flex;gap:0.5rem;margin-top:1rem">
<input id="devpod-key-input" type="text" placeholder="ssh-ed25519 AAAA… user@host"
style="flex:1;background:#161b22;border:1px solid #30363d;border-radius:6px;padding:0.4rem 0.6rem;color:#e6edf3;font-family:monospace;font-size:0.78rem" />
<button class="btn btn-primary btn-sm" onclick="addSshKey()">Add Key</button>
</div>
</div>
`;
loadSshKeys();
// Auto-refresh while starting
clearInterval(devpodRefreshTimer);
if (state === 'starting') {
@@ -1095,6 +1106,60 @@
}
}
async function loadSshKeys() {
const el = document.getElementById('devpod-keys-list');
if (!el) return;
try {
const res = await apiFetch('/api/devpod/keys');
const { keys } = await res.json();
renderSshKeys(keys);
} catch {
if (el) el.innerHTML = '<span style="color:#f85149">Failed to load keys</span>';
}
}
function renderSshKeys(keys) {
const el = document.getElementById('devpod-keys-list');
if (!el) return;
if (!keys.length) { el.innerHTML = '<span style="color:#484f58">No SSH keys configured</span>'; return; }
el.innerHTML = keys.map(k => {
const parts = k.split(' ');
const label = parts.slice(2).join(' ') || parts[0];
const short = parts[1] ? parts[1].slice(0, 20) + '…' : k;
return `<div style="display:flex;justify-content:space-between;align-items:center;padding:0.35rem 0;border-bottom:1px solid #21262d">
<span style="font-family:monospace;font-size:0.78rem;color:#e6edf3">${label} <span style="color:#484f58">${short}</span></span>
<button class="btn btn-danger btn-sm" onclick="removeSshKey(${JSON.stringify(k)})">Remove</button>
</div>`;
}).join('');
}
async function addSshKey() {
const input = document.getElementById('devpod-key-input');
const key = input.value.trim();
if (!key) return;
try {
const res = await apiFetch('/api/devpod/keys', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ key }) });
if (!res.ok) throw new Error((await res.json()).message);
const { keys } = await res.json();
renderSshKeys(keys);
input.value = '';
} catch (err) {
alert('Failed to add key: ' + err.message);
}
}
async function removeSshKey(key) {
if (!confirm('Remove this SSH key?')) return;
try {
const res = await apiFetch('/api/devpod/keys', { method: 'DELETE', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ key }) });
if (!res.ok) throw new Error((await res.json()).message);
const { keys } = await res.json();
renderSshKeys(keys);
} catch (err) {
alert('Failed to remove key: ' + err.message);
}
}
// ── Account ───────────────────────────────────────────────────────────────
async function changePassword() {

View File

@@ -1,8 +1,11 @@
import type { FastifyInstance } from "fastify";
import { k8sFetch, rolloutRestart } from "../lib/k8s";
import { z } from "zod";
import { k8sFetch, rolloutRestart, getSecret, patchSecret } from "../lib/k8s";
const NAMESPACE = "dev";
const DEPLOYMENT = "dev";
const SECRET = "dev-secrets";
const KEY_FIELD = "ssh-authorized-keys";
export async function devpodRoutes(app: FastifyInstance) {
app.get("/devpod/status", async (_req, reply) => {
@@ -63,4 +66,30 @@ export async function devpodRoutes(app: FastifyInstance) {
await rolloutRestart(NAMESPACE, DEPLOYMENT);
return reply.send({ message: "Dev pod restarting" });
});
app.get("/devpod/keys", async (_req, reply) => {
const secrets = await getSecret(NAMESPACE, SECRET);
const raw = secrets[KEY_FIELD] ?? "";
const keys = raw.split("\n").map(k => k.trim()).filter(Boolean);
return reply.send({ keys });
});
app.post("/devpod/keys", async (req, reply) => {
const { key } = z.object({ key: z.string().min(10) }).parse(req.body);
const secrets = await getSecret(NAMESPACE, SECRET);
const existing = (secrets[KEY_FIELD] ?? "").split("\n").map(k => k.trim()).filter(Boolean);
if (existing.includes(key.trim())) return reply.send({ keys: existing });
const updated = [...existing, key.trim()].join("\n");
await patchSecret(NAMESPACE, SECRET, { [KEY_FIELD]: updated });
return reply.send({ keys: [...existing, key.trim()] });
});
app.delete("/devpod/keys", async (req, reply) => {
const { key } = z.object({ key: z.string().min(10) }).parse(req.body);
const secrets = await getSecret(NAMESPACE, SECRET);
const existing = (secrets[KEY_FIELD] ?? "").split("\n").map(k => k.trim()).filter(Boolean);
const updated = existing.filter(k => k !== key.trim());
await patchSecret(NAMESPACE, SECRET, { [KEY_FIELD]: updated.join("\n") });
return reply.send({ keys: updated });
});
}