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 ====================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -195,6 +195,9 @@ pub fn run() {
|
||||
close_application,
|
||||
list_memory_files,
|
||||
get_claude_version,
|
||||
get_auth_status,
|
||||
auth_login,
|
||||
auth_logout,
|
||||
list_plugins,
|
||||
install_plugin,
|
||||
uninstall_plugin,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
} from "$lib/stores/config";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import CostSummary from "./CostSummary.svelte";
|
||||
|
||||
let config: HikariConfig = $state({
|
||||
@@ -56,6 +57,20 @@
|
||||
|
||||
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 isSaving = $state(false);
|
||||
let saveError: string | null = $state(null);
|
||||
@@ -69,6 +84,9 @@
|
||||
|
||||
configStore.isSidebarOpen.subscribe((open) => {
|
||||
isOpen = open;
|
||||
if (open && authStatus === null) {
|
||||
void refreshAuthStatus();
|
||||
}
|
||||
});
|
||||
|
||||
configStore.saveError.subscribe((error) => {
|
||||
@@ -111,6 +129,44 @@
|
||||
"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() {
|
||||
isSaving = true;
|
||||
saveError = null;
|
||||
@@ -228,6 +284,101 @@
|
||||
</div>
|
||||
{/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 -->
|
||||
<section class="mb-6">
|
||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||
|
||||
Reference in New Issue
Block a user