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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/public-admin-meta-is-branding-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"nextly": patch
"create-nextly-app": patch
"@nextlyhq/admin": patch
"@nextlyhq/admin-css": patch
"@nextlyhq/blocks-engine": patch
"@nextlyhq/blocks-react": patch
"@nextlyhq/ui": patch
"@nextlyhq/adapter-drizzle": patch
"@nextlyhq/adapter-postgres": patch
"@nextlyhq/adapter-mysql": patch
"@nextlyhq/adapter-sqlite": patch
"@nextlyhq/storage-s3": patch
"@nextlyhq/storage-uploadthing": patch
"@nextlyhq/storage-vercel-blob": patch
"@nextlyhq/plugin-form-builder": patch
"@nextlyhq/plugin-page-builder": patch
"@nextlyhq/plugin-seo": patch
"@nextlyhq/plugin-sdk": patch
"@nextlyhq/eslint-config": patch
"@nextlyhq/prettier-config": patch
"@nextlyhq/telemetry": patch
"@nextlyhq/tsconfig": patch
"@nextlyhq/builder": patch
"@nextlyhq/module-specifiers": patch
---

Serve only branding from the public `/api/admin-meta`. Plugin contributions, configured locales, custom sidebar groups and builder availability now come from the session-gated `/api/admin-meta/workspace`, so a plugin-declared permission slug is no longer readable before sign-in. The admin reads both and merges them, so no component changes.
15 changes: 11 additions & 4 deletions packages/admin/src/components/field-ui/usePluginClientConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,15 @@ export function usePluginClientConfig(
pluginName: string
): Record<string, unknown> | undefined {
const branding = useBranding();
return useMemo(
() => branding.plugins?.find(p => p.name === pluginName)?.clientConfig,
[branding.plugins, pluginName]
);
return useMemo(() => {
// The installed list when it has arrived, the public channel otherwise.
// Both carry `clientConfig`, and the gated one is the richer record — so
// this prefers it rather than reading two sources and reconciling them.
//
// The fallback is what a plugin contributing to the SIGN-IN screen depends
// on: there is no session yet, so the installed list cannot exist, and its
// absence says nothing about whether the plugin declared a config.
const declared = branding.plugins ?? branding.pluginClientConfigs;
return declared?.find(p => p.name === pluginName)?.clientConfig;
}, [branding.plugins, branding.pluginClientConfigs, pluginName]);
}
53 changes: 49 additions & 4 deletions packages/admin/src/context/providers/BrandingProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { AdminBranding } from "@admin/types/branding";

const get = vi.fn();
const protectedGet = vi.fn();

vi.mock("@admin/lib/api/publicApi", () => ({
publicApi: { get: (...args: unknown[]) => get(...args) },
}));

vi.mock("@admin/lib/api/protectedApi", () => ({
protectedApi: { get: (...args: unknown[]) => protectedGet(...args) },
}));

