diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index eac34ad..3816e52 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -84,6 +84,8 @@ jobs:
OAUTH_MICROSOFT_CLIENT_SECRET: ${{ secrets.OAUTH_MICROSOFT_CLIENT_SECRET }}
OAUTH_BAIDU_CLIENT_ID: ${{ secrets.OAUTH_BAIDU_CLIENT_ID }}
OAUTH_BAIDU_CLIENT_SECRET: ${{ secrets.OAUTH_BAIDU_CLIENT_SECRET }}
+ OAUTH_DROPBOX_CLIENT_ID: ${{ secrets.OAUTH_DROPBOX_CLIENT_ID }}
+ OAUTH_DROPBOX_CLIENT_SECRET: ${{ secrets.OAUTH_DROPBOX_CLIENT_SECRET }}
run: |
SECRETS="
@@ -101,6 +103,7 @@ jobs:
"OAUTH_GOOGLE_CLIENT_ID" "OAUTH_GOOGLE_CLIENT_SECRET" "OAUTH_GOOGLE_REDIRECT_URI"
"OAUTH_MICROSOFT_CLIENT_ID" "OAUTH_MICROSOFT_CLIENT_SECRET"
"OAUTH_BAIDU_CLIENT_ID" "OAUTH_BAIDU_CLIENT_SECRET"
+ "OAUTH_DROPBOX_CLIENT_ID" "OAUTH_DROPBOX_CLIENT_SECRET"
)
for VAR in "${OPTIONAL_VARS[@]}"; do
@@ -153,6 +156,8 @@ jobs:
OAUTH_MICROSOFT_CLIENT_SECRET: ${{ secrets.OAUTH_MICROSOFT_CLIENT_SECRET }}
OAUTH_BAIDU_CLIENT_ID: ${{ secrets.OAUTH_BAIDU_CLIENT_ID }}
OAUTH_BAIDU_CLIENT_SECRET: ${{ secrets.OAUTH_BAIDU_CLIENT_SECRET }}
+ OAUTH_DROPBOX_CLIENT_ID: ${{ secrets.OAUTH_DROPBOX_CLIENT_ID }}
+ OAUTH_DROPBOX_CLIENT_SECRET: ${{ secrets.OAUTH_DROPBOX_CLIENT_SECRET }}
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
diff --git a/backend/src/app/config.ts b/backend/src/app/config.ts
index 6ce13b6..8e7f2d0 100644
--- a/backend/src/app/config.ts
+++ b/backend/src/app/config.ts
@@ -47,6 +47,9 @@ export const CSP_POLICY = {
"https://graph.microsoft.com",
"https://openapi.baidu.com",
"https://pan.baidu.com",
+ "https://api.dropboxapi.com",
+ "https://content.dropboxapi.com",
+ "https://www.dropbox.com",
],
// 框架源
FRAMES: [
@@ -82,6 +85,9 @@ export type EnvBindings = {
OAUTH_BAIDU_CLIENT_ID?: string;
OAUTH_BAIDU_CLIENT_SECRET?: string;
OAUTH_BAIDU_BACKUP_REDIRECT_URI?: string;
+ OAUTH_DROPBOX_CLIENT_ID?: string;
+ OAUTH_DROPBOX_CLIENT_SECRET?: string;
+ OAUTH_DROPBOX_BACKUP_REDIRECT_URI?: string;
OAUTH_ALLOWED_USERS: string; // 允许登录的 Email 或 Username 白名单 (必填)
OAUTH_ALLOW_ALL?: string; // 是否允许所有用户登录 (仅推荐用于测试演示环境)
JWT_SECRET: string;
diff --git a/backend/src/features/backup/backupRoutes.ts b/backend/src/features/backup/backupRoutes.ts
index 2ed052b..5edfc9d 100644
--- a/backend/src/features/backup/backupRoutes.ts
+++ b/backend/src/features/backup/backupRoutes.ts
@@ -469,6 +469,152 @@ backups.get('/oauth/baidu/callback', async (c) => {
`);
});
+// --- Dropbox OAuth Callback ---
+backups.get('/oauth/dropbox/callback', async (c) => {
+ c.header('Cross-Origin-Opener-Policy', 'unsafe-none');
+
+ const stateInQuery = c.req.query('state');
+ const stateInCookie = getCookie(c, 'dropbox_oauth_state');
+
+ if (!stateInQuery || !stateInCookie || stateInQuery !== stateInCookie) {
+ return c.html(`
+
+ `);
+ }
+
+ const token = getCookie(c, 'auth_token');
+ if (!token) {
+ return c.html(`
+
+ `);
+ }
+
+ const payload = await verifySecureJWT(token, c.env.JWT_SECRET);
+ if (!payload?.userInfo) {
+ return c.html(`
+
+ `);
+ }
+
+ const code = c.req.query('code');
+ const error = c.req.query('error');
+ if (error === 'access_denied') {
+ return c.html(`
+
+ `);
+ }
+ if (!code) {
+ return c.html(`
+
+ `);
+ }
+
+ const clientId = c.env.OAUTH_DROPBOX_CLIENT_ID;
+ const clientSecret = c.env.OAUTH_DROPBOX_CLIENT_SECRET;
+ const redirectUri = c.env.OAUTH_DROPBOX_BACKUP_REDIRECT_URI || `${new URL(c.req.url).origin}/api/backups/oauth/dropbox/callback`;
+
+ const tokenRes = await fetch('https://api.dropboxapi.com/oauth2/token', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ client_id: clientId || '',
+ client_secret: clientSecret || '',
+ code: code,
+ grant_type: 'authorization_code',
+ redirect_uri: redirectUri
+ })
+ });
+
+ if (!tokenRes.ok) {
+ const errData = await tokenRes.json() as any;
+ console.error('[OAuth] Dropbox Token exchange failed:', errData);
+ return c.html(`
+
+ `);
+ }
+
+ const tokenData = await tokenRes.json() as any;
+ const refreshToken = tokenData.refresh_token;
+
+ if (!refreshToken) {
+ return c.html(`
+
+ `);
+ }
+
+ return c.html(`
+
+ Success
+
+ 授权成功,正在返回应用...
+
+
+
+ `);
+});
+
// =========================================================================
// === PROTECTED ROUTES ===
// All routes below this middleware require a valid JWT AND a valid CSRF token.
@@ -490,6 +636,9 @@ backups.get('/providers', async (c) => {
if (c.env.OAUTH_BAIDU_CLIENT_ID && c.env.OAUTH_BAIDU_CLIENT_SECRET) {
availableTypes.push('baidu');
}
+ if (c.env.OAUTH_DROPBOX_CLIENT_ID && c.env.OAUTH_DROPBOX_CLIENT_SECRET) {
+ availableTypes.push('dropbox');
+ }
return c.json({ success: true, providers, availableTypes });
});
@@ -654,6 +803,37 @@ backups.post('/oauth/baidu/auth', async (c) => {
});
});
+// --- Dropbox OAuth Initiation ---
+backups.post('/oauth/dropbox/auth', async (c) => {
+ const clientId = c.env.OAUTH_DROPBOX_CLIENT_ID;
+ const redirectUri = c.env.OAUTH_DROPBOX_BACKUP_REDIRECT_URI || `${new URL(c.req.url).origin}/api/backups/oauth/dropbox/callback`;
+
+ if (!clientId) throw new AppError('oauth_config_incomplete', 400);
+
+ const state = crypto.randomUUID();
+
+ setCookie(c, 'dropbox_oauth_state', state, {
+ path: '/api/backups/oauth/dropbox/callback',
+ secure: true,
+ httpOnly: true,
+ sameSite: 'Lax',
+ maxAge: 600
+ });
+
+ const params = new URLSearchParams({
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ response_type: 'code',
+ token_access_type: 'offline', // Request refresh token
+ state: state
+ });
+
+ return c.json({
+ success: true,
+ authUrl: `https://www.dropbox.com/oauth2/authorize?${params.toString()}`
+ });
+});
+
export async function handleScheduledBackup(env: EnvBindings) {
const service = new BackupService(env);
await service.handleScheduledBackup();
diff --git a/backend/src/features/backup/backupService.ts b/backend/src/features/backup/backupService.ts
index a27b6dd..af9b232 100644
--- a/backend/src/features/backup/backupService.ts
+++ b/backend/src/features/backup/backupService.ts
@@ -2,7 +2,7 @@ import { eq } from 'drizzle-orm';
import { EnvBindings, AppError } from '@/app/config';
import { BackupRepository } from '@/shared/db/repositories/backupRepository';
import { encryptData, decryptData, encryptBackupFile } from '@/shared/utils/crypto';
-import { BackupProvider, WebDavProvider, S3Provider, TelegramProvider, GoogleDriveProvider, OneDriveProvider, BaiduNetdiskProvider } from '@/features/backup/providers';
+import { BackupProvider, WebDavProvider, S3Provider, TelegramProvider, GoogleDriveProvider, OneDriveProvider, BaiduNetdiskProvider, DropboxProvider } from '@/features/backup/providers';
import { decryptField } from '@/shared/db/db';
import { vault as vaultTable, backupProviders } from '@/shared/db/schema';
@@ -24,7 +24,8 @@ export class BackupService {
telegram: ['botToken'],
gdrive: ['refreshToken'],
onedrive: ['refreshToken'],
- baidu: ['refreshToken']
+ baidu: ['refreshToken'],
+ dropbox: ['refreshToken']
};
private maskConfigForFrontend(type: string, config: any) {
@@ -86,6 +87,8 @@ export class BackupService {
return new OneDriveProvider(config, this.env);
case 'baidu':
return new BaiduNetdiskProvider(config, this.env);
+ case 'dropbox':
+ return new DropboxProvider(config, this.env);
default:
throw new AppError('provider_not_found', 400);
}
@@ -111,6 +114,9 @@ export class BackupService {
if (type === 'baidu' && processed.refreshToken) {
processed.refreshToken = await encryptData(processed.refreshToken, key);
}
+ if (type === 'dropbox' && processed.refreshToken) {
+ processed.refreshToken = await encryptData(processed.refreshToken, key);
+ }
return JSON.stringify(processed);
}
@@ -134,6 +140,9 @@ export class BackupService {
if (type === 'baidu' && config.refreshToken) {
config.refreshToken = await decryptData(config.refreshToken, key);
}
+ if (type === 'dropbox' && config.refreshToken) {
+ config.refreshToken = await decryptData(config.refreshToken, key);
+ }
return config;
}
diff --git a/backend/src/features/backup/providers/dropboxProvider.ts b/backend/src/features/backup/providers/dropboxProvider.ts
new file mode 100644
index 0000000..bbcab0c
--- /dev/null
+++ b/backend/src/features/backup/providers/dropboxProvider.ts
@@ -0,0 +1,169 @@
+import { BackupProvider, BackupFile } from '@/features/backup/providers/backupProvider';
+import { AppError } from '@/app/config';
+
+export class DropboxProvider implements BackupProvider {
+ private clientId: string;
+ private clientSecret: string;
+ private refreshToken: string;
+ private accessToken: string | null = null;
+ private saveDir: string;
+
+ constructor(config: any, env: any) {
+ this.clientId = env.OAUTH_DROPBOX_CLIENT_ID;
+ this.clientSecret = env.OAUTH_DROPBOX_CLIENT_SECRET;
+
+ if (!config.refreshToken) {
+ throw new Error('dropbox_token_missing');
+ }
+ this.refreshToken = config.refreshToken;
+ this.saveDir = config.saveDir || ''; // Dropbox App Folder is root by default
+ }
+
+ private async getAccessToken(): Promise {
+ if (this.accessToken) return this.accessToken;
+
+ const res = await fetch('https://api.dropboxapi.com/oauth2/token', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ client_id: this.clientId,
+ client_secret: this.clientSecret,
+ refresh_token: this.refreshToken,
+ grant_type: 'refresh_token'
+ })
+ });
+
+ if (!res.ok) {
+ const err = await res.json() as any;
+ const errorDesc = (err.error || err.error_description || '').toLowerCase();
+ if (errorDesc.includes('invalid_grant') || res.status === 401) {
+ throw new AppError('oauth_token_revoked', 401);
+ }
+ throw new Error(`dropbox_auth_failed: ${err.error_description || res.statusText}`);
+ }
+
+ const data = await res.json() as any;
+ this.accessToken = data.access_token;
+ return this.accessToken!;
+ }
+
+ async testConnection(): Promise {
+ await this.getAccessToken();
+ // Simple list check
+ await this.listBackups();
+ return true;
+ }
+
+ async listBackups(): Promise {
+ const token = await this.getAccessToken();
+ const path = this.saveDir === '/' || this.saveDir === '' ? '' : (this.saveDir.startsWith('/') ? this.saveDir : '/' + this.saveDir);
+
+ const res = await fetch('https://api.dropboxapi.com/2/files/list_folder', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ path: path,
+ recursive: false,
+ include_media_info: false,
+ include_deleted: false
+ })
+ });
+
+ if (res.status === 409) {
+ // Path not found, return empty
+ return [];
+ }
+
+ if (!res.ok) {
+ const err = await res.json() as any;
+ if (err?.error?.['.tag'] === 'path' && err?.error?.path?.['.tag'] === 'not_found') {
+ return [];
+ }
+ throw new Error(`dropbox_list_failed: ${res.status}`);
+ }
+
+ const data = await res.json() as any;
+ return (data.entries || [])
+ .filter((f: any) => f['.tag'] === 'file' && f.name.includes('2fa-backup-'))
+ .map((f: any) => ({
+ filename: f.name,
+ size: f.size,
+ lastModified: f.server_modified
+ }));
+ }
+
+ async uploadBackup(filename: string, data: string): Promise {
+ const token = await this.getAccessToken();
+ const path = this.saveDir === '/' || this.saveDir === '' ? '' : (this.saveDir.startsWith('/') ? this.saveDir : '/' + this.saveDir);
+ const fullPath = `${path}/${filename}`.replace(/\/+/g, '/');
+
+ const args = {
+ path: fullPath,
+ mode: 'overwrite',
+ autorename: false,
+ mute: false,
+ strict_conflict: false
+ };
+
+ const res = await fetch('https://content.dropboxapi.com/2/files/upload', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Dropbox-API-Arg': JSON.stringify(args),
+ 'Content-Type': 'application/octet-stream'
+ },
+ body: data
+ });
+
+ if (!res.ok) {
+ const err = await res.json() as any;
+ throw new Error(`dropbox_upload_failed: ${err.error_summary || res.statusText}`);
+ }
+ }
+
+ async downloadBackup(filename: string): Promise {
+ const token = await this.getAccessToken();
+ const path = this.saveDir === '/' || this.saveDir === '' ? '' : (this.saveDir.startsWith('/') ? this.saveDir : '/' + this.saveDir);
+ const fullPath = `${path}/${filename}`.replace(/\/+/g, '/');
+
+ const args = {
+ path: fullPath
+ };
+
+ const res = await fetch('https://content.dropboxapi.com/2/files/download', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Dropbox-API-Arg': JSON.stringify(args)
+ }
+ });
+
+ if (!res.ok) throw new Error(`dropbox_download_failed: ${res.status}`);
+ return await res.text();
+ }
+
+ async deleteBackup(filename: string): Promise {
+ const token = await this.getAccessToken();
+ const path = this.saveDir === '/' || this.saveDir === '' ? '' : (this.saveDir.startsWith('/') ? this.saveDir : '/' + this.saveDir);
+ const fullPath = `${path}/${filename}`.replace(/\/+/g, '/');
+
+ const res = await fetch('https://api.dropboxapi.com/2/files/delete_v2', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ path: fullPath })
+ });
+
+ if (!res.ok) {
+ const err = await res.json() as any;
+ // Ignore if already deleted
+ if (err?.error_summary?.includes('path_lookup/not_found')) return;
+ throw new Error(`dropbox_delete_failed: ${res.statusText}`);
+ }
+ }
+}
diff --git a/backend/src/features/backup/providers/index.ts b/backend/src/features/backup/providers/index.ts
index 12cc011..19bc9a1 100644
--- a/backend/src/features/backup/providers/index.ts
+++ b/backend/src/features/backup/providers/index.ts
@@ -4,4 +4,5 @@ export * from '@/features/backup/providers/s3Provider';
export * from '@/features/backup/providers/telegramProvider';
export * from '@/features/backup/providers/googleDriveProvider';
export * from '@/features/backup/providers/oneDriveProvider';
-export * from '@/features/backup/providers/baiduNetdiskProvider';
\ No newline at end of file
+export * from '@/features/backup/providers/baiduNetdiskProvider';
+export * from '@/features/backup/providers/dropboxProvider';
\ No newline at end of file
diff --git a/frontend/src/app/styles/modules/components/backup.css b/frontend/src/app/styles/modules/components/backup.css
index 6cca563..f4ba63d 100644
--- a/frontend/src/app/styles/modules/components/backup.css
+++ b/frontend/src/app/styles/modules/components/backup.css
@@ -241,4 +241,26 @@
.dark .btn-baidu-auth:hover {
background-color: #222850;
+}
+
+.btn-dropbox-auth {
+ background-color: #ffffff;
+ border: 1px solid #0061FF;
+ color: #0061FF;
+}
+
+.btn-dropbox-auth:hover {
+ background-color: #f0f7ff;
+ border-color: #0061FF;
+ box-shadow: 0 1px 3px 0 rgba(0, 97, 255, 0.30), 0 4px 8px 3px rgba(0, 97, 255, 0.15);
+}
+
+.dark .btn-dropbox-auth {
+ background-color: #002d75;
+ border-color: #0061FF;
+ color: #ffffff;
+}
+
+.dark .btn-dropbox-auth:hover {
+ background-color: #00368c;
}
\ No newline at end of file
diff --git a/frontend/src/app/styles/modules/components/migration.css b/frontend/src/app/styles/modules/components/migration.css
index 4c91191..e7bd8b7 100644
--- a/frontend/src/app/styles/modules/components/migration.css
+++ b/frontend/src/app/styles/modules/components/migration.css
@@ -139,42 +139,68 @@
}
.ecosystem-groups {
- display: flex;
- flex-direction: column;
- gap: 25px;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 20px;
}
.ecosystem-group {
display: flex;
flex-direction: column;
- gap: 12px;
+ gap: 16px;
+ padding: 20px 20px 0;
+ background-color: none;
+ border: 1px solid var(--el-border-color-lighter);
+ border-radius: 12px;
+ transition: all 0.3s ease;
+}
+
+.ecosystem-group:hover {
+ border-color: var(--el-border-color);
+ box-shadow: var(--el-box-shadow-lighter);
}
.group-title {
margin: 0;
font-size: 15px;
- color: var(--el-text-color-regular);
- font-weight: 600;
+ color: var(--el-text-color-primary);
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ gap: 8px;
}
.ecosystem-grid {
display: flex;
flex-wrap: wrap;
- gap: 12px 24px;
+ gap: 10px;
}
.ecosystem-item {
display: inline-flex;
align-items: center;
gap: 8px;
- font-size: 14px;
- color: var(--el-text-color-primary);
+ font-size: 13px;
+ color: var(--el-text-color-regular);
line-height: 1.2;
+ padding: 6px 12px;
+ background: var(--el-fill-color-lighter);
+ border-radius: 20px;
+ border: 1px solid transparent;
+ transition: all 0.2s ease;
+}
+
+.ecosystem-item:hover {
+ color: var(--el-color-primary);
+ background: var(--el-color-primary-light-9);
+ border-color: var(--el-color-primary-light-8);
+ transform: translateY(-1px);
}
.ecosystem-item .el-icon,
.ecosystem-item svg {
- font-size: 18px;
+ font-size: 16px;
+ opacity: 0.85;
}
/* 疑难解答折叠面板 */
@@ -185,9 +211,10 @@
}
.ecosystem-collapse :deep(.el-collapse),
+.ecosystem-collapse :deep(.el-collapse-item),
.ecosystem-collapse :deep(.el-collapse-item__header),
.ecosystem-collapse :deep(.el-collapse-item__wrap) {
- border: none;
+ border: none !important;
}
.ecosystem-collapse :deep(.el-collapse-item__header) {
@@ -204,57 +231,89 @@
}
.ecosystem-collapse :deep(.el-collapse-item__content) {
- padding: 10px 0;
+ padding: 0;
+}
+
+.migration-ga-tip,
+.migration-ms-tip {
+ padding: 12px 16px;
+ border-radius: 8px;
+ border: 1px solid transparent;
+ margin-top: 10px;
}
.migration-ga-tip {
background-color: var(--el-color-primary-light-9);
- padding: 10px 12px;
- border-radius: 6px;
- border-left: 3px solid var(--el-color-primary);
- margin-top: 5px;
-}
-
-.migration-ga-tip span {
- font-weight: 600;
- font-size: 13px;
- color: var(--el-color-primary);
- display: block;
- margin-bottom: 4px;
-}
-
-.migration-ga-tip p {
- margin: 0;
- font-size: 12px;
- color: var(--el-text-color-regular);
- line-height: 1.5;
+ border-color: var(--el-color-primary-light-8);
}
.migration-ms-tip {
background-color: var(--el-color-success-light-9);
- padding: 10px 12px;
- border-radius: 6px;
- border-left: 3px solid var(--el-color-success);
- margin-top: 15px;
+ border-color: var(--el-color-success-light-8);
+}
+
+.migration-ga-tip span,
+.migration-ms-tip span {
+ font-weight: 700;
+ font-size: 13px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 8px;
+}
+
+.migration-ga-tip span {
+ color: var(--el-color-primary);
}
.migration-ms-tip span {
- font-weight: 600;
- font-size: 13px;
color: var(--el-color-success);
- display: block;
- margin-bottom: 4px;
}
+.migration-ga-tip p,
.migration-ms-tip p {
margin: 0;
font-size: 12px;
- color: var(--el-color-regular);
- line-height: 1.5;
+ color: var(--el-text-color-regular);
+ line-height: 1.6;
}
.migration-ms-tip code {
- word-wrap: break-word;
+ display: block;
+ margin-top: 6px;
+ padding: 6px 10px;
+ background: rgba(0, 0, 0, 0.05);
+ border-radius: 4px;
+ font-family: var(--el-font-family-mono);
+ font-size: 11px;
+ word-break: break-all;
+ color: var(--el-text-color-regular);
+ border: 1px solid rgba(0, 0, 0, 0.05);
+}
+
+/* 深色模式优化 */
+html.dark .migration-ga-tip {
+ background-color: rgba(64, 158, 255, 0.1);
+ border-color: rgba(64, 158, 255, 0.2);
+}
+
+html.dark .migration-ms-tip {
+ background-color: rgba(103, 194, 58, 0.1);
+ border-color: rgba(103, 194, 58, 0.2);
+}
+
+html.dark .migration-ms-tip code {
+ background: rgba(255, 255, 255, 0.05);
+ border-color: rgba(255, 255, 255, 0.1);
+ color: var(--el-text-color-secondary);
+}
+
+html.dark .ecosystem-item {
+ background: var(--el-fill-color-light);
+}
+
+html.dark .ecosystem-item:hover {
+ background: rgba(64, 158, 255, 0.15);
}
/* dataMigration.vue */
diff --git a/frontend/src/features/backup/components/backupSettings.vue b/frontend/src/features/backup/components/backupSettings.vue
index df2b72d..f2cd594 100644
--- a/frontend/src/features/backup/components/backupSettings.vue
+++ b/frontend/src/features/backup/components/backupSettings.vue
@@ -80,6 +80,7 @@
+
@@ -327,6 +328,61 @@
+
+
+
+
+
+ {{ $t('backup.dropbox_auth_tip') }}
+
+
+
+
+
+
{{ authErrorMessageDropbox }}
+
{{ $t('backup.re_authorize') }}
+
+
+
+
+
+
+
+
+
+ {{ $t('backup.authorized_success_dropbox') }}
+
+
{{ $t('backup.re_authorize') }}
+
+
+
+
+
+
+
+ {{ $t('backup.gdrive_folder_tip') }}
+
+
+
+
+
+ {{ $t('backup.dropbox_token_active') }}
+
+
{{ $t('backup.re_authorize') }}
+
+
+
+
{{ $t('backup.auto_backup_config') }}
@@ -419,6 +475,7 @@ import { Plus, Edit, Delete, CircleCheck, CircleClose, Timer, Loading } from '@e
import iconGoogleDrive from '@/shared/components/icons/iconGoogleDrive.vue'
import iconOnedrive from '@/shared/components/icons/iconOnedrive.vue'
import iconBaiduNetdisk from '@/shared/components/icons/iconBaiduNetdisk.vue'
+import iconDropbox from '@/shared/components/icons/iconDropbox.vue'
import { useLayoutStore } from '@/shared/stores/layoutStore'
import { useBackupProviders } from '@/features/backup/composables/useBackupProviders'
import { useBackupActions } from '@/features/backup/composables/useBackupActions'
@@ -431,10 +488,10 @@ const {
isEditingWebdavPwd, isEditingS3Secret, isEditingTelegramToken, form,
isAutoBackupPasswordSaved, shouldUseExistingAutoBackupPassword, fetchProviders, openAddDialog,
editProvider, testConnection, saveProvider, deleteProvider,
- startGoogleAuth, startMicrosoftAuth, startBaiduAuth, handleAuthMessage,
- isAuthenticatingGoogle, isAuthenticatingMicrosoft, isAuthenticatingBaidu,
- authStatusGoogle, authStatusMicrosoft, authStatusBaidu,
- authErrorMessageGoogle, authErrorMessageMicrosoft, authErrorMessageBaidu,
+ startGoogleAuth, startMicrosoftAuth, startBaiduAuth, startDropboxAuth, handleAuthMessage,
+ isAuthenticatingGoogle, isAuthenticatingMicrosoft, isAuthenticatingBaidu, isAuthenticatingDropbox,
+ authStatusGoogle, authStatusMicrosoft, authStatusBaidu, authStatusDropbox,
+ authErrorMessageGoogle, authErrorMessageMicrosoft, authErrorMessageBaidu, authErrorMessageDropbox,
setupAuthListener, availableTypes
} = useBackupProviders()
@@ -465,8 +522,9 @@ const getProviderTypeTag = (type) => {
s3: 'warning',
telegram: 'success',
gdrive: 'danger',
- onedrive: '',
- baidu: 'primary'
+ onedrive: 'success',
+ baidu: 'warning',
+ dropbox: 'primary'
}
return map[type] || 'info'
}
diff --git a/frontend/src/features/backup/composables/useBackupProviders.js b/frontend/src/features/backup/composables/useBackupProviders.js
index 53c39c4..8d4ca9d 100644
--- a/frontend/src/features/backup/composables/useBackupProviders.js
+++ b/frontend/src/features/backup/composables/useBackupProviders.js
@@ -26,12 +26,15 @@ export function useBackupProviders() {
const isAuthenticatingGoogle = ref(false)
const isAuthenticatingMicrosoft = ref(false)
const isAuthenticatingBaidu = ref(false)
+ const isAuthenticatingDropbox = ref(false)
const authStatusGoogle = ref(null) // null, 'success', 'error'
const authStatusMicrosoft = ref(null)
const authStatusBaidu = ref(null)
+ const authStatusDropbox = ref(null)
const authErrorMessageGoogle = ref('')
const authErrorMessageMicrosoft = ref('')
const authErrorMessageBaidu = ref('')
+ const authErrorMessageDropbox = ref('')
const initialFormState = () => ({
type: 's3',
@@ -119,6 +122,8 @@ export function useBackupProviders() {
if (!config.refreshToken) return t('backup.require_microsoft_auth')
} else if (form.value.type === 'baidu') {
if (!config.refreshToken) return t('backup.require_baidu_auth')
+ } else if (form.value.type === 'dropbox') {
+ if (!config.refreshToken) return t('backup.require_dropbox_auth')
}
if (form.value.autoBackup) {
@@ -137,13 +142,15 @@ export function useBackupProviders() {
if (error) return ElMessage.warning(error)
isTesting.value = true;
- // Reset old error states before starting a new test to ensure UI reflects current result
- authStatusGoogle.value = null;
+ // Only reset error states, keep 'success' state if it exists
+ if (authStatusGoogle.value === 'error') authStatusGoogle.value = null;
authErrorMessageGoogle.value = '';
- authStatusMicrosoft.value = null;
+ if (authStatusMicrosoft.value === 'error') authStatusMicrosoft.value = null;
authErrorMessageMicrosoft.value = '';
- authStatusBaidu.value = null;
+ if (authStatusBaidu.value === 'error') authStatusBaidu.value = null;
authErrorMessageBaidu.value = '';
+ if (authStatusDropbox.value === 'error') authStatusDropbox.value = null;
+ authErrorMessageDropbox.value = '';
try {
const res = await backupService.testConnection(
@@ -151,7 +158,15 @@ export function useBackupProviders() {
form.value.config,
isEditing.value ? currentProviderId.value : null
)
- if (res.success) ElMessage.success(t('backup.test_success'))
+ if (res.success) {
+ ElMessage.success(t('backup.test_success'))
+ // Ensure the success status is reflected in the UI for OAuth providers
+ const type = form.value.type
+ if (type === 'gdrive') authStatusGoogle.value = 'success'
+ else if (type === 'onedrive') authStatusMicrosoft.value = 'success'
+ else if (type === 'baidu') authStatusBaidu.value = 'success'
+ else if (type === 'dropbox') authStatusDropbox.value = 'success'
+ }
} catch (e) {
// Error is handled here natively since we silenced the global request.js interceptor for testConnection
const rawMsg = e?.details?.message || e?.message || e?.response?.data?.message || (typeof e === 'string' ? e : t('common.error'));
@@ -175,6 +190,9 @@ export function useBackupProviders() {
} else if (type === 'baidu') {
authStatusBaidu.value = 'error';
authErrorMessageBaidu.value = t('backup.token_expired_or_revoked');
+ } else if (type === 'dropbox') {
+ authStatusDropbox.value = 'error';
+ authErrorMessageDropbox.value = t('backup.token_expired_or_revoked');
}
// If we are editing an existing provider, update the global store to reflect the state
@@ -313,6 +331,35 @@ export function useBackupProviders() {
}
}
+ const startDropboxAuth = async () => {
+ isAuthenticatingDropbox.value = true
+ authStatusDropbox.value = null
+ authErrorMessageDropbox.value = ''
+ try {
+ const res = await backupService.getDropboxAuthUrl()
+ if (res.success && res.authUrl) {
+ const name = 'dropbox_auth'
+ const specs = 'width=600,height=700,left=200,top=100'
+ const authWindow = window.open(res.authUrl, name, specs)
+
+ const timer = setInterval(() => {
+ try {
+ if (authWindow && authWindow.closed) {
+ clearInterval(timer)
+ if (isAuthenticatingDropbox.value && !authStatusDropbox.value) {
+ isAuthenticatingDropbox.value = false
+ }
+ }
+ } catch (e) { }
+ }, 1000)
+ } else {
+ isAuthenticatingDropbox.value = false
+ }
+ } catch (e) {
+ isAuthenticatingDropbox.value = false
+ }
+ }
+
const handleAuthMessage = async (event) => {
const data = event instanceof MessageEvent ? event.data : event
if (!data || !data.type) return
@@ -350,6 +397,17 @@ export function useBackupProviders() {
isAuthenticatingBaidu.value = false
authStatusBaidu.value = 'error'
authErrorMessageBaidu.value = data.message || t('backup.baidu_auth_failed')
+ } else if (data.type === 'DROPBOX_AUTH_SUCCESS') {
+ isAuthenticatingDropbox.value = false
+ authStatusDropbox.value = 'success'
+ form.value.config.refreshToken = data.refreshToken
+ if (!form.value.config.saveDir) {
+ form.value.config.saveDir = ''
+ }
+ } else if (data.type === 'DROPBOX_AUTH_ERROR') {
+ isAuthenticatingDropbox.value = false
+ authStatusDropbox.value = 'error'
+ authErrorMessageDropbox.value = data.message || t('backup.dropbox_auth_failed')
}
}
@@ -377,11 +435,15 @@ export function useBackupProviders() {
const bcBaidu = new BroadcastChannel('baidu_oauth_channel')
bcBaidu.onmessage = handleMsg
+ const bcDropbox = new BroadcastChannel('dropbox_oauth_channel')
+ bcDropbox.onmessage = handleMsg
+
return () => {
window.removeEventListener('message', handleMsg)
bcGDrive.close()
bcMs.close()
bcBaidu.close()
+ bcDropbox.close()
}
}
@@ -400,12 +462,15 @@ export function useBackupProviders() {
isAuthenticatingGoogle,
isAuthenticatingMicrosoft,
isAuthenticatingBaidu,
+ isAuthenticatingDropbox,
authStatusGoogle,
authStatusMicrosoft,
authStatusBaidu,
+ authStatusDropbox,
authErrorMessageGoogle,
authErrorMessageMicrosoft,
authErrorMessageBaidu,
+ authErrorMessageDropbox,
form,
isAutoBackupPasswordSaved,
shouldUseExistingAutoBackupPassword,
@@ -418,6 +483,7 @@ export function useBackupProviders() {
startGoogleAuth,
startMicrosoftAuth,
startBaiduAuth,
+ startDropboxAuth,
handleAuthMessage,
setupAuthListener,
availableTypes
diff --git a/frontend/src/features/backup/service/backupService.js b/frontend/src/features/backup/service/backupService.js
index 2b41cb3..738d6a4 100644
--- a/frontend/src/features/backup/service/backupService.js
+++ b/frontend/src/features/backup/service/backupService.js
@@ -221,5 +221,17 @@ export const backupService = {
} catch (e) {
throw new backupError('Failed to get Baidu Auth URL', 'AUTH_URL_FETCH_FAILED', e)
}
+ },
+
+ /**
+ * 获取 Dropbox 授权地址
+ * @returns {Promise<{success: boolean, authUrl: string}>}
+ */
+ async getDropboxAuthUrl() {
+ try {
+ return await request('/api/backups/oauth/dropbox/auth', { method: 'POST' })
+ } catch (e) {
+ throw new backupError('Failed to get Dropbox Auth URL', 'AUTH_URL_FETCH_FAILED', e)
+ }
}
}
diff --git a/frontend/src/features/migration/views/dataImport.vue b/frontend/src/features/migration/views/dataImport.vue
index 11303e3..cae5af2 100644
--- a/frontend/src/features/migration/views/dataImport.vue
+++ b/frontend/src/features/migration/views/dataImport.vue
@@ -25,22 +25,31 @@
-
+
-
-
{{ $t('migration.support_desc') }}
+
+ {{ $t('migration.support_desc') }}
+
+
-
📁 {{ $t('migration.system_backup_format') }}
+
+
+ {{ $t('migration.system_backup_format') }}
+
{{ $t('migration.encrypted_backup_json') }}
{{ $t('migration.plaintext_backup_json') }}
-
-
📱 {{ $t('migration.mobile_app_format') }}
+
+
+
+
+ {{ $t('migration.mobile_app_format') }}
+
2FAS (.2fas)
Aegis (.json/.txt)
@@ -51,30 +60,36 @@
Microsoft Auth
-
-
+
+
- {{ $t('migration.ga_ms_import_guide') }}
+
+ {{ $t('migration.ga_ms_import_guide') }}
+
-
-
Google Authenticator
+
+
Google Authenticator
{{ $t('migration.ga_auth_desc') }}
-
{{ $t('migration.ms_auth_desc') }}
-
{{ $t('migration.ms_auth_detail') }}:
- /data/data/com.azure.authenticator/databases/PhoneFactor
- /data/data/com.azure.authenticator/databases/PhoneFactor-wal
- /data/data/com.azure.authenticator/databases/PhoneFactor-shm
-
+
Microsoft Authenticator
+
{{ $t('migration.ms_auth_desc') }}
+
{{ $t('migration.ms_auth_detail') }}:
+
/data/data/com.azure.authenticator/databases/PhoneFactor
+
/data/data/com.azure.authenticator/databases/PhoneFactor-wal
+
/data/data/com.azure.authenticator/databases/PhoneFactor-shm
+
-
📄 {{ $t('migration.generic_format') }}
+
+
+ {{ $t('migration.generic_format') }}
+
{{ $t('migration.generic_json') }}
OTPAuth URI (.txt)
@@ -132,7 +147,7 @@