@brftech/filex-core 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/{ArchiveViewer-EGgNqLPE.js → ArchiveViewer-Qi4L2jj9.js} +2 -2
  2. package/dist/{ArchiveViewer-EGgNqLPE.js.map → ArchiveViewer-Qi4L2jj9.js.map} +1 -1
  3. package/dist/{CsvViewer-BvriJ42M.js → CsvViewer-IOIrJDsJ.js} +2 -2
  4. package/dist/{CsvViewer-BvriJ42M.js.map → CsvViewer-IOIrJDsJ.js.map} +1 -1
  5. package/dist/{DrawioViewer-B_bSPsWX.js → DrawioViewer-D0KtUQBO.js} +2 -2
  6. package/dist/{DrawioViewer-B_bSPsWX.js.map → DrawioViewer-D0KtUQBO.js.map} +1 -1
  7. package/dist/{EpubViewer-hl68h4gG.js → EpubViewer-DJOXiOi4.js} +2 -2
  8. package/dist/{EpubViewer-hl68h4gG.js.map → EpubViewer-DJOXiOi4.js.map} +1 -1
  9. package/dist/{IpynbViewer-lhwOjXK7.js → IpynbViewer-D16jXVJc.js} +2 -2
  10. package/dist/{IpynbViewer-lhwOjXK7.js.map → IpynbViewer-D16jXVJc.js.map} +1 -1
  11. package/dist/{MermaidViewer-H2Z6AYXg.js → MermaidViewer-BnSaVg1L.js} +2 -2
  12. package/dist/{MermaidViewer-H2Z6AYXg.js.map → MermaidViewer-BnSaVg1L.js.map} +1 -1
  13. package/dist/{PsdViewer-Bsk-11yH.js → PsdViewer-BUerXaQA.js} +2 -2
  14. package/dist/{PsdViewer-Bsk-11yH.js.map → PsdViewer-BUerXaQA.js.map} +1 -1
  15. package/dist/{TiffViewer-D-j9vycG.js → TiffViewer-D0Qn3IV-.js} +2 -2
  16. package/dist/{TiffViewer-D-j9vycG.js.map → TiffViewer-D0Qn3IV-.js.map} +1 -1
  17. package/dist/{Viewer3D-Bcr70rTe.js → Viewer3D-BtRK-k1B.js} +2 -2
  18. package/dist/{Viewer3D-Bcr70rTe.js.map → Viewer3D-BtRK-k1B.js.map} +1 -1
  19. package/dist/filex-core.js +66 -50
  20. package/dist/filex-core.umd.cjs +35 -35
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/index-CGNE1bSv.js +11802 -0
  23. package/dist/index-CGNE1bSv.js.map +1 -0
  24. package/dist/index.d.ts +184 -0
  25. package/dist/style.css +1 -1
  26. package/package.json +1 -1
  27. package/src/FileExplorer.vue +416 -15
  28. package/src/components/EncryptedFolderModal.vue +125 -0
  29. package/src/components/GalleryView.vue +1 -0
  30. package/src/components/GridView.vue +1 -0
  31. package/src/components/ListView.vue +1 -0
  32. package/src/components/SecondaryPane.vue +72 -78
  33. package/src/components/Toolbar.vue +112 -3
  34. package/src/composables/useFileApi.ts +7 -0
  35. package/src/composables/useKeyboardShortcuts.ts +6 -0
  36. package/src/composables/useTabs.ts +11 -2
  37. package/src/index.ts +21 -0
  38. package/src/lib/e2ecrypto.ts +299 -0
  39. package/src/locales/en.ts +34 -0
  40. package/src/locales/tr.ts +34 -0
  41. package/src/modals/NewFolderModal.vue +18 -0
  42. package/src/modals/PreviewModal.vue +7 -2
  43. package/src/styles/base.css +126 -1
  44. package/src/styles/variables.css +2 -2
  45. package/src/types/FileNode.ts +3 -0
  46. package/dist/index-C_ZSnk_I.js +0 -11109
  47. package/dist/index-C_ZSnk_I.js.map +0 -1
