@brftech/filex-core 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,283 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* NFSExportsPanel — mint, disable and revoke the NFS exports this account can
|
|
4
|
+
* be mounted through.
|
|
5
|
+
*
|
|
6
|
+
* ⚠⚠ The path this hands back IS the credential, and that is unusual enough to
|
|
7
|
+
* say twice. NFSv3 cannot authenticate a request without Kerberos, so filex
|
|
8
|
+
* binds the identity to a high-entropy export path: whoever knows it mounts as
|
|
9
|
+
* this account. It goes into an /etc/fstab line or a NAS admin page — places
|
|
10
|
+
* nobody treats as a password store — so the panel says so where the path is
|
|
11
|
+
* shown, not in a document somebody will not read.
|
|
12
|
+
*
|
|
13
|
+
* One component, every surface (see S3KeysPanel for the rule at length).
|
|
14
|
+
*/
|
|
15
|
+
import { computed, onMounted, ref } from 'vue';
|
|
16
|
+
import type { ExplorerConfig, LocaleCode } from '../types/ExplorerConfig';
|
|
17
|
+
import type { NFSExport } from '../types/NFSExports';
|
|
18
|
+
import { useLocale } from '../composables/useLocale';
|
|
19
|
+
import { useNFSExports } from '../composables/useNFSExports';
|
|
20
|
+
|
|
21
|
+
const props = defineProps<{
|
|
22
|
+
config: ExplorerConfig;
|
|
23
|
+
/** Storage names the caller may see, for the confinement picker. */
|
|
24
|
+
storages: string[];
|
|
25
|
+
}>();
|
|
26
|
+
|
|
27
|
+
const emit = defineEmits<{
|
|
28
|
+
(e: 'active', v: { host: string; port: number; enabled: boolean; path?: string; readOnly: boolean }): void;
|
|
29
|
+
}>();
|
|
30
|
+
|
|
31
|
+
const locale = computed<LocaleCode>(() => props.config.locale ?? 'tr');
|
|
32
|
+
const { t } = useLocale(locale);
|
|
33
|
+
|
|
34
|
+
const {
|
|
35
|
+
exports,
|
|
36
|
+
connection,
|
|
37
|
+
loading,
|
|
38
|
+
error,
|
|
39
|
+
canMint,
|
|
40
|
+
revealed,
|
|
41
|
+
guideExport,
|
|
42
|
+
load,
|
|
43
|
+
create,
|
|
44
|
+
setDisabled,
|
|
45
|
+
remove,
|
|
46
|
+
dismissPath,
|
|
47
|
+
} = useNFSExports(props.config);
|
|
48
|
+
|
|
49
|
+
const label = ref('');
|
|
50
|
+
const storage = ref('');
|
|
51
|
+
const prefix = ref('');
|
|
52
|
+
const readOnly = ref(true);
|
|
53
|
+
const allowCidrs = ref('');
|
|
54
|
+
const busy = ref(false);
|
|
55
|
+
const confirmRemove = ref<number | null>(null);
|
|
56
|
+
const copied = ref(false);
|
|
57
|
+
let copyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
58
|
+
|
|
59
|
+
function publish() {
|
|
60
|
+
const c = connection.value;
|
|
61
|
+
if (!c) return;
|
|
62
|
+
emit('active', {
|
|
63
|
+
host: c.host,
|
|
64
|
+
port: c.port,
|
|
65
|
+
enabled: c.enabled,
|
|
66
|
+
// Only the freshly minted path — the stored ones cannot be recovered, and
|
|
67
|
+
// printing a placeholder in a mount line somebody copies would produce a
|
|
68
|
+
// command that fails with no clue why.
|
|
69
|
+
path: revealed.value?.path,
|
|
70
|
+
readOnly: guideExport.value?.read_only ?? readOnly.value,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
onMounted(async () => {
|
|
75
|
+
await load();
|
|
76
|
+
publish();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
async function mint() {
|
|
80
|
+
busy.value = true;
|
|
81
|
+
try {
|
|
82
|
+
await create({
|
|
83
|
+
label: label.value.trim() || t('conn.nfs.defaultLabel'),
|
|
84
|
+
storage: storage.value.trim() || undefined,
|
|
85
|
+
prefix: prefix.value.trim() || undefined,
|
|
86
|
+
read_only: readOnly.value,
|
|
87
|
+
allow_cidrs: allowCidrs.value.trim() || undefined,
|
|
88
|
+
});
|
|
89
|
+
label.value = '';
|
|
90
|
+
publish();
|
|
91
|
+
} finally {
|
|
92
|
+
busy.value = false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function toggle(e: NFSExport) {
|
|
97
|
+
busy.value = true;
|
|
98
|
+
try {
|
|
99
|
+
await setDisabled(e.id, !e.disabled_at);
|
|
100
|
+
publish();
|
|
101
|
+
} finally {
|
|
102
|
+
busy.value = false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function drop(e: NFSExport) {
|
|
107
|
+
if (confirmRemove.value !== e.id) {
|
|
108
|
+
confirmRemove.value = e.id;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
confirmRemove.value = null;
|
|
112
|
+
busy.value = true;
|
|
113
|
+
try {
|
|
114
|
+
await remove(e.id);
|
|
115
|
+
publish();
|
|
116
|
+
} finally {
|
|
117
|
+
busy.value = false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The mount line for the path just minted — the thing to copy. */
|
|
122
|
+
const mountLine = computed(() => {
|
|
123
|
+
const c = connection.value;
|
|
124
|
+
const p = revealed.value?.path;
|
|
125
|
+
if (!c || !p) return '';
|
|
126
|
+
const ro = revealed.value?.export?.read_only ? ',ro' : '';
|
|
127
|
+
return `sudo mount -t nfs -o nfsvers=3,tcp,port=${c.port},mountport=${c.port},nolock${ro} ${c.host}:${p} /mnt/filex`;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
async function copyLine() {
|
|
131
|
+
const text = mountLine.value;
|
|
132
|
+
if (!text) return;
|
|
133
|
+
let ok = false;
|
|
134
|
+
try {
|
|
135
|
+
await navigator.clipboard.writeText(text);
|
|
136
|
+
ok = true;
|
|
137
|
+
} catch {
|
|
138
|
+
try {
|
|
139
|
+
const ta = document.createElement('textarea');
|
|
140
|
+
ta.value = text;
|
|
141
|
+
ta.setAttribute('readonly', '');
|
|
142
|
+
ta.style.position = 'fixed';
|
|
143
|
+
ta.style.opacity = '0';
|
|
144
|
+
document.body.appendChild(ta);
|
|
145
|
+
ta.select();
|
|
146
|
+
ok = document.execCommand('copy');
|
|
147
|
+
document.body.removeChild(ta);
|
|
148
|
+
} catch {
|
|
149
|
+
ok = false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!ok) return;
|
|
153
|
+
copied.value = true;
|
|
154
|
+
if (copyTimer) clearTimeout(copyTimer);
|
|
155
|
+
copyTimer = setTimeout(() => (copied.value = false), 1600);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function shortDate(v?: string | null): string {
|
|
159
|
+
if (!v) return '';
|
|
160
|
+
const d = new Date(v);
|
|
161
|
+
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function scopeOf(e: NFSExport): string {
|
|
165
|
+
if (!e.storage_name) return t('conn.nfs.scopeAll');
|
|
166
|
+
return e.prefix ? `${e.storage_name}/${e.prefix}` : e.storage_name;
|
|
167
|
+
}
|
|
168
|
+
</script>
|
|
169
|
+
|
|
170
|
+
<template>
|
|
171
|
+
<section class="fe-s3keys" data-testid="nfs-exports">
|
|
172
|
+
<header class="fe-s3keys__head">
|
|
173
|
+
<h4 class="fe-s3keys__title">{{ t('conn.nfs.title') }}</h4>
|
|
174
|
+
<p class="fe-s3keys__lead">{{ t('conn.nfs.lead') }}</p>
|
|
175
|
+
</header>
|
|
176
|
+
|
|
177
|
+
<!-- ⚠⚠ The one thing somebody must read before they mint one. -->
|
|
178
|
+
<p class="fe-s3keys__warn">{{ t('conn.nfs.pathIsSecret') }}</p>
|
|
179
|
+
<p v-if="connection && !connection.enabled" class="fe-s3keys__warn">
|
|
180
|
+
{{ t('conn.nfs.disabled') }}
|
|
181
|
+
</p>
|
|
182
|
+
<p v-if="canMint === false" class="fe-s3keys__muted">{{ t('conn.nfs.cannotMint') }}</p>
|
|
183
|
+
<p v-if="error" class="fe-s3keys__warn">{{ error }}</p>
|
|
184
|
+
|
|
185
|
+
<div v-if="canMint" class="fe-s3keys__form fe-nfs__form">
|
|
186
|
+
<input
|
|
187
|
+
v-model="label"
|
|
188
|
+
class="fe-cfield__input"
|
|
189
|
+
:placeholder="t('conn.nfs.label')"
|
|
190
|
+
data-testid="nfs-label"
|
|
191
|
+
/>
|
|
192
|
+
<select v-model="storage" class="fe-cfield__input" data-testid="nfs-storage">
|
|
193
|
+
<option value="">{{ t('conn.nfs.everyStorage') }}</option>
|
|
194
|
+
<option v-for="s in storages" :key="s" :value="s">{{ s }}</option>
|
|
195
|
+
</select>
|
|
196
|
+
<input
|
|
197
|
+
v-model="prefix"
|
|
198
|
+
class="fe-cfield__input"
|
|
199
|
+
:placeholder="t('conn.nfs.prefix')"
|
|
200
|
+
:disabled="!storage"
|
|
201
|
+
data-testid="nfs-prefix"
|
|
202
|
+
/>
|
|
203
|
+
<input
|
|
204
|
+
v-model="allowCidrs"
|
|
205
|
+
class="fe-cfield__input"
|
|
206
|
+
:placeholder="t('conn.nfs.allowCidrs')"
|
|
207
|
+
data-testid="nfs-cidrs"
|
|
208
|
+
/>
|
|
209
|
+
<label class="fe-nfs__ro">
|
|
210
|
+
<input type="checkbox" v-model="readOnly" data-testid="nfs-readonly" />
|
|
211
|
+
{{ t('conn.nfs.readOnly') }}
|
|
212
|
+
</label>
|
|
213
|
+
<button class="fe-s3keys__btn" :disabled="busy" data-testid="nfs-mint" @click="mint">
|
|
214
|
+
{{ t('conn.nfs.mint') }}
|
|
215
|
+
</button>
|
|
216
|
+
</div>
|
|
217
|
+
<p v-if="canMint" class="fe-s3keys__hint">{{ t('conn.nfs.readOnlyHint') }}</p>
|
|
218
|
+
|
|
219
|
+
<!-- The path, once. -->
|
|
220
|
+
<div v-if="revealed" class="fe-s3keys__secret" data-testid="nfs-path">
|
|
221
|
+
<p class="fe-s3keys__once">{{ t('conn.nfs.once') }}</p>
|
|
222
|
+
<div class="fe-s3keys__pair">
|
|
223
|
+
<code>{{ mountLine }}</code>
|
|
224
|
+
<button class="fe-s3keys__copy" @click="copyLine">
|
|
225
|
+
{{ copied ? t('conn.guide.copied') : t('conn.guide.copy') }}
|
|
226
|
+
</button>
|
|
227
|
+
</div>
|
|
228
|
+
<button class="fe-s3keys__dismiss" @click="dismissPath">
|
|
229
|
+
{{ t('conn.nfs.dismiss') }}
|
|
230
|
+
</button>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<p v-if="loading" class="fe-s3keys__muted">…</p>
|
|
234
|
+
<table v-else-if="exports.length" class="fe-s3keys__table">
|
|
235
|
+
<thead>
|
|
236
|
+
<tr>
|
|
237
|
+
<th>{{ t('conn.nfs.col.label') }}</th>
|
|
238
|
+
<th>{{ t('conn.nfs.col.scope') }}</th>
|
|
239
|
+
<th>{{ t('conn.nfs.col.mode') }}</th>
|
|
240
|
+
<th>{{ t('conn.nfs.col.lastUsed') }}</th>
|
|
241
|
+
<th></th>
|
|
242
|
+
</tr>
|
|
243
|
+
</thead>
|
|
244
|
+
<tbody>
|
|
245
|
+
<tr v-for="e in exports" :key="e.id" :class="{ 'is-off': !!e.disabled_at }">
|
|
246
|
+
<td>{{ e.label || t('conn.nfs.noLabel') }}</td>
|
|
247
|
+
<td>{{ scopeOf(e) }}</td>
|
|
248
|
+
<td>{{ e.read_only ? t('conn.nfs.modeRead') : t('conn.nfs.modeWrite') }}</td>
|
|
249
|
+
<td>{{ e.last_used_at ? shortDate(e.last_used_at) : t('conn.nfs.neverUsed') }}</td>
|
|
250
|
+
<td class="fe-s3keys__actions">
|
|
251
|
+
<button class="fe-s3keys__link" :disabled="busy" @click="toggle(e)">
|
|
252
|
+
{{ e.disabled_at ? t('conn.nfs.enable') : t('conn.nfs.disable') }}
|
|
253
|
+
</button>
|
|
254
|
+
<button class="fe-s3keys__link is-danger" :disabled="busy" @click="drop(e)">
|
|
255
|
+
{{ confirmRemove === e.id ? t('conn.nfs.confirm') : t('conn.nfs.revoke') }}
|
|
256
|
+
</button>
|
|
257
|
+
</td>
|
|
258
|
+
</tr>
|
|
259
|
+
</tbody>
|
|
260
|
+
</table>
|
|
261
|
+
<p v-else-if="canMint" class="fe-s3keys__muted">{{ t('conn.nfs.empty') }}</p>
|
|
262
|
+
</section>
|
|
263
|
+
</template>
|
|
264
|
+
|
|
265
|
+
<style>
|
|
266
|
+
/* Shares the access-key panel's layout; only the form is wider, because an
|
|
267
|
+
export has one more field than a key does. */
|
|
268
|
+
.fe-nfs__form {
|
|
269
|
+
grid-template-columns: 1.2fr 1fr 1fr 1fr auto auto;
|
|
270
|
+
}
|
|
271
|
+
@media (max-width: 900px) {
|
|
272
|
+
.fe-nfs__form {
|
|
273
|
+
grid-template-columns: 1fr;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
.fe-nfs__ro {
|
|
277
|
+
display: inline-flex;
|
|
278
|
+
align-items: center;
|
|
279
|
+
gap: 6px;
|
|
280
|
+
font-size: 12px;
|
|
281
|
+
white-space: nowrap;
|
|
282
|
+
}
|
|
283
|
+
</style>
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* S3KeysPanel — mint, list and revoke the caller's own S3 access keys.
|
|
4
|
+
*
|
|
5
|
+
* It sits above the S3 guide because the guide is only worth anything with a
|
|
6
|
+
* real key in it: the commands below fill in the access key id automatically,
|
|
7
|
+
* and the secret right after minting, which is the difference between a
|
|
8
|
+
* document and a working paste.
|
|
9
|
+
*
|
|
10
|
+
* ⚠⚠ One component, every surface. The desktop app and the web app mount this
|
|
11
|
+
* same file; neither owns a copy of the form or the revoke button. A credential
|
|
12
|
+
* surface is the worst possible place for two implementations — one of them
|
|
13
|
+
* would eventually hand out keys the other cannot revoke.
|
|
14
|
+
*
|
|
15
|
+
* ⚠ The secret leaves the server exactly once. Nothing here re-fetches it,
|
|
16
|
+
* nothing stores it, and it is dropped from memory when the user dismisses it
|
|
17
|
+
* or revokes the key — so the panel cannot become a place where a credential
|
|
18
|
+
* quietly persists.
|
|
19
|
+
*/
|
|
20
|
+
import { computed, onMounted, ref } from 'vue';
|
|
21
|
+
import type { ExplorerConfig, LocaleCode } from '../types/ExplorerConfig';
|
|
22
|
+
import type { S3AccessKey } from '../types/S3Keys';
|
|
23
|
+
import { useLocale } from '../composables/useLocale';
|
|
24
|
+
import { useS3Keys } from '../composables/useS3Keys';
|
|
25
|
+
|
|
26
|
+
const props = defineProps<{
|
|
27
|
+
config: ExplorerConfig;
|
|
28
|
+
/** Storage names the caller may see, for the confinement picker. */
|
|
29
|
+
storages: string[];
|
|
30
|
+
}>();
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits<{
|
|
33
|
+
/** The key a guide should render with, and its one-time secret. */
|
|
34
|
+
(e: 'active', v: { accessKeyID: string; secret?: string; endpoint: string; pathStyle: boolean }): void;
|
|
35
|
+
}>();
|
|
36
|
+
|
|
37
|
+
const locale = computed<LocaleCode>(() => props.config.locale ?? 'tr');
|
|
38
|
+
const { t } = useLocale(locale);
|
|
39
|
+
|
|
40
|
+
const {
|
|
41
|
+
keys,
|
|
42
|
+
connection,
|
|
43
|
+
loading,
|
|
44
|
+
error,
|
|
45
|
+
canMint,
|
|
46
|
+
revealed,
|
|
47
|
+
guideKey,
|
|
48
|
+
load,
|
|
49
|
+
create,
|
|
50
|
+
setDisabled,
|
|
51
|
+
remove,
|
|
52
|
+
dismissSecret,
|
|
53
|
+
} = useS3Keys(props.config);
|
|
54
|
+
|
|
55
|
+
const label = ref('');
|
|
56
|
+
const bucket = ref('');
|
|
57
|
+
const prefix = ref('');
|
|
58
|
+
const busy = ref(false);
|
|
59
|
+
const confirmRevoke = ref<number | null>(null);
|
|
60
|
+
const copied = ref<string | null>(null);
|
|
61
|
+
let copyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
62
|
+
|
|
63
|
+
/** Publish whatever the guide should render with, whenever it changes. */
|
|
64
|
+
function publish() {
|
|
65
|
+
const conn = connection.value;
|
|
66
|
+
if (!conn) return;
|
|
67
|
+
emit('active', {
|
|
68
|
+
accessKeyID: guideKey.value?.access_key_id ?? '',
|
|
69
|
+
// Only the freshly minted one. Re-emitting a stale secret would put a
|
|
70
|
+
// dead credential into the commands.
|
|
71
|
+
secret: revealed.value?.key?.id === guideKey.value?.id ? revealed.value?.secret : undefined,
|
|
72
|
+
endpoint: conn.endpoint,
|
|
73
|
+
pathStyle: conn.path_style,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
onMounted(async () => {
|
|
78
|
+
await load();
|
|
79
|
+
publish();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
async function mint() {
|
|
83
|
+
busy.value = true;
|
|
84
|
+
try {
|
|
85
|
+
await create({
|
|
86
|
+
label: label.value.trim() || t('conn.s3keys.defaultLabel'),
|
|
87
|
+
bucket: bucket.value.trim() || undefined,
|
|
88
|
+
prefix: prefix.value.trim() || undefined,
|
|
89
|
+
});
|
|
90
|
+
label.value = '';
|
|
91
|
+
publish();
|
|
92
|
+
} finally {
|
|
93
|
+
busy.value = false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function toggle(k: S3AccessKey) {
|
|
98
|
+
busy.value = true;
|
|
99
|
+
try {
|
|
100
|
+
await setDisabled(k.id, !k.disabled_at);
|
|
101
|
+
publish();
|
|
102
|
+
} finally {
|
|
103
|
+
busy.value = false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function revoke(k: S3AccessKey) {
|
|
108
|
+
if (confirmRevoke.value !== k.id) {
|
|
109
|
+
confirmRevoke.value = k.id;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
confirmRevoke.value = null;
|
|
113
|
+
busy.value = true;
|
|
114
|
+
try {
|
|
115
|
+
await remove(k.id);
|
|
116
|
+
publish();
|
|
117
|
+
} finally {
|
|
118
|
+
busy.value = false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Copy, with the same fallback the guide uses: `navigator.clipboard` needs a
|
|
124
|
+
* secure context, and an embed on plain http is a real deployment.
|
|
125
|
+
*/
|
|
126
|
+
async function copy(text: string, id: string) {
|
|
127
|
+
let ok = false;
|
|
128
|
+
try {
|
|
129
|
+
await navigator.clipboard.writeText(text);
|
|
130
|
+
ok = true;
|
|
131
|
+
} catch {
|
|
132
|
+
try {
|
|
133
|
+
const ta = document.createElement('textarea');
|
|
134
|
+
ta.value = text;
|
|
135
|
+
ta.setAttribute('readonly', '');
|
|
136
|
+
ta.style.position = 'fixed';
|
|
137
|
+
ta.style.opacity = '0';
|
|
138
|
+
document.body.appendChild(ta);
|
|
139
|
+
ta.select();
|
|
140
|
+
ok = document.execCommand('copy');
|
|
141
|
+
document.body.removeChild(ta);
|
|
142
|
+
} catch {
|
|
143
|
+
ok = false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!ok) return;
|
|
147
|
+
copied.value = id;
|
|
148
|
+
if (copyTimer) clearTimeout(copyTimer);
|
|
149
|
+
copyTimer = setTimeout(() => (copied.value = null), 1600);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function shortDate(v?: string | null): string {
|
|
153
|
+
if (!v) return '';
|
|
154
|
+
const d = new Date(v);
|
|
155
|
+
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** What a key is limited to, in one line. */
|
|
159
|
+
function scopeOf(k: S3AccessKey): string {
|
|
160
|
+
if (!k.bucket) return t('conn.s3keys.scopeAll');
|
|
161
|
+
return k.prefix ? `${k.bucket}/${k.prefix}` : k.bucket;
|
|
162
|
+
}
|
|
163
|
+
</script>
|
|
164
|
+
|
|
165
|
+
<template>
|
|
166
|
+
<section class="fe-s3keys" data-testid="s3-keys">
|
|
167
|
+
<header class="fe-s3keys__head">
|
|
168
|
+
<h4 class="fe-s3keys__title">{{ t('conn.s3keys.title') }}</h4>
|
|
169
|
+
<p class="fe-s3keys__lead">{{ t('conn.s3keys.lead') }}</p>
|
|
170
|
+
</header>
|
|
171
|
+
|
|
172
|
+
<!-- The operator turned the endpoint off. Saying so beats handing out a
|
|
173
|
+
key that authenticates against a 404. -->
|
|
174
|
+
<p v-if="connection && !connection.enabled" class="fe-s3keys__warn">
|
|
175
|
+
{{ t('conn.s3keys.disabled') }}
|
|
176
|
+
</p>
|
|
177
|
+
|
|
178
|
+
<p v-if="canMint === false" class="fe-s3keys__muted">{{ t('conn.s3keys.cannotMint') }}</p>
|
|
179
|
+
<p v-if="error" class="fe-s3keys__warn">{{ error }}</p>
|
|
180
|
+
|
|
181
|
+
<!-- ── mint ─────────────────────────────────────────────────── -->
|
|
182
|
+
<div v-if="canMint" class="fe-s3keys__form">
|
|
183
|
+
<input
|
|
184
|
+
v-model="label"
|
|
185
|
+
class="fe-cfield__input"
|
|
186
|
+
:placeholder="t('conn.s3keys.label')"
|
|
187
|
+
data-testid="s3-key-label"
|
|
188
|
+
/>
|
|
189
|
+
<select v-model="bucket" class="fe-cfield__input" data-testid="s3-key-bucket">
|
|
190
|
+
<option value="">{{ t('conn.s3keys.everyBucket') }}</option>
|
|
191
|
+
<option v-for="s in storages" :key="s" :value="s">{{ s }}</option>
|
|
192
|
+
</select>
|
|
193
|
+
<input
|
|
194
|
+
v-model="prefix"
|
|
195
|
+
class="fe-cfield__input"
|
|
196
|
+
:placeholder="t('conn.s3keys.prefix')"
|
|
197
|
+
:disabled="!bucket"
|
|
198
|
+
data-testid="s3-key-prefix"
|
|
199
|
+
/>
|
|
200
|
+
<button class="fe-s3keys__btn" :disabled="busy" data-testid="s3-key-mint" @click="mint">
|
|
201
|
+
{{ t('conn.s3keys.mint') }}
|
|
202
|
+
</button>
|
|
203
|
+
</div>
|
|
204
|
+
<p v-if="canMint" class="fe-s3keys__hint">{{ t('conn.s3keys.inheritNote') }}</p>
|
|
205
|
+
|
|
206
|
+
<!-- ── the one-time secret ──────────────────────────────────── -->
|
|
207
|
+
<div v-if="revealed" class="fe-s3keys__secret" data-testid="s3-key-secret">
|
|
208
|
+
<p class="fe-s3keys__once">{{ t('conn.s3keys.once') }}</p>
|
|
209
|
+
<div class="fe-s3keys__pair">
|
|
210
|
+
<span class="fe-s3keys__k">{{ t('conn.s3keys.accessKeyID') }}</span>
|
|
211
|
+
<code>{{ revealed.key.access_key_id }}</code>
|
|
212
|
+
<button class="fe-s3keys__copy" @click="copy(revealed.key.access_key_id, 'akid')">
|
|
213
|
+
{{ copied === 'akid' ? t('conn.guide.copied') : t('conn.guide.copy') }}
|
|
214
|
+
</button>
|
|
215
|
+
</div>
|
|
216
|
+
<div class="fe-s3keys__pair">
|
|
217
|
+
<span class="fe-s3keys__k">{{ t('conn.s3keys.secret') }}</span>
|
|
218
|
+
<code>{{ revealed.secret }}</code>
|
|
219
|
+
<button class="fe-s3keys__copy" @click="copy(revealed.secret, 'secret')">
|
|
220
|
+
{{ copied === 'secret' ? t('conn.guide.copied') : t('conn.guide.copy') }}
|
|
221
|
+
</button>
|
|
222
|
+
</div>
|
|
223
|
+
<button class="fe-s3keys__dismiss" @click="dismissSecret">
|
|
224
|
+
{{ t('conn.s3keys.dismiss') }}
|
|
225
|
+
</button>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<!-- ── the keys ─────────────────────────────────────────────── -->
|
|
229
|
+
<p v-if="loading" class="fe-s3keys__muted">…</p>
|
|
230
|
+
<table v-else-if="keys.length" class="fe-s3keys__table">
|
|
231
|
+
<thead>
|
|
232
|
+
<tr>
|
|
233
|
+
<th>{{ t('conn.s3keys.col.label') }}</th>
|
|
234
|
+
<th>{{ t('conn.s3keys.col.key') }}</th>
|
|
235
|
+
<th>{{ t('conn.s3keys.col.scope') }}</th>
|
|
236
|
+
<th>{{ t('conn.s3keys.col.lastUsed') }}</th>
|
|
237
|
+
<th></th>
|
|
238
|
+
</tr>
|
|
239
|
+
</thead>
|
|
240
|
+
<tbody>
|
|
241
|
+
<tr v-for="k in keys" :key="k.id" :class="{ 'is-off': !!k.disabled_at }">
|
|
242
|
+
<td>{{ k.label || t('conn.s3keys.noLabel') }}</td>
|
|
243
|
+
<td><code>{{ k.access_key_id }}</code></td>
|
|
244
|
+
<td>{{ scopeOf(k) }}</td>
|
|
245
|
+
<td>{{ k.last_used_at ? shortDate(k.last_used_at) : t('conn.s3keys.neverUsed') }}</td>
|
|
246
|
+
<td class="fe-s3keys__actions">
|
|
247
|
+
<button class="fe-s3keys__link" :disabled="busy" @click="toggle(k)">
|
|
248
|
+
{{ k.disabled_at ? t('conn.s3keys.enable') : t('conn.s3keys.disable') }}
|
|
249
|
+
</button>
|
|
250
|
+
<button class="fe-s3keys__link is-danger" :disabled="busy" @click="revoke(k)">
|
|
251
|
+
{{ confirmRevoke === k.id ? t('conn.s3keys.confirm') : t('conn.s3keys.revoke') }}
|
|
252
|
+
</button>
|
|
253
|
+
</td>
|
|
254
|
+
</tr>
|
|
255
|
+
</tbody>
|
|
256
|
+
</table>
|
|
257
|
+
<p v-else-if="canMint" class="fe-s3keys__muted">{{ t('conn.s3keys.empty') }}</p>
|
|
258
|
+
</section>
|
|
259
|
+
</template>
|
|
260
|
+
|
|
261
|
+
<style>
|
|
262
|
+
.fe-s3keys {
|
|
263
|
+
display: flex;
|
|
264
|
+
flex-direction: column;
|
|
265
|
+
gap: 10px;
|
|
266
|
+
padding: 14px;
|
|
267
|
+
border: 1px solid var(--fe-border);
|
|
268
|
+
border-radius: 10px;
|
|
269
|
+
background: var(--fe-surface);
|
|
270
|
+
}
|
|
271
|
+
.fe-s3keys__title {
|
|
272
|
+
margin: 0;
|
|
273
|
+
font-size: 14px;
|
|
274
|
+
font-weight: 600;
|
|
275
|
+
}
|
|
276
|
+
.fe-s3keys__lead,
|
|
277
|
+
.fe-s3keys__hint,
|
|
278
|
+
.fe-s3keys__muted {
|
|
279
|
+
margin: 0;
|
|
280
|
+
font-size: 12px;
|
|
281
|
+
color: var(--fe-muted);
|
|
282
|
+
}
|
|
283
|
+
.fe-s3keys__warn {
|
|
284
|
+
margin: 0;
|
|
285
|
+
font-size: 12px;
|
|
286
|
+
color: var(--fe-danger, #c0392b);
|
|
287
|
+
}
|
|
288
|
+
.fe-s3keys__form {
|
|
289
|
+
display: grid;
|
|
290
|
+
grid-template-columns: 1.4fr 1fr 1fr auto;
|
|
291
|
+
gap: 8px;
|
|
292
|
+
align-items: center;
|
|
293
|
+
}
|
|
294
|
+
@media (max-width: 640px) {
|
|
295
|
+
.fe-s3keys__form {
|
|
296
|
+
grid-template-columns: 1fr;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
.fe-s3keys__btn {
|
|
300
|
+
padding: 7px 12px;
|
|
301
|
+
border-radius: 8px;
|
|
302
|
+
border: 1px solid transparent;
|
|
303
|
+
background: var(--fe-accent, #2f6df6);
|
|
304
|
+
color: #fff;
|
|
305
|
+
font-size: 13px;
|
|
306
|
+
cursor: pointer;
|
|
307
|
+
}
|
|
308
|
+
.fe-s3keys__btn:disabled {
|
|
309
|
+
opacity: 0.55;
|
|
310
|
+
cursor: default;
|
|
311
|
+
}
|
|
312
|
+
.fe-s3keys__secret {
|
|
313
|
+
display: flex;
|
|
314
|
+
flex-direction: column;
|
|
315
|
+
gap: 6px;
|
|
316
|
+
padding: 10px;
|
|
317
|
+
border-radius: 8px;
|
|
318
|
+
border: 1px solid var(--fe-accent, #2f6df6);
|
|
319
|
+
background: var(--fe-surface-2, rgba(47, 109, 246, 0.06));
|
|
320
|
+
}
|
|
321
|
+
.fe-s3keys__once {
|
|
322
|
+
margin: 0;
|
|
323
|
+
font-size: 12px;
|
|
324
|
+
font-weight: 600;
|
|
325
|
+
}
|
|
326
|
+
.fe-s3keys__pair {
|
|
327
|
+
display: flex;
|
|
328
|
+
align-items: center;
|
|
329
|
+
gap: 8px;
|
|
330
|
+
font-size: 12px;
|
|
331
|
+
min-width: 0;
|
|
332
|
+
}
|
|
333
|
+
.fe-s3keys__pair code {
|
|
334
|
+
flex: 1;
|
|
335
|
+
overflow-wrap: anywhere;
|
|
336
|
+
font-family: var(--fe-mono, ui-monospace, monospace);
|
|
337
|
+
}
|
|
338
|
+
.fe-s3keys__k {
|
|
339
|
+
min-width: 96px;
|
|
340
|
+
color: var(--fe-muted);
|
|
341
|
+
}
|
|
342
|
+
.fe-s3keys__copy,
|
|
343
|
+
.fe-s3keys__dismiss,
|
|
344
|
+
.fe-s3keys__link {
|
|
345
|
+
background: none;
|
|
346
|
+
border: none;
|
|
347
|
+
padding: 0 4px;
|
|
348
|
+
color: var(--fe-accent, #2f6df6);
|
|
349
|
+
font-size: 12px;
|
|
350
|
+
cursor: pointer;
|
|
351
|
+
}
|
|
352
|
+
.fe-s3keys__link.is-danger {
|
|
353
|
+
color: var(--fe-danger, #c0392b);
|
|
354
|
+
}
|
|
355
|
+
.fe-s3keys__dismiss {
|
|
356
|
+
align-self: flex-start;
|
|
357
|
+
}
|
|
358
|
+
.fe-s3keys__table {
|
|
359
|
+
width: 100%;
|
|
360
|
+
border-collapse: collapse;
|
|
361
|
+
font-size: 12px;
|
|
362
|
+
}
|
|
363
|
+
.fe-s3keys__table th {
|
|
364
|
+
text-align: left;
|
|
365
|
+
font-weight: 500;
|
|
366
|
+
color: var(--fe-muted);
|
|
367
|
+
padding: 4px 6px;
|
|
368
|
+
}
|
|
369
|
+
.fe-s3keys__table td {
|
|
370
|
+
padding: 5px 6px;
|
|
371
|
+
border-top: 1px solid var(--fe-border);
|
|
372
|
+
overflow-wrap: anywhere;
|
|
373
|
+
}
|
|
374
|
+
.fe-s3keys__table tr.is-off {
|
|
375
|
+
opacity: 0.55;
|
|
376
|
+
}
|
|
377
|
+
.fe-s3keys__actions {
|
|
378
|
+
white-space: nowrap;
|
|
379
|
+
text-align: right;
|
|
380
|
+
}
|
|
381
|
+
</style>
|