Skip to content

Commit deed565

Browse files
committed
feat(providers): implement "Copy All" refresh tokens functionality
Adds a new "Copy All" button to the Providers page, allowing users to copy refresh tokens from multiple selected providers. This feature includes: - A new dialog for selecting providers to include in the copy operation. - State management in `useProvidersPresenter` for the modal, selection, and copying process. - Logic to fetch and concatenate refresh tokens from selected providers. - Clipboard integration to copy the combined refresh tokens. - Internationalization support for new strings related to "Copy All" functionality. This enhances user convenience by providing a bulk action for managing refresh tokens.
1 parent e7b62e9 commit deed565

9 files changed

Lines changed: 223 additions & 23 deletions

File tree

src/features/providers/ProvidersPage.tsx

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ import {
1818
ChevronRight,
1919
Key
2020
} from 'lucide-react';
21+
import {
22+
Dialog,
23+
DialogContent,
24+
DialogDescription,
25+
DialogFooter,
26+
DialogHeader,
27+
DialogTitle,
28+
} from '@/shared/components/ui/dialog';
2129
import {
2230
AlertDialog,
2331
AlertDialogAction,
@@ -68,6 +76,15 @@ export function ProvidersPage() {
6876
showDeleteAllConfirmation,
6977
setShowDeleteAllConfirmation,
7078
copyRefreshToken,
79+
80+
// Copy All
81+
showCopyAllModal,
82+
setShowCopyAllModal,
83+
copyingAll,
84+
selectedProvidersForCopy,
85+
openCopyAllModal,
86+
toggleCopyProvider,
87+
executeCopyAll,
7188
} = useProvidersPresenter();
7289

7390
if (!isAuthenticated) {
@@ -157,15 +174,26 @@ export function ProvidersPage() {
157174
{t('providers.connectedAccounts')} ({files.length})
158175
</h2>
159176
{files.length > 0 && (
160-
<Button
161-
variant="outline"
162-
size="sm"
163-
onClick={() => setShowDeleteAllConfirmation(true)}
164-
className="h-8 text-xs bg-red-500/10 text-red-500 hover:bg-red-500/20 shadow-none border border-red-500/20"
165-
>
166-
<Trash2 className="mr-2 h-3.5 w-3.5" />
167-
{t('common.deleteAll', 'Delete All')}
168-
</Button>
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>
169197
)}
170198
</div>
171199

@@ -475,6 +503,60 @@ export function ProvidersPage() {
475503
})}
476504
</div>
477505
</section>
506+
507+
<Dialog open={showCopyAllModal} onOpenChange={setShowCopyAllModal}>
508+
<DialogContent className="sm:max-w-[425px]">
509+
<DialogHeader>
510+
<DialogTitle>{t('providers.copyAllTitle', 'Copy All Refresh Tokens')}</DialogTitle>
511+
<DialogDescription>
512+
{t('providers.selectProviders', 'Select the providers you want to include in the copy.')}
513+
</DialogDescription>
514+
</DialogHeader>
515+
<div className="py-4">
516+
<div className="space-y-4">
517+
{groupedFiles.map(([providerId, group]) => (
518+
<div key={providerId} className="flex items-center space-x-2">
519+
<input
520+
type="checkbox"
521+
id={`copy-${providerId}`}
522+
checked={selectedProvidersForCopy.includes(providerId)}
523+
onChange={() => toggleCopyProvider(providerId)}
524+
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary accent-primary"
525+
/>
526+
<label
527+
htmlFor={`copy-${providerId}`}
528+
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 flex items-center gap-2 cursor-pointer select-none"
529+
>
530+
<img
531+
src={group.iconInfo.path}
532+
alt={group.displayName}
533+
className={`h-4 w-4 object-contain ${group.iconInfo.needsInvert ? 'invert-on-dark' : ''}`}
534+
/>
535+
{group.displayName}
536+
<span className="text-xs text-muted-foreground">({group.files.length})</span>
537+
</label>
538+
</div>
539+
))}
540+
</div>
541+
</div>
542+
<DialogFooter>
543+
<Button variant="outline" onClick={() => setShowCopyAllModal(false)}>{t('common.cancel')}</Button>
544+
<Button onClick={executeCopyAll} disabled={copyingAll || selectedProvidersForCopy.length === 0}>
545+
{copyingAll ? (
546+
<>
547+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
548+
{t('common.copying', 'Copying...')}
549+
</>
550+
) : (
551+
<>
552+
<Key className="mr-2 h-4 w-4" />
553+
{t('common.copy', 'Copy')}
554+
</>
555+
)}
556+
</Button>
557+
</DialogFooter>
558+
</DialogContent>
559+
</Dialog>
478560
</div>
479561
);
480562
}

