Compare commits

..

64 Commits

Author SHA1 Message Date
Tom Moor 5bba32e984 Handle logged out OAuth apex 2025-04-28 19:43:17 -04:00
Tom Moor 5a5682f071 Basic /authorize framework for cloud auth 2025-04-28 17:56:53 -04:00
Tom Moor 17f54c0ed0 Merge branch 'main' of github.com:outline/outline into oauth-server 2025-04-27 20:12:44 -04:00
Tom Moor 1330724c75 test 2025-04-26 18:00:52 -04:00
Tom Moor 5ca9fc9b44 Add processor to revoke external tokens when OAuthClient is unpublished 2025-04-26 17:25:04 -04:00
Tom Moor 4c7bc07b28 fix: Delete OAuth authentications when client is deleted 2025-04-26 17:14:43 -04:00
Tom Moor a033b08c83 tsc 2025-04-26 16:59:34 -04:00
Tom Moor f7d5d25247 OAuth token management screen (#8953) 2025-04-26 16:13:27 -04:00
Tom Moor 234e2d84ed Make token lifetimes configurable at installation level 2025-04-26 09:44:58 -04:00
Tom Moor 4389ac0d1d Merge main 2025-04-26 09:12:57 -04:00
Tom Moor f60f5fd66d Merge main 2025-04-22 21:29:53 -04:00
Tom Moor bee61ce1ef PR feedback 2025-04-22 21:19:16 -04:00
Tom Moor 20f5e953b7 Add PKCE parameters to /authorize 2025-04-15 21:20:08 -04:00
Tom Moor c2968a671c Add /oauth/revoke endpoint 2025-04-09 19:50:26 -04:00
Tom Moor 98959dc330 refactor: add OAuthClient.findByClientId 2025-04-08 09:16:55 -04:00
Tom Moor 7fc305b5d5 fix: Prevent OAuth tokens from creating webhooks 2025-04-07 20:47:29 -04:00
Tom Moor 1b90ab85e7 Add PKCE support 2025-04-07 20:29:43 -04:00
Tom Moor 4faf1b8570 fix: Guard authorization with unpublished client 2025-04-07 20:25:12 -04:00
Tom Moor c61bc5dedb fix: Cannot authorize with code grant 2025-04-07 20:23:42 -04:00
Tom Moor 3afde6962b Merge branch 'main' of github.com:outline/outline into oauth-server 2025-04-07 14:43:19 -04:00
Tom Moor cf7c97e9d6 Authentication middleware tests 2025-04-06 11:47:14 -04:00
Tom Moor 6298d7b31b PR feedback 2025-04-06 10:01:10 -04:00
Tom Moor 99ec9c1627 refactors 2025-04-06 09:58:33 -04:00
Tom Moor fdc53b91f8 Restore translations 2025-04-06 08:56:46 -04:00
Tom Moor eb3f74cf21 self review 2025-04-06 00:42:15 -04:00
Tom Moor 8656c21e14 lint 2025-04-06 00:26:43 -04:00
Tom Moor 24fd606a86 Improve validation 2025-04-06 00:16:42 -04:00
Tom Moor fe9a548490 Add OAuthClient icon to list, related Avatar refactor 2025-04-06 00:08:29 -04:00
Tom Moor f71afc2bf5 refactor 2025-04-05 23:12:14 -04:00
Tom Moor 9e7dd5b4f7 refactor 2025-04-05 22:42:34 -04:00
Tom Moor 021e431195 Add rotate secret functionality 2025-04-05 22:22:44 -04:00
Tom Moor 2136be9327 scope validation 2025-04-05 20:16:06 -04:00
Tom Moor 581502f7e2 Add rate limiting on endpoints 2025-04-05 19:05:43 -04:00
Tom Moor 75447cd782 scope formalization 2025-04-05 18:55:00 -04:00
Tom Moor fee3e7d0c3 Add additional read scopes 2025-04-05 18:05:49 -04:00
Tom Moor aed55c7cfd Readable scopes 2025-04-05 18:01:15 -04:00
Tom Moor 180d17e173 OAuth client edit flow 2025-04-05 17:49:29 -04:00
Tom Moor aa60f5ccea Bring error handling to spec for oauth endpoints 2025-04-05 09:51:39 -04:00
Tom Moor ea79883e04 Add constraints and indexes to migration 2025-04-05 00:14:56 -04:00
Tom Moor e9afc1d91f Add avatar upload 2025-04-04 23:40:28 -04:00
Tom Moor ed47b9eda0 fix authorize form 2025-04-04 23:14:41 -04:00
Tom Moor 5a19182757 Add menu, remove published option on self-hosted 2025-04-04 22:40:52 -04:00
Tom Moor 51b3971d21 New app modal 2025-04-04 22:06:47 -04:00
Tom Moor 057a1bbc7f Add developer to authorize page 2025-04-04 21:09:30 -04:00
Tom Moor 1289f5f3be Add tasks and processors 2025-04-04 19:01:54 -04:00
Tom Moor 429de07820 Add oauthClients endpoints 2025-04-04 18:47:07 -04:00
Tom Moor 68489973e0 Disable Authorize button on submit 2025-04-04 17:54:30 -04:00
Tom Moor 4dbd5b3617 fix: post-login redirect should include query string 2025-04-04 17:38:23 -04:00
Tom Moor f9fe1cc308 Merge main 2025-04-04 13:11:30 -04:00
Tom Moor fe03ba8710 scope-mapping 2025-04-04 13:10:27 -04:00
Tom Moor d13770ddf9 Authorize styling, scope conversion 2025-04-03 22:24:46 -04:00
Tom Moor b7425fefc6 Add cancel button 2025-04-03 21:53:03 -04:00
Tom Moor 79df4b030b Styling of Authorize page 2025-04-03 21:43:04 -04:00
Tom Moor 24eaeca47e Refactor out of plugin 2025-04-03 21:08:28 -04:00
Tom Moor db0deb6997 debugging 2025-04-02 09:45:56 -04:00
Tom Moor 9f21e57335 /authorize skeleton 2025-04-02 07:28:56 -04:00
Tom Moor fa6b83382b Add interface 2025-04-01 22:13:26 -04:00
Tom Moor b86475360f Move models to plugin 2025-04-01 07:38:30 -04:00
Tom Moor 9b179a2612 Change scope to array 2025-03-31 21:42:38 -04:00
Tom Moor 7afe69e22a Add token prefixes 2025-03-31 21:14:13 -04:00
Tom Moor 2ac19e3938 Add OAuthAuthentication 2025-03-31 21:04:01 -04:00
Tom Moor a25968d4a7 Add OAuthAuthorizationCode 2025-03-31 20:49:51 -04:00
Tom Moor 87b8e5daeb Add OAuthClient 2025-03-31 20:44:31 -04:00
Tom Moor 94a862ce01 Base migrations 2025-03-31 19:27:28 -04:00
126 changed files with 1494 additions and 2696 deletions
+7 -31
View File
@@ -1,4 +1,3 @@
import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
@@ -15,15 +14,13 @@ import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import InputSelectPermission from "~/components/InputSelectPermission";
import { createLazyComponent } from "~/components/LazyLoad";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import { EmptySelectValue } from "~/types";
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
export interface FormData {
name: string;
@@ -33,26 +30,6 @@ export interface FormData {
permission: CollectionPermission | undefined;
}
const useIconColor = (collection?: Collection) => {
const { collections } = useStores();
const hasMultipleCollections = collections.orderedData.length > 1;
const collectionColors = uniq(
collections.orderedData.map((c) => c.color).filter(Boolean)
) as string[];
const iconColor = React.useMemo(
() =>
collection?.color ??
// If all the existing collections have the same color, use that color,
// otherwise pick a random color from the palette
(hasMultipleCollections && collectionColors.length === 1
? collectionColors[0]
: randomElement(colorPalette)),
[collection?.color]
);
return iconColor;
};
export const CollectionForm = observer(function CollectionForm_({
handleSubmit,
collection,
@@ -65,7 +42,11 @@ export const CollectionForm = observer(function CollectionForm_({
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const iconColor = useIconColor(collection);
const iconColor = React.useMemo(
() => collection?.color ?? randomElement(colorPalette),
[collection?.color]
);
const fallbackIcon = <Icon value="collection" color={iconColor} />;
const {
@@ -89,11 +70,6 @@ export const CollectionForm = observer(function CollectionForm_({
const values = watch();
// Preload the IconPicker component on mount
React.useEffect(() => {
void IconPicker.preload();
}, []);
React.useEffect(() => {
// If the user hasn't picked an icon yet, go ahead and suggest one based on
// the name of the collection. It's the little things sometimes.
@@ -208,7 +184,7 @@ export const CollectionForm = observer(function CollectionForm_({
);
});
const StyledIconPicker = styled(IconPicker.Component)`
const StyledIconPicker = styled(IconPicker)`
margin-left: 4px;
margin-right: 4px;
`;
@@ -6,18 +6,15 @@ import { toast } from "sonner";
import styled from "styled-components";
import { richExtensions } from "@shared/editor/nodes";
import { s } from "@shared/styles";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import Text from "~/components/Text";
import { withUIExtensions } from "~/editor/extensions";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { Properties } from "~/types";
import Text from "./Text";
const extensions = withUIExtensions(richExtensions);
@@ -25,8 +22,8 @@ type Props = {
collection: Collection;
};
function Overview({ collection }: Props) {
const { documents, collections } = useStores();
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: true });
const can = usePolicy(collection);
@@ -57,24 +54,6 @@ function Overview({ collection }: Props) {
[childOffsetHeight]
);
const onCreateLink = React.useCallback(
async (params: Properties<Document>) => {
const newDocument = await documents.create(
{
collectionId: collection.id,
data: ProsemirrorHelper.getEmptyDocument(),
...params,
},
{
publish: true,
}
);
return newDocument.url;
},
[collection, documents]
);
return (
<>
{collections.isSaving && <LoadingIndicator />}
@@ -86,7 +65,6 @@ function Overview({ collection }: Props) {
placeholder={`${t("Add a description")}`}
extensions={extensions}
maxLength={CollectionValidation.maxDescriptionLength}
onCreateLink={onCreateLink}
canUpdate={can.update}
readOnly={!can.update}
userId={user.id}
@@ -105,4 +83,4 @@ const Placeholder = styled(Text)`
min-height: 27px;
`;
export default observer(Overview);
export default observer(CollectionDescription);
-47
View File
@@ -1,47 +0,0 @@
import * as React from "react";
import lazyWithRetry from "~/utils/lazyWithRetry";
export interface LazyComponent<T extends React.ComponentType<any>> {
Component: React.LazyExoticComponent<T>;
preload: () => Promise<{ default: T }>;
}
interface LazyLoadOptions {
retries?: number;
interval?: number;
}
/**
* Creates a lazy-loaded component with preloading capability and automatic retries on failure.
*
* @param factory A function that returns a promise of a component (eg: () => import('./MyComponent'))
* @param options Optional configuration for retry behavior
* @returns An object containing the lazy Component and a preload function
*
* @example
* ```typescript
* const MyComponent = createLazyComponent(() => import('./MyComponent'));
*
* function App() {
* return (
* <Suspense fallback={<div>Loading...</div>}>
* <MyComponent.Component />
* </Suspense>
* );
* }
*
* // Preload when needed:
* MyComponent.preload();
* ```
*/
export function createLazyComponent<T extends React.ComponentType<any>>(
factory: () => Promise<{ default: T }>,
options: LazyLoadOptions = {}
): LazyComponent<T> {
const { retries, interval } = options;
return {
Component: lazyWithRetry(factory, retries, interval),
preload: factory,
};
}
+2 -4
View File
@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
import { PopoverDisclosure, usePopoverState } from "reakit";
import EventBoundary from "@shared/components/EventBoundary";
import Flex from "~/components/Flex";
import { createLazyComponent } from "~/components/LazyLoad";
import NudeButton from "~/components/NudeButton";
import PlaceholderText from "~/components/PlaceholderText";
import Popover from "~/components/Popover";
@@ -13,7 +12,7 @@ import useOnClickOutside from "~/hooks/useOnClickOutside";
import useWindowSize from "~/hooks/useWindowSize";
import Tooltip from "../Tooltip";
const EmojiPanel = createLazyComponent(
const EmojiPanel = React.lazy(
() => import("~/components/IconPicker/components/EmojiPanel")
);
@@ -105,7 +104,6 @@ const ReactionPicker: React.FC<Props> = ({
aria-label={t("Reaction picker")}
className={className}
onClick={handlePopoverButtonClick}
onMouseEnter={() => EmojiPanel.preload()}
size={size}
>
<ReactionIcon size={22} />
@@ -125,7 +123,7 @@ const ReactionPicker: React.FC<Props> = ({
{popover.visible && (
<React.Suspense fallback={<Placeholder />}>
<EventBoundary>
<EmojiPanel.Component
<EmojiPanel
height={300}
panelWidth={panelWidth}
query={query}
@@ -93,13 +93,11 @@ export const Suggestions = observer(
const suggestions = React.useMemo(() => {
const filtered: Suggestion[] = (
document
? users
.notInDocument(document.id, query)
.filter((u) => u.id !== user.id)
? users.notInDocument(document.id, query)
: collection
? users.notInCollection(collection.id, query)
: users.activeOrInvited
).filter((u) => !u.isSuspended);
).filter((u) => !u.isSuspended && u.id !== user.id);
if (isEmail(query)) {
filtered.push(getSuggestionForEmail(query));
+3 -12
View File
@@ -23,20 +23,12 @@ import ToggleButton from "./components/ToggleButton";
import Version from "./components/Version";
function SettingsSidebar() {
const { ui, integrations } = useStores();
const { ui } = useStores();
const { t } = useTranslation();
const history = useHistory();
const location = useLocation();
const configs = useSettingsConfig();
const groupedConfig = groupBy(
configs.filter((item) =>
item.group === "Integrations" && item.pluginId
? integrations.findByService(item.pluginId)
: true
),
"group"
);
const groupedConfig = groupBy(configs, "group");
const returnToApp = React.useCallback(() => {
history.push("/home");
@@ -71,9 +63,8 @@ function SettingsSidebar() {
<SidebarLink
key={item.path}
to={item.path}
onClickIntent={item.preload}
active={
item.path.startsWith(settingsPath("templates"))
item.path !== settingsPath()
? location.pathname.startsWith(item.path)
: undefined
}
-1
View File
@@ -321,7 +321,6 @@ const Container = styled(Flex)<ContainerProps>`
z-index: ${depths.mobileSidebar};
max-width: 80%;
min-width: 280px;
padding-left: var(--sal);
${fadeOnDesktopBackgrounded()}
@media print {
@@ -38,10 +38,10 @@ function StarredLink({ star }: Props) {
const { ui, collections, documents } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const { documentId, collectionId } = star;
const collection = collectionId ? collections.get(collectionId) : undefined;
const collection = collections.get(collectionId);
const locationSidebarContext = useLocationSidebarContext();
const sidebarContext = starredSidebarContext(
star.documentId ?? star.collectionId ?? ""
star.documentId ?? star.collectionId
);
const [expanded, setExpanded] = useState(
(star.documentId
-1
View File
@@ -41,7 +41,6 @@ function useKeyboardShortcuts({
useKeyDown(
(ev) =>
isModKey(ev) &&
!popover.visible &&
ev.code === "KeyF" &&
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
+1 -6
View File
@@ -7,7 +7,6 @@ import { isCode } from "@shared/editor/lib/isCode";
import { findParentNode } from "@shared/editor/queries/findParentNode";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { depths, s } from "@shared/styles";
import { getSafeAreaInsets } from "@shared/utils/browser";
import { HEADER_HEIGHT } from "~/components/Header";
import { Portal } from "~/components/Portal";
import useEventListener from "~/hooks/useEventListener";
@@ -242,16 +241,12 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
if (props.active) {
const rect = document.body.getBoundingClientRect();
const safeAreaInsets = getSafeAreaInsets();
return (
<ReactPortal>
<MobileWrapper
ref={menuRef}
style={{
bottom: `calc(100% - ${
height - rect.y - safeAreaInsets.bottom
}px)`,
bottom: `calc(100% - ${height - rect.y}px)`,
}}
>
{props.children}
+2 -3
View File
@@ -6,7 +6,6 @@ import { v4 } from "uuid";
import { EmbedDescriptor } from "@shared/editor/embeds";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import { isUrl } from "@shared/utils/urls";
import Integration from "~/models/Integration";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
@@ -30,9 +29,9 @@ export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
const user = useCurrentUser({ rejectOnEmpty: false });
let mentionType: MentionType | undefined;
const url = pastedText ? new URL(pastedText) : undefined;
if (pastedText && isUrl(pastedText)) {
const url = new URL(pastedText);
if (url) {
const integration = integrations.find((intg: Integration) =>
isURLMentionable({ url, integration: intg })
);
+1 -2
View File
@@ -2,8 +2,7 @@ import Extension from "@shared/editor/lib/Extension";
import { InputRule } from "@shared/editor/lib/InputRule";
const rightArrow = new InputRule(/->$/, "→");
// Note that the suppression of pipe here prevents conflict with table creation rule.
const emdash = new InputRule(/(?:^|[^\|])(--)$/, "—");
const emdash = new InputRule(/--$/, "—");
const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½");
const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾");
const copyright = new InputRule(/\(c\)$/, "©️");
+3 -3
View File
@@ -67,7 +67,7 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+B`,
icon: <BoldIcon />,
active: isMarkActive(schema.marks.strong),
visible: !isCodeBlock && (!isMobile || !isEmpty),
visible: !isCode && (!isMobile || !isEmpty),
},
{
name: "em",
@@ -75,7 +75,7 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+I`,
icon: <ItalicIcon />,
active: isMarkActive(schema.marks.em),
visible: !isCodeBlock && (!isMobile || !isEmpty),
visible: !isCode && (!isMobile || !isEmpty),
},
{
name: "strikethrough",
@@ -83,7 +83,7 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+D`,
icon: <StrikethroughIcon />,
active: isMarkActive(schema.marks.strikethrough),
visible: !isCodeBlock && (!isMobile || !isEmpty),
visible: !isCode && (!isMobile || !isEmpty),
},
{
tooltip: dictionary.mark,
+2 -1
View File
@@ -18,7 +18,8 @@ export default function useEmbeds(loadIfMissing = false) {
React.useEffect(() => {
async function fetchEmbedIntegrations() {
try {
await integrations.fetchAll({
await integrations.fetchPage({
limit: 100,
type: IntegrationType.Embed,
});
} catch (err) {
+25 -51
View File
@@ -13,21 +13,20 @@ import {
ImportIcon,
ShapesIcon,
Icon,
PlusIcon,
InternetIcon,
} from "outline-icons";
import React, { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
import { Integrations } from "~/scenes/Settings/Integrations";
import { createLazyComponent as lazy } from "~/components/LazyLoad";
import ZapierIcon from "~/components/Icons/ZapierIcon";
import { Hook, PluginManager } from "~/utils/PluginManager";
import isCloudHosted from "~/utils/isCloudHosted";
import lazy from "~/utils/lazyWithRetry";
import { settingsPath } from "~/utils/routeHelpers";
import { useComputed } from "./useComputed";
import useCurrentTeam from "./useCurrentTeam";
import useCurrentUser from "./useCurrentUser";
import usePolicy from "./usePolicy";
import useStores from "./useStores";
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
const Applications = lazy(() => import("~/scenes/Settings/Applications"));
@@ -44,38 +43,30 @@ const Profile = lazy(() => import("~/scenes/Settings/Profile"));
const Security = lazy(() => import("~/scenes/Settings/Security"));
const Shares = lazy(() => import("~/scenes/Settings/Shares"));
const Templates = lazy(() => import("~/scenes/Settings/Templates"));
const Zapier = lazy(() => import("~/scenes/Settings/Zapier"));
export type ConfigItem = {
name: string;
path: string;
icon: React.FC<ComponentProps<typeof Icon>>;
component: React.ComponentType;
description?: string;
preload?: () => void;
enabled: boolean;
group: string;
pluginId?: string;
};
const useSettingsConfig = () => {
const { integrations } = useStores();
const user = useCurrentUser();
const team = useCurrentTeam();
const can = usePolicy(team);
const { t } = useTranslation();
React.useEffect(() => {
void integrations.fetchAll();
}, [integrations]);
const config = useComputed(() => {
const items: ConfigItem[] = [
// Account
{
name: t("Profile"),
path: settingsPath(),
component: Profile.Component,
preload: Profile.preload,
component: Profile,
enabled: true,
group: t("Account"),
icon: ProfileIcon,
@@ -83,8 +74,7 @@ const useSettingsConfig = () => {
{
name: t("Preferences"),
path: settingsPath("preferences"),
component: Preferences.Component,
preload: Preferences.preload,
component: Preferences,
enabled: true,
group: t("Account"),
icon: SettingsIcon,
@@ -92,8 +82,7 @@ const useSettingsConfig = () => {
{
name: t("Notifications"),
path: settingsPath("notifications"),
component: Notifications.Component,
preload: Notifications.preload,
component: Notifications,
enabled: true,
group: t("Account"),
icon: EmailIcon,
@@ -101,8 +90,7 @@ const useSettingsConfig = () => {
{
name: t("API & Apps"),
path: settingsPath("api-and-apps"),
component: APIAndApps.Component,
preload: APIAndApps.preload,
component: APIAndApps,
enabled: true,
group: t("Account"),
icon: PadlockIcon,
@@ -111,8 +99,7 @@ const useSettingsConfig = () => {
{
name: t("Details"),
path: settingsPath("details"),
component: Details.Component,
preload: Details.preload,
component: Details,
enabled: can.update,
group: t("Workspace"),
icon: TeamIcon,
@@ -120,8 +107,7 @@ const useSettingsConfig = () => {
{
name: t("Security"),
path: settingsPath("security"),
component: Security.Component,
preload: Security.preload,
component: Security,
enabled: can.update,
group: t("Workspace"),
icon: PadlockIcon,
@@ -129,8 +115,7 @@ const useSettingsConfig = () => {
{
name: t("Features"),
path: settingsPath("features"),
component: Features.Component,
preload: Features.preload,
component: Features,
enabled: can.update,
group: t("Workspace"),
icon: BeakerIcon,
@@ -138,8 +123,7 @@ const useSettingsConfig = () => {
{
name: t("Members"),
path: settingsPath("members"),
component: Members.Component,
preload: Members.preload,
component: Members,
enabled: can.listUsers,
group: t("Workspace"),
icon: UserIcon,
@@ -147,8 +131,7 @@ const useSettingsConfig = () => {
{
name: t("Groups"),
path: settingsPath("groups"),
component: Groups.Component,
preload: Groups.preload,
component: Groups,
enabled: can.listGroups,
group: t("Workspace"),
icon: GroupIcon,
@@ -156,8 +139,7 @@ const useSettingsConfig = () => {
{
name: t("Templates"),
path: settingsPath("templates"),
component: Templates.Component,
preload: Templates.preload,
component: Templates,
enabled: can.readTemplate,
group: t("Workspace"),
icon: ShapesIcon,
@@ -165,8 +147,7 @@ const useSettingsConfig = () => {
{
name: t("API Keys"),
path: settingsPath("api-keys"),
component: ApiKeys.Component,
preload: ApiKeys.preload,
component: ApiKeys,
enabled: can.listApiKeys,
group: t("Workspace"),
icon: CodeIcon,
@@ -174,8 +155,7 @@ const useSettingsConfig = () => {
{
name: t("Applications"),
path: settingsPath("applications"),
component: Applications.Component,
preload: Applications.preload,
component: Applications,
enabled: can.listOAuthClients,
group: t("Workspace"),
icon: InternetIcon,
@@ -183,8 +163,7 @@ const useSettingsConfig = () => {
{
name: t("Shared Links"),
path: settingsPath("shares"),
component: Shares.Component,
preload: Shares.preload,
component: Shares,
enabled: can.listShares,
group: t("Workspace"),
icon: GlobeIcon,
@@ -192,8 +171,7 @@ const useSettingsConfig = () => {
{
name: t("Import"),
path: settingsPath("import"),
component: Import.Component,
preload: Import.preload,
component: Import,
enabled: can.createImport,
group: t("Workspace"),
icon: ImportIcon,
@@ -201,20 +179,19 @@ const useSettingsConfig = () => {
{
name: t("Export"),
path: settingsPath("export"),
component: Export.Component,
preload: Export.preload,
component: Export,
enabled: can.createExport,
group: t("Workspace"),
icon: ExportIcon,
},
// Integrations
{
name: `${t("Install")}`,
path: settingsPath("integrations"),
component: Integrations,
enabled: true,
name: "Zapier",
path: integrationSettingsPath("zapier"),
component: Zapier,
enabled: can.update && isCloudHosted,
group: t("Integrations"),
icon: PlusIcon,
icon: ZapierIcon,
},
];
@@ -231,10 +208,7 @@ const useSettingsConfig = () => {
? integrationSettingsPath(plugin.id)
: settingsPath(plugin.id),
group: t(group),
pluginId: plugin.id,
description: plugin.value.description,
component: plugin.value.component.Component,
preload: plugin.value.component.preload,
component: plugin.value.component,
enabled: plugin.value.enabled
? plugin.value.enabled(team, user)
: can.update,
-10
View File
@@ -331,16 +331,6 @@ export default class Document extends ArchivableModel implements Searchable {
);
}
/**
* Returns the documents that link to this document.
*
* @returns documents that link to this document
*/
@computed
get backlinks(): Document[] {
return this.store.getBacklinkedDocuments(this.id);
}
/**
* Returns users that have been individually given access to the document.
*
+1 -1
View File
@@ -22,7 +22,7 @@ class Star extends Model {
document?: Document;
/** The collection ID that is starred. */
collectionId?: string;
collectionId: string;
/** The collection that is starred. */
@Relation(() => Collection, { onDelete: "cascade" })
+14 -8
View File
@@ -20,6 +20,7 @@ import Collection from "~/models/Collection";
import { Action } from "~/components/Actions";
import CenteredContent from "~/components/CenteredContent";
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
import CollectionDescription from "~/components/CollectionDescription";
import Heading from "~/components/Heading";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import InputSearchPage from "~/components/InputSearchPage";
@@ -45,7 +46,6 @@ import DropToImport from "./components/DropToImport";
import Empty from "./components/Empty";
import MembershipPreview from "./components/MembershipPreview";
import Notices from "./components/Notices";
import Overview from "./components/Overview";
import ShareButton from "./components/ShareButton";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
@@ -66,6 +66,7 @@ const CollectionScene = observer(function _CollectionScene() {
const location = useLocation();
const { t } = useTranslation();
const { documents, collections, ui } = useStores();
const [isFetching, setFetching] = React.useState(false);
const [error, setError] = React.useState<Error | undefined>();
const currentPath = location.pathname;
const [, setLastVisitedPath] = useLastVisitedPath();
@@ -119,16 +120,21 @@ const CollectionScene = observer(function _CollectionScene() {
React.useEffect(() => {
async function fetchData() {
try {
setError(undefined);
await collections.fetch(id);
} catch (err) {
setError(err);
if ((!can || !collection) && !error && !isFetching) {
try {
setError(undefined);
setFetching(true);
await collections.fetch(id);
} catch (err) {
setError(err);
} finally {
setFetching(false);
}
}
}
void fetchData();
}, []);
}, [collections, isFetching, collection, error, id, can]);
useCommandBarActions([editCollection], [ui.activeCollectionId ?? "none"]);
@@ -259,7 +265,7 @@ const CollectionScene = observer(function _CollectionScene() {
path={collectionPath(collection.path, CollectionPath.Overview)}
>
{hasOverview ? (
<Overview collection={collection} />
<CollectionDescription collection={collection} />
) : (
<Redirect
to={{
@@ -18,7 +18,7 @@ type Props = {
};
function References({ document }: Props) {
const { documents } = useStores();
const { collections, documents } = useStores();
const user = useCurrentUser();
const location = useLocation();
const locationSidebarContext = useLocationSidebarContext();
@@ -27,8 +27,10 @@ function References({ document }: Props) {
void documents.fetchBacklinks(document.id);
}, [documents, document.id]);
const backlinks = document.backlinks;
const collection = document.collection;
const backlinks = documents.getBacklinkedDocuments(document.id);
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const children = collection
? collection.getChildrenForDocument(document.id)
: [];
+5 -20
View File
@@ -4,7 +4,6 @@ import styled from "styled-components";
import Flex from "@shared/components/Flex";
import { s } from "@shared/styles";
import { parseDomain } from "@shared/utils/domains";
import type OAuthClient from "~/models/oauth/OAuthClient";
import ButtonLarge from "~/components/ButtonLarge";
import ChangeLanguage from "~/components/ChangeLanguage";
import Heading from "~/components/Heading";
@@ -17,7 +16,6 @@ import { useLoggedInSessions } from "~/hooks/useLoggedInSessions";
import useQuery from "~/hooks/useQuery";
import useRequest from "~/hooks/useRequest";
import { client } from "~/utils/ApiClient";
import { BadRequestError, NotFoundError } from "~/utils/errors";
import isCloudHosted from "~/utils/isCloudHosted";
import { detectLanguage } from "~/utils/language";
import Login from "./Login";
@@ -68,17 +66,12 @@ function Authorize() {
scope,
} = Object.fromEntries(params);
const [scopes] = React.useState(() => scope?.split(" ") ?? []);
const { error: clientError, data: response } = useRequest<{
data: OAuthClient;
}>(() => client.post("/oauthClients.info", { clientId, redirectUri }), true);
const { error: clientError, data: response } = useRequest(
() => client.post("/oauthClients.info", { clientId }),
true
);
const handleCancel = () => {
if (redirectUri && !clientError) {
const url = new URL(redirectUri);
url.searchParams.set("error", "access_denied");
window.location.href = url.toString();
return;
}
if (window.history.length) {
window.history.back();
} else {
@@ -103,7 +96,6 @@ function Authorize() {
!redirectUri && "redirect_uri",
!responseType && "response_type",
!scope && "scope",
!state && "state",
].filter(Boolean);
if (missingParams.length || clientError) {
@@ -111,20 +103,13 @@ function Authorize() {
<Background>
<Centered>
<StyledHeading>{t("An error occurred")}</StyledHeading>
{clientError instanceof NotFoundError ? (
{clientError ? (
<Text as="p" type="secondary">
{t(
"The OAuth client could not be found, please check the provided client ID"
)}
<Pre>{clientId}</Pre>
</Text>
) : clientError instanceof BadRequestError ? (
<Text as="p" type="secondary">
{t(
"The OAuth client could not be loaded, please check the redirect URI is valid"
)}
<Pre>{redirectUri}</Pre>
</Text>
) : (
<Text as="p" type="secondary">
{t("Required OAuth parameters are missing")}
+2 -6
View File
@@ -80,16 +80,11 @@ const Application = observer(function Application({ oauthClient }: Props) {
async (data: FormData) => {
try {
await oauthClient.save(data);
toast.success(
oauthClient.published
? t("Application published")
: t("Application updated")
);
} catch (error) {
toast.error(error.message);
}
},
[oauthClient, t]
[oauthClient]
);
const handleRotateSecret = React.useCallback(async () => {
@@ -178,6 +173,7 @@ const Application = observer(function Application({ oauthClient }: Props) {
<Input
type="text"
{...register("description", {
required: true,
maxLength: OAuthClientValidation.maxDescriptionLength,
})}
flex
-72
View File
@@ -1,72 +0,0 @@
import groupBy from "lodash/groupBy";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "@shared/components/Flex";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import useStores from "~/hooks/useStores";
import { settingsPath } from "~/utils/routeHelpers";
import IntegrationCard from "./components/IntegrationCard";
import { StickyFilters } from "./components/StickyFilters";
export function Integrations() {
const { t } = useTranslation();
const { integrations } = useStores();
const items = useSettingsConfig();
const [query, setQuery] = React.useState("");
const handleQuery = (event: React.ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value);
};
const groupedItems = groupBy(
items.filter(
(item) =>
item.group === "Integrations" &&
item.enabled &&
item.path !== settingsPath("integrations") &&
item.name.toLowerCase().includes(query.toLowerCase())
),
(item) =>
item.pluginId && integrations.findByService(item.pluginId)
? "connected"
: "available"
);
return (
<Scene title={t("Integrations")}>
<Heading>{t("Integrations")}</Heading>
<Text as="p" type="secondary">
<Trans>
Configure a variety of integrations with third-party services.
</Trans>
</Text>
<StickyFilters gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleQuery}
/>
</StickyFilters>
<Cards gap={30} wrap>
{groupedItems.connected?.map((item) => (
<IntegrationCard key={item.path} integration={item} isConnected />
))}
{groupedItems.available?.map((item) => (
<IntegrationCard key={item.path} integration={item} />
))}
</Cards>
</Scene>
);
}
const Cards = styled(Flex)`
margin-top: 20px;
width: "100%";
`;
@@ -1,22 +1,20 @@
import * as React from "react";
import { Helmet } from "react-helmet-async";
import { Trans } from "react-i18next";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import Heading from "~/components/Heading";
import ZapierIcon from "~/components/Icons/ZapierIcon";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import ZapierIcon from "./Icon";
function Zapier() {
const { ui } = useStores();
const user = useCurrentUser();
const { resolvedTheme } = ui;
const appName = env.APP_NAME;
return (
<IntegrationScene title="Zapier" icon={<ZapierIcon />}>
<Scene title="Zapier" icon={<ZapierIcon />}>
<Heading>Zapier</Heading>
<Helmet>
<script
@@ -41,15 +39,14 @@ function Zapier() {
<zapier-app-directory
app="outline"
link-target="new-tab"
sign-up-email={user.email}
theme={resolvedTheme === "system" ? undefined : resolvedTheme}
theme={resolvedTheme}
hide="notion,confluence-cloud,confluence,google-docs,slack"
applimit={6}
introcopy="hide"
create-without-template="show"
use-this-zap="show"
/>
</IntegrationScene>
</Scene>
);
}
@@ -1,93 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { ConfigItem } from "~/hooks/useSettingsConfig";
import Button from "../../../components/Button";
import Flex from "../../../components/Flex";
import Text from "../../../components/Text";
type Props = {
integration: ConfigItem;
isConnected?: boolean;
};
function IntegrationCard({ integration, isConnected }: Props) {
const { t } = useTranslation();
return (
<Card as={Link} to={integration.path}>
<Flex align="center" gap={8}>
<integration.icon size={48} />
<Flex auto column>
<Name>{integration.name}</Name>
{isConnected && <Status>{t("Connected")}</Status>}
</Flex>
<Button as="span" neutral>
{isConnected ? t("Configure") : t("Connect")}
</Button>
</Flex>
<Description>{integration.description}</Description>
</Card>
);
}
export default IntegrationCard;
const Card = styled.div`
display: flex;
flex-direction: column;
flex-grow: 1;
padding: 20px;
width: 300px;
background: ${s("background")};
border: 1px solid ${s("inputBorder")};
color: ${s("text")};
border-radius: 8px;
transition: box-shadow 200ms ease;
cursor: var(--pointer);
&:hover {
box-shadow: rgba(0, 0, 0, 0.08) 0px 2px 4px, rgba(0, 0, 0, 0.06) 0px 4px 8px;
}
`;
const Name = styled(Text)`
margin: 0;
font-size: 16px;
font-weight: 600;
color: ${s("text")};
${ellipsis()}
`;
const Description = styled(Text)`
margin: 8px 0 0;
font-size: 15px;
max-width: 100%;
color: ${s("textTertiary")};
`;
const Status = styled(Text).attrs({
type: "secondary",
size: "small",
as: "span",
})`
display: inline-flex;
align-items: center;
&::after {
content: "";
display: inline-block;
width: 17px;
height: 17px;
background: radial-gradient(
circle at center,
${s("accent")} 0 33%,
transparent 33%
);
border-radius: 50%;
}
`;
@@ -1,33 +0,0 @@
import { SettingsIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Breadcrumb from "~/components/Breadcrumb";
import Scene from "~/components/Scene";
import { settingsPath } from "~/utils/routeHelpers";
export function IntegrationScene({
children,
...rest
}: React.ComponentProps<typeof Scene>) {
const { t } = useTranslation();
return (
<Scene
left={
<Breadcrumb
items={[
{
type: "route",
title: t("Integrations"),
icon: <SettingsIcon />,
to: settingsPath("integrations"),
},
]}
/>
}
{...rest}
>
{children}
</Scene>
);
}
-7
View File
@@ -186,13 +186,6 @@ export default class CollectionsStore extends Store<Collection> {
statusFilter: [CollectionStatusFilter.Archived],
});
get(id: string): Collection | undefined {
return (
this.data.get(id) ??
this.orderedData.find((collection) => id.endsWith(collection.urlId))
);
}
@computed
get archived(): Collection[] {
return orderBy(this.orderedData, "archivedAt", "desc").filter(
+9 -4
View File
@@ -279,14 +279,19 @@ export default class DocumentsStore extends Store<Document> {
@action
fetchBacklinks = async (documentId: string): Promise<void> => {
const documents = await this.fetchAll({
const res = await client.post(`/documents.list`, {
backlinkDocumentId: documentId,
});
invariant(res?.data, "Document list not available");
const { data } = res;
runInAction("DocumentsStore#fetchBacklinks", () => {
data.forEach(this.add);
this.addPolicies(res.policies);
this.backlinks.set(
documentId,
documents.map((doc) => doc.id)
data.map((doc: Partial<Document>) => doc.id)
);
});
};
@@ -295,8 +300,8 @@ export default class DocumentsStore extends Store<Document> {
const documentIds = this.backlinks.get(documentId) || [];
return orderBy(
compact(documentIds.map((id) => this.data.get(id))),
"title",
"asc"
"updatedAt",
"desc"
);
}
-6
View File
@@ -10,12 +10,6 @@ class IntegrationsStore extends Store<Integration> {
super(rootStore, Integration);
}
findByService(service: string) {
return this.orderedData.find(
(integration) => integration.service === service
);
}
@computed
get orderedData(): Integration[] {
return naturalSort(Array.from(this.data.values()), "name");
+2 -5
View File
@@ -3,7 +3,6 @@ import sortBy from "lodash/sortBy";
import { action, observable } from "mobx";
import Team from "~/models/Team";
import User from "~/models/User";
import { LazyComponent } from "~/components/LazyLoad";
import { useComputed } from "~/hooks/useComputed";
import Logger from "./Logger";
import isCloudHosted from "./isCloudHosted";
@@ -28,10 +27,8 @@ type PluginValueMap = {
after?: string;
/** The displayed icon of the plugin. */
icon: React.ElementType;
/** The lazy loaded settings screen component. */
component: LazyComponent<React.ComponentType>;
/** The description that will show on the plugins card. */
description?: string;
/** The settings screen somponent, should be lazy loaded. */
component: React.LazyExoticComponent<React.ComponentType>;
/** Whether the plugin is enabled in the current context. */
enabled?: (team: Team, user: User) => boolean;
};
+22 -23
View File
@@ -48,18 +48,18 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.803.0",
"@aws-sdk/lib-storage": "3.803.0",
"@aws-sdk/s3-presigned-post": "3.803.0",
"@aws-sdk/s3-request-presigner": "3.803.0",
"@aws-sdk/signature-v4-crt": "^3.803.0",
"@babel/core": "^7.27.1",
"@babel/plugin-proposal-decorators": "^7.27.1",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-destructuring": "^7.27.1",
"@babel/plugin-transform-regenerator": "^7.27.1",
"@babel/preset-env": "^7.27.1",
"@babel/preset-react": "^7.27.1",
"@aws-sdk/client-s3": "3.787.0",
"@aws-sdk/lib-storage": "3.787.0",
"@aws-sdk/s3-presigned-post": "3.787.0",
"@aws-sdk/s3-request-presigner": "3.787.0",
"@aws-sdk/signature-v4-crt": "^3.787.0",
"@babel/core": "^7.26.10",
"@babel/plugin-proposal-decorators": "^7.25.9",
"@babel/plugin-transform-class-properties": "^7.25.9",
"@babel/plugin-transform-destructuring": "^7.25.9",
"@babel/plugin-transform-regenerator": "^7.27.0",
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^6.7.10",
"@bull-board/koa": "^6.7.10",
@@ -141,7 +141,7 @@
"jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.0",
"jszip": "^3.10.1",
"katex": "^0.16.22",
"katex": "^0.16.21",
"kbar": "0.1.0-beta.41",
"koa": "^2.16.1",
"koa-body": "^6.0.1",
@@ -175,7 +175,7 @@
"passport-oauth2": "^1.8.0",
"passport-slack-oauth2": "^1.2.0",
"patch-package": "^7.0.2",
"pg": "^8.15.6",
"pg": "^8.14.1",
"pg-tsquery": "^8.4.2",
"pluralize": "^8.0.0",
"png-chunks-extract": "^1.0.0",
@@ -208,9 +208,9 @@
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.54.2",
"react-i18next": "^12.3.1",
"react-medium-image-zoom": "5.2.14",
"react-medium-image-zoom": "5.2.13",
"react-merge-refs": "^2.1.1",
"react-portal": "^4.3.0",
"react-portal": "^4.2.2",
"react-router-dom": "^5.3.4",
"react-virtualized-auto-sizer": "^1.0.26",
"react-waypoint": "^10.3.0",
@@ -228,7 +228,6 @@
"sequelize": "^6.37.3",
"sequelize-cli": "^6.6.2",
"sequelize-encrypted": "^1.0.0",
"sequelize-strict-attributes": "^1.0.2",
"sequelize-typescript": "^2.1.6",
"slug": "^5.3.0",
"slugify": "^1.6.6",
@@ -249,9 +248,9 @@
"umzug": "^3.8.2",
"utility-types": "^3.11.0",
"uuid": "^8.3.2",
"validator": "13.15.0",
"validator": "13.12.0",
"vaul": "^1.1.2",
"vite": "^6.3.4",
"vite": "^6.3.3",
"vite-plugin-pwa": "^0.21.2",
"winston": "^3.17.0",
"ws": "^7.5.10",
@@ -263,8 +262,8 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@babel/cli": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"@babel/cli": "^7.27.0",
"@babel/preset-typescript": "^7.27.0",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.0",
"@testing-library/react": "^12.0.0",
@@ -329,7 +328,7 @@
"@types/tmp": "^0.2.6",
"@types/turndown": "^5.0.5",
"@types/utf8": "^3.0.3",
"@types/validator": "^13.15.0",
"@types/validator": "^13.12.1",
"@types/yauzl": "^2.10.3",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
@@ -358,7 +357,7 @@
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
"lint-staged": "^13.3.0",
"nodemon": "^3.1.10",
"nodemon": "^3.1.9",
"postinstall-postinstall": "^2.1.0",
"prettier": "^2.8.8",
"react-refresh": "^0.14.2",
+3 -3
View File
@@ -4,7 +4,6 @@ import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { IntegrationService } from "@shared/types";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import { AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
@@ -12,6 +11,7 @@ import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import TeamLogo from "~/components/TeamLogo";
import Text from "~/components/Text";
import Time from "~/components/Time";
@@ -38,7 +38,7 @@ function GitHub() {
}, [integrations]);
return (
<IntegrationScene title="GitHub" icon={<GitHubIcon />}>
<Scene title="GitHub" icon={<GitHubIcon />}>
<Heading>GitHub</Heading>
{error === "access_denied" && (
@@ -146,7 +146,7 @@ function GitHub() {
</Trans>
</Notice>
)}
</IntegrationScene>
</Scene>
);
}
+2 -4
View File
@@ -1,4 +1,4 @@
import { createLazyComponent } from "~/components/LazyLoad";
import * as React from "react";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -10,9 +10,7 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
description:
"Connect your GitHub account to Outline to enable rich, realtime, issue and pull request previews inside documents.",
component: createLazyComponent(() => import("./Settings")),
component: React.lazy(() => import("./Settings")),
},
},
]);
+9 -3
View File
@@ -6,12 +6,12 @@ import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { IntegrationType, IntegrationService } from "@shared/types";
import Integration from "~/models/Integration";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import GoogleIcon from "~/components/Icons/GoogleIcon";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
@@ -40,6 +40,12 @@ function GoogleAnalytics() {
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Analytics,
});
}, [integrations]);
React.useEffect(() => {
reset({ measurementId: integration?.settings.measurementId });
}, [integration, reset]);
@@ -69,7 +75,7 @@ function GoogleAnalytics() {
);
return (
<IntegrationScene title={t("Google Analytics")} icon={<GoogleIcon />}>
<Scene title={t("Google Analytics")} icon={<GoogleIcon />}>
<Heading>{t("Google Analytics")}</Heading>
<Text as="p" type="secondary">
@@ -94,7 +100,7 @@ function GoogleAnalytics() {
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</IntegrationScene>
</Scene>
);
}
+2 -4
View File
@@ -1,4 +1,4 @@
import { createLazyComponent } from "~/components/LazyLoad";
import * as React from "react";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -10,9 +10,7 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
description:
"Measure adoption and engagement by sending view and event analytics directly to your GA4 dashboard.",
component: createLazyComponent(() => import("./Settings")),
component: React.lazy(() => import("./Settings")),
},
},
]);
+1 -1
View File
@@ -1,5 +1,5 @@
{
"id": "google-analytics",
"id": "googleanalytics",
"name": "Google Analytics",
"priority": 30,
"description": "Adds support for reporting analytics to a Google."
+2 -4
View File
@@ -1,4 +1,4 @@
import { createLazyComponent } from "~/components/LazyLoad";
import * as React from "react";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -10,9 +10,7 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
description:
"Connect your Linear account to Outline to enable rich, realtime, issue previews inside documents.",
component: createLazyComponent(() => import("./Settings")),
component: React.lazy(() => import("./Settings")),
},
},
]);
+5 -5
View File
@@ -77,14 +77,14 @@ router.get(
{ transaction }
);
transaction.afterCommit(async () => {
if (workspace.logoUrl) {
await new UploadLinearWorkspaceLogoTask().schedule({
if (workspace.logoUrl) {
transaction.afterCommit(async () => {
await UploadLinearWorkspaceLogoTask.schedule({
integrationId: integration.id,
logoUrl: workspace.logoUrl,
});
}
});
});
}
ctx.redirect(LinearUtils.successUrl());
}
+9 -3
View File
@@ -6,11 +6,11 @@ import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { IntegrationType, IntegrationService } from "@shared/types";
import Integration from "~/models/Integration";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import Icon from "./Icon";
@@ -42,6 +42,12 @@ function Matomo() {
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Analytics,
});
}, [integrations]);
React.useEffect(() => {
reset({
measurementId: integration?.settings.measurementId,
@@ -76,7 +82,7 @@ function Matomo() {
);
return (
<IntegrationScene title="Matomo" icon={<Icon />}>
<Scene title="Matomo" icon={<Icon />}>
<Heading>Matomo</Heading>
<Text as="p" type="secondary">
@@ -115,7 +121,7 @@ function Matomo() {
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</IntegrationScene>
</Scene>
);
}
+2 -4
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { UserRole } from "@shared/types";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -11,9 +11,7 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
description:
"Track your docs with a self-hosted, open-source analytics platform, link Outline to Matomo for 100% data ownership, GDPR compliance, and deep usage insights on your own servers.",
component: createLazyComponent(() => import("./Settings")),
component: React.lazy(() => import("./Settings")),
enabled: (_, user) => user.role === UserRole.Admin,
},
},
@@ -66,6 +66,6 @@ export class NotionImportsProcessor extends ImportsProcessor<IntegrationService.
protected async scheduleTask(
importTask: ImportTask<IntegrationService.Notion>
): Promise<void> {
await new NotionAPIImportTask().schedule({ importTaskId: importTask.id });
await NotionAPIImportTask.schedule({ importTaskId: importTask.id });
}
}
@@ -77,7 +77,7 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
protected async scheduleNextTask(
importTask: ImportTask<IntegrationService.Notion>
) {
await new NotionAPIImportTask().schedule({ importTaskId: importTask.id });
await NotionAPIImportTask.schedule({ importTaskId: importTask.id });
return;
}
+20 -20
View File
@@ -288,7 +288,7 @@ export class NotionConverter {
if (item.mention.type === "link_mention") {
return {
type: "text",
text: item.plain_text || item.mention.link_mention.href,
text: item.plain_text,
marks: [
{
type: "link",
@@ -302,7 +302,7 @@ export class NotionConverter {
if (item.mention.type === "link_preview") {
return {
type: "text",
text: item.plain_text || item.mention.link_preview.url,
text: item.plain_text,
marks: [
{
type: "link",
@@ -314,14 +314,14 @@ export class NotionConverter {
};
}
if (item.plain_text) {
return {
type: "text",
text: item.plain_text,
};
if (!item.plain_text) {
return undefined;
}
return undefined;
return {
type: "text",
text: item.plain_text,
};
}
if (item.type === "equation") {
@@ -336,20 +336,20 @@ export class NotionConverter {
};
}
if (item.text.content) {
return {
type: "text",
text: item.text.content,
marks: [
...mapAttrs(),
...(item.text.link
? [{ type: "link", attrs: { href: item.text.link.url } }]
: []),
].filter(Boolean),
};
if (!item.text.content) {
return undefined;
}
return undefined;
return {
type: "text",
text: item.text.content,
marks: [
...mapAttrs(),
...(item.text.link
? [{ type: "link", attrs: { href: item.text.link.url } }]
: []),
].filter(Boolean),
};
}
private static rich_text_to_plaintext(item: RichTextItemResponse) {
+10 -4
View File
@@ -6,7 +6,6 @@ import { IntegrationService, IntegrationType } from "@shared/types";
import Collection from "~/models/Collection";
import Integration from "~/models/Integration";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
@@ -14,6 +13,7 @@ import CollectionIcon from "~/components/Icons/CollectionIcon";
import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentTeam from "~/hooks/useCurrentTeam";
@@ -34,7 +34,13 @@ function Slack() {
const error = query.get("error");
React.useEffect(() => {
void collections.fetchAll();
void collections.fetchPage({
limit: 100,
});
void integrations.fetchPage({
service: IntegrationService.Slack,
limit: 100,
});
}, [collections, integrations]);
const commandIntegration = integrations.find({
@@ -61,7 +67,7 @@ function Slack() {
const appName = env.APP_NAME;
return (
<IntegrationScene title="Slack" icon={<SlackIcon />}>
<Scene title="Slack" icon={<SlackIcon />}>
<Heading>Slack</Heading>
{error === "access_denied" && (
@@ -199,7 +205,7 @@ function Slack() {
</List>
</>
)}
</IntegrationScene>
</Scene>
);
}
+2 -4
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { UserRole } from "@shared/types";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -11,9 +11,7 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
description:
"Search your knowledge base directly in Slack, get /outline search, rich link previews, and notifications on new or updated docs.",
component: createLazyComponent(() => import("./Settings")),
component: React.lazy(() => import("./Settings")),
enabled: (_, user) =>
[UserRole.Member, UserRole.Admin].includes(user.role),
},
+9 -3
View File
@@ -6,11 +6,11 @@ import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { IntegrationType, IntegrationService } from "@shared/types";
import Integration from "~/models/Integration";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import Icon from "./Icon";
@@ -44,6 +44,12 @@ function Umami() {
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Analytics,
});
}, [integrations]);
React.useEffect(() => {
reset({
umamiWebsiteId: integration?.settings.measurementId,
@@ -79,7 +85,7 @@ function Umami() {
);
return (
<IntegrationScene title="Umami" icon={<Icon />}>
<Scene title="Umami" icon={<Icon />}>
<Heading>Umami</Heading>
<Text as="p" type="secondary">
@@ -139,7 +145,7 @@ function Umami() {
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</IntegrationScene>
</Scene>
);
}
+2 -4
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { UserRole } from "@shared/types";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -11,9 +11,7 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
description:
"Gain privacy-first insights into how your team consumes docs, inject your self-hosted Umami script across Outline pages to track views and engagement while retaining full control of your data.",
component: createLazyComponent(() => import("./Settings")),
component: React.lazy(() => import("./Settings")),
enabled: (_, user) => user.role === UserRole.Admin,
},
},
+3 -6
View File
@@ -1,4 +1,4 @@
import { createLazyComponent } from "~/components/LazyLoad";
import * as React from "react";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -8,12 +8,9 @@ PluginManager.add([
...config,
type: Hook.Settings,
value: {
group: "Workspace",
after: "Shared Links",
group: "Integrations",
icon: Icon,
description:
"Automate downstream workflows with real-time JSON POSTs, subscribe to events in Outline so external systems can react instantly.",
component: createLazyComponent(() => import("./Settings")),
component: React.lazy(() => import("./Settings")),
},
},
]);
@@ -29,12 +29,8 @@ describe("WebhookProcessor", () => {
await processor.perform(event);
expect(
jest.mocked(DeliverWebhookTask.prototype.schedule)
).toHaveBeenCalled();
expect(
jest.mocked(DeliverWebhookTask.prototype.schedule)
).toHaveBeenCalledWith({
expect(DeliverWebhookTask.schedule).toHaveBeenCalled();
expect(DeliverWebhookTask.schedule).toHaveBeenCalledWith({
event,
subscriptionId: subscription.id,
});
@@ -57,9 +53,7 @@ describe("WebhookProcessor", () => {
await processor.perform(event);
expect(
jest.mocked(DeliverWebhookTask.prototype.schedule)
).toHaveBeenCalledTimes(0);
expect(DeliverWebhookTask.schedule).toHaveBeenCalledTimes(0);
});
it("it schedules a delivery for the event for each subscription", async () => {
@@ -85,21 +79,13 @@ describe("WebhookProcessor", () => {
await processor.perform(event);
expect(
jest.mocked(DeliverWebhookTask.prototype.schedule)
).toHaveBeenCalled();
expect(
jest.mocked(DeliverWebhookTask.prototype.schedule)
).toHaveBeenCalledTimes(2);
expect(
jest.mocked(DeliverWebhookTask.prototype.schedule)
).toHaveBeenCalledWith({
expect(DeliverWebhookTask.schedule).toHaveBeenCalled();
expect(DeliverWebhookTask.schedule).toHaveBeenCalledTimes(2);
expect(DeliverWebhookTask.schedule).toHaveBeenCalledWith({
event,
subscriptionId: subscription.id,
});
expect(
jest.mocked(DeliverWebhookTask.prototype.schedule)
).toHaveBeenCalledWith({
expect(DeliverWebhookTask.schedule).toHaveBeenCalledWith({
event,
subscriptionId: subscriptionTwo.id,
});
@@ -24,10 +24,7 @@ export default class WebhookProcessor extends BaseProcessor {
await Promise.all(
applicableSubscriptions.map((subscription) =>
new DeliverWebhookTask().schedule({
event,
subscriptionId: subscription.id,
})
DeliverWebhookTask.schedule({ event, subscriptionId: subscription.id })
)
);
}
-18
View File
@@ -1,18 +0,0 @@
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
PluginManager.add([
{
...config,
type: Hook.Settings,
value: {
group: "Integrations",
icon: Icon,
description:
"Connect your Outline workspace to Zapier to automate workflows and integrate with thousands of other tools.",
component: createLazyComponent(() => import("./Settings")),
},
},
]);
-6
View File
@@ -1,6 +0,0 @@
{
"id": "zapier",
"name": "Zapier",
"description": "Adds a settings screen for connecting to Zapier.",
"deployments": ["cloud"]
}
+30 -19
View File
@@ -1,3 +1,4 @@
import invariant from "invariant";
import { Op, WhereOptions } from "sequelize";
import isUUID from "validator/lib/isUUID";
import { UrlHelper } from "@shared/utils/UrlHelper";
@@ -21,8 +22,8 @@ type Props = {
type Result = {
document: Document;
share: Share | null;
collection: Collection | null;
share?: Share;
collection?: Collection | null;
};
export default async function loadDocument({
@@ -32,9 +33,9 @@ export default async function loadDocument({
user,
includeState,
}: Props): Promise<Result> {
let document: Document | null = null;
let collection: Collection | null = null;
let share: Share | null = null;
let document;
let collection;
let share;
if (!shareId && !(id && user)) {
throw AuthenticationError(`Authentication or shareId required`);
@@ -71,7 +72,20 @@ export default async function loadDocument({
where: whereClause,
include: [
{
model: Document.scope("withDrafts"),
// unscoping here allows us to return unpublished documents
model: Document.unscoped(),
include: [
{
model: User,
as: "createdBy",
paranoid: false,
},
{
model: User,
as: "updatedBy",
paranoid: false,
},
],
required: true,
as: "document",
},
@@ -115,13 +129,14 @@ export default async function loadDocument({
const canReadDocument = user && can(user, "read", document);
if (canReadDocument) {
// Cannot use document.collection here as it does not include the
// documentStructure by default through the relationship.
if (document.collectionId) {
collection = await Collection.scope("withDocumentStructure").findByPk(
document.collectionId,
{
rejectOnEmpty: true,
}
);
collection = await Collection.findByPk(document.collectionId);
if (!collection) {
throw NotFoundError("Collection could not be found for document");
}
}
return {
@@ -140,15 +155,11 @@ export default async function loadDocument({
// It is possible to disable sharing at the collection so we must check
if (document.collectionId) {
collection = await Collection.scope("withDocumentStructure").findByPk(
document.collectionId,
{
rejectOnEmpty: true,
}
);
collection = await Collection.findByPk(document.collectionId);
}
invariant(collection, "collection not found");
if (!collection?.sharing) {
if (!collection.sharing) {
throw AuthorizationError();
}
+12 -18
View File
@@ -1,3 +1,4 @@
import invariant from "invariant";
import { Transaction } from "sequelize";
import { createContext } from "@server/context";
import { traceFunction } from "@server/logging/tracing";
@@ -23,7 +24,7 @@ type Props = {
/** Position of moved document within document structure */
index?: number;
/** The IP address of the user moving the document */
ip: string | null;
ip: string;
/** The database transaction to run within */
transaction?: Transaction;
};
@@ -65,21 +66,16 @@ async function documentMover({
result.documents.push(document);
} else {
// Load the current and the next collection upfront and lock them
const collection = await Collection.scope("withDocumentStructure").findByPk(
document.collectionId!,
{
transaction,
lock: Transaction.LOCK.UPDATE,
paranoid: false,
}
);
const collection = await Collection.findByPk(document.collectionId!, {
transaction,
lock: Transaction.LOCK.UPDATE,
paranoid: false,
});
let newCollection = collection;
if (collectionChanged) {
if (collectionId) {
newCollection = await Collection.scope(
"withDocumentStructure"
).findByPk(collectionId, {
newCollection = await Collection.findByPk(collectionId, {
transaction,
lock: Transaction.LOCK.UPDATE,
});
@@ -148,14 +144,12 @@ async function documentMover({
if (collectionId) {
// Reload the collection to get relationship data
newCollection = await Collection.scope([
{
method: ["withMembership", user.id],
},
]).findByPk(collectionId, {
newCollection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, {
transaction,
rejectOnEmpty: true,
});
invariant(newCollection, "Collection not found");
result.collections.push(newCollection);
@@ -4,11 +4,9 @@ import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
import { buildAttachment, buildDocument } from "@server/test/factories";
import documentPermanentDeleter from "./documentPermanentDeleter";
jest.mock("@server/queues/tasks/DeleteAttachmentTask");
beforeEach(() => {
jest.resetAllMocks();
});
jest.mock("@server/queues/tasks/DeleteAttachmentTask", () => ({
schedule: jest.fn(),
}));
describe("documentPermanentDeleter", () => {
it("should destroy documents", async () => {
@@ -62,9 +60,7 @@ describe("documentPermanentDeleter", () => {
await document.save();
const countDeletedDoc = await documentPermanentDeleter([document]);
expect(countDeletedDoc).toEqual(1);
expect(
jest.mocked(DeleteAttachmentTask.prototype.schedule)
).toHaveBeenCalledTimes(2);
expect(DeleteAttachmentTask.schedule).toHaveBeenCalledTimes(2);
expect(
await Document.unscoped().count({
where: {
+1 -1
View File
@@ -67,7 +67,7 @@ export default async function documentPermanentDeleter(documents: Document[]) {
"commands",
`Attachment ${attachmentId} scheduled for deletion`
);
await new DeleteAttachmentTask().schedule({
await DeleteAttachmentTask.schedule({
attachmentId,
teamId: document.teamId,
});
+1 -1
View File
@@ -56,5 +56,5 @@ export default async function userSuspender({
}
);
await new CleanupDemotedUserTask().schedule({ userId: user.id });
await CleanupDemotedUserTask.schedule({ userId: user.id });
}
@@ -1,29 +0,0 @@
"use strict";
const { execFileSync } = require("child_process");
const path = require("path");
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up() {
if (
process.env.NODE_ENV === "test" ||
process.env.DEPLOYMENT === "hosted"
) {
return;
}
const scriptName = path.basename(__filename);
const scriptPath = path.join(
process.cwd(),
"build",
`server/scripts/${scriptName}`
);
execFileSync("node", [scriptPath], { stdio: "inherit" });
},
async down() {
// noop
},
};
+29 -35
View File
@@ -16,7 +16,7 @@ beforeEach(() => {
});
describe("#url", () => {
it("should return correct url for the collection", () => {
test("should return correct url for the collection", () => {
const collection = new Collection({
id: "1234",
});
@@ -25,7 +25,7 @@ describe("#url", () => {
});
describe("getDocumentParents", () => {
it("should return array of parent document ids", async () => {
test("should return array of parent document ids", async () => {
const parent = await buildDocument();
const document = await buildDocument();
const collection = await buildCollection({
@@ -41,7 +41,7 @@ describe("getDocumentParents", () => {
expect(result ? result[0] : undefined).toBe(parent.id);
});
it("should return array of parent document ids", async () => {
test("should return array of parent document ids", async () => {
const parent = await buildDocument();
const document = await buildDocument();
const collection = await buildCollection({
@@ -56,7 +56,7 @@ describe("getDocumentParents", () => {
expect(result?.length).toBe(0);
});
it("should not error if documentStructure is empty", async () => {
test("should not error if documentStructure is empty", async () => {
const parent = await buildDocument();
await buildDocument();
const collection = await buildCollection();
@@ -66,7 +66,7 @@ describe("getDocumentParents", () => {
});
describe("getDocumentTree", () => {
it("should return document tree", async () => {
test("should return document tree", async () => {
const document = await buildDocument();
const collection = await buildCollection({
documentStructure: [await document.toNavigationNode()],
@@ -76,7 +76,7 @@ describe("getDocumentTree", () => {
);
});
it("should return nested documents in tree", async () => {
test("should return nested documents in tree", async () => {
const parent = await buildDocument();
const document = await buildDocument();
const collection = await buildCollection({
@@ -99,7 +99,7 @@ describe("getDocumentTree", () => {
});
describe("#addDocumentToStructure", () => {
it("should add as last element without index", async () => {
test("should add as last element without index", async () => {
const collection = await buildCollection();
const id = uuidv4();
const newDocument = await buildDocument({
@@ -117,7 +117,7 @@ describe("#addDocumentToStructure", () => {
expect(collection.documentStructure!.length).toBe(1);
});
it("should add with an index", async () => {
test("should add with an index", async () => {
const collection = await buildCollection();
const id = uuidv4();
const newDocument = await buildDocument({
@@ -131,7 +131,7 @@ describe("#addDocumentToStructure", () => {
expect(collection.documentStructure![0].id).toBe(id);
});
it("should add as a child if with parent", async () => {
test("should add as a child if with parent", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -150,7 +150,7 @@ describe("#addDocumentToStructure", () => {
expect(collection.documentStructure![0].children[0].id).toBe(id);
});
it("should add as a child if with parent with index", async () => {
test("should add as a child if with parent with index", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -176,7 +176,7 @@ describe("#addDocumentToStructure", () => {
expect(collection.documentStructure![0].children[0].id).toBe(id);
});
it("should add the document along with its nested document(s)", async () => {
test("should add the document along with its nested document(s)", async () => {
const collection = await buildCollection();
const document = await buildDocument({
@@ -204,7 +204,7 @@ describe("#addDocumentToStructure", () => {
);
});
it("should add the document along with its archived nested document(s)", async () => {
test("should add the document along with its archived nested document(s)", async () => {
const collection = await buildCollection();
const document = await buildDocument({
@@ -237,7 +237,7 @@ describe("#addDocumentToStructure", () => {
);
});
describe("options: documentJson", () => {
it("should append supplied json over document's own", async () => {
test("should append supplied json over document's own", async () => {
const collection = await buildCollection();
const id = uuidv4();
const newDocument = await buildDocument({
@@ -268,7 +268,7 @@ describe("#addDocumentToStructure", () => {
});
describe("#updateDocument", () => {
it("should update root document's data", async () => {
test("should update root document's data", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -279,7 +279,7 @@ describe("#updateDocument", () => {
expect(collection.documentStructure![0].title).toBe("Updated title");
});
it("should update child document's data", async () => {
test("should update child document's data", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -297,7 +297,7 @@ describe("#updateDocument", () => {
newDocument.title = "Updated title";
await newDocument.save();
await collection.updateDocument(newDocument);
const reloaded = await collection.reload();
const reloaded = await Collection.findByPk(collection.id);
expect(reloaded!.documentStructure![0].children[0].title).toBe(
"Updated title"
);
@@ -305,7 +305,7 @@ describe("#updateDocument", () => {
});
describe("#removeDocument", () => {
it("should save if removing", async () => {
test("should save if removing", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -315,7 +315,7 @@ describe("#removeDocument", () => {
expect(collection.save).toBeCalled();
});
it("should remove documents from root", async () => {
test("should remove documents from root", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -331,7 +331,7 @@ describe("#removeDocument", () => {
expect(collectionDocuments.count).toBe(0);
});
it("should remove a document with child documents", async () => {
test("should remove a document with child documents", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -359,7 +359,7 @@ describe("#removeDocument", () => {
expect(collectionDocuments.count).toBe(0);
});
it("should remove a child document", async () => {
test("should remove a child document", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -380,7 +380,7 @@ describe("#removeDocument", () => {
expect(collection.documentStructure![0].children.length).toBe(1);
// Remove the document
await collection.deleteDocument(newDocument);
const reloaded = await collection.reload();
const reloaded = await Collection.findByPk(collection.id);
expect(reloaded!.documentStructure!.length).toBe(1);
expect(reloaded!.documentStructure![0].children.length).toBe(0);
const collectionDocuments = await Document.findAndCountAll({
@@ -393,7 +393,7 @@ describe("#removeDocument", () => {
});
describe("#membershipUserIds", () => {
it("should return collection and group memberships", async () => {
test("should return collection and group memberships", async () => {
const team = await buildTeam();
const teamId = team.id;
// Make 6 users
@@ -464,53 +464,47 @@ describe("#membershipUserIds", () => {
});
describe("#findByPk", () => {
it("should return collection with collection Id", async () => {
test("should return collection with collection Id", async () => {
const collection = await buildCollection();
const response = await Collection.findByPk(collection.id);
expect(response!.id).toBe(collection.id);
});
it("should not return documentStructure by default", async () => {
const collection = await buildCollection();
const response = await Collection.findByPk(collection.id);
expect(() => response!.documentStructure).toThrow();
});
it("should return collection when urlId is present", async () => {
test("should return collection when urlId is present", async () => {
const collection = await buildCollection();
const id = `${slugify(collection.name)}-${collection.urlId}`;
const response = await Collection.findByPk(id);
expect(response!.id).toBe(collection.id);
});
it("should return collection when urlId is present, but missing slug", async () => {
test("should return collection when urlId is present, but missing slug", async () => {
const collection = await buildCollection();
const id = collection.urlId;
const response = await Collection.findByPk(id);
expect(response!.id).toBe(collection.id);
});
it("should return null when incorrect uuid type", async () => {
test("should return null when incorrect uuid type", async () => {
const collection = await buildCollection();
const response = await Collection.findByPk(collection.id + "-incorrect");
expect(response).toBe(null);
});
it("should return null when incorrect urlId length", async () => {
test("should return null when incorrect urlId length", async () => {
const collection = await buildCollection();
const id = `${slugify(collection.name)}-${collection.urlId}incorrect`;
const response = await Collection.findByPk(id);
expect(response).toBe(null);
});
it("should return null when no collection is found with uuid", async () => {
test("should return null when no collection is found with uuid", async () => {
const response = await Collection.findByPk(
"a9e71a81-7342-4ea3-9889-9b9cc8f667da"
);
expect(response).toBe(null);
});
it("should return null when no collection is found with urlId", async () => {
test("should return null when no collection is found with urlId", async () => {
const id = `${slugify("test collection")}-${randomstring.generate(15)}`;
const response = await Collection.findByPk(id);
expect(response).toBe(null);
-13
View File
@@ -37,7 +37,6 @@ import {
AllowNull,
BeforeCreate,
BeforeUpdate,
DefaultScope,
} from "sequelize-typescript";
import isUUID from "validator/lib/isUUID";
import type { CollectionSort, ProsemirrorData } from "@shared/types";
@@ -70,11 +69,6 @@ type AdditionalFindOptions = {
rejectOnEmpty?: boolean | Error;
};
@DefaultScope(() => ({
attributes: {
exclude: ["documentStructure"],
},
}))
@Scopes(() => ({
withAllMemberships: {
include: [
@@ -127,12 +121,6 @@ type AdditionalFindOptions = {
},
],
}),
withDocumentStructure: () => ({
attributes: {
// resets to include the documentStructure column
exclude: [],
},
}),
withMembership: (userId: string) => {
if (!userId) {
return {};
@@ -250,7 +238,6 @@ class Collection extends ParanoidModel<
@Column
maintainerApprovalRequired: boolean;
@Default(null)
@Column(DataType.JSONB)
documentStructure: NavigationNode[] | null;
+5 -2
View File
@@ -11,6 +11,7 @@ import {
buildUser,
buildGuestUser,
} from "@server/test/factories";
import Collection from "./Collection";
import UserMembership from "./UserMembership";
beforeEach(() => {
@@ -95,8 +96,10 @@ describe("#delete", () => {
await document.delete(user);
const [newDocument, newCollection] = await Promise.all([
document.reload({ paranoid: false }),
collection.reload(),
Document.findByPk(document.id, {
paranoid: false,
}),
Collection.findByPk(collection.id),
]);
expect(newDocument?.lastModifiedById).toEqual(user.id);
+56 -71
View File
@@ -13,9 +13,9 @@ import {
Transaction,
Op,
FindOptions,
ScopeOptions,
WhereOptions,
EmptyResultError,
Sequelize,
} from "sequelize";
import {
ForeignKey,
@@ -72,20 +72,12 @@ import Length from "./validators/Length";
export const DOCUMENT_VERSION = 2;
// If content (JSON) is null then we still need to return the state column (BINARY)
// as it's used as a fallback for content deserialization for older documents.
// This can be removed if content is 100% backfilled.
const stateIfContentEmpty = Sequelize.literal(
`CASE WHEN document.content IS NULL THEN document.state ELSE NULL END AS state`
);
type AdditionalFindOptions = {
userId?: string;
includeState?: boolean;
rejectOnEmpty?: boolean | Error;
};
// @ts-expect-error Type 'Literal' is not assignable to type 'string | ProjectionAlias'.
@DefaultScope(() => ({
include: [
{
@@ -110,14 +102,27 @@ type AdditionalFindOptions = {
},
},
attributes: {
include: [stateIfContentEmpty],
exclude: ["state"],
},
}))
// @ts-expect-error Type 'Literal' is not assignable to type 'string | ProjectionAlias'.
@Scopes(() => ({
withCollectionPermissions: (userId: string, paranoid = true) => ({
include: [
{
attributes: ["id", "permission", "sharing", "teamId", "deletedAt"],
model: userId
? Collection.scope({
method: ["withMembership", userId],
})
: Collection,
as: "collection",
paranoid,
},
],
}),
withoutState: {
attributes: {
include: [stateIfContentEmpty],
exclude: ["state"],
},
},
withCollection: {
@@ -131,7 +136,7 @@ type AdditionalFindOptions = {
withState: {
attributes: {
// resets to include the state column
include: [],
exclude: [],
},
},
withDrafts: {
@@ -164,25 +169,13 @@ type AdditionalFindOptions = {
],
};
},
withMembership: (userId: string, paranoid = true) => {
withMembership: (userId: string) => {
if (!userId) {
return {};
}
return {
include: [
{
model: userId
? Collection.scope([
"defaultScope",
{
method: ["withMembership", userId],
},
])
: Collection,
as: "collection",
paranoid,
},
{
association: "memberships",
where: {
@@ -426,13 +419,10 @@ class Document extends ArchivableModel<
return;
}
const collection = await Collection.scope("withDocumentStructure").findByPk(
model.collectionId,
{
transaction,
lock: Transaction.LOCK.UPDATE,
}
);
const collection = await Collection.findByPk(model.collectionId, {
transaction,
lock: Transaction.LOCK.UPDATE,
});
if (!collection) {
return;
}
@@ -453,9 +443,7 @@ class Document extends ArchivableModel<
}
return this.sequelize!.transaction(async (transaction: Transaction) => {
const collection = await Collection.scope(
"withDocumentStructure"
).findByPk(model.collectionId!, {
const collection = await Collection.findByPk(model.collectionId!, {
transaction,
lock: transaction.LOCK.UPDATE,
});
@@ -649,19 +637,21 @@ class Document extends ArchivableModel<
return uniq(membershipUserIds);
}
static withMembershipScope(
userId: string,
options?: FindOptions<Document> & { includeDrafts?: boolean }
) {
static defaultScopeWithUser(userId: string) {
const collectionScope: Readonly<ScopeOptions> = {
method: ["withCollectionPermissions", userId],
};
const viewScope: Readonly<ScopeOptions> = {
method: ["withViews", userId],
};
const membershipScope: Readonly<ScopeOptions> = {
method: ["withMembership", userId],
};
return this.scope([
options?.includeDrafts ? "withDrafts" : "defaultScope",
"withoutState",
{
method: ["withViews", userId],
},
{
method: ["withMembership", userId, options?.paranoid],
},
"defaultScope",
collectionScope,
viewScope,
membershipScope,
]);
}
@@ -695,12 +685,14 @@ class Document extends ArchivableModel<
// almost every endpoint needs the collection membership to determine policy permissions.
const scope = this.scope([
"withDrafts",
options.includeState ? "withState" : "withoutState",
{
method: ["withCollectionPermissions", userId, rest.paranoid],
},
{
method: ["withViews", userId],
},
{
method: ["withMembership", userId, rest.paranoid],
method: ["withMembership", userId],
},
]);
@@ -758,6 +750,9 @@ class Document extends ArchivableModel<
const user = userId ? await User.findByPk(userId) : null;
const documents = await this.scope([
"withDrafts",
{
method: ["withCollectionPermissions", userId, rest.paranoid],
},
{
method: ["withViews", userId],
},
@@ -943,9 +938,7 @@ class Document extends ArchivableModel<
}
if (!this.template && this.collectionId) {
const collection = await Collection.scope(
"withDocumentStructure"
).findByPk(this.collectionId, {
const collection = await Collection.findByPk(this.collectionId, {
transaction,
lock: Transaction.LOCK.UPDATE,
});
@@ -1012,13 +1005,10 @@ class Document extends ArchivableModel<
await this.sequelize.transaction(async (transaction: Transaction) => {
const collection = this.collectionId
? await Collection.scope("withDocumentStructure").findByPk(
this.collectionId,
{
transaction,
lock: transaction.LOCK.UPDATE,
}
)
? await Collection.findByPk(this.collectionId, {
transaction,
lock: transaction.LOCK.UPDATE,
})
: undefined;
if (collection) {
@@ -1049,13 +1039,10 @@ class Document extends ArchivableModel<
archive = async (user: User, options?: FindOptions) => {
const { transaction } = { ...options };
const collection = this.collectionId
? await Collection.scope("withDocumentStructure").findByPk(
this.collectionId,
{
transaction,
lock: transaction?.LOCK.UPDATE,
}
)
? await Collection.findByPk(this.collectionId, {
transaction,
lock: transaction?.LOCK.UPDATE,
})
: undefined;
if (collection) {
@@ -1076,7 +1063,7 @@ class Document extends ArchivableModel<
) => {
const { transaction } = { ...options };
const collection = collectionId
? await Collection.scope("withDocumentStructure").findByPk(collectionId, {
? await Collection.findByPk(collectionId, {
transaction,
lock: transaction?.LOCK.UPDATE,
})
@@ -1128,9 +1115,7 @@ class Document extends ArchivableModel<
let deleted = false;
if (!this.template && this.collectionId) {
const collection = await Collection.scope(
"withDocumentStructure"
).findByPk(this.collectionId!, {
const collection = await Collection.findByPk(this.collectionId!, {
transaction,
lock: transaction.LOCK.UPDATE,
paranoid: false,
+1 -1
View File
@@ -408,7 +408,7 @@ class Team extends ParanoidModel<
});
if (attachment) {
await new DeleteAttachmentTask().schedule({
await DeleteAttachmentTask.schedule({
attachmentId: attachment.id,
teamId: model.id,
});
+1 -1
View File
@@ -717,7 +717,7 @@ class User extends ParanoidModel<
});
if (attachment) {
await new DeleteAttachmentTask().schedule({
await DeleteAttachmentTask.schedule({
attachmentId: attachment.id,
teamId: model.teamId,
});
@@ -1,9 +1,7 @@
import { DocumentPermission, NotificationEventType } from "@shared/types";
import { UserMembership } from "@server/models";
import { NotificationEventType } from "@shared/types";
import {
buildComment,
buildDocument,
buildDraftDocument,
buildSubscription,
buildUser,
} from "@server/test/factories";
@@ -56,78 +54,6 @@ describe("NotificationHelper", () => {
expect(recipients[0].id).toEqual(notificationEnabledUser.id);
});
it("should only return users who have notification enabled for comment creation and are subscribed to the document in case of new thread in draft", async () => {
const documentAuthor = await buildUser();
// create a draft
const document = await buildDraftDocument({
userId: documentAuthor.id,
teamId: documentAuthor.teamId,
collectionId: null,
});
// add a bunch of users as direct members
const user = await buildUser({
teamId: document.teamId,
notificationSettings: { [NotificationEventType.CreateComment]: true },
});
const user2 = await buildUser({
teamId: document.teamId,
notificationSettings: { [NotificationEventType.CreateComment]: true },
});
const user3 = await buildUser({
teamId: document.teamId,
notificationSettings: { [NotificationEventType.CreateComment]: true },
});
await UserMembership.create({
documentId: document.id,
userId: user.id,
permission: DocumentPermission.Read,
createdById: user.id,
});
await UserMembership.create({
documentId: document.id,
userId: user2.id,
permission: DocumentPermission.Read,
createdById: user.id,
});
await UserMembership.create({
documentId: document.id,
userId: user3.id,
permission: DocumentPermission.Read,
createdById: user.id,
});
// Add a subscription for only one of those users
await Promise.all([
buildSubscription({
userId: user.id,
}),
buildSubscription({
userId: user2.id,
}),
buildSubscription({
userId: user3.id,
documentId: document.id,
}),
]);
const comment = await buildComment({
documentId: document.id,
userId: documentAuthor.id,
});
const recipients =
await NotificationHelper.getCommentNotificationRecipients(
document,
comment,
comment.createdById
);
expect(recipients.length).toEqual(1);
expect(recipients[0].id).toEqual(user3.id);
});
it("should only return users who have notification enabled for comment creation and are in the thread in case of child comment", async () => {
const documentAuthor = await buildUser();
const document = await buildDocument({
+4 -10
View File
@@ -193,16 +193,10 @@ export default class NotificationHelper {
[Op.ne]: actorId,
},
event: SubscriptionType.Document,
...(document.collectionId
? {
[Op.or]: [
{ collectionId: document.collectionId },
{ documentId: document.id },
],
}
: {
documentId: document.id,
}),
[Op.or]: [
{ collectionId: document.collectionId },
{ documentId: document.id },
],
},
include: [
{
+24 -4
View File
@@ -182,9 +182,18 @@ export default class SearchHelper {
},
];
return Document.withMembershipScope(user.id, {
includeDrafts: true,
}).findAll({
return Document.scope([
"withDrafts",
{
method: ["withViews", user.id],
},
{
method: ["withCollectionPermissions", user.id],
},
{
method: ["withMembership", user.id],
},
]).findAll({
where,
subQuery: false,
order: [["updatedAt", "DESC"]],
@@ -264,7 +273,18 @@ export default class SearchHelper {
// Final query to get associated document data
const [documents, count] = await Promise.all([
Document.withMembershipScope(user.id, { includeDrafts: true }).findAll({
Document.scope([
"withDrafts",
{
method: ["withViews", user.id],
},
{
method: ["withCollectionPermissions", user.id],
},
{
method: ["withMembership", user.id],
},
]).findAll({
where: {
teamId: user.teamId,
id: map(results, "id"),
+2 -2
View File
@@ -17,7 +17,7 @@ export default class AvatarProcessor extends BaseProcessor {
});
if (user.avatarUrl) {
await new UploadUserAvatarTask().schedule({
await UploadUserAvatarTask.schedule({
userId: event.userId,
avatarUrl: user.avatarUrl,
});
@@ -30,7 +30,7 @@ export default class AvatarProcessor extends BaseProcessor {
});
if (team.avatarUrl) {
await new UploadTeamAvatarTask().schedule({
await UploadTeamAvatarTask.schedule({
teamId: event.teamId,
avatarUrl: team.avatarUrl,
});
@@ -12,7 +12,7 @@ export default class CollectionsProcessor extends BaseProcessor {
];
async perform(event: CollectionEvent) {
await new DetachDraftsFromCollectionTask().schedule({
await DetachDraftsFromCollectionTask.schedule({
collectionId: event.collectionId,
actorId: event.actorId,
ip: event.ip,
@@ -27,7 +27,7 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
async perform(event: ReceivedEvent) {
switch (event.name) {
case "collections.remove_user": {
await new CollectionSubscriptionRemoveUserTask().schedule(event);
await CollectionSubscriptionRemoveUserTask.schedule(event);
return;
}
@@ -35,7 +35,7 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
return this.handleRemoveGroupFromCollection(event);
case "documents.remove_user": {
await new DocumentSubscriptionRemoveUserTask().schedule(event);
await DocumentSubscriptionRemoveUserTask.schedule(event);
return;
}
@@ -57,11 +57,11 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
async (groupUsers) => {
await Promise.all(
groupUsers.map((groupUser) =>
new CollectionSubscriptionRemoveUserTask().schedule({
CollectionSubscriptionRemoveUserTask.schedule({
...event,
name: "collections.remove_user",
userId: groupUser.userId,
} as CollectionUserEvent)
})
)
);
}
@@ -79,11 +79,11 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
async (groupUsers) => {
await Promise.all(
groupUsers.map((groupUser) =>
new DocumentSubscriptionRemoveUserTask().schedule({
DocumentSubscriptionRemoveUserTask.schedule({
...event,
name: "documents.remove_user",
userId: groupUser.userId,
} as DocumentUserEvent)
})
)
);
}
@@ -20,12 +20,12 @@ export default class FileOperationCreatedProcessor extends BaseProcessor {
if (fileOperation.type === FileOperationType.Import) {
switch (fileOperation.format) {
case FileOperationFormat.MarkdownZip:
await new ImportMarkdownZipTask().schedule({
await ImportMarkdownZipTask.schedule({
fileOperationId: event.modelId,
});
break;
case FileOperationFormat.JSON:
await new ImportJSONTask().schedule({
await ImportJSONTask.schedule({
fileOperationId: event.modelId,
});
break;
@@ -36,17 +36,17 @@ export default class FileOperationCreatedProcessor extends BaseProcessor {
if (fileOperation.type === FileOperationType.Export) {
switch (fileOperation.format) {
case FileOperationFormat.HTMLZip:
await new ExportHTMLZipTask().schedule({
await ExportHTMLZipTask.schedule({
fileOperationId: event.modelId,
});
break;
case FileOperationFormat.MarkdownZip:
await new ExportMarkdownZipTask().schedule({
await ExportMarkdownZipTask.schedule({
fileOperationId: event.modelId,
});
break;
case FileOperationFormat.JSON:
await new ExportJSONTask().schedule({
await ExportJSONTask.schedule({
fileOperationId: event.modelId,
});
break;
@@ -20,7 +20,7 @@ export default class IntegrationCreatedProcessor extends BaseProcessor {
}
// Store the available issue sources in the integration record.
await new CacheIssueSourcesTask().schedule({
await CacheIssueSourcesTask.schedule({
integrationId: integration.id,
});
@@ -62,25 +62,25 @@ export default class NotificationsProcessor extends BaseProcessor {
return;
}
await new DocumentPublishedNotificationsTask().schedule(event);
await DocumentPublishedNotificationsTask.schedule(event);
}
async documentAddUser(event: DocumentUserEvent) {
if (!event.data.isNew || event.userId === event.actorId) {
return;
}
await new DocumentAddUserNotificationsTask().schedule(event);
await DocumentAddUserNotificationsTask.schedule(event);
}
async documentAddGroup(event: DocumentGroupEvent) {
if (!event.data.isNew) {
return;
}
await new DocumentAddGroupNotificationsTask().schedule(event);
await DocumentAddGroupNotificationsTask.schedule(event);
}
async revisionCreated(event: RevisionEvent) {
await new RevisionCreatedNotificationsTask().schedule(event);
await RevisionCreatedNotificationsTask.schedule(event);
}
async collectionCreated(event: CollectionEvent) {
@@ -93,7 +93,7 @@ export default class NotificationsProcessor extends BaseProcessor {
return;
}
await new CollectionCreatedNotificationsTask().schedule(event);
await CollectionCreatedNotificationsTask.schedule(event);
}
async collectionAddUser(event: CollectionUserEvent) {
@@ -101,14 +101,14 @@ export default class NotificationsProcessor extends BaseProcessor {
return;
}
await new CollectionAddUserNotificationsTask().schedule(event);
await CollectionAddUserNotificationsTask.schedule(event);
}
async commentCreated(event: CommentEvent) {
await new CommentCreatedNotificationsTask().schedule(event);
await CommentCreatedNotificationsTask.schedule(event);
}
async commentUpdated(event: CommentEvent) {
await new CommentUpdatedNotificationsTask().schedule(event);
await CommentUpdatedNotificationsTask.schedule(event);
}
}
@@ -37,7 +37,7 @@ export default class RevisionsProcessor extends BaseProcessor {
return;
}
await new DocumentUpdateTextTask().schedule(event);
await DocumentUpdateTextTask.schedule(event);
const user = await User.findByPk(event.actorId, {
paranoid: false,
@@ -6,6 +6,6 @@ export default class UserDemotedProcessor extends BaseProcessor {
static applicableEvents: TEvent["name"][] = ["users.demote"];
async perform(event: UserEvent) {
await new CleanupDemotedUserTask().schedule({ userId: event.userId });
await CleanupDemotedUserTask.schedule({ userId: event.userId });
}
}
+1 -3
View File
@@ -325,9 +325,7 @@ export default abstract class APIImportTask<
([url, attachment]) => ({ attachmentId: attachment.id, url })
);
// publish task after attachments are persisted in DB.
const job = await new UploadAttachmentsForImportTask().schedule(
uploadItems
);
const job = await UploadAttachmentsForImportTask.schedule(uploadItems);
await job.finished();
} catch (err) {
// upload attachments failure is not critical enough to fail the whole import.
+1 -18
View File
@@ -21,7 +21,7 @@ export default abstract class BaseTask<T extends Record<string, any>> {
static cron: TaskSchedule | undefined;
/**
* Schedule this task type to be processed asynchronously by a worker.
* Schedule this task type to be processed asyncronously by a worker.
*
* @param props Properties to be used by the task
* @returns A promise that resolves once the job is placed on the task queue
@@ -39,23 +39,6 @@ export default abstract class BaseTask<T extends Record<string, any>> {
);
}
/**
* Schedule this task type to be processed asynchronously by a worker.
*
* @param props Properties to be used by the task
* @param options Job options such as priority and retry strategy, as defined by Bull.
* @returns A promise that resolves once the job is placed on the task queue
*/
public schedule(props: T, options?: JobOptions): Promise<Job> {
return taskQueue.add(
{
name: this.constructor.name,
props,
},
{ ...options, ...this.options }
);
}
/**
* Execute the task.
*
@@ -29,7 +29,7 @@ export default class CleanupDeletedTeamsTask extends BaseTask<Props> {
});
for (const team of teams) {
await new CleanupDeletedTeamTask().schedule({
await CleanupDeletedTeamTask.schedule({
teamId: team.id,
});
}
@@ -7,7 +7,7 @@ import BaseTask from "./BaseTask";
type Props = {
collectionId: string;
actorId: string;
ip: string | null;
ip: string;
};
export default class DetachDraftsFromCollectionTask extends BaseTask<Props> {
@@ -1,6 +1,6 @@
import { Op } from "sequelize";
import { GroupUser } from "@server/models";
import { DocumentGroupEvent, DocumentUserEvent } from "@server/types";
import { DocumentGroupEvent } from "@server/types";
import BaseTask, { TaskPriority } from "./BaseTask";
import DocumentAddUserNotificationsTask from "./DocumentAddUserNotificationsTask";
@@ -19,12 +19,11 @@ export default class DocumentAddGroupNotificationsTask extends BaseTask<Document
async (groupUsers) => {
await Promise.all(
groupUsers.map(async (groupUser) => {
await new DocumentAddUserNotificationsTask().schedule({
await DocumentAddUserNotificationsTask.schedule({
...event,
name: "documents.add_user",
modelId: event.data.membershipId,
userId: groupUser.userId,
} as DocumentUserEvent);
});
})
);
}
+1 -1
View File
@@ -12,7 +12,7 @@ type Props = {
sourceMetadata: Pick<Required<SourceMetadata>, "fileName" | "mimeType">;
publish?: boolean;
collectionId?: string;
parentDocumentId?: string | null;
parentDocumentId?: string;
ip: string;
key: string;
};
@@ -171,8 +171,7 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
/**
* Generates a map of document urls to their path in the zip file.
*
* @param collections The collections to generate the path map for.
* @param format The format of the exported documents.
* @param collections
*/
private createPathMap(
collections: Collection[],
+5 -7
View File
@@ -44,13 +44,11 @@ export default abstract class ExportTask extends BaseTask<Props> {
? [fileOperation.collectionId]
: await user.collectionIds();
const collections = await Collection.scope("withDocumentStructure").findAll(
{
where: {
id: collectionIds,
},
}
);
const collections = await Collection.findAll({
where: {
id: collectionIds,
},
});
let filePath: string | undefined;
@@ -38,7 +38,7 @@ export default class UpdateTeamsAttachmentsSizeTask extends BaseTask<Props> {
const teamIds = rows.map((row) => row.teamId);
for (const teamId of teamIds) {
await new UpdateTeamAttachmentsSizeTask().schedule({ teamId });
await UpdateTeamAttachmentsSizeTask.schedule({ teamId });
}
}
);
+1 -1
View File
@@ -166,7 +166,7 @@ router.post(
)
);
const job = await new UploadAttachmentFromUrlTask().schedule({
const job = await UploadAttachmentFromUrlTask.schedule({
attachmentId: attachment.id,
url,
});
+1 -1
View File
@@ -135,7 +135,7 @@ router.post("auth.info", auth(), async (ctx: APIContext<T.AuthInfoReq>) => {
// If the user did not _just_ sign in then we need to check if they continue
// to have access to the workspace they are signed into.
if (user.lastSignedInAt && user.lastSignedInAt < subHours(new Date(), 1)) {
await new ValidateSSOAccessTask().schedule({ userId: user.id });
await ValidateSSOAccessTask.schedule({ userId: user.id });
}
ctx.body = {
+3 -5
View File
@@ -140,11 +140,9 @@ router.post(
async (ctx: APIContext<T.CollectionsDocumentsReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.scope([
{
method: ["withMembership", user.id],
},
]).findByPk(id);
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
authorize(user, "readDocument", collection);
+3 -6
View File
@@ -29,8 +29,7 @@ const cronHandler = async (ctx: APIContext<T.CronSchemaReq>) => {
for (const name in tasks) {
const TaskClass = tasks[name];
if (TaskClass.cron === period) {
// @ts-expect-error We won't instantiate an abstract class
await new TaskClass().schedule({ limit });
await TaskClass.schedule({ limit });
// Backwards compatibility for installations that have not set up
// cron jobs periods other than daily.
@@ -39,15 +38,13 @@ const cronHandler = async (ctx: APIContext<T.CronSchemaReq>) => {
!receivedPeriods.has(TaskSchedule.Minute) &&
(period === TaskSchedule.Hour || period === TaskSchedule.Day)
) {
// @ts-expect-error We won't instantiate an abstract class
await new TaskClass().schedule({ limit });
await TaskClass.schedule({ limit });
} else if (
TaskClass.cron === TaskSchedule.Hour &&
!receivedPeriods.has(TaskSchedule.Hour) &&
period === TaskSchedule.Day
) {
// @ts-expect-error We won't instantiate an abstract class
await new TaskClass().schedule({ limit });
await TaskClass.schedule({ limit });
}
}
@@ -977,7 +977,7 @@ describe("#documents.list", () => {
const res = await server.post("/api/documents.list", {
body: {
token: user.getJwtToken(),
collectionId: document.collectionId,
collection: document.collectionId,
},
});
const body = await res.json();
@@ -1013,7 +1013,7 @@ describe("#documents.list", () => {
const res = await server.post("/api/documents.list", {
body: {
token: user.getJwtToken(),
collectionId: collection.id,
collection: collection.id,
},
});
const body = await res.json();
+20 -17
View File
@@ -133,19 +133,15 @@ router.post(
// if a specific collection is passed then we need to check auth to view it
if (collectionId) {
where[Op.and].push({ collectionId: [collectionId] });
const collection = await Collection.scope([
sort === "index" ? "withDocumentStructure" : "defaultScope",
{
method: ["withMembership", user.id],
},
]).findByPk(collectionId);
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "readDocument", collection);
// index sort is special because it uses the order of the documents in the
// collection.documentStructure rather than a database column
if (sort === "index") {
documentIds = (collection.documentStructure || [])
documentIds = (collection?.documentStructure || [])
.map((node) => node.id)
.slice(ctx.state.pagination.offset, ctx.state.pagination.limit);
where[Op.and].push({ id: documentIds });
@@ -272,7 +268,7 @@ router.post(
}
const [documents, total] = await Promise.all([
Document.withMembershipScope(user.id).findAll({
Document.defaultScopeWithUser(user.id).findAll({
where,
order: [
[
@@ -352,7 +348,7 @@ router.post(
};
}
const documents = await Document.withMembershipScope(user.id).findAll({
const documents = await Document.defaultScopeWithUser(user.id).findAll({
where,
order: [
[
@@ -401,11 +397,15 @@ router.post(
const membershipScope: Readonly<ScopeOptions> = {
method: ["withMembership", user.id],
};
const collectionScope: Readonly<ScopeOptions> = {
method: ["withCollectionPermissions", user.id],
};
const viewScope: Readonly<ScopeOptions> = {
method: ["withViews", user.id],
};
const documents = await Document.scope([
membershipScope,
collectionScope,
viewScope,
"withDrafts",
]).findAll({
@@ -539,9 +539,7 @@ router.post(
delete where.updatedAt;
}
const documents = await Document.withMembershipScope(user.id, {
includeDrafts: true,
}).findAll({
const documents = await Document.defaultScopeWithUser(user.id).findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
@@ -1541,7 +1539,7 @@ router.post(
acl,
});
const job = await new DocumentImportTask().schedule({
const job = await DocumentImportTask.schedule({
key,
sourceMetadata: {
fileName,
@@ -1551,7 +1549,6 @@ router.post(
collectionId,
parentDocumentId,
publish,
ip: ctx.request.ip,
});
const response: DocumentImportTaskResponse = await job.finished();
if ("error" in response) {
@@ -2035,7 +2032,13 @@ router.post(
const collectionIds = await user.collectionIds({
paranoid: false,
});
const documents = await Document.scope("withDrafts").findAll({
const collectionScope: Readonly<ScopeOptions> = {
method: ["withCollectionPermissions", user.id],
};
const documents = await Document.scope([
collectionScope,
"withDrafts",
]).findAll({
attributes: ["id"],
where: {
deletedAt: {
@@ -2059,7 +2062,7 @@ router.post(
});
if (documents.length) {
await new EmptyTrashTask().schedule({
await EmptyTrashTask.schedule({
documentIds: documents.map((doc) => doc.id),
});
}
@@ -24,7 +24,6 @@ router.post(
async (ctx: APIContext<T.GroupMembershipsListReq>) => {
const { groupId } = ctx.input.body;
const { user } = ctx.state.auth;
const userId = user.id;
const memberships = await GroupMembership.findAll({
where: {
@@ -45,7 +44,7 @@ router.post(
association: "groupUsers",
required: true,
where: {
userId,
userId: user.id,
},
},
],
@@ -58,9 +57,11 @@ router.post(
const documentIds = memberships
.map((p) => p.documentId)
.filter(Boolean) as string[];
const documents = await Document.withMembershipScope(userId, {
includeDrafts: true,
}).findAll({
const documents = await Document.scope([
"withDrafts",
{ method: ["withMembership", user.id] },
{ method: ["withCollectionPermissions", user.id] },
]).findAll({
where: {
id: documentIds,
},
@@ -1,19 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`oauthAuthentications.delete should require authentication 1`] = `
{
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`oauthAuthentications.list should require authentication 1`] = `
{
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
@@ -1,194 +0,0 @@
import { OAuthClient, OAuthAuthentication } from "@server/models";
import {
buildOAuthAuthentication,
buildTeam,
buildUser,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
const server = getTestServer();
describe("oauthAuthentications.list", () => {
it("should require authentication", async () => {
const res = await server.post("/api/oauthAuthentications.list");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should return list of oauth authentications for user", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const oauthClient = await OAuthClient.create({
teamId: team.id,
createdById: user.id,
name: "Test Client",
redirectUris: ["https://example.com/callback"],
});
await buildOAuthAuthentication({
oauthClientId: oauthClient.id,
user,
scope: ["read"],
});
const res = await server.post("/api/oauthAuthentications.list", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toBeDefined();
expect(body.data[0].oauthClient.name).toEqual("Test Client");
expect(body.policies).toBeDefined();
});
it("should only return authentications for requesting user", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const anotherUser = await buildUser({ teamId: team.id });
const oauthClient = await OAuthClient.create({
teamId: team.id,
createdById: user.id,
name: "Test Client",
redirectUris: ["https://example.com/callback"],
});
await buildOAuthAuthentication({
oauthClientId: oauthClient.id,
user: anotherUser,
scope: ["read"],
});
const res = await server.post("/api/oauthAuthentications.list", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
});
describe("oauthAuthentications.delete", () => {
it("should require authentication", async () => {
const res = await server.post("/api/oauthAuthentications.delete");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should delete all authentications for a client without scope", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const oauthClient = await OAuthClient.create({
teamId: team.id,
createdById: user.id,
name: "Test Client",
redirectUris: ["https://example.com/callback"],
});
await buildOAuthAuthentication({
oauthClientId: oauthClient.id,
user,
scope: ["read"],
});
const res = await server.post("/api/oauthAuthentications.delete", {
body: {
token: user.getJwtToken(),
oauthClientId: oauthClient.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toBe(true);
const auths = await OAuthAuthentication.findAll({
where: {
userId: user.id,
oauthClientId: oauthClient.id,
},
});
expect(auths.length).toEqual(0);
});
it("should delete matching authentications for a client with scope", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const oauthClient = await OAuthClient.create({
teamId: team.id,
createdById: user.id,
name: "Test Client",
redirectUris: ["https://example.com/callback"],
});
await buildOAuthAuthentication({
oauthClientId: oauthClient.id,
user,
scope: ["read"],
});
await buildOAuthAuthentication({
oauthClientId: oauthClient.id,
user,
scope: ["write"],
});
const res = await server.post("/api/oauthAuthentications.delete", {
body: {
token: user.getJwtToken(),
oauthClientId: oauthClient.id,
scope: ["read"],
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toBe(true);
const auths = await OAuthAuthentication.findAll({
where: {
userId: user.id,
oauthClientId: oauthClient.id,
},
});
expect(auths.length).toEqual(1);
expect(auths[0].scope[0]).toEqual("write");
});
it("should only delete authentications for requesting user", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const anotherUser = await buildUser({ teamId: team.id });
const oauthClient = await OAuthClient.create({
teamId: team.id,
createdById: user.id,
name: "Test Client",
redirectUris: ["https://example.com/callback"],
});
const otherAuth = await buildOAuthAuthentication({
oauthClientId: oauthClient.id,
user: anotherUser,
scope: ["read"],
});
await server.post("/api/oauthAuthentications.delete", {
body: {
token: user.getJwtToken(),
oauthClientId: oauthClient.id,
scope: "read",
},
});
// Verify other user's auth still exists
const auth = await OAuthAuthentication.findByPk(otherAuth.id);
expect(auth).not.toBeNull();
});
});
@@ -66,12 +66,12 @@ router.post(
transaction(),
async (ctx: APIContext<T.OAuthAuthenticationsDeleteReq>) => {
const { user } = ctx.state.auth;
const { oauthClientId, scope } = ctx.input.body;
const { oauthClientId, scope } = ctx.request.body;
const oauthAuthentications = await OAuthAuthentication.findAll({
where: {
userId: user.id,
oauthClientId,
...(scope ? { scope } : {}),
scope,
},
transaction: ctx.state.transaction,
});
@@ -148,46 +148,6 @@ describe("oauthClients.info", () => {
expect(body.data.id).toBeUndefined();
expect(body.data.redirectUris).toBeUndefined();
});
it("should validate redirectUri parameter", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const user = await buildUser();
const client = await OAuthClient.create({
teamId: team.id,
createdById: admin.id,
name: "Test Client",
redirectUris: [
"https://example.com/callback",
"https://another.com/callback",
],
published: true,
});
// Test with valid redirectUri
const validRes = await server.post("/api/oauthClients.info", {
body: {
token: user.getJwtToken(),
clientId: client.clientId,
redirectUri: "https://example.com/callback",
},
});
const validBody = await validRes.json();
expect(validRes.status).toEqual(200);
expect(validBody.data.name).toEqual("Test Client");
// Test with invalid redirectUri
const invalidRes = await server.post("/api/oauthClients.info", {
body: {
token: user.getJwtToken(),
clientId: client.clientId,
redirectUri: "https://malicious.com/callback",
},
});
expect(invalidRes.status).toEqual(400);
});
});
describe("oauthClients.create", () => {
@@ -1,6 +1,5 @@
import Router from "koa-router";
import { UserRole } from "@shared/types";
import { ValidationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
@@ -50,7 +49,7 @@ router.post(
auth(),
validate(T.OAuthClientsInfoSchema),
async (ctx: APIContext<T.OAuthClientsInfoReq>) => {
const { id, clientId, redirectUri } = ctx.input.body;
const { id, clientId } = ctx.input.body;
const { user } = ctx.state.auth;
const oauthClient = await OAuthClient.findOne({
@@ -59,10 +58,6 @@ router.post(
});
authorize(user, "read", oauthClient);
if (redirectUri && !oauthClient.redirectUris.includes(redirectUri)) {
throw ValidationError("redirect_uri is invalid");
}
const isInternalApp = oauthClient.teamId === user.teamId;
ctx.body = {
-2
View File
@@ -10,8 +10,6 @@ export const OAuthClientsInfoSchema = BaseSchema.extend({
/** OAuth clientId */
clientId: z.string().optional(),
redirectUri: z.string().optional(),
})
.refine((data) => data.id || data.clientId, {
message: "Either id or clientId is required",
+1 -1
View File
@@ -113,7 +113,7 @@ router.post(
user.collectionIds(),
]);
const documents = await Document.withMembershipScope(user.id).findAll({
const documents = await Document.defaultScopeWithUser(user.id).findAll({
where: {
id: pins.map((pin) => pin.documentId),
collectionId: collectionIds,
+1 -5
View File
@@ -54,11 +54,7 @@ router.post(
});
authorize(user, "read", document);
const collection = document.collectionId
? await Collection.scope("withDocumentStructure").findByPk(
document.collectionId
)
: undefined;
const collection = await document.$get("collection");
const parentIds = collection?.getDocumentParents(documentId);
const parentShare = parentIds
? await Share.scope({
+1 -1
View File
@@ -94,7 +94,7 @@ router.post(
.map((star) => star.documentId)
.filter(Boolean) as string[];
const documents = documentIds.length
? await Document.withMembershipScope(user.id).findAll({
? await Document.defaultScopeWithUser(user.id).findAll({
where: {
id: documentIds,
collectionId: collectionIds,

Some files were not shown because too many files have changed in this diff Show More