@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,222 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* SSHKeysPanel — register, disable and remove the keys this account signs in
|
|
4
|
+
* with over SFTP.
|
|
5
|
+
*
|
|
6
|
+
* ⚠⚠ This is not a convenience screen. `ssh-copy-id` — the command everybody
|
|
7
|
+
* reaches for — appends to `~/.ssh/authorized_keys` over a shell, and filex has
|
|
8
|
+
* no shell. Without a box to paste a key into, public-key authentication is
|
|
9
|
+
* unreachable and every user ends up sending their account password to a file
|
|
10
|
+
* server instead. That is why the screen shipped WITH the endpoint rather than
|
|
11
|
+
* after it.
|
|
12
|
+
*
|
|
13
|
+
* One component, every surface: the desktop app and the web app mount this same
|
|
14
|
+
* file (see S3KeysPanel for the same rule stated at length).
|
|
15
|
+
*/
|
|
16
|
+
import { computed, onMounted, ref } from 'vue';
|
|
17
|
+
import type { ExplorerConfig, LocaleCode } from '../types/ExplorerConfig';
|
|
18
|
+
import type { FTPSFacts, SSHPublicKey } from '../types/SSHKeys';
|
|
19
|
+
import { useLocale } from '../composables/useLocale';
|
|
20
|
+
import { useSSHKeys } from '../composables/useSSHKeys';
|
|
21
|
+
|
|
22
|
+
const props = defineProps<{
|
|
23
|
+
config: ExplorerConfig;
|
|
24
|
+
/**
|
|
25
|
+
* Draw the key list and the paste box.
|
|
26
|
+
*
|
|
27
|
+
* ⚠ False for FTPS, which authenticates with a password or an API token and
|
|
28
|
+
* has no use for an SSH key — the panel is still MOUNTED there because it is
|
|
29
|
+
* the call that reports where the FTP endpoint listens and under what login
|
|
30
|
+
* name. Showing a key box on the FTPS page would invite somebody to register
|
|
31
|
+
* a key that protocol will never look at.
|
|
32
|
+
*/
|
|
33
|
+
keysVisible?: boolean;
|
|
34
|
+
}>();
|
|
35
|
+
|
|
36
|
+
const showKeys = computed(() => props.keysVisible !== false);
|
|
37
|
+
|
|
38
|
+
const emit = defineEmits<{
|
|
39
|
+
/** What the guide should render with: where to connect and as whom. */
|
|
40
|
+
(e: 'active', v: {
|
|
41
|
+
host: string;
|
|
42
|
+
port: number;
|
|
43
|
+
login: string;
|
|
44
|
+
enabled: boolean;
|
|
45
|
+
hasKey: boolean;
|
|
46
|
+
ftps?: FTPSFacts;
|
|
47
|
+
}): void;
|
|
48
|
+
}>();
|
|
49
|
+
|
|
50
|
+
const locale = computed<LocaleCode>(() => props.config.locale ?? 'tr');
|
|
51
|
+
const { t } = useLocale(locale);
|
|
52
|
+
|
|
53
|
+
const { keys, connection, loading, error, canAdd, hasUsableKey, load, add, setDisabled, remove } =
|
|
54
|
+
useSSHKeys(props.config);
|
|
55
|
+
|
|
56
|
+
const pasted = ref('');
|
|
57
|
+
const name = ref('');
|
|
58
|
+
const busy = ref(false);
|
|
59
|
+
const confirmRemove = ref<number | null>(null);
|
|
60
|
+
|
|
61
|
+
function publish() {
|
|
62
|
+
const c = connection.value;
|
|
63
|
+
if (!c) return;
|
|
64
|
+
emit('active', {
|
|
65
|
+
host: c.host,
|
|
66
|
+
port: c.port,
|
|
67
|
+
login: c.login,
|
|
68
|
+
enabled: c.enabled,
|
|
69
|
+
hasKey: hasUsableKey.value,
|
|
70
|
+
// The FTPS endpoint travels with the SSH facts: for the user this is one
|
|
71
|
+
// question ("how do I reach this from a program?"), and two calls to
|
|
72
|
+
// answer it would be two chances to disagree.
|
|
73
|
+
ftps: c.ftps,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
onMounted(async () => {
|
|
78
|
+
await load();
|
|
79
|
+
publish();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
async function submit() {
|
|
83
|
+
if (!pasted.value.trim()) return;
|
|
84
|
+
busy.value = true;
|
|
85
|
+
try {
|
|
86
|
+
if (await add(pasted.value.trim(), name.value.trim() || undefined)) {
|
|
87
|
+
pasted.value = '';
|
|
88
|
+
name.value = '';
|
|
89
|
+
}
|
|
90
|
+
publish();
|
|
91
|
+
} finally {
|
|
92
|
+
busy.value = false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function toggle(k: SSHPublicKey) {
|
|
97
|
+
busy.value = true;
|
|
98
|
+
try {
|
|
99
|
+
await setDisabled(k.id, !k.disabled_at);
|
|
100
|
+
publish();
|
|
101
|
+
} finally {
|
|
102
|
+
busy.value = false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function drop(k: SSHPublicKey) {
|
|
107
|
+
if (confirmRemove.value !== k.id) {
|
|
108
|
+
confirmRemove.value = k.id;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
confirmRemove.value = null;
|
|
112
|
+
busy.value = true;
|
|
113
|
+
try {
|
|
114
|
+
await remove(k.id);
|
|
115
|
+
publish();
|
|
116
|
+
} finally {
|
|
117
|
+
busy.value = false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function shortDate(v?: string | null): string {
|
|
122
|
+
if (!v) return '';
|
|
123
|
+
const d = new Date(v);
|
|
124
|
+
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Shown the way OpenSSH prints it, so it can be compared with `ssh-keygen -lf`. */
|
|
128
|
+
function fingerprintOf(k: SSHPublicKey): string {
|
|
129
|
+
return 'SHA256:' + k.fingerprint;
|
|
130
|
+
}
|
|
131
|
+
</script>
|
|
132
|
+
|
|
133
|
+
<template>
|
|
134
|
+
<section v-if="showKeys" class="fe-s3keys" data-testid="ssh-keys">
|
|
135
|
+
<header class="fe-s3keys__head">
|
|
136
|
+
<h4 class="fe-s3keys__title">{{ t('conn.sshkeys.title') }}</h4>
|
|
137
|
+
<p class="fe-s3keys__lead">{{ t('conn.sshkeys.lead') }}</p>
|
|
138
|
+
</header>
|
|
139
|
+
|
|
140
|
+
<p v-if="connection && !connection.enabled" class="fe-s3keys__warn">
|
|
141
|
+
{{ t('conn.sshkeys.disabled') }}
|
|
142
|
+
</p>
|
|
143
|
+
<p v-if="canAdd === false" class="fe-s3keys__muted">{{ t('conn.sshkeys.cannotAdd') }}</p>
|
|
144
|
+
<p v-if="error" class="fe-s3keys__warn">{{ error }}</p>
|
|
145
|
+
|
|
146
|
+
<div v-if="canAdd" class="fe-sshkeys__form">
|
|
147
|
+
<textarea
|
|
148
|
+
v-model="pasted"
|
|
149
|
+
class="fe-cfield__input fe-sshkeys__paste"
|
|
150
|
+
rows="2"
|
|
151
|
+
spellcheck="false"
|
|
152
|
+
:placeholder="t('conn.sshkeys.paste')"
|
|
153
|
+
data-testid="ssh-key-input"
|
|
154
|
+
></textarea>
|
|
155
|
+
<div class="fe-sshkeys__row">
|
|
156
|
+
<input
|
|
157
|
+
v-model="name"
|
|
158
|
+
class="fe-cfield__input"
|
|
159
|
+
:placeholder="t('conn.sshkeys.name')"
|
|
160
|
+
data-testid="ssh-key-name"
|
|
161
|
+
/>
|
|
162
|
+
<button class="fe-s3keys__btn" :disabled="busy" data-testid="ssh-key-add" @click="submit">
|
|
163
|
+
{{ t('conn.sshkeys.add') }}
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
166
|
+
<p class="fe-s3keys__hint">{{ t('conn.sshkeys.noCopyId') }}</p>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<p v-if="loading" class="fe-s3keys__muted">…</p>
|
|
170
|
+
<table v-else-if="keys.length" class="fe-s3keys__table">
|
|
171
|
+
<thead>
|
|
172
|
+
<tr>
|
|
173
|
+
<th>{{ t('conn.sshkeys.col.name') }}</th>
|
|
174
|
+
<th>{{ t('conn.sshkeys.col.fingerprint') }}</th>
|
|
175
|
+
<th>{{ t('conn.sshkeys.col.lastUsed') }}</th>
|
|
176
|
+
<th></th>
|
|
177
|
+
</tr>
|
|
178
|
+
</thead>
|
|
179
|
+
<tbody>
|
|
180
|
+
<tr v-for="k in keys" :key="k.id" :class="{ 'is-off': !!k.disabled_at }">
|
|
181
|
+
<td>{{ k.name || t('conn.sshkeys.noName') }}</td>
|
|
182
|
+
<td><code>{{ fingerprintOf(k) }}</code></td>
|
|
183
|
+
<td>{{ k.last_used_at ? shortDate(k.last_used_at) : t('conn.sshkeys.neverUsed') }}</td>
|
|
184
|
+
<td class="fe-s3keys__actions">
|
|
185
|
+
<button class="fe-s3keys__link" :disabled="busy" @click="toggle(k)">
|
|
186
|
+
{{ k.disabled_at ? t('conn.sshkeys.enable') : t('conn.sshkeys.disable') }}
|
|
187
|
+
</button>
|
|
188
|
+
<button class="fe-s3keys__link is-danger" :disabled="busy" @click="drop(k)">
|
|
189
|
+
{{ confirmRemove === k.id ? t('conn.sshkeys.confirm') : t('conn.sshkeys.remove') }}
|
|
190
|
+
</button>
|
|
191
|
+
</td>
|
|
192
|
+
</tr>
|
|
193
|
+
</tbody>
|
|
194
|
+
</table>
|
|
195
|
+
<p v-else-if="canAdd" class="fe-s3keys__muted">{{ t('conn.sshkeys.empty') }}</p>
|
|
196
|
+
</section>
|
|
197
|
+
</template>
|
|
198
|
+
|
|
199
|
+
<style>
|
|
200
|
+
/* The layout is shared with the access-key panel (fe-s3keys__*); only the
|
|
201
|
+
paste box is different, because a public key is two lines long. */
|
|
202
|
+
.fe-sshkeys__form {
|
|
203
|
+
display: flex;
|
|
204
|
+
flex-direction: column;
|
|
205
|
+
gap: 8px;
|
|
206
|
+
}
|
|
207
|
+
.fe-sshkeys__paste {
|
|
208
|
+
width: 100%;
|
|
209
|
+
font-family: var(--fe-mono, ui-monospace, monospace);
|
|
210
|
+
font-size: 12px;
|
|
211
|
+
resize: vertical;
|
|
212
|
+
overflow-wrap: anywhere;
|
|
213
|
+
}
|
|
214
|
+
.fe-sshkeys__row {
|
|
215
|
+
display: flex;
|
|
216
|
+
gap: 8px;
|
|
217
|
+
align-items: center;
|
|
218
|
+
}
|
|
219
|
+
.fe-sshkeys__row .fe-cfield__input {
|
|
220
|
+
flex: 1;
|
|
221
|
+
}
|
|
222
|
+
</style>
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* StorageFields — a driver's config form, rendered from the driver's own
|
|
4
|
+
* descriptor (`GET /api/admin/storage-drivers`) and from nothing else.
|
|
5
|
+
*
|
|
6
|
+
* ⚠ There is no per-driver template here and there must never be one. The
|
|
7
|
+
* form this replaces on the web was a `v-if` chain that drifted away from
|
|
8
|
+
* the backend it posted to — it asked for `base_path` where the driver read
|
|
9
|
+
* `root` and never offered the s3 `prefix` at all, so three of the four
|
|
10
|
+
* drivers it offered answered 400 ROOT_PATH_FORBIDDEN on every submit. The
|
|
11
|
+
* descriptor fixes the class; a hand-written form re-opens it.
|
|
12
|
+
*
|
|
13
|
+
* It lives in the shared package so the desktop app and the web app render
|
|
14
|
+
* the same fields, in the same order, with the same help text, from the
|
|
15
|
+
* same declaration — which is the only way "the same form on every surface"
|
|
16
|
+
* survives the next driver.
|
|
17
|
+
*/
|
|
18
|
+
import { computed, ref } from 'vue';
|
|
19
|
+
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
20
|
+
import type { StorageField, StorageFieldOption } from '../types/Connections';
|
|
21
|
+
import { useLocale } from '../composables/useLocale';
|
|
22
|
+
|
|
23
|
+
const props = defineProps<{
|
|
24
|
+
fields: StorageField[];
|
|
25
|
+
modelValue: Record<string, unknown>;
|
|
26
|
+
locale: LocaleCode;
|
|
27
|
+
/** Keys to mark as failing validation (missing required). */
|
|
28
|
+
invalid?: string[];
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
}>();
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits<{
|
|
33
|
+
(e: 'update:modelValue', v: Record<string, unknown>): void;
|
|
34
|
+
}>();
|
|
35
|
+
|
|
36
|
+
const { t } = useLocale(() => props.locale);
|
|
37
|
+
|
|
38
|
+
const showAdvanced = ref(false);
|
|
39
|
+
const revealed = ref<Record<string, boolean>>({});
|
|
40
|
+
|
|
41
|
+
const basic = computed(() => props.fields.filter((f) => !f.advanced));
|
|
42
|
+
const advanced = computed(() => props.fields.filter((f) => f.advanced));
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Translation first, the driver's English fallback second.
|
|
46
|
+
*
|
|
47
|
+
* A driver shipped after this bundle carries labels the catalogue has never
|
|
48
|
+
* heard of. Falling back to the descriptor's own English is how the field
|
|
49
|
+
* still reads as a field instead of as `storages.fields.something`.
|
|
50
|
+
*/
|
|
51
|
+
function label(f: StorageField): string {
|
|
52
|
+
const s = t(f.i18n_key);
|
|
53
|
+
return s === f.i18n_key ? f.label : s;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function help(f: StorageField): string {
|
|
57
|
+
if (!f.help_i18n_key) return f.help ?? '';
|
|
58
|
+
const s = t(f.help_i18n_key);
|
|
59
|
+
return s === f.help_i18n_key ? (f.help ?? '') : s;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function optionLabel(o: StorageFieldOption): string {
|
|
63
|
+
if (!o.i18n_key) return o.label;
|
|
64
|
+
const s = t(o.i18n_key);
|
|
65
|
+
return s === o.i18n_key ? o.label : s;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function set(key: string, value: unknown) {
|
|
69
|
+
emit('update:modelValue', { ...props.modelValue, [key]: value });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Current value, honouring the legacy spellings the driver still reads —
|
|
73
|
+
* an old row keyed `base_path` shows up in the `root` box rather than as
|
|
74
|
+
* an empty field the operator would refill by hand. */
|
|
75
|
+
function raw(f: StorageField): unknown {
|
|
76
|
+
const v = props.modelValue?.[f.key];
|
|
77
|
+
if (v !== undefined && v !== null) return v;
|
|
78
|
+
for (const alias of f.aliases ?? []) {
|
|
79
|
+
const a = props.modelValue?.[alias];
|
|
80
|
+
if (a !== undefined && a !== null) return a;
|
|
81
|
+
}
|
|
82
|
+
return f.default ?? undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function str(f: StorageField): string {
|
|
86
|
+
const v = raw(f);
|
|
87
|
+
return v === undefined || v === null ? '' : String(v);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function num(f: StorageField): string {
|
|
91
|
+
const v = raw(f);
|
|
92
|
+
return v === '' || v === undefined || v === null ? '' : String(v);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function bool(f: StorageField): boolean {
|
|
96
|
+
return raw(f) === true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isInvalid(f: StorageField): boolean {
|
|
100
|
+
return (props.invalid ?? []).includes(f.key);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function inputType(f: StorageField): string {
|
|
104
|
+
if (f.type === 'int') return 'number';
|
|
105
|
+
if (f.type === 'password' && !revealed.value[f.key]) return 'password';
|
|
106
|
+
return 'text';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function toggleReveal(key: string) {
|
|
110
|
+
revealed.value = { ...revealed.value, [key]: !revealed.value[key] };
|
|
111
|
+
}
|
|
112
|
+
</script>
|
|
113
|
+
|
|
114
|
+
<template>
|
|
115
|
+
<div class="fe-cfield">
|
|
116
|
+
<p v-if="!fields.length" class="fe-cfield__empty">{{ t('conn.form.noFields') }}</p>
|
|
117
|
+
|
|
118
|
+
<template v-for="f in basic" :key="f.key">
|
|
119
|
+
<div class="fe-cfield__row" :class="{ 'is-invalid': isInvalid(f) }">
|
|
120
|
+
<!-- bool: the label belongs next to the box, not above it -->
|
|
121
|
+
<label v-if="f.type === 'bool'" class="fe-cfield__check">
|
|
122
|
+
<input
|
|
123
|
+
type="checkbox"
|
|
124
|
+
:checked="bool(f)"
|
|
125
|
+
:disabled="disabled"
|
|
126
|
+
@change="set(f.key, ($event.target as HTMLInputElement).checked)"
|
|
127
|
+
/>
|
|
128
|
+
<span>{{ label(f) }}</span>
|
|
129
|
+
</label>
|
|
130
|
+
<label v-else class="fe-cfield__label" :for="`fe-cf-${f.key}`">
|
|
131
|
+
{{ label(f) }}<span v-if="f.required" class="fe-cfield__req" aria-hidden="true">*</span>
|
|
132
|
+
</label>
|
|
133
|
+
|
|
134
|
+
<select
|
|
135
|
+
v-if="f.type === 'select'"
|
|
136
|
+
:id="`fe-cf-${f.key}`"
|
|
137
|
+
class="fe-cfield__input"
|
|
138
|
+
:value="str(f)"
|
|
139
|
+
:disabled="disabled"
|
|
140
|
+
@change="set(f.key, ($event.target as HTMLSelectElement).value)"
|
|
141
|
+
>
|
|
142
|
+
<option v-for="o in f.options ?? []" :key="o.value" :value="o.value">
|
|
143
|
+
{{ optionLabel(o) }}
|
|
144
|
+
</option>
|
|
145
|
+
</select>
|
|
146
|
+
|
|
147
|
+
<textarea
|
|
148
|
+
v-else-if="f.multiline"
|
|
149
|
+
:id="`fe-cf-${f.key}`"
|
|
150
|
+
class="fe-cfield__input fe-cfield__input--area"
|
|
151
|
+
:class="{ 'fe-cfield__input--mono': f.monospace }"
|
|
152
|
+
rows="4"
|
|
153
|
+
:value="str(f)"
|
|
154
|
+
:placeholder="f.placeholder"
|
|
155
|
+
:disabled="disabled"
|
|
156
|
+
@input="set(f.key, ($event.target as HTMLTextAreaElement).value)"
|
|
157
|
+
></textarea>
|
|
158
|
+
|
|
159
|
+
<div v-else-if="f.type === 'password'" class="fe-cfield__secret">
|
|
160
|
+
<input
|
|
161
|
+
:id="`fe-cf-${f.key}`"
|
|
162
|
+
class="fe-cfield__input fe-cfield__input--mono"
|
|
163
|
+
:type="inputType(f)"
|
|
164
|
+
autocomplete="new-password"
|
|
165
|
+
spellcheck="false"
|
|
166
|
+
:value="str(f)"
|
|
167
|
+
:placeholder="f.placeholder"
|
|
168
|
+
:disabled="disabled"
|
|
169
|
+
@input="set(f.key, ($event.target as HTMLInputElement).value)"
|
|
170
|
+
/>
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
class="fe-cfield__eye"
|
|
174
|
+
:title="revealed[f.key] ? t('conn.form.hide') : t('conn.form.reveal')"
|
|
175
|
+
:aria-label="revealed[f.key] ? t('conn.form.hide') : t('conn.form.reveal')"
|
|
176
|
+
@click="toggleReveal(f.key)"
|
|
177
|
+
>
|
|
178
|
+
{{ revealed[f.key] ? '🙈' : '👁' }}
|
|
179
|
+
</button>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<input
|
|
183
|
+
v-else-if="f.type !== 'bool'"
|
|
184
|
+
:id="`fe-cf-${f.key}`"
|
|
185
|
+
class="fe-cfield__input"
|
|
186
|
+
:class="{ 'fe-cfield__input--mono': f.monospace }"
|
|
187
|
+
:type="inputType(f)"
|
|
188
|
+
:value="f.type === 'int' ? num(f) : str(f)"
|
|
189
|
+
:placeholder="f.placeholder"
|
|
190
|
+
:min="f.min"
|
|
191
|
+
:max="f.max"
|
|
192
|
+
:disabled="disabled"
|
|
193
|
+
@input="
|
|
194
|
+
set(
|
|
195
|
+
f.key,
|
|
196
|
+
f.type === 'int'
|
|
197
|
+
? ($event.target as HTMLInputElement).value === ''
|
|
198
|
+
? null
|
|
199
|
+
: Number(($event.target as HTMLInputElement).value)
|
|
200
|
+
: ($event.target as HTMLInputElement).value,
|
|
201
|
+
)
|
|
202
|
+
"
|
|
203
|
+
/>
|
|
204
|
+
|
|
205
|
+
<p v-if="help(f)" class="fe-cfield__help">{{ help(f) }}</p>
|
|
206
|
+
<p v-if="isInvalid(f)" class="fe-cfield__error">{{ t('conn.form.required') }}</p>
|
|
207
|
+
</div>
|
|
208
|
+
</template>
|
|
209
|
+
|
|
210
|
+
<div v-if="advanced.length" class="fe-cfield__adv">
|
|
211
|
+
<button type="button" class="fe-cfield__advtoggle" @click="showAdvanced = !showAdvanced">
|
|
212
|
+
{{ showAdvanced ? '▾' : '▸' }} {{ t('conn.form.advanced') }}
|
|
213
|
+
</button>
|
|
214
|
+
<div v-if="showAdvanced" class="fe-cfield__advbody">
|
|
215
|
+
<div v-for="f in advanced" :key="f.key" class="fe-cfield__row">
|
|
216
|
+
<label v-if="f.type === 'bool'" class="fe-cfield__check">
|
|
217
|
+
<input
|
|
218
|
+
type="checkbox"
|
|
219
|
+
:checked="bool(f)"
|
|
220
|
+
:disabled="disabled"
|
|
221
|
+
@change="set(f.key, ($event.target as HTMLInputElement).checked)"
|
|
222
|
+
/>
|
|
223
|
+
<span>{{ label(f) }}</span>
|
|
224
|
+
</label>
|
|
225
|
+
<template v-else>
|
|
226
|
+
<label class="fe-cfield__label" :for="`fe-cfa-${f.key}`">{{ label(f) }}</label>
|
|
227
|
+
<input
|
|
228
|
+
:id="`fe-cfa-${f.key}`"
|
|
229
|
+
class="fe-cfield__input"
|
|
230
|
+
:class="{ 'fe-cfield__input--mono': f.monospace }"
|
|
231
|
+
:type="inputType(f)"
|
|
232
|
+
:value="f.type === 'int' ? num(f) : str(f)"
|
|
233
|
+
:placeholder="f.placeholder"
|
|
234
|
+
:disabled="disabled"
|
|
235
|
+
@input="
|
|
236
|
+
set(
|
|
237
|
+
f.key,
|
|
238
|
+
f.type === 'int'
|
|
239
|
+
? ($event.target as HTMLInputElement).value === ''
|
|
240
|
+
? null
|
|
241
|
+
: Number(($event.target as HTMLInputElement).value)
|
|
242
|
+
: ($event.target as HTMLInputElement).value,
|
|
243
|
+
)
|
|
244
|
+
"
|
|
245
|
+
/>
|
|
246
|
+
</template>
|
|
247
|
+
<p v-if="help(f)" class="fe-cfield__help">{{ help(f) }}</p>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
</template>
|
|
253
|
+
|
|
254
|
+
<style>
|
|
255
|
+
.fe-cfield {
|
|
256
|
+
display: flex;
|
|
257
|
+
flex-direction: column;
|
|
258
|
+
gap: 14px;
|
|
259
|
+
}
|
|
260
|
+
.fe-cfield__empty {
|
|
261
|
+
margin: 0;
|
|
262
|
+
color: var(--fe-text-muted);
|
|
263
|
+
font-size: 13px;
|
|
264
|
+
}
|
|
265
|
+
.fe-cfield__row {
|
|
266
|
+
display: flex;
|
|
267
|
+
flex-direction: column;
|
|
268
|
+
gap: 5px;
|
|
269
|
+
}
|
|
270
|
+
.fe-cfield__label {
|
|
271
|
+
font-size: 13px;
|
|
272
|
+
font-weight: 600;
|
|
273
|
+
color: var(--fe-text);
|
|
274
|
+
}
|
|
275
|
+
.fe-cfield__req {
|
|
276
|
+
color: var(--fe-danger);
|
|
277
|
+
margin-left: 3px;
|
|
278
|
+
}
|
|
279
|
+
.fe-cfield__input {
|
|
280
|
+
font: inherit;
|
|
281
|
+
font-size: 13.5px;
|
|
282
|
+
padding: 8px 10px;
|
|
283
|
+
border-radius: var(--fe-radius);
|
|
284
|
+
border: 1px solid var(--fe-border-strong);
|
|
285
|
+
background: var(--fe-bg);
|
|
286
|
+
color: var(--fe-text);
|
|
287
|
+
width: 100%;
|
|
288
|
+
/* ⚠ Explicit, because the host's own form styling reaches in here
|
|
289
|
+
(shadowRoot: false). The desktop shell sets `input[type="text"]
|
|
290
|
+
{ min-width: 260px }` for its settings rows; inherited, that floors
|
|
291
|
+
every field in this form and overflows a narrow embed. */
|
|
292
|
+
min-width: 0;
|
|
293
|
+
box-sizing: border-box;
|
|
294
|
+
}
|
|
295
|
+
.fe-cfield__input:focus {
|
|
296
|
+
outline: 2px solid var(--fe-primary);
|
|
297
|
+
outline-offset: -1px;
|
|
298
|
+
}
|
|
299
|
+
.fe-cfield__input--mono {
|
|
300
|
+
font-family: var(--fe-font-mono);
|
|
301
|
+
font-size: 12.5px;
|
|
302
|
+
}
|
|
303
|
+
.fe-cfield__input--area {
|
|
304
|
+
resize: vertical;
|
|
305
|
+
}
|
|
306
|
+
.fe-cfield__row.is-invalid .fe-cfield__input {
|
|
307
|
+
border-color: var(--fe-danger);
|
|
308
|
+
}
|
|
309
|
+
.fe-cfield__secret {
|
|
310
|
+
display: flex;
|
|
311
|
+
gap: 6px;
|
|
312
|
+
align-items: stretch;
|
|
313
|
+
}
|
|
314
|
+
.fe-cfield__eye {
|
|
315
|
+
flex: 0 0 auto;
|
|
316
|
+
border: 1px solid var(--fe-border-strong);
|
|
317
|
+
background: var(--fe-bg-elev);
|
|
318
|
+
color: var(--fe-text);
|
|
319
|
+
border-radius: var(--fe-radius);
|
|
320
|
+
padding: 0 10px;
|
|
321
|
+
cursor: pointer;
|
|
322
|
+
font-size: 14px;
|
|
323
|
+
}
|
|
324
|
+
.fe-cfield__check {
|
|
325
|
+
display: flex;
|
|
326
|
+
align-items: center;
|
|
327
|
+
gap: 8px;
|
|
328
|
+
font-size: 13.5px;
|
|
329
|
+
color: var(--fe-text);
|
|
330
|
+
cursor: pointer;
|
|
331
|
+
}
|
|
332
|
+
.fe-cfield__help {
|
|
333
|
+
margin: 0;
|
|
334
|
+
font-size: 12px;
|
|
335
|
+
color: var(--fe-text-muted);
|
|
336
|
+
line-height: 1.45;
|
|
337
|
+
}
|
|
338
|
+
.fe-cfield__error {
|
|
339
|
+
margin: 0;
|
|
340
|
+
font-size: 12px;
|
|
341
|
+
color: var(--fe-danger);
|
|
342
|
+
}
|
|
343
|
+
.fe-cfield__advtoggle {
|
|
344
|
+
border: 0;
|
|
345
|
+
background: none;
|
|
346
|
+
color: var(--fe-text-muted);
|
|
347
|
+
font: inherit;
|
|
348
|
+
font-size: 12.5px;
|
|
349
|
+
font-weight: 600;
|
|
350
|
+
cursor: pointer;
|
|
351
|
+
padding: 2px 0;
|
|
352
|
+
}
|
|
353
|
+
.fe-cfield__advtoggle:hover {
|
|
354
|
+
color: var(--fe-text);
|
|
355
|
+
}
|
|
356
|
+
.fe-cfield__advbody {
|
|
357
|
+
display: flex;
|
|
358
|
+
flex-direction: column;
|
|
359
|
+
gap: 14px;
|
|
360
|
+
margin-top: 12px;
|
|
361
|
+
}
|
|
362
|
+
</style>
|