feat: 增加"核心密钥强制备份"流程,保护用户数据不丢失
This commit is contained in:
1 parent
d51d558500
commit
efcef823a0
27 files changed
+926
-68
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 })
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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<string, string | undefined>) {
|
||||
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 })
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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<string | null> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
const value = await this.getMetadata('emergency_confirmed');
|
||||
return value === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认系统初始化 (Emergency 流程)
|
||||
*/
|
||||
async confirmEmergency(): Promise<void> {
|
||||
await this.setMetadata('emergency_confirmed', '1');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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 '/'
|
||||
}
|
||||
|
||||
@@ -28,4 +28,5 @@
|
||||
@import './modules/components/migration.css';
|
||||
@import './modules/components/tools.css';
|
||||
@import './modules/components/health.css';
|
||||
@import './modules/components/security.css';
|
||||
@import './modules/components/security.css';
|
||||
@import './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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<div class="emergency-container">
|
||||
<div class="protocol-wrapper">
|
||||
<!-- High Impact Header -->
|
||||
<header class="protocol-header">
|
||||
<div class="header-text">
|
||||
<h1>{{ $t('emergency.emergency_title') }}</h1>
|
||||
<p class="protocol-subtitle">{{ $t('emergency.emergency_subtitle') }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="protocol-stepper">
|
||||
<!-- Step 1: Download -->
|
||||
<section class="step-box" :class="{ 'is-completed': pdfDownloaded }">
|
||||
<div class="step-number">01</div>
|
||||
<div class="step-content">
|
||||
<h3>{{ $t('emergency.download_pdf') }}</h3>
|
||||
<p>{{ $t('emergency.save_instruction') }}</p>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="huge-download-btn"
|
||||
:loading="generatingPdf"
|
||||
@click="generateEmergency"
|
||||
>
|
||||
{{ $t('emergency.download_pdf_btn') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="step-status" v-if="pdfDownloaded">
|
||||
<el-icon color="#67C23A"><CircleCheckFilled /></el-icon>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="step-divider"></div>
|
||||
|
||||
<!-- Step 2: Verify (Locked until Step 1) -->
|
||||
<section class="step-box" :class="{ 'is-locked': !pdfDownloaded, 'is-active': pdfDownloaded }">
|
||||
<div class="step-number">02</div>
|
||||
<div class="step-content">
|
||||
<h3>{{ $t('emergency.verify_title') }}</h3>
|
||||
<p>{{ $t('emergency.verify_desc') }}</p>
|
||||
|
||||
<div class="verify-input-group">
|
||||
<el-input
|
||||
v-model="verificationCode"
|
||||
placeholder="****"
|
||||
maxlength="4"
|
||||
:disabled="!pdfDownloaded"
|
||||
class="huge-verify-input"
|
||||
@keyup.enter="handleVerify"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Key /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button
|
||||
type="success"
|
||||
size="large"
|
||||
class="huge-verify-btn"
|
||||
:loading="verifying"
|
||||
:disabled="!pdfDownloaded || verificationCode.length !== 4"
|
||||
@click="handleVerify"
|
||||
>
|
||||
{{ $t('emergency.verify_btn') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Info Footer -->
|
||||
<footer class="protocol-info-footer">
|
||||
<div class="info-grid">
|
||||
<div class="info-cell">
|
||||
<div class="cell-header">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<label>{{ $t('emergency.importance_title') }}</label>
|
||||
</div>
|
||||
<p>{{ $t('emergency.importance_desc') }}</p>
|
||||
</div>
|
||||
<div class="info-cell">
|
||||
<div class="cell-header">
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
<label>{{ $t('emergency.pdf_self_custody') }}</label>
|
||||
</div>
|
||||
<p>{{ $t('emergency.pdf_self_custody_desc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- The PDF Template (Hidden) -->
|
||||
<div style="position: absolute; left: -9999px;">
|
||||
<div id="pdf-template" class="pdf-document">
|
||||
<div class="doc-header">
|
||||
<div class="doc-brand">2FAuth Worker</div>
|
||||
<div class="doc-protocol">{{ $t('emergency.pdf_header').toUpperCase() }}</div>
|
||||
<div class="doc-id">KIT ID: {{ protocolId }}</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-body">
|
||||
<section class="doc-info">
|
||||
<div class="info-grid-pdf">
|
||||
<div><strong>{{ $t('emergency.pdf_server_url') }}</strong><br>{{ serverUrl }}</div>
|
||||
<div><strong>{{ $t('emergency.pdf_admin_email') }}</strong><br>{{ adminEmail }}</div>
|
||||
<div><strong>{{ $t('emergency.pdf_gen_date') }}</strong><br>{{ genDate }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="doc-key-section">
|
||||
<h2>{{ $t('emergency.pdf_key_title') }}</h2>
|
||||
<div class="doc-key-box">{{ encryptionKey }}</div>
|
||||
<p class="doc-warning">{{ $t('emergency.pdf_warning') }}</p>
|
||||
</section>
|
||||
|
||||
<section class="doc-usage">
|
||||
<h3>{{ $t('emergency.pdf_usage_title') }}</h3>
|
||||
<p>{{ $t('emergency.pdf_usage_desc') }}</p>
|
||||
</section>
|
||||
|
||||
<section class="doc-legal">
|
||||
<h3>{{ $t('emergency.importance_title') }}</h3>
|
||||
<p>{{ $t('emergency.importance_desc') }}</p>
|
||||
</section>
|
||||
|
||||
<section class="doc-legal">
|
||||
<h3>{{ $t('emergency.pdf_self_custody') }}</h3>
|
||||
<p>{{ $t('emergency.pdf_self_custody_desc') }}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="doc-footer">
|
||||
©{{ new Date().getFullYear() }} {{ $t('emergency.pdf_footer') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { CircleCheckFilled, Download, Key, Lock, InfoFilled, WarningFilled } from '@element-plus/icons-vue'
|
||||
import { useAuthUserStore } from '@/features/auth/store/authUserStore'
|
||||
import { authService } from '@/features/auth/service/authService'
|
||||
import jsPDF from 'jspdf'
|
||||
import html2canvas from 'html2canvas'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const authUserStore = useAuthUserStore()
|
||||
|
||||
const encryptionKey = computed(() => authUserStore.tempEncryptionKey)
|
||||
const verificationCode = ref('')
|
||||
const generatingPdf = ref(false)
|
||||
const verifying = ref(false)
|
||||
const pdfDownloaded = ref(false)
|
||||
|
||||
const protocolId = ref(Math.random().toString(36).substr(2, 9).toUpperCase())
|
||||
const serverUrl = ref(window.location.origin)
|
||||
const adminEmail = ref(authUserStore.userInfo?.email || '')
|
||||
const genDate = ref(new Date().toLocaleString())
|
||||
|
||||
onMounted(() => {
|
||||
if (!encryptionKey.value) {
|
||||
// If key is missing, try to fetch it again (it's now returned by /me if needsEmergency)
|
||||
authUserStore.fetchUserInfo().then(success => {
|
||||
if (!success || !encryptionKey.value) {
|
||||
ElMessage.error('Security token expired, please re-login')
|
||||
router.push('/login')
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 安全清理:组件销毁时立即清空内存中的根密钥
|
||||
authUserStore.tempEncryptionKey = ''
|
||||
})
|
||||
|
||||
const generateEmergency = async () => {
|
||||
generatingPdf.value = true
|
||||
try {
|
||||
const element = document.getElementById('pdf-template')
|
||||
const canvas = await html2canvas(element, {
|
||||
scale: 3,
|
||||
useCORS: true,
|
||||
backgroundColor: '#ffffff'
|
||||
})
|
||||
|
||||
const imgData = canvas.toDataURL('image/jpeg', 0.95)
|
||||
const pdf = new jsPDF('p', 'mm', 'a4')
|
||||
const pdfWidth = pdf.internal.pageSize.getWidth()
|
||||
const imgProps = pdf.getImageProperties(imgData)
|
||||
const pdfHeight = (imgProps.height * pdfWidth) / imgProps.width
|
||||
|
||||
pdf.addImage(imgData, 'JPEG', 0, 0, pdfWidth, pdfHeight)
|
||||
pdf.save(`${t('emergency.pdf_filename')}.pdf`)
|
||||
|
||||
pdfDownloaded.value = true
|
||||
ElMessage.success(t('common.success'))
|
||||
} catch (error) {
|
||||
console.error('PDF Generation Failed:', error)
|
||||
ElMessage.error('Failed to generate PDF')
|
||||
} finally {
|
||||
generatingPdf.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (verificationCode.value.length !== 4) return
|
||||
|
||||
verifying.value = true
|
||||
try {
|
||||
// We don't use silent: true here in authService yet, but let's check authService.js
|
||||
const res = await authService.confirmEmergency(verificationCode.value)
|
||||
if (res.success) {
|
||||
ElMessage.success(t('emergency.confirm_success'))
|
||||
authUserStore.needsEmergency = false
|
||||
authUserStore.tempEncryptionKey = ''
|
||||
router.push('/')
|
||||
}
|
||||
} catch (error) {
|
||||
// The error is already toasted by request.js interceptor if it's a 400/500
|
||||
// If we want a specific message here, we should have used silent: true
|
||||
// To fix double toast, we simply remove this local ElMessage.error
|
||||
// and let the interceptor show the translated "invalid_emergency_verification"
|
||||
verificationCode.value = ''
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -66,6 +66,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Lock, Platform, Loading } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -82,6 +83,7 @@ import { useAuthUserStore } from '@/features/auth/store/authUserStore'
|
||||
import { setIdbItem } from '@/shared/utils/idb'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const authUserStore = useAuthUserStore()
|
||||
|
||||
const iconComponents = {
|
||||
@@ -113,14 +115,18 @@ const handlePasskeyLogin = async () => {
|
||||
const res = await webAuthnService.login()
|
||||
if (res.success) {
|
||||
// Passkey 登录逻辑与 OAuth 回调成功后一致
|
||||
await authUserStore.setUserInfo(res.userInfo)
|
||||
await authUserStore.setUserInfo(res.userInfo, !!res.needsEmergency, res.encryptionKey || '')
|
||||
// 设备指纹 key (用于离线加密验证)
|
||||
if (res.deviceKey) {
|
||||
await setIdbItem('sys:sec:device_salt', res.deviceKey)
|
||||
}
|
||||
|
||||
ElMessage.success(t('common.success'))
|
||||
window.location.href = '/'
|
||||
if (res.needsEmergency) {
|
||||
router.push('/emergency')
|
||||
} else {
|
||||
window.location.href = '/'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Passkey login failed:', error)
|
||||
|
||||
@@ -33,7 +33,7 @@ export function useBackupOperations(uiState, emit, fetchProviders) {
|
||||
}
|
||||
|
||||
const handleBackup = async () => {
|
||||
if (!uiState.useAutoPassword.value && uiState.backupPassword.value.length < 12) {
|
||||
if (!uiState.useAutoPassword.value && uiState.backupPassword.value.length < 6) {
|
||||
return ElMessage.warning(t('backup.password_min_length'))
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ export function useBackupProviders() {
|
||||
if (isEditing.value && isAutoBackupPasswordSaved.value && shouldUseExistingAutoBackupPassword.value) {
|
||||
return null
|
||||
}
|
||||
if (!form.value.autoBackupPassword || form.value.autoBackupPassword.length < 12) {
|
||||
if (!form.value.autoBackupPassword || form.value.autoBackupPassword.length < 6) {
|
||||
return t('backup.password_min_length')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export function useDataExport() {
|
||||
if (exportForm.value.password !== exportForm.value.confirm) {
|
||||
return ElMessage.error(t('migration.password_mismatch'))
|
||||
}
|
||||
if (exportForm.value.password.length < 12) {
|
||||
if (exportForm.value.password.length < 6) {
|
||||
return ElMessage.error(t('migration.password_weak'))
|
||||
}
|
||||
password = exportForm.value.password
|
||||
@@ -160,7 +160,11 @@ export function useDataExport() {
|
||||
ElMessage.success(t('migration.export_success'))
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error)
|
||||
ElMessage.error(error.message || t('migration.export_failed'))
|
||||
// fetchAllVault uses request.js (auto-toast)
|
||||
// For local errors like "no_accounts_to_export" or crypto fails, we manually toast if not handled
|
||||
if (error.message && !error.message.includes('fetch') && !error.message.includes('api_errors')) {
|
||||
ElMessage.error(error.message)
|
||||
}
|
||||
} finally {
|
||||
isExporting.value = false
|
||||
}
|
||||
|
||||
@@ -314,16 +314,11 @@ export function useDataImport(emitFn) {
|
||||
duration: batchErrors.value.length > 0 ? 0 : 8000
|
||||
})
|
||||
|
||||
if (data.count > 0) {
|
||||
vaultStore.markDirty()
|
||||
emitFn('success')
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(t('api_errors.request_failed'))
|
||||
// The error case (data.success === false) is already handled by request.js interceptor
|
||||
}
|
||||
} catch (err) {
|
||||
showBatchProgress.value = false
|
||||
ElMessage.error(`${t('api_errors.request_failed')}: ${err.message}`)
|
||||
// Error already toasted by request.js
|
||||
} finally {
|
||||
resetBatchState()
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ export function useVaultActions(fetchVault, vault, categoryStats) {
|
||||
vaultStore.markDirty()
|
||||
} catch (e) {
|
||||
successMsg?.close()
|
||||
ElMessage.error(t('common.error'))
|
||||
// Error already toasted by request.js interceptor
|
||||
// 失败回退:重推旧顺序到 UI
|
||||
vault.value = oldItems
|
||||
fetchVault()
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
"name": "Config Name",
|
||||
"config": "Settings",
|
||||
"auto_backup": "Auto Backup",
|
||||
"password_required": "Auto backup requires a password (min 12 chars)",
|
||||
"password_required": "Auto backup requires a password (min 6 chars)",
|
||||
"test_success": "Connection successful",
|
||||
"test_failed": "Connection failed",
|
||||
"save_success": "Cloud backup config saved",
|
||||
@@ -246,7 +246,7 @@
|
||||
"backup_security_tip": "Please enter an encryption password to secure the backup file.",
|
||||
"use_auto_pwd": "Use Auto Backup Password",
|
||||
"use_new_pwd": "Use New Password",
|
||||
"input_custom_pwd": "Enter custom encryption password (at least 12 chars)",
|
||||
"input_custom_pwd": "Enter custom encryption password (at least 6 chars)",
|
||||
"baidu_auth_failed": "Baidu Netdisk authorization failed",
|
||||
"dropbox_auth_failed": "Dropbox authorization failed",
|
||||
"start_backup": "Start Backup",
|
||||
@@ -262,7 +262,7 @@
|
||||
"center_title": "Cloud Backup Center",
|
||||
"center_desc": "Configure multiple backup sources (WebDAV, S3, etc.) to ensure data safety.",
|
||||
"s3_region_placeholder": "auto or us-east-1",
|
||||
"password_length_req": "Required, length must be ≥ 12 characters.",
|
||||
"password_length_req": "Required, length must be ≥ 6 characters.",
|
||||
"require_name": "Please enter a name",
|
||||
"require_webdav_url": "Please enter WebDAV URL",
|
||||
"require_username": "Please enter username",
|
||||
@@ -282,7 +282,7 @@
|
||||
"testing_connection": "Testing connection...",
|
||||
"fetching_configs": "Fetching config list...",
|
||||
"saving_config": "Saving config...",
|
||||
"password_min_length": "Password must be at least 12 characters",
|
||||
"password_min_length": "Password must be at least 6 characters",
|
||||
"password_no_space": "Password cannot contain spaces",
|
||||
"password_not_all_number": "Password cannot be all numbers",
|
||||
"backup_finish_title": "Cloud Backup Finished",
|
||||
@@ -303,10 +303,10 @@
|
||||
"export": "Export Vault",
|
||||
"import": "Import Data",
|
||||
"export_format": "Export Format",
|
||||
"export_password": "Encryption Password (min 12 chars)",
|
||||
"export_password": "Encryption Password (min 6 chars)",
|
||||
"export_password_confirm": "Confirm Password",
|
||||
"password_mismatch": "Passwords do not match!",
|
||||
"password_weak": "Password too weak. It must be at least 12 characters.",
|
||||
"password_weak": "Password too weak. It must be at least 6 characters.",
|
||||
"select_account": "Please select at least one account.",
|
||||
"export_success": "Export completed.",
|
||||
"export_failed": "Export failed. Please try again later.",
|
||||
@@ -600,14 +600,14 @@
|
||||
"missing_service_account": "Service and Account names are required",
|
||||
"no_account_ids": "No account IDs provided",
|
||||
"export_type_invalid": "Invalid export type",
|
||||
"export_password_length": "Export password needs at least 12 characters",
|
||||
"export_password_length": "Export password needs at least 6 characters",
|
||||
"missing_content_type": "Content and type cannot be empty",
|
||||
"import_password_required": "Encrypted import requires a password",
|
||||
"parse_failed": "Parsing failed, please check file format or password",
|
||||
"provider_not_found": "Provider not found",
|
||||
"missing_fields": "Missing required fields",
|
||||
"backup_password_length": "Auto backup password must be at least 12 characters",
|
||||
"backup_password_required": "Auto backup password is required (at least 12 characters)",
|
||||
"backup_password_length": "Auto backup password must be at least 6 characters",
|
||||
"backup_password_required": "Auto backup password is required (at least 6 characters)",
|
||||
"manual_backup_password_needed": "Manual backup with auto-password requires configuring an auto-backup password first.",
|
||||
"filename_required": "Filename is required",
|
||||
"no_session": "Unauthorized: Missing session cookie",
|
||||
@@ -638,6 +638,7 @@
|
||||
"telegram_download_failed": "Telegram download failed",
|
||||
"file_not_found_in_history": "File not found in history",
|
||||
"telegram_file_path_missing": "File path not returned from Telegram",
|
||||
"invalid_emergency_verification": "Verification failed! Please double-check the last 4 digits of the key at the bottom of your Emergency Kit PDF.",
|
||||
"s3_config_incomplete": "S3 configuration incomplete",
|
||||
"s3_api_error": "S3 API Error",
|
||||
"s3_list_failed": "Failed to list S3 objects",
|
||||
@@ -761,5 +762,34 @@
|
||||
"default_name": "My New Device",
|
||||
"registering": "Communicating with hardware...",
|
||||
"register_success": "Passkey registered successfully!"
|
||||
},
|
||||
"emergency": {
|
||||
"emergency_title": "ENCRYPTION_KEY Backup",
|
||||
"emergency_subtitle": "Initialization successful. For your data security, you must back up the core encryption key.",
|
||||
"encryption_key_label": "Core Encryption Key",
|
||||
"importance_title": "Why is this important?",
|
||||
"importance_desc": "This system operates on zero-knowledge encryption. The core encryption key is the only credential for your data. In case of server environment loss, it is the only way to recover your data.",
|
||||
"save_instruction": "Please download the PDF Core Key on this page and store it in a safe place (e.g., email, cloud storage, or print for safekeeping).",
|
||||
"download_pdf": "Step 1: Download PDF Core Key",
|
||||
"download_pdf_botton": "Download Core Key PDF Document",
|
||||
"verify_title": "Step 2: Verify and Activate",
|
||||
"verify_desc": "Please open the PDF file you just downloaded and fill in the last 4 characters of the core key.",
|
||||
"verify_placeholder": "Last 4 chars",
|
||||
"verify_btn": "Verify Now",
|
||||
"invalid_verification": "Verification failed. Please check the last 4 characters of the key in the PDF.",
|
||||
"confirm_success": "Core key backup completed, welcome to 2FAuth Worker!",
|
||||
"pdf_filename": "2FAuth_Worker_Core_Key_Backup",
|
||||
"pdf_header": "2FAuth Worker Core Key Backup",
|
||||
"pdf_self_custody": "Important Notice",
|
||||
"pdf_self_custody_desc": "2FAuth Worker is a fully self-hosted system. We do not store your master password or encryption keys. This means if you lose this Emergency code, all your data will be permanently locked and irrecoverable.",
|
||||
"pdf_info_title": "System Information",
|
||||
"pdf_server_url": "Server URL: ",
|
||||
"pdf_admin_email": "Admin Email: ",
|
||||
"pdf_gen_date": "Generation Date: ",
|
||||
"pdf_key_title": "Core Encryption Key (ENCRYPTION_KEY)",
|
||||
"pdf_usage_title": "How to use?",
|
||||
"pdf_usage_desc": "If your server environment variables are lost, fill this key into the ENCRYPTION_KEY variable during redeployment to recover all data.",
|
||||
"pdf_warning": "Warning: Never lose this key; it is the only way to recover your data.",
|
||||
"pdf_footer": "2FAuth Worker A Secure 2FA Management Tool"
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@
|
||||
"name": "配置名称",
|
||||
"config": "参数配置",
|
||||
"auto_backup": "自动备份",
|
||||
"password_required": "静默同步需设置至少12位密码",
|
||||
"password_required": "静默同步需设置至少 6 位密码",
|
||||
"test_success": "连接成功",
|
||||
"test_failed": "连接失败",
|
||||
"save_success": "云端备份设置成功",
|
||||
@@ -246,7 +246,7 @@
|
||||
"backup_security_tip": "请输入加密密码用于保护备份文件。",
|
||||
"use_auto_pwd": "使用自动备份密码",
|
||||
"use_new_pwd": "使用新密码",
|
||||
"input_custom_pwd": "输入自定义加密密码 (至少12位)",
|
||||
"input_custom_pwd": "输入自定义加密密码 (至少 6 位)",
|
||||
"baidu_auth_failed": "百度网盘授权失败",
|
||||
"dropbox_auth_failed": "Dropbox 授权失败",
|
||||
"start_backup": "开始备份",
|
||||
@@ -262,7 +262,7 @@
|
||||
"center_title": "云端备份中心",
|
||||
"center_desc": "配置多个备份源(WebDAV, S3等),确保数据万无一失。",
|
||||
"s3_region_placeholder": "auto 或 us-east-1",
|
||||
"password_length_req": "必填项,长度必须 ≥ 12 位。",
|
||||
"password_length_req": "必填项,长度必须 ≥ 6 位。",
|
||||
"require_name": "请输入名称",
|
||||
"require_webdav_url": "请输入 WebDAV 地址",
|
||||
"require_username": "请输入用户名",
|
||||
@@ -282,7 +282,7 @@
|
||||
"testing_connection": "正在测试连接...",
|
||||
"fetching_configs": "正在获取配置列表...",
|
||||
"saving_config": "正在保存配置...",
|
||||
"password_min_length": "密码至少12位",
|
||||
"password_min_length": "密码至少 6 位",
|
||||
"password_no_space": "密码不能包含空格",
|
||||
"password_not_all_number": "密码不能全部为数字",
|
||||
"backup_finish_title": "云端备份完成",
|
||||
@@ -303,10 +303,10 @@
|
||||
"export": "数据导出",
|
||||
"import": "数据导入",
|
||||
"export_format": "导出格式",
|
||||
"export_password": "强加密密码(至少12位)",
|
||||
"export_password": "强加密密码(至少 6 位)",
|
||||
"export_password_confirm": "确认密码",
|
||||
"password_mismatch": "两次输入的密码不一致!",
|
||||
"password_weak": "密码太弱!至少需要 12 个字符。",
|
||||
"password_weak": "密码太弱!至少需要 6 个字符。",
|
||||
"select_account": "请至少选择一个账号",
|
||||
"export_success": "导出完成",
|
||||
"export_failed": "导出失败,请稍后重试",
|
||||
@@ -600,14 +600,14 @@
|
||||
"missing_service_account": "服务商和账号名称为必填项",
|
||||
"no_account_ids": "未提供账号 ID",
|
||||
"export_type_invalid": "无效的导出类型",
|
||||
"export_password_length": "导出密码至少需要 12 个字符",
|
||||
"export_password_length": "导出密码至少需要 6 个字符",
|
||||
"missing_content_type": "内容和类型不能为空",
|
||||
"import_password_required": "加密导入需要密码",
|
||||
"parse_failed": "解析失败,请检查文件格式或密码是否正确",
|
||||
"provider_not_found": "未找到提供商",
|
||||
"missing_fields": "缺少必填字段",
|
||||
"backup_password_length": "自动备份密码至少需要12个字符",
|
||||
"backup_password_required": "自动备份密码为必填项(至少12个字符)",
|
||||
"backup_password_length": "自动备份密码至少需要 6 个字符",
|
||||
"backup_password_required": "自动备份密码为必填项(至少 6 个字符)",
|
||||
"manual_backup_password_needed": "使用自动密码进行手动备份要求先配置好自动备份密码。",
|
||||
"filename_required": "文件名为必填项",
|
||||
"no_session": "未授权:缺少会话 cookie",
|
||||
@@ -638,6 +638,7 @@
|
||||
"telegram_download_failed": "Telegram 下载失败",
|
||||
"file_not_found_in_history": "在历史记录中找不到此文件",
|
||||
"telegram_file_path_missing": "Telegram 未返回文件路径",
|
||||
"invalid_emergency_verification": "验证失败!请核对 PDF 文件中密钥的最后 4 位",
|
||||
"s3_config_incomplete": "S3 配置不完整",
|
||||
"s3_api_error": "S3 API 错误",
|
||||
"s3_list_failed": "获取 S3 对象列表失败",
|
||||
@@ -761,5 +762,34 @@
|
||||
"default_name": "我的新设备",
|
||||
"registering": "正在与硬件通信...",
|
||||
"register_success": "通行密钥绑定成功!"
|
||||
},
|
||||
"emergency": {
|
||||
"emergency_title": "加密密钥 ENCRYPTION_KEY 备份",
|
||||
"emergency_subtitle": "系统初始化成功。为了您的数据安全,必须对加密密钥进行物理备份。",
|
||||
"encryption_key_label": "核心加密密钥",
|
||||
"importance_title": "为什么很重要?",
|
||||
"importance_desc": "本系统采用零知识加密。核心加密密钥是解密所有令牌的唯一凭证。若服务器环境变量丢失,此密钥是找回数据的唯一途径。",
|
||||
"save_instruction": "请在该页面下载 PDF 核心密钥,并将其存放在安全的地方(如邮箱、网盘或打印后保存)。",
|
||||
"download_pdf": "第一步:下载 PDF 核心密钥",
|
||||
"download_pdf_btn": "下载 核心密钥 PDF 文档",
|
||||
"verify_title": "第二步:验证并激活",
|
||||
"verify_desc": "请打开您刚刚下载的 PDF 文件,填写核心密钥的最后 4 位。",
|
||||
"verify_placeholder": "最后 4 位",
|
||||
"verify_btn": "马上验证",
|
||||
"invalid_verification": "验证失败。请核对 PDF 中的密钥最后 4 位。",
|
||||
"confirm_success": "核心密钥备份已完成,欢迎使用 2FAuth Worker!",
|
||||
"pdf_filename": "2FAuth_Worker_核心密钥备份",
|
||||
"pdf_header": "2FAuth Worker 核心密钥备份",
|
||||
"pdf_self_custody": "为什么不能丢失?",
|
||||
"pdf_self_custody_desc": "2FAuth Worker 是一个完全自托管的系统。我们不存储您的主密码或加密密钥。这意味着如果您丢失了此密钥,您的所有数据将永久锁定,无法恢复。",
|
||||
"pdf_info_title": "系统信息",
|
||||
"pdf_server_url": "服务器地址: ",
|
||||
"pdf_admin_email": "管理员邮箱: ",
|
||||
"pdf_gen_date": "生成日期: ",
|
||||
"pdf_key_title": "核心加密密钥 (ENCRYPTION_KEY)",
|
||||
"pdf_usage_title": "如何使用?",
|
||||
"pdf_usage_desc": "如果您的服务器环境变量丢失,请在重新部署时将此密钥填入环境变量 ENCRYPTION_KEY 即可恢复所有数据。",
|
||||
"pdf_warning": "警告:切勿遗失此密钥,这是恢复您数据的唯一途径。",
|
||||
"pdf_footer": "2FAuth Worker A Secure 2FA Management Tool"
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,6 @@ export default defineConfig({
|
||||
imports: ['vue', 'vue-router'],
|
||||
resolvers: [ElementPlusResolver({ importStyle: false })],
|
||||
}),
|
||||
AutoImport({
|
||||
imports: ['vue', 'vue-router'],
|
||||
resolvers: [ElementPlusResolver({ importStyle: false })],
|
||||
}),
|
||||
Components({
|
||||
resolvers: [
|
||||
ElementPlusResolver({
|
||||
@@ -85,6 +81,8 @@ export default defineConfig({
|
||||
'**/assets/libsodium-wrappers*.js',
|
||||
'**/assets/sql*.js',
|
||||
'**/assets/jsQR*.js',
|
||||
'**/assets/pdf-utils*.js',
|
||||
'**/assets/compression-utils*.js',
|
||||
'**/assets/dataImport*.js',
|
||||
'**/assets/dataMigrationService*.js'
|
||||
],
|
||||
@@ -119,7 +117,7 @@ export default defineConfig({
|
||||
},
|
||||
{
|
||||
// 为被 globIgnores 忽略的大体积工具 JS chunk 配置动态缓存
|
||||
urlPattern: /assets\/(wa-sqlite|argon2|hash-wasm|libsodium-wrappers|sql|jsQR|dataImport|dataMigrationService).*\.js$/i,
|
||||
urlPattern: /assets\/(wa-sqlite|argon2|hash-wasm|libsodium-wrappers|sql|jsQR|pdf-utils|compression-utils|dataImport|dataMigrationService).*\.js$/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'lazy-tools-js-cache',
|
||||
@@ -185,6 +183,10 @@ export default defineConfig({
|
||||
// 新增:通行密钥 (Passkey) 相关
|
||||
if (id.includes('/node_modules/@simplewebauthn/')) return 'simplewebauthn'
|
||||
|
||||
// 新增:PDF 生成与压缩相关 (体积较大且仅特定场景使用)
|
||||
if (id.includes('/node_modules/jspdf/') || id.includes('/node_modules/html2canvas/')) return 'pdf-utils'
|
||||
if (id.includes('/node_modules/fflate/')) return 'compression-utils'
|
||||
|
||||
// 二维码处理相关 (体积较大且仅特定页面使用)
|
||||
if (id.includes('/node_modules/qrcode/') || id.includes('/node_modules/jsqr/')) return 'qr-utils'
|
||||
|
||||
|
||||
Reference in new issue
Block a user