feat(saas): 桌面端 P2 客户端补齐 — TOTP 2FA、Relay 任务、Config 同步
- saas-client: 添加 TOTP/Relay/Config 类型和 typed 方法,login 支持 totp_code - saasStore: TOTP 感知登录 (检测 TOTP_ERROR → 两步登录),TOTP 管理动作 - SaaSLogin: TOTP 验证码输入步骤 (6 位数字,Enter 提交) - TOTPSettings (新): 启用流程 (QR 码 + secret + 验证码),禁用 (密码确认) - RelayTasksPanel (新): 状态过滤、任务列表、Admin 重试按钮 - SaaSSettings: 集成 TOTP 和 Relay 面板到设置页
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
type SaaSAccountInfo,
|
||||
type SaaSModelInfo,
|
||||
type SaaSLoginResponse,
|
||||
type TotpSetupResponse,
|
||||
} from '../lib/saas-client';
|
||||
import { createLogger } from '../lib/logger';
|
||||
|
||||
@@ -55,10 +56,13 @@ export interface SaaSStateSlice {
|
||||
availableModels: SaaSModelInfo[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
totpRequired: boolean;
|
||||
totpSetupData: TotpSetupResponse | null;
|
||||
}
|
||||
|
||||
export interface SaaSActionsSlice {
|
||||
login: (saasUrl: string, username: string, password: string) => Promise<void>;
|
||||
loginWithTotp: (saasUrl: string, username: string, password: string, totpCode: string) => Promise<void>;
|
||||
register: (saasUrl: string, username: string, email: string, password: string, displayName?: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
setConnectionMode: (mode: ConnectionMode) => void;
|
||||
@@ -66,6 +70,10 @@ export interface SaaSActionsSlice {
|
||||
registerCurrentDevice: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
restoreSession: () => void;
|
||||
setupTotp: () => Promise<TotpSetupResponse>;
|
||||
verifyTotp: (code: string) => Promise<void>;
|
||||
disableTotp: (password: string) => Promise<void>;
|
||||
cancelTotpSetup: () => void;
|
||||
}
|
||||
|
||||
export type SaaSStore = SaaSStateSlice & SaaSActionsSlice;
|
||||
@@ -108,6 +116,8 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
availableModels: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
totpRequired: false,
|
||||
totpSetupData: null,
|
||||
|
||||
// === Actions ===
|
||||
|
||||
@@ -163,6 +173,12 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
log.warn('Failed to fetch models after login:', err);
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
// Check for TOTP required signal
|
||||
if (err instanceof SaaSApiError && err.code === 'TOTP_ERROR' && err.status === 400) {
|
||||
set({ isLoading: false, totpRequired: true, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const message = err instanceof SaaSApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
@@ -183,6 +199,47 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
}
|
||||
},
|
||||
|
||||
loginWithTotp: async (saasUrl: string, username: string, password: string, totpCode: string) => {
|
||||
set({ isLoading: true, error: null, totpRequired: false });
|
||||
|
||||
try {
|
||||
const normalizedUrl = saasUrl.trim().replace(/\/+$/, '');
|
||||
saasClient.setBaseUrl(normalizedUrl);
|
||||
const loginData = await saasClient.login(username.trim(), password, totpCode);
|
||||
|
||||
const sessionData = {
|
||||
token: loginData.token,
|
||||
account: loginData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
};
|
||||
saveSaaSSession(sessionData);
|
||||
saveConnectionMode('saas');
|
||||
|
||||
set({
|
||||
isLoggedIn: true,
|
||||
account: loginData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
authToken: loginData.token,
|
||||
connectionMode: 'saas',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
totpRequired: false,
|
||||
});
|
||||
|
||||
get().registerCurrentDevice().catch((err: unknown) => {
|
||||
log.warn('Failed to register device:', err);
|
||||
});
|
||||
get().fetchAvailableModels().catch((err: unknown) => {
|
||||
log.warn('Failed to fetch models:', err);
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof SaaSApiError ? err.message
|
||||
: err instanceof Error ? err.message : String(err);
|
||||
set({ isLoading: false, error: message });
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
|
||||
register: async (saasUrl: string, username: string, email: string, password: string, displayName?: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
@@ -260,6 +317,8 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
connectionMode: 'tauri',
|
||||
availableModels: [],
|
||||
error: null,
|
||||
totpRequired: false,
|
||||
totpSetupData: null,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -346,7 +405,62 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
authToken: restored.token,
|
||||
connectionMode: loadConnectionMode() === 'saas' ? 'saas' : 'tauri',
|
||||
});
|
||||
get().fetchAvailableModels().catch(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
setupTotp: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const setupData = await saasClient.setupTotp();
|
||||
set({ totpSetupData: setupData, isLoading: false });
|
||||
return setupData;
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof SaaSApiError ? err.message
|
||||
: err instanceof Error ? err.message : String(err);
|
||||
set({ isLoading: false, error: message });
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
|
||||
verifyTotp: async (code: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await saasClient.verifyTotp(code);
|
||||
const account = await saasClient.me();
|
||||
const { saasUrl, authToken } = get();
|
||||
if (authToken) {
|
||||
saveSaaSSession({ token: authToken, account, saasUrl });
|
||||
}
|
||||
set({ totpSetupData: null, isLoading: false, account });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof SaaSApiError ? err.message
|
||||
: err instanceof Error ? err.message : String(err);
|
||||
set({ isLoading: false, error: message });
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
|
||||
disableTotp: async (password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await saasClient.disableTotp(password);
|
||||
const account = await saasClient.me();
|
||||
const { saasUrl, authToken } = get();
|
||||
if (authToken) {
|
||||
saveSaaSSession({ token: authToken, account, saasUrl });
|
||||
}
|
||||
set({ isLoading: false, account });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof SaaSApiError ? err.message
|
||||
: err instanceof Error ? err.message : String(err);
|
||||
set({ isLoading: false, error: message });
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
|
||||
cancelTotpSetup: () => {
|
||||
set({ totpSetupData: null });
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user