fix(auth): 5 BUG 修复 — refresh token 持久化 + 密码验证 + 浏览器兼容
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
BUG-1 (P1): LoginPage 注册密码验证从 6 位改为 8 位,与后端一致 BUG-2 (P0): refresh token 持久化到 OS keyring + restoreSession 三级恢复 (access token → refresh token → cookie auth) + saveSaaSSession 改为 await BUG-3 (P0): Tauri 聊天路由降级问题,根因同 BUG-2(会话恢复失败) BUG-4 (P1): App.tsx 跳过 Onboarding 改用 agentStore(兼容所有 client), Workspace.tsx Tauri invoke 改为动态 import 避免浏览器崩溃 BUG-5: tauri.conf.json createUpdaterArtifacts 改为 boolean true
This commit is contained in:
@@ -34,7 +34,7 @@
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"createUpdaterArtifacts": "v2Compatible"
|
||||
"createUpdaterArtifacts": true
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
|
||||
@@ -404,11 +404,13 @@ function App() {
|
||||
onClose={async () => {
|
||||
// Skip onboarding but still create a default agent with default personality
|
||||
try {
|
||||
const { getGatewayClient } = await import('./lib/gateway-client');
|
||||
const client = getGatewayClient();
|
||||
if (client) {
|
||||
// Use agentStore (which uses the correct client from connectionStore)
|
||||
// instead of directly importing getGatewayClient
|
||||
const { useAgentStore } = await import('./store/agentStore');
|
||||
const agentStore = useAgentStore.getState();
|
||||
|
||||
// Create default agent with versatile assistant personality
|
||||
const defaultAgent = await client.createClone({
|
||||
const result = await agentStore.createClone({
|
||||
name: '全能助手',
|
||||
role: '全能型 AI 助手',
|
||||
nickname: '小龙',
|
||||
@@ -420,17 +422,16 @@ function App() {
|
||||
communicationStyle: '亲切、耐心、善解人意,用易懂的语言解释复杂概念',
|
||||
});
|
||||
|
||||
if (defaultAgent?.clone) {
|
||||
if (result) {
|
||||
setCurrentAgent({
|
||||
id: defaultAgent.clone.id,
|
||||
name: defaultAgent.clone.name,
|
||||
icon: defaultAgent.clone.emoji || '🦞',
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
icon: result.emoji || '🦞',
|
||||
color: 'bg-gradient-to-br from-orange-500 to-red-500',
|
||||
lastMessage: defaultAgent.clone.role || '全能型 AI 助手',
|
||||
lastMessage: result.role || '全能型 AI 助手',
|
||||
time: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Failed to create default agent on skip:', err);
|
||||
}
|
||||
|
||||
@@ -170,8 +170,8 @@ export function LoginPage() {
|
||||
setLocalError('邮箱格式不正确');
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setLocalError('密码长度至少 6 个字符');
|
||||
if (password.length < 8) {
|
||||
setLocalError('密码长度至少 8 个字符');
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
@@ -427,7 +427,7 @@ export function LoginPage() {
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={isRegister ? '密码(至少 6 位)' : '密码'}
|
||||
placeholder={isRegister ? '密码(至少 8 位)' : '密码'}
|
||||
autoComplete={isRegister ? 'new-password' : 'current-password'}
|
||||
disabled={isLoading}
|
||||
className={cn(inputClass, 'pr-10')}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useConfigStore } from '../../store/configStore';
|
||||
import { silentErrorHandler } from '../../lib/error-utils';
|
||||
|
||||
@@ -11,13 +10,14 @@ export function Workspace() {
|
||||
const [projectDir, setProjectDir] = useState('~/.zclaw/zclaw-workspace');
|
||||
const [dirStats, setDirStats] = useState<{ fileCount: number; totalSize: number } | null>(null);
|
||||
|
||||
// Load real directory stats via Tauri command
|
||||
// Load real directory stats via Tauri command (safe for browser mode)
|
||||
const loadDirStats = async (dir: string) => {
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const stats = await invoke<{ file_count: number; total_size: number }>('workspace_dir_stats', { path: dir });
|
||||
setDirStats({ fileCount: stats.file_count, totalSize: stats.total_size });
|
||||
} catch {
|
||||
// Command may not exist in all modes — fallback to workspaceInfo
|
||||
// Not in Tauri environment or command unavailable — fallback to workspaceInfo
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ const logger = createLogger('saas-session');
|
||||
|
||||
// === Storage Keys ===
|
||||
const SAAS_TOKEN_SECURE_KEY = 'zclaw-saas-token'; // OS keyring key
|
||||
const SAAS_REFRESH_TOKEN_KEY = 'zclaw-saas-refresh-token'; // OS keyring key for refresh token
|
||||
const SAASTOKEN_KEY = 'zclaw-saas-token'; // legacy localStorage — only used for cleanup
|
||||
const SAASURL_KEY = 'zclaw-saas-url';
|
||||
const SAASACCOUNT_KEY = 'zclaw-saas-account';
|
||||
@@ -22,6 +23,7 @@ const SAASMODE_KEY = 'zclaw-connection-mode';
|
||||
|
||||
export interface SaaSSession {
|
||||
token: string | null; // null when using cookie-based auth (page reload)
|
||||
refreshToken: string | null; // for token refresh on restore
|
||||
account: SaaSAccountInfo | null;
|
||||
saasUrl: string;
|
||||
}
|
||||
@@ -51,9 +53,11 @@ export async function loadSaaSSession(): Promise<SaaSSession | null> {
|
||||
|
||||
// Load token from secure storage
|
||||
let token: string | null = null;
|
||||
let refreshToken: string | null = null;
|
||||
try {
|
||||
const { secureStorage } = await import('./secure-storage');
|
||||
token = await secureStorage.get(SAAS_TOKEN_SECURE_KEY);
|
||||
refreshToken = await secureStorage.get(SAAS_REFRESH_TOKEN_KEY);
|
||||
} catch (e) {
|
||||
logger.debug('Secure storage unavailable for token load', { error: e });
|
||||
// Secure storage unavailable — token stays null (cookie auth will be attempted)
|
||||
@@ -64,7 +68,7 @@ export async function loadSaaSSession(): Promise<SaaSSession | null> {
|
||||
? (JSON.parse(accountRaw) as SaaSAccountInfo)
|
||||
: null;
|
||||
|
||||
return { token, account, saasUrl };
|
||||
return { token, refreshToken, account, saasUrl };
|
||||
} catch (e) {
|
||||
logger.debug('Corrupted session data, clearing', { error: e });
|
||||
// Corrupted data - clear all
|
||||
@@ -102,17 +106,26 @@ export function loadSaaSSessionSync(): { saasUrl: string; account: SaaSAccountIn
|
||||
|
||||
/**
|
||||
* Persist SaaS session.
|
||||
* Token goes to secure storage (OS keyring), metadata to localStorage.
|
||||
* Access token + refresh token go to secure storage (OS keyring), metadata to localStorage.
|
||||
*/
|
||||
export async function saveSaaSSession(session: SaaSSession): Promise<void> {
|
||||
// Store token in secure storage (OS keyring), not plain localStorage
|
||||
// Store access token in secure storage (OS keyring)
|
||||
if (session.token) {
|
||||
try {
|
||||
const { secureStorage } = await import('./secure-storage');
|
||||
await secureStorage.set(SAAS_TOKEN_SECURE_KEY, session.token);
|
||||
} catch (e) {
|
||||
logger.debug('Secure storage unavailable for token save', { error: e });
|
||||
// Secure storage unavailable — token only in memory
|
||||
}
|
||||
}
|
||||
|
||||
// Store refresh token in secure storage (OS keyring)
|
||||
if (session.refreshToken) {
|
||||
try {
|
||||
const { secureStorage } = await import('./secure-storage');
|
||||
await secureStorage.set(SAAS_REFRESH_TOKEN_KEY, session.refreshToken);
|
||||
} catch (e) {
|
||||
logger.debug('Secure storage unavailable for refresh token save', { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,12 +139,18 @@ export async function saveSaaSSession(session: SaaSSession): Promise<void> {
|
||||
* Clear the persisted SaaS session from all storage.
|
||||
*/
|
||||
export async function clearSaaSSession(): Promise<void> {
|
||||
// Remove from secure storage
|
||||
// Remove access token from secure storage
|
||||
try {
|
||||
const { secureStorage } = await import('./secure-storage');
|
||||
await secureStorage.set(SAAS_TOKEN_SECURE_KEY, '');
|
||||
} catch (e) { logger.debug('Failed to clear secure storage token', { error: e }); }
|
||||
|
||||
// Remove refresh token from secure storage
|
||||
try {
|
||||
const { secureStorage } = await import('./secure-storage');
|
||||
await secureStorage.set(SAAS_REFRESH_TOKEN_KEY, '');
|
||||
} catch (e) { logger.debug('Failed to clear secure storage refresh token', { error: e }); }
|
||||
|
||||
localStorage.removeItem(SAASTOKEN_KEY);
|
||||
localStorage.removeItem(SAASURL_KEY);
|
||||
localStorage.removeItem(SAASACCOUNT_KEY);
|
||||
|
||||
@@ -201,15 +201,18 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
saasClient.setBaseUrl(normalizedUrl);
|
||||
const loginData: SaaSLoginResponse = await saasClient.login(trimmedUsername, password);
|
||||
|
||||
// Persist session: token → secure storage (OS keyring), metadata → localStorage
|
||||
// Persist session: token + refresh token → secure storage, metadata → localStorage
|
||||
const sessionData = {
|
||||
token: loginData.token, // Will be stored in OS keyring by saveSaaSSession
|
||||
token: loginData.token,
|
||||
refreshToken: loginData.refresh_token,
|
||||
account: loginData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
};
|
||||
saveSaaSSession(sessionData).catch((e) =>
|
||||
log.warn('Failed to persist SaaS session after login', { error: e })
|
||||
);
|
||||
try {
|
||||
await saveSaaSSession(sessionData);
|
||||
} catch (e) {
|
||||
log.warn('Failed to persist SaaS session after login', { error: e });
|
||||
}
|
||||
saveConnectionMode('saas');
|
||||
|
||||
set({
|
||||
@@ -306,12 +309,15 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
|
||||
const sessionData = {
|
||||
token: loginData.token,
|
||||
refreshToken: loginData.refresh_token,
|
||||
account: loginData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
};
|
||||
saveSaaSSession(sessionData).catch((e) =>
|
||||
log.warn('Failed to persist SaaS session after TOTP login', { error: e })
|
||||
);
|
||||
try {
|
||||
await saveSaaSSession(sessionData);
|
||||
} catch (e) {
|
||||
log.warn('Failed to persist SaaS session after TOTP login', { error: e });
|
||||
}
|
||||
saveConnectionMode('saas');
|
||||
|
||||
set({
|
||||
@@ -373,12 +379,15 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
|
||||
const sessionData = {
|
||||
token: registerData.token,
|
||||
refreshToken: registerData.refresh_token,
|
||||
account: registerData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
};
|
||||
saveSaaSSession(sessionData).catch((e) =>
|
||||
log.warn('Failed to persist SaaS session after register', { error: e })
|
||||
);
|
||||
try {
|
||||
await saveSaaSSession(sessionData);
|
||||
} catch (e) {
|
||||
log.warn('Failed to persist SaaS session after register', { error: e });
|
||||
}
|
||||
saveConnectionMode('saas');
|
||||
|
||||
set({
|
||||
@@ -744,8 +753,9 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
|
||||
saasClient.setBaseUrl(restored.saasUrl);
|
||||
|
||||
// Strategy: try secure storage token first, then cookie auth
|
||||
// Strategy: access token → refresh token → cookie auth → fail
|
||||
let account: SaaSAccountInfo | null = null;
|
||||
let newToken: string | null = restored.token;
|
||||
|
||||
if (restored.token) {
|
||||
// Token from secure storage — use as Bearer
|
||||
@@ -753,8 +763,37 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
try {
|
||||
account = await saasClient.me();
|
||||
} catch {
|
||||
// Token expired — try cookie auth
|
||||
// Access token expired — clear and try refresh
|
||||
saasClient.setToken(null);
|
||||
newToken = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!account && restored.refreshToken) {
|
||||
// Try refresh token from secure storage
|
||||
saasClient.setRefreshToken(restored.refreshToken);
|
||||
try {
|
||||
const refreshed = await saasClient.refreshMutex();
|
||||
newToken = refreshed;
|
||||
saasClient.setToken(refreshed);
|
||||
account = await saasClient.me();
|
||||
// Persist the new tokens back to secure storage
|
||||
try {
|
||||
const { saveSaaSSession: save } = await import('../lib/saas-session');
|
||||
await save({
|
||||
token: refreshed,
|
||||
refreshToken: saasClient.getRefreshToken(),
|
||||
account,
|
||||
saasUrl: restored.saasUrl,
|
||||
});
|
||||
} catch (e) {
|
||||
log.warn('Failed to persist refreshed session', { error: e });
|
||||
}
|
||||
} catch {
|
||||
// Refresh token also expired or invalid
|
||||
saasClient.setRefreshToken(null);
|
||||
saasClient.setToken(null);
|
||||
newToken = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,7 +803,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
// Neither token nor cookie works — user needs to re-login
|
||||
// All methods failed — user needs to re-login
|
||||
set({
|
||||
isLoggedIn: false,
|
||||
account: null,
|
||||
@@ -778,7 +817,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
isLoggedIn: true,
|
||||
account,
|
||||
saasUrl: restored.saasUrl,
|
||||
authToken: restored.token, // In-memory from secure storage (null if cookie-only)
|
||||
authToken: newToken,
|
||||
connectionMode: loadConnectionMode() === 'saas' ? 'saas' : 'tauri',
|
||||
});
|
||||
get().fetchAvailableModels().catch(() => {});
|
||||
@@ -812,7 +851,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
await saasClient.verifyTotp(code);
|
||||
const account = await saasClient.me();
|
||||
const { saasUrl } = get();
|
||||
saveSaaSSession({ token: null, account, saasUrl }).catch((e) =>
|
||||
saveSaaSSession({ token: null, refreshToken: null, account, saasUrl }).catch((e) =>
|
||||
log.warn('Failed to persist SaaS session after verifyTotp', { error: e })
|
||||
); // Token in saasClient memory only
|
||||
set({ totpSetupData: null, isLoading: false, account });
|
||||
@@ -830,7 +869,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
await saasClient.disableTotp(password);
|
||||
const account = await saasClient.me();
|
||||
const { saasUrl } = get();
|
||||
saveSaaSSession({ token: null, account, saasUrl }).catch((e) =>
|
||||
saveSaaSSession({ token: null, refreshToken: null, account, saasUrl }).catch((e) =>
|
||||
log.warn('Failed to persist SaaS session after disableTotp', { error: e })
|
||||
);
|
||||
set({ isLoading: false, account });
|
||||
|
||||
Reference in New Issue
Block a user