Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions alchemy.access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,27 @@ export const emailAccessGate = (options: {
policies: [allow.policyId],
});
});

/**
* A machine-auth credential for a non-interactive client (an MCP client, a
* CI job) to authenticate through Cloudflare Access with the
* `CF-Access-Client-Id` / `CF-Access-Client-Secret` headers instead of an
* interactive identity-provider login.
*
* This only provisions the token — it does NOT attach it to an Access
* application. The app's own JWT check (src/middleware/ensure-user/
* cloudflareAccess.ts) verifies the token's `aud` claim against
* `POLICY_AUD`, so the token must be authorized via a policy attached to
* *that same* Access application (whichever one actually issues
* POLICY_AUD-audienced tokens for the deployment's hostname) — attaching it
* to a different/new application would mint a token with the wrong
* audience and the app would reject it. Wire the resulting
* `token.serviceTokenId` into a `non_identity` policy
* (`include: [{ serviceToken: { tokenId } }]`) on that application by hand
* in the Zero Trust dashboard, since which application that is depends on
* how each deployment's Access application was set up.
*/
export const machineAccessToken = (options: {
tokenId: string;
tokenName: string;
}) => Cloudflare.Access.ServiceToken(options.tokenId, { name: options.tokenName });
32 changes: 31 additions & 1 deletion alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { z } from "zod";
import {
emailAccessGate,
HOSTED_PROD_STAGE,
machineAccessToken,
readWorkersSubdomain,
requireAllowedEmails,
workerName,
Expand Down Expand Up @@ -354,10 +355,39 @@ export default Alchemy.Stack(
workersSubdomain,
);

// Lets a non-interactive client (e.g. an MCP client) authenticate
// through Cloudflare Access without the interactive login the rest of
// the app requires. This only provisions the token — see
// machineAccessToken's doc comment for the manual dashboard step
// still needed to actually authorize it.
if (authMode === "cloudflare_access" && stage === "selfhost") {
const mcpToken = yield* machineAccessToken({
tokenId: "McpServiceToken",
tokenName: `open-seo ${stage} mcp clients`,
});
const mcpClientId = yield* (yield* mcpToken.clientId);
const mcpClientSecret = yield* (yield* mcpToken.clientSecret);
if (mcpClientSecret) {
yield* Console.log(
`\nMCP Service Token (only shown on create/rotate — save it now):\n` +
` CF-Access-Client-Id: ${mcpClientId}\n` +
` CF-Access-Client-Secret: ${Redacted.value(mcpClientSecret)}\n` +
`Authorize it in the Zero Trust dashboard: Access > Applications > (the app protecting this hostname) > Policies > add a policy with decision "Service Auth" selecting this token.\n`,
);
}
}

const app = yield* Cloudflare.Worker("open-seo", {
name: workerName(stage),
// Prod serves the real domains; the zone is inferred from the hostname.
domain: prod ? ["app.openseo.so", "www.app.openseo.so"] : undefined,
// selfhost stage: our own custom domain (seo.safar2x.com), so it
// survives future `deploy:selfhost` reconciliations instead of being
// torn down (it's not tracked in alchemy's state if added out-of-band).
domain: prod
? ["app.openseo.so", "www.app.openseo.so"]
: stage === "selfhost"
? ["seo.safar2x.com"]
: undefined,
// Prebuilt worker from `vite build` (@cloudflare/vite-plugin). The entry
// exports the DO + WorkflowEntrypoint classes (re-exported by
// src/server.ts), which `bundle: false` requires. Sibling chunks under
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
"dataforseo-client": "^2.0.19",
"drizzle-orm": "^0.45.2",
"fast-xml-parser": "^5.4.1",
"i18next": "^26.3.6",
"jose": "^6.0.12",
"lucide-react": "^0.542.0",
"papaparse": "^5.5.3",
Expand All @@ -105,6 +106,7 @@
"posthog-node": "^5.38.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-i18next": "^17.0.11",
"react-markdown": "^10.1.0",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1",
Expand Down
50 changes: 50 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions src/client/components/HelpHint.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { HelpCircle } from "lucide-react";

/**
* Small "?" icon that shows an explanatory tooltip on hover/focus.
* Uses daisyUI's `.tooltip` utility (no extra dependency); `data-tip`
* wraps automatically so long explanations are fine.
*/
export function HelpHint({ text }: { text: string }) {
if (!text) return null;

return (
<span
className="tooltip tooltip-top inline-flex align-middle mx-1"
data-tip={text}
>
<button
type="button"
aria-label={text}
className="inline-flex items-center justify-center text-base-content/50 hover:text-base-content/80 focus:text-base-content/80 outline-none"
// Decorative trigger only — nothing to activate, just focus/hover target.
onClick={(e) => e.preventDefault()}
tabIndex={0}
>
<HelpCircle size={14} />
</button>
</span>
);
}
62 changes: 62 additions & 0 deletions src/client/components/LanguagePreferenceMenuItems.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Languages } from "lucide-react";
import { useTranslation } from "react-i18next";
import { type LangPreference, useLangPreference } from "@/client/lib/lang";

const LANG_OPTIONS: { value: LangPreference; labelKey: string }[] = [
{ value: "en", labelKey: "lang.english" },
{ value: "ar", labelKey: "lang.arabic" },
];

export function LanguagePreferenceMenuItems() {
const { t, i18n } = useTranslation();
const { lang, setLang } = useLangPreference();

const handleSelect = (next: LangPreference) => {
setLang(next);
i18n.changeLanguage(next);
};

return (
<>
<li className="menu-title pt-2">
<span>{t("lang.switcher")}</span>
</li>

<li>
<div
role="radiogroup"
aria-label={t("lang.switcher")}
className="flex gap-0.5 rounded-lg bg-base-200 p-0.5"
>
{LANG_OPTIONS.map((option) => {
const isActive = option.value === lang;

return (
<div
key={option.value}
className="tooltip tooltip-bottom flex flex-1 before:whitespace-nowrap"
data-tip={t(option.labelKey)}
>
<button
type="button"
role="radio"
aria-checked={isActive}
aria-label={t(option.labelKey)}
className={`flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs transition-colors ${
isActive
? "bg-base-100 text-base-content shadow-sm"
: "text-base-content/50 hover:text-base-content/80"
}`}
onClick={() => handleSelect(option.value)}
>
<Languages className="size-3.5" />
{option.value.toUpperCase()}
</button>
</div>
);
})}
</div>
</li>
</>
);
}
15 changes: 12 additions & 3 deletions src/client/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import type { LinkOptions } from "@tanstack/react-router";
import { useEffect, useState, type ComponentType } from "react";
import { useTranslation } from "react-i18next";
import {
CircleHelp,
CreditCard,
Expand All @@ -18,6 +19,7 @@ import {
import { ProjectSwitcher } from "@/client/features/projects/ProjectSwitcher";
import { SamSidebarPanel } from "@/client/features/sam/SamSidebarPanel";
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
import { LanguagePreferenceMenuItems } from "@/client/components/LanguagePreferenceMenuItems";
import { closeDropdown } from "@/client/lib/dropdown";
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
Expand Down Expand Up @@ -47,36 +49,41 @@ const navItemActiveProps = {
function SidebarNavLink({
icon: Icon,
label,
helpKey,
onNavigate,
linkProps,
}: {
icon: ComponentType<{ className?: string }>;
label: string;
helpKey?: string;
onNavigate?: () => void;
linkProps: LinkOptions;
}) {
const { t } = useTranslation();
return (
<Link
onClick={onNavigate}
activeOptions={{ exact: false, includeSearch: false }}
{...linkProps}
className={navItemClass}
activeProps={navItemActiveProps}
title={helpKey ? t(helpKey) : undefined}
>
{({ isActive }: { isActive: boolean }) => (
<>
{isActive ? (
<div className="absolute left-0 top-1 bottom-1 w-[3px] rounded-r-full bg-primary" />
) : null}
<Icon className="h-4 w-4 shrink-0" />
<span className="truncate">{label}</span>
<span className="truncate">{t(label)}</span>
</>
)}
</Link>
);
}

export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) {
const { t } = useTranslation();
const navGroups = [
...(projectId ? getProjectNavGroups(projectId) : []),
connectNavGroup,
Expand Down Expand Up @@ -175,15 +182,16 @@ export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) {
{navGroups.map((group) => (
<div key={group.label} className="mb-1">
<div className="px-3 pb-1 pt-3 text-xs font-semibold uppercase tracking-wider text-base-content/40">
{group.label}
{t(group.label)}
</div>
{group.items.map((item) => {
const { icon, label, ...linkProps } = item;
const { icon, label, helpKey, ...linkProps } = item;
return (
<SidebarNavLink
key={linkProps.to}
icon={icon}
label={label}
helpKey={helpKey}
onNavigate={onNavigate}
linkProps={linkProps}
/>
Expand Down Expand Up @@ -275,6 +283,7 @@ function SidebarFooter({ onNavigate }: { onNavigate?: () => void }) {
</li>
) : null}
<ThemePreferenceMenuItems />
<LanguagePreferenceMenuItems />
{isHostedMode ? (
<>
<li
Expand Down
Loading