add login with oskeychain
This commit is contained in:
@@ -20,7 +20,10 @@
|
|||||||
"Bash(grep -qE \"\\(Running \\\\`target|error\\\\[|panicked|terminated\\)\" /tmp/claude-1000/-mnt-data-Development-GitLite/71a8b49f-ac61-4ad9-aaba-dc0219044038/tasks/b7nyub68s.output)",
|
"Bash(grep -qE \"\\(Running \\\\`target|error\\\\[|panicked|terminated\\)\" /tmp/claude-1000/-mnt-data-Development-GitLite/71a8b49f-ac61-4ad9-aaba-dc0219044038/tasks/b7nyub68s.output)",
|
||||||
"Bash(npm install *)",
|
"Bash(npm install *)",
|
||||||
"Bash(cargo check *)",
|
"Bash(cargo check *)",
|
||||||
"Bash(npx vite *)"
|
"Bash(npx vite *)",
|
||||||
|
"Bash(cargo tree *)",
|
||||||
|
"Bash(jobs)",
|
||||||
|
"Bash(npx svelte-check *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+823
-3
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,10 @@ build = "build.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-dialog = "=2.7.0"
|
tauri-plugin-dialog = "=2.7.0"
|
||||||
|
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|||||||
+159
-1
@@ -1,4 +1,4 @@
|
|||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeMap, BTreeSet},
|
collections::{BTreeMap, BTreeSet},
|
||||||
ffi::{OsStr, OsString},
|
ffi::{OsStr, OsString},
|
||||||
@@ -365,6 +365,142 @@ pub fn push(
|
|||||||
status_for_repo(&repo)
|
status_for_repo(&repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Credential storage (OS keychain) ────────────────────────────────────────
|
||||||
|
|
||||||
|
const CRED_SERVICE: &str = "tauri_git_lite";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StoredCredential {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
#[serde(default, rename = "expiresAt", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
|
||||||
|
let key = key.trim();
|
||||||
|
if key.is_empty() {
|
||||||
|
return Err("Kein Schlüssel für die Zugangsdaten angegeben.".to_string());
|
||||||
|
}
|
||||||
|
keyring::Entry::new(CRED_SERVICE, key)
|
||||||
|
.map_err(|err| format!("Schlüsselbund nicht verfügbar: {err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the remote URL used for auth key derivation (upstream remote of the
|
||||||
|
/// current branch, falling back to `origin`, then the first configured remote).
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_remote_url(path: String) -> Result<Option<String>, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let remote = upstream_remote_name(&repo).unwrap_or_else(|| "origin".to_string());
|
||||||
|
|
||||||
|
if let Some(url) = remote_url_for(&repo, &remote) {
|
||||||
|
return Ok(Some(url));
|
||||||
|
}
|
||||||
|
// origin missing → try the first configured remote
|
||||||
|
if let Some(first) = first_remote_name(&repo) {
|
||||||
|
if first != remote {
|
||||||
|
if let Some(url) = remote_url_for(&repo, &first) {
|
||||||
|
return Ok(Some(url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_url_for(repo: &Path, remote: &str) -> Option<String> {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(repo)
|
||||||
|
.args(["remote", "get-url", 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 upstream_remote_name(repo: &Path) -> Option<String> {
|
||||||
|
let branch = run_git(repo, ["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
|
||||||
|
let branch = String::from_utf8_lossy(&branch).trim().to_string();
|
||||||
|
if branch.is_empty() || branch == "HEAD" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let out = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(repo)
|
||||||
|
.args(["config", &format!("branch.{branch}.remote")])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_remote_name(repo: &Path) -> Option<String> {
|
||||||
|
let out = run_git(repo, ["remote"]).ok()?;
|
||||||
|
String::from_utf8_lossy(&out)
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.find(|line| !line.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn cred_load(key: String) -> Result<Option<StoredCredential>, String> {
|
||||||
|
let entry = cred_entry(&key)?;
|
||||||
|
match entry.get_password() {
|
||||||
|
Ok(json) => {
|
||||||
|
let cred = serde_json::from_str::<StoredCredential>(&json)
|
||||||
|
.map_err(|err| format!("Gespeicherte Zugangsdaten unlesbar: {err}"))?;
|
||||||
|
Ok(Some(cred))
|
||||||
|
}
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(None),
|
||||||
|
Err(err) => Err(format!("Schlüsselbund-Zugriff fehlgeschlagen: {err}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn cred_save(
|
||||||
|
key: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
expires_at: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let entry = cred_entry(&key)?;
|
||||||
|
let expires_at = expires_at.filter(|value| !value.trim().is_empty());
|
||||||
|
let cred = StoredCredential {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
expires_at,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&cred)
|
||||||
|
.map_err(|err| format!("Zugangsdaten konnten nicht serialisiert werden: {err}"))?;
|
||||||
|
entry
|
||||||
|
.set_password(&json)
|
||||||
|
.map_err(|err| format!("Speichern im Schlüsselbund fehlgeschlagen: {err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn cred_delete(key: String) -> Result<(), String> {
|
||||||
|
let entry = cred_entry(&key)?;
|
||||||
|
match entry.delete_credential() {
|
||||||
|
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||||
|
Err(err) => Err(format!("Löschen im Schlüsselbund fehlgeschlagen: {err}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
@@ -1824,9 +1960,31 @@ where
|
|||||||
} else {
|
} else {
|
||||||
"Unbekannter Fehler".to_string()
|
"Unbekannter Fehler".to_string()
|
||||||
};
|
};
|
||||||
|
if is_auth_error(&details) {
|
||||||
|
return Err(format!("AUTH_FAILED:{details}"));
|
||||||
|
}
|
||||||
Err(format!("Git-Befehl fehlgeschlagen: {details}"))
|
Err(format!("Git-Befehl fehlgeschlagen: {details}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Heuristic: did git fail because the credentials were rejected/expired,
|
||||||
|
/// as opposed to a network or merge error? Used by the frontend to drop the
|
||||||
|
/// stored credential and re-prompt the login.
|
||||||
|
fn is_auth_error(details: &str) -> bool {
|
||||||
|
let d = details.to_lowercase();
|
||||||
|
d.contains("authentication failed")
|
||||||
|
|| d.contains("could not read username")
|
||||||
|
|| d.contains("could not read password")
|
||||||
|
|| d.contains("invalid username or password")
|
||||||
|
|| d.contains("terminal prompts disabled")
|
||||||
|
|| d.contains("permission denied")
|
||||||
|
|| d.contains("access denied")
|
||||||
|
|| d.contains("403 forbidden")
|
||||||
|
|| d.contains(" 403")
|
||||||
|
|| d.contains(" 401")
|
||||||
|
|| d.contains("authorization failed")
|
||||||
|
|| d.contains("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
fn run_git_with_paths(
|
fn run_git_with_paths(
|
||||||
repo: &Path,
|
repo: &Path,
|
||||||
base_args: &[&str],
|
base_args: &[&str],
|
||||||
|
|||||||
+10
-6
@@ -4,11 +4,11 @@ mod git;
|
|||||||
|
|
||||||
use git::{
|
use git::{
|
||||||
cancel_code_search, checkout_branch, commit, compare_commits, compare_file_to_head,
|
cancel_code_search, checkout_branch, commit, compare_commits, compare_file_to_head,
|
||||||
diff_file_against_working_tree, get_status, list_branches, list_commits, list_file_history,
|
cred_delete, cred_load, cred_save, diff_file_against_working_tree, get_remote_url, get_status,
|
||||||
list_repository_files, merge_branch, open_repository, pull, push, read_conflict,
|
list_branches, list_commits, list_file_history, list_repository_files, merge_branch,
|
||||||
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
|
open_repository, pull, push, read_conflict, resolve_conflict, resolve_conflict_side,
|
||||||
restore_to_commit, search_code_introductions, stage_files, unstage_files,
|
restore_file_from_commit, restore_files, restore_to_commit, search_code_introductions,
|
||||||
SearchCancellationState,
|
stage_files, unstage_files, SearchCancellationState,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -39,7 +39,11 @@ fn main() {
|
|||||||
cancel_code_search,
|
cancel_code_search,
|
||||||
read_conflict,
|
read_conflict,
|
||||||
resolve_conflict,
|
resolve_conflict,
|
||||||
resolve_conflict_side
|
resolve_conflict_side,
|
||||||
|
get_remote_url,
|
||||||
|
cred_load,
|
||||||
|
cred_save,
|
||||||
|
cred_delete
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
+115
-25
@@ -32,6 +32,10 @@
|
|||||||
openRepository,
|
openRepository,
|
||||||
pull,
|
pull,
|
||||||
push,
|
push,
|
||||||
|
getRemoteUrl,
|
||||||
|
credLoad,
|
||||||
|
credSave,
|
||||||
|
credDelete,
|
||||||
readConflict,
|
readConflict,
|
||||||
resolveConflict,
|
resolveConflict,
|
||||||
resolveConflictSide,
|
resolveConflictSide,
|
||||||
@@ -57,8 +61,16 @@
|
|||||||
GitSearchHit,
|
GitSearchHit,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
PreparedResolution,
|
PreparedResolution,
|
||||||
|
StoredCredential,
|
||||||
} from "./lib/types";
|
} from "./lib/types";
|
||||||
|
|
||||||
|
import {
|
||||||
|
orgKeyFromUrl,
|
||||||
|
isCredentialExpired,
|
||||||
|
isAuthError,
|
||||||
|
stripAuthPrefix,
|
||||||
|
} from "./lib/credentials";
|
||||||
|
|
||||||
// ── State ──────────────────────────────────────────────────────────────────
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let repoPath = "";
|
let repoPath = "";
|
||||||
@@ -95,7 +107,7 @@
|
|||||||
let credDialogOpen = false;
|
let credDialogOpen = false;
|
||||||
let credDialogAction: "push" | "pull" | null = null;
|
let credDialogAction: "push" | "pull" | null = null;
|
||||||
let credDialogError = "";
|
let credDialogError = "";
|
||||||
let sessionCredentials: { username: string; password: string } | null = null;
|
let credDialogKey: string | null = null;
|
||||||
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;
|
||||||
@@ -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;
|
if (!activeRepoPath) return;
|
||||||
credDialogError = "";
|
credDialogError = "";
|
||||||
credDialogAction = action;
|
credDialogAction = action;
|
||||||
|
credDialogKey = key === undefined ? await currentCredKey() : key;
|
||||||
credDialogOpen = true;
|
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 = "";
|
errorMessage = "";
|
||||||
await runOperation("Pulling", async () => {
|
await runOperation("Pulling", async () => {
|
||||||
applyStatus(await pull(activeRepoPath, username, password));
|
applyStatus(await pull(activeRepoPath, username, password));
|
||||||
@@ -322,11 +389,15 @@
|
|||||||
await refreshExplorerFiles(activeRepoPath);
|
await refreshExplorerFiles(activeRepoPath);
|
||||||
await refreshFileHistory(activeRepoPath);
|
await refreshFileHistory(activeRepoPath);
|
||||||
});
|
});
|
||||||
if (errorMessage) { credDialogError = errorMessage; errorMessage = ""; }
|
handleRemoteResult("pull", key, fromStore);
|
||||||
else { credDialogOpen = false; credDialogAction = null; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doActualPush(username: string, password: string) {
|
async function doActualPush(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
key: string | null,
|
||||||
|
fromStore: boolean,
|
||||||
|
) {
|
||||||
errorMessage = "";
|
errorMessage = "";
|
||||||
await runOperation("Pushing", async () => {
|
await runOperation("Pushing", async () => {
|
||||||
applyStatus(await push(activeRepoPath, username, password));
|
applyStatus(await push(activeRepoPath, username, password));
|
||||||
@@ -334,32 +405,51 @@
|
|||||||
await refreshCommitHistory(activeRepoPath);
|
await refreshCommitHistory(activeRepoPath);
|
||||||
await refreshFileHistory(activeRepoPath);
|
await refreshFileHistory(activeRepoPath);
|
||||||
});
|
});
|
||||||
if (errorMessage) { credDialogError = errorMessage; errorMessage = ""; }
|
handleRemoteResult("push", key, fromStore);
|
||||||
else { credDialogOpen = false; credDialogAction = null; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCredentialSubmit(username: string, password: string, save: boolean) {
|
async function handleCredentialSubmit(
|
||||||
if (credDialogAction === "pull") await doActualPull(username, password);
|
username: string,
|
||||||
else if (credDialogAction === "push") await doActualPush(username, password);
|
password: string,
|
||||||
if (!credDialogOpen && save) sessionCredentials = { username, password };
|
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() {
|
async function pullRepo() {
|
||||||
if (!activeRepoPath) return;
|
await startRemoteAction("pull");
|
||||||
if (sessionCredentials) {
|
|
||||||
await doActualPull(sessionCredentials.username, sessionCredentials.password);
|
|
||||||
} else {
|
|
||||||
openCredentialDialog("pull");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pushRepo() {
|
async function pushRepo() {
|
||||||
if (!activeRepoPath) return;
|
await startRemoteAction("push");
|
||||||
if (sessionCredentials) {
|
|
||||||
await doActualPush(sessionCredentials.username, sessionCredentials.password);
|
|
||||||
} else {
|
|
||||||
openCredentialDialog("push");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── File staging / restore ─────────────────────────────────────────────────
|
// ── File staging / restore ─────────────────────────────────────────────────
|
||||||
@@ -881,7 +971,7 @@
|
|||||||
error={credDialogError}
|
error={credDialogError}
|
||||||
{isBusy}
|
{isBusy}
|
||||||
onSubmit={handleCredentialSubmit}
|
onSubmit={handleCredentialSubmit}
|
||||||
onCancel={() => { credDialogOpen = false; credDialogAction = null; credDialogError = ""; }}
|
onCancel={() => { credDialogOpen = false; credDialogAction = null; credDialogError = ""; credDialogKey = null; }}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
+13
@@ -1359,6 +1359,19 @@
|
|||||||
color: var(--color-accent);
|
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 {
|
.cred-error {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
action: "push" | "pull";
|
action: "push" | "pull";
|
||||||
error: string;
|
error: string;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
onSubmit: (username: string, password: string, save: boolean) => void;
|
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
let password = $state("");
|
let password = $state("");
|
||||||
let showPassword = $state(false);
|
let showPassword = $state(false);
|
||||||
let saveSession = $state(false);
|
let saveSession = $state(false);
|
||||||
|
let expiresAt = $state("");
|
||||||
|
|
||||||
let canSubmit = $derived(
|
let canSubmit = $derived(
|
||||||
!isBusy &&
|
!isBusy &&
|
||||||
@@ -51,7 +52,12 @@
|
|||||||
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(
|
||||||
|
mode === "token" ? "oauth2" : username,
|
||||||
|
password,
|
||||||
|
saveSession,
|
||||||
|
saveSession && expiresAt ? expiresAt : null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -83,7 +89,7 @@
|
|||||||
|
|
||||||
<div class="cred-security-note">
|
<div class="cred-security-note">
|
||||||
<ShieldCheck size={14} aria-hidden="true" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -176,10 +182,23 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<div class="cred-footer">
|
||||||
<label class="cred-save">
|
<label class="cred-save">
|
||||||
<input type="checkbox" bind:checked={saveSession} disabled={isBusy} />
|
<input type="checkbox" bind:checked={saveSession} disabled={isBusy} />
|
||||||
<span>Fuer diese Sitzung merken</span>
|
<span>Im Schluesselbund speichern</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="cred-btns">
|
<div class="cred-btns">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
GitRepositoryFile,
|
GitRepositoryFile,
|
||||||
GitSearchHit,
|
GitSearchHit,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
|
StoredCredential,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export function openRepository(path: string): Promise<GitStatus> {
|
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 });
|
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[]> {
|
export function listCommits(path: string, limit = 100): Promise<GitCommit[]> {
|
||||||
return invoke<GitCommit[]>("list_commits", { path, limit });
|
return invoke<GitCommit[]>("list_commits", { path, limit });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,3 +117,9 @@ export interface ConflictFile {
|
|||||||
ours_size: number | null;
|
ours_size: number | null;
|
||||||
theirs_size: number | null;
|
theirs_size: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StoredCredential {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
expiresAt?: string | null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user