Skip to content

Commit e1f10a4

Browse files
authored
Merge pull request #8 from LeslieLeung/claude/persist-electron-login-01JR43KbqHC57U8B8ZQC4Nr2
2 parents 5e53652 + 9fe3a2d commit e1f10a4

9 files changed

Lines changed: 184 additions & 26 deletions

File tree

frontend/apps/web/electron/main.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ const __dirname = path.dirname(__filename)
1010
// Configuration store
1111
interface StoreType {
1212
apiUrl: string
13+
accessToken?: string
14+
refreshToken?: string
1315
}
1416

1517
/**
@@ -387,3 +389,40 @@ ipcMain.on('open-config-window', () => {
387389
console.log('[Main] Received request to open config window')
388390
createConfigWindow()
389391
})
392+
393+
// IPC handler: get access token
394+
ipcMain.handle('get-access-token', () => {
395+
return store.get('accessToken') || null
396+
})
397+
398+
// IPC handler: get refresh token
399+
ipcMain.handle('get-refresh-token', () => {
400+
return store.get('refreshToken') || null
401+
})
402+
403+
// IPC handler: set access token
404+
ipcMain.handle('set-access-token', (_event, token: string | null) => {
405+
if (token === null) {
406+
store.delete('accessToken')
407+
} else {
408+
store.set('accessToken', token)
409+
}
410+
return true
411+
})
412+
413+
// IPC handler: set refresh token
414+
ipcMain.handle('set-refresh-token', (_event, token: string | null) => {
415+
if (token === null) {
416+
store.delete('refreshToken')
417+
} else {
418+
store.set('refreshToken', token)
419+
}
420+
return true
421+
})
422+
423+
// IPC handler: clear all tokens
424+
ipcMain.handle('clear-tokens', () => {
425+
store.delete('accessToken')
426+
store.delete('refreshToken')
427+
return true
428+
})

frontend/apps/web/electron/preload.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
1111
// 获取平台信息
1212
getPlatform: () => ipcRenderer.invoke('get-platform'),
1313

14+
// Token management
15+
getAccessToken: () => ipcRenderer.invoke('get-access-token'),
16+
getRefreshToken: () => ipcRenderer.invoke('get-refresh-token'),
17+
setAccessToken: (token: string | null) => ipcRenderer.invoke('set-access-token', token),
18+
setRefreshToken: (token: string | null) => ipcRenderer.invoke('set-refresh-token', token),
19+
clearTokens: () => ipcRenderer.invoke('clear-tokens'),
20+
1421
// 检查是否在 Electron 环境中
1522
isElectron: true
1623
})
@@ -25,6 +32,11 @@ export interface ElectronAPI {
2532
version: string
2633
name: string
2734
}>
35+
getAccessToken: () => Promise<string | null>
36+
getRefreshToken: () => Promise<string | null>
37+
setAccessToken: (token: string | null) => Promise<boolean>
38+
setRefreshToken: (token: string | null) => Promise<boolean>
39+
clearTokens: () => Promise<boolean>
2840
isElectron: boolean
2941
}
3042

frontend/apps/web/src/App.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { Routes, Route, Navigate } from 'react-router-dom'
22
import { ProtectedRoute } from './components/ProtectedRoute'
33
import { Layout } from './components/Layout'
44
import { Rss } from 'lucide-react'
5+
import { useAuthStore } from './stores/authStore'
56

67
// Lazy load pages
7-
import { lazy, Suspense } from 'react'
8+
import { lazy, Suspense, useEffect, useState } from 'react'
89

910
const LoginPage = lazy(() => import('./pages/LoginPage'))
1011
const RegisterPage = lazy(() => import('./pages/RegisterPage'))
@@ -39,6 +40,21 @@ function LoadingSpinner() {
3940
* Defines the main routing structure for the web application.
4041
*/
4142
function App() {
43+
const { loadUser } = useAuthStore()
44+
const [isInitialized, setIsInitialized] = useState(false)
45+
46+
useEffect(() => {
47+
// Initialize authentication state on app startup
48+
loadUser().finally(() => {
49+
setIsInitialized(true)
50+
})
51+
}, [loadUser])
52+
53+
// Show loading spinner while initializing authentication
54+
if (!isInitialized) {
55+
return <LoadingSpinner />
56+
}
57+
4258
return (
4359
<Suspense fallback={<LoadingSpinner />}>
4460
<Routes>

frontend/apps/web/src/electron.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ declare global {
1010
version: string
1111
name: string
1212
}>
13+
getAccessToken: () => Promise<string | null>
14+
getRefreshToken: () => Promise<string | null>
15+
setAccessToken: (token: string | null) => Promise<boolean>
16+
setRefreshToken: (token: string | null) => Promise<boolean>
17+
clearTokens: () => Promise<boolean>
1318
isElectron: boolean
1419
}
1520
}

