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:
Christoph Brandau
2026-08-13 22:27:00 +02:00
parent f621638eb3
commit c442b3735f
Notes: Christoph Brandau 2026-08-13 22:48:24 +02:00
git note test
5 changed files with 266 additions and 75 deletions
+134 -9
View File
@@ -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
View File
@@ -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}
+14 -11
View File
@@ -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
View File
@@ -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> {
+1
View File
@@ -310,4 +310,5 @@ export interface ReflogEntry {
export interface StoredCredential { export interface StoredCredential {
username: string; username: string;
password: string; password: string;
mode?: "credentials" | "token";
} }