src/features/providers/useProvidersPresenter.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ export function useProvidersPresenter() {
6262
const [isDeletingAll, setIsDeletingAll] = useState(false);
6363
const [showDeleteAllConfirmation, setShowDeleteAllConfirmation] = useState(false);
6464

65+
const [showCopyAllModal, setShowCopyAllModal] = useState(false);
66+
const [copyingAll, setCopyingAll] = useState(false);
67+
const [selectedProvidersForCopy, setSelectedProvidersForCopy] = useState<string[]>([]);
68+
6569
const [providerStates, setProviderStates] = useState<Record<string, ProviderState>>({});
6670
const [callbackUrl, setCallbackUrl] = useState('');
6771
const [selectedProvider, setSelectedProvider] = useState<ProviderId | null>(null);
@@ -156,6 +160,69 @@ export function useProvidersPresenter() {
156160
}
157161
}, [loadFiles, t]);
158162

163+
const openCopyAllModal = useCallback(() => {
164+
const allProviderIds = groupedFiles.map(([id]) => id);
165+
setSelectedProvidersForCopy(allProviderIds);
166+
setShowCopyAllModal(true);
167+
}, [groupedFiles]);
168+
169+
const toggleCopyProvider = useCallback((providerId: string) => {
170+
setSelectedProvidersForCopy(prev => {
171+
if (prev.includes(providerId)) {
172+
return prev.filter(id => id !== providerId);
173+
} else {
174+
return [...prev, providerId];
175+
}
176+
});
177+
}, []);
178+
179+
const executeCopyAll = useCallback(async () => {
180+
if (selectedProvidersForCopy.length === 0) return;
181+
182+
setCopyingAll(true);
183+
try {
184+
const results: Array<{ provider: string, account: string, refresh_token: string }> = [];
185+
186+
const filesToProcess = groupedFiles
187+
.filter(([id]) => selectedProvidersForCopy.includes(id))
188+
.flatMap(([, group]) => group.files);
189+
190+
for (const file of filesToProcess) {
191+
let name = file.name || file.filename || file.id;
192+
if (!name) continue;
193+
if (!name.toLowerCase().endsWith('.json')) {
194+
name = `${name}.json`;
195+
}
196+
197+
try {
198+
const data = await authFilesApi.download(name);
199+
if (data && data.refresh_token) {
200+
results.push({
201+
provider: file.provider,
202+
account: (file.metadata?.email as string) || (file.account as string) || file.filename,
203+
refresh_token: data.refresh_token
204+
});
205+
}
206+
} catch (err) {
207+
console.error(`Failed to download ${name}`, err);
208+
}
209+
}
210+
211+
if (results.length > 0) {
212+
const tokens = results.map(r => r.refresh_token).join('\n');
213+
await navigator.clipboard.writeText(tokens);
214+
toast.success(t('providers.copySuccess') || 'Refresh tokens copied to clipboard');
215+
setShowCopyAllModal(false);
216+
} else {
217+
toast.warning(t('providers.noTokensFound') || 'No refresh tokens found');
218+
}
219+
} catch (err) {
220+
toast.error(t('common.error') || 'An error occurred');
221+
} finally {
222+
setCopyingAll(false);
223+
}
224+
}, [groupedFiles, selectedProvidersForCopy, t]);
225+
159226
const updateProviderState = useCallback((provider: string, update: Partial<ProviderState>) => {
160227
setProviderStates((prev) => ({
161228
...prev,
@@ -397,6 +464,15 @@ export function useProvidersPresenter() {
397464
showDeleteAllConfirmation,
398465
setShowDeleteAllConfirmation,
399466

467+
// Copy All
468+
showCopyAllModal,
469+
setShowCopyAllModal,
470+
copyingAll,
471+
selectedProvidersForCopy,
472+
openCopyAllModal,
473+
toggleCopyProvider,
474+
executeCopyAll,
475+
400476
// Add provider (OAuth)
401477
providerStates,
402478
selectedProvider,

src/i18n/locales/en.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
"deleteAll": "Delete All",
1919
"deleting": "Deleting...",
2020
"deleteWarning": "This action cannot be undone. This will permanently delete your account connection.",
21-
"copyRefreshToken": "Copy Refresh Token"
21+
"copyRefreshToken": "Copy Refresh Token",
22+
"copyAll": "Copy All",
23+
"copying": "Copying..."
2224
},
2325
"auth": {
2426
"login": "Login",
@@ -73,7 +75,11 @@
7375
"manualCallback": "If valid callback is not detected automatically:",
7476
"authSuccess": "Provider connected successfully!",
7577
"deleteAllConfirm": "This will permanently delete all connected accounts. This action cannot be undone.",
76-
"deleteAllSuccess": "All accounts deleted successfully"
78+
"deleteAllSuccess": "All accounts deleted successfully",
79+
"copyAllTitle": "Copy All Refresh Tokens",
80+
"selectProviders": "Select the providers you want to include in the copy.",
81+
"copySuccess": "Refresh tokens copied to clipboard",
82+
"noTokensFound": "No refresh tokens found"
7783
},
7884
"oauth": {
7985
"pasteCallback": "Paste callback URL here..."

src/i18n/locales/id.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
"deleteAll": "Hapus Semua",
1919
"deleting": "Menghapus...",
2020
"deleteWarning": "Tindakan ini tidak dapat dibatalkan. Ini akan menghapus koneksi akun Anda secara permanen.",
21-
"copyRefreshToken": "Salin Token Penyegaran"
21+
"copyRefreshToken": "Salin Token Penyegaran",
22+
"copyAll": "Salin Semua",
23+
"copying": "Menyalin..."
2224
},
2325
"auth": {
2426
"login": "Masuk",
@@ -73,7 +75,11 @@
7375
"manualCallback": "Jika callback valid tidak terdeteksi otomatis:",
7476
"authSuccess": "Penyedia berhasil terhubung!",
7577
"deleteAllConfirm": "Ini akan menghapus semua akun yang terhubung secara permanen. Tindakan ini tidak dapat dibatalkan.",
76-
"deleteAllSuccess": "Semua akun berhasil dihapus"
78+
"deleteAllSuccess": "Semua akun berhasil dihapus",
79+
"copyAllTitle": "Salin Semua Token Penyegaran",
80+
"selectProviders": "Pilih penyedia yang ingin Anda sertakan dalam penyalinan.",
81+
"copySuccess": "Token penyegaran disalin ke papan klip",
82+
"noTokensFound": "Tidak ada token penyegaran ditemukan"
7783
},
7884
"oauth": {
7985
"pasteCallback": "Tempelkan URL callback di sini..."

src/i18n/locales/ja.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
"deleteAll": "すべて削除",
1919
"deleting": "削除中...",
2020
"deleteWarning": "この操作は取り消せません。アカウント接続が完全に削除されます。",
21-
"copyRefreshToken": "リフレッシュトークンをコピー"
21+
"copyRefreshToken": "リフレッシュトークンをコピー",
22+
"copyAll": "すべてコピー",
23+
"copying": "コピー中..."
2224
},
2325
"auth": {
2426
"login": "ログイン",
@@ -73,7 +75,11 @@
7375
"manualCallback": "有効なコールバックが自動検出されない場合:",
7476
"authSuccess": "プロバイダーの接続に成功しました!",
7577
"deleteAllConfirm": "接続されているすべてのアカウントが完全に削除されます。この操作は取り消せません。",
76-
"deleteAllSuccess": "すべてのアカウントが正常に削除されました"
78+
"deleteAllSuccess": "すべてのアカウントが正常に削除されました",
79+
"copyAllTitle": "すべてのリフレッシュトークンをコピー",
80+
"selectProviders": "コピーに含めたいプロバイダーを選択してください。",
81+
"copySuccess": "リフレッシュトークンがクリップボードにコピーされました",
82+
"noTokensFound": "リフレッシュトークンが見つかりません"
7783
},
7884
"oauth": {
7985
"pasteCallback": "コールバックURLをここに貼り付け..."

src/i18n/locales/ko.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
"deleteAll": "모두 삭제",
1919
"deleting": "삭제 중...",
2020
"deleteWarning": "이 작업은 취소할 수 없습니다. 계정 연결이 영구적으로 삭제됩니다.",
21-
"copyRefreshToken": "갱신 토큰 복사"
21+
"copyRefreshToken": "갱신 토큰 복사",
22+
"copyAll": "모두 복사",
23+
"copying": "복사 중..."
2224
},
2325
"auth": {
2426
"login": "로그인",
@@ -73,7 +75,11 @@
7375
"manualCallback": "유효한 콜백이 자동으로 감지되지 않는 경우:",
7476
"authSuccess": "제공자가 성공적으로 연결되었습니다!",
7577
"deleteAllConfirm": "연결된 모든 계정이 영구적으로 삭제됩니다. 이 작업은 취소할 수 없습니다.",
76-
"deleteAllSuccess": "모든 계정이 성공적으로 삭제되었습니다"
78+
"deleteAllSuccess": "모든 계정이 성공적으로 삭제되었습니다",
79+
"copyAllTitle": "모든 갱신 토큰 복사",
80+
"selectProviders": "복사에 포함할 공급자를 선택하세요.",
81+
"copySuccess": "갱신 토큰이 클립보드에 복사되었습니다",
82+
"noTokensFound": "갱신 토큰을 찾을 수 없습니다"
7783
},
7884
"oauth": {
7985
"pasteCallback": "콜백 URL을 여기에 붙여넣기..."

src/i18n/locales/th.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
"deleteAll": "ลบทั้งหมด",
1919
"deleting": "กำลังลบ...",
2020
"deleteWarning": "การดำเนินการนี้ไม่สามารถยกเลิกได้ การเชื่อมต่อบัญชีของคุณจะถูกลบอย่างถาวร",
21-
"copyRefreshToken": "คัดลอกโทเค็นรีเฟรช"
21+
"copyRefreshToken": "คัดลอกโทเค็นรีเฟรช",
22+
"copyAll": "คัดลอกทั้งหมด",
23+
"copying": "กำลังคัดลอก..."
2224
},
2325
"auth": {
2426
"login": "เข้าสู่ระบบ",
@@ -73,7 +75,11 @@
7375
"manualCallback": "หากระบบไม่ตรวจพบ callback ที่ถูกต้องโดยอัตโนมัติ:",
7476
"authSuccess": "เชื่อมต่อผู้ให้บริการสำเร็จ!",
7577
"deleteAllConfirm": "บัญชีที่เชื่อมต่อทั้งหมดจะถูกลบอย่างถาวร การดำเนินการนี้ไม่สามารถยกเลิกได้",
76-
"deleteAllSuccess": "ลบบัญชีทั้งหมดเรียบร้อยแล้ว"
78+
"deleteAllSuccess": "ลบบัญชีทั้งหมดเรียบร้อยแล้ว",
79+
"copyAllTitle": "คัดลอกโทเค็นรีเฟรชทั้งหมด",
80+
"selectProviders": "เลือกผู้ให้บริการที่คุณต้องการรวมในการคัดลอก",
81+
"copySuccess": "คัดลอกโทเค็นรีเฟรชไปยังคลิปบอร์ดแล้ว",
82+
"noTokensFound": "ไม่พบโทเค็นรีเฟรช"
7783
},
7884
"oauth": {
7985
"pasteCallback": "วาง URL callback ที่นี่..."

src/i18n/locales/vi.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
"deleteAll": "Xóa tất cả",
1919
"deleting": "Đang xóa...",
2020
"deleteWarning": "Hành động này không thể hoàn tác. Việc này sẽ xóa vĩnh viễn kết nối tài khoản của bạn.",
21-
"copyRefreshToken": "Sao chép Token làm mới"
21+
"copyRefreshToken": "Sao chép Token làm mới",
22+
"copyAll": "Sao chép tất cả",
23+
"copying": "Đang sao chép..."
2224
},
2325
"auth": {
2426
"login": "Đăng nhập",
@@ -73,7 +75,11 @@
7375
"manualCallback": "Nếu không tự động phát hiện callback hợp lệ:",
7476
"authSuccess": "Kết nối nhà cung cấp thành công!",
7577
"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.",
76-
"deleteAllSuccess": "Tất cả tài khoản đã được xóa thành công"
78+
"deleteAllSuccess": "Đã xóa tất cả tài khoản thành công",
79+
"copyAllTitle": "Sao chép tất cả Token làm mới",
80+
"selectProviders": "Chọn nhà cung cấp bạn muốn sao chép.",
81+
"copySuccess": "Đã sao chép token làm mới vào clipboard",
82+
"noTokensFound": "Không tìm thấy token làm mới"
7783
},
7884
"oauth": {
7985
"pasteCallback": "Dán URL callback vào đây..."

src/i18n/locales/zh-CN.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
"deleteAll": "全部删除",
1919
"deleting": "正在删除...",
2020
"deleteWarning": "此操作无法撤销。这将永久删除您的帐户连接。",
21-
"copyRefreshToken": "复制刷新令牌"
21+
"copyRefreshToken": "复制刷新令牌",
22+
"copyAll": "全部复制",
23+
"copying": "正在复制..."
2224
},
2325
"auth": {
2426
"login": "登录",
@@ -73,7 +75,11 @@
7375
"manualCallback": "如果未自动检测到回调:",
7476
"authSuccess": "提供商连接成功!",
7577
"deleteAllConfirm": "这将永久删除所有连接的帐户。此操作无法撤销。",
76-
"deleteAllSuccess": "所有帐户已成功删除"
78+
"deleteAllSuccess": "所有帐户已成功删除",
79+
"copyAllTitle": "复制所有刷新令牌",
80+
"selectProviders": "选择您要包含在复制中的提供商。",
81+
"copySuccess": "刷新令牌已复制到剪贴板",
82+
"noTokensFound": "未找到刷新令牌"
7783
},
7884
"oauth": {
7985
"pasteCallback": "在此粘贴回调 URL..."

0 commit comments

Comments
 (0)