Compare commits

...

20 Commits

Author SHA1 Message Date
Tom Moor fa7f8d3592 fix: ExportDocumentTreeTask needs documentStructure 2025-05-07 19:38:30 -04:00
codegen-sh[bot] c598c61afe Add PromQL as a code highlighting option in the editor (#9146)
* Add PromQL as a code highlighting option in the editor

* Update code.ts

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-05-07 19:11:47 -04:00
Tom Moor 68b07eb466 fix: withoutState scope should include state as fallback (#9145) 2025-05-07 18:49:33 -04:00
Tom Moor 06a149407a fix: withoutState scope should include state as fallback (#9144) 2025-05-07 09:00:42 -04:00
Tom Moor b9387734c7 perf: Remove documentStructure from default query select (#9141)
* perf: Remove documentStructure from default query select

* test
2025-05-07 07:47:57 -04:00
dependabot[bot] 810b7908e4 chore(deps): bump the aws group with 5 updates (#9136)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.797.0` | `3.802.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.797.0` | `3.802.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.797.0` | `3.802.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.797.0` | `3.802.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.796.0` | `3.800.0` |


Updates `@aws-sdk/client-s3` from 3.797.0 to 3.802.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.802.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.797.0 to 3.802.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.802.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.797.0 to 3.802.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.802.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.797.0 to 3.802.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.802.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.796.0 to 3.800.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.800.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.802.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.802.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.802.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.802.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.800.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-06 08:02:14 -04:00
dependabot[bot] 6b76a898fa chore(deps): bump the babel group with 9 updates (#9139)
Bumps the babel group with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) | `7.26.10` | `7.27.1` |
| [@babel/plugin-proposal-decorators](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-proposal-decorators) | `7.25.9` | `7.27.1` |
| [@babel/plugin-transform-class-properties](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-class-properties) | `7.25.9` | `7.27.1` |
| [@babel/plugin-transform-destructuring](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-destructuring) | `7.25.9` | `7.27.1` |
| [@babel/plugin-transform-regenerator](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-regenerator) | `7.27.0` | `7.27.1` |
| [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) | `7.26.9` | `7.27.1` |
| [@babel/preset-react](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-react) | `7.26.3` | `7.27.1` |
| [@babel/cli](https://github.com/babel/babel/tree/HEAD/packages/babel-cli) | `7.27.0` | `7.27.1` |
| [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript) | `7.27.0` | `7.27.1` |


Updates `@babel/core` from 7.26.10 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-core)

Updates `@babel/plugin-proposal-decorators` from 7.25.9 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-plugin-proposal-decorators)

Updates `@babel/plugin-transform-class-properties` from 7.25.9 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-plugin-transform-class-properties)

Updates `@babel/plugin-transform-destructuring` from 7.25.9 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-plugin-transform-destructuring)

Updates `@babel/plugin-transform-regenerator` from 7.27.0 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-plugin-transform-regenerator)

Updates `@babel/preset-env` from 7.26.9 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-preset-env)

Updates `@babel/preset-react` from 7.26.3 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-preset-react)

Updates `@babel/cli` from 7.27.0 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-cli)

