diff --git a/src/components/dashboard-navigation.ts b/src/components/dashboard-navigation.ts index 428c2ae..6d7e311 100644 --- a/src/components/dashboard-navigation.ts +++ b/src/components/dashboard-navigation.ts @@ -6,6 +6,7 @@ import { type LucideIcon, Settings, ShieldCheck, + Users, UsersRound, } from "lucide-react" import azureIcon from "@/assets/svg/azure.svg" @@ -54,7 +55,10 @@ export const dashboardNavigation = [ title: "Web", icon: Globe, iconSrc: undefined, - items: [{ title: "Guides", url: "/dashboard/web/guides", icon: BookOpen }], + items: [ + { title: "Associations", url: "/dashboard/web/associations", icon: Users }, + { title: "Guides", url: "/dashboard/web/guides", icon: BookOpen }, + ], }, ] as const satisfies readonly DashboardNavigationCategory[] diff --git a/src/features/associations/association-card.tsx b/src/features/associations/association-card.tsx new file mode 100644 index 0000000..3f774bf --- /dev/null +++ b/src/features/associations/association-card.tsx @@ -0,0 +1,70 @@ +import { Languages, LinkIcon, Pencil, Trash2 } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { ASSOCIATION_LINK_FIELDS, getAssociationInitials } from "./associations.constants" +import type { Association } from "./types" + +export function AssociationCard({ + association, + onEdit, + onEditLinks, + onDelete, +}: { + association: Association + onEdit: () => void + onEditLinks: () => void + onDelete: () => void +}) { + const linkCount = ASSOCIATION_LINK_FIELDS.filter(({ key }) => association.links[key]).length + + return ( + + + + + {association.logo ? ( + + ) : ( + getAssociationInitials(association.name) + )} + + {association.name} + + + + + + + + + + + + + + + + + + {linkCount} {linkCount === 1 ? "public link" : "public links"} + + + Manage links + + + + + ) +} + +function Description({ language, text }: { language: string; text: string }) { + return ( + + + {language} + + {text} + + ) +} diff --git a/src/features/associations/association-dialogs.tsx b/src/features/associations/association-dialogs.tsx new file mode 100644 index 0000000..34f79b1 --- /dev/null +++ b/src/features/associations/association-dialogs.tsx @@ -0,0 +1,254 @@ +import { useServerFn } from "@tanstack/react-start" +import { LoaderCircle, OctagonX, Upload } from "lucide-react" +import { useEffect, useRef, useState } from "react" +import { toast } from "sonner" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +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 { createAssociation, deleteAssociation, editAssociation } from "./associations.functions" +import type { Association } from "./types" + +export type AssociationDialogState = { mode: "create" } | { mode: "edit"; association: Association } + +export function AssociationDialog({ + dialog, + onClose, + onSaved, +}: { + dialog: AssociationDialogState + onClose: () => void + onSaved: (association: Association, mode: AssociationDialogState["mode"]) => void +}) { + const editing = dialog.mode === "edit" + const association = editing ? dialog.association : null + const [name, setName] = useState(association?.name ?? "") + const [descriptionIt, setDescriptionIt] = useState(association?.descriptionIt ?? "") + const [descriptionEn, setDescriptionEn] = useState(association?.descriptionEn ?? "") + const [logoFile, setLogoFile] = useState(null) + const [logoPreview, setLogoPreview] = useState(null) + const [pending, setPending] = useState(false) + const [error, setError] = useState("") + const logoInput = useRef(null) + const createAssociationFn = useServerFn(createAssociation) + const editAssociationFn = useServerFn(editAssociation) + + useEffect( + () => () => { + if (logoPreview) URL.revokeObjectURL(logoPreview) + }, + [logoPreview] + ) + + function selectLogo(file: File | null) { + if (logoPreview) URL.revokeObjectURL(logoPreview) + setLogoFile(file) + setLogoPreview(file ? URL.createObjectURL(file) : null) + setError("") + } + + async function submit(event: React.FormEvent) { + event.preventDefault() + if (pending) return + if ( + logoFile && + (!["image/jpeg", "image/png", "image/svg+xml"].includes(logoFile.type) || logoFile.size > 2 * 1024 * 1024) + ) { + setError("Choose a JPG, PNG, or SVG logo no larger than 2 MB.") + return + } + + setPending(true) + setError("") + try { + const data = new FormData() + data.set("name", name) + data.set("descriptionIt", descriptionIt) + data.set("descriptionEn", descriptionEn) + if (logoFile) data.set("logo", logoFile) + else if (association?.logo) data.set("logo", association.logo) + + if (editing) data.set("id", String(dialog.association.id)) + 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." + ) + } finally { + setPending(false) + } + } + + const logoSource = logoPreview ?? association?.logo + + return ( + !open && !pending && onClose()}> + + + + WEB · ASSOCIATIONS + + + {editing ? "Edit association" : "Add an association"} + + + {editing + ? "Update the public identity and bilingual descriptions." + : "Create an association entry for the public website."} + + + void submit(event)}> + + + + {logoSource ? ( + + ) : ( + getAssociationInitials(name) || "?" + )} + + + Logo + selectLogo(event.target.files?.[0] ?? null)} + /> + logoInput.current?.click()}> + {logoFile ? "Change selected logo" : "Choose logo"} + + Optional JPG, PNG, or SVG, up to 2 MB. + + + + Name + setName(event.target.value)} + maxLength={200} + required + autoFocus + /> + + + + Italian description + setDescriptionIt(event.target.value)} + className="min-h-40" + maxLength={20_000} + required + /> + + + English description + setDescriptionEn(event.target.value)} + className="min-h-40" + maxLength={20_000} + required + /> + + + {error && {error}} + + + + Cancel + + + {pending && } + {editing ? "Save changes" : "Create association"} + + + + + + ) +} + +export function DeleteAssociationDialog({ + association, + onClose, + onDeleted, +}: { + association: Association + onClose: () => void + onDeleted: (id: number) => void +}) { + const [pending, setPending] = useState(false) + const deleteAssociationFn = useServerFn(deleteAssociation) + + async function remove() { + setPending(true) + try { + await deleteAssociationFn({ data: { id: association.id } }) + onDeleted(association.id) + } catch (cause) { + const message = cause instanceof Error ? cause.message : "" + if (message.includes("NOT_FOUND")) onDeleted(association.id) + else toast.error("The association could not be deleted. Check your permissions and try again.") + } finally { + setPending(false) + } + } + + return ( + !open && !pending && onClose()}> + + + + + + Delete association + + Are you sure you want to delete {association.name}? This action cannot be undone. + + + + + Cancel + + void remove()}> + {pending && } + Delete + + + + + ) +} diff --git a/src/features/associations/association-links-dialog.tsx b/src/features/associations/association-links-dialog.tsx new file mode 100644 index 0000000..d1a698a --- /dev/null +++ b/src/features/associations/association-links-dialog.tsx @@ -0,0 +1,106 @@ +import { useServerFn } from "@tanstack/react-start" +import { LoaderCircle } from "lucide-react" +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Field, FieldError, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { ASSOCIATION_LINK_FIELDS } from "./associations.constants" +import { editAssociationLinks } from "./associations.functions" +import type { Association, AssociationLinks } from "./types" + +function normalizeLinks(links: AssociationLinks): AssociationLinks { + return Object.fromEntries( + ASSOCIATION_LINK_FIELDS.map(({ key }) => { + const value = links[key]?.trim() + return [key, value || null] + }) + ) as AssociationLinks +} + +export function AssociationLinksDialog({ + association, + onClose, + onSaved, +}: { + association: Association + onClose: () => void + onSaved: (association: Association) => void +}) { + const [links, setLinks] = useState(association.links) + const [pending, setPending] = useState(false) + const [error, setError] = useState("") + const editLinksFn = useServerFn(editAssociationLinks) + + async function submit(event: React.FormEvent) { + event.preventDefault() + setPending(true) + setError("") + try { + onSaved(await editLinksFn({ data: { id: association.id, links: normalizeLinks(links) } })) + } catch (cause) { + const message = cause instanceof Error ? cause.message : "" + setError( + message.includes("NOT_FOUND") + ? "This association no longer exists." + : "The links could not be saved. Check the values and your permissions." + ) + } finally { + setPending(false) + } + } + + return ( + !open && !pending && onClose()}> + + + + WEB · ASSOCIATION LINKS + + {association.name} links + Manage the public contact and social profiles for this association. + + void submit(event)}> + + {ASSOCIATION_LINK_FIELDS.map((field) => { + const Icon = field.icon + return ( + + + {field.label} + + + setLinks((current) => ({ ...current, [field.key]: event.target.value || null })) + } + /> + + ) + })} + + {error && {error}} + + + Cancel + + + {pending && } Save links + + + + + + ) +} diff --git a/src/features/associations/associations-page.tsx b/src/features/associations/associations-page.tsx new file mode 100644 index 0000000..97e5537 --- /dev/null +++ b/src/features/associations/associations-page.tsx @@ -0,0 +1,135 @@ +import { useRouter } from "@tanstack/react-router" +import { Plus, UsersRound } from "lucide-react" +import { useEffect, useMemo, useState } from "react" +import { toast } from "sonner" +import { DataToolbar } from "@/components/data-toolbar" +import { EmptyState } from "@/components/empty-state" +import { Button } from "@/components/ui/button" +import { AssociationCard } from "./association-card" +import { AssociationDialog, type AssociationDialogState, DeleteAssociationDialog } from "./association-dialogs" +import { AssociationLinksDialog } from "./association-links-dialog" +import type { Association } from "./types" + +export function AssociationsPage({ loadedAssociations }: { loadedAssociations: Association[] }) { + const router = useRouter() + const [associations, setAssociations] = useState(loadedAssociations) + const [query, setQuery] = useState("") + const [associationDialog, setAssociationDialog] = useState(null) + const [linksDialog, setLinksDialog] = useState(null) + const [deleting, setDeleting] = useState(null) + + useEffect(() => setAssociations(loadedAssociations), [loadedAssociations]) + + const filteredAssociations = useMemo(() => { + const normalized = query.trim().toLocaleLowerCase() + if (!normalized) return associations + return associations.filter((association) => + [association.name, association.descriptionIt, association.descriptionEn].some((value) => + value.toLocaleLowerCase().includes(normalized) + ) + ) + }, [associations, query]) + + async function refresh() { + try { + await router.invalidate({ sync: true }) + } catch { + toast.warning("Your change was saved, but the association list could not be refreshed.") + } + } + + function replaceAssociation(association: Association) { + setAssociations((current) => current.map((item) => (item.id === association.id ? association : item))) + } + + return ( + + setAssociationDialog({ mode: "create" })}> + Add association + + } + /> + + {filteredAssociations.length ? ( + + {filteredAssociations.map((association) => ( + setAssociationDialog({ mode: "edit", association })} + onEditLinks={() => setLinksDialog(association)} + onDelete={() => setDeleting(association)} + /> + ))} + + ) : ( + setAssociationDialog({ mode: "create" })}>Add first association + ) : undefined + } + /> + )} + + {associationDialog && ( + setAssociationDialog(null)} + onSaved={(association, mode) => { + setAssociations((current) => + mode === "create" + ? [association, ...current] + : current.map((item) => (item.id === association.id ? association : item)) + ) + setAssociationDialog(null) + toast.success(mode === "create" ? "Association created" : "Association updated") + void refresh() + }} + /> + )} + + {linksDialog && ( + setLinksDialog(null)} + onSaved={(association) => { + replaceAssociation(association) + setLinksDialog(null) + toast.success("Association links updated") + void refresh() + }} + /> + )} + + {deleting && ( + setDeleting(null)} + onDeleted={(id) => { + setAssociations((current) => current.filter((association) => association.id !== id)) + setDeleting(null) + toast.success("Association deleted") + void refresh() + }} + /> + )} + + ) +} diff --git a/src/features/associations/associations.constants.ts b/src/features/associations/associations.constants.ts new file mode 100644 index 0000000..306c4e9 --- /dev/null +++ b/src/features/associations/associations.constants.ts @@ -0,0 +1,39 @@ +import { AtSign, Globe, Instagram, Linkedin, LinkIcon, Mail, Music2, Send, Youtube } from "lucide-react" +import type { AssociationLink, AssociationLinks } from "./types" + +export const EMPTY_ASSOCIATION_LINKS: AssociationLinks = { + email: null, + website: null, + facebook: null, + instagram: null, + tiktok: null, + x: null, + youtube: null, + telegram: null, + linkedin: null, + spotify: null, +} + +export const ASSOCIATION_LINK_FIELDS: { + key: AssociationLink + label: string + placeholder: string + icon: typeof Mail +}[] = [ + { key: "email", label: "Email", placeholder: "info@example.org", icon: Mail }, + { key: "website", label: "Website", placeholder: "https://example.org", icon: Globe }, + { key: "facebook", label: "Facebook", placeholder: "https://facebook.com/…", icon: LinkIcon }, + { key: "instagram", label: "Instagram", placeholder: "https://instagram.com/…", icon: Instagram }, + { key: "tiktok", label: "TikTok", placeholder: "https://tiktok.com/@…", icon: Music2 }, + { key: "x", label: "X", placeholder: "https://x.com/…", icon: AtSign }, + { key: "telegram", label: "Telegram", placeholder: "https://t.me/…", icon: Send }, + { key: "linkedin", label: "LinkedIn", placeholder: "https://linkedin.com/company/…", icon: Linkedin }, + { key: "youtube", label: "YouTube", placeholder: "https://youtube.com/@…", icon: Youtube }, + { key: "spotify", label: "Spotify", placeholder: "https://open.spotify.com/…", icon: Music2 }, +] + +export function getAssociationInitials(name: string) { + const words = name.replaceAll("-", " ").split(/\s+/).filter(Boolean) + if (words.length > 1) return `${words[0]?.[0] ?? ""}${words[1]?.[0] ?? ""}`.toLocaleUpperCase() + return (words[0] ?? "").slice(0, 2).toLocaleUpperCase() +} diff --git a/src/features/associations/associations.functions.ts b/src/features/associations/associations.functions.ts new file mode 100644 index 0000000..6055a71 --- /dev/null +++ b/src/features/associations/associations.functions.ts @@ -0,0 +1,68 @@ +import { createServerFn } from "@tanstack/react-start" +import { adminMiddleware } from "@/server/auth.middleware" +import { + associationIdInput, + associationLinksInput, + parseCreateAssociationForm, + parseEditAssociationForm, +} from "./associations.validation" + +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}` +} + +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, + }) + ) + +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, + }) + if ("error" in result) throw new Error(result.error) + return result + }) + +export const editAssociationLinks = createServerFn({ method: "POST" }) + .middleware([adminMiddleware]) + .validator(associationLinksInput) + .handler(async ({ data, context }) => { + const result = await context.backend.web.associations.editAssociationLinks.mutate({ + ...data, + modifiedBy: context.telegramId, + }) + if ("error" in result) throw new Error(result.error) + return result + }) + +export const deleteAssociation = createServerFn({ method: "POST" }) + .middleware([adminMiddleware]) + .validator(associationIdInput) + .handler(async ({ data, context }) => { + const result = await context.backend.web.associations.deleteAssociation.mutate(data) + if (result.error) throw new Error(result.error) + return result + }) diff --git a/src/features/associations/associations.validation.ts b/src/features/associations/associations.validation.ts new file mode 100644 index 0000000..0969100 --- /dev/null +++ b/src/features/associations/associations.validation.ts @@ -0,0 +1,64 @@ +import { z } from "zod" + +const MAX_LOGO_SIZE = 2 * 1024 * 1024 +const ALLOWED_LOGO_TYPES = new Set(["image/jpeg", "image/png", "image/svg+xml"]) + +function requiredText(data: FormData, key: string, maximum: number) { + const value = data.get(key) + if (typeof value !== "string" || !value.trim() || value.trim().length > maximum) { + throw new Error(`INVALID_${key.toUpperCase()}`) + } + return value.trim() +} + +function optionalLogo(data: FormData) { + const logo = data.get("logo") + if (logo === null || (typeof logo === "string" && logo === "")) return null + if (typeof logo === "string") { + if (logo.length > 3_000_000) throw new Error("LOGO_TOO_LARGE") + 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") + return logo +} + +function associationFields(data: FormData) { + return { + name: requiredText(data, "name", 200), + descriptionIt: requiredText(data, "descriptionIt", 20_000), + descriptionEn: requiredText(data, "descriptionEn", 20_000), + logo: optionalLogo(data), + } +} + +export function parseCreateAssociationForm(data: FormData) { + return associationFields(data) +} + +export function parseEditAssociationForm(data: FormData) { + const id = Number(data.get("id")) + if (!Number.isInteger(id) || id <= 0) throw new Error("INVALID_ID") + return { id, ...associationFields(data) } +} + +const nullableUrl = z.union([z.url().max(2_048), z.null()]) +const nullableEmail = z.union([z.email().max(320), z.null()]) + +export const associationLinksInput = z.object({ + id: z.number().int().positive(), + links: z.object({ + email: nullableEmail, + website: nullableUrl, + facebook: nullableUrl, + instagram: nullableUrl, + tiktok: nullableUrl, + x: nullableUrl, + youtube: nullableUrl, + telegram: nullableUrl, + linkedin: nullableUrl, + spotify: nullableUrl, + }), +}) + +export const associationIdInput = z.object({ id: z.number().int().positive() }) diff --git a/src/features/associations/types.ts b/src/features/associations/types.ts new file mode 100644 index 0000000..3b1b286 --- /dev/null +++ b/src/features/associations/types.ts @@ -0,0 +1,5 @@ +import type { ApiOutput } from "@/lib/api/types" + +export type Association = ApiOutput["web"]["associations"]["getAllAssociations"][number] +export type AssociationLinks = Association["links"] +export type AssociationLink = keyof AssociationLinks diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index a3e5ec9..d4c5451 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as OnboardingUnauthorizedRouteImport } from './routes/onboarding/ import { Route as OnboardingLinkRouteImport } from './routes/onboarding/link' import { Route as DashboardAccountRouteImport } from './routes/dashboard/account' import { Route as DashboardWebGuidesRouteImport } from './routes/dashboard/web/guides' +import { Route as DashboardWebAssociationsRouteImport } from './routes/dashboard/web/associations' import { Route as DashboardTelegramGroupsRouteImport } from './routes/dashboard/telegram/groups' import { Route as DashboardTelegramGrantsRouteImport } from './routes/dashboard/telegram/grants' import { Route as DashboardAzureMembersRouteImport } from './routes/dashboard/azure/members' @@ -65,6 +66,12 @@ const DashboardWebGuidesRoute = DashboardWebGuidesRouteImport.update({ path: '/web/guides', getParentRoute: () => DashboardRoute, } as any) +const DashboardWebAssociationsRoute = + DashboardWebAssociationsRouteImport.update({ + id: '/web/associations', + path: '/web/associations', + getParentRoute: () => DashboardRoute, + } as any) const DashboardTelegramGroupsRoute = DashboardTelegramGroupsRouteImport.update({ id: '/telegram/groups', path: '/telegram/groups', @@ -116,6 +123,7 @@ export interface FileRoutesByFullPath { '/dashboard/azure/members': typeof DashboardAzureMembersRoute '/dashboard/telegram/grants': typeof DashboardTelegramGrantsRoute '/dashboard/telegram/groups': typeof DashboardTelegramGroupsRoute + '/dashboard/web/associations': typeof DashboardWebAssociationsRoute '/dashboard/web/guides': typeof DashboardWebGuidesRoute '/dashboard/telegram/users/$userId': typeof DashboardTelegramUsersUserIdRoute '/dashboard/telegram/users/': typeof DashboardTelegramUsersIndexRoute @@ -132,6 +140,7 @@ export interface FileRoutesByTo { '/dashboard/azure/members': typeof DashboardAzureMembersRoute '/dashboard/telegram/grants': typeof DashboardTelegramGrantsRoute '/dashboard/telegram/groups': typeof DashboardTelegramGroupsRoute + '/dashboard/web/associations': typeof DashboardWebAssociationsRoute '/dashboard/web/guides': typeof DashboardWebGuidesRoute '/dashboard/telegram/users/$userId': typeof DashboardTelegramUsersUserIdRoute '/dashboard/telegram/users': typeof DashboardTelegramUsersIndexRoute @@ -150,6 +159,7 @@ export interface FileRoutesById { '/dashboard/azure/members': typeof DashboardAzureMembersRoute '/dashboard/telegram/grants': typeof DashboardTelegramGrantsRoute '/dashboard/telegram/groups': typeof DashboardTelegramGroupsRoute + '/dashboard/web/associations': typeof DashboardWebAssociationsRoute '/dashboard/web/guides': typeof DashboardWebGuidesRoute '/dashboard/telegram/users/$userId': typeof DashboardTelegramUsersUserIdRoute '/dashboard/telegram/users/': typeof DashboardTelegramUsersIndexRoute @@ -169,6 +179,7 @@ export interface FileRouteTypes { | '/dashboard/azure/members' | '/dashboard/telegram/grants' | '/dashboard/telegram/groups' + | '/dashboard/web/associations' | '/dashboard/web/guides' | '/dashboard/telegram/users/$userId' | '/dashboard/telegram/users/' @@ -185,6 +196,7 @@ export interface FileRouteTypes { | '/dashboard/azure/members' | '/dashboard/telegram/grants' | '/dashboard/telegram/groups' + | '/dashboard/web/associations' | '/dashboard/web/guides' | '/dashboard/telegram/users/$userId' | '/dashboard/telegram/users' @@ -202,6 +214,7 @@ export interface FileRouteTypes { | '/dashboard/azure/members' | '/dashboard/telegram/grants' | '/dashboard/telegram/groups' + | '/dashboard/web/associations' | '/dashboard/web/guides' | '/dashboard/telegram/users/$userId' | '/dashboard/telegram/users/' @@ -274,6 +287,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardWebGuidesRouteImport parentRoute: typeof DashboardRoute } + '/dashboard/web/associations': { + id: '/dashboard/web/associations' + path: '/web/associations' + fullPath: '/dashboard/web/associations' + preLoaderRoute: typeof DashboardWebAssociationsRouteImport + parentRoute: typeof DashboardRoute + } '/dashboard/telegram/groups': { id: '/dashboard/telegram/groups' path: '/telegram/groups' @@ -333,6 +353,7 @@ interface DashboardRouteChildren { DashboardAzureMembersRoute: typeof DashboardAzureMembersRoute DashboardTelegramGrantsRoute: typeof DashboardTelegramGrantsRoute DashboardTelegramGroupsRoute: typeof DashboardTelegramGroupsRoute + DashboardWebAssociationsRoute: typeof DashboardWebAssociationsRoute DashboardWebGuidesRoute: typeof DashboardWebGuidesRoute DashboardTelegramUsersUserIdRoute: typeof DashboardTelegramUsersUserIdRoute DashboardTelegramUsersIndexRoute: typeof DashboardTelegramUsersIndexRoute @@ -345,6 +366,7 @@ const DashboardRouteChildren: DashboardRouteChildren = { DashboardAzureMembersRoute: DashboardAzureMembersRoute, DashboardTelegramGrantsRoute: DashboardTelegramGrantsRoute, DashboardTelegramGroupsRoute: DashboardTelegramGroupsRoute, + DashboardWebAssociationsRoute: DashboardWebAssociationsRoute, DashboardWebGuidesRoute: DashboardWebGuidesRoute, DashboardTelegramUsersUserIdRoute: DashboardTelegramUsersUserIdRoute, DashboardTelegramUsersIndexRoute: DashboardTelegramUsersIndexRoute, diff --git a/src/routes/dashboard/web/associations.tsx b/src/routes/dashboard/web/associations.tsx new file mode 100644 index 0000000..794ecb2 --- /dev/null +++ b/src/routes/dashboard/web/associations.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from "@tanstack/react-router" +import { DataPageSkeleton } from "@/components/loading-skeleton" +import { getAssociations } from "@/features/associations/associations.functions" +import { AssociationsPage } from "@/features/associations/associations-page" + +export const Route = createFileRoute("/dashboard/web/associations")({ + loader: () => getAssociations(), + pendingComponent: () => , + component: AssociationsRoute, +}) + +function AssociationsRoute() { + return +} diff --git a/tests/server-security.test.mjs b/tests/server-security.test.mjs index 557459b..f98eeec 100644 --- a/tests/server-security.test.mjs +++ b/tests/server-security.test.mjs @@ -2,6 +2,10 @@ import assert from "node:assert/strict" import { readFile } from "node:fs/promises" import test from "node:test" import { parseProfilePictureForm } from "../src/features/account/account.validation.ts" +import { + associationLinksInput, + parseCreateAssociationForm, +} from "../src/features/associations/associations.validation.ts" import { parseGuideForm } from "../src/features/guides/guides.validation.ts" import { forwardAuthRequest } from "../src/server/auth-proxy-core.ts" import { hasAdminRole, isAgentModeEnabled } from "../src/server/authorization.ts" @@ -73,6 +77,7 @@ test("the auth proxy preserves the request and exact upstream response", async ( test("admin server functions attach the authorization middleware", async () => { const adminFunctionFiles = [ + "src/features/associations/associations.functions.ts", "src/features/azure/azure.functions.ts", "src/features/guides/guides.functions.ts", "src/features/telegram/grants.functions.ts", @@ -100,6 +105,8 @@ test("event handlers integrate protected server-function redirects with the rout const consumers = { "src/components/telegram/create-grant-dialog.tsx": ["createTelegramGrant", "findTelegramUser"], "src/features/account/use-account.ts": ["uploadProfilePicture"], + "src/features/associations/association-dialogs.tsx": ["createAssociation", "editAssociation", "deleteAssociation"], + "src/features/associations/association-links-dialog.tsx": ["editAssociationLinks"], "src/features/azure/group-membership.tsx": ["addAzureGroupMember", "removeAzureGroupMember"], "src/features/azure/member-dialog.tsx": ["createAzureMember", "setAzureMemberNumber"], "src/features/guides/guide-dialogs.tsx": ["createGuide", "deleteGuide"], @@ -158,3 +165,40 @@ test("guide validation accepts only strict dated PDF uploads", () => { wrongType.set("file", new File([new Uint8Array(8)], "guide.txt", { type: "text/plain" })) assert.throws(() => parseGuideForm(wrongType), /INVALID_FILE_TYPE/) }) + +test("association validation accepts bounded image uploads and strict public links", () => { + const valid = new FormData() + valid.set("name", " Test association ") + valid.set("descriptionIt", "Descrizione") + valid.set("descriptionEn", "Description") + valid.set("logo", new File([""], "logo.svg", { type: "image/svg+xml" })) + assert.equal(parseCreateAssociationForm(valid).name, "Test association") + + const wrongType = new FormData() + wrongType.set("name", "Test association") + wrongType.set("descriptionIt", "Descrizione") + wrongType.set("descriptionEn", "Description") + wrongType.set("logo", new File([new Uint8Array(8)], "logo.gif", { type: "image/gif" })) + assert.throws(() => parseCreateAssociationForm(wrongType), /INVALID_LOGO_TYPE/) + + const validLinks = { + id: 1, + links: { + email: "hello@example.org", + website: "https://example.org", + facebook: null, + instagram: null, + tiktok: null, + x: null, + youtube: null, + telegram: null, + linkedin: null, + spotify: null, + }, + } + assert.equal(associationLinksInput.parse(validLinks).links.website, "https://example.org") + assert.throws( + () => associationLinksInput.parse({ ...validLinks, links: { ...validLinks.links, website: "not a URL" } }), + /Invalid URL/ + ) +})
{text}
+ WEB · ASSOCIATIONS +
+ WEB · ASSOCIATION LINKS +