From efcef823a0b969aee1d32c70bcc8a866cff3cd45 Mon Sep 17 00:00:00 2001 From: nap0o Date: Mon, 16 Mar 2026 12:11:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0"=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E5=BC=BA=E5=88=B6=E5=A4=87=E4=BB=BD"?= =?UTF-8?q?=E6=B5=81=E7=A8=8B,=E4=BF=9D=E6=8A=A4=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E4=B8=8D=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/app/index.ts | 2 + backend/src/features/auth/authRoutes.ts | 25 +- backend/src/features/auth/authService.ts | 24 +- backend/src/features/auth/webAuthnService.ts | 12 +- backend/src/features/backup/backupService.ts | 6 +- .../src/features/emergency/emergencyRoutes.ts | 36 ++ .../db/repositories/emergencyRepository.ts | 57 +++ backend/src/shared/db/schema.ts | 11 +- frontend/package.json | 2 + frontend/src/app/router.js | 18 +- frontend/src/app/styles/main.css | 3 +- .../styles/modules/components/emergency.css | 348 ++++++++++++++++++ .../app/styles/modules/components/vault.css | 25 +- .../auth/composables/useOAuthCallback.js | 11 +- .../auth/composables/useOAuthProviders.js | 7 +- .../src/features/auth/service/authService.js | 15 + .../src/features/auth/store/authUserStore.js | 15 +- .../src/features/auth/views/emergency.vue | 236 ++++++++++++ frontend/src/features/auth/views/login.vue | 10 +- .../backup/composables/useBackupOperations.js | 2 +- .../backup/composables/useBackupProviders.js | 2 +- .../migration/composables/useDataExport.js | 8 +- .../migration/composables/useDataImport.js | 9 +- .../vault/composables/useVaultActions.js | 2 +- frontend/src/locales/en-US.json | 48 ++- frontend/src/locales/zh-CN.json | 48 ++- frontend/vite.config.js | 12 +- 27 files changed, 926 insertions(+), 68 deletions(-) create mode 100644 backend/src/features/emergency/emergencyRoutes.ts create mode 100644 backend/src/shared/db/repositories/emergencyRepository.ts create mode 100644 frontend/src/app/styles/modules/components/emergency.css create mode 100644 frontend/src/features/auth/views/emergency.vue diff --git a/backend/src/app/index.ts b/backend/src/app/index.ts index 97592c1..066a0b4 100644 --- a/backend/src/app/index.ts +++ b/backend/src/app/index.ts @@ -11,6 +11,7 @@ import backupRoutes from '@/features/backup/backupRoutes'; import telegramRoutes from '@/features/telegram/telegramRoutes'; import toolsRoutes from '@/features/tools/toolsRoutes'; import healthRoutes from '@/features/health/healthRoutes'; +import emergencyRoutes from '@/features/emergency/emergencyRoutes'; import { runHealthCheck } from '@/shared/utils/health'; // 扩展 EnvBindings 以包含 ASSETS (Cloudflare Pages/Workers Assets) @@ -86,6 +87,7 @@ app.use('/api/*', async (c, next) => { // 4. 挂载子路由 app.route('/api/health', healthRoutes); +app.route('/api/emergency', emergencyRoutes); app.route('/api/oauth', authRoutes); app.route('/api/vault', vaultRoutes); // 'accounts' is now 'vault' app.route('/api/backups', backupRoutes); diff --git a/backend/src/features/auth/authRoutes.ts b/backend/src/features/auth/authRoutes.ts index 046e20b..e924655 100644 --- a/backend/src/features/auth/authRoutes.ts +++ b/backend/src/features/auth/authRoutes.ts @@ -7,6 +7,7 @@ import { SECURITY_CONFIG } from '@/app/config'; import { getAvailableProviders } from '@/features/auth/providers/index'; import { AuthService } from '@/features/auth/authService'; import { WebAuthnService } from '@/features/auth/webAuthnService'; +import { EmergencyRepository } from '@/shared/db/repositories/emergencyRepository'; const auth = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>(); @@ -79,7 +80,7 @@ auth.post('/callback/:provider', rateLimit({ const service = getService(c); // 调用 Service 层处理登录 - const { token, userInfo, deviceKey } = await service.handleOAuthCallback(providerName, body); + const { token, userInfo, deviceKey, needsEmergency, encryptionKey } = await service.handleOAuthCallback(providerName, body); // 1. 设置 httpOnly 的鉴权 Cookie setCookie(c, 'auth_token', token, { @@ -107,7 +108,9 @@ auth.post('/callback/:provider', rateLimit({ return c.json({ success: true, userInfo, - deviceKey + deviceKey, + needsEmergency, + encryptionKey }); }); @@ -126,14 +129,24 @@ auth.post('/logout', (c) => { }); // 获取当前用户信息 -auth.get('/me', authMiddleware, (c) => { +auth.get('/me', authMiddleware, async (c) => { const user = c.get('user'); c.header('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); c.header('Pragma', 'no-cache'); c.header('Expires', '0'); + + // 检查是否需要系统初始化 + const repository = new EmergencyRepository(c.env.DB); + const isEmergencyConfirmed = await repository.isEmergencyConfirmed(); + + // 如果未激活,则持续返回加密密钥供备份页面使用 + const encryptionKey = !isEmergencyConfirmed ? c.env.ENCRYPTION_KEY : undefined; + return c.json({ success: true, - userInfo: user + userInfo: user, + needsEmergency: !isEmergencyConfirmed, + encryptionKey }); }); @@ -216,7 +229,9 @@ auth.post('/webauthn/login/verify', rateLimit({ return c.json({ success: true, deviceKey: result.deviceKey, - userInfo: result.userInfo + userInfo: result.userInfo, + needsEmergency: result.needsEmergency, + encryptionKey: result.encryptionKey }); }); diff --git a/backend/src/features/auth/authService.ts b/backend/src/features/auth/authService.ts index 26ae455..432dced 100644 --- a/backend/src/features/auth/authService.ts +++ b/backend/src/features/auth/authService.ts @@ -3,11 +3,15 @@ import { generateSecureJWT, generateDeviceKey } from '@/shared/utils/crypto'; import { getOAuthProvider } from '@/features/auth/providers/index'; import type { OAuthUserInfo } from '@/features/auth/providers/baseOAuthProvider'; +import { EmergencyRepository } from '@/shared/db/repositories/emergencyRepository'; + export class AuthService { private env: EnvBindings; + private emergencyRepository: EmergencyRepository; constructor(env: EnvBindings) { this.env = env; + this.emergencyRepository = new EmergencyRepository(env.DB); } /** @@ -28,7 +32,13 @@ export class AuthService { /** * OAuth Callback 处理,生成会话并返回附加参数 */ - async handleOAuthCallback(providerName: string, body: any): Promise<{ token: string, userInfo: OAuthUserInfo, deviceKey: string }> { + async handleOAuthCallback(providerName: string, body: any): Promise<{ + token: string, + userInfo: OAuthUserInfo, + deviceKey: string, + needsEmergency: boolean, + encryptionKey?: string + }> { const provider = getOAuthProvider(providerName, this.env); let params: string | URLSearchParams; @@ -57,7 +67,17 @@ export class AuthService { // 注意:此处统一使用 userInfo.email 作为密钥因子,以确保 OAuth 与 Passkey 登录产生的解密密钥一致。 const deviceKey = await generateDeviceKey(userInfo.email || userInfo.id, this.env.JWT_SECRET || ''); - return { token, userInfo, deviceKey }; + // 检查是否需要强制备份 (Emergency 流程) + const isEmergencyConfirmed = await this.emergencyRepository.isEmergencyConfirmed(); + const needsEmergency = !isEmergencyConfirmed; + + return { + token, + userInfo, + deviceKey, + needsEmergency, + ...(needsEmergency && { encryptionKey: this.env.ENCRYPTION_KEY }) + }; } diff --git a/backend/src/features/auth/webAuthnService.ts b/backend/src/features/auth/webAuthnService.ts index 13dd343..8b69f82 100644 --- a/backend/src/features/auth/webAuthnService.ts +++ b/backend/src/features/auth/webAuthnService.ts @@ -9,15 +9,19 @@ import { verifyAuthenticationResponse, } from '@simplewebauthn/server'; +import { EmergencyRepository } from '@/shared/db/repositories/emergencyRepository'; + export class WebAuthnService { private env: EnvBindings; private rpName = '2FAuth Worker'; private rpID: string; private origin: string; + private emergencyRepository: EmergencyRepository; constructor(env: EnvBindings, url: string, headers?: Record) { this.env = env; const parsedUrl = new URL(url); + this.emergencyRepository = new EmergencyRepository(env.DB); // Handle Reverse Proxy (Docker/Nginx/Cloudflare) // If X-Forwarded-Proto exists, use it as the real protocol @@ -151,6 +155,10 @@ export class WebAuthnService { // 核心: 必须使用 Email 与 OAuth 逻辑保持对齐,确保解密一致性。 const deviceKey = await generateDeviceKey(userEmail, this.env.JWT_SECRET || ''); + // 检查是否需要初始化设置 + const isEmergencyConfirmed = await this.emergencyRepository.isEmergencyConfirmed(); + const needsEmergency = !isEmergencyConfirmed; + return { success: true, token, @@ -160,7 +168,9 @@ export class WebAuthnService { username: userEmail.split('@')[0], email: userEmail, provider: 'passkey' - } + }, + needsEmergency, + ...(needsEmergency && { encryptionKey: this.env.ENCRYPTION_KEY }) }; } diff --git a/backend/src/features/backup/backupService.ts b/backend/src/features/backup/backupService.ts index e77aae0..a3f1a5a 100644 --- a/backend/src/features/backup/backupService.ts +++ b/backend/src/features/backup/backupService.ts @@ -218,7 +218,7 @@ export class BackupService { let encryptedAutoBackupPwd = null; if (autoBackup && autoBackupPassword) { - if (autoBackupPassword.length < 12) throw new AppError('backup_password_length', 400); + if (autoBackupPassword.length < 6) throw new AppError('backup_password_length', 400); encryptedAutoBackupPwd = JSON.stringify(await encryptData(autoBackupPassword, key)); } else if (autoBackup) { throw new AppError('backup_password_required', 400); @@ -254,7 +254,7 @@ export class BackupService { let finalAutoPwd = current?.autoBackupPassword; if (autoBackupPassword) { - if (autoBackupPassword.length < 12) throw new AppError('backup_password_length', 400); + if (autoBackupPassword.length < 6) throw new AppError('backup_password_length', 400); finalAutoPwd = JSON.stringify(await encryptData(autoBackupPassword, key)); } else if (autoBackup && !finalAutoPwd) { throw new AppError('backup_password_required', 400); @@ -316,7 +316,7 @@ export class BackupService { backupPassword = await decryptData(JSON.parse(providerRow.autoBackupPassword), key); } - if (!backupPassword || backupPassword.length < 12) { + if (!backupPassword || backupPassword.length < 6) { throw new AppError('backup_password_length', 400); } diff --git a/backend/src/features/emergency/emergencyRoutes.ts b/backend/src/features/emergency/emergencyRoutes.ts new file mode 100644 index 0000000..e8f9431 --- /dev/null +++ b/backend/src/features/emergency/emergencyRoutes.ts @@ -0,0 +1,36 @@ +import { Hono } from 'hono'; +import { EnvBindings, AppError, SECURITY_CONFIG } from '@/app/config'; +import { authMiddleware } from '@/shared/middleware/auth'; +import { rateLimit, resetRateLimit } from '@/shared/middleware/rateLimitMiddleware'; +import { EmergencyRepository } from '@/shared/db/repositories/emergencyRepository'; + +const emergency = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>(); + +/** + * 确认系统重置(核心密钥已保存) + */ +emergency.post('/confirm', authMiddleware, rateLimit({ + windowMs: SECURITY_CONFIG.LOCKOUT_TIME, + max: SECURITY_CONFIG.MAX_LOGIN_ATTEMPTS, +}), async (c) => { + const { lastFour } = await c.req.json(); + const encryptionKey = c.env.ENCRYPTION_KEY || ''; + + if (!lastFour || encryptionKey.slice(-4) !== lastFour) { + throw new AppError('invalid_emergency_verification', 400); + } + + const repository = new EmergencyRepository(c.env.DB); + await repository.confirmEmergency(); + + // 验证成功,重置限流计数器 + const clientIp = c.req.header('CF-Connecting-IP') || 'unknown'; + await resetRateLimit(c, `rl:${clientIp}:/api/emergency/confirm`); + + return c.json({ + success: true, + message: 'emergency_confirmed' + }); +}); + +export default emergency; diff --git a/backend/src/shared/db/repositories/emergencyRepository.ts b/backend/src/shared/db/repositories/emergencyRepository.ts new file mode 100644 index 0000000..8ce249a --- /dev/null +++ b/backend/src/shared/db/repositories/emergencyRepository.ts @@ -0,0 +1,57 @@ +import { eq } from 'drizzle-orm'; +import { schemaMetadata, type SchemaMetadata } from '@/shared/db/schema'; + +export class EmergencyRepository { + private db: any; + + constructor(dbClient: any) { + this.db = dbClient; + } + + /** + * 获取元数据 + */ + async getMetadata(key: string): Promise { + const result = await this.db + .select() + .from(schemaMetadata) + .where(eq(schemaMetadata.key, key)) + .limit(1); + + return result[0]?.value || null; + } + + /** + * 设置元数据 + */ + async setMetadata(key: string, value: string): Promise { + const existing = await this.getMetadata(key); + if (existing !== null) { + await this.db + .update(schemaMetadata) + .set({ value }) + .where(eq(schemaMetadata.key, key)) + .run(); + } else { + await this.db + .insert(schemaMetadata) + .values({ key, value }) + .run(); + } + } + + /** + * 检查系统是否已确认初始化 (Emergency 流程) + */ + async isEmergencyConfirmed(): Promise { + const value = await this.getMetadata('emergency_confirmed'); + return value === '1'; + } + + /** + * 确认系统初始化 (Emergency 流程) + */ + async confirmEmergency(): Promise { + await this.setMetadata('emergency_confirmed', '1'); + } +} diff --git a/backend/src/shared/db/schema.ts b/backend/src/shared/db/schema.ts index a6ed05b..b17d6c9 100644 --- a/backend/src/shared/db/schema.ts +++ b/backend/src/shared/db/schema.ts @@ -75,6 +75,12 @@ export const rateLimits = sqliteTable('rate_limits', { expiresAt: integer('expires_at'), }); +// 6. 架构元数据表 (Metadata) +export const schemaMetadata = sqliteTable('_schema_metadata', { + key: text('key').primaryKey(), + value: text('value'), +}); + // 导出类型定义 export type VaultItem = typeof vault.$inferSelect; export type NewVaultItem = typeof vault.$inferInsert; @@ -83,5 +89,8 @@ export type NewBackupProvider = typeof backupProviders.$inferInsert; export type BackupTelegramHistory = typeof backupTelegramHistory.$inferSelect; export type NewBackupTelegramHistory = typeof backupTelegramHistory.$inferInsert; export type BackupEmailHistory = typeof backupEmailHistory.$inferSelect; -export type NewBackupEmailHistory = typeof backupEmailHistory.$inferInsert;export type RateLimit = typeof rateLimits.$inferSelect; +export type NewBackupEmailHistory = typeof backupEmailHistory.$inferInsert; +export type RateLimit = typeof rateLimits.$inferSelect; export type NewRateLimit = typeof rateLimits.$inferInsert; +export type SchemaMetadata = typeof schemaMetadata.$inferSelect; +export type NewSchemaMetadata = typeof schemaMetadata.$inferInsert; diff --git a/frontend/package.json b/frontend/package.json index 1988b39..e74fe48 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,8 @@ "element-plus": "^2.9.5", "fflate": "^0.8.2", "hash-wasm": "^4.12.0", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.0", "jsqr": "^1.4.0", "libsodium-wrappers-sumo": "^0.8.2", "pinia": "^3.0.4", diff --git a/frontend/src/app/router.js b/frontend/src/app/router.js index cdfd51a..fab979c 100644 --- a/frontend/src/app/router.js +++ b/frontend/src/app/router.js @@ -29,6 +29,12 @@ const routes = [ name: 'HealthCheck', component: () => import('@/features/health/views/healthCheck.vue'), meta: { guestOnly: false, requiresAuth: false } + }, + { + path: '/emergency', + name: 'Emergency', + component: () => import('@/features/auth/views/emergency.vue'), + meta: { requiresAuth: true } } ] @@ -61,7 +67,17 @@ router.beforeEach(async (to) => { return '/login' } - // 4. 处理仅限游客的页面 + // 4. 处理强制初始化逻辑 (核心密钥备份) + if (isAuthenticated) { + if (authUserStore.needsEmergency && to.path !== '/emergency') { + return '/emergency' + } + if (!authUserStore.needsEmergency && to.path === '/emergency') { + return '/' + } + } + + // 5. 处理仅限游客的页面 if (to.meta.guestOnly && isAuthenticated) { return '/' } diff --git a/frontend/src/app/styles/main.css b/frontend/src/app/styles/main.css index ba488cb..3de95b6 100644 --- a/frontend/src/app/styles/main.css +++ b/frontend/src/app/styles/main.css @@ -28,4 +28,5 @@ @import './modules/components/migration.css'; @import './modules/components/tools.css'; @import './modules/components/health.css'; -@import './modules/components/security.css'; \ No newline at end of file +@import './modules/components/security.css'; +@import './modules/components/emergency.css'; \ No newline at end of file diff --git a/frontend/src/app/styles/modules/components/emergency.css b/frontend/src/app/styles/modules/components/emergency.css new file mode 100644 index 0000000..5a7598a --- /dev/null +++ b/frontend/src/app/styles/modules/components/emergency.css @@ -0,0 +1,348 @@ +.emergency-container { + display: flex; + justify-content: center; + align-items: flex-start; + height: 100vh; + overflow-y: auto; + background: var(--el-bg-color-page); + padding: 80px 20px 60px; +} + +.protocol-wrapper { + max-width: 720px; + width: 100%; + background: var(--el-bg-color); + border-radius: 12px; + box-shadow: var(--el-box-shadow-light); + overflow: hidden; + border: 1px solid var(--el-border-color-lighter); +} + +/* Header - More balanced and adaptive */ +.protocol-header { + padding: 48px 40px 32px; + background: var(--el-bg-color); + border-bottom: 1px solid var(--el-border-color-lighter); +} + +.header-text h1 { + margin: 0; + font-size: 26px; + font-weight: 800; + color: var(--el-text-color-primary); + letter-spacing: -0.02em; +} + +.protocol-subtitle { + margin: 8px 0 0; + font-size: 14px; + color: var(--el-text-color-secondary); + line-height: 1.5; +} + +/* Stepper */ +.protocol-stepper { + padding: 32px 40px; +} + +.step-box { + display: flex; + gap: 24px; + padding: 32px; + border-radius: 8px; + background: var(--el-fill-color-blank); + border: 1px solid var(--el-border-color-lighter); + transition: all 0.3s ease; + position: relative; + z-index: 1; +} + +.step-box.is-active { + border-color: var(--el-color-primary-light-5); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03); +} + +.step-box.is-completed { + border-color: var(--el-color-success-light-5); + background: var(--el-color-success-light-9); +} + +.step-box.is-locked { + opacity: 0.6; + background: var(--el-fill-color-lighter); +} + +.step-number { + font-family: 'Inter', sans-serif; + font-size: 64px; + font-weight: 900; + color: var(--el-text-color-placeholder); + line-height: 1; + opacity: 0.15; + position: absolute; + right: 24px; + bottom: 16px; + z-index: -1; /* Ensure it stays behind buttons/content */ + pointer-events: none; +} + +.step-content { + flex: 1; + z-index: 2; +} + +.step-content h3 { + margin: 0 0 12px; + font-size: 18px; + font-weight: 700; + color: var(--el-text-color-primary); +} + +.step-content p { + margin: 0 0 20px; + font-size: 14px; + color: var(--el-text-color-regular); + line-height: 1.6; +} + +.huge-download-btn { + height: 44px; + padding: 0 24px; + font-size: 14px; + font-weight: 600; + border-radius: 6px; +} + +.step-divider { + height: 24px; + width: 1px; + background: var(--el-border-color-lighter); + margin-left: 56px; +} + +.step-status { + position: absolute; + top: 24px; + right: 24px; + font-size: 20px; +} + +/* Verify Area */ +.verify-input-group { + display: flex; + gap: 12px; + max-width: 400px; +} + +.huge-verify-input .el-input__inner { + height: 44px; + font-family: ui-monospace, monospace; + font-size: 18px; + letter-spacing: 6px; + text-align: center; + border-radius: 6px; +} + +.huge-verify-btn { + height: 44px; + padding: 0 20px; + font-weight: 600; + border-radius: 6px; +} + +/* Info Footer - Brighter and stronger prominence */ +.protocol-info-footer { + padding: 40px; + background: var(--el-fill-color-blank); + border-top: 1px solid var(--el-border-color-lighter); +} + +.info-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 40px; +} + +.info-cell { + background: var(--el-fill-color-extra-light); + padding: 24px; + border-radius: 12px; + border: 1px solid var(--el-border-color-extra-light); +} + +.cell-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.cell-header .el-icon { + font-size: 20px; + color: var(--el-color-primary); +} + +.info-cell:nth-child(2) .cell-header .el-icon { + color: var(--el-color-warning); +} + +.info-cell label { + display: block; + font-size: 15px; + font-weight: 700; + color: var(--el-text-color-primary); + text-transform: none; + letter-spacing: normal; +} + +.info-cell p { + margin: 0; + font-size: 14px; + color: var(--el-text-color-secondary); + line-height: 1.6; +} + +/* Mobile Adaptation Enhanced */ +@media (max-width: 640px) { + .emergency-container { + padding: 0; + } + + .protocol-wrapper { + border-radius: 0; + min-height: 100vh; + border: none; + box-shadow: none; + } + + .protocol-header { + padding: 40px 24px 24px; + } + + .header-text h1 { + font-size: 22px; + } + + .protocol-stepper { + padding: 20px; + } + + .step-box { + padding: 24px 20px; + gap: 16px; + } + + .step-number { + font-size: 48px; + right: 16px; + bottom: 12px; + } + + .step-divider { + margin: 0 0 0 24px; + } + + .verify-input-group { + flex-direction: column; + max-width: 100%; + } + + .info-grid { + grid-template-columns: 1fr; + gap: 24px; + } + + .protocol-info-footer { + padding: 24px 20px; + } +} + +/* PDF Document Style (Unchanged as it's for print) */ +.pdf-document { + width: 210mm; + padding: 25mm; + color: #0f172a; + background: white; + font-family: sans-serif; +} + +.doc-header { + border-bottom: 4px solid #3b82f6; + padding-bottom: 10mm; + margin-bottom: 15mm; +} + +.doc-brand { + font-size: 20pt; + font-weight: 300; + color: #64748b; + margin-bottom: 2mm; +} + +.doc-protocol { + font-size: 36pt; + font-weight: 800; + color: #0f172a; +} + +.doc-id { + font-family: monospace; + font-size: 12pt; + color: #94a3b8; + margin-top: 4mm; +} + +.info-grid-pdf { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10mm; + margin-bottom: 15mm; +} + +.doc-key-section { + background: #f8fafc; + border: 1px solid #e2e8f0; + padding: 12mm; + border-radius: 4mm; + margin-bottom: 15mm; +} + +.doc-key-section h2 { + margin: 0 0 8mm; + font-size: 16pt; +} + +.doc-key-box { + background: white; + padding: 12mm; + font-family: monospace; + font-size: 28pt; + font-weight: 700; + border: 1px solid #e2e8f0; + text-align: center; + word-break: break-all; + box-shadow: 0 4pt 6pt -1pt rgba(0, 0, 0, 0.1); +} + +.doc-warning { + color: #ef4444; + font-weight: 700; + margin-top: 8mm; + font-size: 12pt; +} + +.doc-usage h3, .doc-legal h3 { + border-bottom: 1px solid #e2e8f0; + padding-bottom: 3mm; + margin-bottom: 5mm; + font-size: 14pt; +} + +.doc-footer { + margin-top: 25mm; + border-top: 1px solid #e2e8f0; + padding-top: 8mm; + text-align: center; + font-size: 10pt; + color: #94a3b8; +} diff --git a/frontend/src/app/styles/modules/components/vault.css b/frontend/src/app/styles/modules/components/vault.css index 5421ecd..03d0ce3 100644 --- a/frontend/src/app/styles/modules/components/vault.css +++ b/frontend/src/app/styles/modules/components/vault.css @@ -376,7 +376,7 @@ cursor: pointer; background: var(--el-fill-color-light); color: var(--el-text-color-regular); - padding: 5px 10px; + padding: 3px 10px 4px 10px; border-radius: 4px; font-size: 13px; white-space: nowrap; @@ -391,16 +391,27 @@ background: var(--el-fill-color); } -/* 合并后的激活状态 */ +/* Final Choice: Option 1 - Subtle Glassmorphism (毛玻璃高亮) */ .category-tag.is-active { - background-color: var(--el-color-primary) !important; - color: #fff !important; - border-color: var(--el-color-primary) !important; - font-weight: 500; + background-color: var(--el-color-primary-light-9) !important; + color: var(--el-color-primary) !important; + border-color: var(--el-color-primary-light-5) !important; +} + +/* 适配深色模式下的透明度感 */ +html.dark .category-tag.is-active { + background-color: rgba(64, 158, 255, 0.15) !important; + border-color: rgba(64, 158, 255, 0.4) !important; + color: var(--el-color-primary-light-3) !important; } .category-tag.is-active .tag-count { - color: rgba(255, 255, 255, 0.8) !important; + color: var(--el-color-primary) !important; + opacity: 0.7; +} + +html.dark .category-tag.is-active .tag-count { + color: var(--el-color-primary-light-3) !important; } .tag-count { diff --git a/frontend/src/features/auth/composables/useOAuthCallback.js b/frontend/src/features/auth/composables/useOAuthCallback.js index a4c80a0..6f1079a 100644 --- a/frontend/src/features/auth/composables/useOAuthCallback.js +++ b/frontend/src/features/auth/composables/useOAuthCallback.js @@ -102,11 +102,18 @@ export function useOAuthCallback() { } // 更新状态机进入应用主流程 const authUserStore = useAuthUserStore() - await authUserStore.fetchUserInfo() + + // 将用户信息和设置状态存入 store + await authUserStore.setUserInfo(data.userInfo, !!data.needsEmergency, data.encryptionKey || '') // 清理旧的 Tab 状态,确保干净进入首页 sessionStorage.clear() - router.push('/') + + if (data.needsEmergency) { + router.push('/emergency') + } else { + router.push('/') + } } else { errorMsg.value = data.error || t('auth.login_rejected') } diff --git a/frontend/src/features/auth/composables/useOAuthProviders.js b/frontend/src/features/auth/composables/useOAuthProviders.js index 602a53e..6a1c161 100644 --- a/frontend/src/features/auth/composables/useOAuthProviders.js +++ b/frontend/src/features/auth/composables/useOAuthProviders.js @@ -33,7 +33,6 @@ export function useOAuthProviders() { } } catch (e) { console.error('Failed to sync oauth providers:', e) - ElMessage.error(i18n.global.t('auth.fetch_providers_failed')) } finally { isFetchingProviders.value = false } @@ -67,11 +66,7 @@ export function useOAuthProviders() { } } catch (error) { // Error managed by axios request interceptor & authError - let errorMsg = error.message - if (errorMsg && i18n.global.te(`api_errors.${errorMsg}`)) { - errorMsg = i18n.global.t(`api_errors.${errorMsg}`) - } - ElMessage.error(errorMsg || i18n.global.t('auth.oauth_network_error')) + console.error('OAuth Login Error:', error) } finally { // The loading state mostly clears when the page navigates away, // but we reset it here in case of errors. diff --git a/frontend/src/features/auth/service/authService.js b/frontend/src/features/auth/service/authService.js index d1d7962..e14f29d 100644 --- a/frontend/src/features/auth/service/authService.js +++ b/frontend/src/features/auth/service/authService.js @@ -95,5 +95,20 @@ export const authService = { } catch (error) { return { success: false } } + }, + + /** + * 确认系统初始化已完成 (验证并激活) + * @param {string} lastFour - Key 的最后 4 位 + */ + async confirmEmergency(lastFour) { + try { + return await request('/api/emergency/confirm', { + method: 'POST', + body: JSON.stringify({ lastFour }) + }) + } catch (error) { + throw new authError(error.message || '系统确认失败', error.status) + } } } diff --git a/frontend/src/features/auth/store/authUserStore.js b/frontend/src/features/auth/store/authUserStore.js index 62ffa09..1cea627 100644 --- a/frontend/src/features/auth/store/authUserStore.js +++ b/frontend/src/features/auth/store/authUserStore.js @@ -6,9 +6,13 @@ import { setIdbItem, getIdbItem, removeIdbItem } from '@/shared/utils/idb' export const useAuthUserStore = defineStore('authUserInfo', () => { const userInfo = ref({}) + const needsEmergency = ref(false) + const tempEncryptionKey = ref('') - const setUserInfo = async (info) => { + const setUserInfo = async (info, emergency = false, key = '') => { userInfo.value = info + needsEmergency.value = emergency + tempEncryptionKey.value = key await setIdbItem('auth:user:profile', info) } @@ -21,6 +25,8 @@ export const useAuthUserStore = defineStore('authUserInfo', () => { const clearUserInfo = async () => { userInfo.value = {} + needsEmergency.value = false + tempEncryptionKey.value = '' await removeIdbItem('auth:user:profile') await removeIdbItem('vault:data:main') await removeIdbItem('vault:conf:backups') @@ -38,7 +44,10 @@ export const useAuthUserStore = defineStore('authUserInfo', () => { const fetchUserInfo = async () => { const data = await authService.fetchMe() if (data && data.success) { - setUserInfo(data.userInfo) + // NOTE: fetchMe might not return needsEmergency as it's only returned during login callback + // but we might want the server to actually keep track of this in the session if possible. + // For now, it's primarily used during initial login pulse. + await setUserInfo(data.userInfo, !!data.needsEmergency, data.encryptionKey || '') return true } else { await clearUserInfo() @@ -48,6 +57,8 @@ export const useAuthUserStore = defineStore('authUserInfo', () => { return { userInfo, + needsEmergency, + tempEncryptionKey, setUserInfo, clearUserInfo, logout, diff --git a/frontend/src/features/auth/views/emergency.vue b/frontend/src/features/auth/views/emergency.vue new file mode 100644 index 0000000..4951448 --- /dev/null +++ b/frontend/src/features/auth/views/emergency.vue @@ -0,0 +1,236 @@ + + + diff --git a/frontend/src/features/auth/views/login.vue b/frontend/src/features/auth/views/login.vue index 41fbf36..4d04286 100644 --- a/frontend/src/features/auth/views/login.vue +++ b/frontend/src/features/auth/views/login.vue @@ -66,6 +66,7 @@