@brftech/filex-core 0.30.0 → 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.
@@ -28,12 +28,12 @@
28
28
  * hash does not match in the web-component build, so the rules silently stop
29
29
  * applying in every embed (measured on the share dialog: raw unstyled HTML).
30
30
  */
31
- import { computed } from 'vue';
31
+ import { computed, ref } from 'vue';
32
32
  import { useLocale } from '../composables/useLocale';
33
33
  import type { LocaleCode } from '../types/ExplorerConfig';
34
34
 
35
35
  /** The virtual listings the panel can open. '' = an ordinary folder. */
36
- export type NavView = '' | 'recent' | 'starred' | 'shared' | 'trash';
36
+ export type NavView = '' | 'recent' | 'starred' | 'shared' | 'trash' | 'tag';
37
37
 
38
38
  export interface NavStorage {
39
39
  name: string;
@@ -61,6 +61,20 @@ const props = defineProps<{
61
61
  sharedStorages?: string[];
62
62
  /** Show the Trash entry (mirrors ExplorerConfig.trashVisible). */
63
63
  trashVisible?: boolean;
64
+ /* === etiket:t1 — the Tags section ==================================
65
+ * "Tagged files should show up inside the tag." Tags are the one
66
+ * navigation family whose entries are USER data: unbounded in number and
67
+ * dynamic in name. Still presentational here — the host fetches
68
+ * `tags/all` (once, cached) and hands the list over, exactly as it does
69
+ * for storages. */
70
+ /** Every tag that exists, alphabetical. Empty → the section shows its own
71
+ * "no tags yet" line rather than disappearing. */
72
+ tags?: string[];
73
+ /** False until the first answer arrives, so "no tags yet" is never shown
74
+ * to somebody who is simply still waiting. */
75
+ tagsLoaded?: boolean;
76
+ /** The tag currently on screen (activeView === 'tag'). */
77
+ activeTag?: string;
64
78
  /**
65
79
  * Show the Connections entries — "How to connect" and "API keys".
66
80
  * ⚠ Never derived from a role here or anywhere: the backend decides what a
@@ -68,6 +82,19 @@ const props = defineProps<{
68
82
  * accounts that need it (see ExplorerConfig.connections).
69
83
  */
70
84
  showConnections?: boolean;
85
+ /**
86
+ * Draw the surfaces that only mean something for ONE person: API keys,
87
+ * Recent, Starred, Shared with me. False when the caller is an app token —
88
+ * a host proxy's shared credential, where "your keys" would be the proxy's
89
+ * own and "your Recent" would be the token owner's history shown to a
90
+ * stranger (see ExplorerConfig.callerKind).
91
+ *
92
+ * ⚠ This is a KIND check, not the role check the comment above forbids, and
93
+ * it is not the whole panel: Upload, the storages, Trash and "How to
94
+ * connect" stay — an embed's users still upload and still mount. An embedder
95
+ * who wants no panel at all already has `sideNav: false`.
96
+ */
97
+ showIdentitySurfaces?: boolean;
71
98
  /** RBAC/root state — false hides the write affordances. */
72
99
  canWrite?: boolean;
73
100
  locale: LocaleCode;
@@ -75,7 +102,8 @@ const props = defineProps<{
75
102
 
76
103
  const emit = defineEmits<{
77
104
  (e: 'toggle'): void;
78
- (e: 'open-view', view: Exclude<NavView, ''>): void;
105
+ (e: 'open-view', view: Exclude<NavView, '' | 'tag'>): void;
106
+ (e: 'open-tag', tag: string): void;
79
107
  (e: 'open-storage', name: string): void;
80
108
  (e: 'open-root'): void;
81
109
  (e: 'upload'): void;
@@ -95,18 +123,51 @@ const showLabels = computed(() => props.expanded || !!props.narrow);
95
123
 
96
124
  const sharedSet = computed(() => new Set(props.sharedStorages ?? []));
97
125
 
126
+ /** The views that answer "what did *I* do" — dropped for an app token. */
127
+ const IDENTITY_VIEWS = new Set<string>(['recent', 'starred', 'shared']);
128
+
98
129
  const views = computed(() => {
99
- const list: Array<{ key: Exclude<NavView, ''>; label: string }> = [
130
+ const list: Array<{ key: Exclude<NavView, '' | 'tag'>; label: string }> = [
100
131
  { key: 'recent', label: t('sidenav.recent') },
101
132
  { key: 'starred', label: t('sidenav.starred') },
102
133
  { key: 'shared', label: t('sidenav.shared') },
103
134
  ];
104
135
  if (props.trashVisible !== false) list.push({ key: 'trash', label: t('sidenav.trash') });
136
+ // Filtered at the end rather than built conditionally: Trash is shared by
137
+ // everyone and the list keeps growing, so one rule at the bottom beats a
138
+ // condition wrapped around each entry.
139
+ if (props.showIdentitySurfaces === false) return list.filter((v) => !IDENTITY_VIEWS.has(v.key));
105
140
  return list;
106
141
  });
107
142
 
108
143
  const writable = computed(() => props.canWrite !== false);
109
144
 
145
+ /* === etiket:t1 — an unbounded list in a fixed panel ====================
146
+ * A user with sixty tags must not push Storages and Connections off the
147
+ * bottom of the panel, and must not be handed sixty identical glyphs on a
148
+ * 56px rail either.
149
+ *
150
+ * expanded / drawer → the first TAG_PEEK, then "Show all (N)". Both states
151
+ * scroll (.fe-sidenav__scroll), so this is about the
152
+ * sections BELOW staying reachable, not about overflow.
153
+ * rail → ONE "Tags" button that opens the panel. Sixty rail
154
+ * icons would be sixty copies of the same glyph with no
155
+ * label — the rail's contract is "every destination one
156
+ * click away", and this keeps it with one click more.
157
+ */
158
+ const TAG_PEEK = 8;
159
+ const tagsExpanded = ref(false);
160
+ const allTags = computed(() => props.tags ?? []);
161
+ const visibleTags = computed(() =>
162
+ tagsExpanded.value ? allTags.value : allTags.value.slice(0, TAG_PEEK),
163
+ );
164
+ const hiddenTagCount = computed(() => Math.max(0, allTags.value.length - visibleTags.value.length));
165
+ /* The section is rendered as soon as the panel knows there ARE tags, and also
166
+ * once the answer came back empty — an empty section that says why is how a
167
+ * user learns the feature exists at all. It stays hidden only while the first
168
+ * answer is still in flight. */
169
+ const showTags = computed(() => !!props.tagsLoaded || allTags.value.length > 0);
170
+
110
171
  const toggleLabel = computed(() =>
111
172
  props.narrow
112
173
  ? t('sidenav.close')
@@ -278,6 +339,105 @@ const toggleLabel = computed(() =>
278
339
  </li>
279
340
  </ul>
280
341
 
342
+ <!-- etiket:t1 — Tags. Between the views and the storages because a tag
343
+ IS a view (a listing with no folder behind it), not a place files
344
+ live. On the rail it collapses to one button that opens the panel:
345
+ sixty tags would otherwise be sixty copies of one glyph with no
346
+ label, and the rail's promise is that every destination stays one
347
+ click away. -->
348
+ <div v-if="showTags" class="fe-sidenav__section">
349
+ <template v-if="showLabels">
350
+ <p class="fe-sidenav__heading">{{ t('sidenav.tags') }}</p>
351
+ <ul class="fe-sidenav__group" :aria-label="t('sidenav.tags')">
352
+ <li v-for="tag in visibleTags" :key="tag">
353
+ <button
354
+ type="button"
355
+ class="fe-sidenav__item fe-sidenav__item--tag"
356
+ :class="{ 'is-active': activeView === 'tag' && activeTag === tag }"
357
+ :aria-current="activeView === 'tag' && activeTag === tag ? 'page' : undefined"
358
+ :title="tag"
359
+ :aria-label="tag"
360
+ :data-testid="`sidenav-tag-${tag}`"
361
+ @click="emit('open-tag', tag)"
362
+ >
363
+ <svg
364
+ class="fe-ficon"
365
+ viewBox="0 0 24 24"
366
+ fill="none"
367
+ stroke="currentColor"
368
+ stroke-width="1.8"
369
+ stroke-linecap="round"
370
+ stroke-linejoin="round"
371
+ aria-hidden="true"
372
+ focusable="false"
373
+ >
374
+ <path d="M4 4.5h7l9 9-6.5 6.5-9-9z" />
375
+ <circle cx="8" cy="8.5" r="1.4" />
376
+ </svg>
377
+ <span class="fe-sidenav__text">{{ tag }}</span>
378
+ </button>
379
+ </li>
380
+ <!-- Nothing tagged yet: the section stays, and says how tags get
381
+ made. A section that only exists once you already know the
382
+ feature teaches nobody. -->
383
+ <li v-if="allTags.length === 0">
384
+ <p class="fe-sidenav__hint">{{ t('sidenav.tags.empty') }}</p>
385
+ </li>
386
+ <li v-if="hiddenTagCount > 0">
387
+ <button
388
+ type="button"
389
+ class="fe-sidenav__more"
390
+ data-testid="sidenav-tags-more"
391
+ @click="tagsExpanded = true"
392
+ >
393
+ {{ t('sidenav.tags.more', { count: hiddenTagCount }) }}
394
+ </button>
395
+ </li>
396
+ <li v-else-if="tagsExpanded && allTags.length > 8">
397
+ <button
398
+ type="button"
399
+ class="fe-sidenav__more"
400
+ data-testid="sidenav-tags-less"
401
+ @click="tagsExpanded = false"
402
+ >
403
+ {{ t('sidenav.tags.less') }}
404
+ </button>
405
+ </li>
406
+ </ul>
407
+ </template>
408
+ <template v-else>
409
+ <hr class="fe-sidenav__rule" aria-hidden="true" />
410
+ <ul class="fe-sidenav__group" :aria-label="t('sidenav.tags')">
411
+ <li>
412
+ <button
413
+ type="button"
414
+ class="fe-sidenav__item"
415
+ :class="{ 'is-active': activeView === 'tag' }"
416
+ :title="t('sidenav.tags')"
417
+ :aria-label="t('sidenav.tags')"
418
+ data-testid="sidenav-tags-rail"
419
+ @click="emit('toggle')"
420
+ >
421
+ <svg
422
+ class="fe-ficon"
423
+ viewBox="0 0 24 24"
424
+ fill="none"
425
+ stroke="currentColor"
426
+ stroke-width="1.8"
427
+ stroke-linecap="round"
428
+ stroke-linejoin="round"
429
+ aria-hidden="true"
430
+ focusable="false"
431
+ >
432
+ <path d="M4 4.5h7l9 9-6.5 6.5-9-9z" />
433
+ <circle cx="8" cy="8.5" r="1.4" />
434
+ </svg>
435
+ </button>
436
+ </li>
437
+ </ul>
438
+ </template>
439
+ </div>
440
+
281
441
  <div v-if="storages.length" class="fe-sidenav__section">
282
442
  <p v-if="showLabels" class="fe-sidenav__heading">{{ t('sidenav.storages') }}</p>
283
443
  <hr v-else class="fe-sidenav__rule" aria-hidden="true" />
@@ -375,7 +535,10 @@ const toggleLabel = computed(() =>
375
535
  <span v-if="showLabels" class="fe-sidenav__text">{{ t('sidenav.connect') }}</span>
376
536
  </button>
377
537
  </li>
378
- <li>
538
+ <!-- API keys is the person half of this section: "How to connect"
539
+ stays for an app token (mount instructions are not identity),
540
+ the keys go. -->
541
+ <li v-if="showIdentitySurfaces !== false">
379
542
  <button
380
543
  type="button"
381
544
  class="fe-sidenav__item"
@@ -6,7 +6,10 @@
6
6
  * `POST /api/files/manager/star`). Optimistic update — flips the local
7
7
  * state immediately and rolls back on API error.
8
8
  */
9
- import { ref, watch } from 'vue';
9
+ import { ref, watch, computed } from 'vue';
10
+ import { setNodeStarred } from '../lib/star';
11
+ import { useLocale } from '../composables/useLocale';
12
+ import type { LocaleCode } from '../types/ExplorerConfig';
10
13
 
11
14
  const props = defineProps<{
12
15
  starred: boolean;
@@ -21,6 +24,17 @@ const props = defineProps<{
21
24
  authCredentials?: RequestCredentials;
22
25
  /** Compact mode for grid view (no label, just the icon). */
23
26
  compact?: boolean;
27
+ /**
28
+ * Card mode — the same button sitting ON a grid/gallery tile instead of in
29
+ * a list cell: a round translucent chip in the tile's corner. It is the SAME
30
+ * component, deliberately: a card star written separately is a second
31
+ * starring path, and the two drift the first time one of them is fixed.
32
+ */
33
+ card?: boolean;
34
+ /** Locale for the title/label. ⚠ The strings used to be hardcoded English
35
+ * ("Star"/"Unstar"), which is a Turkish user's only untranslated control in
36
+ * the row. */
37
+ locale?: LocaleCode;
24
38
  }>();
25
39
 
26
40
  const emit = defineEmits<{
@@ -31,22 +45,18 @@ const emit = defineEmits<{
31
45
  const local = ref(props.starred);
32
46
  watch(() => props.starred, (v) => { local.value = v; });
33
47
 
48
+ const { t } = useLocale(() => props.locale ?? 'tr');
49
+ const label = computed(() => t(local.value ? 'ctx.unstar' : 'ctx.star'));
50
+
34
51
  async function toggle() {
35
52
  const next = !local.value;
36
53
  local.value = next; // optimistic
37
54
  try {
38
- const headers = {
39
- 'Content-Type': 'application/json',
40
- ...(await (props.authHeaders ?? (() => ({})))()),
41
- };
42
- const base = props.apiBase ?? '';
43
- const res = await fetch(`${base}/api/files/manager/star`, {
44
- method: 'POST',
45
- headers,
46
- credentials: props.authCredentials ?? 'same-origin',
47
- body: JSON.stringify({ node_id: props.nodeId, starred: next }),
55
+ await setNodeStarred(props.nodeId, next, {
56
+ apiBase: props.apiBase,
57
+ authHeaders: props.authHeaders,
58
+ authCredentials: props.authCredentials,
48
59
  });
49
- if (!res.ok) throw new Error(`star toggle failed: ${res.status}`);
50
60
  emit('change', next);
51
61
  } catch (err) {
52
62
  local.value = !next; // rollback
@@ -59,9 +69,11 @@ async function toggle() {
59
69
  <button
60
70
  type="button"
61
71
  class="filex-star-btn"
62
- :class="{ 'is-starred': local, 'is-compact': compact }"
72
+ :class="{ 'is-starred': local, 'is-compact': compact, 'is-card': card }"
63
73
  :aria-pressed="local"
64
- :title="local ? 'Unstar' : 'Star'"
74
+ :title="label"
75
+ :aria-label="label"
76
+ data-testid="star-toggle"
65
77
  @click.stop="toggle"
66
78
  >
67
79
  <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
@@ -73,7 +85,7 @@ async function toggle() {
73
85
  d="M12 2.5l3.09 6.26 6.91 1-5 4.87 1.18 6.87L12 18.27l-6.18 3.23L7 14.63 2 9.76l6.91-1z"
74
86
  />
75
87
  </svg>
76
- <span v-if="!compact" class="filex-star-label">{{ local ? 'Starred' : 'Star' }}</span>
88
+ <span v-if="!compact" class="filex-star-label">{{ label }}</span>
77
89
  </button>
78
90
  </template>
79
91
 
@@ -226,6 +226,9 @@ export function resolveEndpoints(config: ExplorerConfig): EndpointMap {
226
226
  // filex trash: list soft-deleted nodes + restore one by node id.
227
227
  trashList: derive(config.trashList, '/api/files/manager/trash'),
228
228
  trashRestore: derive(config.trashRestore, '/api/files/manager/restore'),
229
+ /* wiring:e2 — escrow proof-of-possession, then the owner is told. */
230
+ e2eEscrowChallenge: derive(config.e2eEscrowChallenge, '/api/files/e2e/escrow/challenge'),
231
+ e2eEscrowUsed: derive(config.e2eEscrowUsed, '/api/files/e2e/escrow/used'),
229
232
  };
230
233
  }
231
234
 
@@ -687,6 +690,38 @@ export function useFileApi(config: ExplorerConfig) {
687
690
  return jsonFetch<Capabilities>(endpoints.capabilities);
688
691
  }
689
692
 
693
+ /* wiring:e2 — escrow use is announced, not merely performed.
694
+ *
695
+ * Ask the server for a nonce sealed to the escrow public key; only the
696
+ * holder of the private half can read it back. Returning it is what earns
697
+ * the notification to the folder's owner — a bare "I used escrow" POST
698
+ * would be a string anyone could send.
699
+ *
700
+ * ⚠ This is an announcement, not a gate. An operator holding the private
701
+ * key can decrypt the folder offline with a script and never come here.
702
+ * docs/E2E-ENCRYPTION.md says so plainly and must keep saying so. */
703
+ async function e2eEscrowChallenge(
704
+ path: string,
705
+ ): Promise<{ id: string; challenge: string; kid: string }> {
706
+ if (!endpoints.e2eEscrowChallenge) throw new Error('e2e escrow endpoint not configured');
707
+ return jsonFetch(endpoints.e2eEscrowChallenge, {
708
+ method: 'POST',
709
+ body: JSON.stringify({ path }),
710
+ });
711
+ }
712
+
713
+ async function e2eEscrowUsed(payload: {
714
+ path: string;
715
+ id: string;
716
+ nonce: string;
717
+ }): Promise<{ ok: boolean; notified: boolean }> {
718
+ if (!endpoints.e2eEscrowUsed) throw new Error('e2e escrow endpoint not configured');
719
+ return jsonFetch(endpoints.e2eEscrowUsed, {
720
+ method: 'POST',
721
+ body: JSON.stringify(payload),
722
+ });
723
+ }
724
+
690
725
  async function createShare(payload: {
691
726
  path: string;
692
727
  password?: boolean;
@@ -869,6 +904,9 @@ export function useFileApi(config: ExplorerConfig) {
869
904
  // Peripheral
870
905
  limits,
871
906
  capabilities,
907
+ /* wiring:e2 */
908
+ e2eEscrowChallenge,
909
+ e2eEscrowUsed,
872
910
  createShare,
873
911
  listShares,
874
912
  revokeShare,
@@ -38,6 +38,8 @@ export interface ShortcutHandlers {
38
38
  onShowHelp?: () => void; // ? (Shift+/ on most layouts)
39
39
  onToggleInspector?: () => void; // i (koru:k1 details panel)
40
40
  onToggleHidden?: () => void; // Ctrl+Shift+. — dot-file visibility
41
+ /* yildiz:s1 */
42
+ onStar?: () => void; // S — star / unstar the selection
41
43
  onQuickLook?: () => void; // Space (wiring:c2 quick-look overlay)
42
44
  /* wiring:d1 — tab strip actions */
43
45
  onTabNew?: () => void; // Ctrl+T
@@ -87,6 +89,10 @@ export const SHORTCUT_ACTIONS: ShortcutActionDef[] = [
87
89
  { id: 'inspector', defaultCombo: 'I', labelKey: 'shortcuts.inspector', groupKey: 'shortcuts.group.nav' } /* koru:k1 */,
88
90
  { id: 'help', defaultCombo: '?', labelKey: 'shortcuts.help', groupKey: 'shortcuts.group.nav' },
89
91
  { id: 'toggle-hidden', defaultCombo: 'Ctrl+Shift+.', labelKey: 'shortcuts.toggle_hidden', groupKey: 'shortcuts.group.nav' },
92
+ /* yildiz:s1 — starring is an action, so it belongs where the other verbs
93
+ * are. A bare letter like the inspector's `I`; the handler ignores events
94
+ * from form controls, so it never eats a keystroke meant for a filename. */
95
+ { id: 'star', defaultCombo: 'S', labelKey: 'shortcuts.star', groupKey: 'shortcuts.group.file' },
90
96
  // Selection
91
97
  { id: 'select-all', defaultCombo: 'Ctrl+A', labelKey: 'shortcuts.select_all', groupKey: 'shortcuts.group.selection' },
92
98
  // File operations
@@ -115,6 +121,7 @@ const HANDLER_KEY: Record<string, keyof ShortcutHandlers> = {
115
121
  close: 'onClose',
116
122
  inspector: 'onToggleInspector',
117
123
  'toggle-hidden': 'onToggleHidden',
124
+ star: 'onStar' /* yildiz:s1 */,
118
125
  help: 'onShowHelp',
119
126
  'select-all': 'onSelectAll',
120
127
  rename: 'onRename',
package/src/index.ts CHANGED
@@ -155,21 +155,47 @@ export {
155
155
  E2E_MARKER_NAME,
156
156
  E2E_MAGIC,
157
157
  E2E_VERSION,
158
+ E2E_MARKER_VERSION,
158
159
  E2E_DEFAULT_ITERATIONS,
159
160
  E2E_MAX_FILE_BYTES,
160
161
  E2E_MIN_PASSWORD_LEN,
162
+ E2E_RECOVERY_KEY_BYTES,
163
+ E2E_ESCROW_ALG,
161
164
  E2eDecryptError,
162
165
  deriveKek,
163
166
  createMarker,
167
+ createEncryptedFolder,
168
+ upgradeMarkerV1,
164
169
  parseMarker,
165
170
  verifyPassword,
171
+ unlockWithPassword,
172
+ unlockWithRecoveryKey,
173
+ unlockWithEscrowKey,
174
+ markerHasRecovery,
175
+ markerHasEscrow,
176
+ generateRecoveryKey,
177
+ formatRecoveryKey,
178
+ parseRecoveryKey,
179
+ importEscrowPublicKey,
180
+ importEscrowPrivateKey,
181
+ escrowKeyId,
166
182
  hasMagic,
167
183
  encryptFile,
168
184
  decryptFile,
169
185
  createKeyRing,
170
186
  } from './lib/e2ecrypto';
171
- export type { E2eMarker, E2eKeyRing } from './lib/e2ecrypto';
187
+ export type {
188
+ E2eMarker,
189
+ E2eKeyRing,
190
+ E2eFmkMode,
191
+ E2eRecoverySlot,
192
+ E2eEscrowSlot,
193
+ CreateFolderOptions,
194
+ CreatedFolder,
195
+ } from './lib/e2ecrypto';
172
196
  export { default as EncryptedFolderModal } from './components/EncryptedFolderModal.vue';
197
+ export { default as RecoveryKeyModal } from './components/RecoveryKeyModal.vue';
198
+ export { default as E2eRecoveryUnlockModal } from './components/E2eRecoveryUnlockModal.vue';
173
199
  /* /wiring:e2 */
174
200
 
175
201
  /* ── connections ────────────────────────────────────────────────────