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