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:
parent
e11c0b05f8
commit
6017fbc4ef
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
94
src/routes/help/dataforseo-api-key.tsx
Normal file
94
src/routes/help/dataforseo-api-key.tsx
Normal 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> ->{" "}
|
||||
<code>Workers & Pages</code>
|
||||
and open your OpenSEO Worker.
|
||||
</li>
|
||||
<li>
|
||||
Open <code>Settings</code>.
|
||||
</li>
|
||||
<li>
|
||||
Go to <code>Variables & 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>
|
||||
);
|
||||
}
|
||||
10
src/serverFunctions/config.ts
Normal file
10
src/serverFunctions/config.ts
Normal 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 };
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user