add login with oskeychain

This commit is contained in:
Christoph Brandau
2026-06-29 19:13:41 +02:00
parent 8d56c3f39f
commit 4aa9a537f0
11 changed files with 1234 additions and 40 deletions
+115 -25
View File
@@ -32,6 +32,10 @@
openRepository,
pull,
push,
getRemoteUrl,
credLoad,
credSave,
credDelete,
readConflict,
resolveConflict,
resolveConflictSide,
@@ -57,8 +61,16 @@
GitSearchHit,
GitStatus,
PreparedResolution,
StoredCredential,
} from "./lib/types";
import {
orgKeyFromUrl,
isCredentialExpired,
isAuthError,
stripAuthPrefix,
} from "./lib/credentials";
// ── State ──────────────────────────────────────────────────────────────────
let repoPath = "";
@@ -95,7 +107,7 @@
let credDialogOpen = false;
let credDialogAction: "push" | "pull" | null = null;
let credDialogError = "";
let sessionCredentials: { username: string; password: string } | null = null;
let credDialogKey: string | null = null;
let lastStatusFingerprint = "";
const AUTO_REFRESH_INTERVAL = 4000;
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
@@ -306,14 +318,69 @@
});
}
function openCredentialDialog(action: "push" | "pull") {
// Resolve the keychain key (host/org) for the active repo's remote.
async function currentCredKey(): Promise<string | null> {
if (!activeRepoPath) return null;
try {
const url = await getRemoteUrl(activeRepoPath);
return url ? orgKeyFromUrl(url) : null;
} catch {
return null;
}
}
async function loadStoredCredential(key: string | null): Promise<StoredCredential | null> {
if (!key) return null;
try {
return await credLoad(key);
} catch {
return null;
}
}
async function openCredentialDialog(action: "push" | "pull", key?: string | null) {
if (!activeRepoPath) return;
credDialogError = "";
credDialogAction = action;
credDialogKey = key === undefined ? await currentCredKey() : key;
credDialogOpen = true;
}
async function doActualPull(username: string, password: string) {
// 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", key: string | null, fromStore: boolean) {
if (!errorMessage) {
credDialogOpen = false;
credDialogAction = null;
return;
}
const auth = isAuthError(errorMessage);
const message = stripAuthPrefix(errorMessage);
errorMessage = "";
if (fromStore) {
if (auth) {
if (key) void credDelete(key).catch(() => {});
credDialogError =
"Zugangsdaten wurden abgelehnt oder sind abgelaufen. Bitte erneut anmelden.";
credDialogAction = action;
credDialogKey = key;
credDialogOpen = true;
} else {
// Non-auth failure (e.g. network) keep the stored credential, show it inline.
errorMessage = message;
}
} else {
credDialogError = message || "Anmeldung fehlgeschlagen.";
}
}
async function doActualPull(
username: string,
password: string,
key: string | null,
fromStore: boolean,
) {
errorMessage = "";
await runOperation("Pulling", async () => {
applyStatus(await pull(activeRepoPath, username, password));
@@ -322,11 +389,15 @@
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
if (errorMessage) { credDialogError = errorMessage; errorMessage = ""; }
else { credDialogOpen = false; credDialogAction = null; }
handleRemoteResult("pull", key, fromStore);
}
async function doActualPush(username: string, password: string) {
async function doActualPush(
username: string,
password: string,
key: string | null,
fromStore: boolean,
) {
errorMessage = "";
await runOperation("Pushing", async () => {
applyStatus(await push(activeRepoPath, username, password));
@@ -334,32 +405,51 @@
await refreshCommitHistory(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
if (errorMessage) { credDialogError = errorMessage; errorMessage = ""; }
else { credDialogOpen = false; credDialogAction = null; }
handleRemoteResult("push", key, fromStore);
}
async function handleCredentialSubmit(username: string, password: string, save: boolean) {
if (credDialogAction === "pull") await doActualPull(username, password);
else if (credDialogAction === "push") await doActualPush(username, password);
if (!credDialogOpen && save) sessionCredentials = { username, password };
async function handleCredentialSubmit(
username: string,
password: string,
save: boolean,
expiresAt: string | null,
) {
const key = credDialogKey;
if (credDialogAction === "pull") await doActualPull(username, password, key, false);
else if (credDialogAction === "push") await doActualPush(username, password, key, false);
// Only persist once the operation actually succeeded (dialog has closed).
if (!credDialogOpen && save && key) {
try {
await credSave(key, username, password, expiresAt);
} catch (error) {
errorMessage = errorToMessage(error);
}
}
}
async function startRemoteAction(action: "push" | "pull") {
if (!activeRepoPath) return;
const key = await currentCredKey();
const stored = await loadStoredCredential(key);
if (stored && !isCredentialExpired(stored)) {
if (action === "pull") await doActualPull(stored.username, stored.password, key, true);
else await doActualPush(stored.username, stored.password, key, true);
return;
}
// Expired entry → clean it up before prompting again.
if (stored && key) await credDelete(key).catch(() => {});
await openCredentialDialog(action, key);
}
async function pullRepo() {
if (!activeRepoPath) return;
if (sessionCredentials) {
await doActualPull(sessionCredentials.username, sessionCredentials.password);
} else {
openCredentialDialog("pull");
}
await startRemoteAction("pull");
}
async function pushRepo() {
if (!activeRepoPath) return;
if (sessionCredentials) {
await doActualPush(sessionCredentials.username, sessionCredentials.password);
} else {
openCredentialDialog("push");
}
await startRemoteAction("push");
}
// ── File staging / restore ─────────────────────────────────────────────────
@@ -881,7 +971,7 @@
error={credDialogError}
{isBusy}
onSubmit={handleCredentialSubmit}
onCancel={() => { credDialogOpen = false; credDialogAction = null; credDialogError = ""; }}
onCancel={() => { credDialogOpen = false; credDialogAction = null; credDialogError = ""; credDialogKey = null; }}
/>
{/if}
+13
View File
@@ -1359,6 +1359,19 @@
color: var(--color-accent);
}
.cred-expiry { display: flex; flex-direction: column; gap: 5px; }
.cred-expiry input[type="date"] {
height: 40px;
padding: 0 11px;
border-radius: 9px;
border: 1px solid rgba(65,209,255,0.22);
background: rgba(7, 8, 16, 0.7);
color: var(--color-ink);
font-size: 13.5px;
color-scheme: dark;
}
.cred-expiry-hint { font-size: 11.5px; color: var(--color-ink-faint); line-height: 1.5; }
.cred-error {
display: flex;
align-items: flex-start;
+23 -4
View File
@@ -17,7 +17,7 @@
action: "push" | "pull";
error: string;
isBusy: boolean;
onSubmit: (username: string, password: string, save: boolean) => void;
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void;
onCancel: () => void;
}
@@ -36,6 +36,7 @@
let password = $state("");
let showPassword = $state(false);
let saveSession = $state(false);
let expiresAt = $state("");
let canSubmit = $derived(
!isBusy &&
@@ -51,7 +52,12 @@
function handleSubmit(e: SubmitEvent) {
e.preventDefault();
if (!canSubmit) return;
onSubmit(mode === "token" ? "oauth2" : username, password, saveSession);
onSubmit(
mode === "token" ? "oauth2" : username,
password,
saveSession,
saveSession && expiresAt ? expiresAt : null,
);
}
</script>
@@ -83,7 +89,7 @@
<div class="cred-security-note">
<ShieldCheck size={14} aria-hidden="true" />
<span>Wird nur an Git fuer diese Remote-Operation weitergegeben.</span>
<span>Beim Speichern landet der Token verschluesselt im Schluesselbund des Betriebssystems nie im Klartext.</span>
</div>
</div>
@@ -176,10 +182,23 @@
</div>
{/if}
{#if saveSession}
<div class="cred-expiry">
<label class="cred-field-label" for="cred-expiry">Ablaufdatum (optional)</label>
<input
id="cred-expiry"
type="date"
bind:value={expiresAt}
disabled={isBusy}
/>
<span class="cred-expiry-hint">Nach diesem Datum wird automatisch erneut nach dem Login gefragt.</span>
</div>
{/if}
<div class="cred-footer">
<label class="cred-save">
<input type="checkbox" bind:checked={saveSession} disabled={isBusy} />
<span>Fuer diese Sitzung merken</span>
<span>Im Schluesselbund speichern</span>
</label>
<div class="cred-btns">
+57
View File
@@ -0,0 +1,57 @@
import type { StoredCredential } from "./types";
/**
* Derives a credential key from a remote URL, scoped to host + organisation —
* the same granularity Azure DevOps / GitHub use. Examples:
* https://github.com/owner/repo.git -> github.com/owner
* https://dev.azure.com/org/project/_git/repo -> dev.azure.com/org
* git@github.com:owner/repo.git -> github.com/owner
* ssh://git@host:2222/owner/repo -> host/owner
* Returns null when nothing usable can be parsed.
*/
export function orgKeyFromUrl(raw: string): string | null {
const url = raw.trim();
if (!url) return null;
let host = "";
let path = "";
// scp-like syntax: user@host:owner/repo.git (no scheme, single colon segment)
const scp = url.match(/^[^@/]+@([^:/]+):(.+)$/);
if (scp && !url.includes("://")) {
host = scp[1];
path = scp[2];
} else {
try {
const parsed = new URL(url);
host = parsed.host; // host:port, without userinfo
path = parsed.pathname;
} catch {
return null;
}
}
host = host.toLowerCase();
const org = path.replace(/^\/+/, "").split("/").filter(Boolean)[0] ?? "";
if (!host) return null;
return org ? `${host}/${org}` : host;
}
/** A stored credential is expired only if it carries a past expiry date. */
export function isCredentialExpired(cred: StoredCredential): boolean {
if (!cred.expiresAt) return false;
const time = new Date(cred.expiresAt).getTime();
return !Number.isNaN(time) && time < Date.now();
}
const AUTH_PREFIX = "AUTH_FAILED:";
export function isAuthError(message: string): boolean {
return message.startsWith(AUTH_PREFIX);
}
export function stripAuthPrefix(message: string): string {
return message.startsWith(AUTH_PREFIX)
? message.slice(AUTH_PREFIX.length).trim()
: message;
}
+22
View File
@@ -8,6 +8,7 @@ import type {
GitRepositoryFile,
GitSearchHit,
GitStatus,
StoredCredential,
} from "./types";
export function openRepository(path: string): Promise<GitStatus> {
@@ -54,6 +55,27 @@ export function push(path: string, username?: string, password?: string): Promis
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null });
}
export function getRemoteUrl(path: string): Promise<string | null> {
return invoke<string | null>("get_remote_url", { path });
}
export function credLoad(key: string): Promise<StoredCredential | null> {
return invoke<StoredCredential | null>("cred_load", { key });
}
export function credSave(
key: string,
username: string,
password: string,
expiresAt: string | null,
): Promise<void> {
return invoke<void>("cred_save", { key, username, password, expiresAt });
}
export function credDelete(key: string): Promise<void> {
return invoke<void>("cred_delete", { key });
}
export function listCommits(path: string, limit = 100): Promise<GitCommit[]> {
return invoke<GitCommit[]>("list_commits", { path, limit });
}
+6
View File
@@ -117,3 +117,9 @@ export interface ConflictFile {
ours_size: number | null;
theirs_size: number | null;
}
export interface StoredCredential {
username: string;
password: string;
expiresAt?: string | null;
}