From fc2c251557a898ef47a6326c722a569a734a3f3c Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 7 Jul 2026 10:01:36 +0200 Subject: [PATCH] feat(demo): allow mutations in demo mode via DEMO_READ_ONLY flag (#6224) --- .env.example | 9 ++++++++- .../[locale]/boards/(content)/_header-actions.tsx | 14 ++++++++------ packages/api/src/env.ts | 2 ++ packages/api/src/router/info.ts | 3 ++- packages/api/src/trpc.ts | 8 ++++++-- 5 files changed, 26 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 318b171e1..22dca3adb 100644 --- a/.env.example +++ b/.env.example @@ -59,4 +59,11 @@ TURBO_TELEMETRY_DISABLED=1 # ENABLE_KUBERNETES=true # Enable mock integration -UNSAFE_ENABLE_MOCK_INTEGRATION=true \ No newline at end of file +UNSAFE_ENABLE_MOCK_INTEGRATION=true + +# Enable demo mode (seeds a demo user "demo"/"demo", a sample board and a mock integration) +# DEMO_MODE=true + +# Whether demo mode is read-only. Defaults to true (the public demo blocks all mutations). +# Set to false for writable preview deployments so the demo user can modify the dashboard. +# DEMO_READ_ONLY=false \ No newline at end of file diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index cc3868e50..454256366 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -45,7 +45,9 @@ export const BoardContentHeaderActions = () => { const [isEditMode] = useEditMode(); const board = useRequiredBoard(); const { hasChangeAccess } = useBoardPermissions(board); - const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery(); + // Fall back to read-only if the query has no data (e.g. errored) so we never expose + // edit/save/settings UI that would then fail server-side. + const { data: demoReadOnly = true, isLoading } = clientApi.info.isDemoReadOnly.useQuery(); if (!hasChangeAccess || isLoading) { return ; @@ -55,9 +57,9 @@ export const BoardContentHeaderActions = () => { <> {isEditMode && } - + - {!demoMode && ( + {!demoReadOnly && ( @@ -158,7 +160,7 @@ const AddMenu = () => { ); }; -const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => { +const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { const [isEditMode, { open, close }] = useEditMode(); const board = useRequiredBoard(); const utils = clientApi.useUtils(); @@ -188,11 +190,11 @@ const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => { const toggle = useCallback(() => { if (isEditMode) { - if (demoMode) return discardDemoChanges(); + if (demoReadOnly) return discardDemoChanges(); return saveBoard(board); } open(); - }, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]); + }, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]); useHotkeys([[hotkeys.toggleBoardEdit, toggle]]); usePreventLeaveWithDirty(isEditMode); diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts index ba26a264a..a978acf01 100644 --- a/packages/api/src/env.ts +++ b/packages/api/src/env.ts @@ -6,9 +6,11 @@ export const env = createEnv({ server: { KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(), DEMO_MODE: createBooleanSchema(false), + DEMO_READ_ONLY: createBooleanSchema(true), }, runtimeEnv: { KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME, DEMO_MODE: process.env.DEMO_MODE, + DEMO_READ_ONLY: process.env.DEMO_READ_ONLY, }, }); diff --git a/packages/api/src/router/info.ts b/packages/api/src/router/info.ts index a0436ede3..156f6387a 100644 --- a/packages/api/src/router/info.ts +++ b/packages/api/src/router/info.ts @@ -1,7 +1,7 @@ import z from "zod/v4"; import packageJson from "../../../../package.json"; -import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc"; +import { createTRPCRouter, isDemoMode, isDemoReadOnly, publicProcedure, protectedProcedure } from "../trpc"; export const infoRouter = createTRPCRouter({ getInfo: protectedProcedure @@ -20,4 +20,5 @@ export const infoRouter = createTRPCRouter({ }; }), isDemoMode: publicProcedure.query(() => isDemoMode), + isDemoReadOnly: publicProcedure.query(() => isDemoReadOnly), }); diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 70abe4a3c..6deb06b22 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -95,8 +95,12 @@ export const createTRPCRouter = t.router; export const isDemoMode = env.DEMO_MODE; +// The public "real demo" (demo + mock integrations) stays read-only, while preview +// deployments run with DEMO_MODE + DEMO_READ_ONLY=false to allow mutations. +export const isDemoReadOnly = env.DEMO_MODE && env.DEMO_READ_ONLY; + const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => { - if (env.DEMO_MODE && type === "mutation") { + if (isDemoReadOnly && type === "mutation") { throw new TRPCError({ code: "FORBIDDEN", message: "Mutations are disabled in demo mode", @@ -105,7 +109,7 @@ const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => { return next({ ctx }); }); -const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure; +const baseProcedure = isDemoReadOnly ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure; /** * Public (unauthed) procedure