import {
BrandingProvider,
useBrandingStatus,
Expand Down Expand Up @@ -46,12 +51,14 @@ function status() {

afterEach(() => {
get.mockReset();
protectedGet.mockReset();
vi.restoreAllMocks();
});

describe("useBrandingStatus", () => {
it("reports pending until the request answers", async () => {
it("reports pending until both requests answer", async () => {
get.mockReturnValue(new Promise(() => {}));
protectedGet.mockReturnValue(new Promise(() => {}));

renderProbe(
new QueryClient({ defaultOptions: { queries: { retry: false } } })
Expand All @@ -60,8 +67,9 @@ describe("useBrandingStatus", () => {
expect(status()).toBe("pending");
});

it("reports answered once branding arrives", async () => {
get.mockResolvedValue({ plugins: [] } as AdminBranding);
it("reports answered once both halves arrive", async () => {
get.mockResolvedValue({ logoText: "Acme" } as AdminBranding);
protectedGet.mockResolvedValue({ plugins: [] } as AdminBranding);

renderProbe(
new QueryClient({ defaultOptions: { queries: { retry: false } } })
Expand All @@ -70,8 +78,45 @@ describe("useBrandingStatus", () => {
await waitFor(() => expect(status()).toBe("answered"));
});

it("reports unavailable when the first request fails", async () => {
it("settles once the workspace half answers, even if branding is stalled", async () => {
// The ASYMMETRIC case, and the only one that separates the two halves.
// Every other case here moves both queries together, so a status
// combining them passes all of them — measured: reverting to
// `brandingPending || workspacePending` left the rest of this file green.
//
// Its reader draws a conclusion from a plugin being absent, so holding it
// on a loading state while the plugin list has arrived hides a settled
// answer behind a request that has nothing to do with the question.
get.mockReturnValue(new Promise(() => {}));
protectedGet.mockResolvedValue({ plugins: [] } as AdminBranding);

renderProbe(
new QueryClient({ defaultOptions: { queries: { retry: false } } })
);

await waitFor(() => expect(status()).toBe("answered"));
});

it("stays unavailable when only the branding half arrives", async () => {
// The property this hook exists for, and the one the split could have
// broken silently. Its reader concludes something from a plugin being
// ABSENT, and the plugin list lives in the session-gated half — so
// branding having answered says nothing about whether that conclusion is
// safe. Reporting the public query's state here would call it `answered`
// while the list had never been fetched.
get.mockResolvedValue({ logoText: "Acme" } as AdminBranding);
protectedGet.mockRejectedValue(new Error("unauthenticated"));

renderProbe(
new QueryClient({ defaultOptions: { queries: { retry: false } } })
);

await waitFor(() => expect(status()).toBe("unavailable"));
});

it("reports unavailable when the workspace request fails", async () => {
get.mockRejectedValue(new Error("boom"));
protectedGet.mockRejectedValue(new Error("boom"));

renderProbe(
new QueryClient({ defaultOptions: { queries: { retry: false } } })
Expand Down
82 changes: 70 additions & 12 deletions packages/admin/src/context/providers/BrandingProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import type React from "react";
import { createContext, useContext, useEffect, useMemo } from "react";

import { protectedApi } from "../../lib/api/protectedApi";
import { publicApi } from "../../lib/api/publicApi";
import {
DEFAULT_MARK_PATHS,
Expand All @@ -29,8 +30,22 @@ import type {
*/
interface BrandingState {
branding: AdminBranding | undefined;
/** True until the admin-meta query settles, either way. */
/**
* True until the WORKSPACE query settles, either way.
*
* Not the public half: this pairs with `isUnavailable` to answer whether a
* plugin being absent is a fact, and the plugin list is in the workspace
* half alone.
*/
isPending: boolean;
/**
* True when the PUBLIC half never produced an answer.
*
* Separate from `isUnavailable` because the two halves fail independently
* and for different reasons: the workspace half fails routinely before
* sign-in, which says nothing about branding being readable.
*/
isBrandingUnavailable: boolean;
/**
* True when admin-meta has never produced an answer, so absence proves
* nothing.
Expand Down Expand Up @@ -66,6 +81,7 @@ export function useBrandingStatus(): Omit<BrandingState, "branding"> {
return {
isPending: state?.isPending ?? false,
isUnavailable: state?.isUnavailable ?? false,
isBrandingUnavailable: state?.isBrandingUnavailable ?? false,
};
}

Expand Down Expand Up @@ -182,12 +198,11 @@ interface BrandingProviderProps {

export function BrandingProvider({ children }: BrandingProviderProps) {
const {
data: fetchedData,
isPending,
data: brandingData,
// `isLoadingError`, not `isError`: the latter is also true when a
// background refetch fails while a previous response is still cached, and
// that cached response is a perfectly good answer.
isLoadingError,
isLoadingError: brandingUnavailable,
} = useQuery<AdminBranding>({
queryKey: ["admin-meta"],
queryFn: () => publicApi.get<AdminBranding>("/admin-meta"),
Expand All @@ -197,16 +212,59 @@ export function BrandingProvider({ children }: BrandingProviderProps) {
retry: false,
});

useColorInjection(fetchedData?.colors);
useFaviconInjection(fetchedData?.favicon);
// The half that describes the installation rather than its appearance. It
// comes from a session-gated route, so before sign-in this query fails and
// contributes nothing — which is correct, since no pre-session surface reads
// these fields.
const {
data: workspaceData,
isPending: workspacePending,
isLoadingError: workspaceUnavailable,
} = useQuery<AdminBranding>({
queryKey: ["admin-meta", "workspace"],
queryFn: () => protectedApi.get<AdminBranding>("/admin-meta/workspace"),
staleTime: 5 * 60 * 1000,
retry: false,
});

// Memoized because the value is now an object built here rather than the
// query's own stable `data` reference: without this every consumer of the
useColorInjection(brandingData?.colors);
useFaviconInjection(brandingData?.favicon);

// Memoized because the value is an object built here rather than either
// query's stable `data` reference: without this every consumer of the
// context re-renders on each render of this provider.
const value = useMemo(
() => ({ branding: fetchedData, isPending, isUnavailable: isLoadingError }),
[fetchedData, isPending, isLoadingError]
);
//
// The two halves are merged so the shape consumers read is unchanged; the
// boundary that matters is the one on the server, which decides what an
// anonymous caller can be served at all.
const value = useMemo(() => {
const merged =
brandingData === undefined && workspaceData === undefined
? undefined
: { ...brandingData, ...workspaceData };
return {
branding: merged,
// The WORKSPACE query, matching `isUnavailable`. Both answer one
// question — is it safe to conclude something from a plugin being
// absent — and the plugin list is in that half. Combining the two
// reports "still loading" while the only relevant query has settled, so
// a stalled public request would hold the reader on a loading state
// indefinitely and hide a definitive workspace error behind it.
isPending: workspacePending,
// Reported from the WORKSPACE query. The reader this exists for treats a
// plugin's absence from the list as a fact about the project, and the
// plugin list lives in that half — so branding having arrived says
// nothing about whether that conclusion is safe to draw.
isUnavailable: workspaceUnavailable,
isBrandingUnavailable: brandingUnavailable,
};
}, [
brandingData,
workspaceData,
workspacePending,
workspaceUnavailable,
brandingUnavailable,
]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<BrandingContext.Provider value={value}>
Expand Down
6 changes: 5 additions & 1 deletion packages/admin/src/lib/api/refreshInterceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,17 @@ export function setLoginRedirectPath(path: string): void {
* 3. /admin/login mounts → PublicRoute checks setup-status,
* sees no users → navigateTo("/admin/setup"). Bounce.
*/
const NO_REDIRECT_PUBLIC_PATHS = new Set([
export const NO_REDIRECT_PUBLIC_PATHS = new Set([
"/admin/login",
"/admin/setup",
"/admin/register",
"/admin/forgot-password",
"/admin/reset-password",
"/admin/verify-email",
// An invited user reaching this page has no session yet, so a protected
// background query answers 401 exactly as it does on the others. Its absence
// bounced them to login and lost the invite token in the URL.
"/admin/accept-invite",
]);

/**
Expand Down
33 changes: 3 additions & 30 deletions packages/admin/src/pages/dashboard/plugins/[slug].tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
"use client";

import { Badge } from "@nextlyhq/ui";
import {
useQuery,
useQueryClient,
useSuspenseQuery,
} from "@tanstack/react-query";
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { Suspense } from "react";

import {
Expand All @@ -24,10 +20,7 @@ import {
} from "@admin/components/icons";
import { PageContainer } from "@admin/components/layout/page-container";
import { Breadcrumbs } from "@admin/components/shared";
import {
PageErrorFallback,
SectionErrorFallback,
} from "@admin/components/shared/error-fallbacks";
import { PageErrorFallback } from "@admin/components/shared/error-fallbacks";
import { PluginIcon } from "@admin/components/shared/plugin-icon";
import { QueryErrorBoundary } from "@admin/components/shared/query-error-boundary";
import { Link } from "@admin/components/ui/link";
Expand All @@ -46,6 +39,7 @@ import {
} from "@admin/services/realPermissionsApi";
import type { PluginMetadata } from "@admin/types/branding";

import { InstalledPluginsUnavailable } from "./components/InstalledPluginsUnavailable";
import { NotInstalledPlugin } from "./components/NotInstalledPlugin";
import { PluginPageLoading } from "./components/PluginPageLoading";
import { PluginStatusPill } from "./components/PluginsTable";
Expand Down Expand Up @@ -149,27 +143,6 @@ function UninstalledOrMissing({ activeSlug }: { activeSlug?: string }) {
);
}

/**
* Shown when admin-meta failed, so whether this plugin is installed is
* unknown.
*
* Not the catalogue view: that one states the plugin is absent, which is a
* claim this page cannot make when the request that would have told it failed.
*/
function InstalledPluginsUnavailable() {
const queryClient = useQueryClient();

return (
<SectionErrorFallback
title="Could not load your installed plugins"
description="This page cannot tell whether the plugin is installed until the admin metadata loads."
reset={() => {
void queryClient.invalidateQueries({ queryKey: ["admin-meta"] });
}}
/>
);
}

function PluginDetailContent({ activeSlug }: { activeSlug?: string }) {
const branding = useBranding();
const { isPending, isUnavailable } = useBrandingStatus();
Expand Down
13 changes: 11 additions & 2 deletions packages/admin/src/pages/dashboard/plugins/browse.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,17 @@ import type { AdminBranding } from "@admin/types/branding";

let mockBranding: AdminBranding = { plugins: [] } as unknown as AdminBranding;

vi.mock("@admin/lib/api/publicApi", () => ({
publicApi: { get: () => Promise.resolve(mockBranding) },
// The provider, not the transport. Installed status comes from the
// session-gated half of admin-meta, which the page reads through this hook
// rather than by fetching for itself — mocking the public client would supply
// a payload the page no longer asks for and leave every entry uninstalled.
vi.mock("@admin/context/providers/BrandingProvider", () => ({
useBranding: () => mockBranding,
useBrandingStatus: () => ({
isPending: false,
isUnavailable: false,
isBrandingUnavailable: false,
}),
}));

import PluginBrowsePage from "./browse";
Expand Down
Loading
Loading