From 6017fbc4ef46b136dbc1a3e5c20ae27e3b7057c6 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:12:29 -0400 Subject: [PATCH] feat: Add DataForSEO key setup guard and onboarding help (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add DataForSEO key setup guard and onboarding help Detect missing DataForSEO credentials at app load so users get a clear setup path before running SEO workflows. * fix api key setup warning visibility and help docs Make the setup warning fail-safe when status checks error, switch the top notice to DaisyUI alert styling, and add Cloudflare dashboard secret instructions to the help page. * fix README formatting for CI * fix seo api key status fallback behavior * harden DataForSEO setup status checks and modal UX Gate API key status behind authenticated middleware and improve the setup modal’s keyboard/accessibility behavior, with a clearer fallback notice when status checks fail. * refine setup UX and local env docs Hide the API key setup modal on the setup guide route to avoid a self-blocking flow, and clarify README local configuration for DATAFORSEO_API_KEY format. --- README.md | 7 +- src/routeTree.gen.ts | 21 ++++ src/routes/__root.tsx | 164 ++++++++++++++++++++++++- src/routes/help/dataforseo-api-key.tsx | 94 ++++++++++++++ src/serverFunctions/config.ts | 10 ++ 5 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 src/routes/help/dataforseo-api-key.tsx create mode 100644 src/serverFunctions/config.ts diff --git a/README.md b/README.md index 8b5a6af..7b65870 100644 --- a/README.md +++ b/README.md @@ -148,12 +148,13 @@ pnpm run db:migrate:local Configure .env.local: 1. `cp .env.example .env.local` -2. Add `AUTH_MODE=local_noauth` so that it doesn't expect Cloudflare Access -3. Add `DATAFORSEO_API_KEY=yourkey` +2. Add `DATAFORSEO_API_KEY` as a base64-encoded `login:password` value: + + `printf '%s' 'YOUR_LOGIN:YOUR_PASSWORD' | base64` Run Locally: -``` +```sh # Option 1 pnpm run dev diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 43fcae0..29b6f7d 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' +import { Route as HelpDataforseoApiKeyRouteImport } from './routes/help/dataforseo-api-key' import { Route as PProjectIdRouteRouteImport } from './routes/p/$projectId/route' import { Route as PProjectIdIndexRouteImport } from './routes/p/$projectId/index' import { Route as PProjectIdSavedRouteImport } from './routes/p/$projectId/saved' @@ -26,6 +27,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const HelpDataforseoApiKeyRoute = HelpDataforseoApiKeyRouteImport.update({ + id: '/help/dataforseo-api-key', + path: '/help/dataforseo-api-key', + getParentRoute: () => rootRouteImport, +} as any) const PProjectIdRouteRoute = PProjectIdRouteRouteImport.update({ id: '/p/$projectId', path: '/p/$projectId', @@ -82,6 +88,7 @@ const PProjectIdAuditIssuesResultIdRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute '/p/$projectId': typeof PProjectIdRouteRouteWithChildren + '/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute '/p/$projectId/ai': typeof PProjectIdAiRoute '/p/$projectId/audit': typeof PProjectIdAuditRouteWithChildren '/p/$projectId/domain': typeof PProjectIdDomainRoute @@ -94,6 +101,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute '/p/$projectId/ai': typeof PProjectIdAiRoute '/p/$projectId/domain': typeof PProjectIdDomainRoute '/p/$projectId/keywords': typeof PProjectIdKeywordsRoute @@ -107,6 +115,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/p/$projectId': typeof PProjectIdRouteRouteWithChildren + '/help/dataforseo-api-key': typeof HelpDataforseoApiKeyRoute '/p/$projectId/ai': typeof PProjectIdAiRoute '/p/$projectId/audit': typeof PProjectIdAuditRouteWithChildren '/p/$projectId/domain': typeof PProjectIdDomainRoute @@ -122,6 +131,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/p/$projectId' + | '/help/dataforseo-api-key' | '/p/$projectId/ai' | '/p/$projectId/audit' | '/p/$projectId/domain' @@ -134,6 +144,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/help/dataforseo-api-key' | '/p/$projectId/ai' | '/p/$projectId/domain' | '/p/$projectId/keywords' @@ -146,6 +157,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/p/$projectId' + | '/help/dataforseo-api-key' | '/p/$projectId/ai' | '/p/$projectId/audit' | '/p/$projectId/domain' @@ -160,6 +172,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute PProjectIdRouteRoute: typeof PProjectIdRouteRouteWithChildren + HelpDataforseoApiKeyRoute: typeof HelpDataforseoApiKeyRoute } declare module '@tanstack/react-router' { @@ -171,6 +184,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/help/dataforseo-api-key': { + id: '/help/dataforseo-api-key' + path: '/help/dataforseo-api-key' + fullPath: '/help/dataforseo-api-key' + preLoaderRoute: typeof HelpDataforseoApiKeyRouteImport + parentRoute: typeof rootRouteImport + } '/p/$projectId': { id: '/p/$projectId' path: '/p/$projectId' @@ -285,6 +305,7 @@ const PProjectIdRouteRouteWithChildren = PProjectIdRouteRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, PProjectIdRouteRoute: PProjectIdRouteRouteWithChildren, + HelpDataforseoApiKeyRoute: HelpDataforseoApiKeyRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index ee33b4f..56c06af 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -13,7 +13,12 @@ import { TanStackDevtools } from "@tanstack/react-devtools"; import { QueryClientProvider } from "@tanstack/react-query"; import * as React from "react"; import { useState } from "react"; -import { Menu, ChevronsUpDown } from "lucide-react"; +import { + Menu, + ChevronsUpDown, + AlertTriangle, + ExternalLink, +} from "lucide-react"; import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary"; import { NotFound } from "@/client/components/NotFound"; import appCss from "@/client/styles/app.css?url"; @@ -21,6 +26,9 @@ import { Toaster } from "sonner"; import { Sidebar } from "@/client/components/Sidebar"; import { queryClient } from "@/client/tanstack-db"; import { projectNavItems } from "@/client/navigation/items"; +import { getSeoApiKeyStatus } from "@/serverFunctions/config"; + +const DATAFORSEO_HELP_PATH = "/help/dataforseo-api-key"; export const Route = createRootRoute({ head: () => ({ @@ -73,11 +81,71 @@ export const Route = createRootRoute({ function AppLayout() { const location = useLocation(); const [drawerOpen, setDrawerOpen] = useState(false); + const setupModalRef = React.useRef(null); + const [isSeoApiKeyConfigured, setIsSeoApiKeyConfigured] = useState< + boolean | null + >(null); + const [seoApiKeyStatusError, setSeoApiKeyStatusError] = useState(false); + const [showMissingSeoApiKeyModal, setShowMissingSeoApiKeyModal] = + useState(false); // Extract projectId from the current path const projectIdMatch = location.pathname.match(/^\/p\/([^/]+)/); const projectId = projectIdMatch?.[1] ?? null; + React.useEffect(() => { + let cancelled = false; + + const checkSeoApiKeyStatus = async () => { + try { + const result = await getSeoApiKeyStatus(); + if (cancelled) return; + + setSeoApiKeyStatusError(false); + setIsSeoApiKeyConfigured(result.configured); + if (!result.configured) { + setShowMissingSeoApiKeyModal(true); + } + } catch { + if (cancelled) return; + setSeoApiKeyStatusError(true); + setIsSeoApiKeyConfigured(null); + setShowMissingSeoApiKeyModal(false); + } + }; + + void checkSeoApiKeyStatus(); + + return () => { + cancelled = true; + }; + }, []); + + const shouldShowMissingSeoApiKeyModal = + showMissingSeoApiKeyModal && location.pathname !== DATAFORSEO_HELP_PATH; + + const shouldShowSeoApiWarning = + !seoApiKeyStatusError && + isSeoApiKeyConfigured === false && + !shouldShowMissingSeoApiKeyModal; + + React.useEffect(() => { + if (!shouldShowMissingSeoApiKeyModal) return; + + setupModalRef.current?.focus(); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setShowMissingSeoApiKeyModal(false); + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + }; + }, [shouldShowMissingSeoApiKeyModal]); + return (
{/* Top Navbar */} @@ -141,6 +209,48 @@ function AppLayout() {
+ {shouldShowSeoApiWarning ? ( +
+
+
+ + + Setup needed: add your DataForSEO API key to use OpenSEO + features. See the quick steps on the{" "} + + help page + + . + +
+
+
+ ) : null} + + {seoApiKeyStatusError ? ( +
+
+
+ + + We could not verify your DataForSEO setup. If features are not + working, check the setup steps on the{" "} + + help page + + . + +
+
+
+ ) : null} + {/* Mobile: drawer layout */}
@@ -171,6 +281,58 @@ function AppLayout() {
+ + {shouldShowMissingSeoApiKeyModal ? ( +
+
+
+
+ +
+
+

+ One quick setup step +

+

+ Add your DataForSEO API key to start using OpenSEO. +

+
+
+ +
+ + setShowMissingSeoApiKeyModal(false)} + > + Open setup guide + + +
+
+
+ ) : null}
); } diff --git a/src/routes/help/dataforseo-api-key.tsx b/src/routes/help/dataforseo-api-key.tsx new file mode 100644 index 0000000..bd7c2b1 --- /dev/null +++ b/src/routes/help/dataforseo-api-key.tsx @@ -0,0 +1,94 @@ +import { createFileRoute } from "@tanstack/react-router"; + +const DATAFORSEO_API_ACCESS_URL = "https://app.dataforseo.com/api-access"; + +export const Route = createFileRoute("/help/dataforseo-api-key")({ + component: DataforseoApiKeyHelpPage, +}); + +function DataforseoApiKeyHelpPage() { + return ( +
+
+
+
+

+ Set up your DataForSEO API key +

+

+ OpenSEO needs the DATAFORSEO_API_KEY secret before + keyword, domain, and SEO data workflows can run. +

+
+
+ +
+
+

Steps

+
    +
  1. + Go to{" "} + + DataForSEO API Access + {" "} + and request API credentials by email. +
  2. +
  3. + Base64 encode your DataForSEO login and API password in this + format: +
    +                  printf '%s' 'YOUR_LOGIN:YOUR_PASSWORD' | base64
    +                
    +
  4. +
  5. + Save the output as the DATAFORSEO_API_KEY secret in + your environment. +
  6. +
+
+
+ +
+
+

+ Cloudflare Workers (Dashboard UI) +

+
    +
  1. + In Cloudflare, go to Compute ->{" "} + Workers & Pages + and open your OpenSEO Worker. +
  2. +
  3. + Open Settings. +
  4. +
  5. + Go to Variables & Secrets and add a new secret + named + DATAFORSEO_API_KEY. +
  6. +
  7. + Paste the base64 value from the terminal command above and save. +
  8. +
+ +
+ +

Or set the same secret from your terminal with:

+
+              npx wrangler secret put DATAFORSEO_API_KEY
+            
+

+ Use the base64 value of login:password when prompted. +

+
+
+
+
+ ); +} diff --git a/src/serverFunctions/config.ts b/src/serverFunctions/config.ts new file mode 100644 index 0000000..35593e1 --- /dev/null +++ b/src/serverFunctions/config.ts @@ -0,0 +1,10 @@ +import { createServerFn } from "@tanstack/react-start"; +import { env } from "cloudflare:workers"; +import { authenticatedServerFunctionMiddleware } from "@/serverFunctions/middleware"; + +export const getSeoApiKeyStatus = createServerFn({ method: "GET" }) + .middleware(authenticatedServerFunctionMiddleware) + .handler(() => { + const configured = Boolean(env.DATAFORSEO_API_KEY?.trim()); + return { configured }; + });