feat(auth): add credential mode (credentials/token) support
Introduces a credential mode for stored credentials and wire it to per-remote URL resolution and operation flows. The UI, storage, and remote interactions now track and persist the mode, enabling token based auth alongside username/password credentials. - Remote URL resolution now considers direction (pull/push) and mode - Credential dialog, saving, and keychain handling updated to pass and respect the mode - Unique askpass scripts generated per invocation to avoid clashes
This commit is contained in:
Notes:
Christoph Brandau
2026-08-13 22:48:24 +02:00
git note test
+134
-9
@@ -2432,6 +2432,8 @@ const CRED_SERVICE: &str = "tauri_git_lite";
|
|||||||
pub struct StoredCredential {
|
pub struct StoredCredential {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub mode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
|
fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
|
||||||
@@ -2445,17 +2447,27 @@ fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
|
|||||||
/// Returns the remote URL used for auth key derivation (upstream remote of the
|
/// Returns the remote URL used for auth key derivation (upstream remote of the
|
||||||
/// current branch, falling back to `origin`, then the first configured remote).
|
/// current branch, falling back to `origin`, then the first configured remote).
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
pub fn get_remote_url(path: String) -> Result<Option<String>, String> {
|
pub fn get_remote_url(
|
||||||
|
path: String,
|
||||||
|
remote: Option<String>,
|
||||||
|
push: Option<bool>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
let remote = upstream_remote_name(&repo).unwrap_or_else(|| "origin".to_string());
|
let remote = match remote
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
Some(remote) => validate_remote_name(&repo, &remote, true)?,
|
||||||
|
None => upstream_remote_name(&repo).unwrap_or_else(|| "origin".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(url) = remote_url_for(&repo, &remote) {
|
if let Some(url) = remote_url_for_auth(&repo, &remote, push.unwrap_or(false)) {
|
||||||
return Ok(Some(url));
|
return Ok(Some(url));
|
||||||
}
|
}
|
||||||
// origin missing → try the first configured remote
|
// origin missing → try the first configured remote
|
||||||
if let Some(first) = first_remote_name(&repo) {
|
if let Some(first) = first_remote_name(&repo) {
|
||||||
if first != remote {
|
if first != remote {
|
||||||
if let Some(url) = remote_url_for(&repo, &first) {
|
if let Some(url) = remote_url_for_auth(&repo, &first, push.unwrap_or(false)) {
|
||||||
return Ok(Some(url));
|
return Ok(Some(url));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2463,6 +2475,20 @@ pub fn get_remote_url(path: String) -> Result<Option<String>, String> {
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn remote_url_for_auth(repo: &Path, remote: &str, push: bool) -> Option<String> {
|
||||||
|
let mut command = git_command();
|
||||||
|
command.arg("-C").arg(repo).args(["remote", "get-url"]);
|
||||||
|
if push {
|
||||||
|
command.arg("--push");
|
||||||
|
}
|
||||||
|
let out = command.arg(remote).output().ok()?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
if url.is_empty() { None } else { Some(url) }
|
||||||
|
}
|
||||||
|
|
||||||
fn remote_url_for(repo: &Path, remote: &str) -> Option<String> {
|
fn remote_url_for(repo: &Path, remote: &str) -> Option<String> {
|
||||||
let out = git_command()
|
let out = git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
@@ -2614,9 +2640,22 @@ pub fn cred_load(key: String) -> Result<Option<StoredCredential>, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
pub fn cred_save(key: String, username: String, password: String) -> Result<(), String> {
|
pub fn cred_save(
|
||||||
|
key: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
mode: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
let entry = cred_entry(&key)?;
|
let entry = cred_entry(&key)?;
|
||||||
let cred = StoredCredential { username, password };
|
let mode = match mode.as_deref() {
|
||||||
|
Some("token") => Some("token".to_string()),
|
||||||
|
_ => Some("credentials".to_string()),
|
||||||
|
};
|
||||||
|
let cred = StoredCredential {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
mode,
|
||||||
|
};
|
||||||
let json = serde_json::to_string(&cred)
|
let json = serde_json::to_string(&cred)
|
||||||
.map_err(|err| format!("Could not serialize credentials: {err}"))?;
|
.map_err(|err| format!("Could not serialize credentials: {err}"))?;
|
||||||
entry
|
entry
|
||||||
@@ -4688,6 +4727,13 @@ fn run_git_clone(
|
|||||||
password: Option<&str>,
|
password: Option<&str>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut command = git_command();
|
let mut command = git_command();
|
||||||
|
let has_explicit_credentials = matches!(
|
||||||
|
(username, password),
|
||||||
|
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty()
|
||||||
|
);
|
||||||
|
if has_explicit_credentials {
|
||||||
|
command.arg("-c").arg("credential.helper=");
|
||||||
|
}
|
||||||
command
|
command
|
||||||
.arg("clone")
|
.arg("clone")
|
||||||
.arg("--")
|
.arg("--")
|
||||||
@@ -5649,10 +5695,20 @@ fn run_apply_patch_command(
|
|||||||
run_git(repo, args).map(|_| ())
|
run_git(repo, args).map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static ASKPASS_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
|
fn next_askpass_path(extension: &str) -> std::path::PathBuf {
|
||||||
|
let sequence = ASKPASS_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
std::env::temp_dir().join(format!(
|
||||||
|
"gitty-askpass-{}-{sequence}.{extension}",
|
||||||
|
std::process::id()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn write_askpass_script() -> Result<std::path::PathBuf, String> {
|
fn write_askpass_script() -> Result<std::path::PathBuf, String> {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let path = std::env::temp_dir().join("gitlite_askpass.sh");
|
let path = next_askpass_path("sh");
|
||||||
let script = "#!/bin/sh\ncase \"$1\" in\n *[Uu]sername*) printf '%s\\n' \"$GIT_CRED_USER\" ;;\n *) printf '%s\\n' \"$GIT_CRED_PASS\" ;;\nesac\n";
|
let script = "#!/bin/sh\ncase \"$1\" in\n *[Uu]sername*) printf '%s\\n' \"$GIT_CRED_USER\" ;;\n *) printf '%s\\n' \"$GIT_CRED_PASS\" ;;\nesac\n";
|
||||||
std::fs::write(&path, script)
|
std::fs::write(&path, script)
|
||||||
.map_err(|e| format!("Could not write authentication script: {e}"))?;
|
.map_err(|e| format!("Could not write authentication script: {e}"))?;
|
||||||
@@ -5663,8 +5719,17 @@ fn write_askpass_script() -> Result<std::path::PathBuf, String> {
|
|||||||
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
fn write_askpass_script() -> Result<std::path::PathBuf, String> {
|
fn write_askpass_script() -> Result<std::path::PathBuf, String> {
|
||||||
let path = std::env::temp_dir().join("gitlite_askpass.bat");
|
let path = next_askpass_path("bat");
|
||||||
let script = "@echo off\necho %1 | findstr /I \"sername\" >nul 2>&1\nif %errorlevel% == 0 (echo %GIT_CRED_USER%) else (echo %GIT_CRED_PASS%)\n";
|
// Reading the value from PowerShell avoids cmd.exe interpreting special
|
||||||
|
// characters such as &, |, ^ or % from passwords and access tokens.
|
||||||
|
let script = r#"@echo off
|
||||||
|
echo %1 | findstr /I "sername" >nul 2>&1
|
||||||
|
if %errorlevel% == 0 (
|
||||||
|
powershell.exe -NoProfile -NonInteractive -Command "[Console]::Out.WriteLine($env:GIT_CRED_USER)"
|
||||||
|
) else (
|
||||||
|
powershell.exe -NoProfile -NonInteractive -Command "[Console]::Out.WriteLine($env:GIT_CRED_PASS)"
|
||||||
|
)
|
||||||
|
"#;
|
||||||
std::fs::write(&path, script)
|
std::fs::write(&path, script)
|
||||||
.map_err(|e| format!("Could not write authentication script: {e}"))?;
|
.map_err(|e| format!("Could not write authentication script: {e}"))?;
|
||||||
Ok(path)
|
Ok(path)
|
||||||
@@ -5706,6 +5771,8 @@ where
|
|||||||
let askpass = write_askpass_script()?;
|
let askpass = write_askpass_script()?;
|
||||||
|
|
||||||
let result = git_command()
|
let result = git_command()
|
||||||
|
.arg("-c")
|
||||||
|
.arg("credential.helper=")
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(repo)
|
.arg(repo)
|
||||||
.args(args)
|
.args(args)
|
||||||
@@ -7146,6 +7213,64 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn credential_payload_remains_backward_compatible() {
|
||||||
|
let legacy: StoredCredential =
|
||||||
|
serde_json::from_str(r#"{"username":"alice","password":"secret"}"#)
|
||||||
|
.expect("legacy credential should deserialize");
|
||||||
|
assert_eq!(legacy.username, "alice");
|
||||||
|
assert_eq!(legacy.password, "secret");
|
||||||
|
assert_eq!(legacy.mode, None);
|
||||||
|
|
||||||
|
let token = StoredCredential {
|
||||||
|
username: "alice".to_string(),
|
||||||
|
password: "token".to_string(),
|
||||||
|
mode: Some("token".to_string()),
|
||||||
|
};
|
||||||
|
let encoded = serde_json::to_string(&token).expect("credential should serialize");
|
||||||
|
assert!(encoded.contains(r#""mode":"token""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_remote_url_uses_the_requested_direction() {
|
||||||
|
let repo = init_temp_repo("auth_remote_url_direction");
|
||||||
|
run_git_test(
|
||||||
|
&repo.path,
|
||||||
|
[
|
||||||
|
"remote",
|
||||||
|
"add",
|
||||||
|
"origin",
|
||||||
|
"https://gitea.example/fetch/repo.git",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
run_git_test(
|
||||||
|
&repo.path,
|
||||||
|
[
|
||||||
|
"remote",
|
||||||
|
"set-url",
|
||||||
|
"--push",
|
||||||
|
"origin",
|
||||||
|
"https://gitea.example/push/repo.git",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
remote_url_for_auth(&repo.path, "origin", false).as_deref(),
|
||||||
|
Some("https://gitea.example/fetch/repo.git")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
remote_url_for_auth(&repo.path, "origin", true).as_deref(),
|
||||||
|
Some("https://gitea.example/push/repo.git")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn askpass_scripts_use_unique_paths() {
|
||||||
|
let first = next_askpass_path("test");
|
||||||
|
let second = next_askpass_path("test");
|
||||||
|
assert_ne!(first, second);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[cfg_attr(
|
#[cfg_attr(
|
||||||
windows,
|
windows,
|
||||||
|
|||||||
+101
-39
@@ -111,7 +111,6 @@
|
|||||||
launchExternalTool,
|
launchExternalTool,
|
||||||
credLoad,
|
credLoad,
|
||||||
credSave,
|
credSave,
|
||||||
credDelete,
|
|
||||||
getFilePatch,
|
getFilePatch,
|
||||||
readConflict,
|
readConflict,
|
||||||
resolveConflict,
|
resolveConflict,
|
||||||
@@ -190,6 +189,7 @@
|
|||||||
type UpdateToastState = "available" | "downloading" | "installed" | "error";
|
type UpdateToastState = "available" | "downloading" | "installed" | "error";
|
||||||
type AppView = "management" | "repository";
|
type AppView = "management" | "repository";
|
||||||
type CredentialAction = "push" | "pull" | "fetch" | "clone";
|
type CredentialAction = "push" | "pull" | "fetch" | "clone";
|
||||||
|
type CredentialMode = "credentials" | "token";
|
||||||
type PendingDiscard =
|
type PendingDiscard =
|
||||||
| { kind: "file"; files: GitFileStatus[]; staged: boolean }
|
| { kind: "file"; files: GitFileStatus[]; staged: boolean }
|
||||||
| { kind: "all-changes"; files: GitFileStatus[] }
|
| { kind: "all-changes"; files: GitFileStatus[] }
|
||||||
@@ -412,6 +412,9 @@
|
|||||||
let credDialogAction: CredentialAction | null = null;
|
let credDialogAction: CredentialAction | null = null;
|
||||||
let credDialogError = "";
|
let credDialogError = "";
|
||||||
let credDialogKey: string | null = null;
|
let credDialogKey: string | null = null;
|
||||||
|
let credDialogUsername = "";
|
||||||
|
let credDialogMode: CredentialMode = "credentials";
|
||||||
|
const rejectedCredentialKeys = new Set<string>();
|
||||||
let lastStatusFingerprint = "";
|
let lastStatusFingerprint = "";
|
||||||
const AUTO_REFRESH_INTERVAL = 4000;
|
const AUTO_REFRESH_INTERVAL = 4000;
|
||||||
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
|
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
@@ -2207,6 +2210,7 @@
|
|||||||
password?: string,
|
password?: string,
|
||||||
key?: string | null,
|
key?: string | null,
|
||||||
fromStore = false,
|
fromStore = false,
|
||||||
|
credentialMode: CredentialMode = "credentials",
|
||||||
) {
|
) {
|
||||||
if (isBusy) return;
|
if (isBusy) return;
|
||||||
if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; }
|
if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; }
|
||||||
@@ -2219,7 +2223,16 @@
|
|||||||
if (!username && !password) {
|
if (!username && !password) {
|
||||||
const stored = await loadStoredCredential(credentialKey);
|
const stored = await loadStoredCredential(credentialKey);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true);
|
const storedMode = credentialModeFor(stored);
|
||||||
|
if (credentialKey && rejectedCredentialKeys.has(credentialKey)) {
|
||||||
|
credDialogUsername = stored.username === "oauth2" ? "" : stored.username;
|
||||||
|
credDialogMode = storedMode;
|
||||||
|
credDialogAction = "clone";
|
||||||
|
credDialogKey = credentialKey;
|
||||||
|
credDialogOpen = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true, storedMode);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2260,7 +2273,9 @@
|
|||||||
errorMessage = "";
|
errorMessage = "";
|
||||||
setCloneDialogError("");
|
setCloneDialogError("");
|
||||||
if (fromStore) {
|
if (fromStore) {
|
||||||
if (credentialKey) void credDelete(credentialKey).catch(() => {});
|
if (credentialKey) rejectedCredentialKeys.add(credentialKey);
|
||||||
|
credDialogUsername = username === "oauth2" ? "" : (username ?? "");
|
||||||
|
credDialogMode = credentialMode;
|
||||||
const detail = summarizeGitError(message);
|
const detail = summarizeGitError(message);
|
||||||
credDialogError = detail
|
credDialogError = detail
|
||||||
? `${detail} — please sign in again.`
|
? `${detail} — please sign in again.`
|
||||||
@@ -2917,7 +2932,7 @@
|
|||||||
|
|
||||||
async function pushLocalTag(tag: GitTag) {
|
async function pushLocalTag(tag: GitTag) {
|
||||||
if (!activeRepoPath || isBusy) return;
|
if (!activeRepoPath || isBusy) return;
|
||||||
const key = await currentCredKey();
|
const key = await currentCredKey("push");
|
||||||
const stored = await loadStoredCredential(key);
|
const stored = await loadStoredCredential(key);
|
||||||
const credential = stored ?? null;
|
const credential = stored ?? null;
|
||||||
|
|
||||||
@@ -3102,11 +3117,17 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the keychain key (host/org) for the active repo's remote.
|
function credentialModeFor(credential: StoredCredential): CredentialMode {
|
||||||
async function currentCredKey(): Promise<string | null> {
|
if (credential.mode === "token" || credential.username === "oauth2") return "token";
|
||||||
|
return "credentials";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the keychain key (host/org) from the exact remote URL used by the
|
||||||
|
// operation. Push URLs may intentionally differ from fetch URLs.
|
||||||
|
async function currentCredKey(action: "push" | "pull" | "fetch" = "fetch"): Promise<string | null> {
|
||||||
if (!activeRepoPath) return null;
|
if (!activeRepoPath) return null;
|
||||||
try {
|
try {
|
||||||
const url = await getRemoteUrl(activeRepoPath);
|
const url = await getRemoteUrl(activeRepoPath, selectedRemote || undefined, action === "push");
|
||||||
return url ? orgKeyFromUrl(url) : null;
|
return url ? orgKeyFromUrl(url) : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -3122,21 +3143,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openCredentialDialog(action: CredentialAction, key?: string | null) {
|
async function openCredentialDialog(
|
||||||
|
action: CredentialAction,
|
||||||
|
key?: string | null,
|
||||||
|
credential?: StoredCredential | null,
|
||||||
|
) {
|
||||||
if (!activeRepoPath && action !== "clone") return;
|
if (!activeRepoPath && action !== "clone") return;
|
||||||
credDialogError = "";
|
credDialogError = "";
|
||||||
credDialogAction = action;
|
credDialogAction = action;
|
||||||
credDialogKey = key === undefined && action !== "clone" ? await currentCredKey() : (key ?? null);
|
credDialogKey = key === undefined && action !== "clone"
|
||||||
|
? await currentCredKey(action)
|
||||||
|
: (key ?? null);
|
||||||
|
credDialogUsername = credential?.username === "oauth2" ? "" : (credential?.username ?? "");
|
||||||
|
credDialogMode = credential ? credentialModeFor(credential) : "credentials";
|
||||||
credDialogOpen = true;
|
credDialogOpen = true;
|
||||||
trackEvent("credential_dialog_opened", {
|
trackEvent("credential_dialog_opened", {
|
||||||
action,
|
action,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post-process a pull/push result: surface errors, and on rejected/expired
|
// Post-process a pull/push result. Rejected credentials stay in the keychain
|
||||||
// credentials drop the stored entry and re-open the login dialog.
|
// so a temporary 401/403 cannot erase a valid token; the key is only skipped
|
||||||
function handleRemoteResult(action: "push" | "pull" | "fetch", key: string | null, fromStore: boolean) {
|
// for the rest of this session until the user replaces it successfully.
|
||||||
|
function handleRemoteResult(
|
||||||
|
action: "push" | "pull" | "fetch",
|
||||||
|
key: string | null,
|
||||||
|
fromStore: boolean,
|
||||||
|
username: string,
|
||||||
|
mode: CredentialMode,
|
||||||
|
) {
|
||||||
if (!errorMessage) {
|
if (!errorMessage) {
|
||||||
|
if (key) rejectedCredentialKeys.delete(key);
|
||||||
credDialogOpen = false;
|
credDialogOpen = false;
|
||||||
credDialogAction = null;
|
credDialogAction = null;
|
||||||
return;
|
return;
|
||||||
@@ -3147,7 +3184,9 @@
|
|||||||
|
|
||||||
if (fromStore) {
|
if (fromStore) {
|
||||||
if (auth) {
|
if (auth) {
|
||||||
if (key) void credDelete(key).catch(() => {});
|
if (key) rejectedCredentialKeys.add(key);
|
||||||
|
credDialogUsername = username === "oauth2" ? "" : username;
|
||||||
|
credDialogMode = mode;
|
||||||
const detail = summarizeGitError(message);
|
const detail = summarizeGitError(message);
|
||||||
credDialogError = detail
|
credDialogError = detail
|
||||||
? `${detail} — please sign in again.`
|
? `${detail} — please sign in again.`
|
||||||
@@ -3160,6 +3199,7 @@
|
|||||||
errorMessage = message;
|
errorMessage = message;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (auth && key) rejectedCredentialKeys.add(key);
|
||||||
credDialogError = message || "Sign-in failed.";
|
credDialogError = message || "Sign-in failed.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3169,6 +3209,7 @@
|
|||||||
password: string,
|
password: string,
|
||||||
key: string | null,
|
key: string | null,
|
||||||
fromStore: boolean,
|
fromStore: boolean,
|
||||||
|
mode: CredentialMode,
|
||||||
) {
|
) {
|
||||||
errorMessage = "";
|
errorMessage = "";
|
||||||
await runOperation("Pulling", async () => {
|
await runOperation("Pulling", async () => {
|
||||||
@@ -3179,7 +3220,7 @@
|
|||||||
changed_files: status?.files.length ?? 0,
|
changed_files: status?.files.length ?? 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
handleRemoteResult("pull", key, fromStore);
|
handleRemoteResult("pull", key, fromStore, username, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doActualFetch(
|
async function doActualFetch(
|
||||||
@@ -3187,6 +3228,7 @@
|
|||||||
password: string,
|
password: string,
|
||||||
key: string | null,
|
key: string | null,
|
||||||
fromStore: boolean,
|
fromStore: boolean,
|
||||||
|
mode: CredentialMode,
|
||||||
) {
|
) {
|
||||||
errorMessage = "";
|
errorMessage = "";
|
||||||
await runOperation("Fetching", async () => {
|
await runOperation("Fetching", async () => {
|
||||||
@@ -3199,7 +3241,7 @@
|
|||||||
behind: status?.behind ?? 0,
|
behind: status?.behind ?? 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
handleRemoteResult("fetch", key, fromStore);
|
handleRemoteResult("fetch", key, fromStore, username, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doActualPush(
|
async function doActualPush(
|
||||||
@@ -3207,6 +3249,7 @@
|
|||||||
password: string,
|
password: string,
|
||||||
key: string | null,
|
key: string | null,
|
||||||
fromStore: boolean,
|
fromStore: boolean,
|
||||||
|
mode: CredentialMode,
|
||||||
) {
|
) {
|
||||||
errorMessage = "";
|
errorMessage = "";
|
||||||
await runOperation("Pushing", async () => {
|
await runOperation("Pushing", async () => {
|
||||||
@@ -3235,12 +3278,12 @@
|
|||||||
if (!fromStore) credDialogError = "";
|
if (!fromStore) credDialogError = "";
|
||||||
|
|
||||||
await runOperation("Pulling before push", async () => {
|
await runOperation("Pulling before push", async () => {
|
||||||
applyStatus(await pull(activeRepoPath, username, password));
|
applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined));
|
||||||
await refreshRepositoryViews(activeRepoPath);
|
await refreshRepositoryViews(activeRepoPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
handleRemoteResult("pull", key, fromStore);
|
handleRemoteResult("pull", key, fromStore, username, mode);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3252,7 +3295,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
await runOperation("Pushing after pull", async () => {
|
await runOperation("Pushing after pull", async () => {
|
||||||
applyStatus(await push(activeRepoPath, username, password));
|
applyStatus(await push(activeRepoPath, username, password, false, selectedRemote || undefined));
|
||||||
await refreshRepositoryViews(activeRepoPath, { files: false });
|
await refreshRepositoryViews(activeRepoPath, { files: false });
|
||||||
trackEvent("repository_pushed_after_pull", {
|
trackEvent("repository_pushed_after_pull", {
|
||||||
from_stored_credential: fromStore ? 1 : 0,
|
from_stored_credential: fromStore ? 1 : 0,
|
||||||
@@ -3261,14 +3304,31 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRemoteResult("push", key, fromStore);
|
handleRemoteResult("push", key, fromStore, username, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCredentialSubmit(username: string, password: string, save: boolean) {
|
async function handleCredentialSubmit(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
save: boolean,
|
||||||
|
mode: CredentialMode,
|
||||||
|
) {
|
||||||
const key = credDialogKey;
|
const key = credDialogKey;
|
||||||
if (credDialogAction === "pull") await doActualPull(username, password, key, false);
|
// Honour "Save in keychain" immediately. A successful authentication
|
||||||
else if (credDialogAction === "push") await doActualPush(username, password, key, false);
|
// followed by an unrelated refresh/non-fast-forward error must not lose the
|
||||||
else if (credDialogAction === "fetch") await doActualFetch(username, password, key, false);
|
// token and force the user to type it again on the next operation.
|
||||||
|
if (save && key) {
|
||||||
|
try {
|
||||||
|
await credSave(key, username, password, mode);
|
||||||
|
} catch (error) {
|
||||||
|
credDialogError = errorToMessage(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credDialogAction === "pull") await doActualPull(username, password, key, false, mode);
|
||||||
|
else if (credDialogAction === "push") await doActualPush(username, password, key, false, mode);
|
||||||
|
else if (credDialogAction === "fetch") await doActualFetch(username, password, key, false, mode);
|
||||||
else if (credDialogAction === "clone" && pendingClone) {
|
else if (credDialogAction === "clone" && pendingClone) {
|
||||||
await cloneRepo(
|
await cloneRepo(
|
||||||
pendingClone.remoteUrl,
|
pendingClone.remoteUrl,
|
||||||
@@ -3278,17 +3338,9 @@
|
|||||||
password,
|
password,
|
||||||
key,
|
key,
|
||||||
false,
|
false,
|
||||||
|
mode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only persist once the operation actually succeeded (dialog has closed).
|
|
||||||
if (!credDialogOpen && save && key) {
|
|
||||||
try {
|
|
||||||
await credSave(key, username, password);
|
|
||||||
} catch (error) {
|
|
||||||
errorMessage = errorToMessage(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startRemoteAction(action: "push" | "pull" | "fetch") {
|
async function startRemoteAction(action: "push" | "pull" | "fetch") {
|
||||||
@@ -3296,17 +3348,18 @@
|
|||||||
trackEvent("remote_action_started", {
|
trackEvent("remote_action_started", {
|
||||||
action,
|
action,
|
||||||
});
|
});
|
||||||
const key = await currentCredKey();
|
const key = await currentCredKey(action);
|
||||||
const stored = await loadStoredCredential(key);
|
const stored = await loadStoredCredential(key);
|
||||||
|
|
||||||
if (stored) {
|
if (stored && (!key || !rejectedCredentialKeys.has(key))) {
|
||||||
if (action === "pull") await doActualPull(stored.username, stored.password, key, true);
|
const mode = credentialModeFor(stored);
|
||||||
else if (action === "fetch") await doActualFetch(stored.username, stored.password, key, true);
|
if (action === "pull") await doActualPull(stored.username, stored.password, key, true, mode);
|
||||||
else await doActualPush(stored.username, stored.password, key, true);
|
else if (action === "fetch") await doActualFetch(stored.username, stored.password, key, true, mode);
|
||||||
|
else await doActualPush(stored.username, stored.password, key, true, mode);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await openCredentialDialog(action, key);
|
await openCredentialDialog(action, key, stored);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRepo() {
|
async function fetchRepo() {
|
||||||
@@ -5375,8 +5428,17 @@
|
|||||||
action={credDialogAction}
|
action={credDialogAction}
|
||||||
error={credDialogError}
|
error={credDialogError}
|
||||||
{isBusy}
|
{isBusy}
|
||||||
|
initialUsername={credDialogUsername}
|
||||||
|
initialMode={credDialogMode}
|
||||||
onSubmit={handleCredentialSubmit}
|
onSubmit={handleCredentialSubmit}
|
||||||
onCancel={() => { credDialogOpen = false; credDialogAction = null; credDialogError = ""; credDialogKey = null; }}
|
onCancel={() => {
|
||||||
|
credDialogOpen = false;
|
||||||
|
credDialogAction = null;
|
||||||
|
credDialogError = "";
|
||||||
|
credDialogKey = null;
|
||||||
|
credDialogUsername = "";
|
||||||
|
credDialogMode = "credentials";
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Download,
|
Download,
|
||||||
@@ -17,7 +18,9 @@
|
|||||||
action: "push" | "pull" | "fetch" | "clone";
|
action: "push" | "pull" | "fetch" | "clone";
|
||||||
error: string;
|
error: string;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
onSubmit: (username: string, password: string, save: boolean) => void;
|
initialUsername?: string;
|
||||||
|
initialMode?: Mode;
|
||||||
|
onSubmit: (username: string, password: string, save: boolean, mode: Mode) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,14 +28,16 @@
|
|||||||
action,
|
action,
|
||||||
error = "",
|
error = "",
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
|
initialUsername = "",
|
||||||
|
initialMode = "credentials",
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
type Mode = "credentials" | "token";
|
type Mode = "credentials" | "token";
|
||||||
|
|
||||||
let mode = $state<Mode>("credentials");
|
let mode = $state<Mode>(untrack(() => initialMode));
|
||||||
let username = $state("");
|
let username = $state(untrack(() => initialUsername === "oauth2" ? "" : initialUsername));
|
||||||
let password = $state("");
|
let password = $state("");
|
||||||
let showPassword = $state(false);
|
let showPassword = $state(false);
|
||||||
let saveSession = $state(true);
|
let saveSession = $state(true);
|
||||||
@@ -40,7 +45,7 @@
|
|||||||
let canSubmit = $derived(
|
let canSubmit = $derived(
|
||||||
!isBusy &&
|
!isBusy &&
|
||||||
password.trim().length > 0 &&
|
password.trim().length > 0 &&
|
||||||
(mode === "token" || username.trim().length > 0),
|
username.trim().length > 0,
|
||||||
);
|
);
|
||||||
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : "Pull");
|
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : "Pull");
|
||||||
let actionTitle = $derived(
|
let actionTitle = $derived(
|
||||||
@@ -61,7 +66,7 @@
|
|||||||
function handleSubmit(e: SubmitEvent) {
|
function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
onSubmit(mode === "token" ? "oauth2" : username, password, saveSession);
|
onSubmit(username.trim(), password, saveSession, mode);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -92,7 +97,7 @@
|
|||||||
|
|
||||||
<div class="cred-security-note">
|
<div class="cred-security-note">
|
||||||
<ShieldCheck size={14} aria-hidden="true" />
|
<ShieldCheck size={14} aria-hidden="true" />
|
||||||
<span>When saved, the token is stored encrypted in the operating system's keychain — never in plain text.</span>
|
<span>When saved, the credentials are stored encrypted in the operating system's keychain — never in plain text.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,12 +121,11 @@
|
|||||||
aria-pressed={mode === "token"}
|
aria-pressed={mode === "token"}
|
||||||
>
|
>
|
||||||
<Key size={13} aria-hidden="true" />
|
<Key size={13} aria-hidden="true" />
|
||||||
Token
|
Access token
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cred-fields">
|
<div class="cred-fields">
|
||||||
{#if mode === "credentials"}
|
|
||||||
<div class="cred-field">
|
<div class="cred-field">
|
||||||
<label class="cred-field-label" for="cred-username">Username</label>
|
<label class="cred-field-label" for="cred-username">Username</label>
|
||||||
<div class="cred-input">
|
<div class="cred-input">
|
||||||
@@ -130,13 +134,12 @@
|
|||||||
id="cred-username"
|
id="cred-username"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={username}
|
bind:value={username}
|
||||||
placeholder="e.g. my-github-username"
|
placeholder="Your account username"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="cred-field">
|
<div class="cred-field">
|
||||||
<label class="cred-field-label" for="cred-password">
|
<label class="cred-field-label" for="cred-password">
|
||||||
@@ -174,7 +177,7 @@
|
|||||||
{#if mode === "token"}
|
{#if mode === "token"}
|
||||||
<div class="cred-token-hint">
|
<div class="cred-token-hint">
|
||||||
<Key size={13} aria-hidden="true" />
|
<Key size={13} aria-hidden="true" />
|
||||||
<span>Username is automatically set to <code>oauth2</code>. This works with GitHub, GitLab, and Bitbucket.</span>
|
<span>Use your normal account username. The access token is sent as the password, as required by Gitea and most Git providers.</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -373,16 +373,16 @@ export function push(path: string, username?: string, password?: string, forceWi
|
|||||||
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null, forceWithLease, remote: remote || null });
|
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null, forceWithLease, remote: remote || null });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRemoteUrl(path: string): Promise<string | null> {
|
export function getRemoteUrl(path: string, remote?: string, push = false): Promise<string | null> {
|
||||||
return invoke<string | null>("get_remote_url", { path });
|
return invoke<string | null>("get_remote_url", { path, remote: remote || null, push });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function credLoad(key: string): Promise<StoredCredential | null> {
|
export function credLoad(key: string): Promise<StoredCredential | null> {
|
||||||
return invoke<StoredCredential | null>("cred_load", { key });
|
return invoke<StoredCredential | null>("cred_load", { key });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function credSave(key: string, username: string, password: string): Promise<void> {
|
export function credSave(key: string, username: string, password: string, mode: "credentials" | "token" = "credentials"): Promise<void> {
|
||||||
return invoke<void>("cred_save", { key, username, password });
|
return invoke<void>("cred_save", { key, username, password, mode });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function credDelete(key: string): Promise<void> {
|
export function credDelete(key: string): Promise<void> {
|
||||||
|
|||||||
@@ -310,4 +310,5 @@ export interface ReflogEntry {
|
|||||||
export interface StoredCredential {
|
export interface StoredCredential {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
mode?: "credentials" | "token";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user