@brftech/filex-core 0.30.1 → 0.31.0

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.
@@ -86,16 +86,27 @@ import { useTabs, type TabState } from './composables/useTabs';
86
86
  /* /wiring:d1 */
87
87
  /* wiring:e2 — uçtan uca şifreli klasörler (docs/E2E-ENCRYPTION.md) */
88
88
  import EncryptedFolderModal from './components/EncryptedFolderModal.vue';
89
+ import RecoveryKeyModal from './components/RecoveryKeyModal.vue';
90
+ import E2eRecoveryUnlockModal from './components/E2eRecoveryUnlockModal.vue';
89
91
  import {
90
92
  createKeyRing,
91
- createMarker,
93
+ createEncryptedFolder,
94
+ upgradeMarkerV1,
92
95
  parseMarker,
93
- verifyPassword,
96
+ unlockWithPassword,
97
+ unlockWithRecoveryKey,
98
+ unlockWithEscrowKey,
99
+ importEscrowPrivateKey,
100
+ markerHasRecovery,
101
+ markerHasEscrow,
102
+ bytesToB64,
103
+ b64ToBytes,
94
104
  encryptFile,
95
105
  decryptFile,
96
106
  hasMagic,
97
107
  E2E_MARKER_NAME,
98
108
  E2E_MAX_FILE_BYTES,
109
+ type E2eMarker,
99
110
  } from './lib/e2ecrypto';
100
111
  /* /wiring:e2 */
101
112
 
@@ -104,12 +115,17 @@ import {
104
115
  panes or split view shows mismatched rows). */
105
116
  import {
106
117
  filterListing,
107
- virtualSegmentKey,
118
+ virtualSegmentLabel,
119
+ makeTagSegment,
120
+ tagOfPath,
121
+ VIRTUAL_SEGMENTS,
108
122
  showHiddenFiles,
109
123
  setShowHiddenFiles,
110
124
  injectTrashRow,
111
125
  hydrateTrashRow as hydrateTrashRowShared,
112
126
  } from './lib/listing';
127
+ import { setNodeStarred } from './lib/star';
128
+ import { fetchAllTags, fetchTaggedRows, invalidateTagCache } from './lib/tags';
113
129
  import { resolveTransfer, type TransferIntent } from './lib/transfer';