frontend/apps/web/src/stores/authStore.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ interface AuthState {
2626
*/
2727
export const useAuthStore = create<AuthState>((set) => ({
2828
user: null,
29-
isAuthenticated: authService.isAuthenticated(),
29+
isAuthenticated: false,
3030
isLoading: false,
3131
error: null,
3232

@@ -84,7 +84,8 @@ export const useAuthStore = create<AuthState>((set) => ({
8484
},
8585

8686
loadUser: async () => {
87-
if (!authService.isAuthenticated()) {
87+
const isAuthenticated = await authService.isAuthenticated()
88+
if (!isAuthenticated) {
8889
set({ isAuthenticated: false, user: null })
8990
return
9091
}
@@ -98,7 +99,7 @@ export const useAuthStore = create<AuthState>((set) => ({
9899
isLoading: false,
99100
})
100101
} catch (error) {
101-
authService.clearTokens()
102+
await authService.clearTokens()
102103
set({
103104
user: null,
104105
isAuthenticated: false,

frontend/packages/api-client/src/client.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import axios, { type AxiosInstance, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
2+
import { tokenStorage } from './tokenStorage'
23

34
/**
45
* API client for communicating with the Glean backend.
@@ -59,7 +60,7 @@ export class ApiClient {
5960
}
6061
}
6162

62-
const token = localStorage.getItem('access_token')
63+
const token = await tokenStorage.getAccessToken()
6364
if (token) {
6465
config.headers.Authorization = `Bearer ${token}`
6566
}
@@ -79,14 +80,14 @@ export class ApiClient {
7980

8081
// Don't try to refresh if this is already a refresh request or auth request
8182
if (originalRequest.url?.includes('/auth/refresh') || originalRequest.url?.includes('/auth/login')) {
82-
this.clearTokensAndRedirect()
83+
await this.clearTokensAndRedirect()
8384
return Promise.reject(error)
8485
}
8586

8687
// Check if we have a refresh token
87-
const refreshToken = localStorage.getItem('refresh_token')
88+
const refreshToken = await tokenStorage.getRefreshToken()
8889
if (!refreshToken) {
89-
this.clearTokensAndRedirect()
90+
await this.clearTokensAndRedirect()
9091
return Promise.reject(error)
9192
}
9293

@@ -115,8 +116,8 @@ export class ApiClient {
115116
const { access_token, refresh_token: newRefreshToken } = response.data
116117

117118
// Save new tokens
118-
localStorage.setItem('access_token', access_token)
119-
localStorage.setItem('refresh_token', newRefreshToken)
119+
await tokenStorage.setAccessToken(access_token)
120+
await tokenStorage.setRefreshToken(newRefreshToken)
120121

121122
// Update authorization header
122123
originalRequest.headers.Authorization = `Bearer ${access_token}`
@@ -129,7 +130,7 @@ export class ApiClient {
129130
} catch (refreshError) {
130131
// Refresh failed, clear tokens and redirect to login
131132
this.processQueue(refreshError, null)
132-
this.clearTokensAndRedirect()
133+
await this.clearTokensAndRedirect()
133134
return Promise.reject(refreshError)
134135
} finally {
135136
this.isRefreshing = false
@@ -218,9 +219,8 @@ export class ApiClient {
218219
/**
219220
* Clear tokens and redirect to login page.
220221
*/
221-
private clearTokensAndRedirect(): void {
222-
localStorage.removeItem('access_token')
223-
localStorage.removeItem('refresh_token')
222+
private async clearTokensAndRedirect(): Promise<void> {
223+
await tokenStorage.clearTokens()
224224
// Only redirect if not already on login page
225225
if (!window.location.pathname.includes('/login')) {
226226
window.location.href = '/login'

frontend/packages/api-client/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export { EntryService } from './services/entries'
1010
export { FolderService } from './services/folders'
1111
export { TagService } from './services/tags'
1212
export { BookmarkService, type BookmarkListParams } from './services/bookmarks'
13+
export { tokenStorage } from './tokenStorage'
1314

1415
// Create service instances
1516
import { apiClient } from './client'

frontend/packages/api-client/src/services/auth.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
UserUpdateRequest,
99
} from '@glean/types'
1010
import { ApiClient } from '../client'
11+
import { tokenStorage } from '../tokenStorage'
1112

1213
/**
1314
* Authentication API service.
@@ -43,8 +44,7 @@ export class AuthService {
4344
*/
4445
async logout(): Promise<void> {
4546
await this.client.post<{ message: string }>('/auth/logout')
46-
localStorage.removeItem('access_token')
47-
localStorage.removeItem('refresh_token')
47+
await tokenStorage.clearTokens()
4848
}
4949

5050
/**
@@ -62,25 +62,24 @@ export class AuthService {
6262
}
6363

6464
/**
65-
* Save authentication tokens to local storage.
65+
* Save authentication tokens to storage.
6666
*/
67-
saveTokens(tokens: TokenResponse): void {
68-
localStorage.setItem('access_token', tokens.access_token)
69-
localStorage.setItem('refresh_token', tokens.refresh_token)
67+
async saveTokens(tokens: TokenResponse): Promise<void> {
68+
await tokenStorage.setAccessToken(tokens.access_token)
69+
await tokenStorage.setRefreshToken(tokens.refresh_token)
7070
}
7171

7272
/**
73-
* Clear authentication tokens from local storage.
73+
* Clear authentication tokens from storage.
7474
*/
75-
clearTokens(): void {
76-
localStorage.removeItem('access_token')
77-
localStorage.removeItem('refresh_token')
75+
async clearTokens(): Promise<void> {
76+
await tokenStorage.clearTokens()
7877
}
7978

8079
/**
8180
* Check if user is authenticated.
8281
*/
83-
isAuthenticated(): boolean {
84-
return !!localStorage.getItem('access_token')
82+
async isAuthenticated(): Promise<boolean> {
83+
return await tokenStorage.isAuthenticated()
8584
}
8685
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* Token storage abstraction for cross-platform token persistence.
3+
*
4+
* Automatically uses Electron's secure storage in desktop app,
5+
* and localStorage in web browser.
6+
*/
7+
class TokenStorage {
8+
private isElectron: boolean
9+
10+
constructor() {
11+
this.isElectron = typeof window !== 'undefined' && !!window.electronAPI
12+
}
13+
14+
/**
15+
* Get access token from storage.
16+
*/
17+
async getAccessToken(): Promise<string | null> {
18+
if (this.isElectron && window.electronAPI) {
19+
return await window.electronAPI.getAccessToken()
20+
}
21+
return localStorage.getItem('access_token')
22+
}
23+
24+
/**
25+
* Get refresh token from storage.
26+
*/
27+
async getRefreshToken(): Promise<string | null> {
28+
if (this.isElectron && window.electronAPI) {
29+
return await window.electronAPI.getRefreshToken()
30+
}
31+
return localStorage.getItem('refresh_token')
32+
}
33+
34+
/**
35+
* Save access token to storage.
36+
*/
37+
async setAccessToken(token: string | null): Promise<void> {
38+
if (this.isElectron && window.electronAPI) {
39+
await window.electronAPI.setAccessToken(token)
40+
return
41+
}
42+
if (token === null) {
43+
localStorage.removeItem('access_token')
44+
} else {
45+
localStorage.setItem('access_token', token)
46+
}
47+
}
48+
49+
/**
50+
* Save refresh token to storage.
51+
*/
52+
async setRefreshToken(token: string | null): Promise<void> {
53+
if (this.isElectron && window.electronAPI) {
54+
await window.electronAPI.setRefreshToken(token)
55+
return
56+
}
57+
if (token === null) {
58+
localStorage.removeItem('refresh_token')
59+
} else {
60+
localStorage.setItem('refresh_token', token)
61+
}
62+
}
63+
64+
/**
65+
* Clear all tokens from storage.
66+
*/
67+
async clearTokens(): Promise<void> {
68+
if (this.isElectron && window.electronAPI) {
69+
await window.electronAPI.clearTokens()
70+
return
71+
}
72+
localStorage.removeItem('access_token')
73+
localStorage.removeItem('refresh_token')
74+
}
75+
76+
/**
77+
* Check if user is authenticated (has access token).
78+
*/
79+
async isAuthenticated(): Promise<boolean> {
80+
const token = await this.getAccessToken()
81+
return !!token
82+
}
83+
}
84+
85+
export const tokenStorage = new TokenStorage()

0 commit comments

Comments
 (0)