@@ -0,0 +1,299 @@
1
+ /**
2
+ * e2ecrypto — client-side crypto for E2E-encrypted folders (wiring:e2).
3
+ *
4
+ * WebCrypto ONLY — zero dependencies. Design doc: docs/E2E-ENCRYPTION.md.
5
+ *
6
+ * Scheme (v1):
7
+ * folder password ─PBKDF2-SHA256(600k iter, per-folder 16B salt)─▶ KEK (AES-256-GCM)
8
+ * per-file random 32B DEK (AES-256-GCM) encrypts the content one-shot;
9
+ * the DEK is wrapped with the KEK and stored in the file's own header.
10
+ *
11
+ * File layout ('filexe2e' magic, fixed 97-byte header):
12
+ * [0..8) magic "filexe2e"
13
+ * [8] version 0x01
14
+ * [9..21) wrapIV (12B) — GCM IV of the DEK wrap
15
+ * [21..69) wrappedDEK (48B = 32B DEK + 16B GCM tag)
16
+ * [69..81) dataIV (12B) — GCM IV of the content
17
+ * [81..97) reserved (zeros; v2 chunking/metadata)
18
+ * [97..) ciphertext (content + 16B GCM tag)
19
+ *
20
+ * Folder marker `.filex-e2e.json` at the encrypted-folder root:
21
+ * { v:1, salt:<b64 16B>, iter:600000, verify:<b64 12B IV || GCM('filex-e2e-verify-v1')> }
22
+ *
23
+ * The KEK NEVER leaves memory — no storage of any kind. Password loss is
24
+ * data loss by design (no recovery path exists anywhere).
25
+ */
26
+
27
+ export const E2E_MARKER_NAME = '.filex-e2e.json';
28
+ export const E2E_MAGIC = 'filexe2e';
29
+ export const E2E_VERSION = 1;
30
+ export const E2E_DEFAULT_ITERATIONS = 600_000;
31
+ export const E2E_MIN_ITERATIONS = 600_000;
32
+ /** MVP single-shot in-memory ceiling — larger uploads are refused with a warning. */
33
+ export const E2E_MAX_FILE_BYTES = 200 * 1024 * 1024;
34
+ export const E2E_MIN_PASSWORD_LEN = 8;
35
+
36
+ const VERIFY_PLAINTEXT = 'filex-e2e-verify-v1';
37
+ const MAGIC_BYTES = new TextEncoder().encode(E2E_MAGIC); // 8 bytes
38
+ const HEADER_LEN = 97;
39
+ const WRAP_IV_OFF = 9;
40
+ const WRAPPED_DEK_OFF = 21;
41
+ const WRAPPED_DEK_LEN = 48;
42
+ const DATA_IV_OFF = 69;
43
+ const IV_LEN = 12;
44
+
45
+ export interface E2eMarker {
46
+ v: number;
47
+ salt: string; // base64
48
+ iter: number;
49
+ verify: string; // base64: 12B IV || AES-GCM ciphertext of VERIFY_PLAINTEXT
50
+ }
51
+
52
+ /** Thrown on wrong password / corrupted ciphertext (GCM tag mismatch). */
53
+ export class E2eDecryptError extends Error {
54
+ constructor(msg = 'e2e: decrypt failed') {
55
+ super(msg);
56
+ this.name = 'E2eDecryptError';
57
+ }
58
+ }
59
+
60
+ // ---------------------------------------------------------------------
61
+ // base64 helpers (no deps)
62
+ // ---------------------------------------------------------------------
63
+
64
+ export function bytesToB64(b: Uint8Array): string {
65
+ let s = '';
66
+ for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
67
+ return btoa(s);
68
+ }
69
+
70
+ export function b64ToBytes(s: string): Uint8Array {
71
+ const raw = atob(s);
72
+ const out = new Uint8Array(raw.length);
73
+ for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
74
+ return out;
75
+ }
76
+
77
+ // ---------------------------------------------------------------------
78
+ // Key derivation
79
+ // ---------------------------------------------------------------------
80
+
81
+ /**
82
+ * Derive the folder KEK from a password. Returns a NON-extractable
83
+ * AES-256-GCM CryptoKey — it can encrypt/decrypt but never be exported,
84
+ * so even a same-origin script can't read the raw key material back.
85
+ */
86
+ export async function deriveKek(
87
+ password: string,
88
+ salt: Uint8Array,
89
+ iterations: number,
90
+ ): Promise<CryptoKey> {
91
+ const material = await crypto.subtle.importKey(
92
+ 'raw',
93
+ new TextEncoder().encode(password),
94
+ 'PBKDF2',
95
+ false,
96
+ ['deriveKey'],
97
+ );
98
+ return crypto.subtle.deriveKey(
99
+ // Copy into a fresh ArrayBuffer-backed view — TS 5.9 BufferSource typing
100
+ // rejects Uint8Array<ArrayBufferLike> that may wrap a SharedArrayBuffer.
101
+ { name: 'PBKDF2', salt: new Uint8Array(salt).buffer as ArrayBuffer, iterations, hash: 'SHA-256' },
102
+ material,
103
+ { name: 'AES-GCM', length: 256 },
104
+ false, // non-extractable
105
+ ['encrypt', 'decrypt'],
106
+ );
107
+ }
108
+
109
+ // ---------------------------------------------------------------------
110
+ // Marker create / verify
111
+ // ---------------------------------------------------------------------
112
+
113
+ /** Create a fresh folder marker for `password` (also returns the derived KEK). */
114
+ export async function createMarker(
115
+ password: string,
116
+ iterations: number = E2E_DEFAULT_ITERATIONS,
117
+ ): Promise<{ marker: E2eMarker; kek: CryptoKey }> {
118
+ const iter = Math.max(E2E_MIN_ITERATIONS, iterations);
119
+ const salt = crypto.getRandomValues(new Uint8Array(16));
120
+ const kek = await deriveKek(password, salt, iter);
121
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
122
+ const ct = new Uint8Array(
123
+ await crypto.subtle.encrypt(
124
+ { name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
125
+ kek,
126
+ new TextEncoder().encode(VERIFY_PLAINTEXT),
127
+ ),
128
+ );
129
+ const verify = new Uint8Array(IV_LEN + ct.length);
130
+ verify.set(iv, 0);
131
+ verify.set(ct, IV_LEN);
132
+ return {
133
+ marker: { v: E2E_VERSION, salt: bytesToB64(salt), iter, verify: bytesToB64(verify) },
134
+ kek,
135
+ };
136
+ }
137
+
138
+ /** Parse marker JSON text; returns null when the shape is not a v1 marker. */
139
+ export function parseMarker(text: string): E2eMarker | null {
140
+ try {
141
+ const m = JSON.parse(text) as E2eMarker;
142
+ if (!m || m.v !== E2E_VERSION) return null;
143
+ if (typeof m.salt !== 'string' || typeof m.verify !== 'string') return null;
144
+ if (typeof m.iter !== 'number' || m.iter < 1) return null;
145
+ return m;
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Check `password` against a folder marker. Resolves to the derived KEK on
153
+ * success, or `null` on a wrong password (GCM tag mismatch on the verify
154
+ * blob). Never talks to any server.
155
+ */
156
+ export async function verifyPassword(
157
+ marker: E2eMarker,
158
+ password: string,
159
+ ): Promise<CryptoKey | null> {
160
+ const salt = b64ToBytes(marker.salt);
161
+ const kek = await deriveKek(password, salt, marker.iter);
162
+ const verify = b64ToBytes(marker.verify);
163
+ if (verify.length <= IV_LEN) return null;
164
+ try {
165
+ const pt = await crypto.subtle.decrypt(
166
+ { name: 'AES-GCM', iv: verify.slice(0, IV_LEN).buffer as ArrayBuffer },
167
+ kek,
168
+ verify.slice(IV_LEN).buffer as ArrayBuffer,
169
+ );
170
+ if (new TextDecoder().decode(pt) !== VERIFY_PLAINTEXT) return null;
171
+ return kek;
172
+ } catch {
173
+ return null; // wrong password
174
+ }
175
+ }
176
+
177
+ // ---------------------------------------------------------------------
178
+ // Magic sniff
179
+ // ---------------------------------------------------------------------
180
+
181
+ /** True when the buffer starts with the 'filexe2e' magic. */
182
+ export function hasMagic(buf: ArrayBuffer | Uint8Array): boolean {
183
+ const b = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
184
+ if (b.length < MAGIC_BYTES.length) return false;
185
+ for (let i = 0; i < MAGIC_BYTES.length; i++) {
186
+ if (b[i] !== MAGIC_BYTES[i]) return false;
187
+ }
188
+ return true;
189
+ }
190
+
191
+ // ---------------------------------------------------------------------
192
+ // File encrypt / decrypt (one-shot MVP)
193
+ // ---------------------------------------------------------------------
194
+
195
+ /**
196
+ * Encrypt `content` under the folder KEK: mints a fresh DEK, encrypts the
197
+ * content one-shot, wraps the DEK with the KEK and prepends the fixed
198
+ * 'filexe2e' header. Throws when content exceeds E2E_MAX_FILE_BYTES.
199
+ */
200
+ export async function encryptFile(kek: CryptoKey, content: ArrayBuffer): Promise<ArrayBuffer> {
201
+ if (content.byteLength > E2E_MAX_FILE_BYTES) {
202
+ throw new Error('e2e: file exceeds the 200MB single-shot limit');
203
+ }
204
+ const rawDek = crypto.getRandomValues(new Uint8Array(32));
205
+ const dek = await crypto.subtle.importKey('raw', rawDek.buffer as ArrayBuffer, { name: 'AES-GCM' }, false, [
206
+ 'encrypt',
207
+ ]);
208
+ const wrapIV = crypto.getRandomValues(new Uint8Array(IV_LEN));
209
+ const dataIV = crypto.getRandomValues(new Uint8Array(IV_LEN));
210
+ const wrappedDek = new Uint8Array(
211
+ await crypto.subtle.encrypt({ name: 'AES-GCM', iv: wrapIV.buffer as ArrayBuffer }, kek, rawDek.buffer as ArrayBuffer),
212
+ );
213
+ const ct = new Uint8Array(
214
+ await crypto.subtle.encrypt({ name: 'AES-GCM', iv: dataIV.buffer as ArrayBuffer }, dek, content),
215
+ );
216
+ // Zero the raw DEK copy as a hygiene measure (best-effort — GC may have
217
+ // other copies, but don't leave the obvious one around).
218
+ rawDek.fill(0);
219
+
220
+ const out = new Uint8Array(HEADER_LEN + ct.length);
221
+ out.set(MAGIC_BYTES, 0);
222
+ out[8] = E2E_VERSION;
223
+ out.set(wrapIV, WRAP_IV_OFF);
224
+ out.set(wrappedDek, WRAPPED_DEK_OFF); // 48 bytes
225
+ out.set(dataIV, DATA_IV_OFF);
226
+ // [81..97) reserved zeros
227
+ out.set(ct, HEADER_LEN);
228
+ return out.buffer;
229
+ }
230
+
231
+ /**
232
+ * Decrypt a 'filexe2e' blob with the folder KEK. Throws E2eDecryptError on
233
+ * a wrong key / tampered data, and a plain Error when the header is not an
234
+ * e2e file at all.
235
+ */
236
+ export async function decryptFile(kek: CryptoKey, data: ArrayBuffer): Promise<ArrayBuffer> {
237
+ const b = new Uint8Array(data);
238
+ if (!hasMagic(b) || b.length < HEADER_LEN) {
239
+ throw new Error('e2e: not an encrypted file');
240
+ }
241
+ if (b[8] !== E2E_VERSION) {
242
+ throw new Error(`e2e: unsupported version ${b[8]}`);
243
+ }
244
+ const wrapIV = b.slice(WRAP_IV_OFF, WRAP_IV_OFF + IV_LEN);
245
+ const wrappedDek = b.slice(WRAPPED_DEK_OFF, WRAPPED_DEK_OFF + WRAPPED_DEK_LEN);
246
+ const dataIV = b.slice(DATA_IV_OFF, DATA_IV_OFF + IV_LEN);
247
+ let rawDek: ArrayBuffer;
248
+ try {
249
+ rawDek = await crypto.subtle.decrypt(
250
+ { name: 'AES-GCM', iv: wrapIV.buffer as ArrayBuffer },
251
+ kek,
252
+ wrappedDek.buffer as ArrayBuffer,
253
+ );
254
+ } catch {
255
+ throw new E2eDecryptError('e2e: DEK unwrap failed (wrong password?)');
256
+ }
257
+ const dek = await crypto.subtle.importKey('raw', rawDek, { name: 'AES-GCM' }, false, ['decrypt']);
258
+ try {
259
+ return await crypto.subtle.decrypt(
260
+ { name: 'AES-GCM', iv: dataIV.buffer as ArrayBuffer },
261
+ dek,
262
+ b.slice(HEADER_LEN).buffer as ArrayBuffer,
263
+ );
264
+ } catch {
265
+ throw new E2eDecryptError('e2e: content decrypt failed');
266
+ }
267
+ }
268
+
269
+ // ---------------------------------------------------------------------
270
+ // In-memory session key ring
271
+ // ---------------------------------------------------------------------
272
+
273
+ /**
274
+ * Tiny per-explorer key ring: encrypted-folder root (wire path) → KEK.
275
+ * Lives ONLY in memory — "Kilitle" drops the entry, a reload drops all.
276
+ */
277
+ export function createKeyRing() {
278
+ const keys = new Map<string, CryptoKey>();
279
+ return {
280
+ get(root: string): CryptoKey | undefined {
281
+ return keys.get(root);
282
+ },
283
+ set(root: string, kek: CryptoKey): void {
284
+ keys.set(root, kek);
285
+ },
286
+ /** Drop one folder's key ("Kilitle"). */
287
+ lock(root: string): void {
288
+ keys.delete(root);
289
+ },
290
+ has(root: string): boolean {
291
+ return keys.has(root);
292
+ },
293
+ clear(): void {
294
+ keys.clear();
295
+ },
296
+ };
297
+ }
298
+
299
+ export type E2eKeyRing = ReturnType<typeof createKeyRing>;
package/src/locales/en.ts CHANGED
@@ -365,4 +365,38 @@ export const en: Record<string, string> = {
365
365
  'inspector.comments.placeholder': 'Write a comment…',
366
366
  'inspector.comments.send': 'Send',
367
367
  'inspector.comments.delete': 'Delete comment',
368
+ /* wiring:e2 — end-to-end encrypted folders */
369
+ 'e2e.create.title': 'Create encrypted folder',
370
+ 'e2e.create.option': 'Create encrypted folder…',
371
+ 'e2e.create.pw_placeholder': 'Folder password (min 8 characters)',
372
+ 'e2e.create.pw2_placeholder': 'Repeat the password',
373
+ 'e2e.create.warn_title': 'NO WAY BACK',
374
+ 'e2e.create.warn_body':
375
+ 'Files open ONLY with this password. The password is NEVER stored on the server; if you forget it the content is lost forever. There is no recovery — not even an admin can open it.',
376
+ 'e2e.create.ack': 'I understand: if I lose the password my data cannot be recovered.',
377
+ 'e2e.create.ack_required': 'Please confirm the warning to continue.',
378
+ 'e2e.create.pw_short': 'Password must be at least 8 characters.',
379
+ 'e2e.create.pw_mismatch': 'Passwords do not match.',
380
+ 'e2e.create.bad_name': 'Invalid folder name.',
381
+ 'e2e.create.create': 'Create encrypted folder',
382
+ 'e2e.create.busy': 'Creating…',
383
+ 'e2e.create.done': 'Encrypted folder created',
384
+ 'e2e.create.failed': 'Could not create the encrypted folder',
385
+ 'e2e.create.nested': 'You cannot create an encrypted folder inside another one.',
386
+ 'e2e.locked.title': 'This folder is end-to-end encrypted',
387
+ 'e2e.locked.hint': 'Enter the folder password to see its contents. The password stays in this tab’s memory only.',
388
+ 'e2e.locked.pw_placeholder': 'Folder password',
389
+ 'e2e.locked.unlock': 'Unlock',
390
+ 'e2e.locked.busy': 'Verifying…',
391
+ 'e2e.unlock.wrong': 'Wrong password.',
392
+ 'e2e.unlock.marker_missing': 'Could not read the key file (.filex-e2e.json).',
393
+ 'e2e.strip.label': 'Encrypted folder — unlocked',
394
+ 'e2e.strip.lock': 'Lock',
395
+ 'e2e.locked_toast': 'Folder locked',
396
+ 'e2e.upload.too_big': 'Files over 200 MB cannot be uploaded to an encrypted folder (MVP limit)',
397
+ 'e2e.upload.locked': 'Unlock the folder first',
398
+ 'e2e.decrypting': 'Decrypting…',
399
+ 'e2e.decrypt_failed': 'Could not decrypt the file (password changed or file corrupted).',
400
+ 'e2e.download.failed': 'Could not download the encrypted file.',
401
+ /* /wiring:e2 */
368
402
  };
package/src/locales/tr.ts CHANGED
@@ -365,4 +365,38 @@ export const tr: Record<string, string> = {
365
365
  'inspector.comments.placeholder': 'Yorum yaz…',
366
366
  'inspector.comments.send': 'Gönder',
367
367
  'inspector.comments.delete': 'Yorumu sil',
368
+ /* wiring:e2 — uçtan uca şifreli klasörler */
369
+ 'e2e.create.title': 'Şifreli klasör oluştur',
370
+ 'e2e.create.option': 'Şifreli klasör oluştur…',
371
+ 'e2e.create.pw_placeholder': 'Klasör parolası (en az 8 karakter)',
372
+ 'e2e.create.pw2_placeholder': 'Parolayı tekrar girin',
373
+ 'e2e.create.warn_title': 'GERİ DÖNÜŞÜ YOK',
374
+ 'e2e.create.warn_body':
375
+ 'Dosyalar yalnız bu parolayla açılır. Parola sunucuda TUTULMAZ; unutursanız içerik sonsuza dek kaybolur. Kurtarma yolu yoktur — yönetici dahil kimse açamaz.',
376
+ 'e2e.create.ack': 'Anladım: parolayı kaybedersem verilerim kurtarılamaz.',
377
+ 'e2e.create.ack_required': 'Devam etmek için uyarıyı onaylayın.',
378
+ 'e2e.create.pw_short': 'Parola en az 8 karakter olmalı.',
379
+ 'e2e.create.pw_mismatch': 'Parolalar birbirini tutmuyor.',
380
+ 'e2e.create.bad_name': 'Geçersiz klasör adı.',
381
+ 'e2e.create.create': 'Şifreli klasörü oluştur',
382
+ 'e2e.create.busy': 'Oluşturuluyor…',
383
+ 'e2e.create.done': 'Şifreli klasör oluşturuldu',
384
+ 'e2e.create.failed': 'Şifreli klasör oluşturulamadı',
385
+ 'e2e.create.nested': 'Şifreli klasörün içine ikinci bir şifreli klasör açılamaz.',
386
+ 'e2e.locked.title': 'Bu klasör uçtan uca şifreli',
387
+ 'e2e.locked.hint': 'İçeriği görmek için klasör parolasını girin. Parola yalnız bu sekmede, bellekte tutulur.',
388
+ 'e2e.locked.pw_placeholder': 'Klasör parolası',
389
+ 'e2e.locked.unlock': 'Kilidi aç',
390
+ 'e2e.locked.busy': 'Doğrulanıyor…',
391
+ 'e2e.unlock.wrong': 'Parola yanlış.',
392
+ 'e2e.unlock.marker_missing': 'Şifre dosyası (.filex-e2e.json) okunamadı.',
393
+ 'e2e.strip.label': 'Şifreli klasör — kilit açık',
394
+ 'e2e.strip.lock': 'Kilitle',
395
+ 'e2e.locked_toast': 'Klasör kilitlendi',
396
+ 'e2e.upload.too_big': '200 MB üstü dosyalar şifreli klasöre yüklenemez (MVP sınırı)',
397
+ 'e2e.upload.locked': 'Önce klasörün kilidini açın',
398
+ 'e2e.decrypting': 'Çözülüyor…',
399
+ 'e2e.decrypt_failed': 'Dosya çözülemedi (parola değişmiş ya da dosya bozulmuş olabilir).',
400
+ 'e2e.download.failed': 'Şifreli dosya indirilemedi.',
401
+ /* /wiring:e2 */
368
402
  };
@@ -7,11 +7,17 @@ import Modal from './Modal.vue';
7
7
  const props = defineProps<{
8
8
  open: boolean;
9
9
  locale: LocaleCode;
10
+ /* wiring:e2 — hosts hide the encrypted option (e.g. already inside an
11
+ * encrypted folder / feature-gated embeds). Default: shown. */
12
+ encryptedOption?: boolean;
10
13
  }>();
11
14
 
12
15
  const emit = defineEmits<{
13
16
  (e: 'close'): void;
14
17
  (e: 'submit', name: string): void;
18
+ /* wiring:e2 — user picked "Create encrypted folder…" — the parent swaps
19
+ * this modal for EncryptedFolderModal. */
20
+ (e: 'encrypted'): void;
15
21
  }>();
16
22
 
17
23
  const { t } = useLocale(() => props.locale);
@@ -51,6 +57,18 @@ function submit() {
51
57
  @keydown.enter.prevent="submit"
52
58
  />
53
59
  <p v-if="err" class="fe-form__error">{{ err }}</p>
60
+ <!-- wiring:e2 — encrypted-folder entry point lives inside the normal
61
+ new-folder flow so every trigger (toolbar / context menu / palette)
62
+ reaches it without extra wiring. -->
63
+ <button
64
+ v-if="encryptedOption !== false"
65
+ type="button"
66
+ class="fe-e2e-optlink"
67
+ @click="emit('encrypted')"
68
+ >
69
+ 🔒 {{ t('e2e.create.option') }}
70
+ </button>
71
+ <!-- /wiring:e2 -->
54
72
  </form>
55
73
  <template #actions>
56
74
  <button type="button" class="fe-btn" @click="emit('close')">
@@ -46,6 +46,11 @@ const props = defineProps<{
46
46
  pdfSaveUrl?: string | null;
47
47
  /** Standalone full-screen viewer route — `?path=…&storage=…&type=…`. */
48
48
  viewerBaseUrl?: string | null;
49
+ /* wiring:e2 — false hides the "Düzenle" / "Yeni Sekmede Aç" buttons.
50
+ * The standalone route fetches RAW server bytes, which inside an
51
+ * E2E-encrypted folder is ciphertext (and its save path could clobber
52
+ * the encrypted blob) — the host disables the escape hatches there. */
53
+ newTabEnabled?: boolean;
49
54
  /** Drop the dialog chrome (backdrop tint, header bar, footer actions)
50
55
  * so the viewer fills the full viewport. Used by the standalone
51
56
  * /files/edit route where the browser tab IS the container — a
@@ -1020,13 +1025,13 @@ function loadOnlyOfficeScript(base: string): Promise<void> {
1020
1025
  </div>
1021
1026
  <template #actions>
1022
1027
  <button
1023
- v-if="file && openMode === 'view' && canEditKind"
1028
+ v-if="file && openMode === 'view' && canEditKind && newTabEnabled !== false /* wiring:e2 */"
1024
1029
  type="button"
1025
1030
  class="fe-btn fe-btn--primary"
1026
1031
  @click="openEditInNewTab"
1027
1032
  >✏ {{ t('viewer.edit') }}</button>
1028
1033
  <button
1029
- v-if="file"
1034
+ v-if="file && newTabEnabled !== false /* wiring:e2 */"
1030
1035
  type="button"
1031
1036
  class="fe-btn"
1032
1037
  @click="openInNewTab"
@@ -4,6 +4,7 @@
4
4
  position: relative;
5
5
  display: flex;
6
6
  flex-direction: column;
7
+ box-sizing: border-box;
7
8
  min-height: 420px;
8
9
  height: 100%;
9
10
  background: var(--fe-bg);
@@ -3099,7 +3100,12 @@ filex-explorer {
3099
3100
  flex: 1 1 auto;
3100
3101
  min-width: 0;
3101
3102
  overflow-x: auto;
3102
- scrollbar-width: thin;
3103
+ /* Tab overflow scrolls (wheel / drag / keyboard) but never shows a
3104
+ * scrollbar strip under the tabs — the 30px bar cannot fit one. */
3105
+ scrollbar-width: none;
3106
+ }
3107
+ .fe-tabs__scroll::-webkit-scrollbar {
3108
+ display: none;
3103
3109
  }
3104
3110
  .fe-tabs__tab {
3105
3111
  display: flex;
@@ -3615,3 +3621,122 @@ filex-explorer {
3615
3621
  opacity: 0.6;
3616
3622
  }
3617
3623
  /* === /wiring:d3 === */
3624
+
3625
+ /* === wiring:e2 — uçtan uca şifreli klasörler === */
3626
+ /* Kilit açık şeridi (presence şeridiyle aynı yatay dil). */
3627
+ .fe-e2e-strip {
3628
+ display: flex;
3629
+ align-items: center;
3630
+ gap: 8px;
3631
+ padding: 4px 12px;
3632
+ font-size: 12.5px;
3633
+ color: var(--fe-text-muted);
3634
+ border-bottom: 1px solid var(--fe-border);
3635
+ background: var(--fe-bg-elev);
3636
+ }
3637
+ .fe-e2e-strip__icon {
3638
+ font-size: 13px;
3639
+ line-height: 1;
3640
+ }
3641
+ .fe-e2e-strip__label {
3642
+ flex: 1;
3643
+ overflow: hidden;
3644
+ text-overflow: ellipsis;
3645
+ white-space: nowrap;
3646
+ }
3647
+ .fe-e2e-strip__btn {
3648
+ padding: 2px 10px;
3649
+ font-size: 12px;
3650
+ }
3651
+
3652
+ /* Kilit ekranı: fe-state dilinin üstüne parola formu. */
3653
+ .fe-e2e-lock__form {
3654
+ display: flex;
3655
+ gap: 8px;
3656
+ align-items: stretch;
3657
+ margin-top: 12px;
3658
+ width: min(340px, 100%);
3659
+ }
3660
+ .fe-e2e-lock__input {
3661
+ flex: 1;
3662
+ min-width: 0;
3663
+ }
3664
+
3665
+ /* Şifreli klasör oluşturma modalı. */
3666
+ .fe-e2e-form {
3667
+ display: flex;
3668
+ flex-direction: column;
3669
+ gap: 10px;
3670
+ }
3671
+ .fe-e2e-warn {
3672
+ border: 1px solid var(--fe-danger);
3673
+ border-radius: var(--fe-radius-sm);
3674
+ padding: 8px 10px;
3675
+ font-size: 12.5px;
3676
+ color: var(--fe-text);
3677
+ background: color-mix(in srgb, var(--fe-danger) 8%, transparent);
3678
+ }
3679
+ .fe-e2e-warn strong {
3680
+ display: block;
3681
+ color: var(--fe-danger);
3682
+ margin-bottom: 2px;
3683
+ }
3684
+ .fe-e2e-warn p {
3685
+ margin: 0;
3686
+ color: var(--fe-text-muted);
3687
+ }
3688
+ .fe-e2e-ack {
3689
+ display: flex;
3690
+ align-items: flex-start;
3691
+ gap: 8px;
3692
+ font-size: 12.5px;
3693
+ color: var(--fe-text);
3694
+ cursor: pointer;
3695
+ }
3696
+ .fe-e2e-ack input {
3697
+ margin-top: 2px;
3698
+ accent-color: var(--fe-primary);
3699
+ }
3700
+
3701
+ /* NewFolderModal içindeki "şifreli klasör" giriş linki. */
3702
+ .fe-e2e-optlink {
3703
+ margin-top: 10px;
3704
+ padding: 0;
3705
+ border: none;
3706
+ background: none;
3707
+ font: inherit;
3708
+ font-size: 12.5px;
3709
+ color: var(--fe-primary);
3710
+ cursor: pointer;
3711
+ text-align: left;
3712
+ }
3713
+ .fe-e2e-optlink:hover {
3714
+ text-decoration: underline;
3715
+ }
3716
+ /* === /wiring:e2 === */
3717
+
3718
+ /* === ui-fix — toolbar tek satır + katlama + ölçüm şeridi ============= */
3719
+ .fe:not(.fe--narrow) .fe-toolbar {
3720
+ flex-wrap: nowrap;
3721
+ }
3722
+ .fe:not(.fe--narrow) .fe-toolbar__primary {
3723
+ flex: 1 1 auto;
3724
+ min-width: 0;
3725
+ flex-wrap: nowrap;
3726
+ overflow: hidden;
3727
+ position: relative;
3728
+ }
3729
+ .fe-toolbar__measure {
3730
+ position: absolute;
3731
+ visibility: hidden;
3732
+ pointer-events: none;
3733
+ height: 0;
3734
+ overflow: hidden;
3735
+ display: flex;
3736
+ gap: 8px;
3737
+ white-space: nowrap;
3738
+ }
3739
+ .fe-toolbar__measure .fe-btn {
3740
+ flex: none;
3741
+ }
3742
+ /* === /ui-fix ========================================================= */
@@ -13,7 +13,7 @@
13
13
  --fe-text: #1a1e27;
14
14
  --fe-text-muted: #5a6475;
15
15
  --fe-text-on-primary: #ffffff;
16
- --fe-primary: #3b82f6;
16
+ --fe-primary: #2f6fe0; /* ui-fix: beyaz metinle WCAG AA (eski #3b82f6 = 3.68) */
17
17
  --fe-primary-hover: #2563eb;
18
18
  --fe-danger: #dc2626;
19
19
  --fe-danger-hover: #b91c1c;
@@ -102,7 +102,7 @@
102
102
  --fe-icon-archive: #78716c;
103
103
  --fe-icon-code: #4f46e5;
104
104
  --fe-icon-text: #64748b;
105
- --fe-icon-unknown: #94a3b8;
105
+ --fe-icon-unknown: #64748b; /* ui-fix: acik temada 3:1 UI kontrasti */
106
106
  }
107
107
 
108
108
  /* Dark values — same selector strategy as the palette blocks above. */
@@ -42,6 +42,9 @@ export interface FileNode {
42
42
  * projectFileNodes emits it when a storage has RBAC on). '' / undefined =
43
43
  * ACL not enforced. Used to gate edit/manage affordances client-side. */
44
44
  perm?: 'none' | 'viewer' | 'editor' | 'owner';
45
+ /* wiring:e2 — dir rows: true when the folder is E2E-encrypted (carries a
46
+ * `.filex-e2e.json` marker). Drives the 🔒 badge in the listings. */
47
+ e2e?: boolean;
45
48
  /** Generic — any additional fields the backend wants to inline. */
46
49
  [k: string]: unknown;
47
50
  }