Updates `@babel/preset-typescript` from 7.27.0 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-preset-typescript)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-proposal-decorators"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-class-properties"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-destructuring"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-regenerator"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-env"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/preset-react"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/cli"
  dependency-version: 7.27.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-typescript"
  dependency-version: 7.27.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: babel
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 21:05:41 -04:00
dependabot[bot] 8ba83e2173 chore(deps): bump react-medium-image-zoom from 5.2.13 to 5.2.14 (#9137)
Bumps [react-medium-image-zoom](https://github.com/rpearce/react-medium-image-zoom) from 5.2.13 to 5.2.14.
- [Release notes](https://github.com/rpearce/react-medium-image-zoom/releases)
- [Changelog](https://github.com/rpearce/react-medium-image-zoom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rpearce/react-medium-image-zoom/compare/v5.2.13...v5.2.14)

---
updated-dependencies:
- dependency-name: react-medium-image-zoom
  dependency-version: 5.2.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 21:05:27 -04:00
dependabot[bot] 5a4b8c5faa chore(deps): bump validator and @types/validator (#9138)
Bumps [validator](https://github.com/validatorjs/validator.js) and [@types/validator](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/validator). These dependencies needed to be updated together.

Updates `validator` from 13.12.0 to 13.15.0
- [Release notes](https://github.com/validatorjs/validator.js/releases)
- [Changelog](https://github.com/validatorjs/validator.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/validatorjs/validator.js/compare/13.12.0...13.15.0)

Updates `@types/validator` from 13.12.1 to 13.15.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/validator)

---
updated-dependencies:
- dependency-name: validator
  dependency-version: 13.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: "@types/validator"
  dependency-version: 13.15.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 21:05:14 -04:00
Tom Moor 3f8bdf7ac2 Refactor withMembershipScope (#9134) 2025-05-04 18:37:01 -04:00
Tom Moor 9c4b4f4989 fix: Chained scopes overwrite (#9133) 2025-05-04 22:16:38 +00:00
Hemachandar c5d534b2ad Add script to resolve existing collection index collisions (#8810)
* Add script to resolve existing collection index collisions

* Remove debug logging

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-05-04 16:12:09 -04:00
Tom Moor bed3d1078e fix: More guards against empty text nodes (#9132) 2025-05-04 20:11:02 +00:00
Tom Moor 83e87254c6 fix: Invisible JS error (#9131) 2025-05-04 13:59:21 +00:00
Tom Moor f576ddccfe chore: Add 10 more brand icons (#9130) 2025-05-04 13:18:37 +00:00
Tom Moor 0a674eacfa fix: Improve behavior when hitting backspace/delete with table cell selections (#9129) 2025-05-04 08:51:48 -04:00
Tom Moor ceac57bd64 Pick collection color based on existing collections (#9128) 2025-05-04 03:09:08 +00:00
Tom Moor 97f31e3f2a fix: Cannot create document through @mention on collection overview (#9127) 2025-05-03 22:13:54 -04:00
Tom Moor a06671e8ce OAuth provider (#8884)
This PR contains the necessary work to make Outline an OAuth provider including:

- OAuth app registration
- OAuth app management
- Private / public apps (Public in cloud only)
- Full OAuth 2.0 spec compatible authentication flow
- Granular scopes
- User token management screen in settings
- Associated API endpoints for programatic access
2025-05-03 19:40:18 -04:00
Tom Moor fd3c21d28b Remove withCollectionPermissions scope (#9124)
* Remove withCollectionPermissions scope

* defaultScopeWithUser -> withUserScope

* fix: Include withDrafts in groupMemberships.list

* rename
2025-05-03 12:00:54 -04:00
132 changed files with 6581 additions and 1368 deletions
+25
View File
@@ -0,0 +1,25 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import { OAuthClientNew } from "~/components/OAuthClient/OAuthClientNew";
import { createAction } from "..";
import { SettingsSection } from "../sections";
export const createOAuthClient = createAction({
name: ({ t }) => t("New App"),
analyticsName: "New App",
section: SettingsSection,
icon: <PlusIcon />,
keywords: "create",
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createOAuthClient,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("New Application"),
content: <OAuthClientNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
+2 -2
View File
@@ -11,7 +11,7 @@ import { ActionContext } from "~/types";
import Desktop from "~/utils/Desktop";
import { TeamSection } from "../sections";
export const createTeamsList = ({ stores }: { stores: RootStore }) =>
export const switchTeamsList = ({ 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: createTeamsList,
children: switchTeamsList,
});
export const createTeam = createAction({
+19 -12
View File
@@ -13,6 +13,11 @@ export enum AvatarSize {
Upload = 64,
}
export enum AvatarVariant {
Round = "round",
Square = "square",
}
export interface IAvatar {
avatarUrl: string | null;
color?: string;
@@ -23,6 +28,8 @@ export interface IAvatar {
type Props = {
/** The size of the avatar */
size: AvatarSize;
/** The variant of the avatar */
variant?: AvatarVariant;
/** The source of the avatar image, if not passing a model. */
src?: string;
/** The avatar model, if not passing a source. */
@@ -38,14 +45,14 @@ type Props = {
};
function Avatar(props: Props) {
const { model, style, ...rest } = props;
const { model, style, variant = AvatarVariant.Round, ...rest } = props;
const src = props.src || model?.avatarUrl;
const [error, handleError] = useBoolean(false);
return (
<Relative style={style}>
<Relative style={style} $variant={variant} $size={props.size}>
{src && !error ? (
<CircleImg onError={handleError} src={src} {...rest} />
<Image onError={handleError} src={src} {...rest} />
) : model ? (
<Initials color={model.color} {...rest}>
{model.initial}
@@ -61,19 +68,19 @@ Avatar.defaultProps = {
size: AvatarSize.Medium,
};
const Relative = styled.div`
const Relative = styled.div<{ $variant: AvatarVariant; $size: AvatarSize }>`
position: relative;
user-select: none;
flex-shrink: 0;
`;
const CircleImg = styled.img<{ size: number }>`
display: block;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
flex-shrink: 0;
border-radius: ${(props) =>
props.$variant === AvatarVariant.Round ? "50%" : `${props.$size / 8}px`};
overflow: hidden;
`;
const Image = styled.img<{ size: number }>`
display: block;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
`;
export default Avatar;
-2
View File
@@ -13,7 +13,6 @@ const Initials = styled(Flex)<{
}>`
align-items: center;
justify-content: center;
border-radius: 50%;
width: 100%;
height: 100%;
color: ${(props) =>
@@ -23,7 +22,6 @@ const Initials = styled(Flex)<{
background-color: ${(props) => props.color ?? props.theme.textTertiary};
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
flex-shrink: 0;
// adjust font size down for each additional character
+23 -5
View File
@@ -1,3 +1,4 @@
import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
@@ -18,6 +19,7 @@ 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"));
@@ -30,6 +32,26 @@ 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,
@@ -42,11 +64,7 @@ export const CollectionForm = observer(function CollectionForm_({
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const iconColor = React.useMemo(
() => collection?.color ?? randomElement(colorPalette),
[collection?.color]
);
const iconColor = useIconColor(collection);
const fallbackIcon = <Icon value="collection" color={iconColor} />;
const {
@@ -0,0 +1,142 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { OAuthClientValidation } from "@shared/validations";
import OAuthClient from "~/models/oauth/OAuthClient";
import ImageInput from "~/scenes/Settings/components/ImageInput";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input, { LabelText } from "~/components/Input";
import isCloudHosted from "~/utils/isCloudHosted";
import Switch from "../Switch";
export interface FormData {
name: string;
developerName: string;
developerUrl: string;
description: string;
avatarUrl: string;
redirectUris: string[];
published: boolean;
}
export const OAuthClientForm = observer(function OAuthClientForm_({
handleSubmit,
oauthClient,
}: {
handleSubmit: (data: FormData) => void;
oauthClient?: OAuthClient;
}) {
const { t } = useTranslation();
const {
register,
handleSubmit: formHandleSubmit,
formState,
getValues,
setFocus,
setError,
control,
} = useForm<FormData>({
mode: "all",
defaultValues: {
name: oauthClient?.name ?? "",
description: oauthClient?.description ?? "",
avatarUrl: oauthClient?.avatarUrl ?? "",
redirectUris: oauthClient?.redirectUris ?? [],
published: oauthClient?.published ?? false,
},
});
React.useEffect(() => {
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
}, [setFocus]);
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<>
<label style={{ marginBottom: "1em" }}>
<LabelText>{t("Icon")}</LabelText>
<Controller
control={control}
name="avatarUrl"
render={({ field }) => (
<ImageInput
onSuccess={(url) => field.onChange(url)}
onError={(err) => setError("avatarUrl", { message: err })}
model={{
id: oauthClient?.id,
avatarUrl: field.value,
initial: getValues().name[0],
}}
borderRadius={0}
/>
)}
/>
</label>
<Input
type="text"
label={t("Name")}
placeholder={t("My App")}
{...register("name", {
required: true,
maxLength: OAuthClientValidation.maxNameLength,
})}
autoComplete="off"
autoFocus
flex
/>
<Input
type="text"
label={t("Tagline")}
placeholder={t("A short description")}
{...register("description", {
maxLength: OAuthClientValidation.maxDescriptionLength,
})}
flex
/>
<Controller
control={control}
name="redirectUris"
render={({ field }) => (
<Input
type="textarea"
label={t("Callback URLs")}
placeholder="https://example.com/callback"
ref={field.ref}
value={field.value.join("\n")}
rows={Math.max(2, field.value.length + 1)}
onChange={(event) => {
field.onChange(event.target.value.split("\n"));
}}
required
/>
)}
/>
{isCloudHosted && (
<Switch
{...register("published")}
label={t("Published")}
note={t("Allow this app to be installed by other workspaces")}
/>
)}
</>
<Flex justify="flex-end">
<Button
type="submit"
disabled={formState.isSubmitting || !formState.isValid}
>
{oauthClient
? formState.isSubmitting
? `${t("Saving")}`
: t("Save")
: formState.isSubmitting
? `${t("Creating")}`
: t("Create")}
</Button>
</Flex>
</form>
);
});
@@ -0,0 +1,33 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import useStores from "~/hooks/useStores";
import { settingsPath } from "~/utils/routeHelpers";
import { OAuthClientForm, FormData } from "./OAuthClientForm";
type Props = {
onSubmit: () => void;
};
export const OAuthClientNew = observer(function OAuthClientNew_({
onSubmit,
}: Props) {
const { oauthClients } = useStores();
const history = useHistory();
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
const oauthClient = await oauthClients.save(data);
onSubmit?.();
history.push(settingsPath("applications", oauthClient.id));
} catch (error) {
toast.error(error.message);
}
},
[oauthClients, history, onSubmit]
);
return <OAuthClientForm handleSubmit={handleSubmit} />;
});
+3 -3
View File
@@ -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 OrganizationMenu from "~/menus/OrganizationMenu";
import TeamMenu from "~/menus/TeamMenu";
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 />
<OrganizationMenu>
<TeamMenu>
{(props: SidebarButtonProps) => (
<SidebarButton
{...props}
@@ -91,7 +91,7 @@ function AppSidebar() {
</Tooltip>
</SidebarButton>
)}
</OrganizationMenu>
</TeamMenu>
<Overflow>
<Section>
<SidebarLink
+4 -1
View File
@@ -1,8 +1,11 @@
import styled from "styled-components";
import { s } from "@shared/styles";
import { Avatar } from "./Avatar";
import { AvatarVariant } from "./Avatar/Avatar";
const TeamLogo = styled(Avatar)`
const TeamLogo = styled(Avatar).attrs({
variant: AvatarVariant.Square,
})`
border-radius: 4px;
box-shadow: inset 0 0 0 1px ${s("divider")};
border: 0;
+14
View File
@@ -0,0 +1,14 @@
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") || "{}");
}
+1 -1
View File
@@ -59,7 +59,7 @@ export default function useRequest<T = unknown>(
if (makeRequestOnMount) {
void request();
}
}, [request, makeRequestOnMount]);
}, []);
return { data, loading, loaded, error, request };
}
+16 -6
View File
@@ -13,6 +13,7 @@ import {
ImportIcon,
ShapesIcon,
Icon,
InternetIcon,
} from "outline-icons";
import React, { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
@@ -28,7 +29,8 @@ import useCurrentUser from "./useCurrentUser";
import usePolicy from "./usePolicy";
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
const PersonalApiKeys = lazy(() => import("~/scenes/Settings/PersonalApiKeys"));
const Applications = lazy(() => import("~/scenes/Settings/Applications"));
const APIAndApps = lazy(() => import("~/scenes/Settings/APIAndApps"));
const Details = lazy(() => import("~/scenes/Settings/Details"));
const Export = lazy(() => import("~/scenes/Settings/Export"));
const Features = lazy(() => import("~/scenes/Settings/Features"));
@@ -86,12 +88,12 @@ const useSettingsConfig = () => {
icon: EmailIcon,
},
{
name: t("API Keys"),
path: settingsPath("personal-api-keys"),
component: PersonalApiKeys,
enabled: can.createApiKey && !can.listApiKeys,
name: t("API & Apps"),
path: settingsPath("api-and-apps"),
component: APIAndApps,
enabled: true,
group: t("Account"),
icon: CodeIcon,
icon: PadlockIcon,
},
// Workspace
{
@@ -150,6 +152,14 @@ const useSettingsConfig = () => {
group: t("Workspace"),
icon: CodeIcon,
},
{
name: t("Applications"),
path: settingsPath("applications"),
component: Applications,
enabled: can.listOAuthClients,
group: t("Workspace"),
icon: InternetIcon,
},
{
name: t("Shared Links"),
path: settingsPath("shares"),
+57
View File
@@ -0,0 +1,57 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import OAuthAuthentication from "~/models/oauth/OAuthAuthentication";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import useStores from "~/hooks/useStores";
type Props = {
/** The OAuthAuthentication to associate with the menu */
oauthAuthentication: OAuthAuthentication;
};
function OAuthAuthenticationMenu({ oauthAuthentication }: Props) {
const menu = useMenuState({
modal: true,
});
const { dialogs } = useStores();
const { t } = useTranslation();
const handleRevoke = React.useCallback(() => {
dialogs.openModal({
title: t("Revoke {{ appName }}", {
appName: oauthAuthentication.oauthClient.name,
}),
content: (
<ConfirmationDialog
onSubmit={async () => {
await oauthAuthentication.deleteAll();
dialogs.closeAllModals();
}}
submitText={t("Revoke")}
savingText={`${t("Revoking")}`}
danger
>
{t("Are you sure you want to revoke access?")}
</ConfirmationDialog>
),
});
}, [t, dialogs, oauthAuthentication]);
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu}>
<MenuItem {...menu} onClick={handleRevoke} dangerous>
{t("Revoke")}
</MenuItem>
</ContextMenu>
</>
);
}
export default observer(OAuthAuthenticationMenu);
+68
View File
@@ -0,0 +1,68 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import OAuthClient from "~/models/oauth/OAuthClient";
import OAuthClientDeleteDialog from "~/scenes/Settings/components/OAuthClientDeleteDialog";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import useStores from "~/hooks/useStores";
import { settingsPath } from "~/utils/routeHelpers";
type Props = {
/** The oauthClient to associate with the menu */
oauthClient: OAuthClient;
/** Whether to show the edit button */
showEdit?: boolean;
};
function OAuthClientMenu({ oauthClient, showEdit }: Props) {
const menu = useMenuState({
modal: true,
});
const { dialogs } = useStores();
const { t } = useTranslation();
const handleDelete = React.useCallback(() => {
dialogs.openModal({
title: t("Delete app"),
content: (
<OAuthClientDeleteDialog
onSubmit={dialogs.closeAllModals}
oauthClient={oauthClient}
/>
),
});
}, [t, dialogs, oauthClient]);
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu}>
<Template
{...menu}
items={[
{
type: "route",
title: `${t("Edit")}`,
visible: showEdit,
to: settingsPath("applications", oauthClient.id),
},
{
type: "separator",
},
{
type: "button",
dangerous: true,
title: `${t("Delete")}`,
onClick: handleDelete,
},
]}
/>
</ContextMenu>
</>
);
}
export default observer(OAuthClientMenu);
@@ -10,7 +10,7 @@ import {
} from "~/actions/definitions/navigation";
import {
createTeam,
createTeamsList,
switchTeamsList,
desktopLoginTeam,
} from "~/actions/definitions/teams";
import useActionContext from "~/hooks/useActionContext";
@@ -22,7 +22,7 @@ type Props = {
children?: React.ReactNode;
};
const OrganizationMenu: React.FC = ({ children }: Props) => {
const TeamMenu: React.FC = ({ children }: Props) => {
const menu = useMenuState({
unstable_offset: [4, -4],
placement: "bottom-start",
@@ -44,7 +44,7 @@ const OrganizationMenu: React.FC = ({ children }: Props) => {
// menu is not cached at all.
const actions = React.useMemo(
() => [
...createTeamsList(context),
...switchTeamsList(context),
createTeam,
desktopLoginTeam,
separator(),
@@ -64,4 +64,4 @@ const OrganizationMenu: React.FC = ({ children }: Props) => {
);
};
export default observer(OrganizationMenu);
export default observer(TeamMenu);
+39
View File
@@ -0,0 +1,39 @@
import { action, observable } from "mobx";
import { client } from "~/utils/ApiClient";
import User from "../User";
import ParanoidModel from "../base/ParanoidModel";
import Field from "../decorators/Field";
import Relation from "../decorators/Relation";
import OAuthClient from "./OAuthClient";
class OAuthAuthentication extends ParanoidModel {
static modelName = "OAuthAuthentication";
/** A list of scopes that this authentication has access to */
@Field
@observable
scope: string[];
@Relation(() => User)
user: User;
userId: string;
oauthClient: Pick<OAuthClient, "id" | "name" | "clientId" | "avatarUrl">;
oauthClientId: string;
lastActiveAt: string;
@action
public async deleteAll() {
await client.post(`/${this.store.apiEndpoint}.delete`, {
oauthClientId: this.oauthClientId,
scope: this.scope,
});
return this.store.remove(this.id);
}
}
export default OAuthAuthentication;
+92
View File
@@ -0,0 +1,92 @@
import invariant from "invariant";
import { observable, runInAction } from "mobx";
import queryString from "query-string";
import env from "~/env";
import { client } from "~/utils/ApiClient";
import User from "../User";
import ParanoidModel from "../base/ParanoidModel";
import Field from "../decorators/Field";
import Relation from "../decorators/Relation";
class OAuthClient extends ParanoidModel {
static modelName = "OAuthClient";
/** The human-readable name of this app */
@Field
@observable
name: string;
/** A short description of this app */
@Field
@observable
description: string | null;
/** The name of the developer of this app */
@Field
@observable
developerName: string | null;
/** The URL of the developer of this app */
@Field
@observable
developerUrl: string | null;
/** The URL of the avatar of the developer of this app */
@Field
@observable
avatarUrl: string | null;
/** The public identifier of this app */
@Field
clientId: string;
/** The secret key used to authenticate this app */
@Field
@observable
clientSecret: string;
/** Whether this app is published (available to other workspaces) */
@Field
@observable
published: boolean;
/** A list of valid redirect URIs for this app */
@Field
@observable
redirectUris: string[];
@Relation(() => User)
createdBy: User;
createdById: string;
// instance methods
public async rotateClientSecret() {
const res = await client.post("/oauthClients.rotate_secret", {
id: this.id,
});
invariant(res.data, "Failed to rotate client secret");
runInAction("OAuthClient#rotateSecret", () => {
this.clientSecret = res.data.clientSecret;
});
}
public get initial() {
return this.name[0];
}
public get authorizationUrl(): string {
const params = {
client_id: this.clientId,
redirect_uri: this.redirectUris[0],
response_type: "code",
scope: "read",
};
return `${env.URL}/oauth/authorize?${queryString.stringify(params)}`;
}
}
export default OAuthClient;
+66 -44
View File
@@ -42,55 +42,77 @@ const RedirectDocument = ({
/>
);
/**
* The authenticated routes are all the routes of the application that require
* the user to be logged in.
*/
function AuthenticatedRoutes() {
const team = useCurrentTeam();
const can = usePolicy(team);
return (
<WebsocketProvider>
<AuthenticatedLayout>
<React.Suspense
fallback={
<CenteredContent>
<PlaceholderDocument />
</CenteredContent>
}
>
<Switch>
{can.createDocument && (
<Route exact path={draftsPath()} component={Drafts} />
)}
{can.createDocument && (
<Route exact path={archivePath()} component={Archive} />
)}
{can.createDocument && (
<Route exact path={trashPath()} component={Trash} />
)}
<Route path={`${homePath()}/:tab?`} component={Home} />
<Redirect from="/dashboard" to={homePath()} />
<Redirect exact from="/starred" to={homePath()} />
<Redirect exact from="/templates" to={settingsPath("templates")} />
<Redirect exact from="/collections/*" to="/collection/*" />
<Route exact path="/collection/:id/new" component={DocumentNew} />
<Route exact path="/collection/:id/:tab?" component={Collection} />
<Route exact path="/doc/new" component={DocumentNew} />
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
<Route
exact
path={`/doc/${slug}/history/:revisionId?`}
component={Document}
/>
<Route exact path={`/doc/${slug}/insights`} component={Document} />
<Route exact path={`/doc/${slug}/edit`} component={Document} />
<Route path={`/doc/${slug}`} component={Document} />
<Route exact path={`${searchPath()}/:query?`} component={Search} />
<Route path="/404" component={Error404} />
<SettingsRoutes />
<Route component={Error404} />
</Switch>
</React.Suspense>
</AuthenticatedLayout>
</WebsocketProvider>
<Switch>
<WebsocketProvider>
<AuthenticatedLayout>
<React.Suspense
fallback={
<CenteredContent>
<PlaceholderDocument />
</CenteredContent>
}
>
<Switch>
{can.createDocument && (
<Route exact path={draftsPath()} component={Drafts} />
)}
{can.createDocument && (
<Route exact path={archivePath()} component={Archive} />
)}
{can.createDocument && (
<Route exact path={trashPath()} component={Trash} />
)}
<Route path={`${homePath()}/:tab?`} component={Home} />
<Redirect from="/dashboard" to={homePath()} />
<Redirect exact from="/starred" to={homePath()} />
<Redirect
exact
from="/templates"
to={settingsPath("templates")}
/>
<Redirect exact from="/collections/*" to="/collection/*" />
<Route exact path="/collection/:id/new" component={DocumentNew} />
<Route
exact
path="/collection/:id/:tab?"
component={Collection}
/>
<Route exact path="/doc/new" component={DocumentNew} />
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
<Route
exact
path={`/doc/${slug}/history/:revisionId?`}
component={Document}
/>
<Route
exact
path={`/doc/${slug}/insights`}
component={Document}
/>
<Route exact path={`/doc/${slug}/edit`} component={Document} />
<Route path={`/doc/${slug}`} component={Document} />
<Route
exact
path={`${searchPath()}/:query?`}
component={Search}
/>
<Route path="/404" component={Error404} />
<SettingsRoutes />
<Route component={Error404} />
</Switch>
</React.Suspense>
</AuthenticatedLayout>
</WebsocketProvider>
</Switch>
);
}
+8 -6
View File
@@ -6,14 +6,15 @@ import FullscreenLoading from "~/components/FullscreenLoading";
import Route from "~/components/ProfiledRoute";
import env from "~/env";
import useQueryNotices from "~/hooks/useQueryNotices";
import lazyWithRetry from "~/utils/lazyWithRetry";
import lazy from "~/utils/lazyWithRetry";
import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
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"));
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"));
export default function Routes() {
useQueryNotices();
@@ -43,6 +44,7 @@ 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} />
+7
View File
@@ -7,6 +7,7 @@ import useSettingsConfig from "~/hooks/useSettingsConfig";
import lazy from "~/utils/lazyWithRetry";
import { matchDocumentSlug, settingsPath } from "~/utils/routeHelpers";
const Application = lazy(() => import("~/scenes/Settings/Application"));
const Document = lazy(() => import("~/scenes/Document"));
export default function SettingsRoutes() {
@@ -22,6 +23,12 @@ export default function SettingsRoutes() {
component={config.component}
/>
))}
{/* TODO: Refactor these exceptions into config? */}
<Route
exact
path={`${settingsPath("applications")}/:id`}
component={Application}
/>
<Route
exact
path={`${settingsPath("templates")}/${matchDocumentSlug}`}
@@ -6,15 +6,18 @@ 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 Text from "./Text";
import { Properties } from "~/types";
const extensions = withUIExtensions(richExtensions);
@@ -22,8 +25,8 @@ type Props = {
collection: Collection;
};
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
function Overview({ collection }: Props) {
const { documents, collections } = useStores();
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: true });
const can = usePolicy(collection);
@@ -54,6 +57,24 @@ function CollectionDescription({ 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 />}
@@ -65,6 +86,7 @@ function CollectionDescription({ collection }: Props) {
placeholder={`${t("Add a description")}`}
extensions={extensions}
maxLength={CollectionValidation.maxDescriptionLength}
onCreateLink={onCreateLink}
canUpdate={can.update}
readOnly={!can.update}
userId={user.id}
@@ -83,4 +105,4 @@ const Placeholder = styled(Text)`
min-height: 27px;
`;
export default observer(CollectionDescription);
export default observer(Overview);
+2 -2
View File
@@ -20,7 +20,6 @@ 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";
@@ -46,6 +45,7 @@ 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"));
@@ -259,7 +259,7 @@ const CollectionScene = observer(function _CollectionScene() {
path={collectionPath(collection.path, CollectionPath.Overview)}
>
{hasOverview ? (
<CollectionDescription collection={collection} />
<Overview collection={collection} />
) : (
<Redirect
to={{
@@ -13,7 +13,6 @@ import { Config } from "~/stores/AuthStore";
import { AvatarSize } from "~/components/Avatar";
import ButtonLarge from "~/components/ButtonLarge";
import ChangeLanguage from "~/components/ChangeLanguage";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import OutlineIcon from "~/components/Icons/OutlineIcon";
@@ -30,21 +29,23 @@ import {
} from "~/hooks/useLastVisitedPath";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { draggableOnDesktop } from "~/styles";
import Desktop from "~/utils/Desktop";
import isCloudHosted from "~/utils/isCloudHosted";
import { detectLanguage } from "~/utils/language";
import { homePath } from "~/utils/routeHelpers";
import AuthenticationProvider from "./components/AuthenticationProvider";
import BackButton from "./components/BackButton";
import Notices from "./components/Notices";
import { BackButton } from "./components/BackButton";
import { Background } from "./components/Background";
import { Centered } from "./components/Centered";
import { Notices } from "./components/Notices";
import { getRedirectUrl, navigateToSubdomain } from "./urls";
type Props = {
children?: (config?: Config) => React.ReactNode;
onBack?: () => void;
};
function Login({ children }: Props) {
function Login({ children, onBack }: Props) {
const location = useLocation();
const query = useQuery();
const notice = query.get("notice");
@@ -110,9 +111,9 @@ function Login({ children }: Props) {
if (error) {
return (
<Background>
<BackButton />
<BackButton onBack={onBack} />
<ChangeLanguage locale={detectLanguage()} />
<Centered align="center" justify="center" column auto>
<Centered>
<PageTitle title={t("Login")} />
<Heading centered>{t("Error")}</Heading>
<Note>
@@ -142,9 +143,9 @@ function Login({ children }: Props) {
if (isCloudHosted && isCustomDomain && !config.name) {
return (
<Background>
<BackButton config={config} />
<BackButton onBack={onBack} config={config} />
<ChangeLanguage locale={detectLanguage()} />
<Centered align="center" justify="center" column auto>
<Centered>
<PageTitle title={t("Custom domain setup")} />
<Heading centered>{t("Almost there")}</Heading>
<Note>
@@ -160,17 +161,10 @@ function Login({ children }: Props) {
if (Desktop.isElectron() && notice === "domain-required") {
return (
<Background>
<BackButton config={config} />
<BackButton onBack={onBack} config={config} />
<ChangeLanguage locale={detectLanguage()} />
<Centered
as="form"
onSubmit={handleGoSubdomain}
align="center"
justify="center"
column
auto
>
<Centered as="form" onSubmit={handleGoSubdomain}>
<Heading centered>{t("Choose workspace")}</Heading>
<Note>
{t(
@@ -206,8 +200,8 @@ function Login({ children }: Props) {
if (emailLinkSentTo) {
return (
<Background>
<BackButton config={config} />
<Centered align="center" justify="center" column auto>
<BackButton onBack={onBack} config={config} />
<Centered>
<PageTitle title={t("Check your email")} />
<CheckEmailIcon size={38} />
<Heading centered>{t("Check your email")}</Heading>
@@ -241,10 +235,10 @@ function Login({ children }: Props) {
return (
<Background>
<BackButton config={config} />
<BackButton onBack={onBack} config={config} />
<ChangeLanguage locale={detectLanguage()} />
<Centered align="center" justify="center" gap={12} column auto>
<Centered gap={12}>
<PageTitle
title={config.name ? `${config.name} ${t("Login")}` : t("Login")}
/>
@@ -336,14 +330,6 @@ const CheckEmailIcon = styled(EmailIcon)`
margin-bottom: -1.5em;
`;
const Background = styled(Fade)`
width: 100vw;
height: 100%;
background: ${s("background")};
display: flex;
${draggableOnDesktop()}
`;
const Logo = styled.div`
margin-bottom: -4px;
`;
@@ -389,12 +375,4 @@ const Or = styled.hr`
}
`;
const Centered = styled(Flex)`
user-select: none;
width: 90vw;
height: 100%;
max-width: 320px;
margin: 0 auto;
`;
export default observer(Login);
+260
View File
@@ -0,0 +1,260 @@
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 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.
* It retrieves the OAuth client information, displays the authorization request,
* and allows the user to either authorize or cancel the request.
*/
function Authorize() {
const team = useCurrentTeam();
const params = useQuery();
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = React.useState(false);
const timeoutRef = React.useRef<number>();
const {
client_id: clientId,
redirect_uri: redirectUri,
response_type: responseType,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
state,
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 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 {
window.location.href = "/";
}
};
const handleSubmit = () => {
setIsSubmitting(true);
timeoutRef.current = window.setTimeout(() => setIsSubmitting(false), 5000);
};
React.useEffect(
() => () => {
timeoutRef.current && window.clearTimeout(timeoutRef.current);
},
[]
);
const missingParams = [
!clientId && "client_id",
!redirectUri && "redirect_uri",
!responseType && "response_type",
!scope && "scope",
!state && "state",
].filter(Boolean);
if (missingParams.length || clientError) {
return (
<Background>
<Centered>
<StyledHeading>{t("An error occurred")}</StyledHeading>
{clientError instanceof NotFoundError ? (
<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")}
<Pre>
{missingParams.map((param) => (
<>
{param}
<br />
</>
))}
</Pre>
</Text>
)}
</Centered>
</Background>
);
}
if (!response) {
return <LoadingIndicator />;
}
const { name, developerName, developerUrl } = response.data;
return (
<Background>
<ChangeLanguage locale={detectLanguage()} />
<PageTitle title={t("Authorize")} />
<Centered gap={12}>
<ConnectHeader team={team} oauthClient={response.data} />
<StyledHeading>
{t(`{{ appName }} wants to access {{ teamName }}`, {
appName: name,
teamName: team.name,
})}
</StyledHeading>
{developerName && (
<Text type="secondary" as="p" style={{ marginTop: -12 }}>
<Trans
defaults="By <em>{{ developerName }}</em>"
values={{
developerName,
}}
components={{
em: developerUrl ? (
<Text
as="a"
type="secondary"
weight="bold"
href={developerUrl}
target="_blank"
rel="noopener noreferrer"
/>
) : (
<strong />
),
}}
/>
</Text>
)}
<Text type="tertiary" as="p">
{t(
"{{ appName }} will be able to access your account and perform the following actions",
{
appName: name,
}
)}
:
</Text>
<ul style={{ width: "100%", paddingLeft: "1em", marginTop: 0 }}>
{OAuthScopeHelper.normalizeScopes(scopes, t).map((item) => (
<li key={item}>
<Text type="secondary">{item}</Text>
</li>
))}
</ul>
<form
method="POST"
action="/oauth/authorize"
style={{ width: "100%" }}
onSubmit={handleSubmit}
>
<input type="hidden" name="client_id" value={clientId ?? ""} />
<input type="hidden" name="redirect_uri" value={redirectUri ?? ""} />
<input
type="hidden"
name="response_type"
value={responseType ?? ""}
/>
<input type="hidden" name="state" value={state ?? ""} />
<input type="hidden" name="scope" value={scope ?? ""} />
{codeChallenge && (
<input type="hidden" name="code_challenge" value={codeChallenge} />
)}
{codeChallengeMethod && (
<input
type="hidden"
name="code_challenge_method"
value={codeChallengeMethod}
/>
)}
<Flex gap={8} justify="space-between">
<Button type="button" onClick={handleCancel} neutral>
{t("Cancel")}
</Button>
<Button type="submit" disabled={isSubmitting}>
{t("Authorize")}
</Button>
</Flex>
</form>
</Centered>
</Background>
);
}
const Button = styled(ButtonLarge)`
width: calc(50% - 4px);
`;
const StyledHeading = styled(Heading).attrs({
as: "h2",
centered: true,
})`
margin-top: 0;
`;
const Pre = styled.pre`
background: ${s("backgroundSecondary")};
padding: 16px;
border-radius: 4px;
font-size: 12px;
white-space: pre-wrap;
`;
+56
View File
@@ -0,0 +1,56 @@
import type { TFunction } from "i18next";
import capitalize from "lodash/capitalize";
import uniq from "lodash/uniq";
import { Scope } from "@shared/types";
export class OAuthScopeHelper {
public static normalizeScopes(scopes: string[], t: TFunction): string[] {
const methodToReadable = {
list: t("read"),
info: t("read"),
read: t("read"),
write: t("write"),
create: t("write"),
update: t("write"),
delete: t("write"),
"*": t("read and write"),
};
const translatedNamespaces = {
apiKeys: t("API keys"),
attachments: t("attachments"),
collections: t("collections"),
comments: t("comments"),
documents: t("documents"),
events: t("events"),
groups: t("groups"),
integrations: t("integrations"),
notifications: t("notifications"),
reactions: t("reactions"),
pins: t("pins"),
shares: t("shares"),
users: t("users"),
teams: t("teams"),
"*": t("workspace"),
};
const normalizedScopes = scopes.map((scope) => {
if (scope === Scope.Read) {
return t("Read all data");
}
if (scope === Scope.Write) {
return t("Write all data");
}
const [namespace, method] = scope.replace("/api/", "").split(/[:\.]/g);
const readableMethod =
methodToReadable[method as keyof typeof methodToReadable] ?? method;
const translatedNamespace =
translatedNamespaces[namespace as keyof typeof translatedNamespaces] ??
namespace;
return capitalize(`${readableMethod} ${translatedNamespace}`);
});
return uniq(normalizedScopes);
}
}
+10 -1
View File
@@ -10,12 +10,21 @@ import isCloudHosted from "~/utils/isCloudHosted";
type Props = {
config?: Config;
onBack?: () => void;
};
export default function BackButton({ config }: Props) {
export function BackButton({ onBack, 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;
}
@@ -0,0 +1,12 @@
import styled from "styled-components";
import { s } from "@shared/styles";
import Fade from "~/components/Fade";
import { draggableOnDesktop } from "~/styles";
export const Background = styled(Fade)`
width: 100vw;
height: 100%;
background: ${s("background")};
display: flex;
${draggableOnDesktop()}
`;
+15
View File
@@ -0,0 +1,15 @@
import styled from "styled-components";
import Flex from "@shared/components/Flex";
export const Centered = styled(Flex).attrs({
align: "center",
justify: "center",
column: true,
auto: true,
})`
user-select: none;
width: 90vw;
height: 100%;
max-width: 320px;
margin: 0 auto;
`;
@@ -0,0 +1,40 @@
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 -1
View File
@@ -123,7 +123,7 @@ function Message({ notice }: { notice: string }) {
}
}
export default function Notices() {
export function Notices() {
const query = useQuery();
const notice = query.get("notice");
@@ -0,0 +1,109 @@
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;
`;
+3
View File
@@ -0,0 +1,3 @@
import Login from "./Login";
export default Login;
+107
View File
@@ -0,0 +1,107 @@
import { observer } from "mobx-react";
import { PadlockIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import ApiKey from "~/models/ApiKey";
import OAuthAuthentication from "~/models/oauth/OAuthAuthentication";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import PaginatedList from "~/components/PaginatedList";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import { createApiKey } from "~/actions/definitions/apiKeys";
import env from "~/env";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import ApiKeyListItem from "./components/ApiKeyListItem";
import OAuthAuthenticationListItem from "./components/OAuthAuthenticationListItem";
function APIAndApps() {
const team = useCurrentTeam();
const user = useCurrentUser();
const { t } = useTranslation();
const { apiKeys, oauthAuthentications } = useStores();
const can = usePolicy(team);
const context = useActionContext();
const appName = env.APP_NAME;
return (
<Scene
title={t("API & Apps")}
icon={<PadlockIcon />}
actions={
<>
{can.createApiKey && (
<Action>
<Button
type="submit"
value={`${t("New API key")}`}
action={createApiKey}
context={context}
/>
</Action>
)}
</>
}
>
<Heading>{t("API & Apps")}</Heading>
<h2>{t("API keys")}</h2>
{can.createApiKey ? (
<Text as="p" type="secondary">
<Trans
defaults="Create personal API keys to authenticate with the API and programatically control
your workspace's data. For more details see the <em>developer documentation</em>."
components={{
em: (
<a
href="https://www.getoutline.com/developers"
target="_blank"
rel="noreferrer"
/>
),
}}
/>
</Text>
) : (
<Trans>
{t("API keys have been disabled by an admin for your account")}
</Trans>
)}
<PaginatedList<ApiKey>
fetch={apiKeys.fetchPage}
items={apiKeys.personalApiKeys}
options={{ userId: user.id }}
renderItem={(apiKey) => (
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
)}
/>
<PaginatedList
fetch={oauthAuthentications.fetchPage}
items={oauthAuthentications.orderedData}
heading={
<>
<h2>{t("Application access")}</h2>
<Text as="p" type="secondary">
{t(
"Manage which third-party and internal applications have been granted access to your {{ appName }} account.",
{ appName }
)}
</Text>
</>
}
renderItem={(oauthAuthentication: OAuthAuthentication) => (
<OAuthAuthenticationListItem
key={oauthAuthentication.id}
oauthAuthentication={oauthAuthentication}
/>
)}
/>
</Scene>
);
}
export default observer(APIAndApps);
-1
View File
@@ -61,7 +61,6 @@ function ApiKeys() {
<PaginatedList<ApiKey>
fetch={apiKeys.fetchPage}
items={apiKeys.orderedData}
heading={<h2>{t("All")}</h2>}
renderItem={(apiKey) => (
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
)}
+325
View File
@@ -0,0 +1,325 @@
import { observer } from "mobx-react";
import { CopyIcon, InternetIcon, ReplaceIcon } from "outline-icons";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { toast } from "sonner";
import { OAuthClientValidation } from "@shared/validations";
import OAuthClient from "~/models/oauth/OAuthClient";
import Breadcrumb from "~/components/Breadcrumb";
import Button from "~/components/Button";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ContentEditable from "~/components/ContentEditable";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import LoadingIndicator from "~/components/LoadingIndicator";
import NudeButton from "~/components/NudeButton";
import { FormData } from "~/components/OAuthClient/OAuthClientForm";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
import Tooltip from "~/components/Tooltip";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import OAuthClientMenu from "~/menus/OAuthClientMenu";
import isCloudHosted from "~/utils/isCloudHosted";
import { settingsPath } from "~/utils/routeHelpers";
import { ActionRow } from "./components/ActionRow";
import { CopyButton } from "./components/CopyButton";
import ImageInput from "./components/ImageInput";
import SettingRow from "./components/SettingRow";
type Props = {
oauthClient: OAuthClient;
};
const LoadingState = observer(function LoadingState() {
const { id } = useParams<{ id: string }>();
const { oauthClients } = useStores();
const oauthClient = oauthClients.get(id);
const { request } = useRequest(() => oauthClients.fetch(id));
React.useEffect(() => {
if (!oauthClient) {
void request();
}
}, [oauthClient]);
if (!oauthClient) {
return <LoadingIndicator />;
}
return <Application oauthClient={oauthClient} />;
});
const Application = observer(function Application({ oauthClient }: Props) {
const { t } = useTranslation();
const { dialogs } = useStores();
const {
register,
handleSubmit: formHandleSubmit,
formState,
getValues,
setError,
control,
} = useForm<FormData>({
mode: "all",
defaultValues: {
name: oauthClient.name ?? "",
developerName: oauthClient.developerName ?? "",
developerUrl: oauthClient.developerUrl ?? "",
description: oauthClient.description ?? "",
avatarUrl: oauthClient.avatarUrl ?? "",
redirectUris: oauthClient.redirectUris ?? [],
published: oauthClient.published ?? false,
},
});
const handleSubmit = React.useCallback(
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]
);
const handleRotateSecret = React.useCallback(async () => {
const onDelete = async () => {
try {
await oauthClient.rotateClientSecret();
toast.success(t("Client secret rotated"));
} catch (err) {
toast.error(err.message);
}
};
dialogs.openModal({
title: t("Rotate secret"),
content: (
<ConfirmationDialog onSubmit={onDelete} danger>
{t(
"Rotating the client secret will invalidate the current secret. Make sure to update any applications using these credentials."
)}
</ConfirmationDialog>
),
});
}, [t, dialogs, oauthClient]);
return (
<Scene
title={oauthClient.name}
left={
<Breadcrumb
items={[
{
type: "route",
title: t("Applications"),
to: settingsPath("applications"),
icon: <InternetIcon />,
},
]}
/>
}
actions={<OAuthClientMenu oauthClient={oauthClient} showEdit={false} />}
>
<form onSubmit={formHandleSubmit(handleSubmit)}>
<Heading>
<Controller
control={control}
name="name"
render={({ field }) => (
<ContentEditable
value={field.value}
placeholder={t("Name")}
onChange={field.onChange}
maxLength={OAuthClientValidation.maxNameLength}
/>
)}
/>
</Heading>
<SettingRow
label={t("Icon")}
name="avatarUrl"
description={t("Displayed to users when authorizing")}
>
<Controller
control={control}
name="avatarUrl"
render={({ field }) => (
<ImageInput
onSuccess={(url) => field.onChange(url)}
onError={(err) => setError("avatarUrl", { message: err })}
model={{
id: oauthClient.id,
avatarUrl: field.value,
initial: getValues().name[0],
}}
borderRadius={0}
/>
)}
/>
</SettingRow>
<SettingRow
name="description"
label={t("Tagline")}
description={t("A short description")}
>
<Input
type="text"
{...register("description", {
maxLength: OAuthClientValidation.maxDescriptionLength,
})}
flex
/>
</SettingRow>
<SettingRow
name="details"
label={t("Details")}
description={t(
"Developer information shown to users when authorizing"
)}
border={isCloudHosted}
>
<Input
type="text"
label={t("Developer name")}
{...register("developerName", {
maxLength: OAuthClientValidation.maxDeveloperNameLength,
})}
flex
/>
<Input
type="text"
label={t("Developer URL")}
{...register("developerUrl", {
maxLength: OAuthClientValidation.maxDeveloperUrlLength,
})}
flex
/>
</SettingRow>
{isCloudHosted && (
<SettingRow
name="published"
label={t("Published")}
description={t(
"Allow users from other workspaces to authorize this app"
)}
border={false}
>
<Switch id="published" {...register("published")} />
</SettingRow>
)}
<h2>{t("Credentials")}</h2>
<SettingRow
name="clientId"
label={t("OAuth client ID")}
description={t("The public identifier for this app")}
>
<Input id="clientId" value={oauthClient.clientId} readOnly>
<CopyButton
value={oauthClient.clientId}
success={t("Copied to clipboard")}
tooltip={t("Copy")}
icon={<CopyIcon size={20} />}
/>
</Input>
</SettingRow>
<SettingRow
name="clientSecret"
label={t("OAuth client secret")}
description={t(
"Store this value securely, do not expose it publicly"
)}
>
<Input
id="clientSecret"
type="password"
value={oauthClient.clientSecret}
readOnly
>
<Tooltip content={t("Rotate secret")} placement="top">
<NudeButton type="button" onClick={handleRotateSecret}>
<ReplaceIcon size={20} />
</NudeButton>
</Tooltip>
<CopyButton
value={oauthClient.clientSecret}
success={t("Copied to clipboard")}
tooltip={t("Copy")}
icon={<CopyIcon size={20} />}
/>
</Input>
</SettingRow>
<SettingRow
name="redirectUris"
label={t("Callback URLs")}
description={t(
"Where users are redirected after authorizing this app"
)}
>
<Controller
control={control}
name="redirectUris"
render={({ field }) => (
<Input
id="redirectUris"
type="textarea"
placeholder="https://example.com/callback"
ref={field.ref}
value={field.value.join("\n")}
rows={Math.max(2, field.value.length + 1)}
onChange={(event) => {
field.onChange(event.target.value.split("\n"));
}}
required
/>
)}
/>
</SettingRow>
<SettingRow
name="authorizationUrl"
label={t("Authorization URL")}
description={t("Where users are redirected to authorize this app")}
border={false}
>
<Input
id="authorizationUrl"
value={oauthClient.authorizationUrl}
readOnly
>
<CopyButton
value={oauthClient.authorizationUrl}
success={t("Copied to clipboard")}
tooltip={t("Copy link")}
/>
</Input>
</SettingRow>
<ActionRow>
<Button
type="submit"
disabled={formState.isSubmitting || !formState.isValid}
>
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</ActionRow>
</form>
</Scene>
);
});
export default LoadingState;
@@ -1,42 +1,40 @@
import { observer } from "mobx-react";
import { CodeIcon } from "outline-icons";
import { InternetIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import ApiKey from "~/models/ApiKey";
import OAuthClient from "~/models/oauth/OAuthClient";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import PaginatedList from "~/components/PaginatedList";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import { createApiKey } from "~/actions/definitions/apiKeys";
import { createOAuthClient } from "~/actions/definitions/oauthClients";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import ApiKeyListItem from "./components/ApiKeyListItem";
import OAuthClientListItem from "./components/OAuthClientListItem";
function PersonalApiKeys() {
function Applications() {
const team = useCurrentTeam();
const user = useCurrentUser();
const { t } = useTranslation();
const { apiKeys } = useStores();
const { oauthClients } = useStores();
const can = usePolicy(team);
const context = useActionContext();
return (
<Scene
title={t("API")}
icon={<CodeIcon />}
title={t("Applications")}
icon={<InternetIcon />}
actions={
<>
{can.createApiKey && (
{can.createOAuthClient && (
<Action>
<Button
type="submit"
value={`${t("New API key")}`}
action={createApiKey}
value={`${t("New App")}`}
action={createOAuthClient}
context={context}
/>
</Action>
@@ -44,12 +42,10 @@ function PersonalApiKeys() {
</>
}
>
<Heading>{t("API")}</Heading>
<Heading>{t("Applications")}</Heading>
<Text as="p" type="secondary">
<Trans
defaults="Create personal API keys to authenticate with the API and programatically control
your workspace's data. API keys have the same permissions as your user account.
For more details see the <em>developer documentation</em>."
defaults="Applications allow you to build internal or public integrations with Outline and provide secure access via OAuth. For more details see the <em>developer documentation</em>."
components={{
em: (
<a
@@ -61,17 +57,15 @@ function PersonalApiKeys() {
}}
/>
</Text>
<PaginatedList<ApiKey>
fetch={apiKeys.fetchPage}
items={apiKeys.personalApiKeys}
options={{ userId: user.id }}
heading={<h2>{t("Personal keys")}</h2>}
renderItem={(apiKey) => (
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
<PaginatedList<OAuthClient>
fetch={oauthClients.fetchPage}
items={oauthClients.orderedData}
renderItem={(oauthClient) => (
<OAuthClientListItem key={oauthClient.id} oauthClient={oauthClient} />
)}
/>
</Scene>
);
}
export default observer(PersonalApiKeys);
export default observer(Applications);
+10 -1
View File
@@ -108,7 +108,16 @@ function Details() {
toast.error(err.message);
}
},
[team, name, subdomain, defaultCollectionId, publicBranding, customTheme, t]
[
tocPosition,
team,
name,
subdomain,
defaultCollectionId,
publicBranding,
customTheme,
t,
]
);
const handleNameChange = React.useCallback(
@@ -0,0 +1,50 @@
import { LinkIcon } from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import CopyToClipboard from "~/components/CopyToClipboard";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
type Props = {
/** The value to be copied */
value: string;
/** The message to show when the value is copied */
success: string;
/** The tooltip message */
tooltip: string;
/** An optional icon */
icon?: React.ReactNode;
};
/**
* A button that copies a value to the clipboard when clicked and shows a
* single icon.
*/
export function CopyButton({
value,
success,
tooltip,
icon = <LinkIcon size={20} />,
}: Props) {
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
const handleCopied = React.useCallback(() => {
timeout.current = setTimeout(() => {
toast.message(success);
}, 100);
return () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
};
}, [success]);
return (
<Tooltip content={tooltip} placement="top">
<CopyToClipboard text={value} onCopy={handleCopied}>
<NudeButton type="button">{icon}</NudeButton>
</CopyToClipboard>
</Tooltip>
);
}
+13 -7
View File
@@ -1,8 +1,10 @@
import { EditIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { Avatar, AvatarSize, IAvatar } from "~/components/Avatar";
import { AvatarVariant } from "~/components/Avatar/Avatar";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import ImageUpload, { Props as ImageUploadProps } from "./ImageUpload";
@@ -17,10 +19,18 @@ export default function ImageInput({ model, onSuccess, ...rest }: Props) {
return (
<Flex gap={8} justify="space-between">
<ImageBox>
<ImageUpload onSuccess={onSuccess} {...rest}>
<StyledAvatar model={model} size={AvatarSize.Upload} />
<ImageUpload
onSuccess={onSuccess}
submitText={t("Crop Image")}
{...rest}
>
<Avatar
model={model}
size={AvatarSize.Upload}
variant={AvatarVariant.Square}
/>
<Flex auto align="center" justify="center" className="upload">
{t("Upload")}
<EditIcon />
</Flex>
</ImageUpload>
</ImageBox>
@@ -38,10 +48,6 @@ const avatarStyles = `
height: ${AvatarSize.Upload}px;
`;
const StyledAvatar = styled(Avatar)`
border-radius: 8px;
`;
const ImageBox = styled(Flex)`
${avatarStyles};
position: relative;
@@ -0,0 +1,61 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import OAuthAuthentication from "~/models/oauth/OAuthAuthentication";
import { OAuthScopeHelper } from "~/scenes/Login/OAuthScopeHelper";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { AvatarVariant } from "~/components/Avatar/Avatar";
import ListItem from "~/components/List/Item";
import Text from "~/components/Text";
import Time from "~/components/Time";
import OAuthAuthenticationMenu from "~/menus/OAuthAuthenticationMenu";
type Props = {
/** The OAuthAuthentication to display */
oauthAuthentication: OAuthAuthentication;
};
const OAuthAuthenticationListItem = ({ oauthAuthentication }: Props) => {
const { t } = useTranslation();
const subtitle = (
<>
<Text type="tertiary">
{oauthAuthentication.lastActiveAt ? (
<>
{t("Last active")}{" "}
<Time dateTime={oauthAuthentication.lastActiveAt} addSuffix />
</>
) : (
t("Never used")
)}{" "}
&middot;{" "}
</Text>
<Text type="tertiary" ellipsis>
{OAuthScopeHelper.normalizeScopes(oauthAuthentication.scope, t).join(
", "
)}
</Text>
</>
);
return (
<ListItem
key={oauthAuthentication.id}
image={
<Avatar
model={oauthAuthentication.oauthClient}
size={AvatarSize.Large}
variant={AvatarVariant.Square}
/>
}
title={oauthAuthentication.oauthClient.name}
subtitle={subtitle}
actions={
<OAuthAuthenticationMenu oauthAuthentication={oauthAuthentication} />
}
/>
);
};
export default observer(OAuthAuthenticationListItem);
@@ -0,0 +1,37 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import OAuthClient from "~/models/oauth/OAuthClient";
import ConfirmationDialog from "~/components/ConfirmationDialog";
type Props = {
oauthClient: OAuthClient;
onSubmit: () => void;
};
export default function OAuthClientDeleteDialog({
oauthClient,
onSubmit,
}: Props) {
const { t } = useTranslation();
const handleSubmit = async () => {
await oauthClient.delete();
onSubmit();
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Delete")}
savingText={`${t("Deleting")}`}
danger
>
{t(
"Are you sure you want to delete the {{ appName }} application? This cannot be undone.",
{
appName: oauthClient.name,
}
)}
</ConfirmationDialog>
);
}
@@ -0,0 +1,55 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import OAuthClient from "~/models/oauth/OAuthClient";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { AvatarVariant } from "~/components/Avatar/Avatar";
import ListItem from "~/components/List/Item";
import Text from "~/components/Text";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import OAuthClientMenu from "~/menus/OAuthClientMenu";
import { settingsPath } from "~/utils/routeHelpers";
type Props = {
oauthClient: OAuthClient;
};
const OAuthClientListItem = ({ oauthClient }: Props) => {
const { t } = useTranslation();
const user = useCurrentUser();
const subtitle = (
<>
<Text type="tertiary">
{t(`Created`)} <Time dateTime={oauthClient.createdAt} addSuffix />{" "}
{oauthClient.createdById === user.id
? ""
: t(`by {{ name }}`, { name: user.name })}
</Text>
</>
);
return (
<ListItem
key={oauthClient.id}
image={
<Avatar
model={oauthClient}
size={AvatarSize.Large}
variant={AvatarVariant.Square}
/>
}
title={
<Link to={settingsPath("applications", oauthClient.id)}>
<Text>{oauthClient.name}</Text>
</Link>
}
subtitle={subtitle}
actions={<OAuthClientMenu oauthClient={oauthClient} />}
/>
);
};
export default observer(OAuthClientListItem);
@@ -39,7 +39,7 @@ const Column = styled.div`
flex: 1;
&:first-child {
min-width: 70%;
min-width: 65%;
}
&:last-child {
+11
View File
@@ -0,0 +1,11 @@
import OAuthAuthentication from "~/models/oauth/OAuthAuthentication";
import RootStore from "./RootStore";
import Store from "./base/Store";
export default class OAuthAuthenticationsStore extends Store<OAuthAuthentication> {
apiEndpoint = "oauthAuthentications";
constructor(rootStore: RootStore) {
super(rootStore, OAuthAuthentication);
}
}
+11
View File
@@ -0,0 +1,11 @@
import OAuthClient from "~/models/oauth/OAuthClient";
import RootStore from "./RootStore";
import Store from "./base/Store";
export default class OAuthClientsStore extends Store<OAuthClient> {
apiEndpoint = "oauthClients";
constructor(rootStore: RootStore) {
super(rootStore, OAuthClient);
}
}
+23 -2
View File
@@ -18,6 +18,8 @@ import ImportsStore from "./ImportsStore";
import IntegrationsStore from "./IntegrationsStore";
import MembershipsStore from "./MembershipsStore";
import NotificationsStore from "./NotificationsStore";
import OAuthAuthenticationsStore from "./OAuthAuthenticationsStore";
import OAuthClientsStore from "./OAuthClientsStore";
import PinsStore from "./PinsStore";
import PoliciesStore from "./PoliciesStore";
import RevisionsStore from "./RevisionsStore";
@@ -49,6 +51,8 @@ export default class RootStore {
integrations: IntegrationsStore;
memberships: MembershipsStore;
notifications: NotificationsStore;
oauthAuthentications: OAuthAuthenticationsStore;
oauthClients: OAuthClientsStore;
presence: DocumentPresenceStore;
pins: PinsStore;
policies: PoliciesStore;
@@ -80,6 +84,8 @@ export default class RootStore {
this.registerStore(IntegrationsStore);
this.registerStore(MembershipsStore);
this.registerStore(NotificationsStore);
this.registerStore(OAuthAuthenticationsStore, "oauthAuthentications");
this.registerStore(OAuthClientsStore, "oauthClients");
this.registerStore(PinsStore);
this.registerStore(PoliciesStore);
this.registerStore(RevisionsStore);
@@ -110,8 +116,9 @@ export default class RootStore {
*/
public getStoreForModelName<K extends keyof RootStore>(modelName: string) {
const storeName = this.getStoreNameForModelName(modelName);
invariant(storeName, `No store found for model name "${modelName}"`);
const store = this[storeName];
invariant(store, `No store found for model name "${modelName}"`);
return store as RootStore[K];
}
@@ -139,10 +146,24 @@ export default class RootStore {
// @ts-expect-error TS thinks we are instantiating an abstract class.
const store = new StoreClass(this);
const storeName = name ?? this.getStoreNameForModelName(store.modelName);
invariant(storeName, `No store found for model name "${store.modelName}"`);
this[storeName] = store;
}
private getStoreNameForModelName(modelName: string) {
return pluralize(lowerFirst(modelName)) as keyof RootStore;
for (const key of Object.keys(this)) {
const store = this[key as keyof RootStore];
if (store && "modelName" in store && store.modelName === modelName) {
return key as keyof RootStore;
}
}
const storeName = pluralize(lowerFirst(modelName)) as keyof RootStore;
if (storeName) {
return storeName;
}
return undefined;
}
}
+2 -2
View File
@@ -27,8 +27,8 @@ export function trashPath(): string {
return "/trash";
}
export function settingsPath(section?: string): string {
return "/settings" + (section ? `/${section}` : "");
export function settingsPath(...args: string[]): string {
return "/settings" + (args.length > 0 ? `/${args.join("/")}` : "");
}
export function commentPath(document: Document, comment: Comment): string {
+19 -17
View File
@@ -48,18 +48,18 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.797.0",
"@aws-sdk/lib-storage": "3.797.0",
"@aws-sdk/s3-presigned-post": "3.797.0",
"@aws-sdk/s3-request-presigner": "3.797.0",
"@aws-sdk/signature-v4-crt": "^3.796.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",
"@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",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^6.7.10",
"@bull-board/koa": "^6.7.10",
@@ -80,6 +80,7 @@
"@joplin/turndown-plugin-gfm": "^1.0.49",
"@juggle/resize-observer": "^3.4.0",
"@linear/sdk": "^39.0.0",
"@node-oauth/oauth2-server": "^5.2.0",
"@notionhq/client": "^2.3.0",
"@octokit/auth-app": "^6.1.3",
"@outlinewiki/koa-passport": "^4.2.1",
@@ -207,7 +208,7 @@
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.54.2",
"react-i18next": "^12.3.1",
"react-medium-image-zoom": "5.2.13",
"react-medium-image-zoom": "5.2.14",
"react-merge-refs": "^2.1.1",
"react-portal": "^4.3.0",
"react-router-dom": "^5.3.4",
@@ -227,6 +228,7 @@
"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",
@@ -247,7 +249,7 @@
"umzug": "^3.8.2",
"utility-types": "^3.11.0",
"uuid": "^8.3.2",
"validator": "13.12.0",
"validator": "13.15.0",
"vaul": "^1.1.2",
"vite": "^6.3.4",
"vite-plugin-pwa": "^0.21.2",
@@ -261,8 +263,8 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@babel/cli": "^7.27.0",
"@babel/preset-typescript": "^7.27.0",
"@babel/cli": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.0",
"@testing-library/react": "^12.0.0",
@@ -327,7 +329,7 @@
"@types/tmp": "^0.2.6",
"@types/turndown": "^5.0.5",
"@types/utf8": "^3.0.3",
"@types/validator": "^13.12.1",
"@types/validator": "^13.15.0",
"@types/yauzl": "^2.10.3",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
+20 -20
View File
@@ -288,7 +288,7 @@ export class NotionConverter {
if (item.mention.type === "link_mention") {
return {
type: "text",
text: item.plain_text,
text: item.plain_text || item.mention.link_mention.href,
marks: [
{
type: "link",
@@ -302,7 +302,7 @@ export class NotionConverter {
if (item.mention.type === "link_preview") {
return {
type: "text",
text: item.plain_text,
text: item.plain_text || item.mention.link_preview.url,
marks: [
{
type: "link",
@@ -314,14 +314,14 @@ export class NotionConverter {
};
}
if (!item.plain_text) {
return undefined;
if (item.plain_text) {
return {
type: "text",
text: item.plain_text,
};
}
return {
type: "text",
text: item.plain_text,
};
return undefined;
}
if (item.type === "equation") {
@@ -336,20 +336,20 @@ export class NotionConverter {
};
}
if (!item.text.content) {
return undefined;
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),
};
}
return {
type: "text",
text: item.text.content,
marks: [
...mapAttrs(),
...(item.text.link
? [{ type: "link", attrs: { href: item.text.link.url } }]
: []),
].filter(Boolean),
};
return undefined;
}
private static rich_text_to_plaintext(item: RichTextItemResponse) {
@@ -8,7 +8,7 @@ import validate from "@server/middlewares/validate";
import { WebhookSubscription } from "@server/models";
import { authorize } from "@server/policies";
import pagination from "@server/routes/api/middlewares/pagination";
import { APIContext } from "@server/types";
import { APIContext, AuthenticationType } from "@server/types";
import presentWebhookSubscription from "../presenters/webhookSubscription";
import * as T from "./schema";
@@ -41,7 +41,10 @@ router.post(
router.post(
"webhookSubscriptions.create",
auth({ role: UserRole.Admin }),
auth({
role: UserRole.Admin,
type: [AuthenticationType.API, AuthenticationType.APP],
}),
validate(T.WebhookSubscriptionsCreateSchema),
transaction(),
async (ctx: APIContext<T.WebhookSubscriptionsCreateReq>) => {
@@ -68,7 +71,10 @@ router.post(
router.post(
"webhookSubscriptions.delete",
auth({ role: UserRole.Admin }),
auth({
role: UserRole.Admin,
type: [AuthenticationType.API, AuthenticationType.APP],
}),
validate(T.WebhookSubscriptionsDeleteSchema),
transaction(),
async (ctx: APIContext<T.WebhookSubscriptionsDeleteReq>) => {
@@ -94,7 +100,10 @@ router.post(
router.post(
"webhookSubscriptions.update",
auth({ role: UserRole.Admin }),
auth({
role: UserRole.Admin,
type: [AuthenticationType.API, AuthenticationType.APP],
}),
validate(T.WebhookSubscriptionsUpdateSchema),
transaction(),
async (ctx: APIContext<T.WebhookSubscriptionsUpdateReq>) => {
@@ -237,6 +237,11 @@ 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);
}
+19 -30
View File
@@ -1,4 +1,3 @@
import invariant from "invariant";
import { Op, WhereOptions } from "sequelize";
import isUUID from "validator/lib/isUUID";
import { UrlHelper } from "@shared/utils/UrlHelper";
@@ -22,8 +21,8 @@ type Props = {
type Result = {
document: Document;
share?: Share;
collection?: Collection | null;
share: Share | null;
collection: Collection | null;
};
export default async function loadDocument({
@@ -33,9 +32,9 @@ export default async function loadDocument({
user,
includeState,
}: Props): Promise<Result> {
let document;
let collection;
let share;
let document: Document | null = null;
let collection: Collection | null = null;
let share: Share | null = null;
if (!shareId && !(id && user)) {
throw AuthenticationError(`Authentication or shareId required`);
@@ -72,20 +71,7 @@ export default async function loadDocument({
where: whereClause,
include: [
{
// unscoping here allows us to return unpublished documents
model: Document.unscoped(),
include: [
{
model: User,
as: "createdBy",
paranoid: false,
},
{
model: User,
as: "updatedBy",
paranoid: false,
},
],
model: Document.scope("withDrafts"),
required: true,
as: "document",
},
@@ -129,14 +115,13 @@ 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.findByPk(document.collectionId);
if (!collection) {
throw NotFoundError("Collection could not be found for document");
}
collection = await Collection.scope("withDocumentStructure").findByPk(
document.collectionId,
{
rejectOnEmpty: true,
}
);
}
return {
@@ -155,11 +140,15 @@ export default async function loadDocument({
// It is possible to disable sharing at the collection so we must check
if (document.collectionId) {
collection = await Collection.findByPk(document.collectionId);
collection = await Collection.scope("withDocumentStructure").findByPk(
document.collectionId,
{
rejectOnEmpty: true,
}
);
}
invariant(collection, "collection not found");
if (!collection.sharing) {
if (!collection?.sharing) {
throw AuthorizationError();
}
+17 -11
View File
@@ -1,4 +1,3 @@
import invariant from "invariant";
import { Transaction } from "sequelize";
import { createContext } from "@server/context";
import { traceFunction } from "@server/logging/tracing";
@@ -66,16 +65,21 @@ async function documentMover({
result.documents.push(document);
} else {
// Load the current and the next collection upfront and lock them
const collection = await Collection.findByPk(document.collectionId!, {
transaction,
lock: Transaction.LOCK.UPDATE,
paranoid: false,
});
const collection = await Collection.scope("withDocumentStructure").findByPk(
document.collectionId!,
{
transaction,
lock: Transaction.LOCK.UPDATE,
paranoid: false,
}
);
let newCollection = collection;
if (collectionChanged) {
if (collectionId) {
newCollection = await Collection.findByPk(collectionId, {
newCollection = await Collection.scope(
"withDocumentStructure"
).findByPk(collectionId, {
transaction,
lock: Transaction.LOCK.UPDATE,
});
@@ -144,12 +148,14 @@ 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);
+26
View File
@@ -15,6 +15,7 @@ import {
} from "class-validator";
import uniq from "lodash/uniq";
import { languages } from "@shared/i18n";
import { Day, Hour } from "@shared/utils/time";
import { CannotUseWith, CannotUseWithout } from "@server/utils/validators";
import Deprecated from "./models/decorators/Deprecated";
import { getArg } from "./utils/args";
@@ -609,6 +610,31 @@ export class Environment {
public MAXIMUM_EXPORT_SIZE =
this.toOptionalNumber(environment.MAXIMUM_EXPORT_SIZE) ?? os.totalmem();
/**
* The number of seconds access tokens issue by the OAuth provider are valid.
*/
@IsNumber()
public OAUTH_PROVIDER_ACCESS_TOKEN_LIFETIME =
this.toOptionalNumber(environment.OAUTH_PROVIDER_ACCESS_TOKEN_LIFETIME) ??
Hour.seconds;
/**
* The number of seconds refresh tokens issue by the OAuth provider are valid.
*/
@IsNumber()
public OAUTH_PROVIDER_REFRESH_TOKEN_LIFETIME =
this.toOptionalNumber(environment.OAUTH_PROVIDER_REFRESH_TOKEN_LIFETIME) ??
30 * Day.seconds;
/**
* The number of seconds authorization codes issue by the OAuth provider are valid.
*/
@IsNumber()
public OAUTH_PROVIDER_AUTHORIZATION_CODE_LIFETIME =
this.toOptionalNumber(
environment.OAUTH_PROVIDER_AUTHORIZATION_CODE_LIFETIME
) ?? 300;
/**
* Enable unsafe-inline in script-src CSP directive
*/
+115 -3
View File
@@ -1,16 +1,19 @@
import { DefaultState } from "koa";
import randomstring from "randomstring";
import { Scope } from "@shared/types";
import {
buildUser,
buildTeam,
buildAdmin,
buildApiKey,
buildOAuthAuthentication,
} from "@server/test/factories";
import { AuthenticationType } from "@server/types";
import auth from "./authentication";
describe("Authentication middleware", () => {
describe("with JWT", () => {
it("should authenticate with correct token", async () => {
describe("with session JWT", () => {
it("should authenticate with correct session token", async () => {
const state = {} as DefaultState;
const user = await buildUser();
const authMiddleware = auth();
@@ -28,7 +31,7 @@ describe("Authentication middleware", () => {
expect(state.auth.user.id).toEqual(user.id);
});
it("should return error with invalid token", async () => {
it("should return error with invalid session token", async () => {
const state = {} as DefaultState;
const user = await buildUser();
const authMiddleware = auth();
@@ -49,7 +52,32 @@ describe("Authentication middleware", () => {
expect(e.message).toBe("Invalid token");
}
});
it("should return error if AuthenticationType mismatches", async () => {
const state = {} as DefaultState;
const user = await buildUser();
const authMiddleware = auth({
type: AuthenticationType.API,
});
try {
await authMiddleware(
{
// @ts-expect-error mock request
request: {
get: jest.fn(() => `Bearer ${user.getJwtToken()}`),
},
state,
cache: {},
},
jest.fn()
);
} catch (e) {
expect(e.message).toBe("Invalid authentication type");
}
});
});
describe("with API key", () => {
it("should authenticate user with valid API key", async () => {
const state = {} as DefaultState;
@@ -91,6 +119,90 @@ describe("Authentication middleware", () => {
});
});
describe("with OAuth access token", () => {
it("should authenticate user with valid OAuth access token", async () => {
const state = {} as DefaultState;
const user = await buildUser();
const authMiddleware = auth();
const authentication = await buildOAuthAuthentication({
user,
scope: [Scope.Read],
});
await authMiddleware(
{
// @ts-expect-error mock request
request: {
url: "/api/users.info",
get: jest.fn(() => `Bearer ${authentication.accessToken}`),
},
state,
cache: {},
},
jest.fn()
);
expect(state.auth.user.id).toEqual(user.id);
});
it("should return error with invalid scope", async () => {
const state = {} as DefaultState;
const user = await buildUser();
const authMiddleware = auth();
const authentication = await buildOAuthAuthentication({
user,
scope: [Scope.Read],
});
try {
await authMiddleware(
{
// @ts-expect-error mock request
request: {
url: "/api/documents.create",
get: jest.fn(() => `Bearer ${authentication.accessToken}`),
},
state,
cache: {},
},
jest.fn()
);
} catch (e) {
expect(e.message).toContain("does not have access to this resource");
}
});
it("should return error with OAuth access token in body", async () => {
const state = {} as DefaultState;
const user = await buildUser();
const authMiddleware = auth();
const authentication = await buildOAuthAuthentication({
user,
scope: [Scope.Read],
});
try {
await authMiddleware(
{
request: {
url: "/api/users.info",
// @ts-expect-error mock request
get: jest.fn(() => null),
body: {
token: authentication.accessToken,
},
},
state,
cache: {},
},
jest.fn()
);
} catch (e) {
expect(e.message).toContain(
"must be passed in the Authorization header"
);
}
});
});
it("should return error message if no auth token is available", async () => {
const state = {} as DefaultState;
const authMiddleware = auth();
+52 -4
View File
@@ -7,7 +7,7 @@ import tracer, {
addTags,
getRootSpanFromRequestContext,
} from "@server/logging/tracer";
import { User, Team, ApiKey } from "@server/models";
import { User, Team, ApiKey, OAuthAuthentication } from "@server/models";
import { AppContext, AuthenticationType } from "@server/types";
import { getUserForJWT } from "@server/utils/jwt";
import {
@@ -20,7 +20,7 @@ type AuthenticationOptions = {
/** Role requuired to access the route. */
role?: UserRole;
/** Type of authentication required to access the route. */
type?: AuthenticationType;
type?: AuthenticationType | AuthenticationType[];
/** Authentication is parsed, but optional. */
optional?: boolean;
};
@@ -65,7 +65,50 @@ export default function auth(options: AuthenticationOptions = {}) {
let user: User | null;
let type: AuthenticationType;
if (ApiKey.match(String(token))) {
if (OAuthAuthentication.match(String(token))) {
if (!authorizationHeader) {
throw AuthenticationError(
"OAuth access token must be passed in the Authorization header"
);
}
type = AuthenticationType.OAUTH;
let authentication;
try {
authentication = await OAuthAuthentication.findByAccessToken(token, {
rejectOnEmpty: true,
});
} catch (err) {
throw AuthenticationError("Invalid access token");
}
if (!authentication) {
throw AuthenticationError("Invalid access token");
}
if (authentication.accessTokenExpiresAt < new Date()) {
throw AuthenticationError("Access token is expired");
}
if (!authentication.canAccess(ctx.request.url)) {
throw AuthenticationError(
"Access token does not have access to this resource"
);
}
user = await User.findByPk(authentication.userId, {
include: [
{
model: Team,
as: "team",
required: true,
},
],
});
if (!user) {
throw AuthenticationError("Invalid access token");
}
await authentication.updateActiveAt();
} else if (ApiKey.match(String(token))) {
type = AuthenticationType.API;
let apiKey;
@@ -125,7 +168,12 @@ export default function auth(options: AuthenticationOptions = {}) {
throw AuthorizationError(`${capitalize(options.role)} role required`);
}
if (options.type && type !== options.type) {
if (
options.type &&
(Array.isArray(options.type)
? !options.type.includes(type)
: type !== options.type)
) {
throw AuthorizationError(`Invalid authentication type`);
}
@@ -1,8 +1,8 @@
import { Context, Next } from "koa";
import { addTags, getRootSpanFromRequestContext } from "@server/logging/tracer";
export default function apiTracer() {
return async function apiTracerMiddleware(ctx: Context, next: Next) {
export default function requestTracer() {
return async function requestTracerMiddleware(ctx: Context, next: Next) {
const params = ctx.request.body ?? ctx.request.query;
for (const key in params) {
@@ -0,0 +1,29 @@
"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
},
};
@@ -0,0 +1,212 @@
"use strict";
/** @type {import("sequelize-cli").Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.createTable("oauth_clients", {
id: {
type: Sequelize.UUID,
primaryKey: true,
allowNull: false
},
name: {
type: Sequelize.STRING,
allowNull: false
},
description: {
type: Sequelize.STRING,
allowNull: true
},
developerName: {
type: Sequelize.STRING,
allowNull: true
},
developerUrl: {
type: Sequelize.STRING,
allowNull: true
},
avatarUrl: {
type: Sequelize.STRING,
allowNull: true
},
clientId: {
type: Sequelize.STRING,
allowNull: false,
unique: true,
},
clientSecret: {
type: Sequelize.BLOB,
allowNull: false
},
published: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false
},
teamId: {
type: Sequelize.UUID,
references: {
model: "teams",
},
allowNull: false,
onDelete: "cascade"
},
createdById: {
type: Sequelize.UUID,
references: {
model: "users",
},
allowNull: false
},
redirectUris: {
type: Sequelize.ARRAY(Sequelize.STRING),
allowNull: false,
defaultValue: []
},
createdAt: {
type: Sequelize.DATE,
allowNull: false
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true
}
}, {
transaction
});
await queryInterface.createTable("oauth_authorization_codes", {
id: {
type: Sequelize.UUID,
primaryKey: true,
allowNull: false
},
authorizationCodeHash: {
type: Sequelize.STRING,
allowNull: false
},
codeChallenge: {
type: Sequelize.STRING,
allowNull: true
},
codeChallengeMethod: {
type: Sequelize.STRING,
allowNull: true
},
scope: {
type: Sequelize.ARRAY(Sequelize.STRING),
allowNull: false
},
oauthClientId: {
type: Sequelize.UUID,
references: {
model: "oauth_clients",
},
onDelete: "cascade",
allowNull: false
},
userId: {
type: Sequelize.UUID,
references: {
model: "users",
},
onDelete: "cascade",
allowNull: false
},
redirectUri: {
type: Sequelize.STRING,
allowNull: false
},
expiresAt: {
type: Sequelize.DATE,
allowNull: false
},
createdAt: {
type: Sequelize.DATE,
allowNull: false
}
}, {
transaction
});
await queryInterface.createTable("oauth_authentications", {
id: {
type: Sequelize.UUID,
primaryKey: true,
allowNull: false
},
accessTokenHash: {
type: Sequelize.STRING,
allowNull: false,
unique: true
},
accessTokenExpiresAt: {
type: Sequelize.DATE,
allowNull: false
},
refreshTokenHash: {
type: Sequelize.STRING,
allowNull: false,
unique: true
},
refreshTokenExpiresAt: {
type: Sequelize.DATE,
allowNull: false
},
lastActiveAt: {
type: Sequelize.DATE,
allowNull: true
},
scope: {
type: Sequelize.ARRAY(Sequelize.STRING),
allowNull: false
},
oauthClientId: {
type: Sequelize.UUID,
references: {
model: "oauth_clients",
},
onDelete: "cascade",
allowNull: false
},
userId: {
type: Sequelize.UUID,
references: {
model: "users",
},
onDelete: "cascade",
allowNull: false
},
createdAt: {
type: Sequelize.DATE,
allowNull: false
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true
}
}, {
transaction
});
await queryInterface.addIndex("oauth_clients", ["teamId"], { transaction });
});
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.dropTable("oauth_authentications", { transaction });
await queryInterface.dropTable("oauth_authorization_codes", { transaction });
await queryInterface.dropTable("oauth_clients", { transaction });
});
}
};
+8 -32
View File
@@ -1,4 +1,3 @@
import crypto from "crypto";
import { Matches } from "class-validator";
import { subMinutes } from "date-fns";
import randomstring from "randomstring";
@@ -16,10 +15,12 @@ import {
BeforeSave,
} from "sequelize-typescript";
import { ApiKeyValidation } from "@shared/validations";
import { hash } from "@server/utils/crypto";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
import { SkipChangeset } from "./decorators/Changeset";
import Fix from "./decorators/Fix";
import AuthenticationHelper from "./helpers/AuthenticationHelper";
import Length from "./validators/Length";
@Table({ tableName: "apiKeys", modelName: "apiKey" })
@@ -41,7 +42,7 @@ class ApiKey extends ParanoidModel<
@Column
name: string;
/** A space-separated list of scopes that this API key has access to */
/** A list of scopes that this API key has access to */
@Matches(/[\/\.\w\s]*/, {
each: true,
})
@@ -96,7 +97,7 @@ class ApiKey extends ParanoidModel<
if (!model.hash) {
const secret = `${ApiKey.prefix}${randomstring.generate(38)}`;
model.value = model.secret || secret;
model.hash = this.hash(model.value);
model.hash = hash(model.value);
}
}
@@ -109,18 +110,8 @@ class ApiKey extends ParanoidModel<
}
/**
* Generates a hashed API key for the given input key.
*
* @param key The input string to hash
* @returns The hashed API key
*/
public static hash(key: string) {
return crypto.createHash("sha256").update(key).digest("hex");
}
/**
* Validates that the input touch could be an API key, this does not check
* that the key exists in the database.
* Validates that the input text _could_ be an API key, this does not check
* that the key actually exists in the database.
*
* @param text The text to validate
* @returns True if likely an API key
@@ -140,7 +131,7 @@ class ApiKey extends ParanoidModel<
public static findByToken(input: string) {
return this.findOne({
where: {
[Op.or]: [{ secret: input }, { hash: this.hash(input) }],
[Op.or]: [{ secret: input }, { hash: hash(input) }],
},
});
}
@@ -174,22 +165,7 @@ class ApiKey extends ParanoidModel<
return true;
}
// strip any query string from the path
path = path.split("?")[0];
const resource = path.split("/").pop() ?? "";
const [namespace, method] = resource.split(".");
return this.scope.some((scope) => {
const [scopeNamespace, scopeMethod] = scope
.replace("/api/", "")
.split(".");
return (
scope.startsWith("/api/") &&
(namespace === scopeNamespace || scopeNamespace === "*") &&
(method === scopeMethod || scopeMethod === "*")
);
});
return AuthenticationHelper.canAccess(path, this.scope);
};
}
+35 -29
View File
@@ -16,7 +16,7 @@ beforeEach(() => {
});
describe("#url", () => {
test("should return correct url for the collection", () => {
it("should return correct url for the collection", () => {
const collection = new Collection({
id: "1234",
});
@@ -25,7 +25,7 @@ describe("#url", () => {
});
describe("getDocumentParents", () => {
test("should return array of parent document ids", async () => {
it("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);
});
test("should return array of parent document ids", async () => {
it("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);
});
test("should not error if documentStructure is empty", async () => {
it("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", () => {
test("should return document tree", async () => {
it("should return document tree", async () => {
const document = await buildDocument();
const collection = await buildCollection({
documentStructure: [await document.toNavigationNode()],
@@ -76,7 +76,7 @@ describe("getDocumentTree", () => {
);
});
test("should return nested documents in tree", async () => {
it("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", () => {
test("should add as last element without index", async () => {
it("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);
});
test("should add with an index", async () => {
it("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);
});
test("should add as a child if with parent", async () => {
it("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);
});
test("should add as a child if with parent with index", async () => {
it("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);
});
test("should add the document along with its nested document(s)", async () => {
it("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", () => {
);
});
test("should add the document along with its archived nested document(s)", async () => {
it("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", () => {
test("should append supplied json over document's own", async () => {
it("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", () => {
test("should update root document's data", async () => {
it("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");
});
test("should update child document's data", async () => {
it("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.findByPk(collection.id);
const reloaded = await collection.reload();
expect(reloaded!.documentStructure![0].children[0].title).toBe(
"Updated title"
);
@@ -305,7 +305,7 @@ describe("#updateDocument", () => {
});
describe("#removeDocument", () => {
test("should save if removing", async () => {
it("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();
});
test("should remove documents from root", async () => {
it("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);
});
test("should remove a document with child documents", async () => {
it("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);
});
test("should remove a child document", async () => {
it("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.findByPk(collection.id);
const reloaded = await collection.reload();
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", () => {
test("should return collection and group memberships", async () => {
it("should return collection and group memberships", async () => {
const team = await buildTeam();
const teamId = team.id;
// Make 6 users
@@ -464,47 +464,53 @@ describe("#membershipUserIds", () => {
});
describe("#findByPk", () => {
test("should return collection with collection Id", async () => {
it("should return collection with collection Id", async () => {
const collection = await buildCollection();
const response = await Collection.findByPk(collection.id);
expect(response!.id).toBe(collection.id);
});
test("should return collection when urlId is present", async () => {
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 () => {
const collection = await buildCollection();
const id = `${slugify(collection.name)}-${collection.urlId}`;
const response = await Collection.findByPk(id);
expect(response!.id).toBe(collection.id);
});
test("should return collection when urlId is present, but missing slug", async () => {
it("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);
});
test("should return null when incorrect uuid type", async () => {
it("should return null when incorrect uuid type", async () => {
const collection = await buildCollection();
const response = await Collection.findByPk(collection.id + "-incorrect");
expect(response).toBe(null);
});
test("should return null when incorrect urlId length", async () => {
it("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);
});
test("should return null when no collection is found with uuid", async () => {
it("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);
});
test("should return null when no collection is found with urlId", async () => {
it("should return null when no collection is found with urlId", async () => {
const id = `${slugify("test collection")}-${randomstring.generate(15)}`;
const response = await Collection.findByPk(id);
expect(response).toBe(null);
+13
View File
@@ -37,6 +37,7 @@ import {
AllowNull,
BeforeCreate,
BeforeUpdate,
DefaultScope,
} from "sequelize-typescript";
import isUUID from "validator/lib/isUUID";
import type { CollectionSort, ProsemirrorData } from "@shared/types";
@@ -69,6 +70,11 @@ type AdditionalFindOptions = {
rejectOnEmpty?: boolean | Error;
};
@DefaultScope(() => ({
attributes: {
exclude: ["documentStructure"],
},
}))
@Scopes(() => ({
withAllMemberships: {
include: [
@@ -121,6 +127,12 @@ type AdditionalFindOptions = {
},
],
}),
withDocumentStructure: () => ({
attributes: {
// resets to include the documentStructure column
exclude: [],
},
}),
withMembership: (userId: string) => {
if (!userId) {
return {};
@@ -238,6 +250,7 @@ class Collection extends ParanoidModel<
@Column
maintainerApprovalRequired: boolean;
@Default(null)
@Column(DataType.JSONB)
documentStructure: NavigationNode[] | null;
+2 -5
View File
@@ -11,7 +11,6 @@ import {
buildUser,
buildGuestUser,
} from "@server/test/factories";
import Collection from "./Collection";
import UserMembership from "./UserMembership";
beforeEach(() => {
@@ -96,10 +95,8 @@ describe("#delete", () => {
await document.delete(user);
const [newDocument, newCollection] = await Promise.all([
Document.findByPk(document.id, {
paranoid: false,
}),
Collection.findByPk(collection.id),
document.reload({ paranoid: false }),
collection.reload(),
]);
expect(newDocument?.lastModifiedById).toEqual(user.id);
+71 -56
View File
@@ -13,9 +13,9 @@ import {
Transaction,
Op,
FindOptions,
ScopeOptions,
WhereOptions,
EmptyResultError,
Sequelize,
} from "sequelize";
import {
ForeignKey,
@@ -72,12 +72,20 @@ 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: [
{
@@ -102,27 +110,14 @@ type AdditionalFindOptions = {
},
},
attributes: {
exclude: ["state"],
include: [stateIfContentEmpty],
},
}))
// @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: {
exclude: ["state"],
include: [stateIfContentEmpty],
},
},
withCollection: {
@@ -136,7 +131,7 @@ type AdditionalFindOptions = {
withState: {
attributes: {
// resets to include the state column
exclude: [],
include: [],
},
},
withDrafts: {
@@ -169,13 +164,25 @@ type AdditionalFindOptions = {
],
};
},
withMembership: (userId: string) => {
withMembership: (userId: string, paranoid = true) => {
if (!userId) {
return {};
}
return {
include: [
{
model: userId
? Collection.scope([
"defaultScope",
{
method: ["withMembership", userId],
},
])
: Collection,
as: "collection",
paranoid,
},
{
association: "memberships",
where: {
@@ -419,10 +426,13 @@ class Document extends ArchivableModel<
return;
}
const collection = await Collection.findByPk(model.collectionId, {
transaction,
lock: Transaction.LOCK.UPDATE,
});
const collection = await Collection.scope("withDocumentStructure").findByPk(
model.collectionId,
{
transaction,
lock: Transaction.LOCK.UPDATE,
}
);
if (!collection) {
return;
}
@@ -443,7 +453,9 @@ class Document extends ArchivableModel<
}
return this.sequelize!.transaction(async (transaction: Transaction) => {
const collection = await Collection.findByPk(model.collectionId!, {
const collection = await Collection.scope(
"withDocumentStructure"
).findByPk(model.collectionId!, {
transaction,
lock: transaction.LOCK.UPDATE,
});
@@ -637,21 +649,19 @@ class Document extends ArchivableModel<
return uniq(membershipUserIds);
}
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],
};
static withMembershipScope(
userId: string,
options?: FindOptions<Document> & { includeDrafts?: boolean }
) {
return this.scope([
"defaultScope",
collectionScope,
viewScope,
membershipScope,
options?.includeDrafts ? "withDrafts" : "defaultScope",
"withoutState",
{
method: ["withViews", userId],
},
{
method: ["withMembership", userId, options?.paranoid],
},
]);
}
@@ -685,14 +695,12 @@ class Document extends ArchivableModel<
// almost every endpoint needs the collection membership to determine policy permissions.
const scope = this.scope([
"withDrafts",
{
method: ["withCollectionPermissions", userId, rest.paranoid],
},
options.includeState ? "withState" : "withoutState",
{
method: ["withViews", userId],
},
{
method: ["withMembership", userId],
method: ["withMembership", userId, rest.paranoid],
},
]);
@@ -750,9 +758,6 @@ 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],
},
@@ -938,7 +943,9 @@ class Document extends ArchivableModel<
}
if (!this.template && this.collectionId) {
const collection = await Collection.findByPk(this.collectionId, {
const collection = await Collection.scope(
"withDocumentStructure"
).findByPk(this.collectionId, {
transaction,
lock: Transaction.LOCK.UPDATE,
});
@@ -1005,10 +1012,13 @@ class Document extends ArchivableModel<
await this.sequelize.transaction(async (transaction: Transaction) => {
const collection = this.collectionId
? await Collection.findByPk(this.collectionId, {
transaction,
lock: transaction.LOCK.UPDATE,
})
? await Collection.scope("withDocumentStructure").findByPk(
this.collectionId,
{
transaction,
lock: transaction.LOCK.UPDATE,
}
)
: undefined;
if (collection) {
@@ -1039,10 +1049,13 @@ class Document extends ArchivableModel<
archive = async (user: User, options?: FindOptions) => {
const { transaction } = { ...options };
const collection = this.collectionId
? await Collection.findByPk(this.collectionId, {
transaction,
lock: transaction?.LOCK.UPDATE,
})
? await Collection.scope("withDocumentStructure").findByPk(
this.collectionId,
{
transaction,
lock: transaction?.LOCK.UPDATE,
}
)
: undefined;
if (collection) {
@@ -1063,7 +1076,7 @@ class Document extends ArchivableModel<
) => {
const { transaction } = { ...options };
const collection = collectionId
? await Collection.findByPk(collectionId, {
? await Collection.scope("withDocumentStructure").findByPk(collectionId, {
transaction,
lock: transaction?.LOCK.UPDATE,
})
@@ -1115,7 +1128,9 @@ class Document extends ArchivableModel<
let deleted = false;
if (!this.template && this.collectionId) {
const collection = await Collection.findByPk(this.collectionId!, {
const collection = await Collection.scope(
"withDocumentStructure"
).findByPk(this.collectionId!, {
transaction,
lock: transaction.LOCK.UPDATE,
paranoid: false,
@@ -0,0 +1,126 @@
import AuthenticationHelper from "./AuthenticationHelper";
describe("AuthenticationHelper", () => {
const canAccess = AuthenticationHelper.canAccess;
describe("canAccess", () => {
describe("api scopes", () => {
it("should account for query string", async () => {
const scopes = ["/api/documents.info"];
expect(canAccess("/api/documents.info?foo=bar", scopes)).toBe(true);
});
it("should return false if no matching scope", async () => {
const scopes = ["/api/documents.info"];
expect(canAccess("/api/documents.info", scopes)).toBe(true);
expect(canAccess("/api/collections.create", scopes)).toBe(false);
expect(canAccess("/api/apiKeys.list", scopes)).toBe(false);
});
it("should allow wildcard methods", async () => {
const scopes = ["/api/documents.*"];
expect(canAccess("/api/documents.info", scopes)).toBe(true);
expect(canAccess("/api/documents.create", scopes)).toBe(true);
expect(canAccess("/api/collections.create", scopes)).toBe(false);
});
it("should allow wildcard namespaces", async () => {
const scopes = ["/api/*.info"];
expect(canAccess("/api/documents.info", scopes)).toBe(true);
expect(canAccess("/api/documents.create", scopes)).toBe(false);
expect(canAccess("/api/collections.create", scopes)).toBe(false);
});
it("should allow wildcard namespaces", async () => {
const scopes = ["/api/*.info"];
expect(canAccess("/api/documents.info", scopes)).toBe(true);
expect(canAccess("/api/documents.create", scopes)).toBe(false);
});
});
describe("namespaced access scopes", () => {
it("read", async () => {
const scopes = ["documents:read"];
expect(canAccess("/api/documents.info?foo=bar", scopes)).toBe(true);
expect(canAccess("/api/documents.info", scopes)).toBe(true);
expect(canAccess("/api/documents.list", scopes)).toBe(true);
expect(canAccess("/api/documents.create", scopes)).toBe(false);
expect(canAccess("/api/documents.update", scopes)).toBe(false);
expect(canAccess("/api/users.info", scopes)).toBe(false);
expect(canAccess("/api/users.create", scopes)).toBe(false);
});
it("write", async () => {
const scopes = ["documents:write"];
expect(canAccess("/api/documents.info?foo=bar", scopes)).toBe(true);
expect(canAccess("/api/documents.info", scopes)).toBe(true);
expect(canAccess("/api/documents.list", scopes)).toBe(true);
expect(canAccess("/api/documents.create", scopes)).toBe(true);
expect(canAccess("/api/documents.update", scopes)).toBe(true);
expect(canAccess("/api/users.info", scopes)).toBe(false);
expect(canAccess("/api/users.create", scopes)).toBe(false);
});
it("create", async () => {
const scopes = ["documents:create"];
expect(canAccess("/api/documents.create", scopes)).toBe(true);
expect(canAccess("/api/documents.info?foo=bar", scopes)).toBe(false);
expect(canAccess("/api/documents.info", scopes)).toBe(false);
expect(canAccess("/api/documents.list", scopes)).toBe(false);
expect(canAccess("/api/documents.update", scopes)).toBe(false);
expect(canAccess("/api/users.info", scopes)).toBe(false);
expect(canAccess("/api/users.create", scopes)).toBe(false);
});
});
describe("global access scopes", () => {
it("read", async () => {
const scopes = ["read"];
expect(canAccess("/api/documents.info?foo=bar", scopes)).toBe(true);
expect(canAccess("/api/documents.info", scopes)).toBe(true);
expect(canAccess("/api/documents.list", scopes)).toBe(true);
expect(canAccess("/api/users.info", scopes)).toBe(true);
expect(canAccess("/api/groups.info", scopes)).toBe(true);
expect(canAccess("/api/collections.list", scopes)).toBe(true);
expect(canAccess("/api/documents.create", scopes)).toBe(false);
expect(canAccess("/api/documents.update", scopes)).toBe(false);
expect(canAccess("/api/users.create", scopes)).toBe(false);
});
it("write", async () => {
const scopes = ["write"];
expect(canAccess("/api/documents.info?foo=bar", scopes)).toBe(true);
expect(canAccess("/api/documents.info", scopes)).toBe(true);
expect(canAccess("/api/documents.list", scopes)).toBe(true);
expect(canAccess("/api/users.info", scopes)).toBe(true);
expect(canAccess("/api/groups.info", scopes)).toBe(true);
expect(canAccess("/api/documents.create", scopes)).toBe(true);
expect(canAccess("/api/documents.update", scopes)).toBe(true);
expect(canAccess("/api/users.info", scopes)).toBe(true);
expect(canAccess("/api/users.create", scopes)).toBe(true);
});
it("create", async () => {
const scopes = ["create"];
expect(canAccess("/api/documents.create", scopes)).toBe(true);
expect(canAccess("/api/users.create", scopes)).toBe(true);
expect(canAccess("/api/documents.info?foo=bar", scopes)).toBe(false);
expect(canAccess("/api/documents.info", scopes)).toBe(false);
expect(canAccess("/api/documents.list", scopes)).toBe(false);
expect(canAccess("/api/documents.update", scopes)).toBe(false);
expect(canAccess("/api/users.info", scopes)).toBe(false);
});
});
});
});
@@ -1,10 +1,27 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import find from "lodash/find";
import { Scope } from "@shared/types";
import env from "@server/env";
import Team from "@server/models/Team";
import { Hook, PluginManager } from "@server/utils/PluginManager";
export default class AuthenticationHelper {
/**
* The mapping of method names to their scopes, anything not listed here
* defaults to `Scope.Write`.
*
* - `documents.create` -> `Scope.Create`
* - `documents.list` -> `Scope.Read`
* - `documents.info` -> `Scope.Read`
*/
private static methodToScope = {
create: Scope.Create,
list: Scope.Read,
info: Scope.Read,
search: Scope.Read,
documents: Scope.Read,
};
/**
* Returns the enabled authentication provider configurations for the current
* installation.
@@ -52,4 +69,45 @@ export default class AuthenticationHelper {
);
});
}
/**
* Returns whether the given path can be accessed with any of the scopes. We
* support scopes in the formats of:
*
* - `/api/namespace.method`
* - `namespace:scope`
* - `scope`
*
* @param path The path to check
* @param scopes The scopes to check
* @returns True if the path can be accessed
*/
public static canAccess = (path: string, scopes: string[]) => {
// strip any query string, this is never used as part of scope matching
path = path.split("?")[0];
const resource = path.split("/").pop() ?? "";
const [namespace, method] = resource.split(".");
return scopes.some((scope) => {
const [scopeNamespace, scopeMethod] = scope.match(/[:\.]/g)
? scope.replace("/api/", "").split(/[:\.]/g)
: ["*", scope];
const isRouteScope = scope.startsWith("/api/");
if (isRouteScope) {
return (
(namespace === scopeNamespace || scopeNamespace === "*") &&
(method === scopeMethod || scopeMethod === "*")
);
}
return (
(namespace === scopeNamespace || scopeNamespace === "*") &&
(scopeMethod === Scope.Write ||
this.methodToScope[method as keyof typeof this.methodToScope] ===
scopeMethod)
);
});
};
}
+4 -24
View File
@@ -182,18 +182,9 @@ export default class SearchHelper {
},
];
return Document.scope([
"withDrafts",
{
method: ["withViews", user.id],
},
{
method: ["withCollectionPermissions", user.id],
},
{
method: ["withMembership", user.id],
},
]).findAll({
return Document.withMembershipScope(user.id, {
includeDrafts: true,
}).findAll({
where,
subQuery: false,
order: [["updatedAt", "DESC"]],
@@ -273,18 +264,7 @@ export default class SearchHelper {
// Final query to get associated document data
const [documents, count] = await Promise.all([
Document.scope([
"withDrafts",
{
method: ["withViews", user.id],
},
{
method: ["withCollectionPermissions", user.id],
},
{
method: ["withMembership", user.id],
},
]).findAll({
Document.withMembershipScope(user.id, { includeDrafts: true }).findAll({
where: {
teamId: user.teamId,
id: map(results, "id"),
+6
View File
@@ -34,6 +34,12 @@ export { default as IntegrationAuthentication } from "./IntegrationAuthenticatio
export { default as Notification } from "./Notification";
export { default as OAuthAuthentication } from "./oauth/OAuthAuthentication";
export { default as OAuthAuthorizationCode } from "./oauth/OAuthAuthorizationCode";
export { default as OAuthClient } from "./oauth/OAuthClient";
export { default as Pin } from "./Pin";
export { default as Reaction } from "./Reaction";
+212
View File
@@ -0,0 +1,212 @@
import { Matches } from "class-validator";
import { subMinutes } from "date-fns";
import {
FindOptions,
InferAttributes,
InferCreationAttributes,
NonNullFindOptions,
} from "sequelize";
import {
Column,
DataType,
BelongsTo,
ForeignKey,
Table,
IsDate,
Unique,
} from "sequelize-typescript";
import env from "@server/env";
import User from "@server/models/User";
import ParanoidModel from "@server/models/base/ParanoidModel";
import { SkipChangeset } from "@server/models/decorators/Changeset";
import Fix from "@server/models/decorators/Fix";
import AuthenticationHelper from "@server/models/helpers/AuthenticationHelper";
import { hash } from "@server/utils/crypto";
import OAuthClient from "./OAuthClient";
@Table({
tableName: "oauth_authentications",
modelName: "oauth_authentication",
})
@Fix
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;
/** The lifetime of a refresh token in seconds. */
public static refreshTokenLifetime =
env.OAUTH_PROVIDER_REFRESH_TOKEN_LIFETIME;
/** A recognizable prefix for access tokens. */
public static accessTokenPrefix = "ol_at_";
/** A recognizable prefix for refresh tokens. */
public static refreshTokenPrefix = "ol_rt_";
@Unique
@Column
@SkipChangeset
accessTokenHash: string;
/** The cached plain text access token. Only available during creation. */
@Column(DataType.VIRTUAL)
accessToken: string | null;
@IsDate
@Column
accessTokenExpiresAt: Date;
@Unique
@Column
@SkipChangeset
refreshTokenHash: string;
/** The cached plain text refresh token. Only available during creation. */
@Column(DataType.VIRTUAL)
refreshToken: string | null;
@IsDate
@Column
refreshTokenExpiresAt: Date;
/** A list of scopes that this authentication has access to */
@Matches(/[\/\.\w\s]*/, {
each: true,
})
@Column(DataType.ARRAY(DataType.STRING))
scope: string[];
@IsDate
@Column
@SkipChangeset
lastActiveAt: Date;
// associations
@BelongsTo(() => OAuthClient, "oauthClientId")
oauthClient: OAuthClient;
@ForeignKey(() => OAuthClient)
@Column(DataType.UUID)
oauthClientId: string;
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
// methods
updateActiveAt = async () => {
const fiveMinutesAgo = subMinutes(new Date(), 5);
// ensure this is updated only every few minutes otherwise
// we'll be constantly writing to the DB as API requests happen
if (!this.lastActiveAt || this.lastActiveAt < fiveMinutesAgo) {
this.lastActiveAt = new Date();
}
return this.save({ silent: true });
};
// instance methods
/** Checks if the authentication has access to the given path */
canAccess = (path: string) => {
// Special case for the revoke endpoint, which is always allowed
if (path === "/revoke") {
return true;
}
return AuthenticationHelper.canAccess(path, this.scope);
};
// static methods
/**
* Validates that the input text _could_ be an OAuth token, this does not check
* that the key actually exists in the database.
*
* @param text The text to validate
* @returns True if likely an OAuth token
*/
public static match(text: string) {
return !!text.startsWith(this.accessTokenPrefix);
}
/**
* Validates that the input text _could_ be an OAuth refresh token, this does
* not check that the key actually exists in the database.
*
* @param text The text to validate
* @returns True if likely an OAuth refresh token
*/
public static matchRefreshToken(text: string) {
return !!text.startsWith(this.refreshTokenPrefix);
}
/**
* Finds an OAuthAuthentication by the given access token, including the
* associated user.
*
* @param input The access token to search for
* @param options The options to pass to the find method
* @returns The OAuthAuthentication if found
*/
static findByAccessToken(
input: string,
options?:
| FindOptions<OAuthAuthentication>
| NonNullFindOptions<OAuthAuthentication>
): Promise<OAuthAuthentication | null> {
return this.findOne({
where: {
accessTokenHash: hash(input),
},
include: [
{
association: "user",
required: true,
},
],
...options,
});
}
/**
* Finds an OAuthAuthentication by the given refresh token, including the
* associated user.
*
* @param input The refresh token to search for
* @param options The options to pass to the find method
* @returns The OAuthAuthentication if found
*/
public static findByRefreshToken(
input: string,
options?:
| FindOptions<OAuthAuthentication>
| NonNullFindOptions<OAuthAuthentication>
) {
return this.findOne({
where: {
refreshTokenHash: hash(input),
},
include: [
{
association: "user",
required: true,
},
],
...options,
});
}
}
export default OAuthAuthentication;
@@ -0,0 +1,104 @@
import { Matches } from "class-validator";
import { InferAttributes, InferCreationAttributes } from "sequelize";
import {
Column,
DataType,
BelongsTo,
ForeignKey,
Table,
Length,
} from "sequelize-typescript";
import { OAuthClientValidation } from "@shared/validations";
import env from "@server/env";
import User from "@server/models/User";
import IdModel from "@server/models/base/IdModel";
import { SkipChangeset } from "@server/models/decorators/Changeset";
import Fix from "@server/models/decorators/Fix";
import { hash } from "@server/utils/crypto";
import OAuthClient from "./OAuthClient";
@Table({
tableName: "oauth_authorization_codes",
modelName: "oauth_authorization_code",
updatedAt: false,
})
@Fix
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;
/** A recognizable prefix for authorization codes. */
public static authorizationCodePrefix = "ol_ac_";
@Column
@SkipChangeset
authorizationCodeHash: string;
@Column
@SkipChangeset
codeChallenge?: string;
@Column
@SkipChangeset
codeChallengeMethod?: string;
/** A list of scopes that this authorization code has access to */
@Matches(/[\/\.\w\s]*/, {
each: true,
})
@Column(DataType.ARRAY(DataType.STRING))
scope: string[];
@Length({ max: OAuthClientValidation.maxRedirectUriLength })
@Column
redirectUri: string;
@Column(DataType.DATE)
expiresAt: Date;
// associations
@BelongsTo(() => OAuthClient, "oauthClientId")
oauthClient: OAuthClient;
@ForeignKey(() => OAuthClient)
@Column(DataType.UUID)
oauthClientId: string;
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
/**
* Finds an OAuthAuthorizationCode by the given code.
*
* @param input The code to search for
* @returns The OAuthAuthentication if found
*/
public static findByCode(input: string) {
const authorizationCodeHash = hash(input);
return this.findOne({
where: {
authorizationCodeHash,
},
include: [
{
association: "user",
required: true,
},
],
});
}
}
export default OAuthAuthorizationCode;
+159
View File
@@ -0,0 +1,159 @@
import {
ArrayMaxSize,
ArrayMinSize,
ArrayNotEmpty,
ArrayUnique,
IsUrl,
} from "class-validator";
import rs from "randomstring";
import { InferAttributes, InferCreationAttributes } from "sequelize";
import {
Column,
DataType,
BelongsTo,
ForeignKey,
Table,
Length,
BeforeCreate,
AllowNull,
} from "sequelize-typescript";
import { OAuthClientValidation } from "@shared/validations";
import Team from "@server/models/Team";
import User from "@server/models/User";
import ParanoidModel from "@server/models/base/ParanoidModel";
import Encrypted from "@server/models/decorators/Encrypted";
import Fix from "@server/models/decorators/Fix";
import IsUrlOrRelativePath from "@server/models/validators/IsUrlOrRelativePath";
import NotContainsUrl from "@server/models/validators/NotContainsUrl";
@Table({
tableName: "oauth_clients",
modelName: "oauth_client",
})
@Fix
class OAuthClient extends ParanoidModel<
InferAttributes<OAuthClient>,
Partial<InferCreationAttributes<OAuthClient>>
> {
static eventNamespace = "oauthClients";
public static clientSecretPrefix = "ol_sk_";
@NotContainsUrl
@Length({ max: OAuthClientValidation.maxNameLength })
@Column
name: string;
@AllowNull
@NotContainsUrl
@Length({ max: OAuthClientValidation.maxDescriptionLength })
@Column
description: string | null;
@AllowNull
@NotContainsUrl
@Length({ max: OAuthClientValidation.maxDeveloperNameLength })
@Column
developerName: string | null;
@AllowNull
@IsUrlOrRelativePath
@Length({ max: OAuthClientValidation.maxDeveloperUrlLength })
@Column
developerUrl: string | null;
@AllowNull
@IsUrlOrRelativePath
@Length({ max: OAuthClientValidation.maxAvatarUrlLength })
@Column
avatarUrl: string | null;
@Column
clientId: string;
@Column(DataType.BLOB)
@Encrypted
clientSecret: string;
@Column
published: boolean;
@ArrayNotEmpty()
@ArrayUnique()
@ArrayMinSize(1)
@ArrayMaxSize(10)
@IsUrl(
{
require_tld: false,
allow_underscores: true,
},
{
each: true,
}
)
@Column(DataType.ARRAY(DataType.STRING))
redirectUris: string[];
// associations
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
@BelongsTo(() => User, "createdById")
createdBy: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
createdById: string;
// instance methods
/**
* Rotate the client secret value. Does not persist to database.
*/
public rotateClientSecret() {
this.clientSecret = OAuthClient.generateNewClientSecret();
}
// hooks
@BeforeCreate
public static async generateCredentials(model: OAuthClient) {
model.clientId = OAuthClient.generateNewClientId();
model.clientSecret = OAuthClient.generateNewClientSecret();
}
// static methods
/**
* Find an OAuthClient by it's public `clientId`
*
* @param clientId The public clientId of the OAuthClient
* @returns The OAuthClient or null if not found
*/
public static async findByClientId(clientId: string) {
return this.findOne({
where: {
clientId,
},
});
}
private static generateNewClientId(): string {
return rs.generate({
length: 20,
charset: "alphanumeric",
capitalization: "lowercase",
});
}
private static generateNewClientSecret(): string {
return `${OAuthClient.clientSecretPrefix}${rs.generate(32)}`;
}
}
export default OAuthClient;
+3 -1
View File
@@ -11,6 +11,9 @@ import "./document";
import "./fileOperation";
import "./import";
import "./integration";
import "./notification";
import "./oauthClient";
import "./oauthAuthentication";
import "./pins";
import "./reaction";
import "./revision";
@@ -22,5 +25,4 @@ import "./user";
import "./team";
import "./group";
import "./webhookSubscription";
import "./notification";
import "./userMembership";
+14
View File
@@ -0,0 +1,14 @@
import { Team, User, OAuthAuthentication } from "@server/models";
import { allow } from "./cancan";
import { isTeamModel } from "./utils";
allow(User, "listOAuthAuthentications", Team, (actor, team) =>
isTeamModel(actor, team)
);
allow(
User,
["read", "delete"],
OAuthAuthentication,
(actor, oauthAuthentication) => actor?.id === oauthAuthentication?.userId
);
+19
View File
@@ -0,0 +1,19 @@
import { Team, User, OAuthClient } from "@server/models";
import { allow } from "./cancan";
import { or, isTeamModel, isTeamMutable, and, isTeamAdmin } from "./utils";
allow(User, "createOAuthClient", Team, (actor, team) =>
and(isTeamModel(actor, team), isTeamMutable(actor), actor.isAdmin)
);
allow(User, "listOAuthClients", Team, (actor, team) =>
isTeamAdmin(actor, team)
);
allow(User, "read", OAuthClient, (actor, oauthClient) =>
or(isTeamModel(actor, oauthClient), !!oauthClient?.published)
);
allow(User, ["update", "delete"], OAuthClient, (actor, oauthClient) =>
and(isTeamModel(actor, oauthClient), isTeamMutable(actor), actor.isAdmin)
);
+3
View File
@@ -13,6 +13,7 @@ import presentGroupUser from "./groupUser";
import presentImport from "./import";
import presentIntegration from "./integration";
import presentMembership from "./membership";
import presentOAuthClient, { presentPublishedOAuthClient } from "./oauthClient";
import presentPin from "./pin";
import presentPolicies from "./policy";
import presentProviderConfig from "./providerConfig";
@@ -43,6 +44,8 @@ export {
presentImport,
presentIntegration,
presentMembership,
presentOAuthClient,
presentPublishedOAuthClient,
presentPublicTeam,
presentPin,
presentPolicies,
+16
View File
@@ -0,0 +1,16 @@
import { OAuthAuthentication } from "@server/models";
import { presentPublishedOAuthClient } from "./oauthClient";
export default function presentOAuthAuthentication(
oauthAuthentication: OAuthAuthentication
) {
return {
id: oauthAuthentication.id,
userId: oauthAuthentication.userId,
oauthClientId: oauthAuthentication.oauthClientId,
oauthClient: presentPublishedOAuthClient(oauthAuthentication.oauthClient),
scope: oauthAuthentication.scope,
lastActiveAt: oauthAuthentication.lastActiveAt,
createdAt: oauthAuthentication.createdAt,
};
}
+42
View File
@@ -0,0 +1,42 @@
import { OAuthClient } from "@server/models";
/**
* Presents the OAuth client to the user.
*
* @param oauthClient The OAuth client to present
*/
export default function presentOAuthClient(oauthClient: OAuthClient) {
return {
id: oauthClient.id,
name: oauthClient.name,
description: oauthClient.description,
developerName: oauthClient.developerName,
developerUrl: oauthClient.developerUrl,
avatarUrl: oauthClient.avatarUrl,
clientId: oauthClient.clientId,
clientSecret: oauthClient.clientSecret,
redirectUris: oauthClient.redirectUris,
published: oauthClient.published,
createdAt: oauthClient.createdAt,
updatedAt: oauthClient.updatedAt,
};
}
/**
* 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
* what you expose here.
*
* @param oauthClient The OAuth client to present
*/
export function presentPublishedOAuthClient(oauthClient: OAuthClient) {
return {
name: oauthClient.name,
description: oauthClient.description,
developerName: oauthClient.developerName,
developerUrl: oauthClient.developerUrl,
avatarUrl: oauthClient.avatarUrl,
clientId: oauthClient.clientId,
published: oauthClient.published,
};
}
@@ -0,0 +1,15 @@
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,
},
});
}
}
@@ -0,0 +1,36 @@
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,
},
},
});
}
}
}
@@ -1,6 +1,7 @@
import {
ApiKey,
GroupUser,
OAuthAuthentication,
Star,
Subscription,
UserAuthentication,
@@ -46,6 +47,12 @@ export default class UserDeletedProcessor extends BaseProcessor {
},
transaction,
});
await OAuthAuthentication.destroy({
where: {
userId: event.userId,
},
transaction,
});
await Star.destroy({
where: {
userId: event.userId,
@@ -0,0 +1,14 @@
import { OAuthAuthentication } from "@server/models";
import { Event as TEvent, UserEvent } from "@server/types";
import BaseProcessor from "./BaseProcessor";
export default class UserSuspendedProcessor extends BaseProcessor {
static applicableEvents: TEvent["name"][] = ["users.suspend"];
async perform(event: UserEvent) {
// Remove all OAuth authentications for this user.
await OAuthAuthentication.destroy({
where: { userId: event.userId },
});
}
}
@@ -0,0 +1,37 @@
import { subMonths } from "date-fns";
import { OAuthAuthorizationCode } from "@server/models";
import { buildOAuthAuthorizationCode } from "@server/test/factories";
import CleanupOAuthAuthorizationCodeTask from "./CleanupOAuthAuthorizationCodeTask";
const codeExists = async (code: OAuthAuthorizationCode) => {
const found = await OAuthAuthorizationCode.findByPk(code.id);
return !!found;
};
describe("CleanupOAuthAuthorizationCodeTask", () => {
it("should delete authorization codes expired more than one month ago", async () => {
const brandNewCode = await buildOAuthAuthorizationCode({
expiresAt: new Date(),
});
const oldCode = await buildOAuthAuthorizationCode({
expiresAt: subMonths(new Date(), 2),
});
const task = new CleanupOAuthAuthorizationCodeTask();
await task.perform();
expect(await codeExists(brandNewCode)).toBe(true);
expect(await codeExists(oldCode)).toBe(false);
});
it("should not delete codes that expired less than one month ago", async () => {
const recentCode = await buildOAuthAuthorizationCode({
expiresAt: new Date(),
});
const task = new CleanupOAuthAuthorizationCodeTask();
await task.perform();
expect(await codeExists(recentCode)).toBe(true);
});
});
@@ -0,0 +1,33 @@
import { subMonths } from "date-fns";
import { Op } from "sequelize";
import Logger from "@server/logging/Logger";
import { OAuthAuthorizationCode } from "@server/models";
import BaseTask, { TaskPriority, TaskSchedule } from "./BaseTask";
type Props = Record<string, never>;
export default class CleanupOAuthAuthorizationCodeTask extends BaseTask<Props> {
static cron = TaskSchedule.Day;
public async perform() {
Logger.info(
"task",
`Deleting OAuth authorization codes older than one month…`
);
const count = await OAuthAuthorizationCode.destroy({
where: {
expiresAt: {
[Op.lt]: subMonths(new Date(), 1),
},
},
});
Logger.info("task", `${count} expired OAuth authorization codes deleted.`);
}
public get options() {
return {
attempts: 1,
priority: TaskPriority.Background,
};
}
}
@@ -171,7 +171,8 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
/**
* Generates a map of document urls to their path in the zip file.
*
* @param collections
* @param collections The collections to generate the path map for.
* @param format The format of the exported documents.
*/
private createPathMap(
collections: Collection[],
+7 -5
View File
@@ -44,11 +44,13 @@ export default abstract class ExportTask extends BaseTask<Props> {
? [fileOperation.collectionId]
: await user.collectionIds();
const collections = await Collection.findAll({
where: {
id: collectionIds,
},
});
const collections = await Collection.scope("withDocumentStructure").findAll(
{
where: {
id: collectionIds,
},
}
);
let filePath: string | undefined;
+8 -2
View File
@@ -15,7 +15,10 @@ const router = new Router();
router.post(
"apiKeys.create",
auth({ role: UserRole.Member, type: AuthenticationType.APP }),
auth({
role: UserRole.Member,
type: AuthenticationType.APP,
}),
validate(T.APIKeysCreateSchema),
transaction(),
async (ctx: APIContext<T.APIKeysCreateReq>) => {
@@ -90,7 +93,10 @@ router.post(
router.post(
"apiKeys.delete",
auth({ role: UserRole.Member }),
auth({
role: UserRole.Member,
type: AuthenticationType.APP,
}),
validate(T.APIKeysDeleteSchema),
transaction(),
async (ctx: APIContext<T.APIKeysDeleteReq>) => {
+5 -3
View File
@@ -140,9 +140,11 @@ 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);
@@ -977,7 +977,7 @@ describe("#documents.list", () => {
const res = await server.post("/api/documents.list", {
body: {
token: user.getJwtToken(),
collection: document.collectionId,
collectionId: 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(),
collection: collection.id,
collectionId: collection.id,
},
});
const body = await res.json();
+14 -18
View File
@@ -133,15 +133,19 @@ 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({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const collection = await Collection.scope([
sort === "index" ? "withDocumentStructure" : "defaultScope",
{
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 });
@@ -268,7 +272,7 @@ router.post(
}
const [documents, total] = await Promise.all([
Document.defaultScopeWithUser(user.id).findAll({
Document.withMembershipScope(user.id).findAll({
where,
order: [
[
@@ -348,7 +352,7 @@ router.post(
};
}
const documents = await Document.defaultScopeWithUser(user.id).findAll({
const documents = await Document.withMembershipScope(user.id).findAll({
where,
order: [
[
@@ -397,15 +401,11 @@ 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,7 +539,9 @@ router.post(
delete where.updatedAt;
}
const documents = await Document.defaultScopeWithUser(user.id).findAll({
const documents = await Document.withMembershipScope(user.id, {
includeDrafts: true,
}).findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
@@ -2033,13 +2035,7 @@ router.post(
const collectionIds = await user.collectionIds({
paranoid: false,
});
const collectionScope: Readonly<ScopeOptions> = {
method: ["withCollectionPermissions", user.id],
};
const documents = await Document.scope([
collectionScope,
"withDrafts",
]).findAll({
const documents = await Document.scope("withDrafts").findAll({
attributes: ["id"],
where: {
deletedAt: {
@@ -24,6 +24,7 @@ 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: {
@@ -44,7 +45,7 @@ router.post(
association: "groupUsers",
required: true,
where: {
userId: user.id,
userId,
},
},
],
@@ -57,11 +58,9 @@ router.post(
const documentIds = memberships
.map((p) => p.documentId)
.filter(Boolean) as string[];
const documents = await Document.scope([
"withDrafts",
{ method: ["withMembership", user.id] },
{ method: ["withCollectionPermissions", user.id] },
]).findAll({
const documents = await Document.withMembershipScope(userId, {
includeDrafts: true,
}).findAll({
where: {
id: documentIds,
},
+6 -2
View File
@@ -5,6 +5,7 @@ import userAgent, { UserAgentContext } from "koa-useragent";
import env from "@server/env";
import { NotFoundError } from "@server/errors";
import coalesceBody from "@server/middlewares/coaleseBody";
import requestTracer from "@server/middlewares/requestTracer";
import { AppState, AppContext } from "@server/types";
import { Hook, PluginManager } from "@server/utils/PluginManager";
import apiKeys from "./apiKeys";
@@ -25,9 +26,10 @@ import installation from "./installation";
import integrations from "./integrations";
import apiErrorHandler from "./middlewares/apiErrorHandler";
import apiResponse from "./middlewares/apiResponse";
import apiTracer from "./middlewares/apiTracer";
import editor from "./middlewares/editor";
import notifications from "./notifications";
import oauthAuthentications from "./oauthAuthentications";
import oauthClients from "./oauthClients";
import pins from "./pins";
import reactions from "./reactions";
import revisions from "./revisions";
@@ -60,7 +62,7 @@ api.use(
);
api.use(coalesceBody());
api.use<BaseContext, UserAgentContext>(userAgent);
api.use(apiTracer());
api.use(requestTracer());
api.use(apiResponse());
api.use(apiErrorHandler());
api.use(editor());
@@ -90,6 +92,8 @@ router.use("/", suggestions.routes());
router.use("/", teams.routes());
router.use("/", integrations.routes());
router.use("/", notifications.routes());
router.use("/", oauthAuthentications.routes());
router.use("/", oauthClients.routes());
router.use("/", attachments.routes());
router.use("/", cron.routes());
router.use("/", groups.routes());
@@ -0,0 +1,19 @@
// 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,
}
`;
@@ -0,0 +1 @@
export { default } from "./oauthAuthentications";
@@ -0,0 +1,194 @@
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();
});
});
@@ -0,0 +1,90 @@
import Router from "koa-router";
import { QueryTypes } from "sequelize";
import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { OAuthAuthentication } from "@server/models";
import { authorize } from "@server/policies";
import { presentPolicies } from "@server/presenters";
import presentOAuthAuthentication from "@server/presenters/oauthAuthentication";
import { sequelize } from "@server/storage/database";
import { APIContext } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
const router = new Router();
router.post(
"oauthAuthentications.list",
auth(),
pagination(),
validate(T.OAuthAuthenticationsListSchema),
async (ctx: APIContext<T.OAuthAuthenticationsListReq>) => {
const { user } = ctx.state.auth;
const oauthAuthentications = await sequelize.query<OAuthAuthentication>(
`
SELECT DISTINCT ON (oa."oauthClientId", oa."scope")
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"
WHERE oa."userId" = :userId
AND oa."deletedAt" IS NULL
ORDER BY oa."oauthClientId", oa."scope", oa."lastActiveAt", oa."createdAt" DESC
LIMIT :limit OFFSET :offset
`,
{
replacements: {
userId: user.id,
limit: ctx.state.pagination.limit,
offset: ctx.state.pagination.offset,
},
type: QueryTypes.SELECT,
nest: true,
}
);
ctx.body = {
pagination: { ...ctx.state.pagination },
data: oauthAuthentications.map(presentOAuthAuthentication),
policies: presentPolicies(user, oauthAuthentications),
};
}
);
router.post(
"oauthAuthentications.delete",
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
auth(),
validate(T.OAuthAuthenticationsDeleteSchema),
transaction(),
async (ctx: APIContext<T.OAuthAuthenticationsDeleteReq>) => {
const { user } = ctx.state.auth;
const { oauthClientId, scope } = ctx.input.body;
const oauthAuthentications = await OAuthAuthentication.findAll({
where: {
userId: user.id,
oauthClientId,
...(scope ? { scope } : {}),
},
transaction: ctx.state.transaction,
});
for (const oauthAuthentication of oauthAuthentications) {
authorize(user, "delete", oauthAuthentication);
await oauthAuthentication.destroyWithCtx(ctx);
}
ctx.body = {
success: true,
};
}
);
export default router;
@@ -0,0 +1,21 @@
import { z } from "zod";
import { BaseSchema } from "@server/routes/api/schema";
export const OAuthAuthenticationsListSchema = BaseSchema.extend({
body: z.object({}),
});
export type OAuthAuthenticationsListReq = z.infer<
typeof OAuthAuthenticationsListSchema
>;
export const OAuthAuthenticationsDeleteSchema = BaseSchema.extend({
body: z.object({
oauthClientId: z.string(),
scope: z.array(z.string()).optional(),
}),
});
export type OAuthAuthenticationsDeleteReq = z.infer<
typeof OAuthAuthenticationsDeleteSchema
>;
@@ -0,0 +1,55 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`oauthClients.create should require authentication 1`] = `
{
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`oauthClients.delete should require authentication 1`] = `
{
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`oauthClients.info should require authentication 1`] = `
{
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`oauthClients.list should require authentication 1`] = `
{
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`oauthClients.rotate_secret should require authentication 1`] = `
{
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`oauthclients.update should require authentication 1`] = `
{
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
+1
View File
@@ -0,0 +1 @@
export { default } from "./oauthClients";
@@ -0,0 +1,326 @@
import { OAuthClient } from "@server/models";
import { buildTeam, buildUser, buildAdmin } from "@server/test/factories";
import { getTestServer } from "@server/test/support";
const server = getTestServer();
describe("oauthClients.list", () => {
it("should require authentication", async () => {
const res = await server.post("/api/oauthClients.list");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should return all clients for admin", async () => {
const team = await buildTeam();
const another = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
await OAuthClient.create({
teamId: another.id,
createdById: admin.id,
name: "Another Client",
redirectUris: ["https://example.com/callback"],
published: true,
});
await OAuthClient.create({
teamId: team.id,
createdById: admin.id,
name: "Published Client",
redirectUris: ["https://example.com/callback"],
published: true,
});
await OAuthClient.create({
teamId: team.id,
createdById: admin.id,
name: "Unpublished Client",
redirectUris: ["https://example.com/callback"],
published: false,
});
const res = await server.post("/api/oauthClients.list", {
body: {
token: admin.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(2);
expect(body.data.map((c: { name: string }) => c.name).sort()).toEqual([
"Published Client",
"Unpublished Client",
]);
expect(body.data[0].id).toBeDefined();
expect(body.data[0].redirectUris).toBeDefined();
});
});
describe("oauthClients.info", () => {
it("should require authentication", async () => {
const res = await server.post("/api/oauthClients.info");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should return information about an OAuth client when authorized", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const client = await OAuthClient.create({
teamId: team.id,
createdById: user.id,
name: "Test Client",
redirectUris: ["https://example.com/callback"],
});
const res = await server.post("/api/oauthClients.info", {
body: {
token: user.getJwtToken(),
id: client.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toBeDefined();
expect(body.data.name).toEqual("Test Client");
expect(body.data.published).toBeFalsy();
expect(body.data.redirectUris).toEqual(["https://example.com/callback"]);
});
it("should return information about an OAuth client when published", 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"],
published: true,
});
const res = await server.post("/api/oauthClients.info", {
body: {
token: user.getJwtToken(),
id: client.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toEqual("Test Client");
expect(body.data.published).toBeTruthy();
expect(body.data.id).toBeUndefined();
expect(body.data.redirectUris).toBeUndefined();
});
it("should allow querying by clientId", 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"],
published: true,
});
const res = await server.post("/api/oauthClients.info", {
body: {
token: user.getJwtToken(),
clientId: client.clientId,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toEqual("Test Client");
expect(body.data.published).toBeTruthy();
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", () => {
it("should require authentication", async () => {
const res = await server.post("/api/oauthClients.create");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should create a new OAuth client", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const res = await server.post("/api/oauthClients.create", {
body: {
token: admin.getJwtToken(),
name: "Test Client",
redirectUris: ["https://example.com/callback"],
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toBeDefined();
expect(body.data.name).toEqual("Test Client");
expect(body.data.redirectUris).toEqual(["https://example.com/callback"]);
});
});
describe("oauthclients.update", () => {
it("should require authentication", async () => {
const res = await server.post("/api/oauthClients.update");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should allow updating an OAuth client", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const client = await OAuthClient.create({
teamId: team.id,
createdById: admin.id,
name: "Test Client",
redirectUris: ["https://example.com/callback"],
published: true,
});
const res = await server.post("/api/oauthClients.update", {
body: {
token: admin.getJwtToken(),
id: client.id,
published: false,
name: "Renamed",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toEqual("Renamed");
expect(body.data.published).toBeFalsy();
});
});
describe("oauthClients.rotate_secret", () => {
it("should require authentication", async () => {
const res = await server.post("/api/oauthClients.rotate_secret");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should rotate the client secret", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const client = await OAuthClient.create({
teamId: team.id,
createdById: admin.id,
name: "Test Client",
redirectUris: ["https://example.com/callback"],
});
const originalSecret = client.clientSecret;
const res = await server.post("/api/oauthClients.rotate_secret", {
body: {
token: admin.getJwtToken(),
id: client.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toBeDefined();
expect(body.data.clientSecret).toBeDefined();
expect(body.data.clientSecret).not.toEqual(originalSecret);
});
});
describe("oauthClients.delete", () => {
it("should require authentication", async () => {
const res = await server.post("/api/oauthClients.delete");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should delete an OAuth client", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const client = await OAuthClient.create({
teamId: team.id,
createdById: admin.id,
name: "Test Client",
redirectUris: ["https://example.com/callback"],
});
const res = await server.post("/api/oauthClients.delete", {
body: {
token: admin.getJwtToken(),
id: client.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toBe(true);
const deletedClient = await OAuthClient.findByPk(client.id);
expect(deletedClient).toBeNull();
});
});
@@ -0,0 +1,181 @@
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";
import validate from "@server/middlewares/validate";
import { OAuthClient } from "@server/models";
import { authorize } from "@server/policies";
import {
presentPolicies,
presentOAuthClient,
presentPublishedOAuthClient,
} from "@server/presenters";
import { APIContext } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
const router = new Router();
router.post(
"oauthClients.list",
auth({ role: UserRole.Admin }),
pagination(),
validate(T.OAuthClientsListSchema),
async (ctx: APIContext<T.OAuthClientsListReq>) => {
const { user } = ctx.state.auth;
const where = { teamId: user.teamId };
const [oauthClients, total] = await Promise.all([
OAuthClient.findAll({
where,
order: [["createdAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
OAuthClient.count({ where }),
]);
ctx.body = {
pagination: { ...ctx.state.pagination, total },
data: oauthClients.map(presentOAuthClient),
policies: presentPolicies(user, oauthClients),
};
}
);
router.post(
"oauthClients.info",
auth(),
validate(T.OAuthClientsInfoSchema),
async (ctx: APIContext<T.OAuthClientsInfoReq>) => {
const { id, clientId, redirectUri } = ctx.input.body;
const { user } = ctx.state.auth;
const oauthClient = await OAuthClient.findOne({
where: clientId ? { clientId } : { id },
rejectOnEmpty: true,
});
authorize(user, "read", oauthClient);
if (redirectUri && !oauthClient.redirectUris.includes(redirectUri)) {
throw ValidationError("redirect_uri is invalid");
}
const isInternalApp = oauthClient.teamId === user.teamId;
ctx.body = {
data: isInternalApp
? presentOAuthClient(oauthClient)
: presentPublishedOAuthClient(oauthClient),
policies: isInternalApp ? presentPolicies(user, [oauthClient]) : [],
};
}
);
router.post(
"oauthClients.create",
rateLimiter(RateLimiterStrategy.FivePerHour),
auth({ role: UserRole.Admin }),
validate(T.OAuthClientsCreateSchema),
transaction(),
async (ctx: APIContext<T.OAuthClientsCreateReq>) => {
const input = ctx.input.body;
const { user } = ctx.state.auth;
authorize(user, "createOAuthClient", user.team);
const oauthClient = await OAuthClient.createWithCtx(ctx, {
...input,
teamId: user.teamId,
createdById: user.id,
});
ctx.body = {
data: presentOAuthClient(oauthClient),
policies: presentPolicies(user, [oauthClient]),
};
}
);
router.post(
"oauthClients.update",
auth({ role: UserRole.Admin }),
validate(T.OAuthClientsUpdateSchema),
transaction(),
async (ctx: APIContext<T.OAuthClientsUpdateReq>) => {
const { id, ...input } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const oauthClient = await OAuthClient.findByPk(id, {
transaction,
lock: transaction.LOCK.UPDATE,
rejectOnEmpty: true,
});
authorize(user, "update", oauthClient);
await oauthClient.updateWithCtx(ctx, input);
ctx.body = {
data: presentOAuthClient(oauthClient),
policies: presentPolicies(user, [oauthClient]),
};
}
);
router.post(
"oauthClients.rotate_secret",
rateLimiter(RateLimiterStrategy.FivePerHour),
auth({ role: UserRole.Admin }),
validate(T.OAuthClientsRotateSecretSchema),
transaction(),
async (ctx: APIContext<T.OAuthClientsRotateSecretReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const oauthClient = await OAuthClient.findByPk(id, {
transaction,
lock: transaction.LOCK.UPDATE,
rejectOnEmpty: true,
});
authorize(user, "update", oauthClient);
oauthClient.rotateClientSecret();
await oauthClient.saveWithCtx(ctx);
ctx.body = {
data: presentOAuthClient(oauthClient),
policies: presentPolicies(user, [oauthClient]),
};
}
);
router.post(
"oauthClients.delete",
auth({ role: UserRole.Admin }),
validate(T.OAuthClientsDeleteSchema),
transaction(),
async (ctx: APIContext<T.OAuthClientsDeleteReq>) => {
const { id } = ctx.input.body as { id: string };
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const oauthClient = await OAuthClient.findByPk(id, {
transaction,
lock: transaction.LOCK.UPDATE,
rejectOnEmpty: true,
});
authorize(user, "delete", oauthClient);
await oauthClient.destroyWithCtx(ctx);
ctx.body = {
success: true,
};
}
);
export default router;

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