From 9b008cbf51a4f932b4426125fbd3ac520adaaf48 Mon Sep 17 00:00:00 2001 From: "marcus.hinz" Date: Sat, 4 Jul 2026 13:27:54 +0200 Subject: [PATCH] =?UTF-8?q?Pools:=20l=C3=B6schbar=20mit=20Zuordnungs-Aufl?= =?UTF-8?q?=C3=B6sung,=20Neuanmelden=20=C3=BCber=20Keychain,=20Key-Status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - delete_pool löst Projektzuordnungen (Terminal-Config bleibt), Dialog zeigt betroffene Projekte - oauth_login: nur bei ungenutztem Pool, löscht vorher den suffixierten Keychain-Eintrag; Warn-Dialog mit keychain_status; oauth_refresh raus - PoolInfo.hasCredentials: apikey-Dateicheck; Button Key ändern/eintragen, einheitliche Button-Breite - Starten deaktiviert ohne Pool bzw. ohne Key (Tooltip unterscheidet) - Icon-Pfad im Terminal-Dialog gekürzt mit Hover-Popover --- src-tauri/src/lib.rs | 116 +++++++++++++++++++++++++-------- src/components/PoolList.vue | 110 +++++++++++++++++++++++-------- src/components/ProjectList.vue | 27 ++++++-- src/i18n.ts | 24 ++++++- src/style.css | 51 ++++++++++++--- 5 files changed, 258 insertions(+), 70 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 71516c8..6074962 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -264,6 +264,8 @@ struct PoolInfo { #[serde(rename = "credentialType")] credential_type: String, projects: Vec, + #[serde(rename = "hasCredentials")] + has_credentials: bool, } fn list_pools_in(paths: &Paths) -> Result, String> { @@ -278,10 +280,19 @@ fn list_pools_in(paths: &Paths) -> Result, String> { let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?; let pool: Pool = serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?; + // oauth: Credentials liegen im Keychain, dessen Prüfung wäre ein + // security-Aufruf pro Pool im 3-s-Polling — deshalb hier immer true. + let has_credentials = match pool.credential_type.as_str() { + "apikey" => fs::read_to_string(entry.path().join(APIKEY_FILE)) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false), + _ => true, + }; pools.push(PoolInfo { projects: projects_using_pool(paths, &pool.name)?, name: pool.name, credential_type: pool.credential_type, + has_credentials, }); } pools.sort_by(|a, b| a.name.cmp(&b.name)); @@ -346,14 +357,15 @@ fn create_oauth_pool_in( } /// Löscht einen Pool samt Ordner (inkl. Credentials); nur wenn kein Projekt ihn nutzt. +/// Löscht den Pool; Projekte, die ihn zugeordnet hatten, verlieren die +/// Zuordnung (Terminal-Einstellungen bleiben erhalten). fn delete_pool_in(paths: &Paths, name: &str) -> Result<(), String> { let dir = paths.pool_dir(name); if !dir.join(POOL_FILE).is_file() { return Err(format!("Pool nicht gefunden: {name}")); } - let users = projects_using_pool(paths, name)?; - if !users.is_empty() { - return Err(format!("Pool ist Projekten zugeordnet: {}", users.join(", "))); + for project in projects_using_pool(paths, name)? { + unassign_pool_in(paths, &project)?; } fs::remove_dir_all(&dir).map_err(|e| e.to_string()) } @@ -441,29 +453,59 @@ fn set_apikey(pool: String, key: String) -> Result<(), String> { set_apikey_in(&Paths::real(), &pool, &key) } -/// Meldet einen bestehenden oauth-Pool neu an. -#[tauri::command] -fn oauth_login(pool: String) -> Result<(), String> { - run_oauth_flow(&oauth_credential_path(&Paths::real(), &pool)?) +/// Keychain-Service-Name des Pools: claude legt pro CLAUDE_CONFIG_DIR einen +/// suffixierten Eintrag an, Suffix = erste 8 Hex-Zeichen von SHA-256 über +/// den Pool-Pfad. +fn keychain_service(paths: &Paths, pool: &str) -> String { + let hash = Sha256::digest(paths.pool_dir(pool).to_string_lossy().as_bytes()); + let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect(); + format!("Claude Code-credentials-{}", &hex[..8]) } -/// Erneuert das Access-Token über das gespeicherte Refresh-Token. -#[tauri::command] -fn oauth_refresh(pool: String) -> Result<(), String> { - let cred_path = oauth_credential_path(&Paths::real(), &pool)?; - let raw = fs::read_to_string(&cred_path) - .map_err(|e| format!("{}: {e}", cred_path.display()))?; - let v: serde_json::Value = serde_json::from_str(&raw).map_err(|e| e.to_string())?; - let refresh = v["claudeAiOauth"]["refreshToken"] - .as_str() - .ok_or("kein refreshToken in Credential-Datei")?; +fn keychain_entry_exists(service: &str) -> Result { + let out = Command::new("security") + .args(["find-generic-password", "-s", service]) + .output() + .map_err(|e| e.to_string())?; + Ok(out.status.success()) +} - let body = serde_json::json!({ - "grant_type": "refresh_token", - "refresh_token": refresh, - "client_id": CLIENT_ID, - }); - exchange_and_store(body, &cred_path) +#[tauri::command] +fn keychain_status(pool: String) -> Result { + keychain_entry_exists(&keychain_service(&Paths::real(), &pool)) +} + +/// Meldet einen oauth-Pool neu an: nur bei ungenutztem Pool. Löscht vorher +/// den suffixierten Keychain-Eintrag, damit der neue Login beim nächsten +/// Session-Start auch greift (claude importiert die frische +/// .credentials.json und legt den Eintrag neu an). +#[tauri::command] +fn oauth_login(pool: String) -> Result<(), String> { + let paths = Paths::real(); + let cred_path = oauth_credential_path(&paths, &pool)?; + + let running: Vec = projects_using_pool(&paths, &pool)? + .into_iter() + .filter(|p| is_running(p)) + .collect(); + if !running.is_empty() { + return Err(format!( + "Neuanmeldung nur bei ungenutztem Pool möglich — läuft: {}", + running.join(", ") + )); + } + + let service = keychain_service(&paths, &pool); + if keychain_entry_exists(&service)? { + let out = Command::new("security") + .args(["delete-generic-password", "-s", &service]) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + } + run_oauth_flow(&cred_path) } fn open_project(project: &str) -> Result<(), String> { @@ -841,7 +883,7 @@ fn main_builder() -> tauri::Builder { stop_project, restart_project, oauth_login, - oauth_refresh, + keychain_status, set_apikey, terminal::open_terminal ]) @@ -1037,14 +1079,32 @@ mod tests { } #[test] - fn pool_loeschen_mit_zuordnung_scheitert() { + fn pool_loeschen_loest_zuordnungen() { let p = tmp_paths(); create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); create_project_in(&p, "proj").unwrap(); assign_pool_in(&p, "proj", "kunde").unwrap(); - let err = delete_pool_in(&p, "kunde").unwrap_err(); - assert!(err.contains("proj")); - assert!(p.pool_dir("kunde").exists()); + delete_pool_in(&p, "kunde").unwrap(); + assert!(!p.pool_dir("kunde").exists()); + assert!(!p.project_config("proj").exists()); + } + + #[test] + fn pool_loeschen_erhaelt_terminal_config() { + let p = tmp_paths(); + create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); + create_project_in(&p, "proj").unwrap(); + assign_pool_in(&p, "proj", "kunde").unwrap(); + set_terminal_config_in( + &p, + "proj", + TerminalConfig { theme: Some("dracula".into()), icon: None, title: None }, + ) + .unwrap(); + delete_pool_in(&p, "kunde").unwrap(); + let cfg = read_project_config_in(&p, "proj").unwrap(); + assert_eq!(cfg.pool, None); + assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula")); } #[test] diff --git a/src/components/PoolList.vue b/src/components/PoolList.vue index f0f5397..4e7bcf0 100644 --- a/src/components/PoolList.vue +++ b/src/components/PoolList.vue @@ -9,6 +9,7 @@ interface Pool { name: string; credentialType: string; projects: string[]; + hasCredentials: boolean; } const pools = ref([]); @@ -79,6 +80,10 @@ async function submit() { const deleteName = ref(null); +const deleteProjects = computed( + () => pools.value.find((p) => p.name === deleteName.value)?.projects ?? [], +); + function askDelete(pool: string) { error.value = ""; deleteName.value = pool; @@ -100,26 +105,42 @@ async function confirmDelete() { } } -async function relogin(pool: string) { +interface Project { + name: string; + pool: string | null; + running: boolean; +} + +const reloginPool = ref(null); +const reloginHasEntry = ref(false); +const reloginRunning = ref([]); + +async function askRelogin(pool: string) { + error.value = ""; + try { + reloginHasEntry.value = await invoke("keychain_status", { pool }); + const projects = await invoke("list_projects"); + reloginRunning.value = projects + .filter((p) => p.pool === pool && p.running) + .map((p) => p.name); + reloginPool.value = pool; + } catch (e) { + error.value = String(e); + } +} + +async function confirmRelogin() { + const pool = reloginPool.value; + if (!pool) return; busy.value = true; error.value = ""; try { await invoke("oauth_login", { pool }); + reloginPool.value = null; await refresh(); } catch (e) { error.value = String(e); - } finally { - busy.value = false; - } -} - -async function renew(pool: string) { - busy.value = true; - error.value = ""; - try { - await invoke("oauth_refresh", { pool }); - } catch (e) { - error.value = String(e); + reloginPool.value = null; } finally { busy.value = false; } @@ -164,7 +185,7 @@ onMounted(refresh); {{ p.credentialType }} - + {{ $t("pools.assigned", p.projects.length) }} @@ -176,24 +197,16 @@ onMounted(refresh); - @@ -241,10 +254,55 @@ onMounted(refresh); +
+
+

{{ $t("pools.reloginTitle", { name: reloginPool }) }}

+ + +
+
+

{{ $t("pools.deletePool") }}

{{ $t("pools.deleteWarning", { name: deleteName }) }}

+
- @@ -223,9 +240,11 @@ onUnmounted(() => window.clearInterval(timer)); - - {{ settings.icon ?? $t("projects.defaultIcon") }} + + {{ settings.icon.split("/").pop() }} + {{ settings.icon }} + {{ $t("projects.defaultIcon") }}

{{ $t("projects.appliesNextStart") }}

diff --git a/src/i18n.ts b/src/i18n.ts index 783f2bf..fa41f6e 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -32,6 +32,8 @@ const de = { cancel: "Abbrechen", save: "Speichern", noPoolAssigned: "kein Pool", + needsPool: "Kein Pool zugeordnet", + needsKey: "Pool hat keinen API-Key", }, pools: { pool: "Pool", @@ -40,8 +42,8 @@ const de = { newOauth: "+ oAuth", newApikey: "+ apiKey", relogin: "Neu anmelden", - renewToken: "Token erneuern", changeKey: "Key ändern", + insertKey: "Key eintragen", delete: "Löschen", assigned: "kein Projekt | 1 Projekt | {count} Projekte", empty: "Noch keine Pools angelegt.", @@ -59,6 +61,14 @@ const de = { deletePool: "Pool löschen", deleteWarning: "Pool {name} und seine Credential-Datei werden gelöscht. Das lässt sich nicht rückgängig machen.", + deleteUnassigns: "Diese Projekte verlieren ihre Pool-Zuordnung:", + reloginTitle: "Neu anmelden – {name}", + reloginWarning: + "Der im Schlüsselbund gespeicherte Zugriffstoken von {name} wird gelöscht. Danach öffnet sich der Browser zur Neuanmeldung.", + reloginNoEntry: + "Kein Schlüsselbund-Eintrag vorhanden — es erfolgt nur die Neuanmeldung im Browser.", + reloginBlocked: + "Neuanmeldung ist nur bei ungenutztem Pool möglich. Diese Projekte laufen noch:", }, }; @@ -94,6 +104,8 @@ const en: typeof de = { cancel: "Cancel", save: "Save", noPoolAssigned: "no pool", + needsPool: "No pool assigned", + needsKey: "Pool has no API key", }, pools: { pool: "Pool", @@ -102,8 +114,8 @@ const en: typeof de = { newOauth: "+ oAuth", newApikey: "+ apiKey", relogin: "Sign in again", - renewToken: "Renew token", changeKey: "Change key", + insertKey: "Insert key", delete: "Delete", assigned: "no projects | 1 project | {count} projects", empty: "No pools yet.", @@ -121,6 +133,14 @@ const en: typeof de = { deletePool: "Delete pool", deleteWarning: "Pool {name} and its credential file will be deleted. This cannot be undone.", + deleteUnassigns: "These projects lose their pool assignment:", + reloginTitle: "Sign in again – {name}", + reloginWarning: + "The access token for {name} stored in the keychain will be deleted. The browser then opens for a new sign-in.", + reloginNoEntry: + "No keychain entry present — only the new sign-in happens.", + reloginBlocked: + "Signing in again requires an unused pool. These projects are still running:", }, }; diff --git a/src/style.css b/src/style.css index 79932da..67c416c 100644 --- a/src/style.css +++ b/src/style.css @@ -204,6 +204,10 @@ button.danger:hover:not(:disabled) { gap: 0.4rem; } +.row-actions .w-action { + width: 9.5rem; +} + button.gear { display: inline-flex; align-items: center; @@ -238,7 +242,7 @@ col.col-actions { width: 15rem; } col.col-actions-wide { - width: 19rem; + width: 16rem; } col.col-type { width: 7rem; @@ -330,23 +334,20 @@ td select { color: var(--peach); } -/* Zugeordnete Projekte: Anzahl in der Zeile, Liste im Hover. */ -.assigned { +/* Hover-Popover: kompakter Text in der Zeile, Details im Hover. */ +.hover-pop { position: relative; - color: var(--subtext); - font-size: 0.8rem; cursor: default; - border-bottom: 1px dotted var(--surface2); - padding-bottom: 1px; } -.assigned .pop { +.hover-pop .pop { display: none; position: absolute; top: calc(100% + 8px); left: 0; z-index: 20; min-width: 13rem; + max-width: 26rem; flex-direction: column; gap: 0.4rem; background: var(--crust); @@ -356,10 +357,26 @@ td select { box-shadow: 0 12px 32px rgba(0, 0, 0, 0.55); } -.assigned:hover .pop { +.hover-pop:hover .pop { display: flex; } +.pop-path { + font-family: var(--mono); + font-size: 0.75rem; + line-height: 1.5; + color: var(--text); + word-break: break-all; +} + +/* Zugeordnete Projekte: Anzahl in der Zeile, Liste im Hover. */ +.assigned { + color: var(--subtext); + font-size: 0.8rem; + border-bottom: 1px dotted var(--surface2); + padding-bottom: 1px; +} + .pop-item { font-family: var(--mono); font-size: 0.8rem; @@ -442,7 +459,10 @@ td select { font-family: var(--mono); color: var(--overlay); font-size: 0.75rem; - word-break: break-all; + max-width: 11rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .dialog .pools { @@ -470,6 +490,17 @@ td select { color: var(--overlay); } +.dialog .affected { + margin: 0; + padding-left: 1.1rem; + display: flex; + flex-direction: column; + gap: 0.3rem; + font-family: var(--mono); + font-size: 0.8rem; + color: var(--text); +} + .dialog .actions { display: flex; justify-content: flex-end;