@brftech/filex-core 0.29.0 → 0.30.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 +35 -0
- package/dist/filex-core.js +13511 -12642
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +71 -71
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index.d.ts +78 -0
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +440 -4
- package/src/components/Breadcrumb.vue +14 -1
- package/src/components/RecentlyOpened.vue +9 -1
- package/src/components/SideNav.vue +409 -0
- package/src/components/TokensPanel.vue +109 -1
- package/src/components/Toolbar.vue +98 -3
- package/src/locales/en.ts +34 -0
- package/src/locales/tr.ts +34 -0
- package/src/styles/base.css +360 -0
- package/src/types/ExplorerConfig.ts +68 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* SideNav — the explorer's navigation panel.
|
|
4
|
+
*
|
|
5
|
+
* A left column inside `.fe__main`, sibling to `.fe__primary` and the mirror
|
|
6
|
+
* image of `InspectorPanel` on the other side. Three sections, top to bottom:
|
|
7
|
+
* the primary action (Upload, with New folder as a secondary), the views
|
|
8
|
+
* (Recent / Starred / Shared with me / Trash), and the storages the caller can
|
|
9
|
+
* see.
|
|
10
|
+
*
|
|
11
|
+
* Why it exists (GitHub #14): the explorer's shape — split pane, tabs, three
|
|
12
|
+
* view modes, "How to connect" — is a power-user tool, and the reporter's
|
|
13
|
+
* users read that as a file manager they would have to relearn. The answer is
|
|
14
|
+
* not a second UI: it is one explorer with the navigation people already know
|
|
15
|
+
* from Drive, that anyone can collapse when they want the width back.
|
|
16
|
+
*
|
|
17
|
+
* Collapse goes to a RAIL, not to nothing. A panel that vanishes takes its own
|
|
18
|
+
* re-open affordance with it; the rail keeps every destination one click away
|
|
19
|
+
* and every icon tab-reachable, with its label in `title` + `aria-label`.
|
|
20
|
+
*
|
|
21
|
+
* ⚠ Presentational only. It fetches nothing and owns no listing state — the
|
|
22
|
+
* host (FileExplorer) loads the views and tells this component which one is
|
|
23
|
+
* active, exactly as it does for Toolbar. Two components fetching the same
|
|
24
|
+
* list is how the panel and the pane end up disagreeing.
|
|
25
|
+
*
|
|
26
|
+
* ⚠ No `<style>` block, scoped or otherwise: the package's CSS lives in
|
|
27
|
+
* `styles/base.css`. A scoped block compiles to `.cls[data-v-HASH]` and the
|
|
28
|
+
* hash does not match in the web-component build, so the rules silently stop
|
|
29
|
+
* applying in every embed (measured on the share dialog: raw unstyled HTML).
|
|
30
|
+
*/
|
|
31
|
+
import { computed } from 'vue';
|
|
32
|
+
import { useLocale } from '../composables/useLocale';
|
|
33
|
+
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
34
|
+
|
|
35
|
+
/** The virtual listings the panel can open. '' = an ordinary folder. */
|
|
36
|
+
export type NavView = '' | 'recent' | 'starred' | 'shared' | 'trash';
|
|
37
|
+
|
|
38
|
+
export interface NavStorage {
|
|
39
|
+
name: string;
|
|
40
|
+
label?: string;
|
|
41
|
+
driver?: string;
|
|
42
|
+
readOnly?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const props = defineProps<{
|
|
46
|
+
/** Expanded (labels visible) vs collapsed to the icon rail. */
|
|
47
|
+
expanded: boolean;
|
|
48
|
+
/** Narrow/embed mode — the panel is a drawer over the listing, not a column. */
|
|
49
|
+
narrow?: boolean;
|
|
50
|
+
/** Which virtual view is on screen, so the row can read as selected. */
|
|
51
|
+
activeView: NavView;
|
|
52
|
+
/** Storage currently open ('' at the multi-storage root). */
|
|
53
|
+
activeStorage?: string;
|
|
54
|
+
/** Storages the caller can see (ExplorerConfig.storages). */
|
|
55
|
+
storages: NavStorage[];
|
|
56
|
+
/**
|
|
57
|
+
* Names of storages the caller reaches through a GRANT rather than their own
|
|
58
|
+
* role — Drive's "shared drives". Marked, not sorted out: the reporter's ask
|
|
59
|
+
* was that they "just appear there", one click, no mount instructions.
|
|
60
|
+
*/
|
|
61
|
+
sharedStorages?: string[];
|
|
62
|
+
/** Show the Trash entry (mirrors ExplorerConfig.trashVisible). */
|
|
63
|
+
trashVisible?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Show the Connections entries — "How to connect" and "API keys".
|
|
66
|
+
* ⚠ Never derived from a role here or anywhere: the backend decides what a
|
|
67
|
+
* caller may see, and hiding the surface client-side only hides it from the
|
|
68
|
+
* accounts that need it (see ExplorerConfig.connections).
|
|
69
|
+
*/
|
|
70
|
+
showConnections?: boolean;
|
|
71
|
+
/** RBAC/root state — false hides the write affordances. */
|
|
72
|
+
canWrite?: boolean;
|
|
73
|
+
locale: LocaleCode;
|
|
74
|
+
}>();
|
|
75
|
+
|
|
76
|
+
const emit = defineEmits<{
|
|
77
|
+
(e: 'toggle'): void;
|
|
78
|
+
(e: 'open-view', view: Exclude<NavView, ''>): void;
|
|
79
|
+
(e: 'open-storage', name: string): void;
|
|
80
|
+
(e: 'open-root'): void;
|
|
81
|
+
(e: 'upload'): void;
|
|
82
|
+
(e: 'new-folder'): void;
|
|
83
|
+
(e: 'open-connections'): void;
|
|
84
|
+
(e: 'open-tokens'): void;
|
|
85
|
+
/** Drawer scrim / Esc — narrow mode only. */
|
|
86
|
+
(e: 'close'): void;
|
|
87
|
+
}>();
|
|
88
|
+
|
|
89
|
+
const { t } = useLocale(() => props.locale);
|
|
90
|
+
|
|
91
|
+
// In drawer mode "expanded" is the only meaningful state: a rail inside an
|
|
92
|
+
// overlay would be an overlay that shows nothing but icons while covering the
|
|
93
|
+
// listing anyway.
|
|
94
|
+
const showLabels = computed(() => props.expanded || !!props.narrow);
|
|
95
|
+
|
|
96
|
+
const sharedSet = computed(() => new Set(props.sharedStorages ?? []));
|
|
97
|
+
|
|
98
|
+
const views = computed(() => {
|
|
99
|
+
const list: Array<{ key: Exclude<NavView, ''>; label: string }> = [
|
|
100
|
+
{ key: 'recent', label: t('sidenav.recent') },
|
|
101
|
+
{ key: 'starred', label: t('sidenav.starred') },
|
|
102
|
+
{ key: 'shared', label: t('sidenav.shared') },
|
|
103
|
+
];
|
|
104
|
+
if (props.trashVisible !== false) list.push({ key: 'trash', label: t('sidenav.trash') });
|
|
105
|
+
return list;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const writable = computed(() => props.canWrite !== false);
|
|
109
|
+
|
|
110
|
+
const toggleLabel = computed(() =>
|
|
111
|
+
props.narrow
|
|
112
|
+
? t('sidenav.close')
|
|
113
|
+
: props.expanded
|
|
114
|
+
? t('sidenav.collapse')
|
|
115
|
+
: t('sidenav.expand'),
|
|
116
|
+
);
|
|
117
|
+
</script>
|
|
118
|
+
|
|
119
|
+
<template>
|
|
120
|
+
<nav
|
|
121
|
+
class="fe-sidenav"
|
|
122
|
+
:class="{
|
|
123
|
+
'fe-sidenav--rail': !expanded && !narrow,
|
|
124
|
+
'fe-sidenav--drawer': narrow,
|
|
125
|
+
}"
|
|
126
|
+
role="navigation"
|
|
127
|
+
:aria-label="t('sidenav.title')"
|
|
128
|
+
data-testid="sidenav"
|
|
129
|
+
>
|
|
130
|
+
<div class="fe-sidenav__head">
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
class="fe-sidenav__toggle"
|
|
134
|
+
:aria-expanded="showLabels"
|
|
135
|
+
:title="toggleLabel"
|
|
136
|
+
:aria-label="toggleLabel"
|
|
137
|
+
data-testid="sidenav-toggle"
|
|
138
|
+
@click="narrow ? emit('close') : emit('toggle')"
|
|
139
|
+
>
|
|
140
|
+
<!-- The glyph says what the control does NEXT, and each state gets
|
|
141
|
+
its own: an expanded panel closes (arrow into the sidebar), a rail
|
|
142
|
+
opens (arrow out of it), a drawer dismisses (cross). One hamburger
|
|
143
|
+
for all three reads as decoration — and this toolbar already
|
|
144
|
+
carries two other three-line glyphs. -->
|
|
145
|
+
<svg
|
|
146
|
+
class="fe-ficon"
|
|
147
|
+
viewBox="0 0 24 24"
|
|
148
|
+
fill="none"
|
|
149
|
+
stroke="currentColor"
|
|
150
|
+
stroke-width="1.8"
|
|
151
|
+
stroke-linecap="round"
|
|
152
|
+
stroke-linejoin="round"
|
|
153
|
+
aria-hidden="true"
|
|
154
|
+
focusable="false"
|
|
155
|
+
>
|
|
156
|
+
<template v-if="narrow">
|
|
157
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
158
|
+
</template>
|
|
159
|
+
<template v-else-if="expanded">
|
|
160
|
+
<rect x="3.5" y="4.5" width="17" height="15" rx="2" />
|
|
161
|
+
<path d="M9.5 4.5v15" />
|
|
162
|
+
<path d="M17 9.5L14.5 12l2.5 2.5" />
|
|
163
|
+
</template>
|
|
164
|
+
<template v-else>
|
|
165
|
+
<rect x="3.5" y="4.5" width="17" height="15" rx="2" />
|
|
166
|
+
<path d="M9.5 4.5v15" />
|
|
167
|
+
<path d="M14.5 9.5L17 12l-2.5 2.5" />
|
|
168
|
+
</template>
|
|
169
|
+
</svg>
|
|
170
|
+
</button>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<!-- Primary action. Upload is the thing people come here to do, so in the
|
|
174
|
+
panel it reads as the main button rather than one toolbar icon among
|
|
175
|
+
fourteen. New folder stays, one step quieter. -->
|
|
176
|
+
<!-- Rendered even with nowhere to write, and disabled instead. A block
|
|
177
|
+
that appears and disappears makes every row below it jump by 90px each
|
|
178
|
+
time the user opens a view, which reads as the panel reloading. -->
|
|
179
|
+
<div class="fe-sidenav__primary">
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
class="fe-sidenav__upload"
|
|
183
|
+
:disabled="!writable"
|
|
184
|
+
:title="t('toolbar.upload')"
|
|
185
|
+
:aria-label="t('toolbar.upload')"
|
|
186
|
+
data-testid="sidenav-upload"
|
|
187
|
+
@click="emit('upload')"
|
|
188
|
+
>
|
|
189
|
+
<svg
|
|
190
|
+
class="fe-ficon"
|
|
191
|
+
viewBox="0 0 24 24"
|
|
192
|
+
fill="none"
|
|
193
|
+
stroke="currentColor"
|
|
194
|
+
stroke-width="1.9"
|
|
195
|
+
stroke-linecap="round"
|
|
196
|
+
stroke-linejoin="round"
|
|
197
|
+
aria-hidden="true"
|
|
198
|
+
focusable="false"
|
|
199
|
+
>
|
|
200
|
+
<path d="M12 16V4" />
|
|
201
|
+
<path d="M7 9l5-5 5 5" />
|
|
202
|
+
<path d="M4 17v2a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-2" />
|
|
203
|
+
</svg>
|
|
204
|
+
<span v-if="showLabels" class="fe-sidenav__text">{{ t('toolbar.upload') }}</span>
|
|
205
|
+
</button>
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
class="fe-sidenav__secondary"
|
|
209
|
+
:disabled="!writable"
|
|
210
|
+
:title="t('toolbar.new_folder')"
|
|
211
|
+
:aria-label="t('toolbar.new_folder')"
|
|
212
|
+
data-testid="sidenav-new-folder"
|
|
213
|
+
@click="emit('new-folder')"
|
|
214
|
+
>
|
|
215
|
+
<svg
|
|
216
|
+
class="fe-ficon"
|
|
217
|
+
viewBox="0 0 24 24"
|
|
218
|
+
fill="none"
|
|
219
|
+
stroke="currentColor"
|
|
220
|
+
stroke-width="1.8"
|
|
221
|
+
stroke-linecap="round"
|
|
222
|
+
stroke-linejoin="round"
|
|
223
|
+
aria-hidden="true"
|
|
224
|
+
focusable="false"
|
|
225
|
+
>
|
|
226
|
+
<path d="M3 7.5A1.5 1.5 0 0 1 4.5 6h4l2 2.5h7A1.5 1.5 0 0 1 19 10v7.5a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 3 17.5z" />
|
|
227
|
+
<path d="M12 11.5v5M9.5 14h5" />
|
|
228
|
+
</svg>
|
|
229
|
+
<span v-if="showLabels" class="fe-sidenav__text">{{ t('toolbar.new_folder') }}</span>
|
|
230
|
+
</button>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div class="fe-sidenav__scroll">
|
|
234
|
+
<ul class="fe-sidenav__group" :aria-label="t('sidenav.views')">
|
|
235
|
+
<li v-for="v in views" :key="v.key">
|
|
236
|
+
<button
|
|
237
|
+
type="button"
|
|
238
|
+
class="fe-sidenav__item"
|
|
239
|
+
:class="{ 'is-active': activeView === v.key }"
|
|
240
|
+
:aria-current="activeView === v.key ? 'page' : undefined"
|
|
241
|
+
:title="v.label"
|
|
242
|
+
:aria-label="v.label"
|
|
243
|
+
:data-testid="`sidenav-view-${v.key}`"
|
|
244
|
+
@click="emit('open-view', v.key)"
|
|
245
|
+
>
|
|
246
|
+
<svg
|
|
247
|
+
class="fe-ficon"
|
|
248
|
+
viewBox="0 0 24 24"
|
|
249
|
+
fill="none"
|
|
250
|
+
stroke="currentColor"
|
|
251
|
+
stroke-width="1.8"
|
|
252
|
+
stroke-linecap="round"
|
|
253
|
+
stroke-linejoin="round"
|
|
254
|
+
aria-hidden="true"
|
|
255
|
+
focusable="false"
|
|
256
|
+
>
|
|
257
|
+
<template v-if="v.key === 'recent'">
|
|
258
|
+
<circle cx="12" cy="12" r="8.5" />
|
|
259
|
+
<path d="M12 7.5V12l3 2" />
|
|
260
|
+
</template>
|
|
261
|
+
<template v-else-if="v.key === 'starred'">
|
|
262
|
+
<path d="M12 4l2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4-3.9-3.8 5.4-.8z" />
|
|
263
|
+
</template>
|
|
264
|
+
<template v-else-if="v.key === 'shared'">
|
|
265
|
+
<circle cx="17.5" cy="6.5" r="2.5" />
|
|
266
|
+
<circle cx="6.5" cy="12" r="2.5" />
|
|
267
|
+
<circle cx="17.5" cy="17.5" r="2.5" />
|
|
268
|
+
<path d="M8.8 10.8l6.4-3.2M8.8 13.2l6.4 3.2" />
|
|
269
|
+
</template>
|
|
270
|
+
<template v-else>
|
|
271
|
+
<path d="M4.5 7h15" />
|
|
272
|
+
<path d="M9.5 7V5.2A1.2 1.2 0 0 1 10.7 4h2.6a1.2 1.2 0 0 1 1.2 1.2V7" />
|
|
273
|
+
<path d="M6.5 7l.9 11.1A1.4 1.4 0 0 0 8.8 19.4h6.4a1.4 1.4 0 0 0 1.4-1.3L17.5 7" />
|
|
274
|
+
</template>
|
|
275
|
+
</svg>
|
|
276
|
+
<span v-if="showLabels" class="fe-sidenav__text">{{ v.label }}</span>
|
|
277
|
+
</button>
|
|
278
|
+
</li>
|
|
279
|
+
</ul>
|
|
280
|
+
|
|
281
|
+
<div v-if="storages.length" class="fe-sidenav__section">
|
|
282
|
+
<p v-if="showLabels" class="fe-sidenav__heading">{{ t('sidenav.storages') }}</p>
|
|
283
|
+
<hr v-else class="fe-sidenav__rule" aria-hidden="true" />
|
|
284
|
+
<ul class="fe-sidenav__group" :aria-label="t('sidenav.storages')">
|
|
285
|
+
<li v-for="s in storages" :key="s.name">
|
|
286
|
+
<button
|
|
287
|
+
type="button"
|
|
288
|
+
class="fe-sidenav__item"
|
|
289
|
+
:class="{ 'is-active': !activeView && activeStorage === s.name }"
|
|
290
|
+
:title="
|
|
291
|
+
sharedSet.has(s.name)
|
|
292
|
+
? `${s.label || s.name} — ${t('sidenav.storage.shared')}`
|
|
293
|
+
: s.label || s.name
|
|
294
|
+
"
|
|
295
|
+
:aria-label="
|
|
296
|
+
sharedSet.has(s.name)
|
|
297
|
+
? `${s.label || s.name} — ${t('sidenav.storage.shared')}`
|
|
298
|
+
: s.label || s.name
|
|
299
|
+
"
|
|
300
|
+
:data-testid="`sidenav-storage-${s.name}`"
|
|
301
|
+
@click="emit('open-storage', s.name)"
|
|
302
|
+
>
|
|
303
|
+
<!-- A granted storage gets a different glyph, not a badge glued
|
|
304
|
+
into the label: a badge inside the name span changes the
|
|
305
|
+
row's textContent and breaks every selector that finds a row
|
|
306
|
+
by its name (measured on the compliance badges, PR #12). -->
|
|
307
|
+
<svg
|
|
308
|
+
class="fe-ficon"
|
|
309
|
+
viewBox="0 0 24 24"
|
|
310
|
+
fill="none"
|
|
311
|
+
stroke="currentColor"
|
|
312
|
+
stroke-width="1.8"
|
|
313
|
+
stroke-linecap="round"
|
|
314
|
+
stroke-linejoin="round"
|
|
315
|
+
aria-hidden="true"
|
|
316
|
+
focusable="false"
|
|
317
|
+
>
|
|
318
|
+
<template v-if="sharedSet.has(s.name)">
|
|
319
|
+
<path d="M3 8.5A1.5 1.5 0 0 1 4.5 7h5L11 9h8.5A1.5 1.5 0 0 1 21 10.5v7A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5z" />
|
|
320
|
+
<circle cx="15.6" cy="13.6" r="1.6" />
|
|
321
|
+
<circle cx="9.4" cy="15.4" r="1.6" />
|
|
322
|
+
<path d="M10.9 14.7l3.3-.9" />
|
|
323
|
+
</template>
|
|
324
|
+
<template v-else>
|
|
325
|
+
<rect x="3" y="5" width="18" height="6" rx="1.6" />
|
|
326
|
+
<rect x="3" y="13" width="18" height="6" rx="1.6" />
|
|
327
|
+
<path d="M6.5 8h.01M6.5 16h.01" />
|
|
328
|
+
</template>
|
|
329
|
+
</svg>
|
|
330
|
+
<span v-if="showLabels" class="fe-sidenav__text">{{ s.label || s.name }}</span>
|
|
331
|
+
<span
|
|
332
|
+
v-if="showLabels && sharedSet.has(s.name)"
|
|
333
|
+
class="fe-sidenav__tag"
|
|
334
|
+
role="img"
|
|
335
|
+
:aria-label="t('sidenav.storage.shared')"
|
|
336
|
+
>{{ t('sidenav.storage.shared') }}</span
|
|
337
|
+
>
|
|
338
|
+
</button>
|
|
339
|
+
</li>
|
|
340
|
+
</ul>
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
<!-- Connections. Last, because it is where you go once rather than every
|
|
344
|
+
day — and in core rather than in the host app, so an embedded
|
|
345
|
+
explorer's users can reach the guides and mint their own keys
|
|
346
|
+
instead of being told to ask an administrator. -->
|
|
347
|
+
<div v-if="showConnections" class="fe-sidenav__section">
|
|
348
|
+
<p v-if="showLabels" class="fe-sidenav__heading">{{ t('sidenav.connections') }}</p>
|
|
349
|
+
<hr v-else class="fe-sidenav__rule" aria-hidden="true" />
|
|
350
|
+
<ul class="fe-sidenav__group" :aria-label="t('sidenav.connections')">
|
|
351
|
+
<li>
|
|
352
|
+
<button
|
|
353
|
+
type="button"
|
|
354
|
+
class="fe-sidenav__item"
|
|
355
|
+
:title="t('sidenav.connect')"
|
|
356
|
+
:aria-label="t('sidenav.connect')"
|
|
357
|
+
data-testid="sidenav-connect"
|
|
358
|
+
@click="emit('open-connections')"
|
|
359
|
+
>
|
|
360
|
+
<svg
|
|
361
|
+
class="fe-ficon"
|
|
362
|
+
viewBox="0 0 24 24"
|
|
363
|
+
fill="none"
|
|
364
|
+
stroke="currentColor"
|
|
365
|
+
stroke-width="1.8"
|
|
366
|
+
stroke-linecap="round"
|
|
367
|
+
stroke-linejoin="round"
|
|
368
|
+
aria-hidden="true"
|
|
369
|
+
focusable="false"
|
|
370
|
+
>
|
|
371
|
+
<path d="M9.5 14.5l-2.6 2.6a3.7 3.7 0 0 1-5.2-5.2l2.6-2.6" />
|
|
372
|
+
<path d="M14.5 9.5l2.6-2.6a3.7 3.7 0 0 1 5.2 5.2l-2.6 2.6" />
|
|
373
|
+
<path d="M9 15l6-6" />
|
|
374
|
+
</svg>
|
|
375
|
+
<span v-if="showLabels" class="fe-sidenav__text">{{ t('sidenav.connect') }}</span>
|
|
376
|
+
</button>
|
|
377
|
+
</li>
|
|
378
|
+
<li>
|
|
379
|
+
<button
|
|
380
|
+
type="button"
|
|
381
|
+
class="fe-sidenav__item"
|
|
382
|
+
:title="t('sidenav.apikeys')"
|
|
383
|
+
:aria-label="t('sidenav.apikeys')"
|
|
384
|
+
data-testid="sidenav-apikeys"
|
|
385
|
+
@click="emit('open-tokens')"
|
|
386
|
+
>
|
|
387
|
+
<svg
|
|
388
|
+
class="fe-ficon"
|
|
389
|
+
viewBox="0 0 24 24"
|
|
390
|
+
fill="none"
|
|
391
|
+
stroke="currentColor"
|
|
392
|
+
stroke-width="1.8"
|
|
393
|
+
stroke-linecap="round"
|
|
394
|
+
stroke-linejoin="round"
|
|
395
|
+
aria-hidden="true"
|
|
396
|
+
focusable="false"
|
|
397
|
+
>
|
|
398
|
+
<circle cx="8" cy="12" r="3.5" />
|
|
399
|
+
<path d="M11.5 12H21" />
|
|
400
|
+
<path d="M17.5 12v3.2M20 12v2.2" />
|
|
401
|
+
</svg>
|
|
402
|
+
<span v-if="showLabels" class="fe-sidenav__text">{{ t('sidenav.apikeys') }}</span>
|
|
403
|
+
</button>
|
|
404
|
+
</li>
|
|
405
|
+
</ul>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
</nav>
|
|
409
|
+
</template>
|
|
@@ -13,6 +13,17 @@
|
|
|
13
13
|
*
|
|
14
14
|
* One component, every surface (see S3KeysPanel for the rule at length): the
|
|
15
15
|
* admin panel, the web explorer and the desktop app render THIS.
|
|
16
|
+
*
|
|
17
|
+
* Two shapes, one implementation. `full` adds what a person managing their own
|
|
18
|
+
* API keys needs — which scopes, confined to which folder, expiring when — and
|
|
19
|
+
* without it the panel is the compact minter that sits inside a protocol guide,
|
|
20
|
+
* byte for byte as before.
|
|
21
|
+
*
|
|
22
|
+
* ⚠ `full` exists because the rich version had been written a second time, in
|
|
23
|
+
* `web/src/components/SelfTokensModal.vue`, against the same `/api/tokens`
|
|
24
|
+
* route. That copy was reachable only from our own web app: an embedder
|
|
25
|
+
* mounting the explorer got users with no way to mint the credential WebDAV,
|
|
26
|
+
* FTPS and `filex mount` ask for. The copy is gone; this is the surface.
|
|
16
27
|
*/
|
|
17
28
|
import { computed, onMounted, ref } from 'vue';
|
|
18
29
|
import type { ExplorerConfig, LocaleCode } from '../types/ExplorerConfig';
|
|
@@ -22,6 +33,11 @@ import { useTokens } from '../composables/useTokens';
|
|
|
22
33
|
|
|
23
34
|
const props = defineProps<{
|
|
24
35
|
config: ExplorerConfig;
|
|
36
|
+
/**
|
|
37
|
+
* Render the full self-service key manager (scopes, folder confinement,
|
|
38
|
+
* expiry) instead of the one-field minter the guides embed.
|
|
39
|
+
*/
|
|
40
|
+
full?: boolean;
|
|
25
41
|
/**
|
|
26
42
|
* Which protocol the surrounding guide is showing. It only changes the
|
|
27
43
|
* default label — a token minted here works on all of them, and pretending
|
|
@@ -46,6 +62,31 @@ const busy = ref(false);
|
|
|
46
62
|
const copied = ref(false);
|
|
47
63
|
const confirming = ref<number | null>(null);
|
|
48
64
|
|
|
65
|
+
/* ── full mode ──────────────────────────────────────────────────────────
|
|
66
|
+
* ⚠ All four verbs are offered to everyone on purpose. The old copy of this
|
|
67
|
+
* screen hid `write`/`delete` from viewer accounts by reading a store that
|
|
68
|
+
* only the web app has — which is precisely the coupling that kept this
|
|
69
|
+
* surface out of every embed. The server caps each scope against the caller's
|
|
70
|
+
* own role and grants and answers in words worth showing ("scope 'write' is
|
|
71
|
+
* not available here"), so asking is never granting and the refusal explains
|
|
72
|
+
* itself. `admin` is not offered at all: the server rejects it outright. */
|
|
73
|
+
const FULL_SCOPES = ['read', 'write', 'delete', 'mcp'] as const;
|
|
74
|
+
const scopeState = ref<Record<string, boolean>>({
|
|
75
|
+
read: true,
|
|
76
|
+
write: false,
|
|
77
|
+
delete: false,
|
|
78
|
+
mcp: false,
|
|
79
|
+
});
|
|
80
|
+
const rootPath = ref('');
|
|
81
|
+
const expiresInDays = ref<number | null>(null);
|
|
82
|
+
|
|
83
|
+
function buildScopes(): string {
|
|
84
|
+
const parts = FULL_SCOPES.filter((s) => scopeState.value[s]) as string[];
|
|
85
|
+
const root = rootPath.value.trim();
|
|
86
|
+
if (root) parts.push('root:' + root);
|
|
87
|
+
return parts.join(',');
|
|
88
|
+
}
|
|
89
|
+
|
|
49
90
|
onMounted(async () => {
|
|
50
91
|
await load();
|
|
51
92
|
emit('active', { hasToken: tokens.value.length > 0 });
|
|
@@ -70,6 +111,23 @@ async function mint(): Promise<void> {
|
|
|
70
111
|
busy.value = true;
|
|
71
112
|
copied.value = false;
|
|
72
113
|
try {
|
|
114
|
+
if (props.full) {
|
|
115
|
+
// At least one verb, or the token can do nothing and the server's
|
|
116
|
+
// refusal would be about the wrong thing.
|
|
117
|
+
const scopes = buildScopes() || 'read';
|
|
118
|
+
await create({
|
|
119
|
+
label: label.value.trim() || defaultLabel(),
|
|
120
|
+
scopes,
|
|
121
|
+
expires_in_days: expiresInDays.value && expiresInDays.value > 0
|
|
122
|
+
? expiresInDays.value
|
|
123
|
+
: undefined,
|
|
124
|
+
});
|
|
125
|
+
await load();
|
|
126
|
+
emit('active', { hasToken: tokens.value.length > 0 });
|
|
127
|
+
label.value = '';
|
|
128
|
+
rootPath.value = '';
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
73
131
|
// ⚠ `read,write,delete` and nothing more. `share` is a web-surface verb
|
|
74
132
|
// and `admin` is refused by the server anyway; a token for mounting a
|
|
75
133
|
// drive should not be able to publish public links. The server caps this
|
|
@@ -131,7 +189,7 @@ function usedLabel(row: ApiToken): string {
|
|
|
131
189
|
<p v-if="canMint === false" class="fe-s3keys__muted">{{ t('conn.tokens.cannotMint') }}</p>
|
|
132
190
|
<p v-if="error" class="fe-s3keys__warn">{{ error }}</p>
|
|
133
191
|
|
|
134
|
-
<div v-if="canMint" class="fe-s3keys__form">
|
|
192
|
+
<div v-if="canMint && !full" class="fe-s3keys__form">
|
|
135
193
|
<input
|
|
136
194
|
v-model="label"
|
|
137
195
|
class="fe-cfield__input"
|
|
@@ -143,6 +201,56 @@ function usedLabel(row: ApiToken): string {
|
|
|
143
201
|
</button>
|
|
144
202
|
</div>
|
|
145
203
|
|
|
204
|
+
<!-- full — the self-service key manager. Same route, same composable, same
|
|
205
|
+
list below; only the mint form is richer. -->
|
|
206
|
+
<div v-else-if="canMint" class="fe-tokform" data-testid="token-form-full">
|
|
207
|
+
<input
|
|
208
|
+
v-model="label"
|
|
209
|
+
class="fe-cfield__input"
|
|
210
|
+
:placeholder="defaultLabel()"
|
|
211
|
+
data-testid="token-label"
|
|
212
|
+
/>
|
|
213
|
+
|
|
214
|
+
<fieldset class="fe-tokform__scopes">
|
|
215
|
+
<legend class="fe-tokform__legend">{{ t('conn.tokens.scopes') }}</legend>
|
|
216
|
+
<label v-for="s in FULL_SCOPES" :key="s" class="fe-tokform__scope">
|
|
217
|
+
<input v-model="scopeState[s]" type="checkbox" :data-testid="`token-scope-${s}`" />
|
|
218
|
+
<span>{{ s }}</span>
|
|
219
|
+
</label>
|
|
220
|
+
</fieldset>
|
|
221
|
+
|
|
222
|
+
<label class="fe-tokform__field">
|
|
223
|
+
<span class="fe-tokform__label">{{ t('conn.tokens.root') }}</span>
|
|
224
|
+
<input
|
|
225
|
+
v-model="rootPath"
|
|
226
|
+
class="fe-cfield__input"
|
|
227
|
+
:placeholder="t('conn.tokens.rootPlaceholder')"
|
|
228
|
+
data-testid="token-root"
|
|
229
|
+
/>
|
|
230
|
+
</label>
|
|
231
|
+
|
|
232
|
+
<div class="fe-tokform__row">
|
|
233
|
+
<label class="fe-tokform__field fe-tokform__field--narrow">
|
|
234
|
+
<span class="fe-tokform__label">{{ t('conn.tokens.expiry') }}</span>
|
|
235
|
+
<input
|
|
236
|
+
v-model.number="expiresInDays"
|
|
237
|
+
type="number"
|
|
238
|
+
min="0"
|
|
239
|
+
class="fe-cfield__input"
|
|
240
|
+
:placeholder="t('conn.tokens.expiryNever')"
|
|
241
|
+
data-testid="token-expiry"
|
|
242
|
+
/>
|
|
243
|
+
</label>
|
|
244
|
+
<button class="fe-s3keys__btn" :disabled="busy" data-testid="token-mint" @click="mint">
|
|
245
|
+
{{ t('conn.tokens.mint') }}
|
|
246
|
+
</button>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<!-- Said before the refusal rather than after it: the server caps every
|
|
250
|
+
scope against the account's own role and grants. -->
|
|
251
|
+
<p class="fe-s3keys__hint">{{ t('conn.tokens.capNote') }}</p>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
146
254
|
<!-- The secret, once. -->
|
|
147
255
|
<div v-if="revealed" class="fe-s3keys__secret" data-testid="token-secret">
|
|
148
256
|
<p class="fe-s3keys__once">{{ t('conn.tokens.once') }}</p>
|