fix: SSH password prompt on auth failure, version from Go backend, visible errors
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m5s
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m5s
- ConnectSSH returns NO_CREDENTIALS error when no credential is stored - Frontend catches auth failures and prompts for username/password - ConnectSSHWithPassword method for ad-hoc password auth - Version loaded from Go backend (build-time -ldflags) in settings + unlock screen - Connection errors shown as alert() instead of silent console.error - Added UpdateService.CurrentVersion() and WraithApp.GetVersion() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b46c20b0d0
commit
163af456b4
@ -335,6 +335,9 @@ onMounted(async () => {
|
|||||||
if (theme) settings.value.terminalTheme = theme;
|
if (theme) settings.value.terminalTheme = theme;
|
||||||
if (fontSize) settings.value.fontSize = Number(fontSize);
|
if (fontSize) settings.value.fontSize = Number(fontSize);
|
||||||
if (scrollback) settings.value.scrollbackBuffer = Number(scrollback);
|
if (scrollback) settings.value.scrollbackBuffer = Number(scrollback);
|
||||||
|
// Load version from Go backend
|
||||||
|
const ver = await Call.ByName(`${APP}.GetVersion`) as string;
|
||||||
|
if (ver) currentVersion.value = ver;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load settings:", err);
|
console.error("Failed to load settings:", err);
|
||||||
}
|
}
|
||||||
@ -360,7 +363,8 @@ watch(() => settings.value.scrollbackBuffer, (val) => {
|
|||||||
// --- Update check state ---
|
// --- Update check state ---
|
||||||
type UpdateCheckState = "idle" | "checking" | "found" | "up-to-date" | "error";
|
type UpdateCheckState = "idle" | "checking" | "found" | "up-to-date" | "error";
|
||||||
const updateCheckState = ref<UpdateCheckState>("idle");
|
const updateCheckState = ref<UpdateCheckState>("idle");
|
||||||
const currentVersion = ref("0.1.0-dev");
|
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
|
||||||
|
const currentVersion = ref("loading...");
|
||||||
const latestVersion = ref("");
|
const latestVersion = ref("");
|
||||||
|
|
||||||
function open(): void {
|
function open(): void {
|
||||||
|
|||||||
@ -68,7 +68,7 @@
|
|||||||
|
|
||||||
<!-- Version -->
|
<!-- Version -->
|
||||||
<p class="text-center text-xs text-[var(--wraith-text-muted)] mt-4">
|
<p class="text-center text-xs text-[var(--wraith-text-muted)] mt-4">
|
||||||
v1.0.0-alpha
|
v{{ appVersion }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -76,17 +76,24 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
|
import { Call } from "@wailsio/runtime";
|
||||||
import { useAppStore } from "@/stores/app.store";
|
import { useAppStore } from "@/stores/app.store";
|
||||||
|
|
||||||
|
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
|
|
||||||
const password = ref("");
|
const password = ref("");
|
||||||
const confirmPassword = ref("");
|
const confirmPassword = ref("");
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const passwordInput = ref<HTMLInputElement | null>(null);
|
const passwordInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const appVersion = ref("...");
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
passwordInput.value?.focus();
|
passwordInput.value?.focus();
|
||||||
|
try {
|
||||||
|
const ver = await Call.ByName(`${APP}.GetVersion`) as string;
|
||||||
|
if (ver) appVersion.value = ver;
|
||||||
|
} catch { /* ignore */ }
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleSubmit(): Promise<void> {
|
async function handleSubmit(): Promise<void> {
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
const sessions = ref<Session[]>([]);
|
const sessions = ref<Session[]>([]);
|
||||||
const activeSessionId = ref<string | null>(null);
|
const activeSessionId = ref<string | null>(null);
|
||||||
const connecting = ref(false);
|
const connecting = ref(false);
|
||||||
|
const lastError = ref<string | null>(null);
|
||||||
|
|
||||||
const activeSession = computed(() =>
|
const activeSession = computed(() =>
|
||||||
sessions.value.find((s) => s.id === activeSessionId.value) ?? null,
|
sessions.value.find((s) => s.id === activeSessionId.value) ?? null,
|
||||||
@ -72,13 +73,38 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
connecting.value = true;
|
connecting.value = true;
|
||||||
try {
|
try {
|
||||||
if (conn.protocol === "ssh") {
|
if (conn.protocol === "ssh") {
|
||||||
// Call Go backend — resolves credentials, builds auth, returns sessionID
|
let sessionId: string;
|
||||||
const sessionId = await Call.ByName(
|
|
||||||
|
try {
|
||||||
|
// Try with stored credentials first
|
||||||
|
sessionId = await Call.ByName(
|
||||||
`${APP}.ConnectSSH`,
|
`${APP}.ConnectSSH`,
|
||||||
connectionId,
|
connectionId,
|
||||||
120, // cols (will be resized by xterm.js fit addon)
|
120, // cols (will be resized by xterm.js fit addon)
|
||||||
40, // rows
|
40, // rows
|
||||||
) as string;
|
) as string;
|
||||||
|
} catch (sshErr: any) {
|
||||||
|
const errMsg = typeof sshErr === "string" ? sshErr : sshErr?.message ?? String(sshErr);
|
||||||
|
|
||||||
|
// If no credentials, prompt for username/password
|
||||||
|
if (errMsg.includes("NO_CREDENTIALS") || errMsg.includes("unable to authenticate")) {
|
||||||
|
const username = prompt(`Username for ${conn.hostname}:`, "root");
|
||||||
|
if (!username) throw new Error("Connection cancelled");
|
||||||
|
const password = prompt(`Password for ${username}@${conn.hostname}:`);
|
||||||
|
if (password === null) throw new Error("Connection cancelled");
|
||||||
|
|
||||||
|
sessionId = await Call.ByName(
|
||||||
|
`${APP}.ConnectSSHWithPassword`,
|
||||||
|
connectionId,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
120,
|
||||||
|
40,
|
||||||
|
) as string;
|
||||||
|
} else {
|
||||||
|
throw sshErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sessions.value.push({
|
sessions.value.push({
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
@ -106,9 +132,12 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
});
|
});
|
||||||
activeSessionId.value = sessionId;
|
activeSessionId.value = sessionId;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.error("Connection failed:", err);
|
const msg = typeof err === "string" ? err : err?.message ?? String(err);
|
||||||
// TODO: Show error toast in UI
|
console.error("Connection failed:", msg);
|
||||||
|
lastError.value = msg;
|
||||||
|
// Show error as native alert so it's visible without DevTools
|
||||||
|
alert(`Connection failed: ${msg}`);
|
||||||
} finally {
|
} finally {
|
||||||
connecting.value = false;
|
connecting.value = false;
|
||||||
}
|
}
|
||||||
@ -120,6 +149,7 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
activeSession,
|
activeSession,
|
||||||
sessionCount,
|
sessionCount,
|
||||||
connecting,
|
connecting,
|
||||||
|
lastError,
|
||||||
activateSession,
|
activateSession,
|
||||||
closeSession,
|
closeSession,
|
||||||
connect,
|
connect,
|
||||||
|
|||||||
@ -301,15 +301,9 @@ func (a *WraithApp) ConnectSSH(connectionID int64, cols, rows int) (string, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: keyboard-interactive (prompts for password)
|
// If no stored credentials, return an error telling the frontend to prompt
|
||||||
if len(authMethods) == 0 {
|
if len(authMethods) == 0 {
|
||||||
slog.Info("no stored credentials, SSH will attempt keyboard-interactive auth")
|
return "", fmt.Errorf("NO_CREDENTIALS: no credentials configured for this connection — assign a credential or use ConnectSSHWithPassword")
|
||||||
authMethods = append(authMethods, gossh.KeyboardInteractive(
|
|
||||||
func(name, instruction string, questions []string, echos []bool) ([]string, error) {
|
|
||||||
// No interactive prompt available yet — return empty
|
|
||||||
return make([]string, len(questions)), nil
|
|
||||||
},
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionID, err := a.SSH.Connect(conn.Hostname, conn.Port, username, authMethods, cols, rows)
|
sessionID, err := a.SSH.Connect(conn.Hostname, conn.Port, username, authMethods, cols, rows)
|
||||||
@ -337,6 +331,53 @@ func (a *WraithApp) ConnectSSH(connectionID int64, cols, rows int) (string, erro
|
|||||||
return sessionID, nil
|
return sessionID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConnectSSHWithPassword opens an SSH session using an ad-hoc username/password.
|
||||||
|
// Used when no stored credential exists — the frontend prompts and passes creds directly.
|
||||||
|
func (a *WraithApp) ConnectSSHWithPassword(connectionID int64, username, password string, cols, rows int) (string, error) {
|
||||||
|
conn, err := a.Connections.GetConnection(connectionID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("connection not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authMethods := []gossh.AuthMethod{
|
||||||
|
gossh.Password(password),
|
||||||
|
gossh.KeyboardInteractive(func(name, instruction string, questions []string, echos []bool) ([]string, error) {
|
||||||
|
answers := make([]string, len(questions))
|
||||||
|
for i := range answers {
|
||||||
|
answers[i] = password
|
||||||
|
}
|
||||||
|
return answers, nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID, err := a.SSH.Connect(conn.Hostname, conn.Port, username, authMethods, cols, rows)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("SSH connect failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register SFTP client
|
||||||
|
if sess, ok := a.SSH.GetSession(sessionID); ok && sess != nil {
|
||||||
|
sftpClient, err := sftplib.NewClient(sess.Client)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("failed to create SFTP client", "error", err)
|
||||||
|
} else {
|
||||||
|
a.SFTP.RegisterClient(sessionID, sftpClient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := a.db.Exec("UPDATE connections SET last_connected = CURRENT_TIMESTAMP WHERE id = ?", connectionID); err != nil {
|
||||||
|
slog.Warn("failed to update last_connected", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("SSH session started (ad-hoc password)", "sessionID", sessionID, "host", conn.Hostname, "user", username)
|
||||||
|
return sessionID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVersion returns the build-time version string.
|
||||||
|
func (a *WraithApp) GetVersion() string {
|
||||||
|
return a.Updater.CurrentVersion()
|
||||||
|
}
|
||||||
|
|
||||||
// DisconnectSession closes an active SSH session and its SFTP client.
|
// DisconnectSession closes an active SSH session and its SFTP client.
|
||||||
func (a *WraithApp) DisconnectSession(sessionID string) error {
|
func (a *WraithApp) DisconnectSession(sessionID string) error {
|
||||||
a.SFTP.RemoveClient(sessionID)
|
a.SFTP.RemoveClient(sessionID)
|
||||||
|
|||||||
@ -36,6 +36,11 @@ type UpdateService struct {
|
|||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CurrentVersion returns the build-time version string.
|
||||||
|
func (u *UpdateService) CurrentVersion() string {
|
||||||
|
return u.currentVersion
|
||||||
|
}
|
||||||
|
|
||||||
// NewUpdateService creates an UpdateService that compares against the
|
// NewUpdateService creates an UpdateService that compares against the
|
||||||
// supplied currentVersion (semver without "v" prefix, e.g. "0.2.0").
|
// supplied currentVersion (semver without "v" prefix, e.g. "0.2.0").
|
||||||
func NewUpdateService(currentVersion string) *UpdateService {
|
func NewUpdateService(currentVersion string) *UpdateService {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user