diff --git a/package.json b/package.json
index 48bed3a..ab5afca 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,7 @@
"@base-ui/react": "^1.6.0",
"@better-auth/passkey": "^1.5.5",
"@dnd-kit/react": "^0.4.0",
- "@polinetwork/backend": "^0.17.0",
+ "@polinetwork/backend": "^0.17.1",
"@t3-oss/env-core": "^0.13.10",
"@tanstack/react-router": "1.170.17",
"@tanstack/react-start": "1.168.27",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a1254fb..e210b08 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -18,8 +18,8 @@ importers:
specifier: ^0.4.0
version: 0.4.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@polinetwork/backend':
- specifier: ^0.17.0
- version: 0.17.0
+ specifier: ^0.17.1
+ version: 0.17.1
'@t3-oss/env-core':
specifier: ^0.13.10
version: 0.13.11(typescript@6.0.3)(zod@4.3.5)
@@ -311,6 +311,10 @@ packages:
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
+ '@babel/types@7.29.8':
+ resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
+ engines: {node: '>=6.9.0'}
+
'@base-ui/react@1.6.0':
resolution: {integrity: sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==}
engines: {node: '>=14.0.0'}
@@ -506,6 +510,9 @@ packages:
'@emnapi/runtime@1.11.1':
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
+ '@emnapi/runtime@1.11.3':
+ resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
+
'@emnapi/wasi-threads@1.2.2':
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
@@ -699,8 +706,8 @@ packages:
'@cfworker/json-schema':
optional: true
- '@mongodb-js/saslprep@1.4.11':
- resolution: {integrity: sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==}
+ '@mongodb-js/saslprep@1.4.13':
+ resolution: {integrity: sha512-E3Sv4eCYAlKYUTx8S3ioQcDUscOif+8zZ5OnW1IzJ+Tt+EO+ke8mn+Y3FX6N1H79picwbdOavVOb1jPi2EOyrg==}
'@napi-rs/wasm-runtime@1.1.6':
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
@@ -835,8 +842,8 @@ packages:
resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==}
engines: {node: '>=20.0.0'}
- '@polinetwork/backend@0.17.0':
- resolution: {integrity: sha512-NfU0i4It86LOMsZ5xyOX8ccOcbazwsC8j/D/B4sBb9PaJjENAdyFS27l9I4i0ihM2MrcFBJixxDdmnPO7/D/Rg==}
+ '@polinetwork/backend@0.17.1':
+ resolution: {integrity: sha512-Zhu2ar2DpxxV49Z+RDe6TdDwh8zuAUP2fhPXSStisCjNDou37L3Yk0K8Ggp6XuOIsvxlikCaLSqwtt8hpDY2lA==}
'@preact/signals-core@1.14.4':
resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==}
@@ -1584,8 +1591,8 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
- bson@7.2.0:
- resolution: {integrity: sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==}
+ bson@7.3.2:
+ resolution: {integrity: sha512-1w0ra+ho1cuE+w8jzwgzTFIimFtCfZeCoOsvIPQg6uyFCsp8M29U7bNNf5GrFh88TXbYg1g3TyKUGpd6O7q6zA==}
engines: {node: '>=20.19.0'}
bundle-name@4.1.0:
@@ -1611,6 +1618,9 @@ packages:
caniuse-lite@1.0.30001797:
resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==}
+ caniuse-lite@1.0.30001809:
+ resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==}
+
chalk@5.6.2:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
@@ -2411,8 +2421,8 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
- mongodb-connection-string-url@7.0.1:
- resolution: {integrity: sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==}
+ mongodb-connection-string-url@7.0.2:
+ resolution: {integrity: sha512-ZoS07RoFqpKYQwAk59qmrx8+jJHNHU30UjlU96QktiGn1ltvDr+vCznLX5DiUBLEpMAHatHNWV1nM/74ul66kA==}
engines: {node: '>=20.19.0'}
mongodb@7.1.0:
@@ -2470,6 +2480,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ nanoid@3.3.18:
+ resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
nanostores@1.2.0:
resolution: {integrity: sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==}
engines: {node: ^20.0.0 || >=22.0.0}
@@ -2835,6 +2850,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
send@1.2.1:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
@@ -2845,10 +2865,20 @@ packages:
peerDependencies:
seroval: ^1.0
+ seroval-plugins@1.5.6:
+ resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ seroval: ^1.0
+
seroval@1.5.5:
resolution: {integrity: sha512-bSjOuPcwPKLSJNhr9+bZxA20nQxVle5J5MNsYRVE6cIg7KpRLXGupymePavu0jrxlPiPsr4xGZSB8yUY2sH2sw==}
engines: {node: '>=10'}
+ seroval@1.5.6:
+ resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==}
+ engines: {node: '>=10'}
+
serve-static@2.2.1:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
@@ -2931,6 +2961,11 @@ packages:
engines: {node: '>=20.16.0'}
hasBin: true
+ srvx@0.11.22:
+ resolution: {integrity: sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ==}
+ engines: {node: '>=20.16.0'}
+ hasBin: true
+
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
@@ -3594,6 +3629,12 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
+ '@babel/types@7.29.8':
+ dependencies:
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+ optional: true
+
'@base-ui/react@1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@babel/runtime': 7.29.7
@@ -3787,6 +3828,11 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@emnapi/runtime@1.11.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
'@emnapi/wasi-threads@1.2.2':
dependencies:
tslib: 2.8.1
@@ -3900,7 +3946,7 @@ snapshots:
'@img/sharp-wasm32@0.34.5':
dependencies:
- '@emnapi/runtime': 1.11.1
+ '@emnapi/runtime': 1.11.3
optional: true
'@img/sharp-win32-arm64@0.34.5':
@@ -3955,7 +4001,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@mongodb-js/saslprep@1.4.11':
+ '@mongodb-js/saslprep@1.4.13':
dependencies:
sparse-bitfield: 3.0.3
@@ -4124,7 +4170,7 @@ snapshots:
tslib: 2.8.1
tsyringe: 4.10.0
- '@polinetwork/backend@0.17.0': {}
+ '@polinetwork/backend@0.17.1': {}
'@preact/signals-core@1.14.4': {}
@@ -4754,7 +4800,7 @@ snapshots:
babel-plugin-react-compiler@1.0.0:
dependencies:
- '@babel/types': 7.29.7
+ '@babel/types': 7.29.8
optional: true
balanced-match@4.0.4: {}
@@ -4829,7 +4875,7 @@ snapshots:
node-releases: 2.0.37
update-browserslist-db: 1.2.3(browserslist@4.28.2)
- bson@7.2.0: {}
+ bson@7.3.2: {}
bundle-name@4.1.0:
dependencies:
@@ -4851,6 +4897,9 @@ snapshots:
caniuse-lite@1.0.30001797: {}
+ caniuse-lite@1.0.30001809:
+ optional: true
+
chalk@5.6.2: {}
chokidar@5.0.0:
@@ -5235,7 +5284,7 @@ snapshots:
h3@2.0.1-rc.20(crossws@0.4.9(srvx@0.11.21)):
dependencies:
rou3: 0.8.1
- srvx: 0.11.21
+ srvx: 0.11.22
optionalDependencies:
crossws: 0.4.9(srvx@0.11.21)
@@ -5485,16 +5534,16 @@ snapshots:
minimist@1.2.8: {}
- mongodb-connection-string-url@7.0.1:
+ mongodb-connection-string-url@7.0.2:
dependencies:
'@types/whatwg-url': 13.0.0
whatwg-url: 14.2.0
mongodb@7.1.0:
dependencies:
- '@mongodb-js/saslprep': 1.4.11
- bson: 7.2.0
- mongodb-connection-string-url: 7.0.1
+ '@mongodb-js/saslprep': 1.4.13
+ bson: 7.3.2
+ mongodb-connection-string-url: 7.0.2
motion-dom@12.42.2:
dependencies:
@@ -5514,6 +5563,9 @@ snapshots:
nanoid@3.3.12: {}
+ nanoid@3.3.18:
+ optional: true
+
nanostores@1.2.0: {}
negotiator@1.0.0: {}
@@ -5522,7 +5574,7 @@ snapshots:
dependencies:
'@next/env': 15.5.18
'@swc/helpers': 0.5.15
- caniuse-lite: 1.0.30001797
+ caniuse-lite: 1.0.30001809
postcss: 8.4.31
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
@@ -5721,7 +5773,7 @@ snapshots:
postcss@8.4.31:
dependencies:
- nanoid: 3.3.12
+ nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
optional: true
@@ -5890,6 +5942,9 @@ snapshots:
semver@7.8.2: {}
+ semver@7.8.5:
+ optional: true
+
send@1.2.1:
dependencies:
debug: 4.4.3
@@ -5910,8 +5965,16 @@ snapshots:
dependencies:
seroval: 1.5.5
+ seroval-plugins@1.5.6(seroval@1.5.6):
+ dependencies:
+ seroval: 1.5.6
+ optional: true
+
seroval@1.5.5: {}
+ seroval@1.5.6:
+ optional: true
+
serve-static@2.2.1:
dependencies:
encodeurl: 2.0.0
@@ -5969,7 +6032,7 @@ snapshots:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
- semver: 7.8.2
+ semver: 7.8.5
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
@@ -6040,8 +6103,8 @@ snapshots:
solid-js@1.9.14:
dependencies:
csstype: 3.2.3
- seroval: 1.5.5
- seroval-plugins: 1.5.5(seroval@1.5.5)
+ seroval: 1.5.6
+ seroval-plugins: 1.5.6(seroval@1.5.6)
optional: true
sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
@@ -6061,6 +6124,8 @@ snapshots:
srvx@0.11.21: {}
+ srvx@0.11.22: {}
+
statuses@2.0.2: {}
stdin-discarder@0.2.2: {}
diff --git a/src/features/associations/association-dialogs.tsx b/src/features/associations/association-dialogs.tsx
index 34f79b1..9f549ba 100644
--- a/src/features/associations/association-dialogs.tsx
+++ b/src/features/associations/association-dialogs.tsx
@@ -25,8 +25,9 @@ import {
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
-import { getAssociationInitials } from "./associations.constants"
+import { ASSOCIATION_LOGO_MAX_SIZE, ASSOCIATION_LOGO_TYPES, getAssociationInitials } from "./associations.constants"
import { createAssociation, deleteAssociation, editAssociation } from "./associations.functions"
+import { associationSaveErrorMessage } from "./associations.validation"
import type { Association } from "./types"
export type AssociationDialogState = { mode: "create" } | { mode: "edit"; association: Association }
@@ -72,9 +73,9 @@ export function AssociationDialog({
if (pending) return
if (
logoFile &&
- (!["image/jpeg", "image/png", "image/svg+xml"].includes(logoFile.type) || logoFile.size > 2 * 1024 * 1024)
+ (!ASSOCIATION_LOGO_TYPES.some((type) => type === logoFile.type) || logoFile.size > ASSOCIATION_LOGO_MAX_SIZE)
) {
- setError("Choose a JPG, PNG, or SVG logo no larger than 2 MB.")
+ setError("Choose a JPG, PNG, or SVG logo no larger than 1 MB.")
return
}
@@ -92,14 +93,7 @@ export function AssociationDialog({
const saved = editing ? await editAssociationFn({ data }) : await createAssociationFn({ data })
onSaved(saved, dialog.mode)
} catch (cause) {
- const message = cause instanceof Error ? cause.message : ""
- setError(
- message.includes("NOT_FOUND")
- ? "This association no longer exists."
- : message.includes("LOGO")
- ? "Choose a JPG, PNG, or SVG logo no larger than 2 MB."
- : "The association could not be saved. Check the fields and your permissions."
- )
+ setError(associationSaveErrorMessage(cause))
} finally {
setPending(false)
}
@@ -146,7 +140,7 @@ export function AssociationDialog({
- Optional JPG, PNG, or SVG, up to 2 MB.
+ Optional JPG, PNG, or SVG, up to 1 MB.
diff --git a/src/features/associations/associations.constants.ts b/src/features/associations/associations.constants.ts
index 306c4e9..98711b8 100644
--- a/src/features/associations/associations.constants.ts
+++ b/src/features/associations/associations.constants.ts
@@ -1,6 +1,9 @@
import { AtSign, Globe, Instagram, Linkedin, LinkIcon, Mail, Music2, Send, Youtube } from "lucide-react"
import type { AssociationLink, AssociationLinks } from "./types"
+export const ASSOCIATION_LOGO_MAX_SIZE = 1024 * 1024
+export const ASSOCIATION_LOGO_TYPES = ["image/jpeg", "image/png", "image/svg+xml"] as const
+
export const EMPTY_ASSOCIATION_LINKS: AssociationLinks = {
email: null,
website: null,
diff --git a/src/features/associations/associations.functions.ts b/src/features/associations/associations.functions.ts
index 6055a71..9aa9729 100644
--- a/src/features/associations/associations.functions.ts
+++ b/src/features/associations/associations.functions.ts
@@ -11,37 +11,32 @@ export const getAssociations = createServerFn()
.middleware([adminMiddleware])
.handler(({ context }) => context.backend.web.associations.getAllAssociations.query())
-async function serializeLogo(logo: string | File | null) {
- if (!(logo instanceof File)) return logo
- const contents = Buffer.from(await logo.arrayBuffer()).toString("base64")
- return `data:${logo.type};base64,${contents}`
+function associationFormData(data: ReturnType) {
+ const formData = new FormData()
+ formData.set("name", data.name)
+ formData.set("descriptionIt", data.descriptionIt)
+ formData.set("descriptionEn", data.descriptionEn)
+ if (data.logo instanceof File) formData.set("logo", data.logo)
+ return formData
}
export const createAssociation = createServerFn({ method: "POST" })
.middleware([adminMiddleware])
.validator(parseCreateAssociationForm)
- .handler(async ({ data, context }) =>
- context.backend.web.associations.addAssociation.mutate({
- name: data.name,
- descriptionIt: data.descriptionIt,
- descriptionEn: data.descriptionEn,
- logo: await serializeLogo(data.logo),
- createdBy: context.telegramId,
- })
- )
+ .handler(async ({ data, context }) => {
+ const formData = associationFormData(data)
+ formData.set("createdBy", String(context.telegramId))
+ return context.backend.web.associations.addAssociation.mutate(formData)
+ })
export const editAssociation = createServerFn({ method: "POST" })
.middleware([adminMiddleware])
.validator(parseEditAssociationForm)
.handler(async ({ data, context }) => {
- const result = await context.backend.web.associations.editAssociation.mutate({
- id: data.id,
- name: data.name,
- descriptionIt: data.descriptionIt,
- descriptionEn: data.descriptionEn,
- logo: await serializeLogo(data.logo),
- modifiedBy: context.telegramId,
- })
+ const formData = associationFormData(data)
+ formData.set("id", String(data.id))
+ formData.set("modifiedBy", String(context.telegramId))
+ const result = await context.backend.web.associations.editAssociation.mutate(formData)
if ("error" in result) throw new Error(result.error)
return result
})
diff --git a/src/features/associations/associations.validation.ts b/src/features/associations/associations.validation.ts
index 0969100..e599545 100644
--- a/src/features/associations/associations.validation.ts
+++ b/src/features/associations/associations.validation.ts
@@ -1,7 +1,7 @@
import { z } from "zod"
+import { ASSOCIATION_LOGO_MAX_SIZE, ASSOCIATION_LOGO_TYPES } from "./associations.constants.ts"
-const MAX_LOGO_SIZE = 2 * 1024 * 1024
-const ALLOWED_LOGO_TYPES = new Set(["image/jpeg", "image/png", "image/svg+xml"])
+const ALLOWED_LOGO_TYPES = new Set(ASSOCIATION_LOGO_TYPES)
function requiredText(data: FormData, key: string, maximum: number) {
const value = data.get(key)
@@ -19,7 +19,7 @@ function optionalLogo(data: FormData) {
return logo
}
if (!(logo instanceof File) || !ALLOWED_LOGO_TYPES.has(logo.type)) throw new Error("INVALID_LOGO_TYPE")
- if (logo.size > MAX_LOGO_SIZE) throw new Error("LOGO_TOO_LARGE")
+ if (logo.size > ASSOCIATION_LOGO_MAX_SIZE) throw new Error("LOGO_TOO_LARGE")
return logo
}
@@ -62,3 +62,21 @@ export const associationLinksInput = z.object({
})
export const associationIdInput = z.object({ id: z.number().int().positive() })
+
+export function associationSaveErrorMessage(cause: unknown) {
+ const message = cause instanceof Error ? cause.message : ""
+
+ if (message.includes("NOT_FOUND")) return "This association no longer exists."
+ if (message.includes("INVALID_NAME")) return "Enter an association name no longer than 200 characters."
+ if (message.includes("INVALID_DESCRIPTIONIT")) {
+ return "Enter an Italian description no longer than 20,000 characters."
+ }
+ if (message.includes("INVALID_DESCRIPTIONEN")) {
+ return "Enter an English description no longer than 20,000 characters."
+ }
+ if (message.includes("LOGO") || message.includes("file")) {
+ return "Choose a JPG, PNG, or SVG logo no larger than 1 MB."
+ }
+
+ return "The association could not be saved. Check your permissions and try again."
+}
diff --git a/src/features/projects/projects-page.tsx b/src/features/projects/projects-page.tsx
index d0e9bf1..d70ebd5 100644
--- a/src/features/projects/projects-page.tsx
+++ b/src/features/projects/projects-page.tsx
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button"
import { ProjectCard } from "./project-card"
import { DEFAULT_PROJECT, getProjectCategoryLabel, PROJECT_CATEGORIES } from "./projects.constants"
import { createProject, deleteProject, editProject, reorderProjects } from "./projects.functions"
+import { projectSaveErrorMessage } from "./projects.validation"
import type { Project, ProjectCategory, ProjectFormValues, ProjectReorder } from "./types"
function formDataForProject(values: ProjectFormValues, id?: number) {
@@ -195,14 +196,7 @@ export function ProjectsPage({ loadedProjects }: { loadedProjects: Project[] })
}
return true
} catch (cause) {
- const message = cause instanceof Error ? cause.message : ""
- toast.error(
- message.includes("LOGO_TOO_LARGE")
- ? "The logo must be no larger than 1 MB."
- : message.includes("INVALID_LOGO_TYPE")
- ? "Choose an SVG, PNG, or JPEG logo."
- : "The project could not be saved. Check the fields and try again."
- )
+ toast.error(projectSaveErrorMessage(cause))
return false
}
}
diff --git a/src/features/projects/projects.functions.ts b/src/features/projects/projects.functions.ts
index dee2255..fcc6085 100644
--- a/src/features/projects/projects.functions.ts
+++ b/src/features/projects/projects.functions.ts
@@ -16,10 +16,15 @@ function projectFields(project: Project) {
}
}
-async function resolveLogo(currentLogo: string | null, logoFile: File | null) {
- if (!logoFile) return currentLogo
- const content = Buffer.from(await logoFile.arrayBuffer()).toString("base64")
- return `data:${logoFile.type};base64,${content}`
+function projectFormData(data: ReturnType) {
+ const formData = new FormData()
+ formData.set("title", data.title)
+ formData.set("descriptionIt", data.descriptionIt)
+ formData.set("descriptionEn", data.descriptionEn)
+ formData.set("link", data.link ?? "")
+ formData.set("category", data.category)
+ if (data.logoFile) formData.set("logo", data.logoFile)
+ return formData
}
export const getProjects = createServerFn()
@@ -33,12 +38,9 @@ export const createProject = createServerFn({ method: "POST" })
.middleware([adminMiddleware])
.validator(parseProjectForm)
.handler(async ({ data, context }) => {
- const { logoFile, ...fields } = data
- const project = await context.backend.web.projects.addProject.mutate({
- ...fields,
- logo: await resolveLogo(fields.logo, logoFile),
- createdBy: context.telegramId,
- })
+ const formData = projectFormData(data)
+ formData.set("createdBy", String(context.telegramId))
+ const project = await context.backend.web.projects.addProject.mutate(formData)
return projectFields(project)
})
@@ -50,12 +52,10 @@ export const editProject = createServerFn({ method: "POST" })
return { id, ...parseProjectForm(data) }
})
.handler(async ({ data, context }) => {
- const { logoFile, ...fields } = data
- const result = await context.backend.web.projects.editProject.mutate({
- ...fields,
- logo: await resolveLogo(fields.logo, logoFile),
- modifiedBy: context.telegramId,
- })
+ const formData = projectFormData(data)
+ formData.set("id", String(data.id))
+ formData.set("modifiedBy", String(context.telegramId))
+ const result = await context.backend.web.projects.editProject.mutate(formData)
if ("error" in result) throw new Error(result.error)
return projectFields(result)
})
diff --git a/src/features/projects/projects.validation.ts b/src/features/projects/projects.validation.ts
index 8b63f0c..99a5839 100644
--- a/src/features/projects/projects.validation.ts
+++ b/src/features/projects/projects.validation.ts
@@ -51,3 +51,21 @@ export function parseProjectForm(data: FormData) {
category: category as "news" | "general" | "deprecated",
}
}
+
+export function projectSaveErrorMessage(cause: unknown) {
+ const message = cause instanceof Error ? cause.message : ""
+
+ if (message.includes("INVALID_LINK")) return "Enter a valid HTTP or HTTPS project URL."
+ if (message.includes("INVALID_TITLE")) return "Enter a project title no longer than 160 characters."
+ if (message.includes("INVALID_DESCRIPTIONIT")) {
+ return "Enter an Italian description no longer than 5,000 characters."
+ }
+ if (message.includes("INVALID_DESCRIPTIONEN")) {
+ return "Enter an English description no longer than 5,000 characters."
+ }
+ if (message.includes("INVALID_CATEGORY")) return "Choose a valid project category."
+ if (message.includes("LOGO_TOO_LARGE")) return "The logo must be no larger than 1 MB."
+ if (message.includes("INVALID_LOGO_TYPE")) return "Choose an SVG, PNG, or JPEG logo."
+
+ return "The project could not be saved. Check your permissions and try again."
+}
diff --git a/tests/server-security.test.mjs b/tests/server-security.test.mjs
index 79a9351..91d9644 100644
--- a/tests/server-security.test.mjs
+++ b/tests/server-security.test.mjs
@@ -4,10 +4,11 @@ import test from "node:test"
import { parseProfilePictureForm } from "../src/features/account/account.validation.ts"
import {
associationLinksInput,
+ associationSaveErrorMessage,
parseCreateAssociationForm,
} from "../src/features/associations/associations.validation.ts"
import { parseGuideForm } from "../src/features/guides/guides.validation.ts"
-import { parseProjectForm } from "../src/features/projects/projects.validation.ts"
+import { parseProjectForm, projectSaveErrorMessage } from "../src/features/projects/projects.validation.ts"
import { forwardAuthRequest } from "../src/server/auth-proxy-core.ts"
import { hasAdminRole, isAgentModeEnabled } from "../src/server/authorization.ts"
import { getForwardedCookieHeaders } from "../src/server/request-headers.ts"
@@ -212,6 +213,13 @@ test("association validation accepts bounded image uploads and strict public lin
wrongType.set("logo", new File([new Uint8Array(8)], "logo.gif", { type: "image/gif" }))
assert.throws(() => parseCreateAssociationForm(wrongType), /INVALID_LOGO_TYPE/)
+ const oversized = new FormData()
+ oversized.set("name", "Test association")
+ oversized.set("descriptionIt", "Descrizione")
+ oversized.set("descriptionEn", "Description")
+ oversized.set("logo", new File([new Uint8Array(1024 * 1024 + 1)], "logo.png", { type: "image/png" }))
+ assert.throws(() => parseCreateAssociationForm(oversized), /LOGO_TOO_LARGE/)
+
const validLinks = {
id: 1,
links: {
@@ -233,3 +241,32 @@ test("association validation accepts bounded image uploads and strict public lin
/Invalid URL/
)
})
+
+test("web content save errors explain actionable validation failures", () => {
+ assert.equal(projectSaveErrorMessage(new Error("INVALID_LINK")), "Enter a valid HTTP or HTTPS project URL.")
+ assert.equal(projectSaveErrorMessage(new Error("LOGO_TOO_LARGE")), "The logo must be no larger than 1 MB.")
+ assert.equal(
+ associationSaveErrorMessage(new Error("INVALID_DESCRIPTIONEN")),
+ "Enter an English description no longer than 20,000 characters."
+ )
+ assert.equal(
+ associationSaveErrorMessage(new Error("INVALID_LOGO_TYPE")),
+ "Choose a JPG, PNG, or SVG logo no larger than 1 MB."
+ )
+})
+
+test("project and association mutations forward FormData to the backend", async () => {
+ const [projectsSource, associationsSource] = await Promise.all([
+ readFile(new URL("../src/features/projects/projects.functions.ts", import.meta.url), "utf8"),
+ readFile(new URL("../src/features/associations/associations.functions.ts", import.meta.url), "utf8"),
+ ])
+
+ for (const source of [projectsSource, associationsSource]) {
+ assert.match(source, /new FormData\(\)/)
+ assert.doesNotMatch(source, /Buffer\.from/)
+ }
+ assert.match(projectsSource, /addProject\.mutate\(formData/)
+ assert.match(projectsSource, /editProject\.mutate\(formData/)
+ assert.match(associationsSource, /addAssociation\.mutate\(formData/)
+ assert.match(associationsSource, /editAssociation\.mutate\(formData/)
+})