@brftech/filex-core 0.29.0 → 0.30.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.
@@ -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>