From 8da44d245272277e7bca8a49c44638088ccb62a4 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 19 Aug 2026 00:56:36 +0200 Subject: [PATCH] fix: improve client error reporting - Log caught and returned errors across dashboard flows - Handle nested error codes consistently for user-facing messages --- src/components/dashboard-frame.tsx | 3 +- src/components/dashboard-sidebar.tsx | 6 +- .../telegram/create-grant-dialog.tsx | 4 +- src/features/account/use-account.ts | 56 ++++++++++----- .../associations/association-dialogs.tsx | 6 +- .../associations/association-links-dialog.tsx | 5 +- .../associations/associations-page.tsx | 3 +- .../associations/associations.validation.ts | 17 +++-- src/features/auth/auth.functions.ts | 3 +- src/features/auth/login-page.tsx | 24 +++++-- src/features/azure/group-membership.tsx | 4 +- src/features/azure/member-dialog.tsx | 3 +- src/features/azure/members-page.tsx | 3 +- src/features/guides/guide-dialogs.tsx | 10 +-- src/features/guides/guides-page.tsx | 3 +- src/features/onboarding/use-telegram-link.ts | 23 ++++-- src/features/projects/projects-page.tsx | 17 +++-- src/features/projects/projects.validation.ts | 20 +++--- src/features/telegram/groups-page.tsx | 6 +- src/features/telegram/leave-group-dialog.tsx | 5 +- .../telegram/user-detail/grant-dialogs.tsx | 4 +- .../user-detail/group-admin-dialog.tsx | 7 +- src/features/telegram/user-detail/profile.tsx | 6 +- .../telegram/user-detail/role-dialog.tsx | 4 +- src/features/telegram/users.functions.ts | 8 ++- src/lib/errors.ts | 20 ++++++ src/routes/__root.tsx | 2 +- src/routes/onboarding/unauthorized.tsx | 3 +- tests/server-security.test.mjs | 71 ++++++++++++++++++- 29 files changed, 260 insertions(+), 86 deletions(-) create mode 100644 src/lib/errors.ts diff --git a/src/components/dashboard-frame.tsx b/src/components/dashboard-frame.tsx index a39941f..fd0bde7 100644 --- a/src/components/dashboard-frame.tsx +++ b/src/components/dashboard-frame.tsx @@ -33,7 +33,8 @@ export function DashboardFrame({ initialSession }: { initialSession: AdminSessio if (result.error) throw new Error(result.error.message) await router.invalidate() await router.navigate({ to: "/login", replace: true }) - } catch { + } catch (error) { + console.error(error) toast.error("Could not sign out. Please try again.") setLoggingOut(false) } diff --git a/src/components/dashboard-sidebar.tsx b/src/components/dashboard-sidebar.tsx index 4173632..da2d8be 100644 --- a/src/components/dashboard-sidebar.tsx +++ b/src/components/dashboard-sidebar.tsx @@ -56,7 +56,8 @@ export function DashboardSidebar({ user, loggingOut, onLogout, ...props }: Dashb try { const stored = window.localStorage.getItem(categoryStorageKey) if (stored) setCategoryState(JSON.parse(stored)) - } catch { + } catch (error) { + console.error(error) // Ignore malformed or unavailable local storage and use route-based defaults. } }, []) @@ -66,7 +67,8 @@ export function DashboardSidebar({ user, loggingOut, onLogout, ...props }: Dashb const next = { ...current, [title]: open } try { window.localStorage.setItem(categoryStorageKey, JSON.stringify(next)) - } catch { + } catch (error) { + console.error(error) // The in-memory state still works when storage is unavailable. } return next diff --git a/src/components/telegram/create-grant-dialog.tsx b/src/components/telegram/create-grant-dialog.tsx index f101aa9..0f37a30 100644 --- a/src/components/telegram/create-grant-dialog.tsx +++ b/src/components/telegram/create-grant-dialog.tsx @@ -266,6 +266,7 @@ export function CreateGrantDialog({ user: fixedUser }: CreateGrantDialogProps) { data: { userId: selectedUser.id, since, until, reason: reason.trim() || undefined }, }) if (result.error) { + console.error(result.error) toast.error(grantMutationError(result.error)) return } @@ -273,7 +274,8 @@ export function CreateGrantDialog({ user: fixedUser }: CreateGrantDialogProps) { closeAndReset() try { await router.invalidate({ sync: true }) - } catch { + } catch (error) { + console.error(error) toast.warning("The grant was created, but the latest grants could not be refreshed.") } } catch (error) { diff --git a/src/features/account/use-account.ts b/src/features/account/use-account.ts index bf4dd02..4c19f72 100644 --- a/src/features/account/use-account.ts +++ b/src/features/account/use-account.ts @@ -30,6 +30,8 @@ export function useAccount(initialSession: AdminSession) { try { const [passkeyResult, sessionResult] = await Promise.all([auth.passkey.listUserPasskeys(), auth.listSessions()]) if (passkeyResult.error || sessionResult.error) { + if (passkeyResult.error) console.error(passkeyResult.error) + if (sessionResult.error) console.error(sessionResult.error) setSecurityError("Could not load passkeys and active sessions. Your existing security data is still shown.") return false } @@ -37,7 +39,8 @@ export function useAccount(initialSession: AdminSession) { setSessions(sessionResult.data ?? []) setSecurityError("") return true - } catch { + } catch (error) { + console.error(error) setSecurityError("Could not load passkeys and active sessions. Your existing security data is still shown.") return false } finally { @@ -58,12 +61,15 @@ export function useAccount(initialSession: AdminSession) { setNotice(null) try { const result = await auth.updateUser({ name: name.trim() }) - if (result.error) setNotice({ type: "error", text: result.error.message ?? "Could not update your name." }) - else { + if (result.error) { + console.error(result.error) + setNotice({ type: "error", text: result.error.message ?? "Could not update your name." }) + } else { await sessionQuery.refetch() setNotice({ type: "success", text: "Profile name updated." }) } - } catch { + } catch (error) { + console.error(error) setNotice({ type: "error", text: "Could not update your name." }) } finally { setBusy(null) @@ -85,7 +91,8 @@ export function useAccount(initialSession: AdminSession) { await uploadProfilePictureFn({ data: formData }) await sessionQuery.refetch() setNotice({ type: "success", text: "Profile picture updated." }) - } catch { + } catch (error) { + console.error(error) setNotice({ type: "error", text: "Could not update your profile picture." }) } setBusy(null) @@ -96,12 +103,15 @@ export function useAccount(initialSession: AdminSession) { setNotice(null) try { const result = await auth.updateUser({ image: null }) - if (result.error) setNotice({ type: "error", text: result.error.message ?? "Could not remove the picture." }) - else { + if (result.error) { + console.error(result.error) + setNotice({ type: "error", text: result.error.message ?? "Could not remove the picture." }) + } else { await sessionQuery.refetch() setNotice({ type: "success", text: "Profile picture removed." }) } - } catch { + } catch (error) { + console.error(error) setNotice({ type: "error", text: "Could not remove the picture." }) } finally { setBusy(null) @@ -113,15 +123,18 @@ export function useAccount(initialSession: AdminSession) { setNotice(null) try { const result = await auth.passkey.addPasskey({ name: `Passkey ${passkeys.length + 1}` }) - if (result.error) setNotice({ type: "error", text: result.error.message ?? "Could not create the passkey." }) - else { + if (result.error) { + console.error(result.error) + setNotice({ type: "error", text: result.error.message ?? "Could not create the passkey." }) + } else { const refreshed = await refreshSecurityData() setNotice({ type: "success", text: refreshed ? "Passkey created." : "Passkey created. Refresh security data to see the updated list.", }) } - } catch { + } catch (error) { + console.error(error) setNotice({ type: "error", text: "Could not create the passkey." }) } finally { setBusy(null) @@ -133,15 +146,18 @@ export function useAccount(initialSession: AdminSession) { setNotice(null) try { const result = await auth.passkey.deletePasskey({ id }) - if (result.error) setNotice({ type: "error", text: result.error.message ?? "Could not delete the passkey." }) - else { + if (result.error) { + console.error(result.error) + setNotice({ type: "error", text: result.error.message ?? "Could not delete the passkey." }) + } else { const refreshed = await refreshSecurityData() setNotice({ type: "success", text: refreshed ? "Passkey deleted." : "Passkey deleted. Refresh security data to see the updated list.", }) } - } catch { + } catch (error) { + console.error(error) setNotice({ type: "error", text: "Could not delete the passkey." }) } finally { setBusy(null) @@ -153,8 +169,10 @@ export function useAccount(initialSession: AdminSession) { setNotice(null) try { const result = await auth.revokeOtherSessions() - if (result.error) setNotice({ type: "error", text: result.error.message ?? "Could not revoke other sessions." }) - else { + if (result.error) { + console.error(result.error) + setNotice({ type: "error", text: result.error.message ?? "Could not revoke other sessions." }) + } else { const refreshed = await refreshSecurityData() setNotice({ type: "success", @@ -163,7 +181,8 @@ export function useAccount(initialSession: AdminSession) { : "Sessions were signed out. Refresh security data to see the updated list.", }) } - } catch { + } catch (error) { + console.error(error) setNotice({ type: "error", text: "Could not revoke other sessions." }) } finally { setBusy(null) @@ -178,7 +197,8 @@ export function useAccount(initialSession: AdminSession) { if (result.error) throw new Error(result.error.message) await router.invalidate() await router.navigate({ to: "/login", replace: true }) - } catch { + } catch (error) { + console.error(error) setNotice({ type: "error", text: "Could not sign out. Please try again." }) setBusy(null) } diff --git a/src/features/associations/association-dialogs.tsx b/src/features/associations/association-dialogs.tsx index 9f549ba..3e81cff 100644 --- a/src/features/associations/association-dialogs.tsx +++ b/src/features/associations/association-dialogs.tsx @@ -25,6 +25,7 @@ import { import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" +import { errorHasCode } from "@/lib/errors" import { ASSOCIATION_LOGO_MAX_SIZE, ASSOCIATION_LOGO_TYPES, getAssociationInitials } from "./associations.constants" import { createAssociation, deleteAssociation, editAssociation } from "./associations.functions" import { associationSaveErrorMessage } from "./associations.validation" @@ -93,6 +94,7 @@ export function AssociationDialog({ const saved = editing ? await editAssociationFn({ data }) : await createAssociationFn({ data }) onSaved(saved, dialog.mode) } catch (cause) { + console.error(cause) setError(associationSaveErrorMessage(cause)) } finally { setPending(false) @@ -213,8 +215,8 @@ export function DeleteAssociationDialog({ 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) + console.error(cause) + if (errorHasCode(cause, "NOT_FOUND")) onDeleted(association.id) else toast.error("The association could not be deleted. Check your permissions and try again.") } finally { setPending(false) diff --git a/src/features/associations/association-links-dialog.tsx b/src/features/associations/association-links-dialog.tsx index d1a698a..a1fbdcb 100644 --- a/src/features/associations/association-links-dialog.tsx +++ b/src/features/associations/association-links-dialog.tsx @@ -12,6 +12,7 @@ import { } from "@/components/ui/dialog" import { Field, FieldError, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" +import { errorHasCode } from "@/lib/errors" import { ASSOCIATION_LINK_FIELDS } from "./associations.constants" import { editAssociationLinks } from "./associations.functions" import type { Association, AssociationLinks } from "./types" @@ -46,9 +47,9 @@ export function AssociationLinksDialog({ try { onSaved(await editLinksFn({ data: { id: association.id, links: normalizeLinks(links) } })) } catch (cause) { - const message = cause instanceof Error ? cause.message : "" + console.error(cause) setError( - message.includes("NOT_FOUND") + errorHasCode(cause, "NOT_FOUND") ? "This association no longer exists." : "The links could not be saved. Check the values and your permissions." ) diff --git a/src/features/associations/associations-page.tsx b/src/features/associations/associations-page.tsx index 97e5537..99c1e6c 100644 --- a/src/features/associations/associations-page.tsx +++ b/src/features/associations/associations-page.tsx @@ -33,7 +33,8 @@ export function AssociationsPage({ loadedAssociations }: { loadedAssociations: A async function refresh() { try { await router.invalidate({ sync: true }) - } catch { + } catch (error) { + console.error(error) toast.warning("Your change was saved, but the association list could not be refreshed.") } } diff --git a/src/features/associations/associations.validation.ts b/src/features/associations/associations.validation.ts index e599545..1527f2a 100644 --- a/src/features/associations/associations.validation.ts +++ b/src/features/associations/associations.validation.ts @@ -1,4 +1,5 @@ import { z } from "zod" +import { errorHasCode } from "../../lib/errors.ts" import { ASSOCIATION_LOGO_MAX_SIZE, ASSOCIATION_LOGO_TYPES } from "./associations.constants.ts" const ALLOWED_LOGO_TYPES = new Set(ASSOCIATION_LOGO_TYPES) @@ -64,17 +65,19 @@ 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")) { + if (errorHasCode(cause, "NOT_FOUND")) return "This association no longer exists." + if (errorHasCode(cause, "INVALID_NAME")) return "Enter an association name no longer than 200 characters." + if (errorHasCode(cause, "INVALID_DESCRIPTIONIT")) { return "Enter an Italian description no longer than 20,000 characters." } - if (message.includes("INVALID_DESCRIPTIONEN")) { + if (errorHasCode(cause, "INVALID_DESCRIPTIONEN")) { return "Enter an English description no longer than 20,000 characters." } - if (message.includes("LOGO") || message.includes("file")) { + if ( + errorHasCode(cause, "LOGO_TOO_LARGE") || + errorHasCode(cause, "INVALID_LOGO_TYPE") || + errorHasCode(cause, "INVALID_FILE_TYPE") + ) { return "Choose a JPG, PNG, or SVG logo no larger than 1 MB." } diff --git a/src/features/auth/auth.functions.ts b/src/features/auth/auth.functions.ts index 8b801f7..26abc42 100644 --- a/src/features/auth/auth.functions.ts +++ b/src/features/auth/auth.functions.ts @@ -15,7 +15,8 @@ export const testBackend = createServerFn() try { await context.backend.test.dbQuery.query({ dbName: "web" }) return true - } catch { + } catch (error) { + console.error(error) return false } }) diff --git a/src/features/auth/login-page.tsx b/src/features/auth/login-page.tsx index 193380f..defeb7b 100644 --- a/src/features/auth/login-page.tsx +++ b/src/features/auth/login-page.tsx @@ -27,8 +27,12 @@ export function LoginPage() { try { const { data, error } = await auth.emailOtp.sendVerificationOtp({ type: "sign-in", email: email.trim() }) if (data?.success) setSent(true) - else setNotice(error?.message ?? "We could not send a code. Check your email and try again.") - } catch { + else { + if (error) console.error(error) + setNotice(error?.message ?? "We could not send a code. Check your email and try again.") + } + } catch (error) { + console.error(error) setNotice("We could not reach the authentication service. Please try again.") } finally { setBusy(false) @@ -41,8 +45,12 @@ export function LoginPage() { try { const { data, error } = await auth.signIn.emailOtp({ email: email.trim(), otp }) if (data) await router.navigate({ to: "/dashboard" }) - else setNotice(error?.message ?? "That code is not valid. Please try again.") - } catch { + else { + if (error) console.error(error) + setNotice(error?.message ?? "That code is not valid. Please try again.") + } + } catch (error) { + console.error(error) setNotice("We could not verify the code. Check your connection and try again.") } finally { setBusy(false) @@ -55,8 +63,12 @@ export function LoginPage() { try { const { data, error } = await auth.signIn.passkey() if (data) await router.navigate({ to: "/dashboard" }) - else setNotice(error?.message ?? "Passkey sign in was cancelled or unavailable.") - } catch { + else { + if (error) console.error(error) + setNotice(error?.message ?? "Passkey sign in was cancelled or unavailable.") + } + } catch (error) { + console.error(error) setNotice("Passkey sign in is unavailable right now. Please try again.") } finally { setBusy(false) diff --git a/src/features/azure/group-membership.tsx b/src/features/azure/group-membership.tsx index 4d381dd..645d7b9 100644 --- a/src/features/azure/group-membership.tsx +++ b/src/features/azure/group-membership.tsx @@ -133,6 +133,7 @@ function MembershipDialog({ const input = { data: { groupId: group.id, userId: selectedMember.id } } const result = adding ? await addGroupMember(input) : await removeGroupMember(input) if (result.error) { + console.error(result.error) toast.error(mutationErrorMessage(result.error)) return } @@ -145,7 +146,8 @@ function MembershipDialog({ setConfirmRemoval(false) try { await router.invalidate({ sync: true }) - } catch { + } catch (error) { + console.error(error) toast.warning("The membership was updated, but the latest group data could not be refreshed.") } } catch (error) { diff --git a/src/features/azure/member-dialog.tsx b/src/features/azure/member-dialog.tsx index 368d863..ccbfe86 100644 --- a/src/features/azure/member-dialog.tsx +++ b/src/features/azure/member-dialog.tsx @@ -58,7 +58,8 @@ export function MemberDialog({ await createMember({ data: { firstName, lastName, assocNumber, sendEmailTo: email } }) await onSaved("create") } - } catch { + } catch (error) { + console.error(error) rollback?.() setError("The member could not be saved. Check the values and your permissions.") setPending(false) diff --git a/src/features/azure/members-page.tsx b/src/features/azure/members-page.tsx index 60e6f69..34964bd 100644 --- a/src/features/azure/members-page.tsx +++ b/src/features/azure/members-page.tsx @@ -270,7 +270,8 @@ export function AzureMembersPage({ initialMembers }: { initialMembers: AzureMemb if (mode === "create") { try { await router.invalidate({ sync: true }) - } catch { + } catch (error) { + console.error(error) toast.warning("The member was created, but the latest directory data could not be refreshed.") } } diff --git a/src/features/guides/guide-dialogs.tsx b/src/features/guides/guide-dialogs.tsx index c18abbe..0d30afc 100644 --- a/src/features/guides/guide-dialogs.tsx +++ b/src/features/guides/guide-dialogs.tsx @@ -28,6 +28,7 @@ import { import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { errorHasCode } from "@/lib/errors" import { createGuide, deleteGuide } from "./guides.functions" import type { Guide } from "./types" @@ -75,12 +76,10 @@ export function CreateGuideDialog({ onCreated(await createGuideFn({ data: formData })) } catch (cause) { console.error(cause) - const message = cause instanceof Error ? cause.message : typeof cause === "string" ? cause : "UNKNOWN_ERROR" - setError( - message.includes("DUPLICATE_VERSION") + errorHasCode(cause, "DUPLICATE_VERSION") ? "This version already exists." - : message.includes("UNAUTHORIZED") + : errorHasCode(cause, "UNAUTHORIZED") ? "You do not have permission to publish guides." : "The guide could not be published. Check the file and try again." ) @@ -217,7 +216,8 @@ export function DeleteGuideDialog({ try { await deleteGuideFn({ data: { id: guide.id } }) onDeleted(guide.id) - } catch { + } catch (error) { + console.error(error) toast.error("The guide could not be deleted. Check your permissions and try again.") } finally { setPending(false) diff --git a/src/features/guides/guides-page.tsx b/src/features/guides/guides-page.tsx index 4eb858c..7e76897 100644 --- a/src/features/guides/guides-page.tsx +++ b/src/features/guides/guides-page.tsx @@ -31,7 +31,8 @@ export function GuidesPage({ loadedGuides }: { loadedGuides: Guide[] }) { async function refresh() { try { await router.invalidate({ sync: true }) - } catch { + } catch (error) { + console.error(error) toast.warning("Your change was saved, but the latest guide list could not be refreshed.") } } diff --git a/src/features/onboarding/use-telegram-link.ts b/src/features/onboarding/use-telegram-link.ts index a1da933..8986914 100644 --- a/src/features/onboarding/use-telegram-link.ts +++ b/src/features/onboarding/use-telegram-link.ts @@ -31,7 +31,8 @@ export function useTelegramLink(initialSession: AdminSession) { const clearSavedLink = useCallback(() => { try { window.localStorage.removeItem(storageKey) - } catch { + } catch (error) { + console.error(error) // The in-memory flow can still be reset if storage is unavailable. } setSavedLink(null) @@ -74,7 +75,8 @@ export function useTelegramLink(initialSession: AdminSession) { window.localStorage.removeItem(storageKey) } } - } catch { + } catch (error) { + console.error(error) setNotice({ kind: "error", text: "Saved Telegram link state could not be restored. Generate a new code." }) } finally { setNow(Date.now()) @@ -112,6 +114,7 @@ export function useTelegramLink(initialSession: AdminSession) { const result = await auth.telegram.link.verify({ query: { code: savedLink.code } }) if (stopped) return if (result.error) { + console.error(result.error) setNotice({ kind: "error", text: result.error.message || "Telegram verification could not be checked." }) } else if (result.data.verified) { await completeLink() @@ -122,7 +125,8 @@ export function useTelegramLink(initialSession: AdminSession) { } else { setNotice(null) } - } catch { + } catch (error) { + console.error(error) if (!stopped) { setNotice({ kind: "error", @@ -151,6 +155,7 @@ export function useTelegramLink(initialSession: AdminSession) { try { const result = await auth.telegram.link.start({ telegramUsername }) if (result.error) { + console.error(result.error) setPhase("idle") setNotice({ kind: "error", text: result.error.message || "A Telegram link code could not be created." }) return @@ -159,14 +164,16 @@ export function useTelegramLink(initialSession: AdminSession) { const link = { username: telegramUsername, code: result.data.code, ttl: result.data.ttl, startTime: Date.now() } try { window.localStorage.setItem(storageKey, JSON.stringify(link)) - } catch { + } catch (error) { + console.error(error) setNotice({ kind: "error", text: "The code could not be saved in this browser, but it remains usable now." }) } setUsername(telegramUsername) setSavedLink(link) setNow(Date.now()) setPhase("polling") - } catch { + } catch (error) { + console.error(error) setPhase("idle") setNotice({ kind: "error", text: "The authentication service could not create a Telegram link code." }) } @@ -177,7 +184,8 @@ export function useTelegramLink(initialSession: AdminSession) { try { await navigator.clipboard.writeText(savedLink.code) setNotice({ kind: "success", text: "Code copied to the clipboard." }) - } catch { + } catch (error) { + console.error(error) setNotice({ kind: "error", text: "The code could not be copied. Select it and copy it manually." }) } } @@ -199,7 +207,8 @@ export function useTelegramLink(initialSession: AdminSession) { await refetchSession() await router.invalidate() await router.navigate({ to: "/login", replace: true }) - } catch { + } catch (error) { + console.error(error) setNotice({ kind: "error", text: "Could not sign out. Please try again." }) setLoggingOut(false) } diff --git a/src/features/projects/projects-page.tsx b/src/features/projects/projects-page.tsx index d70ebd5..e77389e 100644 --- a/src/features/projects/projects-page.tsx +++ b/src/features/projects/projects-page.tsx @@ -80,7 +80,8 @@ export function ProjectsPage({ loadedProjects }: { loadedProjects: Project[] }) async function refresh() { try { await router.invalidate({ sync: true }) - } catch { + } catch (error) { + console.error(error) toast.warning("Your change was saved, but the latest project list could not be refreshed.") } } @@ -101,13 +102,16 @@ export function ProjectsPage({ loadedProjects }: { loadedProjects: Project[] }) const operation = reorderQueue.current.then(async () => { for (const projectIds of groups) await reorderProjectsFn({ data: { projectIds } }) }) - reorderQueue.current = operation.catch(() => undefined) + reorderQueue.current = operation.catch((error) => { + console.error(error) + }) try { await operation if (reorderRequestId.current === requestId) void refresh() return true - } catch { + } catch (error) { + console.error(error) if (reorderRequestId.current === requestId) { setProjects(rollback) toast.error("The project order could not be saved.") @@ -196,6 +200,7 @@ export function ProjectsPage({ loadedProjects }: { loadedProjects: Project[] }) } return true } catch (cause) { + console.error(cause) toast.error(projectSaveErrorMessage(cause)) return false } @@ -229,7 +234,8 @@ export function ProjectsPage({ loadedProjects }: { loadedProjects: Project[] }) const ids = persistedIds(nextProjects, removedProject.category) await persistOrders([ids], nextProjects, requestId) return true - } catch { + } catch (error) { + console.error(error) if (reorderRequestId.current === requestId) { setProjects((current) => { if (current.some((item) => item.id === id)) return current @@ -277,7 +283,8 @@ export function ProjectsPage({ loadedProjects }: { loadedProjects: Project[] }) const sourceIds = persistedIds(savedProjects, originalProject.category) const destinationIds = persistedIds(savedProjects, category) await persistOrders([sourceIds, destinationIds], savedProjects, requestId) - } catch { + } catch (error) { + console.error(error) if (reorderRequestId.current === requestId) { setProjects((current) => current.map((item) => (item.id === id && item.category === category ? originalProject : item)) diff --git a/src/features/projects/projects.validation.ts b/src/features/projects/projects.validation.ts index 99a5839..38c9955 100644 --- a/src/features/projects/projects.validation.ts +++ b/src/features/projects/projects.validation.ts @@ -1,3 +1,4 @@ +import { errorHasCode } from "../../lib/errors.ts" import { PROJECT_LOGO_MAX_SIZE, PROJECT_LOGO_TYPES } from "./projects.constants.ts" const PROJECT_CATEGORIES = new Set(["news", "general", "deprecated"]) @@ -25,7 +26,8 @@ function optionalLink(data: FormData) { try { const url = new URL(link) if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("INVALID_LINK") - } catch { + } catch (error) { + console.error(error) throw new Error("INVALID_LINK") } return link @@ -53,19 +55,17 @@ export function parseProjectForm(data: FormData) { } 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")) { + if (errorHasCode(cause, "INVALID_LINK")) return "Enter a valid HTTP or HTTPS project URL." + if (errorHasCode(cause, "INVALID_TITLE")) return "Enter a project title no longer than 160 characters." + if (errorHasCode(cause, "INVALID_DESCRIPTIONIT")) { return "Enter an Italian description no longer than 5,000 characters." } - if (message.includes("INVALID_DESCRIPTIONEN")) { + if (errorHasCode(cause, "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." + if (errorHasCode(cause, "INVALID_CATEGORY")) return "Choose a valid project category." + if (errorHasCode(cause, "LOGO_TOO_LARGE")) return "The logo must be no larger than 1 MB." + if (errorHasCode(cause, "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/src/features/telegram/groups-page.tsx b/src/features/telegram/groups-page.tsx index 32a99e4..928bbb1 100644 --- a/src/features/telegram/groups-page.tsx +++ b/src/features/telegram/groups-page.tsx @@ -58,10 +58,12 @@ export function TelegramGroupsPage({ loadedGroups }: { loadedGroups: TgGroup[] } const { [group.telegramId]: _removed, ...remaining } = current return remaining }) - } catch { + } catch (error) { + console.error(error) setRefreshError("The visibility was updated, but the latest group data could not be refreshed.") } - } catch { + } catch (error) { + console.error(error) setVisibilityOverrides((current) => { const { [group.telegramId]: _removed, ...remaining } = current return remaining diff --git a/src/features/telegram/leave-group-dialog.tsx b/src/features/telegram/leave-group-dialog.tsx index 855fb25..b9546bb 100644 --- a/src/features/telegram/leave-group-dialog.tsx +++ b/src/features/telegram/leave-group-dialog.tsx @@ -16,6 +16,7 @@ import { } from "@/components/ui/alert-dialog" import { Button } from "@/components/ui/button" import { leaveTelegramGroup } from "@/features/telegram/groups.functions" +import { errorMessage } from "@/lib/errors" export function LeaveGroupDialog({ chatId, title }: { chatId: number; title: string }) { const router = useRouter() @@ -36,6 +37,7 @@ export function LeaveGroupDialog({ chatId, title }: { chatId: number; title: str ) } if (result.error === "NOT_FOUND") { + console.error(result.error) toast.warning("The bot left the group, but its database record was already missing.") } else { toast.success(`Left ${title}.`) @@ -43,7 +45,8 @@ export function LeaveGroupDialog({ chatId, title }: { chatId: number; title: str setOpen(false) await router.invalidate({ sync: true }) } catch (error) { - toast.error(error instanceof Error ? error.message : "The group could not be left.") + console.error(error) + toast.error(errorMessage(error, "The group could not be left.")) } finally { setPending(false) } diff --git a/src/features/telegram/user-detail/grant-dialogs.tsx b/src/features/telegram/user-detail/grant-dialogs.tsx index 0ed8897..a717d9f 100644 --- a/src/features/telegram/user-detail/grant-dialogs.tsx +++ b/src/features/telegram/user-detail/grant-dialogs.tsx @@ -30,6 +30,7 @@ export function InterruptGrantDialog({ userId, displayName }: { userId: number; try { const result = await interruptGrant({ data: { userId } }) if (result.error) { + console.error(result.error) toast.error(grantMutationError(result.error)) return } @@ -38,7 +39,8 @@ export function InterruptGrantDialog({ userId, displayName }: { userId: number; setOpen(false) try { await router.invalidate({ sync: true }) - } catch { + } catch (refreshError) { + console.error(refreshError) toast.warning("The grant was ended, but the latest user data could not be refreshed.") } } catch (error) { diff --git a/src/features/telegram/user-detail/group-admin-dialog.tsx b/src/features/telegram/user-detail/group-admin-dialog.tsx index 31ed099..919b04f 100644 --- a/src/features/telegram/user-detail/group-admin-dialog.tsx +++ b/src/features/telegram/user-detail/group-admin-dialog.tsx @@ -69,7 +69,8 @@ export function AddGroupAdminDialog({ try { await addGroupAdmin({ data: { userId, groupId: Number(groupId) } }) await onSaved() - } catch { + } catch (error) { + console.error(error) setError("The user could not be added as a group administrator.") setPending(false) } @@ -156,12 +157,14 @@ export function RemoveGroupAdminDialog({ try { const result = await removeGroupAdmin({ data: { userId, groupId } }) if (result.error) { + console.error(result.error) setError(groupAdminMutationError(result.error)) return } setOpen(false) await onSaved() - } catch { + } catch (error) { + console.error(error) setError("The group administrator assignment could not be removed.") } finally { setPending(false) diff --git a/src/features/telegram/user-detail/profile.tsx b/src/features/telegram/user-detail/profile.tsx index db2a296..36ab3a1 100644 --- a/src/features/telegram/user-detail/profile.tsx +++ b/src/features/telegram/user-detail/profile.tsx @@ -185,7 +185,8 @@ export function TelegramUserProfile({ data }: { data: TelegramUserDetail }) { toast.success("Group administrator removed.") try { await router.invalidate({ sync: true }) - } catch { + } catch (error) { + console.error(error) toast.warning("The assignment was removed, but the latest user data could not be refreshed.") } }} @@ -208,7 +209,8 @@ export function TelegramUserProfile({ data }: { data: TelegramUserDetail }) { toast.success("Group administrator added.") try { await router.invalidate({ sync: true }) - } catch { + } catch (error) { + console.error(error) toast.warning("The administrator was added, but the latest user data could not be refreshed.") } }} diff --git a/src/features/telegram/user-detail/role-dialog.tsx b/src/features/telegram/user-detail/role-dialog.tsx index 7f0fbc8..2716cfe 100644 --- a/src/features/telegram/user-detail/role-dialog.tsx +++ b/src/features/telegram/user-detail/role-dialog.tsx @@ -72,6 +72,7 @@ export function RoleDialog({ const action = adding ? addUserRole : removeUserRole const result = await action({ data: { userId, role: selectedRole } }) if (result.error) { + console.error(result.error) toast.error(roleMutationError(result.error, adding)) return } @@ -81,7 +82,8 @@ export function RoleDialog({ setSelectedRole(null) try { await router.invalidate({ sync: true }) - } catch { + } catch (refreshError) { + console.error(refreshError) toast.warning("The role was updated, but the latest user data could not be refreshed.") } } catch (error) { diff --git a/src/features/telegram/users.functions.ts b/src/features/telegram/users.functions.ts index 6ef2f5e..d148e0b 100644 --- a/src/features/telegram/users.functions.ts +++ b/src/features/telegram/users.functions.ts @@ -34,8 +34,12 @@ export const findTelegramUser = createServerFn() ? await context.backend.tg.users.getByUsername.query({ username: data.username.replace(/^@/, "") }) : await context.backend.tg.users.get.query({ userId: data.userId }) - if (response.error === "NOT_FOUND" || !response.user) return { user: null, status: "not-found" } - if (response.error) return { user: null, status: "error", message: "Telegram user lookup failed." } + if (response.error) { + console.error(response.error) + if (response.error === "NOT_FOUND") return { user: null, status: "not-found" } + return { user: null, status: "error", message: "Telegram user lookup failed." } + } + if (!response.user) return { user: null, status: "not-found" } return { user: response.user, status: "found" } } catch (error) { console.error(error) diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..e1efed5 --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,20 @@ +const ERROR_FIELDS = ["message", "error", "code", "cause"] as const + +function errorMessages(value: unknown, seen: Set): string[] { + if (typeof value === "string") return [value] + if (!value || typeof value !== "object" || seen.has(value)) return [] + + seen.add(value) + const record = value as Record + return ERROR_FIELDS.flatMap((field) => errorMessages(record[field], seen)) +} + +export function errorHasCode(cause: unknown, code: string) { + const escapedCode = code.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + const codePattern = new RegExp(`(^|[^A-Z0-9_])${escapedCode}($|[^A-Z0-9_])`) + return errorMessages(cause, new Set()).some((message) => codePattern.test(message)) +} + +export function errorMessage(cause: unknown, fallback: string) { + return errorMessages(cause, new Set()).find((message) => message.trim()) ?? fallback +} diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index f2b7ce2..99529dc 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -25,7 +25,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {