Merge remote-tracking branch 'gitea/develop' into fix/review-batch-2
# Conflicts: # AGENTS.md # CHANGELOG.md # README.md # apps/server/src/collaboration/collaboration.handler.ts # apps/server/src/common/helpers/prosemirror/html-embed.spec.ts # apps/server/src/common/helpers/prosemirror/html-embed.util.ts # apps/server/src/core/ai-chat/public-share-chat.service.ts # apps/server/src/core/ai-chat/public-share-chat.spec.ts # apps/server/src/core/ai-chat/public-share-workspace-limiter.ts # apps/server/src/core/page/services/page.service.ts # apps/server/src/core/page/transclusion/transclusion.service.ts # apps/server/src/integrations/import/services/file-import-task.service.ts # apps/server/src/integrations/import/services/import.service.ts
This commit is contained in:
@@ -22,6 +22,11 @@ interface MessageItemProps {
|
||||
* UUIDs/routes in the assistant's markdown don't leak as clickable links.
|
||||
*/
|
||||
neutralizeInternalLinks?: boolean;
|
||||
/**
|
||||
* Display name for the dimmed assistant label. Defaults to "AI agent" when
|
||||
* absent; the public share passes the configured identity (agent role) name.
|
||||
*/
|
||||
assistantName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,6 +45,7 @@ export default function MessageItem({
|
||||
message,
|
||||
showCitations = true,
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
}: MessageItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const isUser = message.role === "user";
|
||||
@@ -61,7 +67,7 @@ export default function MessageItem({
|
||||
return (
|
||||
<Box className={classes.messageRow}>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
{t("AI agent")}
|
||||
{assistantName?.trim() || t("AI agent")}
|
||||
</Text>
|
||||
{message.parts.map((part, index) => {
|
||||
if (part.type === "text") {
|
||||
|
||||
@@ -30,6 +30,12 @@ interface MessageListProps {
|
||||
* UUIDs/routes don't leak as clickable links to anonymous readers.
|
||||
*/
|
||||
neutralizeInternalLinks?: boolean;
|
||||
/**
|
||||
* Display name for the assistant's dimmed row label and typing indicator.
|
||||
* Defaults to "AI agent" when absent. The public share passes the configured
|
||||
* identity (agent role) name; the internal chat omits it.
|
||||
*/
|
||||
assistantName?: string;
|
||||
}
|
||||
|
||||
// Distance (px) from the bottom within which the viewport still counts as
|
||||
@@ -67,6 +73,7 @@ export default function MessageList({
|
||||
emptyState,
|
||||
showCitations = true,
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
}: MessageListProps) {
|
||||
const { t } = useTranslation();
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
@@ -148,9 +155,10 @@ export default function MessageList({
|
||||
message={message}
|
||||
showCitations={showCitations}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
assistantName={assistantName}
|
||||
/>
|
||||
))}
|
||||
{typing && <TypingIndicator />}
|
||||
{typing && <TypingIndicator assistantName={assistantName} />}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -2,22 +2,33 @@ import { Box, Group, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface TypingIndicatorProps {
|
||||
/**
|
||||
* Display name for the dimmed label and the "… is typing…" line. Defaults to
|
||||
* "AI agent" when absent; the public share passes the configured identity
|
||||
* (agent role) name.
|
||||
*/
|
||||
assistantName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live "AI agent is typing…" placeholder shown while a turn is in flight but the
|
||||
* latest assistant message has no visible content yet (no rendered text/tool
|
||||
* parts). It covers the gap between sending and the first streamed token, and is
|
||||
* replaced by the real assistant message once content starts arriving.
|
||||
* Live "… is typing…" placeholder shown while a turn is in flight but the latest
|
||||
* assistant message has no visible content yet (no rendered text/tool parts). It
|
||||
* covers the gap between sending and the first streamed token, and is replaced by
|
||||
* the real assistant message once content starts arriving.
|
||||
*
|
||||
* Mirrors the assistant row layout in MessageItem (the dimmed "AI agent" label),
|
||||
* so it reads as the assistant's bubble taking shape.
|
||||
* Mirrors the assistant row layout in MessageItem (the dimmed label), so it reads
|
||||
* as the assistant's bubble taking shape. The label and typing line use the
|
||||
* configured identity name when provided, otherwise the generic "AI agent".
|
||||
*/
|
||||
export default function TypingIndicator() {
|
||||
export default function TypingIndicator({ assistantName }: TypingIndicatorProps) {
|
||||
const { t } = useTranslation();
|
||||
const name = assistantName?.trim();
|
||||
|
||||
return (
|
||||
<Box className={classes.messageRow}>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
{t("AI agent")}
|
||||
{name || t("AI agent")}
|
||||
</Text>
|
||||
<Group gap={8} align="center">
|
||||
<span className={classes.typingDots} aria-hidden="true">
|
||||
@@ -26,7 +37,7 @@ export default function TypingIndicator() {
|
||||
<span />
|
||||
</span>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("AI agent is typing…")}
|
||||
{name ? t("{{name}} is typing…", { name }) : t("AI agent is typing…")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildSandboxSrcdoc,
|
||||
canEdit,
|
||||
HTML_EMBED_HEIGHT_MESSAGE,
|
||||
shouldRender,
|
||||
} from "./html-embed-sandbox";
|
||||
|
||||
describe("buildSandboxSrcdoc", () => {
|
||||
it("embeds the user source verbatim", () => {
|
||||
const out = buildSandboxSrcdoc("<div id='x'>hello</div>");
|
||||
expect(out).toContain("<div id='x'>hello</div>");
|
||||
});
|
||||
|
||||
it("injects the height-postMessage bootstrap after the source", () => {
|
||||
const out = buildSandboxSrcdoc("<p>body</p>");
|
||||
// The bootstrap is appended AFTER the source.
|
||||
expect(out.indexOf("<p>body</p>")).toBeLessThan(
|
||||
out.indexOf(HTML_EMBED_HEIGHT_MESSAGE),
|
||||
);
|
||||
// It reports its height to the parent via postMessage with the agreed type.
|
||||
expect(out).toContain("parent.postMessage");
|
||||
expect(out).toContain(HTML_EMBED_HEIGHT_MESSAGE);
|
||||
// It observes resizes so the parent can keep the iframe sized to fit.
|
||||
expect(out).toContain("ResizeObserver");
|
||||
expect(out).toContain('addEventListener("load"');
|
||||
});
|
||||
|
||||
it("handles an empty source (still injects the bootstrap)", () => {
|
||||
const out = buildSandboxSrcdoc("");
|
||||
expect(out).toContain(HTML_EMBED_HEIGHT_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRender (render policy)", () => {
|
||||
it("read-only renders regardless of the workspace toggle", () => {
|
||||
// isEditable=false → the server already gated the content.
|
||||
expect(shouldRender(false, false)).toBe(true);
|
||||
expect(shouldRender(false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("editable + toggle OFF does NOT render", () => {
|
||||
expect(shouldRender(true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("editable + toggle ON renders", () => {
|
||||
expect(shouldRender(true, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canEdit (edit policy)", () => {
|
||||
it("any member can edit when editable and the toggle is ON (no admin gate)", () => {
|
||||
expect(canEdit(true, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("cannot edit when the toggle is OFF", () => {
|
||||
expect(canEdit(true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("cannot edit in read-only mode (no edit affordance)", () => {
|
||||
expect(canEdit(false, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Pure helpers for the HTML embed node view. Kept out of the React component so
|
||||
* the sandbox srcdoc builder and the render/edit policy can be unit-tested
|
||||
* against a bare environment with no Tiptap/Mantine providers.
|
||||
*/
|
||||
|
||||
/** postMessage type the sandboxed iframe uses to report its content height. */
|
||||
export const HTML_EMBED_HEIGHT_MESSAGE = "gitmost-html-embed-height";
|
||||
|
||||
/**
|
||||
* Build the `srcdoc` document for the sandboxed embed iframe.
|
||||
*
|
||||
* The user's `source` is placed verbatim, then a small bootstrap <script> is
|
||||
* appended at the end of the body. The iframe is rendered with a sandbox that
|
||||
* does NOT include `allow-same-origin`, so this content runs in an opaque
|
||||
* ("null") origin and cannot read the viewer's cookies/session/API — it is
|
||||
* harmless. The bootstrap measures the document height and reports it to the
|
||||
* parent via postMessage on load and whenever the content resizes, so the
|
||||
* parent can size the iframe to fit (auto-resize mode).
|
||||
*/
|
||||
export function buildSandboxSrcdoc(source: string): string {
|
||||
const bootstrap = `
|
||||
<script>
|
||||
(function () {
|
||||
var lastSent = -1;
|
||||
var scheduled = false;
|
||||
function measure() {
|
||||
var doc = document.documentElement;
|
||||
var body = document.body;
|
||||
return Math.max(
|
||||
doc ? doc.scrollHeight : 0,
|
||||
body ? body.scrollHeight : 0
|
||||
);
|
||||
}
|
||||
function flush() {
|
||||
scheduled = false;
|
||||
var height = measure();
|
||||
// Only report when the height actually changed by more than 1px. This
|
||||
// damps the iframe self-measure feedback loop: content sized to the iframe
|
||||
// viewport would otherwise oscillate as the parent resizes the frame in
|
||||
// response to each report.
|
||||
if (Math.abs(height - lastSent) <= 1) return;
|
||||
lastSent = height;
|
||||
parent.postMessage(
|
||||
{ type: ${JSON.stringify(HTML_EMBED_HEIGHT_MESSAGE)}, height: height },
|
||||
"*"
|
||||
);
|
||||
}
|
||||
function reportHeight() {
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(flush);
|
||||
} else {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
window.addEventListener("load", reportHeight);
|
||||
// Report an initial height now (runs during parse, before load/images
|
||||
// settle); the load handler and ResizeObserver refine it as content changes.
|
||||
reportHeight();
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
try {
|
||||
var ro = new ResizeObserver(reportHeight);
|
||||
ro.observe(document.documentElement);
|
||||
} catch (e) {
|
||||
// ResizeObserver unavailable/failed: the load handler still reports once.
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>`;
|
||||
return `${source || ""}${bootstrap}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render policy split by editor mode:
|
||||
* - READ-ONLY / public-share view: the SERVER already decided whether to
|
||||
* include the embed (it strips htmlEmbed from shared content when the
|
||||
* workspace master toggle is OFF). An anonymous viewer has no workspace and
|
||||
* thus reads `featureEnabled` as false, so we must NOT gate rendering on it
|
||||
* here — we render exactly the `source` the server chose to serve.
|
||||
* - EDITABLE editor: gate on the per-workspace master toggle so an author sees
|
||||
* the inert placeholder when the feature is OFF.
|
||||
*/
|
||||
export function shouldRender(
|
||||
isEditable: boolean,
|
||||
featureEnabled: boolean,
|
||||
): boolean {
|
||||
return !isEditable || featureEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* The edit affordance is only meaningful in edit mode and is offered only when
|
||||
* the workspace master toggle is ON. The block renders in a sandboxed iframe
|
||||
* (no same-origin access), so authoring is allowed to ANY member — there is no
|
||||
* admin requirement.
|
||||
*/
|
||||
export function canEdit(isEditable: boolean, featureEnabled: boolean): boolean {
|
||||
return isEditable && featureEnabled;
|
||||
}
|
||||
@@ -2,11 +2,18 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* The container the raw source is injected into. */
|
||||
/* Fallback container used only for the empty, non-editor case. */
|
||||
.htmlEmbedContent {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* The sandboxed iframe the embed source is rendered into. */
|
||||
.htmlEmbedFrame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Edit affordance overlay, only shown while editing the document. */
|
||||
.htmlEmbedToolbar {
|
||||
position: absolute;
|
||||
|
||||
@@ -1,85 +1,118 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { IconCode, IconEdit } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtomValue } from "jotai";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import classes from "./html-embed-view.module.css";
|
||||
import {
|
||||
buildSandboxSrcdoc,
|
||||
canEdit as computeCanEdit,
|
||||
renderRawHtml,
|
||||
shouldExecute as computeShouldExecute,
|
||||
} from "./render-raw-html.ts";
|
||||
HTML_EMBED_HEIGHT_MESSAGE,
|
||||
shouldRender as computeShouldRender,
|
||||
} from "./html-embed-sandbox.ts";
|
||||
|
||||
// Sane bounds for the auto-resized iframe so a runaway embed cannot blow up the
|
||||
// page layout, and a sensible default before the first height message arrives.
|
||||
const MIN_IFRAME_HEIGHT = 40;
|
||||
const MAX_IFRAME_HEIGHT = 4000;
|
||||
const DEFAULT_IFRAME_HEIGHT = 150;
|
||||
|
||||
// Clamp a reported/configured height into the sane iframe bounds.
|
||||
const clampHeight = (h: number) =>
|
||||
Math.min(MAX_IFRAME_HEIGHT, Math.max(MIN_IFRAME_HEIGHT, h));
|
||||
|
||||
export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, selected, updateAttributes, editor } = props;
|
||||
const { source } = node.attrs as { source: string };
|
||||
const { isAdmin } = useUserRole();
|
||||
const { source, height } = node.attrs as {
|
||||
source: string;
|
||||
height: number | null;
|
||||
};
|
||||
|
||||
// Defense in depth: only execute the raw HTML/JS when the workspace HTML embed
|
||||
// feature toggle is ON. When OFF (the default), we render a neutral disabled
|
||||
// placeholder and inject nothing — so turning the feature off neutralizes
|
||||
// existing embeds at render time as well as on the next server-side save.
|
||||
// The HTML embed renders inside a SANDBOXED iframe (no same-origin access), so
|
||||
// the workspace toggle is a feature switch, not a security gate. When OFF (the
|
||||
// default) we render a neutral placeholder in the editor and nothing else.
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const htmlEmbedEnabled = workspace?.settings?.htmlEmbed === true;
|
||||
|
||||
// Execution policy split by editor mode:
|
||||
// - READ-ONLY / public-share view: the SERVER already decided whether to
|
||||
// include the embed (it strips htmlEmbed from shared content when the
|
||||
// workspace toggle is OFF). An anonymous viewer has no workspace and thus
|
||||
// reads `htmlEmbedEnabled` as false, so we must NOT gate execution on it
|
||||
// here — we execute exactly the `source` the server chose to serve.
|
||||
// - EDITABLE editor (admin authoring): keep gating on the per-workspace
|
||||
// toggle so an admin sees the inert placeholder when the feature is OFF.
|
||||
const shouldExecute = computeShouldExecute(
|
||||
const shouldRender = computeShouldRender(
|
||||
editor.isEditable,
|
||||
htmlEmbedEnabled,
|
||||
);
|
||||
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<string>(source || "");
|
||||
const [draftHeight, setDraftHeight] = useState<number | "">(height ?? "");
|
||||
|
||||
// (Re)render the raw source whenever it changes. This runs in BOTH the
|
||||
// editable editor and the read-only / public-share editor (same NodeView),
|
||||
// so trackers fire for readers too — that is the intended behaviour. When the
|
||||
// feature toggle is OFF we clear the container and inject/execute nothing.
|
||||
// True when the author pinned an explicit height; otherwise we auto-resize to
|
||||
// the iframe's reported content height.
|
||||
const hasFixedHeight = typeof height === "number" && Number.isFinite(height);
|
||||
|
||||
// Auto-resize height tracked in state. Seeded to the default and updated from
|
||||
// the iframe's postMessage reports (see effect below) regardless of mode, so
|
||||
// switching a fixed-height embed back to auto immediately reflects the last
|
||||
// reported content height instead of staying pinned to the old fixed value.
|
||||
const [autoHeight, setAutoHeight] = useState<number>(DEFAULT_IFRAME_HEIGHT);
|
||||
|
||||
const srcdoc = useMemo(() => buildSandboxSrcdoc(source || ""), [source]);
|
||||
|
||||
// Auto-resize: accept height messages ONLY from this iframe's own content
|
||||
// window. The sandboxed srcdoc has an opaque ("null") origin, so we cannot
|
||||
// match by event.origin — we match by event.source instead. We track the
|
||||
// reported height even while a fixed height is in effect, so toggling back to
|
||||
// auto shows the current content height with no iframe reload.
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) return;
|
||||
if (shouldExecute) {
|
||||
renderRawHtml(contentRef.current, source || "");
|
||||
} else {
|
||||
contentRef.current.innerHTML = "";
|
||||
function onMessage(event: MessageEvent) {
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
const data = event.data as { type?: string; height?: number };
|
||||
if (data?.type !== HTML_EMBED_HEIGHT_MESSAGE) return;
|
||||
const next = Number(data.height);
|
||||
if (!Number.isFinite(next)) return;
|
||||
setAutoHeight(clampHeight(next));
|
||||
}
|
||||
}, [source, shouldExecute]);
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, []);
|
||||
|
||||
const effectiveHeight = hasFixedHeight ? clampHeight(height) : autoHeight;
|
||||
|
||||
const openEditor = useCallback(() => {
|
||||
setDraft(source || "");
|
||||
setDraftHeight(height ?? "");
|
||||
setModalOpen(true);
|
||||
}, [source]);
|
||||
}, [source, height]);
|
||||
|
||||
const onSave = useCallback(() => {
|
||||
if (editor.isEditable) {
|
||||
updateAttributes({ source: draft });
|
||||
updateAttributes({
|
||||
source: draft,
|
||||
height: draftHeight === "" ? null : Number(draftHeight),
|
||||
});
|
||||
}
|
||||
setModalOpen(false);
|
||||
}, [draft, editor.isEditable, updateAttributes]);
|
||||
}, [draft, draftHeight, editor.isEditable, updateAttributes]);
|
||||
|
||||
// The edit affordance is only meaningful in edit mode, is restricted to admins
|
||||
// (the server strips the node for non-admins anyway), and is offered only when
|
||||
// the workspace feature toggle is ON.
|
||||
const canEdit = computeCanEdit(editor.isEditable, isAdmin, htmlEmbedEnabled);
|
||||
// The edit affordance is only meaningful in edit mode and is offered only when
|
||||
// the workspace master toggle is ON. Any member can edit (sandboxed = safe).
|
||||
const canEdit = computeCanEdit(editor.isEditable, htmlEmbedEnabled);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
@@ -101,12 +134,12 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!shouldExecute ? (
|
||||
{!shouldRender ? (
|
||||
// Feature disabled for this workspace AND we're in the editable editor:
|
||||
// never inject/execute the source. Show a neutral placeholder so an
|
||||
// existing embed is visibly inert for the authoring admin. Read-only /
|
||||
// share viewers never hit this branch (`shouldExecute` is always true
|
||||
// there) — they execute exactly the source the server chose to serve.
|
||||
// render a neutral placeholder so an existing embed is visibly inert for
|
||||
// the author. Read-only / share viewers never hit this branch
|
||||
// (`shouldRender` is always true there) — they render exactly the
|
||||
// source the server chose to serve.
|
||||
<div className={classes.htmlEmbedPlaceholder}>
|
||||
<IconCode size={18} />
|
||||
<Text size="sm">
|
||||
@@ -114,9 +147,18 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
</Text>
|
||||
</div>
|
||||
) : source ? (
|
||||
// Raw HTML/CSS/JS rendered into the wiki origin. Scripts are re-created
|
||||
// in renderRawHtml so they execute.
|
||||
<div ref={contentRef} className={classes.htmlEmbedContent} />
|
||||
// Raw HTML/CSS/JS rendered inside a sandboxed iframe (no same-origin):
|
||||
// scripts run in an opaque origin and cannot touch the viewer's
|
||||
// session/cookies/API.
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className={classes.htmlEmbedFrame}
|
||||
sandbox="allow-scripts allow-popups allow-forms"
|
||||
srcDoc={srcdoc}
|
||||
title={t("HTML embed")}
|
||||
referrerPolicy="no-referrer"
|
||||
style={{ height: effectiveHeight }}
|
||||
/>
|
||||
) : canEdit ? (
|
||||
<div className={classes.htmlEmbedPlaceholder} onClick={openEditor}>
|
||||
<IconCode size={18} />
|
||||
@@ -124,7 +166,7 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
</div>
|
||||
) : (
|
||||
// Empty source, non-editor: render nothing visible.
|
||||
<div ref={contentRef} className={classes.htmlEmbedContent} />
|
||||
<div className={classes.htmlEmbedContent} />
|
||||
)}
|
||||
|
||||
<Modal
|
||||
@@ -135,7 +177,7 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t(
|
||||
"This HTML/CSS/JS runs in the page origin for everyone who views it. Admins only.",
|
||||
"This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.",
|
||||
)}
|
||||
</Text>
|
||||
<Textarea
|
||||
@@ -148,6 +190,19 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
styles={{ input: { fontFamily: "monospace" } }}
|
||||
data-autofocus
|
||||
/>
|
||||
<NumberInput
|
||||
mt="md"
|
||||
label={t("Height (px, blank = auto)")}
|
||||
value={draftHeight}
|
||||
onChange={(value) =>
|
||||
setDraftHeight(
|
||||
value === "" || value === null ? "" : Number(value),
|
||||
)
|
||||
}
|
||||
min={MIN_IFRAME_HEIGHT}
|
||||
max={MAX_IFRAME_HEIGHT}
|
||||
allowDecimal={false}
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>
|
||||
{t("Cancel")}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { renderRawHtml, shouldExecute, canEdit } from "./render-raw-html";
|
||||
|
||||
// jsdom does NOT execute <script> nodes unless its instance was created with
|
||||
// `runScripts: "dangerously"`. The whole point of renderRawHtml is to make
|
||||
// re-created scripts run, so the execution tests drive a dedicated script-
|
||||
// running JSDOM and pass it a container from THAT document (renderRawHtml uses
|
||||
// `container.ownerDocument`, so it creates the fresh scripts in the running
|
||||
// instance). The default vitest jsdom (no runScripts) is used for the
|
||||
// structural and policy assertions.
|
||||
describe("renderRawHtml (script execution against a runScripts jsdom)", () => {
|
||||
let dom: JSDOM;
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
runScripts: "dangerously",
|
||||
});
|
||||
container = dom.window.document.createElement("div");
|
||||
dom.window.document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
it("re-creates and executes an inline <script> (observable side effect)", () => {
|
||||
renderRawHtml(
|
||||
container,
|
||||
"<div>hello</div><script>window.__htmlEmbedFlag = true;</script>",
|
||||
);
|
||||
// The re-created inline script ran inside the jsdom window.
|
||||
expect((dom.window as unknown as Record<string, unknown>).__htmlEmbedFlag).toBe(
|
||||
true,
|
||||
);
|
||||
// The non-script markup is preserved.
|
||||
expect(container.querySelector("div")?.textContent).toBe("hello");
|
||||
});
|
||||
|
||||
it("copies src/async/defer onto a re-created external <script src>", () => {
|
||||
renderRawHtml(
|
||||
container,
|
||||
'<script src="https://example.com/t.js" async defer></script>',
|
||||
);
|
||||
const script = container.querySelector("script");
|
||||
expect(script).not.toBeNull();
|
||||
expect(script?.getAttribute("src")).toBe("https://example.com/t.js");
|
||||
expect(script?.hasAttribute("async")).toBe(true);
|
||||
expect(script?.hasAttribute("defer")).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the container when the source is empty", () => {
|
||||
container.innerHTML = "<p>stale</p>";
|
||||
renderRawHtml(container, "");
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("clears prior content first on a re-render with new source", () => {
|
||||
const win = dom.window as unknown as Record<string, unknown>;
|
||||
renderRawHtml(
|
||||
container,
|
||||
"<span id='first'>one</span><script>window.__htmlEmbedCount = 1;</script>",
|
||||
);
|
||||
expect(win.__htmlEmbedCount).toBe(1);
|
||||
expect(container.querySelector("#first")).not.toBeNull();
|
||||
|
||||
renderRawHtml(
|
||||
container,
|
||||
"<span id='second'>two</span><script>window.__htmlEmbedCount = 2;</script>",
|
||||
);
|
||||
// Prior content is gone; only the new render remains.
|
||||
expect(container.querySelector("#first")).toBeNull();
|
||||
expect(container.querySelector("#second")).not.toBeNull();
|
||||
expect(win.__htmlEmbedCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldExecute (execution policy)", () => {
|
||||
it("read-only executes regardless of the workspace toggle", () => {
|
||||
// isEditable=false → the server already gated the content.
|
||||
expect(shouldExecute(false, false)).toBe(true);
|
||||
expect(shouldExecute(false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("editable + toggle OFF does NOT execute", () => {
|
||||
expect(shouldExecute(true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("editable + toggle ON executes", () => {
|
||||
expect(shouldExecute(true, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canEdit (edit policy)", () => {
|
||||
it("a member (non-admin) can never edit", () => {
|
||||
expect(canEdit(true, false, true)).toBe(false);
|
||||
expect(canEdit(false, false, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("an admin with the toggle OFF cannot edit", () => {
|
||||
expect(canEdit(true, true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("an admin with the toggle ON in editable mode can edit", () => {
|
||||
expect(canEdit(true, true, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("an admin in read-only mode cannot edit (no edit affordance)", () => {
|
||||
expect(canEdit(false, true, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* Pure DOM helpers for the HTML embed node view. Kept out of the React
|
||||
* component so the script re-creation/execution mechanism and the execution/
|
||||
* edit policy can be unit-tested against a bare jsdom container with no
|
||||
* Tiptap/Mantine providers.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Inject raw HTML (including <script> tags) into `container`, executing any
|
||||
* scripts.
|
||||
*
|
||||
* Setting `innerHTML` does NOT run inline or external <script> tags the browser
|
||||
* parses that way: the HTML spec marks scripts inserted via innerHTML as
|
||||
* "already started" so they never execute. To get the tracker/analytics
|
||||
* use-case working we walk the freshly-parsed scripts and replace each with a
|
||||
* brand-new <script> element copying its attributes and inline code. A
|
||||
* programmatically created+inserted <script> DOES execute, so this restores
|
||||
* normal script behaviour in the wiki origin (Variant C).
|
||||
*/
|
||||
export function renderRawHtml(container: HTMLElement, source: string): void {
|
||||
// Clear any previous render (re-render on source change).
|
||||
container.innerHTML = "";
|
||||
if (!source) return;
|
||||
|
||||
container.innerHTML = source;
|
||||
|
||||
// Use the container's own document so the helper works against any document
|
||||
// (the live page or a standalone jsdom instance in tests), not just the
|
||||
// ambient global `document`.
|
||||
const doc = container.ownerDocument;
|
||||
const scripts = Array.from(container.querySelectorAll("script"));
|
||||
for (const oldScript of scripts) {
|
||||
const newScript = doc.createElement("script");
|
||||
// Copy every attribute (src, type, async, defer, data-*, etc.).
|
||||
for (const attr of Array.from(oldScript.attributes)) {
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
// Copy inline code.
|
||||
newScript.text = oldScript.textContent ?? "";
|
||||
// Replacing the node in place triggers execution.
|
||||
oldScript.parentNode?.replaceChild(newScript, oldScript);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution policy split by editor mode:
|
||||
* - READ-ONLY / public-share view: the SERVER already decided whether to
|
||||
* include the embed (it strips htmlEmbed from shared content when the
|
||||
* workspace toggle is OFF). An anonymous viewer has no workspace and thus
|
||||
* reads `featureEnabled` as false, so we must NOT gate execution on it here
|
||||
* — we execute exactly the `source` the server chose to serve.
|
||||
* - EDITABLE editor (admin authoring): keep gating on the per-workspace toggle
|
||||
* so an admin sees the inert placeholder when the feature is OFF.
|
||||
*/
|
||||
export function shouldExecute(
|
||||
isEditable: boolean,
|
||||
featureEnabled: boolean,
|
||||
): boolean {
|
||||
return !isEditable || featureEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* The edit affordance is only meaningful in edit mode, is restricted to admins
|
||||
* (the server strips the node for non-admins anyway), and is offered only when
|
||||
* the workspace feature toggle is ON.
|
||||
*/
|
||||
export function canEdit(
|
||||
isEditable: boolean,
|
||||
isAdmin: boolean,
|
||||
featureEnabled: boolean,
|
||||
): boolean {
|
||||
return isEditable && isAdmin && featureEnabled;
|
||||
}
|
||||
@@ -623,10 +623,9 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
},
|
||||
{
|
||||
title: "HTML embed",
|
||||
description: "Embed raw HTML, CSS and JavaScript (admins only).",
|
||||
description: "Embed raw HTML, CSS and JavaScript (sandboxed).",
|
||||
searchTerms: ["html", "css", "js", "javascript", "script", "tracker", "analytics", "raw", "embed"],
|
||||
icon: IconCode,
|
||||
adminOnly: true,
|
||||
requiresHtmlEmbedFeature: true,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
@@ -795,30 +794,12 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Read whether the current user is a workspace admin/owner from the persisted
|
||||
* `currentUser` (the same payload `currentUserAtom` stores via localStorage).
|
||||
* Used to hide admin-only slash items (e.g. raw HTML embed). This is a UI gate
|
||||
* only; the server independently strips admin-only nodes from non-admin writes.
|
||||
*/
|
||||
function isCurrentUserAdmin(): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem("currentUser");
|
||||
if (!raw) return false;
|
||||
const parsed = JSON.parse(raw);
|
||||
const role = parsed?.user?.role;
|
||||
return role === "owner" || role === "admin";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the workspace-level HTML embed feature toggle from the persisted
|
||||
* Read the workspace-level HTML embed master toggle from the persisted
|
||||
* `currentUser` payload (the same localStorage entry `currentUserAtom` writes,
|
||||
* carrying `workspace.settings`). ABSENT/false => OFF (the default). The slash
|
||||
* `getSuggestionItems` is a plain function (no React/atom context), so we read
|
||||
* the persisted state the same way `isCurrentUserAdmin()` does. UI gate only;
|
||||
* the server independently strips htmlEmbed from every non-allowed write.
|
||||
* the persisted state directly. UI gate only; an anonymous public-share read is
|
||||
* served already-stripped content by the server when the toggle is OFF.
|
||||
*/
|
||||
function isHtmlEmbedFeatureEnabled(): boolean {
|
||||
try {
|
||||
@@ -840,7 +821,6 @@ export const getSuggestionItems = ({
|
||||
}): SlashMenuGroupedItemsType => {
|
||||
const search = query.toLowerCase();
|
||||
const filteredGroups: SlashMenuGroupedItemsType = {};
|
||||
const isAdmin = isCurrentUserAdmin();
|
||||
const htmlEmbedFeatureEnabled = isHtmlEmbedFeatureEnabled();
|
||||
|
||||
const fuzzyMatch = (query: string, target: string) => {
|
||||
@@ -856,9 +836,7 @@ export const getSuggestionItems = ({
|
||||
for (const [group, items] of Object.entries(CommandGroups)) {
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (excludeItems?.has(item.title)) return false;
|
||||
// Hide admin-only items (raw HTML embed) from non-admins.
|
||||
if (item.adminOnly && !isAdmin) return false;
|
||||
// Hide HTML-embed-gated items unless the workspace feature toggle is ON.
|
||||
// Hide the HTML embed item unless the workspace master toggle is ON.
|
||||
if (item.requiresHtmlEmbedFeature && !htmlEmbedFeatureEnabled)
|
||||
return false;
|
||||
return (
|
||||
|
||||
@@ -21,13 +21,9 @@ export type SlashMenuItemType = {
|
||||
searchTerms: string[];
|
||||
command: (props: CommandProps) => void;
|
||||
disable?: (editor: ReturnType<typeof useEditor>) => boolean;
|
||||
// When true, the item is only offered to workspace admins/owners. This is a
|
||||
// UI convenience only — the real authoring gate is enforced server-side.
|
||||
adminOnly?: boolean;
|
||||
// When true, the item is hidden unless the workspace HTML embed feature toggle
|
||||
// is ON. Combined with adminOnly, the item shows only for admins in workspaces
|
||||
// where the feature is enabled. UI gate only — the server strips htmlEmbed on
|
||||
// every write where the toggle is OFF or the user is not an admin.
|
||||
// When true, the item is hidden unless the workspace HTML embed master toggle
|
||||
// is ON. UI gate only — for anonymous public-share reads the server serves
|
||||
// already-stripped content when the toggle is OFF.
|
||||
requiresHtmlEmbedFeature?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ interface ShareAiWidgetProps {
|
||||
shareId: string;
|
||||
/** The page the reader currently has open (context for "this page"). */
|
||||
pageId: string;
|
||||
/** Display name of the configured assistant identity; falls back to 'AI agent' when absent. */
|
||||
assistantName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,7 +50,11 @@ interface ShareAiWidgetProps {
|
||||
* links (so internal UUIDs/auth-gated routes in the answer don't leak as
|
||||
* clickable links), and a documentation-focused empty state.
|
||||
*/
|
||||
export default function ShareAiWidget({ shareId, pageId }: ShareAiWidgetProps) {
|
||||
export default function ShareAiWidget({
|
||||
shareId,
|
||||
pageId,
|
||||
assistantName,
|
||||
}: ShareAiWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
@@ -153,6 +159,7 @@ export default function ShareAiWidget({ shareId, pageId }: ShareAiWidgetProps) {
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
assistantName={assistantName}
|
||||
showCitations={false}
|
||||
// Anonymous reader: neutralize internal/relative links in the
|
||||
// assistant's markdown so internal UUIDs/auth-gated routes don't
|
||||
|
||||
@@ -45,6 +45,10 @@ export interface ISharedPage extends IShare {
|
||||
// Whether the anonymous public-share AI assistant is enabled for the
|
||||
// workspace (server-resolved). Gates the "Ask AI" widget.
|
||||
aiAssistant?: boolean;
|
||||
// Display name of the configured assistant identity (agent role name), used
|
||||
// to label the public-share chat. Null/absent when no identity is set →
|
||||
// the widget falls back to the generic "AI agent" label.
|
||||
aiAssistantName?: string | null;
|
||||
}
|
||||
|
||||
export interface IShareForPage extends IShare {
|
||||
|
||||
@@ -1,57 +1,32 @@
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { useWorkspaceSetting } from "@/features/workspace/hooks/use-workspace-setting.ts";
|
||||
import { Switch, Stack, Paper, Group, Text, List } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Admin toggle for the workspace HTML embed feature.
|
||||
* Workspace master toggle that enables/disables the HTML embed block type.
|
||||
*
|
||||
* SECURITY: when ON, workspace admins/owners can embed raw HTML/CSS/JS that
|
||||
* EXECUTES in the wiki page origin for every reader (a deliberate stored-XSS
|
||||
* surface, e.g. for analytics trackers). OFF by default. The server strips
|
||||
* htmlEmbed nodes on every write where the toggle is OFF or the saver is not an
|
||||
* admin, so this switch fully enables/disables the feature workspace-wide.
|
||||
* The block renders inside a SANDBOXED iframe (no same-origin access), so it
|
||||
* cannot touch the viewer's session/cookies/API — it is a feature switch, not a
|
||||
* security gate. When ON, ANY member can insert the block. OFF by default; for
|
||||
* anonymous public-share reads the server serves already-stripped content when
|
||||
* the toggle is OFF. The toggle itself is managed by workspace admins.
|
||||
*/
|
||||
export default function HtmlEmbedSettings() {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const { workspace, isLoading, save } = useWorkspaceSetting("htmlEmbed");
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
const [checked, setChecked] = useState<boolean>(
|
||||
workspace?.settings?.htmlEmbed ?? false,
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
async function handleToggle(value: boolean) {
|
||||
setIsLoading(true);
|
||||
const previous = checked;
|
||||
setChecked(value); // optimistic update
|
||||
try {
|
||||
const updated = await updateWorkspace({ htmlEmbed: value });
|
||||
// Force settings.htmlEmbed to the new value so the atom is consistent even
|
||||
// if the response shape omits it.
|
||||
setWorkspace({
|
||||
...updated,
|
||||
settings: {
|
||||
...updated.settings,
|
||||
htmlEmbed: value,
|
||||
},
|
||||
});
|
||||
notifications.show({ message: t("Updated successfully") });
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
setChecked(previous); // revert on failure
|
||||
notifications.show({
|
||||
message: t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
const ok = await save(value);
|
||||
if (!ok) setChecked(previous); // revert on failure
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -69,7 +44,7 @@ export default function HtmlEmbedSettings() {
|
||||
<Switch
|
||||
label={t("Enable HTML embed")}
|
||||
description={t(
|
||||
"Allow workspace admins to insert raw HTML/CSS/JavaScript that EXECUTES in the wiki page origin for everyone who views the page (a deliberate stored-XSS surface, e.g. for analytics trackers). Off by default.",
|
||||
"Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.",
|
||||
)}
|
||||
checked={checked}
|
||||
disabled={!isAdmin || isLoading}
|
||||
@@ -79,17 +54,17 @@ export default function HtmlEmbedSettings() {
|
||||
<List size="xs" c="dimmed" mt="md" spacing={4}>
|
||||
<List.Item>
|
||||
{t(
|
||||
"Only workspace admins/owners can insert HTML embeds. Members never can: the editor option is hidden for them and the server strips the embed on save at every write path.",
|
||||
"When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.",
|
||||
)}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"If a non-admin edits and saves a page that contains an admin's embed, that save strips the embed (fail-closed). An admin must re-add it.",
|
||||
"Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.",
|
||||
)}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"Turning this off strips existing embeds on their next save and immediately disables execution (existing embeds render as a disabled placeholder).",
|
||||
"Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.",
|
||||
)}
|
||||
</List.Item>
|
||||
</List>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from "react";
|
||||
import { useWorkspaceSetting } from "@/features/workspace/hooks/use-workspace-setting.ts";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Admin-only analytics/tracker snippet for public share pages.
|
||||
*
|
||||
* The value is injected VERBATIM into the <head> of PUBLIC SHARE pages only,
|
||||
* in the page's own (same-origin) context. It is the deliberate same-origin
|
||||
* surface for analytics snippets (Google Analytics, Yandex.Metrika, etc.).
|
||||
* Admin only — the workspace settings write is admin-gated server-side, and the
|
||||
* Save button is disabled for non-admins.
|
||||
*/
|
||||
export default function TrackerSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { workspace, isLoading, save } = useWorkspaceSetting("trackerHead");
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
const [value, setValue] = useState<string>(
|
||||
workspace?.settings?.trackerHead ?? "",
|
||||
);
|
||||
|
||||
async function handleSave() {
|
||||
await save(value);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack mt="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={700} size="lg">
|
||||
{t("Analytics / tracker")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{t("advanced")}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t(
|
||||
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.",
|
||||
)}
|
||||
</Text>
|
||||
<Textarea
|
||||
autosize
|
||||
minRows={6}
|
||||
maxRows={20}
|
||||
aria-label={t("Analytics / tracker")}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
placeholder={t("<script>...</script>")}
|
||||
styles={{ input: { fontFamily: "monospace" } }}
|
||||
disabled={!isAdmin || isLoading}
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
loading={isLoading}
|
||||
disabled={!isAdmin}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useState } from "react";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Workspace setting keys that this hook can persist. Each key is both a
|
||||
* write-only field on the update payload and a read field under
|
||||
* `workspace.settings`, so the value type is derived from the settings shape.
|
||||
*/
|
||||
type WorkspaceSettingKey = "htmlEmbed" | "trackerHead";
|
||||
type WorkspaceSettingValue<K extends WorkspaceSettingKey> =
|
||||
NonNullable<IWorkspace["settings"][K]>;
|
||||
|
||||
/**
|
||||
* Shared "save a workspace setting" plumbing extracted from the individual
|
||||
* settings components. Owns the `isLoading` state and the persist-then-merge
|
||||
* flow (call `updateWorkspace`, merge the response back into the workspace atom
|
||||
* while forcing `settings[key]` to the saved value, and surface a success/error
|
||||
* notification). Callers keep their own interaction model (optimistic toggle,
|
||||
* edit-then-save, etc.) on top of this.
|
||||
*/
|
||||
export function useWorkspaceSetting<K extends WorkspaceSettingKey>(key: K) {
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const save = useCallback(
|
||||
async (value: WorkspaceSettingValue<K>): Promise<boolean> => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const updated = await updateWorkspace({
|
||||
[key]: value,
|
||||
} as Partial<IWorkspace>);
|
||||
// Force settings[key] to the new value so the atom is consistent even
|
||||
// if the response shape omits it.
|
||||
setWorkspace({
|
||||
...updated,
|
||||
settings: {
|
||||
...updated.settings,
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
notifications.show({ message: t("Updated successfully") });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Failed to update workspace setting "${key}"`, err);
|
||||
notifications.show({
|
||||
message:
|
||||
(err as any)?.response?.data?.message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[key, setWorkspace, t],
|
||||
);
|
||||
|
||||
return { workspace, isLoading, save };
|
||||
}
|
||||
@@ -33,6 +33,9 @@ export interface IWorkspace {
|
||||
// Write-only field for updateWorkspace({ htmlEmbed }). Read state lives at
|
||||
// settings.htmlEmbed.
|
||||
htmlEmbed?: boolean;
|
||||
// Write-only field for updateWorkspace({ trackerHead }). Read state lives at
|
||||
// settings.trackerHead.
|
||||
trackerHead?: string;
|
||||
}
|
||||
|
||||
export interface IWorkspaceSettings {
|
||||
@@ -40,8 +43,13 @@ export interface IWorkspaceSettings {
|
||||
sharing?: IWorkspaceSharingSettings;
|
||||
api?: IWorkspaceApiSettings;
|
||||
templates?: IWorkspaceTemplateSettings;
|
||||
// Admin-only HTML embed feature toggle. ABSENT/false => OFF (default).
|
||||
// HTML embed master toggle (enables/disables the block type). The block
|
||||
// renders in a sandboxed iframe, so this is a feature switch, not a security
|
||||
// gate. ABSENT/false => OFF (default).
|
||||
htmlEmbed?: boolean;
|
||||
// Admin-only analytics/tracker snippet injected into the <head> of public
|
||||
// share pages (same-origin). ABSENT/empty => none.
|
||||
trackerHead?: string;
|
||||
}
|
||||
|
||||
export interface IWorkspaceApiSettings {
|
||||
|
||||
Reference in New Issue
Block a user