Skip to content

Commit 1a28d36

Browse files
committed
feat(auth-files): implement download and upload functionality
feat(auth-files): add file system read and write capabilities for Tauri feat(auth-files): introduce UI for downloading and uploading auth files feat(auth-files): add logic for single and bulk auth file downloads feat(auth-files): implement logic for uploading auth files feat(auth-files): add API endpoint for auth file uploads feat(auth-files): include new i18n strings for download and upload features This feature allows users to export their connected authentication files for backup or transfer, and import them back into the application. This enhances data portability and user control over their authentication data.
1 parent 3be83bf commit 1a28d36

11 files changed

Lines changed: 228 additions & 24 deletions

File tree

src-tauri/capabilities/default.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
"dialog:default",
1111
"updater:default",
1212
"process:allow-restart",
13-
"fs:default"
13+
"fs:default",
14+
"fs:allow-read-text-file",
15+
"fs:allow-write-text-file"
1416
]
1517
}

src/features/providers/ProvidersPage.tsx

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import {
1616
EyeOff,
1717
ChevronDown,
1818
ChevronRight,
19-
Key
19+
Key,
20+
Download,
21+
Upload,
2022
} from 'lucide-react';
2123
import {
2224
Dialog,
@@ -68,6 +70,9 @@ export function ProvidersPage() {
6870
submitCallback,
6971
updateProviderState,
7072
copyToClipboard,
73+
downloadAuthFile,
74+
downloadAllAuthFiles,
75+
uploadAuthFile,
7176
isPrivacyMode,
7277
togglePrivacyMode,
7378
openInBrowser,
@@ -173,28 +178,47 @@ export function ProvidersPage() {
173178
<CheckCircle className="h-5 w-5" />
174179
{t('providers.connectedAccounts')} ({files.length})
175180
</h2>
176-
{files.length > 0 && (
177-
<div className="flex items-center gap-2">
178-
<Button
179-
variant="outline"
180-
size="sm"
181-
onClick={openCopyAllModal}
182-
className="h-8 text-xs"
183-
>
184-
<Key className="mr-2 h-3.5 w-3.5" />
185-
{t('common.copyAll', 'Copy All')}
186-
</Button>
187-
<Button
188-
variant="outline"
189-
size="sm"
190-
onClick={() => setShowDeleteAllConfirmation(true)}
191-
className="h-8 text-xs bg-red-500/10 text-red-500 hover:bg-red-500/20 shadow-none border border-red-500/20"
192-
>
193-
<Trash2 className="mr-2 h-3.5 w-3.5" />
194-
{t('common.deleteAll', 'Delete All')}
195-
</Button>
196-
</div>
197-
)}
181+
<div className="flex items-center gap-2">
182+
<Button
183+
variant="outline"
184+
size="sm"
185+
onClick={openCopyAllModal}
186+
className="h-8 text-xs"
187+
disabled={files.length === 0}
188+
>
189+
<Key className="mr-2 h-3.5 w-3.5" />
190+
{t('common.copyAll', 'Copy All')}
191+
</Button>
192+
<Button
193+
variant="outline"
194+
size="sm"
195+
onClick={downloadAllAuthFiles}
196+
className="h-8 text-xs"
197+
disabled={files.length === 0}
198+
>
199+
<Download className="mr-2 h-3.5 w-3.5" />
200+
{t('providers.downloadAll', 'Download All')}
201+
</Button>
202+
<Button
203+
variant="outline"
204+
size="sm"
205+
onClick={uploadAuthFile}
206+
className="h-8 text-xs"
207+
>
208+
<Upload className="mr-2 h-3.5 w-3.5" />
209+
{t('providers.upload', 'Upload')}
210+
</Button>
211+
<Button
212+
variant="outline"
213+
size="sm"
214+
onClick={() => setShowDeleteAllConfirmation(true)}
215+
className="h-8 text-xs bg-red-500/10 text-red-500 hover:bg-red-500/20 shadow-none border border-red-500/20 disabled:opacity-50"
216+
disabled={files.length === 0}
217+
>
218+
<Trash2 className="mr-2 h-3.5 w-3.5" />
219+
{t('common.deleteAll', 'Delete All')}
220+
</Button>
221+
</div>
198222
</div>
199223

200224
{filesError && (
@@ -293,6 +317,15 @@ export function ProvidersPage() {
293317
</div>
294318

295319
<div className="flex items-center gap-1">
320+
<Button
321+
size="icon"
322+
variant="ghost"
323+
className="h-8 w-8 text-primary hover:bg-primary/10 opacity-80 group-hover:opacity-100"
324+
onClick={() => downloadAuthFile(file.name || file.filename || file.id)}
325+
title={t('providers.download', 'Download')}
326+
>
327+
<Download className="h-4 w-4" />
328+
</Button>
296329
<Button
297330
size="icon"
298331
variant="ghost"

src/features/providers/useProvidersPresenter.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ export function useProvidersPresenter() {
5656

5757
const [fileToDelete, setFileToDelete] = useState<string | null>(null);
5858
const [files, setFiles] = useState<AuthFile[]>([]);
59+
const filesRef = useRef<AuthFile[]>(files);
60+
61+
useEffect(() => {
62+
filesRef.current = files;
63+
}, [files]);
64+
5965
const [loadingFiles, setLoadingFiles] = useState(false);
6066
const [filesError, setFilesError] = useState<string | null>(null);
6167

@@ -444,6 +450,113 @@ export function useProvidersPresenter() {
444450
}
445451
}, [t]);
446452

453+
const downloadAuthFile = useCallback(async (filename: string | undefined | null) => {
454+
try {
455+
if (!filename) throw new Error('Filename is missing');
456+
let name = filename;
457+
if (!name.toLowerCase().endsWith('.json')) name = `${name}.json`;
458+
459+
const { save } = await import('@tauri-apps/plugin-dialog');
460+
const { writeTextFile } = await import('@tauri-apps/plugin-fs');
461+
462+
const data = await authFilesApi.download(name);
463+
464+
const filePath = await save({
465+
defaultPath: name,
466+
filters: [{ name: 'JSON', extensions: ['json'] }],
467+
});
468+
469+
if (filePath) {
470+
await writeTextFile(filePath, JSON.stringify(data, null, 2));
471+
toast.success(t('providers.downloadSuccess', 'File downloaded successfully'));
472+
}
473+
} catch (err) {
474+
toast.error(t('providers.downloadFailed', 'Failed to download file') + `: ${(err as Error).message}`);
475+
}
476+
}, [t]);
477+
478+
const downloadAllAuthFiles = useCallback(async () => {
479+
try {
480+
const { open } = await import('@tauri-apps/plugin-dialog');
481+
const { writeTextFile } = await import('@tauri-apps/plugin-fs');
482+
const { join } = await import('@tauri-apps/api/path');
483+
484+
const dirPath = await open({
485+
directory: true,
486+
multiple: false,
487+
title: 'Select Destination Folder'
488+
});
489+
490+
if (!dirPath || typeof dirPath !== 'string') return;
491+
492+
let successCount = 0;
493+
const currentFiles = filesRef.current;
494+
495+
console.log('Downloading all files. Count:', currentFiles.length);
496+
497+
for (const file of currentFiles) {
498+
try {
499+
const name = file.name || file.filename || file.id;
500+
if (!name) continue;
501+
502+
let fileName = name;
503+
if (!fileName.toLowerCase().endsWith('.json')) fileName = `${fileName}.json`;
504+
505+
const data = await authFilesApi.download(fileName);
506+
const fullPath = await join(dirPath, fileName);
507+
await writeTextFile(fullPath, JSON.stringify(data, null, 2));
508+
successCount++;
509+
} catch (e) {
510+
console.error('Failed to download file:', file.id, e);
511+
}
512+
}
513+
514+
toast.success(t('providers.downloadSuccess', 'Downloaded {{count}} files successfully', { count: successCount }));
515+
} catch (err) {
516+
toast.error(t('providers.downloadFailed', 'Failed to download files') + `: ${(err as Error).message}`);
517+
}
518+
}, [t]);
519+
520+
const uploadAuthFile = useCallback(async () => {
521+
try {
522+
const { open } = await import('@tauri-apps/plugin-dialog');
523+
const { readTextFile } = await import('@tauri-apps/plugin-fs');
524+
const { basename } = await import('@tauri-apps/api/path');
525+
526+
const filePaths = await open({
527+
multiple: true,
528+
filters: [{ name: 'JSON', extensions: ['json'] }],
529+
});
530+
531+
if (!filePaths || filePaths.length === 0) return;
532+
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
533+
534+
let uploads = 0;
535+
for (const path of paths) {
536+
try {
537+
const content = await readTextFile(path);
538+
const name = await basename(path);
539+
540+
const blob = new Blob([content], { type: 'application/json' });
541+
const formData = new FormData();
542+
formData.append('file', blob, name);
543+
544+
await authFilesApi.upload(formData);
545+
uploads++;
546+
} catch (e) {
547+
console.error('Failed to upload file:', path, e);
548+
}
549+
}
550+
551+
if (uploads > 0) {
552+
toast.success(t('providers.uploadSuccess', 'Files uploaded successfully'));
553+
loadFiles();
554+
}
555+
} catch (err) {
556+
toast.error(t('providers.uploadFailed', 'Failed to upload files') + `: ${(err as Error).message}`);
557+
}
558+
}, [t, loadFiles]);
559+
447560
const togglePrivacyMode = useCallback(() => {
448561
setIsPrivacyMode(prev => !prev);
449562
}, []);
@@ -493,6 +606,9 @@ export function useProvidersPresenter() {
493606
updateProviderState,
494607
copyToClipboard,
495608
copyRefreshToken,
609+
downloadAuthFile,
610+
downloadAllAuthFiles,
611+
uploadAuthFile,
496612

497613
// Privacy
498614
isPrivacyMode,

src/i18n/locales/en.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@
7676
"authSuccess": "Provider connected successfully!",
7777
"deleteAllConfirm": "This will permanently delete all connected accounts. This action cannot be undone.",
7878
"deleteAllSuccess": "All accounts deleted successfully",
79+
"downloadAll": "Download All",
80+
"download": "Download",
81+
"downloadSuccess": "File downloaded successfully",
82+
"downloadFailed": "Failed to download file",
83+
"upload": "Upload",
84+
"uploadSuccess": "Files uploaded successfully",
85+
"uploadFailed": "Failed to upload files",
7986
"copyAllTitle": "Copy All Refresh Tokens",
8087
"selectProviders": "Select the providers you want to include in the copy.",
8188
"copySuccess": "Refresh tokens copied to clipboard",

src/i18n/locales/id.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@
7676
"authSuccess": "Penyedia berhasil terhubung!",
7777
"deleteAllConfirm": "Ini akan menghapus semua akun yang terhubung secara permanen. Tindakan ini tidak dapat dibatalkan.",
7878
"deleteAllSuccess": "Semua akun berhasil dihapus",
79+
"downloadAll": "Unduh Semua",
80+
"download": "Unduh",
81+
"downloadSuccess": "File berhasil diunduh",
82+
"downloadFailed": "Gagal mengunduh file",
83+
"upload": "Unggah",
84+
"uploadSuccess": "File berhasil diunggah",
85+
"uploadFailed": "Gagal mengunggah file",
7986
"copyAllTitle": "Salin Semua Token Penyegaran",
8087
"selectProviders": "Pilih penyedia yang ingin Anda sertakan dalam penyalinan.",
8188
"copySuccess": "Token penyegaran disalin ke papan klip",

src/i18n/locales/ja.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@
7676
"authSuccess": "プロバイダーの接続に成功しました!",
7777
"deleteAllConfirm": "接続されているすべてのアカウントが完全に削除されます。この操作は取り消せません。",
7878
"deleteAllSuccess": "すべてのアカウントが正常に削除されました",
79+
"downloadAll": "すべてダウンロード",
80+
"download": "ダウンロード",
81+
"downloadSuccess": "ファイルが正常にダウンロードされました",
82+
"downloadFailed": "ファイルのダウンロードに失敗しました",
83+
"upload": "アップロード",
84+
"uploadSuccess": "ファイルが正常にアップロードされました",
85+
"uploadFailed": "ファイルのアップロードに失敗しました",
7986
"copyAllTitle": "すべてのリフレッシュトークンをコピー",
8087
"selectProviders": "コピーに含めたいプロバイダーを選択してください。",
8188
"copySuccess": "リフレッシュトークンがクリップボードにコピーされました",

src/i18n/locales/ko.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@
7676
"authSuccess": "제공자가 성공적으로 연결되었습니다!",
7777
"deleteAllConfirm": "연결된 모든 계정이 영구적으로 삭제됩니다. 이 작업은 취소할 수 없습니다.",
7878
"deleteAllSuccess": "모든 계정이 성공적으로 삭제되었습니다",
79+
"downloadAll": "모두 다운로드",
80+
"download": "다운로드",
81+
"downloadSuccess": "파일이 성공적으로 다운로드되었습니다",
82+
"downloadFailed": "파일 다운로드 실패",
83+
"upload": "업로드",
84+
"uploadSuccess": "파일이 성공적으로 업로드되었습니다",
85+
"uploadFailed": "파일 업로드 실패",
7986
"copyAllTitle": "모든 갱신 토큰 복사",
8087
"selectProviders": "복사에 포함할 공급자를 선택하세요.",
8188
"copySuccess": "갱신 토큰이 클립보드에 복사되었습니다",

src/i18n/locales/th.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@
7676
"authSuccess": "เชื่อมต่อผู้ให้บริการสำเร็จ!",
7777
"deleteAllConfirm": "บัญชีที่เชื่อมต่อทั้งหมดจะถูกลบอย่างถาวร การดำเนินการนี้ไม่สามารถยกเลิกได้",
7878
"deleteAllSuccess": "ลบบัญชีทั้งหมดเรียบร้อยแล้ว",
79+
"downloadAll": "ดาวน์โหลดทั้งหมด",
80+
"download": "ดาวน์โหลด",
81+
"downloadSuccess": "ดาวน์โหลดไฟล์สำเร็จแล้ว",
82+
"downloadFailed": "ดาวน์โหลดไฟล์ล้มเหลว",
83+
"upload": "อัปโหลด",
84+
"uploadSuccess": "อัปโหลดไฟล์สำเร็จแล้ว",
85+
"uploadFailed": "อัปโหลดไฟล์ล้มเหลว",
7986
"copyAllTitle": "คัดลอกโทเค็นรีเฟรชทั้งหมด",
8087
"selectProviders": "เลือกผู้ให้บริการที่คุณต้องการรวมในการคัดลอก",
8188
"copySuccess": "คัดลอกโทเค็นรีเฟรชไปยังคลิปบอร์ดแล้ว",

src/i18n/locales/vi.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@
7676
"authSuccess": "Kết nối nhà cung cấp thành công!",
7777
"deleteAllConfirm": "Việc này sẽ xóa vĩnh viễn tất cả các tài khoản đã kết nối. Hành động này không thể hoàn tác.",
7878
"deleteAllSuccess": "Đã xóa tất cả tài khoản thành công",
79+
"downloadAll": "Tải xuống tất cả",
80+
"download": "Tải xuống",
81+
"downloadSuccess": "Đã tải tệp xuống thành công",
82+
"downloadFailed": "Tải tệp xuống thất bại",
83+
"upload": "Tải lên",
84+
"uploadSuccess": "Đã tải tệp lên thành công",
85+
"uploadFailed": "Tải tệp lên thất bại",
7986
"copyAllTitle": "Sao chép tất cả Token làm mới",
8087
"selectProviders": "Chọn nhà cung cấp bạn muốn sao chép.",
8188
"copySuccess": "Đã sao chép token làm mới vào clipboard",

src/i18n/locales/zh-CN.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@
7676
"authSuccess": "提供商连接成功!",
7777
"deleteAllConfirm": "这将永久删除所有连接的帐户。此操作无法撤销。",
7878
"deleteAllSuccess": "所有帐户已成功删除",
79+
"downloadAll": "全部下载",
80+
"download": "下载",
81+
"downloadSuccess": "文件下载成功",
82+
"downloadFailed": "文件下载失败",
83+
"upload": "上传",
84+
"uploadSuccess": "文件上传成功",
85+
"uploadFailed": "文件上传失败",
7986
"copyAllTitle": "复制所有刷新令牌",
8087
"selectProviders": "选择您要包含在复制中的提供商。",
8188
"copySuccess": "刷新令牌已复制到剪贴板",

0 commit comments

Comments
 (0)