@brftech/filex-core 0.19.0 → 0.20.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 (39) hide show
  1. package/README.md +34 -4
  2. package/dist/filex-core.js +10626 -6501
  3. package/dist/filex-core.js.map +1 -1
  4. package/dist/filex-core.umd.cjs +94 -63
  5. package/dist/filex-core.umd.cjs.map +1 -1
  6. package/dist/index.d.ts +1026 -7
  7. package/dist/style.css +1 -1
  8. package/package.json +3 -3
  9. package/src/FileExplorer.vue +103 -68
  10. package/src/components/ConnectionGuideView.vue +333 -0
  11. package/src/components/ConnectionsPanel.vue +912 -0
  12. package/src/components/NFSExportsPanel.vue +283 -0
  13. package/src/components/S3KeysPanel.vue +381 -0
  14. package/src/components/SSHKeysPanel.vue +222 -0
  15. package/src/components/StorageFields.vue +362 -0
  16. package/src/components/TokensPanel.vue +191 -0
  17. package/src/components/UploadProgress.vue +5 -1
  18. package/src/composables/useConnections.ts +271 -0
  19. package/src/composables/useFileApi.ts +15 -2
  20. package/src/composables/useNFSExports.ts +148 -0
  21. package/src/composables/useS3Keys.ts +175 -0
  22. package/src/composables/useSSHKeys.ts +119 -0
  23. package/src/composables/useThumbs.ts +1 -1
  24. package/src/composables/useTokens.ts +121 -0
  25. package/src/composables/useUploadChunked.ts +433 -164
  26. package/src/index.ts +77 -2
  27. package/src/lib/connectionGuides.ts +1279 -0
  28. package/src/lib/realtime.ts +1 -1
  29. package/src/lib/uploadResume.ts +157 -0
  30. package/src/locales/en.ts +413 -0
  31. package/src/locales/tr.ts +416 -0
  32. package/src/modals/ConvertModal.vue +1 -1
  33. package/src/styles/base.css +12 -12
  34. package/src/types/Connections.ts +122 -0
  35. package/src/types/ExplorerConfig.ts +23 -2
  36. package/src/types/NFSExports.ts +47 -0
  37. package/src/types/S3Keys.ts +55 -0
  38. package/src/types/SSHKeys.ts +54 -0
  39. package/src/types/Tokens.ts +39 -0
@@ -0,0 +1,912 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ConnectionsPanel — storage connections, and how to connect to them.
4
+ *
5
+ * ⚠⚠ This component is the reason the feature exists ONCE. The desktop
6
+ * app, the web app and any embed mount this same file; none of them owns a
7
+ * hand-written copy of the form, the list or the instructions. A fix here
8
+ * lands everywhere on the next release of the package, which is precisely
9
+ * the standing rule ("never write surface-specific behaviour") applied to a
10
+ * feature that was asked for on three surfaces at once.
11
+ *
12
+ * Two halves, because they are two different questions:
13
+ *
14
+ * • INWARD — "connect filex to a bucket / a share / a server". Rendered
15
+ * entirely from the backend's driver descriptors, so a driver added on
16
+ * the server needs no frontend release and no surface can drift.
17
+ * • OUTWARD — "connect my computer to filex". Generated from the live
18
+ * deployment: the real host, the storage name, the caller's own
19
+ * username, with a copy button.
20
+ *
21
+ * A user who may not manage storages is told so plainly and still gets the
22
+ * outward half — which is the half they actually need. Rendering a form
23
+ * whose every submit 403s would be the dishonest alternative.
24
+ */
25
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
26
+ import type { ExplorerConfig, LocaleCode } from '../types/ExplorerConfig';
27
+ import type { StorageRow } from '../types/Connections';
28
+ import { useLocale } from '../composables/useLocale';
29
+ import { useConnections, connectionsOrigin } from '../composables/useConnections';
30
+ import {
31
+ buildGuide,
32
+ guideName,
33
+ guideProtocols,
34
+ hostOf,
35
+ type ProtocolGuide,
36
+ } from '../lib/connectionGuides';
37
+ import StorageFields from './StorageFields.vue';
38
+ import ConnectionGuideView from './ConnectionGuideView.vue';
39
+ import S3KeysPanel from './S3KeysPanel.vue';
40
+ import SSHKeysPanel from './SSHKeysPanel.vue';
41
+ import NFSExportsPanel from './NFSExportsPanel.vue';
42
+ import TokensPanel from './TokensPanel.vue';
43
+
44
+ const props = defineProps<{
45
+ config: ExplorerConfig;
46
+ /** Which half to open on. */
47
+ initialTab?: 'storages' | 'connect';
48
+ /** Draw a close control — the desktop app opens this as a full surface
49
+ * and needs a way out; a page-embedded copy has the page's own chrome. */
50
+ closable?: boolean;
51
+ }>();
52
+
53
+ const emit = defineEmits<{
54
+ (e: 'changed'): void;
55
+ (e: 'close'): void;
56
+ (e: 'error', err: { message: string }): void;
57
+ }>();
58
+
59
+ const locale = computed<LocaleCode>(() => props.config.locale ?? 'tr');
60
+ const { t } = useLocale(locale);
61
+
62
+ // Destructured on purpose: Vue only auto-unwraps refs that are top-level in
63
+ // the setup scope, so `conn.storages` inside a template would render a Ref
64
+ // object rather than its value.
65
+ const {
66
+ drivers,
67
+ storages,
68
+ visible,
69
+ me,
70
+ loading,
71
+ loaded,
72
+ error,
73
+ canManage,
74
+ denial,
75
+ load,
76
+ createStorage,
77
+ updateStorage,
78
+ deleteStorage,
79
+ testStorage,
80
+ descriptor,
81
+ fields,
82
+ defaults,
83
+ missingRequired,
84
+ } = useConnections(props.config);
85
+
86
+ // ── theme ────────────────────────────────────────────────────────────
87
+ // Resolved in JS rather than left to `prefers-color-scheme`, because the
88
+ // stylesheet's auto rule keys off the explorer's own `.fe` root and this
89
+ // panel is mounted on its own.
90
+ const mq =
91
+ typeof window !== 'undefined' && window.matchMedia
92
+ ? window.matchMedia('(prefers-color-scheme: dark)')
93
+ : undefined;
94
+ const osDark = ref(!!mq?.matches);
95
+ function onMq(e: MediaQueryListEvent) {
96
+ osDark.value = e.matches;
97
+ }
98
+ const themeResolved = computed(() => {
99
+ const mode = props.config.theme ?? 'auto';
100
+ if (mode === 'light' || mode === 'dark') return mode;
101
+ return osDark.value ? 'dark' : 'light';
102
+ });
103
+
104
+ // ── tabs ─────────────────────────────────────────────────────────────
105
+ const tab = ref<'storages' | 'connect'>(props.initialTab ?? 'storages');
106
+
107
+ // ── inward: the storage form ─────────────────────────────────────────
108
+ type FormMode = { kind: 'none' } | { kind: 'new' } | { kind: 'edit'; row: StorageRow };
109
+ const form = ref<FormMode>({ kind: 'none' });
110
+ const fName = ref('');
111
+ const fDriver = ref('');
112
+ const fReadOnly = ref(false);
113
+ const fEnabled = ref(true);
114
+ const fConfig = ref<Record<string, unknown>>({});
115
+ const fInvalid = ref<string[]>([]);
116
+ const saving = ref(false);
117
+ const testing = ref(false);
118
+ const testResult = ref<{ ok: boolean; error?: string; object_count?: number } | null>(null);
119
+
120
+ /** What the NFS panel published: where to mount, and the path just minted. */
121
+ const nfs = ref<{ host: string; port: number; enabled: boolean; path?: string; readOnly: boolean } | null>(
122
+ null,
123
+ );
124
+ const formError = ref<string | null>(null);
125
+ const confirmDelete = ref<number | null>(null);
126
+
127
+ const driverOptions = computed(() =>
128
+ drivers.value.map((d) => {
129
+ const translated = t(d.i18n_key);
130
+ return { value: d.driver, label: translated === d.i18n_key ? d.label : translated };
131
+ }),
132
+ );
133
+
134
+ const formFields = computed(() => fields(fDriver.value));
135
+
136
+ function openNew() {
137
+ const first = drivers.value[0]?.driver ?? '';
138
+ fDriver.value = first;
139
+ fName.value = '';
140
+ fReadOnly.value = false;
141
+ fEnabled.value = true;
142
+ fConfig.value = defaults(first);
143
+ fInvalid.value = [];
144
+ testResult.value = null;
145
+ formError.value = null;
146
+ form.value = { kind: 'new' };
147
+ }
148
+
149
+ function openEdit(row: StorageRow) {
150
+ fDriver.value = row.driver;
151
+ fName.value = row.name;
152
+ fReadOnly.value = !!row.read_only;
153
+ fEnabled.value = row.enabled !== false;
154
+ // A copy: editing must not mutate the list row under the user while
155
+ // they type, and cancelling must actually cancel.
156
+ fConfig.value = { ...(row.config ?? {}) };
157
+ fInvalid.value = [];
158
+ testResult.value = null;
159
+ formError.value = null;
160
+ form.value = { kind: 'edit', row };
161
+ }
162
+
163
+ function closeForm() {
164
+ form.value = { kind: 'none' };
165
+ testResult.value = null;
166
+ formError.value = null;
167
+ }
168
+
169
+ function onDriverChange(next: string) {
170
+ fDriver.value = next;
171
+ // Descriptor defaults, wholesale. Carrying keys over from the previous
172
+ // driver is how a config ends up with fields nothing reads.
173
+ fConfig.value = defaults(next);
174
+ fInvalid.value = [];
175
+ testResult.value = null;
176
+ }
177
+
178
+ function validate(): boolean {
179
+ const missing = missingRequired(fDriver.value, fConfig.value).map((f) => f.key);
180
+ fInvalid.value = missing;
181
+ if (!fName.value.trim()) formError.value = t('conn.form.nameRequired');
182
+ else if (missing.length) formError.value = t('conn.form.fillRequired');
183
+ else formError.value = null;
184
+ return !formError.value;
185
+ }
186
+
187
+ async function runTest() {
188
+ testing.value = true;
189
+ testResult.value = null;
190
+ try {
191
+ testResult.value = await testStorage({
192
+ driver: fDriver.value,
193
+ config: fConfig.value,
194
+ });
195
+ } finally {
196
+ testing.value = false;
197
+ }
198
+ }
199
+
200
+ async function save() {
201
+ if (!validate()) return;
202
+ saving.value = true;
203
+ formError.value = null;
204
+ try {
205
+ const body = {
206
+ name: fName.value.trim(),
207
+ driver: fDriver.value,
208
+ config: fConfig.value,
209
+ read_only: fReadOnly.value,
210
+ enabled: fEnabled.value,
211
+ };
212
+ if (form.value.kind === 'edit') await updateStorage(form.value.row.id, body);
213
+ else await createStorage(body);
214
+ closeForm();
215
+ emit('changed');
216
+ } catch (e) {
217
+ const msg = (e as { detail?: string; message?: string }) ?? {};
218
+ // The backend's own words beat a generic status line: "400" says
219
+ // nothing, `ROOT_PATH_FORBIDDEN` says exactly which field is wrong.
220
+ let detail = '';
221
+ try {
222
+ detail = msg.detail ? (JSON.parse(msg.detail) as { error?: string }).error ?? '' : '';
223
+ } catch {
224
+ detail = msg.detail ?? '';
225
+ }
226
+ formError.value = detail || msg.message || String(e);
227
+ emit('error', { message: formError.value });
228
+ } finally {
229
+ saving.value = false;
230
+ }
231
+ }
232
+
233
+ async function remove(row: StorageRow) {
234
+ if (confirmDelete.value !== row.id) {
235
+ confirmDelete.value = row.id;
236
+ return;
237
+ }
238
+ confirmDelete.value = null;
239
+ try {
240
+ await deleteStorage(row.id);
241
+ emit('changed');
242
+ } catch (e) {
243
+ emit('error', { message: (e as Error).message });
244
+ }
245
+ }
246
+
247
+ /** The one line under a storage's name: where it actually points. */
248
+ function summaryOf(row: StorageRow): string {
249
+ const d = descriptor(row.driver);
250
+ const cfg = row.config ?? {};
251
+ const parts: string[] = [];
252
+ const rootField = d?.fields.find((f) => f.root);
253
+ const pick = (key: string, aliases: string[] = []): string => {
254
+ for (const k of [key, ...aliases]) {
255
+ const v = cfg[k];
256
+ if (typeof v === 'string' && v.trim()) return v;
257
+ }
258
+ return '';
259
+ };
260
+ const host = pick('endpoint') || pick('url') || pick('host');
261
+ if (host) parts.push(host);
262
+ const bucket = pick('bucket');
263
+ if (bucket) parts.push(bucket);
264
+ if (rootField) {
265
+ const r = pick(rootField.key, rootField.aliases ?? []);
266
+ if (r) parts.push(r);
267
+ }
268
+ return parts.join(' · ');
269
+ }
270
+
271
+ // ── outward: the guides ──────────────────────────────────────────────
272
+ const protocols = guideProtocols();
273
+ const protocol = ref(protocols[0] ?? 'webdav');
274
+ const guideStorage = ref<string>('');
275
+
276
+ const origin = computed(() => connectionsOrigin(props.config));
277
+
278
+ /**
279
+ * What the S3 key panel published: the caller's own key, the endpoint the
280
+ * SERVER computed, and whether path-style is mandatory.
281
+ *
282
+ * ⚠ The endpoint is not derived here. With a dedicated host it is a different
283
+ * host from the application, and a guide that assembled it from the page
284
+ * origin would print a URL that reaches the web app — which is exactly how the
285
+ * first real-client run failed.
286
+ */
287
+ const s3 = ref<{ accessKeyID: string; secret?: string; endpoint: string; pathStyle: boolean } | null>(
288
+ null,
289
+ );
290
+
291
+ /**
292
+ * What the SSH key panel published: where the SFTP endpoint listens, the login
293
+ * name the account actually uses, and whether a key is registered.
294
+ *
295
+ * ⚠ The port comes from the SERVER. SFTP is raw TCP on a port of its own, and a
296
+ * page that printed the web port would send every client at a proxy that speaks
297
+ * only HTTP.
298
+ */
299
+ const sftp = ref<{
300
+ host: string;
301
+ port: number;
302
+ login: string;
303
+ enabled: boolean;
304
+ hasKey: boolean;
305
+ ftps?: { enabled: boolean; host: string; port: number; pasv_min: number; pasv_max: number; self_signed: boolean };
306
+ } | null>(null);
307
+
308
+ const guide = computed<ProtocolGuide | null>(() =>
309
+ buildGuide(
310
+ protocol.value,
311
+ {
312
+ origin: origin.value,
313
+ user: me.value?.email ?? '',
314
+ storages: visible.value,
315
+ storage: guideStorage.value || undefined,
316
+ s3Endpoint: s3.value?.endpoint,
317
+ s3PathStyle: s3.value?.pathStyle,
318
+ s3AccessKeyID: s3.value?.accessKeyID || undefined,
319
+ s3Secret: s3.value?.secret,
320
+ sftpHost: sftp.value?.host || undefined,
321
+ sftpPort: sftp.value?.port,
322
+ sftpEnabled: sftp.value?.enabled,
323
+ sftpLogin: sftp.value?.login || undefined,
324
+ sftpHasKey: sftp.value?.hasKey,
325
+ ftpsHost: sftp.value?.ftps?.host || undefined,
326
+ ftpsPort: sftp.value?.ftps?.port,
327
+ ftpsEnabled: sftp.value?.ftps?.enabled,
328
+ ftpsPasvMin: sftp.value?.ftps?.pasv_min,
329
+ ftpsPasvMax: sftp.value?.ftps?.pasv_max,
330
+ ftpsSelfSigned: sftp.value?.ftps?.self_signed,
331
+ nfsHost: nfs.value?.host || undefined,
332
+ nfsPort: nfs.value?.port,
333
+ nfsEnabled: nfs.value?.enabled,
334
+ nfsPath: nfs.value?.path,
335
+ nfsReadOnly: nfs.value?.readOnly,
336
+ },
337
+ t,
338
+ ),
339
+ );
340
+
341
+ watch(
342
+ () => visible.value,
343
+ (names) => {
344
+ if (!guideStorage.value && names.length === 1) guideStorage.value = names[0];
345
+ },
346
+ );
347
+
348
+ // ── lifecycle ────────────────────────────────────────────────────────
349
+ onMounted(() => {
350
+ mq?.addEventListener?.('change', onMq);
351
+ void load();
352
+ });
353
+ onBeforeUnmount(() => mq?.removeEventListener?.('change', onMq));
354
+
355
+ // The panel is often mounted once and re-pointed (the desktop app switches
356
+ // accounts without tearing the window down), so a changed server must
357
+ // re-fetch rather than keep showing the previous one's storages.
358
+ watch(
359
+ () => [props.config.apiBase, props.config.endpoint],
360
+ () => {
361
+ closeForm();
362
+ void load();
363
+ },
364
+ );
365
+ </script>
366
+
367
+ <template>
368
+ <div
369
+ class="fe-conn"
370
+ :class="{
371
+ 'fe--theme-dark': themeResolved === 'dark',
372
+ 'fe--theme-light': themeResolved === 'light',
373
+ }"
374
+ data-testid="connections-panel"
375
+ >
376
+ <header class="fe-conn__head">
377
+ <div>
378
+ <h2 class="fe-conn__title">{{ t('conn.title') }}</h2>
379
+ <p class="fe-conn__sub">{{ t('conn.subtitle', { host: hostOf(origin) }) }}</p>
380
+ </div>
381
+ <button
382
+ v-if="closable"
383
+ type="button"
384
+ class="fe-conn__btn"
385
+ data-testid="connections-close"
386
+ :aria-label="t('conn.close')"
387
+ @click="emit('close')"
388
+ >
389
+
390
+ </button>
391
+ </header>
392
+
393
+ <nav class="fe-conn__tabs" role="tablist">
394
+ <button
395
+ type="button"
396
+ role="tab"
397
+ class="fe-conn__tab"
398
+ :class="{ 'is-active': tab === 'storages' }"
399
+ :aria-selected="tab === 'storages'"
400
+ data-testid="tab-storages"
401
+ @click="tab = 'storages'"
402
+ >
403
+ {{ t('conn.tab.storages') }}
404
+ </button>
405
+ <button
406
+ type="button"
407
+ role="tab"
408
+ class="fe-conn__tab"
409
+ :class="{ 'is-active': tab === 'connect' }"
410
+ :aria-selected="tab === 'connect'"
411
+ data-testid="tab-connect"
412
+ @click="tab = 'connect'"
413
+ >
414
+ {{ t('conn.tab.connect') }}
415
+ </button>
416
+ </nav>
417
+
418
+ <!-- ══ inward ════════════════════════════════════════════════ -->
419
+ <section v-if="tab === 'storages'" class="fe-conn__body" role="tabpanel">
420
+ <p v-if="loading && !loaded" class="fe-conn__muted">
421
+ {{ t('conn.loading') }}
422
+ </p>
423
+
424
+ <template v-else>
425
+ <!-- The honest non-admin state. Not a disabled form: a form you
426
+ cannot submit teaches the wrong thing about whose install this
427
+ is. -->
428
+ <div
429
+ v-if="canManage === false"
430
+ class="fe-conn__card fe-conn__card--notice"
431
+ data-testid="no-admin"
432
+ >
433
+ <strong>{{ t('conn.denied.title') }}</strong>
434
+ <p class="fe-conn__muted">
435
+ {{
436
+ denial === 'anonymous'
437
+ ? t('conn.denied.anonymous')
438
+ : denial === 'unreachable'
439
+ ? t('conn.denied.unreachable', { error: error ?? '' })
440
+ : t('conn.denied.none')
441
+ }}
442
+ </p>
443
+ <p class="fe-conn__muted">{{ t('conn.denied.guideHint') }}</p>
444
+ <button type="button" class="fe-conn__btn fe-conn__btn--primary" @click="tab = 'connect'">
445
+ {{ t('conn.denied.guideCta') }}
446
+ </button>
447
+ </div>
448
+
449
+ <!-- What they CAN see, even without admin: the storages they may
450
+ browse. Otherwise this half of the panel is blank for them. -->
451
+ <div v-if="canManage === false && visible.length" class="fe-conn__list">
452
+ <div v-for="name in visible" :key="name" class="fe-conn__card">
453
+ <div class="fe-conn__rowmain">
454
+ <strong class="fe-conn__name">{{ name }}</strong>
455
+ <span class="fe-conn__muted">{{ t('conn.visibleOnly') }}</span>
456
+ </div>
457
+ </div>
458
+ </div>
459
+
460
+ <template v-if="canManage">
461
+ <p v-if="error" class="fe-conn__error">{{ error }}</p>
462
+
463
+ <!-- ── the form ── -->
464
+ <div v-if="form.kind !== 'none'" class="fe-conn__card fe-conn__form" data-testid="storage-form">
465
+ <h3 class="fe-conn__formtitle">
466
+ {{ form.kind === 'edit' ? t('conn.form.editTitle', { name: fName }) : t('conn.form.newTitle') }}
467
+ </h3>
468
+
469
+ <div class="fe-cfield__row">
470
+ <label class="fe-cfield__label" for="fe-conn-name">
471
+ {{ t('conn.form.name') }}<span class="fe-cfield__req">*</span>
472
+ </label>
473
+ <input
474
+ id="fe-conn-name"
475
+ v-model="fName"
476
+ class="fe-cfield__input"
477
+ data-testid="storage-name"
478
+ :placeholder="t('conn.form.namePlaceholder')"
479
+ />
480
+ <p class="fe-cfield__help">{{ t('conn.form.nameHelp') }}</p>
481
+ </div>
482
+
483
+ <div class="fe-cfield__row">
484
+ <label class="fe-cfield__label" for="fe-conn-driver">{{ t('conn.form.driver') }}</label>
485
+ <select
486
+ id="fe-conn-driver"
487
+ class="fe-cfield__input"
488
+ data-testid="storage-driver"
489
+ :value="fDriver"
490
+ :disabled="form.kind === 'edit'"
491
+ @change="onDriverChange(($event.target as HTMLSelectElement).value)"
492
+ >
493
+ <option v-for="o in driverOptions" :key="o.value" :value="o.value">
494
+ {{ o.label }}
495
+ </option>
496
+ </select>
497
+ <p v-if="form.kind === 'edit'" class="fe-cfield__help">
498
+ {{ t('conn.form.driverLocked') }}
499
+ </p>
500
+ </div>
501
+
502
+ <StorageFields
503
+ v-model="fConfig"
504
+ :fields="formFields"
505
+ :locale="locale"
506
+ :invalid="fInvalid"
507
+ />
508
+
509
+ <label class="fe-cfield__check">
510
+ <input v-model="fReadOnly" type="checkbox" data-testid="storage-readonly" />
511
+ <span>{{ t('conn.form.readOnly') }}</span>
512
+ </label>
513
+ <label class="fe-cfield__check">
514
+ <input v-model="fEnabled" type="checkbox" />
515
+ <span>{{ t('conn.form.enabled') }}</span>
516
+ </label>
517
+
518
+ <p v-if="formError" class="fe-conn__error" data-testid="form-error">{{ formError }}</p>
519
+ <p
520
+ v-if="testResult"
521
+ class="fe-conn__testresult"
522
+ :class="testResult.ok ? 'is-ok' : 'is-bad'"
523
+ data-testid="test-result"
524
+ >
525
+ {{
526
+ testResult.ok
527
+ ? t('conn.form.testOk', { count: testResult.object_count ?? 0 })
528
+ : t('conn.form.testFail', { error: testResult.error ?? '' })
529
+ }}
530
+ </p>
531
+
532
+ <div class="fe-conn__actions">
533
+ <button
534
+ type="button"
535
+ class="fe-conn__btn"
536
+ data-testid="storage-test"
537
+ :disabled="testing"
538
+ @click="runTest"
539
+ >
540
+ {{ testing ? t('conn.form.testing') : t('conn.form.test') }}
541
+ </button>
542
+ <button
543
+ type="button"
544
+ class="fe-conn__btn fe-conn__btn--primary"
545
+ data-testid="storage-save"
546
+ :disabled="saving"
547
+ @click="save"
548
+ >
549
+ {{ saving ? t('conn.form.saving') : t('conn.form.save') }}
550
+ </button>
551
+ <button type="button" class="fe-conn__btn" @click="closeForm">
552
+ {{ t('conn.form.cancel') }}
553
+ </button>
554
+ </div>
555
+ </div>
556
+
557
+ <!-- ── the list ── -->
558
+ <div v-else>
559
+ <div class="fe-conn__listhead">
560
+ <span class="fe-conn__muted">
561
+ {{ t('conn.list.count', { n: storages.length }) }}
562
+ </span>
563
+ <button
564
+ type="button"
565
+ class="fe-conn__btn fe-conn__btn--primary"
566
+ data-testid="storage-add"
567
+ @click="openNew"
568
+ >
569
+ + {{ t('conn.list.add') }}
570
+ </button>
571
+ </div>
572
+
573
+ <p v-if="!storages.length" class="fe-conn__empty">
574
+ {{ t('conn.list.empty') }}
575
+ </p>
576
+
577
+ <div class="fe-conn__list" data-testid="storage-list">
578
+ <div v-for="row in storages" :key="row.id" class="fe-conn__card">
579
+ <div class="fe-conn__rowmain">
580
+ <div class="fe-conn__rowtext">
581
+ <strong class="fe-conn__name">{{ row.name }}</strong>
582
+ <span class="fe-conn__badge">{{ row.driver }}</span>
583
+ <span v-if="row.read_only" class="fe-conn__badge fe-conn__badge--warn">
584
+ {{ t('conn.list.readOnly') }}
585
+ </span>
586
+ <span v-if="row.enabled === false" class="fe-conn__badge">
587
+ {{ t('conn.list.disabled') }}
588
+ </span>
589
+ <div v-if="summaryOf(row)" class="fe-conn__muted fe-conn__summary">
590
+ {{ summaryOf(row) }}
591
+ </div>
592
+ </div>
593
+ <div class="fe-conn__rowbtns">
594
+ <button
595
+ type="button"
596
+ class="fe-conn__btn"
597
+ :data-testid="`storage-edit-${row.name}`"
598
+ @click="openEdit(row)"
599
+ >
600
+ {{ t('conn.list.edit') }}
601
+ </button>
602
+ <button
603
+ type="button"
604
+ class="fe-conn__btn fe-conn__btn--danger"
605
+ @click="remove(row)"
606
+ >
607
+ {{ confirmDelete === row.id ? t('conn.list.confirm') : t('conn.list.remove') }}
608
+ </button>
609
+ </div>
610
+ </div>
611
+ </div>
612
+ </div>
613
+ </div>
614
+ </template>
615
+ </template>
616
+ </section>
617
+
618
+ <!-- ══ outward ═══════════════════════════════════════════════ -->
619
+ <section v-else class="fe-conn__body" role="tabpanel">
620
+ <div class="fe-conn__guidebar">
621
+ <label v-if="protocols.length > 1" class="fe-conn__pick">
622
+ <span class="fe-cfield__label">{{ t('conn.guide.protocol') }}</span>
623
+ <select v-model="protocol" class="fe-cfield__input" data-testid="guide-protocol">
624
+ <option v-for="p in protocols" :key="p" :value="p">{{ guideName(p) }}</option>
625
+ </select>
626
+ </label>
627
+ <label class="fe-conn__pick">
628
+ <span class="fe-cfield__label">{{ t('conn.guide.storage') }}</span>
629
+ <select v-model="guideStorage" class="fe-cfield__input" data-testid="guide-storage">
630
+ <option value="">{{ t('conn.guide.allStorages') }}</option>
631
+ <option v-for="s in visible" :key="s" :value="s">{{ s }}</option>
632
+ </select>
633
+ </label>
634
+ </div>
635
+
636
+ <!-- The keys come first: the guide below is filled in from whichever
637
+ key is active, so minting one rewrites every command on the page. -->
638
+ <S3KeysPanel
639
+ v-if="protocol === 's3'"
640
+ :config="config"
641
+ :storages="visible"
642
+ @active="s3 = $event"
643
+ />
644
+
645
+ <!-- The same shape for SFTP: the credential first, because the commands
646
+ below are only worth anything with a real login name in them. -->
647
+ <!-- Mounted for FTPS too: it is the same call that reports where the FTP
648
+ endpoint listens, and the login name is the same one. -->
649
+ <SSHKeysPanel
650
+ v-if="protocol === 'sftp' || protocol === 'ftps'"
651
+ :config="config"
652
+ :keys-visible="protocol === 'sftp'"
653
+ @active="sftp = $event"
654
+ />
655
+
656
+ <NFSExportsPanel
657
+ v-if="protocol === 'nfs'"
658
+ :config="config"
659
+ :storages="visible"
660
+ @active="nfs = $event"
661
+ />
662
+
663
+ <!-- ⚠⚠ The credential for the other three. FTPS, WebDAV and `filex
664
+ mount` all take an API TOKEN as the password — the guides below say
665
+ so — and until this panel existed the only place to mint one was the
666
+ admin panel, so a normal user read the instruction and had nowhere
667
+ to follow it. Same component on all three surfaces. -->
668
+ <TokensPanel
669
+ v-if="protocol === 'ftps' || protocol === 'webdav' || protocol === 'mount'"
670
+ :config="config"
671
+ :protocol="protocol"
672
+ />
673
+
674
+ <ConnectionGuideView v-if="guide" :guide="guide" :locale="locale" />
675
+ </section>
676
+ </div>
677
+ </template>
678
+
679
+ <style>
680
+ .fe-conn {
681
+ display: flex;
682
+ flex-direction: column;
683
+ gap: 14px;
684
+ font-family: var(--fe-font);
685
+ font-size: 14px;
686
+ color: var(--fe-text);
687
+ background: var(--fe-bg);
688
+ min-width: 0;
689
+ }
690
+ /* An explicit light palette, so a panel asked for light stays light even
691
+ inside a host that publishes dark tokens at :root (the admin shell sets
692
+ `.dark` on <html>). Without this, "light" only means "not dark". */
693
+ .fe-conn.fe--theme-light {
694
+ --fe-bg: #ffffff;
695
+ --fe-bg-elev: #f7f8fa;
696
+ --fe-bg-hover: #edf0f5;
697
+ --fe-border: #e2e6ed;
698
+ --fe-border-strong: #c7ced9;
699
+ --fe-text: #1a1e27;
700
+ --fe-text-muted: #5a6475;
701
+ --fe-primary: #2f6fe0;
702
+ --fe-danger: #dc2626;
703
+ }
704
+ .fe-conn__head {
705
+ display: flex;
706
+ align-items: flex-start;
707
+ justify-content: space-between;
708
+ gap: 16px;
709
+ }
710
+ /* ⚠ The component renders into the HOST's document (shadowRoot: false, on
711
+ purpose — Tailwind, OS dark mode and the host's fonts are meant to reach
712
+ it). That also means the host's element selectors reach it: the desktop
713
+ shell styles `h2 { text-transform: uppercase; letter-spacing: .05em }`
714
+ for its own section headings, and the panel's title came out
715
+ "STORAGE CONNECTIONS" there while the web app rendered
716
+ "Storage connections". Same component, two typographies, decided by
717
+ whichever page it landed on — which is the split this package exists to
718
+ prevent. Headings state their own type. */
719
+ .fe-conn h2,
720
+ .fe-conn h3,
721
+ .fe-conn h4 {
722
+ text-transform: none;
723
+ letter-spacing: normal;
724
+ }
725
+ .fe-conn__title {
726
+ margin: 0;
727
+ font-size: 17px;
728
+ font-weight: 650;
729
+ }
730
+ .fe-conn__sub {
731
+ margin: 3px 0 0;
732
+ color: var(--fe-text-muted);
733
+ font-size: 13px;
734
+ overflow-wrap: anywhere;
735
+ }
736
+ .fe-conn__tabs {
737
+ display: flex;
738
+ gap: 4px;
739
+ border-bottom: 1px solid var(--fe-border);
740
+ }
741
+ .fe-conn__tab {
742
+ font: inherit;
743
+ font-size: 13.5px;
744
+ border: 0;
745
+ background: none;
746
+ color: var(--fe-text-muted);
747
+ padding: 7px 12px;
748
+ cursor: pointer;
749
+ border-bottom: 2px solid transparent;
750
+ }
751
+ .fe-conn__tab:hover {
752
+ color: var(--fe-text);
753
+ }
754
+ .fe-conn__tab.is-active {
755
+ color: var(--fe-primary);
756
+ border-bottom-color: var(--fe-primary);
757
+ font-weight: 600;
758
+ }
759
+ .fe-conn__body {
760
+ display: flex;
761
+ flex-direction: column;
762
+ gap: 12px;
763
+ min-width: 0;
764
+ }
765
+ .fe-conn__card {
766
+ border: 1px solid var(--fe-border);
767
+ border-radius: var(--fe-radius);
768
+ background: var(--fe-bg-elev);
769
+ padding: 12px 14px;
770
+ }
771
+ .fe-conn__card--notice {
772
+ display: flex;
773
+ flex-direction: column;
774
+ gap: 8px;
775
+ align-items: flex-start;
776
+ }
777
+ .fe-conn__form {
778
+ display: flex;
779
+ flex-direction: column;
780
+ gap: 14px;
781
+ }
782
+ .fe-conn__formtitle {
783
+ margin: 0;
784
+ font-size: 14px;
785
+ font-weight: 650;
786
+ }
787
+ .fe-conn__list {
788
+ display: flex;
789
+ flex-direction: column;
790
+ gap: 8px;
791
+ }
792
+ .fe-conn__listhead {
793
+ display: flex;
794
+ align-items: center;
795
+ justify-content: space-between;
796
+ gap: 12px;
797
+ margin-bottom: 4px;
798
+ }
799
+ .fe-conn__rowmain {
800
+ display: flex;
801
+ align-items: flex-start;
802
+ justify-content: space-between;
803
+ gap: 12px;
804
+ flex-wrap: wrap;
805
+ }
806
+ .fe-conn__rowtext {
807
+ min-width: 0;
808
+ display: flex;
809
+ align-items: center;
810
+ gap: 8px;
811
+ flex-wrap: wrap;
812
+ }
813
+ .fe-conn__rowbtns {
814
+ display: flex;
815
+ gap: 6px;
816
+ flex: 0 0 auto;
817
+ }
818
+ .fe-conn__name {
819
+ font-size: 14px;
820
+ }
821
+ .fe-conn__summary {
822
+ flex-basis: 100%;
823
+ font-family: var(--fe-font-mono);
824
+ font-size: 12px;
825
+ overflow-wrap: anywhere;
826
+ }
827
+ .fe-conn__badge {
828
+ font-size: 11px;
829
+ text-transform: uppercase;
830
+ letter-spacing: 0.04em;
831
+ border: 1px solid var(--fe-border-strong);
832
+ border-radius: 999px;
833
+ padding: 1px 8px;
834
+ color: var(--fe-text-muted);
835
+ }
836
+ .fe-conn__badge--warn {
837
+ color: var(--fe-danger);
838
+ border-color: var(--fe-danger);
839
+ }
840
+ .fe-conn__muted {
841
+ color: var(--fe-text-muted);
842
+ font-size: 12.5px;
843
+ margin: 0;
844
+ line-height: 1.5;
845
+ }
846
+ .fe-conn__empty {
847
+ border: 1px dashed var(--fe-border-strong);
848
+ border-radius: var(--fe-radius);
849
+ padding: 16px;
850
+ color: var(--fe-text-muted);
851
+ font-size: 13px;
852
+ margin: 0;
853
+ }
854
+ .fe-conn__error {
855
+ margin: 0;
856
+ color: var(--fe-danger);
857
+ font-size: 13px;
858
+ overflow-wrap: anywhere;
859
+ }
860
+ .fe-conn__testresult {
861
+ margin: 0;
862
+ font-size: 13px;
863
+ overflow-wrap: anywhere;
864
+ }
865
+ .fe-conn__testresult.is-ok {
866
+ color: #16a34a;
867
+ }
868
+ .fe-conn__testresult.is-bad {
869
+ color: var(--fe-danger);
870
+ }
871
+ .fe-conn__actions {
872
+ display: flex;
873
+ gap: 8px;
874
+ flex-wrap: wrap;
875
+ }
876
+ .fe-conn__btn {
877
+ font: inherit;
878
+ font-size: 13px;
879
+ padding: 6px 12px;
880
+ border-radius: var(--fe-radius);
881
+ border: 1px solid var(--fe-border-strong);
882
+ background: var(--fe-bg);
883
+ color: var(--fe-text);
884
+ cursor: pointer;
885
+ }
886
+ .fe-conn__btn:hover:not(:disabled) {
887
+ border-color: var(--fe-primary);
888
+ }
889
+ .fe-conn__btn:disabled {
890
+ opacity: 0.55;
891
+ cursor: default;
892
+ }
893
+ .fe-conn__btn--primary {
894
+ background: var(--fe-primary);
895
+ border-color: var(--fe-primary);
896
+ color: #fff;
897
+ }
898
+ .fe-conn__btn--danger {
899
+ color: var(--fe-danger);
900
+ }
901
+ .fe-conn__guidebar {
902
+ display: flex;
903
+ gap: 12px;
904
+ flex-wrap: wrap;
905
+ }
906
+ .fe-conn__pick {
907
+ display: flex;
908
+ flex-direction: column;
909
+ gap: 4px;
910
+ min-width: 180px;
911
+ }
912
+ </style>