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
3,030 changes: 1,530 additions & 1,500 deletions client/messages/cs.json

Large diffs are not rendered by default.

3,030 changes: 1,530 additions & 1,500 deletions client/messages/de.json

Large diffs are not rendered by default.

3,030 changes: 1,530 additions & 1,500 deletions client/messages/en.json

Large diffs are not rendered by default.

3,030 changes: 1,530 additions & 1,500 deletions client/messages/es.json

Large diffs are not rendered by default.

3,030 changes: 1,530 additions & 1,500 deletions client/messages/fr.json

Large diffs are not rendered by default.

3,030 changes: 1,530 additions & 1,500 deletions client/messages/it.json

Large diffs are not rendered by default.

3,030 changes: 1,530 additions & 1,500 deletions client/messages/ja.json

Large diffs are not rendered by default.

3,030 changes: 1,530 additions & 1,500 deletions client/messages/ko.json

Large diffs are not rendered by default.

3,030 changes: 1,530 additions & 1,500 deletions client/messages/pl.json

Large diffs are not rendered by default.

3,030 changes: 1,530 additions & 1,500 deletions client/messages/pt.json

Large diffs are not rendered by default.

3,030 changes: 1,530 additions & 1,500 deletions client/messages/uk.json

Large diffs are not rendered by default.

3,030 changes: 1,530 additions & 1,500 deletions client/messages/zh.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions client/src/api/admin/endpoints/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export {
} from "./sites";
export type { SiteResponse, GetSitesFromOrgResponse } from "./sites";

// Managed proxy (custom domain) endpoints
export { fetchProxyStatus, enableProxy, disableProxy } from "./proxy";
export type { ProxyStatusResponse, ProxyDnsRecord, ProxyStatus } from "./proxy";

// Organizations endpoints
export {
getUserOrganizations,
Expand Down
46 changes: 46 additions & 0 deletions client/src/api/admin/endpoints/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { authedFetch } from "../../utils";

export type ProxyStatus = "pending" | "active" | "failed";

export interface ProxyDnsRecord {
type: "CNAME" | "TXT";
name: string;
value: string;
purpose: "routing" | "ssl-validation" | "ownership";
}

export interface ProxyStatusResponse {
success?: boolean;
// Whether managed proxy (Cloudflare for SaaS) is configured on this server.
configured: boolean;
enabled: boolean;
domain: string | null;
status?: ProxyStatus | null;
active?: boolean;
cnameTarget?: string;
dnsRecords?: ProxyDnsRecord[];
sslStatus?: string;
validationErrors?: string[];
scriptSnippet?: string;
error?: string;
}

export function fetchProxyStatus(siteId: number) {
return authedFetch<ProxyStatusResponse>(`/sites/${siteId}/proxy`);
}

export function enableProxy(siteId: number, domain: string) {
return authedFetch<ProxyStatusResponse>(`/sites/${siteId}/proxy`, undefined, {
method: "POST",
data: { domain },
headers: {
"Content-Type": "application/json",
},
});
}

export function disableProxy(siteId: number) {
return authedFetch<{ success: boolean; enabled: false }>(`/sites/${siteId}/proxy`, undefined, {
method: "DELETE",
});
}
32 changes: 32 additions & 0 deletions client/src/api/admin/hooks/useProxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { disableProxy, enableProxy, fetchProxyStatus, ProxyStatusResponse } from "../endpoints";

export function useProxyStatus(siteId?: number) {
return useQuery<ProxyStatusResponse>({
queryKey: ["proxy-status", siteId],
queryFn: () => fetchProxyStatus(siteId!),
enabled: !!siteId,
// Poll while Cloudflare is still validating so the badge flips to "active" on its own.
refetchInterval: query => (query.state.data?.status === "pending" ? 5000 : false),
});
}

export function useEnableProxy(siteId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (domain: string) => enableProxy(siteId, domain),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["proxy-status", siteId] });
},
});
}

export function useDisableProxy(siteId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => disableProxy(siteId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["proxy-status", siteId] });
},
});
}
299 changes: 299 additions & 0 deletions client/src/components/SiteSettings/CustomDomainTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
"use client";

import { Check, Copy, Globe, Loader2, RefreshCw, ShieldAlert } from "lucide-react";
import { useExtracted } from "next-intl";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does next-intl v4.8.3 export a hook named useExtracted, and what is the recommended hook for component translations?

💡 Result:

Yes, next-intl v4.8.3 exports an experimental hook named useExtracted [1][2][3]. The recommended hook for component translations is useTranslations [4][5]. Additional context: The useExtracted hook is an experimental feature designed to automate message extraction from source files [1][2]. It integrates with the Next.js build process (via Turbopack or Webpack) to compile calls into useTranslations during development and build time [1][2]. Because it is experimental, it must be explicitly enabled in your next.config.ts file [1]. The standard and primary hook for consuming translations within React components remains useTranslations, which is the established API for most use cases [4][5][6].

