feat: Add DataForSEO key setup guard and onboarding help (#12)

* 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.
This commit is contained in:
Ben Senescu 2026-03-11 15:12:29 -04:00 committed by GitHub
parent e11c0b05f8
commit 6017fbc4ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 292 additions and 4 deletions

View File

@ -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

View File

@ -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)

View File

@ -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<HTMLDivElement | null>(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 (
<div className="flex flex-col h-[100dvh] bg-base-200">
{/* Top Navbar */}
@ -141,6 +209,48 @@ function AppLayout() {
</div>
</div>
{shouldShowSeoApiWarning ? (
<div className="shrink-0 px-4 py-2.5 md:px-6">
<div className="mx-auto max-w-7xl">
<div className="alert alert-warning">
<AlertTriangle className="size-4 shrink-0" />
<span className="text-sm">
Setup needed: add your DataForSEO API key to use OpenSEO
features. See the quick steps on the{" "}
<Link
to={DATAFORSEO_HELP_PATH}
className="link link-primary font-medium"
>
help page
</Link>
.
</span>
</div>
</div>
</div>
) : null}
{seoApiKeyStatusError ? (
<div className="shrink-0 px-4 py-2.5 md:px-6">
<div className="mx-auto max-w-7xl">
<div className="alert alert-info">
<AlertTriangle className="size-4 shrink-0" />
<span className="text-sm">
We could not verify your DataForSEO setup. If features are not
working, check the setup steps on the{" "}
<Link
to={DATAFORSEO_HELP_PATH}
className="link link-primary font-medium"
>
help page
</Link>
.
</span>
</div>
</div>
</div>
) : null}
{/* Mobile: drawer layout */}
<div className="flex-1 min-h-0 md:hidden">
<div className="h-full overflow-auto">
@ -171,6 +281,58 @@ function AppLayout() {
<div className="hidden md:block flex-1 min-h-0 overflow-auto">
<Outlet />
</div>
{shouldShowMissingSeoApiKeyModal ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div
ref={setupModalRef}
role="dialog"
aria-modal="true"
aria-labelledby="dataforseo-setup-title"
aria-describedby="dataforseo-setup-description"
tabIndex={-1}
className="w-full max-w-lg rounded-xl border border-base-300 bg-base-100 p-5 shadow-2xl"
>
<div className="flex items-start gap-3">
<div className="rounded-full bg-warning/20 p-2 text-warning">
<AlertTriangle className="size-5" />
</div>
<div className="space-y-2">
<h2
id="dataforseo-setup-title"
className="text-lg font-semibold text-base-content"
>
One quick setup step
</h2>
<p
id="dataforseo-setup-description"
className="text-sm text-base-content/75"
>
Add your DataForSEO API key to start using OpenSEO.
</p>
</div>
</div>
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
type="button"
className="btn btn-ghost"
onClick={() => setShowMissingSeoApiKeyModal(false)}
>
Dismiss
</button>
<Link
to={DATAFORSEO_HELP_PATH}
className="btn btn-primary"
onClick={() => setShowMissingSeoApiKeyModal(false)}
>
Open setup guide
<ExternalLink className="size-4" />
</Link>
</div>
</div>
</div>
) : null}
</div>
);
}

View File

@ -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 (
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
<div className="mx-auto max-w-3xl space-y-4">
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-3">
<h1 className="text-2xl font-semibold">
Set up your DataForSEO API key
</h1>
<p className="text-sm text-base-content/70">
OpenSEO needs the <code>DATAFORSEO_API_KEY</code> secret before
keyword, domain, and SEO data workflows can run.
</p>
</div>
</div>
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4">
<h2 className="card-title text-base">Steps</h2>
<ol className="list-decimal pl-5 text-sm space-y-3 text-base-content/80">
<li>
Go to{" "}
<a
className="link link-primary"
href={DATAFORSEO_API_ACCESS_URL}
target="_blank"
rel="noreferrer"
>
DataForSEO API Access
</a>{" "}
and request API credentials by email.
</li>
<li>
Base64 encode your DataForSEO login and API password in this
format:
<pre className="mt-2 p-3 rounded bg-base-200 border border-base-300 overflow-x-auto text-xs">
<code>printf '%s' 'YOUR_LOGIN:YOUR_PASSWORD' | base64</code>
</pre>
</li>
<li>
Save the output as the <code>DATAFORSEO_API_KEY</code> secret in
your environment.
</li>
</ol>
</div>
</div>
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-2 text-sm text-base-content/75">
<h2 className="card-title text-base">
Cloudflare Workers (Dashboard UI)
</h2>
<ol className="list-decimal pl-5 space-y-2 text-sm text-base-content/80">
<li>
In Cloudflare, go to <code>Compute</code> -&gt;{" "}
<code>Workers &amp; Pages</code>
and open your OpenSEO Worker.
</li>
<li>
Open <code>Settings</code>.
</li>
<li>
Go to <code>Variables &amp; Secrets</code> and add a new secret
named
<code className="mx-1">DATAFORSEO_API_KEY</code>.
</li>
<li>
Paste the base64 value from the terminal command above and save.
</li>
</ol>
<div className="divider my-1" />
<p>Or set the same secret from your terminal with:</p>
<pre className="p-3 rounded bg-base-200 border border-base-300 overflow-x-auto text-xs">
<code>npx wrangler secret put DATAFORSEO_API_KEY</code>
</pre>
<p>
Use the base64 value of <code>login:password</code> when prompted.
</p>
</div>
</div>
</div>
</div>
);
}

View File

@ -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 };
});