import { defineStore } from 'pinia'; import apiClient from '../utils/apiClient'; // 使用统一的 apiClient import router from '../router'; // 引入 router 用于重定向 import { setLocale } from '../i18n'; // 导入 setLocale // 扩展的用户信息接口,包含 2FA 状态和语言偏好 interface UserInfo { id: number; username: string; isTwoFactorEnabled?: boolean; // 后端 /status 接口会返回这个 language?: 'en' | 'zh'; // 新增:用户偏好语言 } // Passkey Information Interface interface PasskeyInfo { credentialID: string; publicKey: string; // Or a more specific type if available counter: number; transports?: AuthenticatorTransport[]; // e.g., "usb", "nfc", "ble", "internal" creationDate: string; // ISO date string lastUsedDate: string; // ISO date string name?: string; // User-friendly name for the passkey // Add other relevant fields from your backend response } // 新增:登录请求的载荷接口 interface LoginPayload { username: string; password: string; rememberMe?: boolean; // 可选的“记住我”标志 } // Public CAPTCHA Config Interface (mirrors backend public config) interface PublicCaptchaConfig { enabled: boolean; provider: 'hcaptcha' | 'recaptcha' | 'none'; hcaptchaSiteKey?: string; recaptchaSiteKey?: string; } // Backend's full CAPTCHA Settings Interface (as returned by /settings/captcha) interface FullCaptchaSettings { enabled: boolean; provider: 'hcaptcha' | 'recaptcha' | 'none'; hcaptchaSiteKey?: string; hcaptchaSecretKey?: string; // We won't use this in authStore recaptchaSiteKey?: string; recaptchaSecretKey?: string; // We won't use this in authStore } // Auth Store State 接口 interface AuthState { isAuthenticated: boolean; user: UserInfo | null; isLoading: boolean; error: string | null; loginRequires2FA: boolean; // 新增状态:标记登录是否需要 2FA // 新增:存储 IP 黑名单数据 (虽然 actions 在这里,但 state 结构保持) ipBlacklist: { entries: any[]; // TODO: Define a proper type for blacklist entries total: number; }; needsSetup: boolean; // 新增:是否需要初始设置 publicCaptchaConfig: PublicCaptchaConfig | null; // NEW: Public CAPTCHA config passkeys: PasskeyInfo[] | null; // NEW: Store for user's passkeys passkeysLoading: boolean; // NEW: Loading state for passkeys } export const useAuthStore = defineStore('auth', { state: (): AuthState => ({ isAuthenticated: false, // 初始为未登录 user: null, isLoading: false, error: null, loginRequires2FA: false, // 初始为不需要 ipBlacklist: { entries: [], total: 0 }, // 初始化黑名单状态 needsSetup: false, // 初始假设不需要设置 publicCaptchaConfig: null, // NEW: Initialize CAPTCHA config as null passkeys: null, // Initialize passkeys as null passkeysLoading: false, // Initialize passkeysLoading as false }), getters: { // 可以添加一些 getter,例如获取用户名 loggedInUser: (state) => state.user?.username, }, actions: { // 新增:清除错误状态 clearError() { this.error = null; }, // 新增:设置错误状态 setError(errorMessage: string) { this.error = errorMessage; }, // 登录 Action - 更新为接受 LoginPayload + optional captchaToken async login(payload: LoginPayload & { captchaToken?: string }) { // Add captchaToken to payload this.isLoading = true; this.error = null; this.loginRequires2FA = false; // 重置 2FA 状态 try { // 后端可能返回 user 或 requiresTwoFactor // 将完整的 payload (包含 rememberMe 和 captchaToken) 发送给后端 const response = await apiClient.post<{ message: string; user?: UserInfo; requiresTwoFactor?: boolean }>('/auth/login', payload); // 使用 apiClient if (response.data.requiresTwoFactor) { // 需要 2FA 验证 console.log('登录需要 2FA 验证'); this.loginRequires2FA = true; // 不设置 isAuthenticated 和 user,等待 2FA 验证 return { requiresTwoFactor: true }; // 返回特殊状态给调用者 } else if (response.data.user) { // 登录成功 (无 2FA) this.isAuthenticated = true; this.user = response.data.user; console.log('登录成功 (无 2FA):', this.user); // 设置语言 if (this.user?.language) { setLocale(this.user.language); } // await router.push({ name: 'Workspace' }); // 改为页面刷新 window.location.href = '/'; // 跳转到根路径并刷新 return { success: true }; } else { // 不应该发生,但作为防御性编程 throw new Error('登录响应无效'); } } catch (err: any) { console.error('登录失败:', err); this.isAuthenticated = false; this.user = null; this.loginRequires2FA = false; this.error = err.response?.data?.message || err.message || '登录时发生未知错误。'; return { success: false, error: this.error }; } finally { this.isLoading = false; } }, // 登录时的 2FA 验证 Action async verifyLogin2FA(token: string) { if (!this.loginRequires2FA) { throw new Error('当前登录流程不需要 2FA 验证。'); } this.isLoading = true; this.error = null; try { const response = await apiClient.post<{ message: string; user: UserInfo }>('/auth/login/2fa', { token }); // 使用 apiClient // 2FA 验证成功 this.isAuthenticated = true; this.user = response.data.user; this.loginRequires2FA = false; // 重置状态 console.log('2FA 验证成功,登录完成:', this.user); // 设置语言 if (this.user?.language) { setLocale(this.user.language); } // await router.push({ name: 'Workspace' }); // 改为页面刷新 window.location.href = '/'; // 跳转到根路径并刷新 return { success: true }; } catch (err: any) { console.error('2FA 验证失败:', err); // 不清除 isAuthenticated 或 user,因为用户可能只是输错了验证码 this.error = err.response?.data?.message || err.message || '2FA 验证时发生未知错误。'; return { success: false, error: this.error }; } finally { this.isLoading = false; } }, // 登出 Action async logout() { this.isLoading = true; this.error = null; this.loginRequires2FA = false; // 重置 2FA 状态 try { // 调用后端的登出 API await apiClient.post('/auth/logout'); // 使用 apiClient // 清除本地状态 this.isAuthenticated = false; this.user = null; // Removed passkeys clear on logout console.log('已登出'); // 登出后重定向到登录页 await router.push({ name: 'Login' }); } catch (err: any) { console.error('登出失败:', err); this.error = err.response?.data?.message || err.message || '登出时发生未知错误。'; } finally { this.isLoading = false; } }, // 新增:检查并更新认证状态 Action async checkAuthStatus() { this.isLoading = true; try { const response = await apiClient.get<{ isAuthenticated: boolean; user: UserInfo }>('/auth/status'); // 使用 apiClient if (response.data.isAuthenticated && response.data.user) { this.isAuthenticated = true; this.user = response.data.user; // 更新用户信息,包含 isTwoFactorEnabled 和 language this.loginRequires2FA = false; // 确保重置 console.log('认证状态已更新:', this.user); // 设置语言 if (this.user?.language) { setLocale(this.user.language); } } else { this.isAuthenticated = false; this.user = null; this.loginRequires2FA = false; // Removed passkeys clear on unauthenticated } } catch (error: any) { // 如果获取状态失败 (例如 session 过期),则认为未认证 console.warn('检查认证状态失败:', error.response?.data?.message || error.message); this.isAuthenticated = false; this.user = null; this.loginRequires2FA = false; // Removed passkeys clear on error // 可选:如果不是 401 错误,可以记录更详细的日志 } finally { this.isLoading = false; } }, // 修改密码 Action async changePassword(currentPassword: string, newPassword: string) { if (!this.isAuthenticated) { throw new Error('用户未登录,无法修改密码。'); } this.isLoading = true; this.error = null; try { const response = await apiClient.put<{ message: string }>('/auth/password', { // 使用 apiClient currentPassword, newPassword, }); console.log('密码修改成功:', response.data.message); // 密码修改成功后,通常不需要更新本地状态,但可以清除错误 return true; } catch (err: any) { console.error('修改密码失败:', err); this.error = err.response?.data?.message || err.message || '修改密码时发生未知错误。'; // 抛出错误,以便组件可以捕获并显示 (提供默认消息以防 this.error 为 null) throw new Error(this.error ?? '修改密码时发生未知错误。'); } finally { this.isLoading = false; } }, // --- IP 黑名单管理 Actions --- /** * 获取 IP 黑名单列表 * @param limit 每页数量 * @param offset 偏移量 */ async fetchIpBlacklist(limit: number = 50, offset: number = 0) { this.isLoading = true; this.error = null; try { const response = await apiClient.get('/settings/ip-blacklist', { // 使用 apiClient params: { limit, offset } }); // 更新本地状态 this.ipBlacklist.entries = response.data.entries; this.ipBlacklist.total = response.data.total; console.log('获取 IP 黑名单成功:', response.data); return response.data; // { entries: [], total: number } } catch (err: any) { console.error('获取 IP 黑名单失败:', err); this.error = err.response?.data?.message || err.message || '获取 IP 黑名单时发生未知错误。'; // 确保抛出 Error 时提供字符串消息 throw new Error(this.error ?? '获取 IP 黑名单时发生未知错误。'); } finally { this.isLoading = false; } }, /** * 从 IP 黑名单中删除一个 IP * @param ip 要删除的 IP 地址 */ async deleteIpFromBlacklist(ip: string) { this.isLoading = true; this.error = null; try { await apiClient.delete(`/settings/ip-blacklist/${encodeURIComponent(ip)}`); // 使用 apiClient console.log(`IP ${ip} 已从黑名单删除`); // 从本地 state 中移除 (或者重新获取列表) this.ipBlacklist.entries = this.ipBlacklist.entries.filter(entry => entry.ip !== ip); this.ipBlacklist.total = Math.max(0, this.ipBlacklist.total - 1); return true; } catch (err: any) { console.error(`删除 IP ${ip} 失败:`, err); this.error = err.response?.data?.message || err.message || '删除 IP 时发生未知错误。'; // 确保抛出 Error 时提供字符串消息 throw new Error(this.error ?? '删除 IP 时发生未知错误。'); } finally { this.isLoading = false; } }, // 新增:检查是否需要初始设置 async checkSetupStatus() { // 不需要设置 isLoading,这个检查应该在后台快速完成 try { const response = await apiClient.get<{ needsSetup: boolean }>('/auth/needs-setup'); // 使用 apiClient this.needsSetup = response.data.needsSetup; console.log(`[AuthStore] Needs setup status: ${this.needsSetup}`); return this.needsSetup; // 返回状态给调用者 } catch (error: any) { console.error('检查设置状态失败:', error.response?.data?.message || error.message); // 如果检查失败,保守起见假设不需要设置,以避免卡在设置页面 this.needsSetup = false; return false; } }, // NEW: 获取公共 CAPTCHA 配置 (修改为从 /settings/captcha 获取) async fetchCaptchaConfig() { console.log('[AuthStore] fetchCaptchaConfig called. Current publicCaptchaConfig:', JSON.stringify(this.publicCaptchaConfig)); // 添加日志 // Avoid refetching if already loaded if (this.publicCaptchaConfig !== null) { console.log('[AuthStore] publicCaptchaConfig is not null, returning early.'); // 添加日志 return; } // Don't set isLoading for this, it should be quick background fetch try { console.log('[AuthStore] Fetching CAPTCHA config from /settings/captcha...'); // 修改 API 端点 const response = await apiClient.get('/settings/captcha'); const fullConfig = response.data; // 从完整配置中提取公共部分 this.publicCaptchaConfig = { enabled: fullConfig.enabled, provider: fullConfig.provider, hcaptchaSiteKey: fullConfig.hcaptchaSiteKey, recaptchaSiteKey: fullConfig.recaptchaSiteKey, }; console.log('[AuthStore] Public CAPTCHA config derived from /settings/captcha:', this.publicCaptchaConfig); } catch (error: any) { console.error('获取 CAPTCHA 配置失败 (from /settings/captcha):', error.response?.data?.message || error.message); // Set a default disabled config on error to prevent blocking login UI this.publicCaptchaConfig = { enabled: false, provider: 'none', }; } }, // --- Passkey Actions --- async loginWithPasskey(username: string, assertionResponse: any) { this.isLoading = true; this.error = null; this.loginRequires2FA = false; // Passkey login bypasses traditional 2FA try { const response = await apiClient.post<{ message: string; user: UserInfo }>('/auth/passkey/authenticate', { username, assertionResponse, }); this.isAuthenticated = true; this.user = response.data.user; console.log('Passkey 登录成功:', this.user); if (this.user?.language) { setLocale(this.user.language); } window.location.href = '/'; // 跳转到根路径并刷新 return { success: true }; } catch (err: any) { console.error('Passkey 登录失败:', err); this.isAuthenticated = false; this.user = null; this.error = err.response?.data?.message || err.message || 'Passkey 登录时发生未知错误。'; return { success: false, error: this.error }; } finally { this.isLoading = false; } }, async getPasskeyRegistrationOptions(username: string) { this.isLoading = true; this.error = null; try { const response = await apiClient.post('/auth/passkey/registration-options', { username }); return response.data; // Returns FIDO2 creation options } catch (err: any) { console.error('获取 Passkey 注册选项失败:', err); this.error = err.response?.data?.message || err.message || '获取 Passkey 注册选项失败。'; throw new Error(this.error ?? '获取 Passkey 注册选项失败。'); } finally { this.isLoading = false; } }, async registerPasskey(username: string, registrationResponse: any) { this.isLoading = true; this.error = null; try { await apiClient.post('/auth/passkey/register', { username, registrationResponse, }); console.log('Passkey 注册成功'); // Optionally, refresh user data or passkeys list if applicable return { success: true }; } catch (err: any) { console.error('Passkey 注册失败:', err); this.error = err.response?.data?.message || err.message || 'Passkey 注册失败。'; throw new Error(this.error ?? 'Passkey 注册失败。'); } finally { this.isLoading = false; } }, // Action to fetch user's passkeys async fetchPasskeys() { if (!this.isAuthenticated) { console.warn('User not authenticated. Cannot fetch passkeys.'); this.passkeys = null; return; } this.passkeysLoading = true; this.error = null; // Clear previous errors try { // Define an interface for the backend response structure interface BackendPasskeyInfo { credential_id: string; public_key: string; counter: number; transports?: AuthenticatorTransport[]; created_at: string; // Backend uses snake_case last_used_at: string; // Backend uses snake_case name?: string; } const response = await apiClient.get('/auth/user/passkeys'); // Map backend response to frontend PasskeyInfo structure this.passkeys = response.data.map(pk => ({ credentialID: pk.credential_id, publicKey: pk.public_key, counter: pk.counter, transports: pk.transports, creationDate: pk.created_at, // Map created_at to creationDate lastUsedDate: pk.last_used_at, // Map last_used_at to lastUsedDate name: pk.name, })); console.log('Passkeys fetched and mapped successfully:', this.passkeys); } catch (err: any) { console.error('Failed to fetch passkeys:', err); this.error = err.response?.data?.message || err.message || 'Failed to load passkeys.'; this.passkeys = null; // Clear passkeys on error } finally { this.passkeysLoading = false; } }, // Action to delete a passkey async deletePasskey(credentialID: string) { if (!this.isAuthenticated) { throw new Error('User not authenticated. Cannot delete passkey.'); } this.isLoading = true; // Use general isLoading or a specific one for this action this.error = null; try { await apiClient.delete(`/auth/user/passkeys/${credentialID}`); console.log(`Passkey ${credentialID} deleted successfully.`); // Refresh the passkey list await this.fetchPasskeys(); return { success: true }; } catch (err: any) { console.error(`Failed to delete passkey ${credentialID}:`, err); this.error = err.response?.data?.message || err.message || 'Failed to delete passkey.'; throw new Error(this.error ?? 'Failed to delete passkey.'); } finally { this.isLoading = false; } }, // Action to update a passkey's name async updatePasskeyName(credentialID: string, newName: string) { if (!this.isAuthenticated) { throw new Error('User not authenticated. Cannot update passkey name.'); } // Consider using a specific loading state for this if needed, e.g., this.passkeyNameUpdateLoading = true; this.error = null; try { await apiClient.put(`/auth/user/passkeys/${credentialID}/name`, { name: newName }); console.log(`Passkey ${credentialID} name updated to "${newName}".`); // Refresh the passkey list to show the new name await this.fetchPasskeys(); return { success: true }; } catch (err: any) { console.error(`Failed to update passkey ${credentialID} name:`, err); this.error = err.response?.data?.message || err.message || 'Failed to update passkey name.'; throw new Error(this.error ?? 'Failed to update passkey name.'); } finally { // if using specific loading state: this.passkeyNameUpdateLoading = false; } }, }, persist: true, // Revert to simple persistence to fix TS error for now });