Add per-share branding: title and logoUrl overrides (#12003)

* feat: add title and logoUrl to Share model

Agent-Logs-Url: https://github.com/outline/outline/sessions/9bc9d438-6892-4903-9d32-6b6868f4fd97

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* fix: use STRING(4096) for logoUrl column in migration

Agent-Logs-Url: https://github.com/outline/outline/sessions/9bc9d438-6892-4903-9d32-6b6868f4fd97

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* feat: use share title and logoUrl to override team branding on shared page

Agent-Logs-Url: https://github.com/outline/outline/sessions/854d6d22-e80b-4673-b3b2-0f9cf43a3246

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* refactor: use ShareValidation class constants for title/logoUrl max lengths

Agent-Logs-Url: https://github.com/outline/outline/sessions/ea462d6a-d4d3-4882-ab8e-88060bf64877

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* fix: use ShareValidation constants in @Length msg template literals

Agent-Logs-Url: https://github.com/outline/outline/sessions/694116c2-47e8-4001-a103-c8a62c7ac71e

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* feat: add display settings popover with custom title and icon for shares

Move share toggles (search indexing, email subscriptions, show last
modified, show TOC) into a popover triggered by a settings cog. The
popover also includes inputs for a custom site title and icon upload
to override team branding on shared pages. Rename logoUrl to iconUrl,
loosen URL validation to allow relative attachment paths, and surface
the popover in the shared page header for users with edit permission.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* styling

* Display branding on single shared pages

* Review comments

* refactor

* PR feedback

* Lose 'Remove icon' button

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Copilot
2026-04-26 21:23:13 -04:00
committed by GitHub
parent 2e3ae72cd2
commit 7ed41eadc6
19 changed files with 1171 additions and 582 deletions
@@ -1,7 +1,8 @@
import copy from "copy-to-clipboard";
import debounce from "lodash/debounce";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { CopyIcon, GlobeIcon, QuestionMarkIcon } from "outline-icons";
import { CopyIcon, GlobeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -22,6 +23,7 @@ import env from "~/env";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { ListItem } from "../components/ListItem";
import ShareSettingsPopover from "../components/ShareSettingsPopover";
import { DomainPrefix, ShareLinkInput, StyledInfoIcon } from "../components";
type Props = {
@@ -50,70 +52,24 @@ function InnerPublicAccess(
setUrlId(share?.urlId);
}, [share?.urlId]);
const handleIndexingChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
allowIndexing: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handleSubscriptionsChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
allowSubscriptions: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handleShowLastModifiedChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
showLastUpdated: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handleShowTOCChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
showTOC: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handlePublishedChange = React.useCallback(
async (checked: boolean) => {
try {
if (checked && !share) {
setCreating(true);
await shares.create({
const newShare = await shares.create({
type: "collection",
collectionId: collection.id,
published: true,
});
copy(newShare.url);
toast.success(t("Public link copied to clipboard"));
} else if (share) {
await share.save({ published: checked });
if (checked) {
copy(share.url);
toast.success(t("Public link copied to clipboard"));
}
}
} catch (err) {
toast.error(err.message);
@@ -121,7 +77,7 @@ function InnerPublicAccess(
setCreating(false);
}
},
[share, shares, collection]
[t, share, shares, collection]
);
const handleUrlChange = React.useMemo(
@@ -172,7 +128,7 @@ function InnerPublicAccess(
return (
<div ref={ref}>
<ListItem
title={t("Web")}
title={t("Publish to web")}
subtitle={<>{t("Allow anyone with the link to access")}</>}
image={
<Squircle color={theme.text} size={AvatarSize.Medium}>
@@ -194,123 +150,24 @@ function InnerPublicAccess(
<ResizingHeightContainer>
{!!share?.published && (
<>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Search engine indexing")}&nbsp;
<Tooltip
content={t(
"Disable this setting to discourage search engines from indexing the page"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Search engine indexing")}
checked={share?.allowIndexing ?? false}
onChange={handleIndexingChanged}
width={26}
height={14}
/>
}
/>
{env.EMAIL_ENABLED && (
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Email subscriptions")}&nbsp;
<Tooltip
content={t(
"Allow viewers to subscribe and receive email notifications when documents are updated"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
<Flex align="center" gap={2}>
<ShareLinkInput
type="text"
ref={inputRef}
placeholder={share?.id}
onChange={handleUrlChange}
error={validationError}
defaultValue={urlId}
prefix={
<DomainPrefix onClick={() => inputRef.current?.focus()}>
{env.URL.replace(/https?:\/\//, "") + "/s/"}
</DomainPrefix>
}
actions={
<Switch
aria-label={t("Email subscriptions")}
checked={share?.allowSubscriptions ?? true}
onChange={handleSubscriptionsChanged}
width={26}
height={14}
/>
}
/>
)}
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Show last modified")}&nbsp;
<Tooltip
content={t(
"Display the last modified timestamp on the shared page"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Show last modified")}
checked={share?.showLastUpdated ?? false}
onChange={handleShowLastModifiedChanged}
width={26}
height={14}
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Show table of contents")}&nbsp;
<Tooltip
content={t(
"Display the table of contents on documents by default"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Show table of contents")}
checked={share?.showTOC ?? false}
onChange={handleShowTOCChanged}
width={26}
height={14}
/>
}
/>
<ShareLinkInput
type="text"
ref={inputRef}
placeholder={share?.id}
onChange={handleUrlChange}
error={validationError}
defaultValue={urlId}
prefix={
<DomainPrefix onClick={() => inputRef.current?.focus()}>
{env.URL.replace(/https?:\/\//, "") + "/s/"}
</DomainPrefix>
}
>
{copyButton}
</ShareLinkInput>
>
{copyButton}
</ShareLinkInput>
<ShareSettingsPopover share={share} />
</Flex>
<Flex align="flex-start" gap={4}>
<StyledInfoIcon color={theme.textTertiary} />
<Text type="tertiary" size="xsmall">
+30 -178
View File
@@ -1,7 +1,8 @@
import copy from "copy-to-clipboard";
import debounce from "lodash/debounce";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { CopyIcon, GlobeIcon, QuestionMarkIcon } from "outline-icons";
import { CopyIcon, GlobeIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -22,6 +23,7 @@ import { ResizingHeightContainer } from "../../ResizingHeightContainer";
import Text from "../../Text";
import Tooltip from "../../Tooltip";
import { ListItem } from "../components/ListItem";
import ShareSettingsPopover from "../components/ShareSettingsPopover";
import {
DomainPrefix,
ShareLinkInput,
@@ -60,70 +62,24 @@ function PublicAccess(
setUrlId(share?.urlId);
}, [share?.urlId]);
const handleIndexingChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
allowIndexing: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handleSubscriptionsChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
allowSubscriptions: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handleShowLastModifiedChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
showLastUpdated: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handleShowTOCChanged = React.useCallback(
async (checked: boolean) => {
try {
await share?.save({
showTOC: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handlePublishedChange = React.useCallback(
async (checked: boolean) => {
try {
if (checked && !share) {
setCreating(true);
await shares.create({
const newShare = await shares.create({
type: "document",
documentId: document.id,
published: true,
});
copy(newShare.url);
toast.success(t("Public link copied to clipboard"));
} else if (share) {
await share.save({ published: checked });
if (checked) {
copy(share.url);
toast.success(t("Public link copied to clipboard"));
}
}
} catch (err) {
toast.error(err.message);
@@ -131,7 +87,7 @@ function PublicAccess(
setCreating(false);
}
},
[share, shares, document]
[t, share, shares, document]
);
const handleUrlChange = React.useMemo(
@@ -187,7 +143,7 @@ function PublicAccess(
return (
<div ref={ref}>
<ListItem
title={t("Web")}
title={t("Publish to web")}
subtitle={
<>
{sharedParent && !document.isDraft ? (
@@ -236,133 +192,29 @@ function PublicAccess(
/>
<ResizingHeightContainer>
{share?.published && !sharedParent?.published && (
<>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Search engine indexing")}&nbsp;
<Tooltip
content={t(
"Disable this setting to discourage search engines from indexing the page"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Search engine indexing")}
checked={share?.allowIndexing ?? false}
onChange={handleIndexingChanged}
width={26}
height={14}
/>
}
/>
{env.EMAIL_ENABLED && (
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Email subscriptions")}&nbsp;
<Tooltip
content={t(
"Allow viewers to subscribe and receive email notifications when this document is updated"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Email subscriptions")}
checked={share?.allowSubscriptions ?? true}
onChange={handleSubscriptionsChanged}
width={26}
height={14}
/>
}
/>
)}
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Show last modified")}&nbsp;
<Tooltip
content={t(
"Display the last modified timestamp on the shared page"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Show last modified")}
checked={share?.showLastUpdated ?? false}
onChange={handleShowLastModifiedChanged}
width={26}
height={14}
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Show table of contents")}&nbsp;
<Tooltip
content={t(
"Display the table of contents on documents by default"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Show table of contents")}
checked={share?.showTOC ?? false}
onChange={handleShowTOCChanged}
width={26}
height={14}
/>
}
/>
</>
)}
{sharedParent?.published && !document.isDraft ? (
<ShareLinkInput type="text" disabled defaultValue={shareUrl}>
{copyButton}
</ShareLinkInput>
) : share?.published ? (
<ShareLinkInput
type="text"
ref={inputRef}
placeholder={share?.id}
onChange={handleUrlChange}
error={validationError}
defaultValue={urlId}
prefix={
<DomainPrefix onClick={() => inputRef.current?.focus()}>
{env.URL.replace(/https?:\/\//, "") + "/s/"}
</DomainPrefix>
}
>
{copyButton}
</ShareLinkInput>
<Flex align="center" gap={2}>
<ShareLinkInput
type="text"
ref={inputRef}
placeholder={share?.id}
onChange={handleUrlChange}
error={validationError}
defaultValue={urlId}
prefix={
<DomainPrefix onClick={() => inputRef.current?.focus()}>
{env.URL.replace(/https?:\/\//, "") + "/s/"}
</DomainPrefix>
}
>
{copyButton}
</ShareLinkInput>
<ShareSettingsPopover share={share} />
</Flex>
) : null}
{share?.published && !share.includeChildDocuments ? (
@@ -0,0 +1,52 @@
import { observer } from "mobx-react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { ellipsis, s } from "@shared/styles";
import { AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import TeamLogo from "~/components/TeamLogo";
import useShareBranding from "~/hooks/useShareBranding";
import type Share from "~/models/Share";
type Props = {
share: Share;
};
/**
* Renders the team or share-customized branding (logo + name) for shared
* documents that do not have a sidebar.
*/
function HeaderBranding({ share }: Props) {
const { t } = useTranslation();
const { displayName, displayLogoUrl, displayLogoModel, brandingAvailable } =
useShareBranding(share);
if (!brandingAvailable) {
return null;
}
return (
<Wrapper align="center" gap={8}>
<TeamLogo
model={displayLogoModel}
src={displayLogoUrl ?? undefined}
size={AvatarSize.Large}
alt={t("Logo")}
/>
{displayName && <Name>{displayName}</Name>}
</Wrapper>
);
}
const Wrapper = styled(Flex)`
min-width: 0;
color: ${s("text")};
`;
const Name = styled.span`
${ellipsis()}
font-size: 15px;
font-weight: 500;
`;
export default observer(HeaderBranding);
@@ -0,0 +1,428 @@
import debounce from "lodash/debounce";
import uniqueId from "lodash/uniqueId";
import { observer } from "mobx-react";
import {
ImageIcon,
QuestionMarkIcon,
SettingsIcon,
TrashIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled, { useTheme } from "styled-components";
import { s } from "@shared/styles";
import { HStack } from "~/components/primitives/HStack";
import { AttachmentPreset } from "@shared/types";
import { AttachmentValidation } from "@shared/validations";
import type Share from "~/models/Share";
import { createAction } from "~/actions";
import { ShareSection } from "~/actions/sections";
import { AvatarSize } from "~/components/Avatar";
import Input from "~/components/Input";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import NudeButton from "~/components/NudeButton";
import Switch from "~/components/Switch";
import TeamLogo from "~/components/TeamLogo";
import Text from "~/components/Text";
import Tooltip from "~/components/Tooltip";
import env from "~/env";
import { useMenuAction } from "~/hooks/useMenuAction";
import useStores from "~/hooks/useStores";
import { compressImage } from "~/utils/compressImage";
import { uploadFile } from "~/utils/files";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "~/components/primitives/Popover";
import { ListItem } from "./ListItem";
type Props = {
/** The share model to configure settings for. */
share: Share;
/** Custom trigger element. If not provided, a default settings icon button is rendered. */
children?: React.ReactElement;
};
/**
* A popover triggered by a settings icon that contains toggle options
* for configuring a published share link (indexing, subscriptions, etc.),
* as well as custom title and logo branding.
*/
function ShareSettingsPopover({ share, children }: Props) {
const { t } = useTranslation();
const { auth } = useStores();
const theme = useTheme();
const fileInputRef = React.useRef<HTMLInputElement>(null);
const hasChangesRef = React.useRef(false);
const [isUploading, setIsUploading] = React.useState(false);
const idPrefix = React.useMemo(() => uniqueId("share-settings-"), []);
const showLastUpdatedId = `${idPrefix}-show-last-updated`;
const showTOCId = `${idPrefix}-show-toc`;
const indexingId = `${idPrefix}-indexing`;
const subscriptionsId = `${idPrefix}-subscriptions`;
const handleTitleChange = React.useMemo(
() =>
debounce(async (ev: React.ChangeEvent<HTMLInputElement>) => {
const val = ev.target.value;
try {
await share.save({ title: val || null });
hasChangesRef.current = true;
} catch (err) {
toast.error(err.message);
}
}, 500),
[share]
);
const triggerUpload = React.useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleLogoUpload = React.useCallback(
async (ev: React.ChangeEvent<HTMLInputElement>) => {
const file = ev.target.files?.[0];
if (!file) {
return;
}
setIsUploading(true);
try {
const compressed = await compressImage(file, {
maxHeight: 512,
maxWidth: 512,
});
const attachment = await uploadFile(compressed, {
name: file.name,
preset: AttachmentPreset.Avatar,
});
await share.save({ iconUrl: attachment.url });
hasChangesRef.current = true;
} catch (err) {
toast.error(err.message);
} finally {
setIsUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
},
[share]
);
const handleLogoRemove = React.useCallback(async () => {
try {
await share.save({ iconUrl: null });
hasChangesRef.current = true;
} catch (err) {
toast.error(err.message);
}
}, [share]);
const handleIndexingChanged = React.useCallback(
async (checked: boolean) => {
try {
await share.save({ allowIndexing: checked });
hasChangesRef.current = true;
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handleSubscriptionsChanged = React.useCallback(
async (checked: boolean) => {
try {
await share.save({ allowSubscriptions: checked });
hasChangesRef.current = true;
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handleShowLastModifiedChanged = React.useCallback(
async (checked: boolean) => {
try {
await share.save({ showLastUpdated: checked });
hasChangesRef.current = true;
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handleShowTOCChanged = React.useCallback(
async (checked: boolean) => {
try {
await share.save({ showTOC: checked });
hasChangesRef.current = true;
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const flushChangeToast = React.useCallback(() => {
if (hasChangesRef.current) {
toast.success(t("Sharing settings updated"));
hasChangesRef.current = false;
}
}, [t]);
const handleOpenChange = React.useCallback(
(open: boolean) => {
if (!open) {
flushChangeToast();
}
},
[flushChangeToast]
);
// Also flush on unmount in case the parent popover closes us before
// onOpenChange fires.
React.useEffect(
() => () => {
flushChangeToast();
},
[flushChangeToast]
);
const iconActions = React.useMemo(
() => [
createAction({
name: ({ t: translate }) => translate("Upload image"),
analyticsName: "Upload share icon",
section: ShareSection,
icon: <ImageIcon />,
perform: triggerUpload,
}),
createAction({
name: ({ t: translate }) => translate("Remove image"),
analyticsName: "Remove share icon",
section: ShareSection,
icon: <TrashIcon />,
dangerous: true,
perform: handleLogoRemove,
}),
],
[triggerUpload, handleLogoRemove]
);
const iconRootAction = useMenuAction(iconActions);
return (
<Popover modal onOpenChange={handleOpenChange}>
<Tooltip content={t("Display settings")} placement="top">
<PopoverTrigger>
{children ?? (
<SettingsTrigger type="button">
<SettingsIcon color={theme.placeholder} size={24} />
</SettingsTrigger>
)}
</PopoverTrigger>
</Tooltip>
<PopoverContent
side="bottom"
align="end"
minWidth={400}
style={{ paddingTop: 20, paddingBottom: 20 }}
>
<Text as="h3" weight="bold">
{t("Display settings")}
</Text>
<Text as="p" size="small" type="secondary">
{t("Customize how the published document is displayed")}
</Text>
<input
ref={fileInputRef}
type="file"
accept={AttachmentValidation.avatarContentTypes.join(",")}
onChange={handleLogoUpload}
style={{ display: "none" }}
/>
<HStack spacing={8} style={{ marginBottom: 8 }}>
{share.iconUrl ? (
<DropdownMenu
action={iconRootAction}
align="start"
ariaLabel={t("Image options")}
>
<LogoButton type="button" disabled={isUploading}>
<TeamLogo
src={share.iconUrl}
size={AvatarSize.Large}
alt={t("Icon")}
/>
</LogoButton>
</DropdownMenu>
) : (
<LogoButton
type="button"
onClick={triggerUpload}
disabled={isUploading}
aria-label={t("Upload")}
>
<TeamLogo
model={auth.team ?? undefined}
size={AvatarSize.Large}
alt={t("Icon")}
/>
</LogoButton>
)}
<Input
type="text"
label={t("Site title")}
labelHidden
placeholder={auth.team?.name ?? ""}
defaultValue={share.title ?? ""}
onChange={handleTitleChange}
margin={0}
flex
/>
</HStack>
<ListItem
title={
<SwitchLabel htmlFor={showLastUpdatedId}>
{t("Show last modified")}&nbsp;
<Tooltip
content={t(
"Display the last modified timestamp on the shared page"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</SwitchLabel>
}
actions={
<Switch
id={showLastUpdatedId}
checked={share.showLastUpdated ?? false}
onChange={handleShowLastModifiedChanged}
width={26}
height={14}
/>
}
/>
<ListItem
title={
<SwitchLabel htmlFor={showTOCId}>
{t("Show table of contents")}&nbsp;
<Tooltip
content={t(
"Display the table of contents on documents by default"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</SwitchLabel>
}
actions={
<Switch
id={showTOCId}
checked={share.showTOC ?? false}
onChange={handleShowTOCChanged}
width={26}
height={14}
/>
}
/>
<Text as="h3" weight="bold" style={{ marginTop: 16 }}>
{t("Behavior")}
</Text>
<ListItem
title={
<SwitchLabel htmlFor={indexingId}>
{t("Search engine indexing")}&nbsp;
<Tooltip
content={t(
"Disable this setting to discourage search engines from indexing the page"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</SwitchLabel>
}
actions={
<Switch
id={indexingId}
checked={share.allowIndexing ?? false}
onChange={handleIndexingChanged}
width={26}
height={14}
/>
}
/>
{env.EMAIL_ENABLED && (
<ListItem
title={
<SwitchLabel htmlFor={subscriptionsId}>
{t("Email subscriptions")}&nbsp;
<Tooltip
content={t(
"Allow viewers to subscribe and receive email notifications when documents are updated"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</SwitchLabel>
}
actions={
<Switch
id={subscriptionsId}
checked={share.allowSubscriptions ?? true}
onChange={handleSubscriptionsChanged}
width={26}
height={14}
/>
}
/>
)}
</PopoverContent>
</Popover>
);
}
const SwitchLabel = styled.label`
display: flex;
align-items: center;
color: ${s("textSecondary")};
cursor: var(--pointer);
`;
const SettingsTrigger = styled(NudeButton)`
width: 32px;
height: 32px;
flex-shrink: 0;
position: relative;
top: -2px;
right: -4px;
`;
const LogoButton = styled.button`
background: none;
border: 0;
padding: 0;
cursor: var(--pointer);
flex-shrink: 0;
&:disabled {
opacity: 0.5;
cursor: default;
}
`;
export default observer(ShareSettingsPopover);
+11 -6
View File
@@ -11,11 +11,11 @@ import type Share from "~/models/Share";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import useCurrentUser from "~/hooks/useCurrentUser";
import useShareBranding from "~/hooks/useShareBranding";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { homePath, sharedModelPath } from "~/utils/routeHelpers";
import { AvatarSize } from "../Avatar";
import { useTeamContext } from "../TeamContext";
import TeamLogo from "../TeamLogo";
import Sidebar from "./Sidebar";
import Section from "./components/Section";
@@ -28,13 +28,13 @@ type Props = {
};
function SharedSidebar({ share }: Props) {
const team = useTeamContext();
const user = useCurrentUser({ rejectOnEmpty: false });
const { ui, documents, collections } = useStores();
const { t } = useTranslation();
const { query } = useKBar();
const teamAvailable = !!team?.name;
const { displayName, displayLogoUrl, displayLogoModel, brandingAvailable } =
useShareBranding(share);
const rootNode = share.tree;
const shareId = share.urlId || share.id;
const collection = collections.get(rootNode?.id);
@@ -56,11 +56,16 @@ function SharedSidebar({ share }: Props) {
return (
<Sidebar canCollapse={false}>
{teamAvailable && (
{brandingAvailable && (
<SidebarButton
title={team.name}
title={displayName}
image={
<TeamLogo model={team} size={AvatarSize.XLarge} alt={t("Logo")} />
<TeamLogo
model={displayLogoModel}
src={displayLogoUrl ?? undefined}
size={AvatarSize.XLarge}
alt={t("Logo")}
/>
}
disabled={hideRootNode}
onClick={
+3 -2
View File
@@ -44,7 +44,7 @@ const PopoverContent = React.forwardRef<
const timeoutRef = React.useRef<NodeJS.Timeout>();
const container = usePortalContext();
const {
width = 380,
width,
minWidth,
minHeight,
scrollable = true,
@@ -53,6 +53,7 @@ const PopoverContent = React.forwardRef<
children,
...rest
} = props;
const resolvedWidth = width ?? (minWidth ? undefined : 380);
const enablePointerEvents = React.useCallback(() => {
if (timeoutRef.current) {
@@ -78,7 +79,7 @@ const PopoverContent = React.forwardRef<
<StyledContent
ref={mergeRefs([ref, forwardedRef])}
sideOffset={sideOffset}
$width={width}
$width={resolvedWidth}
$minWidth={minWidth}
$minHeight={minHeight}
$scrollable={scrollable}
+33
View File
@@ -0,0 +1,33 @@
import type { PublicTeam } from "@shared/types";
import { useTeamContext } from "~/components/TeamContext";
import type Share from "~/models/Share";
import type Team from "~/models/Team";
type ShareBranding = {
displayName: string | null;
displayLogoUrl: string | null;
displayLogoModel: Team | PublicTeam | undefined;
brandingAvailable: boolean;
};
/**
* Returns the resolved branding (name + logo) to display for a share, falling
* back to the team's name and avatar when the share has no custom values.
*
* @param share the share to derive branding for.
* @returns the resolved name, logo URL, fallback model, and whether any
* branding is available to display.
*/
export default function useShareBranding(share: Share): ShareBranding {
const team = useTeamContext();
const displayName = share.title ?? team?.name ?? null;
const displayLogoUrl = share.iconUrl ?? team?.avatarUrl ?? null;
const displayLogoModel = displayLogoUrl ? undefined : team;
return {
displayName,
displayLogoUrl,
displayLogoModel,
brandingAvailable: !!(displayName || displayLogoUrl),
};
}
+11 -6
View File
@@ -82,6 +82,16 @@ class Share extends Model implements Searchable {
@observable
showTOC: boolean;
/** Custom branding title to display on the shared page, supersedes team name. */
@Field
@observable
title: string | null;
/** Custom branding icon URL to display on the shared page, supersedes team avatar. */
@Field
@observable
iconUrl: string | null;
@observable
views: number;
@@ -89,11 +99,6 @@ class Share extends Model implements Searchable {
@Relation(() => User, { onDelete: "null" })
createdBy: User;
@computed
get title(): string {
return this.sourceTitle ?? this.documentTitle;
}
@computed
get sourcePathWithFallback(): string {
return this.sourcePath ?? this.documentUrl;
@@ -101,7 +106,7 @@ class Share extends Model implements Searchable {
@computed
get searchContent(): string[] {
return [this.title];
return [this.sourceTitle ?? this.documentTitle];
}
@computed
+20 -13
View File
@@ -38,6 +38,7 @@ import Header from "./Header";
import Notices from "./Notices";
import References from "./References";
import RevisionViewer from "./RevisionViewer";
import SharedHeader from "./SharedHeader";
type LocationState = {
title?: string;
@@ -316,19 +317,25 @@ function DocumentScene({
)}
/>
)}
<Header
editorRef={editorRef}
document={document}
revision={revision}
isDraft={document.isDraft}
isEditing={!readOnly && !!user?.separateEditMode}
isSaving={isSaving}
isPublishing={isPublishing}
publishingIsDisabled={document.isSaving || isPublishing || isEmpty}
savingIsDisabled={document.isSaving || isEmpty}
onSelectTemplate={handleSelectTemplate}
onSave={onSave}
/>
{isShare ? (
<SharedHeader document={document} />
) : (
<Header
editorRef={editorRef}
document={document}
revision={revision}
isDraft={document.isDraft}
isEditing={!readOnly && !!user?.separateEditMode}
isSaving={isSaving}
isPublishing={isPublishing}
publishingIsDisabled={
document.isSaving || isPublishing || isEmpty
}
savingIsDisabled={document.isSaving || isEmpty}
onSelectTemplate={handleSelectTemplate}
onSave={onSave}
/>
)}
<Main
fullWidth={document.fullWidth}
tocPosition={tocPos}
+123 -192
View File
@@ -6,7 +6,6 @@ import { Link } from "react-router-dom";
import useMeasure from "react-use-measure";
import styled, { useTheme } from "styled-components";
import Icon from "@shared/components/Icon";
import useShare from "@shared/hooks/useShare";
import { altDisplay, metaDisplay } from "@shared/utils/keyboard";
import { publishDocument } from "~/actions/definitions/documents";
import { restoreRevision } from "~/actions/definitions/revisions";
@@ -18,14 +17,9 @@ import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import { useDocumentContext } from "~/components/DocumentContext";
import Flex from "~/components/Flex";
import Header from "~/components/Header";
import {
AppearanceAction,
SubscribeAction,
} from "~/components/Sharing/components/Actions";
import Star from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import { type Editor } from "~/editor";
import env from "~/env";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useEditingFocus from "~/hooks/useEditingFocus";
@@ -44,7 +38,6 @@ import type Template from "~/models/Template";
import { documentEditPath } from "~/utils/routeHelpers";
import { ChangesNavigation } from "./ChangesNavigation";
import ObservingBanner from "./ObservingBanner";
import PublicBreadcrumb from "./PublicBreadcrumb";
import { SearchHighlightChip } from "./SearchHighlightChip";
import ShareButton from "./ShareButton";
@@ -97,7 +90,6 @@ function DocumentHeader({
const { hasHeadings, editor } = useDocumentContext();
const sidebarContext = useLocationSidebarContext();
const [measureRef, size] = useMeasure();
const { isShare, shareId, sharedTree, allowSubscriptions } = useShare();
const isMobile = isMobileMedia || (size.width > 0 && size.width < 700);
// We cache this value for as long as the component is mounted so that if you
@@ -112,19 +104,13 @@ function DocumentHeader({
}, [onSave]);
const handleToggle = useCallback(() => {
// Public shares, by default, show ToC on load.
if (isShare && ui.tocVisible === undefined) {
ui.set({ tocVisible: false });
} else {
ui.set({ tocVisible: !ui.tocVisible });
}
}, [ui, isShare]);
ui.set({ tocVisible: !ui.tocVisible });
}, [ui]);
const can = usePolicy(document);
const { isDeleted } = document;
const canToggleEmbeds = team?.documentEmbeds;
const showContents =
ui.tocVisible === true || (isShare && ui.tocVisible !== false);
const showContents = ui.tocVisible === true;
useEffect(() => {
if (isMobile && showContents) {
@@ -186,192 +172,137 @@ function DocumentHeader({
}
);
if (shareId) {
return (
<StyledHeader
ref={measureRef}
$hidden={isEditingFocus}
title={
<Flex gap={4}>
{document.icon && (
<Icon
value={document.icon}
initial={document.initial}
color={document.color ?? undefined}
/>
)}
{document.title}
</Flex>
}
hasSidebar={sharedTree && sharedTree.children?.length > 0}
left={
isMobile ? (
hasHeadings ? (
<TableOfContentsMenu />
) : null
) : (
<PublicBreadcrumb
documentId={document.id}
shareId={shareId}
sharedTree={sharedTree}
>
{hasHeadings ? toc : null}
</PublicBreadcrumb>
)
}
actions={
<>
{allowSubscriptions !== false && !user && env.EMAIL_ENABLED && (
<SubscribeAction shareId={shareId} documentId={document.id} />
)}
<AppearanceAction />
{can.update && !isEditing ? editAction : <div />}
</>
}
/>
);
}
return (
<>
<StyledHeader
ref={measureRef}
$hidden={isEditingFocus}
hasSidebar
left={
isMobile ? (
<TableOfContentsMenu />
) : (
<DocumentBreadcrumb document={document as Document}>
{toc}{" "}
<Star
document={document as Document}
color={theme.textSecondary}
/>
</DocumentBreadcrumb>
)
}
title={
<Flex gap={4} align="center">
{document.icon && (
<Icon
value={document.icon}
initial={document.initial}
color={document.color ?? undefined}
/>
)}
{document.title}
{document.isArchived && <Badge>{t("Archived")}</Badge>}
{document.isDraft && <Badge>{t("Draft")}</Badge>}
</Flex>
}
actions={({ isCompact }) => (
<>
<ObservingBanner />
<SearchHighlightChip />
{!isDeleted && !isRevision && can.listViews && (
<Collaborators
<StyledHeader
ref={measureRef}
$hidden={isEditingFocus}
hasSidebar
left={
isMobile ? (
<TableOfContentsMenu />
) : (
<DocumentBreadcrumb document={document}>
{toc} <Star document={document} color={theme.textSecondary} />
</DocumentBreadcrumb>
)
}
title={
<Flex gap={4} align="center">
{document.icon && (
<Icon
value={document.icon}
initial={document.initial}
color={document.color ?? undefined}
/>
)}
{document.title}
{document.isArchived && <Badge>{t("Archived")}</Badge>}
{document.isDraft && <Badge>{t("Draft")}</Badge>}
</Flex>
}
actions={({ isCompact }) => (
<>
<ObservingBanner />
<SearchHighlightChip />
{!isDeleted && !isRevision && can.listViews && (
<Collaborators
document={document}
limit={isCompact ? 3 : undefined}
/>
)}
{(isEditing || !user?.separateEditMode) && wasNew && can.update && (
<Action>
<TemplatesMenu
isCompact={isCompact}
document={document}
limit={isCompact ? 3 : undefined}
onSelectTemplate={onSelectTemplate}
/>
)}
{(isEditing || !user?.separateEditMode) && wasNew && can.update && (
<Action>
<TemplatesMenu
isCompact={isCompact}
document={document as Document}
onSelectTemplate={onSelectTemplate}
/>
</Action>
)}
{!isEditing && !isRevision && can.update && (
<Action>
<ShareButton document={document} />
</Action>
)}
{isEditing && (
<Action>
<Tooltip
content={isDraft ? t("Save draft") : t("Done editing")}
shortcut={`${metaDisplay}+enter`}
placement="bottom"
</Action>
)}
{!isEditing && !isRevision && can.update && (
<Action>
<ShareButton document={document} />
</Action>
)}
{isEditing && (
<Action>
<Tooltip
content={isDraft ? t("Save draft") : t("Done editing")}
shortcut={`${metaDisplay}+enter`}
placement="bottom"
>
<Button
onClick={handleSave}
disabled={savingIsDisabled}
neutral={isDraft}
haptic="medium"
hideIcon
>
{isDraft ? t("Save draft") : t("Done editing")}
</Button>
</Tooltip>
</Action>
)}
{can.update &&
!isEditing &&
user?.separateEditMode &&
!isRevision &&
editAction}
{can.update &&
can.createChildDocument &&
!isRevision &&
!isCompact &&
!isMobile && (
<Action>
<NewChildDocumentMenu document={document} />
</Action>
)}
{revision && (
<>
<Action>
<ChangesNavigation revision={revision} editorRef={editorRef} />
</Action>
<Action>
<Tooltip content={t("Restore version")} placement="bottom">
<Button
onClick={handleSave}
disabled={savingIsDisabled}
neutral={isDraft}
haptic="medium"
hideIcon
action={restoreRevision}
disabled={revision.createdAt === document.updatedAt}
neutral
hideOnActionDisabled
>
{isDraft ? t("Save draft") : t("Done editing")}
{t("Restore")}
</Button>
</Tooltip>
</Action>
)}
{can.update &&
!isEditing &&
user?.separateEditMode &&
!isRevision &&
editAction}
{can.update &&
can.createChildDocument &&
!isRevision &&
!isCompact &&
!isMobile && (
<Action>
<NewChildDocumentMenu document={document} />
</Action>
)}
{revision && (
<>
<Action>
<ChangesNavigation
revision={revision}
editorRef={editorRef}
/>
</Action>
<Action>
<Tooltip content={t("Restore version")} placement="bottom">
<Button
action={restoreRevision}
disabled={revision.createdAt === document.updatedAt}
neutral
hideOnActionDisabled
>
{t("Restore")}
</Button>
</Tooltip>
</Action>
</>
)}
{can.publish && (
<Action>
<Button
action={publishDocument}
disabled={publishingIsDisabled}
hideOnActionDisabled
hideIcon
>
{t("Publish")}
</Button>
</Action>
)}
{!isDeleted && <Separator />}
</>
)}
{can.publish && (
<Action>
<DocumentMenu
document={document}
align="end"
neutral
onSelectTemplate={onSelectTemplate}
onFindAndReplace={editor?.commands.openFindAndReplace}
showToggleEmbeds={canToggleEmbeds}
showDisplayOptions
/>
<Button
action={publishDocument}
disabled={publishingIsDisabled}
hideOnActionDisabled
hideIcon
>
{t("Publish")}
</Button>
</Action>
</>
)}
/>
</>
)}
{!isDeleted && <Separator />}
<Action>
<DocumentMenu
document={document}
align="end"
neutral
onSelectTemplate={onSelectTemplate}
onFindAndReplace={editor?.commands.openFindAndReplace}
showToggleEmbeds={canToggleEmbeds}
showDisplayOptions
/>
</Action>
</>
)}
/>
);
}
@@ -0,0 +1,208 @@
import { observer } from "mobx-react";
import { TableOfContentsIcon, EditIcon, SettingsIcon } from "outline-icons";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import useMeasure from "react-use-measure";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import useShare from "@shared/hooks/useShare";
import { altDisplay } from "@shared/utils/keyboard";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import { useDocumentContext } from "~/components/DocumentContext";
import Flex from "~/components/Flex";
import Header from "~/components/Header";
import {
AppearanceAction,
SubscribeAction,
} from "~/components/Sharing/components/Actions";
import HeaderBranding from "~/components/Sharing/components/HeaderBranding";
import ShareSettingsPopover from "~/components/Sharing/components/ShareSettingsPopover";
import Tooltip from "~/components/Tooltip";
import env from "~/env";
import useCurrentUser from "~/hooks/useCurrentUser";
import useEditingFocus from "~/hooks/useEditingFocus";
import useKeyDown from "~/hooks/useKeyDown";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import TableOfContentsMenu from "~/menus/TableOfContentsMenu";
import type Document from "~/models/Document";
import { documentEditPath } from "~/utils/routeHelpers";
import PublicBreadcrumb from "./PublicBreadcrumb";
type Props = {
document: Document;
};
function SharedDocumentHeader({ document }: Props) {
const { t } = useTranslation();
const { ui, shares } = useStores();
const user = useCurrentUser({ rejectOnEmpty: false });
const isMobileMedia = useMobile();
const isEditingFocus = useEditingFocus();
// Set CSS variable for header offset (used by sticky table headers)
useEffect(() => {
window.document.documentElement.style.setProperty(
"--header-offset",
isEditingFocus ? "0px" : "64px"
);
}, [isEditingFocus]);
const { hasHeadings } = useDocumentContext();
const sidebarContext = useLocationSidebarContext();
const [measureRef, size] = useMeasure();
const { shareId, sharedTree, allowSubscriptions } = useShare();
const share = shareId ? shares.get(shareId) : undefined;
const isMobile = isMobileMedia || (size.width > 0 && size.width < 700);
const handleToggle = useCallback(() => {
// Public shares, by default, show ToC on load.
if (ui.tocVisible === undefined) {
ui.set({ tocVisible: false });
} else {
ui.set({ tocVisible: !ui.tocVisible });
}
}, [ui]);
const can = usePolicy(document);
const showContents = ui.tocVisible !== false;
useEffect(() => {
if (isMobile && showContents) {
ui.set({ tocVisible: false });
}
}, [isMobile, showContents, ui]);
useKeyDown(
(event) => event.ctrlKey && event.altKey && event.code === "KeyH",
handleToggle,
{
allowInInput: true,
}
);
if (!shareId) {
return null;
}
const toc = (
<Tooltip
content={
showContents
? t("Hide contents")
: hasHeadings
? t("Show contents")
: `${t("Show contents")} (${t("available when headings are added")})`
}
shortcut={`Ctrl+${altDisplay}+h`}
placement="bottom"
>
<Button
aria-label={t("Show contents")}
onClick={handleToggle}
icon={<TableOfContentsIcon />}
borderOnHover
neutral
/>
</Tooltip>
);
const editAction = can.update ? (
<Action>
<Tooltip
content={t("Edit {{noun}}", { noun: document.noun })}
shortcut="e"
placement="bottom"
>
<Button
as={Link}
icon={<EditIcon />}
to={{
pathname: documentEditPath(document),
state: { sidebarContext },
}}
haptic="light"
neutral
>
{isMobile ? null : t("Edit")}
</Button>
</Tooltip>
</Action>
) : (
<div />
);
const hasSidebar = !!(sharedTree && sharedTree.children?.length);
return (
<StyledHeader
ref={measureRef}
$hidden={isEditingFocus}
title={
<Flex gap={4}>
{document.icon && (
<Icon
value={document.icon}
initial={document.initial}
color={document.color ?? undefined}
/>
)}
{document.title}
</Flex>
}
hasSidebar={hasSidebar}
left={
isMobile ? (
hasHeadings ? (
<TableOfContentsMenu />
) : null
) : hasSidebar ? (
<PublicBreadcrumb
documentId={document.id}
shareId={shareId}
sharedTree={sharedTree}
>
{hasHeadings ? toc : null}
</PublicBreadcrumb>
) : (
<Flex align="center" gap={8}>
{share && <HeaderBranding share={share} />}
{hasHeadings ? toc : null}
</Flex>
)
}
actions={
<>
{allowSubscriptions !== false && !user && env.EMAIL_ENABLED && (
<SubscribeAction shareId={shareId} documentId={document.id} />
)}
<AppearanceAction />
{can.update && share && (
<Action>
<ShareSettingsPopover share={share}>
<Button
icon={<SettingsIcon />}
aria-label={t("Display settings")}
neutral
borderOnHover
/>
</ShareSettingsPopover>
</Action>
)}
{editAction}
</>
}
/>
);
}
const StyledHeader = styled(Header)<{ $hidden: boolean }>`
transition: opacity 500ms ease-in-out;
${(props) => props.$hidden && "opacity: 0;"}
`;
export default observer(SharedDocumentHeader);
@@ -0,0 +1,19 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("shares", "title", {
type: Sequelize.STRING,
allowNull: true,
});
await queryInterface.addColumn("shares", "iconUrl", {
type: Sequelize.STRING(4096),
allowNull: true,
});
},
async down(queryInterface) {
await queryInterface.removeColumn("shares", "title");
await queryInterface.removeColumn("shares", "iconUrl");
},
};
+19
View File
@@ -15,6 +15,7 @@ import {
BeforeUpdate,
} from "sequelize-typescript";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { ShareValidation } from "@shared/validations";
import env from "@server/env";
import { ValidationError } from "@server/errors";
import type { APIContext } from "@server/types";
@@ -25,6 +26,7 @@ import User from "./User";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
import IsFQDN from "./validators/IsFQDN";
import IsUrlOrRelativePath from "./validators/IsUrlOrRelativePath";
import Length from "./validators/Length";
@DefaultScope(() => ({
@@ -155,6 +157,23 @@ class Share extends IdModel<
@Column
showTOC: boolean;
@AllowNull
@Length({
max: ShareValidation.maxTitleLength,
msg: `title must be ${ShareValidation.maxTitleLength} characters or less`,
})
@Column
title: string | null;
@AllowNull
@IsUrlOrRelativePath
@Length({
max: ShareValidation.maxIconUrlLength,
msg: `iconUrl must be ${ShareValidation.maxIconUrlLength} characters or less`,
})
@Column
iconUrl: string | null;
// hooks
@BeforeUpdate
+2
View File
@@ -19,6 +19,8 @@ export default function presentShare(share: Share, isAdmin = false) {
allowSubscriptions: share.allowSubscriptions,
showLastUpdated: share.showLastUpdated,
showTOC: share.showTOC,
title: share.title,
iconUrl: share.iconUrl,
lastAccessedAt: share.lastAccessedAt || undefined,
views: share.views || 0,
domain: share.domain,
+12
View File
@@ -1,7 +1,10 @@
import { isURL } from "class-validator";
import isEmpty from "lodash/isEmpty";
import { z } from "zod";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { ShareValidation } from "@shared/validations";
import { Share } from "@server/models";
import { ValidateURL } from "@server/validation";
import { zodIdType } from "@server/utils/zod";
import { BaseSchema } from "../schema";
@@ -56,6 +59,15 @@ export const SharesUpdateSchema = BaseSchema.extend({
allowSubscriptions: z.boolean().optional(),
showLastUpdated: z.boolean().optional(),
showTOC: z.boolean().optional(),
title: z.string().max(ShareValidation.maxTitleLength).nullish(),
iconUrl: z
.string()
.max(ShareValidation.maxIconUrlLength)
.refine(
(val) => isURL(val, { require_host: false, require_protocol: false }),
{ error: ValidateURL.message }
)
.nullish(),
urlId: z
.string()
.regex(UrlHelper.SHARE_URL_SLUG_REGEX, {
+135
View File
@@ -1009,6 +1009,141 @@ describe("#shares.update", () => {
expect(body.data.urlId).toBeNull();
});
it("should update title and iconUrl", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
});
const res = await server.post("/api/shares.update", {
body: {
token: user.getJwtToken(),
id: share.id,
title: "Custom Title",
iconUrl: "https://example.com/icon.png",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.title).toEqual("Custom Title");
expect(body.data.iconUrl).toEqual("https://example.com/icon.png");
});
it("should allow clearing title and iconUrl with null", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
title: "Custom Title",
iconUrl: "https://example.com/icon.png",
});
const res = await server.post("/api/shares.update", {
body: {
token: user.getJwtToken(),
id: share.id,
title: null,
iconUrl: null,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.title).toBeNull();
expect(body.data.iconUrl).toBeNull();
});
it("should normalize empty title to null", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
title: "Custom Title",
});
const res = await server.post("/api/shares.update", {
body: {
token: user.getJwtToken(),
id: share.id,
title: "",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.title).toBeNull();
});
it("should accept a relative iconUrl", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
});
const res = await server.post("/api/shares.update", {
body: {
token: user.getJwtToken(),
id: share.id,
iconUrl: "/uploads/icon.png",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.iconUrl).toEqual("/uploads/icon.png");
});
it("should reject malformed iconUrl", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
});
const res = await server.post("/api/shares.update", {
body: {
token: user.getJwtToken(),
id: share.id,
iconUrl: "not a url",
},
});
expect(res.status).toEqual(400);
});
it("should reject iconUrl with disallowed protocol", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
});
const res = await server.post("/api/shares.update", {
body: {
token: user.getJwtToken(),
id: share.id,
iconUrl: "javascript:alert(1)",
},
});
expect(res.status).toEqual(400);
});
it("should allow user to update a share", async () => {
const user = await buildUser();
const document = await buildDocument({
+10
View File
@@ -350,6 +350,8 @@ router.post(
allowSubscriptions,
showLastUpdated,
showTOC,
title,
iconUrl,
} = ctx.input.body;
const { user } = ctx.state.auth;
@@ -390,6 +392,14 @@ router.post(
share.showTOC = showTOC;
}
if (!isUndefined(title)) {
share.title = title || null;
}
if (!isUndefined(iconUrl)) {
share.iconUrl = iconUrl || null;
}
await share.saveWithCtx(ctx);
ctx.body = {
+18 -13
View File
@@ -421,20 +421,12 @@
"Everyone in the workspace": "Everyone in the workspace",
"{{ count }} member": "{{ count }} member",
"{{ count }} member_plural": "{{ count }} members",
"Public link copied to clipboard": "Public link copied to clipboard",
"Only lowercase letters, digits and dashes allowed": "Only lowercase letters, digits and dashes allowed",
"Sorry, this link has already been used": "Sorry, this link has already been used",
"Public link copied to clipboard": "Public link copied to clipboard",
"Web": "Web",
"Publish to web": "Publish to web",
"Allow anyone with the link to access": "Allow anyone with the link to access",
"Publish to internet": "Publish to internet",
"Search engine indexing": "Search engine indexing",
"Disable this setting to discourage search engines from indexing the page": "Disable this setting to discourage search engines from indexing the page",
"Email subscriptions": "Email subscriptions",
"Allow viewers to subscribe and receive email notifications when documents are updated": "Allow viewers to subscribe and receive email notifications when documents are updated",
"Show last modified": "Show last modified",
"Display the last modified timestamp on the shared page": "Display the last modified timestamp on the shared page",
"Show table of contents": "Show table of contents",
"Display the table of contents on documents by default": "Display the table of contents on documents by default",
"All documents in this collection will be shared on the web, including any new documents added later": "All documents in this collection will be shared on the web, including any new documents added later",
"Invite": "Invite",
"{{ userName }} was added to the collection": "{{ userName }} was added to the collection",
@@ -445,8 +437,24 @@
"Switch to dark": "Switch to dark",
"Switch to light": "Switch to light",
"Subscribe to updates": "Subscribe to updates",
"Logo": "Logo",
"Add": "Add",
"Add or invite": "Add or invite",
"Sharing settings updated": "Sharing settings updated",
"Display settings": "Display settings",
"Customize how the published document is displayed": "Customize how the published document is displayed",
"Image options": "Image options",
"Upload": "Upload",
"Site title": "Site title",
"Show last modified": "Show last modified",
"Display the last modified timestamp on the shared page": "Display the last modified timestamp on the shared page",
"Show table of contents": "Show table of contents",
"Display the table of contents on documents by default": "Display the table of contents on documents by default",
"Behavior": "Behavior",
"Search engine indexing": "Search engine indexing",
"Disable this setting to discourage search engines from indexing the page": "Disable this setting to discourage search engines from indexing the page",
"Email subscriptions": "Email subscriptions",
"Allow viewers to subscribe and receive email notifications when documents are updated": "Allow viewers to subscribe and receive email notifications when documents are updated",
"Something went wrong": "Something went wrong",
"Check your email to confirm your subscription": "Check your email to confirm your subscription",
"Get notified when this document is updated": "Get notified when this document is updated",
@@ -475,14 +483,12 @@
"Leave": "Leave",
"Anyone with the link can access because the containing collection, <2>{sharedParent.sourceTitle}</2>, is shared": "Anyone with the link can access because the containing collection, <2>{sharedParent.sourceTitle}</2>, is shared",
"Anyone with the link can access because the parent document, <2>{sharedParent.sourceTitle}</2>, is shared": "Anyone with the link can access because the parent document, <2>{sharedParent.sourceTitle}</2>, is shared",
"Allow viewers to subscribe and receive email notifications when this document is updated": "Allow viewers to subscribe and receive email notifications when this document is updated",
"Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future": "Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future",
"{{ userName }} was added to the document": "{{ userName }} was added to the document",
"{{ count }} people added to the document": "{{ count }} people added to the document",
"{{ count }} people added to the document_plural": "{{ count }} people added to the document",
"{{ count }} groups added to the document": "{{ count }} groups added to the document",
"{{ count }} groups added to the document_plural": "{{ count }} groups added to the document",
"Logo": "Logo",
"Expand sidebar": "Expand sidebar",
"Collapse sidebar": "Collapse sidebar",
"Archived collections": "Archived collections",
@@ -1249,7 +1255,6 @@
"Show your workspace logo, description, and branding on publicly shared pages.": "Show your workspace logo, description, and branding on publicly shared pages.",
"Table of contents position": "Table of contents position",
"The side to display the table of contents in relation to the main content.": "The side to display the table of contents in relation to the main content.",
"Behavior": "Behavior",
"Subdomain": "Subdomain",
"Your workspace will be accessible at": "Your workspace will be accessible at",
"Choose a subdomain to enable a login page just for your team.": "Choose a subdomain to enable a login page just for your team.",
+8
View File
@@ -101,6 +101,14 @@ export const OAuthClientValidation = {
clientTypes: ["confidential", "public"] as const,
};
export const ShareValidation = {
/** The maximum length of the share title */
maxTitleLength: 255,
/** The maximum length of the share iconUrl */
maxIconUrlLength: 4096,
};
export const RevisionValidation = {
minNameLength: 1,
maxNameLength: 255,