Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src-tauri/src/device_activation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,10 @@ pub struct ActivatedResult {
pub limit: u32,
pub used: u32,
/// Device-bound 30-day lease — persisted into the session store.
/// The backend keeps `access_token`-style snake_case for token fields.
#[serde(rename = "refresh_token", default)]
/// The backend keeps `access_token`-style snake_case for token fields;
/// `refreshToken` is aliased because camelCase responses were observed
/// in the wild and a silent None here is what kills session renewal.
#[serde(rename = "refresh_token", alias = "refreshToken", default)]
pub refresh_token: Option<String>,
}

Expand Down Expand Up @@ -295,6 +297,17 @@ mod tests {
let result: ActivatedResult = serde_json::from_value(raw).expect("result");
assert_eq!(result.refresh_token.as_deref(), Some("opaque-lease"));

// camelCase lease responses must land too — a silent None here is
// exactly what broke session renewal in the field.
let camel = json!({
"deviceId": "dev_9",
"limit": 3,
"used": 3,
"refreshToken": "camel-lease",
});
let result: ActivatedResult = serde_json::from_value(camel).expect("camel result");
assert_eq!(result.refresh_token.as_deref(), Some("camel-lease"));

// tolerate responses without the lease (older backends)
let bare: ActivatedResult =
serde_json::from_value(json!({ "deviceId": "d", "limit": 3, "used": 1 }))
Expand Down
20 changes: 20 additions & 0 deletions src-tauri/src/entitlement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,26 @@ pub fn get_entitlement(state: State<'_, EntitlementState>) -> EntitlementView {
state.view(true, None)
}

/// Seed the entitlement cache from the login handoff so the plan shows
/// instantly; the regular refresh remains the source of truth.
#[tauri::command]
pub fn seed_entitlement(
ultimate_expires_at: Option<String>,
version_lock_horizon: Option<String>,
cancel_scheduled_at: Option<String>,
state: State<'_, EntitlementState>,
) -> EntitlementView {
if ultimate_expires_at.is_some() || version_lock_horizon.is_some() {
state.set_cache(SubscriptionCache {
fetched_at_ms: now_unix_ms(),
ultimate_expires_at,
version_lock_horizon,
cancel_scheduled_at,
});
}
state.view(true, None)
}

/// Called on logout — entitlements are account-scoped, so the cached state
/// must not outlive the session (a different account on the same machine
/// must not inherit the previous account's version lock).
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,10 @@ pub fn run() {
crate::consume_pending_auth,
crate::entitlement::refresh_entitlement,
crate::entitlement::get_entitlement,
crate::entitlement::seed_entitlement,
crate::entitlement::clear_entitlement,
crate::session::rotate_session_now,
crate::session::revoke_session,
crate::device_activation::activate_device,
crate::mcp_bridge::get_mcp_status,
crate::mcp_bridge::save_mcp_config,
Expand Down
46 changes: 46 additions & 0 deletions src-tauri/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use serde::{Deserialize, Serialize};
use serde_json::json;
use tauri::{AppHandle, Emitter, State};

use crate::common::console;
use crate::device_identity;
Expand Down Expand Up @@ -93,6 +94,51 @@ pub async fn rotate_session(
Ok(session)
}

/// Proactive renewal: returns the successor pair (also emitted via
/// `session-refreshed`); rejections carry `SESSION_REJECTED` for the
/// frontend to drop the lease.
#[tauri::command]
pub async fn rotate_session_now(
refresh_token: Option<String>,
state: State<'_, SessionState>,
identity: State<'_, crate::device_activation::DeviceIdentityState>,
app: AppHandle,
) -> Result<RefreshedSession, String> {
let lease = refresh_token.as_deref().unwrap_or("").trim().to_string();
if lease.is_empty() {
return Err("no stored refresh token".to_string());
}
let refreshed = rotate_session(&state, &lease, &identity.payload()).await?;
let _ = app.emit(
"session-refreshed",
json!({
"accessToken": refreshed.access_token,
"refreshToken": refreshed.refresh_token,
}),
);
Ok(refreshed)
}

/// Logout: best-effort revocation of the presented lease; idempotent and
/// non-fatal by contract — the client clears its local copy regardless.
#[tauri::command]
pub async fn revoke_session(refresh_token: Option<String>) -> Result<(), String> {
let lease = refresh_token.as_deref().unwrap_or("").trim();
if lease.is_empty() {
return Ok(());
}
let response = console::client()
.post(format!("{}/api/v1/auth/logout", console::api_base_url()))
.json(&json!({ "refresh_token": lease }))
.send()
.await
.map_err(|e| format!("network error: {e}"))?;
if !response.status().is_success() {
return Err(format!("logout endpoint returned HTTP {}", response.status()));
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
28 changes: 27 additions & 1 deletion src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { listen } from '@tauri-apps/api/event'
import { storeToRefs } from 'pinia'
import { onMounted, onUnmounted, watch } from 'vue'
import { RouterView } from 'vue-router'
import { shouldRotateToken } from '@/common'
import DeviceReplaceDialog from '@/components/DeviceReplaceDialog.vue'
import AppNotifications from '@/components/ui/notification/AppNotifications.vue'
import UpdateNotification from '@/components/UpdateNotification.vue'
Expand All @@ -21,6 +22,9 @@ type AuthPayload = {
email: string
userId?: string
avatar?: string
ultimateExpiresAt?: string | null
versionLockHorizon?: string | null
cancelScheduledAt?: string | null
}

const appStore = useAppStore()
Expand All @@ -39,18 +43,40 @@ let unlistenAuth: UnlistenFn | null = null
let unlistenSessionRefresh: UnlistenFn | null = null

// Idempotent: events and the cold-start pull may both deliver the same link.
function handleAuth(payload: AuthPayload) {
async function handleAuth(payload: AuthPayload) {
accountStore.setAuth(payload.token, payload.username, payload.email, payload.userId, payload.avatar)
// Await the local seed so it can never land after the network refresh
// below and overwrite a fresher server answer with the snapshot.
await entitlementStore.seedFromHandoff(payload)
entitlementStore.refreshEntitlement(true)
// The deep-linked token comes from a web login with no device attached —
// register/verify this machine right away.
deviceStore.ensureActivated(true)
}

async function rotateStaleSession() {
if (!accountStore.refreshToken || !shouldRotateToken(accountStore.token, accountStore.refreshToken))
return
try {
const refreshed = await invoke<{ access_token: string, refresh_token: string }>(
'rotate_session_now',
{ refreshToken: accountStore.refreshToken },
)
accountStore.setToken(refreshed.access_token)
accountStore.setRefreshToken(refreshed.refresh_token)
}
catch {
// rejected or transient — the reactive 401 paths still recover
}
}

onMounted(async () => {
checkForUpdates(false)

if (accountStore.isLoggedIn) {
await entitlementStore.hydrate()
// Rotate before the refresh so it goes out with a valid bearer.
await rotateStaleSession()
entitlementStore.refreshEntitlement(true)
// geekfun#59: entitlement-activation point — register/verify this device.
deviceStore.ensureActivated()
Expand Down
1 change: 1 addition & 0 deletions src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
export const pureObject = <T>(obj: T): T => JSON.parse(JSON.stringify(obj)) as T

export * from './entitlement'
export * from './sessionExpiry'
export * from './sqlParser'
28 changes: 28 additions & 0 deletions src/common/sessionExpiry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const TOKEN_EXPIRY_MARGIN_MS = 10 * 60 * 1000

export function parseJwtExpMs(token: string): number | null {
const parts = token.split('.')
if (parts.length !== 3 || !parts[1])
return null
try {
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/')
const payload = JSON.parse(atob(b64)) as { exp?: unknown }
const exp = payload.exp
return typeof exp === 'number' && Number.isFinite(exp) && exp > 0 ? exp * 1000 : null
}
catch {
return null
}
}

/** Opaque (non-JWT) tokens return false — the reactive 401 path decides. */
export function shouldRotateToken(token: string, refreshToken: string, now = Date.now(), marginMs = TOKEN_EXPIRY_MARGIN_MS): boolean {
if (!refreshToken.trim())
return false
if (!token.trim())
return true
const expMs = parseJwtExpMs(token)
if (expMs === null)
return false
return expMs - now <= marginMs
}
142 changes: 73 additions & 69 deletions src/components/layout/AiAssistantSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ChatPanel from '@/components/chat-panel.vue'
// NOTE: without this import the <PaidGate> tag resolves to an unknown element
import { PaidGate } from '@/components/upgrade'
import { useSidebarChatAgent } from '@/composables/useSidebarChatAgent'
import { useDataStudioStore } from '@/store/dataStudioStore'
import SessionHistoryPanel from '@/views/data-studio/components/session-history-panel.vue'
Expand Down Expand Up @@ -106,76 +108,78 @@ onUnmounted(() => {
<div class="ai-assistant-resizable" :style="{ width: `${currentWidth}px` }">
<div class="resize-handle" @mousedown="startResize" />

<!-- Session history slide-over -->
<transition name="history-slide">
<div v-if="historyPanelOpen" class="ai-assistant-history-overlay">
<SessionHistoryPanel
@select="switchSession"
@delete="deleteSession"
@new-session="handleNewSession"
@close="historyPanelOpen = false"
/>
</div>
</transition>

<ChatPanel
:messages="messages"
:is-loading="isLoading"
:error="error"
:empty-hint="t('dataStudio.agent.emptyState')"
:input-placeholder="t('dataStudio.inputPlaceholder')"
:session-id="activeSession?.id ?? null"
:context-settings="lastSettings"
:stop-reason="stopReason"
:stop-message="stopMessage"
:progress="progress"
feature="sidebarAssistant"
compact
@send="sendMessage"
@stop-loop="cancelSession"
@confirm-tool-call="handleConfirmation"
@model-change="onModelChange"
@dismiss-error="dismissError"
>
<template #header>
<div class="header-row">
<span class="header-title">
{{ $t('aside.aiAssistant') }}
</span>
<div class="header-actions">
<button
class="header-icon-btn"
:title="t('dataStudio.history.newSession')"
@click="handleNewSession"
>
<span class="i-carbon-add h-3.5 w-3.5" />
</button>
<button
class="header-icon-btn"
:class="{ 'header-icon-btn--active': historyPanelOpen }"
:title="t('dataStudio.history.title')"
@click="historyPanelOpen = !historyPanelOpen"
>
<span class="i-carbon-time h-3.5 w-3.5" />
</button>
<button
class="header-icon-btn"
:title="t('dataStudio.agent.clearChat')"
@click="handleClearChat"
>
<span class="i-carbon-trash-can h-3.5 w-3.5" />
</button>
<button
class="header-icon-btn header-icon-btn--close"
:title="t('common.buttons.close')"
@click="handleClose"
>
<span class="i-carbon-close h-3.5 w-3.5" />
</button>
</div>
<PaidGate feature="ai" compact class="h-full w-full">
<!-- Session history slide-over -->
<transition name="history-slide">
<div v-if="historyPanelOpen" class="ai-assistant-history-overlay">
<SessionHistoryPanel
@select="switchSession"
@delete="deleteSession"
@new-session="handleNewSession"
@close="historyPanelOpen = false"
/>
</div>
</template>
</ChatPanel>
</transition>

<ChatPanel
:messages="messages"
:is-loading="isLoading"
:error="error"
:empty-hint="t('dataStudio.agent.emptyState')"
:input-placeholder="t('dataStudio.inputPlaceholder')"
:session-id="activeSession?.id ?? null"
:context-settings="lastSettings"
:stop-reason="stopReason"
:stop-message="stopMessage"
:progress="progress"
feature="sidebarAssistant"
compact
@send="sendMessage"
@stop-loop="cancelSession"
@confirm-tool-call="handleConfirmation"
@model-change="onModelChange"
@dismiss-error="dismissError"
>
<template #header>
<div class="header-row">
<span class="header-title">
{{ $t('aside.aiAssistant') }}
</span>
<div class="header-actions">
<button
class="header-icon-btn"
:title="t('dataStudio.history.newSession')"
@click="handleNewSession"
>
<span class="i-carbon-add h-3.5 w-3.5" />
</button>
<button
class="header-icon-btn"
:class="{ 'header-icon-btn--active': historyPanelOpen }"
:title="t('dataStudio.history.title')"
@click="historyPanelOpen = !historyPanelOpen"
>
<span class="i-carbon-time h-3.5 w-3.5" />
</button>
<button
class="header-icon-btn"
:title="t('dataStudio.agent.clearChat')"
@click="handleClearChat"
>
<span class="i-carbon-trash-can h-3.5 w-3.5" />
</button>
<button
class="header-icon-btn header-icon-btn--close"
:title="t('common.buttons.close')"
@click="handleClose"
>
<span class="i-carbon-close h-3.5 w-3.5" />
</button>
</div>
</div>
</template>
</ChatPanel>
</PaidGate>
</div>
</template>

Expand Down
Loading
Loading