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
+101 -39
View File
@@ -111,7 +111,6 @@
launchExternalTool,
credLoad,
credSave,
credDelete,
getFilePatch,
readConflict,
resolveConflict,
@@ -190,6 +189,7 @@
type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository";
type CredentialAction = "push" | "pull" | "fetch" | "clone";
type CredentialMode = "credentials" | "token";
type PendingDiscard =
| { kind: "file"; files: GitFileStatus[]; staged: boolean }
| { kind: "all-changes"; files: GitFileStatus[] }
@@ -412,6 +412,9 @@
let credDialogAction: CredentialAction | null = null;
let credDialogError = "";
let credDialogKey: string | null = null;
let credDialogUsername = "";
let credDialogMode: CredentialMode = "credentials";
const rejectedCredentialKeys = new Set<string>();
let lastStatusFingerprint = "";
const AUTO_REFRESH_INTERVAL = 4000;
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
@@ -2207,6 +2210,7 @@
password?: string,
key?: string | null,
fromStore = false,
credentialMode: CredentialMode = "credentials",
) {
if (isBusy) return;
if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; }
@@ -2219,7 +2223,16 @@
if (!username && !password) {
const stored = await loadStoredCredential(credentialKey);
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;
}
}
@@ -2260,7 +2273,9 @@
errorMessage = "";
setCloneDialogError("");
if (fromStore) {
if (credentialKey) void credDelete(credentialKey).catch(() => {});
if (credentialKey) rejectedCredentialKeys.add(credentialKey);
credDialogUsername = username === "oauth2" ? "" : (username ?? "");
credDialogMode = credentialMode;
const detail = summarizeGitError(message);
credDialogError = detail
? `${detail} — please sign in again.`
@@ -2917,7 +2932,7 @@
async function pushLocalTag(tag: GitTag) {
if (!activeRepoPath || isBusy) return;
const key = await currentCredKey();
const key = await currentCredKey("push");
const stored = await loadStoredCredential(key);
const credential = stored ?? null;
@@ -3102,11 +3117,17 @@
});
}
// Resolve the keychain key (host/org) for the active repo's remote.
async function currentCredKey(): Promise<string | null> {
function credentialModeFor(credential: StoredCredential): CredentialMode {
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;
try {
const url = await getRemoteUrl(activeRepoPath);
const url = await getRemoteUrl(activeRepoPath, selectedRemote || undefined, action === "push");
return url ? orgKeyFromUrl(url) : null;
} catch {
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;
credDialogError = "";
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;
trackEvent("credential_dialog_opened", {
action,
});
}
// Post-process a pull/push result: surface errors, and on rejected/expired
// credentials drop the stored entry and re-open the login dialog.
function handleRemoteResult(action: "push" | "pull" | "fetch", key: string | null, fromStore: boolean) {
// Post-process a pull/push result. Rejected credentials stay in the keychain
// so a temporary 401/403 cannot erase a valid token; the key is only skipped
// 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 (key) rejectedCredentialKeys.delete(key);
credDialogOpen = false;
credDialogAction = null;
return;
@@ -3147,7 +3184,9 @@
if (fromStore) {
if (auth) {
if (key) void credDelete(key).catch(() => {});
if (key) rejectedCredentialKeys.add(key);
credDialogUsername = username === "oauth2" ? "" : username;
credDialogMode = mode;
const detail = summarizeGitError(message);
credDialogError = detail
? `${detail} — please sign in again.`
@@ -3160,6 +3199,7 @@
errorMessage = message;
}
} else {
if (auth && key) rejectedCredentialKeys.add(key);
credDialogError = message || "Sign-in failed.";
}
}
@@ -3169,6 +3209,7 @@
password: string,
key: string | null,
fromStore: boolean,
mode: CredentialMode,
) {
errorMessage = "";
await runOperation("Pulling", async () => {
@@ -3179,7 +3220,7 @@
changed_files: status?.files.length ?? 0,
});
});
handleRemoteResult("pull", key, fromStore);
handleRemoteResult("pull", key, fromStore, username, mode);
}
async function doActualFetch(
@@ -3187,6 +3228,7 @@
password: string,
key: string | null,
fromStore: boolean,
mode: CredentialMode,
) {
errorMessage = "";
await runOperation("Fetching", async () => {
@@ -3199,7 +3241,7 @@
behind: status?.behind ?? 0,
});
});
handleRemoteResult("fetch", key, fromStore);
handleRemoteResult("fetch", key, fromStore, username, mode);
}
async function doActualPush(
@@ -3207,6 +3249,7 @@
password: string,
key: string | null,
fromStore: boolean,
mode: CredentialMode,
) {
errorMessage = "";
await runOperation("Pushing", async () => {
@@ -3235,12 +3278,12 @@
if (!fromStore) credDialogError = "";
await runOperation("Pulling before push", async () => {
applyStatus(await pull(activeRepoPath, username, password));
applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined));
await refreshRepositoryViews(activeRepoPath);
});
if (errorMessage) {
handleRemoteResult("pull", key, fromStore);
handleRemoteResult("pull", key, fromStore, username, mode);
return;
}
@@ -3252,7 +3295,7 @@
}
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 });
trackEvent("repository_pushed_after_pull", {
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;
if (credDialogAction === "pull") await doActualPull(username, password, key, false);
else if (credDialogAction === "push") await doActualPush(username, password, key, false);
else if (credDialogAction === "fetch") await doActualFetch(username, password, key, false);
// Honour "Save in keychain" immediately. A successful authentication
// followed by an unrelated refresh/non-fast-forward error must not lose the
// 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) {
await cloneRepo(
pendingClone.remoteUrl,
@@ -3278,17 +3338,9 @@
password,
key,
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") {
@@ -3296,17 +3348,18 @@
trackEvent("remote_action_started", {
action,
});
const key = await currentCredKey();
const key = await currentCredKey(action);
const stored = await loadStoredCredential(key);
if (stored) {
if (action === "pull") await doActualPull(stored.username, stored.password, key, true);
else if (action === "fetch") await doActualFetch(stored.username, stored.password, key, true);
else await doActualPush(stored.username, stored.password, key, true);
if (stored && (!key || !rejectedCredentialKeys.has(key))) {
const mode = credentialModeFor(stored);
if (action === "pull") await doActualPull(stored.username, stored.password, key, true, mode);
else if (action === "fetch") await doActualFetch(stored.username, stored.password, key, true, mode);
else await doActualPush(stored.username, stored.password, key, true, mode);
return;
}
await openCredentialDialog(action, key);
await openCredentialDialog(action, key, stored);
}
async function fetchRepo() {
@@ -5375,8 +5428,17 @@
action={credDialogAction}
error={credDialogError}
{isBusy}
initialUsername={credDialogUsername}
initialMode={credDialogMode}
onSubmit={handleCredentialSubmit}
onCancel={() => { credDialogOpen = false; credDialogAction = null; credDialogError = ""; credDialogKey = null; }}
onCancel={() => {
credDialogOpen = false;
credDialogAction = null;
credDialogError = "";
credDialogKey = null;
credDialogUsername = "";
credDialogMode = "credentials";
}}
/>
{/if}