From 4fe9fd0ea4a347b9951cb586c6633bc40c16d2fb Mon Sep 17 00:00:00 2001 From: Bharath Lakshman Kumar Date: Thu, 29 May 2025 14:19:21 +0530 Subject: [PATCH] fix: improve API key validation and error handling in import modals - Add server-side API key validation for Rebrandly and Short.io - Implement proper input patterns and length validation - Fix duplicate toast notifications on validation errors - Prevent continuous polling after errors to reduce layout shifts - Show specific error messages from backend validation --- .../[idOrSlug]/import/rebrandly/route.ts | 16 ++++++++++++++++ .../[idOrSlug]/import/short/route.ts | 16 ++++++++++++++++ apps/web/ui/modals/import-rebrandly-modal.tsx | 16 ++++++++++++++-- apps/web/ui/modals/import-short-modal.tsx | 18 +++++++++++++++--- 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/apps/web/app/api/workspaces/[idOrSlug]/import/rebrandly/route.ts b/apps/web/app/api/workspaces/[idOrSlug]/import/rebrandly/route.ts index 4b3e83eb7d..4cbf0888b3 100644 --- a/apps/web/app/api/workspaces/[idOrSlug]/import/rebrandly/route.ts +++ b/apps/web/app/api/workspaces/[idOrSlug]/import/rebrandly/route.ts @@ -79,6 +79,22 @@ export const GET = withWorkspace(async ({ workspace }) => { // PUT /api/workspaces/[idOrSlug]/import/rebrandly - save Rebrandly API key export const PUT = withWorkspace(async ({ req, workspace }) => { const { apiKey } = await req.json(); + const isValidApiKeyResponse = await fetch( + "https://api.rebrandly.com/v1/account", + { + method: "HEAD", + headers: { + "Content-Type": "application/json", + apikey: apiKey, + }, + }, + ); + if (!isValidApiKeyResponse.ok) { + throw new DubApiError({ + code: "bad_request", + message: "Invalid Rebrandly API key", + }); + } const response = await redis.set(`import:rebrandly:${workspace.id}`, apiKey); return NextResponse.json(response); }); diff --git a/apps/web/app/api/workspaces/[idOrSlug]/import/short/route.ts b/apps/web/app/api/workspaces/[idOrSlug]/import/short/route.ts index 2438054118..a5aecfe9ec 100644 --- a/apps/web/app/api/workspaces/[idOrSlug]/import/short/route.ts +++ b/apps/web/app/api/workspaces/[idOrSlug]/import/short/route.ts @@ -66,6 +66,22 @@ export const GET = withWorkspace(async ({ workspace }) => { // PUT /api/workspaces/[idOrSlug]/import/short - save Short.io API key export const PUT = withWorkspace(async ({ req, workspace }) => { const { apiKey } = await req.json(); + const isValidApiKeyResponse = await fetch( + "https://api.short.io/api/domains?limit=1&offset=0", + { + method: "HEAD", + headers: { + "Content-Type": "application/json", + Authorization: apiKey, + }, + }, + ); + if (!isValidApiKeyResponse.ok) { + throw new DubApiError({ + code: "bad_request", + message: "Invalid Short.io API key", + }); + } const response = await redis.set(`import:short:${workspace.id}`, apiKey); return NextResponse.json(response); }); diff --git a/apps/web/ui/modals/import-rebrandly-modal.tsx b/apps/web/ui/modals/import-rebrandly-modal.tsx index 4768aaa98a..c0b33750cc 100644 --- a/apps/web/ui/modals/import-rebrandly-modal.tsx +++ b/apps/web/ui/modals/import-rebrandly-modal.tsx @@ -38,6 +38,8 @@ function ImportRebrandlyModal({ const folderId = searchParams.get("folderId"); + const [hasEncounteredError, setHasEncounteredError] = useState(false); + const { data: { domains, tagsCount } = { domains: null, @@ -55,10 +57,12 @@ function ImportRebrandlyModal({ fetcher, { onError: (err) => { + setHasEncounteredError(true); if (err.message !== "No Rebrandly access token found") { toast.error(err.message); } }, + onSuccess: () => setHasEncounteredError(false), }, ); @@ -88,6 +92,9 @@ function ImportRebrandlyModal({ const { isMobile } = useMediaQuery(); + // Only show loading if we haven't encountered an error before and workspaceId exists + const shouldShowLoading = (isLoading && !hasEncounteredError) || !workspaceId; + return (
- {isLoading || !workspaceId ? ( + {shouldShowLoading ? (

Connecting to Rebrandly

@@ -229,7 +236,8 @@ function ImportRebrandlyModal({ await mutate(); toast.success("Successfully added API key"); } else { - toast.error("Error adding API key"); + const body = await res.json(); + toast.error(body.error?.message || "Error adding API key"); } setSubmitting(false); }); @@ -257,6 +265,10 @@ function ImportRebrandlyModal({ autoFocus={!isMobile} type="text" placeholder="93467061146a64622df83c12bcc0bffb" + minLength={32} + maxLength={32} + pattern="[0-9a-f]{32}" + title="Please enter a valid Rebrandly API key" autoComplete="off" required className="mt-1 block w-full appearance-none rounded-md border border-neutral-300 px-3 py-2 placeholder-neutral-400 shadow-sm focus:border-black focus:outline-none focus:ring-black sm:text-sm" diff --git a/apps/web/ui/modals/import-short-modal.tsx b/apps/web/ui/modals/import-short-modal.tsx index 37ef8ea519..d8bc79c3e7 100644 --- a/apps/web/ui/modals/import-short-modal.tsx +++ b/apps/web/ui/modals/import-short-modal.tsx @@ -39,6 +39,8 @@ function ImportShortModal({ const folderId = searchParams.get("folderId"); + const [hasEncounteredError, setHasEncounteredError] = useState(false); + const { data: domains, isLoading, @@ -50,10 +52,12 @@ function ImportShortModal({ fetcher, { onError: (err) => { + setHasEncounteredError(true); if (err.message !== "No Short.io access token found") { toast.error(err.message); } }, + onSuccess: () => setHasEncounteredError(false), }, ); @@ -83,6 +87,9 @@ function ImportShortModal({ const { isMobile } = useMediaQuery(); + // Only show loading if we haven't encountered an error before and workspaceId exists + const shouldShowLoading = (isLoading && !hasEncounteredError) || !workspaceId; + return (
- {isLoading || !workspaceId ? ( + {shouldShowLoading ? (

Connecting to Short.io

@@ -147,7 +154,7 @@ function ImportShortModal({ { loading: "Adding links to import queue...", success: - "Successfully added links to import queue! You can now safely navigate from this tab – we will send you an email when your links have been fully imported.", + "Successfully added links to import queue! You can now safely navigate from this tab – we will send you an email when your links have been fully imported.", error: "Error adding links to import queue", }, ); @@ -239,7 +246,8 @@ function ImportShortModal({ await mutate(); toast.success("Successfully added API key"); } else { - toast.error("Error adding API key"); + const body = await res.json(); + toast.error(body.error?.message || "Error adding API key"); } setSubmitting(false); }); @@ -267,6 +275,10 @@ function ImportShortModal({ autoFocus={!isMobile} type="text" placeholder="sk_xxxxxxxxxxxxxxxx" + minLength={19} + maxLength={19} + pattern="sk_[A-Za-z0-9]{16}" + title="Please enter a valid Short.io API key" autoComplete="off" required className="mt-1 block w-full appearance-none rounded-md border border-neutral-300 px-3 py-2 placeholder-neutral-400 shadow-sm focus:border-black focus:outline-none focus:ring-black sm:text-sm"