@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.
- package/README.md +34 -4
- package/dist/filex-core.js +10626 -6501
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +94 -63
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index.d.ts +1026 -7
- package/dist/style.css +1 -1
- package/package.json +3 -3
- package/src/FileExplorer.vue +103 -68
- package/src/components/ConnectionGuideView.vue +333 -0
- package/src/components/ConnectionsPanel.vue +912 -0
- package/src/components/NFSExportsPanel.vue +283 -0
- package/src/components/S3KeysPanel.vue +381 -0
- package/src/components/SSHKeysPanel.vue +222 -0
- package/src/components/StorageFields.vue +362 -0
- package/src/components/TokensPanel.vue +191 -0
- package/src/components/UploadProgress.vue +5 -1
- package/src/composables/useConnections.ts +271 -0
- package/src/composables/useFileApi.ts +15 -2
- package/src/composables/useNFSExports.ts +148 -0
- package/src/composables/useS3Keys.ts +175 -0
- package/src/composables/useSSHKeys.ts +119 -0
- package/src/composables/useThumbs.ts +1 -1
- package/src/composables/useTokens.ts +121 -0
- package/src/composables/useUploadChunked.ts +433 -164
- package/src/index.ts +77 -2
- package/src/lib/connectionGuides.ts +1279 -0
- package/src/lib/realtime.ts +1 -1
- package/src/lib/uploadResume.ts +157 -0
- package/src/locales/en.ts +413 -0
- package/src/locales/tr.ts +416 -0
- package/src/modals/ConvertModal.vue +1 -1
- package/src/styles/base.css +12 -12
- package/src/types/Connections.ts +122 -0
- package/src/types/ExplorerConfig.ts +23 -2
- package/src/types/NFSExports.ts +47 -0
- package/src/types/S3Keys.ts +55 -0
- package/src/types/SSHKeys.ts +54 -0
- 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>
|