Citations:


Replace useExtracted with useTranslations.

The project guidelines require using useTranslations() for user-facing strings. While useExtracted is an available experimental hook in next-intl, it is intended for build-time message extraction and is not the standard API for component translations.

import { useTranslations } from "next-intl";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/components/SiteSettings/CustomDomainTab.tsx` at line 4, The
CustomDomainTab component is importing the wrong next-intl hook for runtime
user-facing text. Replace useExtracted with useTranslations in the
CustomDomainTab.tsx import and update the component to use the standard
useTranslations() hook for all translated strings, keeping the existing
translation keys and usage consistent.

Source: Coding guidelines

import { useCallback, useState } from "react";

import { CodeSnippet } from "@/components/CodeSnippet";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "@/components/ui/sonner";

import { ProxyStatus } from "@/api/admin/endpoints";
import { useDisableProxy, useEnableProxy, useProxyStatus } from "@/api/admin/hooks/useProxy";

// Mirror of the server-side discouraged labels — ad blockers target these subdomains, so we
// nudge customers toward a neutral name without hard-blocking.
const DISCOURAGED_LABELS = ["analytics", "tracking", "track", "metrics", "stats", "rybbit"];

function CopyField({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = useState(false);
const copy = useCallback(async () => {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [value]);

return (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{label}</p>
<div className="flex items-center gap-2">
<code className="flex-1 truncate rounded-md border border-neutral-150 bg-neutral-50 px-2 py-1.5 font-mono text-xs dark:border-neutral-800 dark:bg-neutral-900">
{value}
</code>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={copy}>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</Button>
</div>
</div>
);
}

function StatusBadge({ status }: { status?: ProxyStatus | null }) {
const t = useExtracted();
if (status === "active") return <Badge variant="success">{t("Active")}</Badge>;
if (status === "failed") return <Badge variant="destructive">{t("Failed")}</Badge>;
return <Badge variant="warning">{t("Pending verification")}</Badge>;
}

export function CustomDomainTab({ siteId, disabled = false }: { siteId: number; disabled?: boolean }) {
const t = useExtracted();
const { data, isLoading, refetch, isRefetching } = useProxyStatus(siteId);
const enableMutation = useEnableProxy(siteId);
const disableMutation = useDisableProxy(siteId);
const [domain, setDomain] = useState("");

const handleEnable = async () => {
const normalized = domain.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/+$/, "");
if (!normalized.includes(".")) {
toast.error(t("Enter a full subdomain you control, e.g. a.yourdomain.com"));
return;
}
try {
await enableMutation.mutateAsync(normalized);
toast.success(t("Custom domain added. Add the DNS records below to finish setup."));
setDomain("");
} catch (error) {
toast.error(error instanceof Error ? error.message : t("Failed to add custom domain"));
}
};

const handleDisable = async () => {
try {
await disableMutation.mutateAsync();
toast.success(t("Custom domain removed"));
} catch (error) {
toast.error(error instanceof Error ? error.message : t("Failed to remove custom domain"));
}
};

if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-10 w-2/3" />
</div>
);
}

// Feature isn't enabled on this server (no Cloudflare credentials configured).
if (!data?.configured) {
return (
Comment on lines +103 to +105

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don’t treat missing configured as “not configured.”

if (!data?.configured) will show the “not configured” state when configured is omitted/undefined, even for valid proxy responses. Use an explicit check (data?.configured === false) or align the API contract to always send configured.

Suggested fix
-  if (!data?.configured) {
+  if (data?.configured === false) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Feature isn't enabled on this server (no Cloudflare credentials configured).
if (!data?.configured) {
return (
// Feature isn't enabled on this server (no Cloudflare credentials configured).
if (data?.configured === false) {
return (
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/components/SiteSettings/CustomDomainTab.tsx` around lines 103 -
105, The CustomDomainTab state check is too broad and treats an omitted
configured field as false. Update the conditional in the CustomDomainTab
component to only enter the “not configured” branch when configured is
explicitly false, or otherwise make sure the API always returns configured for
valid responses. Use the existing data?.configured check in the component to
locate and tighten this logic.

<div className="space-y-3">
<div>
<h4 className="text-sm font-semibold text-foreground flex items-center gap-2">
<Globe className="h-4 w-4" />
{t("Custom Domain")}
</h4>
<p className="text-xs text-muted-foreground mt-1">
{t(
"Managed custom domains aren't enabled on this server. A self-hosted instance needs Cloudflare for SaaS credentials configured to offer this."
)}
</p>
</div>
</div>
);
}

const firstLabel = domain.trim().toLowerCase().split(".")[0];
const discouraged = firstLabel.length > 0 && DISCOURAGED_LABELS.includes(firstLabel);