114
130
  import {
115
131
  activeNativeDrag,
@@ -310,17 +326,38 @@ const trashActive = computed(() => trashMode.value);
310
326
  * pattern is trashMode's, generalised — including the part that matters most,
311
327
  * that load() clears the mode, or the view sticks and every later navigation
312
328
  * renders under the wrong heading. */
313
- type NavView = '' | 'recent' | 'starred' | 'shared' | 'trash';
329
+ type NavView = '' | 'recent' | 'starred' | 'shared' | 'trash' | 'tag';
314
330
  const navView = ref<NavView>('');
315
331
  /** Where the view was entered from, so "up" goes back there. */
316
332
  const navViewOrigin = ref<string>('');
317
- /** Sentinel parked in `dirname` so the breadcrumb can label the view. */
318
- const NAV_VIEW_DIRNAME: Record<Exclude<NavView, '' | 'trash'>, string> = {
333
+ /** The tag being browsed while navView === 'tag' ('' otherwise). */
334
+ const navTag = ref<string>('');
335
+ /** Sentinel parked in `dirname` so the breadcrumb can label the view. The tag
336
+ * view's sentinel is built per tag (`makeTagSegment`) — see lib/listing. */
337
+ const NAV_VIEW_DIRNAME: Record<Exclude<NavView, '' | 'trash' | 'tag'>, string> = {
319
338
  recent: '.recent',
320
339
  starred: '.starred',
321
340
  shared: '.shared',
322
341
  };
323
342
 
343
+ /**
344
+ * A path that is a virtual view rather than a folder. Used by load() so a
345
+ * sentinel reaching it — a restored tab, a pasted `#.tag~invoices`, a reload,
346
+ * the breadcrumb's own crumb — opens the VIEW instead of asking the backend
347
+ * for a folder called `.starred` and landing on "not found". (That was already
348
+ * true of the four shipped views; the tag view would have inherited it.)
349
+ */
350
+ function virtualViewOf(path: string): { kind: Exclude<NavView, ''>; tag: string } | null {
351
+ const clean = String(path ?? '').replace(/^\/+|\/+$/g, '');
352
+ if (!clean) return null;
353
+ const tag = tagOfPath(clean);
354
+ if (tag) return { kind: 'tag', tag };
355
+ const key = VIRTUAL_SEGMENTS[clean];
356
+ if (!key) return null;
357
+ const kind = clean.slice(1) as Exclude<NavView, '' | 'tag'>;
358
+ return { kind, tag: '' };
359
+ }
360
+
324
361
  // When the caller can see exactly ONE storage, the multi-storage root is a
325
362
  // one-row list that carries no information — the user clicks through it every
326
363
  // single time. Treat that storage as the floor instead: open it directly and
@@ -448,6 +485,72 @@ function onStarChange(n: FileNode, value: boolean) {
448
485
  starredIds.value = next;
449
486
  }
450
487
 
488
+ /* === yildiz:s1 — starring as an ACTION ================================
489
+ * The star shipped as an indicator in ONE view: `StarButton` was rendered by
490
+ * ListView and nowhere else, so a user in grid view (the mode the navigation
491
+ * panel's own screenshots show) had a Starred view with no way to fill it.
492
+ * It is a verb, like tagging — so it is a menu entry beside Tags, a chip on
493
+ * every card, and a key.
494
+ *
495
+ * ⚠ ONE implementation of the request: `lib/star.ts`. StarButton calls it,
496
+ * this calls it. A menu cannot render a component, but it must not grow its
497
+ * own fetch either — that is the second path that drifts.
498
+ */
499
+ /** Which of `targets` can carry a star: files the server knows by id. */
500
+ function starableNodes(targets: FileNode[]): FileNode[] {
501
+ return targets.filter((n) => typeof n.id === 'number' && n.type === 'file');
502
+ }
503
+
504
+ /** True when EVERY starable target is starred — i.e. the action reads
505
+ * "Unstar". A mixed selection reads "Star" and stars the rest, which is the
506
+ * behaviour that needs no explanation. */
507
+ function selectionAllStarred(targets: FileNode[]): boolean {
508
+ const list = starableNodes(targets);
509
+ return list.length > 0 && list.every((n) => starredIds.value.has(n.id as number));
510
+ }
511
+
512
+ /**
513
+ * Toggle the star on a selection. Optimistic like the button, and rolled back
514
+ * per node on failure — a partial failure must not leave the set lying about
515
+ * what the server holds.
516
+ */
517
+ async function toggleStar(targets: FileNode[]) {
518
+ const list = starableNodes(targets);
519
+ if (list.length === 0) return;
520
+ const next = !selectionAllStarred(list);
521
+ const opts = {
522
+ apiBase: props.config.apiBase ?? '',
523
+ authHeaders: () => buildAuthHeaders(),
524
+ authCredentials: api.credentialsMode(),
525
+ };
526
+ const set = new Set(starredIds.value);
527
+ for (const n of list) {
528
+ if (next) set.add(n.id as number);
529
+ else set.delete(n.id as number);
530
+ }
531
+ starredIds.value = set;
532
+ let failed = 0;
533
+ await Promise.all(
534
+ list.map(async (n) => {
535
+ try {
536
+ await setNodeStarred(n.id as number, next, opts);
537
+ } catch {
538
+ failed += 1;
539
+ const rollback = new Set(starredIds.value);
540
+ if (next) rollback.delete(n.id as number);
541
+ else rollback.add(n.id as number);
542
+ starredIds.value = rollback;
543
+ }
544
+ }),
545
+ );
546
+ if (failed > 0) flashToast(t('star.failed'));
547
+ // Starring is what fills the Starred view; if that IS the view on screen,
548
+ // an unstar has to remove the row instead of leaving a listing that
549
+ // disagrees with its own heading.
550
+ if (navView.value === 'starred') await loadNavView('starred');
551
+ }
552
+ /* === /yildiz:s1 === */
553
+
451
554
  async function markRecent(n: FileNode) {
452
555
  if (typeof n.id !== 'number') return;
453
556
  try {
@@ -470,6 +573,17 @@ function openTagPickerFor(n: FileNode) {
470
573
  showTagPicker.value = true;
471
574
  }
472
575
 
576
+ /* etiket:t1 — the user just changed a node's tags, so the cached "every tag
577
+ * that exists" list is wrong RIGHT NOW, which is the only staleness anybody
578
+ * notices. Drop it and re-ask; if a tag view is on screen, refresh it too —
579
+ * removing a file's tag has to remove it from the listing that is named after
580
+ * that tag. */
581
+ function onNodeTagsChanged() {
582
+ invalidateTagCache();
583
+ void loadNavTags(true);
584
+ if (navView.value === 'tag' && navTag.value) void loadTagView(navTag.value);
585
+ }
586
+
473
587
  function onRecentOpen(entry: { id: number; storage_id?: number; path: string; name: string }) {
474
588
  // RecentlyOpened emits the bare row — synthesize a FileNode shaped
475
589
  // enough for openNode to route into the editor / preview.
@@ -659,6 +773,32 @@ const sharedStorageNames = ref<string[]>([]);
659
773
  * storage form), and /api/tokens caps every scope against the caller's own role.
660
774
  */
661
775
  const connectionsEnabled = computed(() => props.config.connections ?? !simpleUi.value);
776
+
777
+ /**
778
+ * Is this caller an integration rather than a person (backend migration 00030)?
779
+ *
780
+ * A filex API token authenticates AS its owner, so from here a shared embed
781
+ * token and somebody's own token are indistinguishable — only the server knows
782
+ * which kind it is, and it says so in `capabilities.caller_kind`. A host that
783
+ * already knows can say so with `config.callerKind`, which wins — purely to
784
+ * spare the flash of a Starred row that appears and then disappears when
785
+ * capabilities land.
786
+ *
787
+ * ⚠ Defaults to "person" in every unknown state — no config, capabilities not
788
+ * back yet, or a server too old to answer. The cost of guessing wrong that way
789
+ * is one row too many for a moment; guessing the other way would hide Recent
790
+ * and API keys from every ordinary user of every older server.
791
+ */
792
+ const callerIsApp = computed(
793
+ () => (props.config.callerKind ?? capabilitiesData.value?.caller_kind) === 'app',
794
+ );
795
+ /**
796
+ * The identity-bearing surfaces: API keys, Recent, Starred, Shared with me.
797
+ * ⚠ Suppression is per token KIND, never per role — a viewer is still a person
798
+ * with their own recents. And it is not the whole panel: Upload, the storages,
799
+ * Trash and "How to connect" stay useful inside an embed.
800
+ */
801
+ const identitySurfaces = computed(() => !callerIsApp.value);
662
802
  const showConnections = ref(false);
663
803
  const showTokens = ref(false);
664
804
  function openConnections() {
@@ -666,6 +806,10 @@ function openConnections() {
666
806
  showConnections.value = true;
667
807
  }
668
808
  function openTokens() {
809
+ // Belt and braces: the panel entry is gone for an app token, but a host may
810
+ // also open this overlay from its own chrome, and /api/tokens would answer
811
+ // that caller with a 403 it has nowhere to show.
812
+ if (callerIsApp.value) return;
669
813
  showConnections.value = false;
670
814
  showTokens.value = true;
671
815
  }
@@ -776,17 +920,27 @@ async function fetchNavRows(kind: 'recent' | 'starred' | 'shared'): Promise<File
776
920
  /** Open one of the panel views in the main pane. */
777
921
  async function loadNavView(kind: Exclude<NavView, ''>) {
778
922
  closeNavDrawer();
923
+ if (kind === 'tag') {
924
+ // The tag view needs a name; the panel calls loadTagView directly.
925
+ if (navTag.value) await loadTagView(navTag.value);
926
+ return;
927
+ }
779
928
  if (kind === 'trash') {
780
929
  await loadTrash();
781
930
  // ⚠ After loadTrash, not before: loadTrash goes through load()-adjacent
782
931
  // state and the mode has to be the last word, or the panel row for Trash
783
932
  // never lights up.
784
933
  navView.value = 'trash';
934
+ navTag.value = '';
785
935
  return;
786
936
  }
787
937
  loading.value = true;
788
- navViewOrigin.value = currentPath.value ?? '';
938
+ // Only when coming from a real folder. Stepping Starred → Recent used to
939
+ // record `.starred` as the origin, so "up" out of Recent landed in Starred
940
+ // and the user had to press it twice to get back to their files.
941
+ if (!navView.value) navViewOrigin.value = currentPath.value ?? '';
789
942
  navView.value = kind;
943
+ navTag.value = '';
790
944
  trashMode.value = false;
791
945
  e2eRoot.value = '';
792
946
  selection.clear();
@@ -808,6 +962,78 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
808
962
  }
809
963
  }
810
964
 
965
+ /* === etiket:t1 — the tag view ==========================================
966
+ * "Tagged files should show up inside the tag." A tag is not a folder: its
967
+ * files live all over the tree and in every storage, so this is the same
968
+ * shape as Starred — a per-user endpoint answering with node rows, each
969
+ * carrying its own qualified path, so opening one navigates normally.
970
+ *
971
+ * The sentinel is `.tag~<name>` (lib/listing). Every surface that renders a
972
+ * path segment — tab strip, breadcrumb, inspector heading, the address-bar
973
+ * hash — goes through `virtualSegmentLabel`, so none of them can print the
974
+ * sentinel the way the strip once printed `.shared`.
975
+ */
976
+ async function loadTagView(tag: string) {
977
+ closeNavDrawer();
978
+ const name = String(tag ?? '').trim();
979
+ if (!name) return;
980
+ loading.value = true;
981
+ if (!navView.value) navViewOrigin.value = currentPath.value ?? '';
982
+ navView.value = 'tag';
983
+ navTag.value = name;
984
+ trashMode.value = false;
985
+ e2eRoot.value = '';
986
+ selection.clear();
987
+ try {
988
+ const rows = await fetchTaggedRows(name, {
989
+ apiBase: props.config.apiBase ?? '',
990
+ authHeaders: () => buildAuthHeaders(),
991
+ authCredentials: api.credentialsMode(),
992
+ });
993
+ files.value = rows.map(nodeRowToFileNode).filter((n): n is FileNode => n !== null);
994
+ const seg = makeTagSegment(name);
995
+ dirname.value = seg;
996
+ currentPath.value = seg;
997
+ // Spans every storage, like Starred/Recent/Shared — so no storage crumb.
998
+ adapter.value = '';
999
+ } catch (err) {
1000
+ const msg = err instanceof Error ? err.message : String(err);
1001
+ files.value = [];
1002
+ emit('error', { message: msg, context: { op: `nav-view:tag:${name}` } });
1003
+ flashToast(msg);
1004
+ } finally {
1005
+ loading.value = false;
1006
+ }
1007
+ }
1008
+
1009
+ /**
1010
+ * The tags that exist, for the panel's Tags section.
1011
+ *
1012
+ * ⚠ WHEN this loads was a deliberate decision, not a default: `tags/all` is a
1013
+ * distinct-scan and the panel renders in every mounted explorer (a page can
1014
+ * hold several). It is therefore NOT fetched during mount — it is asked for
1015
+ * once the first listing is on screen, through a module-level cache that
1016
+ * dedupes concurrent callers and reuses the answer for a minute
1017
+ * (lib/tags.ts). N explorers on a page cost ONE query; a navigation costs
1018
+ * none. The cache is dropped the instant the user edits tags, which is the
1019
+ * only staleness anybody can notice.
1020
+ */
1021
+ const navTags = ref<string[]>([]);
1022
+ const navTagsLoaded = ref(false);
1023
+
1024
+ async function loadNavTags(force = false) {
1025
+ if (!navVisible.value) return; // no panel → nobody can see the list
1026
+ navTags.value = await fetchAllTags({
1027
+ apiBase: props.config.apiBase ?? '',
1028
+ authHeaders: () => buildAuthHeaders(),
1029
+ authCredentials: api.credentialsMode(),
1030
+ force,
1031
+ });
1032
+ navTagsLoaded.value = true;
1033
+ }
1034
+
1035
+ /* === /etiket:t1 === */
1036
+
811
1037
  /** Panel to a storage root. */
812
1038
  function openNavStorage(name: string) {
813
1039
  closeNavDrawer();
@@ -840,7 +1066,11 @@ const inspectorDirLabel = computed(() => {
840
1066
  if (trashMode.value) return t('node.trash');
841
1067
  const p = (currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
842
1068
  if (!p) return adapter.value || t('breadcrumb.root');
843
- return p.split('/').pop() || p;
1069
+ const seg = p.split('/').pop() || p;
1070
+ /* etiket:t1 — a THIRD surface that renders a path segment, and it had the
1071
+ same hole the tab strip did: in a virtual view the details panel headed
1072
+ itself ".starred". Same shared resolver, so it cannot drift again. */
1073
+ return virtualSegmentLabel(seg, t) || seg;
844
1074
  });
845
1075
  function onInspectorManage(n: FileNode) {
846
1076
  permTarget.value = n;
@@ -1025,6 +1255,48 @@ function toggleHiddenFiles() {
1025
1255
  }
1026
1256
 
1027
1257
  async function load(path?: string) {
1258
+ /* === etiket:t1 — a sentinel is a VIEW, not a folder ===================
1259
+ * A restored tab, a reload on `#.trash` / `#.starred` / `#.tag~invoices`,
1260
+ * or the breadcrumb crumb for the view you are standing in all arrive here
1261
+ * as a plain path. Without this they went to the backend as a FOLDER NAME
1262
+ * and came back 404, so a view that exists and is merely empty greeted the
1263
+ * user with "Folder not found — this folder does not exist, was moved, or
1264
+ * you do not have access to it" (measured on `#.trash` and `#.starred`,
1265
+ * v0.30.1). The trash is not missing; it is empty, and it has a state that
1266
+ * says so.
1267
+ *
1268
+ * ⚠ Through `virtualViewOf` → the ONE map in lib/listing.ts, never a second
1269
+ * list of names here: two copies of that mapping are what printed `.shared`
1270
+ * in the tab strip two days ago, and the tag view adds a dynamic third kind.
1271
+ *
1272
+ * ⚠ Only a sentinel this build KNOWS is intercepted. Anything else keeps
1273
+ * going — a user may genuinely own a folder called `.config`, and with
1274
+ * hidden files shown they can open it.
1275
+ *
1276
+ * ⚠ And only when the view is actually REACHABLE here. Under `rootPath` the
1277
+ * panel is off on purpose (the views span storages and would list files
1278
+ * outside the folder the embed was confined to), so a stale hash from
1279
+ * another deployment must not smuggle them in: it falls back to the root —
1280
+ * which the floor clamp below then turns into the confined folder.
1281
+ *
1282
+ * ⚠ No recursion: neither loader calls load(), and the fallback passes '',
1283
+ * which is not a sentinel.
1284
+ */
1285
+ const asView = virtualViewOf(path ?? currentPath.value ?? '');
1286
+ if (asView) {
1287
+ const reachable =
1288
+ asView.kind === 'trash' ? props.config.trashVisible !== false : sideNavEnabled.value;
1289
+ if (!reachable) {
1290
+ // Clear it explicitly: if the fallback lands on the path we are already
1291
+ // on, watch(currentPath) never fires and the dead hash would survive to
1292
+ // the next reload (the same trap leaveNotFound documents).
1293
+ writePersistedPath('');
1294
+ return await load('');
1295
+ }
1296
+ if (asView.kind === 'tag') await loadTagView(asView.tag);
1297
+ else await loadNavView(asView.kind);
1298
+ return;
1299
+ }
1028
1300
  loading.value = true;
1029
1301
  // Any normal navigation exits trash mode (the trash view is entered only
1030
1302
  // by opening the virtual `.trash` row, which calls loadTrash()).
@@ -1033,6 +1305,7 @@ async function load(path?: string) {
1033
1305
  this the mode sticks and the breadcrumb keeps saying "Starred" over a
1034
1306
  folder listing. */
1035
1307
  navView.value = '';
1308
+ navTag.value = '';
1036
1309
  let requested = path ?? currentPath.value ?? '';
1037
1310
  try {
1038
1311
  notFoundPath.value = '';
@@ -1376,6 +1649,25 @@ onMounted(async () => {
1376
1649
  /* gezinti:g1 — which storages are grant-only, so the panel can mark them
1377
1650
  before anybody opens the shared view. */
1378
1651
  void loadSharedStorages();
1652
+ /* etiket:t1 — the panel's tag list. AFTER the first listing has been
1653
+ awaited above, never racing it: `tags/all` is a distinct-scan and the
1654
+ folder the user asked for is the only thing on the critical path. The
1655
+ module-level cache in lib/tags.ts means several explorers on one page
1656
+ still cost a single query. */
1657
+ void loadNavTags();
1658
+ /* ⚠ The panel is not always on screen at mount. Below 560px it is a DRAWER
1659
+ that starts closed, so `navVisible` is false and the call above returns
1660
+ without asking for anything — measured at 390px: the drawer opened with
1661
+ no Tags section at all. Ask again the first time the panel appears.
1662
+ ⚠ Registered HERE and not beside loadNavTags: `watch` evaluates its
1663
+ source immediately, `navVisible` reads `isNarrow`, and `isNarrow` is
1664
+ declared further down the file — so a watcher created at setup time threw
1665
+ "Cannot access 'isNarrow' before initialization" and took the whole
1666
+ explorer down with it (measured: blank pane, two TDZ errors in the
1667
+ console). In onMounted every ref exists. */
1668
+ watch(navVisible, (visible) => {
1669
+ if (visible && !navTagsLoaded.value) void loadNavTags();
1670
+ });
1379
1671
  if (hashPersistEnabled()) {
1380
1672
  window.addEventListener('hashchange', onHashChange);
1381
1673
  }
@@ -1522,6 +1814,7 @@ useKeyboardShortcuts(rootEl, {
1522
1814
  /* /cila:c wiring */
1523
1815
  onQuickLook: () => quickLookToggle() /* wiring:c2 */,
1524
1816
  onToggleHidden: () => toggleHiddenFiles(),
1817
+ onStar: () => void toggleStar(selection.nodes.value) /* yildiz:s1 */,
1525
1818
  onToggleInspector: () => toggleInspector() /* koru:k1 */,
1526
1819
  /* wiring:d1 — sekme aksiyonları (registry: tab-new/close/next/prev) */
1527
1820
  onTabNew: () => newTabHere(),
@@ -2033,6 +2326,9 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
2033
2326
  const isFile = single && sel[0]?.type === 'file';
2034
2327
  const tagsLabel = locale.value === 'en' ? 'Tags…' : 'Etiketler…';
2035
2328
  const singleHasId = single && typeof sel[0]?.id === 'number';
2329
+ /* yildiz:s1 */
2330
+ const canStar = starableNodes(sel).length > 0;
2331
+ const allStarred = selectionAllStarred(sel);
2036
2332
  const copyIdLabel = locale.value === 'en' ? 'Copy node id' : "Node id'yi kopyala";
2037
2333
  // RBAC: gate mutating actions when the caller lacks edit on the target. The
2038
2334
  // "İzinler" (permissions) action shows only for owners on RBAC-on storages.
@@ -2058,7 +2354,17 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
2058
2354
  { key: 'cut', label: t('ctx.cut'), icon: '✂', hidden: !any || !w, disabled: !any },
2059
2355
  { key: 'copy', label: t('ctx.copy'), icon: '❐', hidden: !any, disabled: !any },
2060
2356
  { key: 'paste', label: t('ctx.paste'), icon: '📋', hidden: !w, disabled: !clipboard.value.mode },
2061
- { divider: true, key: 'sep-meta', label: '', hidden: !singleHasId },
2357
+ { divider: true, key: 'sep-meta', label: '', hidden: !singleHasId && !canStar },
2358
+ /* yildiz:s1 — "star must be an action, like a tag" (owner, v0.30.0).
2359
+ Beside Tags on purpose: they are the same kind of verb, and this is the
2360
+ ONLY star a grid/gallery user reaches with the keyboard. Works on a
2361
+ multi-selection; the label follows the selection's state. */
2362
+ {
2363
+ key: 'star',
2364
+ label: allStarred ? t('ctx.unstar') : t('ctx.star'),
2365
+ icon: allStarred ? '★' : '☆',
2366
+ hidden: !canStar,
2367
+ },
2062
2368
  { key: 'tags', label: tagsLabel, icon: '🏷', hidden: !singleHasId, disabled: !singleHasId },
2063
2369
  ...keepActionsFor(sel),
2064
2370
  { divider: true, key: 'sep2', label: '', hidden: !w },
@@ -2177,6 +2483,9 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
2177
2483
  );
2178
2484
  }
2179
2485
  break;
2486
+ case 'star':
2487
+ await toggleStar(targets);
2488
+ break;
2180
2489
  case 'tags':
2181
2490
  if (targets[0]) openTagPickerFor(targets[0]);
2182
2491
  break;
@@ -3285,8 +3594,8 @@ function tabLabel(path: string): string {
3285
3594
  // gezinti:g1 — the virtual views park a sentinel in the path. Translate via
3286
3595
  // the SHARED map: this special-cased only '.trash' when recent/starred/shared
3287
3596
  // arrived, so the strip read ".shared" at users (reported 2026-09-04).
3288
- const virtualKey = virtualSegmentKey(p.split('/').pop() || p);
3289
- if (virtualKey) return t(virtualKey);
3597
+ const virtualLabel = virtualSegmentLabel(p.split('/').pop() || p, t);
3598
+ if (virtualLabel) return virtualLabel;
3290
3599
  if (!p) return multiStorageRoot.value ? t('breadcrumb.root') : adapter.value || t('breadcrumb.root');
3291
3600
  return p.split('/').pop() || p;
3292
3601
  }
@@ -3577,6 +3886,35 @@ const e2eUnlockErr = ref('');
3577
3886
  const showEncFolder = ref(false);
3578
3887
  const e2eCreateBusy = ref(false);
3579
3888
 
3889
+ /* wiring:e2 recovery — kurtarma anahtarı + escrow.
3890
+ *
3891
+ * The marker of the folder we are looking at is cached here while the lock
3892
+ * screen is up: the recovery dialog needs to know which doors this folder
3893
+ * actually has (a pre-0.31 folder has none) before offering them. */
3894
+ const e2eMarker = ref<E2eMarker | null>(null);
3895
+ const showRecoveryUnlock = ref(false);
3896
+ const e2eRecoverBusy = ref(false);
3897
+ const e2eRecoverErr = ref<string | null>(null);
3898
+ // The shown-once key. Held only while its dialog is open.
3899
+ const showRecoveryKey = ref(false);
3900
+ const recoveryKeyValue = ref('');
3901
+ const recoveryKeyVariant = ref<'created' | 'upgraded'>('created');
3902
+ const recoveryKeyFolder = ref('');
3903
+ // A v1 folder that just opened by password: offer to give it recovery now,
3904
+ // because this is the only moment filex holds the password.
3905
+ const e2eUpgradeOffer = ref(false);
3906
+ const e2eUpgradePw = ref('');
3907
+ const e2eUpgradeBusy = ref(false);
3908
+
3909
+ /** The installation's escrow public key, or null when escrow is off.
3910
+ * Published in /api/capabilities on purpose — see docs/E2E-ENCRYPTION.md. */
3911
+ const e2eEscrowPub = computed<string | null>(
3912
+ () => capabilitiesData.value?.e2e_escrow?.public_key || null,
3913
+ );
3914
+ const e2eEscrowKid = computed<string | null>(
3915
+ () => capabilitiesData.value?.e2e_escrow?.kid || null,
3916
+ );
3917
+
3580
3918
  function e2eKek(): CryptoKey | null {
3581
3919
  return e2eRing.get(e2eRoot.value) ?? null;
3582
3920
  }
@@ -3607,13 +3945,24 @@ async function e2eUnlock() {
3607
3945
  e2eUnlockErr.value = t('e2e.unlock.marker_missing');
3608
3946
  return;
3609
3947
  }
3610
- const kek = await verifyPassword(marker, e2ePw.value);
3611
- if (!kek) {
3948
+ e2eMarker.value = marker;
3949
+ const fmk = await unlockWithPassword(marker, e2ePw.value);
3950
+ if (!fmk) {
3612
3951
  e2eUnlockErr.value = t('e2e.unlock.wrong');
3613
3952
  return;
3614
3953
  }
3615
- e2eRing.set(e2eRoot.value, kek);
3954
+ e2eRing.set(e2eRoot.value, fmk);
3616
3955
  e2eRingVer.value++;
3956
+ /* wiring:e2 recovery — a folder from before recovery existed has no way
3957
+ * back in but its password. This is the ONE moment we hold that password,
3958
+ * so ask now. Asking is all we do: the folder keeps working untouched if
3959
+ * the user says no, and saying yes is the only path that also hands the
3960
+ * operator an escrow key (when the install has one), which is why the
3961
+ * prompt says so rather than doing it quietly. */
3962
+ if (marker.v === 1) {
3963
+ e2eUpgradePw.value = e2ePw.value;
3964
+ e2eUpgradeOffer.value = true;
3965
+ }
3617
3966
  e2ePw.value = '';
3618
3967
  } finally {
3619
3968
  e2eUnlockBusy.value = false;
@@ -3740,17 +4089,28 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
3740
4089
  try {
3741
4090
  const dirWire = qualify(currentPath.value);
3742
4091
  await api.newFolder(dirWire, payload.name);
3743
- const { marker, kek } = await createMarker(payload.password);
4092
+ /* wiring:e2 recovery the folder gets a recovery key at birth, and an
4093
+ * escrow slot when the installation has one. Both are decided HERE and
4094
+ * never again: the wrapped copies are written into the marker now, so a
4095
+ * folder created without escrow can never be opened by an escrow key. */
4096
+ const { marker, fmk, recoveryKey } = await createEncryptedFolder(payload.password, {
4097
+ escrowPublicKey: e2eEscrowPub.value,
4098
+ });
3744
4099
  const markerFile = new File([JSON.stringify(marker)], E2E_MARKER_NAME, {
3745
4100
  type: 'application/json',
3746
4101
  });
3747
4102
  const newDirWire = wireJoin(dirWire, payload.name);
3748
4103
  await api.uploadMultipart(newDirWire, [markerFile]);
3749
4104
  // Oluşturan oturumda kilit açık başlar (parolayı az önce kendisi girdi).
3750
- e2eRing.set(newDirWire, kek);
4105
+ e2eRing.set(newDirWire, fmk);
3751
4106
  e2eRingVer.value++;
3752
4107
  showEncFolder.value = false;
3753
- flashToast(t('e2e.create.done'));
4108
+ // ⚠ Show the key only after the marker is safely uploaded. Showing it
4109
+ // first would promise recovery for a folder that failed to be created.
4110
+ recoveryKeyValue.value = recoveryKey;
4111
+ recoveryKeyFolder.value = payload.name;
4112
+ recoveryKeyVariant.value = 'created';
4113
+ showRecoveryKey.value = true;
3754
4114
  await load();
3755
4115
  } catch (err) {
3756
4116
  emit('error', { message: (err as Error).message, context: { op: 'e2e-create' } });
@@ -3759,6 +4119,143 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
3759
4119
  e2eCreateBusy.value = false;
3760
4120
  }
3761
4121
  }
4122
+
4123
+ /* --- wiring:e2 recovery ------------------------------------------------
4124
+ *
4125
+ * Two more ways into a locked folder, and one way to give an old folder
4126
+ * those ways. The password path above is untouched, and nothing here runs
4127
+ * without an explicit user action.
4128
+ */
4129
+
4130
+ /** Unlock without the password: user recovery key, or the operator's escrow
4131
+ * key. The escrow branch announces itself to the server first. */
4132
+ async function e2eRecoverUnlock(payload: { mode: 'recovery' | 'escrow'; value: string }) {
4133
+ if (!e2eMarker.value || !e2eRoot.value) return;
4134
+ e2eRecoverBusy.value = true;
4135
+ e2eRecoverErr.value = null;
4136
+ try {
4137
+ let fmk: CryptoKey | null = null;
4138
+ if (payload.mode === 'recovery') {
4139
+ fmk = await unlockWithRecoveryKey(e2eMarker.value, payload.value);
4140
+ if (!fmk) {
4141
+ e2eRecoverErr.value = t('e2e.recover.wrong_recovery');
4142
+ return;
4143
+ }
4144
+ } else {
4145
+ let priv: CryptoKey;
4146
+ try {
4147
+ priv = await importEscrowPrivateKey(payload.value);
4148
+ } catch {
4149
+ e2eRecoverErr.value = t('e2e.recover.bad_escrow_key');
4150
+ return;
4151
+ }
4152
+ fmk = await unlockWithEscrowKey(e2eMarker.value, priv);
4153
+ if (!fmk) {
4154
+ e2eRecoverErr.value = t('e2e.recover.wrong_escrow');
4155
+ return;
4156
+ }
4157
+ /* ⚠ Announce BEFORE unlocking, and treat a failure to announce as a
4158
+ * failure to unlock. The server hands out a nonce sealed to the escrow
4159
+ * public key; returning it proves the key was really here, and that is
4160
+ * what earns the owner their notification.
4161
+ *
4162
+ * ⚠⚠ This is not enforcement and must never be described as such. An
4163
+ * operator holding the escrow private key can decrypt the same folder
4164
+ * offline, with a script, and this code will never run. Refusing to
4165
+ * unlock on a failed announcement only keeps the honest path honest. */
4166
+ try {
4167
+ const ch = await api.e2eEscrowChallenge(e2eRoot.value);
4168
+ const nonce = new Uint8Array(
4169
+ await crypto.subtle.decrypt(
4170
+ { name: 'RSA-OAEP' },
4171
+ priv,
4172
+ b64ToBytes(ch.challenge).buffer as ArrayBuffer,
4173
+ ),
4174
+ );
4175
+ await api.e2eEscrowUsed({
4176
+ path: e2eRoot.value,
4177
+ id: ch.id,
4178
+ nonce: bytesToB64(nonce),
4179
+ });
4180
+ } catch (err) {
4181
+ e2eRecoverErr.value = t('e2e.recover.notify_failed');
4182
+ emit('error', {
4183
+ message: (err as Error).message,
4184
+ context: { op: 'e2e-escrow-notify' },
4185
+ });
4186
+ return;
4187
+ }
4188
+ }
4189
+ e2eRing.set(e2eRoot.value, fmk);
4190
+ e2eRingVer.value++;
4191
+ showRecoveryUnlock.value = false;
4192
+ flashToast(
4193
+ payload.mode === 'escrow' ? t('e2e.recover.escrow_done') : t('e2e.recover.recovery_done'),
4194
+ );
4195
+ } catch (err) {
4196
+ e2eRecoverErr.value = (err as Error).message;
4197
+ } finally {
4198
+ e2eRecoverBusy.value = false;
4199
+ }
4200
+ }
4201
+
4202
+ /** Open the recovery dialog from the lock screen. The marker was cached by
4203
+ * the last unlock attempt; fetch it if the user came straight here. */
4204
+ async function openRecoveryUnlock() {
4205
+ if (!e2eMarker.value && e2eRoot.value) {
4206
+ try {
4207
+ const { blob, url } = await api.fetchBlob(wireJoin(e2eRoot.value, E2E_MARKER_NAME));
4208
+ URL.revokeObjectURL(url);
4209
+ e2eMarker.value = parseMarker(await blob.text());
4210
+ } catch {
4211
+ e2eMarker.value = null;
4212
+ }
4213
+ }
4214
+ e2eRecoverErr.value = null;
4215
+ showRecoveryUnlock.value = true;
4216
+ }
4217
+
4218
+ /** Give a pre-0.31 folder a recovery key, in place, using the password the
4219
+ * user just typed. The files are NOT rewritten — only the marker is. */
4220
+ async function e2eDoUpgrade() {
4221
+ if (!e2eMarker.value || !e2eRoot.value || !e2eUpgradePw.value) return;
4222
+ e2eUpgradeBusy.value = true;
4223
+ try {
4224
+ const up = await upgradeMarkerV1(e2eMarker.value, e2eUpgradePw.value, {
4225
+ escrowPublicKey: e2eEscrowPub.value,
4226
+ });
4227
+ const markerFile = new File([JSON.stringify(up.marker)], E2E_MARKER_NAME, {
4228
+ type: 'application/json',
4229
+ });
4230
+ await api.uploadMultipart(e2eRoot.value, [markerFile]);
4231
+ e2eMarker.value = up.marker;
4232
+ e2eUpgradeOffer.value = false;
4233
+ e2eUpgradePw.value = '';
4234
+ recoveryKeyValue.value = up.recoveryKey;
4235
+ recoveryKeyFolder.value = wireBasename(e2eRoot.value);
4236
+ recoveryKeyVariant.value = 'upgraded';
4237
+ showRecoveryKey.value = true;
4238
+ } catch (err) {
4239
+ emit('error', { message: (err as Error).message, context: { op: 'e2e-upgrade' } });
4240
+ flashToast(t('e2e.upgrade.failed'));
4241
+ } finally {
4242
+ e2eUpgradeBusy.value = false;
4243
+ }
4244
+ }
4245
+
4246
+ /** Decline the offer. The folder keeps working exactly as it did, and the
4247
+ * prompt returns on the next unlock because the risk has not changed. */
4248
+ function e2eDeclineUpgrade() {
4249
+ e2eUpgradeOffer.value = false;
4250
+ e2eUpgradePw.value = '';
4251
+ }
4252
+
4253
+ /** Drop the shown-once key from memory the moment its dialog closes. */
4254
+ function closeRecoveryKey() {
4255
+ showRecoveryKey.value = false;
4256
+ recoveryKeyValue.value = '';
4257
+ recoveryKeyFolder.value = '';
4258
+ }
3762
4259
  /* === /wiring:e2 === */
3763
4260
  </script>
3764
4261
 
@@ -3844,16 +4341,21 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
3844
4341
  :expanded="sideNavExpanded"
3845
4342
  :narrow="isNarrow"
3846
4343
  :active-view="navView"
4344
+ :active-tag="navTag"
4345
+ :tags="navTags"
4346
+ :tags-loaded="navTagsLoaded"
3847
4347
  :active-storage="adapter"
3848
4348
  :storages="config.storages ?? []"
3849
4349
  :shared-storages="sharedStorageNames"
3850
4350
  :trash-visible="config.trashVisible !== false"
3851
4351
  :show-connections="connectionsEnabled"
4352
+ :show-identity-surfaces="identitySurfaces"
3852
4353
  :can-write="canWriteHere && !atVirtualRoot && !trashActive"
3853
4354
  :locale="locale"
3854
4355
  @toggle="toggleSideNav"
3855
4356
  @close="closeNavDrawer"
3856
4357
  @open-view="loadNavView"
4358
+ @open-tag="loadTagView"
3857
4359
  @open-storage="openNavStorage"
3858
4360
  @upload="triggerUpload"
3859
4361
  @new-folder="showNewFolder = true"
@@ -3910,6 +4412,32 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
3910
4412
 
3911
4413
  <!-- wiring:e2 — kilit açık şeridi: şifreli klasörde anahtar bellekteyken
3912
4414
  görünür; "Kilitle" anahtarı ve çözülmüş blob'ları atar. -->
4415
+ <!-- wiring:e2 recovery — a v1 folder just opened by password. Offer it
4416
+ recovery HERE, visibly, rather than doing anything silently: this is
4417
+ the only moment filex holds the password, and (when the install has
4418
+ escrow) accepting also gives the operator a key. -->
4419
+ <div v-if="e2eUpgradeOffer" class="fe-e2e-upgrade" role="alert">
4420
+ <div class="fe-e2e-upgrade__text">
4421
+ <strong>{{ t('e2e.upgrade.title') }}</strong>
4422
+ <p>{{ t('e2e.upgrade.body') }}</p>
4423
+ <p v-if="e2eEscrowKid" class="fe-e2e-upgrade__escrow">
4424
+ {{ t('e2e.upgrade.escrow_note') }}
4425
+ </p>
4426
+ </div>
4427
+ <div class="fe-e2e-upgrade__actions">
4428
+ <button type="button" class="fe-btn" :disabled="e2eUpgradeBusy" @click="e2eDeclineUpgrade">
4429
+ {{ t('e2e.upgrade.decline') }}
4430
+ </button>
4431
+ <button
4432
+ type="button"
4433
+ class="fe-btn fe-btn--primary"
4434
+ :disabled="e2eUpgradeBusy"
4435
+ @click="e2eDoUpgrade"
4436
+ >
4437
+ {{ e2eUpgradeBusy ? t('e2e.upgrade.busy') : t('e2e.upgrade.accept') }}
4438
+ </button>
4439
+ </div>
4440
+ </div>
3913
4441
  <div v-if="e2eUnlocked" class="fe-e2e-strip" role="status">
3914
4442
  <span class="fe-e2e-strip__icon" aria-hidden="true">🔒</span>
3915
4443
  <span class="fe-e2e-strip__label">{{ t('e2e.strip.label') }}</span>
@@ -4048,6 +4576,13 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
4048
4576
  </button>
4049
4577
  </form>
4050
4578
  <p v-if="e2eUnlockErr" class="fe-form__error" role="alert">{{ e2eUnlockErr }}</p>
4579
+ <!-- wiring:e2 recovery — the second door. Always offered: whether
4580
+ this folder actually has one is answered inside the dialog,
4581
+ which can say "this folder predates recovery keys" instead of
4582
+ leaving the user guessing why there is no link. -->
4583
+ <button type="button" class="fe-e2e-optlink" @click="openRecoveryUnlock">
4584
+ {{ t('e2e.locked.use_recovery') }}
4585
+ </button>
4051
4586
  </div>
4052
4587
  <!-- /wiring:e2 -->
4053
4588
  <!-- Search with zero hits — its own message, not "folder is empty". -->
@@ -4098,6 +4633,10 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
4098
4633
  <template v-else-if="navView === 'starred'">
4099
4634
  <path d="M60 26l9 18.6 20.4 3-14.8 14.4 3.5 20.4L60 72.8 41.9 82.4l3.5-20.4L30.6 47.6l20.4-3z" />
4100
4635
  </template>
4636
+ <template v-else-if="navView === 'tag'">
4637
+ <path d="M30 30h24l32 32-24 24-32-32z" />
4638
+ <circle cx="43" cy="43" r="4.5" />
4639
+ </template>
4101
4640
  <template v-else>
4102
4641
  <circle cx="84" cy="34" r="9" />
4103
4642
  <circle cx="36" cy="52" r="9" />
@@ -4105,8 +4644,15 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
4105
4644
  <path d="M44.5 47.5l31-9M44.5 56.5l31 9" />
4106
4645
  </template>
4107
4646
  </svg>
4108
- <p class="fe-state__title">{{ t(`empty.${navView}.title`) }}</p>
4109
- <p class="fe-state__hint">{{ t(`empty.${navView}.hint`) }}</p>
4647
+ <!-- etiket:t1 — the tag view's empty state names the TAG. "Nothing
4648
+ here" would be the fourth identical sentence and would not say
4649
+ which of the user's tags is the empty one. -->
4650
+ <p class="fe-state__title">
4651
+ {{ navView === 'tag' ? t('empty.tag.title', { tag: navTag }) : t(`empty.${navView}.title`) }}
4652
+ </p>
4653
+ <p class="fe-state__hint">
4654
+ {{ navView === 'tag' ? t('empty.tag.hint') : t(`empty.${navView}.hint`) }}
4655
+ </p>
4110
4656
  </div>
4111
4657
  <!-- Empty trash view. -->
4112
4658
  <div v-else-if="!loading && files.length === 0 && trashMode" class="fe-state">
@@ -4189,11 +4735,16 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
4189
4735
  :loading="loading"
4190
4736
  :keep-badge-for="desktopSync ? keepBadgeFor : undefined"
4191
4737
  :thumb-src="thumbs.src"
4738
+ :starred-ids="starredIds"
4739
+ :api-base="props.config.apiBase ?? ''"
4740
+ :auth-headers="() => buildAuthHeaders()"
4741
+ :auth-credentials="api.credentialsMode()"
4192
4742
  @click-card="(n, m) => selection.click(n.path, m)"
4193
4743
  @dbl-card="openNode"
4194
4744
  @context-card="onContextTarget"
4195
4745
  @item-drag-start="onItemDragStart"
4196
4746
  @item-drop-into="onItemDropInto"
4747
+ @star-change="onStarChange"
4197
4748
  />
4198
4749
  <!-- wiring:d2 — galeri görünümü (GridView ile aynı event sözleşmesi) -->
4199
4750
  <GalleryView
@@ -4205,11 +4756,16 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
4205
4756
  :locale="locale"
4206
4757
  :loading="loading"
4207
4758
  :thumb-src="thumbs.src"
4759
+ :starred-ids="starredIds"
4760
+ :api-base="props.config.apiBase ?? ''"
4761
+ :auth-headers="() => buildAuthHeaders()"
4762
+ :auth-credentials="api.credentialsMode()"
4208
4763
  @click-card="(n, m) => selection.click(n.path, m)"
4209
4764
  @dbl-card="openNode"
4210
4765
  @context-card="onContextTarget"
4211
4766
  @item-drag-start="onItemDragStart"
4212
4767
  @item-drop-into="onItemDropInto"
4768
+ @star-change="onStarChange"
4213
4769
  />
4214
4770
  <!-- /wiring:d2 -->
4215
4771
  </div>
@@ -4399,9 +4955,32 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
4399
4955
  :open="showEncFolder"
4400
4956
  :locale="locale"
4401
4957
  :busy="e2eCreateBusy"
4958
+ :escrow-kid="e2eEscrowKid"
4402
4959
  @close="showEncFolder = false"
4403
4960
  @submit="submitEncryptedFolder"
4404
4961
  />
4962
+ <!-- wiring:e2 recovery — the key, shown exactly once. -->
4963
+ <RecoveryKeyModal
4964
+ :open="showRecoveryKey"
4965
+ :locale="locale"
4966
+ :recovery-key="recoveryKeyValue"
4967
+ :folder-name="recoveryKeyFolder"
4968
+ :escrow-kid="e2eEscrowKid"
4969
+ :variant="recoveryKeyVariant"
4970
+ @close="closeRecoveryKey"
4971
+ />
4972
+ <!-- wiring:e2 recovery — the way back in without the password. -->
4973
+ <E2eRecoveryUnlockModal
4974
+ :open="showRecoveryUnlock"
4975
+ :locale="locale"
4976
+ :has-recovery="markerHasRecovery(e2eMarker)"
4977
+ :has-escrow="markerHasEscrow(e2eMarker) && !!e2eEscrowKid"
4978
+ :escrow-kid="e2eEscrowKid"
4979
+ :busy="e2eRecoverBusy"
4980
+ :error="e2eRecoverErr"
4981
+ @close="showRecoveryUnlock = false"
4982
+ @submit="e2eRecoverUnlock"
4983
+ />
4405
4984
  <!-- /wiring:e2 -->
4406
4985
  <RenameModal
4407
4986
  :open="showRename"
@@ -4519,6 +5098,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
4519
5098
  :api-base="props.config.apiBase ?? ''"
4520
5099
  :auth-headers="() => buildAuthHeaders()"
4521
5100
  :auth-credentials="api.credentialsMode()"
5101
+ @change="onNodeTagsChanged"
4522
5102
  @error="(msg: string) => emit('error', { message: msg, context: { op: 'tags' } })"
4523
5103
  />
4524
5104
  </div>