generated from nhcarrigan/template
feat: add auth status display and account management to settings sidebar
Implements issue #153. Adds Account section to ConfigSidebar with: - Claude auth status (logged in/out, email, org, plan, API source) - API key override indicator reading from local Hikari config - Login/logout action buttons - Refresh button for manual status updates Adds Rust commands: get_auth_status, auth_login, auth_logout
This commit is contained in:
@@ -1360,6 +1360,136 @@ pub async fn get_claude_version() -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Auth Commands ====================
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ClaudeAuthStatus {
|
||||||
|
pub is_logged_in: bool,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub org_name: Option<String>,
|
||||||
|
pub api_key_source: Option<String>,
|
||||||
|
pub api_provider: Option<String>,
|
||||||
|
pub subscription_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_auth_status() -> Result<ClaudeAuthStatus, String> {
|
||||||
|
tracing::debug!("Getting Claude auth status");
|
||||||
|
|
||||||
|
let output = create_claude_command()
|
||||||
|
.args(["auth", "status"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to run claude auth status: {}", e))?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||||
|
|
||||||
|
if let Ok(json) = serde_json::from_str::<serde_json::Value>(raw) {
|
||||||
|
let is_logged_in = json
|
||||||
|
.get("loggedIn")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let email = json
|
||||||
|
.get("email")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
let org_name = json
|
||||||
|
.get("orgName")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
let api_key_source = json
|
||||||
|
.get("apiKeySource")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
let api_provider = json
|
||||||
|
.get("apiProvider")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
let subscription_type = json
|
||||||
|
.get("subscriptionType")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
tracing::info!("Claude auth status: logged_in={}", is_logged_in);
|
||||||
|
Ok(ClaudeAuthStatus {
|
||||||
|
is_logged_in,
|
||||||
|
email,
|
||||||
|
org_name,
|
||||||
|
api_key_source,
|
||||||
|
api_provider,
|
||||||
|
subscription_type,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Non-JSON output: fall back to heuristic
|
||||||
|
let lower = raw.to_lowercase();
|
||||||
|
let is_logged_in = output.status.success()
|
||||||
|
&& !lower.contains("not logged in")
|
||||||
|
&& !lower.contains("not authenticated")
|
||||||
|
&& !lower.contains("no account");
|
||||||
|
tracing::info!("Claude auth status (non-JSON): logged_in={}", is_logged_in);
|
||||||
|
Ok(ClaudeAuthStatus {
|
||||||
|
is_logged_in,
|
||||||
|
email: None,
|
||||||
|
org_name: None,
|
||||||
|
api_key_source: None,
|
||||||
|
api_provider: None,
|
||||||
|
subscription_type: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn auth_login() -> Result<String, String> {
|
||||||
|
tracing::info!("Running claude auth login");
|
||||||
|
|
||||||
|
let output = create_claude_command()
|
||||||
|
.args(["auth", "login"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to run claude auth login: {}", e))?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
let message = if stdout.is_empty() { "Login successful".to_string() } else { stdout };
|
||||||
|
tracing::info!("Claude auth login succeeded");
|
||||||
|
Ok(message)
|
||||||
|
} else {
|
||||||
|
let error = if stderr.is_empty() { stdout } else { stderr };
|
||||||
|
tracing::error!("Claude auth login failed: {}", error);
|
||||||
|
Err(format!("Login failed: {}", error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn auth_logout() -> Result<String, String> {
|
||||||
|
tracing::info!("Running claude auth logout");
|
||||||
|
|
||||||
|
let output = create_claude_command()
|
||||||
|
.args(["auth", "logout"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to run claude auth logout: {}", e))?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
let message = if stdout.is_empty() { "Logged out successfully".to_string() } else { stdout };
|
||||||
|
tracing::info!("Claude auth logout succeeded");
|
||||||
|
Ok(message)
|
||||||
|
} else {
|
||||||
|
let error = if stderr.is_empty() { stdout } else { stderr };
|
||||||
|
tracing::error!("Claude auth logout failed: {}", error);
|
||||||
|
Err(format!("Logout failed: {}", error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Plugin Management Commands ====================
|
// ==================== Plugin Management Commands ====================
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -195,6 +195,9 @@ pub fn run() {
|
|||||||
close_application,
|
close_application,
|
||||||
list_memory_files,
|
list_memory_files,
|
||||||
get_claude_version,
|
get_claude_version,
|
||||||
|
get_auth_status,
|
||||||
|
auth_login,
|
||||||
|
auth_logout,
|
||||||
list_plugins,
|
list_plugins,
|
||||||
install_plugin,
|
install_plugin,
|
||||||
uninstall_plugin,
|
uninstall_plugin,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
} from "$lib/stores/config";
|
} from "$lib/stores/config";
|
||||||
import { claudeStore } from "$lib/stores/claude";
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import CostSummary from "./CostSummary.svelte";
|
import CostSummary from "./CostSummary.svelte";
|
||||||
|
|
||||||
let config: HikariConfig = $state({
|
let config: HikariConfig = $state({
|
||||||
@@ -56,6 +57,20 @@
|
|||||||
|
|
||||||
let showCustomThemeEditor = $state(false);
|
let showCustomThemeEditor = $state(false);
|
||||||
|
|
||||||
|
interface AuthStatus {
|
||||||
|
is_logged_in: boolean;
|
||||||
|
email: string | null;
|
||||||
|
org_name: string | null;
|
||||||
|
api_key_source: string | null;
|
||||||
|
api_provider: string | null;
|
||||||
|
subscription_type: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let authStatus: AuthStatus | null = $state(null);
|
||||||
|
let authLoading = $state(false);
|
||||||
|
let authActionLoading = $state(false);
|
||||||
|
let authError: string | null = $state(null);
|
||||||
|
|
||||||
let isOpen = $state(false);
|
let isOpen = $state(false);
|
||||||
let isSaving = $state(false);
|
let isSaving = $state(false);
|
||||||
let saveError: string | null = $state(null);
|
let saveError: string | null = $state(null);
|
||||||
@@ -69,6 +84,9 @@
|
|||||||
|
|
||||||
configStore.isSidebarOpen.subscribe((open) => {
|
configStore.isSidebarOpen.subscribe((open) => {
|
||||||
isOpen = open;
|
isOpen = open;
|
||||||
|
if (open && authStatus === null) {
|
||||||
|
void refreshAuthStatus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
configStore.saveError.subscribe((error) => {
|
configStore.saveError.subscribe((error) => {
|
||||||
@@ -111,6 +129,44 @@
|
|||||||
"Task",
|
"Task",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
async function refreshAuthStatus() {
|
||||||
|
authLoading = true;
|
||||||
|
authError = null;
|
||||||
|
try {
|
||||||
|
authStatus = await invoke<AuthStatus>("get_auth_status");
|
||||||
|
} catch (e) {
|
||||||
|
authError = String(e);
|
||||||
|
} finally {
|
||||||
|
authLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAuthLogin() {
|
||||||
|
authActionLoading = true;
|
||||||
|
authError = null;
|
||||||
|
try {
|
||||||
|
await invoke<string>("auth_login");
|
||||||
|
await refreshAuthStatus();
|
||||||
|
} catch (e) {
|
||||||
|
authError = String(e);
|
||||||
|
} finally {
|
||||||
|
authActionLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAuthLogout() {
|
||||||
|
authActionLoading = true;
|
||||||
|
authError = null;
|
||||||
|
try {
|
||||||
|
await invoke<string>("auth_logout");
|
||||||
|
await refreshAuthStatus();
|
||||||
|
} catch (e) {
|
||||||
|
authError = String(e);
|
||||||
|
} finally {
|
||||||
|
authActionLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
saveError = null;
|
saveError = null;
|
||||||
@@ -228,6 +284,101 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Account Section -->
|
||||||
|
<section class="mb-6">
|
||||||
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
|
Account
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{#if authLoading}
|
||||||
|
<div class="text-sm text-[var(--text-secondary)] py-2">Checking auth status...</div>
|
||||||
|
{:else if authStatus}
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span
|
||||||
|
class="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 {authStatus.is_logged_in
|
||||||
|
? 'bg-green-500'
|
||||||
|
: 'bg-red-500'}"
|
||||||
|
></span>
|
||||||
|
<span class="text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
{authStatus.is_logged_in ? "Logged in" : "Not logged in"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if authStatus.email || authStatus.org_name || authStatus.api_key_source || config.api_key}
|
||||||
|
<dl class="text-xs space-y-1 mb-3">
|
||||||
|
{#if authStatus.email}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Email</dt>
|
||||||
|
<dd class="text-[var(--text-primary)] break-all">{authStatus.email}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if authStatus.org_name}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Org</dt>
|
||||||
|
<dd class="text-[var(--text-primary)]">{authStatus.org_name}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if authStatus.api_key_source}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">API key</dt>
|
||||||
|
<dd class="text-[var(--text-primary)]">{authStatus.api_key_source}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if authStatus.subscription_type}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Plan</dt>
|
||||||
|
<dd class="text-[var(--text-primary)]">{authStatus.subscription_type}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Override</dt>
|
||||||
|
<dd class="text-[var(--text-primary)]">
|
||||||
|
{#if config.api_key}
|
||||||
|
{config.streamer_mode ? "Custom key set 🔒" : "Custom key set"}
|
||||||
|
{:else}
|
||||||
|
None
|
||||||
|
{/if}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="text-sm text-[var(--text-secondary)] py-2">Auth status unavailable</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if authError}
|
||||||
|
<div class="mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-red-400 text-xs">
|
||||||
|
{authError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick={refreshAuthStatus}
|
||||||
|
disabled={authLoading || authActionLoading}
|
||||||
|
class="px-3 py-1.5 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-secondary)] hover:border-[var(--accent-primary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
{#if authStatus && !authStatus.is_logged_in}
|
||||||
|
<button
|
||||||
|
onclick={handleAuthLogin}
|
||||||
|
disabled={authActionLoading}
|
||||||
|
class="btn-trans-gradient px-3 py-1.5 text-sm rounded-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{authActionLoading ? "Logging in..." : "Login"}
|
||||||
|
</button>
|
||||||
|
{:else if authStatus && authStatus.is_logged_in}
|
||||||
|
<button
|
||||||
|
onclick={handleAuthLogout}
|
||||||
|
disabled={authActionLoading}
|
||||||
|
class="px-3 py-1.5 text-sm bg-red-500/20 border border-red-500/50 rounded-lg text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{authActionLoading ? "Logging out..." : "Logout"}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Agent Settings Section -->
|
<!-- Agent Settings Section -->
|
||||||
<section class="mb-6">
|
<section class="mb-6">
|
||||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user