// Setup form — no custom domain configured yet.
if (!data.enabled) {
return (
<div className="space-y-6">
<div className="space-y-3">
<div>
<h4 className="text-sm font-semibold text-foreground flex items-center gap-2">
<Globe className="h-4 w-4" />
{t("Custom Domain")}
</h4>
<p className="text-xs text-muted-foreground mt-1">
{t(
"Serve the tracking script from a subdomain of your own site. This makes analytics first-party, improving data quality and resilience to ad blockers — and we handle TLS for you."
)}
</p>
</div>

<div className="flex space-x-2">
<Input
value={domain}
onChange={e => setDomain(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter" && !disabled && !enableMutation.isPending) handleEnable();
}}
placeholder="a.yourdomain.com"
disabled={disabled || enableMutation.isPending}
/>
<Button
variant="outline"
onClick={handleEnable}
disabled={disabled || enableMutation.isPending || domain.trim().length === 0}
>
{enableMutation.isPending ? t("Adding...") : t("Add Domain")}
</Button>
</div>

{discouraged && (
<p className="flex items-start gap-1.5 text-xs text-yellow-600 dark:text-yellow-400">
<ShieldAlert className="h-3.5 w-3.5 mt-0.5 shrink-0" />
{t(
'Subdomains like "{label}" are commonly blocked by ad blockers. Consider a neutral name to maximize data quality.',
{ label: firstLabel }
)}
</p>
)}

{data.cnameTarget && (
<p className="text-xs text-muted-foreground">
{t("You'll point this subdomain at {target} with a CNAME record after adding it.", {
target: data.cnameTarget,
})}
</p>
)}
</div>
</div>
);
}

// A custom domain is configured — show its status, DNS records, and the install snippet.
return (
<div className="space-y-6">
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h4 className="text-sm font-semibold text-foreground flex items-center gap-2">
<Globe className="h-4 w-4 shrink-0" />
<span className="truncate">{data.domain}</span>
</h4>
<p className="text-xs text-muted-foreground mt-1">{t("Your custom tracking domain")}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<StatusBadge status={data.status} />
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => refetch()}
disabled={isRefetching}
aria-label={t("Check status")}
>
<RefreshCw className={`size-3.5 ${isRefetching ? "animate-spin" : ""}`} />
</Button>
</div>
</div>

{data.status === "pending" && (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("Add the DNS records below. Verification runs automatically once DNS propagates (usually a few minutes).")}
</p>
)}

{data.status === "failed" && (
<div className="space-y-1">
<p className="text-xs text-red-600 dark:text-red-400">
{t("Verification failed. Double-check the DNS records below, then re-check.")}
</p>
{data.validationErrors?.map((err, i) => (
<p key={i} className="text-xs text-muted-foreground font-mono">
{err}
</p>
))}
</div>
)}
</div>

{data.dnsRecords && data.dnsRecords.length > 0 && (
<div className="space-y-3">
<div>
<h4 className="text-sm font-semibold text-foreground">{t("DNS Records")}</h4>
<p className="text-xs text-muted-foreground mt-1">
{t("Add these records at your DNS provider for {domain}.", { domain: data.domain ?? "" })}
</p>
</div>
{data.dnsRecords.map((rec, i) => (
<div key={i} className="rounded-md border border-neutral-200 p-3 dark:border-neutral-800 space-y-2">
<div className="flex items-center gap-2">
<Badge variant="secondary">{rec.type}</Badge>
{rec.purpose === "ssl-validation" && (
<span className="text-xs text-muted-foreground">{t("SSL validation")}</span>
)}
{rec.purpose === "ownership" && (
<span className="text-xs text-muted-foreground">{t("Ownership verification")}</span>
)}
</div>
<CopyField label={t("Name / Host")} value={rec.name} />
<CopyField label={t("Value / Target")} value={rec.value} />
</div>
))}
</div>
)}

{data.active && data.scriptSnippet && (
<div className="space-y-3">
<div>
<h4 className="text-sm font-semibold text-foreground">{t("Update your tracking script")}</h4>
<p className="text-xs text-muted-foreground mt-1">
{t("Replace your existing snippet with this one to serve analytics from your own domain.")}
</p>
</div>
<CodeSnippet language="HTML" code={data.scriptSnippet} />
</div>
)}

<div className="space-y-3 pt-3">
<h4 className="text-sm font-semibold text-destructive">{t("Danger Zone")}</h4>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" disabled={disabled || disableMutation.isPending}>
{disableMutation.isPending ? t("Removing...") : t("Remove custom domain")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("Remove this custom domain?")}</AlertDialogTitle>
<AlertDialogDescription>
{t(
"Analytics will stop being served from {domain} and its TLS certificate will be released. If you've installed the custom-domain snippet, switch it back to the default before removing.",
{ domain: data.domain ?? "" }
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("Cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDisable} disabled={disableMutation.isPending} variant="destructive">
{disableMutation.isPending ? t("Removing...") : t("Yes, remove it")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
);
}
Loading
Loading