From 190022ec0afd9810975400629f26cc6fdb85c560 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Tue, 15 Sep 2026 15:59:31 +0530 Subject: [PATCH 01/10] feat(pages): offer "Anyone with the link" visibility `everyone` has been in the visibility enum and honoured by the usercontent Worker since pages shipped, but nothing could select it: OFFERED_VISIBILITIES stopped at just_me/org and every read path collapsed the third value back to org. Opening it up needed more than the enum. visibilityFilter() matched only org and your own just_me, so a public page vanished from page.list and made pageNotFound() report a bogus cross-org FORBIDDEN. The desktop grid had the same shape of bug in matchesScope: an everyone page fell out of both the "team" and "mine" chips and was reachable only under "all". PageCard had already spent Globe on org, so the three states now read Lock / Building2 / Globe, matching the icons in the share popover. page.publicView is the one page read with no session behind it, for link unfurlers and readers who have a link and no account. It answers for everyone-pages and returns null for everything else, so a private page stays indistinguishable from one that never existed. The R2 head check keeps an og:image from pointing at a capture that never ran. The CLI kept its own copy of the visibility list; it now imports OFFERED_VISIBILITIES through the existing @superset/trpc/page-schema export so the two cannot drift again. --- .../usePageHeaderData/usePageHeaderData.ts | 5 ++- .../components/PageCard/PageCard.tsx | 9 ++++- .../utils/filterPages/filterPages.test.ts | 6 +++ .../pages/utils/filterPages/filterPages.ts | 2 +- .../cli/src/commands/pages/publish/command.ts | 3 +- packages/mcp/src/tools/pages/publish.ts | 2 +- packages/trpc/src/router/page/page.ts | 39 ++++++++++++++++++- packages/trpc/src/router/page/schema.ts | 4 +- .../PageSharePopover/PageSharePopover.tsx | 6 ++- .../components/PageHeader/types.ts | 2 +- 10 files changed, 66 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts index f0e7e49a846..a28557887d4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts @@ -3,6 +3,7 @@ import type { CommentThread, PageHeaderPage, PageHeaderVersion, + PageVisibility, } from "@superset/ui/page-comments"; import { useCallback } from "react"; import { authClient } from "renderer/lib/auth-client"; @@ -19,7 +20,7 @@ interface PageHeaderData { versions: PageHeaderVersion[]; threads: CommentThread[]; currentUserId: string | undefined; - onSetVisibility: (visibility: "just_me" | "org") => Promise; + onSetVisibility: (visibility: PageVisibility) => Promise; onSetSharedVersion: (version: number | null) => Promise; onDelete: () => Promise; } @@ -57,7 +58,7 @@ export function usePageHeaderData(data: PageHeaderTarget): PageHeaderData { id: pageId, title: data.title ?? resolved.title ?? data.slug, url: resolved.url, - visibility: resolved.visibility === "just_me" ? "just_me" : "org", + visibility: resolved.visibility, createdByUserId: resolved.createdByUserId, owner: access.data?.owner ?? null, updatedAt: resolved.updatedAt, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/PageCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/PageCard.tsx index 24e418ed34f..1e6ff42476a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/PageCard.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/PageCard.tsx @@ -11,6 +11,7 @@ import { DeletePageDialog } from "@superset/ui/page-comments"; import { toast } from "@superset/ui/sonner"; import { cn } from "@superset/ui/utils"; import { + Building2, Globe, Link2, Lock, @@ -58,11 +59,15 @@ export function PageCard({ const { t } = useLingui(); const [deleteOpen, setDeleteOpen] = useState(false); - const isShared = page.visibility === "org"; const isOwner = currentUserId !== undefined && currentUserId === page.createdByUserId; const ownerName = isOwner ? null : page.ownerName; - const VisibilityIcon = isShared ? Globe : Lock; + const VisibilityIcon = + page.visibility === "everyone" + ? Globe + : page.visibility === "org" + ? Building2 + : Lock; const edited = new Date(page.updatedAt).getTime(); const created = new Date(page.createdAt).getTime(); const wasEdited = edited - created > 60_000; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.test.ts index 7b3ec402793..361a083c00c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.test.ts @@ -26,6 +26,7 @@ const page = ( const team = page("1", "Q3 Metrics", "org", null, "user-a"); const mine = page("2", "Ingest Runbook", "just_me", "hookdeck notes", "user-b"); const other = page("3", "Warning Tokens", "org"); +const published = page("4", "Launch Notes", "everyone", null, "user-a"); describe("isPageScope", () => { it("accepts known scopes and rejects anything else", () => { @@ -60,6 +61,11 @@ describe("matchesScope", () => { expect(matchesScope(team, "mine", pinned)).toBe(false); }); + it("keeps a page shared with everyone in the team scope", () => { + expect(matchesScope(published, "team", pinned)).toBe(true); + expect(matchesScope(published, "mine", pinned)).toBe(false); + }); + it("uses the pinned set rather than visibility for the pinned scope", () => { expect(matchesScope(mine, "pinned", pinned)).toBe(true); expect(matchesScope(team, "pinned", pinned)).toBe(false); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.ts index 71e0300ffdf..ea73ff08672 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.ts @@ -32,7 +32,7 @@ export function matchesScope( case "pinned": return pinnedPageIds.has(page.id); case "team": - return page.visibility === "org"; + return page.visibility !== "just_me"; case "mine": return page.visibility === "just_me"; default: diff --git a/packages/cli/src/commands/pages/publish/command.ts b/packages/cli/src/commands/pages/publish/command.ts index 6591c9c44ed..6f7572156e2 100644 --- a/packages/cli/src/commands/pages/publish/command.ts +++ b/packages/cli/src/commands/pages/publish/command.ts @@ -1,6 +1,7 @@ import { readFileSync, statSync } from "node:fs"; import { basename, extname, resolve } from "node:path"; import { boolean, CLIError, positional, string } from "@superset/cli-framework"; +import { OFFERED_VISIBILITIES } from "@superset/trpc/page-schema"; import { command } from "../../../lib/command"; import { resolveWorkspaceId } from "../workspaceRef"; import { @@ -17,7 +18,7 @@ import { import { resolvePageId } from "./utils/resolvePageId"; import { uploadAssets, uploadDocument } from "./utils/upload"; -const VISIBILITIES = ["just_me", "org"] as const; +const VISIBILITIES = OFFERED_VISIBILITIES; export default command({ description: "Publish an HTML file, or a directory of files, as a page", diff --git a/packages/mcp/src/tools/pages/publish.ts b/packages/mcp/src/tools/pages/publish.ts index 06b8ad1e1f3..736fc52a176 100644 --- a/packages/mcp/src/tools/pages/publish.ts +++ b/packages/mcp/src/tools/pages/publish.ts @@ -42,7 +42,7 @@ export function register(server: McpServer): void { "What changed in this version, shown in the version history. Display-only.", ), visibility: optionalish(pageFields.visibility).describe( - "`org` (the default) lets anyone in the organization open it; `just_me` keeps it private to the publisher.", + "`org` (the default) lets anyone in the organization open it; `just_me` keeps it private to the publisher; `everyone` lets anyone with the link open it without signing in.", ), workspaceId: pageFields.workspaceId.describe( "The workspace this page belongs to. Required: a page that names no workspace is listed by nothing and cannot be versioned later. Get it from the `SUPERSET_WORKSPACE_ID` environment variable, or by running `superset workspaces list`.", diff --git a/packages/trpc/src/router/page/page.ts b/packages/trpc/src/router/page/page.ts index 0a085de3422..93f58c3d4e4 100644 --- a/packages/trpc/src/router/page/page.ts +++ b/packages/trpc/src/router/page/page.ts @@ -22,8 +22,8 @@ import { TRPCError, type TRPCRouterRecord } from "@trpc/server"; import { and, desc, eq, inArray, or, type SQL, sql } from "drizzle-orm"; import { z } from "zod"; import { env } from "../../env"; -import { deleteObjects, presignedGetUrl } from "../../lib/r2"; -import { protectedProcedure, userError } from "../../trpc"; +import { deleteObjects, objectExists, presignedGetUrl } from "../../lib/r2"; +import { protectedProcedure, publicProcedure, userError } from "../../trpc"; import { requireActiveOrgMembership } from "../utils/active-org"; import { assertPageReadable, assertPageWritable } from "./access"; import { pageAssetRouter } from "./assets"; @@ -37,6 +37,7 @@ import { listPagesSchema, pageFields, pageRefSchema, + publicPageSchema, publishPageSchema, pullPageSchema, setPageVisibilitySchema, @@ -56,6 +57,7 @@ import { assertWorkspaceAccess } from "./workspace-access"; function visibilityFilter(userId: string) { return or( eq(pages.visibility, "org"), + eq(pages.visibility, "everyone"), and(eq(pages.visibility, "just_me"), eq(pages.createdByUserId, userId)), ); } @@ -732,4 +734,37 @@ export const pageRouter = { viewUrl, }; }), + + publicView: publicProcedure + .input(publicPageSchema) + .query(async ({ input }) => { + const [page] = await db + .select() + .from(pages) + .where(eq(pages.slug, input.slug)) + .limit(1); + if (!page || page.visibility !== "everyone") return null; + + const version = servedVersion( + page.sharedVersion, + await latestVersionNumber(page.id), + ); + if (version === null) return null; + + const baseUrl = env.USERCONTENT_URL; + const captured = await objectExists(pageThumbnailKey(page.id, version)); + return { + id: page.id, + slug: page.slug, + title: page.title, + description: page.description, + url: pageUrl(page.slug), + updatedAt: page.updatedAt, + version, + viewUrl: pageViewUrl({ baseUrl, pageId: page.id, version }), + thumbnailUrl: captured + ? pageThumbnailUrl({ baseUrl, pageId: page.id, version }) + : null, + }; + }), } satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/page/schema.ts b/packages/trpc/src/router/page/schema.ts index bc0e5ace4fe..a48da0c0760 100644 --- a/packages/trpc/src/router/page/schema.ts +++ b/packages/trpc/src/router/page/schema.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -export const OFFERED_VISIBILITIES = ["just_me", "org"] as const; +export const OFFERED_VISIBILITIES = ["just_me", "org", "everyone"] as const; /** * Field-level schemas shared by this router's inputs and by the MCP tool @@ -152,3 +152,5 @@ export const setPageWatchSchema = z.object({ }); export const clearPageWatchSchema = z.object({ id: pageFields.id }); + +export const publicPageSchema = z.object({ slug: pageFields.slug }); diff --git a/packages/ui/src/components/PageComments/components/PageHeader/components/PageSharePopover/PageSharePopover.tsx b/packages/ui/src/components/PageComments/components/PageHeader/components/PageSharePopover/PageSharePopover.tsx index f6e7b6b391c..8088e6940c3 100644 --- a/packages/ui/src/components/PageComments/components/PageHeader/components/PageSharePopover/PageSharePopover.tsx +++ b/packages/ui/src/components/PageComments/components/PageHeader/components/PageSharePopover/PageSharePopover.tsx @@ -2,7 +2,7 @@ import { Trans, useLingui } from "@lingui/react/macro"; import { getInitials } from "@superset/shared/names"; -import { Building2, Check, Link2, Lock } from "lucide-react"; +import { Building2, Check, Globe, Link2, Lock } from "lucide-react"; import { type ReactNode, useCallback, useEffect, useState } from "react"; import { Avatar, AvatarFallback, AvatarImage } from "../../../../../ui/avatar"; import { Button } from "../../../../../ui/button"; @@ -203,6 +203,10 @@ export function PageSharePopover({ Anyone in your organization + + + Anyone with the link + diff --git a/packages/ui/src/components/PageComments/components/PageHeader/types.ts b/packages/ui/src/components/PageComments/components/PageHeader/types.ts index f0899770133..e155cd66299 100644 --- a/packages/ui/src/components/PageComments/components/PageHeader/types.ts +++ b/packages/ui/src/components/PageComments/components/PageHeader/types.ts @@ -1,4 +1,4 @@ -export type PageVisibility = "just_me" | "org"; +export type PageVisibility = "just_me" | "org" | "everyone"; export interface PageHeaderOwner { id: string; From 676d2081ea1e596f3239da27d767a5db06d1346b Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Tue, 15 Sep 2026 15:59:49 +0530 Subject: [PATCH 02/10] feat(web): show page thumbnails in link unfurls, and stop 307ing anonymous readers Closes SUPER-2189 and SUPER-1998, which turn out to be one bug seen from two sides. proxy.ts redirects any unauthenticated request for a non-public route to /sign-in, and /page was not on the list. A crawler asking for a shared page got a 307 and never reached generateMetadata, so no amount of og: tags would have unfurled. That redirect is also exactly what SUPER-1998 reports. Adding /page to otherPublicRoutes lets the route decide for itself who sees what. generateMetadata now reads page.publicView first, so what it emits is by construction what a crawler is allowed to see: title, description and the ticket-free thumbnail for an everyone-page, a generic Superset card for anything narrower. It falls back to the authenticated pull for the browser tab title only, which keeps titles on org pages for signed-in users while leaking nothing to link unfurlers. Pages whose capture never ran declare summary rather than a broken summary_large_image. Anonymous readers of an everyone-page get the page itself, read-only: no share popover, no version picker, no comments panel. Comments stay org-only. A signed-in user from another organization gets the same view instead of "This page belongs to Acme". Everyone else gets PageUnavailable, which deliberately reuses the existing not-found copy. Saying "this page is shared with a Superset organization" would confirm to a stranger holding a guessed slug that the page exists, which is the disclosure assertPageReadable avoids by throwing NOT_FOUND rather than FORBIDDEN for just_me pages. Only the button differs: Sign in when signed out, Go to Superset when signed in. It answers 200 so the generic card still unfurls. --- .../PageUnavailable/PageUnavailable.tsx | 34 ++++++++ .../components/PageUnavailable/index.ts | 1 + .../PublicPageView/PublicPageView.tsx | 40 ++++++++++ .../[slug]/components/PublicPageView/index.ts | 1 + apps/web/src/app/page/[slug]/page.tsx | 78 +++++++++++++++++-- apps/web/src/proxy-routes.test.ts | 3 + apps/web/src/proxy-routes.ts | 1 + packages/i18n/locales/cs/messages.po | 9 +++ packages/i18n/locales/de/messages.po | 9 +++ packages/i18n/locales/en/messages.po | 9 +++ packages/i18n/locales/es/messages.po | 9 +++ packages/i18n/locales/fr/messages.po | 9 +++ packages/i18n/locales/id/messages.po | 9 +++ packages/i18n/locales/it/messages.po | 9 +++ packages/i18n/locales/ja/messages.po | 9 +++ packages/i18n/locales/ko/messages.po | 9 +++ packages/i18n/locales/nl/messages.po | 9 +++ packages/i18n/locales/pl/messages.po | 9 +++ packages/i18n/locales/pt-BR/messages.po | 9 +++ packages/i18n/locales/ru/messages.po | 9 +++ packages/i18n/locales/tr/messages.po | 9 +++ packages/i18n/locales/vi/messages.po | 9 +++ packages/i18n/locales/zh-CN/messages.po | 9 +++ packages/i18n/locales/zh-TW/messages.po | 9 +++ .../ui/src/components/PageComments/index.ts | 1 + 25 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/app/page/[slug]/components/PageUnavailable/PageUnavailable.tsx create mode 100644 apps/web/src/app/page/[slug]/components/PageUnavailable/index.ts create mode 100644 apps/web/src/app/page/[slug]/components/PublicPageView/PublicPageView.tsx create mode 100644 apps/web/src/app/page/[slug]/components/PublicPageView/index.ts diff --git a/apps/web/src/app/page/[slug]/components/PageUnavailable/PageUnavailable.tsx b/apps/web/src/app/page/[slug]/components/PageUnavailable/PageUnavailable.tsx new file mode 100644 index 00000000000..15884a620ee --- /dev/null +++ b/apps/web/src/app/page/[slug]/components/PageUnavailable/PageUnavailable.tsx @@ -0,0 +1,34 @@ +import { msg } from "@lingui/core/macro"; +import { Button } from "@superset/ui/button"; +import { Pixel404 } from "@superset/ui/pixel-404"; +import Link from "next/link"; +import { MessageScreen } from "@/components/MessageScreen"; +import { initServerI18n } from "@/lib/i18n-server"; + +interface PageUnavailableProps { + slug: string; +} + +export async function PageUnavailable({ slug }: PageUnavailableProps) { + const i18n = await initServerI18n(); + + return ( + } + title={i18n._(msg({ message: "This page isn't here" }))} + description={i18n._( + msg({ + message: + "The link may be wrong, the page may have been deleted, or you may not have access to it.", + }), + )} + action={ + + } + /> + ); +} diff --git a/apps/web/src/app/page/[slug]/components/PageUnavailable/index.ts b/apps/web/src/app/page/[slug]/components/PageUnavailable/index.ts new file mode 100644 index 00000000000..bd0205d8b19 --- /dev/null +++ b/apps/web/src/app/page/[slug]/components/PageUnavailable/index.ts @@ -0,0 +1 @@ +export { PageUnavailable } from "./PageUnavailable"; diff --git a/apps/web/src/app/page/[slug]/components/PublicPageView/PublicPageView.tsx b/apps/web/src/app/page/[slug]/components/PublicPageView/PublicPageView.tsx new file mode 100644 index 00000000000..84df20e832e --- /dev/null +++ b/apps/web/src/app/page/[slug]/components/PublicPageView/PublicPageView.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Trans, useLingui } from "@lingui/react/macro"; +import { Button } from "@superset/ui/button"; +import { PageFrame } from "@superset/ui/page-comments"; +import { Globe } from "lucide-react"; +import Link from "next/link"; + +interface PublicPageViewProps { + title: string; + viewUrl: string; + slug: string; +} + +export function PublicPageView({ title, viewUrl, slug }: PublicPageViewProps) { + const { t } = useLingui(); + + return ( +
+
+ + {title} +
+ +
+
+ +
+ +
+
+ ); +} diff --git a/apps/web/src/app/page/[slug]/components/PublicPageView/index.ts b/apps/web/src/app/page/[slug]/components/PublicPageView/index.ts new file mode 100644 index 00000000000..0e426ef4368 --- /dev/null +++ b/apps/web/src/app/page/[slug]/components/PublicPageView/index.ts @@ -0,0 +1 @@ +export { PublicPageView } from "./PublicPageView"; diff --git a/apps/web/src/app/page/[slug]/page.tsx b/apps/web/src/app/page/[slug]/page.tsx index 800c4357842..3da8d311e79 100644 --- a/apps/web/src/app/page/[slug]/page.tsx +++ b/apps/web/src/app/page/[slug]/page.tsx @@ -13,6 +13,8 @@ import { initServerI18n } from "@/lib/i18n-server"; import { api } from "../../../trpc/server"; import { PageCommentsShell } from "./components/PageCommentsShell"; import { PageHeaderBar } from "./components/PageHeaderBar"; +import { PageUnavailable } from "./components/PageUnavailable"; +import { PublicPageView } from "./components/PublicPageView"; import { WrongOrganization } from "./components/WrongOrganization"; import { getPagesAccess } from "./utils/getPagesAccess"; import { isForbidden, isNotFound } from "./utils/trpcErrors"; @@ -38,18 +40,59 @@ const pullAccess = cache(async (slug: string) => { return trpc.page.access.query({ slug }); }); +const pullPublicPage = cache(async (slug: string) => { + const trpc = await api(); + try { + return await trpc.page.publicView.query({ slug }); + } catch { + return null; + } +}); + export async function generateMetadata({ params, }: PageProps): Promise { const { slug } = await params; + const i18n = await initServerI18n(); + + const shared = await pullPublicPage(slug); + if (shared) { + const description = shared.description ?? undefined; + const images = shared.thumbnailUrl + ? [{ url: shared.thumbnailUrl, width: 1280, height: 880 }] + : undefined; + return { + title: shared.title, + description, + openGraph: { + type: "website", + siteName: "Superset", + url: shared.url, + title: shared.title, + description, + images, + }, + twitter: { + card: images ? "summary_large_image" : "summary", + title: shared.title, + description, + images, + }, + }; + } + const { hasPagesAccess } = await getPagesAccess(); - if (!hasPagesAccess) return { title: "Page" }; - try { - const page = await pullPage(slug); - return { title: page.title, description: page.description ?? undefined }; - } catch { - return { title: "Page" }; + if (hasPagesAccess) { + const page = await pullPage(slug).catch(() => null); + if (page) { + return { title: page.title, description: page.description ?? undefined }; + } } + + return { + title: "Superset", + description: i18n._(msg({ message: "Sign in to view this page" })), + }; } export default async function PublishedPage({ params }: PageProps) { @@ -58,12 +101,31 @@ export default async function PublishedPage({ params }: PageProps) { const { slug } = await params; const { hasPagesAccess, session } = await getPagesAccess(); - if (!hasPagesAccess) notFound(); + + const publicView = async () => { + const shared = await pullPublicPage(slug); + return shared ? ( + + ) : null; + }; + + if (!hasPagesAccess) { + const view = await publicView(); + if (view) return view; + if (session) notFound(); + return ; + } let page: Awaited>; try { page = await pullPage(slug); } catch (error) { + const view = await publicView(); + if (view) return view; if (isNotFound(error)) notFound(); if (isForbidden(error) && error instanceof TRPCClientError) { return ; @@ -89,7 +151,7 @@ export default async function PublishedPage({ params }: PageProps) { id: page.id, title: page.title, url: page.url, - visibility: page.visibility === "just_me" ? "just_me" : "org", + visibility: page.visibility, createdByUserId: page.createdByUserId, owner: access.owner, updatedAt: page.updatedAt, diff --git a/apps/web/src/proxy-routes.test.ts b/apps/web/src/proxy-routes.test.ts index 4f4a6b6d7d4..0279d2d6ee2 100644 --- a/apps/web/src/proxy-routes.test.ts +++ b/apps/web/src/proxy-routes.test.ts @@ -21,6 +21,8 @@ describe("isPublicRoute", () => { "/cli/auth/code/success", "/tasks/my-slug", "/automations/automation-123", + "/page", + "/page/my-published-page", ])("allows the exact public route or its children: %s", (pathname: string) => { expect(isPublicRoute(pathname)).toBe(true); }); @@ -37,6 +39,7 @@ describe("isPublicRoute", () => { "/cli/auth/codegen", "/tasksy", "/automations-overview", + "/pages", ])("keeps sibling routes protected: %s", (pathname: string) => { expect(isPublicRoute(pathname)).toBe(false); }); diff --git a/apps/web/src/proxy-routes.ts b/apps/web/src/proxy-routes.ts index 18b6a112443..18318a2feb6 100644 --- a/apps/web/src/proxy-routes.ts +++ b/apps/web/src/proxy-routes.ts @@ -9,6 +9,7 @@ const otherPublicRoutes = [ // app, so a sign-in wall only strands the recipient of a shared link. "/tasks", "/automations", + "/page", ] as const; const publicRoutes = [...authPageRoutes, ...otherPublicRoutes] as const; diff --git a/packages/i18n/locales/cs/messages.po b/packages/i18n/locales/cs/messages.po index e1f91a8505a..f1d9c81b45c 100644 --- a/packages/i18n/locales/cs/messages.po +++ b/packages/i18n/locales/cs/messages.po @@ -2003,6 +2003,9 @@ msgstr "Kdokoli" msgid "Anyone in your organization" msgstr "Kdokoli ve vaší organizaci" +msgid "Anyone with the link" +msgstr "Kdokoli s odkazem" + msgid "anything" msgstr "cokoli" @@ -13267,6 +13270,9 @@ msgstr "Sdílené JSON soubory motivů, které si můžete naimportovat do Super msgid "Shared version" msgstr "Sdílená verze" +msgid "Shared with everyone" +msgstr "Sdíleno se všemi" + msgid "Shares the project's checkout — files, git index and branch — with its other local workspaces" msgstr "Sdílí checkout projektu — soubory, git index a větev — s ostatními lokálními pracovními prostory" @@ -13475,6 +13481,9 @@ msgstr "Přihlaste se a pokračujte do Superset" msgid "Sign in to get started" msgstr "Přihlaste se a můžete začít" +msgid "Sign in to view this page" +msgstr "Přihlaste se, abyste zobrazili tuto stránku" + msgid "Sign in with email" msgstr "Přihlásit se e-mailem" diff --git a/packages/i18n/locales/de/messages.po b/packages/i18n/locales/de/messages.po index 8c2834c0d61..3313f0177f2 100644 --- a/packages/i18n/locales/de/messages.po +++ b/packages/i18n/locales/de/messages.po @@ -2003,6 +2003,9 @@ msgstr "Beliebige Person" msgid "Anyone in your organization" msgstr "Alle in deiner Organisation" +msgid "Anyone with the link" +msgstr "Alle mit dem Link" + msgid "anything" msgstr "beliebig" @@ -13267,6 +13270,9 @@ msgstr "Geteilte Theme-JSON-Dateien, die du in Superset importieren kannst." msgid "Shared version" msgstr "Geteilte Version" +msgid "Shared with everyone" +msgstr "Mit allen geteilt" + msgid "Shares the project's checkout — files, git index and branch — with its other local workspaces" msgstr "Teilt den Checkout des Projekts – Dateien, Git-Index und Branch – mit seinen anderen lokalen Arbeitsbereichen" @@ -13475,6 +13481,9 @@ msgstr "Melde dich an, um zu Superset zu gelangen" msgid "Sign in to get started" msgstr "Melde dich an, um loszulegen" +msgid "Sign in to view this page" +msgstr "Melde dich an, um diese Seite zu sehen" + msgid "Sign in with email" msgstr "Mit E-Mail anmelden" diff --git a/packages/i18n/locales/en/messages.po b/packages/i18n/locales/en/messages.po index ceadbd11e7e..77016568283 100644 --- a/packages/i18n/locales/en/messages.po +++ b/packages/i18n/locales/en/messages.po @@ -2003,6 +2003,9 @@ msgstr "Anyone" msgid "Anyone in your organization" msgstr "Anyone in your organization" +msgid "Anyone with the link" +msgstr "Anyone with the link" + msgid "anything" msgstr "anything" @@ -13267,6 +13270,9 @@ msgstr "Shared theme JSON files you can import into Superset." msgid "Shared version" msgstr "Shared version" +msgid "Shared with everyone" +msgstr "Shared with everyone" + msgid "Shares the project's checkout — files, git index and branch — with its other local workspaces" msgstr "Shares the project's checkout — files, git index and branch — with its other local workspaces" @@ -13475,6 +13481,9 @@ msgstr "Sign in to continue to Superset" msgid "Sign in to get started" msgstr "Sign in to get started" +msgid "Sign in to view this page" +msgstr "Sign in to view this page" + msgid "Sign in with email" msgstr "Sign in with email" diff --git a/packages/i18n/locales/es/messages.po b/packages/i18n/locales/es/messages.po index 675a5379d57..56c35617bee 100644 --- a/packages/i18n/locales/es/messages.po +++ b/packages/i18n/locales/es/messages.po @@ -2003,6 +2003,9 @@ msgstr "Cualquiera" msgid "Anyone in your organization" msgstr "Cualquiera de tu organización" +msgid "Anyone with the link" +msgstr "Cualquier persona con el enlace" + msgid "anything" msgstr "cualquier cosa" @@ -13267,6 +13270,9 @@ msgstr "Archivos JSON de temas compartidos que puedes importar en Superset." msgid "Shared version" msgstr "Versión compartida" +msgid "Shared with everyone" +msgstr "Compartida con todo el mundo" + msgid "Shares the project's checkout — files, git index and branch — with its other local workspaces" msgstr "Comparte el checkout del proyecto (archivos, índice de git y rama) con sus otros espacios de trabajo locales" @@ -13475,6 +13481,9 @@ msgstr "Inicia sesión para continuar en Superset" msgid "Sign in to get started" msgstr "Iniciar sesión para empezar" +msgid "Sign in to view this page" +msgstr "Inicia sesión para ver esta página" + msgid "Sign in with email" msgstr "Iniciar sesión con correo electrónico" diff --git a/packages/i18n/locales/fr/messages.po b/packages/i18n/locales/fr/messages.po index 574de9af6dc..4bc154a9260 100644 --- a/packages/i18n/locales/fr/messages.po +++ b/packages/i18n/locales/fr/messages.po @@ -2003,6 +2003,9 @@ msgstr "N'importe qui" msgid "Anyone in your organization" msgstr "Toute personne de votre organisation" +msgid "Anyone with the link" +msgstr "Toute personne disposant du lien" + msgid "anything" msgstr "n'importe quoi" @@ -13267,6 +13270,9 @@ msgstr "Des fichiers JSON de thèmes partagés que vous pouvez importer dans Sup msgid "Shared version" msgstr "Version partagée" +msgid "Shared with everyone" +msgstr "Partagée avec tout le monde" + msgid "Shares the project's checkout — files, git index and branch — with its other local workspaces" msgstr "Partage le checkout du projet — fichiers, index git et branche — avec ses autres espaces de travail locaux" @@ -13475,6 +13481,9 @@ msgstr "Connectez-vous pour continuer vers Superset" msgid "Sign in to get started" msgstr "Connectez-vous pour commencer" +msgid "Sign in to view this page" +msgstr "Connectez-vous pour voir cette page" + msgid "Sign in with email" msgstr "Se connecter par e-mail" diff --git a/packages/i18n/locales/id/messages.po b/packages/i18n/locales/id/messages.po index 5a39cc59e97..3317d35813e 100644 --- a/packages/i18n/locales/id/messages.po +++ b/packages/i18n/locales/id/messages.po @@ -2003,6 +2003,9 @@ msgstr "Siapa pun" msgid "Anyone in your organization" msgstr "Siapa pun di organisasi Anda" +msgid "Anyone with the link" +msgstr "Siapa saja yang memiliki tautan" + msgid "anything" msgstr "apa saja" @@ -13267,6 +13270,9 @@ msgstr "File JSON tema bersama yang bisa Anda impor ke Superset." msgid "Shared version" msgstr "Versi yang dibagikan" +msgid "Shared with everyone" +msgstr "Dibagikan ke semua orang" + msgid "Shares the project's checkout — files, git index and branch — with its other local workspaces" msgstr "Berbagi checkout proyek — file, indeks git, dan branch — dengan workspace lokal lainnya" @@ -13475,6 +13481,9 @@ msgstr "Masuk untuk melanjutkan ke Superset" msgid "Sign in to get started" msgstr "Masuk untuk memulai" +msgid "Sign in to view this page" +msgstr "Masuk untuk melihat halaman ini" + msgid "Sign in with email" msgstr "Masuk dengan email" diff --git a/packages/i18n/locales/it/messages.po b/packages/i18n/locales/it/messages.po index 1576f6d798c..9b6bf799867 100644 --- a/packages/i18n/locales/it/messages.po +++ b/packages/i18n/locales/it/messages.po @@ -2003,6 +2003,9 @@ msgstr "Chiunque" msgid "Anyone in your organization" msgstr "Chiunque nella tua organizzazione" +msgid "Anyone with the link" +msgstr "Chiunque abbia il link" + msgid "anything" msgstr "qualsiasi cosa" @@ -13267,6 +13270,9 @@ msgstr "File JSON di temi condivisi che puoi importare in Superset." msgid "Shared version" msgstr "Versione condivisa" +msgid "Shared with everyone" +msgstr "Condivisa con tutti" + msgid "Shares the project's checkout — files, git index and branch — with its other local workspaces" msgstr "Condivide il checkout del progetto (file, indice git e branch) con gli altri workspace locali" @@ -13475,6 +13481,9 @@ msgstr "Accedi per continuare su Superset" msgid "Sign in to get started" msgstr "Accedi per iniziare" +msgid "Sign in to view this page" +msgstr "Accedi per vedere questa pagina" + msgid "Sign in with email" msgstr "Accedi con email" diff --git a/packages/i18n/locales/ja/messages.po b/packages/i18n/locales/ja/messages.po index 6052a5773b8..52519a0e533 100644 --- a/packages/i18n/locales/ja/messages.po +++ b/packages/i18n/locales/ja/messages.po @@ -2003,6 +2003,9 @@ msgstr "全員" msgid "Anyone in your organization" msgstr "組織内の全員" +msgid "Anyone with the link" +msgstr "リンクを知っている全員" + msgid "anything" msgstr "何でも" @@ -13267,6 +13270,9 @@ msgstr "Supersetにインポートできる、共有されたテーマJSONファ msgid "Shared version" msgstr "共有バージョン" +msgid "Shared with everyone" +msgstr "全員と共有中" + msgid "Shares the project's checkout — files, git index and branch — with its other local workspaces" msgstr "プロジェクトのチェックアウト(ファイル、git インデックス、ブランチ)を他のローカルワークスペースと共有します" @@ -13475,6 +13481,9 @@ msgstr "サインインしてSupersetに進む" msgid "Sign in to get started" msgstr "サインインして始めましょう" +msgid "Sign in to view this page" +msgstr "サインインしてこのページを表示" + msgid "Sign in with email" msgstr "メールでサインイン" diff --git a/packages/i18n/locales/ko/messages.po b/packages/i18n/locales/ko/messages.po index fa1797801aa..3ff6e7fbbab 100644 --- a/packages/i18n/locales/ko/messages.po +++ b/packages/i18n/locales/ko/messages.po @@ -2003,6 +2003,9 @@ msgstr "모든 사람" msgid "Anyone in your organization" msgstr "조직 내 모든 사람" +msgid "Anyone with the link" +msgstr "링크가 있는 모든 사용자" + msgid "anything" msgstr "모든 내용" @@ -13267,6 +13270,9 @@ msgstr "Superset으로 가져올 수 있는 공유 테마 JSON 파일입니다." msgid "Shared version" msgstr "공유 버전" +msgid "Shared with everyone" +msgstr "전체 공개로 공유됨" + msgid "Shares the project's checkout — files, git index and branch — with its other local workspaces" msgstr "프로젝트의 체크아웃(파일, git 인덱스, 브랜치)을 다른 로컬 워크스페이스와 공유합니다" @@ -13475,6 +13481,9 @@ msgstr "Superset을 계속 사용하려면 로그인하세요" msgid "Sign in to get started" msgstr "로그인하고 시작하세요" +msgid "Sign in to view this page" +msgstr "이 페이지를 보려면 로그인하세요" + msgid "Sign in with email" msgstr "이메일로 로그인" diff --git a/packages/i18n/locales/nl/messages.po b/packages/i18n/locales/nl/messages.po index 3f3d986363f..6ae2ea36caf 100644 --- a/packages/i18n/locales/nl/messages.po +++ b/packages/i18n/locales/nl/messages.po @@ -2003,6 +2003,9 @@ msgstr "Iedereen" msgid "Anyone in your organization" msgstr "Iedereen in je organisatie" +msgid "Anyone with the link" +msgstr "Iedereen met de link" + msgid "anything" msgstr "wat dan ook" @@ -13267,6 +13270,9 @@ msgstr "Gedeelde thema-JSON-bestanden die je in Superset kunt importeren." msgid "Shared version" msgstr "Gedeelde versie" +msgid "Shared with everyone" +msgstr "Gedeeld met iedereen" + msgid "Shares the project's checkout — files, git index and branch — with its other local workspaces" msgstr "Deelt de checkout van het project — bestanden, git-index en branch — met de andere lokale werkruimtes" @@ -13475,6 +13481,9 @@ msgstr "Log in om verder te gaan naar Superset" msgid "Sign in to get started" msgstr "Log in om te beginnen" +msgid "Sign in to view this page" +msgstr "Log in om deze pagina te bekijken" + msgid "Sign in with email" msgstr "Inloggen met e-mail" diff --git a/packages/i18n/locales/pl/messages.po b/packages/i18n/locales/pl/messages.po index 61598720b6a..e9a8796b6df 100644 --- a/packages/i18n/locales/pl/messages.po +++ b/packages/i18n/locales/pl/messages.po @@ -2003,6 +2003,9 @@ msgstr "Ktokolwiek" msgid "Anyone in your organization" msgstr "Wszyscy w Twojej organizacji" +msgid "Anyone with the link" +msgstr "Każdy, kto ma link" + msgid "anything" msgstr "cokolwiek" @@ -13267,6 +13270,9 @@ msgstr "Udostępnione pliki JSON motywów, które można zaimportować do Supers msgid "Shared version" msgstr "Udostępniona wersja" +msgid "Shared with everyone" +msgstr "Udostępniona wszystkim" + msgid "Shares the project's checkout — files, git index and branch — with its other local workspaces" msgstr "Współdzieli checkout projektu — pliki, indeks git i gałąź — z pozostałymi lokalnymi obszarami roboczymi" @@ -13475,6 +13481,9 @@ msgstr "Zaloguj się, aby przejść do Superset" msgid "Sign in to get started" msgstr "Zaloguj się, aby zacząć" +msgid "Sign in to view this page" +msgstr "Zaloguj się, aby zobaczyć tę stronę" + msgid "Sign in with email" msgstr "Zaloguj się e-mailem" diff --git a/packages/i18n/locales/pt-BR/messages.po b/packages/i18n/locales/pt-BR/messages.po index 92dde50e293..1e550bcd408 100644 --- a/packages/i18n/locales/pt-BR/messages.po +++ b/packages/i18n/locales/pt-BR/messages.po @@ -2003,6 +2003,9 @@ msgstr "Qualquer pessoa" msgid "Anyone in your organization" msgstr "Qualquer pessoa da sua organização" +msgid "Anyone with the link" +msgstr "Qualquer pessoa com o link" + msgid "anything" msgstr "qualquer coisa" @@ -13267,6 +13270,9 @@ msgstr "Arquivos JSON de tema compartilhados que você pode importar no Superset msgid "Shared version" msgstr "Versão compartilhada" +msgid "Shared with everyone" +msgstr "Compartilhada com todos" + msgid "Shares the project's checkout — files, git index and branch — with its other local workspaces" msgstr "Compartilha o checkout do projeto (arquivos, índice do git e branch) com suas outras áreas de trabalho locais" @@ -13475,6 +13481,9 @@ msgstr "Entre para continuar no Superset" msgid "Sign in to get started" msgstr "Entre para começar" +msgid "Sign in to view this page" +msgstr "Entre para ver esta página" + msgid "Sign in with email" msgstr "Entrar com e-mail" diff --git a/packages/i18n/locales/ru/messages.po b/packages/i18n/locales/ru/messages.po index ba2f792151e..15b6495ff2b 100644 --- a/packages/i18n/locales/ru/messages.po +++ b/packages/i18n/locales/ru/messages.po @@ -2003,6 +2003,9 @@ msgstr "Кто угодно" msgid "Anyone in your organization" msgstr "Любой в вашей организации" +msgid "Anyone with the link" +msgstr "Все, у кого есть ссылка" + msgid "anything" msgstr "что угодно" @@ -13267,6 +13270,9 @@ msgstr "Общие JSON-файлы тем, которые можно импор msgid "Shared version" msgstr "Общедоступная версия" +msgid "Shared with everyone" +msgstr "Доступна всем" + msgid "Shares the project's checkout — files, git index and branch — with its other local workspaces" msgstr "Использует checkout проекта — файлы, индекс git и ветку — совместно с другими локальными рабочими областями" @@ -13475,6 +13481,9 @@ msgstr "Войдите, чтобы продолжить в Superset" msgid "Sign in to get started" msgstr "Войдите, чтобы начать" +msgid "Sign in to view this page" +msgstr "Войдите, чтобы открыть эту страницу" + msgid "Sign in with email" msgstr "Войти по email" diff --git a/packages/i18n/locales/tr/messages.po b/packages/i18n/locales/tr/messages.po index ad5930e4735..1f4f28861f7 100644 --- a/packages/i18n/locales/tr/messages.po +++ b/packages/i18n/locales/tr/messages.po @@ -2003,6 +2003,9 @@ msgstr "Herkes" msgid "Anyone in your organization" msgstr "Kuruluşunuzdaki herkes" +msgid "Anyone with the link" +msgstr "Bağlantıya sahip herkes" + msgid "anything" msgstr "herhangi bir şey" @@ -13267,6 +13270,9 @@ msgstr "Superset'e içe aktarabileceğiniz paylaşılan tema JSON dosyaları." msgid "Shared version" msgstr "Paylaşılan sürüm" +msgid "Shared with everyone" +msgstr "Herkesle paylaşıldı" + msgid "Shares the project's checkout — files, git index and branch — with its other local workspaces" msgstr "Projenin checkout'unu (dosyalar, git indeksi ve dal) diğer yerel çalışma alanlarıyla paylaşır" @@ -13475,6 +13481,9 @@ msgstr "Superset'e devam etmek için oturum açın" msgid "Sign in to get started" msgstr "Başlamak için oturum açın" +msgid "Sign in to view this page" +msgstr "Bu sayfayı görmek için oturum açın" + msgid "Sign in with email" msgstr "E-posta ile oturum aç" diff --git a/packages/i18n/locales/vi/messages.po b/packages/i18n/locales/vi/messages.po index 4133fe898e4..7a417e46564 100644 --- a/packages/i18n/locales/vi/messages.po +++ b/packages/i18n/locales/vi/messages.po @@ -2003,6 +2003,9 @@ msgstr "Bất kỳ ai" msgid "Anyone in your organization" msgstr "Bất kỳ ai trong tổ chức của bạn" +msgid "Anyone with the link" +msgstr "Bất kỳ ai có liên kết" + msgid "anything" msgstr "bất kỳ điều gì" @@ -13267,6 +13270,9 @@ msgstr "Các tệp JSON chủ đề được chia sẻ, bạn có thể nhập v msgid "Shared version" msgstr "Phiên bản được chia sẻ" +msgid "Shared with everyone" +msgstr "Đã chia sẻ với mọi người" + msgid "Shares the project's checkout — files, git index and branch — with its other local workspaces" msgstr "Dùng chung checkout của dự án — tệp, chỉ mục git và nhánh — với các workspace cục bộ khác" @@ -13475,6 +13481,9 @@ msgstr "Đăng nhập để tiếp tục vào Superset" msgid "Sign in to get started" msgstr "Đăng nhập để bắt đầu" +msgid "Sign in to view this page" +msgstr "Đăng nhập để xem trang này" + msgid "Sign in with email" msgstr "Đăng nhập bằng email" diff --git a/packages/i18n/locales/zh-CN/messages.po b/packages/i18n/locales/zh-CN/messages.po index 278b49658b2..f0878c2531d 100644 --- a/packages/i18n/locales/zh-CN/messages.po +++ b/packages/i18n/locales/zh-CN/messages.po @@ -2003,6 +2003,9 @@ msgstr "任何人" msgid "Anyone in your organization" msgstr "你组织中的任何人" +msgid "Anyone with the link" +msgstr "知道链接的任何人" + msgid "anything" msgstr "任意内容" @@ -13267,6 +13270,9 @@ msgstr "可导入Superset的共享主题JSON文件。" msgid "Shared version" msgstr "分享的版本" +msgid "Shared with everyone" +msgstr "已与所有人共享" + msgid "Shares the project's checkout — files, git index and branch — with its other local workspaces" msgstr "与该项目的其他本地工作区共享项目的检出(文件、git 索引和分支)" @@ -13475,6 +13481,9 @@ msgstr "登录以继续使用Superset" msgid "Sign in to get started" msgstr "登录即可开始" +msgid "Sign in to view this page" +msgstr "登录以查看此页面" + msgid "Sign in with email" msgstr "使用邮箱登录" diff --git a/packages/i18n/locales/zh-TW/messages.po b/packages/i18n/locales/zh-TW/messages.po index fb99b01b0bf..530ca03fc69 100644 --- a/packages/i18n/locales/zh-TW/messages.po +++ b/packages/i18n/locales/zh-TW/messages.po @@ -2003,6 +2003,9 @@ msgstr "任何人" msgid "Anyone in your organization" msgstr "你組織中的任何人" +msgid "Anyone with the link" +msgstr "知道連結的任何人" + msgid "anything" msgstr "任意內容" @@ -13267,6 +13270,9 @@ msgstr "可匯入Superset的共享主題JSON檔案。" msgid "Shared version" msgstr "分享的版本" +msgid "Shared with everyone" +msgstr "已與所有人分享" + msgid "Shares the project's checkout — files, git index and branch — with its other local workspaces" msgstr "與該專案的其他本地工作區共用專案的簽出(檔案、git 索引和分支)" @@ -13475,6 +13481,9 @@ msgstr "登入以繼續使用Superset" msgid "Sign in to get started" msgstr "登入即可開始" +msgid "Sign in to view this page" +msgstr "登入以檢視此頁面" + msgid "Sign in with email" msgstr "使用電子郵件登入" diff --git a/packages/ui/src/components/PageComments/index.ts b/packages/ui/src/components/PageComments/index.ts index fcd0d7a232e..dba22a3684e 100644 --- a/packages/ui/src/components/PageComments/index.ts +++ b/packages/ui/src/components/PageComments/index.ts @@ -17,6 +17,7 @@ export { } from "./components/CommentModeToggle"; export { CommentsPanel } from "./components/CommentsPanel"; export { PageCommentsView } from "./components/PageCommentsView"; +export { PageFrame } from "./components/PageCommentsView/components/PageFrame"; export { DeletePageDialog, PageHeader, From ee24460c663e3c7906e95f23fe8f07e4fc3024b9 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Tue, 15 Sep 2026 16:21:18 +0530 Subject: [PATCH 03/10] feat(pages): tell the owner when a page is live publicly An everyone-page is reachable by anyone holding the link, and nothing in the viewer said so. The visibility icon in the header is a 14px glyph inside a menu nobody opens twice. The banner sits inside PageHeader rather than at the call sites, so both the web viewer and the desktop PageDetailView get it without changes, and "Share settings" just opens the popover PageHeader already owns. Owner-only: it is an authoring affordance, and an anonymous reader is the public it would be warning about. Neutral chrome rather than Notion's tinted bar, following the direction set in 254ee7089. --- packages/i18n/locales/cs/messages.po | 7 + packages/i18n/locales/de/messages.po | 7 + packages/i18n/locales/en/messages.po | 7 + packages/i18n/locales/es/messages.po | 7 + packages/i18n/locales/fr/messages.po | 7 + packages/i18n/locales/id/messages.po | 7 + packages/i18n/locales/it/messages.po | 7 + packages/i18n/locales/ja/messages.po | 7 + packages/i18n/locales/ko/messages.po | 7 + packages/i18n/locales/nl/messages.po | 7 + packages/i18n/locales/pl/messages.po | 7 + packages/i18n/locales/pt-BR/messages.po | 7 + packages/i18n/locales/ru/messages.po | 7 + packages/i18n/locales/tr/messages.po | 7 + packages/i18n/locales/vi/messages.po | 7 + packages/i18n/locales/zh-CN/messages.po | 7 + packages/i18n/locales/zh-TW/messages.po | 7 + .../components/PageHeader/PageHeader.tsx | 126 ++++++++++-------- .../PagePublicBanner/PagePublicBanner.tsx | 59 ++++++++ .../components/PagePublicBanner/index.ts | 1 + 20 files changed, 247 insertions(+), 58 deletions(-) create mode 100644 packages/ui/src/components/PageComments/components/PageHeader/components/PagePublicBanner/PagePublicBanner.tsx create mode 100644 packages/ui/src/components/PageComments/components/PageHeader/components/PagePublicBanner/index.ts diff --git a/packages/i18n/locales/cs/messages.po b/packages/i18n/locales/cs/messages.po index f1d9c81b45c..857e9c51725 100644 --- a/packages/i18n/locales/cs/messages.po +++ b/packages/i18n/locales/cs/messages.po @@ -13249,6 +13249,9 @@ msgstr "Sdílejte návrhy a zprávy. Zpětnou vazbu nechte na svém agentovi." msgid "Share page" msgstr "Sdílet stránku" +msgid "Share settings" +msgstr "Nastavení sdílení" + msgid "Share this page" msgstr "Sdílet tuto stránku" @@ -15169,6 +15172,10 @@ msgstr "Tato stránka se právě zveřejňuje odjinud — zkuste to znovu" msgid "This page is in another organization" msgstr "Tato stránka patří do jiné organizace" +#. placeholder {0}: url.replace(/^https?:\/\//, "") +msgid "This page is live at <0>{0}" +msgstr "Tato stránka je živá na <0>{0}" + msgid "This page isn't here" msgstr "Tahle stránka tu není" diff --git a/packages/i18n/locales/de/messages.po b/packages/i18n/locales/de/messages.po index 3313f0177f2..788cb01c5f2 100644 --- a/packages/i18n/locales/de/messages.po +++ b/packages/i18n/locales/de/messages.po @@ -13249,6 +13249,9 @@ msgstr "Teile Entwürfe und Berichte. Lass deinen Agenten das Feedback bearbeite msgid "Share page" msgstr "Seite teilen" +msgid "Share settings" +msgstr "Freigabeeinstellungen" + msgid "Share this page" msgstr "Diese Seite teilen" @@ -15169,6 +15172,10 @@ msgstr "Diese Seite wird gerade von anderer Stelle veröffentlicht — erneut ve msgid "This page is in another organization" msgstr "Diese Seite gehört zu einer anderen Organisation" +#. placeholder {0}: url.replace(/^https?:\/\//, "") +msgid "This page is live at <0>{0}" +msgstr "Diese Seite ist live unter <0>{0}" + msgid "This page isn't here" msgstr "Diese Seite gibt es hier nicht" diff --git a/packages/i18n/locales/en/messages.po b/packages/i18n/locales/en/messages.po index 77016568283..d5f8f0b0abe 100644 --- a/packages/i18n/locales/en/messages.po +++ b/packages/i18n/locales/en/messages.po @@ -13249,6 +13249,9 @@ msgstr "Share designs and reports. Let your agent handle the feedback." msgid "Share page" msgstr "Share page" +msgid "Share settings" +msgstr "Share settings" + msgid "Share this page" msgstr "Share this page" @@ -15169,6 +15172,10 @@ msgstr "This page is being published from somewhere else — retry" msgid "This page is in another organization" msgstr "This page is in another organization" +#. placeholder {0}: url.replace(/^https?:\/\//, "") +msgid "This page is live at <0>{0}" +msgstr "This page is live at <0>{0}" + msgid "This page isn't here" msgstr "This page isn't here" diff --git a/packages/i18n/locales/es/messages.po b/packages/i18n/locales/es/messages.po index 56c35617bee..9321d66deb3 100644 --- a/packages/i18n/locales/es/messages.po +++ b/packages/i18n/locales/es/messages.po @@ -13249,6 +13249,9 @@ msgstr "Comparte diseños e informes. Deja que tu agente se encargue de los come msgid "Share page" msgstr "Compartir la página" +msgid "Share settings" +msgstr "Ajustes de uso compartido" + msgid "Share this page" msgstr "Compartir esta página" @@ -15169,6 +15172,10 @@ msgstr "Esta página se está publicando desde otro sitio — reinténtalo" msgid "This page is in another organization" msgstr "Esta página está en otra organización" +#. placeholder {0}: url.replace(/^https?:\/\//, "") +msgid "This page is live at <0>{0}" +msgstr "Esta página está publicada en <0>{0}" + msgid "This page isn't here" msgstr "Esta página no está aquí" diff --git a/packages/i18n/locales/fr/messages.po b/packages/i18n/locales/fr/messages.po index 4bc154a9260..127b0102444 100644 --- a/packages/i18n/locales/fr/messages.po +++ b/packages/i18n/locales/fr/messages.po @@ -13249,6 +13249,9 @@ msgstr "Partagez des maquettes et des rapports. Laissez votre agent traiter les msgid "Share page" msgstr "Partager la page" +msgid "Share settings" +msgstr "Paramètres de partage" + msgid "Share this page" msgstr "Partager cette page" @@ -15169,6 +15172,10 @@ msgstr "Cette page est en cours de publication ailleurs — réessayez" msgid "This page is in another organization" msgstr "Cette page appartient à une autre organisation" +#. placeholder {0}: url.replace(/^https?:\/\//, "") +msgid "This page is live at <0>{0}" +msgstr "Cette page est en ligne sur <0>{0}" + msgid "This page isn't here" msgstr "Cette page n'est pas ici" diff --git a/packages/i18n/locales/id/messages.po b/packages/i18n/locales/id/messages.po index 3317d35813e..8600be90315 100644 --- a/packages/i18n/locales/id/messages.po +++ b/packages/i18n/locales/id/messages.po @@ -13249,6 +13249,9 @@ msgstr "Bagikan desain dan laporan. Biarkan agen Anda menangani masukan." msgid "Share page" msgstr "Bagikan halaman" +msgid "Share settings" +msgstr "Pengaturan berbagi" + msgid "Share this page" msgstr "Bagikan halaman ini" @@ -15169,6 +15172,10 @@ msgstr "Halaman ini sedang dipublikasikan dari tempat lain — coba lagi" msgid "This page is in another organization" msgstr "Halaman ini ada di organisasi lain" +#. placeholder {0}: url.replace(/^https?:\/\//, "") +msgid "This page is live at <0>{0}" +msgstr "Halaman ini aktif di <0>{0}" + msgid "This page isn't here" msgstr "Halaman ini tidak ada di sini" diff --git a/packages/i18n/locales/it/messages.po b/packages/i18n/locales/it/messages.po index 9b6bf799867..dfc5a120a10 100644 --- a/packages/i18n/locales/it/messages.po +++ b/packages/i18n/locales/it/messages.po @@ -13249,6 +13249,9 @@ msgstr "Condividi design e report. Lascia che il tuo agente gestisca il feedback msgid "Share page" msgstr "Condividi la pagina" +msgid "Share settings" +msgstr "Impostazioni di condivisione" + msgid "Share this page" msgstr "Condividi questa pagina" @@ -15169,6 +15172,10 @@ msgstr "Questa pagina è in fase di pubblicazione da un'altra parte — riprova" msgid "This page is in another organization" msgstr "Questa pagina è in un'altra organizzazione" +#. placeholder {0}: url.replace(/^https?:\/\//, "") +msgid "This page is live at <0>{0}" +msgstr "Questa pagina è online su <0>{0}" + msgid "This page isn't here" msgstr "Questa pagina non c'è" diff --git a/packages/i18n/locales/ja/messages.po b/packages/i18n/locales/ja/messages.po index 52519a0e533..1c4bd70ddf5 100644 --- a/packages/i18n/locales/ja/messages.po +++ b/packages/i18n/locales/ja/messages.po @@ -13249,6 +13249,9 @@ msgstr "デザインやレポートを共有しましょう。フィードバッ msgid "Share page" msgstr "ページを共有" +msgid "Share settings" +msgstr "共有設定" + msgid "Share this page" msgstr "このページを共有" @@ -15169,6 +15172,10 @@ msgstr "このページは別の場所から公開処理中です — 再試行 msgid "This page is in another organization" msgstr "このページは別の組織にあります" +#. placeholder {0}: url.replace(/^https?:\/\//, "") +msgid "This page is live at <0>{0}" +msgstr "このページは <0>{0} で公開中です" + msgid "This page isn't here" msgstr "このページはありません" diff --git a/packages/i18n/locales/ko/messages.po b/packages/i18n/locales/ko/messages.po index 3ff6e7fbbab..bb6750a5484 100644 --- a/packages/i18n/locales/ko/messages.po +++ b/packages/i18n/locales/ko/messages.po @@ -13249,6 +13249,9 @@ msgstr "디자인과 보고서를 공유하세요. 피드백 처리는 에이전 msgid "Share page" msgstr "페이지 공유" +msgid "Share settings" +msgstr "공유 설정" + msgid "Share this page" msgstr "이 페이지 공유" @@ -15169,6 +15172,10 @@ msgstr "다른 곳에서 이 페이지를 게시하는 중입니다 — 다시 msgid "This page is in another organization" msgstr "이 페이지는 다른 조직에 있습니다" +#. placeholder {0}: url.replace(/^https?:\/\//, "") +msgid "This page is live at <0>{0}" +msgstr "이 페이지는 <0>{0}에서 공개 중입니다" + msgid "This page isn't here" msgstr "이 페이지는 여기에 없습니다" diff --git a/packages/i18n/locales/nl/messages.po b/packages/i18n/locales/nl/messages.po index 6ae2ea36caf..a371864cf3d 100644 --- a/packages/i18n/locales/nl/messages.po +++ b/packages/i18n/locales/nl/messages.po @@ -13249,6 +13249,9 @@ msgstr "Deel ontwerpen en rapporten. Laat je agent de feedback verwerken." msgid "Share page" msgstr "Pagina delen" +msgid "Share settings" +msgstr "Deelinstellingen" + msgid "Share this page" msgstr "Deze pagina delen" @@ -15169,6 +15172,10 @@ msgstr "Deze pagina wordt ergens anders gepubliceerd — probeer opnieuw" msgid "This page is in another organization" msgstr "Deze pagina hoort bij een andere organisatie" +#. placeholder {0}: url.replace(/^https?:\/\//, "") +msgid "This page is live at <0>{0}" +msgstr "Deze pagina staat live op <0>{0}" + msgid "This page isn't here" msgstr "Deze pagina staat hier niet" diff --git a/packages/i18n/locales/pl/messages.po b/packages/i18n/locales/pl/messages.po index e9a8796b6df..8a5b7508956 100644 --- a/packages/i18n/locales/pl/messages.po +++ b/packages/i18n/locales/pl/messages.po @@ -13249,6 +13249,9 @@ msgstr "Udostępniaj projekty i raporty. Pozwól agentowi zająć się opiniami. msgid "Share page" msgstr "Udostępnij stronę" +msgid "Share settings" +msgstr "Ustawienia udostępniania" + msgid "Share this page" msgstr "Udostępnij tę stronę" @@ -15169,6 +15172,10 @@ msgstr "Ta strona jest właśnie publikowana z innego miejsca — ponów" msgid "This page is in another organization" msgstr "Ta strona należy do innej organizacji" +#. placeholder {0}: url.replace(/^https?:\/\//, "") +msgid "This page is live at <0>{0}" +msgstr "Ta strona jest dostępna pod adresem <0>{0}" + msgid "This page isn't here" msgstr "Nie ma tu tej strony" diff --git a/packages/i18n/locales/pt-BR/messages.po b/packages/i18n/locales/pt-BR/messages.po index 1e550bcd408..e776d97b92a 100644 --- a/packages/i18n/locales/pt-BR/messages.po +++ b/packages/i18n/locales/pt-BR/messages.po @@ -13249,6 +13249,9 @@ msgstr "Compartilhe designs e relatórios. Deixe seu agente cuidar do feedback." msgid "Share page" msgstr "Compartilhar página" +msgid "Share settings" +msgstr "Configurações de compartilhamento" + msgid "Share this page" msgstr "Compartilhar esta página" @@ -15169,6 +15172,10 @@ msgstr "Esta página está sendo publicada de outro lugar — tente de novo" msgid "This page is in another organization" msgstr "Esta página está em outra organização" +#. placeholder {0}: url.replace(/^https?:\/\//, "") +msgid "This page is live at <0>{0}" +msgstr "Esta página está no ar em <0>{0}" + msgid "This page isn't here" msgstr "Esta página não está aqui" diff --git a/packages/i18n/locales/ru/messages.po b/packages/i18n/locales/ru/messages.po index 15b6495ff2b..fed05a2fb5a 100644 --- a/packages/i18n/locales/ru/messages.po +++ b/packages/i18n/locales/ru/messages.po @@ -13249,6 +13249,9 @@ msgstr "Делитесь дизайнами и отчётами. Пусть ва msgid "Share page" msgstr "Поделиться страницей" +msgid "Share settings" +msgstr "Настройки доступа" + msgid "Share this page" msgstr "Поделиться этой страницей" @@ -15169,6 +15172,10 @@ msgstr "Эта страница публикуется откуда-то ещё msgid "This page is in another organization" msgstr "Эта страница в другой организации" +#. placeholder {0}: url.replace(/^https?:\/\//, "") +msgid "This page is live at <0>{0}" +msgstr "Эта страница опубликована по адресу <0>{0}" + msgid "This page isn't here" msgstr "Этой страницы здесь нет" diff --git a/packages/i18n/locales/tr/messages.po b/packages/i18n/locales/tr/messages.po index 1f4f28861f7..6596854b043 100644 --- a/packages/i18n/locales/tr/messages.po +++ b/packages/i18n/locales/tr/messages.po @@ -13249,6 +13249,9 @@ msgstr "Tasarımları ve raporları paylaşın. Geri bildirimleri ajanınız ele msgid "Share page" msgstr "Sayfayı paylaş" +msgid "Share settings" +msgstr "Paylaşım ayarları" + msgid "Share this page" msgstr "Bu sayfayı paylaş" @@ -15169,6 +15172,10 @@ msgstr "Bu sayfa başka bir yerden yayımlanıyor — yeniden deneyin" msgid "This page is in another organization" msgstr "Bu sayfa başka bir kuruluşta" +#. placeholder {0}: url.replace(/^https?:\/\//, "") +msgid "This page is live at <0>{0}" +msgstr "Bu sayfa <0>{0} adresinde yayında" + msgid "This page isn't here" msgstr "Bu sayfa burada değil" diff --git a/packages/i18n/locales/vi/messages.po b/packages/i18n/locales/vi/messages.po index 7a417e46564..969a1acff8d 100644 --- a/packages/i18n/locales/vi/messages.po +++ b/packages/i18n/locales/vi/messages.po @@ -13249,6 +13249,9 @@ msgstr "Chia sẻ thiết kế và báo cáo. Hãy để agent của bạn xử msgid "Share page" msgstr "Chia sẻ trang" +msgid "Share settings" +msgstr "Cài đặt chia sẻ" + msgid "Share this page" msgstr "Chia sẻ trang này" @@ -15169,6 +15172,10 @@ msgstr "Trang này đang được xuất bản từ nơi khác — hãy thử l msgid "This page is in another organization" msgstr "Trang này thuộc một tổ chức khác" +#. placeholder {0}: url.replace(/^https?:\/\//, "") +msgid "This page is live at <0>{0}" +msgstr "Trang này đang hoạt động tại <0>{0}" + msgid "This page isn't here" msgstr "Trang này không có ở đây" diff --git a/packages/i18n/locales/zh-CN/messages.po b/packages/i18n/locales/zh-CN/messages.po index f0878c2531d..6f9b080d9db 100644 --- a/packages/i18n/locales/zh-CN/messages.po +++ b/packages/i18n/locales/zh-CN/messages.po @@ -13249,6 +13249,9 @@ msgstr "分享设计和报告,让你的智能体处理反馈。" msgid "Share page" msgstr "分享页面" +msgid "Share settings" +msgstr "共享设置" + msgid "Share this page" msgstr "分享此页面" @@ -15169,6 +15172,10 @@ msgstr "此页面正在别处发布,请重试" msgid "This page is in another organization" msgstr "这个页面属于另一个组织" +#. placeholder {0}: url.replace(/^https?:\/\//, "") +msgid "This page is live at <0>{0}" +msgstr "此页面已发布在 <0>{0}" + msgid "This page isn't here" msgstr "找不到这个页面" diff --git a/packages/i18n/locales/zh-TW/messages.po b/packages/i18n/locales/zh-TW/messages.po index 530ca03fc69..154b5a90c34 100644 --- a/packages/i18n/locales/zh-TW/messages.po +++ b/packages/i18n/locales/zh-TW/messages.po @@ -13249,6 +13249,9 @@ msgstr "分享設計和報告,讓你的代理程式處理回饋。" msgid "Share page" msgstr "分享頁面" +msgid "Share settings" +msgstr "分享設定" + msgid "Share this page" msgstr "分享此頁面" @@ -15169,6 +15172,10 @@ msgstr "此頁面正在別處釋出,請重試" msgid "This page is in another organization" msgstr "這個頁面屬於另一個組織" +#. placeholder {0}: url.replace(/^https?:\/\//, "") +msgid "This page is live at <0>{0}" +msgstr "此頁面已發布在 <0>{0}" + msgid "This page isn't here" msgstr "找不到這個頁面" diff --git a/packages/ui/src/components/PageComments/components/PageHeader/PageHeader.tsx b/packages/ui/src/components/PageComments/components/PageHeader/PageHeader.tsx index 68595bce190..aa629c8de64 100644 --- a/packages/ui/src/components/PageComments/components/PageHeader/PageHeader.tsx +++ b/packages/ui/src/components/PageComments/components/PageHeader/PageHeader.tsx @@ -7,6 +7,7 @@ import { cn } from "../../../../lib/utils"; import { Button } from "../../../ui/button"; import { toast } from "../../../ui/sonner"; import { DeletePageDialog } from "./components/DeletePageDialog"; +import { PagePublicBanner } from "./components/PagePublicBanner"; import { PageSharePopover } from "./components/PageSharePopover"; import { PageTitleMenu } from "./components/PageTitleMenu"; import type { @@ -59,66 +60,75 @@ export function PageHeader({ }; return ( -
- {leading} -
- { - setMenuOpen(false); - setShareOpen(true); - }} - onDelete={() => { - setMenuOpen(false); - setDeleteOpen(true); - }} - onPickVersion={(version) => { - setMenuOpen(false); - void pickVersion(version); - }} - /> - {!isOwner && page.owner ? ( - - {page.owner.name} - - ) : null} -
+ <> +
+ {leading} +
+ { + setMenuOpen(false); + setShareOpen(true); + }} + onDelete={() => { + setMenuOpen(false); + setDeleteOpen(true); + }} + onPickVersion={(version) => { + setMenuOpen(false); + void pickVersion(version); + }} + /> + {!isOwner && page.owner ? ( + + {page.owner.name} + + ) : null} +
+ +
+ {trailing} + + + +
-
- {trailing} - - - +
- -
+ {isOwner && page.visibility === "everyone" ? ( + setShareOpen(true)} + /> + ) : null} + ); } diff --git a/packages/ui/src/components/PageComments/components/PageHeader/components/PagePublicBanner/PagePublicBanner.tsx b/packages/ui/src/components/PageComments/components/PageHeader/components/PagePublicBanner/PagePublicBanner.tsx new file mode 100644 index 00000000000..47eb034887f --- /dev/null +++ b/packages/ui/src/components/PageComments/components/PageHeader/components/PagePublicBanner/PagePublicBanner.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Trans, useLingui } from "@lingui/react/macro"; +import { Check, Globe, Link2, Settings2 } from "lucide-react"; +import { useState } from "react"; +import { Button } from "../../../../../ui/button"; +import { toast } from "../../../../../ui/sonner"; + +interface PagePublicBannerProps { + url: string; + onOpenShareSettings: () => void; +} + +export function PagePublicBanner({ + url, + onOpenShareSettings, +}: PagePublicBannerProps) { + const { t } = useLingui(); + const [copied, setCopied] = useState(false); + + const copyLink = async () => { + try { + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + toast.error(t({ message: "Could not copy the link" })); + } + }; + + return ( +
+ + + + This page is live at{" "} + + {url.replace(/^https?:\/\//, "")} + + + + +
+ + +
+
+ ); +} diff --git a/packages/ui/src/components/PageComments/components/PageHeader/components/PagePublicBanner/index.ts b/packages/ui/src/components/PageComments/components/PageHeader/components/PagePublicBanner/index.ts new file mode 100644 index 00000000000..ea38a0b969b --- /dev/null +++ b/packages/ui/src/components/PageComments/components/PageHeader/components/PagePublicBanner/index.ts @@ -0,0 +1 @@ +export { PagePublicBanner } from "./PagePublicBanner"; From a032d97fa4bf828192d8206324d4607b7508bf00 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Tue, 15 Sep 2026 16:46:25 +0530 Subject: [PATCH 04/10] feat(pages): rebuild the page title menu around read-only version history Closes SUPER-1990. The version list had the wrong job. Picking a version called setSharedVersion, which changes what the whole team sees, so non-owners got a disabled list and an owner who only wanted to look republished by accident. Version history now opens a version read-only, for anyone who can see the page. Publishing a version stays in the share popover, which already has the "Shared version" control. Preview is a search param on web (?v=N), so pull pins the version, the back button works and the URL is shareable, with no client refactor of a server-rendered route. Desktop lifts the version into PageDetailView, and the pane surface routes it through usePagePaneUi, the per-pane store PagePaneTitle and PagePane already share. Rows carry the version's thumbnail. Only versions that were served ever get a capture, so the rest fall back to the file icon rather than generating on demand. A page is one prefix to list, so one ListObjectsV2 resolves every version instead of a HEAD per row. Rename was blocked on SUPER-2089, which is cancelled, so page.update lands here: title and description, no new version minted. Report is still absent rather than disabled. There is no backend to call, and the ticket gates it on sharing outside the organization. --- .../components/PageViewer/PageViewer.tsx | 7 +- .../usePageHeaderData/usePageHeaderData.ts | 13 +++ .../PageDetailView/PageDetailView.tsx | 10 ++ .../components/PagePane/PagePane.tsx | 4 +- .../PagePaneTitle/PagePaneTitle.tsx | 23 +++- .../hooks/usePagePaneUi/usePagePaneUi.ts | 12 +- .../PageHeaderBar/PageHeaderBar.tsx | 15 ++- apps/web/src/app/page/[slug]/page.tsx | 21 +++- packages/i18n/locales/cs/messages.po | 18 +++ packages/i18n/locales/de/messages.po | 18 +++ packages/i18n/locales/en/messages.po | 18 +++ packages/i18n/locales/es/messages.po | 18 +++ packages/i18n/locales/fr/messages.po | 18 +++ packages/i18n/locales/id/messages.po | 18 +++ packages/i18n/locales/it/messages.po | 18 +++ packages/i18n/locales/ja/messages.po | 18 +++ packages/i18n/locales/ko/messages.po | 18 +++ packages/i18n/locales/nl/messages.po | 18 +++ packages/i18n/locales/pl/messages.po | 18 +++ packages/i18n/locales/pt-BR/messages.po | 18 +++ packages/i18n/locales/ru/messages.po | 18 +++ packages/i18n/locales/tr/messages.po | 18 +++ packages/i18n/locales/vi/messages.po | 18 +++ packages/i18n/locales/zh-CN/messages.po | 18 +++ packages/i18n/locales/zh-TW/messages.po | 18 +++ packages/trpc/src/lib/r2.ts | 23 ++++ packages/trpc/src/router/page/page.ts | 60 +++++++++- packages/trpc/src/router/page/schema.ts | 11 ++ .../components/PageHeader/PageHeader.tsx | 47 ++++---- .../PageTitleMenu/PageTitleMenu.tsx | 86 +++++++++----- .../PageVersionBanner/PageVersionBanner.tsx | 24 ++++ .../components/PageVersionBanner/index.ts | 1 + .../RenamePageDialog/RenamePageDialog.tsx | 107 ++++++++++++++++++ .../components/RenamePageDialog/index.ts | 1 + .../components/PageHeader/index.ts | 1 + .../components/PageHeader/types.ts | 5 + .../ui/src/components/PageComments/index.ts | 1 + 37 files changed, 712 insertions(+), 66 deletions(-) create mode 100644 packages/ui/src/components/PageComments/components/PageHeader/components/PageVersionBanner/PageVersionBanner.tsx create mode 100644 packages/ui/src/components/PageComments/components/PageHeader/components/PageVersionBanner/index.ts create mode 100644 packages/ui/src/components/PageComments/components/PageHeader/components/RenamePageDialog/RenamePageDialog.tsx create mode 100644 packages/ui/src/components/PageComments/components/PageHeader/components/RenamePageDialog/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PageViewer/PageViewer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PageViewer/PageViewer.tsx index f04ecd72fe4..eb5dd29a6d5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PageViewer/PageViewer.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PageViewer/PageViewer.tsx @@ -32,6 +32,7 @@ interface PageViewerProps { onCommentsEnabledChange: (enabled: boolean) => void; onResolved?: (page: ResolvedPage) => void; onFramePointerDown?: () => void; + version?: number | null; } export function PageViewer({ @@ -42,10 +43,14 @@ export function PageViewer({ onCommentsEnabledChange, onResolved, onFramePointerDown, + version, }: PageViewerProps) { const { t } = useLingui(); const { data: session } = authClient.useSession(); - const pull = cloudTrpc.page.pull.useQuery(pageId ? { id: pageId } : { slug }); + const pull = cloudTrpc.page.pull.useQuery({ + ...(pageId ? { id: pageId } : { slug }), + ...(version ? { version } : {}), + }); const resolvedPageId = pageId ?? pull.data?.id; const resolvedTitle = title ?? pull.data?.title ?? slug; const user = useMemo( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts index a28557887d4..ba0e83aca47 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts @@ -22,6 +22,8 @@ interface PageHeaderData { currentUserId: string | undefined; onSetVisibility: (visibility: PageVisibility) => Promise; onSetSharedVersion: (version: number | null) => Promise; + onRename: (title: string) => Promise; + onRefresh: () => void; onDelete: () => Promise; } @@ -45,6 +47,7 @@ export function usePageHeaderData(data: PageHeaderTarget): PageHeaderData { const utils = cloudTrpc.useUtils(); const setVisibility = cloudTrpc.page.setVisibility.useMutation(); const setSharedVersion = cloudTrpc.page.setSharedVersion.useMutation(); + const updatePage = cloudTrpc.page.update.useMutation(); const deletePage = cloudTrpc.page.delete.useMutation(); const refresh = useCallback(async () => { @@ -88,6 +91,16 @@ export function usePageHeaderData(data: PageHeaderTarget): PageHeaderData { await setSharedVersion.mutateAsync({ id: pageId, version }); await refresh(); }, + onRename: async (title) => { + if (!pageId) return; + const updated = await updatePage.mutateAsync({ id: pageId, title }); + utils.page.pull.setData(ref, (prev) => + prev ? { ...prev, title: updated.title } : prev, + ); + }, + onRefresh: () => { + void refresh(); + }, onDelete: async () => { if (!pageId) return; await deletePage.mutateAsync({ id: pageId }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/$slug/components/PageDetailView/PageDetailView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/$slug/components/PageDetailView/PageDetailView.tsx index 1af91932792..c5e9d55218b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/$slug/components/PageDetailView/PageDetailView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/$slug/components/PageDetailView/PageDetailView.tsx @@ -16,6 +16,7 @@ export function PageDetailView({ slug }: PageDetailViewProps) { const { t } = useLingui(); const navigate = useNavigate(); const [commentsEnabled, setCommentsEnabled] = useState(false); + const [previewVersion, setPreviewVersion] = useState(null); const { page, versions, @@ -23,6 +24,8 @@ export function PageDetailView({ slug }: PageDetailViewProps) { currentUserId, onSetVisibility, onSetSharedVersion, + onRename, + onRefresh, onDelete, } = usePageHeaderData({ slug }); @@ -61,6 +64,12 @@ export function PageDetailView({ slug }: PageDetailViewProps) { } onSetVisibility={onSetVisibility} onSetSharedVersion={onSetSharedVersion} + onRename={onRename} + onRefresh={onRefresh} + onPreviewVersion={setPreviewVersion} + previewVersion={ + previewVersion === page.servedVersion ? null : previewVersion + } onDelete={async () => { await onDelete(); goBack(); @@ -76,6 +85,7 @@ export function PageDetailView({ slug }: PageDetailViewProps) { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePane/PagePane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePane/PagePane.tsx index 8d988d4bcc3..6811a63e960 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePane/PagePane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePane/PagePane.tsx @@ -21,7 +21,8 @@ export function PagePane({ onDataChange, onFocus, }: PagePaneProps) { - const { commentsEnabled, setCommentsEnabled } = usePagePaneUi(paneId); + const { commentsEnabled, setCommentsEnabled, previewVersion } = + usePagePaneUi(paneId); const onDataChangeRef = useRef(onDataChange); onDataChangeRef.current = onDataChange; @@ -43,6 +44,7 @@ export function PagePane({ slug={data.slug} pageId={data.pageId} title={data.title} + version={previewVersion} commentsEnabled={commentsEnabled} onCommentsEnabledChange={setCommentsEnabled} onResolved={handleResolved} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneTitle/PagePaneTitle.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneTitle/PagePaneTitle.tsx index f85d97fd430..4691354a864 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneTitle/PagePaneTitle.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneTitle/PagePaneTitle.tsx @@ -1,4 +1,8 @@ -import { DeletePageDialog, PageTitleMenu } from "@superset/ui/page-comments"; +import { + DeletePageDialog, + PageTitleMenu, + RenamePageDialog, +} from "@superset/ui/page-comments"; import { FileText } from "lucide-react"; import { useState } from "react"; import { usePageHeaderData } from "renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData"; @@ -13,11 +17,12 @@ interface PagePaneTitleProps { } export function PagePaneTitle({ data, paneId, onClose }: PagePaneTitleProps) { - const { page, versions, currentUserId, onSetSharedVersion, onDelete } = + const { page, versions, currentUserId, onRename, onRefresh, onDelete } = usePageHeaderData(data); - const { setShareOpen } = usePagePaneUi(paneId); + const { setShareOpen, setPreviewVersion } = usePagePaneUi(paneId); const [menuOpen, setMenuOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); + const [renameOpen, setRenameOpen] = useState(false); if (!page) { return ( @@ -50,10 +55,12 @@ export function PagePaneTitle({ data, paneId, onClose }: PagePaneTitleProps) { setDeleteOpen(true); }} compact - onPickVersion={(version) => { + onRename={() => { setMenuOpen(false); - void onSetSharedVersion(version); + setRenameOpen(true); }} + onRefresh={onRefresh} + onPreviewVersion={setPreviewVersion} /> + ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/hooks/usePagePaneUi/usePagePaneUi.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/hooks/usePagePaneUi/usePagePaneUi.ts index 62083638c97..2247d869ba8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/hooks/usePagePaneUi/usePagePaneUi.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/hooks/usePagePaneUi/usePagePaneUi.ts @@ -3,9 +3,14 @@ import { useCallback, useSyncExternalStore } from "react"; interface PagePaneUiState { commentsEnabled: boolean; shareOpen: boolean; + previewVersion: number | null; } -const EMPTY: PagePaneUiState = { commentsEnabled: false, shareOpen: false }; +const EMPTY: PagePaneUiState = { + commentsEnabled: false, + shareOpen: false, + previewVersion: null, +}; const states = new Map(); const listeners = new Map void>>(); @@ -41,6 +46,7 @@ export function usePagePaneUi(paneId: string) { return { commentsEnabled: state.commentsEnabled, shareOpen: state.shareOpen, + previewVersion: state.previewVersion, setCommentsEnabled: useCallback( (commentsEnabled: boolean) => patch(paneId, { commentsEnabled }), [paneId], @@ -49,5 +55,9 @@ export function usePagePaneUi(paneId: string) { (shareOpen: boolean) => patch(paneId, { shareOpen }), [paneId], ), + setPreviewVersion: useCallback( + (previewVersion: number | null) => patch(paneId, { previewVersion }), + [paneId], + ), }; } diff --git a/apps/web/src/app/page/[slug]/components/PageHeaderBar/PageHeaderBar.tsx b/apps/web/src/app/page/[slug]/components/PageHeaderBar/PageHeaderBar.tsx index 1079d81b4d6..3e93656e4ec 100644 --- a/apps/web/src/app/page/[slug]/components/PageHeaderBar/PageHeaderBar.tsx +++ b/apps/web/src/app/page/[slug]/components/PageHeaderBar/PageHeaderBar.tsx @@ -7,7 +7,7 @@ import { type PageHeaderVersion, } from "@superset/ui/page-comments"; import { useMutation } from "@tanstack/react-query"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { useTRPC } from "@/trpc/react"; import { OpenInSupersetButton } from "./components/OpenInSupersetButton"; import { PageWatchBadge } from "./components/PageWatchBadge"; @@ -19,6 +19,7 @@ interface PageHeaderBarProps { slug: string; watching: boolean; watchAgentId: string | null; + previewVersion: number | null; } export function PageHeaderBar({ @@ -28,13 +29,16 @@ export function PageHeaderBar({ slug, watching, watchAgentId, + previewVersion, }: PageHeaderBarProps) { const trpc = useTRPC(); const router = useRouter(); + const pathname = usePathname(); const setVisibility = useMutation(trpc.page.setVisibility.mutationOptions()); const setSharedVersion = useMutation( trpc.page.setSharedVersion.mutationOptions(), ); + const updatePage = useMutation(trpc.page.update.mutationOptions()); const deletePage = useMutation(trpc.page.delete.mutationOptions()); return ( @@ -42,6 +46,7 @@ export function PageHeaderBar({ page={page} versions={versions} currentUserId={currentUserId} + previewVersion={previewVersion} trailing={ <> @@ -61,6 +66,14 @@ export function PageHeaderBar({ await setSharedVersion.mutateAsync({ id: page.id, version }); router.refresh(); }} + onRename={async (title) => { + await updatePage.mutateAsync({ id: page.id, title }); + router.refresh(); + }} + onRefresh={() => router.refresh()} + onPreviewVersion={(version) => { + router.push(version === null ? pathname : `${pathname}?v=${version}`); + }} onDelete={async () => { await deletePage.mutateAsync({ id: page.id }); router.replace("/"); diff --git a/apps/web/src/app/page/[slug]/page.tsx b/apps/web/src/app/page/[slug]/page.tsx index 3da8d311e79..116c60f7913 100644 --- a/apps/web/src/app/page/[slug]/page.tsx +++ b/apps/web/src/app/page/[slug]/page.tsx @@ -21,15 +21,21 @@ import { isForbidden, isNotFound } from "./utils/trpcErrors"; interface PageProps { params: Promise<{ slug: string }>; + searchParams: Promise<{ v?: string }>; } // `api()` caches the client, not the result — this cache is what keeps // generateMetadata and the component to a single pull. -const pullPage = cache(async (slug: string) => { +const pullPage = cache(async (slug: string, version?: number) => { const trpc = await api(); - return trpc.page.pull.query({ slug }); + return trpc.page.pull.query({ slug, version }); }); +function previewVersionOf(raw: string | undefined): number | undefined { + const parsed = Number(raw); + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; +} + const pullVersions = cache(async (slug: string) => { const trpc = await api(); return trpc.page.versions.query({ slug }); @@ -95,10 +101,14 @@ export async function generateMetadata({ }; } -export default async function PublishedPage({ params }: PageProps) { +export default async function PublishedPage({ + params, + searchParams, +}: PageProps) { const i18n = await initServerI18n(); const { slug } = await params; + const requestedVersion = previewVersionOf((await searchParams).v); const { hasPagesAccess, session } = await getPagesAccess(); @@ -122,7 +132,7 @@ export default async function PublishedPage({ params }: PageProps) { let page: Awaited>; try { - page = await pullPage(slug); + page = await pullPage(slug, requestedVersion); } catch (error) { const view = await publicView(); if (view) return view; @@ -164,6 +174,9 @@ export default async function PublishedPage({ params }: PageProps) { slug={slug} watching={page.watch.watching} watchAgentId={page.watch.agentId} + previewVersion={ + page.version === page.servedVersion ? null : page.version + } />
diff --git a/packages/i18n/locales/cs/messages.po b/packages/i18n/locales/cs/messages.po index 857e9c51725..10add1ab519 100644 --- a/packages/i18n/locales/cs/messages.po +++ b/packages/i18n/locales/cs/messages.po @@ -2413,6 +2413,9 @@ msgstr "Zpět na changelog" msgid "Back to checklist" msgstr "Zpět na seznam kroků" +msgid "Back to current" +msgstr "Zpět na aktuální" + msgid "Back to GitHub issues" msgstr "Zpět na GitHub issues" @@ -4260,6 +4263,9 @@ msgstr "Prostředí se nepodařilo uložit" msgid "Could not rename the session" msgstr "Relaci se nepodařilo přejmenovat" +msgid "Could not rename this page" +msgstr "Tuto stránku se nepodařilo přejmenovat" + msgid "Could not reopen" msgstr "Nepodařilo se znovu otevřít" @@ -10691,6 +10697,9 @@ msgstr "Stránka nenalezena" msgid "Page Not Found" msgstr "Stránka nenalezena" +msgid "Page title" +msgstr "Název stránky" + msgid "Page views" msgstr "Zobrazení stránek" @@ -11913,6 +11922,9 @@ msgstr "Přejmenovat skupinu" msgid "Rename host" msgstr "Přejmenovat hosta" +msgid "Rename page" +msgstr "Přejmenovat stránku" + msgid "Rename session" msgstr "Přejmenovat relaci" @@ -14973,6 +14985,9 @@ msgstr "cílový host" msgid "The team invitation has either expired or doesn't exist. Request a new link from the team owner or check the URL to make sure it is entered correctly." msgstr "Pozvánka do týmu buď vypršela, nebo neexistuje. Vyžádejte si od vlastníka týmu nový odkaz nebo zkontrolujte, že je URL zadaná správně." +msgid "The title shows everywhere this page is listed and in its link preview." +msgstr "Název se zobrazuje všude, kde je tato stránka uvedena, a v náhledu odkazu." + msgid "The web agents UI is a read-only preview for now." msgstr "Webové rozhraní agentů je zatím náhled jen pro čtení." @@ -16779,6 +16794,9 @@ msgstr "Jste jediný vlastník organizace, která má další členy. Nejprve p msgid "You are unranked until {0} days carry usage. Scoring starts once the board can see a full picture." msgstr "Nemáš pořadí, dokud nebude dní s aktivitou: {0}. Bodování začne, až bude vidět celý obrázek." +msgid "You are viewing version {version}, read-only" +msgstr "Prohlížíte si verzi {version}, jen pro čtení" + msgid "You can attach up to {MAX_ATTACHMENTS} files" msgstr "Můžete přiložit nejvýše {MAX_ATTACHMENTS} souborů" diff --git a/packages/i18n/locales/de/messages.po b/packages/i18n/locales/de/messages.po index 788cb01c5f2..3d0bcef7b2f 100644 --- a/packages/i18n/locales/de/messages.po +++ b/packages/i18n/locales/de/messages.po @@ -2413,6 +2413,9 @@ msgstr "Zurück zum Changelog" msgid "Back to checklist" msgstr "Zurück zur Checkliste" +msgid "Back to current" +msgstr "Zurück zur aktuellen Version" + msgid "Back to GitHub issues" msgstr "Zurück zu GitHub-Issues" @@ -4260,6 +4263,9 @@ msgstr "Umgebung konnte nicht gespeichert werden" msgid "Could not rename the session" msgstr "Sitzung konnte nicht umbenannt werden" +msgid "Could not rename this page" +msgstr "Diese Seite konnte nicht umbenannt werden" + msgid "Could not reopen" msgstr "Konnte nicht wieder geöffnet werden" @@ -10691,6 +10697,9 @@ msgstr "Seite nicht gefunden" msgid "Page Not Found" msgstr "Seite nicht gefunden" +msgid "Page title" +msgstr "Seitentitel" + msgid "Page views" msgstr "Seitenaufrufe" @@ -11913,6 +11922,9 @@ msgstr "Gruppe umbenennen" msgid "Rename host" msgstr "Host umbenennen" +msgid "Rename page" +msgstr "Seite umbenennen" + msgid "Rename session" msgstr "Sitzung umbenennen" @@ -14973,6 +14985,9 @@ msgstr "der Ziel-Host" msgid "The team invitation has either expired or doesn't exist. Request a new link from the team owner or check the URL to make sure it is entered correctly." msgstr "Die Team-Einladung ist abgelaufen oder existiert nicht. Bitte den Team-Eigentümer um einen neuen Link oder prüfe, ob die URL korrekt eingegeben ist." +msgid "The title shows everywhere this page is listed and in its link preview." +msgstr "Der Titel erscheint überall dort, wo diese Seite aufgeführt ist, und in ihrer Linkvorschau." + msgid "The web agents UI is a read-only preview for now." msgstr "Die Web-Oberfläche für Agenten ist vorerst eine schreibgeschützte Vorschau." @@ -16779,6 +16794,9 @@ msgstr "Du bist die einzige Inhaberin einer Organisation, die weitere Mitglieder msgid "You are unranked until {0} days carry usage. Scoring starts once the board can see a full picture." msgstr "Du bist ohne Rang, bis {0} Tage Nutzung aufweisen. Die Bewertung beginnt, sobald das Gesamtbild sichtbar ist." +msgid "You are viewing version {version}, read-only" +msgstr "Du siehst Version {version}, schreibgeschützt" + msgid "You can attach up to {MAX_ATTACHMENTS} files" msgstr "Du kannst bis zu {MAX_ATTACHMENTS} Dateien anhängen" diff --git a/packages/i18n/locales/en/messages.po b/packages/i18n/locales/en/messages.po index d5f8f0b0abe..062237e0589 100644 --- a/packages/i18n/locales/en/messages.po +++ b/packages/i18n/locales/en/messages.po @@ -2413,6 +2413,9 @@ msgstr "Back to Changelog" msgid "Back to checklist" msgstr "Back to checklist" +msgid "Back to current" +msgstr "Back to current" + msgid "Back to GitHub issues" msgstr "Back to GitHub issues" @@ -4260,6 +4263,9 @@ msgstr "Could not record environment" msgid "Could not rename the session" msgstr "Could not rename the session" +msgid "Could not rename this page" +msgstr "Could not rename this page" + msgid "Could not reopen" msgstr "Could not reopen" @@ -10691,6 +10697,9 @@ msgstr "Page not found" msgid "Page Not Found" msgstr "Page Not Found" +msgid "Page title" +msgstr "Page title" + msgid "Page views" msgstr "Page views" @@ -11913,6 +11922,9 @@ msgstr "Rename group" msgid "Rename host" msgstr "Rename host" +msgid "Rename page" +msgstr "Rename page" + msgid "Rename session" msgstr "Rename session" @@ -14973,6 +14985,9 @@ msgstr "the target host" msgid "The team invitation has either expired or doesn't exist. Request a new link from the team owner or check the URL to make sure it is entered correctly." msgstr "The team invitation has either expired or doesn't exist. Request a new link from the team owner or check the URL to make sure it is entered correctly." +msgid "The title shows everywhere this page is listed and in its link preview." +msgstr "The title shows everywhere this page is listed and in its link preview." + msgid "The web agents UI is a read-only preview for now." msgstr "The web agents UI is a read-only preview for now." @@ -16779,6 +16794,9 @@ msgstr "You are the only owner of an organization that has other members. Transf msgid "You are unranked until {0} days carry usage. Scoring starts once the board can see a full picture." msgstr "You are unranked until {0} days carry usage. Scoring starts once the board can see a full picture." +msgid "You are viewing version {version}, read-only" +msgstr "You are viewing version {version}, read-only" + msgid "You can attach up to {MAX_ATTACHMENTS} files" msgstr "You can attach up to {MAX_ATTACHMENTS} files" diff --git a/packages/i18n/locales/es/messages.po b/packages/i18n/locales/es/messages.po index 9321d66deb3..0db95ec05f8 100644 --- a/packages/i18n/locales/es/messages.po +++ b/packages/i18n/locales/es/messages.po @@ -2413,6 +2413,9 @@ msgstr "Volver al registro de cambios" msgid "Back to checklist" msgstr "Volver a la lista" +msgid "Back to current" +msgstr "Volver a la actual" + msgid "Back to GitHub issues" msgstr "Volver a los issues de GitHub" @@ -4260,6 +4263,9 @@ msgstr "No se pudo guardar el entorno" msgid "Could not rename the session" msgstr "No se pudo cambiar el nombre de la sesión" +msgid "Could not rename this page" +msgstr "No se pudo cambiar el nombre de esta página" + msgid "Could not reopen" msgstr "No se pudo reabrir" @@ -10691,6 +10697,9 @@ msgstr "Página no encontrada" msgid "Page Not Found" msgstr "Página no encontrada" +msgid "Page title" +msgstr "Título de la página" + msgid "Page views" msgstr "Páginas vistas" @@ -11913,6 +11922,9 @@ msgstr "Cambiar nombre del grupo" msgid "Rename host" msgstr "Cambiar el nombre del host" +msgid "Rename page" +msgstr "Cambiar el nombre de la página" + msgid "Rename session" msgstr "Cambiar el nombre de la sesión" @@ -14973,6 +14985,9 @@ msgstr "el host de destino" msgid "The team invitation has either expired or doesn't exist. Request a new link from the team owner or check the URL to make sure it is entered correctly." msgstr "La invitación al equipo ha caducado o no existe. Pide un enlace nuevo al propietario del equipo o comprueba que la URL esté bien escrita." +msgid "The title shows everywhere this page is listed and in its link preview." +msgstr "El título aparece en todos los sitios donde se lista esta página y en la vista previa de su enlace." + msgid "The web agents UI is a read-only preview for now." msgstr "La interfaz web de agentes es, por ahora, una vista previa de solo lectura." @@ -16779,6 +16794,9 @@ msgstr "Eres el único propietario de una organización que tiene otros miembros msgid "You are unranked until {0} days carry usage. Scoring starts once the board can see a full picture." msgstr "No estás clasificado hasta que haya {0} días con actividad. La puntuación empieza cuando se ve el panorama completo." +msgid "You are viewing version {version}, read-only" +msgstr "Estás viendo la versión {version}, solo lectura" + msgid "You can attach up to {MAX_ATTACHMENTS} files" msgstr "Puedes adjuntar hasta {MAX_ATTACHMENTS} archivos" diff --git a/packages/i18n/locales/fr/messages.po b/packages/i18n/locales/fr/messages.po index 127b0102444..14a45c7590b 100644 --- a/packages/i18n/locales/fr/messages.po +++ b/packages/i18n/locales/fr/messages.po @@ -2413,6 +2413,9 @@ msgstr "Retour au changelog" msgid "Back to checklist" msgstr "Retour à la liste de contrôle" +msgid "Back to current" +msgstr "Revenir à la version actuelle" + msgid "Back to GitHub issues" msgstr "Retour aux issues GitHub" @@ -4260,6 +4263,9 @@ msgstr "Impossible d'enregistrer l'environnement" msgid "Could not rename the session" msgstr "Impossible de renommer la session" +msgid "Could not rename this page" +msgstr "Impossible de renommer cette page" + msgid "Could not reopen" msgstr "Impossible de rouvrir" @@ -10691,6 +10697,9 @@ msgstr "Page introuvable" msgid "Page Not Found" msgstr "Page introuvable" +msgid "Page title" +msgstr "Titre de la page" + msgid "Page views" msgstr "Pages vues" @@ -11913,6 +11922,9 @@ msgstr "Renommer le groupe" msgid "Rename host" msgstr "Renommer l'hôte" +msgid "Rename page" +msgstr "Renommer la page" + msgid "Rename session" msgstr "Renommer la session" @@ -14973,6 +14985,9 @@ msgstr "l'hôte cible" msgid "The team invitation has either expired or doesn't exist. Request a new link from the team owner or check the URL to make sure it is entered correctly." msgstr "L'invitation à l'équipe a expiré ou n'existe pas. Demandez un nouveau lien au propriétaire de l'équipe ou vérifiez que l'URL est correctement saisie." +msgid "The title shows everywhere this page is listed and in its link preview." +msgstr "Le titre apparaît partout où cette page est listée ainsi que dans son aperçu de lien." + msgid "The web agents UI is a read-only preview for now." msgstr "L'interface web des agents est pour l'instant un aperçu en lecture seule." @@ -16779,6 +16794,9 @@ msgstr "Vous êtes le seul propriétaire d'une organisation qui compte d'autres msgid "You are unranked until {0} days carry usage. Scoring starts once the board can see a full picture." msgstr "Vous n'êtes pas classé tant que {0} jours n'ont pas d'activité. Le score démarre une fois la vue d'ensemble disponible." +msgid "You are viewing version {version}, read-only" +msgstr "Vous consultez la version {version}, en lecture seule" + msgid "You can attach up to {MAX_ATTACHMENTS} files" msgstr "Vous pouvez joindre jusqu'à {MAX_ATTACHMENTS} fichiers" diff --git a/packages/i18n/locales/id/messages.po b/packages/i18n/locales/id/messages.po index 8600be90315..80821859602 100644 --- a/packages/i18n/locales/id/messages.po +++ b/packages/i18n/locales/id/messages.po @@ -2413,6 +2413,9 @@ msgstr "Kembali ke Changelog" msgid "Back to checklist" msgstr "Kembali ke checklist" +msgid "Back to current" +msgstr "Kembali ke versi saat ini" + msgid "Back to GitHub issues" msgstr "Kembali ke issue GitHub" @@ -4260,6 +4263,9 @@ msgstr "Tidak dapat menyimpan environment" msgid "Could not rename the session" msgstr "Tidak dapat mengganti nama sesi" +msgid "Could not rename this page" +msgstr "Tidak dapat mengganti nama halaman ini" + msgid "Could not reopen" msgstr "Tidak bisa membuka lagi" @@ -10691,6 +10697,9 @@ msgstr "Halaman tidak ditemukan" msgid "Page Not Found" msgstr "Halaman Tidak Ditemukan" +msgid "Page title" +msgstr "Judul halaman" + msgid "Page views" msgstr "Tampilan halaman" @@ -11913,6 +11922,9 @@ msgstr "Ganti nama grup" msgid "Rename host" msgstr "Ganti nama host" +msgid "Rename page" +msgstr "Ganti nama halaman" + msgid "Rename session" msgstr "Ganti nama sesi" @@ -14973,6 +14985,9 @@ msgstr "host tujuan" msgid "The team invitation has either expired or doesn't exist. Request a new link from the team owner or check the URL to make sure it is entered correctly." msgstr "Undangan tim ini sudah kedaluwarsa atau tidak ada. Minta tautan baru dari pemilik tim atau periksa URL-nya sudah benar." +msgid "The title shows everywhere this page is listed and in its link preview." +msgstr "Judul muncul di semua tempat halaman ini terdaftar dan di pratinjau tautannya." + msgid "The web agents UI is a read-only preview for now." msgstr "UI agen di web masih pratinjau baca-saja untuk saat ini." @@ -16779,6 +16794,9 @@ msgstr "Anda satu-satunya pemilik organisasi yang punya anggota lain. Alihkan ke msgid "You are unranked until {0} days carry usage. Scoring starts once the board can see a full picture." msgstr "Anda tanpa peringkat sampai ada {0} hari dengan penggunaan. Penilaian dimulai setelah gambaran utuh terlihat." +msgid "You are viewing version {version}, read-only" +msgstr "Anda melihat versi {version}, hanya-baca" + msgid "You can attach up to {MAX_ATTACHMENTS} files" msgstr "Anda dapat melampirkan hingga {MAX_ATTACHMENTS} file" diff --git a/packages/i18n/locales/it/messages.po b/packages/i18n/locales/it/messages.po index dfc5a120a10..b6838444939 100644 --- a/packages/i18n/locales/it/messages.po +++ b/packages/i18n/locales/it/messages.po @@ -2413,6 +2413,9 @@ msgstr "Torna al changelog" msgid "Back to checklist" msgstr "Torna alla checklist" +msgid "Back to current" +msgstr "Torna alla versione corrente" + msgid "Back to GitHub issues" msgstr "Torna alle issue GitHub" @@ -4260,6 +4263,9 @@ msgstr "Impossibile salvare l'ambiente" msgid "Could not rename the session" msgstr "Impossibile rinominare la sessione" +msgid "Could not rename this page" +msgstr "Impossibile rinominare questa pagina" + msgid "Could not reopen" msgstr "Impossibile riaprire" @@ -10691,6 +10697,9 @@ msgstr "Pagina non trovata" msgid "Page Not Found" msgstr "Pagina non trovata" +msgid "Page title" +msgstr "Titolo della pagina" + msgid "Page views" msgstr "Visualizzazioni di pagina" @@ -11913,6 +11922,9 @@ msgstr "Rinomina il gruppo" msgid "Rename host" msgstr "Rinomina l'host" +msgid "Rename page" +msgstr "Rinomina pagina" + msgid "Rename session" msgstr "Rinomina la sessione" @@ -14973,6 +14985,9 @@ msgstr "l'host di destinazione" msgid "The team invitation has either expired or doesn't exist. Request a new link from the team owner or check the URL to make sure it is entered correctly." msgstr "L'invito al team è scaduto oppure non esiste. Chiedi un nuovo link al proprietario del team o controlla che l'URL sia corretto." +msgid "The title shows everywhere this page is listed and in its link preview." +msgstr "Il titolo compare ovunque questa pagina sia elencata e nell'anteprima del suo link." + msgid "The web agents UI is a read-only preview for now." msgstr "L'interfaccia web degli agenti è per ora un'anteprima in sola lettura." @@ -16779,6 +16794,9 @@ msgstr "Sei l'unico proprietario di un'organizzazione che ha altri membri. Trasf msgid "You are unranked until {0} days carry usage. Scoring starts once the board can see a full picture." msgstr "Non sei classificato finché {0} giorni non registrano attività. Il punteggio parte quando il quadro è completo." +msgid "You are viewing version {version}, read-only" +msgstr "Stai visualizzando la versione {version}, in sola lettura" + msgid "You can attach up to {MAX_ATTACHMENTS} files" msgstr "Puoi allegare fino a {MAX_ATTACHMENTS} file" diff --git a/packages/i18n/locales/ja/messages.po b/packages/i18n/locales/ja/messages.po index 1c4bd70ddf5..61c988014bd 100644 --- a/packages/i18n/locales/ja/messages.po +++ b/packages/i18n/locales/ja/messages.po @@ -2413,6 +2413,9 @@ msgstr "Changelogに戻る" msgid "Back to checklist" msgstr "チェックリストに戻る" +msgid "Back to current" +msgstr "最新に戻る" + msgid "Back to GitHub issues" msgstr "GitHub issueに戻る" @@ -4260,6 +4263,9 @@ msgstr "環境を保存できませんでした" msgid "Could not rename the session" msgstr "セッションの名前を変更できませんでした" +msgid "Could not rename this page" +msgstr "このページの名前を変更できませんでした" + msgid "Could not reopen" msgstr "再オープンできませんでした" @@ -10691,6 +10697,9 @@ msgstr "ページが見つかりません" msgid "Page Not Found" msgstr "ページが見つかりません" +msgid "Page title" +msgstr "ページタイトル" + msgid "Page views" msgstr "ページビュー" @@ -11913,6 +11922,9 @@ msgstr "グループ名を変更" msgid "Rename host" msgstr "ホストの名前を変更" +msgid "Rename page" +msgstr "ページの名前を変更" + msgid "Rename session" msgstr "セッションの名前を変更" @@ -14973,6 +14985,9 @@ msgstr "対象ホスト" msgid "The team invitation has either expired or doesn't exist. Request a new link from the team owner or check the URL to make sure it is entered correctly." msgstr "チームの招待は期限切れか、存在しません。チームのオーナーに新しいリンクを依頼するか、URLが正しく入力されているか確認してください。" +msgid "The title shows everywhere this page is listed and in its link preview." +msgstr "タイトルはこのページが一覧表示される場所すべてと、リンクのプレビューに表示されます。" + msgid "The web agents UI is a read-only preview for now." msgstr "Web版のエージェントUIは現時点では読み取り専用のプレビューです。" @@ -16779,6 +16794,9 @@ msgstr "他にメンバーがいる組織の唯一のオーナーです。先に msgid "You are unranked until {0} days carry usage. Scoring starts once the board can see a full picture." msgstr "{0}日分の使用実績がそろうまではランク外です。全体像が見えるようになってからスコアリングが始まります。" +msgid "You are viewing version {version}, read-only" +msgstr "バージョン {version} を閲覧中です(読み取り専用)" + msgid "You can attach up to {MAX_ATTACHMENTS} files" msgstr "添付できるファイルは最大{MAX_ATTACHMENTS}件です" diff --git a/packages/i18n/locales/ko/messages.po b/packages/i18n/locales/ko/messages.po index bb6750a5484..475a029794b 100644 --- a/packages/i18n/locales/ko/messages.po +++ b/packages/i18n/locales/ko/messages.po @@ -2413,6 +2413,9 @@ msgstr "변경 로그로 돌아가기" msgid "Back to checklist" msgstr "체크리스트로 돌아가기" +msgid "Back to current" +msgstr "현재 버전으로 돌아가기" + msgid "Back to GitHub issues" msgstr "GitHub 이슈로 돌아가기" @@ -4260,6 +4263,9 @@ msgstr "환경을 저장할 수 없습니다" msgid "Could not rename the session" msgstr "세션 이름을 변경할 수 없습니다" +msgid "Could not rename this page" +msgstr "이 페이지의 이름을 변경할 수 없습니다" + msgid "Could not reopen" msgstr "다시 열 수 없습니다" @@ -10691,6 +10697,9 @@ msgstr "페이지를 찾을 수 없습니다" msgid "Page Not Found" msgstr "페이지를 찾을 수 없음" +msgid "Page title" +msgstr "페이지 제목" + msgid "Page views" msgstr "페이지뷰" @@ -11913,6 +11922,9 @@ msgstr "그룹 이름 변경" msgid "Rename host" msgstr "호스트 이름 변경" +msgid "Rename page" +msgstr "페이지 이름 변경" + msgid "Rename session" msgstr "세션 이름 변경" @@ -14973,6 +14985,9 @@ msgstr "대상 호스트" msgid "The team invitation has either expired or doesn't exist. Request a new link from the team owner or check the URL to make sure it is entered correctly." msgstr "팀 초대가 만료되었거나 존재하지 않습니다. 팀 소유자에게 새 링크를 요청하거나 URL이 올바르게 입력되었는지 확인하세요." +msgid "The title shows everywhere this page is listed and in its link preview." +msgstr "제목은 이 페이지가 표시되는 모든 곳과 링크 미리보기에 나타납니다." + msgid "The web agents UI is a read-only preview for now." msgstr "웹 에이전트 UI는 아직 읽기 전용 미리 보기입니다." @@ -16779,6 +16794,9 @@ msgstr "다른 멤버가 있는 조직의 유일한 소유자입니다. 먼저 msgid "You are unranked until {0} days carry usage. Scoring starts once the board can see a full picture." msgstr "{0}일치 사용 기록이 쌓이기 전까지는 순위가 없습니다. 전체 그림이 보이면 점수 산정이 시작됩니다." +msgid "You are viewing version {version}, read-only" +msgstr "버전 {version}을 읽기 전용으로 보고 있습니다" + msgid "You can attach up to {MAX_ATTACHMENTS} files" msgstr "파일은 최대 {MAX_ATTACHMENTS}개까지 첨부할 수 있습니다" diff --git a/packages/i18n/locales/nl/messages.po b/packages/i18n/locales/nl/messages.po index a371864cf3d..2c65a41a496 100644 --- a/packages/i18n/locales/nl/messages.po +++ b/packages/i18n/locales/nl/messages.po @@ -2413,6 +2413,9 @@ msgstr "Terug naar changelog" msgid "Back to checklist" msgstr "Terug naar checklist" +msgid "Back to current" +msgstr "Terug naar huidige" + msgid "Back to GitHub issues" msgstr "Terug naar GitHub-issues" @@ -4260,6 +4263,9 @@ msgstr "Omgeving kon niet worden opgeslagen" msgid "Could not rename the session" msgstr "Kan de naam van de sessie niet wijzigen" +msgid "Could not rename this page" +msgstr "Kan deze pagina niet hernoemen" + msgid "Could not reopen" msgstr "Kan niet heropenen" @@ -10691,6 +10697,9 @@ msgstr "Pagina niet gevonden" msgid "Page Not Found" msgstr "Pagina niet gevonden" +msgid "Page title" +msgstr "Paginatitel" + msgid "Page views" msgstr "Paginaweergaven" @@ -11913,6 +11922,9 @@ msgstr "Groepsnaam wijzigen" msgid "Rename host" msgstr "Host hernoemen" +msgid "Rename page" +msgstr "Pagina hernoemen" + msgid "Rename session" msgstr "Sessienaam wijzigen" @@ -14973,6 +14985,9 @@ msgstr "de doelhost" msgid "The team invitation has either expired or doesn't exist. Request a new link from the team owner or check the URL to make sure it is entered correctly." msgstr "De teamuitnodiging is verlopen of bestaat niet. Vraag een nieuwe link aan bij de eigenaar van het team of controleer of de URL klopt." +msgid "The title shows everywhere this page is listed and in its link preview." +msgstr "De titel verschijnt overal waar deze pagina wordt vermeld en in het linkvoorbeeld." + msgid "The web agents UI is a read-only preview for now." msgstr "De web-UI voor agents is voorlopig een alleen-lezen preview." @@ -16779,6 +16794,9 @@ msgstr "Je bent de enige eigenaar van een organisatie met andere leden. Draag he msgid "You are unranked until {0} days carry usage. Scoring starts once the board can see a full picture." msgstr "Je hebt geen positie tot {0} dagen gebruik tonen. De score start zodra het volledige beeld zichtbaar is." +msgid "You are viewing version {version}, read-only" +msgstr "Je bekijkt versie {version}, alleen-lezen" + msgid "You can attach up to {MAX_ATTACHMENTS} files" msgstr "Je kunt maximaal {MAX_ATTACHMENTS} bestanden toevoegen" diff --git a/packages/i18n/locales/pl/messages.po b/packages/i18n/locales/pl/messages.po index 8a5b7508956..3a6f2f89b0e 100644 --- a/packages/i18n/locales/pl/messages.po +++ b/packages/i18n/locales/pl/messages.po @@ -2413,6 +2413,9 @@ msgstr "Wróć do changeloga" msgid "Back to checklist" msgstr "Wróć do listy kontrolnej" +msgid "Back to current" +msgstr "Wróć do bieżącej" + msgid "Back to GitHub issues" msgstr "Wróć do zgłoszeń GitHub" @@ -4260,6 +4263,9 @@ msgstr "Nie udało się zapisać środowiska" msgid "Could not rename the session" msgstr "Nie można zmienić nazwy sesji" +msgid "Could not rename this page" +msgstr "Nie udało się zmienić nazwy tej strony" + msgid "Could not reopen" msgstr "Nie udało się otworzyć ponownie" @@ -10691,6 +10697,9 @@ msgstr "Nie znaleziono strony" msgid "Page Not Found" msgstr "Nie znaleziono strony" +msgid "Page title" +msgstr "Tytuł strony" + msgid "Page views" msgstr "Odsłony" @@ -11913,6 +11922,9 @@ msgstr "Zmień nazwę grupy" msgid "Rename host" msgstr "Zmień nazwę hosta" +msgid "Rename page" +msgstr "Zmień nazwę strony" + msgid "Rename session" msgstr "Zmień nazwę sesji" @@ -14973,6 +14985,9 @@ msgstr "host docelowy" msgid "The team invitation has either expired or doesn't exist. Request a new link from the team owner or check the URL to make sure it is entered correctly." msgstr "Zaproszenie do zespołu wygasło lub nie istnieje. Poproś właściciela zespołu o nowy link albo sprawdź, czy adres URL jest poprawny." +msgid "The title shows everywhere this page is listed and in its link preview." +msgstr "Tytuł pojawia się wszędzie tam, gdzie ta strona jest wymieniona, oraz w podglądzie linku." + msgid "The web agents UI is a read-only preview for now." msgstr "Webowy interfejs agentów jest na razie podglądem tylko do odczytu." @@ -16779,6 +16794,9 @@ msgstr "Jesteś jedynym właścicielem organizacji, która ma innych członków. msgid "You are unranked until {0} days carry usage. Scoring starts once the board can see a full picture." msgstr "Nie masz rangi, dopóki nie uzbiera się dni z aktywnością: {0}. Punktacja rusza, gdy widać pełny obraz." +msgid "You are viewing version {version}, read-only" +msgstr "Przeglądasz wersję {version}, tylko do odczytu" + msgid "You can attach up to {MAX_ATTACHMENTS} files" msgstr "Możesz dołączyć maksymalnie {MAX_ATTACHMENTS} plików" diff --git a/packages/i18n/locales/pt-BR/messages.po b/packages/i18n/locales/pt-BR/messages.po index e776d97b92a..32b42646cb8 100644 --- a/packages/i18n/locales/pt-BR/messages.po +++ b/packages/i18n/locales/pt-BR/messages.po @@ -2413,6 +2413,9 @@ msgstr "Voltar para o changelog" msgid "Back to checklist" msgstr "Voltar para a lista" +msgid "Back to current" +msgstr "Voltar para a atual" + msgid "Back to GitHub issues" msgstr "Voltar para as issues do GitHub" @@ -4260,6 +4263,9 @@ msgstr "Não foi possível salvar o ambiente" msgid "Could not rename the session" msgstr "Não foi possível renomear a sessão" +msgid "Could not rename this page" +msgstr "Não foi possível renomear esta página" + msgid "Could not reopen" msgstr "Não foi possível reabrir" @@ -10691,6 +10697,9 @@ msgstr "Página não encontrada" msgid "Page Not Found" msgstr "Página não encontrada" +msgid "Page title" +msgstr "Título da página" + msgid "Page views" msgstr "Visualizações de página" @@ -11913,6 +11922,9 @@ msgstr "Renomear grupo" msgid "Rename host" msgstr "Renomear host" +msgid "Rename page" +msgstr "Renomear página" + msgid "Rename session" msgstr "Renomear sessão" @@ -14973,6 +14985,9 @@ msgstr "o host de destino" msgid "The team invitation has either expired or doesn't exist. Request a new link from the team owner or check the URL to make sure it is entered correctly." msgstr "O convite da equipe expirou ou não existe. Peça um novo link a quem é dono da equipe ou confira se a URL está correta." +msgid "The title shows everywhere this page is listed and in its link preview." +msgstr "O título aparece em todos os lugares onde esta página é listada e na prévia do link." + msgid "The web agents UI is a read-only preview for now." msgstr "A interface web de agentes é uma prévia somente leitura por enquanto." @@ -16779,6 +16794,9 @@ msgstr "Você é o único proprietário de uma organização que tem outros memb msgid "You are unranked until {0} days carry usage. Scoring starts once the board can see a full picture." msgstr "Você fica sem classificação até que {0} dias tenham atividade. A pontuação começa quando o quadro enxerga o quadro completo." +msgid "You are viewing version {version}, read-only" +msgstr "Você está vendo a versão {version}, somente leitura" + msgid "You can attach up to {MAX_ATTACHMENTS} files" msgstr "Você pode anexar até {MAX_ATTACHMENTS} arquivos" diff --git a/packages/i18n/locales/ru/messages.po b/packages/i18n/locales/ru/messages.po index fed05a2fb5a..725b471af84 100644 --- a/packages/i18n/locales/ru/messages.po +++ b/packages/i18n/locales/ru/messages.po @@ -2413,6 +2413,9 @@ msgstr "К changelog" msgid "Back to checklist" msgstr "К списку задач" +msgid "Back to current" +msgstr "Вернуться к текущей" + msgid "Back to GitHub issues" msgstr "К задачам GitHub" @@ -4260,6 +4263,9 @@ msgstr "Не удалось сохранить окружение" msgid "Could not rename the session" msgstr "Не удалось переименовать сессию" +msgid "Could not rename this page" +msgstr "Не удалось переименовать эту страницу" + msgid "Could not reopen" msgstr "Не удалось открыть заново" @@ -10691,6 +10697,9 @@ msgstr "Страница не найдена" msgid "Page Not Found" msgstr "Страница не найдена" +msgid "Page title" +msgstr "Заголовок страницы" + msgid "Page views" msgstr "Просмотры страниц" @@ -11913,6 +11922,9 @@ msgstr "Переименовать группу" msgid "Rename host" msgstr "Переименовать хост" +msgid "Rename page" +msgstr "Переименовать страницу" + msgid "Rename session" msgstr "Переименовать сессию" @@ -14973,6 +14985,9 @@ msgstr "целевой хост" msgid "The team invitation has either expired or doesn't exist. Request a new link from the team owner or check the URL to make sure it is entered correctly." msgstr "Приглашение в команду истекло или не существует. Запросите новую ссылку у владельца команды или проверьте правильность URL." +msgid "The title shows everywhere this page is listed and in its link preview." +msgstr "Заголовок отображается везде, где указана эта страница, и в превью ссылки." + msgid "The web agents UI is a read-only preview for now." msgstr "Веб-интерфейс агентов пока доступен только для просмотра." @@ -16779,6 +16794,9 @@ msgstr "Вы единственный владелец организации, msgid "You are unranked until {0} days carry usage. Scoring starts once the board can see a full picture." msgstr "Вы вне рейтинга, пока не наберётся дней с активностью: {0}. Подсчёт очков начнётся, когда картина станет полной." +msgid "You are viewing version {version}, read-only" +msgstr "Вы просматриваете версию {version}, только для чтения" + msgid "You can attach up to {MAX_ATTACHMENTS} files" msgstr "Можно прикрепить не более {MAX_ATTACHMENTS} файлов" diff --git a/packages/i18n/locales/tr/messages.po b/packages/i18n/locales/tr/messages.po index 6596854b043..0404def1031 100644 --- a/packages/i18n/locales/tr/messages.po +++ b/packages/i18n/locales/tr/messages.po @@ -2413,6 +2413,9 @@ msgstr "Değişiklik günlüğüne dön" msgid "Back to checklist" msgstr "Kontrol listesine dön" +msgid "Back to current" +msgstr "Güncel sürüme dön" + msgid "Back to GitHub issues" msgstr "GitHub issue'larına dön" @@ -4260,6 +4263,9 @@ msgstr "Ortam kaydedilemedi" msgid "Could not rename the session" msgstr "Oturum yeniden adlandırılamadı" +msgid "Could not rename this page" +msgstr "Bu sayfa yeniden adlandırılamadı" + msgid "Could not reopen" msgstr "Yeniden açılamadı" @@ -10691,6 +10697,9 @@ msgstr "Sayfa bulunamadı" msgid "Page Not Found" msgstr "Sayfa bulunamadı" +msgid "Page title" +msgstr "Sayfa başlığı" + msgid "Page views" msgstr "Sayfa görüntülemeleri" @@ -11913,6 +11922,9 @@ msgstr "Grubu yeniden adlandır" msgid "Rename host" msgstr "Host'u yeniden adlandır" +msgid "Rename page" +msgstr "Sayfayı yeniden adlandır" + msgid "Rename session" msgstr "Oturumu yeniden adlandır" @@ -14973,6 +14985,9 @@ msgstr "hedef host" msgid "The team invitation has either expired or doesn't exist. Request a new link from the team owner or check the URL to make sure it is entered correctly." msgstr "Takım daveti ya süresi dolmuş ya da mevcut değil. Takım sahibinden yeni bir bağlantı isteyin veya URL'nin doğru girildiğini kontrol edin." +msgid "The title shows everywhere this page is listed and in its link preview." +msgstr "Başlık, bu sayfanın listelendiği her yerde ve bağlantı önizlemesinde görünür." + msgid "The web agents UI is a read-only preview for now." msgstr "Web ajan arayüzü şimdilik salt okunur bir önizlemedir." @@ -16779,6 +16794,9 @@ msgstr "Başka üyeleri olan bir kuruluşun tek sahibisiniz. Önce sahipliği de msgid "You are unranked until {0} days carry usage. Scoring starts once the board can see a full picture." msgstr "{0} gün kullanım birikene kadar sıralama dışısın. Tablo bütün resmi gördüğünde puanlama başlar." +msgid "You are viewing version {version}, read-only" +msgstr "Sürüm {version} görüntüleniyor, salt okunur" + msgid "You can attach up to {MAX_ATTACHMENTS} files" msgstr "En fazla {MAX_ATTACHMENTS} dosya ekleyebilirsiniz" diff --git a/packages/i18n/locales/vi/messages.po b/packages/i18n/locales/vi/messages.po index 969a1acff8d..c5bb3c39a22 100644 --- a/packages/i18n/locales/vi/messages.po +++ b/packages/i18n/locales/vi/messages.po @@ -2413,6 +2413,9 @@ msgstr "Quay lại Changelog" msgid "Back to checklist" msgstr "Quay lại danh sách kiểm" +msgid "Back to current" +msgstr "Quay lại bản hiện tại" + msgid "Back to GitHub issues" msgstr "Quay lại issue GitHub" @@ -4260,6 +4263,9 @@ msgstr "Không thể lưu environment" msgid "Could not rename the session" msgstr "Không thể đổi tên phiên" +msgid "Could not rename this page" +msgstr "Không thể đổi tên trang này" + msgid "Could not reopen" msgstr "Không thể mở lại" @@ -10691,6 +10697,9 @@ msgstr "Không tìm thấy trang" msgid "Page Not Found" msgstr "Không tìm thấy trang" +msgid "Page title" +msgstr "Tiêu đề trang" + msgid "Page views" msgstr "Lượt xem trang" @@ -11913,6 +11922,9 @@ msgstr "Đổi tên nhóm" msgid "Rename host" msgstr "Đổi tên host" +msgid "Rename page" +msgstr "Đổi tên trang" + msgid "Rename session" msgstr "Đổi tên phiên" @@ -14973,6 +14985,9 @@ msgstr "host đích" msgid "The team invitation has either expired or doesn't exist. Request a new link from the team owner or check the URL to make sure it is entered correctly." msgstr "Lời mời vào nhóm đã hết hạn hoặc không tồn tại. Hãy xin liên kết mới từ chủ sở hữu nhóm hoặc kiểm tra lại URL xem đã nhập đúng chưa." +msgid "The title shows everywhere this page is listed and in its link preview." +msgstr "Tiêu đề hiển thị ở mọi nơi trang này được liệt kê và trong bản xem trước liên kết." + msgid "The web agents UI is a read-only preview for now." msgstr "Giao diện agent trên web hiện chỉ là bản xem trước chỉ đọc." @@ -16779,6 +16794,9 @@ msgstr "Bạn là chủ sở hữu duy nhất của một tổ chức còn thàn msgid "You are unranked until {0} days carry usage. Scoring starts once the board can see a full picture." msgstr "Bạn chưa được xếp hạng cho đến khi có {0} ngày hoạt động. Việc tính điểm bắt đầu khi thấy được bức tranh đầy đủ." +msgid "You are viewing version {version}, read-only" +msgstr "Bạn đang xem phiên bản {version}, chỉ đọc" + msgid "You can attach up to {MAX_ATTACHMENTS} files" msgstr "Bạn có thể đính kèm tối đa {MAX_ATTACHMENTS} tệp" diff --git a/packages/i18n/locales/zh-CN/messages.po b/packages/i18n/locales/zh-CN/messages.po index 6f9b080d9db..6abc4bfc60d 100644 --- a/packages/i18n/locales/zh-CN/messages.po +++ b/packages/i18n/locales/zh-CN/messages.po @@ -2413,6 +2413,9 @@ msgstr "返回更新日志" msgid "Back to checklist" msgstr "返回清单" +msgid "Back to current" +msgstr "返回当前版本" + msgid "Back to GitHub issues" msgstr "返回GitHub议题" @@ -4260,6 +4263,9 @@ msgstr "无法保存环境" msgid "Could not rename the session" msgstr "无法重命名会话" +msgid "Could not rename this page" +msgstr "无法重命名此页面" + msgid "Could not reopen" msgstr "无法重新打开" @@ -10691,6 +10697,9 @@ msgstr "未找到页面" msgid "Page Not Found" msgstr "页面未找到" +msgid "Page title" +msgstr "页面标题" + msgid "Page views" msgstr "页面浏览" @@ -11913,6 +11922,9 @@ msgstr "重命名分组" msgid "Rename host" msgstr "重命名主机" +msgid "Rename page" +msgstr "重命名页面" + msgid "Rename session" msgstr "重命名会话" @@ -14973,6 +14985,9 @@ msgstr "目标主机" msgid "The team invitation has either expired or doesn't exist. Request a new link from the team owner or check the URL to make sure it is entered correctly." msgstr "该团队邀请已过期或不存在。请向团队所有者索取新链接,或检查URL是否输入正确。" +msgid "The title shows everywhere this page is listed and in its link preview." +msgstr "标题会显示在此页面列出的所有位置以及链接预览中。" + msgid "The web agents UI is a read-only preview for now." msgstr "网页版智能体界面目前是只读预览。" @@ -16779,6 +16794,9 @@ msgstr "你是某个仍有其他成员的组织的唯一所有者。请先转移 msgid "You are unranked until {0} days carry usage. Scoring starts once the board can see a full picture." msgstr "在累计 {0} 天使用记录之前你不参与排名。等看板掌握完整情况后才会开始计分。" +msgid "You are viewing version {version}, read-only" +msgstr "您正在查看版本 {version},只读" + msgid "You can attach up to {MAX_ATTACHMENTS} files" msgstr "最多可附加{MAX_ATTACHMENTS}个文件" diff --git a/packages/i18n/locales/zh-TW/messages.po b/packages/i18n/locales/zh-TW/messages.po index 154b5a90c34..226f2c54099 100644 --- a/packages/i18n/locales/zh-TW/messages.po +++ b/packages/i18n/locales/zh-TW/messages.po @@ -2413,6 +2413,9 @@ msgstr "返回更新日誌" msgid "Back to checklist" msgstr "返回清單" +msgid "Back to current" +msgstr "返回目前版本" + msgid "Back to GitHub issues" msgstr "返回GitHub議題" @@ -4260,6 +4263,9 @@ msgstr "無法儲存環境" msgid "Could not rename the session" msgstr "無法重新命名會話" +msgid "Could not rename this page" +msgstr "無法重新命名此頁面" + msgid "Could not reopen" msgstr "無法重新開啟" @@ -10691,6 +10697,9 @@ msgstr "未找到頁面" msgid "Page Not Found" msgstr "頁面未找到" +msgid "Page title" +msgstr "頁面標題" + msgid "Page views" msgstr "頁面瀏覽" @@ -11913,6 +11922,9 @@ msgstr "重新命名群組" msgid "Rename host" msgstr "重新命名主機" +msgid "Rename page" +msgstr "重新命名頁面" + msgid "Rename session" msgstr "重新命名會話" @@ -14973,6 +14985,9 @@ msgstr "目標主機" msgid "The team invitation has either expired or doesn't exist. Request a new link from the team owner or check the URL to make sure it is entered correctly." msgstr "該團隊邀請已過期或不存在。請向團隊擁有者索取新連結,或檢查URL是否輸入正確。" +msgid "The title shows everywhere this page is listed and in its link preview." +msgstr "標題會顯示在此頁面列出的所有位置以及連結預覽中。" + msgid "The web agents UI is a read-only preview for now." msgstr "網頁版代理程式介面目前是隻讀預覽。" @@ -16779,6 +16794,9 @@ msgstr "你是某個仍有其他成員的組織的唯一擁有者。請先轉移 msgid "You are unranked until {0} days carry usage. Scoring starts once the board can see a full picture." msgstr "在累計 {0} 天使用紀錄之前你不參與排名。等看板掌握完整情況後才會開始計分。" +msgid "You are viewing version {version}, read-only" +msgstr "您正在檢視版本 {version},唯讀" + msgid "You can attach up to {MAX_ATTACHMENTS} files" msgstr "最多可附加{MAX_ATTACHMENTS}個檔案" diff --git a/packages/trpc/src/lib/r2.ts b/packages/trpc/src/lib/r2.ts index 4fcc29eafbd..1ea7b89229f 100644 --- a/packages/trpc/src/lib/r2.ts +++ b/packages/trpc/src/lib/r2.ts @@ -3,6 +3,7 @@ import { DeleteObjectsCommand, GetObjectCommand, HeadObjectCommand, + ListObjectsV2Command, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; @@ -159,6 +160,28 @@ export async function objectExists( return (await headObject(key, { bucket })) !== null; } +export async function listObjectKeys( + prefix: string, + { bucket = "private" }: { bucket?: Bucket } = {}, +): Promise> { + const keys = new Set(); + let token: string | undefined; + do { + const result = await s3().send( + new ListObjectsV2Command({ + Bucket: bucketName(bucket), + Prefix: prefix, + ContinuationToken: token, + }), + ); + for (const entry of result.Contents ?? []) { + if (entry.Key) keys.add(entry.Key); + } + token = result.IsTruncated ? result.NextContinuationToken : undefined; + } while (token); + return keys; +} + /** Deletes are idempotent and batched; a missing key is not an error. */ export async function deleteObjects( keys: readonly string[], diff --git a/packages/trpc/src/router/page/page.ts b/packages/trpc/src/router/page/page.ts index 93f58c3d4e4..a883f941631 100644 --- a/packages/trpc/src/router/page/page.ts +++ b/packages/trpc/src/router/page/page.ts @@ -22,7 +22,12 @@ import { TRPCError, type TRPCRouterRecord } from "@trpc/server"; import { and, desc, eq, inArray, or, type SQL, sql } from "drizzle-orm"; import { z } from "zod"; import { env } from "../../env"; -import { deleteObjects, objectExists, presignedGetUrl } from "../../lib/r2"; +import { + deleteObjects, + listObjectKeys, + objectExists, + presignedGetUrl, +} from "../../lib/r2"; import { protectedProcedure, publicProcedure, userError } from "../../trpc"; import { requireActiveOrgMembership } from "../utils/active-org"; import { assertPageReadable, assertPageWritable } from "./access"; @@ -43,6 +48,7 @@ import { setPageVisibilitySchema, setPageWatchSchema, setSharedVersionSchema, + updatePageSchema, } from "./schema"; import { resolveSharedVersion, servedVersion } from "./shared-version"; import { @@ -423,6 +429,40 @@ export const pageRouter = { }; }), + update: protectedProcedure + .input(updatePageSchema) + .mutation(async ({ ctx, input }) => { + const organizationId = await requireActiveOrgMembership(ctx); + const userId = ctx.session.user.id; + const page = await loadPage({ id: input.id, organizationId, userId }); + assertPageWritable(page, userId); + + const [updated] = await db + .update(pages) + .set({ + ...(input.title !== undefined ? { title: input.title } : {}), + ...(input.description !== undefined + ? { description: input.description } + : {}), + }) + .where(eq(pages.id, page.id)) + .returning(); + + if (!updated) { + throw userError({ + code: "NOT_FOUND", + message: "Page not found", + i18nKey: "serverError.page.pageNotFound", + }); + } + + return { + id: updated.id, + title: updated.title, + description: updated.description, + }; + }), + setVisibility: protectedProcedure .input(setPageVisibilitySchema) .mutation(async ({ ctx, input }) => { @@ -632,7 +672,7 @@ export const pageRouter = { userId: ctx.session.user.id, }); - return await db + const rows = await db .select({ version: pageVersions.version, label: pageVersions.label, @@ -645,6 +685,22 @@ export const pageRouter = { .from(pageVersions) .where(eq(pageVersions.pageId, page.id)) .orderBy(desc(pageVersions.version)); + + const captured = await listObjectKeys(`pages/${page.id}/versions/`); + const baseUrl = env.USERCONTENT_URL; + return await Promise.all( + rows.map(async (row) => ({ + ...row, + thumbnailUrl: captured.has(pageThumbnailKey(page.id, row.version)) + ? pageThumbnailUrl({ + baseUrl, + pageId: page.id, + version: row.version, + ticket: await mintPageTicket(page, { version: row.version }), + }) + : null, + })), + ); }), pull: protectedProcedure diff --git a/packages/trpc/src/router/page/schema.ts b/packages/trpc/src/router/page/schema.ts index a48da0c0760..16ec3862e84 100644 --- a/packages/trpc/src/router/page/schema.ts +++ b/packages/trpc/src/router/page/schema.ts @@ -154,3 +154,14 @@ export const setPageWatchSchema = z.object({ export const clearPageWatchSchema = z.object({ id: pageFields.id }); export const publicPageSchema = z.object({ slug: pageFields.slug }); + +export const updatePageSchema = z + .object({ + id: pageFields.id, + title: pageFields.title.optional(), + description: pageFields.description.nullable().optional(), + }) + .refine( + (value) => value.title !== undefined || value.description !== undefined, + { message: "Provide a title or a description to change" }, + ); diff --git a/packages/ui/src/components/PageComments/components/PageHeader/PageHeader.tsx b/packages/ui/src/components/PageComments/components/PageHeader/PageHeader.tsx index aa629c8de64..87666421d9a 100644 --- a/packages/ui/src/components/PageComments/components/PageHeader/PageHeader.tsx +++ b/packages/ui/src/components/PageComments/components/PageHeader/PageHeader.tsx @@ -1,15 +1,16 @@ "use client"; -import { Trans, useLingui } from "@lingui/react/macro"; +import { Trans } from "@lingui/react/macro"; import { Share2 } from "lucide-react"; import { type ReactNode, useState } from "react"; import { cn } from "../../../../lib/utils"; import { Button } from "../../../ui/button"; -import { toast } from "../../../ui/sonner"; import { DeletePageDialog } from "./components/DeletePageDialog"; import { PagePublicBanner } from "./components/PagePublicBanner"; import { PageSharePopover } from "./components/PageSharePopover"; import { PageTitleMenu } from "./components/PageTitleMenu"; +import { PageVersionBanner } from "./components/PageVersionBanner"; +import { RenamePageDialog } from "./components/RenamePageDialog"; import type { PageHeaderActions, PageHeaderPage, @@ -35,30 +36,19 @@ export function PageHeader({ onSetVisibility, onSetSharedVersion, onDelete, + onRename, + onRefresh, + onPreviewVersion, + previewVersion = null, }: PageHeaderProps) { - const { t } = useLingui(); const [menuOpen, setMenuOpen] = useState(false); const [shareOpen, setShareOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); + const [renameOpen, setRenameOpen] = useState(false); const isOwner = currentUserId !== undefined && currentUserId === page.createdByUserId; - const pickVersion = async (version: number) => { - if (page.sharedVersion !== null && version === page.sharedVersion) return; - try { - await onSetSharedVersion(version); - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : t({ - message: "Could not change the shared version", - }), - ); - } - }; - return ( <>
{ - setMenuOpen(false); - void pickVersion(version); - }} + onRename={() => setRenameOpen(true)} + onRefresh={onRefresh} + onPreviewVersion={onPreviewVersion} /> {!isOwner && page.owner ? ( @@ -121,9 +110,21 @@ export function PageHeader({ versionCount={versions.length} onConfirm={onDelete} /> + +
- {isOwner && page.visibility === "everyone" ? ( + {previewVersion !== null ? ( + onPreviewVersion(null)} + /> + ) : isOwner && page.visibility === "everyone" ? ( setShareOpen(true)} diff --git a/packages/ui/src/components/PageComments/components/PageHeader/components/PageTitleMenu/PageTitleMenu.tsx b/packages/ui/src/components/PageComments/components/PageHeader/components/PageTitleMenu/PageTitleMenu.tsx index b0a08a101a4..8aa0bbf8fb4 100644 --- a/packages/ui/src/components/PageComments/components/PageHeader/components/PageTitleMenu/PageTitleMenu.tsx +++ b/packages/ui/src/components/PageComments/components/PageHeader/components/PageTitleMenu/PageTitleMenu.tsx @@ -2,10 +2,11 @@ import { Trans, useLingui } from "@lingui/react/macro"; import { - Check, ChevronDown, FileText, History, + Pencil, + RotateCw, Share2, Trash2, } from "lucide-react"; @@ -36,7 +37,9 @@ interface PageTitleMenuProps { onOpenChange: (open: boolean) => void; onShare: () => void; onDelete: () => void; - onPickVersion: (version: number) => void; + onRename: () => void; + onRefresh: () => void; + onPreviewVersion: (version: number) => void; compact?: boolean; } @@ -49,7 +52,9 @@ export function PageTitleMenu({ onOpenChange, onShare, onDelete, - onPickVersion, + onRename, + onRefresh, + onPreviewVersion, compact = false, }: PageTitleMenuProps) { const { t } = useLingui(); @@ -60,6 +65,12 @@ export function PageTitleMenu({ const itemClass = compact ? "text-xs" : undefined; const iconClass = compact ? "size-3.5" : undefined; + const run = (action: () => void) => (event: Event) => { + event.preventDefault(); + onOpenChange(false); + action(); + }; + return ( @@ -101,13 +112,14 @@ export function PageTitleMenu({ - { - event.preventDefault(); - onShare(); - }} - > + {editable ? ( + + + Rename + + ) : null} + + Share @@ -116,11 +128,11 @@ export function PageTitleMenu({ Version history - + {versions.length} - + {versions.length === 0 ? ( No versions yet @@ -129,38 +141,56 @@ export function PageTitleMenu({ versions.map((entry) => ( onPickVersion(entry.version)} + className="gap-2.5 p-1.5" + onSelect={run(() => onPreviewVersion(entry.version))} > - - {entry.label ?? - t({ - message: `Version ${entry.version}`, - })} - + {entry.thumbnailUrl ? ( + + ) : ( + + + + )} + + + {entry.label ?? page.title} + + + Version {entry.version} + {" · "} {relativeTime(entry.createdAt)} + {entry.version === served ? ( + <> + {" · "} + + Current + + + ) : null} - {entry.version === served ? ( - - ) : null} )) )} + + + Refresh + + {editable ? ( <> { - event.preventDefault(); - onDelete(); - }} + onSelect={run(onDelete)} > Delete page diff --git a/packages/ui/src/components/PageComments/components/PageHeader/components/PageVersionBanner/PageVersionBanner.tsx b/packages/ui/src/components/PageComments/components/PageHeader/components/PageVersionBanner/PageVersionBanner.tsx new file mode 100644 index 00000000000..86016fc720a --- /dev/null +++ b/packages/ui/src/components/PageComments/components/PageHeader/components/PageVersionBanner/PageVersionBanner.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { Trans } from "@lingui/react/macro"; +import { History } from "lucide-react"; +import { Button } from "../../../../../ui/button"; + +interface PageVersionBannerProps { + version: number; + onExit: () => void; +} + +export function PageVersionBanner({ version, onExit }: PageVersionBannerProps) { + return ( +
+ + + You are viewing version {version}, read-only + + +
+ ); +} diff --git a/packages/ui/src/components/PageComments/components/PageHeader/components/PageVersionBanner/index.ts b/packages/ui/src/components/PageComments/components/PageHeader/components/PageVersionBanner/index.ts new file mode 100644 index 00000000000..d2c4d11f26c --- /dev/null +++ b/packages/ui/src/components/PageComments/components/PageHeader/components/PageVersionBanner/index.ts @@ -0,0 +1 @@ +export { PageVersionBanner } from "./PageVersionBanner"; diff --git a/packages/ui/src/components/PageComments/components/PageHeader/components/RenamePageDialog/RenamePageDialog.tsx b/packages/ui/src/components/PageComments/components/PageHeader/components/RenamePageDialog/RenamePageDialog.tsx new file mode 100644 index 00000000000..2f63ca40676 --- /dev/null +++ b/packages/ui/src/components/PageComments/components/PageHeader/components/RenamePageDialog/RenamePageDialog.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { Trans, useLingui } from "@lingui/react/macro"; +import { errorMessage } from "@superset/i18n/errors"; +import { useEffect, useState } from "react"; +import { Button } from "../../../../../ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../../../../../ui/dialog"; +import { Input } from "../../../../../ui/input"; +import { toast } from "../../../../../ui/sonner"; + +interface RenamePageDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + onRename: (title: string) => Promise; +} + +export function RenamePageDialog({ + open, + onOpenChange, + title, + onRename, +}: RenamePageDialogProps) { + const { t } = useLingui(); + const [value, setValue] = useState(title); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (open) setValue(title); + }, [open, title]); + + const trimmed = value.trim(); + const submittable = trimmed.length > 0 && trimmed !== title && !busy; + + const submit = async () => { + if (!submittable) return; + setBusy(true); + try { + await onRename(trimmed); + onOpenChange(false); + } catch (error) { + toast.error( + errorMessage(error, t({ message: "Could not rename this page" })), + ); + } finally { + setBusy(false); + } + }; + + return ( + + + + + Rename page + + + + The title shows everywhere this page is listed and in its link + preview. + + + + + setValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void submit(); + } + }} + /> + + + + + + + + ); +} diff --git a/packages/ui/src/components/PageComments/components/PageHeader/components/RenamePageDialog/index.ts b/packages/ui/src/components/PageComments/components/PageHeader/components/RenamePageDialog/index.ts new file mode 100644 index 00000000000..697ca7bee71 --- /dev/null +++ b/packages/ui/src/components/PageComments/components/PageHeader/components/RenamePageDialog/index.ts @@ -0,0 +1 @@ +export { RenamePageDialog } from "./RenamePageDialog"; diff --git a/packages/ui/src/components/PageComments/components/PageHeader/index.ts b/packages/ui/src/components/PageComments/components/PageHeader/index.ts index 10d2d40c49f..e1aba038167 100644 --- a/packages/ui/src/components/PageComments/components/PageHeader/index.ts +++ b/packages/ui/src/components/PageComments/components/PageHeader/index.ts @@ -1,6 +1,7 @@ export { DeletePageDialog } from "./components/DeletePageDialog"; export { PageSharePopover } from "./components/PageSharePopover"; export { PageTitleMenu } from "./components/PageTitleMenu"; +export { RenamePageDialog } from "./components/RenamePageDialog"; export { PageHeader } from "./PageHeader"; export type { PageHeaderActions, diff --git a/packages/ui/src/components/PageComments/components/PageHeader/types.ts b/packages/ui/src/components/PageComments/components/PageHeader/types.ts index e155cd66299..9e62964a2d9 100644 --- a/packages/ui/src/components/PageComments/components/PageHeader/types.ts +++ b/packages/ui/src/components/PageComments/components/PageHeader/types.ts @@ -11,6 +11,7 @@ export interface PageHeaderVersion { version: number; label: string | null; createdAt: Date | string; + thumbnailUrl?: string | null; } export interface PageHeaderPage { @@ -30,4 +31,8 @@ export interface PageHeaderActions { onSetVisibility: (visibility: PageVisibility) => Promise; onSetSharedVersion: (version: number | null) => Promise; onDelete: () => Promise; + onRename: (title: string) => Promise; + onRefresh: () => void; + onPreviewVersion: (version: number | null) => void; + previewVersion?: number | null; } diff --git a/packages/ui/src/components/PageComments/index.ts b/packages/ui/src/components/PageComments/index.ts index dba22a3684e..54d9a09fc2f 100644 --- a/packages/ui/src/components/PageComments/index.ts +++ b/packages/ui/src/components/PageComments/index.ts @@ -28,6 +28,7 @@ export { PageSharePopover, PageTitleMenu, type PageVisibility, + RenamePageDialog, } from "./components/PageHeader"; export { useFramePointerDown } from "./hooks/useFramePointerDown"; export { From c74deaeb9be177d6d6678ce262243375f499c87a Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Tue, 15 Sep 2026 17:36:50 +0530 Subject: [PATCH 05/10] fix(pages): keep visibility consistent, and stop R2 taking the page down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects found by running the branch against a real local stack. setVisibility wrote the row and then the manifest. The manifest is what the Worker authorizes against, so a failed second write left the two disagreeing for good. The dangerous direction is tightening: the row says org, the manifest still says everyone, and the Worker keeps serving the page to anyone holding the link while the UI reports it private. A failed write now reverts the row before rethrowing. publicView and versions both called into R2 for a thumbnail and let the failure escape. A thumbnail is decoration; losing it should not turn a page view into a 500 or empty the whole version list. Both degrade to "no thumbnail" now. PageFrame uses useState with no "use client". That was latent until this branch exported it from the PageComments barrel, which the web route imports from a server component — every render of /page/[slug] then failed on the directive. PageSharePopover reported errors with a raw error.message. A TRPCError carries an empty message, so a failed visibility change surfaced as a toast with no text in it. errorMessage() is what the rest of these components already use. --- packages/trpc/src/router/page/page.ts | 18 +++++++++++++++--- .../components/PageFrame/PageFrame.tsx | 2 ++ .../PageSharePopover/PageSharePopover.tsx | 12 ++++++------ 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/trpc/src/router/page/page.ts b/packages/trpc/src/router/page/page.ts index a883f941631..838424e7547 100644 --- a/packages/trpc/src/router/page/page.ts +++ b/packages/trpc/src/router/page/page.ts @@ -484,7 +484,15 @@ export const pageRouter = { i18nKey: "serverError.page.pageNotFound", }); } - await writePageManifest(page.id); + try { + await writePageManifest(page.id); + } catch (error) { + await db + .update(pages) + .set({ visibility: page.visibility }) + .where(eq(pages.id, page.id)); + throw error; + } return { id: updated.id, visibility: updated.visibility }; }), @@ -686,7 +694,9 @@ export const pageRouter = { .where(eq(pageVersions.pageId, page.id)) .orderBy(desc(pageVersions.version)); - const captured = await listObjectKeys(`pages/${page.id}/versions/`); + const captured = await listObjectKeys(`pages/${page.id}/versions/`).catch( + () => new Set(), + ); const baseUrl = env.USERCONTENT_URL; return await Promise.all( rows.map(async (row) => ({ @@ -808,7 +818,9 @@ export const pageRouter = { if (version === null) return null; const baseUrl = env.USERCONTENT_URL; - const captured = await objectExists(pageThumbnailKey(page.id, version)); + const captured = await objectExists( + pageThumbnailKey(page.id, version), + ).catch(() => false); return { id: page.id, slug: page.slug, diff --git a/packages/ui/src/components/PageComments/components/PageCommentsView/components/PageFrame/PageFrame.tsx b/packages/ui/src/components/PageComments/components/PageCommentsView/components/PageFrame/PageFrame.tsx index ceab34e4f56..c0a7e4ed97a 100644 --- a/packages/ui/src/components/PageComments/components/PageCommentsView/components/PageFrame/PageFrame.tsx +++ b/packages/ui/src/components/PageComments/components/PageCommentsView/components/PageFrame/PageFrame.tsx @@ -1,3 +1,5 @@ +"use client"; + import { type Ref, useState } from "react"; import { cn } from "../../../../../../lib/utils"; import { Spinner } from "../../../../../ui/spinner"; diff --git a/packages/ui/src/components/PageComments/components/PageHeader/components/PageSharePopover/PageSharePopover.tsx b/packages/ui/src/components/PageComments/components/PageHeader/components/PageSharePopover/PageSharePopover.tsx index 8088e6940c3..a7bcabb96ca 100644 --- a/packages/ui/src/components/PageComments/components/PageHeader/components/PageSharePopover/PageSharePopover.tsx +++ b/packages/ui/src/components/PageComments/components/PageHeader/components/PageSharePopover/PageSharePopover.tsx @@ -1,6 +1,7 @@ "use client"; import { Trans, useLingui } from "@lingui/react/macro"; +import { errorMessage } from "@superset/i18n/errors"; import { getInitials } from "@superset/shared/names"; import { Building2, Check, Globe, Link2, Lock } from "lucide-react"; import { type ReactNode, useCallback, useEffect, useState } from "react"; @@ -95,11 +96,10 @@ export function PageSharePopover({ } catch (error) { setPending(null); toast.error( - error instanceof Error - ? error.message - : t({ - message: "Could not change who can see this page", - }), + errorMessage( + error, + t({ message: "Could not change who can see this page" }), + ), ); } finally { setBusy(false); @@ -111,7 +111,7 @@ export function PageSharePopover({ try { await action(); } catch (error) { - toast.error(error instanceof Error ? error.message : failure); + toast.error(errorMessage(error, failure)); } finally { setBusy(false); } From df0a4bbd48edb35510b3a719a66d63dfd722e955 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Tue, 15 Sep 2026 17:46:48 +0530 Subject: [PATCH 06/10] test(pages): pin the comment routers shut from outside the organization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public pages arrive with this branch, so the property SUPER-2254 asks for — a public reader cannot comment, and therefore cannot reach an agent on someone's machine — now has a test rather than only an argument. Every pageComment procedure is exercised twice: with no session, and signed in with no active org. The database is a Proxy that throws on any access, so the assertion is not merely that the call is refused but that it is refused at the auth layer, before a lookup. Not covered, because it needs a database: the third layer, where a signed-in member of another organization gets NOT_FOUND from the org-scoped lookups in loadReadablePage and loadThread. --- .../router/page-comment/public-access.test.ts | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 packages/trpc/src/router/page-comment/public-access.test.ts diff --git a/packages/trpc/src/router/page-comment/public-access.test.ts b/packages/trpc/src/router/page-comment/public-access.test.ts new file mode 100644 index 00000000000..1133713713a --- /dev/null +++ b/packages/trpc/src/router/page-comment/public-access.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { TRPCError } from "@trpc/server"; + +let dbTouched = false; + +const forbidDb = new Proxy( + {}, + { + get: () => { + dbTouched = true; + throw new Error("reached the database"); + }, + }, +); + +mock.module("@superset/db/client", () => ({ db: forbidDb, dbWs: forbidDb })); +mock.module("../../lib/analytics", () => ({ posthog: { capture: () => {} } })); + +const { pageCommentRouter } = await import("./page-comment"); +const { createCallerFactory, createTRPCContext, createTRPCRouter } = + await import("../../trpc"); + +const router = createTRPCRouter({ pageComment: pageCommentRouter }); + +const callerFor = (session: unknown) => + createCallerFactory(router)( + createTRPCContext({ + session: session as never, + auth: {} as never, + headers: new Headers(), + }), + ); + +const PAGE_ID = "00000000-0000-4000-8000-000000000001"; +const THREAD_ID = "00000000-0000-4000-8000-000000000002"; +const COMMENT_ID = "00000000-0000-4000-8000-000000000003"; + +const everyProcedure = (caller: ReturnType) => + Object.entries({ + list: () => caller.pageComment.list({ pageId: PAGE_ID }), + create: () => + caller.pageComment.create({ + pageId: PAGE_ID, + version: 1, + anchorKind: "page", + anchor: null, + anchorText: null, + body: "rewrite this section", + }), + reply: () => caller.pageComment.reply({ threadId: THREAD_ID, body: "ok" }), + edit: () => caller.pageComment.edit({ commentId: COMMENT_ID, body: "ok" }), + resolve: () => + caller.pageComment.resolve({ threadId: THREAD_ID, resolved: true }), + delete: () => caller.pageComment.delete({ threadId: THREAD_ID }), + }); + +const codeOf = async (call: () => Promise) => { + try { + await call(); + return "resolved"; + } catch (error) { + return (error as TRPCError).code; + } +}; + +describe("page comments, from outside an organization", () => { + test.each( + everyProcedure(callerFor(null)), + )("%s turns away a reader with no session", async (_name, call) => { + dbTouched = false; + expect(await codeOf(call)).toBe("UNAUTHORIZED"); + expect(dbTouched).toBe(false); + }); + + test.each( + everyProcedure( + callerFor({ + user: { + id: "reader", + email: "reader@example.com", + deletionRequestedAt: null, + }, + session: { activeOrganizationId: null }, + }), + ), + )("%s turns away a signed-in reader with no active org", async (_name, call) => { + dbTouched = false; + expect(await codeOf(call)).toBe("FORBIDDEN"); + expect(dbTouched).toBe(false); + }); +}); From a9caea275722f700983bbc75e349ddb30e7a3af3 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Tue, 15 Sep 2026 20:51:23 +0530 Subject: [PATCH 07/10] fix(pages): address the PR review on public pages and unfurls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A public page now shares only the version its link resolves to. The worker's `everyone` branch answered for every `/versions/N/`, and the page id is not secret on a shared page — it is the host of the unfurl thumbnail — so anyone with the link could walk the whole history, including versions the owner had moved away from and every version an agent published later. Public readability is therefore revocable, which also rules out caching those responses immutably: a CDN would keep answering after the owner moved on. Tickets are now minted whenever a version is named, so owners keep their own history. Mobile learns the visibility desktop already knew about: an `everyone` page rendered with a Lock icon, dropped out of the team filter, and had no row to set or undo it from the phone. Also from the review: rate-limit the anonymous `publicView`, noindex every metadata branch, make version previews actually read-only on web and desktop, give the workspace pane the banner and a way back, fix the `pullPage` cache key that made every render pull twice, stop falling back to the anonymous view on errors that are not not-found/forbidden, drop the per-call R2 listing from `page.versions`, and refresh titles after a rename. Types and components move to one home: `PageVisibility` comes from `@superset/shared`, `PageFrame` and `OpenInSupersetButton` move to their shared parents, and the two copies of the missing-page screen become one. --- .../components/PageViewer/PageViewer.tsx | 47 ++++++++++++----- .../usePageHeaderData/usePageHeaderData.ts | 11 +++- .../_dashboard/pages/$slug/page.tsx | 2 +- .../components/PagePane/PagePane.tsx | 9 +++- .../hooks/usePageSharing/usePageSharing.ts | 3 +- .../pages/[slug]/share/PageShareSheet.tsx | 8 +++ .../pages/components/PageRow/PageRow.tsx | 10 +++- .../pagesFilterStore/pagesFilterStore.ts | 2 +- apps/usercontent/src/index.ts | 33 +++++++----- .../OpenInSupersetButton.tsx | 0 .../OpenInSupersetButton/index.ts | 0 .../PageCommentsShell/PageCommentsShell.tsx | 14 ++++- .../PageHeaderBar/PageHeaderBar.tsx | 2 +- .../PageMissingScreen/PageMissingScreen.tsx | 32 ++++++++++++ .../components/PageMissingScreen/index.ts | 1 + .../PageUnavailable/PageUnavailable.tsx | 20 +++---- .../PublicPageView/PublicPageView.tsx | 28 +++++++--- apps/web/src/app/page/[slug]/not-found.tsx | 25 ++------- apps/web/src/app/page/[slug]/page.tsx | 48 ++++++++++++----- .../cli/src/commands/pages/publish/command.ts | 13 ++--- packages/i18n/src/server-errors.ts | 6 +++ packages/shared/src/usercontent/index.ts | 1 + .../shared/src/usercontent/manifest.test.ts | 52 ++++++++++++++++++- packages/shared/src/usercontent/manifest.ts | 15 ++++++ packages/trpc/src/lib/r2.ts | 23 -------- packages/trpc/src/router/page/page.ts | 32 +++++------- packages/trpc/src/router/page/rate-limit.ts | 45 ++++++++++++++++ packages/trpc/src/router/page/storage.ts | 7 +-- .../PageCommentsView/PageCommentsView.tsx | 2 +- .../components => }/PageFrame/PageFrame.tsx | 4 +- .../components => }/PageFrame/index.ts | 0 .../PageTitleMenu/PageTitleMenu.tsx | 13 +---- .../VersionThumbnail/VersionThumbnail.tsx | 35 +++++++++++++ .../components/VersionThumbnail/index.ts | 1 + .../components/PageHeader/index.ts | 1 + .../components/PageHeader/types.ts | 4 +- .../ui/src/components/PageComments/index.ts | 3 +- 37 files changed, 399 insertions(+), 153 deletions(-) rename apps/web/src/app/page/[slug]/components/{PageHeaderBar/components => }/OpenInSupersetButton/OpenInSupersetButton.tsx (100%) rename apps/web/src/app/page/[slug]/components/{PageHeaderBar/components => }/OpenInSupersetButton/index.ts (100%) create mode 100644 apps/web/src/app/page/[slug]/components/PageMissingScreen/PageMissingScreen.tsx create mode 100644 apps/web/src/app/page/[slug]/components/PageMissingScreen/index.ts create mode 100644 packages/trpc/src/router/page/rate-limit.ts rename packages/ui/src/components/PageComments/components/{PageCommentsView/components => }/PageFrame/PageFrame.tsx (94%) rename packages/ui/src/components/PageComments/components/{PageCommentsView/components => }/PageFrame/index.ts (100%) create mode 100644 packages/ui/src/components/PageComments/components/PageHeader/components/PageTitleMenu/components/VersionThumbnail/VersionThumbnail.tsx create mode 100644 packages/ui/src/components/PageComments/components/PageHeader/components/PageTitleMenu/components/VersionThumbnail/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PageViewer/PageViewer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PageViewer/PageViewer.tsx index eb5dd29a6d5..8eb85ce4892 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PageViewer/PageViewer.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PageViewer/PageViewer.tsx @@ -7,6 +7,7 @@ import { CommentProvider, CommentsPanel, PageCommentsView, + PageVersionBanner, } from "@superset/ui/page-comments"; import { toast } from "@superset/ui/sonner"; import { Spinner } from "@superset/ui/spinner"; @@ -33,6 +34,12 @@ interface PageViewerProps { onResolved?: (page: ResolvedPage) => void; onFramePointerDown?: () => void; version?: number | null; + /** + * Leaves the historical version. Surfaces that already carry the banner + * themselves — the detail view's `PageHeader` — leave this unset so the + * viewer does not render a second one. + */ + onExitPreview?: () => void; } export function PageViewer({ @@ -44,6 +51,7 @@ export function PageViewer({ onResolved, onFramePointerDown, version, + onExitPreview, }: PageViewerProps) { const { t } = useLingui(); const { data: session } = authClient.useSession(); @@ -112,28 +120,43 @@ export function PageViewer({ ); } + // A historical version is read-only, so comment mode stays off: a new + // thread here would be anchored to content the page no longer serves, and + // a watching agent would be asked to act on it. + const previewing = + pull.data.servedVersion !== null && + pull.data.version !== pull.data.servedVersion; + return ( -
-
- scrollPositions.set(scrollKey, y)} - onFramePointerDown={onFramePointerDown} +
+ {previewing && onExitPreview ? ( + + ) : null} +
+
+ scrollPositions.set(scrollKey, y)} + onFramePointerDown={onFramePointerDown} + /> +
+ +
- -
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts index ba0e83aca47..6626d07284e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts @@ -59,7 +59,10 @@ export function usePageHeaderData(data: PageHeaderTarget): PageHeaderData { resolved && pageId ? { id: pageId, - title: data.title ?? resolved.title ?? data.slug, + // The pull wins over the caller's cached label: a pane carries + // the title it was opened with, and a rename has to show there + // without waiting for the pane to re-resolve. + title: resolved.title ?? data.title ?? data.slug, url: resolved.url, visibility: resolved.visibility, createdByUserId: resolved.createdByUserId, @@ -97,6 +100,12 @@ export function usePageHeaderData(data: PageHeaderTarget): PageHeaderData { utils.page.pull.setData(ref, (prev) => prev ? { ...prev, title: updated.title } : prev, ); + // The grid and any other pane reading this page hold their own + // entries, and neither is keyed by the ref patched above. + await Promise.all([ + utils.page.pull.invalidate(), + utils.page.list.invalidate(), + ]); }, onRefresh: () => { void refresh(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/$slug/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/$slug/page.tsx index f40b0a45b65..4a2592c8b52 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/$slug/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/$slug/page.tsx @@ -15,5 +15,5 @@ function PageDetailPage() { if (isEnabled === undefined) return null; if (!isEnabled) return ; - return ; + return ; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePane/PagePane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePane/PagePane.tsx index 6811a63e960..cf26c384864 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePane/PagePane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePane/PagePane.tsx @@ -21,8 +21,12 @@ export function PagePane({ onDataChange, onFocus, }: PagePaneProps) { - const { commentsEnabled, setCommentsEnabled, previewVersion } = - usePagePaneUi(paneId); + const { + commentsEnabled, + setCommentsEnabled, + previewVersion, + setPreviewVersion, + } = usePagePaneUi(paneId); const onDataChangeRef = useRef(onDataChange); onDataChangeRef.current = onDataChange; @@ -49,6 +53,7 @@ export function PagePane({ onCommentsEnabledChange={setCommentsEnabled} onResolved={handleResolved} onFramePointerDown={onFocus} + onExitPreview={() => setPreviewVersion(null)} /> ); } diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/hooks/usePageSharing/usePageSharing.ts b/apps/mobile/screens/(authenticated)/pages/[slug]/hooks/usePageSharing/usePageSharing.ts index adfd6b06d42..20c15b74ee0 100644 --- a/apps/mobile/screens/(authenticated)/pages/[slug]/hooks/usePageSharing/usePageSharing.ts +++ b/apps/mobile/screens/(authenticated)/pages/[slug]/hooks/usePageSharing/usePageSharing.ts @@ -1,9 +1,10 @@ +import type { PageVisibility } from "@superset/shared/usercontent"; import type { RouterOutputs } from "@superset/trpc"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; import { apiClient } from "@/lib/trpc/client"; -export type PageVisibility = "just_me" | "org"; +export type { PageVisibility } from "@superset/shared/usercontent"; export type PageVersion = RouterOutputs["page"]["versions"][number]; export function usePageAccessQuery(slug: string | undefined) { diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/share/PageShareSheet.tsx b/apps/mobile/screens/(authenticated)/pages/[slug]/share/PageShareSheet.tsx index 61dce2db612..b1ad9cd9ba0 100644 --- a/apps/mobile/screens/(authenticated)/pages/[slug]/share/PageShareSheet.tsx +++ b/apps/mobile/screens/(authenticated)/pages/[slug]/share/PageShareSheet.tsx @@ -8,6 +8,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { Building2, Check, + Globe, Link2, Lock, type LucideIcon, @@ -156,6 +157,13 @@ export function PageShareSheet() { disabled={locked} onPress={() => setVisibility.mutate("org")} /> + setVisibility.mutate("everyone")} + /> diff --git a/apps/mobile/screens/(authenticated)/pages/components/PageRow/PageRow.tsx b/apps/mobile/screens/(authenticated)/pages/components/PageRow/PageRow.tsx index 0fe6ffae244..76bbbfda42d 100644 --- a/apps/mobile/screens/(authenticated)/pages/components/PageRow/PageRow.tsx +++ b/apps/mobile/screens/(authenticated)/pages/components/PageRow/PageRow.tsx @@ -2,7 +2,7 @@ import { formatDate } from "@superset/i18n/format"; import { getInitials } from "@superset/shared/names"; import { Image } from "expo-image"; import { useRouter } from "expo-router"; -import { FileText, Globe, Lock } from "lucide-react-native"; +import { Building2, FileText, Globe, Lock } from "lucide-react-native"; import { Pressable, View } from "react-native"; import { Icon } from "@/components/ui/icon"; import { Text } from "@/components/ui/text"; @@ -18,6 +18,12 @@ export function PageRow({ page }: { page: OrgPage }) { const timestamp = formatDate( edited - created > EDIT_THRESHOLD_MS ? edited : created, ); + const VisibilityIcon = + page.visibility === "everyone" + ? Globe + : page.visibility === "org" + ? Building2 + : Lock; return ( diff --git a/apps/mobile/screens/(authenticated)/pages/stores/pagesFilterStore/pagesFilterStore.ts b/apps/mobile/screens/(authenticated)/pages/stores/pagesFilterStore/pagesFilterStore.ts index 79426ea7263..c3e57057b1f 100644 --- a/apps/mobile/screens/(authenticated)/pages/stores/pagesFilterStore/pagesFilterStore.ts +++ b/apps/mobile/screens/(authenticated)/pages/stores/pagesFilterStore/pagesFilterStore.ts @@ -34,7 +34,7 @@ export function matchesScope( ): boolean { switch (scope) { case "team": - return page.visibility === "org"; + return page.visibility !== "just_me"; case "mine": return page.visibility === "just_me"; default: diff --git a/apps/usercontent/src/index.ts b/apps/usercontent/src/index.ts index 135ca814cdc..52ce7b73f26 100644 --- a/apps/usercontent/src/index.ts +++ b/apps/usercontent/src/index.ts @@ -15,6 +15,7 @@ import { pageManifestKey, pageThumbnailKey, parsePageManifest, + publiclyReadable, RUNTIME_SCRIPT_PATH, servedVersionOf, THUMBNAIL_FILENAME, @@ -29,7 +30,13 @@ type AppContext = { Bindings: UsercontentEnv }; const app = new Hono(); -const IMMUTABLE = "public, max-age=31536000, immutable"; +/** + * A public version's bytes are immutable, but its *readability* is not: it + * lasts only while the page still serves that version. Caching it immutably + * would let a shared CDN keep answering for a version the owner has already + * moved away from, so public responses get a short, bounded reuse instead. + */ +const PUBLIC_REVOCABLE = "public, max-age=300"; /** * How long a reader may keep what a ticket fetched: never past the ticket @@ -130,15 +137,17 @@ function requestedVersion(c: Context): number | null | undefined { } /** - * A public page is open. Anything narrower needs the ticket the API minted - * for it — for this page, and if the ticket names a version, for this one. + * A public page is open at the version its link resolves to. Anything + * narrower — including the other versions of that same public page — needs + * the ticket the API minted for it, for this page, and if the ticket names a + * version, for this one. */ async function authorized( c: Context, manifest: PageManifest, version: number, ): Promise { - if (manifest.visibility === "everyone") return "public"; + if (publiclyReadable(manifest, version)) return "public"; const ticket = requestTicket(c); if (!ticket) return null; const claims = await verifyPageTicket( @@ -178,15 +187,15 @@ async function servePage(c: Context): Promise { const auth = await authorized(c, manifest, version); if (!auth) return signInRedirect(c, manifest.slug); - // A pinned version is an immutable snapshot, so it caches like the assets - // beside it. The served alias moves when a new version is published, so it - // has to revalidate — the ETag is what makes that cost a set of headers - // rather than the whole document again. + // A pinned ticketed version is an immutable snapshot, so it caches like the + // assets beside it. The served alias moves when a new version is published, + // so it has to revalidate — the ETag is what makes that cost a set of + // headers rather than the whole document again. const pinned = requested !== null; const cacheControl = auth === "public" ? pinned - ? IMMUTABLE + ? PUBLIC_REVOCABLE : "no-cache" : pinned ? `private, max-age=${ticketSeconds(auth)}, immutable` @@ -286,7 +295,7 @@ async function serveThumbnail(c: Context): Promise { "Superset-Storage-Key": key, "X-Content-Type-Options": "nosniff", "Cache-Control": - remaining === null ? IMMUTABLE : `private, max-age=${remaining}`, + remaining === null ? PUBLIC_REVOCABLE : `private, max-age=${remaining}`, }, }); } @@ -431,11 +440,11 @@ async function serveAsset(c: Context): Promise { const auth = await authorized(c, manifest, version); if (!auth) return signInRedirect(c, manifest.slug); - // Assets cache like the document's thumbnail: public versions immutably, + // Assets cache like the document's thumbnail: public versions briefly, // ticketed ones only until the ticket that fetched them expires. const cacheControl = auth === "public" - ? IMMUTABLE + ? PUBLIC_REVOCABLE : `private, max-age=${ticketSeconds(auth)}, immutable`; const isHtml = asset.contentType.startsWith("text/html"); // Everything that is not the page's own document gets a policy of its own: diff --git a/apps/web/src/app/page/[slug]/components/PageHeaderBar/components/OpenInSupersetButton/OpenInSupersetButton.tsx b/apps/web/src/app/page/[slug]/components/OpenInSupersetButton/OpenInSupersetButton.tsx similarity index 100% rename from apps/web/src/app/page/[slug]/components/PageHeaderBar/components/OpenInSupersetButton/OpenInSupersetButton.tsx rename to apps/web/src/app/page/[slug]/components/OpenInSupersetButton/OpenInSupersetButton.tsx diff --git a/apps/web/src/app/page/[slug]/components/PageHeaderBar/components/OpenInSupersetButton/index.ts b/apps/web/src/app/page/[slug]/components/OpenInSupersetButton/index.ts similarity index 100% rename from apps/web/src/app/page/[slug]/components/PageHeaderBar/components/OpenInSupersetButton/index.ts rename to apps/web/src/app/page/[slug]/components/OpenInSupersetButton/index.ts diff --git a/apps/web/src/app/page/[slug]/components/PageCommentsShell/PageCommentsShell.tsx b/apps/web/src/app/page/[slug]/components/PageCommentsShell/PageCommentsShell.tsx index 9c6e5d4d7a8..86913c73b21 100644 --- a/apps/web/src/app/page/[slug]/components/PageCommentsShell/PageCommentsShell.tsx +++ b/apps/web/src/app/page/[slug]/components/PageCommentsShell/PageCommentsShell.tsx @@ -12,6 +12,12 @@ interface PageCommentsShellProps { version: number; user: PageCommentUser; pageOwnerId?: string | null; + /** + * A historical version is read-only, so comment mode is pinned off: a new + * thread here would be anchored to content the page no longer serves, and + * a watching agent would be asked to act on it. + */ + readOnly?: boolean; children: ReactNode; } @@ -20,6 +26,7 @@ export function PageCommentsShell({ version, user, pageOwnerId, + readOnly, children, }: PageCommentsShellProps) { const store = usePageComments({ @@ -30,7 +37,12 @@ export function PageCommentsShell({ }); return ( - + {children} ); diff --git a/apps/web/src/app/page/[slug]/components/PageHeaderBar/PageHeaderBar.tsx b/apps/web/src/app/page/[slug]/components/PageHeaderBar/PageHeaderBar.tsx index 3e93656e4ec..54aa391255e 100644 --- a/apps/web/src/app/page/[slug]/components/PageHeaderBar/PageHeaderBar.tsx +++ b/apps/web/src/app/page/[slug]/components/PageHeaderBar/PageHeaderBar.tsx @@ -9,7 +9,7 @@ import { import { useMutation } from "@tanstack/react-query"; import { usePathname, useRouter } from "next/navigation"; import { useTRPC } from "@/trpc/react"; -import { OpenInSupersetButton } from "./components/OpenInSupersetButton"; +import { OpenInSupersetButton } from "../OpenInSupersetButton"; import { PageWatchBadge } from "./components/PageWatchBadge"; interface PageHeaderBarProps { diff --git a/apps/web/src/app/page/[slug]/components/PageMissingScreen/PageMissingScreen.tsx b/apps/web/src/app/page/[slug]/components/PageMissingScreen/PageMissingScreen.tsx new file mode 100644 index 00000000000..488bd99f257 --- /dev/null +++ b/apps/web/src/app/page/[slug]/components/PageMissingScreen/PageMissingScreen.tsx @@ -0,0 +1,32 @@ +import { msg } from "@lingui/core/macro"; +import { Pixel404 } from "@superset/ui/pixel-404"; +import type { ReactNode } from "react"; +import { MessageScreen } from "@/components/MessageScreen"; +import { initServerI18n } from "@/lib/i18n-server"; + +interface PageMissingScreenProps { + action: ReactNode; +} + +/** + * Deliberately says the same thing whether the page is missing, deleted or + * merely closed to the reader: the wording is what keeps a stranger from + * learning which one it was. + */ +export async function PageMissingScreen({ action }: PageMissingScreenProps) { + const i18n = await initServerI18n(); + + return ( + } + title={i18n._(msg({ message: "This page isn't here" }))} + description={i18n._( + msg({ + message: + "The link may be wrong, the page may have been deleted, or you may not have access to it.", + }), + )} + action={action} + /> + ); +} diff --git a/apps/web/src/app/page/[slug]/components/PageMissingScreen/index.ts b/apps/web/src/app/page/[slug]/components/PageMissingScreen/index.ts new file mode 100644 index 00000000000..cad05a17b97 --- /dev/null +++ b/apps/web/src/app/page/[slug]/components/PageMissingScreen/index.ts @@ -0,0 +1 @@ +export { PageMissingScreen } from "./PageMissingScreen"; diff --git a/apps/web/src/app/page/[slug]/components/PageUnavailable/PageUnavailable.tsx b/apps/web/src/app/page/[slug]/components/PageUnavailable/PageUnavailable.tsx index 15884a620ee..6eef9bf3ec5 100644 --- a/apps/web/src/app/page/[slug]/components/PageUnavailable/PageUnavailable.tsx +++ b/apps/web/src/app/page/[slug]/components/PageUnavailable/PageUnavailable.tsx @@ -1,9 +1,8 @@ import { msg } from "@lingui/core/macro"; import { Button } from "@superset/ui/button"; -import { Pixel404 } from "@superset/ui/pixel-404"; import Link from "next/link"; -import { MessageScreen } from "@/components/MessageScreen"; import { initServerI18n } from "@/lib/i18n-server"; +import { PageMissingScreen } from "../PageMissingScreen"; interface PageUnavailableProps { slug: string; @@ -13,18 +12,15 @@ export async function PageUnavailable({ slug }: PageUnavailableProps) { const i18n = await initServerI18n(); return ( - } - title={i18n._(msg({ message: "This page isn't here" }))} - description={i18n._( - msg({ - message: - "The link may be wrong, the page may have been deleted, or you may not have access to it.", - }), - )} + - + {i18n._(msg({ message: "Sign in" }))} diff --git a/apps/web/src/app/page/[slug]/components/PublicPageView/PublicPageView.tsx b/apps/web/src/app/page/[slug]/components/PublicPageView/PublicPageView.tsx index 84df20e832e..bf15a0be148 100644 --- a/apps/web/src/app/page/[slug]/components/PublicPageView/PublicPageView.tsx +++ b/apps/web/src/app/page/[slug]/components/PublicPageView/PublicPageView.tsx @@ -5,14 +5,21 @@ import { Button } from "@superset/ui/button"; import { PageFrame } from "@superset/ui/page-comments"; import { Globe } from "lucide-react"; import Link from "next/link"; +import { OpenInSupersetButton } from "../OpenInSupersetButton"; interface PublicPageViewProps { title: string; viewUrl: string; slug: string; + signedIn: boolean; } -export function PublicPageView({ title, viewUrl, slug }: PublicPageViewProps) { +export function PublicPageView({ + title, + viewUrl, + slug, + signedIn, +}: PublicPageViewProps) { const { t } = useLingui(); return ( @@ -24,11 +31,20 @@ export function PublicPageView({ title, viewUrl, slug }: PublicPageViewProps) { /> {title}
- + {signedIn ? ( + + ) : ( + + )}
diff --git a/apps/web/src/app/page/[slug]/not-found.tsx b/apps/web/src/app/page/[slug]/not-found.tsx index 148da996125..306a12346fe 100644 --- a/apps/web/src/app/page/[slug]/not-found.tsx +++ b/apps/web/src/app/page/[slug]/not-found.tsx @@ -1,36 +1,17 @@ import { msg } from "@lingui/core/macro"; import { Button } from "@superset/ui/button"; -import { Pixel404 } from "@superset/ui/pixel-404"; import Link from "next/link"; -import { MessageScreen } from "@/components/MessageScreen"; import { initServerI18n } from "@/lib/i18n-server"; +import { PageMissingScreen } from "./components/PageMissingScreen"; export default async function PageNotFound() { const i18n = await initServerI18n(); return ( - } - title={i18n._( - msg({ - message: "This page isn't here", - }), - )} - description={i18n._( - msg({ - message: - "The link may be wrong, the page may have been deleted, or you may not have access to it.", - }), - )} + - - {i18n._( - msg({ - message: "Go to Superset", - }), - )} - + {i18n._(msg({ message: "Go to Superset" }))} } /> diff --git a/apps/web/src/app/page/[slug]/page.tsx b/apps/web/src/app/page/[slug]/page.tsx index 116c60f7913..f00bcde6b65 100644 --- a/apps/web/src/app/page/[slug]/page.tsx +++ b/apps/web/src/app/page/[slug]/page.tsx @@ -25,8 +25,11 @@ interface PageProps { } // `api()` caches the client, not the result — this cache is what keeps -// generateMetadata and the component to a single pull. -const pullPage = cache(async (slug: string, version?: number) => { +// generateMetadata and the component to a single pull. React keys on the +// argument count as well as the values, so `version` is required: passing it +// from one caller and omitting it from the other made two entries, and every +// render pulled twice. +const pullPage = cache(async (slug: string, version: number | undefined) => { const trpc = await api(); return trpc.page.pull.query({ slug, version }); }); @@ -36,6 +39,11 @@ function previewVersionOf(raw: string | undefined): number | undefined { return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; } +// "Anyone with the link" is not "index me": usercontent already answers +// `X-Robots-Tag: noindex`, and this wrapper has to say the same. Unfurlers +// ignore robots, so link previews still work. +const ROBOTS = { index: false, follow: false } as const; + const pullVersions = cache(async (slug: string) => { const trpc = await api(); return trpc.page.versions.query({ slug }); @@ -46,22 +54,23 @@ const pullAccess = cache(async (slug: string) => { return trpc.page.access.query({ slug }); }); +// Answers null for anything that is not public. A throw here is a real +// failure — a DB or R2 outage — and must not read as "not public". const pullPublicPage = cache(async (slug: string) => { const trpc = await api(); - try { - return await trpc.page.publicView.query({ slug }); - } catch { - return null; - } + return trpc.page.publicView.query({ slug }); }); export async function generateMetadata({ params, + searchParams, }: PageProps): Promise { const { slug } = await params; + const requestedVersion = previewVersionOf((await searchParams).v); const i18n = await initServerI18n(); - const shared = await pullPublicPage(slug); + // Metadata is best-effort: a card still renders when the lookup fails. + const shared = await pullPublicPage(slug).catch(() => null); if (shared) { const description = shared.description ?? undefined; const images = shared.thumbnailUrl @@ -70,6 +79,7 @@ export async function generateMetadata({ return { title: shared.title, description, + robots: ROBOTS, openGraph: { type: "website", siteName: "Superset", @@ -89,15 +99,20 @@ export async function generateMetadata({ const { hasPagesAccess } = await getPagesAccess(); if (hasPagesAccess) { - const page = await pullPage(slug).catch(() => null); + const page = await pullPage(slug, requestedVersion).catch(() => null); if (page) { - return { title: page.title, description: page.description ?? undefined }; + return { + title: page.title, + description: page.description ?? undefined, + robots: ROBOTS, + }; } } return { title: "Superset", description: i18n._(msg({ message: "Sign in to view this page" })), + robots: ROBOTS, }; } @@ -119,6 +134,7 @@ export default async function PublishedPage({ title={shared.title} viewUrl={shared.viewUrl} slug={slug} + signedIn={Boolean(session)} /> ) : null; }; @@ -134,10 +150,15 @@ export default async function PublishedPage({ try { page = await pullPage(slug, requestedVersion); } catch (error) { - const view = await publicView(); + // Only a missing or forbidden page might still be readable publicly. + // A presign failure is an error for a member of the organization, not a + // reason to show them anonymous chrome — and neither is a `?v` that does + // not resolve, which would silently drop them onto the served version. + if (!isNotFound(error) && !isForbidden(error)) throw error; + const view = requestedVersion === undefined ? await publicView() : null; if (view) return view; if (isNotFound(error)) notFound(); - if (isForbidden(error) && error instanceof TRPCClientError) { + if (error instanceof TRPCClientError) { return ; } throw error; @@ -148,11 +169,14 @@ export default async function PublishedPage({ pullAccess(slug), ]); + const previewing = page.version !== page.servedVersion; + return (
diff --git a/packages/cli/src/commands/pages/publish/command.ts b/packages/cli/src/commands/pages/publish/command.ts index 6f7572156e2..c7c37bf6c29 100644 --- a/packages/cli/src/commands/pages/publish/command.ts +++ b/packages/cli/src/commands/pages/publish/command.ts @@ -18,8 +18,6 @@ import { import { resolvePageId } from "./utils/resolvePageId"; import { uploadAssets, uploadDocument } from "./utils/upload"; -const VISIBILITIES = OFFERED_VISIBILITIES; - export default command({ description: "Publish an HTML file, or a directory of files, as a page", args: [ @@ -36,7 +34,7 @@ export default command({ .alias("l") .desc("What changed in this version, shown in the version history"), visibility: string().desc( - `One of: ${VISIBILITIES.join(", ")} (new pages default to org)`, + `One of: ${OFFERED_VISIBILITIES.join(", ")} (new pages default to org)`, ), page: string().desc( "Publish a new version of this page id, instead of resolving by workspace", @@ -80,11 +78,11 @@ export default command({ } if ( options.visibility && - !VISIBILITIES.includes(options.visibility as never) + !OFFERED_VISIBILITIES.includes(options.visibility as never) ) { throw new CLIError( `Invalid visibility: ${options.visibility}`, - `Use one of: ${VISIBILITIES.join(", ")}`, + `Use one of: ${OFFERED_VISIBILITIES.join(", ")}`, ); } @@ -156,7 +154,10 @@ export default command({ ...(options.description ? { description: options.description } : {}), ...(options.label ? { label: options.label } : {}), ...(options.visibility - ? { visibility: options.visibility as (typeof VISIBILITIES)[number] } + ? { + visibility: + options.visibility as (typeof OFFERED_VISIBILITIES)[number], + } : {}), }); diff --git a/packages/i18n/src/server-errors.ts b/packages/i18n/src/server-errors.ts index 7743a309aa5..8815d128936 100644 --- a/packages/i18n/src/server-errors.ts +++ b/packages/i18n/src/server-errors.ts @@ -555,6 +555,12 @@ export const serverErrorMessages: Record< message: "Provide either id or slug", }), ), + "serverError.page.rateLimitExceeded": () => + i18n._( + msg({ + message: "Rate limit exceeded.", + }), + ), "serverError.page.thisPageIsBeingPublishedFrom": () => i18n._( msg({ diff --git a/packages/shared/src/usercontent/index.ts b/packages/shared/src/usercontent/index.ts index abc0a3274bc..8d406c54ce3 100644 --- a/packages/shared/src/usercontent/index.ts +++ b/packages/shared/src/usercontent/index.ts @@ -22,6 +22,7 @@ export { type PageManifestVersion, type PageVisibility, parsePageManifest, + publiclyReadable, servedVersionOf, } from "./manifest"; export { PAGE_THEME_CSS } from "./theme"; diff --git a/packages/shared/src/usercontent/manifest.test.ts b/packages/shared/src/usercontent/manifest.test.ts index 3fbedaf3146..92f7fc93e06 100644 --- a/packages/shared/src/usercontent/manifest.test.ts +++ b/packages/shared/src/usercontent/manifest.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { parsePageManifest } from "./manifest"; +import { + type PageManifest, + type PageVisibility, + parsePageManifest, + publiclyReadable, +} from "./manifest"; const base = { v: 1, @@ -53,3 +58,48 @@ describe("parsePageManifest", () => { } }); }); + +describe("publiclyReadable", () => { + const manifest = ( + visibility: PageVisibility, + sharedVersion: number | null, + latestVersion: number | null = 3, + ): PageManifest => ({ + v: 1, + pageId: base.pageId, + slug: base.slug, + visibility, + sharedVersion, + latestVersion, + versions: { + "1": { key: "k1", contentType: "text/html" }, + "2": { key: "k2", contentType: "text/html" }, + "3": { key: "k3", contentType: "text/html" }, + }, + }); + + test("opens only the pinned version a public page serves", () => { + const pinned = manifest("everyone", 2); + expect(publiclyReadable(pinned, 2)).toBe(true); + expect(publiclyReadable(pinned, 1)).toBe(false); + expect(publiclyReadable(pinned, 3)).toBe(false); + }); + + test("follows the latest version when a public page pins none", () => { + const unpinned = manifest("everyone", null); + expect(publiclyReadable(unpinned, 3)).toBe(true); + expect(publiclyReadable(unpinned, 2)).toBe(false); + }); + + test("keeps narrower visibilities closed at every version", () => { + for (const visibility of ["just_me", "org"] as const) { + for (const version of [1, 2, 3]) { + expect(publiclyReadable(manifest(visibility, 2), version)).toBe(false); + } + } + }); + + test("stays closed when a public page serves nothing", () => { + expect(publiclyReadable(manifest("everyone", null, null), 1)).toBe(false); + }); +}); diff --git a/packages/shared/src/usercontent/manifest.ts b/packages/shared/src/usercontent/manifest.ts index 6ae6f9b5057..8783aacb770 100644 --- a/packages/shared/src/usercontent/manifest.ts +++ b/packages/shared/src/usercontent/manifest.ts @@ -95,3 +95,18 @@ export function parsePageManifest(text: string): PageManifest | null { export function servedVersionOf(manifest: PageManifest): number | null { return manifest.sharedVersion ?? manifest.latestVersion; } + +/** + * "Anyone with the link" shares the version the link resolves to, not the + * history behind it. The page id is public on a shared page — it is the host + * of the unfurl thumbnail — so every other version stays behind a ticket, and + * a version the owner moved away from stops being readable the moment they do. + */ +export function publiclyReadable( + manifest: PageManifest, + version: number, +): boolean { + return ( + manifest.visibility === "everyone" && version === servedVersionOf(manifest) + ); +} diff --git a/packages/trpc/src/lib/r2.ts b/packages/trpc/src/lib/r2.ts index 1ea7b89229f..4fcc29eafbd 100644 --- a/packages/trpc/src/lib/r2.ts +++ b/packages/trpc/src/lib/r2.ts @@ -3,7 +3,6 @@ import { DeleteObjectsCommand, GetObjectCommand, HeadObjectCommand, - ListObjectsV2Command, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; @@ -160,28 +159,6 @@ export async function objectExists( return (await headObject(key, { bucket })) !== null; } -export async function listObjectKeys( - prefix: string, - { bucket = "private" }: { bucket?: Bucket } = {}, -): Promise> { - const keys = new Set(); - let token: string | undefined; - do { - const result = await s3().send( - new ListObjectsV2Command({ - Bucket: bucketName(bucket), - Prefix: prefix, - ContinuationToken: token, - }), - ); - for (const entry of result.Contents ?? []) { - if (entry.Key) keys.add(entry.Key); - } - token = result.IsTruncated ? result.NextContinuationToken : undefined; - } while (token); - return keys; -} - /** Deletes are idempotent and batched; a missing key is not an error. */ export async function deleteObjects( keys: readonly string[], diff --git a/packages/trpc/src/router/page/page.ts b/packages/trpc/src/router/page/page.ts index 838424e7547..2baf0408e6f 100644 --- a/packages/trpc/src/router/page/page.ts +++ b/packages/trpc/src/router/page/page.ts @@ -22,12 +22,7 @@ import { TRPCError, type TRPCRouterRecord } from "@trpc/server"; import { and, desc, eq, inArray, or, type SQL, sql } from "drizzle-orm"; import { z } from "zod"; import { env } from "../../env"; -import { - deleteObjects, - listObjectKeys, - objectExists, - presignedGetUrl, -} from "../../lib/r2"; +import { deleteObjects, objectExists, presignedGetUrl } from "../../lib/r2"; import { protectedProcedure, publicProcedure, userError } from "../../trpc"; import { requireActiveOrgMembership } from "../utils/active-org"; import { assertPageReadable, assertPageWritable } from "./access"; @@ -35,6 +30,7 @@ import { pageAssetRouter } from "./assets"; import { pageUrl } from "./page-url"; import { publishPage } from "./publish"; import { isEntryPathConflict } from "./publish-rules"; +import { enforcePublicPageRead } from "./rate-limit"; import { clearPageWatchSchema, createPageSchema, @@ -694,21 +690,19 @@ export const pageRouter = { .where(eq(pageVersions.pageId, page.id)) .orderBy(desc(pageVersions.version)); - const captured = await listObjectKeys(`pages/${page.id}/versions/`).catch( - () => new Set(), - ); const baseUrl = env.USERCONTENT_URL; + // Emitted unchecked, like `list` does: this runs on every page render + // and every header, and an R2 listing per call buys nothing the + // client's own `onError` placeholder does not already handle. return await Promise.all( rows.map(async (row) => ({ ...row, - thumbnailUrl: captured.has(pageThumbnailKey(page.id, row.version)) - ? pageThumbnailUrl({ - baseUrl, - pageId: page.id, - version: row.version, - ticket: await mintPageTicket(page, { version: row.version }), - }) - : null, + thumbnailUrl: pageThumbnailUrl({ + baseUrl, + pageId: page.id, + version: row.version, + ticket: await mintPageTicket(page, { version: row.version }), + }), })), ); }), @@ -803,7 +797,9 @@ export const pageRouter = { publicView: publicProcedure .input(publicPageSchema) - .query(async ({ input }) => { + .query(async ({ ctx, input }) => { + await enforcePublicPageRead(ctx.headers); + const [page] = await db .select() .from(pages) diff --git a/packages/trpc/src/router/page/rate-limit.ts b/packages/trpc/src/router/page/rate-limit.ts new file mode 100644 index 00000000000..ce1b6bd8185 --- /dev/null +++ b/packages/trpc/src/router/page/rate-limit.ts @@ -0,0 +1,45 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; +import { env } from "../../env"; +import { userError } from "../../trpc"; + +const redis = + env.KV_REST_API_URL && env.KV_REST_API_TOKEN + ? new Redis({ url: env.KV_REST_API_URL, token: env.KV_REST_API_TOKEN }) + : null; + +/** + * On a public page the slug is the credential, and it is only six random + * characters wide. This is what makes guessing one cost more than a loop. + */ +const publicViewRateLimit = redis + ? new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(60, "1 m"), + prefix: "ratelimit:page:public", + }) + : null; + +export async function enforcePublicPageRead(headers: Headers): Promise { + if (!publicViewRateLimit) return; + const ip = + headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + headers.get("x-real-ip") || + "unknown"; + + let success: boolean; + try { + ({ success } = await publicViewRateLimit.limit(ip)); + } catch (error) { + // Fail open: a Redis blip should not take every shared page down. + console.error("[pages] public rate limiter unavailable:", error); + return; + } + if (!success) { + throw userError({ + code: "TOO_MANY_REQUESTS", + message: "Rate limit exceeded.", + i18nKey: "serverError.page.rateLimitExceeded", + }); + } +} diff --git a/packages/trpc/src/router/page/storage.ts b/packages/trpc/src/router/page/storage.ts index 267b4a32e28..7b0532b48fe 100644 --- a/packages/trpc/src/router/page/storage.ts +++ b/packages/trpc/src/router/page/storage.ts @@ -141,14 +141,15 @@ export async function deletePageObjects({ } /** - * A public page needs no ticket; anything narrower gets one bound to the - * page and, when given, to a single version. + * A public page needs no ticket for the version its link resolves to; + * anything narrower — including another version of that same public page — + * gets one bound to the page and, when given, to a single version. */ export async function mintPageTicket( page: Pick, { version, ttlSeconds }: { version?: number; ttlSeconds?: number } = {}, ): Promise { - if (page.visibility === "everyone") return undefined; + if (page.visibility === "everyone" && version === undefined) return undefined; const now = Math.floor(Date.now() / 1000); const window = version !== undefined diff --git a/packages/ui/src/components/PageComments/components/PageCommentsView/PageCommentsView.tsx b/packages/ui/src/components/PageComments/components/PageCommentsView/PageCommentsView.tsx index 1429aa916a0..b499faba922 100644 --- a/packages/ui/src/components/PageComments/components/PageCommentsView/PageCommentsView.tsx +++ b/packages/ui/src/components/PageComments/components/PageCommentsView/PageCommentsView.tsx @@ -16,9 +16,9 @@ import { } from "@superset/shared/page-zoom"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComments } from "../../providers/CommentProvider"; +import { PageFrame } from "../PageFrame"; import { CommentBubble, pinClassName } from "./components/CommentBubble"; import { CommentPopover } from "./components/CommentPopover"; -import { PageFrame } from "./components/PageFrame"; import { type PinPoint, pinPointOf, diff --git a/packages/ui/src/components/PageComments/components/PageCommentsView/components/PageFrame/PageFrame.tsx b/packages/ui/src/components/PageComments/components/PageFrame/PageFrame.tsx similarity index 94% rename from packages/ui/src/components/PageComments/components/PageCommentsView/components/PageFrame/PageFrame.tsx rename to packages/ui/src/components/PageComments/components/PageFrame/PageFrame.tsx index c0a7e4ed97a..65d18557d78 100644 --- a/packages/ui/src/components/PageComments/components/PageCommentsView/components/PageFrame/PageFrame.tsx +++ b/packages/ui/src/components/PageComments/components/PageFrame/PageFrame.tsx @@ -1,8 +1,8 @@ "use client"; import { type Ref, useState } from "react"; -import { cn } from "../../../../../../lib/utils"; -import { Spinner } from "../../../../../ui/spinner"; +import { cn } from "../../../../lib/utils"; +import { Spinner } from "../../../ui/spinner"; interface PageFrameProps { src: string; diff --git a/packages/ui/src/components/PageComments/components/PageCommentsView/components/PageFrame/index.ts b/packages/ui/src/components/PageComments/components/PageFrame/index.ts similarity index 100% rename from packages/ui/src/components/PageComments/components/PageCommentsView/components/PageFrame/index.ts rename to packages/ui/src/components/PageComments/components/PageFrame/index.ts diff --git a/packages/ui/src/components/PageComments/components/PageHeader/components/PageTitleMenu/PageTitleMenu.tsx b/packages/ui/src/components/PageComments/components/PageHeader/components/PageTitleMenu/PageTitleMenu.tsx index 8aa0bbf8fb4..117619039e6 100644 --- a/packages/ui/src/components/PageComments/components/PageHeader/components/PageTitleMenu/PageTitleMenu.tsx +++ b/packages/ui/src/components/PageComments/components/PageHeader/components/PageTitleMenu/PageTitleMenu.tsx @@ -27,6 +27,7 @@ import { import { useFramePointerDown } from "../../../../hooks/useFramePointerDown"; import { relativeTime } from "../../../../utils/relativeTime"; import type { PageHeaderPage, PageHeaderVersion } from "../../types"; +import { VersionThumbnail } from "./components/VersionThumbnail"; interface PageTitleMenuProps { page: PageHeaderPage; @@ -144,17 +145,7 @@ export function PageTitleMenu({ className="gap-2.5 p-1.5" onSelect={run(() => onPreviewVersion(entry.version))} > - {entry.thumbnailUrl ? ( - - ) : ( - - - - )} + {entry.label ?? page.title} diff --git a/packages/ui/src/components/PageComments/components/PageHeader/components/PageTitleMenu/components/VersionThumbnail/VersionThumbnail.tsx b/packages/ui/src/components/PageComments/components/PageHeader/components/PageTitleMenu/components/VersionThumbnail/VersionThumbnail.tsx new file mode 100644 index 00000000000..0fed0779b63 --- /dev/null +++ b/packages/ui/src/components/PageComments/components/PageHeader/components/PageTitleMenu/components/VersionThumbnail/VersionThumbnail.tsx @@ -0,0 +1,35 @@ +import { FileText } from "lucide-react"; +import { useState } from "react"; + +interface VersionThumbnailProps { + src: string | null | undefined; +} + +/** + * Version thumbnails are emitted without checking R2, so one that has not been + * captured yet 404s and falls back here rather than costing every caller a + * listing to find out. + */ +export function VersionThumbnail({ src }: VersionThumbnailProps) { + const [failed, setFailed] = useState(false); + + if (!src || failed) { + return ( + + + + ); + } + + return ( + setFailed(true)} + className="h-10 w-14 shrink-0 rounded-sm border border-border object-cover object-top" + /> + ); +} diff --git a/packages/ui/src/components/PageComments/components/PageHeader/components/PageTitleMenu/components/VersionThumbnail/index.ts b/packages/ui/src/components/PageComments/components/PageHeader/components/PageTitleMenu/components/VersionThumbnail/index.ts new file mode 100644 index 00000000000..bc4dbd29357 --- /dev/null +++ b/packages/ui/src/components/PageComments/components/PageHeader/components/PageTitleMenu/components/VersionThumbnail/index.ts @@ -0,0 +1 @@ +export { VersionThumbnail } from "./VersionThumbnail"; diff --git a/packages/ui/src/components/PageComments/components/PageHeader/index.ts b/packages/ui/src/components/PageComments/components/PageHeader/index.ts index e1aba038167..5cc4999fd9c 100644 --- a/packages/ui/src/components/PageComments/components/PageHeader/index.ts +++ b/packages/ui/src/components/PageComments/components/PageHeader/index.ts @@ -1,6 +1,7 @@ export { DeletePageDialog } from "./components/DeletePageDialog"; export { PageSharePopover } from "./components/PageSharePopover"; export { PageTitleMenu } from "./components/PageTitleMenu"; +export { PageVersionBanner } from "./components/PageVersionBanner"; export { RenamePageDialog } from "./components/RenamePageDialog"; export { PageHeader } from "./PageHeader"; export type { diff --git a/packages/ui/src/components/PageComments/components/PageHeader/types.ts b/packages/ui/src/components/PageComments/components/PageHeader/types.ts index 9e62964a2d9..50273e9eab6 100644 --- a/packages/ui/src/components/PageComments/components/PageHeader/types.ts +++ b/packages/ui/src/components/PageComments/components/PageHeader/types.ts @@ -1,4 +1,6 @@ -export type PageVisibility = "just_me" | "org" | "everyone"; +import type { PageVisibility } from "@superset/shared/usercontent"; + +export type { PageVisibility } from "@superset/shared/usercontent"; export interface PageHeaderOwner { id: string; diff --git a/packages/ui/src/components/PageComments/index.ts b/packages/ui/src/components/PageComments/index.ts index 54d9a09fc2f..09d5b824a2b 100644 --- a/packages/ui/src/components/PageComments/index.ts +++ b/packages/ui/src/components/PageComments/index.ts @@ -17,7 +17,7 @@ export { } from "./components/CommentModeToggle"; export { CommentsPanel } from "./components/CommentsPanel"; export { PageCommentsView } from "./components/PageCommentsView"; -export { PageFrame } from "./components/PageCommentsView/components/PageFrame"; +export { PageFrame } from "./components/PageFrame"; export { DeletePageDialog, PageHeader, @@ -27,6 +27,7 @@ export { type PageHeaderVersion, PageSharePopover, PageTitleMenu, + PageVersionBanner, type PageVisibility, RenamePageDialog, } from "./components/PageHeader"; From 225b19dffea1133742c825fe6f5fbf88d4e887fd Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Wed, 16 Sep 2026 13:26:00 +0530 Subject: [PATCH 08/10] fix(pages): rate-limit public reads where the reader is visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The limit was keyed by x-forwarded-for inside page.publicView, copying leaderboard.ts. The web app calls publicView server-side, so on Vercel every reader arrives from the web function's egress address and shares one bucket — the same mistake the leaderboard hit from the marketing server, which would have throttled all public pages together. It moves to the /page/[slug] route, the last hop that can see the real client IP, and sits in front of pullPublicPage so a caller working through guessed slugs is refused before it costs a DB lookup and an R2 HEAD. React's cache means metadata and the component share one check. Also drops the comments added in a9caea275. --- .../components/PageViewer/PageViewer.tsx | 8 ---- .../usePageHeaderData/usePageHeaderData.ts | 5 --- apps/usercontent/src/index.ts | 6 --- apps/web/package.json | 2 + .../PageCommentsShell/PageCommentsShell.tsx | 5 --- .../PageMissingScreen/PageMissingScreen.tsx | 5 --- apps/web/src/app/page/[slug]/page.tsx | 17 ++----- .../[slug]/utils/publicReadLimit/index.ts | 1 + .../utils/publicReadLimit/publicReadLimit.ts | 36 +++++++++++++++ bun.lock | 2 + packages/i18n/src/server-errors.ts | 6 --- packages/shared/src/usercontent/manifest.ts | 6 --- packages/trpc/src/router/page/page.ts | 8 +--- packages/trpc/src/router/page/rate-limit.ts | 45 ------------------- .../VersionThumbnail/VersionThumbnail.tsx | 5 --- 15 files changed, 45 insertions(+), 112 deletions(-) create mode 100644 apps/web/src/app/page/[slug]/utils/publicReadLimit/index.ts create mode 100644 apps/web/src/app/page/[slug]/utils/publicReadLimit/publicReadLimit.ts delete mode 100644 packages/trpc/src/router/page/rate-limit.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PageViewer/PageViewer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PageViewer/PageViewer.tsx index 8eb85ce4892..6bf2d0cd49c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PageViewer/PageViewer.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PageViewer/PageViewer.tsx @@ -34,11 +34,6 @@ interface PageViewerProps { onResolved?: (page: ResolvedPage) => void; onFramePointerDown?: () => void; version?: number | null; - /** - * Leaves the historical version. Surfaces that already carry the banner - * themselves — the detail view's `PageHeader` — leave this unset so the - * viewer does not render a second one. - */ onExitPreview?: () => void; } @@ -120,9 +115,6 @@ export function PageViewer({ ); } - // A historical version is read-only, so comment mode stays off: a new - // thread here would be anchored to content the page no longer serves, and - // a watching agent would be asked to act on it. const previewing = pull.data.servedVersion !== null && pull.data.version !== pull.data.servedVersion; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts index 6626d07284e..9d334fcfbcb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts @@ -59,9 +59,6 @@ export function usePageHeaderData(data: PageHeaderTarget): PageHeaderData { resolved && pageId ? { id: pageId, - // The pull wins over the caller's cached label: a pane carries - // the title it was opened with, and a rename has to show there - // without waiting for the pane to re-resolve. title: resolved.title ?? data.title ?? data.slug, url: resolved.url, visibility: resolved.visibility, @@ -100,8 +97,6 @@ export function usePageHeaderData(data: PageHeaderTarget): PageHeaderData { utils.page.pull.setData(ref, (prev) => prev ? { ...prev, title: updated.title } : prev, ); - // The grid and any other pane reading this page hold their own - // entries, and neither is keyed by the ref patched above. await Promise.all([ utils.page.pull.invalidate(), utils.page.list.invalidate(), diff --git a/apps/usercontent/src/index.ts b/apps/usercontent/src/index.ts index 52ce7b73f26..fbd9065be7d 100644 --- a/apps/usercontent/src/index.ts +++ b/apps/usercontent/src/index.ts @@ -30,12 +30,6 @@ type AppContext = { Bindings: UsercontentEnv }; const app = new Hono(); -/** - * A public version's bytes are immutable, but its *readability* is not: it - * lasts only while the page still serves that version. Caching it immutably - * would let a shared CDN keep answering for a version the owner has already - * moved away from, so public responses get a short, bounded reuse instead. - */ const PUBLIC_REVOCABLE = "public, max-age=300"; /** diff --git a/apps/web/package.json b/apps/web/package.json index e2bfad00216..adfcd9c55a9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,6 +24,8 @@ "@superset/trpc": "workspace:*", "@superset/ui": "workspace:*", "@superset/workspace-client": "workspace:*", + "@upstash/ratelimit": "2.0.8", + "@upstash/redis": "1.37.0", "@t3-oss/env-nextjs": "0.13.11", "@tanstack/react-query": "5.101.4", "@tanstack/react-query-devtools": "5.101.4", diff --git a/apps/web/src/app/page/[slug]/components/PageCommentsShell/PageCommentsShell.tsx b/apps/web/src/app/page/[slug]/components/PageCommentsShell/PageCommentsShell.tsx index 86913c73b21..b36abf96ced 100644 --- a/apps/web/src/app/page/[slug]/components/PageCommentsShell/PageCommentsShell.tsx +++ b/apps/web/src/app/page/[slug]/components/PageCommentsShell/PageCommentsShell.tsx @@ -12,11 +12,6 @@ interface PageCommentsShellProps { version: number; user: PageCommentUser; pageOwnerId?: string | null; - /** - * A historical version is read-only, so comment mode is pinned off: a new - * thread here would be anchored to content the page no longer serves, and - * a watching agent would be asked to act on it. - */ readOnly?: boolean; children: ReactNode; } diff --git a/apps/web/src/app/page/[slug]/components/PageMissingScreen/PageMissingScreen.tsx b/apps/web/src/app/page/[slug]/components/PageMissingScreen/PageMissingScreen.tsx index 488bd99f257..42468554165 100644 --- a/apps/web/src/app/page/[slug]/components/PageMissingScreen/PageMissingScreen.tsx +++ b/apps/web/src/app/page/[slug]/components/PageMissingScreen/PageMissingScreen.tsx @@ -8,11 +8,6 @@ interface PageMissingScreenProps { action: ReactNode; } -/** - * Deliberately says the same thing whether the page is missing, deleted or - * merely closed to the reader: the wording is what keeps a stranger from - * learning which one it was. - */ export async function PageMissingScreen({ action }: PageMissingScreenProps) { const i18n = await initServerI18n(); diff --git a/apps/web/src/app/page/[slug]/page.tsx b/apps/web/src/app/page/[slug]/page.tsx index f00bcde6b65..caf6263f792 100644 --- a/apps/web/src/app/page/[slug]/page.tsx +++ b/apps/web/src/app/page/[slug]/page.tsx @@ -17,6 +17,7 @@ import { PageUnavailable } from "./components/PageUnavailable"; import { PublicPageView } from "./components/PublicPageView"; import { WrongOrganization } from "./components/WrongOrganization"; import { getPagesAccess } from "./utils/getPagesAccess"; +import { allowPublicRead } from "./utils/publicReadLimit"; import { isForbidden, isNotFound } from "./utils/trpcErrors"; interface PageProps { @@ -25,10 +26,7 @@ interface PageProps { } // `api()` caches the client, not the result — this cache is what keeps -// generateMetadata and the component to a single pull. React keys on the -// argument count as well as the values, so `version` is required: passing it -// from one caller and omitting it from the other made two entries, and every -// render pulled twice. +// generateMetadata and the component to a single pull. const pullPage = cache(async (slug: string, version: number | undefined) => { const trpc = await api(); return trpc.page.pull.query({ slug, version }); @@ -39,9 +37,6 @@ function previewVersionOf(raw: string | undefined): number | undefined { return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; } -// "Anyone with the link" is not "index me": usercontent already answers -// `X-Robots-Tag: noindex`, and this wrapper has to say the same. Unfurlers -// ignore robots, so link previews still work. const ROBOTS = { index: false, follow: false } as const; const pullVersions = cache(async (slug: string) => { @@ -54,9 +49,8 @@ const pullAccess = cache(async (slug: string) => { return trpc.page.access.query({ slug }); }); -// Answers null for anything that is not public. A throw here is a real -// failure — a DB or R2 outage — and must not read as "not public". const pullPublicPage = cache(async (slug: string) => { + if (!(await allowPublicRead())) return null; const trpc = await api(); return trpc.page.publicView.query({ slug }); }); @@ -69,7 +63,6 @@ export async function generateMetadata({ const requestedVersion = previewVersionOf((await searchParams).v); const i18n = await initServerI18n(); - // Metadata is best-effort: a card still renders when the lookup fails. const shared = await pullPublicPage(slug).catch(() => null); if (shared) { const description = shared.description ?? undefined; @@ -150,10 +143,6 @@ export default async function PublishedPage({ try { page = await pullPage(slug, requestedVersion); } catch (error) { - // Only a missing or forbidden page might still be readable publicly. - // A presign failure is an error for a member of the organization, not a - // reason to show them anonymous chrome — and neither is a `?v` that does - // not resolve, which would silently drop them onto the served version. if (!isNotFound(error) && !isForbidden(error)) throw error; const view = requestedVersion === undefined ? await publicView() : null; if (view) return view; diff --git a/apps/web/src/app/page/[slug]/utils/publicReadLimit/index.ts b/apps/web/src/app/page/[slug]/utils/publicReadLimit/index.ts new file mode 100644 index 00000000000..6b335f4ec99 --- /dev/null +++ b/apps/web/src/app/page/[slug]/utils/publicReadLimit/index.ts @@ -0,0 +1 @@ +export { allowPublicRead } from "./publicReadLimit"; diff --git a/apps/web/src/app/page/[slug]/utils/publicReadLimit/publicReadLimit.ts b/apps/web/src/app/page/[slug]/utils/publicReadLimit/publicReadLimit.ts new file mode 100644 index 00000000000..228579722ea --- /dev/null +++ b/apps/web/src/app/page/[slug]/utils/publicReadLimit/publicReadLimit.ts @@ -0,0 +1,36 @@ +import "server-only"; + +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; +import { headers } from "next/headers"; +import { env } from "@/env"; + +const redis = + env.KV_REST_API_URL && env.KV_REST_API_TOKEN + ? new Redis({ url: env.KV_REST_API_URL, token: env.KV_REST_API_TOKEN }) + : null; + +const publicReadRateLimit = redis + ? new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(60, "1 m"), + prefix: "ratelimit:page:public", + }) + : null; + +export async function allowPublicRead(): Promise { + if (!publicReadRateLimit) return true; + const heads = await headers(); + const ip = + heads.get("x-forwarded-for")?.split(",")[0]?.trim() || + heads.get("x-real-ip") || + "unknown"; + + try { + const { success } = await publicReadRateLimit.limit(ip); + return success; + } catch (error) { + console.error("[pages] public rate limiter unavailable:", error); + return true; + } +} diff --git a/bun.lock b/bun.lock index 4eff1823157..3fef0dc9cce 100644 --- a/bun.lock +++ b/bun.lock @@ -730,6 +730,8 @@ "@trpc/server": "11.16.0", "@trpc/tanstack-react-query": "11.16.0", "@uiw/react-md-editor": "4.1.0", + "@upstash/ratelimit": "2.0.8", + "@upstash/redis": "1.37.0", "better-auth": "1.6.22", "framer-motion": "12.38.0", "geist": "1.7.0", diff --git a/packages/i18n/src/server-errors.ts b/packages/i18n/src/server-errors.ts index 8815d128936..7743a309aa5 100644 --- a/packages/i18n/src/server-errors.ts +++ b/packages/i18n/src/server-errors.ts @@ -555,12 +555,6 @@ export const serverErrorMessages: Record< message: "Provide either id or slug", }), ), - "serverError.page.rateLimitExceeded": () => - i18n._( - msg({ - message: "Rate limit exceeded.", - }), - ), "serverError.page.thisPageIsBeingPublishedFrom": () => i18n._( msg({ diff --git a/packages/shared/src/usercontent/manifest.ts b/packages/shared/src/usercontent/manifest.ts index 8783aacb770..f0b9ecb10e6 100644 --- a/packages/shared/src/usercontent/manifest.ts +++ b/packages/shared/src/usercontent/manifest.ts @@ -96,12 +96,6 @@ export function servedVersionOf(manifest: PageManifest): number | null { return manifest.sharedVersion ?? manifest.latestVersion; } -/** - * "Anyone with the link" shares the version the link resolves to, not the - * history behind it. The page id is public on a shared page — it is the host - * of the unfurl thumbnail — so every other version stays behind a ticket, and - * a version the owner moved away from stops being readable the moment they do. - */ export function publiclyReadable( manifest: PageManifest, version: number, diff --git a/packages/trpc/src/router/page/page.ts b/packages/trpc/src/router/page/page.ts index 2baf0408e6f..678509ea5de 100644 --- a/packages/trpc/src/router/page/page.ts +++ b/packages/trpc/src/router/page/page.ts @@ -30,7 +30,6 @@ import { pageAssetRouter } from "./assets"; import { pageUrl } from "./page-url"; import { publishPage } from "./publish"; import { isEntryPathConflict } from "./publish-rules"; -import { enforcePublicPageRead } from "./rate-limit"; import { clearPageWatchSchema, createPageSchema, @@ -691,9 +690,6 @@ export const pageRouter = { .orderBy(desc(pageVersions.version)); const baseUrl = env.USERCONTENT_URL; - // Emitted unchecked, like `list` does: this runs on every page render - // and every header, and an R2 listing per call buys nothing the - // client's own `onError` placeholder does not already handle. return await Promise.all( rows.map(async (row) => ({ ...row, @@ -797,9 +793,7 @@ export const pageRouter = { publicView: publicProcedure .input(publicPageSchema) - .query(async ({ ctx, input }) => { - await enforcePublicPageRead(ctx.headers); - + .query(async ({ input }) => { const [page] = await db .select() .from(pages) diff --git a/packages/trpc/src/router/page/rate-limit.ts b/packages/trpc/src/router/page/rate-limit.ts deleted file mode 100644 index ce1b6bd8185..00000000000 --- a/packages/trpc/src/router/page/rate-limit.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Ratelimit } from "@upstash/ratelimit"; -import { Redis } from "@upstash/redis"; -import { env } from "../../env"; -import { userError } from "../../trpc"; - -const redis = - env.KV_REST_API_URL && env.KV_REST_API_TOKEN - ? new Redis({ url: env.KV_REST_API_URL, token: env.KV_REST_API_TOKEN }) - : null; - -/** - * On a public page the slug is the credential, and it is only six random - * characters wide. This is what makes guessing one cost more than a loop. - */ -const publicViewRateLimit = redis - ? new Ratelimit({ - redis, - limiter: Ratelimit.slidingWindow(60, "1 m"), - prefix: "ratelimit:page:public", - }) - : null; - -export async function enforcePublicPageRead(headers: Headers): Promise { - if (!publicViewRateLimit) return; - const ip = - headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - headers.get("x-real-ip") || - "unknown"; - - let success: boolean; - try { - ({ success } = await publicViewRateLimit.limit(ip)); - } catch (error) { - // Fail open: a Redis blip should not take every shared page down. - console.error("[pages] public rate limiter unavailable:", error); - return; - } - if (!success) { - throw userError({ - code: "TOO_MANY_REQUESTS", - message: "Rate limit exceeded.", - i18nKey: "serverError.page.rateLimitExceeded", - }); - } -} diff --git a/packages/ui/src/components/PageComments/components/PageHeader/components/PageTitleMenu/components/VersionThumbnail/VersionThumbnail.tsx b/packages/ui/src/components/PageComments/components/PageHeader/components/PageTitleMenu/components/VersionThumbnail/VersionThumbnail.tsx index 0fed0779b63..1555105fca8 100644 --- a/packages/ui/src/components/PageComments/components/PageHeader/components/PageTitleMenu/components/VersionThumbnail/VersionThumbnail.tsx +++ b/packages/ui/src/components/PageComments/components/PageHeader/components/PageTitleMenu/components/VersionThumbnail/VersionThumbnail.tsx @@ -5,11 +5,6 @@ interface VersionThumbnailProps { src: string | null | undefined; } -/** - * Version thumbnails are emitted without checking R2, so one that has not been - * captured yet 404s and falls back here rather than costing every caller a - * listing to find out. - */ export function VersionThumbnail({ src }: VersionThumbnailProps) { const [failed, setFailed] = useState(false); From 5b1a93a2a5d5d37c37ae1a1424ef6f194fdd6cee Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Wed, 16 Sep 2026 13:41:14 +0530 Subject: [PATCH 09/10] fix(pages): the page header's thread count follows the previewed version usePageHeaderData pulled the served version and keyed its comment threads on it, so while a pane or the detail view previewed an older version the count in the title bar disagreed with the panel underneath. The target now carries the previewed version, and both surfaces pass it. --- .../_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts | 3 ++- .../pages/$slug/components/PageDetailView/PageDetailView.tsx | 2 +- .../components/PagePaneTitle/PagePaneTitle.tsx | 5 +++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts index 9d334fcfbcb..d6d64760a65 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts @@ -13,6 +13,7 @@ export interface PageHeaderTarget { slug: string; pageId?: string; title?: string; + version?: number | null; } interface PageHeaderData { @@ -38,7 +39,7 @@ export function usePageHeaderData(data: PageHeaderTarget): PageHeaderData { const versions = cloudTrpc.page.versions.useQuery(ref, { enabled }); const access = cloudTrpc.page.access.useQuery(ref, { enabled }); - const version = pull.data?.version ?? 0; + const version = data.version ?? pull.data?.version ?? 0; const { threads } = usePageCommentThreads({ pageId: pageId ?? "", version, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/$slug/components/PageDetailView/PageDetailView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/$slug/components/PageDetailView/PageDetailView.tsx index c5e9d55218b..408a47ec784 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/$slug/components/PageDetailView/PageDetailView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/$slug/components/PageDetailView/PageDetailView.tsx @@ -27,7 +27,7 @@ export function PageDetailView({ slug }: PageDetailViewProps) { onRename, onRefresh, onDelete, - } = usePageHeaderData({ slug }); + } = usePageHeaderData({ slug, version: previewVersion }); const goBack = () => navigate({ to: "/pages" }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneTitle/PagePaneTitle.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneTitle/PagePaneTitle.tsx index 4691354a864..cc75d66fd2b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneTitle/PagePaneTitle.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/PagePaneTitle/PagePaneTitle.tsx @@ -17,9 +17,10 @@ interface PagePaneTitleProps { } export function PagePaneTitle({ data, paneId, onClose }: PagePaneTitleProps) { + const { setShareOpen, previewVersion, setPreviewVersion } = + usePagePaneUi(paneId); const { page, versions, currentUserId, onRename, onRefresh, onDelete } = - usePageHeaderData(data); - const { setShareOpen, setPreviewVersion } = usePagePaneUi(paneId); + usePageHeaderData({ ...data, version: previewVersion }); const [menuOpen, setMenuOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [renameOpen, setRenameOpen] = useState(false); From ebcf8deadc06640c0c168323fd46fa71d422a10c Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 16 Sep 2026 10:05:17 -0700 Subject: [PATCH 10/10] chore(web): sort the new upstash dependencies Sherif fails the build on unordered dependencies, and the rate limiter's two packages landed above @t3-oss. --- apps/web/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index adfcd9c55a9..9fedfe6c527 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,8 +24,6 @@ "@superset/trpc": "workspace:*", "@superset/ui": "workspace:*", "@superset/workspace-client": "workspace:*", - "@upstash/ratelimit": "2.0.8", - "@upstash/redis": "1.37.0", "@t3-oss/env-nextjs": "0.13.11", "@tanstack/react-query": "5.101.4", "@tanstack/react-query-devtools": "5.101.4", @@ -33,6 +31,8 @@ "@trpc/server": "11.16.0", "@trpc/tanstack-react-query": "11.16.0", "@uiw/react-md-editor": "4.1.0", + "@upstash/ratelimit": "2.0.8", + "@upstash/redis": "1.37.0", "better-auth": "1.6.22", "framer-motion": "12.38.0", "geist": "1.7.0",