@frigear-nu/forms 0.1.1 → 0.1.3

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.
Files changed (70) hide show
  1. package/dist/module.d.mts +18 -1
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +29 -26
  4. package/dist/runtime/app/components/FormBuilderAccess.vue +255 -262
  5. package/dist/runtime/app/components/FormBuilderCaptchaSettings.vue +8 -1
  6. package/dist/runtime/app/components/FormBuilderSettings.vue +143 -141
  7. package/dist/runtime/app/components/FormCreateSlideover.vue +2 -2
  8. package/dist/runtime/app/components/FormEmbed.vue +2 -2
  9. package/dist/runtime/app/components/FormImportSlideover.d.vue.ts +8 -0
  10. package/dist/runtime/app/components/FormImportSlideover.vue +57 -0
  11. package/dist/runtime/app/components/FormImportSlideover.vue.d.ts +8 -0
  12. package/dist/runtime/app/components/FormRenderer.vue +3 -3
  13. package/dist/runtime/app/components/FormSubmissionView.vue +2 -2
  14. package/dist/runtime/app/composables/useCaptchaSecrets.js +17 -7
  15. package/dist/runtime/app/composables/useFormActions.d.ts +14 -2
  16. package/dist/runtime/app/composables/useFormActions.js +33 -21
  17. package/dist/runtime/app/composables/useForms.d.ts +12 -5
  18. package/dist/runtime/app/composables/useForms.js +11 -5
  19. package/dist/runtime/app/pages/admin-form.vue +36 -20
  20. package/dist/runtime/app/pages/admin-submission.vue +27 -16
  21. package/dist/runtime/app/pages/admin-submissions.vue +30 -22
  22. package/dist/runtime/app/pages/admin.vue +29 -78
  23. package/dist/runtime/app/pages/form.vue +2 -1
  24. package/dist/runtime/i18n/lang/da.json +10 -2
  25. package/dist/runtime/i18n/lang/en.json +10 -2
  26. package/dist/runtime/server/mcp/tools/admin/get-submission.js +4 -4
  27. package/dist/runtime/server/mcp/tools/submit-form.js +2 -1
  28. package/dist/runtime/server/routes/admin/form-clone.post.js +6 -6
  29. package/dist/runtime/server/routes/admin/form-password.delete.js +5 -3
  30. package/dist/runtime/server/routes/admin/form-password.get.js +4 -2
  31. package/dist/runtime/server/routes/admin/form-password.put.js +4 -2
  32. package/dist/runtime/server/routes/admin/form-secret.delete.js +6 -5
  33. package/dist/runtime/server/routes/admin/form-secret.put.js +11 -0
  34. package/dist/runtime/server/routes/admin/form-secrets.get.js +4 -2
  35. package/dist/runtime/server/routes/admin/form-token.delete.js +9 -6
  36. package/dist/runtime/server/routes/admin/form-tokens.get.js +5 -3
  37. package/dist/runtime/server/routes/admin/form-tokens.post.js +5 -4
  38. package/dist/runtime/server/routes/admin/form.delete.js +6 -4
  39. package/dist/runtime/server/routes/admin/form.get.js +4 -2
  40. package/dist/runtime/server/routes/admin/form.patch.js +3 -3
  41. package/dist/runtime/server/routes/admin/forms.get.js +37 -16
  42. package/dist/runtime/server/routes/admin/forms.post.js +3 -4
  43. package/dist/runtime/server/routes/admin/import.post.js +3 -2
  44. package/dist/runtime/server/routes/admin/submission-file.get.js +8 -5
  45. package/dist/runtime/server/routes/admin/submission-redeliver.post.js +4 -3
  46. package/dist/runtime/server/routes/admin/submission.get.js +5 -17
  47. package/dist/runtime/server/routes/admin/submissions.get.js +29 -13
  48. package/dist/runtime/server/routes/public/access.get.js +3 -1
  49. package/dist/runtime/server/routes/public/form.get.js +3 -1
  50. package/dist/runtime/server/routes/public/submit.post.js +2 -1
  51. package/dist/runtime/server/utils/access.js +3 -2
  52. package/dist/runtime/server/utils/admin-schemas.d.ts +9 -0
  53. package/dist/runtime/server/utils/admin-schemas.js +4 -0
  54. package/dist/runtime/server/utils/auth.d.ts +8 -4
  55. package/dist/runtime/server/utils/auth.js +6 -7
  56. package/dist/runtime/server/utils/captcha.js +3 -2
  57. package/dist/runtime/server/utils/forms.d.ts +12 -2
  58. package/dist/runtime/server/utils/forms.js +57 -10
  59. package/dist/runtime/server/utils/mcp-access.js +2 -1
  60. package/dist/runtime/server/utils/rate-limit.js +3 -2
  61. package/dist/runtime/server/utils/submit.d.ts +3 -0
  62. package/dist/runtime/server/utils/submit.js +21 -17
  63. package/dist/runtime/utils/api.d.ts +36 -0
  64. package/dist/runtime/utils/api.js +39 -0
  65. package/dist/runtime/utils/index.d.ts +1 -0
  66. package/dist/runtime/utils/index.js +1 -0
  67. package/dist/types.d.mts +1 -1
  68. package/package.json +17 -12
  69. package/dist/runtime/server/routes/admin/form-secrets.put.js +0 -12
  70. /package/dist/runtime/server/routes/admin/{form-secrets.put.d.ts → form-secret.put.d.ts} +0 -0
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { resolveAccess, setAccess } from "../../utils";
2
+ import { formsApi, resolveAccess, setAccess } from "../../utils";
3
3
  const definition = defineModel({ type: Object, ...{ required: true } });
