@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.
- package/dist/module.d.mts +18 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +29 -26
- package/dist/runtime/app/components/FormBuilderAccess.vue +255 -262
- package/dist/runtime/app/components/FormBuilderCaptchaSettings.vue +8 -1
- package/dist/runtime/app/components/FormBuilderSettings.vue +143 -141
- package/dist/runtime/app/components/FormCreateSlideover.vue +2 -2
- package/dist/runtime/app/components/FormEmbed.vue +2 -2
- package/dist/runtime/app/components/FormImportSlideover.d.vue.ts +8 -0
- package/dist/runtime/app/components/FormImportSlideover.vue +57 -0
- package/dist/runtime/app/components/FormImportSlideover.vue.d.ts +8 -0
- package/dist/runtime/app/components/FormRenderer.vue +3 -3
- package/dist/runtime/app/components/FormSubmissionView.vue +2 -2
- package/dist/runtime/app/composables/useCaptchaSecrets.js +17 -7
- package/dist/runtime/app/composables/useFormActions.d.ts +14 -2
- package/dist/runtime/app/composables/useFormActions.js +33 -21
- package/dist/runtime/app/composables/useForms.d.ts +12 -5
- package/dist/runtime/app/composables/useForms.js +11 -5
- package/dist/runtime/app/pages/admin-form.vue +36 -20
- package/dist/runtime/app/pages/admin-submission.vue +27 -16
- package/dist/runtime/app/pages/admin-submissions.vue +30 -22
- package/dist/runtime/app/pages/admin.vue +29 -78
- package/dist/runtime/app/pages/form.vue +2 -1
- package/dist/runtime/i18n/lang/da.json +10 -2
- package/dist/runtime/i18n/lang/en.json +10 -2
- package/dist/runtime/server/mcp/tools/admin/get-submission.js +4 -4
- package/dist/runtime/server/mcp/tools/submit-form.js +2 -1
- package/dist/runtime/server/routes/admin/form-clone.post.js +6 -6
- package/dist/runtime/server/routes/admin/form-password.delete.js +5 -3
- package/dist/runtime/server/routes/admin/form-password.get.js +4 -2
- package/dist/runtime/server/routes/admin/form-password.put.js +4 -2
- package/dist/runtime/server/routes/admin/form-secret.delete.js +6 -5
- package/dist/runtime/server/routes/admin/form-secret.put.js +11 -0
- package/dist/runtime/server/routes/admin/form-secrets.get.js +4 -2
- package/dist/runtime/server/routes/admin/form-token.delete.js +9 -6
- package/dist/runtime/server/routes/admin/form-tokens.get.js +5 -3
- package/dist/runtime/server/routes/admin/form-tokens.post.js +5 -4
- package/dist/runtime/server/routes/admin/form.delete.js +6 -4
- package/dist/runtime/server/routes/admin/form.get.js +4 -2
- package/dist/runtime/server/routes/admin/form.patch.js +3 -3
- package/dist/runtime/server/routes/admin/forms.get.js +37 -16
- package/dist/runtime/server/routes/admin/forms.post.js +3 -4
- package/dist/runtime/server/routes/admin/import.post.js +3 -2
- package/dist/runtime/server/routes/admin/submission-file.get.js +8 -5
- package/dist/runtime/server/routes/admin/submission-redeliver.post.js +4 -3
- package/dist/runtime/server/routes/admin/submission.get.js +5 -17
- package/dist/runtime/server/routes/admin/submissions.get.js +29 -13
- package/dist/runtime/server/routes/public/access.get.js +3 -1
- package/dist/runtime/server/routes/public/form.get.js +3 -1
- package/dist/runtime/server/routes/public/submit.post.js +2 -1
- package/dist/runtime/server/utils/access.js +3 -2
- package/dist/runtime/server/utils/admin-schemas.d.ts +9 -0
- package/dist/runtime/server/utils/admin-schemas.js +4 -0
- package/dist/runtime/server/utils/auth.d.ts +8 -4
- package/dist/runtime/server/utils/auth.js +6 -7
- package/dist/runtime/server/utils/captcha.js +3 -2
- package/dist/runtime/server/utils/forms.d.ts +12 -2
- package/dist/runtime/server/utils/forms.js +57 -10
- package/dist/runtime/server/utils/mcp-access.js +2 -1
- package/dist/runtime/server/utils/rate-limit.js +3 -2
- package/dist/runtime/server/utils/submit.d.ts +3 -0
- package/dist/runtime/server/utils/submit.js +21 -17
- package/dist/runtime/utils/api.d.ts +36 -0
- package/dist/runtime/utils/api.js +39 -0
- package/dist/runtime/utils/index.d.ts +1 -0
- package/dist/runtime/utils/index.js +1 -0
- package/dist/types.d.mts +1 -1
- package/package.json +17 -12
- package/dist/runtime/server/routes/admin/form-secrets.put.js +0 -12
- /package/dist/runtime/server/routes/admin/{form-secrets.put.d.ts → form-secret.put.d.ts} +0 -0
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type { H3Event } from 'h3';
|
|
2
|
-
/**
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
/**
|
|
3
|
+
* The staff gate of the admin routes (form definitions, submissions, files, access credentials): the `forms:manage`
|
|
4
|
+
* scope for bearer credentials, then the host's `forms:authorize` Nitro hook (denied by default).
|
|
5
|
+
*
|
|
6
|
+
* await formsStaff.require(event) // reads and ordinary edits
|
|
7
|
+
* await formsStaff.require(event, { sessionOnly: true }) // minting or removing access credentials
|
|
8
|
+
*/
|
|
9
|
+
export declare const formsStaff: any;
|
|
6
10
|
/** Non-throwing form of the `forms:authorize` policy for the caller of an already-authenticated request (MCP). */
|
|
7
11
|
export declare const canManageForms: (event: H3Event, principal: {
|
|
8
12
|
user: {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
const
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export const canManageForms = (event, principal) => gate.isAllowed(event, principal.user.id);
|
|
1
|
+
export const formsStaff = defineStaffGuard({
|
|
2
|
+
hook: "forms:authorize",
|
|
3
|
+
scope: "forms:manage",
|
|
4
|
+
message: "You are not allowed to manage forms."
|
|
5
|
+
});
|
|
6
|
+
export const canManageForms = (event, principal) => formsStaff.can(event, principal.user.id);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { apiError } from "@frigear-nu/module-kit/server";
|
|
1
2
|
const VERIFY_URLS = {
|
|
2
3
|
turnstile: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
|
3
4
|
recaptcha: "https://www.google.com/recaptcha/api/siteverify",
|
|
@@ -11,7 +12,7 @@ export async function verifyCaptcha(event, provider, token, formId) {
|
|
|
11
12
|
if (ctx.valid !== void 0) return ctx.valid;
|
|
12
13
|
if (!secret) {
|
|
13
14
|
console.error(`[forms] captcha provider "${provider}" has no secret key: set one on the form or NUXT_FORMS_CAPTCHA_${provider.toUpperCase()}_SECRET_KEY.`);
|
|
14
|
-
throw
|
|
15
|
+
throw apiError(500, "not_configured", `The ${provider} captcha is not configured on the server.`, { captcha: true });
|
|
15
16
|
}
|
|
16
17
|
const body = new URLSearchParams({ secret, response: token });
|
|
17
18
|
if (ip) body.set("remoteip", ip);
|
|
@@ -20,6 +21,6 @@ export async function verifyCaptcha(event, provider, token, formId) {
|
|
|
20
21
|
return result?.success === true;
|
|
21
22
|
} catch (cause) {
|
|
22
23
|
console.error(`[forms] ${provider} verification request failed:`, cause);
|
|
23
|
-
throw
|
|
24
|
+
throw apiError(502, "captcha_unavailable", "The captcha could not be verified. Please try again.", { captcha: true });
|
|
24
25
|
}
|
|
25
26
|
}
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { schema } from '@nuxthub/db';
|
|
2
|
-
import type { FormAccessTokenDto, FormAdminDto, PublicFormDto } from '../../../types.js';
|
|
2
|
+
import type { FormAccessTokenDto, FormAdminDto, FormSummaryDto, PublicFormDto, SubmissionDto, SubmissionFileDto, SubmissionSummaryDto } from '../../../types.js';
|
|
3
3
|
import type { z } from 'zod';
|
|
4
4
|
import type { formInput, formPatch } from './admin-schemas.js';
|
|
5
5
|
type FormRow = typeof schema.forms.$inferSelect;
|
|
6
6
|
export declare const toPublicForm: (row: FormRow) => PublicFormDto;
|
|
7
|
-
export declare const
|
|
7
|
+
export declare const toFormTokenDto: (row: typeof schema.formAccessTokens.$inferSelect) => FormAccessTokenDto;
|
|
8
8
|
export declare const toAdminForm: (row: FormRow) => FormAdminDto;
|
|
9
|
+
/** A row of the admin form list. */
|
|
10
|
+
export declare const toFormSummaryDto: (row: FormRow, submissionCount: number) => FormSummaryDto;
|
|
11
|
+
type SubmissionRow = typeof schema.formSubmissions.$inferSelect;
|
|
12
|
+
type SubmissionFileRow = typeof schema.formSubmissionFiles.$inferSelect;
|
|
13
|
+
/** A row of a form's submission list: the first few answers as a preview. */
|
|
14
|
+
export declare const toSubmissionSummaryDto: (row: SubmissionRow) => SubmissionSummaryDto;
|
|
15
|
+
/** File metadata of a submission; the blob pathname stays on the server. */
|
|
16
|
+
export declare const toSubmissionFileDto: (row: SubmissionFileRow) => SubmissionFileDto;
|
|
17
|
+
export declare const toSubmissionDto: (row: SubmissionRow, files: SubmissionFileRow[]) => SubmissionDto;
|
|
18
|
+
export declare function findSubmissionById(id: string): Promise<SubmissionRow>;
|
|
9
19
|
export declare function findFormById(id: string): Promise<FormRow>;
|
|
10
20
|
export declare function findFormBySlug(slug: string): Promise<FormRow | undefined>;
|
|
11
21
|
export declare function findPublishedForm(slug: string): Promise<FormRow>;
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { eq } from "drizzle-orm";
|
|
2
2
|
import { db, schema } from "@nuxthub/db";
|
|
3
|
+
import { conflict, isoDate, notFound } from "@frigear-nu/module-kit/server";
|
|
3
4
|
import { newId } from "../../utils/index.js";
|
|
5
|
+
import { renderAnswers } from "./render.js";
|
|
4
6
|
export const toPublicForm = (row) => {
|
|
5
7
|
const { delivery: _delivery, ...definition } = row.definition;
|
|
6
8
|
return { id: row.id, slug: row.slug, name: row.name, version: row.version, definition };
|
|
7
9
|
};
|
|
8
|
-
export const
|
|
10
|
+
export const toFormTokenDto = (row) => ({
|
|
9
11
|
id: row.id,
|
|
10
12
|
name: row.name,
|
|
11
13
|
prefix: row.prefix,
|
|
12
|
-
createdAt: row.createdAt
|
|
13
|
-
lastUsedAt: row.lastUsedAt
|
|
14
|
-
revokedAt: row.revokedAt
|
|
14
|
+
createdAt: isoDate(row.createdAt),
|
|
15
|
+
lastUsedAt: isoDate(row.lastUsedAt),
|
|
16
|
+
revokedAt: isoDate(row.revokedAt)
|
|
15
17
|
});
|
|
16
18
|
export const toAdminForm = (row) => ({
|
|
17
19
|
id: row.id,
|
|
@@ -20,12 +22,57 @@ export const toAdminForm = (row) => ({
|
|
|
20
22
|
status: row.status,
|
|
21
23
|
version: row.version,
|
|
22
24
|
definition: row.definition,
|
|
23
|
-
createdAt: row.createdAt
|
|
24
|
-
updatedAt: row.updatedAt
|
|
25
|
+
createdAt: isoDate(row.createdAt),
|
|
26
|
+
updatedAt: isoDate(row.updatedAt)
|
|
25
27
|
});
|
|
28
|
+
export const toFormSummaryDto = (row, submissionCount) => ({
|
|
29
|
+
id: row.id,
|
|
30
|
+
slug: row.slug,
|
|
31
|
+
name: row.name,
|
|
32
|
+
status: row.status,
|
|
33
|
+
version: row.version,
|
|
34
|
+
storage: row.definition.storage,
|
|
35
|
+
mode: row.definition.mode,
|
|
36
|
+
submissionCount,
|
|
37
|
+
updatedAt: isoDate(row.updatedAt)
|
|
38
|
+
});
|
|
39
|
+
export const toSubmissionSummaryDto = (row) => ({
|
|
40
|
+
id: row.id,
|
|
41
|
+
createdAt: isoDate(row.createdAt),
|
|
42
|
+
locale: row.locale,
|
|
43
|
+
userId: row.userId,
|
|
44
|
+
deliveryStatus: row.deliveryStatus,
|
|
45
|
+
preview: renderAnswers(row.definitionSnapshot, row.answers, row.locale).filter((answer) => answer.value).slice(0, 3).map(({ label, value }) => ({ label, value: value.slice(0, 120) }))
|
|
46
|
+
});
|
|
47
|
+
export const toSubmissionFileDto = (row) => ({
|
|
48
|
+
id: row.id,
|
|
49
|
+
fieldKey: row.fieldKey,
|
|
50
|
+
filename: row.filename,
|
|
51
|
+
contentType: row.contentType,
|
|
52
|
+
size: row.size
|
|
53
|
+
});
|
|
54
|
+
export const toSubmissionDto = (row, files) => ({
|
|
55
|
+
id: row.id,
|
|
56
|
+
formId: row.formId,
|
|
57
|
+
formSlug: row.formSlug,
|
|
58
|
+
formVersion: row.formVersion,
|
|
59
|
+
definition: row.definitionSnapshot,
|
|
60
|
+
answers: row.answers,
|
|
61
|
+
files: files.map(toSubmissionFileDto),
|
|
62
|
+
locale: row.locale,
|
|
63
|
+
userId: row.userId,
|
|
64
|
+
deliveryStatus: row.deliveryStatus,
|
|
65
|
+
deliveryError: row.deliveryError,
|
|
66
|
+
createdAt: isoDate(row.createdAt)
|
|
67
|
+
});
|
|
68
|
+
export async function findSubmissionById(id) {
|
|
69
|
+
const [row] = await db.select().from(schema.formSubmissions).where(eq(schema.formSubmissions.id, id)).limit(1);
|
|
70
|
+
if (!row) throw notFound("Submission not found.");
|
|
71
|
+
return row;
|
|
72
|
+
}
|
|
26
73
|
export async function findFormById(id) {
|
|
27
74
|
const [row] = await db.select().from(schema.forms).where(eq(schema.forms.id, id)).limit(1);
|
|
28
|
-
if (!row) throw
|
|
75
|
+
if (!row) throw notFound("Form not found.");
|
|
29
76
|
return row;
|
|
30
77
|
}
|
|
31
78
|
export async function findFormBySlug(slug) {
|
|
@@ -34,12 +81,12 @@ export async function findFormBySlug(slug) {
|
|
|
34
81
|
}
|
|
35
82
|
export async function findPublishedForm(slug) {
|
|
36
83
|
const row = await findFormBySlug(slug);
|
|
37
|
-
if (!row || row.status !== "published") throw
|
|
84
|
+
if (!row || row.status !== "published") throw notFound("Form not found.");
|
|
38
85
|
return row;
|
|
39
86
|
}
|
|
40
87
|
export async function createForm(input) {
|
|
41
88
|
if (await findFormBySlug(input.slug)) {
|
|
42
|
-
throw
|
|
89
|
+
throw conflict("A form with this slug already exists.", { field: "slug" });
|
|
43
90
|
}
|
|
44
91
|
const [row] = await db.insert(schema.forms).values({ id: newId("frm"), ...input }).returning();
|
|
45
92
|
return row;
|
|
@@ -47,7 +94,7 @@ export async function createForm(input) {
|
|
|
47
94
|
export async function patchForm(id, input) {
|
|
48
95
|
const current = await findFormById(id);
|
|
49
96
|
if (input.slug && input.slug !== current.slug && await findFormBySlug(input.slug)) {
|
|
50
|
-
throw
|
|
97
|
+
throw conflict("A form with this slug already exists.", { field: "slug" });
|
|
51
98
|
}
|
|
52
99
|
const definitionChanged = input.definition !== void 0 && JSON.stringify(input.definition) !== JSON.stringify(current.definition);
|
|
53
100
|
const [row] = await db.update(schema.forms).set({
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { forbidden } from "@frigear-nu/module-kit/server";
|
|
1
2
|
import { resolveAccess } from "../../utils/index.js";
|
|
2
3
|
export async function assertMcpFormAccess(event, form) {
|
|
3
4
|
const { auth } = resolveAccess(form.definition);
|
|
4
5
|
if (auth === "token" || auth === "password") {
|
|
5
|
-
throw
|
|
6
|
+
throw forbidden("This form needs its own access token or password and cannot be used through MCP.");
|
|
6
7
|
}
|
|
7
8
|
await authorizeFormAccess(event, form);
|
|
8
9
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { apiError } from "@frigear-nu/module-kit/server";
|
|
1
2
|
import { kv } from "@nuxthub/kv";
|
|
2
3
|
export async function enforceRateLimit(event, slug) {
|
|
3
4
|
const { rateLimit, rateLimitWindowSeconds } = useRuntimeConfig().forms;
|
|
@@ -8,7 +9,7 @@ export async function enforceRateLimit(event, slug) {
|
|
|
8
9
|
const used = Number(await kv.get(key) ?? 0);
|
|
9
10
|
if (used >= rateLimit) {
|
|
10
11
|
setResponseHeader(event, "retry-after", rateLimitWindowSeconds);
|
|
11
|
-
throw
|
|
12
|
+
throw apiError(429, "rate_limited", "Too many submissions, try again later.");
|
|
12
13
|
}
|
|
13
14
|
await kv.set(key, used + 1, { ttl: rateLimitWindowSeconds });
|
|
14
15
|
}
|
|
@@ -22,7 +23,7 @@ export async function assertPasswordAttemptsLeft(event, slug) {
|
|
|
22
23
|
if (!rateLimit) return;
|
|
23
24
|
if (Number(await kv.get(attemptKey(event, slug)) ?? 0) >= rateLimit) {
|
|
24
25
|
setResponseHeader(event, "retry-after", rateLimitWindowSeconds);
|
|
25
|
-
throw
|
|
26
|
+
throw apiError(429, "rate_limited", "Too many attempts, try again later.");
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
export async function recordPasswordFailure(event, slug) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { H3Event } from 'h3';
|
|
2
|
+
import { z } from 'zod';
|
|
2
3
|
import { schema } from '@nuxthub/db';
|
|
3
4
|
type Upload = {
|
|
4
5
|
filename: string;
|
|
@@ -12,6 +13,8 @@ export type SubmissionBody = {
|
|
|
12
13
|
locale: string;
|
|
13
14
|
uploads: Map<string, Upload[]>;
|
|
14
15
|
};
|
|
16
|
+
/** A BCP 47-ish locale tag (`en`, `da`, `en-GB`, `zh-Hant-TW`), as every submission path (HTTP, MCP) accepts it. */
|
|
17
|
+
export declare const submissionLocale: z.ZodString;
|
|
15
18
|
/** Validates and processes one submission for a published form. */
|
|
16
19
|
export declare function processSubmission(event: H3Event, form: FormRow, userId: number | null, body?: SubmissionBody): Promise<{
|
|
17
20
|
ok: true;
|
|
@@ -1,34 +1,46 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { apiError, validationError } from "@frigear-nu/module-kit/server";
|
|
1
3
|
import { eq } from "drizzle-orm";
|
|
2
4
|
import { db, schema } from "@nuxthub/db";
|
|
3
5
|
import { blob } from "@nuxthub/blob";
|
|
4
6
|
import { buildFormSchema, newId, resolveAccess, resolveCaptcha, validatedFields } from "../../utils/index.js";
|
|
5
7
|
const safeFilename = (name) => name.replace(/[^\w.-]+/g, "_").replace(/^\.+/, "").slice(0, 120) || "file";
|
|
8
|
+
export const submissionLocale = z.string().trim().max(20).regex(/^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/i, "Invalid locale.");
|
|
9
|
+
const submissionBodySchema = z.object({
|
|
10
|
+
answers: z.record(z.string(), z.unknown()).default({}),
|
|
11
|
+
locale: submissionLocale.default("en")
|
|
12
|
+
}).prefault({});
|
|
6
13
|
async function readSubmission(event) {
|
|
7
14
|
const uploads = /* @__PURE__ */ new Map();
|
|
8
15
|
const contentType = getRequestHeader(event, "content-type") ?? "";
|
|
9
16
|
if (contentType.includes("multipart/form-data")) {
|
|
10
17
|
const parts = await readMultipartFormData(event) ?? [];
|
|
11
18
|
let answers = {};
|
|
12
|
-
let locale
|
|
19
|
+
let locale;
|
|
13
20
|
for (const part of parts) {
|
|
14
21
|
if (part.name === "answers") {
|
|
15
22
|
try {
|
|
16
23
|
answers = JSON.parse(part.data.toString("utf8"));
|
|
17
24
|
} catch {
|
|
18
|
-
throw
|
|
25
|
+
throw validationError([{ path: ["answers"], message: "Invalid answers." }]);
|
|
19
26
|
}
|
|
20
27
|
} else if (part.name === "locale") {
|
|
21
|
-
locale = part.data.toString("utf8")
|
|
28
|
+
locale = part.data.toString("utf8");
|
|
22
29
|
} else if (part.name?.startsWith("file:") && part.filename) {
|
|
23
30
|
const key = part.name.slice(5);
|
|
24
31
|
uploads.set(key, [...uploads.get(key) ?? [], { filename: part.filename, type: part.type ?? "application/octet-stream", data: part.data }]);
|
|
25
32
|
}
|
|
26
33
|
}
|
|
27
|
-
return { answers, locale, uploads };
|
|
34
|
+
return { ...parseSubmissionBody({ answers, locale }), uploads };
|
|
28
35
|
}
|
|
29
|
-
|
|
30
|
-
return { answers: body?.answers ?? {}, locale: String(body?.locale ?? "en").slice(0, 20), uploads };
|
|
36
|
+
return { ...parseSubmissionBody(await readBody(event)), uploads };
|
|
31
37
|
}
|
|
38
|
+
function parseSubmissionBody(input) {
|
|
39
|
+
const parsed = submissionBodySchema.safeParse(input);
|
|
40
|
+
if (!parsed.success) throw validationError(toIssues(parsed.error.issues));
|
|
41
|
+
return parsed.data;
|
|
42
|
+
}
|
|
43
|
+
const toIssues = (issues) => issues.map((issue) => ({ path: issue.path.map((segment) => typeof segment === "symbol" ? String(segment) : segment), message: issue.message }));
|
|
32
44
|
export async function processSubmission(event, form, userId, body) {
|
|
33
45
|
const definition = form.definition;
|
|
34
46
|
const { answers: raw, locale, uploads } = body ?? await readSubmission(event);
|
|
@@ -41,11 +53,7 @@ export async function processSubmission(event, form, userId, body) {
|
|
|
41
53
|
const skipCaptcha = resolveAccess(definition).auth === "token";
|
|
42
54
|
const parsed = buildFormSchema(definition, answers, { locale, skipCaptcha }).safeParse(answers);
|
|
43
55
|
if (!parsed.success) {
|
|
44
|
-
throw
|
|
45
|
-
statusCode: 422,
|
|
46
|
-
statusMessage: "Invalid submission.",
|
|
47
|
-
data: { issues: parsed.error.issues.map((issue) => ({ path: issue.path, message: issue.message })) }
|
|
48
|
-
});
|
|
56
|
+
throw validationError(toIssues(parsed.error.issues));
|
|
49
57
|
}
|
|
50
58
|
const validated = parsed.data;
|
|
51
59
|
for (const field of validatedFields(definition, raw).filter((f) => f.type === "captcha")) {
|
|
@@ -54,11 +62,7 @@ export async function processSubmission(event, form, userId, body) {
|
|
|
54
62
|
Reflect.deleteProperty(answers, field.key);
|
|
55
63
|
if (skipCaptcha) continue;
|
|
56
64
|
if (!await verifyCaptcha(event, resolveCaptcha(field, useRuntimeConfig(event).public.forms?.captcha?.defaultProvider).provider, String(token), form.id)) {
|
|
57
|
-
throw
|
|
58
|
-
statusCode: 422,
|
|
59
|
-
statusMessage: "Invalid submission.",
|
|
60
|
-
data: { issues: [{ path: [field.key], message: "Captcha verification failed. Please try again." }] }
|
|
61
|
-
});
|
|
65
|
+
throw validationError([{ path: [field.key], message: "Captcha verification failed. Please try again." }]);
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
68
|
const stored = definition.storage !== "none";
|
|
@@ -124,7 +128,7 @@ export async function processSubmission(event, form, userId, body) {
|
|
|
124
128
|
deliveryError: error ?? null
|
|
125
129
|
}).where(eq(schema.formSubmissions.id, submissionId));
|
|
126
130
|
} else if (error) {
|
|
127
|
-
throw
|
|
131
|
+
throw apiError(502, "delivery_failed", "The submission could not be delivered.");
|
|
128
132
|
}
|
|
129
133
|
if (ctx.delivered || error) {
|
|
130
134
|
const result = { submissionId, formSlug: form.slug, status: error ? "failed" : "delivered", error };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export declare const formsApi: {
|
|
2
|
+
/** `GET` a published form's definition. */
|
|
3
|
+
readonly form: (slug: string) => string;
|
|
4
|
+
/** `GET` what the public page needs to pick between the form, a gate and "not found". */
|
|
5
|
+
readonly access: (slug: string) => string;
|
|
6
|
+
/** `POST` a submission (JSON or multipart). */
|
|
7
|
+
readonly submit: (slug: string) => string;
|
|
8
|
+
readonly admin: {
|
|
9
|
+
/** `GET` (paginated) / `POST` forms. */
|
|
10
|
+
readonly forms: "/api/admin/forms";
|
|
11
|
+
/** `POST` an import of form definitions. */
|
|
12
|
+
readonly import: "/api/admin/forms/import";
|
|
13
|
+
/** `GET` / `PATCH` / `DELETE` (archive) one form. */
|
|
14
|
+
readonly form: (id: string) => string;
|
|
15
|
+
/** `POST` a copy of a form as a new draft. */
|
|
16
|
+
readonly clone: (id: string) => string;
|
|
17
|
+
/** `GET` a form's submissions (paginated). */
|
|
18
|
+
readonly submissions: (formId: string) => string;
|
|
19
|
+
/** `GET` / `POST` a form's access tokens. */
|
|
20
|
+
readonly tokens: (formId: string) => string;
|
|
21
|
+
/** `DELETE` (revoke) one access token. */
|
|
22
|
+
readonly token: (formId: string, tokenId: string) => string;
|
|
23
|
+
/** `GET` / `PUT` / `DELETE` a form's shared password. */
|
|
24
|
+
readonly password: (formId: string) => string;
|
|
25
|
+
/** `GET` which of a form's secrets are set. */
|
|
26
|
+
readonly secrets: (formId: string) => string;
|
|
27
|
+
/** `PUT` / `DELETE` one secret of a form. */
|
|
28
|
+
readonly secret: (formId: string, key: string) => string;
|
|
29
|
+
/** `GET` one submission. */
|
|
30
|
+
readonly submission: (id: string) => string;
|
|
31
|
+
/** `GET` (download) one uploaded file of a submission. */
|
|
32
|
+
readonly submissionFile: (id: string, fileId: string) => string;
|
|
33
|
+
/** `POST` to run delivery of a stored submission again. */
|
|
34
|
+
readonly redeliver: (id: string) => string;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const enc = encodeURIComponent;
|
|
2
|
+
const PUBLIC_BASE = "/api/forms";
|
|
3
|
+
const ADMIN_BASE = "/api/admin/forms";
|
|
4
|
+
export const formsApi = {
|
|
5
|
+
/** `GET` a published form's definition. */
|
|
6
|
+
form: (slug) => `${PUBLIC_BASE}/${enc(slug)}`,
|
|
7
|
+
/** `GET` what the public page needs to pick between the form, a gate and "not found". */
|
|
8
|
+
access: (slug) => `${PUBLIC_BASE}/${enc(slug)}/access`,
|
|
9
|
+
/** `POST` a submission (JSON or multipart). */
|
|
10
|
+
submit: (slug) => `${PUBLIC_BASE}/${enc(slug)}/submit`,
|
|
11
|
+
admin: {
|
|
12
|
+
/** `GET` (paginated) / `POST` forms. */
|
|
13
|
+
forms: ADMIN_BASE,
|
|
14
|
+
/** `POST` an import of form definitions. */
|
|
15
|
+
import: `${ADMIN_BASE}/import`,
|
|
16
|
+
/** `GET` / `PATCH` / `DELETE` (archive) one form. */
|
|
17
|
+
form: (id) => `${ADMIN_BASE}/${enc(id)}`,
|
|
18
|
+
/** `POST` a copy of a form as a new draft. */
|
|
19
|
+
clone: (id) => `${ADMIN_BASE}/${enc(id)}/clone`,
|
|
20
|
+
/** `GET` a form's submissions (paginated). */
|
|
21
|
+
submissions: (formId) => `${ADMIN_BASE}/${enc(formId)}/submissions`,
|
|
22
|
+
/** `GET` / `POST` a form's access tokens. */
|
|
23
|
+
tokens: (formId) => `${ADMIN_BASE}/${enc(formId)}/tokens`,
|
|
24
|
+
/** `DELETE` (revoke) one access token. */
|
|
25
|
+
token: (formId, tokenId) => `${ADMIN_BASE}/${enc(formId)}/tokens/${enc(tokenId)}`,
|
|
26
|
+
/** `GET` / `PUT` / `DELETE` a form's shared password. */
|
|
27
|
+
password: (formId) => `${ADMIN_BASE}/${enc(formId)}/password`,
|
|
28
|
+
/** `GET` which of a form's secrets are set. */
|
|
29
|
+
secrets: (formId) => `${ADMIN_BASE}/${enc(formId)}/secrets`,
|
|
30
|
+
/** `PUT` / `DELETE` one secret of a form. */
|
|
31
|
+
secret: (formId, key) => `${ADMIN_BASE}/${enc(formId)}/secrets/${enc(key)}`,
|
|
32
|
+
/** `GET` one submission. */
|
|
33
|
+
submission: (id) => `${ADMIN_BASE}/submissions/${enc(id)}`,
|
|
34
|
+
/** `GET` (download) one uploaded file of a submission. */
|
|
35
|
+
submissionFile: (id, fileId) => `${ADMIN_BASE}/submissions/${enc(id)}/files/${enc(fileId)}`,
|
|
36
|
+
/** `POST` to run delivery of a stored submission again. */
|
|
37
|
+
redeliver: (id) => `${ADMIN_BASE}/submissions/${enc(id)}/redeliver`
|
|
38
|
+
}
|
|
39
|
+
};
|
package/dist/types.d.mts
CHANGED
|
@@ -4,4 +4,4 @@ export { type Translated } from '@frigear-nu/ui/translated'
|
|
|
4
4
|
|
|
5
5
|
export { default } from './module.mjs'
|
|
6
6
|
|
|
7
|
-
export { type AuthorizeContext, type CaptchaConfig, type CaptchaProvider, type CaptchaVerifyContext, type Condition, type ConditionOp, type CreatedFormAccessTokenDto, type DeliveredContext, type DeliveryStatus, type FieldOption, type FieldType, type FieldValidation, type FileDescriptor, type FormAccess, type FormAccessInfoDto, type FormAccessTokenDto, type FormAdminDto, type FormAuthMode, type FormAvailability, type FormDefinition, type FormDelivery, type FormField, type FormMode, type FormPasswordStatusDto, type FormSecretsDto, type FormStatus, type FormStep, type FormStorage, type FormSubmitSettings, type FormSummaryDto, type FormsRuntimeConfig, type ModuleOptions, type PhoneConfig, type PublicFormDto, type PublicModuleOptions, type SecretSource, type SeedForm, type SubmissionDto, type SubmissionFileDto, type SubmissionSummaryDto, type SubmitResponse, type SubmittedContext, type SubmittedFile } from './module.mjs'
|
|
7
|
+
export { type AuthorizeContext, type CaptchaConfig, type CaptchaProvider, type CaptchaVerifyContext, type Condition, type ConditionOp, type CreatedFormAccessTokenDto, type DeliveredContext, type DeliveryStatus, type FieldOption, type FieldType, type FieldValidation, type FileDescriptor, type FormAccess, type FormAccessInfoDto, type FormAccessTokenDto, type FormAdminDto, type FormAuthMode, type FormAvailability, type FormDefinition, type FormDelivery, type FormField, type FormImportResultDto, type FormMode, type FormPasswordStatusDto, type FormSecretsDto, type FormStatus, type FormStep, type FormStorage, type FormSubmitSettings, type FormSummaryDto, type FormsPage, type FormsRuntimeConfig, type ModuleOptions, type PhoneConfig, type PublicFormDto, type PublicModuleOptions, type SecretSource, type SeedForm, type SubmissionDeliveryDto, type SubmissionDto, type SubmissionFileDto, type SubmissionSummaryDto, type SubmitResponse, type SubmittedContext, type SubmittedFile } from './module.mjs'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frigear-nu/forms",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Dynamic, database-stored forms (single page or stepped) with submissions, file uploads and email delivery for the frigear ecosystem.",
|
|
5
5
|
"homepage": "https://docs.frigear.dev/modules/forms",
|
|
6
6
|
"repository": {
|
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
"types": "./dist/runtime/utils/index.d.ts",
|
|
22
22
|
"import": "./dist/runtime/utils/index.js",
|
|
23
23
|
"default": "./dist/runtime/utils/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./api": {
|
|
26
|
+
"types": "./dist/runtime/utils/api.d.ts",
|
|
27
|
+
"import": "./dist/runtime/utils/api.js",
|
|
28
|
+
"default": "./dist/runtime/utils/api.js"
|
|
24
29
|
}
|
|
25
30
|
},
|
|
26
31
|
"files": [
|
|
@@ -30,13 +35,13 @@
|
|
|
30
35
|
"playground"
|
|
31
36
|
],
|
|
32
37
|
"dependencies": {
|
|
33
|
-
"@frigear-nu/admin": "0.1.
|
|
34
|
-
"@frigear-nu/module-kit": "1.1.
|
|
35
|
-
"@frigear-nu/notify": "0.1.
|
|
36
|
-
"@frigear-nu/ui": "0.1.
|
|
37
|
-
"@frigear-nu/auth": "0.1.
|
|
38
|
-
"@frigear-nu/mcp": "0.1.
|
|
39
|
-
"@frigear-nu/users": "1.1.
|
|
38
|
+
"@frigear-nu/admin": "0.1.3",
|
|
39
|
+
"@frigear-nu/module-kit": "1.1.3",
|
|
40
|
+
"@frigear-nu/notify": "0.1.3",
|
|
41
|
+
"@frigear-nu/ui": "0.1.3",
|
|
42
|
+
"@frigear-nu/auth": "0.1.4",
|
|
43
|
+
"@frigear-nu/mcp": "0.1.3",
|
|
44
|
+
"@frigear-nu/users": "1.1.4",
|
|
40
45
|
"@libsql/client": "^0.18.0",
|
|
41
46
|
"@nuxt/kit": "^4.6.1",
|
|
42
47
|
"drizzle-kit": "^0.31.11",
|
|
@@ -46,8 +51,8 @@
|
|
|
46
51
|
"zod": "^4.6.5"
|
|
47
52
|
},
|
|
48
53
|
"peerDependencies": {
|
|
49
|
-
"@frigear-nu/menu": "0.1.
|
|
50
|
-
"@nuxt/ui": "^4.
|
|
54
|
+
"@frigear-nu/menu": "0.1.3",
|
|
55
|
+
"@nuxt/ui": "^4.12.0",
|
|
51
56
|
"@nuxthub/core": "^0.10.8"
|
|
52
57
|
},
|
|
53
58
|
"peerDependenciesMeta": {
|
|
@@ -59,13 +64,13 @@
|
|
|
59
64
|
}
|
|
60
65
|
},
|
|
61
66
|
"devDependencies": {
|
|
62
|
-
"@frigear-nu/menu": "0.1.
|
|
67
|
+
"@frigear-nu/menu": "0.1.3",
|
|
63
68
|
"@nuxt/devtools": "^3.4.2",
|
|
64
69
|
"@nuxt/eslint-config": "^1.17.0",
|
|
65
70
|
"@nuxt/module-builder": "^1.0.3",
|
|
66
71
|
"@nuxt/schema": "^4.6.1",
|
|
67
72
|
"@nuxt/test-utils": "^4.3.2",
|
|
68
|
-
"@nuxt/ui": "^4.
|
|
73
|
+
"@nuxt/ui": "^4.12.0",
|
|
69
74
|
"@nuxthub/core": "^0.10.8",
|
|
70
75
|
"@types/node": "^26.6.5",
|
|
71
76
|
"changelogen": "^0.6.2",
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
const input = z.object({
|
|
3
|
-
key: z.string().refine(isFormSecretKey, "Unknown secret key."),
|
|
4
|
-
value: z.string().trim().min(1).max(2e3)
|
|
5
|
-
});
|
|
6
|
-
export default defineEventHandler(async (event) => {
|
|
7
|
-
await requireFormsManager(event);
|
|
8
|
-
const form = await findFormById(getRouterParam(event, "id"));
|
|
9
|
-
const { key, value } = await readValidatedBody(event, input.parse);
|
|
10
|
-
await setFormSecret(form.id, key, value);
|
|
11
|
-
return describeFormSecrets(event, form.id);
|
|
12
|
-
});
|
|
File without changes
|