PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / trunk
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler vtrunk
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Modules / StorageDrivers / S3 / VueTemplates / SettingsPage.vue

SettingsPage.vue in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler trunk, at app/Modules/StorageDrivers/S3/VueTemplates/SettingsPage.vue

1,172 lines 50.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <template>
2 <div class="space-y-6">
3 <div v-if="fetching" class="fct-card fct-card-border overflow-hidden mb-0">
4 <div class="fct-card-body">
5 <el-skeleton animated :rows="6" />
6 </div>
7 </div>
8
9 <template v-else>
10 <div v-if="showDashboard" class="space-y-6">
11 <div class="fct-storage-activated-card">
12 <div class="activated-card-header">
13 <div class="activated-card-content">
14 <img
15 v-if="payload.logo"
16 :class="{ 'dark:hidden': payload.dark_logo }"
17 :src="payload.logo"
18 :alt="payload.driver_label"
19 />
20 <img
21 v-if="payload.dark_logo"
22 :src="payload.dark_logo"
23 :alt="payload.driver_label"
24 class="hidden dark:block"
25 />
26
27 <div
28 v-if="!payload.logo && !payload.dark_logo"
29 class="bucket-name-logo"
30 >
31 S3
32 </div>
33
34 <div class="activated-card-content-inner">
35 <div class="activated-card-bucket-name">
36 <h2 class="title">
37 {{ payload.driver_label }}
38 </h2>
39
40 <span :class="['badge small', form.is_active === 'yes' ? 'success' : 'info']">
41 {{ form.is_active === 'yes' ? translate('Active') : translate('Inactive') }}
42 </span>
43
44 <span class="badge small info">
45 {{ connectionMethodLabel }}
46 </span>
47 </div>
48
49 <p class="activated-card-bucket-desc">
50 {{ payload.description }}
51 </p>
52
53 <!-- <div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-system-mid dark:text-gray-300 hidden">
54 <a
55 v-if="bucketConsoleUrl"
56 :href="bucketConsoleUrl"
57 target="_blank"
58 rel="noopener noreferrer"
59 class="font-medium text-primary-500 no-underline hover:underline dark:text-gray-50"
60 >
61 {{ form.bucket }}
62 </a>
63 <span v-else class="font-medium text-system-light dark:text-gray-400">
64 {{ translate('No bucket selected') }}
65 </span>
66 <span v-if="form.region" class="text-system-light dark:text-gray-400">/</span>
67 <span v-if="form.region">{{ formattedRegion }}</span>
68 </div> -->
69
70 <!-- btn group -->
71 <div class="fct-btn-group mt-3">
72 <LoadingButton size="small" :loading="checking_connection" @click="checkConnection">
73 {{ translate('Check again') }}
74 </LoadingButton>
75
76 <el-button size="small" @click="enableEditMode">
77 {{ translate('Edit Config') }}
78 </el-button>
79
80 <el-popconfirm
81 :title="translate('Are you sure you want to reset all settings? This cannot be undone.')"
82 :confirm-button-text="translate('Yes, Reset')"
83 :cancel-button-text="translate('No')"
84 confirm-button-type="danger"
85 @confirm="resetSettings"
86 >
87 <template #reference>
88 <el-button size="small" plain type="danger">
89 {{ translate('Reset') }}
90 </el-button>
91 </template>
92 </el-popconfirm>
93 </div>
94 </div>
95 </div>
96
97 <div class="shrink-0">
98 <el-switch
99 v-model="form.is_active"
100 active-value="yes"
101 inactive-value="no"
102 :loading="saving"
103 @change="handleSwitchChange"
104 />
105 </div>
106 </div>
107
108 <div class="activated-card-body">
109 <ul class="fct-activated-bucket-list-info">
110 <li>
111 <div class="info-title">
112 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">>
113 <path d="M4.66659 4.66667H11.1666C12.571 4.66667 13.2733 4.66667 13.7777 5.00373C13.9961 5.14964 14.1836 5.33715 14.3295 5.55553C14.6666 6.05997 14.6666 6.7622 14.6666 8.16667C14.6666 10.5074 14.6666 11.6778 14.1048 12.5186C13.8616 12.8825 13.5491 13.195 13.1852 13.4382C12.3444 14 11.174 14 8.83325 14H7.99992C4.85722 14 3.28587 14 2.30956 13.0237C1.33325 12.0474 1.33325 10.476 1.33325 7.33333V5.29618C1.33325 4.0852 1.33325 3.47971 1.5868 3.02538C1.76753 2.70151 2.03476 2.43428 2.35863 2.25354C2.81296 2 3.41845 2 4.62943 2C5.40527 2 5.79318 2 6.13276 2.12734C6.90807 2.41808 7.22778 3.12238 7.57763 3.82208L7.99992 4.66667" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
114 </svg>
115 {{ translate('Bucket') }}
116 </div>
117 <div class="info-value">
118 <a
119 v-if="bucketConsoleUrl"
120 :href="bucketConsoleUrl"
121 target="_blank"
122 rel="noopener noreferrer"
123 >
124 {{ form.bucket }}
125 </a>
126 <span v-else>
127 {{ translate('No bucket selected') }}
128 </span>
129 </div>
130 </li>
131
132 <li>
133 <div class="info-title">
134 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">>
135 <g clip-path="url(#clip0_22443_64899)">
136 <circle cx="7.99992" cy="7.9987" r="6.66667" stroke="currentColor" stroke-width="1.5"/>
137 <ellipse cx="7.99992" cy="7.9987" rx="2.66667" ry="6.66667" stroke="currentColor" stroke-width="1.5"/>
138 <path d="M1.33325 8H14.6666" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
139 </g>
140 <defs>
141 <clipPath id="clip0_22443_64899">
142 <rect width="16" height="16" fill="white"/>
143 </clipPath>
144 </defs>
145 </svg>
146 {{ translate('Region') }}
147 </div>
148 <div class="info-value">
149 {{ form.region ? formattedRegion : translate('Not selected') }}
150 </div>
151 </li>
152
153 <li>
154 <div class="info-title">
155 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">>
156 <g clip-path="url(#clip0_22443_64906)">
157 <path d="M4.66659 1.33203C2.82564 1.33203 1.33325 2.82442 1.33325 4.66536C1.33325 5.89917 2.00358 6.97641 2.99992 7.55276V11.8941C2.99992 12.4391 2.99992 12.7116 3.10141 12.9567C3.20291 13.2017 3.39559 13.3944 3.78097 13.7797L4.66659 14.6654L6.07205 13.2599C6.13687 13.1951 6.1693 13.1627 6.19618 13.1275C6.26679 13.0351 6.31203 12.9259 6.3274 12.8107C6.33325 12.7668 6.33325 12.721 6.33325 12.6293C6.33325 12.5551 6.33325 12.518 6.32932 12.482C6.31901 12.3874 6.28861 12.2962 6.24015 12.2144C6.22167 12.1833 6.19941 12.1536 6.15489 12.0942L5.33325 10.9987L5.79992 10.3765C6.06424 10.024 6.19641 9.84782 6.26483 9.64256C6.33325 9.43729 6.33325 9.21702 6.33325 8.77648V7.55276C7.32959 6.97641 7.99992 5.89917 7.99992 4.66536C7.99992 2.82442 6.50753 1.33203 4.66659 1.33203Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
158 <path d="M4.66675 4.66797H4.67274" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
159 <path d="M8.66675 9.33203H12.6667C13.288 9.33203 13.5986 9.33203 13.8437 9.43353C14.1704 9.56885 14.4299 9.82842 14.5653 10.1551C14.6667 10.4001 14.6667 10.7108 14.6667 11.332C14.6667 11.9533 14.6667 12.2639 14.5653 12.5089C14.4299 12.8356 14.1704 13.0952 13.8437 13.2305C13.5986 13.332 13.288 13.332 12.6667 13.332H8.66675" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
160 <path d="M10 3.33203H12.6667C13.2879 3.33203 13.5985 3.33203 13.8436 3.43353C14.1703 3.56885 14.4298 3.82842 14.5652 4.15512C14.6667 4.40015 14.6667 4.71078 14.6667 5.33203C14.6667 5.95329 14.6667 6.26391 14.5652 6.50894C14.4298 6.83565 14.1703 7.09521 13.8436 7.23054C13.5985 7.33203 13.2879 7.33203 12.6667 7.33203H10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
161 </g>
162 <defs>
163 <clipPath id="clip0_22443_64906">
164 <rect width="16" height="16" fill="white"/>
165 </clipPath>
166 </defs></svg>
167 {{ translate('Authentication') }}
168 </div>
169 <div class="info-value">
170 {{ connectionMethodLabel }}
171 </div>
172 </li>
173
174 <li>
175 <div class="info-title">
176 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">>
177 <g clip-path="url(#clip0_22443_64941)">
178 <path d="M7.99888 1.33203C5.9937 1.33203 4.69354 2.67803 3.15589 3.16863C2.53067 3.36812 2.21806 3.46786 2.09155 3.60846C1.96504 3.74907 1.92799 3.95453 1.8539 4.36545C1.06104 8.76269 2.79401 12.828 6.92694 14.4103C7.371 14.5804 7.59303 14.6654 8.00107 14.6654C8.40911 14.6654 8.63112 14.5804 9.07515 14.4103C13.2078 12.828 14.9392 8.76268 14.1461 4.36545C14.0719 3.95446 14.0349 3.74896 13.9083 3.60836C13.7818 3.46775 13.4692 3.36806 12.844 3.16869C11.3058 2.67813 10.0041 1.33203 7.99888 1.33203Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
179 <path d="M6 8.66667C6 8.66667 6.66667 8.66667 7.33333 10C7.33333 10 9.45098 6.66667 11.3333 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
180 </g>
181 <defs>
182 <clipPath id="clip0_22443_64941">
183 <rect width="16" height="16" fill="white"/>
184 </clipPath>
185 </defs></svg>
186 {{ translate('Security posture') }}
187 </div>
188 <div class="info-value">
189 {{ form.block_public_access === 'yes' ? translate('Public access blocked') : translate('Public access open') }},
190 {{ form.object_ownership === 'yes' ? translate('ownership enforced') : translate('ownership not enforced') }}
191
192 <span :class="['badge small', securityRecommendationLabel === translate('Recommended') ? 'success' : 'warning']">
193 {{ securityRecommendationLabel }}
194 </span>
195 </div>
196 </li>
197 </ul>
198 </div>
199
200
201 </div>
202 </div>
203
204 <div v-else class="space-y-6">
205 <el-alert
206 v-if="showMissingDefineCredentialsWarning"
207 :title="missingDefineCredentialsMessage"
208 type="warning"
209 show-icon
210 :closable="false"
211 />
212
213 <div class="fct-card">
214 <div class="fct-card-header border-bottom">
215 <StepIndicator
216 :steps="visibleSteps"
217 :current-step="stepperActive + 1"
218 @step-click="handleStepClick"
219 />
220 </div>
221
222 <div class="fct-card-body">
223 <div v-show="currentStep === 1" class="fct-step-content-box space-y-4">
224 <h4 class="fct-step-content-box-heading">
225 {{ translate('Verify Access Keys') }}
226 </h4>
227 <ul class="fct-coverage-selector">
228 <li
229 :class="{ active: form.auth_method === 'define' }"
230 @click="!form.defined_in_wp_config && (form.auth_method = 'define')"
231 >
232 <div class="w-full">
233 <div class="fct-coverage-selector-content-wrap">
234 <div class="fct-coverage-selector-content">
235 <span class="fct-coverage-selector-title">
236 {{ translate('Define access keys in wp-config.php') }}
237 <span class="opacity-50">{{ translate('(Recommended)') }}</span>
238 </span>
239 <span class="fct-coverage-selector-desc">
240 {{ form.defined_in_wp_config
241 ? translate('You have already defined your credentials in wp-config.php.')
242 : translate('Recommended. Keeps your credentials secure by defining them in your configuration file.') }}
243 </span>
244 </div>
245 <div class="fct-coverage-selector-dot-wrap shrink-0">
246 <span class="fct-coverage-selector-dot"></span>
247 </div>
248 </div>
249
250 <Animation :visible="form.auth_method === 'define'" accordion :duration="220">
251 <div class="mt-4">
252 <div class="space-y-4">
253 <div class="config-snippet">
254 <pre class="m-0 overflow-x-auto text-xs text-system-dark dark:text-gray-50">{{ wpConfigSnippet }}</pre>
255 </div>
256
257 <div class="flex justify-start">
258 <el-button @click.stop="copyCode">{{ translate('Copy Snippet') }}</el-button>
259 </div>
260 </div>
261 </div>
262 </Animation>
263 </div>
264 </li>
265
266 <li
267 :class="{ active: form.auth_method === 'db', 'opacity-60 cursor-not-allowed': form.defined_in_wp_config }"
268 @click="!form.defined_in_wp_config && (form.auth_method = 'db')"
269 >
270 <div class="w-full">
271 <div class="fct-coverage-selector-content-wrap">
272 <div class="fct-coverage-selector-content">
273 <span class="fct-coverage-selector-title">{{ translate('Store credentials in the database') }}</span>
274 <span class="fct-coverage-selector-desc">
275 {{ translate('Store credentials in the database. Less secure than other methods.') }}
276 </span>
277 <span v-if="form.defined_in_wp_config" class="mt-2 block text-sm text-system-mid dark:text-gray-300">
278 {{ translate('The database credential option is unavailable while your access keys are defined in wp-config.php.') }}
279 </span>
280 </div>
281 <div class="fct-coverage-selector-dot-wrap shrink-0">
282 <span class="fct-coverage-selector-dot"></span>
283 </div>
284 </div>
285
286 <Animation :visible="form.auth_method === 'db'" accordion :duration="220">
287 <div class="mt-4">
288 <div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
289 <div>
290 <label class="mb-2 block text-sm font-medium text-system-dark dark:text-gray-50">{{ translate('Access Key') }}</label>
291 <el-input v-model="form.access_key" :placeholder="translate('Enter access key')" @click.stop />
292 </div>
293
294 <div>
295 <label class="mb-2 block text-sm font-medium text-system-dark dark:text-gray-50">{{ translate('Secret Key') }}</label>
296 <el-input v-model="form.secret_key" show-password :placeholder="translate('Enter secret key')" @click.stop />
297 </div>
298 </div>
299 </div>
300 </Animation>
301 </div>
302 </li>
303 </ul>
304 </div>
305
306 <div v-show="currentStep === 2" class="fct-step-content-box space-y-4">
307 <h4 class="fct-step-content-box-heading">
308 {{ translate('Choose where files live') }}
309 </h4>
310
311 <ul class="fct-coverage-selector">
312 <li :class="{ active: bucketAction === 'existing' }" @click="bucketAction = 'existing'">
313 <div class="w-full">
314 <div class="fct-coverage-selector-content-wrap">
315 <div class="fct-coverage-selector-content">
316 <span class="fct-coverage-selector-title">{{ translate('Connect an existing bucket') }}</span>
317 <span class="fct-coverage-selector-desc">{{ translate('Choose a bucket you already manage in this AWS account.') }}</span>
318 </div>
319 <div class="fct-coverage-selector-dot-wrap shrink-0">
320 <span class="fct-coverage-selector-dot"></span>
321 </div>
322 </div>
323
324 <Animation :visible="bucketAction === 'existing'" accordion :duration="220">
325 <div class="mt-4">
326 <div class="space-y-4">
327 <div class="space-y-2">
328 <span class="block text-sm font-medium text-system-dark dark:text-gray-50">{{ translate('Bucket name') }}</span>
329 <div class="fct-coverage-bucket-name-selectors">
330 <label @click.stop>
331 <el-radio v-model="bucketInputMode" value="enter" @click.stop />
332 <span>{{ translate('Enter bucket name') }}</span>
333 </label>
334
335 <label @click.stop>
336 <el-radio v-model="bucketInputMode" value="select" @click.stop />
337 <span>{{ translate('Select from existing') }}</span>
338 </label>
339
340 </div>
341 </div>
342
343 <div v-if="bucketInputMode === 'enter'">
344 <el-input v-model="form.bucket" :placeholder="translate('Enter bucket name')" @click.stop />
345 </div>
346
347 <div v-else class="fct-bucket-list-wrap">
348 <div class="fct-bucket-list-search">
349 <el-input
350 v-model="bucketSearchQuery"
351 clearable
352 :placeholder="translate('Search buckets')"
353 @input="handleBucketSearch"
354 @clear="handleBucketSearch"
355 @click.stop
356 />
357 </div>
358
359 <div class="fct-bucket-list-header">
360 <span class="selected-bucket-count">
361 {{ bucketOptions.length }} {{ translate(bucketOptions.length === 1 ? 'bucket' : 'buckets') }}
362 </span>
363 <el-button text class="px-0" :loading="bucketLoading" @click.stop="refreshBucketList">
364 {{ translate('Reload bucket list') }}
365 </el-button>
366 </div>
367
368 <div v-if="bucketLoading" class="px-4 py-5 text-sm text-system-mid dark:text-gray-300">
369 {{ translate('Loading buckets...') }}
370 </div>
371
372 <div v-else-if="!bucketOptions.length" class="px-4 py-5 text-sm text-system-mid dark:text-gray-300">
373 {{ translate('No buckets found or credentials are invalid.') }}
374 </div>
375
376 <div v-else-if="!filteredBucketOptions.length" class="px-4 py-5 text-sm text-system-mid dark:text-gray-300">
377 {{ translate('No buckets match your search.') }}
378 </div>
379
380 <div v-else :class="bucketListClasses" class="fct-bucket-list">
381 <button
382 v-for="item in filteredBucketOptions"
383 :key="item.value"
384 type="button"
385 :class="form.bucket === item.value ? 'is-bucket-selected' : ''"
386 @click.stop="selectBucket(item)"
387 >
388 <span class="min-w-0 flex-1">
389 <span class="bucket-name">
390 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">>
391 <path d="M4.75 10.375C4.75 10.6097 5.09575 11.0185 5.8975 11.4197C6.9355 11.9387 8.40775 12.25 10 12.25C11.5923 12.25 13.0645 11.9387 14.1025 11.4197C14.9043 11.0185 15.25 10.6097 15.25 10.375V8.74675C14.0125 9.51175 12.1203 10 10 10C7.87975 10 5.9875 9.511 4.75 8.74675V10.375ZM15.25 12.4968C14.0125 13.2618 12.1203 13.75 10 13.75C7.87975 13.75 5.9875 13.261 4.75 12.4968V14.125C4.75 14.3597 5.09575 14.7685 5.8975 15.1697C6.9355 15.6887 8.40775 16 10 16C11.5923 16 13.0645 15.6887 14.1025 15.1697C14.9043 14.7685 15.25 14.3597 15.25 14.125V12.4968ZM3.25 14.125V6.625C3.25 4.76125 6.2725 3.25 10 3.25C13.7275 3.25 16.75 4.76125 16.75 6.625V14.125C16.75 15.9887 13.7275 17.5 10 17.5C6.2725 17.5 3.25 15.9887 3.25 14.125ZM10 8.5C11.5923 8.5 13.0645 8.18875 14.1025 7.66975C14.9043 7.2685 15.25 6.85975 15.25 6.625C15.25 6.39025 14.9043 5.9815 14.1025 5.58025C13.0645 5.06125 11.5923 4.75 10 4.75C8.40775 4.75 6.9355 5.06125 5.8975 5.58025C5.09575 5.9815 4.75 6.39025 4.75 6.625C4.75 6.85975 5.09575 7.2685 5.8975 7.66975C6.9355 8.18875 8.40775 8.5 10 8.5Z" fill="currentColor"/>
392 </svg>
393
394 {{ item.label }}
395 </span>
396
397 <span v-if="item.region" class="bucket-region">
398 {{ item.region }}
399 </span>
400 </span>
401
402 <span v-if="form.bucket === item.value" class="selected-icon">
403 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">>
404 <path d="M7 14C3.1339 14 0 10.8661 0 7C0 3.1339 3.1339 0 7 0C10.8661 0 14 3.1339 14 7C14 10.8661 10.8661 14 7 14ZM6.3021 9.8L11.2511 4.8503L10.2613 3.8605L6.3021 7.8204L4.3218 5.8401L3.332 6.8299L6.3021 9.8Z" fill="currentColor"/>
405 </svg>
406 </span>
407 </button>
408 </div>
409 </div>
410 </div>
411 </div>
412 </Animation>
413 </div>
414 </li>
415
416 <li :class="{ active: bucketAction === 'new' }" @click="bucketAction = 'new'">
417 <div class="w-full">
418 <div class="fct-coverage-selector-content-wrap">
419 <div class="fct-coverage-selector-content">
420 <span class="fct-coverage-selector-title">{{ translate('Create a new bucket') }}</span>
421 <span class="fct-coverage-selector-desc">{{ translate('Create and select a fresh bucket using the current credentials.') }}</span>
422 </div>
423 <div class="fct-coverage-selector-dot-wrap shrink-0">
424 <span class="fct-coverage-selector-dot"></span>
425 </div>
426 </div>
427
428 <Animation :visible="bucketAction === 'new'" accordion :duration="220">
429 <div class="mt-4">
430 <div class="space-y-4">
431 <div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
432 <div class="lg:col-span-2">
433 <label class="mb-2 block text-sm font-medium text-system-dark dark:text-gray-50">{{ translate('Bucket Name') }}</label>
434 <el-input v-model="newBucketName" :placeholder="translate('Enter bucket name')" @click.stop />
435 <p v-if="createBucketAttempted && !newBucketName" class="mt-1 text-xs text-red-500">
436 {{ translate('Bucket name not entered.') }}
437 </p>
438 </div>
439
440 <div>
441 <label class="mb-2 block text-sm font-medium text-system-dark dark:text-gray-50">{{ translate('Region') }}</label>
442 <el-select v-model="newBucketRegion" class="w-full" :placeholder="translate('Select Region')" @click.stop>
443 <el-option v-for="region in awsRegions" :key="region.value" :label="region.label" :value="region.value" />
444 </el-select>
445 <p v-if="bucketStepAttempted && !hasSelectedRegion(newBucketRegion)" class="mt-1 text-xs text-red-500">
446 {{ translate('Region is required.') }}
447 </p>
448 </div>
449 </div>
450 </div>
451 </div>
452 </Animation>
453 </div>
454 </li>
455 </ul>
456 </div>
457
458 <div v-show="currentStep === 3" class="fct-step-content-box space-y-4">
459 <h4 class="fct-step-content-box-heading">
460 {{ translate('Review protection settings') }}
461 </h4>
462 <div class="space-y-4">
463 <div class="config-snippet config-snippet-white">
464 <div class="flex items-start justify-between gap-4">
465 <div class="min-w-0">
466 <div class="flex flex-wrap items-center gap-2">
467 <span class="text-sm font-medium text-system-dark dark:text-gray-50">{{ translate('Block All Public Access') }}</span>
468 <span :class="['badge small', form.block_public_access === 'yes' ? 'success' : 'warning']">
469 {{ form.block_public_access === 'yes' ? translate('Recommended') : translate('Review') }}
470 </span>
471 </div>
472 <p class="m-0 mt-2 text-sm text-system-mid dark:text-gray-300">
473 {{ translate('Recommended for private asset delivery.') }}
474 </p>
475 </div>
476
477 <el-switch v-model="form.block_public_access" active-value="yes" inactive-value="no" />
478 </div>
479 </div>
480
481 <div class="config-snippet config-snippet-white">
482 <div class="flex items-start justify-between gap-4">
483 <div class="min-w-0">
484 <div class="flex flex-wrap items-center gap-2">
485 <span class="text-sm font-medium text-system-dark dark:text-gray-50">{{ translate('Object Ownership') }}</span>
486 <span :class="['badge small', form.object_ownership === 'yes' ? 'success' : 'warning']">
487 {{ form.object_ownership === 'yes' ? translate('Recommended') : translate('Review') }}
488 </span>
489 </div>
490 <p class="m-0 mt-2 text-sm text-system-mid dark:text-gray-300">
491 {{ translate('Ensure uploaded files stay under the expected ownership model.') }}
492 </p>
493 </div>
494
495 <el-switch v-model="form.object_ownership" active-value="yes" inactive-value="no" />
496 </div>
497 </div>
498 </div>
499 </div>
500 </div>
501 </div>
502
503 <div class="flex items-center justify-end gap-3">
504 <div class="fct-btn-group">
505 <el-button v-if="editMode" @click="cancelEdit">
506 {{ translate('Cancel') }}
507 </el-button>
508
509 <el-button v-if="currentStep === 2" @click="currentStep = 1">
510 {{ translate('Back') }}
511 </el-button>
512
513 <el-button v-if="currentStep === 3" @click="currentStep = 2">
514 {{ translate('Back') }}
515 </el-button>
516
517 <el-button v-if="currentStep === 1" type="primary" :loading="saving" @click="saveAndContinue">
518 {{ translate('Save & Continue') }}
519 </el-button>
520
521 <el-button
522 v-if="currentStep === 2"
523 type="primary"
524 :loading="saving"
525 @click="advanceFromBucketStep"
526 >
527 {{ bucketAction === 'new' ? translate('Create Bucket & Continue') : translate('Next') }}
528 </el-button>
529
530 <el-button v-if="currentStep === 3" type="primary" :loading="saving" @click="saveSecuritySettings">
531 {{ translate('Save Settings') }}
532 </el-button>
533 </div>
534 </div>
535 </div>
536 </template>
537 </div>
538 </template>
539
540 <script>
541 export default {
542 components: {
543 Animation,
544 StepIndicator,
545 LoadingButton
546 },
547 props: {
548 payload: {
549 type: Object,
550 required: true
551 }
552 },
553 data() {
554 return {
555 saving: false,
556 fetching: false,
557 checking_connection: false,
558 bucketLoading: false,
559 editMode: false,
560 wizardMode: true,
561 currentStep: 1,
562 bucketAction: 'existing',
563 bucketInputMode: 'enter',
564 bucketSearchQuery: '',
565 newBucketName: '',
566 newBucketRegion: 'us-east-1',
567 bucketStepAttempted: false,
568 createBucketAttempted: false,
569 bucketOptions: [],
570 connectionError: null,
571 form: this.makeForm(this.payload.settings || {}),
572 awsRegions: [
573 { label: 'US East (N. Virginia)', value: 'us-east-1' },
574 { label: 'US East (Ohio)', value: 'us-east-2' },
575 { label: 'US West (N. California)', value: 'us-west-1' },
576 { label: 'US West (Oregon)', value: 'us-west-2' },
577 { label: 'Canada (Central)', value: 'ca-central-1' },
578 { label: 'Canada West (Calgary)', value: 'ca-west-1' },
579 { label: 'Africa (Cape Town)', value: 'af-south-1' },
580 { label: 'Asia Pacific (Hong Kong)', value: 'ap-east-1' },
581 { label: 'Asia Pacific (Mumbai)', value: 'ap-south-1' },
582 { label: 'Asia Pacific (Hyderabad)', value: 'ap-south-2' },
583 { label: 'Asia Pacific (Tokyo)', value: 'ap-northeast-1' },
584 { label: 'Asia Pacific (Seoul)', value: 'ap-northeast-2' },
585 { label: 'Asia Pacific (Osaka)', value: 'ap-northeast-3' },
586 { label: 'Asia Pacific (Singapore)', value: 'ap-southeast-1' },
587 { label: 'Asia Pacific (Sydney)', value: 'ap-southeast-2' },
588 { label: 'Asia Pacific (Jakarta)', value: 'ap-southeast-3' },
589 { label: 'Asia Pacific (Melbourne)', value: 'ap-southeast-4' },
590 { label: 'Asia Pacific (Malaysia)', value: 'ap-southeast-5' },
591 { label: 'Asia Pacific (Thailand)', value: 'ap-southeast-7' },
592 { label: 'China (Beijing)', value: 'cn-north-1' },
593 { label: 'China (Ningxia)', value: 'cn-northwest-1' },
594 { label: 'EU (Frankfurt)', value: 'eu-central-1' },
595 { label: 'EU (Zurich)', value: 'eu-central-2' },
596 { label: 'EU (Ireland)', value: 'eu-west-1' },
597 { label: 'EU (London)', value: 'eu-west-2' },
598 { label: 'EU (Paris)', value: 'eu-west-3' },
599 { label: 'EU (Milan)', value: 'eu-south-1' },
600 { label: 'EU (Spain)', value: 'eu-south-2' },
601 { label: 'EU (Stockholm)', value: 'eu-north-1' },
602 { label: 'Israel (Tel Aviv)', value: 'il-central-1' },
603 { label: 'Middle East (Bahrain)', value: 'me-south-1' },
604 { label: 'Middle East (UAE)', value: 'me-central-1' },
605 { label: 'Mexico (Central)', value: 'mx-central-1' },
606 { label: 'South America (São Paulo)', value: 'sa-east-1' }
607 ]
608 };
609 },
610 computed: {
611 showDashboard() {
612 return !this.wizardMode && !this.editMode;
613 },
614 stepperActive() {
615 const currentIndex = this.visibleSteps.findIndex((step) => step.number === this.currentStep);
616 return currentIndex >= 0 ? currentIndex : 0;
617 },
618 showMissingDefineCredentialsWarning() {
619 return this.currentStep === 1 && this.form.define_credentials_missing;
620 },
621 missingDefineCredentialsMessage() {
622 return this.form.define_credentials_missing_message || this.translate('S3 was configured previously, but the wp-config.php credentials are missing now. Add them again to continue.');
623 },
624 formattedRegion() {
625 const region = this.awsRegions.find((item) => item.value === this.form.region);
626 return region ? region.label : this.form.region;
627 },
628 visibleSteps() {
629 const steps = [
630 {
631 number: 1,
632 label: this.translate('Credential'),
633 description: this.translate('Verify access keys')
634 },
635 {
636 number: 2,
637 label: this.translate('Bucket'),
638 description: this.translate('Choose where files live')
639 },
640 {
641 number: 3,
642 label: this.translate('Security'),
643 description: this.translate('Review protection settings')
644 }
645 ];
646
647 // Keep the credential step reachable in edit mode: dropping it strands
648 // anyone whose stored keys are missing or rejected, leaving Reset as the
649 // only way back to the key fields.
650 return steps;
651 },
652 connectionMethodLabel() {
653 if (this.form.auth_method === 'define') {
654 return this.translate('Defined mode');
655 }
656
657 return this.translate('Database mode');
658 },
659 securityRecommendationLabel() {
660 return this.form.block_public_access === 'yes' && this.form.object_ownership === 'yes'
661 ? this.translate('Recommended')
662 : this.translate('Needs review');
663 },
664 bucketConsoleUrl() {
665 if (!this.form.bucket) {
666 return '';
667 }
668
669 return `https://console.aws.amazon.com/s3/buckets/${this.form.bucket}/?region=${this.form.region || 'us-east-1'}`;
670 },
671 filteredBucketOptions() {
672 const query = (this.bucketSearchQuery || '').trim().toLowerCase();
673
674 if (!query) {
675 return this.bucketOptions;
676 }
677
678 return this.bucketOptions.filter((item) => {
679 return [item.label, item.value, item.region].some((value) => {
680 return String(value || '').toLowerCase().includes(query);
681 });
682 });
683 },
684 bucketListClasses() {
685 const classes = [];
686
687 if (this.filteredBucketOptions.length > 5) {
688 classes.push('max-h-[16rem]', 'overflow-y-auto');
689 }
690
691 return classes;
692 },
693 wpConfigSnippet() {
694 return `define( 'FCT_S3_ACCESS_KEY', '********************' );
695 define( 'FCT_S3_SECRET_KEY', '****************************************' );`;
696 }
697 },
698 watch: {
699 payload: {
700 deep: true,
701 handler(newPayload) {
702 this.applyServerSettings(newPayload.settings || {});
703 }
704 },
705 newBucketName(val) {
706 const formatted = (val || '').toLowerCase().replace(/[^a-z0-9.-]/g, '-').replace(/-+/g, '-');
707 if (formatted !== val) {
708 this.newBucketName = formatted;
709 }
710 },
711 bucketAction(newValue) {
712 if (newValue === 'existing') {
713 this.bucketSearchQuery = '';
714 this.ensureBucketOptionsLoaded();
715 }
716 },
717 bucketInputMode() {
718 this.form.bucket = '';
719 },
720 currentStep(newValue) {
721 if (newValue === 2 && this.bucketAction === 'existing') {
722 this.ensureBucketOptionsLoaded();
723 }
724 }
725 },
726 mounted() {
727 this.applyServerSettings(this.payload.settings || {});
728 },
729 methods: {
730 translate,
731 isStepAvailable(step) {
732 return this.editMode || step < this.currentStep || (this.form.bucket && step <= 3);
733 },
734 defaults() {
735 return {
736 is_active: 'no',
737 auth_method: 'define',
738 access_key: '',
739 secret_key: '',
740 bucket: '',
741 region: 'us-east-1',
742 buckets: [],
743 defined_in_wp_config: false,
744 define_credentials_missing: false,
745 define_credentials_missing_message: '',
746 block_public_access: 'yes',
747 object_ownership: 'yes',
748 connection_error: ''
749 };
750 },
751 normalizeSettings(settings = {}) {
752 const normalized = {
753 ...this.defaults(),
754 ...settings
755 };
756
757 normalized.defined_in_wp_config = !!normalized.defined_in_wp_config;
758 normalized.define_credentials_missing = !!normalized.define_credentials_missing;
759 normalized.block_public_access = normalized.block_public_access === true || normalized.block_public_access === 'yes' ? 'yes' : 'no';
760 normalized.object_ownership = normalized.object_ownership === true || normalized.object_ownership === 'yes' ? 'yes' : 'no';
761
762 return normalized;
763 },
764 makeForm(settings) {
765 return this.normalizeSettings(settings);
766 },
767 applyServerSettings(settings = {}, options = {}) {
768 const { preserveWizardState = false } = options;
769 const normalized = this.normalizeSettings(settings);
770
771 if (!Object.prototype.hasOwnProperty.call(settings, 'access_key') && this.form?.access_key) {
772 normalized.access_key = this.form.access_key;
773 }
774
775 if (!Object.prototype.hasOwnProperty.call(settings, 'secret_key') && this.form?.secret_key) {
776 normalized.secret_key = this.form.secret_key;
777 }
778
779 this.form = {
780 ...this.form,
781 ...normalized
782 };
783
784 if (this.form.define_credentials_missing) {
785 this.connectionError = null;
786 this.bucketOptions = [];
787 this.wizardMode = true;
788 this.currentStep = 1;
789 return;
790 }
791
792 this.connectionError = settings.connection_error || null;
793
794 if (preserveWizardState) {
795 return;
796 }
797
798 this.wizardMode = !this.form.bucket;
799 this.currentStep = this.form.bucket ? 2 : 1;
800 },
801 mergeResponseSettings(response, options = {}) {
802 const serverSettings = response?.data?.data || response?.data || response?.settings || {};
803 this.applyServerSettings(serverSettings, options);
804 return serverSettings;
805 },
806 copyCode() {
807 if (!navigator?.clipboard?.writeText) {
808 Notify.error(this.translate('Clipboard API is not available.'));
809 return;
810 }
811
812 navigator.clipboard.writeText(this.wpConfigSnippet).then(() => {
813 Notify.success(this.translate('Code copied to clipboard!'));
814 }).catch(() => {
815 Notify.error(this.translate('Failed to copy code.'));
816 });
817 },
818 getDriver() {
819 return this.payload.driver || 's3';
820 },
821 getSaveEndpoint() {
822 return this.payload.save_endpoint || 'settings/storage-drivers';
823 },
824 getCreateBucketEndpoint() {
825 return this.payload.create_bucket_endpoint || 'settings/storage-drivers/create-bucket';
826 },
827 getBucketListEndpoint() {
828 return this.payload.bucket_list_endpoint || 'settings/storage-drivers/bucket-list';
829 },
830 getVerifyEndpoint() {
831 return this.payload.connection_verify_endpoint || 'settings/storage-drivers/verify-info';
832 },
833 getResetEndpoint() {
834 return this.payload.reset_endpoint || 'settings/storage-drivers/reset';
835 },
836 enableEditMode() {
837 this.editMode = true;
838 this.bucketAction = 'existing';
839 this.bucketInputMode = 'enter';
840 this.currentStep = this.form.bucket ? 2 : 1;
841 this.bucketSearchQuery = '';
842 this.connectionError = null;
843 },
844 cancelEdit() {
845 this.editMode = false;
846 this.refreshSettings();
847 },
848 goToStep(step) {
849 if (this.editMode || step < this.currentStep || (this.form.bucket && step <= 3)) {
850 this.currentStep = step;
851 }
852 },
853 handleStepClick(localIndex) {
854 const step = this.visibleSteps[localIndex - 1];
855 if (step) {
856 this.goToStep(step.number);
857 }
858 },
859 refreshSettings(showLoading = false) {
860 if (showLoading) {
861 this.fetching = true;
862 }
863
864 return Rest.get('settings/storage-drivers/' + this.getDriver()).then((response) => {
865 this.applyServerSettings(response.settings || {});
866 }).finally(() => {
867 if (showLoading) {
868 this.fetching = false;
869 }
870 });
871 },
872 checkConnection() {
873 this.checking_connection = true;
874
875 Rest.post(this.getVerifyEndpoint(), {
876 driver: this.getDriver(),
877 settings: this.buildSettingsPayload()
878 }).then((response) => {
879 this.connectionError = null;
880 Notify.success(response?.message || this.translate('Connection verified successfully.'));
881 }).catch((errors) => {
882 const message = errors?.data?.message || this.translate('Connection failed. Please check settings.');
883 this.connectionError = message;
884 Notify.error(message);
885 }).finally(() => {
886 this.checking_connection = false;
887 });
888 },
889 handleSwitchChange() {
890 if (this.form.is_active === 'yes') {
891 this.activateDriver().catch(() => {
892 this.form.is_active = 'no';
893 });
894 } else {
895 this.deactivateDriver().catch(() => {
896 this.form.is_active = 'yes';
897 });
898 }
899 },
900 activateDriver() {
901 this.saving = true;
902 return Rest.post(this.getSaveEndpoint(), {
903 driver: this.getDriver(),
904 settings: this.buildSettingsPayload({
905 is_active: 'yes'
906 })
907 }).then((response) => {
908 this.mergeResponseSettings(response);
909 Notify.success(this.translate('S3 activated successfully.'));
910 }).catch((errors) => {
911 const message = errors?.data?.message || this.translate('Failed to activate driver.');
912 Notify.error(message);
913 throw errors;
914 }).finally(() => {
915 this.saving = false;
916 });
917 },
918 deactivateDriver() {
919 this.saving = true;
920 return Rest.post(this.getSaveEndpoint(), {
921 driver: this.getDriver(),
922 settings: this.buildSettingsPayload({
923 is_active: 'no',
924 preserve_settings: true
925 })
926 }).then(() => {
927 Notify.success(this.translate('S3 deactivated successfully.'));
928 }).catch((errors) => {
929 const message = errors?.data?.message || this.translate('Failed to deactivate driver.');
930 Notify.error(message);
931 throw errors;
932 }).finally(() => {
933 this.saving = false;
934 });
935 },
936 resetSettings() {
937 this.saving = true;
938 Rest.post(this.getResetEndpoint(), {
939 driver: this.getDriver()
940 }).then((response) => {
941 this.applyServerSettings(response?.data?.data || {});
942 this.form = this.makeForm(response?.data?.data || {});
943 this.currentStep = 1;
944 this.editMode = false;
945 this.wizardMode = true;
946 this.bucketOptions = [];
947 Notify.success(this.translate('S3 settings reset successfully.'));
948 }).catch((errors) => {
949 const message = errors?.data?.message || this.translate('Failed to reset settings.');
950 Notify.error(message);
951 }).finally(() => {
952 this.saving = false;
953 });
954 },
955 fetchBuckets(query) {
956 this.bucketLoading = true;
957 Rest.post(this.getBucketListEndpoint(), {
958 driver: this.getDriver(),
959 query: query,
960 settings: this.buildSettingsPayload()
961 }).then((response) => {
962 this.bucketOptions = response.options || [];
963 }).catch((errors) => {
964 this.bucketOptions = [];
965
966 const message = errors?.data?.message || this.translate('Failed to load buckets.');
967 Notify.error(message);
968 }).finally(() => {
969 this.bucketLoading = false;
970 });
971 },
972 ensureBucketOptionsLoaded() {
973 if (!this.bucketOptions.length && !this.bucketLoading) {
974 this.fetchBuckets('');
975 }
976 },
977 refreshBucketList() {
978 this.fetchBuckets('');
979 },
980 handleBucketSearch() {
981 this.bucketSearchQuery = (this.bucketSearchQuery || '').trim();
982 },
983 hasSelectedRegion(region) {
984 return !!String(region || '').trim();
985 },
986 resetCreateBucketState(options = {}) {
987 const { clearName = false } = options;
988
989 this.bucketStepAttempted = false;
990 this.createBucketAttempted = false;
991
992 if (clearName) {
993 this.newBucketName = '';
994 }
995 },
996 buildSettingsPayload(extra = {}) {
997 const settings = {
998 ...this.form,
999 ...extra
1000 };
1001
1002 delete settings.create_new_bucket;
1003 delete settings.new_bucket_name;
1004 delete settings.new_bucket_region;
1005
1006 // Never post blank credentials: the server hides them from the settings
1007 // response, so an untouched field must read as "not submitted" and keep
1008 // the stored value instead of overwriting it with an empty string.
1009 ['access_key', 'secret_key'].forEach((key) => {
1010 if (!settings[key]) {
1011 delete settings[key];
1012 }
1013 });
1014
1015 return settings;
1016 },
1017 selectBucket(item) {
1018 this.form.bucket = item.value;
1019 if (item.region) {
1020 this.form.region = item.region;
1021 }
1022 },
1023 advanceFromBucketStep() {
1024 this.bucketStepAttempted = true;
1025
1026 if (this.bucketAction === 'new') {
1027 if (!this.hasSelectedRegion(this.newBucketRegion)) {
1028 Notify.error(this.translate('Please select a region before continuing.'));
1029 return;
1030 }
1031 this.createNewBucket();
1032 return;
1033 }
1034
1035 this.saveBucketSettings();
1036 },
1037 saveAndContinue() {
1038 this.saving = true;
1039 Rest.post(this.getSaveEndpoint(), {
1040 driver: this.getDriver(),
1041 settings: this.buildSettingsPayload({
1042 is_active: 'no',
1043 preserve_settings: true,
1044 verify_credentials: true,
1045 verify_only_credentials: true
1046 })
1047 }).then((response) => {
1048 this.mergeResponseSettings(response);
1049 this.currentStep = 2;
1050 this.bucketOptions = [];
1051 this.bucketSearchQuery = '';
1052 this.ensureBucketOptionsLoaded();
1053 Notify.success(this.translate('Credentials saved. Proceeding to bucket selection.'));
1054 }).catch((errors) => {
1055 const message = errors?.data?.message || this.translate('Failed to save credentials.');
1056 if (errors.status_code === '422' && Notify.validationErrors) {
1057 Notify.validationErrors(errors);
1058 } else {
1059 Notify.error(message);
1060 }
1061 }).finally(() => {
1062 this.saving = false;
1063 });
1064 },
1065 createNewBucket() {
1066 this.createBucketAttempted = true;
1067
1068 if (!this.newBucketName) {
1069 return;
1070 }
1071
1072 this.saving = true;
1073 Rest.post(this.getCreateBucketEndpoint(), {
1074 driver: this.getDriver(),
1075 settings: {
1076 ...this.buildSettingsPayload(),
1077 create_new_bucket: 'yes',
1078 new_bucket_name: this.newBucketName,
1079 new_bucket_region: this.newBucketRegion
1080 }
1081 }).then((response) => {
1082 const bucketData = response?.data?.data || response?.data || {};
1083 this.form.bucket = bucketData.bucket || this.newBucketName;
1084 this.form.region = bucketData.region || this.newBucketRegion;
1085 this.form.connection_error = '';
1086
1087 if (bucketData.block_public_access) {
1088 this.form.block_public_access = bucketData.block_public_access;
1089 }
1090
1091 if (bucketData.object_ownership) {
1092 this.form.object_ownership = bucketData.object_ownership;
1093 }
1094
1095 this.bucketAction = 'existing';
1096 this.bucketSearchQuery = '';
1097 this.resetCreateBucketState({
1098 clearName: true
1099 });
1100 this.currentStep = 3;
1101 Notify.success(this.translate('Bucket created and selected successfully.'));
1102 }).catch((errors) => {
1103 const message = errors?.data?.message || this.translate('Failed to create bucket.');
1104 if (errors.status_code === '422' && Notify.validationErrors) {
1105 Notify.validationErrors(errors);
1106 } else {
1107 Notify.error(message);
1108 }
1109 }).finally(() => {
1110 this.saving = false;
1111 });
1112 },
1113 saveBucketSettings() {
1114 this.form.bucket = (this.form.bucket || '').trim();
1115
1116 if (!this.form.bucket) {
1117 Notify.error(this.translate('Please select or enter a bucket before continuing.'));
1118 return;
1119 }
1120
1121 this.saving = true;
1122 Rest.post(this.getSaveEndpoint(), {
1123 driver: this.getDriver(),
1124 settings: this.buildSettingsPayload({
1125 is_active: 'no',
1126 preserve_settings: true,
1127 check_bucket_exists: 'yes'
1128 })
1129 }).then((response) => {
1130 this.mergeResponseSettings(response, {
1131 preserveWizardState: true
1132 });
1133 this.resetCreateBucketState();
1134 this.currentStep = 3;
1135 Notify.success(this.translate('Bucket settings saved.'));
1136 }).catch((errors) => {
1137 const message = errors?.data?.message || this.translate('Failed to save bucket settings.');
1138 if (errors.status_code === '422' && Notify.validationErrors) {
1139 Notify.validationErrors(errors);
1140 } else {
1141 Notify.error(message);
1142 }
1143 }).finally(() => {
1144 this.saving = false;
1145 });
1146 },
1147 saveSecuritySettings() {
1148 this.saving = true;
1149 Rest.post(this.getSaveEndpoint(), {
1150 driver: this.getDriver(),
1151 settings: this.buildSettingsPayload({
1152 is_active: 'yes'
1153 })
1154 }).then((response) => {
1155 this.mergeResponseSettings(response);
1156 this.wizardMode = false;
1157 this.editMode = false;
1158 this.resetCreateBucketState({
1159 clearName: true
1160 });
1161 Notify.success(this.translate('S3 settings saved successfully.'));
1162 }).catch((errors) => {
1163 const message = errors?.data?.message || this.translate('Failed to save security settings.');
1164 Notify.error(message);
1165 }).finally(() => {
1166 this.saving = false;
1167 });
1168 }
1169 }
1170 };
1171 </script>
1172