-
-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathdecrypt_export.html
More file actions
702 lines (618 loc) · 50.6 KB
/
decrypt_export.html
File metadata and controls
702 lines (618 loc) · 50.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Decrypt Export — T-Disp TOTP</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117; --surface: #1a1d2e; --surface2: #252840;
--border: #2e3155; --accent: #5b6af5; --accent2: #7c8dff;
--text: #e8eaf6; --text2: #9099c8;
--danger: #f55b5b; --success: #5bf5a0; --warn: #f5c55b;
}
body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; min-height: 100vh; display: flex; flex-direction: column; align-items: center; padding: 2rem 1rem; }
.logo { font-size: 1.5rem; font-weight: 700; color: var(--accent2); margin-bottom: 0.25rem; letter-spacing: 1px; }
.subtitle { color: var(--text2); font-size: 0.85rem; margin-bottom: 2rem; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 2rem; width: 100%; max-width: 520px; margin-bottom: 1.5rem; }
#dropzone { border: 2px dashed var(--border); border-radius: 10px; padding: 2.5rem 1rem; text-align: center; cursor: pointer; transition: border-color 0.2s, background 0.2s; user-select: none; }
#dropzone:hover, #dropzone.over { border-color: var(--accent); background: rgba(91,106,245,0.07); }
#dropzone .icon { font-size: 2.5rem; margin-bottom: 0.5rem; }
#dropzone p { color: var(--text2); font-size: 0.9rem; }
#dropzone p span { color: var(--accent2); text-decoration: underline; }
#fileInput { display: none; }
#filename { margin-top: 0.75rem; font-size: 0.82rem; color: var(--success); min-height: 1.2em; text-align: center; }
.field-label { display: block; font-size: 0.8rem; color: var(--text2); margin: 1.25rem 0 0.4rem; letter-spacing: 0.5px; text-transform: uppercase; }
.pw-wrap { position: relative; display: flex; align-items: center; }
.pw-wrap input { flex: 1; background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 0.65rem 2.8rem 0.65rem 0.9rem; color: var(--text); font-size: 1rem; outline: none; transition: border-color 0.2s; }
.pw-wrap input:focus { border-color: var(--accent); }
.pw-wrap input::placeholder { color: var(--text2); }
.pw-toggle { position: absolute; right: 0.7rem; background: none; border: none; cursor: pointer; font-size: 1.1rem; color: var(--text2); padding: 0.2rem; }
.hint { font-size: 0.76rem; color: var(--text2); margin-top: 0.35rem; }
.btn { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.65rem 1.4rem; border-radius: 8px; border: none; cursor: pointer; font-size: 0.9rem; font-weight: 600; transition: opacity 0.15s, transform 0.1s; }
.btn:active { transform: scale(0.97); }
.btn-primary { background: var(--accent); color: #fff; width: 100%; justify-content: center; margin-top: 1.25rem; }
.btn-primary:hover { opacity: 0.88; }
.btn-sm { background: var(--surface2); color: var(--text2); font-size: 0.8rem; padding: 0.4rem 0.9rem; border: 1px solid var(--border); border-radius: 7px; }
.btn-sm:hover { color: var(--text); border-color: var(--accent2); }
.btn-encrypt { background: #1e3a2e; color: var(--success); border: 1px solid #2e6a4e; font-size: 0.8rem; padding: 0.4rem 0.9rem; border-radius: 7px; }
.btn-encrypt:hover { background: #254a3a; }
#status { min-height: 1.4em; font-size: 0.85rem; text-align: center; margin-top: 0.9rem; font-weight: 500; }
#status.error { color: var(--danger); } #status.ok { color: var(--success); } #status.loading { color: var(--warn); }
#results { display: none; width: 100%; max-width: 860px; }
.results-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem; }
.results-title { font-size: 1rem; font-weight: 600; color: var(--accent2); display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.results-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.table-wrap { overflow-x: auto; border-radius: 10px; border: 1px solid var(--border); }
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
thead { background: var(--surface2); }
th { padding: 0.7rem 0.9rem; text-align: left; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text2); border-bottom: 1px solid var(--border); white-space: nowrap; }
td { padding: 0.45rem 0.6rem; border-bottom: 1px solid rgba(46,49,85,0.5); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,0.02); }
.cell-edit { background: transparent; border: 1px solid transparent; border-radius: 5px; padding: 4px 7px; color: var(--text); font-size: 0.875rem; font-family: inherit; width: 100%; min-width: 90px; outline: none; transition: border-color 0.15s, background 0.15s; }
.cell-edit:focus { border-color: var(--accent); background: var(--surface2); }
.cell-edit.mono { font-family: 'Courier New', monospace; color: var(--accent2); }
select.cell-edit { cursor: pointer; min-width: 80px; }
select.cell-edit option { background: var(--surface); color: var(--text); }
input[type="number"].cell-edit { min-width: 70px; }
.badge { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; margin-left: 4px; }
.badge-hotp { background: rgba(245,197,91,0.2); color: var(--warn); }
.badge-sha256 { background: rgba(91,245,160,0.2); color: var(--success); }
.badge-sha512 { background: rgba(91,106,245,0.2); color: var(--accent2); }
.badge-8digit { background: rgba(245,91,91,0.2); color: var(--danger); }
.pw-cell-wrap { display: flex; align-items: center; gap: 4px; }
.pw-input { background: transparent; border: 1px solid transparent; border-radius: 5px; padding: 4px 7px; color: var(--text); font-size: 0.875rem; font-family: 'Courier New', monospace; width: 100%; min-width: 120px; outline: none; transition: border-color 0.15s, background 0.15s; }
.pw-input:focus { border-color: var(--accent); background: var(--surface2); }
.reveal-btn { background: none; border: none; cursor: pointer; color: var(--text2); font-size: 0.85rem; padding: 3px 5px; border-radius: 4px; flex-shrink: 0; }
.reveal-btn:hover { color: var(--accent2); background: var(--surface2); }
.copy-btn { background: none; border: none; cursor: pointer; color: var(--text2); font-size: 0.85rem; padding: 3px 8px; border-radius: 5px; }
.copy-btn:hover { color: var(--accent2); background: var(--surface2); }
.num-col { color: var(--text2); font-size: 0.78rem; width: 32px; text-align: center; }
.security-note { margin-top: 2rem; text-align: center; font-size: 0.76rem; color: var(--text2); opacity: 0.7; }
.security-note span { color: var(--success); }
.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); backdrop-filter: blur(4px); align-items: center; justify-content: center; }
.modal.show { display: flex; }
.modal-content { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 2rem; width: 90%; max-width: 480px; box-shadow: 0 8px 32px rgba(0,0,0,0.4); animation: modalSlide 0.2s ease-out; }
@keyframes modalSlide { from { opacity: 0; transform: translateY(-20px); } to { opacity: 1; transform: translateY(0); } }
.modal h3 { font-size: 1.1rem; }
#modalStatus.error, #createModalStatus.error { color: var(--danger); font-weight: 500; }
#modalStatus.ok, #createModalStatus.ok { color: var(--success); font-weight: 500; }
.lang-switcher { position: fixed; top: 1rem; right: 1rem; z-index: 999; }
.lang-switcher select { background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 4px 8px; font-size: 0.85rem; cursor: pointer; outline: none; }
.lang-switcher select:focus { border-color: var(--accent); }
</style>
</head>
<body>
<div class="lang-switcher">
<select id="langSelect">
<option value="en">🇬🇧 EN</option>
<option value="ru">🇷🇺 RU</option>
<option value="de">🇩🇪 DE</option>
<option value="zh">🇨🇳 ZH</option>
<option value="es">🇪🇸 ES</option>
</select>
</div>
<div class="logo">🔐 T-Disp TOTP</div>
<p class="subtitle" data-i18n="subtitle">Offline Export Decryption Tool</p>
<div class="card">
<div style="display: flex; gap: 0.75rem; margin-bottom: 1.25rem;">
<button class="btn btn-sm" id="createKeysBtn" data-i18n="create.keys" style="flex: 1;">➕ Create TOTP Keys File</button>
<button class="btn btn-sm" id="createPasswordsBtn" data-i18n="create.passwords" style="flex: 1;">➕ Create Passwords File</button>
</div>
<div id="dropzone">
<div class="icon">📁</div>
<p>Drop your backup file here<br>or <span>click to browse</span></p>
</div>
<input type="file" id="fileInput" accept=".json,application/json">
<div id="filename"></div>
<label class="field-label" for="pwInput" data-i18n="pw.label">Password</label>
<div class="pw-wrap">
<input type="password" id="pwInput" placeholder="Enter export password…" data-i18n="pw.placeholder" data-i18n-attr="placeholder" autocomplete="off">
<button class="pw-toggle" id="pwToggle" type="button">👁️</button>
</div>
<p class="hint" data-i18n="pw.hint">Same password as the web cabinet admin password</p>
<button class="btn btn-primary" id="decryptBtn" data-i18n="decrypt.btn">🔓 Decrypt</button>
<div id="status"></div>
</div>
<div id="results">
<div class="results-header">
<div class="results-title" id="resultsTitle"></div>
<div class="results-actions">
<button class="btn btn-sm" id="addRowBtn" data-i18n="add.row">➕ Add Row</button>
<button class="btn btn-sm" id="changePasswordBtn" data-i18n="change.pw">🔑 Change Password</button>
<button class="btn btn-sm" id="csvBtn" data-i18n="copy.csv">📋 Copy CSV</button>
<button class="btn btn-sm" id="downloadBtn" data-i18n="download.json">⬇️ Download JSON</button>
<button class="btn btn-encrypt" id="encryptBtn" data-i18n="save.encrypted">🔒 Save Encrypted</button>
</div>
</div>
<p class="hint" data-i18n="editable.hint" style="margin-bottom: 0.75rem; text-align: center;">💡 All fields are editable — click to modify values</p>
<div class="table-wrap">
<table>
<thead id="tableHead"></thead>
<tbody id="tableBody"></tbody>
</table>
</div>
</div>
<p class="security-note"><span data-i18n="security.offline">🛡️ Fully offline.</span> <span data-i18n="security.note">Your data never leaves this device.</span><br>
<span data-i18n="security.extended">✨ Extended format support:</span> <span data-i18n="security.formats">TOTP/HOTP, SHA1/SHA256/SHA512, 6/8 digits, custom periods.</span></p>
<!-- Modal for password change -->
<div id="passwordModal" class="modal">
<div class="modal-content">
<h3 data-i18n="modal.changepw.title" style="margin-bottom: 1rem; color: var(--accent2);">🔑 Change Password</h3>
<p data-i18n="modal.changepw.desc" style="color: var(--text2); font-size: 0.85rem; margin-bottom: 1.25rem;">
Enter a new password to re-encrypt the file. This password will be required to import the file on the device.
</p>
<label class="field-label" for="currentPwInput" data-i18n="modal.current.pw">Current Password</label>
<div class="pw-wrap">
<input type="password" id="currentPwInput" placeholder="Current password…" data-i18n="modal.current.pw.ph" data-i18n-attr="placeholder" autocomplete="off">
<button class="pw-toggle" type="button" onclick="toggleModalPw('currentPwInput', this)">👁️</button>
</div>
<label class="field-label" for="newPwInput" data-i18n="modal.new.pw">New Password</label>
<div class="pw-wrap">
<input type="password" id="newPwInput" placeholder="New password…" data-i18n="modal.new.pw.ph" data-i18n-attr="placeholder" autocomplete="off">
<button class="pw-toggle" type="button" onclick="toggleModalPw('newPwInput', this)">👁️</button>
</div>
<label class="field-label" for="confirmPwInput" data-i18n="modal.confirm.pw">Confirm New Password</label>
<div class="pw-wrap">
<input type="password" id="confirmPwInput" placeholder="Confirm new password…" data-i18n="modal.confirm.pw.ph" data-i18n-attr="placeholder" autocomplete="off">
<button class="pw-toggle" type="button" onclick="toggleModalPw('confirmPwInput', this)">👁️</button>
</div>
<div id="modalStatus" style="min-height: 1.2em; font-size: 0.85rem; text-align: center; margin-top: 0.75rem;"></div>
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
<button class="btn btn-sm" id="cancelPasswordBtn" data-i18n="modal.cancel" style="flex: 1;">Cancel</button>
<button class="btn btn-primary" id="confirmPasswordBtn" data-i18n="modal.apply" style="flex: 1; margin-top: 0;">Apply</button>
</div>
</div>
</div>
<!-- Modal for creating new file -->
<div id="createModal" class="modal">
<div class="modal-content">
<h3 data-i18n="modal.create.title" style="margin-bottom: 1rem; color: var(--accent2);" id="createModalTitle">➕ Create New File</h3>
<p data-i18n="modal.create.desc" style="color: var(--text2); font-size: 0.85rem; margin-bottom: 1.25rem;" id="createModalDesc">
Create a new empty encrypted file. You can add entries and import it to your device.
</p>
<label class="field-label" for="createPwInput">Password</label>
<div class="pw-wrap">
<input type="password" id="createPwInput" placeholder="Enter password for new file…" data-i18n="modal.create.pw.ph" data-i18n-attr="placeholder" autocomplete="off">
<button class="pw-toggle" type="button" onclick="toggleModalPw('createPwInput', this)">👁️</button>
</div>
<p class="hint" data-i18n="modal.create.pw.hint">Use the same password as your device admin password</p>
<label class="field-label" for="createConfirmPwInput" data-i18n="modal.create.confirm.label">Confirm Password</label>
<div class="pw-wrap">
<input type="password" id="createConfirmPwInput" placeholder="Confirm password…" data-i18n="modal.create.confirm.ph" data-i18n-attr="placeholder" autocomplete="off">
<button class="pw-toggle" type="button" onclick="toggleModalPw('createConfirmPwInput', this)">👁️</button>
</div>
<div id="createModalStatus" style="min-height: 1.2em; font-size: 0.85rem; text-align: center; margin-top: 0.75rem;"></div>
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
<button class="btn btn-sm" id="cancelCreateBtn" data-i18n="modal.cancel" style="flex: 1;">Cancel</button>
<button class="btn btn-primary" id="confirmCreateBtn" data-i18n="modal.create.btn" style="flex: 1; margin-top: 0;">Create</button>
</div>
</div>
</div>
<script>
// ── i18n ──
const TOOL_I18N = {
en: {subtitle:'Offline Export Decryption Tool','create.keys':'➕ Create TOTP Keys File','create.passwords':'➕ Create Passwords File','pw.label':'Password','pw.placeholder':'Enter export password…','pw.hint':'Same password as the web cabinet admin password','decrypt.btn':'🔓 Decrypt','add.row':'➕ Add Row','change.pw':'🔑 Change Password','copy.csv':'📋 Copy CSV','download.json':'⬇️ Download JSON','save.encrypted':'🔒 Save Encrypted','editable.hint':'💡 All fields are editable — click to modify values','security.offline':'🛡️ Fully offline.','security.note':'Your data never leaves this device.','security.extended':'✨ Extended format support:','security.formats':'TOTP/HOTP, SHA1/SHA256/SHA512, 6/8 digits, custom periods.','modal.changepw.title':'🔑 Change Password','modal.changepw.desc':'Enter a new password to re-encrypt the file. This password will be required to import the file on the device.','modal.current.pw':'Current Password','modal.current.pw.ph':'Current password…','modal.new.pw':'New Password','modal.new.pw.ph':'New password…','modal.confirm.pw':'Confirm New Password','modal.confirm.pw.ph':'Confirm new password…','modal.cancel':'Cancel','modal.apply':'Apply','modal.create.title':'➕ Create New File','modal.create.desc':'Create a new empty encrypted file. You can add entries and import it to your device.','modal.create.keys.title':'➕ Create TOTP Keys File','modal.create.keys.desc':'Create a new empty TOTP/HOTP keys file. You can add entries and import it to your device.','modal.create.pw.title':'➕ Create Passwords File','modal.create.pw.desc':'Create a new empty passwords file. You can add entries and import it to your device.','modal.create.pw.ph':'Enter password for new file…','modal.create.pw.hint':'Use the same password as your device admin password','modal.create.confirm.label':'Confirm Password','modal.create.confirm.ph':'Confirm password…','modal.create.btn':'Create','err.pw.empty':'❌ Password cannot be empty','err.pw.match':'❌ Passwords do not match','created':'✅ New file created! Add entries and save.','err.create':'❌ Failed to create file: ','file.loaded':'File loaded — enter password and decrypt.','err.file':'Could not read file.','err.no.file':'Please select a file first.','err.no.pw':'Please enter the password.','decrypting':'Decrypting…','decrypted':'✅ Decrypted successfully!','err.wrong.pw':'❌ Wrong password or corrupted file.','err.decrypt.first':'Decrypt first.','err.encrypt':'❌ Encryption failed: ','err.format':'Invalid data format.','table.pw.title':'🔑 Passwords','table.keys.title':'🔐 TOTP Keys','col.name':'Name','col.password':'Password','col.secret':'Secret','col.type':'Type','col.algorithm':'Algorithm','col.digits':'Digits','col.period':'Period/Counter','period.ph':'Period (s)','counter.ph':'Counter','modal.pw.incorrect':'❌ Current password is incorrect','modal.pw.new.empty':'❌ New password cannot be empty','modal.pw.changed':'✅ Password changed successfully!','modal.pw.err':'❌ Failed to change password: ','reencrypted':'✅ File re-encrypted with new password!'},
ru: {subtitle:'Инструмент расшифровки экспорта','create.keys':'➕ Создать файл TOTP-ключей','create.passwords':'➕ Создать файл паролей','pw.label':'Пароль','pw.placeholder':'Введите пароль экспорта…','pw.hint':'Тот же пароль, что и пароль веб-кабинета','decrypt.btn':'🔓 Расшифровать','add.row':'➕ Добавить строку','change.pw':'🔑 Сменить пароль','copy.csv':'📋 Копировать CSV','download.json':'⬇️ Скачать JSON','save.encrypted':'🔒 Сохранить зашифрованным','editable.hint':'💡 Все поля редактируемы — нажмите для изменения','security.offline':'🛡️ Полностью офлайн.','security.note':'Ваши данные не покидают устройство.','security.extended':'✨ Расширенная поддержка форматов:','security.formats':'TOTP/HOTP, SHA1/SHA256/SHA512, 6/8 цифр, произвольные периоды.','modal.changepw.title':'🔑 Сменить пароль','modal.changepw.desc':'Введите новый пароль для перешифрования файла. Этот пароль потребуется для импорта файла на устройство.','modal.current.pw':'Текущий пароль','modal.current.pw.ph':'Текущий пароль…','modal.new.pw':'Новый пароль','modal.new.pw.ph':'Новый пароль…','modal.confirm.pw':'Подтвердите новый пароль','modal.confirm.pw.ph':'Подтвердите пароль…','modal.cancel':'Отмена','modal.apply':'Применить','modal.create.title':'➕ Создать новый файл','modal.create.desc':'Создайте новый пустой зашифрованный файл. Добавьте записи и импортируйте на устройство.','modal.create.keys.title':'➕ Создать файл TOTP-ключей','modal.create.keys.desc':'Создайте новый пустой файл TOTP/HOTP-ключей. Добавьте записи и импортируйте на устройство.','modal.create.pw.title':'➕ Создать файл паролей','modal.create.pw.desc':'Создайте новый пустой файл паролей. Добавьте записи и импортируйте на устройство.','modal.create.pw.ph':'Введите пароль для нового файла…','modal.create.pw.hint':'Используйте тот же пароль, что и на устройстве','modal.create.confirm.label':'Подтвердите пароль','modal.create.confirm.ph':'Подтвердите пароль…','modal.create.btn':'Создать','err.pw.empty':'❌ Пароль не может быть пустым','err.pw.match':'❌ Пароли не совпадают','created':'✅ Новый файл создан! Добавьте записи и сохраните.','err.create':'❌ Ошибка создания файла: ','file.loaded':'Файл загружен — введите пароль и расшифруйте.','err.file':'Не удалось прочитать файл.','err.no.file':'Сначала выберите файл.','err.no.pw':'Введите пароль.','decrypting':'Расшифровка…','decrypted':'✅ Успешно расшифровано!','err.wrong.pw':'❌ Неверный пароль или повреждённый файл.','err.decrypt.first':'Сначала расшифруйте.','err.encrypt':'❌ Ошибка шифрования: ','err.format':'Неверный формат данных.','table.pw.title':'🔑 Пароли','table.keys.title':'🔐 TOTP-ключи','col.name':'Имя','col.password':'Пароль','col.secret':'Секрет','col.type':'Тип','col.algorithm':'Алгоритм','col.digits':'Цифры','col.period':'Период/Счётчик','period.ph':'Период (с)','counter.ph':'Счётчик','modal.pw.incorrect':'❌ Текущий пароль неверен','modal.pw.new.empty':'❌ Новый пароль не может быть пустым','modal.pw.changed':'✅ Пароль успешно изменён!','modal.pw.err':'❌ Ошибка смены пароля: ','reencrypted':'✅ Файл перешифрован с новым паролем!'},
de: {subtitle:'Offline-Exportentschlüsselungswerkzeug','create.keys':'➕ TOTP-Schlüsseldatei erstellen','create.passwords':'➕ Passwortdatei erstellen','pw.label':'Passwort','pw.placeholder':'Export-Passwort eingeben…','pw.hint':'Dasselbe Passwort wie das Web-Kabinett-Admin-Passwort','decrypt.btn':'🔓 Entschlüsseln','add.row':'➕ Zeile hinzufügen','change.pw':'🔑 Passwort ändern','copy.csv':'📋 CSV kopieren','download.json':'⬇️ JSON herunterladen','save.encrypted':'🔒 Verschlüsselt speichern','editable.hint':'💡 Alle Felder sind bearbeitbar — zum Ändern klicken','security.offline':'🛡️ Vollständig offline.','security.note':'Ihre Daten verlassen dieses Gerät nicht.','security.extended':'✨ Erweiterte Formatunterstützung:','security.formats':'TOTP/HOTP, SHA1/SHA256/SHA512, 6/8 Stellen, benutzerdefinierte Perioden.','modal.changepw.title':'🔑 Passwort ändern','modal.changepw.desc':'Neues Passwort eingeben, um die Datei neu zu verschlüsseln. Dieses Passwort wird beim Import auf dem Gerät benötigt.','modal.current.pw':'Aktuelles Passwort','modal.current.pw.ph':'Aktuelles Passwort…','modal.new.pw':'Neues Passwort','modal.new.pw.ph':'Neues Passwort…','modal.confirm.pw':'Neues Passwort bestätigen','modal.confirm.pw.ph':'Passwort bestätigen…','modal.cancel':'Abbrechen','modal.apply':'Anwenden','modal.create.title':'➕ Neue Datei erstellen','modal.create.desc':'Neue leere verschlüsselte Datei erstellen. Einträge hinzufügen und auf Gerät importieren.','modal.create.keys.title':'➕ TOTP-Schlüsseldatei erstellen','modal.create.keys.desc':'Neue leere TOTP/HOTP-Schlüsseldatei erstellen. Einträge hinzufügen und importieren.','modal.create.pw.title':'➕ Passwortdatei erstellen','modal.create.pw.desc':'Neue leere Passwortdatei erstellen. Einträge hinzufügen und importieren.','modal.create.pw.ph':'Passwort für neue Datei eingeben…','modal.create.pw.hint':'Dasselbe Passwort wie auf dem Gerät verwenden','modal.create.confirm.label':'Passwort bestätigen','modal.create.confirm.ph':'Passwort bestätigen…','modal.create.btn':'Erstellen','err.pw.empty':'❌ Passwort darf nicht leer sein','err.pw.match':'❌ Passwörter stimmen nicht überein','created':'✅ Neue Datei erstellt! Einträge hinzufügen und speichern.','err.create':'❌ Datei konnte nicht erstellt werden: ','file.loaded':'Datei geladen — Passwort eingeben und entschlüsseln.','err.file':'Datei konnte nicht gelesen werden.','err.no.file':'Bitte zuerst eine Datei auswählen.','err.no.pw':'Bitte Passwort eingeben.','decrypting':'Entschlüsseln…','decrypted':'✅ Erfolgreich entschlüsselt!','err.wrong.pw':'❌ Falsches Passwort oder beschädigte Datei.','err.decrypt.first':'Zuerst entschlüsseln.','err.encrypt':'❌ Verschlüsselung fehlgeschlagen: ','err.format':'Ungültiges Datenformat.','table.pw.title':'🔑 Passwörter','table.keys.title':'🔐 TOTP-Schlüssel','col.name':'Name','col.password':'Passwort','col.secret':'Geheimnis','col.type':'Typ','col.algorithm':'Algorithmus','col.digits':'Stellen','col.period':'Periode/Zähler','period.ph':'Periode (s)','counter.ph':'Zähler','modal.pw.incorrect':'❌ Aktuelles Passwort ist falsch','modal.pw.new.empty':'❌ Neues Passwort darf nicht leer sein','modal.pw.changed':'✅ Passwort erfolgreich geändert!','modal.pw.err':'❌ Passwortänderung fehlgeschlagen: ','reencrypted':'✅ Datei mit neuem Passwort neu verschlüsselt!'},
zh: {subtitle:'离线导出解密工具','create.keys':'➕ 创建TOTP密钥文件','create.passwords':'➕ 创建密码文件','pw.label':'密码','pw.placeholder':'输入导出密码…','pw.hint':'与Web控制台管理员密码相同','decrypt.btn':'🔓 解密','add.row':'➕ 添加行','change.pw':'🔑 更改密码','copy.csv':'📋 复制CSV','download.json':'⬇️ 下载JSON','save.encrypted':'🔒 保存加密文件','editable.hint':'💡 所有字段均可编辑 — 点击修改','security.offline':'🛡️ 完全离线。','security.note':'您的数据不会离开此设备。','security.extended':'✨ 扩展格式支持:','security.formats':'TOTP/HOTP、SHA1/SHA256/SHA512、6/8位、自定义周期。','modal.changepw.title':'🔑 更改密码','modal.changepw.desc':'输入新密码以重新加密文件。在设备上导入文件时需要此密码。','modal.current.pw':'当前密码','modal.current.pw.ph':'当前密码…','modal.new.pw':'新密码','modal.new.pw.ph':'新密码…','modal.confirm.pw':'确认新密码','modal.confirm.pw.ph':'确认密码…','modal.cancel':'取消','modal.apply':'应用','modal.create.title':'➕ 创建新文件','modal.create.desc':'创建新的空加密文件,添加条目后导入设备。','modal.create.keys.title':'➕ 创建TOTP密钥文件','modal.create.keys.desc':'创建新的空TOTP/HOTP密钥文件,添加条目后导入。','modal.create.pw.title':'➕ 创建密码文件','modal.create.pw.desc':'创建新的空密码文件,添加条目后导入。','modal.create.pw.ph':'输入新文件密码…','modal.create.pw.hint':'使用与设备相同的密码','modal.create.confirm.label':'确认密码','modal.create.confirm.ph':'确认密码…','modal.create.btn':'创建','err.pw.empty':'❌ 密码不能为空','err.pw.match':'❌ 密码不匹配','created':'✅ 新文件已创建!添加条目并保存。','err.create':'❌ 创建文件失败:','file.loaded':'文件已加载 — 输入密码并解密。','err.file':'无法读取文件。','err.no.file':'请先选择文件。','err.no.pw':'请输入密码。','decrypting':'解密中…','decrypted':'✅ 解密成功!','err.wrong.pw':'❌ 密码错误或文件损坏。','err.decrypt.first':'请先解密。','err.encrypt':'❌ 加密失败:','err.format':'数据格式无效。','table.pw.title':'🔑 密码','table.keys.title':'🔐 TOTP密钥','col.name':'名称','col.password':'密码','col.secret':'密钥','col.type':'类型','col.algorithm':'算法','col.digits':'位数','col.period':'周期/计数器','period.ph':'周期(秒)','counter.ph':'计数器','modal.pw.incorrect':'❌ 当前密码不正确','modal.pw.new.empty':'❌ 新密码不能为空','modal.pw.changed':'✅ 密码更改成功!','modal.pw.err':'❌ 密码更改失败:','reencrypted':'✅ 文件已用新密码重新加密!'},
es: {subtitle:'Herramienta de descifrado de exportación','create.keys':'➕ Crear archivo de claves TOTP','create.passwords':'➕ Crear archivo de contraseñas','pw.label':'Contraseña','pw.placeholder':'Introducir contraseña de exportación…','pw.hint':'La misma contraseña que la del panel web','decrypt.btn':'🔓 Descifrar','add.row':'➕ Añadir fila','change.pw':'🔑 Cambiar contraseña','copy.csv':'📋 Copiar CSV','download.json':'⬇️ Descargar JSON','save.encrypted':'🔒 Guardar cifrado','editable.hint':'💡 Todos los campos son editables — haz clic para modificar','security.offline':'🛡️ Completamente offline.','security.note':'Sus datos nunca abandonan este dispositivo.','security.extended':'✨ Soporte de formato extendido:','security.formats':'TOTP/HOTP, SHA1/SHA256/SHA512, 6/8 dígitos, períodos personalizados.','modal.changepw.title':'🔑 Cambiar contraseña','modal.changepw.desc':'Introduce una nueva contraseña para re-cifrar el archivo. Esta contraseña será necesaria para importar el archivo en el dispositivo.','modal.current.pw':'Contraseña actual','modal.current.pw.ph':'Contraseña actual…','modal.new.pw':'Nueva contraseña','modal.new.pw.ph':'Nueva contraseña…','modal.confirm.pw':'Confirmar nueva contraseña','modal.confirm.pw.ph':'Confirmar contraseña…','modal.cancel':'Cancelar','modal.apply':'Aplicar','modal.create.title':'➕ Crear nuevo archivo','modal.create.desc':'Crear un nuevo archivo cifrado vacío. Añade entradas e impórtalo a tu dispositivo.','modal.create.keys.title':'➕ Crear archivo de claves TOTP','modal.create.keys.desc':'Crear un nuevo archivo TOTP/HOTP vacío. Añade entradas e impórtalo.','modal.create.pw.title':'➕ Crear archivo de contraseñas','modal.create.pw.desc':'Crear un nuevo archivo de contraseñas vacío. Añade entradas e impórtalo.','modal.create.pw.ph':'Introducir contraseña para el nuevo archivo…','modal.create.pw.hint':'Usa la misma contraseña que en el dispositivo','modal.create.confirm.label':'Confirmar contraseña','modal.create.confirm.ph':'Confirmar contraseña…','modal.create.btn':'Crear','err.pw.empty':'❌ La contraseña no puede estar vacía','err.pw.match':'❌ Las contraseñas no coinciden','created':'✅ ¡Nuevo archivo creado! Añade entradas y guarda.','err.create':'❌ Error al crear el archivo: ','file.loaded':'Archivo cargado — introduce la contraseña y descifra.','err.file':'No se pudo leer el archivo.','err.no.file':'Por favor, selecciona un archivo primero.','err.no.pw':'Por favor, introduce la contraseña.','decrypting':'Descifrando…','decrypted':'✅ ¡Descifrado correctamente!','err.wrong.pw':'❌ Contraseña incorrecta o archivo corrupto.','err.decrypt.first':'Primero descifra el archivo.','err.encrypt':'❌ Error de cifrado: ','err.format':'Formato de datos no válido.','table.pw.title':'🔑 Contraseñas','table.keys.title':'🔐 Claves TOTP','col.name':'Nombre','col.password':'Contraseña','col.secret':'Secreto','col.type':'Tipo','col.algorithm':'Algoritmo','col.digits':'Dígitos','col.period':'Período/Contador','period.ph':'Período (s)','counter.ph':'Contador','modal.pw.incorrect':'❌ La contraseña actual es incorrecta','modal.pw.new.empty':'❌ La nueva contraseña no puede estar vacía','modal.pw.changed':'✅ ¡Contraseña cambiada correctamente!','modal.pw.err':'❌ Error al cambiar la contraseña: ','reencrypted':'✅ ¡Archivo re-cifrado con nueva contraseña!'}
};
let toolLang = localStorage.getItem('tool_lang') || 'en';
function tr(key) { return TOOL_I18N[toolLang]?.[key] ?? TOOL_I18N['en']?.[key] ?? key; }
function applyToolLang() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.dataset.i18n;
const attr = el.dataset.i18nAttr;
if (attr) { el[attr] = tr(key); }
else { el.textContent = tr(key); }
});
}
document.addEventListener('DOMContentLoaded', () => {
const sel = document.getElementById('langSelect');
if (sel) {
sel.value = toolLang;
sel.addEventListener('change', () => {
toolLang = sel.value;
localStorage.setItem('tool_lang', toolLang);
applyToolLang();
});
}
applyToolLang();
});
document.addEventListener('dragover', e => e.preventDefault());
document.addEventListener('drop', e => e.preventDefault());
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('fileInput');
const filenameEl = document.getElementById('filename');
const pwInput = document.getElementById('pwInput');
const pwToggle = document.getElementById('pwToggle');
const decryptBtn = document.getElementById('decryptBtn');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
let rawFileText = null;
let decryptedData = null;
let isPasswords = false;
let createFileType = null; // 'keys' or 'passwords'
// Modal helpers
function toggleModalPw(inputId, btn) {
const input = document.getElementById(inputId);
const visible = input.type === 'text';
input.type = visible ? 'password' : 'text';
btn.textContent = visible ? '👁️' : '🙈';
}
function showModal(modalId) {
document.getElementById(modalId).classList.add('show');
}
function hideModal(modalId) {
document.getElementById(modalId).classList.remove('show');
}
// Create new file buttons
document.getElementById('createKeysBtn').addEventListener('click', () => {
createFileType = 'keys';
document.getElementById('createModalTitle').textContent = tr('modal.create.keys.title');
document.getElementById('createModalDesc').textContent = tr('modal.create.keys.desc');
document.getElementById('createPwInput').value = '';
document.getElementById('createConfirmPwInput').value = '';
document.getElementById('createModalStatus').textContent = '';
showModal('createModal');
});
document.getElementById('createPasswordsBtn').addEventListener('click', () => {
createFileType = 'passwords';
document.getElementById('createModalTitle').textContent = tr('modal.create.pw.title');
document.getElementById('createModalDesc').textContent = tr('modal.create.pw.desc');
document.getElementById('createPwInput').value = '';
document.getElementById('createConfirmPwInput').value = '';
document.getElementById('createModalStatus').textContent = '';
showModal('createModal');
});
document.getElementById('cancelCreateBtn').addEventListener('click', () => hideModal('createModal'));
document.getElementById('confirmCreateBtn').addEventListener('click', async () => {
const pw = document.getElementById('createPwInput').value;
const confirmPw = document.getElementById('createConfirmPwInput').value;
const statusEl = document.getElementById('createModalStatus');
if (!pw) {
statusEl.textContent = tr('err.pw.empty');
statusEl.className = 'error';
return;
}
if (pw !== confirmPw) {
statusEl.textContent = tr('err.pw.match');
statusEl.className = 'error';
return;
}
try {
// Create empty array
decryptedData = [];
isPasswords = createFileType === 'passwords';
pwInput.value = pw;
// Encrypt and download
await doEncrypt();
// Show empty table
renderTable(decryptedData);
hideModal('createModal');
setStatus(tr('created'), 'ok');
resultsEl.scrollIntoView({ behavior: 'smooth' });
} catch(err) {
statusEl.textContent = tr('err.create') + err.message;
statusEl.className = 'error';
}
});
// File pick
dropzone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => { if (fileInput.files[0]) loadFile(fileInput.files[0]); });
dropzone.addEventListener('dragover', e => { e.preventDefault(); e.stopPropagation(); dropzone.classList.add('over'); });
dropzone.addEventListener('dragleave', e => { e.stopPropagation(); dropzone.classList.remove('over'); });
dropzone.addEventListener('drop', e => {
e.preventDefault(); e.stopPropagation();
dropzone.classList.remove('over');
if (e.dataTransfer.files[0]) loadFile(e.dataTransfer.files[0]);
});
function loadFile(file) {
filenameEl.textContent = '📄 ' + file.name;
const r = new FileReader();
r.onload = ev => { rawFileText = ev.target.result; setStatus(tr('file.loaded'), 'ok'); };
r.onerror = () => setStatus(tr('err.file'), 'error');
r.readAsText(file);
}
// Password toggle
let pwVisible = false;
pwToggle.addEventListener('click', () => {
pwVisible = !pwVisible;
pwInput.type = pwVisible ? 'text' : 'password';
pwToggle.textContent = pwVisible ? '🙈' : '👁️';
});
// Decrypt
decryptBtn.addEventListener('click', doDecrypt);
pwInput.addEventListener('keydown', e => { if (e.key === 'Enter') doDecrypt(); });
async function doDecrypt() {
if (!rawFileText) { setStatus(tr('err.no.file'), 'error'); return; }
if (!pwInput.value) { setStatus(tr('err.no.pw'), 'error'); return; }
setStatus(tr('decrypting'), 'loading');
resultsEl.style.display = 'none';
try {
let parsed = JSON.parse(rawFileText);
if (parsed.fileContent) parsed = JSON.parse(parsed.fileContent);
const { salt, iv, ciphertext } = parsed;
if (!salt || !iv || !ciphertext) throw new Error('Unrecognized file format.');
const pwKey = await crypto.subtle.importKey('raw', enc(pwInput.value), { name: 'PBKDF2' }, false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: b64(salt), iterations: 15000, hash: 'SHA-256' }, pwKey, 256);
const aes = await crypto.subtle.importKey('raw', bits, { name: 'AES-CBC' }, false, ['decrypt']);
const plain = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: b64(iv) }, aes, b64(ciphertext));
decryptedData = JSON.parse(new TextDecoder().decode(plain));
renderTable(decryptedData);
setStatus(tr('decrypted'), 'ok');
} catch(err) {
setStatus(err.name === 'OperationError' ? tr('err.wrong.pw') : '❌ ' + err.message, 'error');
}
}
// Re-encrypt with same password → device-compatible file
async function doEncrypt() {
if (!decryptedData || !pwInput.value) { setStatus(tr('err.decrypt.first'), 'error'); return; }
try {
const plaintext = JSON.stringify(decryptedData);
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(16));
const pwKey = await crypto.subtle.importKey('raw', enc(pwInput.value), { name: 'PBKDF2' }, false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt, iterations: 15000, hash: 'SHA-256' }, pwKey, 256);
const aes = await crypto.subtle.importKey('raw', bits, { name: 'AES-CBC' }, false, ['encrypt']);
const cipher = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, aes, enc(plaintext));
const result = JSON.stringify({
salt: btoa(String.fromCharCode(...salt)),
iv: btoa(String.fromCharCode(...iv)),
ciphertext: btoa(String.fromCharCode(...new Uint8Array(cipher)))
});
const filename = isPasswords ? 'encrypted_passwords_backup.json' : 'encrypted_keys_backup.json';
download(result, filename);
flash(document.getElementById('encryptBtn'), '✅ Saved!');
} catch(err) {
setStatus(tr('err.encrypt') + err.message, 'error');
}
}
function renderTable(data) {
if (!Array.isArray(data)) { setStatus(tr('err.format'), 'error'); return; }
// Allow empty arrays for new files
if (data.length === 0) {
isPasswords = createFileType === 'passwords';
} else {
isPasswords = data.some(r => 'password' in r);
}
// Count special features for TOTP keys
let hotpCount = 0, sha256Count = 0, sha512Count = 0, digit8Count = 0;
if (!isPasswords) {
data.forEach(item => {
if ((item.type || 'T') === 'H') hotpCount++;
if ((item.algorithm || '1') === '256') sha256Count++;
if ((item.algorithm || '1') === '512') sha512Count++;
if ((item.digits || 6) === 8) digit8Count++;
});
}
let titleBadges = '';
if (!isPasswords) {
if (hotpCount > 0) titleBadges += `<span class="badge badge-hotp">HOTP: ${hotpCount}</span>`;
if (sha256Count > 0) titleBadges += `<span class="badge badge-sha256">SHA256: ${sha256Count}</span>`;
if (sha512Count > 0) titleBadges += `<span class="badge badge-sha512">SHA512: ${sha512Count}</span>`;
if (digit8Count > 0) titleBadges += `<span class="badge badge-8digit">8-digit: ${digit8Count}</span>`;
}
document.getElementById('resultsTitle').innerHTML = isPasswords
? `${tr('table.pw.title')} (${data.length})`
: `${tr('table.keys.title')} (${data.length}) ${titleBadges}`;
const head = document.getElementById('tableHead');
const body = document.getElementById('tableBody');
head.innerHTML = '';
body.innerHTML = '';
if (isPasswords) {
head.innerHTML = `<tr><th>#</th><th>${tr('col.name')}</th><th>${tr('col.password')}</th><th></th></tr>`;
data.forEach((item, i) => {
const row = document.createElement('tr');
const td0 = mkTd('num-col'); td0.textContent = i + 1;
const td1 = mkTd();
const nameInp = mkInput('cell-edit', item.name || '');
nameInp.addEventListener('input', () => { data[i].name = nameInp.value; });
td1.appendChild(nameInp);
const td2 = mkTd();
const wrap = document.createElement('div'); wrap.className = 'pw-cell-wrap';
const pwInp = mkInput('pw-input', item.password || '');
pwInp.type = 'password';
pwInp.addEventListener('input', () => { data[i].password = pwInp.value; });
const revBtn = document.createElement('button'); revBtn.className = 'reveal-btn'; revBtn.textContent = '👁️';
revBtn.addEventListener('click', () => {
const h = pwInp.type === 'password'; pwInp.type = h ? 'text' : 'password'; revBtn.textContent = h ? '🙈' : '👁️';
});
wrap.append(pwInp, revBtn); td2.appendChild(wrap);
const td3 = mkTd();
const copyBtn = document.createElement('button'); copyBtn.className = 'copy-btn'; copyBtn.textContent = '📋';
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText((data[i].name||'') + '\t' + (data[i].password||'')).then(() => flash(copyBtn,'✅'));
});
const delBtn = document.createElement('button'); delBtn.className = 'copy-btn'; delBtn.textContent = '🗑️'; delBtn.style.color = 'var(--danger)';
delBtn.addEventListener('click', () => {
if (confirm('Delete this entry?')) {
data.splice(i, 1);
renderTable(data);
}
});
td3.append(copyBtn, delBtn);
row.append(td0, td1, td2, td3); body.appendChild(row);
});
} else {
head.innerHTML = `<tr><th>#</th><th>${tr('col.name')}</th><th>${tr('col.secret')}</th><th>${tr('col.type')}</th><th>${tr('col.algorithm')}</th><th>${tr('col.digits')}</th><th>${tr('col.period')}</th><th></th></tr>`;
data.forEach((item, i) => {
const row = document.createElement('tr');
const td0 = mkTd('num-col'); td0.textContent = i + 1;
const td1 = mkTd();
const nameInp = mkInput('cell-edit', item.name || '');
nameInp.addEventListener('input', () => { data[i].name = nameInp.value; });
td1.appendChild(nameInp);
const td2 = mkTd();
const secInp = mkInput('cell-edit mono', item.secret || '');
secInp.addEventListener('input', () => { data[i].secret = secInp.value; });
td2.appendChild(secInp);
// Type (TOTP/HOTP)
const td3 = mkTd();
const typeSelect = document.createElement('select');
typeSelect.className = 'cell-edit';
typeSelect.innerHTML = `<option value="T">TOTP</option><option value="H">HOTP</option>`;
typeSelect.value = item.type || 'T';
typeSelect.addEventListener('change', () => {
data[i].type = typeSelect.value;
// Update period/counter field visibility
if (typeSelect.value === 'H') {
periodInp.placeholder = tr('counter.ph');
periodInp.value = item.counter || 0;
} else {
periodInp.placeholder = tr('period.ph');
periodInp.value = item.period || 30;
}
});
td3.appendChild(typeSelect);
// Algorithm
const td4 = mkTd();
const algoSelect = document.createElement('select');
algoSelect.className = 'cell-edit';
algoSelect.innerHTML = `<option value="1">SHA1</option><option value="256">SHA256</option><option value="512">SHA512</option>`;
algoSelect.value = item.algorithm || '1';
algoSelect.addEventListener('change', () => { data[i].algorithm = algoSelect.value; });
td4.appendChild(algoSelect);
// Digits
const td5 = mkTd();
const digitsSelect = document.createElement('select');
digitsSelect.className = 'cell-edit';
digitsSelect.innerHTML = `<option value="6">6</option><option value="8">8</option>`;
digitsSelect.value = item.digits || 6;
digitsSelect.addEventListener('change', () => { data[i].digits = parseInt(digitsSelect.value); });
td5.appendChild(digitsSelect);
// Period/Counter (dynamic based on type)
const td6 = mkTd();
const periodInp = mkInput('cell-edit', '');
periodInp.type = 'number';
periodInp.min = '0';
if ((item.type || 'T') === 'H') {
periodInp.placeholder = tr('counter.ph');
periodInp.value = item.counter || 0;
periodInp.addEventListener('input', () => { data[i].counter = parseInt(periodInp.value) || 0; });
} else {
periodInp.placeholder = tr('period.ph');
periodInp.value = item.period || 30;
periodInp.addEventListener('input', () => { data[i].period = parseInt(periodInp.value) || 30; });
}
td6.appendChild(periodInp);
const td7 = mkTd();
const copyBtn = document.createElement('button'); copyBtn.className = 'copy-btn'; copyBtn.textContent = '📋';
copyBtn.addEventListener('click', () => {
const meta = `Type: ${data[i].type||'T'}, Algo: SHA${data[i].algorithm||'1'}, Digits: ${data[i].digits||6}`;
navigator.clipboard.writeText((data[i].name||'') + '\t' + (data[i].secret||'') + '\t' + meta).then(() => flash(copyBtn,'✅'));
});
const delBtn = document.createElement('button'); delBtn.className = 'copy-btn'; delBtn.textContent = '🗑️'; delBtn.style.color = 'var(--danger)';
delBtn.addEventListener('click', () => {
if (confirm('Delete this TOTP key?')) {
data.splice(i, 1);
renderTable(data);
}
});
td7.append(copyBtn, delBtn);
row.append(td0, td1, td2, td3, td4, td5, td6, td7); body.appendChild(row);
});
}
// CSV
document.getElementById('csvBtn').onclick = () => {
const csv = isPasswords
? 'Name,Password\n' + data.map(r => [r.name, r.password].map(q).join(',')).join('\n')
: 'Name,Secret,Type,Algorithm,Digits,Period,Counter\n' + data.map(r => [
r.name,
r.secret,
r.type || 'T',
'SHA' + (r.algorithm || '1'),
r.digits || 6,
r.period || 30,
r.counter || 0
].map(q).join(',')).join('\n');
navigator.clipboard.writeText(csv).then(() => flash(document.getElementById('csvBtn'), '✅ Copied!'));
};
// Download plain JSON
document.getElementById('downloadBtn').onclick = () => {
download(JSON.stringify(data, null, 2), isPasswords ? 'passwords_decrypted.json' : 'totp_keys_decrypted.json');
};
// Re-encrypt
document.getElementById('encryptBtn').onclick = doEncrypt;
// Add row
document.getElementById('addRowBtn').onclick = () => {
if (isPasswords) {
data.push({ name: '', password: '' });
} else {
data.push({ name: '', secret: '', type: 'T', algorithm: '1', digits: 6, period: 30, counter: 0 });
}
renderTable(data);
// Scroll to bottom
setTimeout(() => {
const table = document.querySelector('#tableBody');
table.lastElementChild.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 50);
};
// Change password
document.getElementById('changePasswordBtn').onclick = () => {
document.getElementById('currentPwInput').value = pwInput.value;
document.getElementById('newPwInput').value = '';
document.getElementById('confirmPwInput').value = '';
document.getElementById('modalStatus').textContent = '';
showModal('passwordModal');
};
resultsEl.style.display = 'block';
setTimeout(() => resultsEl.scrollIntoView({ behavior: 'smooth' }), 50);
}
// Password change modal handlers
document.getElementById('cancelPasswordBtn').addEventListener('click', () => hideModal('passwordModal'));
document.getElementById('confirmPasswordBtn').addEventListener('click', async () => {
const currentPw = document.getElementById('currentPwInput').value;
const newPw = document.getElementById('newPwInput').value;
const confirmPw = document.getElementById('confirmPwInput').value;
const statusEl = document.getElementById('modalStatus');
if (currentPw !== pwInput.value) {
statusEl.textContent = tr('modal.pw.incorrect');
statusEl.className = 'error';
return;
}
if (!newPw) {
statusEl.textContent = tr('modal.pw.new.empty');
statusEl.className = 'error';
return;
}
if (newPw !== confirmPw) {
statusEl.textContent = tr('err.pw.match');
statusEl.className = 'error';
return;
}
try {
// Update password
pwInput.value = newPw;
// Re-encrypt with new password
await doEncrypt();
statusEl.textContent = tr('modal.pw.changed');
statusEl.className = 'ok';
setTimeout(() => {
hideModal('passwordModal');
setStatus(tr('reencrypted'), 'ok');
}, 1500);
} catch(err) {
statusEl.textContent = tr('modal.pw.err') + err.message;
statusEl.className = 'error';
}
});
// ── Helpers ──
function mkTd(cls) { const t = document.createElement('td'); if(cls) t.className = cls; return t; }
function mkInput(cls, val){ const i = document.createElement('input'); i.className = cls; i.value = val; return i; }
function b64(s) { const b=atob(s), u=new Uint8Array(b.length); for(let i=0;i<b.length;i++) u[i]=b.charCodeAt(i); return u; }
function enc(s) { return new TextEncoder().encode(s); }
function q(v) { if(v==null) return ''; const s=String(v); return (s.includes(',')||s.includes('"')||s.includes('\n')) ? '"'+s.replace(/"/g,'""')+'"' : s; }
function flash(btn, lbl) { const o=btn.textContent; btn.textContent=lbl; setTimeout(()=>btn.textContent=o, 1300); }
function setStatus(m, t) { statusEl.textContent=m; statusEl.className=t||''; }
function download(text, name) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
a.download = name; a.click();
}
</script>
</body>
</html>