mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c37bed2572 | |||
| df34cf2b76 | |||
| 820c5ea65e | |||
| 234e2d84ed | |||
| 4389ac0d1d | |||
| f60f5fd66d | |||
| bee61ce1ef | |||
| ba247c5ba8 | |||
| 2504b051ec | |||
| 20f5e953b7 | |||
| 5ce17b799b | |||
| c2968a671c | |||
| 98959dc330 | |||
| 7fc305b5d5 | |||
| 1b90ab85e7 | |||
| 4faf1b8570 | |||
| c61bc5dedb | |||
| 3afde6962b | |||
| cf7c97e9d6 | |||
| 6298d7b31b | |||
| 99ec9c1627 | |||
| fdc53b91f8 | |||
| eb3f74cf21 | |||
| 8656c21e14 | |||
| 24fd606a86 | |||
| fe9a548490 | |||
| f71afc2bf5 | |||
| 9e7dd5b4f7 | |||
| 021e431195 | |||
| 2136be9327 | |||
| 581502f7e2 | |||
| 75447cd782 | |||
| fee3e7d0c3 | |||
| aed55c7cfd | |||
| 180d17e173 | |||
| aa60f5ccea | |||
| ea79883e04 | |||
| e9afc1d91f | |||
| ed47b9eda0 | |||
| 5a19182757 | |||
| 51b3971d21 | |||
| 057a1bbc7f | |||
| 1289f5f3be | |||
| 429de07820 | |||
| 68489973e0 | |||
| 4dbd5b3617 | |||
| f9fe1cc308 | |||
| fe03ba8710 | |||
| d13770ddf9 | |||
| b7425fefc6 | |||
| 79df4b030b | |||
| 24eaeca47e | |||
| db0deb6997 | |||
| 9f21e57335 | |||
| fa6b83382b | |||
| b86475360f | |||
| 9b179a2612 | |||
| 7afe69e22a | |||
| 2ac19e3938 | |||
| a25968d4a7 | |||
| 87b8e5daeb | |||
| 94a862ce01 |
@@ -11,7 +11,7 @@ import { ActionContext } from "~/types";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { TeamSection } from "../sections";
|
||||
|
||||
export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
export const createTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
stores.auth.availableTeams?.map((session) => ({
|
||||
id: `switch-${session.id}`,
|
||||
name: session.name,
|
||||
@@ -44,7 +44,7 @@ export const switchTeam = createAction({
|
||||
section: TeamSection,
|
||||
visible: ({ stores }) =>
|
||||
!!stores.auth.availableTeams && stores.auth.availableTeams?.length > 1,
|
||||
children: switchTeamsList,
|
||||
children: createTeamsList,
|
||||
});
|
||||
|
||||
export const createTeam = createAction({
|
||||
|
||||
@@ -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";
|
||||
@@ -19,7 +18,6 @@ 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 = React.lazy(() => import("~/components/IconPicker"));
|
||||
@@ -32,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,
|
||||
@@ -64,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 {
|
||||
|
||||
+4
-26
@@ -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);
|
||||
@@ -195,27 +195,21 @@ const PaginatedList = <T extends PaginatedItem>({
|
||||
}
|
||||
}, [allowLoadMore, isFetching, items?.length, renderCount, fetchResults]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchResults();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const prevFetch = usePrevious(fetch);
|
||||
const prevOptions = usePrevious(options);
|
||||
|
||||
// Initial fetch on mount
|
||||
// Equivalent to componentDidUpdate
|
||||
React.useEffect(() => {
|
||||
if (fetch) {
|
||||
void fetchResults();
|
||||
}
|
||||
}, [fetch]);
|
||||
|
||||
// Handle updates to fetch or options
|
||||
React.useEffect(() => {
|
||||
if (!prevFetch || !prevOptions) {
|
||||
return; // Skip on initial mount since it's handled by the above effect
|
||||
}
|
||||
|
||||
if (prevFetch !== fetch || !isEqual(prevOptions, options)) {
|
||||
reset();
|
||||
void fetchResults();
|
||||
}
|
||||
}, [fetch, options, reset, fetchResults, prevFetch, prevOptions]);
|
||||
}, [fetch, options, reset, prevFetch, prevOptions, fetchResults]);
|
||||
|
||||
// Computed property equivalent
|
||||
const itemsToRender = React.useMemo(
|
||||
|
||||
@@ -12,7 +12,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import TeamMenu from "~/menus/TeamMenu";
|
||||
import OrganizationMenu from "~/menus/OrganizationMenu";
|
||||
import { homePath, searchPath } from "~/utils/routeHelpers";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Tooltip from "../Tooltip";
|
||||
@@ -62,7 +62,7 @@ function AppSidebar() {
|
||||
<DndProvider backend={HTML5Backend} options={html5Options}>
|
||||
<DragPlaceholder />
|
||||
|
||||
<TeamMenu>
|
||||
<OrganizationMenu>
|
||||
{(props: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{...props}
|
||||
@@ -91,7 +91,7 @@ function AppSidebar() {
|
||||
</Tooltip>
|
||||
</SidebarButton>
|
||||
)}
|
||||
</TeamMenu>
|
||||
</OrganizationMenu>
|
||||
<Overflow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 })
|
||||
);
|
||||
|
||||
@@ -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\)$/, "©️");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { getCookie } from "tiny-cookie";
|
||||
|
||||
export type Sessions = Record<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
logoUrl: string;
|
||||
url: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export function useLoggedInSessions(): Sessions {
|
||||
return JSON.parse(getCookie("sessions") || "{}");
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export default function useRequest<T = unknown>(
|
||||
if (makeRequestOnMount) {
|
||||
void request();
|
||||
}
|
||||
}, []);
|
||||
}, [request, makeRequestOnMount]);
|
||||
|
||||
return { data, loading, loaded, error, request };
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "~/actions/definitions/navigation";
|
||||
import {
|
||||
createTeam,
|
||||
switchTeamsList,
|
||||
createTeamsList,
|
||||
desktopLoginTeam,
|
||||
} from "~/actions/definitions/teams";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
@@ -22,7 +22,7 @@ type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const TeamMenu: React.FC = ({ children }: Props) => {
|
||||
const OrganizationMenu: React.FC = ({ children }: Props) => {
|
||||
const menu = useMenuState({
|
||||
unstable_offset: [4, -4],
|
||||
placement: "bottom-start",
|
||||
@@ -44,7 +44,7 @@ const TeamMenu: React.FC = ({ children }: Props) => {
|
||||
// menu is not cached at all.
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
...switchTeamsList(context),
|
||||
...createTeamsList(context),
|
||||
createTeam,
|
||||
desktopLoginTeam,
|
||||
separator(),
|
||||
@@ -64,4 +64,4 @@ const TeamMenu: React.FC = ({ children }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(TeamMenu);
|
||||
export default observer(OrganizationMenu);
|
||||
@@ -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
@@ -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" })
|
||||
|
||||
@@ -19,7 +19,7 @@ class OAuthAuthentication extends ParanoidModel {
|
||||
|
||||
userId: string;
|
||||
|
||||
oauthClient: Pick<OAuthClient, "id" | "name" | "clientId" | "avatarUrl">;
|
||||
oauthClient: Pick<OAuthClient, "id" | "name" | "clientId">;
|
||||
|
||||
oauthClientId: string;
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ const Drafts = lazy(() => import("~/scenes/Drafts"));
|
||||
const Home = lazy(() => import("~/scenes/Home"));
|
||||
const Search = lazy(() => import("~/scenes/Search"));
|
||||
const Trash = lazy(() => import("~/scenes/Trash"));
|
||||
const OAuthAuthorize = lazy(() => import("~/scenes/Login/OAuthAuthorize"));
|
||||
|
||||
const RedirectDocument = ({
|
||||
match,
|
||||
@@ -52,6 +53,8 @@ function AuthenticatedRoutes() {
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route exact path="/oauth/authorize" component={OAuthAuthorize} />
|
||||
|
||||
<WebsocketProvider>
|
||||
<AuthenticatedLayout>
|
||||
<React.Suspense
|
||||
|
||||
@@ -6,15 +6,14 @@ import FullscreenLoading from "~/components/FullscreenLoading";
|
||||
import Route from "~/components/ProfiledRoute";
|
||||
import env from "~/env";
|
||||
import useQueryNotices from "~/hooks/useQueryNotices";
|
||||
import lazy from "~/utils/lazyWithRetry";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
|
||||
|
||||
const Authenticated = lazy(() => import("~/components/Authenticated"));
|
||||
const AuthenticatedRoutes = lazy(() => import("./authenticated"));
|
||||
const SharedDocument = lazy(() => import("~/scenes/Document/Shared"));
|
||||
const Login = lazy(() => import("~/scenes/Login"));
|
||||
const Logout = lazy(() => import("~/scenes/Logout"));
|
||||
const OAuthAuthorize = lazy(() => import("~/scenes/Login/OAuthAuthorize"));
|
||||
const Authenticated = lazyWithRetry(() => import("~/components/Authenticated"));
|
||||
const AuthenticatedRoutes = lazyWithRetry(() => import("./authenticated"));
|
||||
const SharedDocument = lazyWithRetry(() => import("~/scenes/Document/Shared"));
|
||||
const Login = lazyWithRetry(() => import("~/scenes/Login"));
|
||||
const Logout = lazyWithRetry(() => import("~/scenes/Logout"));
|
||||
|
||||
export default function Routes() {
|
||||
useQueryNotices();
|
||||
@@ -44,7 +43,6 @@ export default function Routes() {
|
||||
<Route exact path="/create" component={Login} />
|
||||
<Route exact path="/logout" component={Logout} />
|
||||
<Route exact path="/desktop-redirect" component={DesktopRedirect} />
|
||||
<Route exact path="/oauth/authorize" component={OAuthAuthorize} />
|
||||
|
||||
<Redirect exact from="/share/:shareId" to="/s/:shareId" />
|
||||
<Route exact path="/s/:shareId" component={SharedDocument} />
|
||||
|
||||
@@ -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)
|
||||
: [];
|
||||
|
||||
@@ -42,10 +42,9 @@ import { getRedirectUrl, navigateToSubdomain } from "./urls";
|
||||
|
||||
type Props = {
|
||||
children?: (config?: Config) => React.ReactNode;
|
||||
onBack?: () => void;
|
||||
};
|
||||
|
||||
function Login({ children, onBack }: Props) {
|
||||
function Login({ children }: Props) {
|
||||
const location = useLocation();
|
||||
const query = useQuery();
|
||||
const notice = query.get("notice");
|
||||
@@ -111,7 +110,7 @@ function Login({ children, onBack }: Props) {
|
||||
if (error) {
|
||||
return (
|
||||
<Background>
|
||||
<BackButton onBack={onBack} />
|
||||
<BackButton />
|
||||
<ChangeLanguage locale={detectLanguage()} />
|
||||
<Centered>
|
||||
<PageTitle title={t("Login")} />
|
||||
@@ -143,7 +142,7 @@ function Login({ children, onBack }: Props) {
|
||||
if (isCloudHosted && isCustomDomain && !config.name) {
|
||||
return (
|
||||
<Background>
|
||||
<BackButton onBack={onBack} config={config} />
|
||||
<BackButton config={config} />
|
||||
<ChangeLanguage locale={detectLanguage()} />
|
||||
<Centered>
|
||||
<PageTitle title={t("Custom domain setup")} />
|
||||
@@ -161,7 +160,7 @@ function Login({ children, onBack }: Props) {
|
||||
if (Desktop.isElectron() && notice === "domain-required") {
|
||||
return (
|
||||
<Background>
|
||||
<BackButton onBack={onBack} config={config} />
|
||||
<BackButton config={config} />
|
||||
<ChangeLanguage locale={detectLanguage()} />
|
||||
|
||||
<Centered as="form" onSubmit={handleGoSubdomain}>
|
||||
@@ -200,7 +199,7 @@ function Login({ children, onBack }: Props) {
|
||||
if (emailLinkSentTo) {
|
||||
return (
|
||||
<Background>
|
||||
<BackButton onBack={onBack} config={config} />
|
||||
<BackButton config={config} />
|
||||
<Centered>
|
||||
<PageTitle title={t("Check your email")} />
|
||||
<CheckEmailIcon size={38} />
|
||||
@@ -235,7 +234,7 @@ function Login({ children, onBack }: Props) {
|
||||
|
||||
return (
|
||||
<Background>
|
||||
<BackButton onBack={onBack} config={config} />
|
||||
<BackButton config={config} />
|
||||
<ChangeLanguage locale={detectLanguage()} />
|
||||
|
||||
<Centered gap={12}>
|
||||
|
||||
@@ -1,51 +1,23 @@
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
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 { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import { AvatarVariant } from "~/components/Avatar/Avatar";
|
||||
import ButtonLarge from "~/components/ButtonLarge";
|
||||
import ChangeLanguage from "~/components/ChangeLanguage";
|
||||
import Heading from "~/components/Heading";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
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";
|
||||
import { OAuthScopeHelper } from "./OAuthScopeHelper";
|
||||
import { Background } from "./components/Background";
|
||||
import { Centered } from "./components/Centered";
|
||||
import { ConnectHeader } from "./components/ConnectHeader";
|
||||
import { TeamSwitcher } from "./components/TeamSwitcher";
|
||||
|
||||
export default function OAuthAuthorize() {
|
||||
const team = useCurrentTeam({ rejectOnEmpty: false });
|
||||
const sessions = useLoggedInSessions();
|
||||
|
||||
// We're self-hosted or on a team subdomain already, just show the authorize screen.
|
||||
if (team) {
|
||||
return <Authorize />;
|
||||
}
|
||||
|
||||
// Cloud hosted and on root domain – show the workspace switcher.
|
||||
const isAppRoot =
|
||||
parseDomain(window.location.hostname).host === parseDomain(env.URL).host;
|
||||
const hasLoggedInSessions = Object.keys(sessions).length > 0;
|
||||
if (isCloudHosted && hasLoggedInSessions && isAppRoot) {
|
||||
return <TeamSwitcher sessions={sessions} />;
|
||||
}
|
||||
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize component is responsible for handling the OAuth authorization process.
|
||||
@@ -68,17 +40,19 @@ 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,
|
||||
request,
|
||||
} = useRequest(() => client.post("/oauthClients.info", { clientId }));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (clientId) {
|
||||
void request();
|
||||
}
|
||||
}, []);
|
||||
|
||||
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 +77,6 @@ function Authorize() {
|
||||
!redirectUri && "redirect_uri",
|
||||
!responseType && "response_type",
|
||||
!scope && "scope",
|
||||
!state && "state",
|
||||
].filter(Boolean);
|
||||
|
||||
if (missingParams.length || clientError) {
|
||||
@@ -111,20 +84,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")}
|
||||
@@ -151,10 +117,30 @@ function Authorize() {
|
||||
|
||||
return (
|
||||
<Background>
|
||||
<ChangeLanguage locale={detectLanguage()} />
|
||||
<PageTitle title={t("Authorize")} />
|
||||
<Centered gap={12}>
|
||||
<ConnectHeader team={team} oauthClient={response.data} />
|
||||
<Text type="tertiary">
|
||||
<Flex gap={12} align="center">
|
||||
<Avatar
|
||||
variant={AvatarVariant.Square}
|
||||
model={{
|
||||
avatarUrl: response.data.avatarUrl,
|
||||
initial: response.data.name[0],
|
||||
}}
|
||||
size={AvatarSize.XXLarge}
|
||||
alt={response.data.name}
|
||||
/>
|
||||
|
||||
<MoreIcon />
|
||||
|
||||
<Avatar
|
||||
variant={AvatarVariant.Square}
|
||||
model={team}
|
||||
size={AvatarSize.XXLarge}
|
||||
alt={team.name}
|
||||
/>
|
||||
</Flex>
|
||||
</Text>
|
||||
<StyledHeading>
|
||||
{t(`{{ appName }} wants to access {{ teamName }}`, {
|
||||
appName: name,
|
||||
@@ -258,3 +244,5 @@ const Pre = styled.pre`
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
|
||||
export default Authorize;
|
||||
|
||||
@@ -10,21 +10,12 @@ import isCloudHosted from "~/utils/isCloudHosted";
|
||||
|
||||
type Props = {
|
||||
config?: Config;
|
||||
onBack?: () => void;
|
||||
};
|
||||
|
||||
export function BackButton({ onBack, config }: Props) {
|
||||
export function BackButton({ config }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const isSubdomain = !!config?.hostname;
|
||||
|
||||
if (onBack) {
|
||||
return (
|
||||
<Link onClick={onBack}>
|
||||
<BackIcon /> {t("Back")}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isCloudHosted || parseDomain(window.location.origin).custom) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import Text from "@shared/components/Text";
|
||||
import type Team from "~/models/Team";
|
||||
import type OAuthClient from "~/models/oauth/OAuthClient";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { AvatarSize, AvatarVariant } from "~/components/Avatar/Avatar";
|
||||
|
||||
type Props = {
|
||||
team: Team;
|
||||
oauthClient: OAuthClient;
|
||||
};
|
||||
|
||||
export function ConnectHeader({ team, oauthClient }: Props) {
|
||||
return (
|
||||
<Text type="tertiary">
|
||||
<Flex gap={12} align="center">
|
||||
<Avatar
|
||||
variant={AvatarVariant.Square}
|
||||
model={{
|
||||
avatarUrl: oauthClient.avatarUrl,
|
||||
initial: oauthClient.name[0],
|
||||
}}
|
||||
size={AvatarSize.XXLarge}
|
||||
alt={oauthClient.name}
|
||||
/>
|
||||
|
||||
<MoreIcon />
|
||||
|
||||
<Avatar
|
||||
variant={AvatarVariant.Square}
|
||||
model={team}
|
||||
size={AvatarSize.XXLarge}
|
||||
alt={team.name}
|
||||
/>
|
||||
</Flex>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { ArrowIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Text from "@shared/components/Text";
|
||||
import { s } from "@shared/styles";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import Avatar, { AvatarVariant } from "~/components/Avatar/Avatar";
|
||||
import ChangeLanguage from "~/components/ChangeLanguage";
|
||||
import Heading from "~/components/Heading";
|
||||
import OutlineIcon from "~/components/Icons/OutlineIcon";
|
||||
import env from "~/env";
|
||||
import type { Sessions } from "~/hooks/useLoggedInSessions";
|
||||
import { detectLanguage } from "~/utils/language";
|
||||
import Login from "../Login";
|
||||
import { Background } from "./Background";
|
||||
import { Centered } from "./Centered";
|
||||
|
||||
type Props = { sessions: Sessions };
|
||||
|
||||
export function TeamSwitcher({ sessions }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [showLogin, setShowLogin] = React.useState(false);
|
||||
const url = new URL(window.location.href);
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
if (showLogin) {
|
||||
return <Login onBack={() => setShowLogin(false)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Background>
|
||||
<ChangeLanguage locale={detectLanguage()} />
|
||||
<Centered>
|
||||
<OutlineIcon size={AvatarSize.XXLarge} />
|
||||
|
||||
<StyledHeading>{t("Choose a workspace")}</StyledHeading>
|
||||
<Text type="tertiary" as="p">
|
||||
{t(
|
||||
"Choose an {{ appName }} workspace or login to continue connecting this app",
|
||||
{ appName }
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
{Object.keys(sessions)?.map((teamId) => {
|
||||
const session = sessions[teamId];
|
||||
const location = session.url + url.pathname + url.search;
|
||||
return (
|
||||
<TeamLink href={location} key={session.url}>
|
||||
<Avatar
|
||||
variant={AvatarVariant.Square}
|
||||
model={{
|
||||
avatarUrl: session.logoUrl,
|
||||
initial: session.name[0],
|
||||
}}
|
||||
size={AvatarSize.Large}
|
||||
alt={session.name}
|
||||
/>
|
||||
{session.name}
|
||||
<StyledArrowIcon />
|
||||
</TeamLink>
|
||||
);
|
||||
})}
|
||||
<TeamLink onClick={() => setShowLogin(true)}>
|
||||
<ArrowIcon size={AvatarSize.Large} />
|
||||
{t("Login to workspace")}
|
||||
</TeamLink>
|
||||
</Centered>
|
||||
</Background>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledArrowIcon = styled(ArrowIcon)`
|
||||
position: absolute;
|
||||
transition: all 0.2s ease-in-out;
|
||||
opacity: 0;
|
||||
right: 12px;
|
||||
`;
|
||||
|
||||
const TeamLink = styled.a`
|
||||
position: relative;
|
||||
left: -8px;
|
||||
right: -8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
margin: 4px;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
color: ${s("text")};
|
||||
font-weight: ${s("fontWeightMedium")};
|
||||
|
||||
&:hover {
|
||||
background: ${s("listItemHoverBackground")};
|
||||
|
||||
${StyledArrowIcon} {
|
||||
opacity: 1;
|
||||
right: 8px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledHeading = styled(Heading).attrs({
|
||||
as: "h2",
|
||||
centered: true,
|
||||
})`
|
||||
margin-top: 0;
|
||||
`;
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -300,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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ export default class RootStore {
|
||||
private getStoreNameForModelName(modelName: string) {
|
||||
for (const key of Object.keys(this)) {
|
||||
const store = this[key as keyof RootStore];
|
||||
if (store && "modelName" in store && store.modelName === modelName) {
|
||||
if ("modelName" in store && store.modelName === modelName) {
|
||||
return key as keyof RootStore;
|
||||
}
|
||||
}
|
||||
|
||||
+21
-22
@@ -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",
|
||||
@@ -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",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import Router from "koa-router";
|
||||
import find from "lodash/find";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import { createContext } from "@server/context";
|
||||
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { IntegrationAuthentication, Integration } from "@server/models";
|
||||
import { IntegrationAuthentication, Integration, Team } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
import { GitHubUtils } from "../../shared/GitHubUtils";
|
||||
import { GitHub } from "../github";
|
||||
@@ -16,17 +17,10 @@ const router = new Router();
|
||||
|
||||
router.get(
|
||||
"github.callback",
|
||||
auth({ optional: true }),
|
||||
validate(T.GitHubCallbackSchema),
|
||||
apexAuthRedirect<T.GitHubCallbackReq>({
|
||||
getTeamId: (ctx) => ctx.input.query.state,
|
||||
getRedirectPath: (ctx, team) =>
|
||||
GitHubUtils.callbackUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
}),
|
||||
getErrorPath: () => GitHubUtils.errorUrl("unauthenticated"),
|
||||
auth({
|
||||
optional: true,
|
||||
}),
|
||||
validate(T.GitHubCallbackSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.GitHubCallbackReq>) => {
|
||||
const {
|
||||
@@ -49,6 +43,33 @@ router.get(
|
||||
return;
|
||||
}
|
||||
|
||||
// this code block accounts for the root domain being unable to
|
||||
// access authentication for subdomains. We must forward to the appropriate
|
||||
// subdomain to complete the oauth flow
|
||||
if (!user) {
|
||||
if (teamId) {
|
||||
try {
|
||||
const team = await Team.findByPk(teamId, {
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
});
|
||||
return parseDomain(ctx.host).teamSubdomain === team.subdomain
|
||||
? ctx.redirect("/")
|
||||
: ctx.redirectOnClient(
|
||||
GitHubUtils.callbackUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
Logger.error(`Error fetching team for teamId: ${teamId}!`, err);
|
||||
return ctx.redirect(GitHubUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
} else {
|
||||
return ctx.redirect(GitHubUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
}
|
||||
|
||||
const client = await GitHub.authenticateAsUser(code!, teamId);
|
||||
const installationsByUser = await client.requestAppInstallations();
|
||||
const installation = find(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import Router from "koa-router";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { IntegrationAuthentication, Integration } from "@server/models";
|
||||
import { IntegrationAuthentication, Integration, Team } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
import { Linear } from "../linear";
|
||||
import UploadLinearWorkspaceLogoTask from "../tasks/UploadLinearWorkspaceLogoTask";
|
||||
@@ -19,21 +20,49 @@ router.get(
|
||||
optional: true,
|
||||
}),
|
||||
validate(T.LinearCallbackSchema),
|
||||
apexAuthRedirect<T.LinearCallbackReq>({
|
||||
getTeamId: (ctx) => LinearUtils.parseState(ctx.input.query.state)?.teamId,
|
||||
getRedirectPath: (ctx, team) =>
|
||||
LinearUtils.callbackUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
}),
|
||||
getErrorPath: () => LinearUtils.errorUrl("unauthenticated"),
|
||||
}),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.LinearCallbackReq>) => {
|
||||
const { code, error } = ctx.input.query;
|
||||
const { code, state, error } = ctx.input.query;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
let parsedState;
|
||||
try {
|
||||
parsedState = LinearUtils.parseState(state);
|
||||
} catch {
|
||||
ctx.redirect(LinearUtils.errorUrl("invalid_state"));
|
||||
return;
|
||||
}
|
||||
|
||||
const { teamId } = parsedState;
|
||||
|
||||
// this code block accounts for the root domain being unable to
|
||||
// access authentication for subdomains. We must forward to the appropriate
|
||||
// subdomain to complete the oauth flow
|
||||
if (!user) {
|
||||
if (teamId) {
|
||||
try {
|
||||
const team = await Team.findByPk(teamId, {
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
});
|
||||
return parseDomain(ctx.host).teamSubdomain === team.subdomain
|
||||
? ctx.redirect("/")
|
||||
: ctx.redirectOnClient(
|
||||
LinearUtils.callbackUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
Logger.error(`Error fetching team for teamId: ${teamId}!`, err);
|
||||
return ctx.redirect(LinearUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
} else {
|
||||
return ctx.redirect(LinearUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
|
||||
if (error) {
|
||||
ctx.redirect(LinearUtils.errorUrl(error));
|
||||
@@ -78,12 +107,10 @@ router.get(
|
||||
);
|
||||
|
||||
transaction.afterCommit(async () => {
|
||||
if (workspace.logoUrl) {
|
||||
await new UploadLinearWorkspaceLogoTask().schedule({
|
||||
integrationId: integration.id,
|
||||
logoUrl: workspace.logoUrl,
|
||||
});
|
||||
}
|
||||
await UploadLinearWorkspaceLogoTask.schedule({
|
||||
integrationId: integration.id,
|
||||
logoUrl: workspace.logoUrl,
|
||||
});
|
||||
});
|
||||
|
||||
ctx.redirect(LinearUtils.successUrl());
|
||||
|
||||
@@ -46,7 +46,7 @@ export class LinearUtils {
|
||||
scope: this.oauthScopes,
|
||||
response_type: "code",
|
||||
prompt: "consent",
|
||||
actor: "app",
|
||||
actor: "application",
|
||||
};
|
||||
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import Router from "koa-router";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Integration, IntegrationAuthentication } from "@server/models";
|
||||
import { Integration, IntegrationAuthentication, Team } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
import { NotionClient } from "../notion";
|
||||
import * as T from "./schema";
|
||||
@@ -16,21 +17,49 @@ router.get(
|
||||
"notion.callback",
|
||||
auth({ optional: true }),
|
||||
validate(T.NotionCallbackSchema),
|
||||
apexAuthRedirect<T.NotionCallbackReq>({
|
||||
getTeamId: (ctx) => NotionUtils.parseState(ctx.input.query.state)?.teamId,
|
||||
getRedirectPath: (ctx, team) =>
|
||||
NotionUtils.callbackUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
}),
|
||||
getErrorPath: () => NotionUtils.errorUrl("unauthenticated"),
|
||||
}),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.NotionCallbackReq>) => {
|
||||
const { code, error } = ctx.input.query;
|
||||
const { code, state, error } = ctx.input.query;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
let parsedState;
|
||||
try {
|
||||
parsedState = NotionUtils.parseState(state);
|
||||
} catch {
|
||||
ctx.redirect(NotionUtils.errorUrl("invalid_state"));
|
||||
return;
|
||||
}
|
||||
|
||||
const { teamId } = parsedState;
|
||||
|
||||
// This code block accounts for the root domain being unable to access authentication for subdomains.
|
||||
// We must forward to the appropriate subdomain to complete the oauth flow.
|
||||
if (!user) {
|
||||
if (teamId) {
|
||||
try {
|
||||
const team = await Team.findByPk(teamId, {
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
});
|
||||
|
||||
return parseDomain(ctx.host).teamSubdomain === team.subdomain
|
||||
? ctx.redirect("/")
|
||||
: ctx.redirectOnClient(
|
||||
NotionUtils.callbackUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
Logger.error(`Error fetching team for teamId: ${teamId}!`, err);
|
||||
return ctx.redirect(NotionUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
} else {
|
||||
return ctx.redirect(NotionUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
|
||||
if (error) {
|
||||
ctx.redirect(NotionUtils.errorUrl(error));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -227,6 +227,9 @@ router.post(
|
||||
const options = {
|
||||
query: text,
|
||||
limit: 5,
|
||||
searchConfig: {
|
||||
boostRecent: true,
|
||||
},
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -4,15 +4,16 @@ import Router from "koa-router";
|
||||
import { Profile } from "passport";
|
||||
import { Strategy as SlackStrategy } from "passport-slack-oauth2";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import accountProvisioner from "@server/commands/accountProvisioner";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import passportMiddleware from "@server/middlewares/passport";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import {
|
||||
IntegrationAuthentication,
|
||||
Integration,
|
||||
Team,
|
||||
User,
|
||||
Collection,
|
||||
} from "@server/models";
|
||||
@@ -125,15 +126,6 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
||||
"slack.post",
|
||||
auth({ optional: true }),
|
||||
validate(T.SlackPostSchema),
|
||||
apexAuthRedirect<T.SlackPostReq>({
|
||||
getTeamId: (ctx) => SlackUtils.parseState(ctx.input.query.state)?.teamId,
|
||||
getRedirectPath: (ctx, team) =>
|
||||
SlackUtils.connectUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
}),
|
||||
getErrorPath: () => SlackUtils.errorUrl("unauthenticated"),
|
||||
}),
|
||||
async (ctx: APIContext<T.SlackPostReq>) => {
|
||||
const { code, error, state } = ctx.input.query;
|
||||
const { user } = ctx.state.auth;
|
||||
@@ -152,7 +144,31 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
||||
throw ValidationError("Invalid state");
|
||||
}
|
||||
|
||||
const { collectionId, type } = parsedState;
|
||||
const { teamId, collectionId, type } = parsedState;
|
||||
|
||||
// This code block accounts for the root domain being unable to access authentication for
|
||||
// subdomains. We must forward to the appropriate subdomain to complete the OAuth flow.
|
||||
if (!user) {
|
||||
if (teamId) {
|
||||
try {
|
||||
const team = await Team.findByPk(teamId, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
return parseDomain(ctx.host).teamSubdomain === team.subdomain
|
||||
? ctx.redirect("/")
|
||||
: ctx.redirectOnClient(
|
||||
SlackUtils.connectUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
return ctx.redirect(SlackUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
} else {
|
||||
return ctx.redirect(SlackUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case IntegrationType.Post: {
|
||||
|
||||
@@ -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 })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -237,11 +237,6 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
|
||||
case "imports.delete":
|
||||
// Ignored
|
||||
return;
|
||||
case "oauthClients.create":
|
||||
case "oauthClients.update":
|
||||
case "oauthClients.delete":
|
||||
// Ignored
|
||||
return;
|
||||
default:
|
||||
assertUnreachable(event);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -56,5 +56,5 @@ export default async function userSuspender({
|
||||
}
|
||||
);
|
||||
|
||||
await new CleanupDemotedUserTask().schedule({ userId: user.id });
|
||||
await CleanupDemotedUserTask.schedule({ userId: user.id });
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Next } from "koa";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import { Team } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
/**
|
||||
* An authentication middleware that should be used on routes that return from external auth flows
|
||||
* to the apex domain. In these cases the user will be redirected to the correct subdomain where
|
||||
* they are authenticated.
|
||||
*
|
||||
* @param options Options for the middleware
|
||||
* @returns Koa middleware function
|
||||
*/
|
||||
export default function apexAuthRedirect<T>({
|
||||
getTeamId,
|
||||
getRedirectPath,
|
||||
getErrorPath,
|
||||
}: {
|
||||
/** Get the team ID for the current request */
|
||||
getTeamId: (ctx: APIContext<T>) => string | null | undefined;
|
||||
/** Get the redirect URL for the given team ID */
|
||||
getRedirectPath: (ctx: APIContext<T>, team: Team) => string;
|
||||
/** Get the error URL for the current request */
|
||||
getErrorPath: (ctx: APIContext<T>) => string;
|
||||
}) {
|
||||
return async function apexAuthRedirectMiddleware(
|
||||
ctx: APIContext<T>,
|
||||
next: Next
|
||||
) {
|
||||
const { user } = ctx.state.auth;
|
||||
if (user) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const teamId = getTeamId(ctx);
|
||||
|
||||
if (teamId) {
|
||||
try {
|
||||
const team = await Team.findByPk(teamId, {
|
||||
attributes: ["id", "subdomain"],
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
return parseDomain(ctx.host).teamSubdomain === team.subdomain
|
||||
? ctx.redirect("/")
|
||||
: ctx.redirectOnClient(getRedirectPath(ctx, team));
|
||||
} catch (err) {
|
||||
return ctx.redirect(getErrorPath(ctx));
|
||||
}
|
||||
} else {
|
||||
return ctx.redirect(getErrorPath(ctx));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
};
|
||||
@@ -160,7 +160,7 @@ module.exports = {
|
||||
},
|
||||
lastActiveAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
allowNull: false
|
||||
},
|
||||
scope: {
|
||||
type: Sequelize.ARRAY(Sequelize.STRING),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -408,7 +408,7 @@ class Team extends ParanoidModel<
|
||||
});
|
||||
|
||||
if (attachment) {
|
||||
await new DeleteAttachmentTask().schedule({
|
||||
await DeleteAttachmentTask.schedule({
|
||||
attachmentId: attachment.id,
|
||||
teamId: model.id,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { describe, expect } from "@jest/globals";
|
||||
import { subMonths } from "date-fns";
|
||||
import { DocumentPermission, StatusFilter } from "@shared/types";
|
||||
import SearchHelper from "@server/models/helpers/SearchHelper";
|
||||
import {
|
||||
buildDocument,
|
||||
buildDraftDocument,
|
||||
buildCollection,
|
||||
buildTeam,
|
||||
buildUser,
|
||||
buildCollection,
|
||||
buildDocument,
|
||||
buildDraftDocument,
|
||||
buildShare,
|
||||
} from "@server/test/factories";
|
||||
import UserMembership from "../UserMembership";
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
await buildDocument();
|
||||
});
|
||||
import SearchHelper from "./SearchHelper";
|
||||
|
||||
describe("SearchHelper", () => {
|
||||
describe("#searchForTeam", () => {
|
||||
test("should return search results from public collections", async () => {
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
await buildDocument();
|
||||
});
|
||||
|
||||
it("should return search results from public collections", async () => {
|
||||
const team = await buildTeam();
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
@@ -34,7 +36,7 @@ describe("SearchHelper", () => {
|
||||
expect(results[0].document?.id).toBe(document.id);
|
||||
});
|
||||
|
||||
test("should return search results from a collection without search term", async () => {
|
||||
it("should return search results from a collection without search term", async () => {
|
||||
const team = await buildTeam();
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
@@ -58,7 +60,7 @@ describe("SearchHelper", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should not return results from private collections without providing collectionId", async () => {
|
||||
it("should not return results from private collections without providing collectionId", async () => {
|
||||
const team = await buildTeam();
|
||||
const collection = await buildCollection({
|
||||
permission: null,
|
||||
@@ -75,7 +77,7 @@ describe("SearchHelper", () => {
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
test("should return results from private collections when collectionId is provided", async () => {
|
||||
it("should return results from private collections when collectionId is provided", async () => {
|
||||
const team = await buildTeam();
|
||||
const collection = await buildCollection({
|
||||
permission: null,
|
||||
@@ -93,7 +95,7 @@ describe("SearchHelper", () => {
|
||||
expect(results.length).toBe(1);
|
||||
});
|
||||
|
||||
test("should return results from document tree of shared document", async () => {
|
||||
it("should return results from document tree of shared document", async () => {
|
||||
const team = await buildTeam();
|
||||
const collection = await buildCollection({
|
||||
permission: null,
|
||||
@@ -123,7 +125,7 @@ describe("SearchHelper", () => {
|
||||
expect(results.length).toBe(1);
|
||||
});
|
||||
|
||||
test("should handle no collections", async () => {
|
||||
it("should handle no collections", async () => {
|
||||
const team = await buildTeam();
|
||||
const { results } = await SearchHelper.searchForTeam(team, {
|
||||
query: "test",
|
||||
@@ -131,7 +133,7 @@ describe("SearchHelper", () => {
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
test("should handle backslashes in search term", async () => {
|
||||
it("should handle backslashes in search term", async () => {
|
||||
const team = await buildTeam();
|
||||
const { results } = await SearchHelper.searchForTeam(team, {
|
||||
query: "\\\\",
|
||||
@@ -139,7 +141,7 @@ describe("SearchHelper", () => {
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
test("should return the total count of search results", async () => {
|
||||
it("should return the total count of search results", async () => {
|
||||
const team = await buildTeam();
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
@@ -160,7 +162,7 @@ describe("SearchHelper", () => {
|
||||
expect(total).toBe(2);
|
||||
});
|
||||
|
||||
test("should return the document when searched with their previous titles", async () => {
|
||||
it("should return the document when searched with their previous titles", async () => {
|
||||
const team = await buildTeam();
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
@@ -178,7 +180,7 @@ describe("SearchHelper", () => {
|
||||
expect(total).toBe(1);
|
||||
});
|
||||
|
||||
test("should not return the document when searched with neither the titles nor the previous titles", async () => {
|
||||
it("should not return the document when searched with neither the titles nor the previous titles", async () => {
|
||||
const team = await buildTeam();
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
@@ -198,7 +200,12 @@ describe("SearchHelper", () => {
|
||||
});
|
||||
|
||||
describe("#searchForUser", () => {
|
||||
test("should return search results from collections", async () => {
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
await buildDocument();
|
||||
});
|
||||
|
||||
it("should return search results from collections", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
@@ -226,7 +233,7 @@ describe("SearchHelper", () => {
|
||||
expect(results[0].document?.id).toBe(document.id);
|
||||
});
|
||||
|
||||
test("should return search results for a user without search term", async () => {
|
||||
it("should return search results for a user without search term", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
@@ -254,7 +261,7 @@ describe("SearchHelper", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should return search results from a collection without search term", async () => {
|
||||
it("should return search results from a collection without search term", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
@@ -284,7 +291,7 @@ describe("SearchHelper", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle no collections", async () => {
|
||||
it("should handle no collections", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const { results } = await SearchHelper.searchForUser(user, {
|
||||
@@ -293,7 +300,7 @@ describe("SearchHelper", () => {
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
test("should search only drafts created by user", async () => {
|
||||
it("should search only drafts created by user", async () => {
|
||||
const user = await buildUser();
|
||||
await buildDraftDocument({
|
||||
title: "test",
|
||||
@@ -324,7 +331,7 @@ describe("SearchHelper", () => {
|
||||
expect(results.length).toBe(1);
|
||||
});
|
||||
|
||||
test("should not include drafts with user read permission", async () => {
|
||||
it("should not include drafts with user read permission", async () => {
|
||||
const user = await buildUser();
|
||||
await buildDraftDocument({
|
||||
title: "test",
|
||||
@@ -349,7 +356,7 @@ describe("SearchHelper", () => {
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
test("should search only published created by user", async () => {
|
||||
it("should search only published created by user", async () => {
|
||||
const user = await buildUser();
|
||||
await buildDocument({
|
||||
title: "test",
|
||||
@@ -380,7 +387,7 @@ describe("SearchHelper", () => {
|
||||
expect(results.length).toBe(1);
|
||||
});
|
||||
|
||||
test("should search only archived documents created by user", async () => {
|
||||
it("should search only archived documents created by user", async () => {
|
||||
const user = await buildUser();
|
||||
await buildDocument({
|
||||
title: "test",
|
||||
@@ -417,7 +424,7 @@ describe("SearchHelper", () => {
|
||||
expect(results.length).toBe(1);
|
||||
});
|
||||
|
||||
test("should return results from archived and published", async () => {
|
||||
it("should return results from archived and published", async () => {
|
||||
const user = await buildUser();
|
||||
await buildDraftDocument({
|
||||
teamId: user.teamId,
|
||||
@@ -445,7 +452,7 @@ describe("SearchHelper", () => {
|
||||
expect(results.length).toBe(2);
|
||||
});
|
||||
|
||||
test("should return results from drafts and published", async () => {
|
||||
it("should return results from drafts and published", async () => {
|
||||
const user = await buildUser();
|
||||
await buildDocument({
|
||||
userId: user.id,
|
||||
@@ -473,7 +480,7 @@ describe("SearchHelper", () => {
|
||||
expect(results.length).toBe(2);
|
||||
});
|
||||
|
||||
test("should include results from drafts and archived", async () => {
|
||||
it("should include results from drafts and archived", async () => {
|
||||
const user = await buildUser();
|
||||
await buildDocument({
|
||||
userId: user.id,
|
||||
@@ -501,7 +508,7 @@ describe("SearchHelper", () => {
|
||||
expect(results.length).toBe(2);
|
||||
});
|
||||
|
||||
test("should return the total count of search results", async () => {
|
||||
it("should return the total count of search results", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
@@ -526,7 +533,7 @@ describe("SearchHelper", () => {
|
||||
expect(total).toBe(2);
|
||||
});
|
||||
|
||||
test("should return the document when searched with their previous titles", async () => {
|
||||
it("should return the document when searched with their previous titles", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
@@ -547,7 +554,7 @@ describe("SearchHelper", () => {
|
||||
expect(total).toBe(1);
|
||||
});
|
||||
|
||||
test("should not return the document when searched with neither the titles nor the previous titles", async () => {
|
||||
it("should not return the document when searched with neither the titles nor the previous titles", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
@@ -568,7 +575,7 @@ describe("SearchHelper", () => {
|
||||
expect(total).toBe(0);
|
||||
});
|
||||
|
||||
test("should find exact phrases", async () => {
|
||||
it("should find exact phrases", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
@@ -589,7 +596,7 @@ describe("SearchHelper", () => {
|
||||
expect(total).toBe(1);
|
||||
});
|
||||
|
||||
test("should correctly handle removal of trailing spaces", async () => {
|
||||
it("should correctly handle removal of trailing spaces", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
@@ -612,7 +619,12 @@ describe("SearchHelper", () => {
|
||||
});
|
||||
|
||||
describe("#searchTitlesForUser", () => {
|
||||
test("should return search results from collections", async () => {
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
await buildDocument();
|
||||
});
|
||||
|
||||
it("should return search results from collections", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
@@ -632,7 +644,7 @@ describe("SearchHelper", () => {
|
||||
expect(documents[0]?.id).toBe(document.id);
|
||||
});
|
||||
|
||||
test("should filter to specific collection", async () => {
|
||||
it("should filter to specific collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
@@ -668,7 +680,7 @@ describe("SearchHelper", () => {
|
||||
expect(documents[0]?.id).toBe(document.id);
|
||||
});
|
||||
|
||||
test("should handle no collections", async () => {
|
||||
it("should handle no collections", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const documents = await SearchHelper.searchTitlesForUser(user, {
|
||||
@@ -677,7 +689,7 @@ describe("SearchHelper", () => {
|
||||
expect(documents.length).toBe(0);
|
||||
});
|
||||
|
||||
test("should search only drafts created by user", async () => {
|
||||
it("should search only drafts created by user", async () => {
|
||||
const user = await buildUser();
|
||||
await buildDraftDocument({
|
||||
title: "test",
|
||||
@@ -708,7 +720,7 @@ describe("SearchHelper", () => {
|
||||
expect(documents.length).toBe(1);
|
||||
});
|
||||
|
||||
test("should search only published created by user", async () => {
|
||||
it("should search only published created by user", async () => {
|
||||
const user = await buildUser();
|
||||
await buildDocument({
|
||||
title: "test",
|
||||
@@ -739,7 +751,7 @@ describe("SearchHelper", () => {
|
||||
expect(documents.length).toBe(1);
|
||||
});
|
||||
|
||||
test("should search only archived documents created by user", async () => {
|
||||
it("should search only archived documents created by user", async () => {
|
||||
const user = await buildUser();
|
||||
await buildDocument({
|
||||
title: "test",
|
||||
@@ -776,7 +788,7 @@ describe("SearchHelper", () => {
|
||||
expect(documents.length).toBe(1);
|
||||
});
|
||||
|
||||
test("should return results from archived and published", async () => {
|
||||
it("should return results from archived and published", async () => {
|
||||
const user = await buildUser();
|
||||
await buildDraftDocument({
|
||||
teamId: user.teamId,
|
||||
@@ -804,7 +816,7 @@ describe("SearchHelper", () => {
|
||||
expect(documents.length).toBe(2);
|
||||
});
|
||||
|
||||
test("should return results from drafts and published", async () => {
|
||||
it("should return results from drafts and published", async () => {
|
||||
const user = await buildUser();
|
||||
await buildDocument({
|
||||
userId: user.id,
|
||||
@@ -832,7 +844,7 @@ describe("SearchHelper", () => {
|
||||
expect(documents.length).toBe(2);
|
||||
});
|
||||
|
||||
test("should include results from drafts and archived", async () => {
|
||||
it("should include results from drafts and archived", async () => {
|
||||
const user = await buildUser();
|
||||
await buildDocument({
|
||||
userId: user.id,
|
||||
@@ -862,7 +874,12 @@ describe("SearchHelper", () => {
|
||||
});
|
||||
|
||||
describe("#searchCollectionsForUser", () => {
|
||||
test("should return search results from collections", async () => {
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
await buildDocument();
|
||||
});
|
||||
|
||||
it("should return search results from collections", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection1 = await buildCollection({
|
||||
@@ -884,7 +901,7 @@ describe("SearchHelper", () => {
|
||||
expect(results[0].id).toBe(collection1.id);
|
||||
});
|
||||
|
||||
test("should return all collections when no query provided", async () => {
|
||||
it("should return all collections when no query provided", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection1 = await buildCollection({
|
||||
@@ -907,25 +924,196 @@ describe("SearchHelper", () => {
|
||||
});
|
||||
|
||||
describe("webSearchQuery", () => {
|
||||
test("should correctly sanitize query", () => {
|
||||
it("should correctly sanitize query", () => {
|
||||
expect(SearchHelper.webSearchQuery("one/two")).toBe("one/two:*");
|
||||
expect(SearchHelper.webSearchQuery("one\\two")).toBe("one\\\\two:*");
|
||||
expect(SearchHelper.webSearchQuery("test''")).toBe("test");
|
||||
});
|
||||
test("should wildcard unquoted queries", () => {
|
||||
it("should wildcard unquoted queries", () => {
|
||||
expect(SearchHelper.webSearchQuery("test")).toBe("test:*");
|
||||
expect(SearchHelper.webSearchQuery("'")).toBe("");
|
||||
expect(SearchHelper.webSearchQuery("'quoted'")).toBe(`"quoted":*`);
|
||||
});
|
||||
test("should wildcard multi-word queries", () => {
|
||||
it("should wildcard multi-word queries", () => {
|
||||
expect(SearchHelper.webSearchQuery("this is a test")).toBe(
|
||||
"this&is&a&test:*"
|
||||
);
|
||||
});
|
||||
test("should not wildcard quoted queries", () => {
|
||||
it("should not wildcard quoted queries", () => {
|
||||
expect(SearchHelper.webSearchQuery(`"this is a test"`)).toBe(
|
||||
`"this<->is<->a<->test"`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchConfig", () => {
|
||||
it("should boost recent documents when boostRecentMonths is set", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({ teamId: team.id });
|
||||
const now = new Date();
|
||||
|
||||
const recentDoc = await buildDocument({
|
||||
teamId: team.id,
|
||||
collectionId: collection.id,
|
||||
title: "test document recent",
|
||||
text: "test search term recent",
|
||||
});
|
||||
|
||||
// Set date 4 months ago
|
||||
const olderDoc = await buildDocument({
|
||||
teamId: team.id,
|
||||
collectionId: collection.id,
|
||||
title: "test document older",
|
||||
text: "test search term older test",
|
||||
createdAt: subMonths(now, 4),
|
||||
updatedAt: subMonths(now, 4),
|
||||
});
|
||||
|
||||
// Search without recency boost
|
||||
const resultsWithoutBoost = await SearchHelper.searchForUser(user, {
|
||||
query: "test search term",
|
||||
});
|
||||
|
||||
// Search with recency boost
|
||||
const resultsWithBoost = await SearchHelper.searchForUser(user, {
|
||||
query: "test search term",
|
||||
searchConfig: {
|
||||
boostRecent: true,
|
||||
boostRecentMonths: 6,
|
||||
maxRecentBoost: 2.0,
|
||||
},
|
||||
});
|
||||
|
||||
// Without boost, documents should be ordered by base relevance
|
||||
expect(resultsWithoutBoost.results.length).toBe(2);
|
||||
expect(resultsWithoutBoost.results[0].document.id).toBe(olderDoc.id);
|
||||
expect(resultsWithoutBoost.results[1].document.id).toBe(recentDoc.id);
|
||||
|
||||
// With boost, recent document should be ranked higher
|
||||
expect(resultsWithBoost.results.length).toBe(2);
|
||||
expect(resultsWithBoost.results[0].document.id).toBe(recentDoc.id);
|
||||
expect(resultsWithBoost.results[1].document.id).toBe(olderDoc.id);
|
||||
|
||||
// Recent document should have higher ranking
|
||||
expect(resultsWithBoost.results[0].ranking).toBeGreaterThan(
|
||||
resultsWithBoost.results[1].ranking
|
||||
);
|
||||
});
|
||||
|
||||
it("should respect different time windows", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({ teamId: team.id });
|
||||
const now = new Date();
|
||||
|
||||
const recentDoc = await buildDocument({
|
||||
teamId: team.id,
|
||||
collectionId: collection.id,
|
||||
title: "test document recent",
|
||||
text: "test search term recent",
|
||||
});
|
||||
|
||||
// Set date 2 months ago
|
||||
const twoMonthOldDoc = await buildDocument({
|
||||
teamId: team.id,
|
||||
collectionId: collection.id,
|
||||
title: "test document two months",
|
||||
text: "test search term two months",
|
||||
createdAt: subMonths(now, 2),
|
||||
updatedAt: subMonths(now, 2),
|
||||
});
|
||||
|
||||
// Search with 1-month window
|
||||
const resultsShortWindow = await SearchHelper.searchForUser(user, {
|
||||
query: "test search term",
|
||||
searchConfig: {
|
||||
boostRecent: true,
|
||||
boostRecentMonths: 1,
|
||||
maxRecentBoost: 2.0,
|
||||
},
|
||||
});
|
||||
|
||||
// Search with 3-month window
|
||||
const resultsLongWindow = await SearchHelper.searchForUser(user, {
|
||||
query: "test search term",
|
||||
searchConfig: {
|
||||
boostRecentMonths: 3,
|
||||
maxRecentBoost: 2.0,
|
||||
},
|
||||
});
|
||||
|
||||
// With 1-month window, two-month-old doc should have no boost
|
||||
expect(resultsShortWindow.results[0].document.id).toBe(recentDoc.id);
|
||||
expect(resultsShortWindow.results[1].document.id).toBe(twoMonthOldDoc.id);
|
||||
expect(resultsShortWindow.results[0].ranking).toBeGreaterThan(
|
||||
resultsShortWindow.results[1].ranking * 1.5
|
||||
);
|
||||
|
||||
// With 3-month window, two-month-old doc should have some boost
|
||||
expect(resultsLongWindow.results[0].document.id).toBe(recentDoc.id);
|
||||
expect(resultsLongWindow.results[1].document.id).toBe(twoMonthOldDoc.id);
|
||||
const rankingRatio =
|
||||
resultsLongWindow.results[0].ranking /
|
||||
resultsLongWindow.results[1].ranking;
|
||||
expect(rankingRatio).toBeLessThan(1.5);
|
||||
expect(rankingRatio).toBeGreaterThan(1.0);
|
||||
});
|
||||
|
||||
it("should respect custom boost factor", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({ teamId: team.id });
|
||||
const now = new Date();
|
||||
|
||||
const recentDoc = await buildDocument({
|
||||
teamId: team.id,
|
||||
collectionId: collection.id,
|
||||
title: "test document recent",
|
||||
text: "test search term recent",
|
||||
});
|
||||
|
||||
// Set date 2 months ago
|
||||
await buildDocument({
|
||||
teamId: team.id,
|
||||
collectionId: collection.id,
|
||||
title: "test document older",
|
||||
text: "test search term older",
|
||||
createdAt: subMonths(now, 2),
|
||||
updatedAt: subMonths(now, 2),
|
||||
});
|
||||
|
||||
// Search with low boost factor
|
||||
const resultsLowBoost = await SearchHelper.searchForUser(user, {
|
||||
query: "test search term",
|
||||
searchConfig: {
|
||||
boostRecent: true,
|
||||
boostRecentMonths: 6,
|
||||
maxRecentBoost: 1.2,
|
||||
},
|
||||
});
|
||||
|
||||
// Search with high boost factor
|
||||
const resultsHighBoost = await SearchHelper.searchForUser(user, {
|
||||
query: "test search term",
|
||||
searchConfig: {
|
||||
boostRecent: true,
|
||||
boostRecentMonths: 6,
|
||||
maxRecentBoost: 3.0,
|
||||
},
|
||||
});
|
||||
|
||||
// Both searches should rank recent document higher
|
||||
expect(resultsLowBoost.results[0].document.id).toBe(recentDoc.id);
|
||||
expect(resultsHighBoost.results[0].document.id).toBe(recentDoc.id);
|
||||
|
||||
// High boost should have greater difference in rankings
|
||||
const lowBoostRatio =
|
||||
resultsLowBoost.results[0].ranking / resultsLowBoost.results[1].ranking;
|
||||
const highBoostRatio =
|
||||
resultsHighBoost.results[0].ranking /
|
||||
resultsHighBoost.results[1].ranking;
|
||||
expect(highBoostRatio).toBeGreaterThan(lowBoostRatio);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,14 @@ type SearchResponse = {
|
||||
total: number;
|
||||
};
|
||||
|
||||
type SearchConfig = {
|
||||
boostRecent?: boolean;
|
||||
/** Time window in months for recent content boosting. When set, enables recency boosting. */
|
||||
boostRecentMonths?: number;
|
||||
/** Maximum boost multiplier for recent content */
|
||||
maxRecentBoost?: number;
|
||||
};
|
||||
|
||||
type SearchOptions = {
|
||||
/** The query limit for pagination */
|
||||
limit?: number;
|
||||
@@ -60,6 +68,8 @@ type SearchOptions = {
|
||||
snippetMinWords?: number;
|
||||
/** The maximum number of words to be returned in the contextual snippet */
|
||||
snippetMaxWords?: number;
|
||||
/** Configuration for search behavior */
|
||||
searchConfig?: SearchConfig;
|
||||
};
|
||||
|
||||
type RankedDocument = Document & {
|
||||
@@ -79,7 +89,7 @@ export default class SearchHelper {
|
||||
team: Team,
|
||||
options: SearchOptions = {}
|
||||
): Promise<SearchResponse> {
|
||||
const { limit = 15, offset = 0, query } = options;
|
||||
const { limit = 15, offset = 0, query, searchConfig } = options;
|
||||
|
||||
const where = await this.buildWhere(team, {
|
||||
...options,
|
||||
@@ -101,7 +111,7 @@ export default class SearchHelper {
|
||||
});
|
||||
}
|
||||
|
||||
const findOptions = this.buildFindOptions(query);
|
||||
const findOptions = this.buildFindOptions(query, searchConfig);
|
||||
|
||||
try {
|
||||
const resultsQuery = Document.unscoped().findAll({
|
||||
@@ -182,9 +192,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"]],
|
||||
@@ -227,11 +246,11 @@ export default class SearchHelper {
|
||||
user: User,
|
||||
options: SearchOptions = {}
|
||||
): Promise<SearchResponse> {
|
||||
const { limit = 15, offset = 0, query } = options;
|
||||
const { limit = 15, offset = 0, query, searchConfig } = options;
|
||||
|
||||
const where = await this.buildWhere(user, options);
|
||||
|
||||
const findOptions = this.buildFindOptions(query);
|
||||
const findOptions = this.buildFindOptions(query, searchConfig);
|
||||
|
||||
const include = [
|
||||
{
|
||||
@@ -264,7 +283,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"),
|
||||
@@ -289,18 +319,46 @@ export default class SearchHelper {
|
||||
}
|
||||
}
|
||||
|
||||
private static buildFindOptions(query?: string): FindOptions {
|
||||
private static buildFindOptions(
|
||||
query?: string,
|
||||
searchConfig?: SearchConfig
|
||||
): FindOptions {
|
||||
const attributes: FindAttributeOptions = ["id"];
|
||||
const replacements: BindOrReplacements = {};
|
||||
const order: Order = [["updatedAt", "DESC"]];
|
||||
|
||||
if (query) {
|
||||
attributes.push([
|
||||
Sequelize.literal(
|
||||
`ts_rank("searchVector", to_tsquery('english', :query))`
|
||||
),
|
||||
"searchRanking",
|
||||
]);
|
||||
// Default values for recency boosting
|
||||
const boostRecent = searchConfig?.boostRecent ?? false;
|
||||
const boostRecentMonths = searchConfig?.boostRecentMonths ?? 2;
|
||||
const maxRecentBoost = searchConfig?.maxRecentBoost ?? 2.0;
|
||||
|
||||
if (boostRecent) {
|
||||
// Calculate ranking with recency boost
|
||||
// The formula creates a multiplier between 1.0 and maxRecentBoost based on document age
|
||||
attributes.push([
|
||||
Sequelize.literal(
|
||||
`(
|
||||
ts_rank("searchVector", to_tsquery('english', :query)) *
|
||||
(1 + (LEAST(
|
||||
${maxRecentBoost - 1},
|
||||
(1 - EXTRACT(EPOCH FROM (NOW() - document."updatedAt")) /
|
||||
EXTRACT(EPOCH FROM INTERVAL '${boostRecentMonths} months'))
|
||||
) * ${maxRecentBoost}))
|
||||
)`
|
||||
),
|
||||
"searchRanking",
|
||||
]);
|
||||
} else {
|
||||
// Original ranking without recency boost
|
||||
attributes.push([
|
||||
Sequelize.literal(
|
||||
`ts_rank("searchVector", to_tsquery('english', :query))`
|
||||
),
|
||||
"searchRanking",
|
||||
]);
|
||||
}
|
||||
|
||||
replacements["query"] = this.webSearchQuery(query);
|
||||
order.unshift(["searchRanking", "DESC"]);
|
||||
}
|
||||
|
||||
@@ -33,8 +33,6 @@ class OAuthAuthentication extends ParanoidModel<
|
||||
InferAttributes<OAuthAuthentication>,
|
||||
Partial<InferCreationAttributes<OAuthAuthentication>>
|
||||
> {
|
||||
static eventNamespace = "oauthAuthentications";
|
||||
|
||||
/** The lifetime of an access token in seconds. */
|
||||
public static accessTokenLifetime = env.OAUTH_PROVIDER_ACCESS_TOKEN_LIFETIME;
|
||||
|
||||
|
||||
@@ -27,8 +27,6 @@ class OAuthAuthorizationCode extends IdModel<
|
||||
InferAttributes<OAuthAuthorizationCode>,
|
||||
Partial<InferCreationAttributes<OAuthAuthorizationCode>>
|
||||
> {
|
||||
static eventNamespace = "oauthAuthorizationCodes";
|
||||
|
||||
/** The lifetime of an authorization code in seconds. */
|
||||
public static authorizationCodeLifetime =
|
||||
env.OAUTH_PROVIDER_AUTHORIZATION_CODE_LIFETIME;
|
||||
|
||||
@@ -35,8 +35,6 @@ class OAuthClient extends ParanoidModel<
|
||||
InferAttributes<OAuthClient>,
|
||||
Partial<InferCreationAttributes<OAuthClient>>
|
||||
> {
|
||||
static eventNamespace = "oauthClients";
|
||||
|
||||
public static clientSecretPrefix = "ol_sk_";
|
||||
|
||||
@NotContainsUrl
|
||||
|
||||
@@ -24,13 +24,14 @@ export default function presentOAuthClient(oauthClient: OAuthClient) {
|
||||
|
||||
/**
|
||||
* Important: This function is used to present the OAuth client to users
|
||||
* that are NOT in the same workspace as the client. Be very careful about
|
||||
* that are not in the same workspace as the client. Be very careful about
|
||||
* what you expose here.
|
||||
*
|
||||
* @param oauthClient The OAuth client to present
|
||||
*/
|
||||
export function presentPublishedOAuthClient(oauthClient: OAuthClient) {
|
||||
return {
|
||||
id: oauthClient.id,
|
||||
name: oauthClient.name,
|
||||
description: oauthClient.description,
|
||||
developerName: oauthClient.developerName,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { OAuthAuthentication } from "@server/models";
|
||||
import { OAuthClientEvent, Event as TEvent } from "@server/types";
|
||||
import BaseProcessor from "./BaseProcessor";
|
||||
|
||||
export default class OAuthClientDeletedProcessor extends BaseProcessor {
|
||||
static applicableEvents: TEvent["name"][] = ["oauthClients.delete"];
|
||||
|
||||
async perform(event: OAuthClientEvent) {
|
||||
await OAuthAuthentication.destroy({
|
||||
where: {
|
||||
oauthClientId: event.modelId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Op } from "sequelize";
|
||||
import { OAuthAuthentication, OAuthClient, User } from "@server/models";
|
||||
import { OAuthClientEvent, Event as TEvent } from "@server/types";
|
||||
import BaseProcessor from "./BaseProcessor";
|
||||
|
||||
export default class OAuthClientUnpublishedProcessor extends BaseProcessor {
|
||||
static applicableEvents: TEvent["name"][] = ["oauthClients.update"];
|
||||
|
||||
async perform(event: OAuthClientEvent) {
|
||||
if (
|
||||
event.changes?.previous.published === true &&
|
||||
event.changes.attributes.published === false
|
||||
) {
|
||||
const oauthClient = await OAuthClient.findByPk(event.modelId, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
const users = await User.findAll({
|
||||
attributes: ["id"],
|
||||
where: {
|
||||
teamId: oauthClient.teamId,
|
||||
},
|
||||
});
|
||||
const userIds = users.map((user) => user.id);
|
||||
|
||||
// Revoke access for all users except any that are in the same team
|
||||
await OAuthAuthentication.destroy({
|
||||
where: {
|
||||
oauthClientId: event.modelId,
|
||||
userId: {
|
||||
[Op.notIn]: userIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
ApiKey,
|
||||
GroupUser,
|
||||
OAuthAuthentication,
|
||||
Star,
|
||||
Subscription,
|
||||
UserAuthentication,
|
||||
@@ -47,12 +46,6 @@ export default class UserDeletedProcessor extends BaseProcessor {
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
await OAuthAuthentication.destroy({
|
||||
where: {
|
||||
userId: event.userId,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
await Star.destroy({
|
||||
where: {
|
||||
userId: event.userId,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -166,7 +166,7 @@ router.post(
|
||||
)
|
||||
);
|
||||
|
||||
const job = await new UploadAttachmentFromUrlTask().schedule({
|
||||
const job = await UploadAttachmentFromUrlTask.schedule({
|
||||
attachmentId: attachment.id,
|
||||
url,
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
@@ -1062,6 +1060,9 @@ router.post(
|
||||
limit,
|
||||
snippetMinWords,
|
||||
snippetMaxWords,
|
||||
searchConfig: {
|
||||
boostRecent: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1541,7 +1542,7 @@ router.post(
|
||||
acl,
|
||||
});
|
||||
|
||||
const job = await new DocumentImportTask().schedule({
|
||||
const job = await DocumentImportTask.schedule({
|
||||
key,
|
||||
sourceMetadata: {
|
||||
fileName,
|
||||
@@ -1551,7 +1552,6 @@ router.post(
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
publish,
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
const response: DocumentImportTaskResponse = await job.finished();
|
||||
if ("error" in response) {
|
||||
@@ -2035,7 +2035,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 +2065,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,
|
||||
},
|
||||
|
||||
-19
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,6 @@ router.post(
|
||||
oa.*,
|
||||
oc.id AS "oauthClient.id",
|
||||
oc.name AS "oauthClient.name",
|
||||
oc."avatarUrl" AS "oauthClient.avatarUrl",
|
||||
oc."clientId" AS "oauthClient.clientId"
|
||||
FROM oauth_authentications oa
|
||||
INNER JOIN oauth_clients oc ON oc.id = oa."oauthClientId"
|
||||
@@ -66,12 +65,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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user