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:
iven
2026-03-27 18:20:11 +08:00
parent 452ff45a5f
commit 4d8d560d1f
6 changed files with 1028 additions and 183 deletions

View File

@@ -72,6 +72,20 @@ interface SaaSRefreshResponse {
token: string;
}
/** TOTP setup response from POST /api/v1/auth/totp/setup */
export interface TotpSetupResponse {
otpauth_uri: string;
secret: string;
issuer: string;
}
/** TOTP verify/disable response */
export interface TotpResultResponse {
ok: boolean;
totp_enabled: boolean;
message: string;
}
/** Device info stored on the SaaS backend */
export interface DeviceInfo {
id: string;
@@ -83,6 +97,55 @@ export interface DeviceInfo {
created_at: string;
}
/** Relay task info from GET /api/v1/relay/tasks */
export interface RelayTaskInfo {
id: string;
account_id: string;
provider_id: string;
model_id: string;
status: string;
priority: number;
attempt_count: number;
max_attempts: number;
input_tokens: number;
output_tokens: number;
error_message: string | null;
queued_at: string;
started_at: string | null;
completed_at: string | null;
created_at: string;
}
/** Config diff request for POST /api/v1/config/diff and /sync */
export interface SyncConfigRequest {
client_fingerprint: string;
action: 'push' | 'merge';
config_keys: string[];
client_values: Record<string, unknown>;
}
/** A single config diff entry */
export interface ConfigDiffItem {
key_path: string;
client_value: string | null;
saas_value: string | null;
conflict: boolean;
}
/** Config diff response */
export interface ConfigDiffResponse {
items: ConfigDiffItem[];
total_keys: number;
conflicts: number;
}
/** Config sync result */
export interface ConfigSyncResult {
updated: number;
created: number;
skipped: number;
}
// === Error Class ===
export class SaaSApiError extends Error {
@@ -210,7 +273,7 @@ export class SaaSClient {
* Retries up to 2 times with exponential backoff (1s, 2s).
* Throws SaaSApiError on non-ok responses.
*/
private async request<T>(
public async request<T>(
method: string,
path: string,
body?: unknown,
@@ -298,9 +361,11 @@ export class SaaSClient {
* Login with username and password.
* Auto-sets the client token on success.
*/
async login(username: string, password: string): Promise<SaaSLoginResponse> {
async login(username: string, password: string, totpCode?: string): Promise<SaaSLoginResponse> {
const body: Record<string, string> = { username, password };
if (totpCode) body.totp_code = totpCode;
const data = await this.request<SaaSLoginResponse>(
'POST', '/api/v1/auth/login', { username, password },
'POST', '/api/v1/auth/login', body,
);
this.token = data.token;
return data;
@@ -350,6 +415,23 @@ export class SaaSClient {
});
}
// --- TOTP Endpoints ---
/** Generate a TOTP secret and otpauth URI */
async setupTotp(): Promise<TotpSetupResponse> {
return this.request<TotpSetupResponse>('POST', '/api/v1/auth/totp/setup');
}
/** Verify a TOTP code and enable 2FA */
async verifyTotp(code: string): Promise<TotpResultResponse> {
return this.request<TotpResultResponse>('POST', '/api/v1/auth/totp/verify', { code });
}
/** Disable 2FA (requires password confirmation) */
async disableTotp(password: string): Promise<TotpResultResponse> {
return this.request<TotpResultResponse>('POST', '/api/v1/auth/totp/disable', { password });
}
// --- Device Endpoints ---
/**
@@ -391,6 +473,28 @@ export class SaaSClient {
return this.request<SaaSModelInfo[]>('GET', '/api/v1/relay/models');
}
// --- Relay Task Management ---
/** List relay tasks for the current user */
async listRelayTasks(query?: { status?: string; page?: number; page_size?: number }): Promise<RelayTaskInfo[]> {
const params = new URLSearchParams();
if (query?.status) params.set('status', query.status);
if (query?.page) params.set('page', String(query.page));
if (query?.page_size) params.set('page_size', String(query.page_size));
const qs = params.toString();
return this.request<RelayTaskInfo[]>('GET', `/api/v1/relay/tasks${qs ? '?' + qs : ''}`);
}
/** Get a single relay task */
async getRelayTask(taskId: string): Promise<RelayTaskInfo> {
return this.request<RelayTaskInfo>('GET', `/api/v1/relay/tasks/${taskId}`);
}
/** Retry a failed relay task (admin only) */
async retryRelayTask(taskId: string): Promise<{ ok: boolean; task_id: string }> {
return this.request<{ ok: boolean; task_id: string }>('POST', `/api/v1/relay/tasks/${taskId}/retry`);
}
// --- Chat Relay ---
/**
@@ -437,6 +541,16 @@ export class SaaSClient {
const qs = category ? `?category=${encodeURIComponent(category)}` : '';
return this.request<SaaSConfigItem[]>('GET', `/api/v1/config/items${qs}`);
}
/** Compute config diff between client and SaaS (read-only) */
async computeConfigDiff(request: SyncConfigRequest): Promise<ConfigDiffResponse> {
return this.request<ConfigDiffResponse>('POST', '/api/v1/config/diff', request);
}
/** Sync config from client to SaaS (push) or merge */
async syncConfig(request: SyncConfigRequest): Promise<ConfigSyncResult> {
return this.request<ConfigSyncResult>('POST', '/api/v1/config/sync', request);
}
}
// === Singleton ===