4
4
  const props = defineProps({
5
5
  formId: { type: String, required: true },
@@ -10,7 +10,6 @@ const toast = useToast();
10
10
  const showError = useErrorToast();
11
11
  const { copy: copyText } = useCopyToClipboard();
12
12
  const format = useFormat();
13
- const base = computed(() => `/api/forms/admin/forms/${props.formId}`);
14
13
  const access = computed(() => resolveAccess(definition.value));
15
14
  const availabilities = computed(() => [
16
15
  { value: "web", label: t("access.availabilityWeb", "Web page and API") },
@@ -50,14 +49,14 @@ function patchMoment(side, patch) {
50
49
  if (schedule.value.type === "yearly") setSchedule({ ...schedule.value, [side]: { ...schedule.value[side], ...patch } });
51
50
  }
52
51
  const fail = (cause) => showError(cause, t("error.generic", "Something went wrong. Please try again."));
53
- const { data: tokens, refresh: refreshTokens } = await useFetch(() => `${base.value}/tokens`, { default: () => [] });
52
+ const { data: tokens, refresh: refreshTokens } = await useFetch(() => formsApi.admin.tokens(props.formId), { default: () => [] });
54
53
  const tokenName = ref("");
55
54
  const creating = ref(false);
56
55
  const revealed = ref();
57
56
  async function createToken() {
58
57
  creating.value = true;
59
58
  try {
60
- revealed.value = await $fetch(`${base.value}/tokens`, { method: "POST", body: { name: tokenName.value } });
59
+ revealed.value = await $fetch(formsApi.admin.tokens(props.formId), { method: "POST", body: { name: tokenName.value } });
61
60
  tokenName.value = "";
62
61
  await refreshTokens();
63
62
  } catch (cause) {
@@ -68,20 +67,24 @@ async function createToken() {
68
67
  }
69
68
  async function revoke(token) {
70
69
  try {
71
- await $fetch(`${base.value}/tokens/${token.id}`, { method: "DELETE" });
70
+ await $fetch(formsApi.admin.token(props.formId, token.id), { method: "DELETE" });
72
71
  await refreshTokens();
73
72
  } catch (cause) {
74
73
  fail(cause);
75
74
  }
76
75
  }
77
76
  const copy = (text) => copyText(text, t("preview.copied", "Copied to clipboard."));
78
- const { data: passwordStatus, refresh: refreshPassword } = await useFetch(() => `${base.value}/password`, { default: () => ({ set: false }) });
77
+ const { data: passwordStatus, refresh: refreshPassword } = await useFetch(() => formsApi.admin.password(props.formId), { default: () => ({ set: false }) });
79
78
  const password = ref("");
80
79
  const savingPassword = ref(false);
80
+ const passwordHint = computed(() => [
81
+ access.value.auth === "password" && !passwordStatus.value?.set ? "" : passwordStatus.value?.set ? t("access.passwordIsSet", "A password is set.") : t("access.passwordIsNotSet", "No password is set."),
82
+ access.value.auth !== "password" ? t("access.passwordInactive", "It is only checked when authentication is set to Shared password.") : ""
83
+ ].filter(Boolean).join(" ") || void 0);
81
84
  async function savePassword() {
82
85
  savingPassword.value = true;
83
86
  try {
84
- await $fetch(`${base.value}/password`, { method: "PUT", body: { password: password.value } });
87
+ await $fetch(formsApi.admin.password(props.formId), { method: "PUT", body: { password: password.value } });
85
88
  password.value = "";
86
89
  await refreshPassword();
87
90
  toast.add({ title: t("access.passwordSaved", "Password saved."), color: "success" });
@@ -93,14 +96,14 @@ async function savePassword() {
93
96
  }
94
97
  async function clearPassword() {
95
98
  try {
96
- await $fetch(`${base.value}/password`, { method: "DELETE" });
99
+ await $fetch(formsApi.admin.password(props.formId), { method: "DELETE" });
97
100
  await refreshPassword();
98
101
  } catch (cause) {
99
102
  fail(cause);
100
103
  }
101
104
  }
102
105
  const origin = useRequestURL().origin;
103
- const submitUrl = computed(() => `${origin}/api/forms/${props.slug}/submit`);
106
+ const submitUrl = computed(() => `${origin}${formsApi.submit(props.slug)}`);
104
107
  const snippet = computed(() => {
105
108
  const headers = {
106
109
  token: " -H 'Authorization: Bearer TOKEN' \\\n",
@@ -115,283 +118,273 @@ ${headers} -H 'content-type: application/json' \\
115
118
  </script>
116
119
 
117
120
  <template>
118
- <div class="max-w-2xl space-y-8">
119
- <section class="space-y-4">
120
- <h2 class="font-semibold">
121
- {{ t("access.title", "Who can use this form") }}
122
- </h2>
123
- <UFormField
124
- :label="t('access.availability', 'Availability')"
125
- :description="access.availability === 'api' ? t('access.availabilityApiHint', 'No public page: the form can only be read and submitted through the API.') : t('access.availabilityWebHint', 'The public page and the API are both available.')"
126
- >
127
- <USelect
128
- :model-value="access.availability"
129
- :items="availabilities"
130
- :disabled="access.auth === 'token'"
131
- class="w-full"
132
- @update:model-value="definition = setAccess(definition, { availability: $event })"
133
- />
134
- </UFormField>
135
-
136
- <UFormField
137
- :label="t('access.auth', 'Authentication')"
138
- :description="access.auth === 'token' ? t('access.authTokenHint', 'Token forms are API only: a token cannot be typed into a page.') : void 0"
139
- >
140
- <USelect
141
- :model-value="access.auth"
142
- :items="authModes"
143
- class="w-full"
144
- @update:model-value="definition = setAccess(definition, { auth: $event })"
145
- />
146
- </UFormField>
147
- <UFormField
148
- :label="t('schedule.title', 'Open period')"
149
- :description="t('schedule.hint', 'Outside the period the form shows a closed notice and rejects submissions. A season whose end is before its start wraps over new year.')"
150
- >
151
- <USelect
152
- :model-value="schedule.type"
153
- :items="scheduleTypes"
154
- class="w-full"
155
- @update:model-value="changeScheduleType($event)"
156
- />
157
- </UFormField>
121
+ <div class="flex flex-col gap-4">
122
+ <SectionCard :title="t('access.title', 'Who can use this form')">
123
+ <div class="flex flex-col divide-y divide-default">
124
+ <FormRow
125
+ :label="t('access.availability', 'Availability')"
126
+ :description="access.availability === 'api' ? t('access.availabilityApiHint', 'No public page: the form can only be read and submitted through the API.') : t('access.availabilityWebHint', 'The public page and the API are both available.')"
127
+ >
128
+ <USelect
129
+ :model-value="access.availability"
130
+ :items="availabilities"
131
+ :disabled="access.auth === 'token'"
132
+ class="w-full sm:max-w-sm"
133
+ @update:model-value="definition = setAccess(definition, { availability: $event })"
134
+ />
135
+ </FormRow>
158
136
 
159
- <div
160
- v-if="schedule.type === 'range'"
161
- class="grid gap-4 sm:grid-cols-2"
162
- >
163
- <UFormField :label="t('schedule.opens', 'Opens')">
164
- <UInput
165
- type="datetime-local"
166
- class="w-full"
167
- :model-value="toLocalInput(schedule.from)"
168
- @update:model-value="setSchedule({ ...schedule, from: fromLocalInput($event) })"
137
+ <FormRow
138
+ :label="t('access.auth', 'Authentication')"
139
+ :description="access.auth === 'token' ? t('access.authTokenHint', 'Token forms are API only: a token cannot be typed into a page.') : void 0"
140
+ >
141
+ <USelect
142
+ :model-value="access.auth"
143
+ :items="authModes"
144
+ class="w-full sm:max-w-sm"
145
+ @update:model-value="definition = setAccess(definition, { auth: $event })"
169
146
  />
170
- </UFormField>
171
- <UFormField :label="t('schedule.closes', 'Closes')">
172
- <UInput
173
- type="datetime-local"
174
- class="w-full"
175
- :model-value="toLocalInput(schedule.to)"
176
- @update:model-value="setSchedule({ ...schedule, to: fromLocalInput($event) })"
147
+ </FormRow>
148
+ <FormRow
149
+ :label="t('schedule.title', 'Open period')"
150
+ :description="t('schedule.hint', 'Outside the period the form shows a closed notice and rejects submissions. A season whose end is before its start wraps over new year.')"
151
+ >
152
+ <USelect
153
+ :model-value="schedule.type"
154
+ :items="scheduleTypes"
155
+ class="w-full sm:max-w-sm"
156
+ @update:model-value="changeScheduleType($event)"
177
157
  />
178
- </UFormField>
179
- </div>
158
+ </FormRow>
180
159
 
181
- <div
182
- v-else-if="schedule.type === 'yearly'"
183
- class="space-y-4"
184
- >
185
- <div
186
- v-for="side in ['from', 'to']"
187
- :key="side"
188
- class="grid gap-4 sm:grid-cols-3"
189
- >
190
- <UFormField :label="side === 'from' ? t('schedule.opens', 'Opens') : t('schedule.closes', 'Closes')">
191
- <USelect
192
- :model-value="schedule[side].month"
193
- :items="months"
194
- class="w-full"
195
- @update:model-value="patchMoment(side, { month: Number($event) })"
160
+ <template v-if="schedule.type === 'range'">
161
+ <FormRow :label="t('schedule.opens', 'Opens')">
162
+ <UInput
163
+ type="datetime-local"
164
+ class="w-full sm:max-w-sm"
165
+ :model-value="toLocalInput(schedule.from)"
166
+ @update:model-value="setSchedule({ ...schedule, from: fromLocalInput($event) })"
196
167
  />
197
- </UFormField>
198
- <UFormField :label="t('schedule.day', 'Day')">
168
+ </FormRow>
169
+ <FormRow :label="t('schedule.closes', 'Closes')">
199
170
  <UInput
200
- type="number"
201
- min="1"
202
- max="31"
203
- class="w-full"
204
- :model-value="schedule[side].day"
205
- @update:model-value="patchMoment(side, { day: Number($event) })"
171
+ type="datetime-local"
172
+ class="w-full sm:max-w-sm"
173
+ :model-value="toLocalInput(schedule.to)"
174
+ @update:model-value="setSchedule({ ...schedule, to: fromLocalInput($event) })"
206
175
  />
207
- </UFormField>
208
- <UFormField :label="t('schedule.time', 'Time')">
176
+ </FormRow>
177
+ </template>
178
+
179
+ <template v-else-if="schedule.type === 'yearly'">
180
+ <FormRow
181
+ v-for="side in ['from', 'to']"
182
+ :key="side"
183
+ :label="side === 'from' ? t('schedule.opens', 'Opens') : t('schedule.closes', 'Closes')"
184
+ >
185
+ <div class="grid grid-cols-3 gap-2 sm:max-w-sm">
186
+ <UFormField :label="t('schedule.month', 'Month')">
187
+ <USelect
188
+ :model-value="schedule[side].month"
189
+ :items="months"
190
+ class="w-full"
191
+ @update:model-value="patchMoment(side, { month: Number($event) })"
192
+ />
193
+ </UFormField>
194
+ <UFormField :label="t('schedule.day', 'Day')">
195
+ <UInput
196
+ type="number"
197
+ min="1"
198
+ max="31"
199
+ class="w-full"
200
+ :model-value="schedule[side].day"
201
+ @update:model-value="patchMoment(side, { day: Number($event) })"
202
+ />
203
+ </UFormField>
204
+ <UFormField :label="t('schedule.time', 'Time')">
205
+ <UInput
206
+ type="time"
207
+ class="w-full"
208
+ :model-value="schedule[side].time"
209
+ @update:model-value="patchMoment(side, { time: String($event) })"
210
+ />
211
+ </UFormField>
212
+ </div>
213
+ </FormRow>
214
+ <FormRow :label="t('schedule.timezone', 'Timezone')">
209
215
  <UInput
210
- type="time"
211
- class="w-full"
212
- :model-value="schedule[side].time"
213
- @update:model-value="patchMoment(side, { time: String($event) })"
216
+ class="w-full sm:max-w-sm"
217
+ :model-value="schedule.timezone"
218
+ @update:model-value="setSchedule({ ...schedule, timezone: String($event) })"
214
219
  />
215
- </UFormField>
216
- </div>
217
- <UFormField :label="t('schedule.timezone', 'Timezone')">
218
- <UInput
219
- class="w-full"
220
- :model-value="schedule.timezone"
221
- @update:model-value="setSchedule({ ...schedule, timezone: String($event) })"
222
- />
223
- </UFormField>
220
+ </FormRow>
221
+ </template>
222
+
223
+ <p class="pt-4 text-sm text-muted">
224
+ {{ t("access.saveHint", "Availability and authentication are saved with the form. Tokens and the password take effect immediately.") }}
225
+ </p>
224
226
  </div>
227
+ </SectionCard>
225
228
 
226
- <p class="text-sm text-muted">
227
- {{ t("access.saveHint", "Availability and authentication are saved with the form. Tokens and the password take effect immediately.") }}
228
- </p>
229
- </section>
229
+ <SectionCard :title="t('access.tokensTitle', 'API tokens')">
230
+ <div class="flex flex-col gap-4">
231
+ <p class="text-sm text-muted">
232
+ {{ t("access.tokensHint", "Send a token as Authorization: Bearer TOKEN or in the x-form-token header. It is shown only once.") }}
233
+ </p>
234
+ <UAlert
235
+ v-if="access.auth !== 'token'"
236
+ color="neutral"
237
+ variant="subtle"
238
+ :title="t('access.tokensInactive', 'Tokens are only checked when authentication is set to API token.')"
239
+ />
230
240
 
231
- <section class="space-y-4">
232
- <h2 class="font-semibold">
233
- {{ t("access.tokensTitle", "API tokens") }}
234
- </h2>
235
- <p class="text-sm text-muted">
236
- {{ t("access.tokensHint", "Send a token as Authorization: Bearer TOKEN or in the x-form-token header. It is shown only once.") }}
237
- </p>
238
- <UAlert
239
- v-if="access.auth !== 'token'"
240
- color="neutral"
241
- variant="subtle"
242
- :title="t('access.tokensInactive', 'Tokens are only checked when authentication is set to API token.')"
243
- />
241
+ <form
242
+ class="flex gap-2"
243
+ @submit.prevent="createToken"
244
+ >
245
+ <UInput
246
+ v-model="tokenName"
247
+ :placeholder="t('access.tokenName', 'Token name, e.g. Mobile app')"
248
+ class="flex-1 sm:max-w-sm"
249
+ />
250
+ <UButton
251
+ type="submit"
252
+ :loading="creating"
253
+ :disabled="!tokenName.trim()"
254
+ :label="t('access.createToken', 'Create token')"
255
+ />
256
+ </form>
244
257
 
245
- <form
246
- class="flex gap-2"
247
- @submit.prevent="createToken"
248
- >
249
- <UInput
250
- v-model="tokenName"
251
- :placeholder="t('access.tokenName', 'Token name, e.g. Mobile app')"
252
- class="flex-1"
253
- />
254
- <UButton
255
- type="submit"
256
- :loading="creating"
257
- :disabled="!tokenName.trim()"
258
- :label="t('access.createToken', 'Create token')"
259
- />
260
- </form>
258
+ <UAlert
259
+ v-if="revealed"
260
+ color="success"
261
+ variant="subtle"
262
+ :title="t('access.tokenCreated', 'Copy this token now. You will not see it again.')"
263
+ :close="true"
264
+ @update:open="revealed = void 0"
265
+ >
266
+ <template #description>
267
+ <div class="mt-2 flex items-center gap-2">
268
+ <code
269
+ data-testid="revealed-token"
270
+ class="break-all rounded bg-elevated px-2 py-1 text-xs"
271
+ >{{ revealed.token }}</code>
272
+ <UButton
273
+ color="neutral"
274
+ variant="outline"
275
+ size="xs"
276
+ icon="i-lucide-copy"
277
+ :label="t('preview.copy', 'Copy')"
278
+ @click="copy(revealed.token)"
279
+ />
280
+ </div>
281
+ </template>
282
+ </UAlert>
261
283
 
262
- <UAlert
263
- v-if="revealed"
264
- color="success"
265
- variant="subtle"
266
- :title="t('access.tokenCreated', 'Copy this token now. You will not see it again.')"
267
- :close="true"
268
- @update:open="revealed = void 0"
269
- >
270
- <template #description>
271
- <div class="mt-2 flex items-center gap-2">
272
- <code
273
- data-testid="revealed-token"
274
- class="break-all rounded bg-elevated px-2 py-1 text-xs"
275
- >{{ revealed.token }}</code>
284
+ <ul
285
+ v-if="tokens?.length"
286
+ class="divide-y divide-default rounded-md border border-default"
287
+ >
288
+ <li
289
+ v-for="token in tokens"
290
+ :key="token.id"
291
+ class="flex items-center justify-between gap-3 p-3"
292
+ >
293
+ <div class="min-w-0">
294
+ <p class="truncate font-medium">
295
+ {{ token.name }}
296
+ <StatusBadge
297
+ v-if="token.revokedAt"
298
+ status="revoked"
299
+ size="sm"
300
+ :label="t('access.revoked', 'Revoked')"
301
+ :colors="{ revoked: 'error' }"
302
+ />
303
+ </p>
304
+ <p class="text-xs text-muted">
305
+ <code>{{ token.prefix }}…</code>
306
+ ·
307
+ {{ token.lastUsedAt ? t("access.lastUsed", "Last used {date}", { date: format.dateTime(token.lastUsedAt) }) : t("access.neverUsed", "Never used") }}
308
+ </p>
309
+ </div>
276
310
  <UButton
277
- color="neutral"
311
+ v-if="!token.revokedAt"
312
+ color="error"
278
313
  variant="outline"
279
314
  size="xs"
280
- icon="i-lucide-copy"
281
- :label="t('preview.copy', 'Copy')"
282
- @click="copy(revealed.token)"
315
+ :label="t('access.revoke', 'Revoke')"
316
+ @click="revoke(token)"
283
317
  />
284
- </div>
285
- </template>
286
- </UAlert>
318
+ </li>
319
+ </ul>
320
+ <p
321
+ v-else
322
+ class="text-sm text-muted"
323
+ >
324
+ {{ t("access.noTokens", "No tokens yet.") }}
325
+ </p>
326
+ </div>
327
+ </SectionCard>
287
328
 
288
- <ul
289
- v-if="tokens?.length"
290
- class="divide-y divide-default rounded-md border border-default"
291
- >
292
- <li
293
- v-for="token in tokens"
294
- :key="token.id"
295
- class="flex items-center justify-between gap-3 p-3"
329
+ <SectionCard :title="t('access.passwordTitle', 'Shared password')">
330
+ <div class="flex flex-col gap-4">
331
+ <UAlert
332
+ v-if="access.auth === 'password' && !passwordStatus?.set"
333
+ color="warning"
334
+ variant="subtle"
335
+ :title="t('access.passwordMissing', 'No password is set, so nobody can open this form yet.')"
336
+ />
337
+ <FormRow
338
+ :label="t('access.passwordLabel', 'Password')"
339
+ :description="passwordHint"
296
340
  >
297
- <div class="min-w-0">
298
- <p class="truncate font-medium">
299
- {{ token.name }}
300
- <UBadge
301
- v-if="token.revokedAt"
302
- color="error"
303
- variant="subtle"
304
- size="sm"
305
- :label="t('access.revoked', 'Revoked')"
306
- />
307
- </p>
308
- <p class="text-xs text-muted">
309
- <code>{{ token.prefix }}…</code>
310
- ·
311
- {{ token.lastUsedAt ? t("access.lastUsed", "Last used {date}", { date: format.dateTime(token.lastUsedAt) }) : t("access.neverUsed", "Never used") }}
312
- </p>
313
- </div>
341
+ <form
342
+ class="flex flex-wrap gap-2"
343
+ @submit.prevent="savePassword"
344
+ >
345
+ <UInput
346
+ v-model="password"
347
+ type="password"
348
+ autocomplete="new-password"
349
+ :placeholder="passwordStatus?.set ? t('access.newPassword', 'New password') : t('access.setPassword', 'Password (min. 6 characters)')"
350
+ class="min-w-0 flex-1 sm:max-w-sm"
351
+ />
352
+ <UButton
353
+ type="submit"
354
+ :loading="savingPassword"
355
+ :disabled="password.length < 6"
356
+ :label="passwordStatus?.set ? t('access.changePassword', 'Change') : t('access.savePassword', 'Set password')"
357
+ />
358
+ <UButton
359
+ v-if="passwordStatus?.set"
360
+ type="button"
361
+ color="error"
362
+ variant="outline"
363
+ :label="t('access.clearPassword', 'Clear')"
364
+ @click="clearPassword"
365
+ />
366
+ </form>
367
+ </FormRow>
368
+ </div>
369
+ </SectionCard>
370
+
371
+ <SectionCard :title="t('access.apiTitle', 'API')">
372
+ <div class="flex flex-col gap-4">
373
+ <p class="text-sm text-muted">
374
+ {{ t("access.apiHint", "Submit with POST (JSON, or multipart with file:KEY parts). The definition is available with GET on /api/forms/{slug}.", { slug: props.slug }) }}
375
+ </p>
376
+ <div class="flex items-start gap-2">
377
+ <pre class="flex-1 overflow-x-auto rounded-md bg-elevated p-3 text-xs">{{ snippet }}</pre>
314
378
  <UButton
315
- v-if="!token.revokedAt"
316
- color="error"
379
+ color="neutral"
317
380
  variant="outline"
318
381
  size="xs"
319
- :label="t('access.revoke', 'Revoke')"
320
- @click="revoke(token)"
382
+ icon="i-lucide-copy"
383
+ :label="t('preview.copy', 'Copy')"
384
+ @click="copy(snippet)"
321
385
  />
322
- </li>
323
- </ul>
324
- <p
325
- v-else
326
- class="text-sm text-muted"
327
- >
328
- {{ t("access.noTokens", "No tokens yet.") }}
329
- </p>
330
- </section>
331
-
332
- <section class="space-y-4">
333
- <h2 class="font-semibold">
334
- {{ t("access.passwordTitle", "Shared password") }}
335
- </h2>
336
- <UAlert
337
- v-if="access.auth === 'password' && !passwordStatus?.set"
338
- color="warning"
339
- variant="subtle"
340
- :title="t('access.passwordMissing', 'No password is set, so nobody can open this form yet.')"
341
- />
342
- <p
343
- v-else
344
- class="text-sm text-muted"
345
- >
346
- {{ passwordStatus?.set ? t("access.passwordIsSet", "A password is set.") : t("access.passwordIsNotSet", "No password is set.") }}
347
- {{ access.auth !== "password" ? t("access.passwordInactive", "It is only checked when authentication is set to Shared password.") : "" }}
348
- </p>
349
- <form
350
- class="flex gap-2"
351
- @submit.prevent="savePassword"
352
- >
353
- <UInput
354
- v-model="password"
355
- type="password"
356
- autocomplete="new-password"
357
- :placeholder="passwordStatus?.set ? t('access.newPassword', 'New password') : t('access.setPassword', 'Password (min. 6 characters)')"
358
- class="flex-1"
359
- />
360
- <UButton
361
- type="submit"
362
- :loading="savingPassword"
363
- :disabled="password.length < 6"
364
- :label="passwordStatus?.set ? t('access.changePassword', 'Change') : t('access.savePassword', 'Set password')"
365
- />
366
- <UButton
367
- v-if="passwordStatus?.set"
368
- type="button"
369
- color="error"
370
- variant="outline"
371
- :label="t('access.clearPassword', 'Clear')"
372
- @click="clearPassword"
373
- />
374
- </form>
375
- </section>
376
-
377
- <section class="space-y-3">
378
- <h2 class="font-semibold">
379
- {{ t("access.apiTitle", "API") }}
380
- </h2>
381
- <p class="text-sm text-muted">
382
- {{ t("access.apiHint", "Submit with POST (JSON, or multipart with file:KEY parts). The definition is available with GET on /api/forms/{slug}.", { slug: props.slug }) }}
383
- </p>
384
- <div class="flex items-start gap-2">
385
- <pre class="flex-1 overflow-x-auto rounded-md bg-elevated p-3 text-xs">{{ snippet }}</pre>
386
- <UButton
387
- color="neutral"
388
- variant="outline"
389
- size="xs"
390
- icon="i-lucide-copy"
391
- :label="t('preview.copy', 'Copy')"
392
- @click="copy(snippet)"
393
- />
386
+ </div>
394
387
  </div>
395
- </section>
388
+ </SectionCard>
396
389
  </div>
397
390
  </template>
@@ -60,7 +60,14 @@ const keyMissing = computed(() => !captchaSiteKey(props.field));
60
60
  data-testid="captcha-secret-status"
61
61
  >
62
62
  <span class="text-muted">{{ t("captcha.secretStatus", "Secret key") }}:</span>
63
- <span :class="secrets.source === 'unset' ? 'text-warning' : 'text-success'">{{ secrets.statusLabel }}</span>
63
+ <USkeleton
64
+ v-if="secrets.loading"
65
+ class="h-5 w-32"
66
+ />
67
+ <span
68
+ v-else
69
+ :class="secrets.source === 'unset' ? 'text-warning' : 'text-success'"
70
+ >{{ secrets.statusLabel }}</span>
64
71
  </p>
65
72
  <UAlert
66
73
  v-if="secrets.secrets?.unreadable"