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

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:
iven
2026-04-11 09:43:17 +08:00
parent 1171218276
commit d871685e25
6 changed files with 113 additions and 54 deletions

View File

@@ -404,32 +404,33 @@ 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) {
// Create default agent with versatile assistant personality
const defaultAgent = await client.createClone({
name: '全能助手',
role: '全能型 AI 助手',
nickname: '小龙',
emoji: '🦞',
personality: 'friendly',
scenarios: ['coding', 'writing', 'research', 'product', 'data'],
userName: 'User',
userRole: 'user',
communicationStyle: '亲切、耐心、善解人意,用易懂的语言解释复杂概念',
});
// Use agentStore (which uses the correct client from connectionStore)
// instead of directly importing getGatewayClient
const { useAgentStore } = await import('./store/agentStore');
const agentStore = useAgentStore.getState();
if (defaultAgent?.clone) {
setCurrentAgent({
id: defaultAgent.clone.id,
name: defaultAgent.clone.name,
icon: defaultAgent.clone.emoji || '🦞',
color: 'bg-gradient-to-br from-orange-500 to-red-500',
lastMessage: defaultAgent.clone.role || '全能型 AI 助手',
time: '',
});
}
// Create default agent with versatile assistant personality
const result = await agentStore.createClone({
name: '全能助手',
role: '全能型 AI 助手',
nickname: '小龙',
emoji: '🦞',
personality: 'friendly',
scenarios: ['coding', 'writing', 'research', 'product', 'data'],
userName: 'User',
userRole: 'user',
communicationStyle: '亲切、耐心、善解人意,用易懂的语言解释复杂概念',
});
if (result) {
setCurrentAgent({
id: result.id,
name: result.name,
icon: result.emoji || '🦞',
color: 'bg-gradient-to-br from-orange-500 to-red-500',
lastMessage: result.role || '全能型 AI 助手',
time: '',
});
}
} catch (err) {
log.warn('Failed to create default agent on skip:', err);

View File

@@ -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')}

View File

@@ -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
}
};

View File

@@ -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);

View File

@@ -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 });